diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ff8de0b6..a6eefb50 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,7 +21,7 @@ - golden files under `test/regression/` - operation-count file under `test/regression/` (if present) - `test/api_surface.json` (public API) - - `KNOWN_BUGS` in `test/network/test_all_classes.py` + - `FIXED_BUGS` in `test/network/test_all_classes.py` - [ ] I added an entry to the `Unreleased` section of `CHANGELOG.md`. - [ ] If I used an AI tool to write or find this change, I name it here: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5179fe8c..5eb6461a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,70 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases). ## [Unreleased] +### Results changes (read this before upgrading) +These fixes change numbers that code produced before. Everything else in this +release either was a crash before or gives bit-identical results (checked by the +golden-output tests in `test/regression/`). +- `reset_state_variables()` now clears the state that `MSTDP`, `MSTDPET` and `Rmax` + carry across time steps (`p_plus`, `p_minus`, `eligibility`, `eligibility_trace`). + Before, the next trial continued the previous trial's traces. Affects any run that + learns with these rules and resets between trials, for example + `examples/breakout/breakout_stdp.py` and `examples/dotTracing/dot_tracing.py`. +- `CSRMNodes.reset_state_variables()` clears the neuron's refractory spike window + (`last_spikes`), and the connection's input window is cleared too (#815). Affects + CSRM runs with more than one trial. +- `DiehlAndCook2015(sparse=True)` now learns. Its input weights never changed before, + because bounding the sparse weights made a new tensor that the connection did not + read. With one sample it now learns the same weights as the dense model (bit for bit + without weight normalisation; with it, sums differ in order by at most 6e-8). +- `MeanFieldConnection` applies `weight_decay`. It was passed by position into the + `reduction` slot and ignored. +- `MeanFieldConnection` and the `MeanField` feature use each sample's own mean + firing rate. Before, the mean was taken over the whole batch, so samples mixed + (batch size 1 is unchanged). +- Sparse `MulticompartmentConnection` weights hold one copy per sample; every + learning rule (`PostPre`, `DiehlAndCook`, `MSTDP`, `MSTDPET`) now gives every copy + the batch's shared update, so sparse weights learn as dense ones do (batch size > 1 + crashed before, so no earlier results exist). +- `Rmax.eligibility_trace` and `MCC_learning.MSTDPET`'s state tensors now have a + leading batch dimension (`[batch, source, target]`); code that reads them directly + sees the new shape. +- An `Updating` sub-feature now drives its parent's learning: the connection's own + update skips that parent, so it learns once per step (it crashed before). +- Checkpoints: `state_dict()` of a network with a `MulticompartmentConnection` now + holds `feature.` entries (and CSRM windows), so a checkpoint saved by an + earlier version fails `load_state_dict(..., strict=True)` with missing keys. Load + it with `strict=False`; the feature values then keep their constructed values, + as before. Feature names in one pipeline must be unique to save. +- The published results in `REPRODUCING.md` use code paths whose golden outputs are + bit-identical before and after (dense `DiehlAndCook2015`, `Connection` with + `PostPre`, convolutional and Izhikevich layers); the full experiments were not rerun. + ### Fixed +- Crashes, all with a reproducer kept in `test/network/test_all_classes.py` + (`FIXED_BUGS`): `IzhikevichNodes` and `CSRMNodes` with a multi-dimensional shape; + `LocalConnection` with batch size > 1; `MaxPool1d/2d/3dConnection` without a + `decay` (now plain spike counts) or with one channel at batch size > 1; + `NetworkMonitor.reset_state_variables()` with `time` set (looked connections up + among layers); `MulticompartmentConnection` with a tuple target shape; + `DiehlAndCook2015(sparse=True)` with batch size > 1; `Rmax` and + `MCC_learning.MSTDPET` with batch size > 1 (both now keep one state per sample and + reduce the update over the batch, as `MSTDP` does); `WeightDependentPostPre`, + `DiehlAndCook` and `PostPre` with `wmin`/`wmax` on a `SparseConnection` (now the + same weights as a dense `Connection`); the `MeanField` feature (no name); + `Normalization` and `Updating` sub-features in a pipeline; + `AdaptationBaseSynapsHistory` and `AdaptationBaseOtherSynaps` with batch size > 1 + (a synapse counts as active when it spiked in any sample). +- `MulticompartmentConnection` into `CSRMNodes` always raised; it now keeps a window + of its own outputs and matches a `Connection` with the same static weights bit for + bit. +- `state_dict()` now saves `MulticompartmentConnection` feature values (so a + `DiehlAndCook2015` network can be saved and restored) and the CSRM spike windows. +- `LearningRule.reset_state_variables()` added; connections forward their reset to + their learning rule. +- Reset the spike-history window of dense connections into `CSRMNodes` when + resetting network state, so spikes from a previous trial do not drive the next one. + Recreate the window when the input batch size changes, matching the neuron state. - `--seed` now makes GPU runs repeatable in `eth_mnist.py`, `batch_eth_mnist.py`, `supervised_mnist.py`, `conv_mnist.py`, `conv1d_MNIST.py`, `conv3d_MNIST.py`, `SOM_LM-SNNs.py` and `breakout/random_network_baseline.py`: they call @@ -42,6 +105,9 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases). - Dockerfile on `python:3.13-slim`, inside `requires-python = ">=3.11,<3.14"` (#810 had moved it to 3.14); Dependabot no longer proposes minor or major Python bumps for the image. +- Mean-field connection normalization preserves the registered weight parameter and + signs, keeps zero weights finite, and counts a shared scalar once per target neuron + when normalizing the total absolute weight. Scalar and per-target forms agree. - API reference on Read the Docs was empty: the build never installed `bindsnet`, so every `automodule` failed to import (`No module named 'matplotlib'`), and `docs/pyproject.toml` downgraded Sphinx to 7.2.6. `.readthedocs.yaml` now installs a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2250cab..0ad14ac2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,9 +82,8 @@ harmless. Please do not edit them by hand to make them pass. connection, learning rule, feature, monitor and model class is found automatically and run for a few steps. A new connection class needs an entry in `CONNECTION_SPECS` (its source and target shapes), a new model needs one in `MODEL_SPECS`, and a new - learning rule needs one in `SUPPORTED`. Known bugs on `master` are listed in - `KNOWN_BUGS` with their exact error; if your change fixes one, its test fails with - "DID NOT RAISE", and you should remove the entry in the same pull request. + learning rule needs one in `SUPPORTED`. `FIXED_BUGS` holds the reproducers of + crashes fixed in 0.3.5; each must keep running without error. - **Operation counts (`test/regression/op_counts.json`).** `test/regression/test_op_counts.py` counts the tensor operations of one learning-rule update, one simulation step and one neuron update on small fixed networks. It fails when a count grows, because more diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py index 94c867e8..7dd514a3 100644 --- a/bindsnet/learning/MCC_learning.py +++ b/bindsnet/learning/MCC_learning.py @@ -30,6 +30,28 @@ def _dense_outer_update_ok(rule, w: torch.Tensor) -> bool: ) +def _reduce_batch(reduction, x: torch.Tensor) -> torch.Tensor: + # language=rst + """ + Reduce per-sample updates ``x`` (``[batch, ...]``) over the batch. The default + ``torch.squeeze`` (chosen for batch size 1) means a sum over a larger batch, as + in the fused dense path; for one sample both are exact. + """ + return x.sum(0) if reduction is torch.squeeze else reduction(x, dim=0) + + +def _add_to_feature(value: torch.Tensor, update: torch.Tensor) -> None: + # language=rst + """ + ``value += update`` for a batch-reduced ``update``. A sparse value holds one + weight copy per sample (``[batch, src, tgt]``); every copy gets the same update, + so sparse weights learn as the dense (shared) weights do. + """ + if value.is_sparse: + update = update.expand(value.shape).to_sparse() + value += update + + def _batch_outer(a: torch.Tensor, b: torch.Tensor, reduction) -> torch.Tensor: # language=rst """ @@ -133,7 +155,8 @@ def update(self, **kwargs) -> None: self, NoOp ): if self.feature_value.is_sparse: - self.feature_value = ( + # In place: the feature (and compute()) holds this same tensor. + self.feature_value.copy_( self.feature_value.to_dense().clamp_(self.min, self.max).to_sparse() ) else: @@ -300,7 +323,10 @@ def _connection_update(self, **kwargs) -> None: self.feature_value -= torch.mean(self.average_buffer_pre, dim=0) else: if self.feature_value.is_sparse: - self.feature_value -= (torch.bmm(source_s, target_x)).to_sparse() + _add_to_feature( + self.feature_value, + -_reduce_batch(self.reduction, torch.bmm(source_s, target_x)), + ) else: self.feature_value -= self.reduction( torch.bmm(source_s, target_x), dim=0 @@ -329,7 +355,10 @@ def _connection_update(self, **kwargs) -> None: self.feature_value += torch.mean(self.average_buffer_post, dim=0) else: if self.feature_value.is_sparse: - self.feature_value += (torch.bmm(source_x, target_s)).to_sparse() + _add_to_feature( + self.feature_value, + _reduce_batch(self.reduction, torch.bmm(source_x, target_s)), + ) else: self.feature_value += self.reduction( torch.bmm(source_x, target_s), dim=0 @@ -538,11 +567,11 @@ def _connection_update(self, **kwargs) -> None: source_x = self.source.x.view(batch_size, -1) - self.x_tar target_s = self.target.s.view(batch_size, -1).float() outer = _batch_outer(source_x, target_s, self.reduction) - factor = self.max - w + factor = self.max - (w.to_dense() if w.is_sparse else w) if self.mu != 1.0: factor = factor.clamp(min=0.0) ** self.mu if w.is_sparse: - w += (self.nu[1] * outer * factor.to_dense()).to_sparse() + _add_to_feature(w, self.nu[1] * outer * factor) else: w += self.nu[1] * outer * factor super().update() @@ -651,6 +680,7 @@ def _connection_update(self, **kwargs) -> None: :param Union[float, torch.Tensor] reward: Reward signal from reinforcement learning task. + A per-sample reward has shape ``[batch, 1, 1]``. :param float a_plus: Learning rate (post-synaptic). :param float a_minus: Learning rate (pre-synaptic). """ @@ -871,20 +901,11 @@ def __init__( self.tc_e_trace = torch.tensor( kwargs.get("tc_e_trace", 25.0) ) # How long trace effects weights - self.eligibility = torch.zeros( - *self.feature_value.shape, device=self.feature_value.device - ) - self.eligibility_trace = torch.zeros( - *self.feature_value.shape, device=self.feature_value.device - ) - - # Initialize eligibility, eligibility trace, P^+, and P^-. - if not hasattr(self, "p_plus"): - self.p_plus = torch.zeros((self.source.n), device=self.feature_value.device) - if not hasattr(self, "p_minus"): - self.p_minus = torch.zeros( - (self.target.n), device=self.feature_value.device - ) + # Per-sample state ([batch, ...]), created on the first update. + self.p_plus = None + self.p_minus = None + self.eligibility = None + self.eligibility_trace = None # Initialize variables for average update and continues update self.average_update = kwargs.get("average_update", 0) @@ -908,19 +929,28 @@ def _connection_update(self, **kwargs) -> None: :param Union[float, torch.Tensor] reward: Reward signal from reinforcement learning task. + A per-sample reward has shape ``[batch, 1, 1]``. :param float a_plus: Learning rate (post-synaptic). :param float a_minus: Learning rate (pre-synaptic). """ # Reshape pre- and post-synaptic spikes. - source_s = self.source.s.view(-1).float() - target_s = self.target.s.view(-1).float() + batch_size = self.source.batch_size + source_s = self.source.s.view(batch_size, -1).float() + target_s = self.target.s.view(batch_size, -1).float() # Parse keyword arguments. reward = kwargs["reward"] + dev = self.feature_value.device + if self.p_plus is None or self.p_plus.size(0) != batch_size: + n_pre, n_post = self.source.n, self.target.n + self.p_plus = torch.zeros(batch_size, n_pre, device=dev) + self.p_minus = torch.zeros(batch_size, n_post, device=dev) + self.eligibility = torch.zeros(batch_size, n_pre, n_post, device=dev) + self.eligibility_trace = torch.zeros(batch_size, n_pre, n_post, device=dev) + # Build learning-rate and decay tensors, cached on (dt, device) so a # change to either (network.dt edits, .to(device) moves) recomputes them. - dev = self.feature_value.device dt = float(self.connection.dt) if getattr(self, "_decay_key", None) != (dt, dev): self._decay_key = (dt, dev) @@ -952,24 +982,23 @@ def _connection_update(self, **kwargs) -> None: # Compute weight update. + # Weights are shared across the batch: reduce the per-sample updates (a + # sparse value's per-sample copies all get the same update). + update = self.nu[0] * self.connection.dt * reward * self.eligibility_trace + update = _reduce_batch(self.reduction, update) + if self.average_update > 0: - self.average_buffer[self.average_buffer_index] = ( - self.nu[0] * self.connection.dt * reward * self.eligibility_trace - ) + self.average_buffer[self.average_buffer_index] = update self.average_buffer_index = ( self.average_buffer_index + 1 ) % self.average_update if self.continues_update or self.average_buffer_index == 0: - update = torch.mean(self.average_buffer, dim=0) - if self.feature_value.is_sparse: - update = update.to_sparse() - self.feature_value += update + _add_to_feature( + self.feature_value, torch.mean(self.average_buffer, dim=0) + ) else: - update = self.nu[0] * self.connection.dt * reward * self.eligibility_trace - if self.feature_value.is_sparse: - update = update.to_sparse() - self.feature_value += update + _add_to_feature(self.feature_value, update) # Update P^+ and P^- values. self.p_plus *= self._decay_plus # Decay @@ -984,10 +1013,10 @@ def _connection_update(self, **kwargs) -> None: # p_plus -> +a_plus every spike, with decay # p_minus -> +a_minus every spike, with decay - # Calculate point eligibility value. - self.eligibility = torch.outer(self.p_plus, target_s) + torch.outer( - source_s, self.p_minus - ) + # Calculate point eligibility value (per sample). + self.eligibility = torch.bmm( + self.p_plus.unsqueeze(2), target_s.unsqueeze(1) + ) + torch.bmm(source_s.unsqueeze(2), self.p_minus.unsqueeze(1)) super().update() @@ -998,10 +1027,14 @@ def reset_state_variables(self) -> None: starts from the same state as a freshly-built rule. """ - self.eligibility.zero_() - self.eligibility_trace.zero_() - self.p_plus.zero_() - self.p_minus.zero_() + for state in ( + self.eligibility, + self.eligibility_trace, + self.p_plus, + self.p_minus, + ): + if state is not None: + state.zero_() if self.average_update > 0: self.average_buffer.zero_() self.average_buffer_index = 0 diff --git a/bindsnet/learning/README.md b/bindsnet/learning/README.md index 3a8189c7..a62bcd86 100644 --- a/bindsnet/learning/README.md +++ b/bindsnet/learning/README.md @@ -39,18 +39,15 @@ step against a from-scratch transcription of the paper. weight by the same amount at any simulation step. (`MSTDPET` does carry the paper's `dt` factor, eq. 2.7.) -- **Reward-rule state survives `reset_state_variables()` and is not saved.** - `MSTDP` (`p_plus`, `p_minus`, `eligibility`), `MSTDPET` (the same plus - `eligibility_trace`) and `Rmax` (`eligibility_trace`) keep these tensors on the - rule object, outside the network's `state_dict()`. A reset does not clear them, - and `load_state_dict` does not restore them, so a second trial after a reset - does not equal a fresh network, and a reloaded network does not continue - exactly. The same holds for the CSRM spike window `Connection.s_w`. Measured - 2026-10-24 by `test/regression/test_invariants.py` (strict expected failures); - a fix changes results and is scheduled for 0.4.0. -- **`MulticompartmentConnection` weights are not in `state_dict()`.** A - `DiehlAndCook2015` network saved with `state_dict()` and loaded back loses its - trained weights. Same test file, same release plan. +- **Reward-rule traces are cleared by a reset but not saved.** Since 0.3.5, + `reset_state_variables()` clears the state `MSTDP` (`p_plus`, `p_minus`, + `eligibility`), `MSTDPET` (the same plus `eligibility_trace`) and `Rmax` + (`eligibility_trace`) carry across time steps, so a trial after a reset equals a + fresh network (`test/regression/test_invariants.py`). These tensors are still not + in `state_dict()`: a network reloaded in the middle of a trial restarts them from + zero. The CSRM spike windows (`Connection.s_w`, `MulticompartmentConnection.out_w`) + and `MulticompartmentConnection` feature values (for example `DiehlAndCook2015`'s + weights) are saved since 0.3.5. ## Using the Diehl & Cook (2015) rule diff --git a/bindsnet/learning/learning.py b/bindsnet/learning/learning.py index 898bf815..2feaa2ca 100644 --- a/bindsnet/learning/learning.py +++ b/bindsnet/learning/learning.py @@ -190,6 +190,22 @@ def __init__( # Weight decay. self.weight_decay = 1.0 - weight_decay if weight_decay else 1.0 + # Tensors a rule creates on its first update and carries across time steps. + _step_state = () + + def reset_state_variables(self) -> None: + # language=rst + """ + Forget the state carried across time steps. The tensors in + ``_step_state`` are deleted, so the next update re-creates them as zeros + for the current batch size, as in a freshly built rule. The default batch + reduction is still the one chosen when the rule was built (``squeeze`` for + batch size 1, ``sum`` otherwise), so a rule built for batch size 1 does not + run a larger batch; pass ``reduction=torch.sum`` to allow both. + """ + for name in self._step_state: + self.__dict__.pop(name, None) + def update(self) -> None: # language=rst """ @@ -205,10 +221,16 @@ def update(self) -> None: (self.connection.wmin != -np.inf).any() or (self.connection.wmax != np.inf).any() ) and not isinstance(self, NoOp): - if self.connection.w.is_sparse: - raise Exception("SparseConnection isn't supported for wmin\\wmax") + w = self.connection.w + if w.is_sparse: + # In place, so the connection keeps the same Parameter. + w.copy_( + w.to_dense() + .clamp_(self.connection.wmin, self.connection.wmax) + .to_sparse() + ) else: - self.connection.w.clamp_(self.connection.wmin, self.connection.wmax) + w.clamp_(self.connection.wmin, self.connection.wmax) class NoOp(LearningRule): @@ -744,17 +766,23 @@ def _connection_update(self, **kwargs) -> None: target_x = self.target.x.view(batch_size, -1).unsqueeze(1) update = 0 + # Dense view of w for the weight-dependent factors (sparse w: torch has + # no sparse-dense subtraction). + w = self.connection.w + w = w.to_dense() if w.is_sparse else w # Pre-synaptic update. if self.nu[0].any(): outer_product = self.reduction(torch.bmm(source_s, target_x), dim=0) - update -= self.nu[0] * outer_product * (self.connection.w - self.wmin) + update -= self.nu[0] * outer_product * (w - self.wmin) # Post-synaptic update. if self.nu[1].any(): outer_product = self.reduction(torch.bmm(source_x, target_s), dim=0) - update += self.nu[1] * outer_product * (self.wmax - self.connection.w) + update += self.nu[1] * outer_product * (self.wmax - w) + if self.connection.w.is_sparse and isinstance(update, torch.Tensor): + update = update.to_sparse() self.connection.w += update super().update() @@ -1610,7 +1638,7 @@ def _connection_update(self, **kwargs) -> None: outer = self.reduction( torch.bmm(source_x.unsqueeze(2), target_s.unsqueeze(1)), dim=0 ) - factor = self.connection.wmax - w + factor = self.connection.wmax - (w.to_dense() if w.is_sparse else w) if self.mu != 1.0: factor = factor.clamp(min=0.0) ** self.mu update = self.nu[1] * outer * factor @@ -1627,6 +1655,8 @@ class MSTDP(LearningRule): `_. """ + _step_state = ("p_plus", "p_minus", "eligibility") + def __init__( self, connection: AbstractConnection, @@ -1700,6 +1730,7 @@ def _connection_update(self, **kwargs) -> None: :param Union[float, torch.Tensor] reward: Reward signal from reinforcement learning task. + A per-sample reward has shape ``[batch, 1, 1]``. :param float a_plus: Learning rate (post-synaptic). :param float a_minus: Learning rate (pre-synaptic). """ @@ -2074,6 +2105,7 @@ def _conv_connection_update(self, dim: int, **kwargs) -> None: :param Union[float, torch.Tensor] reward: Reward signal from reinforcement learning task. + A per-sample reward has shape ``[batch, 1, 1]``. :param float a_plus: Learning rate (post-synaptic). :param float a_minus: Learning rate (pre-synaptic). """ @@ -2128,6 +2160,8 @@ class MSTDPET(LearningRule): `(Florian 2007) `_. """ + _step_state = ("p_plus", "p_minus", "eligibility", "eligibility_trace") + def __init__( self, connection: AbstractConnection, @@ -2204,6 +2238,7 @@ def _connection_update(self, **kwargs) -> None: :param Union[float, torch.Tensor] reward: Reward signal from reinforcement learning task. + A per-sample reward has shape ``[batch, 1, 1]``. :param float a_plus: Learning rate (post-synaptic). :param float a_minus: Learning rate (pre-synaptic). """ @@ -2599,6 +2634,7 @@ def _conv_connection_update(self, dim: int, **kwargs) -> None: :param Union[float, torch.Tensor] reward: Reward signal from reinforcement learning task. + A per-sample reward has shape ``[batch, 1, 1]``. :param float a_plus: Learning rate (post-synaptic). :param float a_minus: Learning rate (pre-synaptic). """ @@ -2662,6 +2698,8 @@ class Rmax(LearningRule): `_. """ + _step_state = ("eligibility_trace",) + def __init__( self, connection: AbstractConnection, @@ -2729,17 +2767,20 @@ def _connection_update(self, **kwargs) -> None: :param Union[float, torch.Tensor] reward: Reward signal from reinforcement learning task. + A per-sample reward has shape ``[batch, 1, 1]``. """ - # Initialize eligibility trace. + batch_size = self.source.batch_size + + # Initialize eligibility trace (one per sample). if not hasattr(self, "eligibility_trace"): self.eligibility_trace = torch.zeros( - *self.connection.w.shape, device=self.connection.w.device + batch_size, *self.connection.w.shape, device=self.connection.w.device ) # Reshape variables. - target_s = self.target.s.view(-1).float() - target_s_prob = self.target.s_prob.view(-1) - source_x = self.source.x.view(-1) + target_s = self.target.s.view(batch_size, -1).float() + target_s_prob = self.target.s_prob.view(batch_size, -1) + source_x = self.source.x.view(batch_size, -1) # Parse keyword arguments. reward = kwargs["reward"] @@ -2749,10 +2790,10 @@ def _connection_update(self, **kwargs) -> None: self.eligibility_trace += ( target_s - (target_s_prob / (1.0 + self.tc_c / self.connection.dt * target_s_prob)) - ) * source_x[:, None] + )[:, None, :] * source_x[:, :, None] - # Compute weight update. - update = self.nu[0] * reward * self.eligibility_trace + # Compute weight update, reduced over the batch. + update = self.reduction(self.nu[0] * reward * self.eligibility_trace, dim=0) if self.connection.w.is_sparse: update = update.to_sparse() self.connection.w += update diff --git a/bindsnet/network/monitors.py b/bindsnet/network/monitors.py index 00d875d1..2070452d 100644 --- a/bindsnet/network/monitors.py +++ b/bindsnet/network/monitors.py @@ -327,5 +327,5 @@ def reset_state_variables(self) -> None: for c in self.connections: if hasattr(self.network.connections[c], v): self.recording[c][v] = torch.zeros( - self.time, *getattr(self.network.layers[c], v).size() + self.time, *getattr(self.network.connections[c], v).size() ) diff --git a/bindsnet/network/nodes.py b/bindsnet/network/nodes.py index 6609c215..58aae4fe 100644 --- a/bindsnet/network/nodes.py +++ b/bindsnet/network/nodes.py @@ -1233,37 +1233,37 @@ def __init__( excitatory = 0 if excitatory == 1: - self.r = torch.rand(n) - self.a = 0.02 * torch.ones(n) - self.b = 0.2 * torch.ones(n) + self.r = torch.rand(self.n) + self.a = 0.02 * torch.ones(self.n) + self.b = 0.2 * torch.ones(self.n) self.c = -65.0 + 15 * (self.r**2) self.d = 8 - 6 * (self.r**2) - self.S = 0.5 * torch.rand(n, n) - self.excitatory = torch.ones(n).byte() + self.S = 0.5 * torch.rand(self.n, self.n) + self.excitatory = torch.ones(self.n).byte() elif excitatory == 0: - self.r = torch.rand(n) + self.r = torch.rand(self.n) self.a = 0.02 + 0.08 * self.r self.b = 0.25 - 0.05 * self.r - self.c = -65.0 * torch.ones(n) - self.d = 2 * torch.ones(n) - self.S = -torch.rand(n, n) + self.c = -65.0 * torch.ones(self.n) + self.d = 2 * torch.ones(self.n) + self.S = -torch.rand(self.n, self.n) - self.excitatory = torch.zeros(n).byte() + self.excitatory = torch.zeros(self.n).byte() else: - self.excitatory = torch.zeros(n).byte() + self.excitatory = torch.zeros(self.n).byte() - ex = int(n * excitatory) - inh = n - ex + ex = int(self.n * excitatory) + inh = self.n - ex # init - self.r = torch.zeros(n) - self.a = torch.zeros(n) - self.b = torch.zeros(n) - self.c = torch.zeros(n) - self.d = torch.zeros(n) - self.S = torch.zeros(n, n) + self.r = torch.zeros(self.n) + self.a = torch.zeros(self.n) + self.b = torch.zeros(self.n) + self.c = torch.zeros(self.n) + self.d = torch.zeros(self.n) + self.S = torch.zeros(self.n, self.n) # excitatory self.r[:ex] = torch.rand(ex) @@ -1271,7 +1271,7 @@ def __init__( self.b[:ex] = 0.2 * torch.ones(ex) self.c[:ex] = -65.0 + 15 * self.r[:ex] ** 2 self.d[:ex] = 8 - 6 * self.r[:ex] ** 2 - self.S[:, :ex] = 0.5 * torch.rand(n, ex) + self.S[:, :ex] = 0.5 * torch.rand(self.n, ex) self.excitatory[:ex] = 1 # inhibitory @@ -1280,10 +1280,15 @@ def __init__( self.b[ex:] = 0.25 - 0.05 * self.r[ex:] self.c[ex:] = -65.0 * torch.ones(inh) self.d[ex:] = 2 * torch.ones(inh) - self.S[:, ex:] = -torch.rand(n, inh) + self.S[:, ex:] = -torch.rand(self.n, inh) self.excitatory[ex:] = 0 - self.register_buffer("v", self.rest * torch.ones(n)) # Neuron voltages. + # Per-neuron parameters take the layer's shape; S stays [n, n] over the + # flattened neurons. + for name in ("r", "a", "b", "c", "d", "excitatory"): + setattr(self, name, getattr(self, name).view(*self.shape)) + + self.register_buffer("v", self.rest * torch.ones(*self.shape)) # Voltages. self.register_buffer("u", self.b * self.v) # Neuron recovery. def forward(self, x: torch.Tensor) -> None: @@ -1300,10 +1305,11 @@ def forward(self, x: torch.Tensor) -> None: # Add inter-columnar input. if self.s.any(): + s = self.s.view(self.s.size(0), -1) x += torch.cat( - [self.S[:, self.s[i]].sum(dim=1)[None] for i in range(self.s.shape[0])], + [self.S[:, s[i]].sum(dim=1)[None] for i in range(s.size(0))], dim=0, - ) + ).view_as(x) # Apply v and u updates. self.v.add_(self.dt * 0.5 * (0.04 * self.v**2 + 5 * self.v + 140 - self.u + x)) @@ -1463,10 +1469,10 @@ def forward(self, x: torch.Tensor) -> None: # Integrate inputs. v = torch.einsum( - "i,kij->kj", self.resKernel, x + "i,ki...->k...", self.resKernel, x ) # Response due to incoming current v += torch.einsum( - "i,kij->kj", self.refKernel, self.last_spikes + "i,ki...->k...", self.refKernel, self.last_spikes ) # Refractoriness due to previous spikes self.v.add_(v.view(x.size(0), *self.shape)) @@ -1494,6 +1500,7 @@ def reset_state_variables(self) -> None: """ super().reset_state_variables() self.v.fill_(self.rest) # Neuron voltages. + self.last_spikes.zero_() # Spikes in the refractory window. def compute_decays(self, dt) -> None: # language=rst diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index aa43c5cc..3a009fa0 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -9,10 +9,28 @@ from torch.nn import Module, Parameter from torch.nn.modules.utils import _pair, _triple -from bindsnet.network.nodes import CSRMNodes, Nodes +from bindsnet.network.nodes import Nodes from bindsnet.utils import im2col_indices +def _load_window(module, state_dict, prefix, name): + # language=rst + """ + Match ``module``'s spike-window buffer ``name`` (created on the first step) to + a state dict about to be loaded: adopt a saved window, or drop one the saved + state did not have, so ``load_state_dict`` restores the window exactly. + """ + key = prefix + name + if key in state_dict: + if ( + getattr(module, name) is None + or getattr(module, name).shape != state_dict[key].shape + ): + setattr(module, name, torch.empty_like(state_dict[key])) + else: + setattr(module, name, None) + + class AbstractConnection(ABC, Module): # language=rst """ @@ -141,8 +159,12 @@ def update(self, **kwargs) -> None: def reset_state_variables(self) -> None: # language=rst """ - Contains resetting logic for the connection. + Contains resetting logic for the connection. Subclasses call this, which + resets the learning rule's state carried across time steps. """ + reset = getattr(self.update_rule, "reset_state_variables", None) + if reset is not None: + reset() @staticmethod def cast_dtype_if_needed(w, w_dtype): @@ -193,14 +215,28 @@ def __init__( [] if pipeline is None else pipeline ) # <- *Ordered* executables for features - # TODO: Make it so there can't be repeated names!!! # Initialize feature index & prime self.feature_index = ( {} ) # <- *Unordered* and named set of references for features - for feature in pipeline: + for feature in self.pipeline: self.feature_index[feature.name] = feature feature.prime_feature(connection=self, device=self.device, **kwargs) + self._check_sub_features() + + def _check_sub_features(self) -> None: + # language=rst + """ + A sub-feature (``Normalization``, ``Updating``) runs a method of its parent + feature, which must be primed as an entry of the same pipeline. + """ + for f in self.pipeline: + parent = getattr(f, "parent", None) + if parent is not None and not any(parent is g for g in self.pipeline): + raise ValueError( + f"sub-feature {f.name!r}: its parent feature {parent.name!r} " + "must be in the same pipeline" + ) @abstractmethod def compute(self, s: torch.Tensor) -> None: @@ -240,6 +276,7 @@ def append_pipeline(self, feature) -> None: self.pipeline.append(feature) feature.prime_feature(connection=self, device=self.device) self.feature_index[feature.name] = feature + self._check_sub_features() def insert_pipeline(self, feature, index) -> None: # language=rst @@ -248,9 +285,10 @@ def insert_pipeline(self, feature, index) -> None: :param index: Index for where to insert the feature """ - self.pipeline.insert(feature, index) + self.pipeline.insert(index, feature) feature.prime_feature(connection=self, device=self.device) self.feature_index[feature.name] = feature + self._check_sub_features() def remove_pipeline(self, feature) -> None: # language=rst @@ -346,7 +384,9 @@ def __init__( w = w.to(dtype=w_dtype) else: if (self.wmin != -np.inf).any() or (self.wmax != np.inf).any(): - w = torch.clamp(torch.as_tensor(w), self.wmin, self.wmax) + w = torch.as_tensor(w) + w = w.to_dense() if w.is_sparse else w # no sparse clamp in torch + w = torch.clamp(w, self.wmin, self.wmax) w = self.cast_dtype_if_needed(w, w_dtype) self.w = Parameter(w, requires_grad=False) @@ -357,8 +397,12 @@ def __init__( else: self.b = None - if isinstance(self.target, CSRMNodes): - self.s_w = None + # Window of recent input spikes for CSRM targets (see compute_window). + self.register_buffer("s_w", None) + + def _load_from_state_dict(self, state_dict, prefix, *args, **kwargs): + _load_window(self, state_dict, prefix, "s_w") + super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) def compute(self, s: torch.Tensor) -> torch.Tensor: # language=rst @@ -378,12 +422,22 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: def compute_window(self, s: torch.Tensor) -> torch.Tensor: # language=rst - """ """ + """ + Post-synaptic input for CSRM targets: the last ``target.res_window_size`` + input spike vectors, each multiplied by the *current* weights, shape + ``[batch, window, *target.shape]``. (``MulticompartmentConnection`` keeps its + past outputs instead, so the two differ once the weights learn.) - if self.s_w == None: + :param s: Incoming spikes, shape ``[batch, *source.shape]``. + """ + + if self.s_w is None or self.s_w.size(0) != s.size(0): # Construct a matrix of shape batch size * window size * dimension of layer self.s_w = torch.zeros( - self.target.batch_size, self.target.res_window_size, *self.source.shape + self.target.batch_size, + self.target.res_window_size, + *self.source.shape, + device=s.device, ) # Add the spike vector into the first in first out matrix of windowed (res) spike trains @@ -428,6 +482,7 @@ def reset_state_variables(self) -> None: Contains resetting logic for the connection. """ super().reset_state_variables() + self.s_w = None class MulticompartmentConnection(AbstractMulticompartmentConnection): @@ -478,6 +533,79 @@ def __init__( # change: learning updates, normalize, reset, device/dtype moves. self._fold_cache = None + # Window of recent outputs for CSRM targets (see compute_window). + self.register_buffer("out_w", None) + + # Pipeline features are not submodules, so their values are saved and + # loaded here, under "feature.". + def _feature_values(self): + # (key, tensor, feature, extra-state name or None) for every saved tensor. + for f in self.pipeline: + value = getattr(f, "value", None) # sub-features have no value + if isinstance(value, torch.Tensor): + yield f"feature.{f.name}", value, f, None + extra = getattr(f, "extra_state_tensors", None) + for name, t in (extra() if extra else {}).items(): + yield f"feature.{f.name}.{name}", t, f, name + + def _save_to_state_dict(self, destination, prefix, keep_vars): + super()._save_to_state_dict(destination, prefix, keep_vars) + names = [f.name for f in self.pipeline] + repeated = sorted({n for n in names if names.count(n) > 1}) + if repeated: + raise ValueError( + f"pipeline feature names must be unique to save the state: {repeated}" + ) + for key, value, _, _ in self._feature_values(): + destination[prefix + key] = value if keep_vars else value.detach() + + def _load_from_state_dict( + self, + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ): + _load_window(self, state_dict, prefix, "out_w") + loaded, extras = set(), {} + for key, value, feature, extra in list(self._feature_values()): + key = prefix + key + if key not in state_dict: + if strict: + missing_keys.append(key) + continue + saved = state_dict[key] + loaded.add(key) + if saved.shape != value.shape or saved.is_sparse != value.is_sparse: + error_msgs.append( + f"size mismatch for {key}: copying a " + f"{'sparse' if saved.is_sparse else 'dense'} tensor of shape " + f"{tuple(saved.shape)} from checkpoint, the current one is " + f"{'sparse' if value.is_sparse else 'dense'} with shape " + f"{tuple(value.shape)}." + ) + elif extra is None: + with torch.no_grad(): # in place: rules hold the same tensor + value.copy_(saved) + else: + extras.setdefault(feature, {})[extra] = saved + for feature, tensors in extras.items(): + feature.load_extra_state_tensors(tensors) + self._fold_cache = None + super()._load_from_state_dict( + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ) + unexpected_keys[:] = [k for k in unexpected_keys if k not in loaded] + def _apply(self, fn, recurse=True): self._fold_cache = None return super()._apply(fn, recurse) @@ -522,9 +650,13 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: # Side-effect-only pipeline entries (sub-features) fold as identity. continue if isinstance(factor, torch.Tensor) and factor.is_sparse: - # Sparse feature values carry a leading batch dim ([1, src, tgt]) - # from prime_feature; densify to the fold's [src, tgt] shape. - factor = factor.to_dense().view(self.source.n, self.target.n) + # Sparse feature values carry a leading batch dim ([batch, src, + # tgt], one weight copy per sample) from prime_feature. One copy + # densifies to the fold's [src, tgt] shape; several stay + # [batch, src, tgt] and are applied per sample below. + factor = factor.to_dense() + if factor.size(0) == 1: + factor = factor.view(self.source.n, self.target.n) op = getattr(f, "op", "mul") if op == "mul": a_eff = factor if a_eff is None else a_eff * factor @@ -543,7 +675,7 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: # Additive terms apply to every synapse regardless of spikes, so # their contribution is the source-sum, a constant [target.n] row. - b_sum = b_eff.sum(dim=0) if b_eff is not None else None + b_sum = b_eff.sum(dim=-2) if b_eff is not None else None if all(getattr(f, "is_static", True) for f in self.pipeline): self._fold_cache = (a_eff, b_sum) @@ -554,7 +686,9 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: use_gather = self.sparse_compute and ( not a_eff.is_cuda or self.source.n * self.target.n >= 4_000_000 ) - if use_gather: + if a_eff.dim() == 3: # one weight copy per sample (sparse, batch > 1) + out = torch.bmm(s.unsqueeze(1).to(a_eff.dtype), a_eff).squeeze(1) + elif use_gather: # Read only the rows of A for source neurons that spiked this step # (numerically identical; faster only when few are active). active = s.any(dim=0).nonzero(as_tuple=False).squeeze(1) @@ -576,37 +710,34 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: if self.traces: self.activity = out - if out.size() != torch.Size([s.size(0)] + self.target.shape): + if out.size() != torch.Size([s.size(0), *self.target.shape]): return out.view(s.size(0), *self.target.shape) return out def compute_window(self, s: torch.Tensor) -> torch.Tensor: # language=rst - """""" - - if self.s_w == None: - # Construct a matrix of shape batch size * window size * dimension of layer - self.s_w = torch.zeros( - self.target.batch_size, self.target.res_window_size, *self.source.shape - ) - - # Add the spike vector into the first in first out matrix of windowed (res) spike trains - self.s_w = torch.cat((self.s_w[:, 1:, :], s[:, None, :]), 1) + """ + Post-synaptic input for CSRM targets: the last ``target.res_window_size`` + outputs of :meth:`compute`, newest last, shape + ``[batch, window, *target.shape]``. The pipeline runs once per step, on that + step's spikes, so features with state advance once per step. Each past + output keeps the weights of its own step (``Connection.compute_window`` + applies the current weights to past spikes instead; with static weights the + two agree). - # Compute multiplication of spike activations by weights and add bias. - if self.b is None: - post = ( - self.s_w.view(self.s_w.size(0), self.s_w.size(1), -1).float() @ self.w - ) - else: - post = ( - self.s_w.view(self.s_w.size(0), self.s_w.size(1), -1).float() @ self.w - + self.b + :param s: Incoming spikes, shape ``[batch, *source.shape]``. + """ + out = self.compute(s) + if self.out_w is None or self.out_w.size(0) != s.size(0): + self.out_w = torch.zeros( + s.size(0), + self.target.res_window_size, + *self.target.shape, + device=out.device, + dtype=out.dtype, ) - - return post.view( - self.s_w.size(0), self.target.res_window_size, *self.target.shape - ) + self.out_w = torch.cat((self.out_w[:, 1:], out[:, None]), 1) + return self.out_w def update(self, **kwargs) -> None: # language=rst @@ -615,10 +746,15 @@ def update(self, **kwargs) -> None: """ learning = kwargs.get("learning", False) if learning and not self.manual_update: - # Pipeline learning + # Pipeline learning. A feature driven by an ``Updating`` sub-feature + # learns when that entry runs, so it is not updated a second time here. self._fold_cache = None + driven = [ + f.parent for f in self.pipeline if getattr(f, "drives_update", False) + ] for f in self.pipeline: - f.update(**kwargs) + if not any(f is d for d in driven): + f.update(**kwargs) def normalize(self) -> None: # language=rst @@ -638,6 +774,7 @@ def reset_state_variables(self) -> None: super().reset_state_variables() self._fold_cache = None + self.out_w = None for f in self.pipeline: f.reset_state_variables() @@ -1193,8 +1330,11 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: :return: Incoming spikes multiplied by synaptic weights (with or without decaying spike activation). """ - self.firing_rates -= self.decay * self.firing_rates - self.firing_rates += s.float().squeeze() + if self.firing_rates.shape != s.shape: # batch size changed + self.firing_rates = torch.zeros_like(s, dtype=torch.float) + if self.decay is not None: # no decay given: plain spike counts + self.firing_rates -= self.decay * self.firing_rates + self.firing_rates += s.float() _, indices = F.max_pool1d( self.firing_rates, @@ -1228,7 +1368,9 @@ def reset_state_variables(self) -> None: super().reset_state_variables() self.firing_rates = torch.zeros( - self.source.batch_size, *(self.source.s.shape[1:]) + self.source.batch_size, + *(self.source.s.shape[1:]), + device=self.firing_rates.device, ) @@ -1283,8 +1425,11 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: :return: Incoming spikes multiplied by synaptic weights (with or without decaying spike activation). """ - self.firing_rates -= self.decay * self.firing_rates - self.firing_rates += s.float().squeeze() + if self.firing_rates.shape != s.shape: # batch size changed + self.firing_rates = torch.zeros_like(s, dtype=torch.float) + if self.decay is not None: # no decay given: plain spike counts + self.firing_rates -= self.decay * self.firing_rates + self.firing_rates += s.float() _, indices = F.max_pool2d( self.firing_rates, @@ -1318,7 +1463,9 @@ def reset_state_variables(self) -> None: super().reset_state_variables() self.firing_rates = torch.zeros( - self.source.batch_size, *(self.source.s.shape[1:]) + self.source.batch_size, + *(self.source.s.shape[1:]), + device=self.firing_rates.device, ) @@ -1373,8 +1520,11 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: :return: Incoming spikes multiplied by synaptic weights (with or without decaying spike activation). """ - self.firing_rates -= self.decay * self.firing_rates - self.firing_rates += s.float().squeeze() + if self.firing_rates.shape != s.shape: # batch size changed + self.firing_rates = torch.zeros_like(s, dtype=torch.float) + if self.decay is not None: # no decay given: plain spike counts + self.firing_rates -= self.decay * self.firing_rates + self.firing_rates += s.float() _, indices = F.max_pool3d( self.firing_rates, @@ -1408,7 +1558,9 @@ def reset_state_variables(self) -> None: super().reset_state_variables() self.firing_rates = torch.zeros( - self.source.batch_size, *(self.source.s.shape[1:]) + self.source.batch_size, + *(self.source.s.shape[1:]), + device=self.firing_rates.device, ) @@ -1561,7 +1713,7 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: s.float().view(s.size(0), -1) @ self.w.view(self.source.n, self.target.n) + self.b ) - return a_post.view(*self.target.shape) + return a_post.view(s.size(0), *self.target.shape) def update(self, **kwargs) -> None: # language=rst @@ -2087,9 +2239,10 @@ def __init__( tensor of same size as w :param Union[float, torch.Tensor] wmax: Maximum allowed value(s) on the connection weights. Single value, or tensor of same size as w - :param float norm: Total weight per target neuron normalization constant. + :param float norm: Total absolute weight across target neurons. A shared scalar + weight is counted once per target neuron. """ - super().__init__(source, target, nu, weight_decay, **kwargs) + super().__init__(source, target, nu, weight_decay=weight_decay, **kwargs) w = kwargs.get("w", None) if w is None: @@ -2113,8 +2266,9 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: :return: Incoming spikes multiplied by synaptic weights (with or without decaying spike activation). """ - # Compute multiplication of mean-field pre-activation by connection weights. - return s.float().mean() * self.w + # Mean-field pre-activation of each sample, times the connection weights. + mean = s.float().flatten(1).mean(1) + return mean.view(-1, *[1] * len(self.target.shape)) * self.w def update(self, **kwargs) -> None: # language=rst @@ -2126,13 +2280,13 @@ def update(self, **kwargs) -> None: def normalize(self) -> None: # language=rst """ - Normalize weights so each target neuron has sum of connection weights equal to - ``self.norm``. + Normalize absolute weights across target neurons to ``self.norm``, preserving + signs and scalar or per-target tensor shape. A shared scalar is counted once + per target neuron. All-zero stored weights remain zero. """ if self.norm is not None: - self.w = self.w.view(1, self.target.n) - self.w *= self.norm / self.w.sum() - self.w = self.w.view(1, *self.target.shape) + total = self.w.abs().sum() * (self.target.n / self.w.numel()) + self.w *= self.norm / total.masked_fill(total == 0, 1) def reset_state_variables(self) -> None: # language=rst diff --git a/bindsnet/network/topology_features.py b/bindsnet/network/topology_features.py index 8426ee62..cb703aec 100644 --- a/bindsnet/network/topology_features.py +++ b/bindsnet/network/topology_features.py @@ -285,7 +285,8 @@ def normalize(self) -> None: abs_sum = self.value.sum(1).to_dense() abs_sum[abs_sum == 0] = 1.0 abs_sum = abs_sum.unsqueeze(1).expand(-1, *self.value.shape[1:]) - self.value = self.value * (self.norm / abs_sum) + # In place: the learning rule holds this same tensor. + self.value.copy_(self.value * (self.norm / abs_sum)) else: abs_sum = self.value.sum(0).unsqueeze(0) abs_sum[abs_sum == 0] = 1.0 @@ -568,17 +569,24 @@ class MeanField(AbstractFeature): # Depends on the incoming spikes; never cacheable. is_static = False - def __init__(self) -> None: + def __init__(self, name: str = "mean_field") -> None: # language=rst """ - Takes the mean of all outgoing signals, and outputs that mean across every synapse in the connection + Takes the mean of each sample's incoming spikes, and outputs that mean across + every synapse in the connection. + + :param name: Name of the feature. """ - pass + super().__init__(name=name) def compute(self, s) -> Union[torch.Tensor, float, int]: - return s.float().mean() * torch.ones( - self.source_n, self.target_n, device=s.device - ) + # One [source.n, target.n] factor per sample: samples do not mix. + mean = s.float().flatten(1).mean(1) + return mean.view(-1, 1, 1).expand(-1, self.source_n, self.target_n) + + def initialize_value(self): + # The output depends only on the spikes; the value is a placeholder. + return torch.ones(1) def prime_feature(self, connection, device, **kwargs) -> None: self.source_n = connection.source.n @@ -839,7 +847,30 @@ def compute(self, s) -> Union[torch.Tensor, float, int]: return self.value -class AdaptationBaseSynapsHistory(AbstractFeature): +class _AdaptationState: + # The adaptation features' state besides ``value``, saved in the state_dict by + # MulticompartmentConnection (``feature..``). + def extra_state_tensors(self) -> dict: + return { + "mask": self.mask, + "spike_buffer": self.spike_buffer, + "counter": torch.tensor(self.counter), + "start_counter": torch.tensor(self.start_counter), + } + + def load_extra_state_tensors(self, tensors: dict) -> None: + for name in ("mask", "spike_buffer"): + if name in tensors: + setattr( + self, name, tensors[name].clone().to(getattr(self, name).device) + ) + if "counter" in tensors: + self.counter = int(tensors["counter"]) + if "start_counter" in tensors: + self.start_counter = bool(tensors["start_counter"]) + + +class AdaptationBaseSynapsHistory(_AdaptationState, AbstractFeature): # Value evolves with the spike history every step; never cacheable. is_static = False @@ -912,9 +943,13 @@ def forward(self, x): ) def compute(self, s) -> Union[torch.Tensor, float, int]: - # This feature needs the per-synapse spikes, so build them from s - conn_spikes = s.view(s.size(0), -1, 1).expand( - s.size(0), s.size(1), self.value.size(-1) + # This feature needs the per-synapse spikes, so build them from s. The + # mask is connectivity, shared by the batch: a synapse is active when its + # pre-synaptic neuron spiked in any sample. + conn_spikes = ( + s.view(s.size(0), -1, 1) + .expand(s.size(0), s.size(1), self.value.size(-1)) + .any(0) ) # Update the spike buffer @@ -956,7 +991,7 @@ def reset_state_variables( self.value = self.init_value.clone().detach() # initial mask -class AdaptationBaseOtherSynaps(AbstractFeature): +class AdaptationBaseOtherSynaps(_AdaptationState, AbstractFeature): # Value evolves with the spike history every step; never cacheable. is_static = False @@ -1029,9 +1064,13 @@ def forward(self, x): ) def compute(self, s) -> Union[torch.Tensor, float, int]: - # This feature needs the per-synapse spikes, so build them from s - conn_spikes = s.view(s.size(0), -1, 1).expand( - s.size(0), s.size(1), self.value.size(-1) + # This feature needs the per-synapse spikes, so build them from s. The + # mask is connectivity, shared by the batch: a synapse is active when its + # pre-synaptic neuron spiked in any sample. + conn_spikes = ( + s.view(s.size(0), -1, 1) + .expand(s.size(0), s.size(1), self.value.size(-1)) + .any(0) ) # Update the spike buffer @@ -1116,6 +1155,21 @@ def compute(self, s): self.sub_feature() return None + # The connection calls these on every pipeline entry. A sub-feature has no + # value of its own: its work runs in compute(), and its parent feature is + # primed, updated, normalized and reset as a pipeline entry itself. + def prime_feature(self, connection, device, **kwargs) -> None: + pass + + def update(self, **kwargs) -> None: + pass + + def normalize(self) -> None: + pass + + def reset_state_variables(self) -> None: + pass + class Normalization(AbstractSubFeature): # language=rst @@ -1136,9 +1190,13 @@ def __init__( class Updating(AbstractSubFeature): # language=rst """ - Update parent features values using the assigned update rule. + Update parent features values using the assigned update rule, at this entry's + place in the pipeline. The connection's own update then skips the parent, so it + learns once per step. """ + drives_update = True + def __init__( self, name: str, diff --git a/test/api_surface.json b/test/api_surface.json index 1a7df2f4..58f80c36 100644 --- a/test/api_surface.json +++ b/test/api_surface.json @@ -1,8 +1,8 @@ { "meta": { - "reason": "Initial snapshot of the public API (test hardening Phase 6)", - "date": "2026-10-24", - "commit": "db7d193", + "reason": "Sub-features get no-op prime_feature/update/normalize/reset_state_variables (they broke MulticompartmentConnection); MeanField gets an optional name (it had none) and initialize_value", + "date": "2026-10-28", + "commit": "c5ee6e9", "python": "3.11.9", "torch": "2.14.1+cu130" }, @@ -11,6 +11,16 @@ "date": "2026-10-24", "commit": "db7d193", "reason": "Initial snapshot of the public API (test hardening Phase 6)" + }, + { + "date": "2026-10-28", + "commit": "926e60d", + "reason": "LearningRule.reset_state_variables added (clears per-step rule state on reset)" + }, + { + "date": "2026-10-28", + "commit": "c5ee6e9", + "reason": "Sub-features get no-op prime_feature/update/normalize/reset_state_variables (they broke MulticompartmentConnection); MeanField gets an optional name (it had none) and initialize_value" } ], "surface": { @@ -190,6 +200,7 @@ "bindsnet.learning.learning.DiehlAndCook": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", "bindsnet.learning.learning.Hebbian": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", "bindsnet.learning.learning.LearningRule": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", + "bindsnet.learning.learning.LearningRule.reset_state_variables": "(self)", "bindsnet.learning.learning.LearningRule.update": "(self)", "bindsnet.learning.learning.MSTDP": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", "bindsnet.learning.learning.MSTDPET": "(connection, nu=None, reduction=None, weight_decay=0.0, **kwargs)", @@ -386,6 +397,10 @@ "bindsnet.network.topology_features.AbstractFeature.update": "(self, **kwargs)", "bindsnet.network.topology_features.AbstractSubFeature": "(name, parent_feature)", "bindsnet.network.topology_features.AbstractSubFeature.compute": "(self, s)", + "bindsnet.network.topology_features.AbstractSubFeature.normalize": "(self)", + "bindsnet.network.topology_features.AbstractSubFeature.prime_feature": "(self, connection, device, **kwargs)", + "bindsnet.network.topology_features.AbstractSubFeature.reset_state_variables": "(self)", + "bindsnet.network.topology_features.AbstractSubFeature.update": "(self, **kwargs)", "bindsnet.network.topology_features.AdaptationBaseOtherSynaps": "(name, value=None, value_dtype=torch.float32, ann_values=None, const_update_rate=0.1, const_decay=0.01, sparse=False, batch_size=1)", "bindsnet.network.topology_features.AdaptationBaseOtherSynaps.compute": "(self, s)", "bindsnet.network.topology_features.AdaptationBaseOtherSynaps.reset_state_variables": "(self)", @@ -403,8 +418,9 @@ "bindsnet.network.topology_features.Mask": "(name, value=None, sparse=False, batch_size=1)", "bindsnet.network.topology_features.Mask.compute": "(self, s)", "bindsnet.network.topology_features.Mask.prime_feature": "(self, connection, device, **kwargs)", - "bindsnet.network.topology_features.MeanField": "()", + "bindsnet.network.topology_features.MeanField": "(name='mean_field')", "bindsnet.network.topology_features.MeanField.compute": "(self, s)", + "bindsnet.network.topology_features.MeanField.initialize_value": "(self)", "bindsnet.network.topology_features.MeanField.prime_feature": "(self, connection, device, **kwargs)", "bindsnet.network.topology_features.Normalization": "(name, parent_feature)", "bindsnet.network.topology_features.Probability": "(name, value=None, value_dtype=torch.float32, range=None, norm=None, learning_rule=None, nu=None, reduction=None, decay=0.0, parent_feature=None, sparse=False, batch_size=1)", diff --git a/test/network/test_all_classes.py b/test/network/test_all_classes.py index ad265a7b..c76590db 100644 --- a/test/network/test_all_classes.py +++ b/test/network/test_all_classes.py @@ -8,10 +8,9 @@ outputs are finite and have the right shapes. Learning-rule cases also check that the weights moved, and the table of which rule supports which connection type is pinned. -Classes or combinations that crash on master are listed in ``KNOWN_BUGS`` at the end -with their exact error. When someone fixes one, its reproducer stops raising, the test -fails with "DID NOT RAISE", and the entry must be removed (and the normal test -widened) in the same change. +``FIXED_BUGS`` at the end holds the reproducers of the crashes found on master in +October 2026 and fixed for 0.3.5, each with what was wrong; each must keep running +cleanly. """ import inspect @@ -94,8 +93,6 @@ def _weights(conn): # --------------------------------------------------------------------------- # NODE_SHAPE = (3, 4) -# Node classes that only work with a one-dimensional shape (see KNOWN_BUGS). -NODE_1D_ONLY = {"CSRMNodes", "IzhikevichNodes"} def _run_nodes(name, shape, batch=BATCH): @@ -122,8 +119,7 @@ def test_nodes_construct_and_run(name): Caught: ``self.v = self.v / 0`` inserted at the end of ``BoostedLIFNodes.forward``. """ - shape = (12,) if name in NODE_1D_ONLY else NODE_SHAPE - net = _run_nodes(name, shape) + net = _run_nodes(name, NODE_SHAPE) _check_layers(net) @@ -171,10 +167,8 @@ def test_nodes_construct_and_run(name): # A connection class not listed above is tried with this dense spec, and # test_every_connection_class_has_a_shape_spec fails until it is listed. DEFAULT_SPEC = ((20,), (10,), {}) -# Extra arguments that are not optional in practice (see KNOWN_BUGS). +# Max-pooling connections (run with decay=0.5; without decay see FIXED_BUGS). MAXPOOL = {"MaxPool1dConnection", "MaxPool2dConnection", "MaxPoo3dConnection"} -# Connection classes whose compute() only works for batch size 1 (see KNOWN_BUGS). -CONNECTION_BATCH1_ONLY = {"LocalConnection"} def _build(conn_name, rule=None, batch=BATCH, spec=None, **extra): @@ -195,16 +189,12 @@ def _build(conn_name, rule=None, batch=BATCH, spec=None, **extra): net.add_layer(src, name="in") net.add_layer(tgt, name="out") if cls is topology.MulticompartmentConnection: - if not isinstance(tgt.shape, list): # tuple shapes crash (KNOWN_BUGS) - tgt.shape = list(tgt.shape) w = torch.rand(src.n, tgt.n) weight = tf.Weight(name="w", value=w, range=(0.0, 1.0), **kwargs) conn = cls(source=src, target=tgt, device="cpu", pipeline=[weight]) else: if rule is not None: - kwargs.update(update_rule=rule, nu=(1e-2, 1e-2)) - if conn_name != "SparseConnection": # sparse w rejects wmin/wmax - kwargs.update(wmin=0.0, wmax=1.0) + kwargs.update(update_rule=rule, nu=(1e-2, 1e-2), wmin=0.0, wmax=1.0) conn = cls(src, tgt, **kwargs) net.add_connection(conn, source="in", target="out") return net, conn, src_shape @@ -216,7 +206,7 @@ def test_connection_construct_and_run(name): Caught: ``.view_as(indices)`` changed to ``.view(-1)`` in ``MaxPool1dConnection.compute``. """ - batch = 1 if name in CONNECTION_BATCH1_ONLY else BATCH + batch = BATCH extra = {"decay": 0.5} if name in MAXPOOL else {} net, conn, src_shape = _build(name, batch=batch, **extra) net.run(inputs={"in": _spikes(src_shape, batch)}, time=STEPS) @@ -263,19 +253,13 @@ def test_every_connection_class_has_a_shape_spec(): SUPPORTED = { "NoOp": set(LEARNABLE), "PostPre": set(LEARNABLE), - "WeightDependentPostPre": set(LEARNABLE) - {"SparseConnection"}, + "WeightDependentPostPre": set(LEARNABLE), "Hebbian": set(LEARNABLE), "MSTDP": set(LEARNABLE), "MSTDPET": set(LEARNABLE), - "DiehlAndCook": DENSE_LIKE - {"SparseConnection"}, + "DiehlAndCook": DENSE_LIKE, "Rmax": DENSE_LIKE, } -# Pairs that pass the construction check but crash when run (see KNOWN_BUGS). -BROKEN_PAIRS = { - ("WeightDependentPostPre", "SparseConnection"), - ("DiehlAndCook", "SparseConnection"), -} -RULE_BATCH1_ONLY = {"Rmax"} @pytest.mark.parametrize("conn_name", LEARNABLE) @@ -287,15 +271,11 @@ def test_rule_on_connection(rule_name, conn_name): """ assert rule_name in SUPPORTED, f"add {rule_name} to SUPPORTED" rule = RULE_CLASSES[rule_name] - if (rule_name, conn_name) in BROKEN_PAIRS: - pytest.skip("crashes on master; pinned in KNOWN_BUGS") if conn_name not in SUPPORTED[rule_name]: with pytest.raises(NotImplementedError): _build(conn_name, rule=rule) return batch = BATCH - if rule_name in RULE_BATCH1_ONLY or conn_name in CONNECTION_BATCH1_ONLY: - batch = 1 net, conn, src_shape = _build(conn_name, rule=rule, batch=batch) w0 = _weights(conn).clone() net.run(inputs={"in": _spikes(src_shape, batch)}, time=STEPS, reward=1.0) @@ -309,9 +289,6 @@ def test_rule_on_connection(rule_name, conn_name): assert not torch.equal(w1, w0), "weights did not change" -MCC_RULE_BATCH1_ONLY = {"MSTDPET"} - - @pytest.mark.parametrize("rule_name", sorted(MCC_RULE_CLASSES)) def test_mcc_rule_on_multicompartment_connection(rule_name): """Each MCC rule runs on a MulticompartmentConnection Weight and moves it. @@ -319,7 +296,7 @@ def test_mcc_rule_on_multicompartment_connection(rule_name): Caught: both ``addmm_`` updates removed from ``MCC_learning.Hebbian._connection_update``. """ rule = MCC_RULE_CLASSES[rule_name] - batch = 1 if rule_name in MCC_RULE_BATCH1_ONLY else BATCH + batch = BATCH net, conn, src_shape = _build( "MulticompartmentConnection", batch=batch, learning_rule=rule, nu=(1e-2, 1e-2) ) @@ -344,8 +321,7 @@ def test_mcc_rule_on_multicompartment_connection(rule_name): "TwoLayerNetwork": [({"n_inpt": 16, "n_neurons": 8}, BATCH)], "DiehlAndCook2015": [ ({"n_inpt": 16, "n_neurons": 8}, BATCH), - # Batch 1 and no learning check: see KNOWN_BUGS (sparse-batch2, no-learning). - ({"n_inpt": 16, "n_neurons": 8, "sparse": True, "batch_size": 1}, 1), + ({"n_inpt": 16, "n_neurons": 8, "sparse": True, "batch_size": BATCH}, BATCH), ( { "n_inpt": 16, @@ -358,7 +334,6 @@ def test_mcc_rule_on_multicompartment_connection(rule_name): ], "DiehlAndCook2015v2": [({"n_inpt": 16, "n_neurons": 8}, BATCH)], "IncreasingInhibitionNetwork": [({"n_input": 16, "n_neurons": 9}, BATCH)], - # Batch 1: it is built on LocalConnection (see KNOWN_BUGS). "LocallyConnectedNetwork": [ ( { @@ -368,11 +343,10 @@ def test_mcc_rule_on_multicompartment_connection(rule_name): "stride": 2, "n_filters": 2, }, - 1, + BATCH, ) ], } -MODEL_NO_LEARNING_CHECK = {("DiehlAndCook2015", 1)} MODEL_CASES = [ (name, i) for name in sorted(MODEL_CLASSES) @@ -413,8 +387,7 @@ def test_model_construct_and_run(name, case): assert torch.isfinite(w).all(), f"{k} weights not finite" if not torch.equal(w, _weights(frozen.connections[k])): learned.append(k) - if (name, case) not in MODEL_NO_LEARNING_CHECK: - assert learned, "no connection learned in 10 steps" + assert learned, "no connection learned in 10 steps" # --------------------------------------------------------------------------- # @@ -447,7 +420,7 @@ def _ann(): w, tf.Degradation("d", _rand(SRC_N, TGT_N), degrade_function=lambda v: v / 2), ], - "MeanField": None, # cannot be built into a pipeline (KNOWN_BUGS) + "MeanField": lambda w: [tf.MeanField(), w], "AdaptationBaseSynapsHistory": lambda w: [ w, tf.AdaptationBaseSynapsHistory("a", _rand(SRC_N, TGT_N), ann_values=_ann()), @@ -461,10 +434,6 @@ def _ann(): } -FEATURE_BATCH1_ONLY = {"AdaptationBaseSynapsHistory", "AdaptationBaseOtherSynaps"} -SUB_FEATURES = {"Normalization", "Updating"} - - @pytest.mark.parametrize("name", sorted(FEATURE_CLASSES)) def test_feature_in_pipeline(name): """Every topology feature runs inside a MulticompartmentConnection for 10 steps. @@ -472,8 +441,6 @@ def test_feature_in_pipeline(name): Caught: ``return self.value`` changed to ``return self.value * float("nan")`` in ``Bias.compute``. """ assert name in FEATURE_PIPELINES, f"add a pipeline for {name} to FEATURE_PIPELINES" - if FEATURE_PIPELINES[name] is None: - pytest.skip(KNOWN_BUGS[f"{name}-feature"][0]) torch.manual_seed(0) weight = tf.Weight("w", _rand(SRC_N, TGT_N) * 4, norm=8.0) pipeline = FEATURE_PIPELINES[name](weight) @@ -482,28 +449,17 @@ def test_feature_in_pipeline(name): net = Network(dt=1.0, batch_size=BATCH) net.add_layer(src, name="in") net.add_layer(tgt, name="out") - # Sub-features cannot be primed (KNOWN_BUGS): build without them, then set - # the pipeline, as test_connections.py does. - primable = [f for f in pipeline if isinstance(f, tf.AbstractFeature)] conn = topology.MulticompartmentConnection( - source=src, target=tgt, device="cpu", pipeline=primable + source=src, target=tgt, device="cpu", pipeline=pipeline ) - conn.pipeline = pipeline net.add_connection(conn, source="in", target="out") - batch = 1 if name in FEATURE_BATCH1_ONLY else BATCH - if name in SUB_FEATURES: # Network.run calls update(), which they lack - s = _spikes((SRC_N,), batch)[0] - net.layers["in"].set_batch_size(batch) - else: - net.run(inputs={"in": _spikes((SRC_N,), batch)}, time=STEPS) - _check_layers(net, batch) - s = src.s - out = conn.compute(s) - assert tuple(out.shape) == (batch, TGT_N) + net.run(inputs={"in": _spikes((SRC_N,), BATCH)}, time=STEPS) + _check_layers(net, BATCH) + out = conn.compute(src.s) + assert tuple(out.shape) == (BATCH, TGT_N) assert torch.isfinite(out).all() - if name not in SUB_FEATURES: # they also lack normalize() and reset - conn.normalize() - conn.reset_state_variables() + conn.normalize() + conn.reset_state_variables() # --------------------------------------------------------------------------- # @@ -793,22 +749,8 @@ def _sparse_dc2015_batch2(): _run_model("DiehlAndCook2015", kwargs, 2) -# id -> (what is wrong and where, exception type, message fragment, reproducer). -KNOWN_BUGS = { - "CSRMNodes-2d-shape": ( - "CSRMNodes.forward uses einsum 'i,kij->kj', so only one-dimensional " - "shapes work", - RuntimeError, - "einsum", - _nodes_2d("CSRMNodes"), - ), - "IzhikevichNodes-2d-shape": ( - "IzhikevichNodes sizes its parameters by n, not by shape, so a " - "multi-dimensional shape gives a size mismatch (nodes.py forward)", - RuntimeError, - "must match the size", - _nodes_2d("IzhikevichNodes"), - ), +# id -> (what was wrong and where, exception type, message fragment, reproducer). +FIXED_BUGS = { "LocalConnection-batch2": ( "LocalConnection.compute reshapes to target.shape without the batch " "dimension (topology.py, `a_post.view(*self.target.shape)`)", @@ -948,13 +890,97 @@ def _sparse_dc2015_batch2(): } -@pytest.mark.parametrize("bug_id", sorted(KNOWN_BUGS)) -def test_known_bug_still_fails(bug_id): - """Pins each known crash, with its error, so a fix is noticed. +@pytest.mark.parametrize("bug_id", sorted(FIXED_BUGS)) +def test_fixed_bug_stays_fixed(bug_id): + """Each reproducer of a fixed crash runs cleanly. + + Caught: restoring ``s.float().squeeze()`` in MaxPool2dConnection.compute fails + MaxPool2dConnection-one-channel. + """ + FIXED_BUGS[bug_id][3]() + + +@pytest.mark.parametrize("batch", [1, 2]) +@pytest.mark.parametrize("rule", ["default", "DiehlAndCook"]) +def test_sparse_dc2015_learns_like_dense(rule, batch): + """Sparse weights hold one copy per sample, and every copy gets the batch's + shared update, so sparse and dense DiehlAndCook2015 learn the same weights (no + normalisation; with it, sparse sums in another order). - Caught: a ``return`` added as the first line of ``_rmax_batch2`` fails with DID NOT RAISE. + Caught: dropping the in-place copy_ in MCC_LearningRule.update (the sparse + clamp) leaves the sparse weights at their initial values; per-sample sparse + updates (no batch reduction) make batch 2 differ from dense. """ - reason, exc_type, match, reproduce = KNOWN_BUGS[bug_id] - with pytest.raises(exc_type, match=match): - reproduce() - pytest.xfail(reason) + kwargs = {"n_inpt": 16, "n_neurons": 8, "batch_size": batch} + kwargs.update(exc_thresh=-62.0, norm=None) + if rule == "DiehlAndCook": + kwargs.update( + learning_rule=mcc_learning.DiehlAndCook, learning_rule_kwargs={"x_tar": 0.4} + ) + learned = [] + for sparse in (False, True): + net = _run_model("DiehlAndCook2015", {**kwargs, "sparse": sparse}, batch) + w = _weights(net.connections[("X", "Ae")]) + w = w.to_dense() if w.is_sparse else w + learned.append(w.reshape(-1, 16, 8)) + dense, copies = learned[0][0], learned[1] + assert copies.shape[0] == batch + for copy in copies: + assert torch.allclose( + copy, dense, rtol=1e-6, atol=1e-7 + ) # batch summation order + if batch == 1: + assert torch.equal(copies[0], dense) + + +def test_mean_field_feature_keeps_samples_apart(): + """A MeanField feature uses each sample's own mean: the batch-2 output equals + each sample computed alone. + + Caught: MeanField.compute taking s.float().mean() over the whole batch. + """ + src, tgt = nodes.Input(n=SRC_N), nodes.LIFNodes(n=TGT_N) + weight = tf.Weight("w", _rand(SRC_N, TGT_N)) + conn = topology.MulticompartmentConnection( + source=src, target=tgt, device="cpu", pipeline=[tf.MeanField(), weight] + ) + s = torch.tensor([[1.0] * SRC_N, [1.0] + [0.0] * (SRC_N - 1)]) + both = conn.compute(s) + alone = torch.cat([conn.compute(s[i : i + 1]) for i in range(2)]) + assert not torch.equal(both[0], both[1]) + assert torch.equal(both, alone) + + +@pytest.mark.parametrize( + "rule_name", ["PostPre", "WeightDependentPostPre", "DiehlAndCook"] +) +def test_sparse_connection_learns_like_dense(rule_name): + """Rules on a bounded SparseConnection give the dense result: weight-dependent + factors use a dense view of w and the bounds are applied in place. + + Caught: the sparse branch of LearningRule.update leaving w unbounded (PostPre + fails; the weight-dependent rules never reach the bounds). + """ + w0 = torch.rand(SRC_N, TGT_N, generator=torch.Generator().manual_seed(3)) + inputs = {"in": _spikes((SRC_N,), 1)} + learned = [] + for cls, w in ( + (topology.Connection, w0), + (topology.SparseConnection, w0.to_sparse()), + ): + torch.manual_seed(0) + src = nodes.Input(n=SRC_N, traces=True) + tgt = nodes.LIFNodes(n=TGT_N, traces=True, thresh=-60.0) + net = Network(dt=1.0, batch_size=1) + net.add_layer(src, name="in") + net.add_layer(tgt, name="out") + rule = RULE_CLASSES[rule_name] + conn = cls( + src, tgt, w=w.clone(), update_rule=rule, nu=(0.5, 0.5), wmin=0.0, wmax=1.0 + ) + net.add_connection(conn, source="in", target="out") + net.run(inputs=inputs, time=STEPS) + learned.append(conn.w.to_dense() if conn.w.is_sparse else conn.w) + assert not torch.equal(learned[0], w0) + assert learned[0].min() >= 0.0 and learned[0].max() <= 1.0 + assert torch.equal(learned[0], learned[1]) diff --git a/test/network/test_connections.py b/test/network/test_connections.py index d308bc60..b5a5249d 100644 --- a/test/network/test_connections.py +++ b/test/network/test_connections.py @@ -1,5 +1,6 @@ import math +import pytest import torch import bindsnet.learning.MCC_learning as mcc @@ -14,7 +15,7 @@ WeightDependentPostPre, ) from bindsnet.network import Network -from bindsnet.network.nodes import Input, LIFNodes, SRM0Nodes +from bindsnet.network.nodes import CSRMNodes, Input, LIFNodes, SRM0Nodes from bindsnet.network.topology import * @@ -25,6 +26,67 @@ class TestConnection: device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + @staticmethod + def _make_csrm_network(window, bias=None): + network = Network(learning=False) + source = Input(n=2) + target = CSRMNodes( + n=2, + res_window_size=window, + ref_window_size=2, + rest=0.0, + thresh=100.0, + ) + network.add_layer(source, "X") + network.add_layer(target, "Y") + connection = Connection( + source, target, w=torch.tensor([[1.0, 0.5], [0.25, 1.0]]), b=bias + ) + network.add_connection(connection, "X", "Y") + return network, target, connection + + @pytest.mark.parametrize("window", [3, 5]) + @pytest.mark.parametrize("bias", [None, torch.tensor([0.2, 0.1])]) + def test_csrm_reset_discards_previous_trial(self, window, bias): + reused, target, connection = self._make_csrm_network(window, bias) + fresh, reference, _ = self._make_csrm_network(window, bias) + # A short first trial leaves both occupied and empty slots in the window. + reused.run({"X": torch.ones(2, 2)}, time=2) + weights = connection.w + before = weights.clone() + reused.reset_state_variables() + # No input spikes in the next independent trial: old spikes must not drive it. + for _ in range(window): + reused.run({"X": torch.zeros(1, 2)}, time=1) + fresh.run({"X": torch.zeros(1, 2)}, time=1) + torch.testing.assert_close(target.v, reference.v) + torch.testing.assert_close(target.s, reference.s) + assert connection.w is weights + torch.testing.assert_close(connection.w, before) + + def test_csrm_window_survives_run_boundary(self): + split, target, _ = self._make_csrm_network(3) + continuous, reference, _ = self._make_csrm_network(3) + spikes = torch.tensor([[1.0, 0.0], [0.0, 1.0], [0.0, 0.0], [0.0, 0.0]]) + continuous.run({"X": spikes}, time=4) + split.run({"X": spikes[:2]}, time=2) + split.run({"X": spikes[2:]}, time=2) + assert target.v.abs().sum() > 0 + torch.testing.assert_close(target.v, reference.v) + torch.testing.assert_close(target.s, reference.s) + + @pytest.mark.parametrize("previous_batch,new_batch", [(1, 2), (2, 1)]) + def test_csrm_window_resizes_with_batch(self, previous_batch, new_batch): + reused, target, _ = self._make_csrm_network(3) + fresh, reference, _ = self._make_csrm_network(3) + reused.run({"X": torch.ones(2, previous_batch, 2)}, time=2) + # Network.run resizes neuron state when a new input batch size arrives. + for _ in range(3): + reused.run({"X": torch.zeros(1, new_batch, 2)}, time=1) + fresh.run({"X": torch.zeros(1, new_batch, 2)}, time=1) + torch.testing.assert_close(target.v, reference.v) + torch.testing.assert_close(target.s, reference.s) + def test_transfer(self): if not torch.cuda.is_available(): return @@ -805,3 +867,70 @@ def test_hebbian_predictable(self): args = [[[1, 28, 28], [1, 26, 26], (1, 26), 3, 1]] for conn_type, arg in zip(conn_types, args): tester.check_weights(conn_type, decay=1, update_rule=NoOp, *arg) + + +def test_multicompartment_into_csrm_matches_connection(): + """With static weights a MulticompartmentConnection (window of outputs) drives + CSRMNodes exactly like a Connection (window of input spikes), at batch 2. + + Caught: MulticompartmentConnection.compute_window on master (no s_w, then + self.w), which raised AttributeError. + """ + from bindsnet.network.monitors import Monitor + from bindsnet.network.nodes import CSRMNodes, Input + from bindsnet.network.topology import Connection, MulticompartmentConnection + from bindsnet.network.topology_features import Weight + + w = torch.rand(6, 4, generator=torch.Generator().manual_seed(0)) * 30 + x = torch.rand(40, 2, 6, generator=torch.Generator().manual_seed(1)) < 0.4 + recorded = [] + for mcc in (False, True): + net = Network(dt=1.0, batch_size=2) + net.add_layer(Input(n=6), "in") + net.add_layer(CSRMNodes(n=4), "out") + if mcc: + pipeline = [Weight("w", w.clone())] + conn = MulticompartmentConnection( + net.layers["in"], net.layers["out"], device="cpu", pipeline=pipeline + ) + else: + conn = Connection(net.layers["in"], net.layers["out"], w=w.clone()) + net.add_connection(conn, "in", "out") + net.add_monitor(Monitor(net.layers["out"], ["s", "v"], time=40), "m") + net.run(inputs={"in": x}, time=40) + recorded.append([net.monitors["m"].get(v) for v in ("s", "v")]) + assert recorded[0][0].sum() > 0 + assert torch.equal(recorded[0][0], recorded[1][0]) + assert torch.equal(recorded[0][1], recorded[1][1]) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") +def test_windows_and_maxpool_state_stay_on_gpu(): + """The CSRM input window and MaxPool firing rates are created on the spikes' + device, also after a reset. + + Caught: torch.zeros without device= in Connection.compute_window and in + MaxPool2dConnection.reset_state_variables (device mismatch on CUDA). + """ + from bindsnet.network.nodes import CSRMNodes, Input + from bindsnet.network.topology import Connection, MaxPool2dConnection + + net = Network(dt=1.0) + net.add_layer(Input(n=6), "in") + net.add_layer(CSRMNodes(n=4), "out") + net.add_connection(Connection(net.layers["in"], net.layers["out"]), "in", "out") + net.add_layer(Input(shape=(1, 4, 4)), "img") + net.add_layer(Input(shape=(1, 2, 2)), "pool") + pool = MaxPool2dConnection( + net.layers["img"], net.layers["pool"], kernel_size=2, stride=2, decay=0.5 + ) + net.add_connection(pool, "img", "pool") + net.to("cuda") + inputs = { + "in": (torch.rand(5, 1, 6) < 0.5).cuda(), + "img": (torch.rand(5, 1, 1, 4, 4) < 0.5).cuda(), + } + for _ in range(2): + net.run(inputs=inputs, time=5) + net.reset_state_variables() + assert pool.firing_rates.is_cuda diff --git a/test/network/test_mcc_state.py b/test/network/test_mcc_state.py new file mode 100644 index 00000000..210c8d87 --- /dev/null +++ b/test/network/test_mcc_state.py @@ -0,0 +1,135 @@ +"""MulticompartmentConnection pipeline bookkeeping: saving and loading feature state, +sub-features, and pipeline edits.""" + +import pytest +import torch + +from bindsnet.learning import MCC_learning +from bindsnet.network import Network +from bindsnet.network import topology_features as tf +from bindsnet.network.nodes import Input, LIFNodes +from bindsnet.network.topology import MulticompartmentConnection + + +def _net(pipeline, n_in=6, n_out=4, batch=1): + net = Network(dt=1.0, batch_size=batch) + net.add_layer(Input(n=n_in, traces=True), "in") + net.add_layer(LIFNodes(n=n_out, traces=True, thresh=-60.0), "out") + conn = MulticompartmentConnection( + net.layers["in"], net.layers["out"], device="cpu", pipeline=pipeline + ) + net.add_connection(conn, "in", "out") + return net, conn + + +def _spikes(steps, n=6, batch=1, seed=0): + g = torch.Generator().manual_seed(seed) + return torch.rand(steps, batch, n, generator=g) < 0.5 + + +def test_duplicate_feature_names_refuse_to_save(): + """Two features named "w" would share one state_dict key. + + Caught: saving without the name check (the Bias value overwrote the Weight's). + """ + _, conn = _net( + [tf.Weight("w", torch.ones(6, 4)), tf.Bias("w", torch.full((6, 4), 7.0))] + ) + with pytest.raises(ValueError, match="unique"): + conn.state_dict() + + +def test_load_rejects_wrong_shape(): + """A feature value of another shape is an error, as for a normal Parameter. + + Caught: loading with a plain copy_ (a [1, 4] row was broadcast over [6, 4]). + """ + _, conn = _net([tf.Weight("w", torch.rand(6, 4))]) + state = conn.state_dict() + state["feature.w"] = torch.ones(1, 4) + with pytest.raises(RuntimeError, match="size mismatch for feature.w"): + conn.load_state_dict(state) + + +def test_adaptation_feature_state_round_trip(): + """The adaptation features' mask, spike buffer and counter are saved and loaded, + so a reloaded copy continues exactly. + + Caught: saving only ``value`` (the copy's mask and counter restarted). + """ + + def build(): + torch.manual_seed(0) + ann = [torch.rand(4, 4) - 0.5, torch.rand(1, 4) - 0.5] + adapt = tf.AdaptationBaseSynapsHistory( + "a", value=torch.rand(6, 4) - 0.5, ann_values=ann + ) + return _net([tf.Weight("w", torch.rand(6, 4) * 4, range=[0, 10]), adapt]) + + net, _ = build() + x = _spikes(80) + net.run(inputs={"in": x[:40]}, time=40) + copy, _ = build() + copy.load_state_dict(net.state_dict()) + net.run(inputs={"in": x[40:]}, time=40) + copy.run(inputs={"in": x[40:]}, time=40) + a, b = net.connections[("in", "out")], copy.connections[("in", "out")] + assert torch.equal(a.feature_index["a"].mask, b.feature_index["a"].mask) + assert torch.equal(a.feature_index["a"].value, b.feature_index["a"].value) + assert torch.equal(net.layers["out"].v, copy.layers["out"].v) + + +def test_updating_parent_learns_once_per_step(): + """With an Updating sub-feature in the pipeline, its parent learns when that + entry runs, not a second time in the connection's update. + + Caught: the connection's update also updating the driven parent (the weight + change doubled). + """ + x = _spikes(20) + + def change(with_updating): + torch.manual_seed(0) + w = tf.Weight( + "w", + torch.rand(6, 4) * 4, + range=[0, 10], + nu=(1e-2, 1e-2), + learning_rule=MCC_learning.PostPre, + ) + pipeline = [w, tf.Updating("u", parent_feature=w)] if with_updating else [w] + net, conn = _net(pipeline) + w0 = w.value.clone() + net.run(inputs={"in": x}, time=20) + return (w.value - w0).sum() + + once, via_updating = change(False), change(True) + assert once != 0 + # Updating runs during compute, so the schedule differs slightly; the change + # must be that of one update per step, not two. + assert (via_updating - once).abs() < (via_updating - 2 * once).abs() + + +def test_sub_feature_parent_must_be_in_pipeline(): + """A sub-feature runs a method of its parent, which must be primed in the same + pipeline; otherwise the constructor says so. + + Caught: building it (Network.run later raised an unclear TypeError). + """ + w = tf.Weight( + "w", torch.rand(6, 4), nu=(1e-2, 1e-2), learning_rule=MCC_learning.PostPre + ) + with pytest.raises(ValueError, match="must be in the same pipeline"): + _net([tf.Weight("v", torch.rand(6, 4)), tf.Updating("u", parent_feature=w)]) + + +def test_insert_pipeline_inserts_at_index(): + """insert_pipeline(feature, index) puts the feature at ``index``. + + Caught: list.insert called with its arguments swapped (TypeError). + """ + w = tf.Weight("w", torch.rand(6, 4)) + _, conn = _net([w]) + bias = tf.Bias("b", torch.rand(6, 4)) + conn.insert_pipeline(bias, 0) + assert conn.pipeline[0] is bias and conn.feature_index["b"] is bias diff --git a/test/network/test_meanfield_normalization.py b/test/network/test_meanfield_normalization.py new file mode 100644 index 00000000..d4754fe7 --- /dev/null +++ b/test/network/test_meanfield_normalization.py @@ -0,0 +1,112 @@ +import pytest +import torch + +from bindsnet.network import Network +from bindsnet.network.nodes import Input, LIFNodes +from bindsnet.network.topology import MeanFieldConnection + + +@pytest.mark.parametrize("weight_shape", [(), (4,), (2, 2)]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.float64]) +@pytest.mark.parametrize("norm", [None, 0.0, 2.0]) +def test_meanfield_normalization_preserves_parameter(weight_shape, dtype, norm): + target_shape = weight_shape or (3,) + weights = ( + torch.arange(1, 5, dtype=dtype).reshape(weight_shape) + if weight_shape + else torch.tensor(0.25, dtype=dtype) + ) + connection = MeanFieldConnection( + Input(n=2), + LIFNodes(shape=target_shape), + w=weights.clone(), + w_dtype=dtype, + norm=norm, + ) + parameter = connection.w + state_keys = set(connection.state_dict()) + expected = weights.clone() + if norm is not None: + expected = ( + weights * (norm / 10) + if weight_shape + else torch.tensor(norm / 3, dtype=dtype) + ) + + connection.normalize() + assert connection.w is parameter + assert isinstance(connection.w, torch.nn.Parameter) + assert connection.w.shape == weights.shape + assert set(connection.state_dict()) == state_keys + torch.testing.assert_close(connection.w, expected) + spikes = torch.tensor([[0.0, 1.0], [1.0, 1.0]]) + # Each sample's own mean (0.5 and 1.0); samples do not mix. + means = torch.tensor([0.5, 1.0], dtype=dtype).view(-1, *[1] * len(target_shape)) + torch.testing.assert_close(connection.compute(spikes).to(dtype), means * expected) + connection.normalize() + torch.testing.assert_close(connection.w, expected) + + +@pytest.mark.parametrize("norm", [0.0, 2.0]) +def test_network_run_normalizes_meanfield_connection(norm): + network = Network(dt=1.0) + source, target = Input(n=2), LIFNodes(n=3) + network.add_layer(source, name="source") + network.add_layer(target, name="target") + connection = MeanFieldConnection(source, target, w=torch.tensor(0.25), norm=norm) + network.add_connection(connection, source="source", target="target") + for _ in range(2): + network.run(inputs={"source": torch.ones(2, 1, 2)}, time=2) + torch.testing.assert_close(connection.w, torch.tensor(norm / target.n)) + assert torch.isfinite(target.v).all() + + +@pytest.mark.parametrize( + "weights,expected", + [ + ([-1.0, -2.0], [-2 / 3, -4 / 3]), + ([1.0, -1.0], [1.0, -1.0]), + ([1.0, -0.999999], [2 / 1.999999, -1.999998 / 1.999999]), + ], +) +def test_meanfield_normalization_preserves_signed_weights(weights, expected): + connection = MeanFieldConnection( + Input(n=2), LIFNodes(n=2), w=torch.tensor(weights), norm=2.0 + ) + for _ in range(2): + connection.normalize() + assert torch.isfinite(connection.w).all() + torch.testing.assert_close(connection.w, torch.tensor(expected)) + + +@pytest.mark.parametrize("value", [-0.25, 0.25]) +@pytest.mark.parametrize("target_shape", [(3,), (2, 2)]) +def test_meanfield_shared_and_per_target_weights_agree(value, target_shape): + source, target = Input(n=2), LIFNodes(shape=target_shape) + shared = MeanFieldConnection(source, target, w=torch.tensor(value), norm=2.0) + per_target = MeanFieldConnection( + source, target, w=torch.full(target_shape, value), norm=2.0 + ) + spikes = torch.tensor([[0.0, 1.0], [1.0, 1.0]]) + for _ in range(2): + shared.normalize() + per_target.normalize() + expected = torch.full(target_shape, (2.0 if value > 0 else -2.0) / target.n) + torch.testing.assert_close(shared.w.expand(target_shape), expected) + torch.testing.assert_close(per_target.w, expected) + torch.testing.assert_close( + shared.compute(spikes).expand(2, *target_shape), per_target.compute(spikes) + ) + + +def test_meanfield_weight_decay_is_applied(): + """weight_decay reaches AbstractConnection as weight_decay (it was passed by + position into the reduction slot and ignored). + + Caught: passing weight_decay by position again. + """ + connection = MeanFieldConnection( + Input(n=2), LIFNodes(n=3), w=torch.tensor(1.0), weight_decay=0.1 + ) + assert connection.reduction is None + assert connection.weight_decay == 0.1 diff --git a/test/network/test_perf_equivalence.py b/test/network/test_perf_equivalence.py index 342d93b6..9a6f2899 100644 --- a/test/network/test_perf_equivalence.py +++ b/test/network/test_perf_equivalence.py @@ -34,6 +34,7 @@ Hebbian, MCC_learning, PostPre, + Rmax, WeightDependentPostPre, ) from bindsnet.learning.learning import ( @@ -701,3 +702,178 @@ def test_local_connection_matches_reference(self): ref *= connection.norm / ref.sum(0).view(1, -1) connection.normalize() assert torch.equal(connection.w, ref) + + +class TestMccMstdpetBatched: + """MCC_learning.MSTDPET keeps per-sample state ([batch, ...]) since 0.3.5.""" + + @staticmethod + def _rule(batch_size, reduction=None, n_in=12, n_out=7, dt=1.0): + net = Network(dt=dt, batch_size=batch_size) + net.add_layer(Input(n=n_in), "in") + net.add_layer(LIFNodes(n=n_out), "out") + conn = MulticompartmentConnection( + net.layers["in"], + net.layers["out"], + device="cpu", + pipeline=[ + Weight( + "w", + torch.zeros(n_in, n_out), + range=[-100.0, 100.0], + nu=(1e-2, 1e-2), + reduction=reduction, + learning_rule=MCC_learning.MSTDPET, + ) + ], + ) + net.add_connection(conn, "in", "out") + return conn + + @staticmethod + def _spikes(steps, batch_size, n, seed): + g = torch.Generator().manual_seed(seed) + return torch.rand(steps, batch_size, n, generator=g) < 0.3 + + def _run(self, conn, pre, post, rewards): + for t in range(pre.size(0)): + conn.source.s = pre[t] + conn.target.s = post[t] + conn.update(learning=True, reward=rewards[t]) + return conn.pipeline[0].value.detach().clone() + + @pytest.mark.parametrize("dt", [1.0, 0.5]) + def test_batch1_matches_unbatched_formula(self, dt): + """Batch 1 is bit-identical to the former one-dimensional code + (Florian 2007 eqs. 2.7-2.8 with ``torch.outer``). + + Caught: dropping ``self.connection.dt`` from the weight update. + """ + steps, n_in, n_out = 30, 12, 7 + conn = self._rule(1, dt=dt) + rule = conn.pipeline[0].learning_rule + pre = self._spikes(steps, 1, n_in, 1) + post = self._spikes(steps, 1, n_out, 2) + rewards = torch.linspace(-1.0, 1.0, steps).tolist() + w = self._run(conn, pre, post, rewards) + + decay_plus = torch.exp(-dt / torch.tensor(20.0)) + decay_minus = torch.exp(-dt / torch.tensor(20.0)) + decay_e = torch.exp(-dt / torch.tensor(25.0)) + p_plus, p_minus = torch.zeros(n_in), torch.zeros(n_out) + elig, trace = torch.zeros(n_in, n_out), torch.zeros(n_in, n_out) + ref = torch.zeros(n_in, n_out) + for t in range(steps): + s_pre, s_post = pre[t].view(-1).float(), post[t].view(-1).float() + trace *= decay_e + trace += elig / torch.tensor(25.0) + ref += rule.nu[0] * dt * rewards[t] * trace + ref.clamp_(-100.0, 100.0) + p_plus *= decay_plus + p_plus += torch.tensor(1.0) * s_pre + p_minus *= decay_minus + p_minus += torch.tensor(-1.0) * s_post + elig = torch.outer(p_plus, s_post) + torch.outer(s_pre, p_minus) + assert torch.equal(w, ref) + + def test_batch_sum_equals_sum_of_single_samples(self): + """Traces depend only on spikes, so with reduction=sum the batch-2 weight + change is the sum of each sample's change alone. Floats agree to batch + summation order (rtol 1e-6, atol 1e-7; CLAUDE.md performance rule). + + Caught: using only the first sample's update instead of the reduction. + """ + steps, n_in, n_out = 30, 12, 7 + pre = self._spikes(steps, 2, n_in, 3) + post = self._spikes(steps, 2, n_out, 4) + rewards = [0.5] * steps + both = self._run(self._rule(2, torch.sum), pre, post, rewards) + alone = sum( + self._run( + self._rule(1, torch.sum), pre[:, i : i + 1], post[:, i : i + 1], rewards + ) + for i in range(2) + ) + assert both.abs().max() > 0 + assert torch.allclose(both, alone, rtol=1e-6, atol=1e-7) + + +def test_rmax_batch_sum_equals_sum_of_single_samples(): + """Rmax traces depend only on spikes, spike probabilities and input traces, + so with reduction=sum the batch-2 weight change is the sum of each sample's + change alone (Vasilaki et al. 2009 eq. 13 per sample). Floats agree to batch + summation order (rtol 1e-6, atol 1e-7; CLAUDE.md performance rule). + + Caught: using only the first sample's update instead of the reduction. + """ + steps, n_in, n_out = 30, 12, 7 + g = torch.Generator().manual_seed(5) + x = torch.rand(steps, 2, n_in, generator=g) + prob = torch.rand(steps, 2, n_out, generator=g) * 0.5 + spikes = torch.rand(steps, 2, n_out, generator=g) < prob + + def run(batch): + net = Network(dt=1.0, batch_size=len(batch)) + net.add_layer(Input(n=n_in, traces=True, traces_additive=True), "in") + net.add_layer(SRM0Nodes(n=n_out), "out") + conn = Connection( + net.layers["in"], + net.layers["out"], + w=torch.zeros(n_in, n_out), + update_rule=Rmax, + nu=1e-2, + reduction=torch.sum, + ) + net.add_connection(conn, "in", "out") + for t in range(steps): + conn.source.x = x[t, batch] + conn.target.s_prob = prob[t, batch] + conn.target.s = spikes[t, batch] + conn.update(learning=True, reward=0.5) + return conn.w.detach().clone() + + both = run([0, 1]) + alone = run([0]) + run([1]) + assert both.abs().max() > 0 + assert torch.allclose(both, alone, rtol=1e-6, atol=1e-7) + + +def test_mcc_mstdpet_sparse_copies_learn_like_dense(): + """A sparse MCC weight keeps one copy per sample; MSTDPET gives every copy the + batch's shared update, equal to the dense weights (to summation order). + + Caught: per-sample sparse updates (each copy learned only from its sample). + """ + T = TestMccMstdpetBatched + steps, batch = 30, 3 + pre, post = T._spikes(steps, batch, 12, 7), T._spikes(steps, batch, 7, 8) + results = [] + for sparse in (False, True): + net = Network(dt=1.0, batch_size=batch) + net.add_layer(Input(n=12), "in") + net.add_layer(LIFNodes(n=7), "out") + w = torch.zeros(12, 7) + 0.5 + conn = MulticompartmentConnection( + net.layers["in"], + net.layers["out"], + device="cpu", + pipeline=[ + Weight( + "w", + w.unsqueeze(0).repeat(batch, 1, 1) if sparse else w, + range=[-100.0, 100.0], + nu=(1e-2, 1e-2), + reduction=torch.sum, + learning_rule=MCC_learning.MSTDPET, + sparse=sparse, + batch_size=batch, + ) + ], + ) + net.add_connection(conn, "in", "out") + value = T()._run(conn, pre, post, [0.5] * steps) + results.append(value.to_dense() if value.is_sparse else value) + dense, copies = results + assert not torch.equal(dense, torch.full_like(dense, 0.5)) + for copy in copies: + assert torch.allclose(copy, dense, rtol=1e-6, atol=1e-7) diff --git a/test/regression/golden/csrm_window.pt.xz b/test/regression/golden/csrm_window.pt.xz index d29af1cc..9a8e006b 100644 Binary files a/test/regression/golden/csrm_window.pt.xz and b/test/regression/golden/csrm_window.pt.xz differ diff --git a/test/regression/golden/dc2015_b1.pt.xz b/test/regression/golden/dc2015_b1.pt.xz index 9aa6a565..09f98c3b 100644 Binary files a/test/regression/golden/dc2015_b1.pt.xz and b/test/regression/golden/dc2015_b1.pt.xz differ diff --git a/test/regression/golden/dc2015_b16.pt.xz b/test/regression/golden/dc2015_b16.pt.xz index ca2a41a7..6cad0cca 100644 Binary files a/test/regression/golden/dc2015_b16.pt.xz and b/test/regression/golden/dc2015_b16.pt.xz differ diff --git a/test/regression/golden/meanfield_decay.pt.xz b/test/regression/golden/meanfield_decay.pt.xz index 7e4d7c45..b07eb0b1 100644 Binary files a/test/regression/golden/meanfield_decay.pt.xz and b/test/regression/golden/meanfield_decay.pt.xz differ diff --git a/test/regression/golden/meanfield_norm.pt.xz b/test/regression/golden/meanfield_norm.pt.xz index c2a22b4a..21dd9f19 100644 Binary files a/test/regression/golden/meanfield_norm.pt.xz and b/test/regression/golden/meanfield_norm.pt.xz differ diff --git a/test/regression/golden/mstdp_mstdpet.pt.xz b/test/regression/golden/mstdp_mstdpet.pt.xz index 710b8b33..1bcc07e9 100644 Binary files a/test/regression/golden/mstdp_mstdpet.pt.xz and b/test/regression/golden/mstdp_mstdpet.pt.xz differ diff --git a/test/regression/golden/mstdp_mstdpet_dt05.pt.xz b/test/regression/golden/mstdp_mstdpet_dt05.pt.xz index f4be026b..9dfb03b8 100644 Binary files a/test/regression/golden/mstdp_mstdpet_dt05.pt.xz and b/test/regression/golden/mstdp_mstdpet_dt05.pt.xz differ diff --git a/test/regression/golden/rules_mix.pt.xz b/test/regression/golden/rules_mix.pt.xz index 55f986ff..5e37b705 100644 Binary files a/test/regression/golden/rules_mix.pt.xz and b/test/regression/golden/rules_mix.pt.xz differ diff --git a/test/regression/golden/rules_mix_dt05.pt.xz b/test/regression/golden/rules_mix_dt05.pt.xz index 065d47f6..d233a7a7 100644 Binary files a/test/regression/golden/rules_mix_dt05.pt.xz and b/test/regression/golden/rules_mix_dt05.pt.xz differ diff --git a/test/regression/op_counts.json b/test/regression/op_counts.json index 3a17e277..395fc6ff 100644 --- a/test/regression/op_counts.json +++ b/test/regression/op_counts.json @@ -2,14 +2,11 @@ "meta": { "torch": "2.14.1+cu130", "python": "3.11.9", - "date": "2026-10-24", - "commit": "db7d19303af15e38dc5598cd728065930bee3eef", - "reason": "initial operation counts (Phase 5 of the test hardening plan)" - }, - "excluded": { - "rule.Rmax.b4": "Rmax crashes at batch > 1 on master (view(-1) over the batch, learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS", - "mcc.MSTDPET.b4": "MCC_learning.MSTDPET crashes at batch > 1 on master (MCC_learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS" + "date": "2026-10-28", + "commit": "926e60dcbe593715424780b403878a9d45d18729+uncommitted bindsnet code changes", + "reason": "MCC_learning.MSTDPET and Rmax keep per-sample state ([batch, ...]) so batch > 1 works: bmm instead of outer and a reduction over the batch; at batch 1 mcc.MSTDPET 24 -> 27 and rule.Rmax 24 -> 26 operations; mcc.MSTDPET.b4 and rule.Rmax.b4 counted again" }, + "excluded": {}, "totals": { "mcc.DiehlAndCook.b1": 14, "mcc.DiehlAndCook.b4": 14, @@ -17,7 +14,8 @@ "mcc.Hebbian.b4": 15, "mcc.MSTDP.b1": 19, "mcc.MSTDP.b4": 19, - "mcc.MSTDPET.b1": 24, + "mcc.MSTDPET.b1": 27, + "mcc.MSTDPET.b4": 27, "mcc.NoOp.b1": 0, "mcc.NoOp.b4": 0, "mcc.PostPre.b1": 19, @@ -36,7 +34,8 @@ "rule.NoOp.b4": 3, "rule.PostPre.b1": 24, "rule.PostPre.b4": 24, - "rule.Rmax.b1": 24, + "rule.Rmax.b1": 26, + "rule.Rmax.b4": 26, "rule.WeightDependentPostPre.b1": 35, "rule.WeightDependentPostPre.b4": 35, "run.DiehlAndCook2015_784x100.b1": 75, @@ -117,12 +116,29 @@ "aten::_to_copy": 2, "aten::add.Tensor": 1, "aten::add_.Tensor": 4, + "aten::bmm": 2, "aten::clamp_": 1, "aten::div.Tensor": 1, - "aten::mul.Tensor": 7, + "aten::mul.Tensor": 5, "aten::mul_.Tensor": 3, "aten::select.int": 1, - "aten::view": 4 + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 4, + "aten::view": 2 + }, + "mcc.MSTDPET.b4": { + "aten::_to_copy": 2, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 4, + "aten::bmm": 2, + "aten::clamp_": 1, + "aten::div.Tensor": 1, + "aten::mul.Tensor": 5, + "aten::mul_.Tensor": 3, + "aten::select.int": 1, + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 4, + "aten::view": 2 }, "mcc.NoOp.b1": {}, "mcc.NoOp.b4": {}, @@ -331,7 +347,27 @@ "aten::rsub.Scalar": 1, "aten::select.int": 1, "aten::sub.Tensor": 1, - "aten::unsqueeze": 1, + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 2, + "aten::view": 3 + }, + "rule.Rmax.b4": { + "aten::_local_scalar_dense": 1, + "aten::_to_copy": 1, + "aten::add.Tensor": 1, + "aten::add_.Tensor": 2, + "aten::any": 1, + "aten::clamp_.Tensor": 1, + "aten::div.Tensor": 2, + "aten::mul.Tensor": 5, + "aten::mul_.Tensor": 1, + "aten::ne.Scalar": 1, + "aten::reciprocal": 1, + "aten::rsub.Scalar": 1, + "aten::select.int": 1, + "aten::sub.Tensor": 1, + "aten::sum.dim_IntList": 1, + "aten::unsqueeze": 2, "aten::view": 3 }, "rule.WeightDependentPostPre.b1": { @@ -430,6 +466,13 @@ "date": "2026-10-24", "commit": "db7d19303af15e38dc5598cd728065930bee3eef", "reason": "initial operation counts (Phase 5 of the test hardening plan)" + }, + { + "torch": "2.14.1+cu130", + "python": "3.11.9", + "date": "2026-10-28", + "commit": "926e60dcbe593715424780b403878a9d45d18729+uncommitted bindsnet code changes", + "reason": "MCC_learning.MSTDPET and Rmax keep per-sample state ([batch, ...]) so batch > 1 works: bmm instead of outer and a reduction over the batch; at batch 1 mcc.MSTDPET 24 -> 27 and rule.Rmax 24 -> 26 operations; mcc.MSTDPET.b4 and rule.Rmax.b4 counted again" } ] } diff --git a/test/regression/test_invariants.py b/test/regression/test_invariants.py index 0fa17ccf..521e54a6 100644 --- a/test/regression/test_invariants.py +++ b/test/regression/test_invariants.py @@ -19,57 +19,6 @@ SEED_A, SEED_B = 11, 12 # input seeds of the two trials used below -def _params(known): - """``NAMES`` as pytest params; names in ``known`` get an xfail mark. - - ``known[name]`` is ``(reason, exception type or None, strict)``. - """ - out = [] - for n in NAMES: - if n in known: - reason, raises, strict = known[n] - mark = pytest.mark.xfail(strict=strict, reason=reason, raises=raises) - out.append(pytest.param(n, marks=mark)) - else: - out.append(n) - return out - - -# Known failures on master db7d193 (2026-10-24); see the decision log in -# logs/test_hardening_2026-10/PLAN.md. A fix makes the strict xfail fail, which -# is the signal to remove the entry. -MEANFIELD_NORM = ( - "MeanFieldConnection.normalize assigns a plain tensor to the parameter w " - "(TypeError; open PR #813)", - TypeError, - True, -) -CSRM_WINDOW = ( - "Connection.s_w (CSRM spike window) is neither reset by " - "reset_state_variables nor kept in the state_dict (open PR #815)", - None, - True, -) -MCC_WEIGHTS = ( - "MulticompartmentConnection keeps its Weight feature outside the state_dict, " - "so DiehlAndCook2015's weights are not saved or loaded by state_dict", - None, - True, -) -MSTDP_STATE = ( - "MSTDP and MSTDPET keep p_plus, p_minus, eligibility (and eligibility_trace) " - "outside the state_dict and reset_state_variables does not clear them", - None, - True, -) -RMAX_STATE = ( - "Rmax keeps eligibility_trace outside the state_dict and " - "reset_state_variables does not clear it", - None, - True, -) - - def _first_trial(name): spec = N.SPECS[name] batch, steps, _ = spec.trials[0] @@ -157,10 +106,7 @@ def _reset_path(name, learning): return N.collect(net), net -RESET_XFAIL = {"csrm_window": CSRM_WINDOW, "meanfield_norm": MEANFIELD_NORM} - - -@pytest.mark.parametrize("name", _params(RESET_XFAIL)) +@pytest.mark.parametrize("name", NAMES) def test_reset_equals_fresh_learning_off(name): """Learning off: trial, reset, trial equals a fresh network running the second trial. Caught: LIFNodes.reset_state_variables not zeroing refrac_count.""" @@ -174,19 +120,7 @@ def test_reset_equals_fresh_learning_off(name): _assert_equal(after_reset, N.collect(net), f"{name} reset vs fresh") -RESET_LEARN_XFAIL = { - "dc2015_b1": MCC_WEIGHTS, - "dc2015_b16": MCC_WEIGHTS, - "mstdp_mstdpet": MSTDP_STATE, - "mstdp_mstdpet_dt05": MSTDP_STATE, - "csrm_window": CSRM_WINDOW, - "meanfield_norm": MEANFIELD_NORM, - "rules_mix": RMAX_STATE, - "rules_mix_dt05": RMAX_STATE, -} - - -@pytest.mark.parametrize("name", _params(RESET_LEARN_XFAIL)) +@pytest.mark.parametrize("name", NAMES) def test_reset_equals_fresh_learning_on(name): """Learning on: after reset, the second trial equals a fresh network that loaded the first trial's state_dict. Caught: PostPre caching the previous step's post @@ -208,15 +142,8 @@ def test_reset_equals_fresh_learning_on(name): # 4. state_dict round trip # --------------------------------------------------------------------------- # -ROUND_TRIP_XFAIL = { - "dc2015_b1": MCC_WEIGHTS, - "dc2015_b16": MCC_WEIGHTS, - "csrm_window": CSRM_WINDOW, - "meanfield_norm": MEANFIELD_NORM, -} - -@pytest.mark.parametrize("name", _params(ROUND_TRIP_XFAIL)) +@pytest.mark.parametrize("name", NAMES) def test_state_dict_round_trip(name): """Learning off: a network rebuilt from another seed that loads a saved state_dict continues exactly like the original. Caught: LIFNodes keeping @@ -279,9 +206,6 @@ def params(): # --------------------------------------------------------------------------- # BATCH_N = 3 -BATCH_XFAIL = { - "meanfield_norm": MEANFIELD_NORM, -} # Floats are compared with a relative tolerance as well as the absolute one: # batched kernels sum in a different order, which moves a float32 value by a # couple of units in the last place (measured: CSRMNodes v off by 7.6e-6 at @@ -290,7 +214,7 @@ def params(): BATCH_RTOL = 1e-6 -@pytest.mark.parametrize("name", _params(BATCH_XFAIL)) +@pytest.mark.parametrize("name", NAMES) def test_batch_of_copies_equals_batch_one(name): """Learning off: every sample of a batch of N copies equals the batch-1 run; spikes exact, floats within atol 1e-6 plus rtol 1e-6 (summation order). one_spike is turned off and SRM0Nodes layers diff --git a/test/regression/test_op_counts.py b/test/regression/test_op_counts.py index 14e8a921..b0123b20 100644 --- a/test/regression/test_op_counts.py +++ b/test/regression/test_op_counts.py @@ -48,12 +48,7 @@ LIF_N = 100 # Workloads that are not counted, with the reason (copied into op_counts.json). -EXCLUDED = { - "rule.Rmax.b4": "Rmax crashes at batch > 1 on master (view(-1) over the batch, " - "learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS", - "mcc.MSTDPET.b4": "MCC_learning.MSTDPET crashes at batch > 1 on master " - "(MCC_learning.py); pinned in test/network/test_all_classes.py KNOWN_BUGS", -} +EXCLUDED = {} class OpCounter(TorchDispatchMode):