Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` 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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 74 additions & 41 deletions bindsnet/learning/MCC_learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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).
"""
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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
21 changes: 9 additions & 12 deletions bindsnet/learning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading