Skip to content

Commit 70fb92c

Browse files
committed
Color bar for weights feature
1 parent c72dff3 commit 70fb92c

2 files changed

Lines changed: 57 additions & 29 deletions

File tree

bindsnet/rendering/widgets.py

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,20 @@ class FeaturePlot(GraphPlotWidget):
168168
# language=rst
169169
"""
170170
Abstract base for plotting a connection feature's ``value`` matrix as a live,
171-
GPU-resident heatmap (x = target neuron, y = source neuron, color = value).
172-
173-
Shared here: locating the :class:`AbstractFeature` in the network and driving
174-
the per-frame texture migration. Subclasses describe *how* to colour a specific
175-
feature by overriding the ``texture_format`` / ``cmap`` knobs and ``_clim()``.
176-
Same zero-copy contract as RasterPlot/VoltagePlot -- the value never leaves the
177-
GPU (see [[gpu-only-rendering]]).
171+
GPU-resident heatmap (x = target neuron, y = source neuron, color = value),
172+
with a colorbar legend.
173+
174+
Shared here: locating the :class:`AbstractFeature` in the network, driving the
175+
per-frame texture migration, and building the colorbar. Subclasses describe
176+
*how* to colour a specific feature by overriding the ``texture_format`` /
177+
``cmap`` knobs (and optionally ``_clim()``). Same zero-copy contract as
178+
RasterPlot/VoltagePlot -- the value never leaves the GPU (see
179+
[[gpu-only-rendering]]).
180+
181+
Color limits default to the feature's declared ``range`` (e.g. a Weight built
182+
with ``range=[-1, 1]``); when that range is non-finite (the default Weight
183+
range is ``[-inf, +inf]``) we fall back to a symmetric range read once from the
184+
initial values. Pass ``clim=(lo, hi)`` to override.
178185
"""
179186

180187
# --- knobs a subclass overrides for its feature ---
@@ -184,15 +191,17 @@ class FeaturePlot(GraphPlotWidget):
184191
y_label = "Source neuron"
185192

186193
def __init__(self, source: str, target: str, feature_name: str,
187-
refresh_every: int = 1):
194+
clim: tuple[float, float] | None = None, refresh_every: int = 1):
188195
super().__init__() # heatmap: both axes linked to the panzoom camera
189196
self.source = source # source layer name (connection key part 1)
190197
self.target = target # target layer name (connection key part 2)
191198
self.feature_name = feature_name
199+
self._clim_override = clim # explicit color limits; else range/data (see _clim)
192200
self.refresh_every = max(1, refresh_every) # throttle big-matrix re-uploads
193201
self.connection = None # Initialized in prime()
194202
self.feature = None
195203
self.visual = None
204+
self.colorbar = None
196205

197206
def prime(self, network):
198207
self.connection = network.connections[(self.source, self.target)]
@@ -206,18 +215,31 @@ def prime(self, network):
206215
f"sparse={getattr(value, 'is_sparse', None)}."
207216
)
208217
rows, cols = value.shape # (source.n, target.n)
218+
clim = self._clim()
209219

210220
self.visual = FeatureMatrix(
211221
rows=rows, cols=cols,
212222
value_getter=lambda: self.feature.value, # re-fetch: value may be rebound
213223
texture_format=self.texture_format,
214-
clim=self._clim(),
224+
clim=clim,
215225
cmap=self.cmap,
216226
)
217227
self.view.add(self.visual)
218228
self.view.camera.rect = (0, 0, cols, rows)
219229
self.y_axis.axis.axis_label = self.y_label
220230
self.x_axis.axis.axis_label = self.x_label
231+
self._add_colorbar(clim)
232+
233+
def _add_colorbar(self, clim):
234+
# Vertical bar to the right of the view (grid col 2). White text/border: the
235+
# canvas bg is black and ColorBarWidget defaults to black. label_color also
236+
# colours the min/max tick labels (drawn from clim).
237+
self.colorbar = scene.ColorBarWidget(
238+
cmap=self.cmap, orientation='right',
239+
label=self.feature_name, clim=clim,
240+
label_color='white', border_color='white', border_width=1,
241+
)
242+
self.grid.add_widget(self.colorbar, row=0, col=2).width_max = 95
221243

222244
def render(self, t):
223245
if t % self.refresh_every == 0:
@@ -226,36 +248,41 @@ def render(self, t):
226248
def get_history(self):
227249
return None # values live on the GPU; no CPU copy kept
228250

229-
@abstractmethod
230251
def _clim(self):
231252
# language=rst
232253
"""Return ``(low, high)`` colour limits in feature-value units."""
233-
pass
254+
if self._clim_override is not None:
255+
return tuple(self._clim_override)
256+
257+
# Prefer the feature's declared range, when it's a finite (lo < hi) scalar pair.
258+
rng = getattr(self.feature, "range", None)
259+
if rng is not None and len(rng) == 2:
260+
try:
261+
lo, hi = float(rng[0]), float(rng[1])
262+
if np.isfinite(lo) and np.isfinite(hi) and lo < hi:
263+
return (lo, hi)
264+
except (TypeError, ValueError):
265+
pass # e.g. a non-scalar tensor range -> fall through to data-derived
266+
267+
# Fallback: symmetric range from the initial values (a scalar reduction -- two
268+
# floats reach the host, not the matrix -- so the zero-copy render path is
269+
# untouched). Symmetric keeps 0 at the colormap center; rounded for a clean
270+
# colorbar label.
271+
m = self.feature.value.abs().max().item()
272+
if not np.isfinite(m) or m == 0.0:
273+
return (-1.0, 1.0)
274+
m = round(m, 3)
275+
return (-m, m)
234276

235277

236278
class WeightPlot(FeaturePlot):
237279
# language=rst
238280
"""
239281
Live heatmap of a :class:`Weight` feature's values. Uses a diverging colormap
240282
centered at 0 so excitatory (positive) and inhibitory (negative) weights read
241-
as opposite colours.
283+
as opposite colours. Color limits follow the Weight's ``range`` if finite, else
284+
a symmetric range from the initial weights (see :class:`FeaturePlot`).
242285
"""
243286

244287
texture_format = np.float32
245288
cmap = 'coolwarm' # diverging: low=blue, 0=white, high=red
246-
247-
def __init__(self, source: str, target: str, feature_name: str,
248-
clim: tuple[float, float] | None = None, refresh_every: int = 1):
249-
super().__init__(source, target, feature_name, refresh_every=refresh_every)
250-
self._clim_override = clim # explicit color limits; else symmetric-from-data
251-
252-
def _clim(self):
253-
if self._clim_override is not None:
254-
return self._clim_override
255-
# One-time symmetric range from the initial weights. A scalar reduction (two
256-
# floats reach the host, not the matrix), so the zero-copy render path is
257-
# untouched; symmetric keeps 0 at the colormap center (white).
258-
m = self.feature.value.abs().max().item()
259-
if not np.isfinite(m) or m == 0.0:
260-
return (-1.0, 1.0)
261-
return (-m, m)

examples/rendering/model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ def create_model(
3131
Weight(
3232
name='I_to_EXC_weight',
3333
value=torch.rand(in_size, exc_size, device=device),
34-
learning_rule=MSTDP
34+
learning_rule=MSTDP,
35+
range=(0, 1)
3536
),
3637
Mask(
3738
name='I_to_EXC_mask',

0 commit comments

Comments
 (0)