Skip to content

Commit 8beb4e1

Browse files
committed
Inheritable GUI Network
1 parent 4699b69 commit 8beb4e1

6 files changed

Lines changed: 552 additions & 161 deletions

File tree

‎bindsnet/network/network.py‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,12 @@ class GUINetwork(Network):
534534

535535
def __init__(self, *args, **kwargs) -> None:
536536
super().__init__(*args, **kwargs)
537+
# GUI-tunable model parameters (name -> current value), declared by a subclass
538+
# via set_parameters(). The control panel renders these as editable rows and
539+
# passes the edited values back to rebuild() on "Apply & Reload". Empty for a
540+
# network assembled imperatively (no build() override).
541+
self.parameters = {}
542+
self._built = False
537543
self.opengl_vbos = {'connections': {}, 'layers': {}}
538544
# name -> dict describing a CUDA-registered GL buffer holding that layer's
539545
# FULL spike history, (T, batch, n) one byte/spike. Populated lazily by a
@@ -552,6 +558,96 @@ def __init__(self, *args, **kwargs) -> None:
552558
# Resolved from the layers' device on first use (see _resolve_backend).
553559
self._use_cuda = None
554560

561+
# --- inheritable model definition ------------------------------------------
562+
# A GUINetwork subclass defines its model by overriding build() (and usually
563+
# make_input()), storing its parameters as constructor arguments via
564+
# set_parameters(). The Application then drives the lifecycle: it calls build()
565+
# to assemble the network, make_input() to generate the stimulus, and -- when the
566+
# user edits the parameters and clicks "Apply & Reload" -- rebuild() to reassemble
567+
# the model in place from the new values. Example:
568+
#
569+
# class MyNet(GUINetwork):
570+
# def __init__(self, device="cuda", in_size=100, exc_size=20_000):
571+
# super().__init__()
572+
# self.device = device
573+
# self.set_parameters(in_size=in_size, exc_size=exc_size)
574+
# def build(self):
575+
# self.add_layer(Input(self.in_size), name="I")
576+
# self.add_layer(LIFNodes(self.exc_size), name="EXC")
577+
# self.add_connection(..., source="I", target="EXC")
578+
# self.to(self.device)
579+
# def make_input(self, runtime):
580+
# return {"I": torch.rand(runtime, self.batch_size, self.in_size,
581+
# device=self.device) > 0.9}
582+
583+
def set_parameters(self, **params) -> None:
584+
# language=rst
585+
"""
586+
Declare the GUI-tunable model parameters. Each is stored in ``self.parameters``
587+
(so the control panel can render and edit it) AND set as an attribute, so
588+
:meth:`build` / :meth:`make_input` can read it as ``self.<name>``. Call from the
589+
subclass constructor; :meth:`rebuild` re-calls it with the edited values.
590+
"""
591+
self.parameters = dict(params)
592+
for name, value in params.items():
593+
setattr(self, name, value)
594+
595+
def build(self) -> None:
596+
# language=rst
597+
"""
598+
Assemble the network: a subclass overrides this to call :meth:`add_layer` /
599+
:meth:`add_connection` (and ``self.to(self.device)``) using the parameters
600+
stored by :meth:`set_parameters`. Called by the Application -- not the
601+
constructor -- so a live reload can re-run it.
602+
"""
603+
raise NotImplementedError(
604+
"GUINetwork subclasses must implement build() to assemble the model "
605+
"(call self.add_layer / self.add_connection using the stored parameters).")
606+
607+
def make_input(self, runtime: int) -> Dict[str, torch.Tensor]:
608+
# language=rst
609+
"""
610+
Produce the stimulus for a ``runtime``-step run as a ``{layer_name: tensor}``
611+
dict (tensors shaped ``[runtime, batch, n]``). A subclass overrides this so the
612+
stimulus tracks the parameters (e.g. an input layer sized by a parameter) on
613+
every reload. Optional: a network may instead be driven by a fixed dict passed
614+
to ``Application.run(inputs=...)``.
615+
"""
616+
raise NotImplementedError(
617+
"This GUINetwork does not implement make_input(); either override it or "
618+
"pass a fixed `inputs` dict to Application.run().")
619+
620+
def rebuild(self, **params) -> None:
621+
# language=rst
622+
"""
623+
Live model reload: free this network's GL history buffers, tear down its
624+
layers/connections, apply the edited parameters, and re-run :meth:`build` -- all
625+
on the SAME instance, so the Application's widgets simply re-bind to it. Calling
626+
with no params (the initial build) just assembles the model from the parameters
627+
already set in the constructor.
628+
"""
629+
self.release_gl() # free the old run's history buffers (no-op if none)
630+
self._clear_structure() # drop layers/connections/modules + GL bookkeeping
631+
if params:
632+
self.set_parameters(**{**self.parameters, **params})
633+
self.build()
634+
self._built = True
635+
636+
def _clear_structure(self) -> None:
637+
# Reset the network to an empty shell so build() can reassemble it from scratch.
638+
# Drops the layer/connection registries AND the nn.Module submodule entries they
639+
# were added under (add_layer/add_connection call add_module), plus the per-run
640+
# GL bookkeeping. Plain attributes (device, parameters, dt, ...) are preserved.
641+
self.layers = {}
642+
self.connections = {}
643+
self.monitors = {}
644+
self._modules.clear()
645+
self.opengl_vbos = {'connections': {}, 'layers': {}}
646+
self._spike_history = {}
647+
self._voltage_history = {}
648+
self._step_t = 0
649+
self._use_cuda = None # re-resolve GPU/CPU backend after the rebuild
650+
555651
def _resolve_backend(self) -> bool:
556652
# language=rst
557653
"""
@@ -923,6 +1019,35 @@ def reset_history(self) -> None:
9231019
h['vmax'].fill_(float('-inf'))
9241020
self._step_t = 0
9251021

1022+
def release_gl(self) -> None:
1023+
# language=rst
1024+
"""
1025+
Free the spike/voltage history GL buffers (and their CUDA registrations) owned
1026+
by this network. Called when the Application swaps this network out for a freshly
1027+
built one (live model reload) so the large per-run history buffers -- e.g.
1028+
``T*batch*n`` float32 voltage, tens of MB each -- don't leak on every rebuild.
1029+
1030+
Best-effort: every step is guarded so a cleanup failure can never abort the
1031+
reload (the GL context is being reused, not destroyed). The legacy per-layer
1032+
``opengl_vbos`` buffers from :meth:`migrate` are intentionally left alone (unused
1033+
by the renderer; documented legacy).
1034+
"""
1035+
gpu = self._resolve_backend()
1036+
for h in list(self._spike_history.values()) + list(self._voltage_history.values()):
1037+
if gpu and driver is not None and h.get('res') is not None:
1038+
try:
1039+
driver.cuGraphicsUnregisterResource(h['res'])
1040+
except Exception:
1041+
pass
1042+
vbo = h.get('vbo')
1043+
if vbo:
1044+
try:
1045+
gl.glDeleteBuffers(1, [int(vbo)])
1046+
except Exception:
1047+
pass
1048+
self._spike_history = {}
1049+
self._voltage_history = {}
1050+
9261051
def _upload_spike_row(self, layer_name: str, t: int) -> None:
9271052
# CPU path: copy this timestep's spikes from the layer's host tensor into row
9281053
# `t` of the spike-history GL buffer (R8UI, one byte/spike). Past the capacity

‎bindsnet/rendering/app.py‎

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,29 @@
1515

1616

1717
class Application():
18-
def __init__(self, network: GUINetwork, width=1400, height=900, title="BindsNET GUI",
18+
def __init__(self, network: GUINetwork,
19+
width=1400, height=900, title="BindsNET GUI",
1920
header: str | None = None,
2021
max_steps_per_second: int | float | str | None = None,
2122
draw_fps: float | None = None,
2223
parameters: dict | None = None):
2324
self.width, self.height = width, height
25+
26+
# `network` is a GUINetwork. If it OVERRIDES build() (an inheritable model
27+
# definition), the Application drives its lifecycle: build() assembles it,
28+
# make_input() supplies the stimulus, network.parameters feeds the editable panel,
29+
# and the "Apply & Reload" button rebuilds it in place (see reload_model). A plain
30+
# GUINetwork assembled imperatively (no build() override) is used as-is with no
31+
# reload, driven by the `inputs` dict passed to run().
2432
self.network = network
25-
# Optional {label: value} map surfaced as editable rows in the control panel's
26-
# Parameters section (for future model-rebuild support).
27-
self.parameters = parameters
33+
self._buildable = type(network).build is not GUINetwork.build
34+
self._has_make_input = type(network).make_input is not GUINetwork.make_input
35+
self.can_reload = self._buildable
36+
if self._buildable:
37+
self.network.rebuild() # initial build() from constructor params
38+
self.parameters = dict(network.parameters)
39+
else:
40+
self.parameters = parameters # legacy: cosmetic-only panel rows
2841
self.widgets = []
2942
self.inputs = None # Set when run() is called; Inputs into network during runtime
3043
self.runtime = None # Set when run() is called; Total runtime of network simulation
@@ -89,7 +102,7 @@ def __init__(self, network: GUINetwork, width=1400, height=900, title="BindsNET
89102
self.grid = self.layout.add_grid(row=next_row, col=0, margin=10)
90103

91104
# Migrate network tensors to shared OpenGL buffers
92-
network.migrate()
105+
self.network.migrate()
93106

94107
# Build the control surface (a separate Qt window). It calls back into
95108
# toggle_play/step_once/run_n and reads back via set_time etc.
@@ -165,6 +178,98 @@ def reset(self):
165178
self.panel.on_reset()
166179
self.canvas.update()
167180

181+
def reload_model(self):
182+
# language=rst
183+
"""
184+
Rebuild the network from the control panel's current parameters and re-bind the
185+
plots in place, WITHOUT recreating the canvas / control window (that is what causes
186+
the black-screen/lag we avoid). Driven by the panel's "Apply & Reload" button, which
187+
fires on the main GL thread during panel.pump() -- the same path reset() uses to
188+
touch GL, so the context is current and these calls are safe.
189+
190+
The network's :meth:`GUINetwork.rebuild` reassembles the model IN PLACE (frees the
191+
old GL buffers, tears down the layers, re-runs build() with the edited parameters),
192+
so the same network object is kept and the widgets simply re-bind to it.
193+
"""
194+
if not self.can_reload:
195+
return
196+
if self.runtime is None:
197+
self.panel.show_status("Start the run before reloading.", error=True)
198+
return
199+
200+
# Coerce the edited fields first; a bad value aborts before the model is touched.
201+
try:
202+
values = self.panel.get_parameter_values()
203+
except Exception as exc:
204+
self.panel.show_status(f"Invalid parameter: {exc}", error=True)
205+
return
206+
207+
self.canvas.set_current() # ensure GL calls below target the plot context
208+
try:
209+
self.network.rebuild(**values) # free GL, clear, set params, re-run build()
210+
self.network.migrate() # allocate the rebuilt net's shared GL buffers
211+
except Exception as exc:
212+
# build() failed (e.g. an invalid size). The network is now an empty shell, but
213+
# rebuild() always clears-then-builds, so a subsequent reload with good values
214+
# fully recovers. Leave the sim paused and report.
215+
self.panel.show_status(f"Build failed: {exc}", error=True)
216+
return
217+
218+
# Re-bind every plot to the rebuilt network (releases old visuals, allocates fresh
219+
# history buffers). Done before regenerating inputs so a (rare) input mismatch can't
220+
# leave widgets pointing at freed buffers.
221+
for widget in self.widgets:
222+
widget.reload(self.network)
223+
self.parameters = dict(self.network.parameters)
224+
225+
# Regenerate the stimulus for the rebuilt model. make_input() tracks the parameters
226+
# so it always fits; a fixed dict is only warned about if it no longer matches.
227+
input_warning = None
228+
if self._has_make_input:
229+
self.inputs = self.network.make_input(self.runtime)
230+
else:
231+
try:
232+
self._validate_inputs(self.inputs, self.network)
233+
except Exception as exc:
234+
input_warning = f"Reloaded, but inputs no longer fit: {exc}"
235+
236+
# Re-arm the run-state machine at t=0 (the rebuilt net + its history buffers are
237+
# already fresh/zeroed, so no reset_state_variables/reset_history needed).
238+
self.running = False
239+
self.step_budget = 0
240+
self._was_active = None
241+
self._last_draw = None
242+
self._sps_count = 0
243+
self._sps_t0 = None
244+
self.current_time = 0
245+
self._set_active(False)
246+
if hasattr(self, "timer") and not self.timer.running:
247+
self.timer.start()
248+
249+
self.panel.on_reset()
250+
self.panel.set_parameter_values(self.network.parameters)
251+
if input_warning is not None:
252+
self.panel.show_status(input_warning, error=True)
253+
else:
254+
self.panel.show_status("Model reloaded.")
255+
self.canvas.update()
256+
257+
def _validate_inputs(self, inputs: dict, network: GUINetwork):
258+
# Ensure the (possibly regenerated) inputs match the rebuilt network before swapping
259+
# anything in: each input tensor's last dim must equal its target layer's n, and a
260+
# time-major tensor must cover the whole runtime. Raises ValueError on a mismatch.
261+
for name, tensor in inputs.items():
262+
if name not in network.layers:
263+
continue
264+
n = int(network.layers[name].n)
265+
if int(tensor.shape[-1]) != n:
266+
raise ValueError(
267+
f"input '{name}' last dim {int(tensor.shape[-1])} != layer '{name}' n {n}. "
268+
f"Pass `inputs` to run() as a builder function so it tracks the parameters.")
269+
if tensor.dim() >= 2 and int(tensor.shape[0]) < self.runtime:
270+
raise ValueError(
271+
f"input '{name}' has {int(tensor.shape[0])} timesteps < runtime {self.runtime}.")
272+
168273
def _set_active(self, active: bool):
169274
# Fire on each play<->pause transition: lock the cameras (and resume the
170275
# follow window) while advancing, hand them back for zoom/pan while idle.
@@ -244,9 +349,21 @@ def _should_draw(self):
244349
return True
245350
return False
246351

247-
def run(self, inputs: dict[str, torch.Tensor], runtime: int):
248-
self.inputs = inputs
352+
def run(self, inputs: dict[str, torch.Tensor] | None = None, runtime: int = None):
353+
# The stimulus comes from the network's make_input(runtime) when it implements one
354+
# (regenerated on every reload so its shape tracks the parameters); otherwise a fixed
355+
# `inputs` dict must be supplied here. `runtime` sizes the full-history GL buffers.
356+
if runtime is None:
357+
raise ValueError("Application.run() requires `runtime`.")
249358
self.runtime = runtime
359+
if self._has_make_input:
360+
self.inputs = self.network.make_input(runtime)
361+
elif inputs is not None:
362+
self.inputs = inputs
363+
else:
364+
raise ValueError(
365+
"Application.run() needs an `inputs` dict unless the network implements "
366+
"make_input().")
250367
# Prime widgets now that runtime is known (full-history buffers need it).
251368
for widget in self.widgets:
252369
widget.prime(self.network, runtime)

0 commit comments

Comments
 (0)