From fe320fd8bc9da08ac31c33b0f50351dc6d1c4015 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Wed, 2 Sep 2026 18:30:47 -0400
Subject: [PATCH 01/14] docs: add SECURITY.md with reporting process and
incident note
---
SECURITY.md | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 SECURITY.md
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..b730ff5f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,38 @@
+# Security Policy
+
+## Reporting a vulnerability
+
+Please report security problems privately rather than in a public issue.
+
+Use GitHub's private reporting form at
+https://github.com/BindsNET/bindsnet/security/advisories/new, or email
+hananel@hazan.org.il.
+
+Please include what you found, how to reproduce it, and which version or commit
+you were on. We aim to acknowledge reports within a few days.
+
+## Supported versions
+
+Security fixes are applied to the `master` branch and to the most recent release
+on PyPI. Older releases are not patched.
+
+## Repository integrity
+
+BindsNET is a research library, and its git history is part of what users rely
+on. The following controls are in place on this repository:
+
+- Force-pushes and branch deletions are blocked on every branch.
+- `master` requires a pull request with an approving review.
+
+A supply-chain incident affecting this repository was reported and remediated in
+September 2026; see issue #781 for the full account. No PyPI release was
+affected. If you cloned this repository between 2026-08-29 and 2026-09-02 and
+opened it in Visual Studio Code, please read that issue.
+
+## What we will never do
+
+BindsNET does not contain, and will never contain, code that runs automatically
+when you open the project in an editor. There are no build hooks, no editor
+tasks that execute on folder open, and no post-install scripts. If you find
+anything of that shape in this repository, treat it as an incident and report it
+using the process above.
From 06ffc8bcbc60c34c7ef5d7ee4af591680d88f1bf Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Sun, 6 Sep 2026 11:03:48 -0400
Subject: [PATCH 02/14] perf: fuse STDP updates, drop per-step identity
matrices, cache MSTDP constants, in-place neuron state; fix network.to() on
MulticompartmentConnection
- PostPre/Hebbian (classic + MCC) apply the outer-product update with one addmm_
- LearningRule.update skips the w *= 1.0 multiply when no weight decay is set
- Local-connection rules scale rows instead of bmm with torch.eye per step
- MSTDP/MSTDPET cache exp(-dt/tc) and default a_plus/a_minus tensors
- Nodes update v/refrac_count/theta/x in place (same ops, same order)
- rank_order encoding vectorised
- Fix AbstractMulticompartmentConnection._apply(recurse) so network.to(device)
works on DiehlAndCook2015
- test/network/test_perf_equivalence.py pins each change to its reference formula
- examples/benchmark/hot_path_bench.py; CLAUDE.md + .vscode point at the bindsNET env
Co-Authored-By: Claude Fable 5.1
---
.vscode/launch.json | 1 +
CHANGELOG.md | 28 ++
CLAUDE.md | 27 ++
bindsnet/encoding/encodings.py | 9 +-
bindsnet/learning/MCC_learning.py | 66 ++-
bindsnet/learning/learning.py | 389 ++++++++---------
bindsnet/network/nodes.py | 82 ++--
bindsnet/network/topology.py | 4 +-
examples/benchmark/hot_path_bench.py | 120 ++++++
test/network/test_perf_equivalence.py | 597 ++++++++++++++++++++++++++
10 files changed, 1065 insertions(+), 258 deletions(-)
create mode 100644 CLAUDE.md
create mode 100644 examples/benchmark/hot_path_bench.py
create mode 100644 test/network/test_perf_equivalence.py
diff --git a/.vscode/launch.json b/.vscode/launch.json
index f08810a6..91a77023 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -9,6 +9,7 @@
"type": "python",
"request": "launch",
"program": "${file}",
+ "python": "/home/hananel/miniconda3/envs/bindsNET/bin/python",
"console": "integratedTerminal",
"justMyCode": false
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 056b7c4d..c09834e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,34 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
### Changed
- README Python requirement aligned to `>=3.11,<3.14`; added a reproducible-install note.
- `pyproject.toml` version bumped to 0.3.4 to match the released tag.
+- Performance pass on the per-timestep hot paths (numerics unchanged; every item
+ is pinned by `test/network/test_perf_equivalence.py`, and a seeded old-vs-new
+ comparison of 59 networks was bit-identical except three batch>1 weight
+ matrices that differ by one float32 rounding step):
+ - `PostPre` / `Hebbian` on dense `Connection` and `MulticompartmentConnection`
+ apply the STDP update with one fused `addmm_` instead of materialising the
+ `[batch, source.n, target.n]` outer product (dense 784->1000 STDP,
+ batch 16, 250 steps on CPU: 9.6 s -> 0.42 s; Diehl & Cook 784->400,
+ batch 1: 1.6 s -> 0.26 s).
+ - `LearningRule.update` no longer multiplies the whole weight matrix by `1.0`
+ every step when no weight decay is configured.
+ - `LocalConnection1D/2D/3D` learning rules scale rows directly instead of
+ building an `[n, n]` identity matrix per step (64-filter local connection on
+ GPU: 61 MiB -> 2.8 MiB of per-step temporaries). `MSTDP`/`MSTDPET` keep
+ their post-synaptic trace as a `[batch, n, 1]` vector instead of a diagonal
+ matrix.
+ - `MSTDP` / `MSTDPET` cache `exp(-dt / tc)` and the default learning-rate
+ tensors instead of recomputing / re-copying them to the device each step.
+ - Neuron models update `v`, `refrac_count`, `theta`, `x`, ... in place with
+ the same operations in the same order, avoiding a `Module.__setattr__`
+ round-trip per assignment per step.
+ - `rank_order` encoding is vectorised.
+- Benchmark script for the above: `examples/benchmark/hot_path_bench.py`.
+
+### Fixed
+- `network.to(device)` crashed on any `MulticompartmentConnection` (used by
+ `DiehlAndCook2015`) with `_apply() takes 2 positional arguments but 3 were
+ given`; `AbstractMulticompartmentConnection._apply` now accepts `recurse`.
## [0.3.4] - 2026-06-15
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..b529463b
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,27 @@
+# BindsNET project notes
+
+## Python environment
+
+This project runs in the conda environment named `bindsNET`.
+
+- Interpreter: `/home/hananel/miniconda3/envs/bindsNET/bin/python`
+- Activate: `conda activate bindsNET`
+- The package is installed in editable mode from this directory, so edits to
+ `bindsnet/` are picked up without reinstalling.
+- Torch 2.14 with CUDA 13.0 is installed there. Do not use the `base` env.
+
+Run tests with:
+
+```shell
+conda run -n bindsNET python -m pytest -q
+```
+
+Run a single file with:
+
+```shell
+conda run -n bindsNET python -m pytest -q test/network/test_learning.py
+```
+
+## Committing
+
+Use `sc "message"` instead of `git commit` (see the user's global instructions).
diff --git a/bindsnet/encoding/encodings.py b/bindsnet/encoding/encodings.py
index d17f5dc3..7e299eed 100644
--- a/bindsnet/encoding/encodings.py
+++ b/bindsnet/encoding/encodings.py
@@ -182,10 +182,11 @@ def rank_order(
times *= time / times.max() # Extended through simulation time.
times = torch.ceil(times).long()
- # Create spike times tensor.
+ # Create spike times tensor (one spike per neuron whose time lies in
+ # ``(0, time)``; vectorised form of the per-neuron loop).
spikes = torch.zeros(time, size, device=device).byte()
- for i in range(size):
- if 0 < times[i] < time:
- spikes[times[i] - 1, i] = 1
+ fire = (times > 0) & (times < time)
+ idx = fire.nonzero(as_tuple=False).squeeze(1)
+ spikes[(times[fire] - 1).to(device), idx.to(device)] = 1
return spikes.reshape(time, *shape)
diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py
index be067e31..9418c02a 100644
--- a/bindsnet/learning/MCC_learning.py
+++ b/bindsnet/learning/MCC_learning.py
@@ -13,6 +13,23 @@
from ..utils import im2col_indices
+def _dense_outer_update_ok(rule, w: torch.Tensor) -> bool:
+ # language=rst
+ """
+ Whether a rule's ``[batch, source.n] x [batch, target.n]`` outer-product
+ update can be applied with a single fused ``w.addmm_`` call: dense float32
+ weights and one of the two default batch reductions (``torch.squeeze`` for
+ batch size 1, ``torch.sum`` otherwise), both equal to the matrix product's
+ contraction over the batch dimension.
+ """
+ return (
+ isinstance(w, torch.Tensor)
+ and not w.is_sparse
+ and w.dtype == torch.float32
+ and rule.reduction in (torch.squeeze, torch.sum)
+ )
+
+
class MCC_LearningRule(ABC):
# language=rst
"""
@@ -229,6 +246,26 @@ def _connection_update(self, **kwargs) -> None:
class.
"""
batch_size = self.source.batch_size
+ w = self.feature_value
+
+ if self.average_update == 0 and _dense_outer_update_ok(self, w):
+ # Fused path: ``w += alpha * s^T @ x`` folds the outer product, the
+ # batch reduction, the ``dt`` scaling and the in-place update into
+ # one ``addmm_`` call, so no ``[batch, source.n, target.n]``
+ # temporary is allocated. The learning rate is applied to the
+ # ``[batch, n]`` factor first (as in the un-fused formula) and spikes
+ # are exactly 0/1, so for batch size 1 the result is bit-identical.
+ dt = float(self.connection.dt)
+ if self.nu[0]:
+ source_s = self.source.s.view(batch_size, -1).float()
+ target_x = self.target.x.view(batch_size, -1) * self.nu[0]
+ w.addmm_(source_s.t(), target_x, alpha=-dt)
+ if self.nu[1]:
+ target_s = self.target.s.view(batch_size, -1).float() * self.nu[1]
+ source_x = self.source.x.view(batch_size, -1)
+ w.addmm_(source_x.t(), target_s, alpha=dt)
+ super().update()
+ return
# Pre-synaptic update.
if self.nu[0]:
@@ -370,18 +407,27 @@ def _connection_update(self, **kwargs) -> None:
batch_size = self.source.batch_size
- source_s = self.source.s.view(batch_size, -1).unsqueeze(2).float()
- source_x = self.source.x.view(batch_size, -1).unsqueeze(2)
- target_s = self.target.s.view(batch_size, -1).unsqueeze(1).float()
- target_x = self.target.x.view(batch_size, -1).unsqueeze(1)
+ if _dense_outer_update_ok(self, self.feature_value):
+ # Fused path (see ``PostPre._connection_update``).
+ source_s = self.source.s.view(batch_size, -1).float()
+ source_x = self.source.x.view(batch_size, -1)
+ target_s = self.target.s.view(batch_size, -1).float()
+ target_x = self.target.x.view(batch_size, -1)
+ self.feature_value.addmm_(source_s.t(), target_x * self.nu[0], alpha=1.0)
+ self.feature_value.addmm_(source_x.t(), target_s * self.nu[1], alpha=1.0)
+ else:
+ source_s = self.source.s.view(batch_size, -1).unsqueeze(2).float()
+ source_x = self.source.x.view(batch_size, -1).unsqueeze(2)
+ target_s = self.target.s.view(batch_size, -1).unsqueeze(1).float()
+ target_x = self.target.x.view(batch_size, -1).unsqueeze(1)
- # Pre-synaptic update.
- update = self.reduction(torch.bmm(source_s, target_x), dim=0)
- self.feature_value += self.nu[0] * update
+ # Pre-synaptic update.
+ update = self.reduction(torch.bmm(source_s, target_x), dim=0)
+ self.feature_value += self.nu[0] * update
- # Post-synaptic update.
- update = self.reduction(torch.bmm(source_x, target_s), dim=0)
- self.feature_value += self.nu[1] * update
+ # Post-synaptic update.
+ update = self.reduction(torch.bmm(source_x, target_s), dim=0)
+ self.feature_value += self.nu[1] * update
# Add polarities back to feature after updates
if self.enforce_polarity:
diff --git a/bindsnet/learning/learning.py b/bindsnet/learning/learning.py
index a0a8f027..6850b44d 100644
--- a/bindsnet/learning/learning.py
+++ b/bindsnet/learning/learning.py
@@ -51,6 +51,82 @@ def _conv_point_eligibility(connection, p_plus, p_minus, source_s, target_s, dim
)
+def _dense_outer_update_ok(rule, w: torch.Tensor) -> bool:
+ # language=rst
+ """
+ Whether a rule's ``[batch, source.n] x [batch, target.n]`` outer-product
+ update can be applied with a single fused ``w.addmm_`` call.
+
+ This requires a dense float32 weight matrix, scalar learning rates (a
+ per-synapse ``nu`` tensor cannot be folded into ``alpha``), and one of the
+ two default batch reductions (``torch.squeeze`` for batch size 1,
+ ``torch.sum`` otherwise), both of which equal the matrix product's
+ contraction over the batch dimension.
+ """
+ return (
+ not w.is_sparse
+ and w.dtype == torch.float32
+ and rule.nu.dim() == 1
+ and rule.reduction in (torch.squeeze, torch.sum)
+ )
+
+
+def _row_scale(vec: torch.Tensor, mat: torch.Tensor) -> torch.Tensor:
+ # language=rst
+ """
+ ``torch.bmm(torch.diag_embed(vec), mat)`` without building the
+ ``[batch, n, n]`` diagonal matrix: scales row ``i`` of ``mat`` by
+ ``vec[:, i]``. Bit-identical for finite values (the dropped terms are exact
+ zeros).
+
+ :param vec: Tensor of shape ``[batch, n, 1]`` or ``[batch, n]``.
+ :param mat: Tensor of shape ``[batch, n, k]``.
+ """
+ if vec.dim() == 2:
+ vec = vec.unsqueeze(2)
+ return vec * mat
+
+
+def _cached_decay(rule, name: str) -> torch.Tensor:
+ # language=rst
+ """
+ ``exp(-dt / rule.)`` computed once per ``(name, dt)`` and reused on
+ every timestep instead of re-evaluating the exponential each call.
+ """
+ dt = float(rule.connection.dt)
+ tc = getattr(rule, name)
+ cache = rule.__dict__.setdefault("_decay_cache", {})
+ # Keyed on the time constant's value too, so a later change to it is honoured.
+ key = (name, dt, float(tc))
+ if key not in cache:
+ cache[key] = torch.exp(-dt / tc)
+ return cache[key]
+
+
+def _reward_rates(rule, kwargs: dict, device: torch.device):
+ # language=rst
+ """
+ Return the ``(a_plus, a_minus)`` learning-rate tensors for the reward-modulated
+ rules. The defaults (``1.0`` / ``-1.0``) are allocated once on ``device`` and
+ reused, so a run that does not pass ``a_plus``/``a_minus`` no longer copies a
+ fresh scalar to the device on every timestep.
+ """
+ cache = rule.__dict__.setdefault("_rate_cache", {})
+ if cache.get("device") != device:
+ cache["device"] = device
+ cache["a_plus"] = torch.tensor(1.0, device=device)
+ cache["a_minus"] = torch.tensor(-1.0, device=device)
+ a_plus = kwargs.get("a_plus", None)
+ a_minus = kwargs.get("a_minus", None)
+ a_plus = (
+ cache["a_plus"] if a_plus is None else torch.as_tensor(a_plus, device=device)
+ )
+ a_minus = (
+ cache["a_minus"] if a_minus is None else torch.as_tensor(a_minus, device=device)
+ )
+ return a_plus, a_minus
+
+
class LearningRule(ABC):
# language=rst
"""
@@ -118,8 +194,9 @@ def update(self) -> None:
"""
Abstract method for a learning rule update.
"""
- # Implement weight decay.
- if self.weight_decay:
+ # Implement weight decay (1.0 is the no-decay default; skip the
+ # full-matrix multiply in that case).
+ if self.weight_decay != 1.0:
self.connection.w *= self.weight_decay
# Bound weights.
@@ -249,9 +326,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
height_out = self.connection.conv_size
target_x = self.target.x.reshape(batch_size, out_channels * height_out, 1)
- target_x = target_x * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
source_s = (
self.source.s.type(torch.float)
.unfold(-1, kernel_height, stride)
@@ -263,9 +337,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
source_x = (
self.source.x.unfold(-1, kernel_height, stride)
.reshape(batch_size, height_out, in_channels * kernel_height)
@@ -275,11 +346,11 @@ def _local_connection1d_update(self, **kwargs) -> None:
# Pre-synaptic update.
if self.nu[0].any():
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
self.connection.w -= self.nu[0] * pre.view(self.connection.w.size())
# Post-synaptic update.
if self.nu[1].any():
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
self.connection.w += self.nu[1] * post.view(self.connection.w.size())
super().update()
@@ -303,9 +374,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_x = self.target.x.reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_x = target_x * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
source_s = (
self.source.s.type(torch.float)
.unfold(-2, kernel_height, stride[0])
@@ -322,9 +390,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
source_x = (
self.source.x.unfold(-2, kernel_height, stride[0])
.unfold(-2, kernel_width, stride[1])
@@ -339,11 +404,11 @@ def _local_connection2d_update(self, **kwargs) -> None:
# Pre-synaptic update.
if self.nu[0].any():
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
self.connection.w -= self.nu[0] * pre.view(self.connection.w.size())
# Post-synaptic update.
if self.nu[1].any():
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
self.connection.w += self.nu[1] * post.view(self.connection.w.size())
super().update()
@@ -369,9 +434,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_x = self.target.x.reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_x = target_x * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
source_s = (
self.source.s.type(torch.float)
.unfold(-3, kernel_height, stride[0])
@@ -389,9 +451,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_s = target_s * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
source_x = (
self.source.x.unfold(-3, kernel_height, stride[0])
.unfold(-3, kernel_width, stride[1])
@@ -407,11 +466,11 @@ def _local_connection3d_update(self, **kwargs) -> None:
# Pre-synaptic update.
if self.nu[0].any():
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
self.connection.w -= self.nu[0] * pre.view(self.connection.w.size())
# Post-synaptic update.
if self.nu[1].any():
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
self.connection.w += self.nu[1] * post.view(self.connection.w.size())
super().update()
@@ -423,6 +482,24 @@ def _connection_update(self, **kwargs) -> None:
class.
"""
batch_size = self.source.batch_size
+ w = self.connection.w
+
+ if _dense_outer_update_ok(self, w):
+ # Fused path: ``w += alpha * s^T @ x`` folds the outer product, the
+ # batch reduction and the in-place update into one ``addmm_`` call,
+ # so no ``[batch, source.n, target.n]`` temporary is allocated.
+ # Spikes are exactly 0/1, so the products are bit-identical to the
+ # un-fused formula for batch size 1.
+ if self.nu[0].any():
+ source_s = self.source.s.view(batch_size, -1).float()
+ target_x = self.target.x.view(batch_size, -1) * self.nu[0]
+ w.addmm_(source_s.t(), target_x, alpha=-1.0)
+ if self.nu[1].any():
+ target_s = self.target.s.view(batch_size, -1).float() * self.nu[1]
+ source_x = self.source.x.view(batch_size, -1)
+ w.addmm_(source_x.t(), target_s, alpha=1.0)
+ super().update()
+ return
# Pre-synaptic update.
if self.nu[0].any():
@@ -696,9 +773,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
height_out = self.connection.conv_size
target_x = self.target.x.reshape(batch_size, out_channels * height_out, 1)
- target_x = target_x * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
source_s = (
self.source.s.type(torch.float)
.unfold(-1, kernel_height, stride)
@@ -710,9 +784,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
source_x = (
self.source.x.unfold(-1, kernel_height, stride)
.reshape(batch_size, height_out, in_channels * kernel_height)
@@ -724,7 +795,7 @@ def _local_connection1d_update(self, **kwargs) -> None:
# Pre-synaptic update.
if self.nu[0].any():
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
update -= (
self.nu[0]
* pre.view(self.connection.w.size())
@@ -732,7 +803,7 @@ def _local_connection1d_update(self, **kwargs) -> None:
)
# Post-synaptic update.
if self.nu[1].any():
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
update += (
self.nu[1]
* post.view(self.connection.w.size())
@@ -762,9 +833,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_x = self.target.x.reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_x = target_x * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
source_s = (
self.source.s.type(torch.float)
.unfold(-2, kernel_height, stride[0])
@@ -781,9 +849,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
source_x = (
self.source.x.unfold(-2, kernel_height, stride[0])
.unfold(-2, kernel_width, stride[1])
@@ -800,7 +865,7 @@ def _local_connection2d_update(self, **kwargs) -> None:
# Pre-synaptic update.
if self.nu[0].any():
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
update -= (
self.nu[0]
* pre.view(self.connection.w.size())
@@ -808,7 +873,7 @@ def _local_connection2d_update(self, **kwargs) -> None:
)
# Post-synaptic update.
if self.nu[1].any():
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
update += (
self.nu[1]
* post.view(self.connection.w.size())
@@ -840,9 +905,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_x = self.target.x.reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_x = target_x * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
source_s = (
self.source.s.type(torch.float)
.unfold(-3, kernel_height, stride[0])
@@ -860,9 +922,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_s = target_s * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
source_x = (
self.source.x.unfold(-3, kernel_height, stride[0])
.unfold(-3, kernel_width, stride[1])
@@ -880,7 +939,7 @@ def _local_connection3d_update(self, **kwargs) -> None:
# Pre-synaptic update.
if self.nu[0].any():
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
update -= (
self.nu[0]
* pre.view(self.connection.w.size())
@@ -888,7 +947,7 @@ def _local_connection3d_update(self, **kwargs) -> None:
)
# Post-synaptic update.
if self.nu[1].any():
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
update += (
self.nu[1]
* post.view(self.connection.w.size())
@@ -1143,6 +1202,20 @@ def _connection_update(self, **kwargs) -> None:
class.
"""
batch_size = self.source.batch_size
+ w = self.connection.w
+
+ if _dense_outer_update_ok(self, w):
+ # Fused path (see ``PostPre._connection_update``): the learning rate
+ # is applied to the [batch, n] factor first, so the per-synapse
+ # product matches the un-fused ``nu * (s * x)`` bit for bit.
+ source_s = self.source.s.view(batch_size, -1).float()
+ source_x = self.source.x.view(batch_size, -1)
+ target_s = self.target.s.view(batch_size, -1).float()
+ target_x = self.target.x.view(batch_size, -1)
+ w.addmm_(source_s.t(), target_x * self.nu[0], alpha=1.0)
+ w.addmm_(source_x.t(), target_s * self.nu[1], alpha=1.0)
+ super().update()
+ return
source_s = self.source.s.view(batch_size, -1).unsqueeze(2).float()
source_x = self.source.x.view(batch_size, -1).unsqueeze(2)
@@ -1178,9 +1251,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
height_out = self.connection.conv_size
target_x = self.target.x.reshape(batch_size, out_channels * height_out, 1)
- target_x = target_x * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
source_s = (
self.source.s.type(torch.float)
.unfold(-1, kernel_height, stride)
@@ -1192,9 +1262,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
source_x = (
self.source.x.unfold(-1, kernel_height, stride)
.reshape(batch_size, height_out, in_channels * kernel_height)
@@ -1203,11 +1270,11 @@ def _local_connection1d_update(self, **kwargs) -> None:
)
# Pre-synaptic update.
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
self.connection.w += self.nu[0] * pre.view(self.connection.w.size())
# Post-synaptic update.
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
self.connection.w += self.nu[1] * post.view(self.connection.w.size())
super().update()
@@ -1231,9 +1298,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_x = self.target.x.reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_x = target_x * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
source_s = (
self.source.s.type(torch.float)
.unfold(-2, kernel_height, stride[0])
@@ -1250,9 +1314,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
source_x = (
self.source.x.unfold(-2, kernel_height, stride[0])
.unfold(-2, kernel_width, stride[1])
@@ -1266,11 +1327,11 @@ def _local_connection2d_update(self, **kwargs) -> None:
)
# Pre-synaptic update.
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
self.connection.w += self.nu[0] * pre.view(self.connection.w.size())
# Post-synaptic update.
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
self.connection.w += self.nu[1] * post.view(self.connection.w.size())
super().update()
@@ -1296,9 +1357,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_x = self.target.x.reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_x = target_x * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
source_s = (
self.source.s.type(torch.float)
.unfold(-3, kernel_height, stride[0])
@@ -1316,9 +1374,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_s = target_s * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
source_x = (
self.source.x.unfold(-3, kernel_height, stride[0])
.unfold(-3, kernel_width, stride[1])
@@ -1333,11 +1388,11 @@ def _local_connection3d_update(self, **kwargs) -> None:
)
# Pre-synaptic update.
- pre = self.reduction(torch.bmm(target_x, source_s), dim=0)
+ pre = self.reduction(_row_scale(target_x, source_s), dim=0)
self.connection.w += self.nu[0] * pre.view(self.connection.w.size())
# Post-synaptic update.
- post = self.reduction(torch.bmm(target_s, source_x), dim=0)
+ post = self.reduction(_row_scale(target_s, source_x), dim=0)
self.connection.w += self.nu[1] * post.view(self.connection.w.size())
super().update()
@@ -1575,24 +1630,27 @@ def _connection_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = kwargs.get("a_plus", 1.0)
- if isinstance(a_plus, dict):
- for k, v in a_plus.items():
- a_plus[k] = torch.tensor(v, device=self.connection.w.device)
- else:
- a_plus = torch.tensor(a_plus, device=self.connection.w.device)
- a_minus = kwargs.get("a_minus", -1.0)
- if isinstance(a_minus, dict):
- for k, v in a_minus.items():
- a_minus[k] = torch.tensor(v, device=self.connection.w.device)
+ a_plus = kwargs.get("a_plus", None)
+ a_minus = kwargs.get("a_minus", None)
+ if isinstance(a_plus, dict) or isinstance(a_minus, dict):
+ if isinstance(a_plus, dict):
+ for k, v in a_plus.items():
+ a_plus[k] = torch.tensor(v, device=self.connection.w.device)
+ elif a_plus is None:
+ a_plus = 1.0
+ if isinstance(a_minus, dict):
+ for k, v in a_minus.items():
+ a_minus[k] = torch.tensor(v, device=self.connection.w.device)
+ elif a_minus is None:
+ a_minus = -1.0
else:
- a_minus = torch.tensor(a_minus, device=self.connection.w.device)
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Update P^+/P^- traces and the point eligibility for this timestep.
def _update_traces_and_eligibility():
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
self.eligibility = torch.bmm(
self.p_plus.unsqueeze(2), target_s.unsqueeze(1)
@@ -1639,12 +1697,7 @@ def _local_connection1d_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Compute weight update based on the eligibility value of the past timestep.
update = reward * self.eligibility
@@ -1669,9 +1722,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
self.p_minus = self.p_minus.reshape(
batch_size, out_channels * height_out, 1
)
- self.p_minus = self.p_minus * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
# Reshaping spike occurrences.
source_s = (
@@ -1685,18 +1735,15 @@ def _local_connection1d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
# Update P^+ and P^- values.
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
# Calculate point eligibility value.
- self.eligibility = torch.bmm(target_s, self.p_plus) + torch.bmm(
+ self.eligibility = _row_scale(target_s, self.p_plus) + _row_scale(
self.p_minus, source_s
)
@@ -1728,12 +1775,7 @@ def _local_connection2d_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Compute weight update based on the eligibility value of the past timestep.
update = reward * self.eligibility
@@ -1764,9 +1806,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
self.p_minus = self.p_minus.reshape(
batch_size, out_channels * height_out * width_out, 1
)
- self.p_minus = self.p_minus * torch.eye(
- out_channels * height_out * width_out
- ).to(self.connection.w.device)
# Reshaping spike occurrences.
source_s = (
@@ -1785,18 +1824,15 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
# Update P^+ and P^- values.
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
# Calculate point eligibility value.
- self.eligibility = torch.bmm(target_s, self.p_plus) + torch.bmm(
+ self.eligibility = _row_scale(target_s, self.p_plus) + _row_scale(
self.p_minus, source_s
)
@@ -1831,12 +1867,7 @@ def _local_connection3d_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Compute weight update based on the eligibility value of the past timestep.
update = reward * self.eligibility
@@ -1867,9 +1898,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
self.p_minus = self.p_minus.reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- self.p_minus = self.p_minus * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
# Reshaping spike occurrences.
source_s = (
@@ -1889,18 +1917,15 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_s = target_s * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
# Update P^+ and P^- values.
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
# Calculate point eligibility value.
- self.eligibility = torch.bmm(target_s, self.p_plus) + torch.bmm(
+ self.eligibility = _row_scale(target_s, self.p_plus) + _row_scale(
self.p_minus, source_s
)
@@ -1967,20 +1992,15 @@ def _conv_connection_update(self, dim: int, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
source_s = self.source.s.float()
target_s = self.target.s.float()
def _update_traces_and_eligibility():
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
self.eligibility = _conv_point_eligibility(
self.connection, self.p_plus, self.p_minus, source_s, target_s, dim
@@ -2106,18 +2126,13 @@ def _connection_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Update P^+/P^- traces and the point eligibility for this timestep.
def _update_traces_and_eligibility():
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
self.eligibility = torch.bmm(
self.p_plus.unsqueeze(2), target_s.unsqueeze(1)
@@ -2130,7 +2145,7 @@ def _update_traces_and_eligibility():
# Calculate value of eligibility trace based on the value
# of the point eligibility value of the past timestep.
- self.eligibility_trace *= torch.exp(-self.connection.dt / self.tc_e_trace)
+ self.eligibility_trace *= _cached_decay(self, "tc_e_trace")
self.eligibility_trace += self.eligibility / self.tc_e_trace
# Compute weight update, reducing over the minibatch dimension.
@@ -2177,16 +2192,11 @@ def _local_connection1d_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Integrate the eligibility trace from the point eligibility of the
# previous timestep (decay then accumulate).
- self.eligibility_trace *= torch.exp(-self.connection.dt / self.tc_e_trace)
+ self.eligibility_trace *= _cached_decay(self, "tc_e_trace")
self.eligibility_trace += self.eligibility / self.tc_e_trace
# Compute weight update, reducing over the minibatch dimension.
@@ -2212,9 +2222,6 @@ def _local_connection1d_update(self, **kwargs) -> None:
self.p_minus = self.p_minus.reshape(
batch_size, out_channels * height_out, 1
)
- self.p_minus = self.p_minus * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
# Reshaping spike occurrences.
source_s = (
@@ -2229,18 +2236,15 @@ def _local_connection1d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out).to(
- self.connection.w.device
- )
# Update P^+ and P^- values.
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
# Calculate point eligibility value.
- self.eligibility = torch.bmm(target_s, self.p_plus) + torch.bmm(
+ self.eligibility = _row_scale(target_s, self.p_plus) + _row_scale(
self.p_minus, source_s
)
self.eligibility = self.eligibility.view(batch_size, *self.connection.w.shape)
@@ -2277,16 +2281,11 @@ def _local_connection2d_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Integrate the eligibility trace from the point eligibility of the
# previous timestep (decay then accumulate).
- self.eligibility_trace *= torch.exp(-self.connection.dt / self.tc_e_trace)
+ self.eligibility_trace *= _cached_decay(self, "tc_e_trace")
self.eligibility_trace += self.eligibility / self.tc_e_trace
# Compute weight update, reducing over the minibatch dimension.
@@ -2317,9 +2316,6 @@ def _local_connection2d_update(self, **kwargs) -> None:
self.p_minus = self.p_minus.reshape(
batch_size, out_channels * height_out * width_out, 1
)
- self.p_minus = self.p_minus * torch.eye(
- out_channels * height_out * width_out
- ).to(self.connection.w.device)
# Reshaping spike occurrences.
source_s = (
@@ -2339,18 +2335,15 @@ def _local_connection2d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out, 1
)
- target_s = target_s * torch.eye(out_channels * height_out * width_out).to(
- self.connection.w.device
- )
# Update P^+ and P^- values.
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
# Calculate point eligibility value.
- self.eligibility = torch.bmm(target_s, self.p_plus) + torch.bmm(
+ self.eligibility = _row_scale(target_s, self.p_plus) + _row_scale(
self.p_minus, source_s
)
self.eligibility = self.eligibility.view(batch_size, *self.connection.w.shape)
@@ -2389,16 +2382,11 @@ def _local_connection3d_update(self, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
# Integrate the eligibility trace from the point eligibility of the
# previous timestep (decay then accumulate).
- self.eligibility_trace *= torch.exp(-self.connection.dt / self.tc_e_trace)
+ self.eligibility_trace *= _cached_decay(self, "tc_e_trace")
self.eligibility_trace += self.eligibility / self.tc_e_trace
# Compute weight update, reducing over the minibatch dimension.
@@ -2430,9 +2418,6 @@ def _local_connection3d_update(self, **kwargs) -> None:
self.p_minus = self.p_minus.reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- self.p_minus = self.p_minus * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
# Reshaping spike occurrences.
source_s = (
@@ -2453,18 +2438,15 @@ def _local_connection3d_update(self, **kwargs) -> None:
target_s = self.target.s.type(torch.float).reshape(
batch_size, out_channels * height_out * width_out * depth_out, 1
)
- target_s = target_s * torch.eye(
- out_channels * height_out * width_out * depth_out
- ).to(self.connection.w.device)
# Update P^+ and P^- values.
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
# Calculate point eligibility value.
- self.eligibility = torch.bmm(target_s, self.p_plus) + torch.bmm(
+ self.eligibility = _row_scale(target_s, self.p_plus) + _row_scale(
self.p_minus, source_s
)
self.eligibility = self.eligibility.view(batch_size, *self.connection.w.shape)
@@ -2534,20 +2516,15 @@ def _conv_connection_update(self, dim: int, **kwargs) -> None:
# Parse keyword arguments.
reward = kwargs["reward"]
- a_plus = torch.tensor(
- kwargs.get("a_plus", 1.0), device=self.connection.w.device
- )
- a_minus = torch.tensor(
- kwargs.get("a_minus", -1.0), device=self.connection.w.device
- )
+ a_plus, a_minus = _reward_rates(self, kwargs, self.connection.w.device)
source_s = self.source.s.float()
target_s = self.target.s.float()
def _update_traces_and_eligibility():
- self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus)
+ self.p_plus *= _cached_decay(self, "tc_plus")
self.p_plus += a_plus * source_s
- self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus)
+ self.p_minus *= _cached_decay(self, "tc_minus")
self.p_minus += a_minus * target_s
self.eligibility = _conv_point_eligibility(
self.connection, self.p_plus, self.p_minus, source_s, target_s, dim
@@ -2558,7 +2535,7 @@ def _update_traces_and_eligibility():
_update_traces_and_eligibility()
# Integrate the eligibility trace and apply the weight update.
- self.eligibility_trace *= torch.exp(-self.connection.dt / self.tc_e_trace)
+ self.eligibility_trace *= _cached_decay(self, "tc_e_trace")
self.eligibility_trace += self.eligibility / self.tc_e_trace
self.connection.w += (
self.nu[0] * self.connection.dt * reward * self.eligibility_trace
diff --git a/bindsnet/network/nodes.py b/bindsnet/network/nodes.py
index cf8b709c..694b1e8f 100644
--- a/bindsnet/network/nodes.py
+++ b/bindsnet/network/nodes.py
@@ -95,16 +95,16 @@ def forward(self, x: torch.Tensor) -> None:
"""
if self.traces:
# Decay and set spike traces.
- self.x *= self.trace_decay
+ self.x.mul_(self.trace_decay)
if self.traces_additive:
- self.x += self.trace_scale * self.s.float()
+ self.x.add_(self.trace_scale * self.s.float())
else:
self.x.masked_fill_(self.s.bool(), self.trace_scale)
if self.sum_input:
# Add current input to running sum.
- self.summed += x.float()
+ self.summed.add_(x.float())
def reset_state_variables(self) -> None:
# language=rst
@@ -376,10 +376,10 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Integrate input voltages.
- self.v += (self.refrac_count <= 0).float() * x
+ self.v.add_((self.refrac_count <= 0).float() * x)
# Decrement refractory counters.
- self.refrac_count -= self.dt
+ self.refrac_count.sub_(self.dt)
# Check for spiking neurons.
self.s = self.v >= self.thresh
@@ -505,15 +505,17 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Decay voltages.
- self.v = self.decay * (self.v - self.rest) + self.rest
+ # In place: same three operations in the same order as
+ # ``decay * (v - rest) + rest`` (bit-identical), without the temporaries.
+ self.v.sub_(self.rest).mul_(self.decay).add_(self.rest)
# Integrate inputs.
x.masked_fill_(self.refrac_count > 0, 0.0)
# Decrement refractory counters.
- self.refrac_count -= self.dt
+ self.refrac_count.sub_(self.dt)
- self.v += x # interlaced
+ self.v.add_(x) # interlaced
# Check for spiking neurons.
self.s = self.v >= self.thresh
@@ -626,17 +628,17 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Decay voltages.
- self.v *= self.decay
+ self.v.mul_(self.decay)
# Integrate inputs.
if x is not None:
x.masked_fill_(self.refrac_count > 0, 0.0)
# Decrement refractory counters.
- self.refrac_count -= self.dt
+ self.refrac_count.sub_(self.dt)
if x is not None:
- self.v += x
+ self.v.add_(x)
# Check for spiking neurons.
self.s = self.v >= self.thresh
@@ -767,15 +769,17 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Decay voltages and current.
- self.v = self.decay * (self.v - self.rest) + self.rest
- self.i *= self.i_decay
+ # In place: same three operations in the same order as
+ # ``decay * (v - rest) + rest`` (bit-identical), without the temporaries.
+ self.v.sub_(self.rest).mul_(self.decay).add_(self.rest)
+ self.i.mul_(self.i_decay)
# Decrement refractory counters.
- self.refrac_count -= self.dt
+ self.refrac_count.sub_(self.dt)
# Integrate inputs.
- self.i += x
- self.v += (self.refrac_count <= 0).float() * self.i
+ self.i.add_(x)
+ self.v.add_((self.refrac_count <= 0).float() * self.i)
# Check for spiking neurons.
self.s = self.v >= self.thresh
@@ -919,15 +923,17 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Decay voltages and adaptive thresholds.
- self.v = self.decay * (self.v - self.rest) + self.rest
+ # In place: same three operations in the same order as
+ # ``decay * (v - rest) + rest`` (bit-identical), without the temporaries.
+ self.v.sub_(self.rest).mul_(self.decay).add_(self.rest)
if self.learning:
- self.theta *= self.theta_decay
+ self.theta.mul_(self.theta_decay)
# Integrate inputs.
- self.v += (self.refrac_count <= 0).float() * x
+ self.v.add_((self.refrac_count <= 0).float() * x)
# Decrement refractory counters.
- self.refrac_count -= self.dt
+ self.refrac_count.sub_(self.dt)
# Check for spiking neurons.
self.s = self.v >= self.thresh + self.theta
@@ -936,7 +942,7 @@ def forward(self, x: torch.Tensor) -> None:
self.refrac_count.masked_fill_(self.s, self.refrac)
self.v.masked_fill_(self.s, self.reset)
if self.learning:
- self.theta += self.theta_plus * self.s.float().sum(0)
+ self.theta.add_(self.theta_plus * self.s.float().sum(0))
# voltage clipping to lowerbound
if self.lbound is not None:
@@ -1074,15 +1080,17 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Decay voltages and adaptive thresholds.
- self.v = self.decay * (self.v - self.rest) + self.rest
+ # In place: same three operations in the same order as
+ # ``decay * (v - rest) + rest`` (bit-identical), without the temporaries.
+ self.v.sub_(self.rest).mul_(self.decay).add_(self.rest)
if self.learning:
- self.theta *= self.theta_decay
+ self.theta.mul_(self.theta_decay)
# Integrate inputs.
- self.v += (self.refrac_count <= 0).float() * x
+ self.v.add_((self.refrac_count <= 0).float() * x)
# Decrement refractory counters.
- self.refrac_count -= self.dt
+ self.refrac_count.sub_(self.dt)
# Check for spiking neurons.
self.s = self.v >= self.thresh + self.theta
@@ -1091,7 +1099,7 @@ def forward(self, x: torch.Tensor) -> None:
self.refrac_count.masked_fill_(self.s, self.refrac)
self.v.masked_fill_(self.s, self.reset)
if self.learning:
- self.theta += self.theta_plus * self.s.float().sum(0)
+ self.theta.add_(self.theta_plus * self.s.float().sum(0))
# Choose only a single neuron to spike.
if self.one_spike:
@@ -1282,9 +1290,9 @@ def forward(self, x: torch.Tensor) -> None:
)
# Apply v and u updates.
- self.v += self.dt * 0.5 * (0.04 * self.v**2 + 5 * self.v + 140 - self.u + x)
- self.v += self.dt * 0.5 * (0.04 * self.v**2 + 5 * self.v + 140 - self.u + x)
- self.u += self.dt * self.a * (self.b * self.v - self.u)
+ self.v.add_(self.dt * 0.5 * (0.04 * self.v**2 + 5 * self.v + 140 - self.u + x))
+ self.v.add_(self.dt * 0.5 * (0.04 * self.v**2 + 5 * self.v + 140 - self.u + x))
+ self.u.add_(self.dt * self.a * (self.b * self.v - self.u))
# Voltage clipping to lower bound.
if self.lbound is not None:
@@ -1432,10 +1440,10 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Decay voltages.
- self.v *= self.decay
+ self.v.mul_(self.decay)
if self.learning:
- self.theta *= self.theta_decay
+ self.theta.mul_(self.theta_decay)
# Integrate inputs.
v = torch.einsum(
@@ -1444,13 +1452,13 @@ def forward(self, x: torch.Tensor) -> None:
v += torch.einsum(
"i,kij->kj", self.refKernel, self.last_spikes
) # Refractoriness due to previous spikes
- self.v += v.view(x.size(0), *self.shape)
+ self.v.add_(v.view(x.size(0), *self.shape))
# Check for spiking neurons.
self.s = self.v >= self.thresh + self.theta
if self.learning:
- self.theta += self.theta_plus * self.s.float().sum(0)
+ self.theta.add_(self.theta_plus * self.s.float().sum(0))
# Add the spike vector into the first in first out matrix of windowed (ref) spike trains
self.last_spikes = torch.cat(
@@ -1644,10 +1652,12 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
# Decay voltages.
- self.v = self.decay * (self.v - self.rest) + self.rest
+ # In place: same three operations in the same order as
+ # ``decay * (v - rest) + rest`` (bit-identical), without the temporaries.
+ self.v.sub_(self.rest).mul_(self.decay).add_(self.rest)
# Integrate inputs.
- self.v += (self.refrac_count <= 0).float() * self.eps_0 * x
+ self.v.add_((self.refrac_count <= 0).float() * self.eps_0 * x)
# Compute (instantaneous) probabilities of spiking, clamp between 0 and 1 using exponentials.
# Also known as 'escape noise', this simulates nearby neurons.
@@ -1655,7 +1665,7 @@ def forward(self, x: torch.Tensor) -> None:
self.s_prob = 1.0 - torch.exp(-self.rho * self.dt)
# Decrement refractory counters.
- self.refrac_count -= self.dt
+ self.refrac_count.sub_(self.dt)
# Check for spiking neurons (spike when probability > some random number).
self.s = torch.rand_like(self.s_prob) < self.s_prob
diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py
index f277e831..9c342a6c 100644
--- a/bindsnet/network/topology.py
+++ b/bindsnet/network/topology.py
@@ -261,7 +261,7 @@ def remove_pipeline(self, feature) -> None:
self.pipeline.remove(feature)
del self.feature_index[feature.name]
- def _apply(self, fn):
+ def _apply(self, fn, recurse=True):
# language=rst
"""
Relocate pipeline features (and their learning rules) along with the connection
@@ -274,7 +274,7 @@ def _apply(self, fn):
mismatch. The feature value is moved in place (via ``.data``) so it stays
aliased to the learning rule's cached reference.
"""
- super()._apply(fn)
+ super()._apply(fn, recurse)
for feature in self.pipeline:
value = getattr(feature, "value", None)
if isinstance(value, torch.Tensor):
diff --git a/examples/benchmark/hot_path_bench.py b/examples/benchmark/hot_path_bench.py
new file mode 100644
index 00000000..27c7f053
--- /dev/null
+++ b/examples/benchmark/hot_path_bench.py
@@ -0,0 +1,120 @@
+"""
+Timing / peak-memory benchmark for the per-timestep hot paths (learning rules,
+neuron updates). Prints one line per workload.
+
+ python examples/benchmark/hot_path_bench.py # CPU
+ python examples/benchmark/hot_path_bench.py --gpu # CUDA
+"""
+
+import argparse
+import time
+
+import torch
+
+from bindsnet.encoding import poisson
+from bindsnet.learning import MSTDP, PostPre
+from bindsnet.models import DiehlAndCook2015
+from bindsnet.network import Network
+from bindsnet.network.nodes import Input, LIFNodes
+from bindsnet.network.topology import Connection, LocalConnection2D
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--gpu", action="store_true")
+parser.add_argument("--reps", type=int, default=3)
+parser.add_argument("--time", type=int, default=250)
+args = parser.parse_args()
+dev = "cuda" if args.gpu and torch.cuda.is_available() else "cpu"
+T = args.time
+
+
+def timeit(net, inputs, **kw):
+ if dev != "cpu":
+ net.to(dev)
+ inputs = {k: v.to(dev) for k, v in inputs.items()}
+ torch.cuda.reset_peak_memory_stats()
+ torch.cuda.synchronize()
+ net.run(inputs=inputs, time=T, **kw) # warm-up
+ net.reset_state_variables()
+ if dev != "cpu":
+ torch.cuda.synchronize()
+ t = time.perf_counter()
+ for _ in range(args.reps):
+ net.run(inputs=inputs, time=T, **kw)
+ net.reset_state_variables()
+ if dev != "cpu":
+ torch.cuda.synchronize()
+ wall = (time.perf_counter() - t) / args.reps
+ mem = torch.cuda.max_memory_allocated() / 2**20 if dev != "cpu" else float("nan")
+ return wall, mem
+
+
+rows = []
+
+for b in (1, 16):
+ torch.manual_seed(0)
+ net = DiehlAndCook2015(
+ n_inpt=784, n_neurons=400, batch_size=b, inpt_shape=(1, 28, 28), device=dev
+ )
+ inp = poisson(torch.rand(b, 1, 28, 28) * 128, time=T)
+ rows.append((f"DiehlAndCook2015 784->400 batch={b}",) + timeit(net, {"X": inp}))
+
+for b in (1, 16):
+ torch.manual_seed(0)
+ net = Network(dt=1.0, batch_size=b)
+ net.add_layer(Input(n=784, traces=True), "in")
+ net.add_layer(LIFNodes(n=1000, traces=True), "out")
+ net.add_connection(
+ Connection(
+ net.layers["in"],
+ net.layers["out"],
+ nu=(1e-4, 1e-2),
+ update_rule=PostPre,
+ wmin=0,
+ wmax=1,
+ norm=78.4,
+ ),
+ "in",
+ "out",
+ )
+ inp = poisson(torch.rand(b, 784) * 128, time=T)
+ rows.append((f"Connection+PostPre 784->1000 batch={b}",) + timeit(net, {"in": inp}))
+
+torch.manual_seed(0)
+net = Network(dt=1.0)
+net.add_layer(Input(shape=[1, 28, 28], traces=True), "in")
+net.add_layer(LIFNodes(shape=[16, 6, 6], traces=True), "out")
+net.add_connection(
+ LocalConnection2D(
+ net.layers["in"],
+ net.layers["out"],
+ kernel_size=8,
+ stride=4,
+ n_filters=16,
+ nu=(1e-4, 1e-2),
+ update_rule=PostPre,
+ wmin=0,
+ wmax=1,
+ ),
+ "in",
+ "out",
+)
+inp = poisson(torch.rand(1, 1, 28, 28) * 128, time=T)
+rows.append(("LocalConnection2D+PostPre 28x28 k8 s4 f16",) + timeit(net, {"in": inp}))
+
+torch.manual_seed(0)
+net = Network(dt=1.0)
+net.add_layer(Input(n=784), "in")
+net.add_layer(LIFNodes(n=500), "out")
+net.add_connection(
+ Connection(
+ net.layers["in"], net.layers["out"], nu=1e-3, update_rule=MSTDP, wmin=0, wmax=1
+ ),
+ "in",
+ "out",
+)
+inp = poisson(torch.rand(1, 784) * 128, time=T)
+rows.append(("Connection+MSTDP 784->500",) + timeit(net, {"in": inp}, reward=1.0))
+
+print(f"device={dev} timesteps={T} reps={args.reps}")
+for name, wall, mem in rows:
+ print(f"{name:<45s} {wall:8.3f} s/run peak {mem:8.1f} MiB")
diff --git a/test/network/test_perf_equivalence.py b/test/network/test_perf_equivalence.py
new file mode 100644
index 00000000..1f46ec2c
--- /dev/null
+++ b/test/network/test_perf_equivalence.py
@@ -0,0 +1,597 @@
+"""
+Regression tests for the performance / memory changes to the per-timestep hot
+paths. Every test pins the optimised code to the plain formula it replaced, so a
+future change that alters the numerics is caught here.
+
+Covered:
+
+* ``MulticompartmentConnection`` can be moved with ``network.to(device)``
+ (fixes a ``_apply`` signature mismatch that crashed the Diehl & Cook model).
+* ``PostPre`` / ``Hebbian`` on dense ``Connection`` (classic and
+ multicompartment) use one fused ``addmm_`` instead of materialising the
+ ``[batch, source.n, target.n]`` outer product.
+* ``LearningRule`` skips the ``w *= 1.0`` multiply when no weight decay is set.
+* Local-connection rules scale rows directly instead of multiplying by an
+ ``[n, n]`` identity matrix every timestep.
+* Reward-modulated rules cache ``exp(-dt / tc)`` and their default learning
+ rate tensors.
+* Neuron models update their state buffers in place (same operations, same
+ order) instead of re-assigning module attributes every step.
+* ``rank_order`` encoding is vectorised.
+"""
+
+import pytest
+import torch
+
+from bindsnet.encoding import rank_order
+from bindsnet.learning import MSTDP, MSTDPET, Hebbian, PostPre, WeightDependentPostPre
+from bindsnet.learning import MCC_learning
+from bindsnet.learning.learning import (
+ _cached_decay,
+ _dense_outer_update_ok,
+ _reward_rates,
+ _row_scale,
+)
+from bindsnet.models import DiehlAndCook2015
+from bindsnet.network import Network
+from bindsnet.network.nodes import (
+ AdaptiveLIFNodes,
+ CurrentLIFNodes,
+ DiehlAndCookNodes,
+ Input,
+ IzhikevichNodes,
+ LIFNodes,
+ SRM0Nodes,
+)
+from bindsnet.network.topology import (
+ Connection,
+ LocalConnection1D,
+ LocalConnection2D,
+ LocalConnection3D,
+ MulticompartmentConnection,
+)
+from bindsnet.network.topology_features import Weight
+
+
+def _dense_net(rule, batch_size=1, n_in=40, n_out=25, dt=1.0, seed=0, **kwargs):
+ torch.manual_seed(seed)
+ net = Network(dt=dt, batch_size=batch_size)
+ net.add_layer(Input(n=n_in, traces=True), "in")
+ net.add_layer(LIFNodes(n=n_out, traces=True), "out")
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ nu=kwargs.pop("nu", (1e-2, 2e-2)),
+ update_rule=rule,
+ wmin=0.0,
+ wmax=1.0,
+ **kwargs,
+ )
+ net.add_connection(conn, "in", "out")
+ return net, conn
+
+
+def _warm_up(net, steps=15, seed=1):
+ """Run a few steps so traces and spikes are non-trivial, then return the
+ snapshot needed by the reference formulas."""
+ torch.manual_seed(seed)
+ b = net.batch_size
+ n_in = net.layers["in"].n
+ inp = torch.bernoulli(0.4 * torch.rand(steps, b, n_in)).byte()
+ net.run(inputs={"in": inp}, time=steps * net.dt)
+ # Make sure both sides have spikes and traces in the snapshot.
+ net.layers["in"].s = torch.bernoulli(0.5 * torch.ones(b, n_in)).bool()
+ net.layers["out"].s = torch.bernoulli(
+ 0.5 * torch.ones(b, net.layers["out"].n)
+ ).bool()
+ net.layers["in"].x = torch.rand(b, n_in)
+ net.layers["out"].x = torch.rand(b, net.layers["out"].n)
+ return net
+
+
+def _reference_outer(source, target, batch_size, reduction):
+ """The un-fused formula: outer products over the batch, then the batch
+ reduction (``squeeze`` for batch 1, ``sum`` otherwise)."""
+ source_s = source.s.view(batch_size, -1).unsqueeze(2).float()
+ source_x = source.x.view(batch_size, -1).unsqueeze(2)
+ target_s = target.s.view(batch_size, -1).unsqueeze(1).float()
+ target_x = target.x.view(batch_size, -1).unsqueeze(1)
+ return (
+ reduction(torch.bmm(source_s, target_x), dim=0), # pre: s_pre x x_post
+ reduction(torch.bmm(source_x, target_s), dim=0), # post: x_pre x s_post
+ )
+
+
+class TestMulticompartmentDeviceMove:
+ def test_to_cpu_works(self):
+ net = DiehlAndCook2015(n_inpt=16, n_neurons=4, inpt_shape=(1, 4, 4))
+ net.to("cpu") # crashed before: _apply() got an unexpected ``recurse``
+ net.run(inputs={"X": torch.zeros(5, 1, 1, 4, 4).byte()}, time=5)
+
+ @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
+ def test_to_cuda_moves_features(self):
+ net = DiehlAndCook2015(n_inpt=16, n_neurons=4, inpt_shape=(1, 4, 4))
+ net.to("cuda")
+ for conn in net.connections.values():
+ assert conn.pipeline[0].value.is_cuda
+ net.run(inputs={"X": torch.zeros(5, 1, 1, 4, 4).byte().cuda()}, time=5)
+
+
+class TestFusedOuterProductRules:
+ @pytest.mark.parametrize("batch_size", [1, 4])
+ def test_postpre_matches_reference(self, batch_size):
+ net, conn = _dense_net(PostPre, batch_size)
+ _warm_up(net)
+ rule = conn.update_rule
+ w0 = conn.w.detach().clone()
+ pre, post = _reference_outer(
+ conn.source, conn.target, batch_size, rule.reduction
+ )
+ expected = (w0 - pre * rule.nu[0] + post * rule.nu[1]).clamp_(0.0, 1.0)
+
+ conn.update(learning=True)
+
+ if batch_size == 1:
+ assert torch.equal(conn.w, expected)
+ else:
+ assert torch.allclose(conn.w, expected, rtol=1e-6, atol=1e-7)
+
+ @pytest.mark.parametrize("batch_size", [1, 4])
+ def test_hebbian_matches_reference(self, batch_size):
+ net, conn = _dense_net(Hebbian, batch_size)
+ _warm_up(net)
+ rule = conn.update_rule
+ w0 = conn.w.detach().clone()
+ pre, post = _reference_outer(
+ conn.source, conn.target, batch_size, rule.reduction
+ )
+ expected = (w0 + rule.nu[0] * pre + rule.nu[1] * post).clamp_(0.0, 1.0)
+
+ conn.update(learning=True)
+
+ if batch_size == 1:
+ assert torch.equal(conn.w, expected)
+ else:
+ assert torch.allclose(conn.w, expected, rtol=1e-6, atol=1e-7)
+
+ def test_fused_path_predicate(self):
+ # The fused ``addmm_`` path is only taken for dense float32 weights,
+ # scalar learning rates and the two default batch reductions.
+ net, conn = _dense_net(PostPre, 1)
+ rule = conn.update_rule
+ assert _dense_outer_update_ok(rule, conn.w)
+ assert not _dense_outer_update_ok(rule, conn.w.to(torch.float64))
+ assert not _dense_outer_update_ok(rule, conn.w.to_sparse())
+ rule.reduction = torch.mean
+ assert not _dense_outer_update_ok(rule, conn.w)
+ rule.reduction = torch.sum
+ rule.nu = torch.stack([torch.rand(40, 25), torch.rand(40, 25)])
+ assert not _dense_outer_update_ok(rule, conn.w)
+
+ def test_custom_reduction_falls_back_and_matches(self):
+ net, conn = _dense_net(PostPre, 4, reduction=torch.mean)
+ _warm_up(net)
+ rule = conn.update_rule
+ assert rule.reduction is torch.mean
+ w0 = conn.w.detach().clone()
+ pre, post = _reference_outer(conn.source, conn.target, 4, torch.mean)
+ expected = (w0 - pre * rule.nu[0] + post * rule.nu[1]).clamp_(0.0, 1.0)
+
+ conn.update(learning=True)
+ assert torch.allclose(conn.w, expected, rtol=1e-6, atol=1e-7)
+
+ @pytest.mark.parametrize("dt", [1.0, 0.5])
+ def test_mcc_postpre_matches_reference(self, dt):
+ torch.manual_seed(0)
+ net = Network(dt=dt)
+ net.add_layer(Input(n=40, traces=True), "in")
+ net.add_layer(LIFNodes(n=25, traces=True), "out")
+ conn = MulticompartmentConnection(
+ net.layers["in"],
+ net.layers["out"],
+ device="cpu",
+ pipeline=[
+ Weight(
+ "w",
+ 0.3 * torch.rand(40, 25),
+ range=[0.0, 1.0],
+ nu=(1e-2, 2e-2),
+ learning_rule=MCC_learning.PostPre,
+ )
+ ],
+ )
+ net.add_connection(conn, "in", "out")
+ _warm_up(net)
+ rule = conn.pipeline[0].learning_rule
+ w0 = conn.pipeline[0].value.detach().clone()
+ pre, post = _reference_outer(conn.source, conn.target, 1, rule.reduction)
+ expected = (w0 - pre * rule.nu[0] * dt + post * rule.nu[1] * dt).clamp_(
+ 0.0, 1.0
+ )
+
+ conn.update(learning=True)
+ assert torch.equal(conn.pipeline[0].value, expected)
+
+ def test_mcc_hebbian_matches_reference(self):
+ torch.manual_seed(0)
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=40, traces=True), "in")
+ net.add_layer(LIFNodes(n=25, traces=True), "out")
+ conn = MulticompartmentConnection(
+ net.layers["in"],
+ net.layers["out"],
+ device="cpu",
+ pipeline=[
+ Weight(
+ "w",
+ 0.3 * torch.rand(40, 25),
+ range=[0.0, 1.0],
+ nu=(1e-2, 2e-2),
+ learning_rule=MCC_learning.Hebbian,
+ )
+ ],
+ )
+ net.add_connection(conn, "in", "out")
+ _warm_up(net)
+ rule = conn.pipeline[0].learning_rule
+ w0 = conn.pipeline[0].value.detach().clone()
+ pre, post = _reference_outer(conn.source, conn.target, 1, rule.reduction)
+ expected = (w0 + rule.nu[0] * pre + rule.nu[1] * post).clamp_(0.0, 1.0)
+
+ conn.update(learning=True)
+ assert torch.equal(conn.pipeline[0].value, expected)
+
+
+class TestWeightDecay:
+ def test_no_decay_leaves_weights_untouched(self):
+ net, conn = _dense_net(PostPre, 1)
+ assert conn.update_rule.weight_decay == 1.0
+ w0 = conn.w.detach().clone()
+ # No spikes and no traces: the only thing that could change ``w`` is decay.
+ conn.update(learning=True)
+ assert torch.equal(conn.w, w0)
+
+ def test_decay_still_applied(self):
+ net, conn = _dense_net(PostPre, 1, weight_decay=0.01)
+ w0 = conn.w.detach().clone()
+ conn.update(learning=True)
+ assert torch.equal(conn.w, w0 * (1.0 - 0.01))
+
+
+class TestLocalConnectionRules:
+ def test_row_scale_equals_diagonal_bmm(self):
+ torch.manual_seed(0)
+ vec = torch.rand(3, 17, 1)
+ mat = torch.rand(3, 17, 9)
+ reference = torch.bmm(vec * torch.eye(17), mat)
+ assert torch.equal(_row_scale(vec, mat), reference)
+ assert torch.equal(_row_scale(vec.squeeze(2), mat), reference)
+
+ @staticmethod
+ def _local2d_net(rule, batch_size):
+ torch.manual_seed(0)
+ net = Network(dt=1.0, batch_size=batch_size)
+ net.add_layer(Input(shape=[2, 8, 8], traces=True), "in")
+ net.add_layer(LIFNodes(shape=[3, 3, 3], traces=True), "out")
+ conn = LocalConnection2D(
+ net.layers["in"],
+ net.layers["out"],
+ kernel_size=4,
+ stride=2,
+ n_filters=3,
+ nu=(1e-2, 2e-2),
+ update_rule=rule,
+ wmin=0.0,
+ wmax=1.0,
+ )
+ net.add_connection(conn, "in", "out")
+ torch.manual_seed(1)
+ inp = torch.bernoulli(0.4 * torch.rand(10, batch_size, 2, 8, 8)).byte()
+ net.run(inputs={"in": inp}, time=10)
+ net.layers["in"].s = torch.bernoulli(
+ 0.5 * torch.ones(batch_size, 2, 8, 8)
+ ).bool()
+ net.layers["out"].s = torch.bernoulli(
+ 0.5 * torch.ones(batch_size, 3, 3, 3)
+ ).bool()
+ net.layers["in"].x = torch.rand(batch_size, 2, 8, 8)
+ net.layers["out"].x = torch.rand(batch_size, 3, 3, 3)
+ return net, conn
+
+ @staticmethod
+ def _reference_local2d(conn, batch_size):
+ """The previous implementation: diagonal matrices built with ``torch.eye``
+ and multiplied with ``bmm``."""
+ kh, kw = conn.kernel_size
+ sh, sw = conn.stride
+ c_in = conn.source.shape[0]
+ n_out = conn.n_filters * conn.conv_size[0] * conn.conv_size[1]
+
+ def unfold(t):
+ return (
+ t.float()
+ .unfold(-2, kh, sh)
+ .unfold(-2, kw, sw)
+ .reshape(
+ batch_size, conn.conv_size[0] * conn.conv_size[1], c_in * kh * kw
+ )
+ .repeat(1, conn.n_filters, 1)
+ )
+
+ target_x = conn.target.x.reshape(batch_size, n_out, 1) * torch.eye(n_out)
+ target_s = conn.target.s.float().reshape(batch_size, n_out, 1) * torch.eye(
+ n_out
+ )
+ source_s, source_x = unfold(conn.source.s), unfold(conn.source.x)
+ red = conn.update_rule.reduction
+ pre = red(torch.bmm(target_x, source_s), dim=0).view(conn.w.size())
+ post = red(torch.bmm(target_s, source_x), dim=0).view(conn.w.size())
+ return pre, post
+
+ @pytest.mark.parametrize("batch_size", [1, 2])
+ def test_postpre_local2d_matches_diag_reference(self, batch_size):
+ net, conn = self._local2d_net(PostPre, batch_size)
+ nu = conn.update_rule.nu
+ w0 = conn.w.detach().clone()
+ pre, post = self._reference_local2d(conn, batch_size)
+ expected = (w0 - nu[0] * pre + nu[1] * post).clamp_(0.0, 1.0)
+ conn.update(learning=True)
+ assert torch.equal(conn.w, expected)
+
+ @pytest.mark.parametrize("batch_size", [1, 2])
+ def test_hebbian_local2d_matches_diag_reference(self, batch_size):
+ net, conn = self._local2d_net(Hebbian, batch_size)
+ nu = conn.update_rule.nu
+ w0 = conn.w.detach().clone()
+ pre, post = self._reference_local2d(conn, batch_size)
+ expected = (w0 + nu[0] * pre + nu[1] * post).clamp_(0.0, 1.0)
+ conn.update(learning=True)
+ assert torch.equal(conn.w, expected)
+
+ @pytest.mark.parametrize("batch_size", [1, 2])
+ def test_weight_dependent_local2d_matches_diag_reference(self, batch_size):
+ net, conn = self._local2d_net(WeightDependentPostPre, batch_size)
+ nu = conn.update_rule.nu
+ w0 = conn.w.detach().clone()
+ pre, post = self._reference_local2d(conn, batch_size)
+ update = -nu[0] * pre * (w0 - conn.wmin) + nu[1] * post * (conn.wmax - w0)
+ expected = (w0 + update).clamp_(0.0, 1.0)
+ conn.update(learning=True)
+ assert torch.equal(conn.w, expected)
+
+ @pytest.mark.parametrize(
+ "rule", [PostPre, Hebbian, WeightDependentPostPre, MSTDP, MSTDPET]
+ )
+ @pytest.mark.parametrize("dim", [1, 2, 3])
+ def test_all_local_rules_run(self, rule, dim):
+ torch.manual_seed(0)
+ net = Network(dt=1.0, batch_size=2)
+ if dim == 1:
+ in_shape, out_shape = [2, 12], [3, 5]
+ conn_cls, k = LocalConnection1D, 4
+ elif dim == 2:
+ in_shape, out_shape = [2, 8, 8], [3, 3, 3]
+ conn_cls, k = LocalConnection2D, 4
+ else:
+ in_shape, out_shape = [2, 6, 6, 6], [2, 2, 2, 2]
+ conn_cls, k = LocalConnection3D, 4
+ net.add_layer(Input(shape=in_shape, traces=True), "in")
+ net.add_layer(LIFNodes(shape=out_shape, traces=True), "out")
+ conn = conn_cls(
+ net.layers["in"],
+ net.layers["out"],
+ kernel_size=k,
+ stride=2,
+ n_filters=out_shape[0],
+ nu=(1e-2, 2e-2),
+ update_rule=rule,
+ wmin=0.0,
+ wmax=1.0,
+ )
+ net.add_connection(conn, "in", "out")
+ inp = torch.bernoulli(0.4 * torch.rand(20, 2, *in_shape)).byte()
+ kw = {"reward": 0.5} if rule in (MSTDP, MSTDPET) else {}
+ net.run(inputs={"in": inp}, time=20, **kw)
+ assert torch.isfinite(conn.w).all()
+ assert (conn.w >= 0).all() and (conn.w <= 1).all()
+ if rule in (MSTDP, MSTDPET):
+ # The traces are kept as vectors now, not diagonal matrices.
+ r = conn.update_rule
+ assert r.p_minus.shape == (2, net.layers["out"].n, 1)
+ assert r.eligibility.shape == (2, *conn.w.shape)
+
+
+class TestRewardRuleCaching:
+ def test_cached_decay_matches_formula_and_tracks_changes(self):
+ net, conn = _dense_net(MSTDP, 1, dt=0.5)
+ rule = conn.update_rule
+ d = _cached_decay(rule, "tc_plus")
+ assert torch.equal(d, torch.exp(-0.5 / rule.tc_plus))
+ assert _cached_decay(rule, "tc_plus") is d # reused, not recomputed
+ rule.tc_plus = torch.tensor(7.0)
+ assert torch.equal(
+ _cached_decay(rule, "tc_plus"), torch.exp(-0.5 / torch.tensor(7.0))
+ )
+ conn.dt = 2.0
+ assert torch.equal(
+ _cached_decay(rule, "tc_plus"), torch.exp(-2.0 / torch.tensor(7.0))
+ )
+
+ def test_reward_rates_defaults_reused_and_overrides_honoured(self):
+ net, conn = _dense_net(MSTDP, 1)
+ rule = conn.update_rule
+ ap, am = _reward_rates(rule, {}, torch.device("cpu"))
+ assert ap.item() == 1.0 and am.item() == -1.0
+ ap2, am2 = _reward_rates(rule, {}, torch.device("cpu"))
+ assert ap2 is ap and am2 is am
+ ap3, am3 = _reward_rates(
+ rule, {"a_plus": 0.3, "a_minus": -0.2}, torch.device("cpu")
+ )
+ assert ap3.item() == pytest.approx(0.3) and am3.item() == pytest.approx(-0.2)
+
+ @pytest.mark.parametrize("rule", [MSTDP, MSTDPET])
+ def test_reward_rules_match_step_by_step_reference(self, rule):
+ # From-scratch Florian (2007) reference with the default one-step lag,
+ # run alongside the network on identical spike trains.
+ torch.manual_seed(0)
+ n_in, n_out, T, dt = 12, 7, 25, 0.5
+ net = Network(dt=dt)
+ net.add_layer(Input(n=n_in), "in")
+ net.add_layer(LIFNodes(n=n_out), "out")
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ nu=1e-2,
+ update_rule=rule,
+ wmin=-5.0,
+ wmax=5.0,
+ w=0.5 * torch.rand(n_in, n_out),
+ )
+ net.add_connection(conn, "in", "out")
+ w_ref = conn.w.detach().clone()
+ r = conn.update_rule
+ tc_plus, tc_minus, tc_e = float(r.tc_plus), float(r.tc_minus), 25.0
+ inp = torch.bernoulli(0.4 * torch.rand(T, n_in)).byte()
+ p_plus, p_minus = torch.zeros(n_in), torch.zeros(n_out)
+ elig, e_trace = torch.zeros(n_in, n_out), torch.zeros(n_in, n_out)
+ reward = 0.7
+ for t in range(T):
+ net.run(inputs={"in": inp[t : t + 1]}, time=dt, reward=reward)
+ pre = net.layers["in"].s.view(-1).float()
+ post = net.layers["out"].s.view(-1).float()
+ if rule is MSTDP:
+ w_ref += 1e-2 * reward * elig
+ else:
+ e_trace = e_trace * torch.exp(torch.tensor(-dt / tc_e)) + elig / tc_e
+ w_ref += 1e-2 * dt * reward * e_trace
+ p_plus = p_plus * torch.exp(torch.tensor(-dt / tc_plus)) + pre
+ p_minus = p_minus * torch.exp(torch.tensor(-dt / tc_minus)) - post
+ elig = torch.outer(p_plus, post) + torch.outer(pre, p_minus)
+ w_ref.clamp_(-5.0, 5.0)
+ assert torch.allclose(conn.w, w_ref, rtol=1e-5, atol=1e-6), t
+
+
+class TestNodesInPlace:
+ def test_lif_matches_explicit_simulation(self):
+ torch.manual_seed(0)
+ n, T, dt = 30, 40, 0.5
+ layer = LIFNodes(n=n, traces=True, lbound=-70.0)
+ net = Network(dt=dt)
+ net.add_layer(Input(n=n), "in")
+ net.add_layer(layer, "out")
+ w = torch.diag(20.0 * torch.ones(n))
+ net.add_connection(Connection(net.layers["in"], layer, w=w), "in", "out")
+ inp = torch.bernoulli(0.6 * torch.rand(T, n)).byte()
+
+ v = layer.rest * torch.ones(1, n)
+ refrac = torch.zeros(1, n)
+ x_trace = torch.zeros(1, n)
+ decay = torch.exp(-torch.tensor(dt) / layer.tc_decay)
+ trace_decay = torch.exp(-torch.tensor(dt) / layer.tc_trace)
+ for t in range(T):
+ net.run(inputs={"in": inp[t : t + 1]}, time=dt)
+ # Synchronous update: the layer sees the input spikes of step t-1.
+ prev = inp[t - 1].float() if t > 0 else torch.zeros(n)
+ inj = (prev @ w).unsqueeze(0)
+ v = decay * (v - layer.rest) + layer.rest
+ inj.masked_fill_(refrac > 0, 0.0)
+ refrac -= torch.tensor(dt)
+ v += inj
+ s = v >= layer.thresh
+ refrac.masked_fill_(s, layer.refrac)
+ v.masked_fill_(s, layer.reset)
+ v.masked_fill_(v < -70.0, -70.0)
+ x_trace *= trace_decay
+ x_trace.masked_fill_(s, 1.0)
+ assert torch.equal(layer.s, s), t
+ assert torch.equal(layer.v, v), t
+ assert torch.equal(layer.x, x_trace), t
+
+ def test_diehl_and_cook_matches_explicit_simulation(self):
+ torch.manual_seed(0)
+ n, T = 20, 40
+ layer = DiehlAndCookNodes(n=n, one_spike=False, theta_plus=0.05)
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=n), "in")
+ net.add_layer(layer, "out")
+ w = torch.diag(15.0 * torch.ones(n))
+ net.add_connection(Connection(net.layers["in"], layer, w=w), "in", "out")
+ inp = torch.bernoulli(0.7 * torch.rand(T, n)).byte()
+
+ v = layer.rest * torch.ones(1, n)
+ refrac = torch.zeros(1, n)
+ theta = torch.zeros(n)
+ decay = torch.exp(-torch.tensor(1.0) / layer.tc_decay)
+ theta_decay = torch.exp(-torch.tensor(1.0) / layer.tc_theta_decay)
+ for t in range(T):
+ net.run(inputs={"in": inp[t : t + 1]}, time=1)
+ prev = inp[t - 1].float() if t > 0 else torch.zeros(n)
+ inj = (prev @ w).unsqueeze(0)
+ v = decay * (v - layer.rest) + layer.rest
+ theta *= theta_decay
+ v += (refrac <= 0).float() * inj
+ refrac -= torch.tensor(1.0)
+ s = v >= layer.thresh + theta
+ refrac.masked_fill_(s, layer.refrac)
+ v.masked_fill_(s, layer.reset)
+ theta += layer.theta_plus * s.float().sum(0)
+ assert torch.equal(layer.s, s), t
+ assert torch.equal(layer.v, v), t
+ assert torch.equal(layer.theta, theta), t
+
+ @pytest.mark.parametrize(
+ "node",
+ [LIFNodes, CurrentLIFNodes, AdaptiveLIFNodes, DiehlAndCookNodes, SRM0Nodes],
+ )
+ def test_state_buffers_keep_identity_and_registration(self, node):
+ torch.manual_seed(0)
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=10), "in")
+ layer = node(n=6, traces=True)
+ net.add_layer(layer, "out")
+ net.add_connection(
+ Connection(net.layers["in"], layer, w=torch.rand(10, 6)), "in", "out"
+ )
+ v_before = layer.v
+ net.run(inputs={"in": torch.ones(5, 10).byte()}, time=5)
+ # In-place updates: same tensor object, still a registered buffer.
+ assert layer.v is v_before
+ assert "v" in dict(layer.named_buffers())
+ assert torch.isfinite(layer.v).all()
+
+ def test_izhikevich_runs_finite(self):
+ torch.manual_seed(0)
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=10), "in")
+ layer = IzhikevichNodes(n=10, excitatory=0.8)
+ net.add_layer(layer, "out")
+ net.add_connection(
+ Connection(net.layers["in"], layer, w=5.0 * torch.rand(10, 10)), "in", "out"
+ )
+ net.run(
+ inputs={"in": torch.bernoulli(0.5 * torch.rand(30, 10)).byte()}, time=30
+ )
+ assert torch.isfinite(layer.v).all() and torch.isfinite(layer.u).all()
+
+
+class TestRankOrderEncoding:
+ def test_matches_loop_reference(self):
+ torch.manual_seed(0)
+ time = 25
+ datum = torch.rand(6, 9) * torch.bernoulli(torch.full((6, 9), 0.7))
+ datum[0, 0] = 1.0 # a guaranteed maximum -> earliest spike
+ out = rank_order(datum.clone(), time=time, dt=1.0)
+
+ # Previous per-neuron loop implementation.
+ d = datum.flatten().clone()
+ d /= d.max()
+ times = torch.zeros(d.numel())
+ times[d != 0] = 1 / d[d != 0]
+ times *= time / times.max()
+ times = torch.ceil(times).long()
+ ref = torch.zeros(time, d.numel()).byte()
+ for i in range(d.numel()):
+ if 0 < times[i] < time:
+ ref[times[i] - 1, i] = 1
+ assert torch.equal(out, ref.reshape(time, 6, 9))
+ assert out.dtype == torch.uint8
From 60f95c6568b288d6ea58c87af177c6ca4218b0c3 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Sun, 6 Sep 2026 11:13:56 -0400
Subject: [PATCH 03/14] validate learning rules against their source papers
- test/network/test_learning_rule_specs.py: PostPre / WeightDependentPostPre /
Hebbian vs Morrison, Diesmann & Gerstner (2008) eqs 11-14; Rmax vs Vasilaki
et al. (2009) eqs 7, 8, 13; MCC PostPre/Hebbian vs classic; Diehl & Cook
deviation pinned
- test_mstdp_florian.py: Florian (2007) equation numbers recorded
- Fix reversed Rmax tc_c docstring and misleading MSTDP/MSTDPET zero_lag comments
- docs/source/models_spec.rst: cite the equations per rule
- CHANGELOG entry
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 11 +
bindsnet/learning/learning.py | 42 ++-
docs/source/models_spec.rst | 39 ++-
test/network/test_learning_rule_specs.py | 426 +++++++++++++++++++++++
test/network/test_mstdp_florian.py | 19 +-
5 files changed, 509 insertions(+), 28 deletions(-)
create mode 100644 test/network/test_learning_rule_specs.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c09834e4..9b587b89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,17 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
round-trip per assignment per step.
- `rank_order` encoding is vectorised.
- Benchmark script for the above: `examples/benchmark/hot_path_bench.py`.
+- Learning rules validated against their source papers, with the equations cited in
+ `docs/source/models_spec.rst` and pinned by `test/network/test_learning_rule_specs.py`:
+ `PostPre` / `WeightDependentPostPre` / `Hebbian` against Morrison, Diesmann &
+ Gerstner (2008) eqs. (11)-(14); `MSTDP` / `MSTDPET` against Florian (2007)
+ eqs. (3.9)-(3.12) and (2.7)-(2.8) (equation numbers added to
+ `test_mstdp_florian.py`); `Rmax` against Vasilaki et al. (2009) eqs. (7), (8), (13).
+ The `MCC_learning` `PostPre` / `Hebbian` are checked to match the classic rules.
+- Docstrings corrected: `Rmax` `tc_c` limits were stated backwards (`0` is the strict
+ policy-gradient rule, `inf` the naive Hebbian rule, Vasilaki et al. eq. 8); the
+ `MSTDP` / `MSTDPET` `zero_lag` comments called the un-lagged variant "exact Florian",
+ whereas the default one-step lag is Florian's discrete-time eq. (3.9).
### Fixed
- `network.to(device)` crashed on any `MulticompartmentConnection` (used by
diff --git a/bindsnet/learning/learning.py b/bindsnet/learning/learning.py
index 6850b44d..60f5454c 100644
--- a/bindsnet/learning/learning.py
+++ b/bindsnet/learning/learning.py
@@ -1584,10 +1584,13 @@ def __init__(
self.tc_plus = torch.tensor(kwargs.get("tc_plus", 20.0))
self.tc_minus = torch.tensor(kwargs.get("tc_minus", 20.0))
- # If True, the reward at step t modulates the eligibility that already
- # includes the spikes at step t (exact Florian 2007 timing). If False
- # (default, backward-compatible) the eligibility is applied with a
- # one-timestep lag. Currently honoured by the ``Connection`` path.
+ # Timing of reward vs. eligibility. Default (False) is Florian (2007)
+ # eq. 3.9, w(t+dt) = w(t) + gamma r(t+dt) zeta(t): the reward supplied
+ # at a step multiplies the eligibility built from the previous step's
+ # spikes. ``zero_lag=True`` instead multiplies the reward by the
+ # eligibility that already includes this step's spikes (a direct
+ # discretisation of the continuous-time eq. 3.4). Currently honoured by
+ # the ``Connection`` path.
self.zero_lag = kwargs.get("zero_lag", False)
def _connection_update(self, **kwargs) -> None:
@@ -1657,7 +1660,8 @@ def _update_traces_and_eligibility():
) + torch.bmm(source_s.unsqueeze(2), self.p_minus.unsqueeze(1))
# With zero_lag, fold in the current spikes before applying the update,
- # so reward(t) multiplies eligibility(t) exactly as in Florian 2007.
+ # so reward(t) multiplies eligibility(t) (un-lagged variant; the default
+ # below is Florian 2007 eq. 3.9).
if self.zero_lag:
_update_traces_and_eligibility()
@@ -1667,8 +1671,8 @@ def _update_traces_and_eligibility():
update = update.to_sparse()
self.connection.w += self.nu[0] * update
- # Default (backward-compatible): the eligibility computed here is applied
- # on the next timestep (one-step lag).
+ # Default: the eligibility computed here is applied on the next
+ # timestep, as in Florian (2007) eq. 3.9 / eqs. 2.7-2.8.
if not self.zero_lag:
_update_traces_and_eligibility()
@@ -2081,10 +2085,13 @@ def __init__(
self.tc_plus = torch.tensor(kwargs.get("tc_plus", 20.0))
self.tc_minus = torch.tensor(kwargs.get("tc_minus", 20.0))
self.tc_e_trace = torch.tensor(kwargs.get("tc_e_trace", 25.0))
- # If True, the current spikes are folded into the eligibility before the
- # eligibility trace is integrated (exact Florian 2007 timing). If False
- # (default, backward-compatible) a one-timestep lag is kept. Currently
- # honoured by the ``Connection`` path.
+ # Timing of eligibility vs. trace integration. Default (False) is
+ # Florian (2007) eqs. 2.7-2.8, z(t+dt) = beta z(t) + zeta(t)/tau_z and
+ # w(t+dt) = w(t) + gamma dt r(t+dt) z(t+dt): the trace integrated at a
+ # step uses the eligibility built from the previous step's spikes.
+ # ``zero_lag=True`` folds this step's spikes into the eligibility
+ # before integrating (continuous-time eqs. 3.1-3.2 discretised without
+ # the lag). Currently honoured by the ``Connection`` path.
self.zero_lag = kwargs.get("zero_lag", False)
def _connection_update(self, **kwargs) -> None:
@@ -2139,7 +2146,7 @@ def _update_traces_and_eligibility():
) + torch.bmm(source_s.unsqueeze(2), self.p_minus.unsqueeze(1))
# With zero_lag, fold in the current spikes before integrating the
- # eligibility trace (exact Florian 2007 timing).
+ # eligibility trace (un-lagged variant; the default is eqs. 2.7-2.8).
if self.zero_lag:
_update_traces_and_eligibility()
@@ -2156,8 +2163,8 @@ def _update_traces_and_eligibility():
update = update.to_sparse()
self.connection.w += update
- # Default (backward-compatible): the eligibility computed here is applied
- # on the next timestep (one-step lag).
+ # Default: the eligibility computed here is applied on the next
+ # timestep, as in Florian (2007) eq. 3.9 / eqs. 2.7-2.8.
if not self.zero_lag:
_update_traces_and_eligibility()
@@ -2578,8 +2585,9 @@ def __init__(
Keyword arguments:
- :param float tc_c: Time constant for balancing naive Hebbian and policy gradient
- learning.
+ :param float tc_c: Time constant :math:`\\tau_c` balancing policy-gradient and
+ naive Hebbian learning (Vasilaki et al. 2009, eq. 8): ``0`` gives the
+ strict policy-gradient rule, ``inf`` the naive Hebbian rule. Default 5.
:param float tc_e_trace: Time constant for the eligibility trace.
"""
super().__init__(
@@ -2609,7 +2617,7 @@ def __init__(
self.tc_c = torch.tensor(
kwargs.get("tc_c", 5.0)
- ) # 0 for pure naive Hebbian, inf for pure policy gradient.
+ ) # 0 for strict policy gradient, inf for pure naive Hebbian (eq. 8).
self.tc_e_trace = torch.tensor(kwargs.get("tc_e_trace", 25.0))
def _connection_update(self, **kwargs) -> None:
diff --git a/docs/source/models_spec.rst b/docs/source/models_spec.rst
index a4e64118..e5988492 100644
--- a/docs/source/models_spec.rst
+++ b/docs/source/models_spec.rst
@@ -177,6 +177,20 @@ i.e. a pre-synaptic spike **depresses** the synapse in proportion to the post-sy
trace, and a post-synaptic spike **potentiates** it in proportion to the pre-synaptic
trace. Convolutional and locally-connected variants apply the same rule patch-wise.
+This is the additive pair-based trace STDP of Morrison, Diesmann & Gerstner (2008),
+*Biol. Cybern.* 98:459-478, eqs. (11)-(14), with :math:`F_+ = \nu_\text{post}`,
+:math:`F_- = \nu_\text{pre}`. Traces follow their Sect. 2.3: ``traces_additive=True``
+accumulates 1 per spike; ``traces_additive=False`` resets the trace to 1 on each spike.
+Validated in ``test/network/test_learning_rule_specs.py``.
+
+.. note::
+
+ ``PostPre`` is **not** the rule of Diehl & Cook (2015) even though it is the rule
+ used by the ``DiehlAndCook2015`` model. Diehl & Cook change weights only on
+ post-synaptic spikes, :math:`\Delta w = \eta (x_\text{pre} - x_\text{tar})(w_\max - w)^\mu`,
+ with a target trace :math:`x_\text{tar}`; ``PostPre`` has no target trace and adds a
+ depression term on pre-synaptic spikes instead.
+
Hebbian (``Hebbian``)
~~~~~~~~~~~~~~~~~~~~~~
Both pre- and post-synaptic events **increase** the weight (no depression term),
@@ -185,7 +199,9 @@ proportional to the opposite layer's trace.
Weight-dependent post-pre (``WeightDependentPostPre``)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``PostPre`` whose potentiation/depression magnitudes are scaled by the distance of the
-weight from its bounds (``wmin``/``wmax``), yielding soft saturation at the limits.
+weight from its bounds (``wmin``/``wmax``), yielding soft saturation at the limits:
+Morrison et al. (2008) eqs. (13)-(14) with :math:`F_+ = \nu_\text{post}(w_\max - w)` and
+:math:`F_- = \nu_\text{pre}(w - w_\min)` (the multiplicative / soft-bound rule).
Reward-modulated STDP (``MSTDP``, ``MSTDPET``)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -193,12 +209,27 @@ Three-factor rules: a STDP-like eligibility signal is gated by a scalar **reward
``MSTDP`` modulates the immediate pre/post correlation by reward; ``MSTDPET`` adds an
**eligibility trace** that accumulates the correlation over time (time constant
``tc_e_trace``) before reward gating. Reward is supplied via the pipeline / an
-``AbstractReward`` (e.g. ``MovingAvgRPE``). See source for the exact eligibility update.
+``AbstractReward`` (e.g. ``MovingAvgRPE``).
+
+Both follow the discrete-time equations of Florian (2007), *Neural Comput.*
+19:1468-1502: traces (3.11)-(3.12), eligibility (3.10), ``MSTDP`` update (3.9)
+:math:`w(t+\delta t) = w(t) + \gamma\, r(t+\delta t)\, \zeta(t)`, and ``MSTDPET``
+(2.7)-(2.8). The reward passed to ``network.run`` at a step therefore multiplies the
+eligibility of the previous step (``zero_lag=False``, the default). Defaults
+``tc_plus = tc_minus = 20``, ``tc_e_trace = 25``, ``a_plus = 1``, ``a_minus = -1`` are
+the paper's. Validated in ``test/network/test_mstdp_florian.py``.
Rmax (``Rmax``)
~~~~~~~~~~~~~~~
-Reward-maximizing rule intended for stochastic (SRM0) neurons; see source for its
-formulation.
+Reward-maximizing rule for stochastic ``SRM0Nodes``: Vasilaki, Fremaux, Urbanczik,
+Senn & Gerstner (2009), *PLoS Comput. Biol.* 5(12):e1000586, eqs. (7)-(8) with the
+escape rate of eq. (13). The eligibility trace decays with ``tc_e_trace`` and, on each
+step, adds :math:`[Y_i - p_i / (1 + (\tau_c/\delta t)\, p_i)]` times the additive
+pre-synaptic trace, where :math:`Y_i` is the post-synaptic spike and
+:math:`p_i = 1 - e^{-\rho_i \delta t}` its spike probability; ``tc_c`` is
+:math:`\tau_c` (``0`` = strict policy gradient, ``inf`` = naive Hebbian). The constant
+:math:`g'/g = 1/\Delta u` of eq. (8) is absorbed into ``nu``. Validated in
+``test/network/test_learning_rule_specs.py``.
.. note::
diff --git a/test/network/test_learning_rule_specs.py b/test/network/test_learning_rule_specs.py
new file mode 100644
index 00000000..f53c706b
--- /dev/null
+++ b/test/network/test_learning_rule_specs.py
@@ -0,0 +1,426 @@
+# language=rst
+"""
+Validation of the weight-modifying learning rules against the equations of the
+papers they implement. Each reference below is a from-scratch transcription of
+the cited equations, driven with the spike trains actually produced by the
+network, so the tests check the rule (not the neuron model) step by step.
+
+Sources
+-------
+* Pair-based trace STDP (``PostPre``, ``WeightDependentPostPre``, ``Hebbian``):
+ Morrison, Diesmann & Gerstner (2008), *Biol. Cybern.* 98:459-478,
+ Sect. 4.1, eqs. (11)-(15):
+
+ .. math::
+
+ \\dot x_j = -x_j/\\tau_x + \\sum_f \\delta(t - t_j^f) \\qquad (11)
+
+ \\dot y_i = -y_i/\\tau_y + \\sum_f \\delta(t - t_i^f) \\qquad (12)
+
+ \\Delta w_{ij}^+(t_i^f) = F_+(w_{ij})\\, x_j(t_i^f) \\qquad (13)
+
+ \\Delta w_{ij}^-(t_j^f) = -F_-(w_{ij})\\, y_i(t_j^f) \\qquad (14)
+
+ with ``j`` presynaptic (``source``) and ``i`` postsynaptic (``target``).
+ ``PostPre`` is the additive case :math:`F_+ = \\nu_\\text{post}`,
+ :math:`F_- = \\nu_\\text{pre}`; ``WeightDependentPostPre`` is the soft-bounded
+ (multiplicative) case :math:`F_+ = \\nu_\\text{post}(w_\\max - w)`,
+ :math:`F_- = \\nu_\\text{pre}(w - w_\\min)`. Traces follow Sect. 2.3: the
+ accumulating trace (``traces_additive=True``) adds 1 per spike; the saturating
+ trace with :math:`A = 1` (``traces_additive=False``) resets to 1 on each spike.
+ ``Hebbian`` is the same trace machinery with both terms positive (BindsNET's
+ own definition; no paper equation).
+
+* Reward-modulated STDP (``MSTDP``, ``MSTDPET``): Florian (2007), *Neural
+ Comput.* 19:1468-1502, discrete-time eqs. (3.9)-(3.12) and (2.7)-(2.8). Those
+ rules are validated in ``test_mstdp_florian.py``; this file only records the
+ equation numbers and the timing convention:
+ :math:`w(t+\\delta t) = w(t) + \\gamma\\, r(t+\\delta t)\\, \\zeta(t)` (3.9), i.e.
+ the reward supplied at a step multiplies the eligibility of the *previous*
+ step (the default ``zero_lag=False``).
+
+* R-max (``Rmax``): Vasilaki, Fremaux, Urbanczik, Senn & Gerstner (2009),
+ *PLoS Comput. Biol.* 5(12):e1000586, eqs. (7), (8) and (13):
+
+ .. math::
+
+ \\dot w_{ij} = \\alpha (R - b)\\, \\delta(t - t_\\text{hit})\\, e_{ij}(t) \\qquad (7)
+
+ \\dot e_{ij} = -e_{ij}/\\tau_e + \\frac{g'}{g}\\Big[Y_i(t) -
+ \\frac{\\rho_i(t)}{1 + \\tau_c\\,\\rho_i(t)}\\Big] \\sum_f \\epsilon(t - t_j^f)
+ \\qquad (8)
+
+ \\rho_i = g(u_i) = \\rho_0 \\exp\\big((u_i - u_0)/\\Delta u\\big) \\qquad (13)
+
+ :math:`\\tau_c = 0` is the strict policy-gradient rule and
+ :math:`\\tau_c \\to \\infty` the naive Hebbian rule (Vasilaki et al., p. 3-4).
+ For the exponential :math:`g` of (13), :math:`g'/g = 1/\\Delta u` is a constant
+ absorbed into the learning rate (ibid.). BindsNET discretises (8) with a
+ forward-Euler decay and the per-step spike probability
+ :math:`p = 1 - e^{-\\rho\\,\\delta t}` in place of :math:`\\rho\\,\\delta t`.
+
+Known limitation (not a rule defect): spikes forced with the ``clamp`` argument of
+``Network.run`` are applied after ``Nodes.forward`` has already updated the layer's
+spike trace, so a clamped spike enters the same-step potentiation term of the rule
+but never the trace used by later depression terms. The STDP-window test below
+therefore fires the post-synaptic neuron through a strong teacher input.
+"""
+
+import math
+
+import pytest
+import torch
+
+from bindsnet.learning import Hebbian, PostPre, Rmax, WeightDependentPostPre
+from bindsnet.learning import MCC_learning
+from bindsnet.network import Network
+from bindsnet.network.nodes import Input, LIFNodes, SRM0Nodes
+from bindsnet.network.topology import Connection, MulticompartmentConnection
+from bindsnet.network.topology_features import Weight
+
+TOL = 1e-5
+
+
+def _trace_step(trace, spikes, decay, additive):
+ """Morrison et al. (2008) Sect. 2.3 trace, one time step."""
+ trace = trace * decay
+ if additive:
+ return trace + spikes
+ return torch.where(spikes > 0, torch.ones_like(trace), trace)
+
+
+def _pair_stdp_reference(
+ pre, post, w0, nu_pre, nu_post, dt, tc_trace, additive, f, wmin, wmax
+):
+ """
+ Morrison et al. (2008) eqs. (11)-(14) in discrete time. ``pre``/``post`` are
+ ``[T, n]`` 0/1 spike arrays; ``f(w)`` returns ``(F_minus(w), F_plus(w))``.
+ Traces are updated before the weight change of the same step, matching the
+ network's order (``Nodes.forward`` then ``Connection.update``).
+ """
+ w = w0.clone()
+ x = torch.zeros(pre.shape[1])
+ y = torch.zeros(post.shape[1])
+ decay = math.exp(-dt / tc_trace)
+ hist = []
+ for t in range(pre.shape[0]):
+ x = _trace_step(x, pre[t], decay, additive)
+ y = _trace_step(y, post[t], decay, additive)
+ f_minus, f_plus = f(w)
+ w = w - nu_pre * f_minus * torch.outer(pre[t], y) # (14): pre spike
+ w = w + nu_post * f_plus * torch.outer(x, post[t]) # (13): post spike
+ w = w.clamp(wmin, wmax) # hard bounds, applied every step as in BindsNET
+ hist.append(w.clone())
+ return torch.stack(hist)
+
+
+def _run_pair_rule(rule, dt, additive, wmin, wmax, seed=0, T=40, n_in=12, n_out=6):
+ torch.manual_seed(seed)
+ net = Network(dt=dt)
+ net.add_layer(
+ Input(n=n_in, traces=True, traces_additive=additive, tc_trace=20.0), "in"
+ )
+ net.add_layer(
+ LIFNodes(n=n_out, traces=True, traces_additive=additive, tc_trace=20.0),
+ "out",
+ )
+ w0 = 0.5 * torch.rand(n_in, n_out)
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=w0.clone(),
+ nu=(1e-2, 3e-2),
+ update_rule=rule,
+ wmin=wmin,
+ wmax=wmax,
+ )
+ net.add_connection(conn, "in", "out")
+ # Strong drive so the LIF layer actually spikes.
+ torch.manual_seed(seed + 1)
+ pre = torch.bernoulli(0.5 * torch.ones(T, n_in))
+ net.layers["out"].thresh.fill_(-60.0)
+ w_hist, post_hist = [], []
+ for t in range(T):
+ net.run(inputs={"in": pre[t : t + 1].byte()}, time=dt)
+ post_hist.append(net.layers["out"].s.view(-1).float().clone())
+ w_hist.append(conn.w.detach().clone())
+ post = torch.stack(post_hist)
+ assert post.sum() > 0, "post-synaptic layer never spiked; test is vacuous"
+ return w0, pre, post, torch.stack(w_hist), conn
+
+
+class TestPairSTDPMorrison2008:
+ @pytest.mark.parametrize("dt", [1.0, 0.5])
+ @pytest.mark.parametrize("additive", [False, True])
+ def test_postpre_is_additive_pair_stdp(self, dt, additive):
+ w0, pre, post, w_b, conn = _run_pair_rule(PostPre, dt, additive, 0.0, 1.0)
+ w_ref = _pair_stdp_reference(
+ pre,
+ post,
+ w0,
+ 1e-2,
+ 3e-2,
+ dt,
+ 20.0,
+ additive,
+ lambda w: (1.0, 1.0),
+ 0.0,
+ 1.0,
+ )
+ assert (w_b - w_ref).abs().max().item() < TOL
+
+ @pytest.mark.parametrize("additive", [False, True])
+ def test_weight_dependent_is_soft_bounded_pair_stdp(self, additive):
+ wmin, wmax = 0.0, 1.0
+ w0, pre, post, w_b, conn = _run_pair_rule(
+ WeightDependentPostPre, 1.0, additive, wmin, wmax
+ )
+ w_ref = _pair_stdp_reference(
+ pre,
+ post,
+ w0,
+ 1e-2,
+ 3e-2,
+ 1.0,
+ 20.0,
+ additive,
+ lambda w: (w - wmin, wmax - w),
+ wmin,
+ wmax,
+ )
+ assert (w_b - w_ref).abs().max().item() < TOL
+
+ def test_hebbian_both_terms_potentiate(self):
+ w0, pre, post, w_b, conn = _run_pair_rule(Hebbian, 1.0, False, 0.0, 1.0)
+ w = w0.clone()
+ x = torch.zeros(pre.shape[1])
+ y = torch.zeros(post.shape[1])
+ decay = math.exp(-1.0 / 20.0)
+ hist = []
+ for t in range(pre.shape[0]):
+ x = _trace_step(x, pre[t], decay, False)
+ y = _trace_step(y, post[t], decay, False)
+ w = w + 1e-2 * torch.outer(pre[t], y) + 3e-2 * torch.outer(x, post[t])
+ hist.append(w.clamp(0.0, 1.0).clone())
+ w = w.clamp(0.0, 1.0)
+ assert (w_b - torch.stack(hist)).abs().max().item() < TOL
+
+ def test_stdp_window_sign_and_shape(self):
+ # Morrison (2008) eq. (10): pre-before-post potentiates by
+ # F_+ exp(-|dt|/tau_+); post-before-pre depresses by F_- exp(-|dt|/tau_-).
+ # The post neuron is fired by a strong "teacher" input (not ``clamp``,
+ # see the note in the module docstring) and the actual spike times are
+ # read back from the layer.
+ tc = 20.0
+ for delta in (1, 5, 15):
+ for pre_first in (True, False):
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=1, traces=True, tc_trace=tc), "in")
+ net.add_layer(Input(n=1), "teacher")
+ net.add_layer(LIFNodes(n=1, traces=True, tc_trace=tc), "out")
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=torch.zeros(1, 1), # no drive from the plastic synapse
+ nu=(1.0, 1.0),
+ update_rule=PostPre,
+ wmin=-10.0,
+ wmax=10.0,
+ )
+ net.add_connection(conn, "in", "out")
+ net.add_connection(
+ Connection(
+ net.layers["teacher"],
+ net.layers["out"],
+ w=torch.full((1, 1), 100.0),
+ ),
+ "teacher",
+ "out",
+ )
+ T = 40
+ pre = torch.zeros(T, 1, 1)
+ teach = torch.zeros(T, 1, 1)
+ if pre_first:
+ pre[10, 0, 0] = 1
+ teach[10 + delta - 1, 0, 0] = 1 # post fires one step later
+ else:
+ teach[10 - 1, 0, 0] = 1
+ pre[10 + delta, 0, 0] = 1
+ post_times = []
+ for t in range(T):
+ net.run(
+ inputs={"in": pre[t].byte(), "teacher": teach[t].byte()}, time=1
+ )
+ if net.layers["out"].s.any():
+ post_times.append(t)
+ assert post_times == [10 + delta if pre_first else 10], post_times
+ change = conn.w.item()
+ expected = math.exp(-delta / tc) * (1 if pre_first else -1)
+ assert abs(change - expected) < 1e-5, (delta, pre_first, change)
+
+ def test_diehl_and_cook_2015_rule_is_not_postpre(self):
+ # Diehl & Cook (2015), Methods "Learning": weights change only on
+ # postsynaptic spikes, Delta w = eta (x_pre - x_tar) (w_max - w)^mu.
+ # BindsNET's ``PostPre`` (used by ``DiehlAndCook2015``) instead has no
+ # x_tar term and a depression term on presynaptic spikes. Pin that fact
+ # so the deviation stays documented: a lone presynaptic spike changes
+ # the weight under PostPre (it would not under Diehl & Cook's rule).
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=1, traces=True), "in")
+ net.add_layer(LIFNodes(n=1, traces=True), "out")
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=torch.full((1, 1), 0.5),
+ nu=(1.0, 1.0),
+ update_rule=PostPre,
+ wmin=0.0,
+ wmax=1.0,
+ )
+ net.add_connection(conn, "in", "out")
+ net.layers["out"].x.fill_(0.3) # a lingering post-synaptic trace
+ pre = torch.zeros(3, 1, 1)
+ pre[1, 0, 0] = 1
+ net.run(inputs={"in": pre.byte()}, time=3)
+ assert conn.w.item() < 0.5 # depressed by the pre spike alone
+
+
+class TestMulticompartmentRulesMatchClassic:
+ """The ``MCC_learning`` PostPre / Hebbian must apply the same equations as the
+ classic rules (at ``dt = 1``, where the MCC rule's extra ``dt`` factor is 1)."""
+
+ @pytest.mark.parametrize(
+ "rule_pair", [(PostPre, MCC_learning.PostPre), (Hebbian, MCC_learning.Hebbian)]
+ )
+ def test_same_weights_step_by_step(self, rule_pair):
+ classic, mcc = rule_pair
+ torch.manual_seed(0)
+ w0 = 0.5 * torch.rand(12, 6)
+ torch.manual_seed(1)
+ pre = torch.bernoulli(0.5 * torch.ones(40, 12)).byte()
+
+ def build(use_mcc):
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=12, traces=True), "in")
+ net.add_layer(LIFNodes(n=6, traces=True, thresh=-60.0), "out")
+ if use_mcc:
+ conn = MulticompartmentConnection(
+ net.layers["in"],
+ net.layers["out"],
+ device="cpu",
+ pipeline=[
+ Weight(
+ "w",
+ w0.clone(),
+ range=[0.0, 1.0],
+ nu=(1e-2, 3e-2),
+ learning_rule=mcc,
+ )
+ ],
+ )
+ else:
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=w0.clone(),
+ nu=(1e-2, 3e-2),
+ update_rule=classic,
+ wmin=0.0,
+ wmax=1.0,
+ )
+ net.add_connection(conn, "in", "out")
+ return net, conn
+
+ net_a, conn_a = build(False)
+ net_b, conn_b = build(True)
+ for t in range(40):
+ net_a.run(inputs={"in": pre[t : t + 1]}, time=1)
+ net_b.run(inputs={"in": pre[t : t + 1]}, time=1)
+ wa, wb = conn_a.w, conn_b.pipeline[0].value
+ assert (wa - wb).abs().max().item() < TOL, t
+
+
+class TestRmaxVasilaki2009:
+ @staticmethod
+ def _build(tc_c, dt=1.0, seed=0, n_in=10, n_out=4):
+ torch.manual_seed(seed)
+ net = Network(dt=dt)
+ net.add_layer(
+ Input(n=n_in, traces=True, traces_additive=True, tc_trace=10.0), "in"
+ )
+ net.add_layer(SRM0Nodes(n=n_out, tc_decay=10.0, thresh=-55.0), "out")
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=2.0 * torch.rand(n_in, n_out),
+ nu=1e-2,
+ update_rule=Rmax,
+ wmin=-10.0,
+ wmax=10.0,
+ tc_c=tc_c,
+ tc_e_trace=25.0,
+ )
+ net.add_connection(conn, "in", "out")
+ return net, conn
+
+ def test_srm0_escape_rate_eq13(self):
+ # rho = rho_0 exp((u - theta)/Delta u), spike probability 1 - exp(-rho dt).
+ net, conn = self._build(5.0, dt=0.5)
+ layer = net.layers["out"]
+ torch.manual_seed(3)
+ net.run(
+ inputs={"in": torch.bernoulli(0.5 * torch.ones(5, 10)).byte()},
+ time=2.5,
+ reward=0.0,
+ )
+ rho = layer.rho_0 * torch.exp((layer.v - layer.thresh) / layer.d_thresh)
+ assert torch.allclose(layer.rho, rho)
+ assert torch.allclose(layer.s_prob, 1.0 - torch.exp(-rho * 0.5))
+
+ @pytest.mark.parametrize("tc_c", [0.0, 5.0, 1e9])
+ @pytest.mark.parametrize("dt", [1.0, 0.5])
+ def test_eligibility_and_update_eq7_eq8(self, tc_c, dt):
+ net, conn = self._build(tc_c, dt=dt)
+ src, tgt = net.layers["in"], net.layers["out"]
+ w_ref = conn.w.detach().clone()
+ e = torch.zeros_like(w_ref)
+ torch.manual_seed(4)
+ T = 30
+ pre = torch.bernoulli(0.5 * torch.ones(T, 10)).byte()
+ rewards = torch.randn(T)
+ for t in range(T):
+ net.run(inputs={"in": pre[t : t + 1]}, time=dt, reward=rewards[t].item())
+ # Observed post-synaptic factors of this step (Y_i and rho_i dt as p_i).
+ Y = tgt.s.view(-1).float()
+ p = tgt.s_prob.view(-1)
+ eps = src.x.view(-1) # sum_f epsilon(t - t_j^f): additive pre trace
+ # Eq. (8), forward Euler in dt, with rho dt -> p and tau_c rho -> (tau_c/dt) p.
+ post_factor = Y - p / (1.0 + (tc_c / dt) * p)
+ e = e * (1.0 - dt / 25.0) + torch.outer(eps, post_factor)
+ # Eq. (7): the reward of this step gates the eligibility of this step.
+ w_ref = (w_ref + 1e-2 * rewards[t] * e).clamp(-10.0, 10.0)
+ assert (conn.w - w_ref).abs().max().item() < TOL, t
+
+ def test_tc_c_limits(self):
+ # tau_c = 0: post factor is Y - p (policy gradient, mean-zero in
+ # expectation); tau_c -> inf: post factor is Y (naive Hebbian).
+ net, conn = self._build(0.0)
+ rule = conn.update_rule
+ tgt = net.layers["out"]
+ tgt.s = torch.tensor([[1, 0, 1, 0]], dtype=torch.bool)
+ tgt.s_prob = torch.tensor([[0.2, 0.4, 0.6, 0.8]])
+ net.layers["in"].x = torch.ones(1, 10)
+ conn.update(reward=1.0, learning=True)
+ expected = torch.tensor([1.0, 0.0, 1.0, 0.0]) - tgt.s_prob.view(-1)
+ assert torch.allclose(rule.eligibility_trace[0], expected)
+
+ net, conn = self._build(1e12)
+ rule = conn.update_rule
+ tgt = net.layers["out"]
+ tgt.s = torch.tensor([[1, 0, 1, 0]], dtype=torch.bool)
+ tgt.s_prob = torch.tensor([[0.2, 0.4, 0.6, 0.8]])
+ net.layers["in"].x = torch.ones(1, 10)
+ conn.update(reward=1.0, learning=True)
+ assert torch.allclose(
+ rule.eligibility_trace[0], torch.tensor([1.0, 0.0, 1.0, 0.0]), atol=1e-6
+ )
diff --git a/test/network/test_mstdp_florian.py b/test/network/test_mstdp_florian.py
index c675709d..478a74b7 100644
--- a/test/network/test_mstdp_florian.py
+++ b/test/network/test_mstdp_florian.py
@@ -11,13 +11,18 @@
both rules against a from-scratch Florian reference (point eligibility, weight
update, batch handling, STDP sign, and the ``zero_lag`` timing option).
-Reference (i = presynaptic/source, j = postsynaptic/target):
- P+_i(t) = P+_i(t-dt) * exp(-dt/tc_plus) + a_plus * pre_i(t)
- P-_j(t) = P-_j(t-dt) * exp(-dt/tc_minus) + a_minus * post_j(t)
- zeta_ij(t) = P+_i(t) * post_j(t) + pre_i(t) * P-_j(t)
- MSTDP: dw_ij(t) = nu * r(t) * zeta_ij(t)
- MSTDPET: e_ij(t) = e_ij(t-dt)*exp(-dt/tc_e) + zeta_ij(t-dt)/tc_e
- dw_ij(t) = nu * dt * r(t) * e_ij(t)
+Reference (i = presynaptic/source, j = postsynaptic/target), Florian (2007)
+discrete-time equations; the paper's eq. numbers are given on the right:
+ P+_i(t) = P+_i(t-dt) * exp(-dt/tc_plus) + a_plus * pre_i(t) (3.11)
+ P-_j(t) = P-_j(t-dt) * exp(-dt/tc_minus) + a_minus * post_j(t) (3.12)
+ zeta_ij(t) = P+_i(t) * post_j(t) + pre_i(t) * P-_j(t) (3.10)
+ MSTDP: w_ij(t+dt) = w_ij(t) + nu * r(t+dt) * zeta_ij(t) (3.9)
+ MSTDPET: e_ij(t+dt) = e_ij(t)*exp(-dt/tc_e) + zeta_ij(t)/tc_e (2.8)
+ w_ij(t+dt) = w_ij(t) + nu * dt * r(t+dt) * e_ij(t+dt) (2.7)
+i.e. the reward passed at a simulation step multiplies the eligibility built
+from the previous step's spikes (``lag=True`` below, the library default).
+Defaults tc_plus = tc_minus = 20 ms, tc_e = 25 ms, a_plus = 1, a_minus = -1
+are the paper's Sect. 4.1 values.
"""
import itertools
From 3baf31ca623d200c4548eaec11642a193c9787de Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Sun, 6 Sep 2026 11:54:05 -0400
Subject: [PATCH 04/14] fix: clamp/unclamp spikes now enter the spike traces;
document rule sources
- Nodes.forward applies Network.run's clamp/unclamp before the trace update
(previously applied after, so forced spikes never reached the traces the
learning rules read; affected supervised_mnist.py)
- Tests: TestClampEntersTraces; STDP window test now also driven by clamp
- README section 'Learning rules and their sources'; models_spec.rst clamp and
known-deviations sections; CLAUDE.md rule/validation notes; CHANGELOG
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 6 ++
CLAUDE.md | 31 ++++++++
README.md | 18 +++++
bindsnet/network/network.py | 40 ++++++-----
bindsnet/network/nodes.py | 16 +++++
docs/source/models_spec.rst | 28 ++++++--
test/network/test_learning_rule_specs.py | 90 ++++++++++++++++++++----
7 files changed, 195 insertions(+), 34 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b587b89..197779aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,6 +53,12 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
whereas the default one-step lag is Florian's discrete-time eq. (3.9).
### Fixed
+- `network.run(clamp=...)` / `unclamp` are now applied inside `Nodes.forward` before
+ the spike trace is updated, so a forced spike leaves a trace (and a suppressed one
+ does not). Previously the clamp was applied after the trace update, so clamped
+ spikes entered the same-step potentiation term of STDP rules but never the trace
+ used by later depression terms (affected `examples/mnist/supervised_mnist.py`).
+ Pinned by `TestClampEntersTraces` and the clamp-driven STDP window test.
- `network.to(device)` crashed on any `MulticompartmentConnection` (used by
`DiehlAndCook2015`) with `_apply() takes 2 positional arguments but 3 were
given`; `AbstractMulticompartmentConnection._apply` now accepts `recurse`.
diff --git a/CLAUDE.md b/CLAUDE.md
index b529463b..6796e283 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,3 +25,34 @@ conda run -n bindsNET python -m pytest -q test/network/test_learning.py
## Committing
Use `sc "message"` instead of `git commit` (see the user's global instructions).
+
+## Learning rules: sources and how to validate
+
+Each rule is pinned to its paper's equations by a from-scratch reference test.
+Read these before touching any rule:
+
+| Rule | Paper and equations | Test |
+|---|---|---|
+| `PostPre`, `WeightDependentPostPre`, `Hebbian` | Morrison, Diesmann & Gerstner 2008, *Biol. Cybern.* 98:459, eqs. 11-14 (traces Sect. 2.3) | `test/network/test_learning_rule_specs.py` |
+| `MSTDP`, `MSTDPET` | Florian 2007, *Neural Comput.* 19:1468, eqs. 3.9-3.12, 2.7-2.8 | `test/network/test_mstdp_florian.py` |
+| `Rmax` | Vasilaki et al. 2009, *PLoS Comput. Biol.* 5:e1000586, eqs. 7, 8, 13 | `test/network/test_learning_rule_specs.py` |
+
+Facts that were wrong in docstrings once and are now fixed (do not reintroduce):
+- `MSTDP` default `zero_lag=False` **is** Florian's discrete eq. 3.9 (reward at a
+ step multiplies the previous step's eligibility). `zero_lag=True` is the
+ un-lagged variant, not "exact Florian".
+- `Rmax` `tc_c = 0` is strict policy gradient; `inf` is naive Hebbian.
+- `clamp` spikes enter the trace (applied in `Nodes.forward` before the trace update).
+
+Known, deliberately unchanged deviations (numerics would change; ask first):
+- `MCC_learning.PostPre` multiplies updates by `dt`; classic `PostPre` does not.
+- `PostPre` is not Diehl & Cook 2015's rule (no `x_tar`, has pre-spike depression).
+
+## Performance changes: the rule
+
+Any change to a per-timestep path must ship with a test that pins it to the
+formula it replaced (`test/network/test_perf_equivalence.py`). Before claiming
+"no change in results", run the same seeded networks on the old code (a git
+worktree of the previous commit) and the new code and compare with
+`torch.equal`; only batch>1 summation-order differences (about 1e-7) are
+acceptable, and must be stated. Benchmark: `examples/benchmark/hot_path_bench.py`.
diff --git a/README.md b/README.md
index 16f22536..67d38386 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,24 @@ There are a number of optional command-line arguments which can be passed in, in
A number of other examples are available in the `examples` directory that are meant to showcase BindsNET's functionality. Take a look, and let us know what you think!
+## Learning rules and their sources
+
+Every weight-changing rule is validated step by step against the equations of the
+paper it implements; the equations, with their numbers, are listed in
+[`docs/source/models_spec.rst`](docs/source/models_spec.rst) and pinned by tests.
+
+| Rule | Paper | Test |
+|---|---|---|
+| `PostPre`, `WeightDependentPostPre`, `Hebbian` | Morrison, Diesmann & Gerstner (2008), eqs. 11-14 | `test/network/test_learning_rule_specs.py` |
+| `MSTDP`, `MSTDPET` | Florian (2007), eqs. 3.9-3.12 and 2.7-2.8 | `test/network/test_mstdp_florian.py` |
+| `Rmax` | Vasilaki et al. (2009), eqs. 7, 8, 13 | `test/network/test_learning_rule_specs.py` |
+
+Two points that are easy to get wrong: the reward passed to `network.run` at a step
+multiplies the eligibility of the *previous* step (that is Florian's discrete rule,
+`zero_lag=False`); and `PostPre` is the standard pair-based STDP, not the Diehl & Cook
+(2015) rule, even though `DiehlAndCook2015` uses it. Spikes forced with the `clamp`
+argument of `network.run` enter the spike traces like any other spike.
+
## Running the tests
Issue the following to run the tests:
diff --git a/bindsnet/network/network.py b/bindsnet/network/network.py
index 0359e9c1..1c17fd59 100644
--- a/bindsnet/network/network.py
+++ b/bindsnet/network/network.py
@@ -267,10 +267,15 @@ def run(
:param Dict[str, torch.Tensor] clamp: Mapping of layer names to boolean masks if
neurons should be clamped to spiking. The ``Tensor``s have shape
- ``[n_neurons]`` or ``[time, n_neurons]``.
+ ``[n_neurons]`` or ``[time, n_neurons]``. A clamped spike is a real spike
+ for everything downstream: it is propagated through connections, enters
+ the layer's spike trace (``x``) and therefore the learning rules, and is
+ recorded by monitors. It does not reset the neuron's voltage or start its
+ refractory period.
:param Dict[str, torch.Tensor] unclamp: Mapping of layer names to boolean masks
if neurons should be clamped to not spiking. The ``Tensor``s should have
- shape ``[n_neurons]`` or ``[time, n_neurons]``.
+ shape ``[n_neurons]`` or ``[time, n_neurons]``. A suppressed spike is
+ removed before the trace update, so it leaves no trace.
:param Dict[str, torch.Tensor] injects_v: Mapping of layer names to boolean
masks if neurons should be added voltage. The ``Tensor``s should have shape
``[n_neurons]`` or ``[time, n_neurons]``.
@@ -403,6 +408,21 @@ def run(
else:
self.layers[l].v += inject_v[t]
+ # Spike clamps for this step. The layer applies them inside
+ # ``forward`` before updating its spike traces, so forced /
+ # suppressed spikes are seen by the traces and hence by the
+ # learning rules (see ``Nodes.forward``).
+ clamp = clamps.get(l, None)
+ if clamp is not None:
+ self.layers[l]._clamp = (
+ clamp if clamp.ndimension() == 1 else clamp[t]
+ )
+ unclamp = unclamps.get(l, None)
+ if unclamp is not None:
+ self.layers[l]._unclamp = (
+ unclamp if unclamp.ndimension() == 1 else unclamp[t]
+ )
+
if l in current_inputs:
self.layers[l].forward(x=current_inputs[l])
else:
@@ -412,22 +432,6 @@ def run(
)
)
- # Clamp neurons to spike.
- clamp = clamps.get(l, None)
- if clamp is not None:
- if clamp.ndimension() == 1:
- self.layers[l].s[:, clamp] = 1
- else:
- self.layers[l].s[:, clamp[t]] = 1
-
- # Clamp neurons not to spike.
- unclamp = unclamps.get(l, None)
- if unclamp is not None:
- if unclamp.ndimension() == 1:
- self.layers[l].s[:, unclamp] = 0
- else:
- self.layers[l].s[:, unclamp[t]] = 0
-
for c in self.connections:
flad_m = False
if A_Minus != None and ((isinstance(A_Minus, float)) or (c in A_Minus)):
diff --git a/bindsnet/network/nodes.py b/bindsnet/network/nodes.py
index 694b1e8f..1fcdd3af 100644
--- a/bindsnet/network/nodes.py
+++ b/bindsnet/network/nodes.py
@@ -12,6 +12,13 @@ class Nodes(torch.nn.Module):
Abstract base class for groups of neurons.
"""
+ # Per-step spike clamps set by ``Network.run`` (``clamp`` / ``unclamp``
+ # keyword arguments). They are applied in ``forward`` *before* the spike
+ # traces are updated, so a forced or suppressed spike is reflected in the
+ # traces that the learning rules read. Cleared after use.
+ _clamp: Optional[torch.Tensor] = None
+ _unclamp: Optional[torch.Tensor] = None
+
def __init__(
self,
n: Optional[int] = None,
@@ -93,6 +100,15 @@ def forward(self, x: torch.Tensor) -> None:
:param x: Inputs to the layer.
"""
+ # Force / suppress spikes requested for this step (see ``Network.run``),
+ # before the traces below see ``self.s``.
+ if self._clamp is not None:
+ self.s[:, self._clamp] = 1
+ self._clamp = None
+ if self._unclamp is not None:
+ self.s[:, self._unclamp] = 0
+ self._unclamp = None
+
if self.traces:
# Decay and set spike traces.
self.x.mul_(self.trace_decay)
diff --git a/docs/source/models_spec.rst b/docs/source/models_spec.rst
index e5988492..3f7c07a5 100644
--- a/docs/source/models_spec.rst
+++ b/docs/source/models_spec.rst
@@ -231,9 +231,29 @@ pre-synaptic trace, where :math:`Y_i` is the post-synaptic spike and
:math:`g'/g = 1/\Delta u` of eq. (8) is absorbed into ``nu``. Validated in
``test/network/test_learning_rule_specs.py``.
+Spike clamps and traces
+~~~~~~~~~~~~~~~~~~~~~~~
+``network.run(..., clamp={layer: mask}, unclamp={layer: mask})`` forces or suppresses
+spikes for a step. The clamp is applied inside ``Nodes.forward`` *before* the spike
+trace ``x`` is updated, so a forced spike leaves a trace and a suppressed spike does
+not; the learning rules therefore treat clamped spikes exactly like natural ones. A
+forced spike does not reset the neuron's voltage or start its refractory period.
+(Before September 2026 the clamp was applied after the trace update, so clamped
+spikes entered the same-step potentiation term but never the trace.)
+
+Known deviations from the papers
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+These are documented rather than changed, because changing them alters results:
+
+* ``PostPre`` is not the Diehl & Cook (2015) rule (see the note above), although the
+ ``DiehlAndCook2015`` model uses it and reproduces the published accuracy with it.
+* ``bindsnet.learning.MCC_learning.PostPre`` (the multicompartment version) multiplies
+ each update by the simulation step ``dt``; the classic ``PostPre`` and Morrison et
+ al. (2008) eqs. (13)-(14) do not. The two agree only at ``dt = 1``.
+
.. note::
- Where this page summarizes a rule "see source", the equations were not reproduced here
- to avoid mis-stating constants; consult ``bindsnet/learning/learning.py`` for the
- authoritative form. If an implementation deviates from a textbook model, the code is
- the specification.
+ Where this page does not reproduce an equation, consult
+ ``bindsnet/learning/learning.py`` for the authoritative form. If an implementation
+ deviates from a textbook model, the code is the specification, and the deviation
+ is listed above.
diff --git a/test/network/test_learning_rule_specs.py b/test/network/test_learning_rule_specs.py
index f53c706b..03a117b2 100644
--- a/test/network/test_learning_rule_specs.py
+++ b/test/network/test_learning_rule_specs.py
@@ -59,11 +59,11 @@
forward-Euler decay and the per-step spike probability
:math:`p = 1 - e^{-\\rho\\,\\delta t}` in place of :math:`\\rho\\,\\delta t`.
-Known limitation (not a rule defect): spikes forced with the ``clamp`` argument of
-``Network.run`` are applied after ``Nodes.forward`` has already updated the layer's
-spike trace, so a clamped spike enters the same-step potentiation term of the rule
-but never the trace used by later depression terms. The STDP-window test below
-therefore fires the post-synaptic neuron through a strong teacher input.
+Spikes forced with the ``clamp`` argument of ``Network.run`` (and spikes removed with
+``unclamp``) are applied inside ``Nodes.forward`` before the spike trace is updated,
+so they are seen by the learning rules exactly like naturally generated spikes.
+``TestClampEntersTraces`` pins that; the STDP-window test fires the post-synaptic
+neuron through a teacher input and repeats it with ``clamp``.
"""
import math
@@ -205,12 +205,12 @@ def test_hebbian_both_terms_potentiate(self):
w = w.clamp(0.0, 1.0)
assert (w_b - torch.stack(hist)).abs().max().item() < TOL
- def test_stdp_window_sign_and_shape(self):
+ @pytest.mark.parametrize("drive", ["teacher", "clamp"])
+ def test_stdp_window_sign_and_shape(self, drive):
# Morrison (2008) eq. (10): pre-before-post potentiates by
# F_+ exp(-|dt|/tau_+); post-before-pre depresses by F_- exp(-|dt|/tau_-).
- # The post neuron is fired by a strong "teacher" input (not ``clamp``,
- # see the note in the module docstring) and the actual spike times are
- # read back from the layer.
+ # The post neuron is fired either by a strong "teacher" input or by the
+ # ``clamp`` argument of ``Network.run``; both must give the same window.
tc = 20.0
for delta in (1, 5, 15):
for pre_first in (True, False):
@@ -248,9 +248,25 @@ def test_stdp_window_sign_and_shape(self):
pre[10 + delta, 0, 0] = 1
post_times = []
for t in range(T):
- net.run(
- inputs={"in": pre[t].byte(), "teacher": teach[t].byte()}, time=1
- )
+ if drive == "teacher":
+ net.run(
+ inputs={"in": pre[t].byte(), "teacher": teach[t].byte()},
+ time=1,
+ )
+ else:
+ # Force the post spike at the step the teacher would
+ # have fired it (one step after the teacher spike).
+ force = (
+ teach[t - 1, 0].bool() if t > 0 else torch.zeros(1).bool()
+ )
+ net.run(
+ inputs={
+ "in": pre[t].byte(),
+ "teacher": torch.zeros(1, 1).byte(),
+ },
+ time=1,
+ clamp={"out": force},
+ )
if net.layers["out"].s.any():
post_times.append(t)
assert post_times == [10 + delta if pre_first else 10], post_times
@@ -424,3 +440,53 @@ def test_tc_c_limits(self):
assert torch.allclose(
rule.eligibility_trace[0], torch.tensor([1.0, 0.0, 1.0, 0.0]), atol=1e-6
)
+
+
+class TestClampEntersTraces:
+ """``clamp`` / ``unclamp`` spikes must be reflected in the spike traces."""
+
+ @staticmethod
+ def _layer_net():
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=3), "in")
+ net.add_layer(LIFNodes(n=3, traces=True, tc_trace=20.0), "out")
+ net.add_connection(
+ Connection(net.layers["in"], net.layers["out"], w=torch.zeros(3, 3)),
+ "in",
+ "out",
+ )
+ return net
+
+ def test_clamped_spike_sets_trace(self):
+ net = self._layer_net()
+ mask = torch.tensor([True, False, False])
+ net.run(inputs={"in": torch.zeros(1, 3).byte()}, time=1, clamp={"out": mask})
+ assert torch.equal(net.layers["out"].s.view(-1), mask)
+ assert torch.equal(net.layers["out"].x.view(-1), mask.float())
+ # And it decays afterwards like any other spike.
+ net.run(inputs={"in": torch.zeros(1, 3).byte()}, time=1)
+ assert torch.allclose(
+ net.layers["out"].x.view(-1), mask.float() * math.exp(-1.0 / 20.0)
+ )
+
+ def test_time_indexed_clamp(self):
+ net = self._layer_net()
+ mask = torch.zeros(4, 3, dtype=torch.bool)
+ mask[2, 1] = True
+ net.run(inputs={"in": torch.zeros(4, 3).byte()}, time=4, clamp={"out": mask})
+ x = net.layers["out"].x.view(-1)
+ assert x[1].item() == pytest.approx(math.exp(-1.0 / 20.0))
+ assert x[0].item() == 0.0 and x[2].item() == 0.0
+
+ def test_unclamped_spike_leaves_no_trace(self):
+ net = self._layer_net()
+ net.layers["out"].thresh.fill_(-64.0)
+ net.run(
+ inputs={"in": torch.ones(1, 3).byte()},
+ time=1,
+ injects_v={"out": 100.0 * torch.ones(3)},
+ unclamp={"out": torch.tensor([False, False, True])},
+ )
+ s = net.layers["out"].s.view(-1)
+ assert s[0] and s[1] and not s[2]
+ assert torch.equal(net.layers["out"].x.view(-1), s.float())
From 17184557265512747a8e28e5254e34b29c46f16c Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Sun, 6 Sep 2026 12:07:33 -0400
Subject: [PATCH 05/14] learning: add Diehl & Cook (2015) rule, drop dt factor
from MCC PostPre, move rule docs
- bindsnet.learning.DiehlAndCook + MCC_learning.DiehlAndCook: post-spike-only
dw = eta (x_pre - x_tar)(w_max - w)^mu (Diehl & Cook 2015 Sect. 2.3);
DiehlAndCook2015(learning_rule=..., learning_rule_kwargs=...) opt-in, default
PostPre unchanged
- MCC_learning.PostPre no longer scales updates by dt (per-spike increment, as
the classic rule and Morrison 2008 eqs 13-14)
- Weight feature forwards extra kwargs to its learning rule
- Tests: DiehlAndCook vs paper reference (mu, x_tar, both trace modes), MCC
vs classic at dt 1.0 and 0.5, dt-independence of MCC PostPre, model opt-in
- Docs: bindsnet/learning/README.md (moved from top-level README), models_spec,
CLAUDE.md, CHANGELOG
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 15 ++
CLAUDE.md | 12 +-
README.md | 19 +-
bindsnet/learning/MCC_learning.py | 164 ++++++++++++++----
bindsnet/learning/README.md | 73 ++++++++
bindsnet/learning/__init__.py | 2 +
bindsnet/learning/learning.py | 97 +++++++++++
bindsnet/models/models.py | 25 ++-
bindsnet/network/topology_features.py | 11 +-
docs/source/models_spec.rst | 36 +++-
test/network/test_learning_rule_specs.py | 212 ++++++++++++++++++++++-
test/network/test_perf_equivalence.py | 6 +-
12 files changed, 599 insertions(+), 73 deletions(-)
create mode 100644 bindsnet/learning/README.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 197779aa..194dc3e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,22 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
- `CHANGELOG.md`.
- `examples/breakout/README.md` documenting the `trained_shallow_ANN.pt` provenance.
+### Added
+- `bindsnet.learning.DiehlAndCook` and `bindsnet.learning.MCC_learning.DiehlAndCook`:
+ the post-spike-only STDP of Diehl & Cook (2015), Sect. 2.3,
+ `dw = eta (x_pre - x_tar)(w_max - w)^mu` (keyword arguments `x_tar`, `mu`).
+ `DiehlAndCook2015(learning_rule=..., learning_rule_kwargs=...)` selects it; the
+ model's default stays `PostPre` so published results are unchanged.
+- Multicompartment `Weight` features forward extra keyword arguments to their
+ learning rule.
+- `bindsnet/learning/README.md`: rules, source papers, equation numbers, tests and
+ pitfalls (moved from the top-level README).
+
### Changed
+- `MCC_learning.PostPre` no longer multiplies its update by the simulation step
+ `dt`; like the classic `PostPre` and Morrison et al. (2008) eqs. 13-14 it is a
+ per-spike increment. Identical at `dt = 1`; at other steps the effective learning
+ rate is now `nu` instead of `nu * dt`.
- README Python requirement aligned to `>=3.11,<3.14`; added a reproducible-install note.
- `pyproject.toml` version bumped to 0.3.4 to match the released tag.
- Performance pass on the per-timestep hot paths (numerics unchanged; every item
diff --git a/CLAUDE.md b/CLAUDE.md
index 6796e283..0825da90 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,6 +34,7 @@ Read these before touching any rule:
| Rule | Paper and equations | Test |
|---|---|---|
| `PostPre`, `WeightDependentPostPre`, `Hebbian` | Morrison, Diesmann & Gerstner 2008, *Biol. Cybern.* 98:459, eqs. 11-14 (traces Sect. 2.3) | `test/network/test_learning_rule_specs.py` |
+| `DiehlAndCook` (classic and MCC) | Diehl & Cook 2015, *Front. Comput. Neurosci.* 9:99, Sect. 2.3: post-spike-only, dw = eta (x_pre - x_tar)(w_max - w)^mu | `test/network/test_learning_rule_specs.py` |
| `MSTDP`, `MSTDPET` | Florian 2007, *Neural Comput.* 19:1468, eqs. 3.9-3.12, 2.7-2.8 | `test/network/test_mstdp_florian.py` |
| `Rmax` | Vasilaki et al. 2009, *PLoS Comput. Biol.* 5:e1000586, eqs. 7, 8, 13 | `test/network/test_learning_rule_specs.py` |
@@ -44,9 +45,14 @@ Facts that were wrong in docstrings once and are now fixed (do not reintroduce):
- `Rmax` `tc_c = 0` is strict policy gradient; `inf` is naive Hebbian.
- `clamp` spikes enter the trace (applied in `Nodes.forward` before the trace update).
-Known, deliberately unchanged deviations (numerics would change; ask first):
-- `MCC_learning.PostPre` multiplies updates by `dt`; classic `PostPre` does not.
-- `PostPre` is not Diehl & Cook 2015's rule (no `x_tar`, has pre-spike depression).
+- Pair STDP rules carry no `dt` factor (the MCC `PostPre` used to; removed
+ 2026-09-06). `MSTDPET` keeps the paper's `dt` (Florian eq. 2.7).
+- `PostPre` is not Diehl & Cook 2015's rule; the paper's rule is `DiehlAndCook`.
+ `DiehlAndCook2015` keeps `PostPre` as default (published replication); opt in
+ with `learning_rule=MCC_learning.DiehlAndCook`.
+
+User-facing summary of all this: `bindsnet/learning/README.md` (keep it in sync
+with `docs/source/models_spec.rst`).
## Performance changes: the rule
diff --git a/README.md b/README.md
index 67d38386..83fb2cbf 100644
--- a/README.md
+++ b/README.md
@@ -88,21 +88,10 @@ A number of other examples are available in the `examples` directory that are me
## Learning rules and their sources
-Every weight-changing rule is validated step by step against the equations of the
-paper it implements; the equations, with their numbers, are listed in
-[`docs/source/models_spec.rst`](docs/source/models_spec.rst) and pinned by tests.
-
-| Rule | Paper | Test |
-|---|---|---|
-| `PostPre`, `WeightDependentPostPre`, `Hebbian` | Morrison, Diesmann & Gerstner (2008), eqs. 11-14 | `test/network/test_learning_rule_specs.py` |
-| `MSTDP`, `MSTDPET` | Florian (2007), eqs. 3.9-3.12 and 2.7-2.8 | `test/network/test_mstdp_florian.py` |
-| `Rmax` | Vasilaki et al. (2009), eqs. 7, 8, 13 | `test/network/test_learning_rule_specs.py` |
-
-Two points that are easy to get wrong: the reward passed to `network.run` at a step
-multiplies the eligibility of the *previous* step (that is Florian's discrete rule,
-`zero_lag=False`); and `PostPre` is the standard pair-based STDP, not the Diehl & Cook
-(2015) rule, even though `DiehlAndCook2015` uses it. Spikes forced with the `clamp`
-argument of `network.run` enter the spike traces like any other spike.
+Each weight-changing rule is validated against the equations of the paper it
+implements. The table of rules, papers, equation numbers and tests, plus the pitfalls
+(reward timing, traces, the Diehl & Cook rule versus `PostPre`), is in
+[`bindsnet/learning/README.md`](bindsnet/learning/README.md).
## Running the tests
diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py
index 9418c02a..de3734ce 100644
--- a/bindsnet/learning/MCC_learning.py
+++ b/bindsnet/learning/MCC_learning.py
@@ -30,6 +30,18 @@ def _dense_outer_update_ok(rule, w: torch.Tensor) -> bool:
)
+def _batch_outer(a: torch.Tensor, b: torch.Tensor, reduction) -> torch.Tensor:
+ # language=rst
+ """
+ ``reduction(bmm(a[:, :, None], b[:, None, :]), dim=0)``: the batch of outer
+ products of ``a`` (``[batch, n]``) and ``b`` (``[batch, m]``) reduced over the
+ batch. For the two default reductions this is the single matmul ``a^T b``.
+ """
+ if reduction in (torch.squeeze, torch.sum):
+ return a.t() @ b
+ return reduction(torch.bmm(a.unsqueeze(2), b.unsqueeze(1)), dim=0)
+
+
class MCC_LearningRule(ABC):
# language=rst
"""
@@ -168,7 +180,9 @@ class PostPre(MCC_LearningRule):
# language=rst
"""
Simple STDP rule involving both pre- and post-synaptic spiking activity. By default,
- pre-synaptic update is negative and the post-synaptic update is positive.
+ pre-synaptic update is negative and the post-synaptic update is positive. Same
+ equations as ``bindsnet.learning.PostPre`` (Morrison et al. 2008, eqs. 13-14): the
+ update is a per-spike increment and does not depend on the simulation step ``dt``.
"""
def __init__(
@@ -250,20 +264,19 @@ def _connection_update(self, **kwargs) -> None:
if self.average_update == 0 and _dense_outer_update_ok(self, w):
# Fused path: ``w += alpha * s^T @ x`` folds the outer product, the
- # batch reduction, the ``dt`` scaling and the in-place update into
- # one ``addmm_`` call, so no ``[batch, source.n, target.n]``
- # temporary is allocated. The learning rate is applied to the
- # ``[batch, n]`` factor first (as in the un-fused formula) and spikes
- # are exactly 0/1, so for batch size 1 the result is bit-identical.
- dt = float(self.connection.dt)
+ # batch reduction and the in-place update into one ``addmm_`` call,
+ # so no ``[batch, source.n, target.n]`` temporary is allocated. The
+ # learning rate is applied to the ``[batch, n]`` factor first (as in
+ # the un-fused formula) and spikes are exactly 0/1, so for batch
+ # size 1 the result is bit-identical.
if self.nu[0]:
source_s = self.source.s.view(batch_size, -1).float()
target_x = self.target.x.view(batch_size, -1) * self.nu[0]
- w.addmm_(source_s.t(), target_x, alpha=-dt)
+ w.addmm_(source_s.t(), target_x, alpha=-1.0)
if self.nu[1]:
target_s = self.target.s.view(batch_size, -1).float() * self.nu[1]
source_x = self.source.x.view(batch_size, -1)
- w.addmm_(source_x.t(), target_s, alpha=dt)
+ w.addmm_(source_x.t(), target_s, alpha=1.0)
super().update()
return
@@ -282,22 +295,15 @@ def _connection_update(self, **kwargs) -> None:
) % self.average_update
if self.continues_update:
- self.feature_value -= (
- torch.mean(self.average_buffer_pre, dim=0) * self.connection.dt
- )
+ self.feature_value -= torch.mean(self.average_buffer_pre, dim=0)
elif self.average_buffer_index_pre == 0:
- self.feature_value -= (
- torch.mean(self.average_buffer_pre, dim=0) * self.connection.dt
- )
+ 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) * self.connection.dt
- ).to_sparse()
+ self.feature_value -= (torch.bmm(source_s, target_x)).to_sparse()
else:
- self.feature_value -= (
- self.reduction(torch.bmm(source_s, target_x), dim=0)
- * self.connection.dt
+ self.feature_value -= self.reduction(
+ torch.bmm(source_s, target_x), dim=0
)
del source_s, target_x
@@ -318,22 +324,15 @@ def _connection_update(self, **kwargs) -> None:
) % self.average_update
if self.continues_update:
- self.feature_value += (
- torch.mean(self.average_buffer_post, dim=0) * self.connection.dt
- )
+ self.feature_value += torch.mean(self.average_buffer_post, dim=0)
elif self.average_buffer_index_post == 0:
- self.feature_value += (
- torch.mean(self.average_buffer_post, dim=0) * self.connection.dt
- )
+ 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) * self.connection.dt
- ).to_sparse()
+ self.feature_value += (torch.bmm(source_x, target_s)).to_sparse()
else:
- self.feature_value += (
- self.reduction(torch.bmm(source_x, target_s), dim=0)
- * self.connection.dt
+ self.feature_value += self.reduction(
+ torch.bmm(source_x, target_s), dim=0
)
del source_x, target_s
@@ -439,6 +438,105 @@ def reset_state_variables(self):
return
+class DiehlAndCook(MCC_LearningRule):
+ # language=rst
+ """
+ Post-synaptic-spike-only STDP of `Diehl & Cook (2015)
+ `_, Sect. 2.3:
+
+ .. math::
+
+ \\Delta w = \\eta\\,(x_\\text{pre} - x_\\text{tar})\\,(w_\\max - w)^\\mu
+
+ applied on every post-synaptic spike, with :math:`x_\\text{pre}` the source
+ spike trace (the paper's trace adds 1 per spike: use ``traces_additive=True``),
+ :math:`x_\\text{tar}` the target trace value, :math:`w_\\max` the upper end of
+ ``range``, and :math:`\\mu` the weight-dependence exponent. Pre-synaptic spikes do
+ not change the weight. Multicompartment twin of
+ ``bindsnet.learning.DiehlAndCook``.
+ """
+
+ def __init__(
+ self,
+ connection: AbstractMulticompartmentConnection,
+ feature_value: Union[torch.Tensor, float, int],
+ range: Optional[Sequence[float]] = None,
+ nu: Optional[Union[float, Sequence[float]]] = None,
+ reduction: Optional[callable] = None,
+ decay: float = 0.0,
+ **kwargs,
+ ) -> None:
+ # language=rst
+ """
+ Constructor for the ``DiehlAndCook`` learning rule.
+
+ :param connection: A ``MulticompartmentConnection`` whose weight feature the
+ rule will modify.
+ :param feature_value: The weight tensor.
+ :param range: ``[w_min, w_max]``; ``w_max`` must be finite.
+ :param nu: Learning rate :math:`\\eta`. A pair is accepted for API symmetry;
+ only the second (post-synaptic) entry is used.
+ :param reduction: Method for reducing parameter updates along the batch
+ dimension.
+ :param decay: Coefficient controlling rate of decay of the weights each iteration.
+
+ Keyword arguments:
+
+ :param float x_tar: Target pre-synaptic trace :math:`x_\\text{tar}` (default 0).
+ :param float mu: Weight-dependence exponent :math:`\\mu` (default 1).
+ """
+ super().__init__(
+ connection=connection,
+ feature_value=feature_value,
+ range=[0.0, 1.0] if range is None else range,
+ nu=nu,
+ reduction=reduction,
+ decay=decay,
+ **kwargs,
+ )
+
+ assert self.source.traces, "Pre-synaptic nodes must record spike traces."
+ assert self.max is not None and np.isfinite(
+ self.max
+ ), "DiehlAndCook needs a finite upper weight bound (range[1] = w_max)."
+
+ if isinstance(connection, MulticompartmentConnection):
+ self.update = self._connection_update
+ else:
+ raise NotImplementedError(
+ "This learning rule is not supported for this Connection type."
+ )
+
+ self.x_tar = float(kwargs.get("x_tar", 0.0))
+ self.mu = float(kwargs.get("mu", 1.0))
+
+ def _connection_update(self, **kwargs) -> None:
+ # language=rst
+ """
+ ``w += eta * ((x_pre - x_tar) outer s_post) * (w_max - w) ** mu`` reduced
+ over the batch.
+ """
+ if not self.nu[1]:
+ super().update()
+ return
+ batch_size = self.source.batch_size
+ w = self.feature_value
+ 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
+ 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()
+ else:
+ w += self.nu[1] * outer * factor
+ super().update()
+
+ def reset_state_variables(self):
+ return
+
+
class MSTDP(MCC_LearningRule):
# language=rst
"""
diff --git a/bindsnet/learning/README.md b/bindsnet/learning/README.md
new file mode 100644
index 00000000..53cd6c7f
--- /dev/null
+++ b/bindsnet/learning/README.md
@@ -0,0 +1,73 @@
+# Learning rules and their sources
+
+Every weight-changing rule in `bindsnet.learning` is validated step by step against
+the equations of the paper it implements. The equations are written out with their
+numbers in [`docs/source/models_spec.rst`](../../docs/source/models_spec.rst)
+("Learning rules") and pinned by the tests in the last column, which drive the rule
+with the spike trains the network actually produces and compare the weights on every
+step against a from-scratch transcription of the paper.
+
+| Rule (`bindsnet.learning`) | Multicompartment twin (`bindsnet.learning.MCC_learning`) | Paper | Test |
+|---|---|---|---|
+| `PostPre` | `PostPre` | Morrison, Diesmann & Gerstner (2008), *Biol. Cybern.* 98:459, eqs. 11-14: additive pair STDP with traces | `test/network/test_learning_rule_specs.py` |
+| `WeightDependentPostPre` | - | same, soft-bounded: F+ = nu_post (w_max - w), F- = nu_pre (w - w_min) | same |
+| `Hebbian` | `Hebbian` | BindsNET's own definition: both trace terms potentiate | same |
+| `DiehlAndCook` | `DiehlAndCook` | Diehl & Cook (2015), *Front. Comput. Neurosci.* 9:99, Sect. 2.3: post-spike-only rule dw = eta (x_pre - x_tar)(w_max - w)^mu | same |
+| `MSTDP`, `MSTDPET` | `MSTDP`, `MSTDPET` | Florian (2007), *Neural Comput.* 19:1468, eqs. 3.9-3.12 and 2.7-2.8 | `test/network/test_mstdp_florian.py` |
+| `Rmax` | - | Vasilaki et al. (2009), *PLoS Comput. Biol.* 5:e1000586, eqs. 7, 8, 13 | `test/network/test_learning_rule_specs.py` |
+
+## Things that are easy to get wrong
+
+- **Reward timing.** The reward passed to `network.run` at a step multiplies the
+ eligibility built from the *previous* step's spikes. That is Florian's discrete
+ equation 3.9 and the default (`zero_lag=False`). `zero_lag=True` is the un-lagged
+ variant.
+- **`PostPre` is not the Diehl & Cook rule.** `PostPre` is standard pair STDP: pre
+ spikes depress by the post trace, post spikes potentiate by the pre trace. The
+ `DiehlAndCook2015` model uses it by default because that is what the published
+ BindsNET replication used. The paper's own rule is `DiehlAndCook` (below).
+- **`Rmax` `tc_c`.** `tc_c = 0` is the strict policy-gradient rule; `tc_c = inf` is
+ naive Hebbian (Vasilaki et al. eq. 8).
+- **Traces.** `traces_additive=False` (default) resets the trace to `trace_scale` on
+ each spike (Morrison's saturating trace with A = 1); `traces_additive=True` adds
+ `trace_scale` per spike (the accumulating trace, which is what Diehl & Cook use).
+- **Clamped spikes count.** Spikes forced with `network.run(clamp=...)` enter the
+ spike trace and the learning rules like any other spike; `unclamp` removes a
+ spike before the trace sees it.
+- **No `dt` factor in pair STDP.** `PostPre`, `WeightDependentPostPre`, `Hebbian`
+ and `DiehlAndCook` are per-spike increments; the same spike pair changes the
+ weight by the same amount at any simulation step. (`MSTDPET` does carry the
+ paper's `dt` factor, eq. 2.7.)
+
+## Using the Diehl & Cook (2015) rule
+
+```python
+from bindsnet.learning import DiehlAndCook
+from bindsnet.network.topology import Connection
+
+conn = Connection(source, target, nu=(0.0, 1e-2), update_rule=DiehlAndCook,
+ wmin=0.0, wmax=1.0, x_tar=0.4, mu=1.0)
+```
+
+`source` must record traces; use `traces_additive=True` on the source layer for the
+paper's accumulating trace. Only the second learning rate (post-synaptic) is used.
+`wmax` must be finite. The paper does not give numbers for `x_tar` and `mu`; the
+defaults (0 and 1) are BindsNET's, so set them for your experiment.
+
+With the `DiehlAndCook2015` model:
+
+```python
+from bindsnet.learning.MCC_learning import DiehlAndCook
+from bindsnet.models import DiehlAndCook2015
+
+net = DiehlAndCook2015(n_inpt=784, n_neurons=100, inpt_shape=(1, 28, 28),
+ learning_rule=DiehlAndCook,
+ learning_rule_kwargs={"x_tar": 0.4, "mu": 1.0})
+```
+
+This also switches the input layer to accumulating traces. Leaving `learning_rule`
+unset keeps the published `PostPre` behaviour, so results in `REPRODUCING.md` are
+unchanged.
+
+For any multicompartment `Weight` feature, extra keyword arguments (`x_tar`, `mu`,
+`tc_plus`, ...) are forwarded to its learning rule.
diff --git a/bindsnet/learning/__init__.py b/bindsnet/learning/__init__.py
index 5a733783..ef5a4bf2 100644
--- a/bindsnet/learning/__init__.py
+++ b/bindsnet/learning/__init__.py
@@ -1,6 +1,7 @@
from bindsnet.learning.learning import (
MSTDP,
MSTDPET,
+ DiehlAndCook,
Hebbian,
LearningRule,
NoOp,
@@ -15,6 +16,7 @@
"PostPre",
"WeightDependentPostPre",
"Hebbian",
+ "DiehlAndCook",
"MSTDP",
"MSTDPET",
"Rmax",
diff --git a/bindsnet/learning/learning.py b/bindsnet/learning/learning.py
index 60f5454c..cf45ed9a 100644
--- a/bindsnet/learning/learning.py
+++ b/bindsnet/learning/learning.py
@@ -1522,6 +1522,103 @@ def _conv3d_connection_update(self, **kwargs) -> None:
super().update()
+class DiehlAndCook(LearningRule):
+ # language=rst
+ """
+ Post-synaptic-spike-only STDP of `Diehl & Cook (2015)
+ `_, Sect. 2.3:
+
+ .. math::
+
+ \\Delta w = \\eta\\,(x_\\text{pre} - x_\\text{tar})\\,(w_\\max - w)^\\mu
+
+ applied on every post-synaptic spike. :math:`x_\\text{pre}` is the source layer's
+ spike trace (the paper's trace adds 1 per spike, i.e. ``traces_additive=True``);
+ :math:`x_\\text{tar}` is the target trace value ("the higher the target value, the
+ lower the synaptic weight will be"); :math:`w_\\max` is the connection's ``wmax``;
+ :math:`\\mu` sets the weight dependence. Pre-synaptic spikes do not change the
+ weight, unlike ``PostPre``. The paper gives no numeric values for
+ :math:`x_\\text{tar}` and :math:`\\mu`; the defaults here (0 and 1) are BindsNET's.
+ """
+
+ def __init__(
+ self,
+ connection: AbstractConnection,
+ nu: Optional[Union[float, Sequence[float], Sequence[torch.Tensor]]] = None,
+ reduction: Optional[callable] = None,
+ weight_decay: float = 0.0,
+ **kwargs,
+ ) -> None:
+ # language=rst
+ """
+ Constructor for the ``DiehlAndCook`` learning rule.
+
+ :param connection: A ``Connection`` or ``LocalConnection`` whose weights the
+ rule will modify. It must have a finite ``wmax``.
+ :param nu: Learning rate :math:`\\eta`. A pair is accepted for API symmetry
+ with the other rules; only the second (post-synaptic) entry is used.
+ :param reduction: Method for reducing parameter updates along the batch
+ dimension.
+ :param weight_decay: Coefficient controlling rate of decay of the weights each
+ iteration.
+
+ Keyword arguments:
+
+ :param float x_tar: Target pre-synaptic trace :math:`x_\\text{tar}` (default 0).
+ :param float mu: Weight-dependence exponent :math:`\\mu` (default 1).
+ """
+ super().__init__(
+ connection=connection,
+ nu=nu,
+ reduction=reduction,
+ weight_decay=weight_decay,
+ **kwargs,
+ )
+
+ assert self.source.traces, "Pre-synaptic nodes must record spike traces."
+ assert (
+ connection.wmax != np.inf
+ ).all(), "DiehlAndCook needs a finite wmax (the paper's w_max)."
+
+ if isinstance(connection, (Connection, LocalConnection)):
+ self.update = self._connection_update
+ else:
+ raise NotImplementedError(
+ "This learning rule is not supported for this Connection type."
+ )
+
+ self.x_tar = float(kwargs.get("x_tar", 0.0))
+ self.mu = float(kwargs.get("mu", 1.0))
+
+ def _connection_update(self, **kwargs) -> None:
+ # language=rst
+ """
+ ``w += eta * ((x_pre - x_tar) outer s_post) * (wmax - w) ** mu`` reduced
+ over the batch.
+ """
+ if not self.nu[1].any():
+ super().update()
+ return
+ batch_size = self.source.batch_size
+ w = self.connection.w
+ source_x = self.source.x.view(batch_size, -1) - self.x_tar
+ target_s = self.target.s.view(batch_size, -1).float()
+ if self.reduction in (torch.squeeze, torch.sum):
+ outer = source_x.t() @ target_s
+ else:
+ outer = self.reduction(
+ torch.bmm(source_x.unsqueeze(2), target_s.unsqueeze(1)), dim=0
+ )
+ factor = self.connection.wmax - w
+ if self.mu != 1.0:
+ factor = factor.clamp(min=0.0) ** self.mu
+ update = self.nu[1] * outer * factor
+ if w.is_sparse:
+ update = update.to_sparse()
+ self.connection.w += update
+ super().update()
+
+
class MSTDP(LearningRule):
# language=rst
"""
diff --git a/bindsnet/models/models.py b/bindsnet/models/models.py
index 04ff3361..a5027e0c 100644
--- a/bindsnet/models/models.py
+++ b/bindsnet/models/models.py
@@ -7,6 +7,7 @@
from torch import device
from bindsnet.learning import PostPre
+from bindsnet.learning.MCC_learning import DiehlAndCook as MMCDiehlAndCook
from bindsnet.learning.MCC_learning import PostPre as MMCPostPre
from bindsnet.network import Network
from bindsnet.network.nodes import DiehlAndCookNodes, Input, LIFNodes
@@ -119,6 +120,8 @@ def __init__(
inpt_shape: Optional[Iterable[int]] = None,
inh_thresh: float = -40.0,
exc_thresh: float = -52.0,
+ learning_rule: Optional[type] = None,
+ learning_rule_kwargs: Optional[dict] = None,
) -> None:
# language=rst
"""
@@ -143,6 +146,14 @@ def __init__(
:param tc_theta_decay: Time constant of ``DiehlAndCookNodes`` threshold
potential decay.
:param inpt_shape: The dimensionality of the input layer.
+ :param learning_rule: ``bindsnet.learning.MCC_learning`` rule for the input to
+ excitatory weights. Default ``PostPre`` (pair-based STDP, the rule the
+ published BindsNET replication used). Pass
+ ``bindsnet.learning.MCC_learning.DiehlAndCook`` for the paper's own
+ post-spike-only rule; that also switches the input traces to the paper's
+ accumulating form (``traces_additive=True``).
+ :param learning_rule_kwargs: Extra options for the rule, e.g.
+ ``{"x_tar": 0.4, "mu": 1.0}`` for ``DiehlAndCook``.
"""
super().__init__(dt=dt)
@@ -153,9 +164,18 @@ def __init__(
self.inh = inh
self.dt = dt
+ if learning_rule is None:
+ learning_rule = MMCPostPre
+ learning_rule_kwargs = dict(learning_rule_kwargs or {})
+ paper_rule = learning_rule is MMCDiehlAndCook
+
# Layers
input_layer = Input(
- n=self.n_inpt, shape=self.inpt_shape, traces=True, tc_trace=20.0
+ n=self.n_inpt,
+ shape=self.inpt_shape,
+ traces=True,
+ traces_additive=paper_rule,
+ tc_trace=20.0,
)
exc_layer = DiehlAndCookNodes(
n=self.n_neurons,
@@ -195,9 +215,10 @@ def __init__(
norm=norm,
reduction=reduction,
nu=nu,
- learning_rule=MMCPostPre,
+ learning_rule=learning_rule,
sparse=sparse,
batch_size=batch_size,
+ **learning_rule_kwargs,
)
],
)
diff --git a/bindsnet/network/topology_features.py b/bindsnet/network/topology_features.py
index 7abcd636..7875236f 100644
--- a/bindsnet/network/topology_features.py
+++ b/bindsnet/network/topology_features.py
@@ -92,6 +92,7 @@ def __init__(
NoOp,
PostPre,
Hebbian,
+ DiehlAndCook,
MSTDP,
MSTDPET,
)
@@ -100,6 +101,7 @@ def __init__(
NoOp,
PostPre,
Hebbian,
+ DiehlAndCook,
MSTDP,
MSTDPET,
]
@@ -244,6 +246,8 @@ def prime_feature(self, connection, device, **kwargs) -> None:
if self.learning_rule is None:
self.learning_rule = NoOp
+ # Rule-specific options given to the feature (e.g. ``x_tar``, ``mu``,
+ # ``tc_plus``) are forwarded to the rule; connection-level kwargs win.
self.learning_rule = self.learning_rule(
connection=connection,
feature_value=self.value,
@@ -251,7 +255,7 @@ def prime_feature(self, connection, device, **kwargs) -> None:
nu=self.nu,
reduction=self.reduction,
decay=self.decay,
- **kwargs,
+ **{**self.kwargs, **kwargs},
)
#### Recycle unnecessary variables ####
@@ -612,6 +616,7 @@ def __init__(
decay: float = 0.0,
sparse: Optional[bool] = False,
batch_size: int = 1,
+ **kwargs,
) -> None:
# language=rst
"""
@@ -633,6 +638,9 @@ def __init__(
:param decay: Constant multiple to decay weights by on each iteration
:param sparse: Should :code:`value` parameter be sparse tensor or not
:param batch_size: Mini-batch size.
+
+ Any further keyword arguments (e.g. ``x_tar``, ``mu``, ``tc_plus``) are
+ forwarded to the learning rule when it is instantiated.
"""
self.norm_frequency = norm_frequency
@@ -655,6 +663,7 @@ def __init__(
decay=decay,
sparse=sparse,
batch_size=batch_size,
+ **kwargs,
)
def reset_state_variables(self) -> None:
diff --git a/docs/source/models_spec.rst b/docs/source/models_spec.rst
index 3f7c07a5..771de981 100644
--- a/docs/source/models_spec.rst
+++ b/docs/source/models_spec.rst
@@ -203,6 +203,26 @@ weight from its bounds (``wmin``/``wmax``), yielding soft saturation at the limi
Morrison et al. (2008) eqs. (13)-(14) with :math:`F_+ = \nu_\text{post}(w_\max - w)` and
:math:`F_- = \nu_\text{pre}(w - w_\min)` (the multiplicative / soft-bound rule).
+Diehl & Cook STDP (``DiehlAndCook``)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+The post-spike-only rule of Diehl & Cook (2015), *Front. Comput. Neurosci.* 9:99,
+Sect. 2.3 "Learning": on every post-synaptic spike
+
+.. math::
+
+ \\Delta w = \\eta\\,(x_\\text{pre} - x_\\text{tar})\\,(w_\\max - w)^\\mu
+
+where the pre-synaptic trace :math:`x_\\text{pre}` is increased by 1 on each
+pre-synaptic spike and decays exponentially (``traces_additive=True``),
+:math:`x_\\text{tar}` is the target trace value ("the higher the target value, the lower
+the synaptic weight will be"), :math:`w_\\max` is ``wmax`` and :math:`\\mu` the weight
+dependence. Pre-synaptic spikes do not change the weight. Keyword arguments ``x_tar``
+(default 0) and ``mu`` (default 1); the paper gives no numeric values for them. Only
+the post-synaptic learning rate ``nu[1]`` is used. Available for ``Connection`` /
+``LocalConnection`` and as ``MCC_learning.DiehlAndCook`` for multicompartment
+connections; ``DiehlAndCook2015(learning_rule=MCC_learning.DiehlAndCook, ...)``
+switches the model to it. Validated in ``test/network/test_learning_rule_specs.py``.
+
Reward-modulated STDP (``MSTDP``, ``MSTDPET``)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Three-factor rules: a STDP-like eligibility signal is gated by a scalar **reward**.
@@ -243,13 +263,15 @@ spikes entered the same-step potentiation term but never the trace.)
Known deviations from the papers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-These are documented rather than changed, because changing them alters results:
-
-* ``PostPre`` is not the Diehl & Cook (2015) rule (see the note above), although the
- ``DiehlAndCook2015`` model uses it and reproduces the published accuracy with it.
-* ``bindsnet.learning.MCC_learning.PostPre`` (the multicompartment version) multiplies
- each update by the simulation step ``dt``; the classic ``PostPre`` and Morrison et
- al. (2008) eqs. (13)-(14) do not. The two agree only at ``dt = 1``.
+* ``PostPre`` is not the Diehl & Cook (2015) rule (see the note above). The
+ ``DiehlAndCook2015`` model keeps ``PostPre`` as its default because that is what the
+ published BindsNET replication used; the paper's rule is available as
+ ``DiehlAndCook`` and can be selected with the model's ``learning_rule`` argument.
+* Until September 2026 the multicompartment ``MCC_learning.PostPre`` multiplied each
+ update by the simulation step ``dt`` (the classic ``PostPre`` and Morrison et al.
+ (2008) eqs. (13)-(14) do not). The factor was removed; results at ``dt = 1`` are
+ unchanged, and at other steps the update is now a per-spike increment like the
+ classic rule.
.. note::
diff --git a/test/network/test_learning_rule_specs.py b/test/network/test_learning_rule_specs.py
index 03a117b2..7e64b547 100644
--- a/test/network/test_learning_rule_specs.py
+++ b/test/network/test_learning_rule_specs.py
@@ -31,6 +31,17 @@
``Hebbian`` is the same trace machinery with both terms positive (BindsNET's
own definition; no paper equation).
+* Diehl & Cook STDP (``DiehlAndCook``): Diehl & Cook (2015), *Front. Comput.
+ Neurosci.* 9:99, Sect. 2.3 "Learning": on each post-synaptic spike
+
+ .. math::
+
+ \\Delta w = \\eta\\,(x_\\text{pre} - x_\\text{tar})\\,(w_\\max - w)^\\mu
+
+ with the pre-synaptic trace increased by 1 per spike and decaying
+ exponentially; pre-synaptic spikes do not change the weight. The paper gives
+ no numeric values for :math:`x_\\text{tar}` or :math:`\\mu`.
+
* Reward-modulated STDP (``MSTDP``, ``MSTDPET``): Florian (2007), *Neural
Comput.* 19:1468-1502, discrete-time eqs. (3.9)-(3.12) and (2.7)-(2.8). Those
rules are validated in ``test_mstdp_florian.py``; this file only records the
@@ -71,8 +82,15 @@
import pytest
import torch
-from bindsnet.learning import Hebbian, PostPre, Rmax, WeightDependentPostPre
+from bindsnet.learning import (
+ DiehlAndCook,
+ Hebbian,
+ PostPre,
+ Rmax,
+ WeightDependentPostPre,
+)
from bindsnet.learning import MCC_learning
+from bindsnet.models import DiehlAndCook2015
from bindsnet.network import Network
from bindsnet.network.nodes import Input, LIFNodes, SRM0Nodes
from bindsnet.network.topology import Connection, MulticompartmentConnection
@@ -302,22 +320,30 @@ def test_diehl_and_cook_2015_rule_is_not_postpre(self):
class TestMulticompartmentRulesMatchClassic:
- """The ``MCC_learning`` PostPre / Hebbian must apply the same equations as the
- classic rules (at ``dt = 1``, where the MCC rule's extra ``dt`` factor is 1)."""
+ """The ``MCC_learning`` PostPre / Hebbian / DiehlAndCook must apply the same
+ equations as the classic rules at any ``dt`` (the MCC PostPre used to scale
+ its update by ``dt``; per-spike increments must not depend on the step)."""
+ @pytest.mark.parametrize("dt", [1.0, 0.5])
@pytest.mark.parametrize(
- "rule_pair", [(PostPre, MCC_learning.PostPre), (Hebbian, MCC_learning.Hebbian)]
+ "rule_pair",
+ [
+ (PostPre, MCC_learning.PostPre),
+ (Hebbian, MCC_learning.Hebbian),
+ (DiehlAndCook, MCC_learning.DiehlAndCook),
+ ],
)
- def test_same_weights_step_by_step(self, rule_pair):
+ def test_same_weights_step_by_step(self, rule_pair, dt):
classic, mcc = rule_pair
+ rule_kw = {"x_tar": 0.3, "mu": 1.0} if classic is DiehlAndCook else {}
torch.manual_seed(0)
w0 = 0.5 * torch.rand(12, 6)
torch.manual_seed(1)
pre = torch.bernoulli(0.5 * torch.ones(40, 12)).byte()
def build(use_mcc):
- net = Network(dt=1.0)
- net.add_layer(Input(n=12, traces=True), "in")
+ net = Network(dt=dt)
+ net.add_layer(Input(n=12, traces=True, traces_additive=True), "in")
net.add_layer(LIFNodes(n=6, traces=True, thresh=-60.0), "out")
if use_mcc:
conn = MulticompartmentConnection(
@@ -331,6 +357,7 @@ def build(use_mcc):
range=[0.0, 1.0],
nu=(1e-2, 3e-2),
learning_rule=mcc,
+ **rule_kw,
)
],
)
@@ -343,6 +370,7 @@ def build(use_mcc):
update_rule=classic,
wmin=0.0,
wmax=1.0,
+ **rule_kw,
)
net.add_connection(conn, "in", "out")
return net, conn
@@ -350,10 +378,38 @@ def build(use_mcc):
net_a, conn_a = build(False)
net_b, conn_b = build(True)
for t in range(40):
- net_a.run(inputs={"in": pre[t : t + 1]}, time=1)
- net_b.run(inputs={"in": pre[t : t + 1]}, time=1)
+ net_a.run(inputs={"in": pre[t : t + 1]}, time=dt)
+ net_b.run(inputs={"in": pre[t : t + 1]}, time=dt)
wa, wb = conn_a.w, conn_b.pipeline[0].value
assert (wa - wb).abs().max().item() < TOL, t
+ assert (conn_a.w - w0).abs().max().item() > 1e-3, "vacuous: no learning"
+
+ def test_mcc_postpre_update_independent_of_dt(self):
+ # One post spike with a pre trace of 1 must add exactly nu_post,
+ # whatever the simulation step.
+ for dt in (1.0, 0.5, 0.1):
+ net = Network(dt=dt)
+ net.add_layer(Input(n=1, traces=True), "in")
+ net.add_layer(LIFNodes(n=1, traces=True), "out")
+ conn = MulticompartmentConnection(
+ net.layers["in"],
+ net.layers["out"],
+ device="cpu",
+ pipeline=[
+ Weight(
+ "w",
+ torch.full((1, 1), 0.5),
+ range=[0.0, 1.0],
+ nu=(0.0, 0.1),
+ learning_rule=MCC_learning.PostPre,
+ )
+ ],
+ )
+ net.add_connection(conn, "in", "out")
+ net.layers["in"].x.fill_(1.0)
+ net.layers["out"].s.fill_(True)
+ conn.update(learning=True)
+ assert conn.pipeline[0].value.item() == pytest.approx(0.6), dt
class TestRmaxVasilaki2009:
@@ -490,3 +546,141 @@ def test_unclamped_spike_leaves_no_trace(self):
s = net.layers["out"].s.view(-1)
assert s[0] and s[1] and not s[2]
assert torch.equal(net.layers["out"].x.view(-1), s.float())
+
+
+class TestDiehlAndCook2015Rule:
+ """``DiehlAndCook`` against Diehl & Cook (2015) Sect. 2.3."""
+
+ @staticmethod
+ def _reference(pre, post, w0, eta, x_tar, mu, wmax, dt, tc, additive):
+ w = w0.clone()
+ x = torch.zeros(pre.shape[1])
+ decay = math.exp(-dt / tc)
+ hist = []
+ for t in range(pre.shape[0]):
+ x = _trace_step(x, pre[t], decay, additive)
+ # Post-synaptic spikes only: Delta w = eta (x_pre - x_tar) (w_max - w)^mu.
+ dw = eta * torch.outer(x - x_tar, post[t]) * (wmax - w).clamp(min=0) ** mu
+ w = (w + dw).clamp(0.0, wmax)
+ hist.append(w.clone())
+ return torch.stack(hist)
+
+ @pytest.mark.parametrize("x_tar", [0.0, 0.3])
+ @pytest.mark.parametrize("mu", [1.0, 0.5])
+ @pytest.mark.parametrize("additive", [True, False])
+ def test_matches_paper_rule(self, x_tar, mu, additive):
+ dt, tc, wmax, eta = 1.0, 20.0, 1.0, 2e-2
+ torch.manual_seed(0)
+ net = Network(dt=dt)
+ net.add_layer(
+ Input(n=12, traces=True, traces_additive=additive, tc_trace=tc), "in"
+ )
+ net.add_layer(LIFNodes(n=6, traces=True, thresh=-60.0), "out")
+ w0 = 0.5 * torch.rand(12, 6)
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=w0.clone(),
+ nu=(0.0, eta),
+ update_rule=DiehlAndCook,
+ wmin=0.0,
+ wmax=wmax,
+ x_tar=x_tar,
+ mu=mu,
+ )
+ net.add_connection(conn, "in", "out")
+ torch.manual_seed(1)
+ pre = torch.bernoulli(0.5 * torch.ones(40, 12))
+ w_hist, post_hist = [], []
+ for t in range(40):
+ net.run(inputs={"in": pre[t : t + 1].byte()}, time=dt)
+ post_hist.append(net.layers["out"].s.view(-1).float().clone())
+ w_hist.append(conn.w.detach().clone())
+ post = torch.stack(post_hist)
+ assert post.sum() > 0
+ w_ref = self._reference(pre, post, w0, eta, x_tar, mu, wmax, dt, tc, additive)
+ assert (torch.stack(w_hist) - w_ref).abs().max().item() < TOL
+
+ def test_pre_spike_alone_does_not_change_weight(self):
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=1, traces=True, traces_additive=True), "in")
+ net.add_layer(LIFNodes(n=1, traces=True), "out")
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=torch.full((1, 1), 0.5),
+ nu=1.0,
+ update_rule=DiehlAndCook,
+ wmin=0.0,
+ wmax=1.0,
+ x_tar=0.5,
+ )
+ net.add_connection(conn, "in", "out")
+ net.layers["out"].x.fill_(0.3)
+ pre = torch.zeros(3, 1, 1)
+ pre[1, 0, 0] = 1
+ net.run(inputs={"in": pre.byte()}, time=3)
+ assert conn.w.item() == 0.5
+
+ def test_x_tar_depresses_silent_inputs(self):
+ # A post spike with no recent pre spike depresses by eta * x_tar * (wmax - w).
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=1, traces=True, traces_additive=True), "in")
+ net.add_layer(LIFNodes(n=1, traces=True), "out")
+ conn = Connection(
+ net.layers["in"],
+ net.layers["out"],
+ w=torch.full((1, 1), 0.5),
+ nu=0.1,
+ update_rule=DiehlAndCook,
+ wmin=0.0,
+ wmax=1.0,
+ x_tar=0.4,
+ mu=1.0,
+ )
+ net.add_connection(conn, "in", "out")
+ net.run(
+ inputs={"in": torch.zeros(1, 1, 1).byte()},
+ time=1,
+ clamp={"out": torch.tensor([True])},
+ )
+ assert conn.w.item() == pytest.approx(0.5 - 0.1 * 0.4 * 0.5)
+
+ def test_requires_finite_wmax(self):
+ net = Network(dt=1.0)
+ net.add_layer(Input(n=2, traces=True), "in")
+ net.add_layer(LIFNodes(n=2, traces=True), "out")
+ with pytest.raises(AssertionError):
+ Connection(
+ net.layers["in"], net.layers["out"], nu=0.1, update_rule=DiehlAndCook
+ )
+
+ def test_model_opt_in(self):
+ torch.manual_seed(0)
+ net = DiehlAndCook2015(
+ n_inpt=16,
+ n_neurons=4,
+ inpt_shape=(1, 4, 4),
+ learning_rule=MCC_learning.DiehlAndCook,
+ learning_rule_kwargs={"x_tar": 0.2, "mu": 1.0},
+ )
+ rule = net.connections[("X", "Ae")].pipeline[0].learning_rule
+ assert isinstance(rule, MCC_learning.DiehlAndCook)
+ assert rule.x_tar == 0.2 and rule.mu == 1.0
+ assert net.layers["X"].traces_additive # the paper's accumulating trace
+ w0 = net.connections[("X", "Ae")].pipeline[0].value.clone()
+ net.run(
+ inputs={"X": torch.bernoulli(0.5 * torch.ones(30, 1, 1, 4, 4)).byte()},
+ time=30,
+ )
+ w = net.connections[("X", "Ae")].pipeline[0].value
+ # (The model re-normalises each neuron's incoming weights to ``norm``
+ # after a run, so only the sign and the change are checked here.)
+ assert (w >= 0).all() and torch.isfinite(w).all() and not torch.equal(w, w0)
+ # Default is unchanged: pair STDP with saturating traces.
+ default = DiehlAndCook2015(n_inpt=16, n_neurons=4, inpt_shape=(1, 4, 4))
+ assert isinstance(
+ default.connections[("X", "Ae")].pipeline[0].learning_rule,
+ MCC_learning.PostPre,
+ )
+ assert not default.layers["X"].traces_additive
diff --git a/test/network/test_perf_equivalence.py b/test/network/test_perf_equivalence.py
index 1f46ec2c..69ffc329 100644
--- a/test/network/test_perf_equivalence.py
+++ b/test/network/test_perf_equivalence.py
@@ -205,9 +205,9 @@ def test_mcc_postpre_matches_reference(self, dt):
rule = conn.pipeline[0].learning_rule
w0 = conn.pipeline[0].value.detach().clone()
pre, post = _reference_outer(conn.source, conn.target, 1, rule.reduction)
- expected = (w0 - pre * rule.nu[0] * dt + post * rule.nu[1] * dt).clamp_(
- 0.0, 1.0
- )
+ # Per-spike increments: no dependence on dt (Morrison et al. 2008
+ # eqs. 13-14; the former ``* dt`` factor was removed).
+ expected = (w0 - pre * rule.nu[0] + post * rule.nu[1]).clamp_(0.0, 1.0)
conn.update(learning=True)
assert torch.equal(conn.pipeline[0].value, expected)
From f2d61cefe938f8f3100e0be364f54427a7d8438e Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Sun, 6 Sep 2026 22:16:52 -0400
Subject: [PATCH 06/14] fix: MSTDP reset must tolerate state that is not built
yet
Follow-up to #794. MSTDP creates p_plus and p_minus lazily on the first
update, because only then are the batch size and device known. #794 made
reset_state_variables zero them unconditionally, so calling
network.reset_state_variables() before the first run raised
AttributeError: 'MSTDP' object has no attribute 'p_plus'. Building a
network and resetting it before the first episode is a normal thing to
do, so this was reachable.
Declare p_plus and p_minus as None in __init__ alongside the other lazily
built state, switch the update path's hasattr guards to 'is None' to
match, and have the reset skip whatever has not been built.
MSTDPET was never affected: it builds both in __init__.
New test parametrised over MSTDP, MSTDPET and PostPre resets a freshly
built network before running it. It fails for MSTDP without this change.
Full suite 94 passed.
Co-Authored-By: Claude Opus 5
---
bindsnet/learning/MCC_learning.py | 22 ++++++++++++----------
test/network/test_learning.py | 9 +++++++++
2 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py
index 77bdd78b..ef20d44d 100644
--- a/bindsnet/learning/MCC_learning.py
+++ b/bindsnet/learning/MCC_learning.py
@@ -471,10 +471,13 @@ def __init__(
self.tc_plus = torch.tensor(kwargs.get("tc_plus", 20.0))
self.tc_minus = torch.tensor(kwargs.get("tc_minus", 20.0))
- # State the update path fills in lazily: the previous step's spikes,
- # kept by the fast path for its rank-1 update, and the dense path's
- # eligibility. None means "not built yet", which is also the state
- # ``reset_state_variables`` restores.
+ # State the update path fills in lazily, because it needs the batch
+ # size and device that only the first update knows: P+/P-, the previous
+ # step's spikes kept by the fast path for its rank-1 update, and the
+ # dense path's eligibility. None means "not built yet", which is also
+ # the state ``reset_state_variables`` restores.
+ self.p_plus = None
+ self.p_minus = None
self._prev_source_s = None
self._prev_target_s = None
self.eligibility = None
@@ -506,14 +509,14 @@ def _connection_update(self, **kwargs) -> None:
batch_size = self.source.batch_size
# Initialize eligibility, P^+, and P^-.
- if not hasattr(self, "p_plus"):
+ if self.p_plus is None:
self.p_plus = torch.zeros(
# batch_size, *self.source.shape, device=self.source.s.device
batch_size,
self.source.n,
device=self.source.s.device,
)
- if not hasattr(self, "p_minus"):
+ if self.p_minus is None:
self.p_minus = torch.zeros(
# batch_size, *self.target.shape, device=self.target.s.device
batch_size,
@@ -636,10 +639,9 @@ def reset_state_variables(self) -> None:
starts from the same state as a freshly-built rule.
"""
- if self.eligibility is not None:
- self.eligibility.zero_()
- self.p_plus.zero_()
- self.p_minus.zero_()
+ for state in (self.eligibility, 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/test/network/test_learning.py b/test/network/test_learning.py
index 16903781..69eee849 100644
--- a/test/network/test_learning.py
+++ b/test/network/test_learning.py
@@ -376,6 +376,15 @@ def test_mstdp_reset_clears_fast_path_spike_lag(self):
assert rule._prev_source_s is None
assert rule._prev_target_s is None
+ @pytest.mark.parametrize("rule", [mcc.MSTDP, mcc.MSTDPET, mcc.PostPre])
+ def test_reset_before_first_run_does_not_raise(self, rule):
+ # Some of this state is built lazily on the first update, because only
+ # then are the batch size and device known. Resetting a network before
+ # running it must still work.
+ network, rule_obj = self._build(rule)
+ network.reset_state_variables()
+ assert rule_obj is not None
+
def test_postpre_reset_clears_average_buffers(self):
# PostPre's reset was a bare ``return``; both buffers survived.
network, rule = self._build(
From 4a3d86124f827b8f08a701b4757580aee79cf998 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Sun, 6 Sep 2026 22:22:03 -0400
Subject: [PATCH 07/14] learning: give DiehlAndCook the same documented reset
as Hebbian
DiehlAndCook arrived on this branch while the reset audit (#794) was
happening on master, so it kept the bare 'return' that the audit removed
everywhere else.
It holds no state between steps: each update comes from the source trace,
the target spikes and the current weight, exactly like Hebbian. So the
no-op is correct, but it now says so rather than looking like the
oversight the other rules turned out to be.
Extends the two reset tests to cover Hebbian and DiehlAndCook as well.
Full suite 186 passed.
Co-Authored-By: Claude Opus 5
---
bindsnet/learning/MCC_learning.py | 8 ++++++--
test/network/test_learning.py | 8 ++++++--
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py
index 56d2adc8..a6ef5538 100644
--- a/bindsnet/learning/MCC_learning.py
+++ b/bindsnet/learning/MCC_learning.py
@@ -547,8 +547,12 @@ def _connection_update(self, **kwargs) -> None:
w += self.nu[1] * outer * factor
super().update()
- def reset_state_variables(self):
- return
+ def reset_state_variables(self) -> None:
+ # language=rst
+ """
+ Nothing to reset: the rule holds no state between steps, deriving each
+ update from the source trace, the target spikes and the current weight.
+ """
class MSTDP(MCC_LearningRule):
diff --git a/test/network/test_learning.py b/test/network/test_learning.py
index 69eee849..33c47608 100644
--- a/test/network/test_learning.py
+++ b/test/network/test_learning.py
@@ -376,7 +376,9 @@ def test_mstdp_reset_clears_fast_path_spike_lag(self):
assert rule._prev_source_s is None
assert rule._prev_target_s is None
- @pytest.mark.parametrize("rule", [mcc.MSTDP, mcc.MSTDPET, mcc.PostPre])
+ @pytest.mark.parametrize(
+ "rule", [mcc.MSTDP, mcc.MSTDPET, mcc.PostPre, mcc.Hebbian, mcc.DiehlAndCook]
+ )
def test_reset_before_first_run_does_not_raise(self, rule):
# Some of this state is built lazily on the first update, because only
# then are the batch size and device known. Resetting a network before
@@ -399,7 +401,9 @@ def test_postpre_reset_clears_average_buffers(self):
assert rule.average_buffer_index_pre == 0
assert rule.average_buffer_index_post == 0
- @pytest.mark.parametrize("rule", [mcc.MSTDP, mcc.MSTDPET, mcc.PostPre])
+ @pytest.mark.parametrize(
+ "rule", [mcc.MSTDP, mcc.MSTDPET, mcc.PostPre, mcc.Hebbian, mcc.DiehlAndCook]
+ )
def test_episodes_are_independent_after_reset(self, rule):
# The symptom #777 reported: with a reset between them, two identical
# episodes must produce identical weights. Before the fix the second
From 56ef5d49e93f1454b3ad5b92aa5b3d6750bc0433 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Fri, 11 Sep 2026 11:34:17 -0400
Subject: [PATCH 08/14] ci: restrict GITHUB_TOKEN to contents:read in all
workflows
CodeQL alerts 10, 11 and 12 (actions/missing-workflow-permissions) flag that
none of the three workflows declare a permissions block, so each run inherits
the repository default token scope.
None of the three write back to the repository: black.yml runs psf/black,
python-app.yml runs flake8 and pytest, pythonpackage.yml runs black and pytest
across Python 3.11-3.13. Read-only access to the repository contents is
sufficient for all of them.
Co-Authored-By: Claude Opus 5
---
.github/workflows/black.yml | 3 +++
.github/workflows/python-app.yml | 3 +++
.github/workflows/pythonpackage.yml | 3 +++
3 files changed, 9 insertions(+)
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
index bf865078..c2e70ed9 100644
--- a/.github/workflows/black.yml
+++ b/.github/workflows/black.yml
@@ -2,6 +2,9 @@ name: Black Formater
on: [push, pull_request]
+permissions:
+ contents: read
+
jobs:
lint:
runs-on: ubuntu-latest
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 88936dbf..69e7b58f 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -9,6 +9,9 @@ on:
pull_request:
branches: [ master ]
+permissions:
+ contents: read
+
jobs:
build:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index bf44399b..d153d83f 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -2,6 +2,9 @@ name: Python package
on: [push]
+permissions:
+ contents: read
+
jobs:
build:
From 68715b3692f70232c5709d91279ab1fb1de2a4cd Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Tue, 15 Sep 2026 18:24:33 -0400
Subject: [PATCH 09/14] security: document pickle risk in load(); fix three
broken torch.load sites
bindsnet.network.load() reads a Python pickle file via torch.load, so loading a
network file from an untrusted source runs code in that file. This is standard
torch.load behaviour across the PyTorch ecosystem rather than a defect specific
to BindsNET, and it is not being treated as a vulnerability, but it was
completely undocumented. Adds a warning to each affected function's docstring
and a "Loading saved networks and models" section to SECURITY.md.
load() gains a weights_only parameter passed through to torch.load. It defaults
to False, so behaviour is unchanged: Network.save() stores the whole network
object rather than a tensor state dict, and the safe loader cannot read those
files at all. Reversing the default would break every saved network.
Reviewing the neighbouring call sites turned up three things that were simply
broken, all from PyTorch 2.6 flipping the weights_only default to True:
- Network.clone() raised UnpicklingError. No test, no caller in the tree.
- Network.save() called add_safe_globals([self]) with an instance where a class
is expected. It did nothing useful and corrupted PyTorch's safe-globals
registry, so any later load in the same process failed with
'Network' object has no attribute '__qualname__'. Removed.
- conversion.ann_to_snn and data_based_normalization raised UnpicklingError when
given a path instead of a module. Only the in-memory form was tested.
SpokenMNIST now reads its processed-data cache with weights_only=True. That
cache holds only tensors, so refusing code execution there costs nothing. It is
the one place the reporter's suggested fix actually applies.
Six regression tests added, each carrying the reason in its docstring. No
per-timestep path is touched, so performance is unaffected. Full suite: 193
passed.
Reported-by: Gavin Branaa
Thanks to Gavin for the careful private report, and for the follow-up that led
to the three fixes above.
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 32 ++++++++++
SECURITY.md | 35 +++++++++++
bindsnet/conversion/conversion.py | 20 +++++-
bindsnet/datasets/spoken_mnist.py | 10 ++-
bindsnet/network/network.py | 45 +++++++++++--
test/conversion/test_conversion.py | 34 +++++++++-
test/datasets/test_cache_serialization.py | 52 +++++++++++++++
test/network/test_network.py | 77 +++++++++++++++++++++++
8 files changed, 296 insertions(+), 9 deletions(-)
create mode 100644 test/datasets/test_cache_serialization.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 194dc3e2..01ff4d89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -77,6 +77,38 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
- `network.to(device)` crashed on any `MulticompartmentConnection` (used by
`DiehlAndCook2015`) with `_apply() takes 2 positional arguments but 3 were
given`; `AbstractMulticompartmentConnection._apply` now accepts `recurse`.
+- `Network.clone()` was broken outright: it called `torch.load` without
+ `weights_only=False`, so it raised `UnpicklingError` under PyTorch 2.6+, which
+ changed that default to `True`. It had no test and no caller in the tree, so the
+ breakage went unnoticed. Pinned by `TestNetwork.test_clone`.
+- `Network.save()` called `torch.serialization.add_safe_globals([self])` with a
+ network instance where PyTorch expects a class. It did nothing useful and
+ corrupted PyTorch's safe-globals registry, so any later load in the same process
+ failed with `'Network' object has no attribute '__qualname__'`. Removed. Pinned by
+ `TestNetwork.test_clone_after_save`.
+- `bindsnet.conversion.ann_to_snn` and `data_based_normalization` were broken when
+ given a path instead of a `torch.nn.Module`, for the same PyTorch 2.6 reason as
+ `Network.clone()`. Only the in-memory form was tested. Pinned by
+ `test_conversion_from_path` and `test_data_based_normalization_from_path`.
+
+### Security
+- Documented that loading a saved network runs code. `bindsnet.network.load`,
+ `bindsnet.conversion.ann_to_snn` and `bindsnet.conversion.data_based_normalization`
+ read Python pickle files via `torch.load`, so a file from an untrusted source can
+ execute arbitrary code on load. This is the standard behaviour of `torch.load`
+ across the PyTorch ecosystem and is not a defect specific to BindsNET, but it was
+ undocumented. Added warnings to each function's docstring and a "Loading saved
+ networks and models" section to `SECURITY.md`.
+- `bindsnet.network.load` gained a `weights_only` parameter, passed through to
+ `torch.load`. It defaults to `False`, which is required to read files written by
+ `Network.save` (those store the whole network object, not a tensor state dict), so
+ behaviour is unchanged. `weights_only=True` refuses code execution and is usable
+ only for files holding plain tensors.
+- `SpokenMNIST` now reads its processed-data cache with `weights_only=True`. That
+ cache holds only tensors, so refusing code execution there costs nothing. Pinned by
+ `test/datasets/test_cache_serialization.py`.
+- Reported by Gavin Branaa , who also prompted the three
+ `torch.load` fixes listed under Fixed above. Thank you.
## [0.3.4] - 2026-06-15
diff --git a/SECURITY.md b/SECURITY.md
index b730ff5f..dad47ace 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -16,6 +16,41 @@ you were on. We aim to acknowledge reports within a few days.
Security fixes are applied to the `master` branch and to the most recent release
on PyPI. Older releases are not patched.
+## Loading saved networks and models
+
+Saved network files are Python pickle files. Loading one runs whatever code is
+stored inside it. This means:
+
+**Only load network or model files you created yourself, or that came from a
+source you trust.** A file from a download, a model-sharing site, shared group
+storage, or a collaborator can run arbitrary commands on your machine as soon
+as you load it. There is no way to inspect a pickle file safely first.
+
+This affects the following functions, all of which load a file path you give
+them:
+
+- `bindsnet.network.load`
+- `bindsnet.conversion.ann_to_snn`, when passed a path instead of a module
+- `bindsnet.conversion.data_based_normalization`, when passed a path instead of
+ a module
+
+This is the standard behaviour of `torch.load`, and it applies to saved models
+across the PyTorch ecosystem rather than being specific to BindsNET. We do not
+treat it as a vulnerability in BindsNET, because there is no way to load a
+saved network object without it. We do treat it as something you need to be
+told about clearly, which is what this section and the warnings in those
+functions' documentation are for.
+
+`bindsnet.network.load` accepts `weights_only=True`, which asks PyTorch to
+refuse to execute code while loading. It cannot read files written by
+`Network.save`, because those store the whole network object rather than a
+plain tensor state dictionary, so it is only useful for files you know contain
+plain tensors.
+
+If you need to share a trained network with people who cannot verify where it
+came from, share the weights as a tensor state dictionary rather than a pickled
+network object.
+
## Repository integrity
BindsNET is a research library, and its git history is part of what users rely
diff --git a/bindsnet/conversion/conversion.py b/bindsnet/conversion/conversion.py
index dbcf7785..3febb0f2 100644
--- a/bindsnet/conversion/conversion.py
+++ b/bindsnet/conversion/conversion.py
@@ -88,6 +88,12 @@ def data_based_normalization(
Use a dataset to rescale ANN weights and biases such that that the max ReLU
activation is less than 1.
+ .. warning::
+ When ``ann`` is a path, the file is loaded with :func:`torch.load`
+ using ``weights_only=False``, which runs code stored in the file. Only
+ pass a path to a file you created yourself or otherwise trust. Pass an
+ already-loaded ``torch.nn.Module`` to avoid this.
+
:param ann: Artificial neural network implemented in PyTorch. Accepts
either ``torch.nn.Module`` or path to network saved using
``torch.save()``.
@@ -99,7 +105,9 @@ def data_based_normalization(
according to activations on the dataset.
"""
if isinstance(ann, str):
- ann = torch.load(ann)
+ # weights_only=False is required to load a whole nn.Module. PyTorch 2.6
+ # changed the default to True, which cannot read these files.
+ ann = torch.load(ann, weights_only=False)
assert isinstance(ann, nn.Module)
@@ -277,6 +285,12 @@ def ann_to_snn(
Converts an artificial neural network (ANN) written as a
``torch.nn.Module`` into a near-equivalent spiking neural network.
+ .. warning::
+ When ``ann`` is a path, the file is loaded with :func:`torch.load`
+ using ``weights_only=False``, which runs code stored in the file. Only
+ pass a path to a file you created yourself or otherwise trust. Pass an
+ already-loaded ``torch.nn.Module`` to avoid this.
+
:param ann: Artificial neural network implemented in PyTorch. Accepts
either ``torch.nn.Module`` or path to network saved using
``torch.save()``.
@@ -290,7 +304,9 @@ def ann_to_snn(
:return: Spiking neural network implemented in PyTorch.
"""
if isinstance(ann, str):
- ann = torch.load(ann)
+ # weights_only=False is required to load a whole nn.Module. PyTorch 2.6
+ # changed the default to True, which cannot read these files.
+ ann = torch.load(ann, weights_only=False)
else:
ann = deepcopy(ann)
diff --git a/bindsnet/datasets/spoken_mnist.py b/bindsnet/datasets/spoken_mnist.py
index b8c6f544..ce60a64d 100644
--- a/bindsnet/datasets/spoken_mnist.py
+++ b/bindsnet/datasets/spoken_mnist.py
@@ -115,7 +115,10 @@ def _get_train(self, split: float = 0.8) -> Tuple[torch.Tensor, torch.Tensor]:
else:
# Load image data from disk if it has already been processed.
print("Loading training data from serialized object file.\n")
- audio, labels = torch.load(open(path, "rb"))
+ # weights_only=True is safe here: this cache file holds only
+ # tensors, written by torch.save above, and refusing code
+ # execution costs nothing.
+ audio, labels = torch.load(open(path, "rb"), weights_only=True)
labels = torch.Tensor(labels)
@@ -163,7 +166,10 @@ def _get_test(self, split: float = 0.8) -> Tuple[torch.Tensor, List[torch.Tensor
else:
# Load image data from disk if it has already been processed.
print("Loading test data from serialized object file.\n")
- audio, labels = torch.load(open(path, "rb"))
+ # weights_only=True is safe here: this cache file holds only
+ # tensors, written by torch.save above, and refusing code
+ # execution costs nothing.
+ audio, labels = torch.load(open(path, "rb"), weights_only=True)
labels = torch.Tensor(labels)
diff --git a/bindsnet/network/network.py b/bindsnet/network/network.py
index 1c17fd59..7754bf89 100644
--- a/bindsnet/network/network.py
+++ b/bindsnet/network/network.py
@@ -9,18 +9,46 @@
from bindsnet.network.topology import AbstractConnection
-def load(file_name: str, map_location: str = "cpu", learning: bool = None) -> "Network":
+def load(
+ file_name: str,
+ map_location: str = "cpu",
+ learning: bool = None,
+ weights_only: bool = False,
+) -> "Network":
# language=rst
"""
Loads serialized network object from disk.
+ .. warning::
+ **Only load network files you created yourself or otherwise trust.**
+
+ Network files are Python pickle files. Loading one runs code that is
+ stored inside it, so a file from an untrusted source (a download, a
+ model-sharing site, shared group storage, a file sent by someone else)
+ can run arbitrary commands on your machine as soon as you call this
+ function. There is no way to inspect a pickle file safely before
+ loading it.
+
+ This is the standard behaviour of :func:`torch.load` and applies to
+ saved models across the PyTorch ecosystem, not just to BindsNET.
+
+ ``weights_only=True`` asks PyTorch to refuse to execute code while
+ loading, but it **cannot** load networks written by
+ :py:meth:`Network.save`, because those store the whole network object
+ rather than a plain tensor state dictionary. It is offered here only
+ for files you know contain plain tensors.
+
:param file_name: Path to serialized network object on disk.
:param map_location: One of ``"cpu"`` or ``"cuda"``. Defaults to ``"cpu"``.
:param learning: Whether to load with learning enabled. Default loads value from
disk.
+ :param weights_only: Passed through to :func:`torch.load`. Defaults to
+ ``False``, which permits code execution and is required to load files
+ written by :py:meth:`Network.save`. Set to ``True`` to refuse code
+ execution, which only works for files holding plain tensors.
"""
network = torch.load(
- open(file_name, "rb"), map_location=map_location, weights_only=False
+ open(file_name, "rb"), map_location=map_location, weights_only=weights_only
)
if learning is not None and "learning" in vars(network):
network.learning = learning
@@ -165,6 +193,12 @@ def save(self, file_name: str) -> None:
"""
Serializes the network object to disk.
+ .. note::
+ The saved file is a Python pickle file holding the whole network
+ object. Anyone who loads it with :py:func:`bindsnet.network.load`
+ runs whatever code it contains, so treat a network file you share
+ the same way you would treat a script you ask someone to run.
+
:param file_name: Path to store serialized network object on disk.
**Example:**
@@ -193,7 +227,6 @@ def save(self, file_name: str) -> None:
# Save the network to disk.
network.save(str(Path.home()) + '/network.pt')
"""
- torch.serialization.add_safe_globals([self])
torch.save(self, open(file_name, "wb"))
def clone(self) -> "Network":
@@ -206,7 +239,11 @@ def clone(self) -> "Network":
virtual_file = tempfile.SpooledTemporaryFile()
torch.save(self, virtual_file)
virtual_file.seek(0)
- return torch.load(virtual_file)
+ # weights_only=False is required: this buffer holds the whole network
+ # object, written by torch.save just above, so it is trusted by
+ # construction. PyTorch 2.6 changed the default to True, which cannot
+ # read it.
+ return torch.load(virtual_file, weights_only=False)
def _get_inputs(self, layers: Iterable = None) -> Dict[str, torch.Tensor]:
# language=rst
diff --git a/test/conversion/test_conversion.py b/test/conversion/test_conversion.py
index 8f5deb07..5eafdb8d 100644
--- a/test/conversion/test_conversion.py
+++ b/test/conversion/test_conversion.py
@@ -2,7 +2,7 @@
import torch.nn as nn
import torch.nn.functional as F
-from bindsnet.conversion import ann_to_snn
+from bindsnet.conversion import ann_to_snn, data_based_normalization
class FullyConnectedNetwork(nn.Module):
@@ -36,6 +36,38 @@ def test_conversion_2():
snn = ann_to_snn(ann, data=data, input_shape=(784,))
+def test_conversion_from_path(tmp_path):
+ """
+ ``ann_to_snn`` accepts a path to a saved network.
+
+ Regression test: this path called ``torch.load`` without
+ ``weights_only=False``, so it broke outright when PyTorch 2.6 changed that
+ default to ``True``. Only the in-memory ``nn.Module`` form was tested, so
+ the breakage went unnoticed.
+ """
+ ann = FullyConnectedNetwork()
+ file_path = str(tmp_path / "ann.pt")
+ torch.save(ann, file_path)
+
+ snn = ann_to_snn(file_path, input_shape=(784,))
+
+ assert snn is not None
+
+
+def test_data_based_normalization_from_path(tmp_path):
+ """
+ ``data_based_normalization`` accepts a path to a saved network. Same
+ PyTorch 2.6 regression as ``test_conversion_from_path``.
+ """
+ ann = FullyConnectedNetwork()
+ file_path = str(tmp_path / "ann.pt")
+ torch.save(ann, file_path)
+
+ normalized = data_based_normalization(file_path, data=torch.rand(20, 784))
+
+ assert isinstance(normalized, nn.Module)
+
+
def main():
test_conversion_1()
test_conversion_2()
diff --git a/test/datasets/test_cache_serialization.py b/test/datasets/test_cache_serialization.py
new file mode 100644
index 00000000..67b0185a
--- /dev/null
+++ b/test/datasets/test_cache_serialization.py
@@ -0,0 +1,52 @@
+import numpy as np
+import torch
+
+
+class TestSpokenMNISTCache:
+ """
+ Pins the serialization contract of the ``SpokenMNIST`` processed-data cache.
+
+ ``SpokenMNIST`` writes its processed data with
+ ``torch.save((audio, labels), ...)`` and reads it back with
+ ``torch.load(..., weights_only=True)``. ``weights_only=True`` refuses to
+ execute code while loading, which is only usable because this cache holds
+ nothing but tensors. These tests pin that property, so that if the cached
+ payload ever gains a non-tensor object the failure shows up here rather
+ than as a broken dataset load for a user.
+
+ The real loader needs a download, so these tests exercise the same
+ save/load pair on the same payload shape instead: ``audio`` is a list of
+ 2-D float tensors (filter banks, one per utterance) and ``labels`` is a
+ 1-D float tensor, per ``SpokenMNIST.process_data``.
+ """
+
+ def test_cache_round_trips_under_weights_only(self, tmp_path):
+ audio = [torch.rand(7, 13), torch.rand(11, 13)]
+ labels = torch.Tensor([3.0, 8.0])
+
+ path = str(tmp_path / "audio.pt")
+ torch.save((audio, labels), open(path, "wb"))
+
+ _audio, _labels = torch.load(open(path, "rb"), weights_only=True)
+
+ assert len(_audio) == len(audio)
+ for loaded, original in zip(_audio, audio):
+ assert torch.equal(loaded, original)
+ assert torch.equal(_labels, labels)
+
+ def test_numpy_derived_filter_banks_round_trip(self, tmp_path):
+ """
+ ``process_data`` builds filter banks through NumPy before they become
+ tensors. This pins that the converted result still loads under
+ ``weights_only=True``.
+ """
+ audio = [torch.Tensor(np.random.rand(5, 13).astype(np.float32))]
+ labels = torch.Tensor([1.0])
+
+ path = str(tmp_path / "audio.pt")
+ torch.save((audio, labels), open(path, "wb"))
+
+ _audio, _labels = torch.load(open(path, "rb"), weights_only=True)
+
+ assert torch.equal(_audio[0], audio[0])
+ assert torch.equal(_labels, labels)
diff --git a/test/network/test_network.py b/test/network/test_network.py
index d2743ef7..1cfcde25 100644
--- a/test/network/test_network.py
+++ b/test/network/test_network.py
@@ -66,3 +66,80 @@ def test_add_objects(self, tmp_path):
assert ("X", "Y") in _network.connections
assert "Y" in _network.monitors
del _network
+
+ def test_clone(self):
+ """
+ ``clone()`` round-trips a network in memory.
+
+ Regression test: ``clone()`` called ``torch.load`` without
+ ``weights_only=False``, so it broke outright when PyTorch 2.6 changed
+ that default to ``True``. It had no test and no caller, so the breakage
+ went unnoticed.
+ """
+ import torch
+
+ network = Network(dt=1.0, learning=False)
+ inpt = Input(10)
+ network.add_layer(inpt, name="X")
+ lif = LIFNodes(5)
+ network.add_layer(lif, name="Y")
+ w = torch.rand(10, 5)
+ network.add_connection(
+ Connection(inpt, lif, w=w.clone()), source="X", target="Y"
+ )
+
+ clone = network.clone()
+
+ assert isinstance(clone, Network)
+ assert clone is not network
+ assert clone.dt == network.dt
+ assert clone.learning == network.learning
+ assert "X" in clone.layers and "Y" in clone.layers
+ assert ("X", "Y") in clone.connections
+ assert torch.equal(clone.connections[("X", "Y")].w, w)
+
+ def test_clone_after_save(self, tmp_path):
+ """
+ ``clone()`` still works after ``save()`` has run in the same process.
+
+ Regression test: ``save()`` called
+ ``torch.serialization.add_safe_globals([self])`` with an instance
+ instead of a class, which corrupted PyTorch's safe-globals registry and
+ made every later load in the process fail with
+ ``'Network' object has no attribute '__qualname__'``.
+ """
+ network = Network(dt=1.0)
+ network.add_layer(Input(4), name="X")
+
+ network.save(str(tmp_path / "net.pt"))
+
+ clone = network.clone()
+ assert isinstance(clone, Network)
+ assert clone.dt == 1.0
+
+ def test_load_weights_only_parameter(self, tmp_path):
+ """
+ ``load()`` exposes ``weights_only`` and still defaults to ``False``.
+
+ The default has to stay ``False`` because ``save()`` writes the whole
+ network object, which the safe loader cannot read. ``weights_only=True``
+ is offered for files holding plain tensors only, so on a real network
+ file it is expected to fail rather than silently return something wrong.
+ """
+ import inspect
+
+ signature = inspect.signature(load)
+ assert "weights_only" in signature.parameters
+ assert signature.parameters["weights_only"].default is False
+
+ file_path = str(tmp_path / "net.pt")
+ network = Network(dt=1.0)
+ network.add_layer(Input(4), name="X")
+ network.save(file_path)
+
+ # Default path loads a saved network.
+ assert isinstance(load(file_path), Network)
+
+ # Safe path refuses this file rather than mis-loading it.
+ with pytest.raises(Exception):
+ load(file_path, weights_only=True)
From ff7b5af7ffc76b1171e47921ea58f15ba02051b4 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Wed, 16 Sep 2026 12:33:06 -0400
Subject: [PATCH 10/14] release: publish 0.3.4 to PyPI
Add a trusted-publishing workflow (runs on GitHub Release or by hand),
drop install requirements nothing imports (Cython, scikit-build, foolbox,
numba), loosen torch/torchvision to >=2.14,<3 / >=0.29,<1, point README
and install docs at pip install bindsnet, and record in CHANGELOG that
PyPI 0.3.4 is master on 2026-09-16, not the 0.3.4 tag.
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/workflows/publish.yml | 51 +++++++
CHANGELOG.md | 18 +++
CONTRIBUTING.md | 11 ++
README.md | 23 ++-
docs/source/installation.rst | 12 +-
poetry.lock | 279 +---------------------------------
pyproject.toml | 12 +-
7 files changed, 116 insertions(+), 290 deletions(-)
create mode 100644 .github/workflows/publish.yml
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000..82c17387
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,51 @@
+name: Publish to PyPI
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.13"
+ - name: Build sdist and wheel
+ run: |
+ python -m pip install --upgrade build twine
+ python -m build
+ python -m twine check dist/*
+ - name: Check release tag matches pyproject version
+ if: github.event_name == 'release'
+ run: |
+ version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['tool']['poetry']['version'])")
+ tag="${GITHUB_REF_NAME#v}"
+ if [ "$version" != "$tag" ]; then
+ echo "Release tag $GITHUB_REF_NAME does not match pyproject version $version"
+ exit 1
+ fi
+ - uses: actions/upload-artifact@v7
+ with:
+ name: dist
+ path: dist/
+
+ publish:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/project/bindsnet/
+ permissions:
+ id-token: write
+ steps:
+ - uses: actions/download-artifact@v7
+ with:
+ name: dist
+ path: dist/
+ - uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01ff4d89..c3e1ea5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,24 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
## [Unreleased]
+## [0.3.4 (PyPI)] - 2026-09-16
+
+First PyPI upload since 0.2.7. It is built from the `master` branch on this date, not
+from the GitHub tag `0.3.4`, so `pip install bindsnet==0.3.4` contains everything in
+this section **in addition to** the tag. The Zenodo archive
+[10.5281/zenodo.20695116](https://doi.org/10.5281/zenodo.20695116) is the tag only.
+Results can differ between the two: see the `MCC_learning.PostPre` entry under Changed.
+
+### Packaging
+- Published to PyPI by `.github/workflows/publish.yml` (PyPI trusted publishing; runs
+ when a GitHub Release is published, or by hand).
+- Removed install requirements that no module in `bindsnet/` or `examples/` imports:
+ `Cython`, `scikit-build`, `foolbox`, `numba`.
+- `torch` is now `>=2.14,<3` and `torchvision` `>=0.29,<1` instead of exact pins;
+ `poetry.lock` still pins the tested versions (torch 2.14.0, torchvision 0.29.0).
+- README: `pip install bindsnet`, PyPI badge, and absolute links and logo URL so the
+ PyPI project page renders.
+
### Added
- Reproducibility/transparency docs: `DATA.md` (dataset & stimulus declaration),
`REPRODUCING.md` (model→script→command→seed map), and a
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1ca0f8ae..804d8e32 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -29,6 +29,17 @@ poetry run pytest
Notable changes are recorded in [`CHANGELOG.md`](CHANGELOG.md); please add an entry to the
`Unreleased` section in your pull request.
+## Releasing to PyPI
+
+1. Set `version` in `pyproject.toml` and `CITATION.cff`, and move the `Unreleased`
+ entries in `CHANGELOG.md` under the new version.
+2. Merge to `master`, then publish a GitHub Release whose tag equals that version.
+3. `.github/workflows/publish.yml` builds the package, checks that the tag matches the
+ `pyproject.toml` version, and uploads to PyPI through trusted publishing (no token).
+ It can also be started by hand from the Actions tab.
+
+A version number can be uploaded to PyPI only once, even after deletion.
+
All development should take place on a branch separate from master. To create a branch, issue
```shell
diff --git a/README.md b/README.md
index 83fb2cbf..6f5b4a96 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
A Python package used for simulating spiking neural networks (SNNs) on CPUs or GPUs using [PyTorch](http://pytorch.org/) `Tensor` functionality.
@@ -9,6 +9,7 @@ This package is used as part of ongoing research on applying SNNs, machine learn
Check out the [BindsNET examples](https://github.com/BindsNET/bindsnet/tree/master/examples) for a collection of experiments, functions for the analysis of results, plots of experiment outcomes, and more. Documentation for the package can be found [here](https://bindsnet-docs.readthedocs.io).
+[](https://pypi.org/project/bindsnet/)
[](https://github.com/BindsNET/bindsnet/actions/workflows/python-app.yml)
[](https://github.com/BindsNET/bindsnet/actions/workflows/github-code-scanning/codeql)
[](https://bindsnet-docs.readthedocs.io/?badge=latest)
@@ -32,7 +33,13 @@ poetry install
Alternatively, the provided `Dockerfile` builds the full pinned stack (see *Using Docker* below).
## Using Pip
-To install the most recent stable release from the GitHub repository
+To install the latest release from [PyPI](https://pypi.org/project/bindsnet/)
+
+```
+pip install bindsnet
+```
+
+To install the current development code from the GitHub repository
```
pip install git+https://github.com/BindsNET/bindsnet.git
@@ -91,7 +98,7 @@ A number of other examples are available in the `examples` directory that are me
Each weight-changing rule is validated against the equations of the paper it
implements. The table of rules, papers, equation numbers and tests, plus the pitfalls
(reward timing, traces, the Diehl & Cook rule versus `PostPre`), is in
-[`bindsnet/learning/README.md`](bindsnet/learning/README.md).
+[`bindsnet/learning/README.md`](https://github.com/BindsNET/bindsnet/blob/master/bindsnet/learning/README.md).
## Running the tests
@@ -108,11 +115,11 @@ Some tests will fail if Open AI `gym` is not installed on your machine.
BindsNET ships no third-party datasets; its loaders fetch them from upstream sources.
Every dataset and synthetic stimulus used by the examples, benchmarks, and dataset
loaders — with source, retrieval method, license pointer, and spike-encoding
-preprocessing — is declared in [DATA.md](DATA.md).
+preprocessing — is declared in [DATA.md](https://github.com/BindsNET/bindsnet/blob/master/DATA.md).
## Reproducing results
-[REPRODUCING.md](REPRODUCING.md) maps each shipped model and published claim to its
+[REPRODUCING.md](https://github.com/BindsNET/bindsnet/blob/master/REPRODUCING.md) maps each shipped model and published claim to its
model class, example script, exact command, seed, and expected output (e.g. the
Diehl & Cook 2015 MNIST replication via `examples/mnist/eth_mnist.py`, and the
Hazan et al. 2018 scaling benchmark).
@@ -169,8 +176,10 @@ The concept DOI below always resolves to the latest version:
> BindsNET contributors. *BindsNET*. Zenodo. https://doi.org/10.5281/zenodo.20695115
(For the exact release used, cite its version DOI; e.g. v0.3.4 is
-[10.5281/zenodo.20695116](https://doi.org/10.5281/zenodo.20695116).) A machine-readable
-citation is provided in [`CITATION.cff`](CITATION.cff).
+[10.5281/zenodo.20695116](https://doi.org/10.5281/zenodo.20695116). That archive is the
+GitHub tag `0.3.4`; the PyPI package `bindsnet==0.3.4` was built later and also
+contains the changes listed in `CHANGELOG.md` under 0.3.4 (PyPI).) A machine-readable
+citation is provided in [`CITATION.cff`](https://github.com/BindsNET/bindsnet/blob/master/CITATION.cff).
## Contributors
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
index e1355d59..f0cd208f 100644
--- a/docs/source/installation.rst
+++ b/docs/source/installation.rst
@@ -5,7 +5,13 @@ Installation
Pip install
-----------
-Issue:
+To install the latest release from `PyPI `_, issue:
+
+.. code-block:: bash
+
+ pip install bindsnet
+
+To install the current development code from GitHub, issue:
.. code-block:: bash
@@ -19,8 +25,8 @@ On \*nix systems, issue one of the following in a shell:
.. code-block:: bash
- git clone https://github.com/Hananel-Hazan/bindsnet.git # HTTPS
- git clone git@github.com:Hananel-Hazan/bindsnet.git # SSH
+ git clone https://github.com/BindsNET/bindsnet.git # HTTPS
+ git clone git@github.com:BindsNET/bindsnet.git # SSH
Change directory into :code:`bindsnet` and issue one of the following:
diff --git a/poetry.lock b/poetry.lock
index 4d8797b8..0cfb28ae 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.4.2 and should not be changed by hand.
[[package]]
name = "ale-py"
@@ -329,7 +329,7 @@ version = "2026.7.22"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
-groups = ["main", "dev"]
+groups = ["dev"]
files = [
{file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"},
{file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"},
@@ -466,7 +466,7 @@ version = "3.5.1"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
-groups = ["main", "dev"]
+groups = ["dev"]
files = [
{file = "charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa"},
{file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda"},
@@ -902,55 +902,6 @@ files = [
docs = ["ipython", "matplotlib", "numpydoc", "sphinx"]
tests = ["pytest", "pytest-cov", "pytest-xdist"]
-[[package]]
-name = "cython"
-version = "3.3.0"
-description = "The Cython compiler for writing C extensions in the Python language."
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "cython-3.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0507d9caf7dc35f1212627145d5d13dbc5dd7128529a6608ab72690472fa688e"},
- {file = "cython-3.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de883ec6764b61547c1e7674c0d8a8a875d398bd6bb684e46b93d83e4f13b260"},
- {file = "cython-3.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda47eb7731c3b41180b58bb83de423f43aa58a677677e3390e8d332b003859e"},
- {file = "cython-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:bf411da3ef1af8763781c219108860f7de33f1100038da35d6bf1b4d83fcb2c0"},
- {file = "cython-3.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ec09dbf73ff4f7be2b339b995fadae9c4bb517bbbed7ec11d6fe99c2092b48fd"},
- {file = "cython-3.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11e437f086affee8051cec4bb531be3edb646ab66e325154aa6849377f365033"},
- {file = "cython-3.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6035b5231a9316edc19d6415f4296fd1d0370e2a165a714b3edc167b9ca00e1"},
- {file = "cython-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8566ea804cfc265f5e9dda71d1b716aa24ee4c3423a5da4b28a248a78c33e3f9"},
- {file = "cython-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03bc5333932f5dda3ba9315298ecdd21daa1b58410bb1f8ce04c78ec8337130a"},
- {file = "cython-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e321ae700995a16dc3055ada06ffb8d61e1a7434e5d0e811547a45ac1015ebd"},
- {file = "cython-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:428fafed98ea26927000a287b4dfc9ef07339f56656a5329a34eaa593f79a4f8"},
- {file = "cython-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:333449cc0350baedee5a6af27929eac8a71eac4ec59333c45ff476b33c6c660d"},
- {file = "cython-3.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:03056533fe4fdbc4f1d34a39178f9a4937ff35196f8bcdde2a67b5b5809c61fe"},
- {file = "cython-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2f2a6b65a991666cfd35a35bab0cd88ffba4df2f601edb6e76cc8116de24b9"},
- {file = "cython-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23942b0662642927a55676e4b26e6840fb166dd7d76436384685227e7e8619a4"},
- {file = "cython-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab24d1a4fb6aaf0b5b6fcd75a6d70255fbd3130fa78884c26991f8d5502616b5"},
- {file = "cython-3.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0deedc2e9a5a664e1adfa4c2d310aa7b54903e1a647c274b6c9213f77a02d637"},
- {file = "cython-3.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46072c0d404616b5e652a63882c79cc3f8a1d62635a8692f56ed0e416a4dfed8"},
- {file = "cython-3.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82f94565b6001bab8e31bf52a0911672910b5735910612a2c0f772c719670006"},
- {file = "cython-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:51999fb834365721b6c7f689cf6e2ec7c8667aae783df9eb5e589c290a414d9c"},
- {file = "cython-3.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:596e8df019372a2cd417805015022d42cb8ee4e1803ccdc11ed00e451625fb66"},
- {file = "cython-3.3.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a36c34d1950845b8ac148653b07cdc62421a4b0d9abfcc849e69f1c4ff9919d"},
- {file = "cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616"},
- {file = "cython-3.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:b55c72e8eccdd508c8de3cf3bbc543aafbb3bf6a518e1ee20358d3241cd780ef"},
- {file = "cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc"},
- {file = "cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f"},
- {file = "cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6"},
- {file = "cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570"},
- {file = "cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38"},
- {file = "cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a"},
- {file = "cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b"},
- {file = "cython-3.3.0-cp39-abi3-win32.whl", hash = "sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081"},
- {file = "cython-3.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd"},
- {file = "cython-3.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:14e825253455e943ca765a95096b355745558436b0c46c24856de9269cc4dbd9"},
- {file = "cython-3.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:843d7134e784e7b320ef387512e89f1b29af80c641e176dfa8eabd52aab61c3c"},
- {file = "cython-3.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26a5e536fc68e85a9de091a0b51c42c5ac834f8d00aaa43f227cbc3efa797ae5"},
- {file = "cython-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:66d86b6a1548ae64851b211e3c3504535814b8c8e6c46ddcaf01062bf8d5fad2"},
- {file = "cython-3.3.0-py3-none-any.whl", hash = "sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1"},
- {file = "cython-3.3.0.tar.gz", hash = "sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8"},
-]
-
[[package]]
name = "debugpy"
version = "1.8.21"
@@ -1015,37 +966,6 @@ files = [
{file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"},
]
-[[package]]
-name = "distro"
-version = "1.9.0"
-description = "Distro - an OS platform information API"
-optional = false
-python-versions = ">=3.6"
-groups = ["main"]
-files = [
- {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"},
- {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
-]
-
-[[package]]
-name = "eagerpy"
-version = "0.30.0"
-description = "EagerPy is a thin wrapper around PyTorch, TensorFlow Eager, JAX and NumPy that unifies their interface and thus allows writing code that works natively across all of them."
-optional = false
-python-versions = "*"
-groups = ["main"]
-files = [
- {file = "eagerpy-0.30.0-py3-none-any.whl", hash = "sha256:79c461b04577f02bf3b48191b2f911b55521204df99ec02288d96bfa34f13d80"},
- {file = "eagerpy-0.30.0.tar.gz", hash = "sha256:014c02b5a7f7e19f8471885cf8aa469f2e9cf518c88400f20b6b8db83d413106"},
-]
-
-[package.dependencies]
-numpy = "*"
-typing-extensions = ">=3.7.4.1"
-
-[package.extras]
-testing = ["pytest (>=5.3.5)", "pytest-cov (>=2.8.1)"]
-
[[package]]
name = "executing"
version = "2.2.1"
@@ -1189,30 +1109,6 @@ type1 = ["xattr ; sys_platform == \"darwin\""]
unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""]
woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"]
-[[package]]
-name = "foolbox"
-version = "3.3.4"
-description = "Foolbox is an adversarial attacks library that works natively with PyTorch, TensorFlow and JAX"
-optional = false
-python-versions = "*"
-groups = ["main"]
-files = [
- {file = "foolbox-3.3.4-py3-none-any.whl", hash = "sha256:11049d515d38e765e206a73ace9cc648b81a0d6d1da9ad9057e149ee117989cb"},
- {file = "foolbox-3.3.4.tar.gz", hash = "sha256:1276cb1c1f636d1e6db08fb0d5cb00ec02144f145465166192dc86a86fce9e1c"},
-]
-
-[package.dependencies]
-eagerpy = ">=0.30.0"
-GitPython = ">=3.0.7"
-numpy = "*"
-requests = ">=2.24.0"
-scipy = "*"
-setuptools = "*"
-typing-extensions = ">=3.7.4.1"
-
-[package.extras]
-testing = ["pytest (>=7.1.1)", "pytest-cov (>=3.0.0)"]
-
[[package]]
name = "fqdn"
version = "1.5.1"
@@ -1265,40 +1161,6 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto
test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd ; python_version < \"3.14\"", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs (>=2026.4.0)", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "s3fs (>=2026.6.0)", "smbprotocol", "tqdm", "urllib3", "zarr (<3.2.0)", "zstandard ; python_version < \"3.14\""]
tqdm = ["tqdm"]
-[[package]]
-name = "gitdb"
-version = "4.0.12"
-description = "Git Object Database"
-optional = false
-python-versions = ">=3.7"
-groups = ["main"]
-files = [
- {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"},
- {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"},
-]
-
-[package.dependencies]
-smmap = ">=3.0.1,<6"
-
-[[package]]
-name = "gitpython"
-version = "3.1.61"
-description = "GitPython is a Python library used to interact with Git repositories"
-optional = false
-python-versions = ">=3.7"
-groups = ["main"]
-files = [
- {file = "gitpython-3.1.61-py3-none-any.whl", hash = "sha256:8ab28c9da863cdd9e7d7694ec46cf3e6c9a12d8a30a1acd3447aec11975d530c"},
- {file = "gitpython-3.1.61.tar.gz", hash = "sha256:f51c24d8c0f733a195447385f5774a5dfe8767f5acfd7994a33755644c6ecc95"},
-]
-
-[package.dependencies]
-gitdb = ">=4.0.1,<5"
-
-[package.extras]
-doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
-test = ["basedpyright (==1.39.9) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
-
[[package]]
name = "gymnasium"
version = "1.3.0"
@@ -1411,7 +1273,7 @@ version = "3.19"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
-groups = ["main", "dev"]
+groups = ["dev"]
files = [
{file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"},
{file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"},
@@ -2167,42 +2029,6 @@ dev = ["changelist (==0.5)", "spin (==0.15)"]
lint = ["pre-commit (==4.3.0)"]
test = ["coverage[toml] (>=7.2)", "pytest (>=8.0)", "pytest-cov (>=5.0)"]
-[[package]]
-name = "llvmlite"
-version = "0.49.0"
-description = "lightweight wrapper around basic LLVM functionality"
-optional = false
-python-versions = ">=3.10"
-groups = ["main"]
-files = [
- {file = "llvmlite-0.49.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ee81e96c15a6f870918f1eb60c913551c16aa23defb4f5f1acfa660d6a0aaac2"},
- {file = "llvmlite-0.49.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:854941c2267fd4fc5b2ce02b8af8ecdffa79fb7784591d3a89370322039ea09f"},
- {file = "llvmlite-0.49.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da7b64474ac15ca595efa2644d5c6836638ccf70709fad3aba3fc56a55966928"},
- {file = "llvmlite-0.49.0-cp310-cp310-win_amd64.whl", hash = "sha256:b352c14353330c879e339b8f8d7491d565fe94242697714a24e80bd757202384"},
- {file = "llvmlite-0.49.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:4b0e710880b7cc910392bd6b9f1bbf468fed99b182e4420d51598f36114b3dce"},
- {file = "llvmlite-0.49.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a8c0fc9d624bdc30a3d2db11eb2fb98f80fb209d20b37604eda516cd9b699cf4"},
- {file = "llvmlite-0.49.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20496a5c9fdb8179fb9300e7d19f6782555d98aeeb4a322264aa7fd99f980618"},
- {file = "llvmlite-0.49.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a5b06c1b5fc4ae4c9b169b065f42b719448ef1f873687ef224ef69969b75ec3"},
- {file = "llvmlite-0.49.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b541c8fac3450db7574d1f53cf9dff83f285bfed9d69bf81fe71fc2a7d4f97fe"},
- {file = "llvmlite-0.49.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6acba646d88abbc87d5c113a3d62c1fbf8b8fee11c6493f516803e30f21ae870"},
- {file = "llvmlite-0.49.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec8ad805e7515cb8440a690eb3cef4d34acb29eef80b705ec4e1c1ad3c43c68"},
- {file = "llvmlite-0.49.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a9c9e3af4e214acfefa4f73ebe7bc3fb35854a62b654edb3953f5ae33c08ba3"},
- {file = "llvmlite-0.49.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:039fa4054a06f537fb39248d4472284ca96be311a142ec09e69f95630ab469cc"},
- {file = "llvmlite-0.49.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddc7aecd4f56397ed6e8f120ec5dcd5a1a8f0e6032ca4af413462792d4dca2e3"},
- {file = "llvmlite-0.49.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dee64784201b64c13a8df62c48a4f4218858faaa65889866bb29bdc243c038"},
- {file = "llvmlite-0.49.0-cp313-cp313-win_amd64.whl", hash = "sha256:a1b414dc6b164738ec39dd8987cea73829057b7dd92fc6d91b52838385fc1dd2"},
- {file = "llvmlite-0.49.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:80a84683d04516bb51da1bbeebddaf2c2f558809c93078a8f91807909ae331f8"},
- {file = "llvmlite-0.49.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4281a0171d66d2098adce4ba706b8c550b1b10718650f682d64cde16e84e4de5"},
- {file = "llvmlite-0.49.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b095f15fb12c4d90495df5b1a3772b4732cc408398b204a787dbedd370e09c69"},
- {file = "llvmlite-0.49.0-cp314-cp314-win_amd64.whl", hash = "sha256:294e2f0b70aef8f92d0ae7b203e2609f08beb39437eee73de59a21669331aae9"},
- {file = "llvmlite-0.49.0-cp314-cp314-win_arm64.whl", hash = "sha256:95d1071023ed858b79f6971954fd7cc1f5dbcbab987718a4ccbe1411e47d0b81"},
- {file = "llvmlite-0.49.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72"},
- {file = "llvmlite-0.49.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5555ea1d63928481cbf7fcb1d67452b216c7e5b393a4eb7aa1401e67f2a4fc4"},
- {file = "llvmlite-0.49.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32adb84fdaae28aeb86fdb6253084ee707ee157289a2e98fe3caf48a62bee82"},
- {file = "llvmlite-0.49.0-cp314-cp314t-win_amd64.whl", hash = "sha256:be637e465010bc9c50f070468f7f1cf5385e92fee364d192dd5e6cea790ecba9"},
- {file = "llvmlite-0.49.0.tar.gz", hash = "sha256:00f16db782f4a13c78c5804aedc434e46794a77e89999a168f9401106270e50a"},
-]
-
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -2627,46 +2453,6 @@ jupyter-server = ">=1.8,<3"
[package.extras]
test = ["pytest", "pytest-console-scripts", "pytest-jupyter", "pytest-tornasync"]
-[[package]]
-name = "numba"
-version = "0.67.0"
-description = "compiling Python code using LLVM"
-optional = false
-python-versions = ">=3.10"
-groups = ["main"]
-files = [
- {file = "numba-0.67.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:8c0e88acd4341ddf40779db3c0228b9188aca7fcab5f5f3ce9949a1fc71e9a02"},
- {file = "numba-0.67.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6c8e9ba3f9602471e8c6f563ffcce8db8046741f0bafb782a052e41dc6b6861"},
- {file = "numba-0.67.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:694c81c6560b2b47e5fc1dc39c29175b907adf862d9af0af801453400a022a61"},
- {file = "numba-0.67.0-cp310-cp310-win_amd64.whl", hash = "sha256:ed333e0af4386294e7f03e550e01411856b6935e717d859225e0a7338c6b6795"},
- {file = "numba-0.67.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3fa3d1b27f96f2c0d54513d953d7197886aa1eaa7d2439a0eedc44d993fb181a"},
- {file = "numba-0.67.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c80c847301dc33dc8f84a97a952004023d9a05578ae4512b087176264cc1960"},
- {file = "numba-0.67.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7a7b0121466f1e9a8a074b0545fe90e16389623abf979b5d7c299dca1294d7e"},
- {file = "numba-0.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfba1ac34f0363fb1a250a10e97240780d11e05227892f7286b26fbfd0ad58ce"},
- {file = "numba-0.67.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6004d8d5f28d4028687fb2d972d629295b13685943bd2ed5cd8810c3b848e219"},
- {file = "numba-0.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f63d43db06b4756424d6d2484737c902e0ae944a0eec3e8b0b4de2c695b15caa"},
- {file = "numba-0.67.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76d3335aaeffb9dc88309420890e73497a00be08a7530441bc2b58ffe025bfa5"},
- {file = "numba-0.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:50e2b72406c18cda5dd7431b0082cb85ea94e06c64c33607248fc8bef92cfb81"},
- {file = "numba-0.67.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:77e1c7173fee57a0d84e006c7e70346689d6cb3e7db503489bae58646b4eff7b"},
- {file = "numba-0.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c4953387c77864b596d8296e2cfbdef82b0eea4166ab4864b05d226c51143e0"},
- {file = "numba-0.67.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88f6e0f5cb6c545e158b6ef0496c01b6d6958a7ccc6634a1576a94bbbab29ff2"},
- {file = "numba-0.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:b68ad5125fe245339cc8dcc036081fc1ea482c5063387b9612a76ccd83dc91cd"},
- {file = "numba-0.67.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f99f880ff25f418a67f9a1d00d0ddfbc63430f627b523e515085a592a7567f4b"},
- {file = "numba-0.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269245a675abdd3e2c35ec6bb2f250355effa9032514d8f2354f0d2d10854bd"},
- {file = "numba-0.67.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f074a8e23db78490f11a3930c940be758316c10ac5985be83d2f298dc080acf7"},
- {file = "numba-0.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4d576e62bf2c9370f61312b51573c4bb1f3fe96798bbab56730847a368a316c4"},
- {file = "numba-0.67.0-cp314-cp314-win_arm64.whl", hash = "sha256:7930748ce8355d2a5a28602abab056a61fdc676d17377f27d17993905428171f"},
- {file = "numba-0.67.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:4a2ed006635bbd0fe45681ed49f3b4f4bad1abf0c233bcc5842c9e3a34cabd61"},
- {file = "numba-0.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa5f002f665bec321b950dacaa26ee009e1d720f6ac9d9856eed5efe1caa03a6"},
- {file = "numba-0.67.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ab968b0e0fa744eba03351282dd8000796e6ec8e4518f47bd3ed86c0a20c7b"},
- {file = "numba-0.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00c964a5b94d3ae82d83ac162cd610755875b98dadb779fdde06e6bfcdbca47e"},
- {file = "numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851"},
-]
-
-[package.dependencies]
-llvmlite = "==0.49.*"
-numpy = ">=1.22,<2.6"
-
[[package]]
name = "numpy"
version = "2.4.6"
@@ -3881,7 +3667,7 @@ version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
-groups = ["main", "dev"]
+groups = ["dev"]
files = [
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
@@ -4068,30 +3854,6 @@ files = [
{file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"},
]
-[[package]]
-name = "scikit-build"
-version = "0.19.1"
-description = "Improved build system generator for Python C/C++/Fortran/Cython extensions"
-optional = false
-python-versions = ">=3.8"
-groups = ["main"]
-files = [
- {file = "scikit_build-0.19.1-py3-none-any.whl", hash = "sha256:a4fa78a7222ca40d5e8bae6700514bbf7efea4346efd9fc1f8cfca837cd1de71"},
- {file = "scikit_build-0.19.1.tar.gz", hash = "sha256:b9a8d07fca2d5d10d93220bc57a685161d72af1fc76285d55c564ddaa862e584"},
-]
-
-[package.dependencies]
-distro = "*"
-packaging = "*"
-setuptools = ">=42.0.0"
-wheel = ">=0.32.0"
-
-[package.extras]
-cov = ["coverage[toml] (>=4.2)", "pytest-cov (>=2.7.1)"]
-docs = ["pygments", "sphinx (>=4)", "sphinx-issues", "sphinx-rtd-theme (>=1.0)", "sphinxcontrib-moderncmakedomain (>=3.19)"]
-doctest = ["ubelt (>=0.8.2)", "xdoctest (>=0.10.0)"]
-test = ["build (>=0.7)", "cython (>=0.25.1)", "pip", "pytest (>=6.0.0)", "pytest-mock (>=1.10.4)", "requests", "virtualenv"]
-
[[package]]
name = "scikit-image"
version = "0.26.0"
@@ -4359,18 +4121,6 @@ files = [
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
-[[package]]
-name = "smmap"
-version = "5.0.3"
-description = "A pure Python implementation of a sliding window memory map manager"
-optional = false
-python-versions = ">=3.7"
-groups = ["main"]
-files = [
- {file = "smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f"},
- {file = "smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c"},
-]
-
[[package]]
name = "soupsieve"
version = "2.9.2"
@@ -4850,7 +4600,7 @@ version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
-groups = ["main", "dev"]
+groups = ["dev"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
@@ -4937,22 +4687,7 @@ docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"]
optional = ["python-socks", "wsaccel"]
test = ["pytest", "websockets"]
-[[package]]
-name = "wheel"
-version = "0.48.0"
-description = "Command line tool for manipulating wheel files"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "wheel-0.48.0-py3-none-any.whl", hash = "sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab"},
- {file = "wheel-0.48.0.tar.gz", hash = "sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322"},
-]
-
-[package.dependencies]
-packaging = ">=24.0"
-
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.14"
-content-hash = "d2b2b82985da02a1edc6c58c7d7e14baa052c80fcc6bc7eec09a69a22abb0067"
+content-hash = "4a3851b17c9568f6a68a4b385e9f69569004d7e6c8606c609a1237ce6d33b789"
diff --git a/pyproject.toml b/pyproject.toml
index 35e05070..3a008089 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,28 +12,24 @@ keywords = ["spiking", "neural", "networks", "pytorch"]
[tool.poetry.dependencies]
python = ">=3.11,<3.14"
numpy = "^2"
-numba = "^0"
scipy = "^1"
-Cython = "^3"
torch = [
- {version = "2.14.0", markers = "sys_platform != 'darwin'", source = "torch+cu130"},
- {version = "2.14.0", markers = "sys_platform == 'darwin'" },
+ {version = ">=2.14,<3", markers = "sys_platform != 'darwin'", source = "torch+cu130"},
+ {version = ">=2.14,<3", markers = "sys_platform == 'darwin'" },
]
torchvision = [
- {version = "0.29.0", markers = "sys_platform != 'darwin'", source = "torch+cu130"},
- {version = "0.29.0", markers = "sys_platform == 'darwin'" },
+ {version = ">=0.29,<1", markers = "sys_platform != 'darwin'", source = "torch+cu130"},
+ {version = ">=0.29,<1", markers = "sys_platform == 'darwin'" },
]
tensorboardX = "^2.6.4"
tqdm = "^4"
matplotlib = "^3"
ale-py = "^0"
gymnasium = {extras = ["atari"], version = "^1"}
-scikit-build = "^0"
scikit-image = "^0"
scikit-learn = "^1"
opencv-python = ">=4.14,<6"
pandas = "^3"
-foolbox = "^3"
[[tool.poetry.source]]
name = "torch+cu130"
From 77beb10261f77246b8d718d3b7d7b8eb3b51f005 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Wed, 16 Sep 2026 12:51:40 -0400
Subject: [PATCH 11/14] ci+packaging: fix CI-only test failures, Poetry 2.4.3,
[project] metadata, working Dockerfile
- test_perf_equivalence: batch-1 fused addmm_ checks accept float32 rounding
(bit equality is CPU dependent; 5 tests failed on GitHub runners) and warn
with the size of the difference.
- Poetry 2.4.3 in CI and docs; metadata moved to [project]; lock resolves to
the same packages; poetry-core>=2.0; unused setup.py removed.
- Dockerfile rewritten (old base image, installer and .python-version gone).
- publish.yml uses download-artifact v8; old repo links fixed.
Co-Authored-By: Claude Opus 5 (1M context)
---
.dockerignore | 8 +++
.github/workflows/publish.yml | 2 +-
.github/workflows/python-app.yml | 2 +-
.github/workflows/pythonpackage.yml | 2 +-
CHANGELOG.md | 18 ++++++
CONTRIBUTING.md | 8 +--
Dockerfile | 87 +++++++++------------------
README.md | 22 +++----
docs/source/quickstart.rst | 2 +-
poetry.lock | 4 +-
pyproject.toml | 69 +++++++++++++--------
setup.py | 6 --
test/network/test_perf_equivalence.py | 28 +++++++--
13 files changed, 141 insertions(+), 117 deletions(-)
create mode 100644 .dockerignore
delete mode 100644 setup.py
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..109e0358
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.git
+**/__pycache__
+*.pyc
+data
+logs
+dist
+build
+.venv
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 82c17387..6b248e8d 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -44,7 +44,7 @@ jobs:
permissions:
id-token: write
steps:
- - uses: actions/download-artifact@v7
+ - uses: actions/download-artifact@v8
with:
name: dist
path: dist/
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 69e7b58f..18d29384 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -25,7 +25,7 @@ jobs:
python-version: 3.13
- name: Install Poetry
env:
- POETRY_VERSION: 2.1.2
+ POETRY_VERSION: 2.4.3
run: |
curl -sSL https://install.python-poetry.org | python - -y &&\
poetry config virtualenvs.create false
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index d153d83f..689080d4 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -22,7 +22,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
env:
- POETRY_VERSION: 2.1.2
+ POETRY_VERSION: 2.4.3
run: |
curl -sSL https://install.python-poetry.org | python - -y
- name: Install dependencies
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3e1ea5a..5f254749 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,24 @@ Results can differ between the two: see the `MCC_learning.PostPre` entry under C
`poetry.lock` still pins the tested versions (torch 2.14.0, torchvision 0.29.0).
- README: `pip install bindsnet`, PyPI badge, and absolute links and logo URL so the
PyPI project page renders.
+- `pyproject.toml` package metadata moved from `[tool.poetry]` to the standard
+ `[project]` table (Poetry 2 deprecates the old one); `[tool.poetry]` now only routes
+ torch/torchvision to the CUDA 13.0 wheel index. `poetry.lock` resolves to the same
+ packages and versions. Build backend `poetry-core>=2.0`; the unused `setup.py` is removed.
+- Poetry 2.4.3 in CI (was 2.1.2) and in `CONTRIBUTING.md` (said 1.1.8; `poetry shell`
+ replaced by `poetry env activate`, which Poetry 2 uses).
+- `Dockerfile` rewritten. The old one could not build: its CUDA 11.1 base image, the
+ `get-poetry.py` installer and the `.python-version` file it copied no longer exist.
+ The new one uses `python:3.13-slim`, Poetry 2.4.3 and `poetry.lock`. The README no
+ longer links the Docker Hub image `hqkhan/bindsnet` (last updated 2019-01-28).
+- Remaining links to the old `Hananel-Hazan/bindsnet` repository point to `BindsNET/bindsnet`.
+
+### Tests
+- `test_perf_equivalence.py`: the batch-1 checks of the fused `addmm_` STDP update
+ required bit-for-bit equality with the un-fused formula. That holds on some CPUs
+ and not on GitHub's CI runners, where 5 tests failed. They now accept float32
+ rounding (the tolerance already used for batch>1) and warn with the size of any
+ difference.
### Added
- Reproducibility/transparency docs: `DATA.md` (dataset & stimulus declaration),
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 804d8e32..465c62cd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,12 +3,12 @@
To clone this project locally, issue
```shell
-git clone https://github.com/Hananel-Hazan/bindsnet.git # clones bindsnet repository
+git clone https://github.com/BindsNET/bindsnet.git # clones bindsnet repository
```
in the directory of your choice. This will place the repository's code in a directory titled `bindsnet`.
-Install the project with [Poetry](https://python-poetry.org/) (current supported version - 1.1.8)
+Install the project with [Poetry](https://python-poetry.org/) (version 2.4.3 is used in CI)
```shell
poetry install
@@ -16,7 +16,7 @@ poetry run pre-commit install
```
-Now you can access the project environment with `poetry shell` or run commands with `poetry run `. For example, `poetry run python examples/mnist/conv_mnist.py`.
+Now you can activate the project environment with `eval $(poetry env activate)` or run commands with `poetry run `. For example, `poetry run python examples/mnist/conv_mnist.py`.
Please make sure the `Poetry` environment is activated when you commit your files! The `git commit` command will invoke `pre-commit`, which is installed with Poetry too. IDEs like PyCharm have plugins for `Poetry` and will activate the environment automatically.
@@ -113,6 +113,6 @@ where `[origin]` is the name of the remote repository, and `[branch-name]` is th
__Note__: See [push.default](https://git-scm.com/docs/git-config#git-config-pushdefault) for more information.
-To merge your changes into the `master` branch (the definitive version of the project's code), open a pull request on the [webpage](https://github.com/Hananel-Hazan/bindsnet) of the project. You can select the `base` branch (typically `master`, to merge changes _into_ the definitive version of the code) and the `compare` branch (say, `dan`, if I added a new feature locally and want to add it to the project code). You may add an optional extended description of your pull request changes. If there are merge conflicts at this stage, you may fix these using GitHub's pull request review interface.
+To merge your changes into the `master` branch (the definitive version of the project's code), open a pull request on the [webpage](https://github.com/BindsNET/bindsnet) of the project. You can select the `base` branch (typically `master`, to merge changes _into_ the definitive version of the code) and the `compare` branch (say, `dan`, if I added a new feature locally and want to add it to the project code). You may add an optional extended description of your pull request changes. If there are merge conflicts at this stage, you may fix these using GitHub's pull request review interface.
Assign reviewer(s) from the group of project contributors to perform a code review of your pull request. If the reviewer(s) are happy with your changes, you may then merge it in to the `master` branch. _Code review is crucial for the development of this project_, as the whole team should be held accountable for all changes.
diff --git a/Dockerfile b/Dockerfile
index 2a4dc25d..b4446b9e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,66 +1,33 @@
-ARG DEPS=development
-ARG NVIDIA_30XX=false
-
-FROM nvidia/cuda:11.1-base AS base-default
-
-ARG DEBIAN_FRONTEND=noninteractive
-RUN apt-get update && apt-get install --no-install-recommends --no-install-suggests -y \
- build-essential libgeos-dev liblzma-dev libssl-dev libbz2-dev curl vim python3.8-dev python-dev git libffi-dev \
- libglib2.0-0 libsm6 libxext6 libblas-dev libatlas-base-dev ffmpeg \
- && rm -rf /var/lib/apt/lists/*
-
-# install pyenv
-ENV PYENV_ROOT=$HOME/.pyenv
-ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH
-RUN curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash
-RUN echo 'eval "$(pyenv init -)"' >> $HOME/.bashrc
-
-# install python version specified in the .python-version file
-COPY .python-version .
-RUN PYTHON_VERSION=$(cat .python-version) pyenv install $PYTHON_VERSION && pyenv global $PYTHON_VERSION && pyenv rehash
-
-# install poetry and our package
-ENV POETRY_NO_INTERACTION=1\
- # send python output directory to stdout
- PYTHONUNBUFFERED=1\
- PIP_NO_CACHE_DIR=off\
- PIP_DISABLE_PIP_VERSION_CHECK=on\
- PIP_DEFAULT_TIMEOUT=100\
- POETRY_HOME="/opt/poetry"\
- VENV_PATH="/opt/pysetup/.venv"\
-
-# install poetry and our package
-ENV POETRY_NO_INTERACTION=1 \
- # send python output directory to stdout
- PYTHONUNBUFFERED=1 \
- PIP_NO_CACHE_DIR=off \
- PIP_DISABLE_PIP_VERSION_CHECK=on \
- PIP_DEFAULT_TIMEOUT=100
-
-ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" POETRY_VERSION=1.1.8
-
-RUN mkdir $HOME/opt/ && \
- curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python - &&\
- poetry config virtualenvs.create false
+# BindsNET with the dependency set pinned in poetry.lock.
+# docker build -t bindsnet . # with dev tools (pytest, black, ...)
+# docker build --build-arg DEPS=main -t bindsnet . # runtime dependencies only
+# docker run --rm -it --gpus all bindsnet bash # --gpus needs the NVIDIA container toolkit
+# The torch wheels from the cu130 index bundle their CUDA libraries, so no CUDA base
+# image is needed; the host only needs an NVIDIA driver.
+FROM python:3.13-slim
+
+ARG DEPS=dev
+ARG POETRY_VERSION=2.4.3
+
+ENV PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ POETRY_NO_INTERACTION=1 \
+ POETRY_VIRTUALENVS_CREATE=false
+
+# libgl1 and libglib2.0-0 are needed by opencv-python.
+RUN apt-get update && apt-get install --no-install-recommends -y \
+ git libgl1 libglib2.0-0 \
+ && rm -rf /var/lib/apt/lists/* \
+ && pip install "poetry==${POETRY_VERSION}"
WORKDIR /bindsnet
-RUN mkdir bindsnet && touch bindsnet/__init__.py ## empty package for Poetry to add to path
+# Dependencies first, so editing the code does not re-download them.
COPY pyproject.toml poetry.lock README.md ./
+RUN if [ "$DEPS" = "main" ]; then poetry install --no-root --only main; \
+ else poetry install --no-root; fi \
+ && rm -rf /root/.cache/pypoetry
-FROM base-default AS base-production
-RUN poetry install --no-dev # this will only install production dependencies
-
-FROM base-default AS base-development
-RUN poetry install
-
-FROM base-${DEPS} AS nvidia-30xx-false
-RUN rm -rf $HOME/.cache/pypoetry/artifacts # remove downloaded wheels
-
-# a fix for NVIDIA 30xx GPUs
-FROM installed AS nvidia-30xx-true
-
-RUN python -m pip install torch==1.9.0+cu111 torchvision==0.10.0+cu111 -f https://download.pytorch.org/whl/torch_stable.html
-
-FROM nvidia-30xx-${NVIDIA_30XX} AS final
COPY . .
+RUN poetry install --only-root
diff --git a/README.md b/README.md
index 6f5b4a96..d812177d 100644
--- a/README.md
+++ b/README.md
@@ -60,26 +60,22 @@ pip install -e .
To install the packages necessary to interface with the [OpenAI gym RL environments library](https://github.com/openai/gym), follow their instructions for installing the packages needed to run the RL environments simulator (on Linux / MacOS).
### Using Docker
-[Link](https://hub.docker.com/r/hqkhan/bindsnet/) to Docker repository.
-
-We also provide a Dockerfile in which BindsNET and all of its dependencies come installed in. Issue
+The `Dockerfile` installs BindsNET with the dependency versions pinned in `poetry.lock`.
+From the top level directory of this project, issue
```
-docker build .
+docker build -t bindsnet .
```
-at the top level directory of this project to create a docker image.
-To change the name of the newly built image, issue
-```
-docker tag
-```
-
-To run a container and get a bash terminal inside it, issue
+(add `--build-arg DEPS=main` to leave out the development tools). To get a bash
+terminal inside a container, issue
```
-docker run -it bash
+docker run --rm -it --gpus all bindsnet bash
```
+`--gpus all` needs the NVIDIA Container Toolkit; leave it out to run on CPU only.
+
## Getting started
To run a near-replication of the SNN from [this paper](https://www.frontiersin.org/articles/10.3389/fncom.2015.00099/full#), issue
@@ -144,7 +140,7 @@ We simulated a network with a population of n Poisson input neurons with firing
Several packages, including BRIAN and PyNEST, allow the setting of certain global preferences; e.g., the number of CPU threads, the number of OpenMP processes, etc. We chose these settings for our benchmark study in an attempt to maximize each library's speed, but note that BindsNET requires no setting of such options. Our approach, inheriting the computational model of PyTorch, appears to make the best use of the available hardware, and therefore makes it simple for practitioners to get the best performance from their system with the least effort.
-
+
All simulations run on Ubuntu 16.04 LTS with Intel(R) Xeon(R) CPU E5-2687W v3 @ 3.10GHz, 128Gb RAM @ 2133MHz, and two GeForce GTX TITAN X (GM200) GPUs. Python 3.6 is used in all cases. Clock time was recorded for each simulation run.
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
index 117c542e..286fa807 100644
--- a/docs/source/quickstart.rst
+++ b/docs/source/quickstart.rst
@@ -2,7 +2,7 @@ Quickstart
==========
Check out some example use cases for BindsNET in the :code:`examples/` folder
-(`link `_). For example, changing directory to
+(`link `_). For example, changing directory to
`[bindsnet-root]/examples/mnist` and running the following will result in a near-replication of the architecture of
`Diehl & Cook 2015 `_:
diff --git a/poetry.lock b/poetry.lock
index 0cfb28ae..01940749 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.4.2 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand.
[[package]]
name = "ale-py"
@@ -4690,4 +4690,4 @@ test = ["pytest", "websockets"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.14"
-content-hash = "4a3851b17c9568f6a68a4b385e9f69569004d7e6c8606c609a1237ce6d33b789"
+content-hash = "634a5f974068c11e11ab0a6a7ed03785e62ce36116d6b911ea590e39e8d86c5c"
diff --git a/pyproject.toml b/pyproject.toml
index 3a008089..9d5844aa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,40 +1,61 @@
-[tool.poetry]
+[project]
name = "bindsnet"
version = "0.3.4"
description = "Spiking neural networks for ML in Python"
-authors = [ "Hananel Hazan ", "Daniel Saunders", "Darpan Sanghavi", "Hassaan Khan" ]
+authors = [
+ { name = "Hananel Hazan", email = "hananel@hazan.org.il" },
+ { name = "Daniel Saunders" },
+ { name = "Darpan Sanghavi" },
+ { name = "Hassaan Khan" },
+]
license = "AGPL-3.0-only"
+license-files = ["LICENSE"]
readme = "README.md"
-repository = "https://github.com/BindsNET/bindsnet"
-documentation = "https://bindsnet-docs.readthedocs.io/"
+requires-python = ">=3.11,<3.14"
keywords = ["spiking", "neural", "networks", "pytorch"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+]
+dependencies = [
+ "numpy>=2,<3",
+ "scipy>=1,<2",
+ "torch>=2.14,<3",
+ "torchvision>=0.29,<1",
+ "tensorboardX>=2.6.4,<3",
+ "tqdm>=4,<5",
+ "matplotlib>=3,<4",
+ "ale-py>=0,<1",
+ "gymnasium[atari]>=1,<2",
+ "scikit-image>=0,<1",
+ "scikit-learn>=1,<2",
+ "opencv-python>=4.14,<6",
+ "pandas>=3,<4",
+]
+
+[project.urls]
+Repository = "https://github.com/BindsNET/bindsnet"
+Documentation = "https://bindsnet-docs.readthedocs.io/"
+Changelog = "https://github.com/BindsNET/bindsnet/blob/master/CHANGELOG.md"
+# Poetry-only detail: on Linux and Windows, install torch/torchvision from the CUDA 13.0
+# wheel index. PyPI users get the torch build that PyPI serves.
[tool.poetry.dependencies]
-python = ">=3.11,<3.14"
-numpy = "^2"
-scipy = "^1"
torch = [
- {version = ">=2.14,<3", markers = "sys_platform != 'darwin'", source = "torch+cu130"},
- {version = ">=2.14,<3", markers = "sys_platform == 'darwin'" },
+ { markers = "sys_platform != 'darwin'", source = "torch+cu130" },
+ { markers = "sys_platform == 'darwin'" },
]
torchvision = [
- {version = ">=0.29,<1", markers = "sys_platform != 'darwin'", source = "torch+cu130"},
- {version = ">=0.29,<1", markers = "sys_platform == 'darwin'" },
+ { markers = "sys_platform != 'darwin'", source = "torch+cu130" },
+ { markers = "sys_platform == 'darwin'" },
]
-tensorboardX = "^2.6.4"
-tqdm = "^4"
-matplotlib = "^3"
-ale-py = "^0"
-gymnasium = {extras = ["atari"], version = "^1"}
-scikit-image = "^0"
-scikit-learn = "^1"
-opencv-python = ">=4.14,<6"
-pandas = "^3"
[[tool.poetry.source]]
- name = "torch+cu130"
- url = "https://download.pytorch.org/whl/cu130"
- priority = "explicit"
+name = "torch+cu130"
+url = "https://download.pytorch.org/whl/cu130"
+priority = "explicit"
[tool.poetry.group.dev.dependencies]
pytest = "^9"
@@ -46,7 +67,7 @@ black = "^26"
autoflake = "^2"
[build-system]
-requires = ["setuptools", "poetry-core>=1.0.0"]
+requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"
[tool.isort]
diff --git a/setup.py b/setup.py
deleted file mode 100644
index bac24a43..00000000
--- a/setup.py
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env python
-
-import setuptools
-
-if __name__ == "__main__":
- setuptools.setup()
diff --git a/test/network/test_perf_equivalence.py b/test/network/test_perf_equivalence.py
index 69ffc329..0735fed5 100644
--- a/test/network/test_perf_equivalence.py
+++ b/test/network/test_perf_equivalence.py
@@ -20,6 +20,8 @@
* ``rank_order`` encoding is vectorised.
"""
+import warnings
+
import pytest
import torch
@@ -102,6 +104,24 @@ def _reference_outer(source, target, batch_size, reduction):
)
+def _assert_matches_fused(actual, expected):
+ """Compare the fused ``addmm_`` result with the un-fused reference formula.
+
+ Both sides call the BLAS library, whose kernel choice depends on the CPU. On
+ some CPUs the two agree bit for bit; on others (GitHub's CI runners, September
+ 2026) they differ by float32 rounding. Accept that rounding (the same tolerance
+ as the batch>1 cases) and report the size of any difference as a warning.
+ """
+ if torch.equal(actual, expected):
+ return
+ diff = (actual - expected).abs().max().item()
+ warnings.warn(
+ f"fused addmm_ differs from the reference formula by rounding only: "
+ f"max abs diff {diff:.3e}"
+ )
+ assert torch.allclose(actual, expected, rtol=1e-6, atol=1e-7), diff
+
+
class TestMulticompartmentDeviceMove:
def test_to_cpu_works(self):
net = DiehlAndCook2015(n_inpt=16, n_neurons=4, inpt_shape=(1, 4, 4))
@@ -132,7 +152,7 @@ def test_postpre_matches_reference(self, batch_size):
conn.update(learning=True)
if batch_size == 1:
- assert torch.equal(conn.w, expected)
+ _assert_matches_fused(conn.w, expected)
else:
assert torch.allclose(conn.w, expected, rtol=1e-6, atol=1e-7)
@@ -150,7 +170,7 @@ def test_hebbian_matches_reference(self, batch_size):
conn.update(learning=True)
if batch_size == 1:
- assert torch.equal(conn.w, expected)
+ _assert_matches_fused(conn.w, expected)
else:
assert torch.allclose(conn.w, expected, rtol=1e-6, atol=1e-7)
@@ -210,7 +230,7 @@ def test_mcc_postpre_matches_reference(self, dt):
expected = (w0 - pre * rule.nu[0] + post * rule.nu[1]).clamp_(0.0, 1.0)
conn.update(learning=True)
- assert torch.equal(conn.pipeline[0].value, expected)
+ _assert_matches_fused(conn.pipeline[0].value, expected)
def test_mcc_hebbian_matches_reference(self):
torch.manual_seed(0)
@@ -239,7 +259,7 @@ def test_mcc_hebbian_matches_reference(self):
expected = (w0 + rule.nu[0] * pre + rule.nu[1] * post).clamp_(0.0, 1.0)
conn.update(learning=True)
- assert torch.equal(conn.pipeline[0].value, expected)
+ _assert_matches_fused(conn.pipeline[0].value, expected)
class TestWeightDecay:
From 878dc91ae8a5895c776de2f0e5a1f230853dcd7f Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Wed, 16 Sep 2026 12:54:32 -0400
Subject: [PATCH 12/14] docs: record CPU-dependent rounding of the fused addmm_
path
Co-Authored-By: Claude Opus 5 (1M context)
---
CLAUDE.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 0825da90..539b1fab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,4 +61,7 @@ formula it replaced (`test/network/test_perf_equivalence.py`). Before claiming
"no change in results", run the same seeded networks on the old code (a git
worktree of the previous commit) and the new code and compare with
`torch.equal`; only batch>1 summation-order differences (about 1e-7) are
-acceptable, and must be stated. Benchmark: `examples/benchmark/hot_path_bench.py`.
+acceptable, and must be stated. Exception: the batch-1 fused `addmm_` STDP update
+matches the un-fused formula bit for bit on some CPUs but not on GitHub's CI runners
+(max abs diff 1.5e-8 to 3.0e-8, CI run 35124573539, 2026-09-16), so those tests use
+the same tolerance and warn with the size. Benchmark: `examples/benchmark/hot_path_bench.py`.
From 2a0a8418195fb8e4beb5097f536f37c520d3de14 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Wed, 16 Sep 2026 13:18:50 -0400
Subject: [PATCH 13/14] docs+ci: make the API reference build, consolidate CI,
fix outdated README
- Read the Docs never installed bindsnet, so every API page was empty; install
CPU torch + the package, drop docs/pyproject.toml (it downgraded Sphinx).
- Docs build now has zero warnings under -W: docstring markup fixes only,
broken rst links, version from package metadata, 5 missing modules added.
- CI: single test workflow (build on 3.13 + 3.11/3.12 matrix, Poetry cache,
concurrency), black checked with the locked version, pythonpackage.yml
removed; required check names build and lint unchanged.
- Dependabot tracks the Docker base image; pre-commit config added.
- README: dead Markram link -> DOI, Breakout not Space Invaders, Gymnasium
instead of OpenAI gym, benchmark dated, badge refresh.
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/dependabot.yml | 18 ++++++--
.github/workflows/black.yml | 19 +++++++-
.github/workflows/python-app.yml | 64 +++++++++++++++-----------
.github/workflows/pythonpackage.yml | 36 ---------------
.pre-commit-config.yaml | 10 ++++
.readthedocs.yaml | 30 ++++--------
CHANGELOG.md | 31 +++++++++++++
README.md | 20 ++++----
bindsnet/analysis/plotting.py | 4 +-
bindsnet/conversion/conversion.py | 4 +-
bindsnet/datasets/davis.py | 3 +-
bindsnet/datasets/preprocess.py | 4 +-
bindsnet/encoding/encoders.py | 2 +-
bindsnet/environment/cue_reward.py | 4 +-
bindsnet/environment/dot_simulator.py | 19 ++++----
bindsnet/learning/learning.py | 2 +
bindsnet/network/monitors.py | 6 +--
bindsnet/network/nodes.py | 2 +-
bindsnet/network/topology.py | 38 +++++++++++----
bindsnet/network/topology_features.py | 12 ++++-
docs/pyproject.toml | 11 -----
docs/requirements.txt | 15 ++----
docs/source/bindsnet.analysis.rst | 9 ++++
docs/source/bindsnet.conversion.rst | 1 +
docs/source/bindsnet.datasets.rst | 1 +
docs/source/bindsnet.encoding.rst | 1 +
docs/source/bindsnet.environment.rst | 17 +++++++
docs/source/bindsnet.evaluation.rst | 1 +
docs/source/bindsnet.learning.rst | 9 ++++
docs/source/bindsnet.models.rst | 1 +
docs/source/bindsnet.network.rst | 9 ++++
docs/source/bindsnet.pipeline.rst | 1 +
docs/source/bindsnet.preprocessing.rst | 1 +
docs/source/bindsnet.rst | 1 +
docs/source/conf.py | 27 +++++------
docs/source/guide/guide_part_i.rst | 4 +-
docs/source/index.rst | 5 +-
docs/source/modules.rst | 7 ---
pyproject.toml | 2 +-
39 files changed, 264 insertions(+), 187 deletions(-)
delete mode 100644 .github/workflows/pythonpackage.yml
create mode 100644 .pre-commit-config.yaml
delete mode 100644 docs/pyproject.toml
delete mode 100644 docs/source/modules.rst
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 3b800f0b..89a7a70b 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -21,11 +21,10 @@ updates:
- "minor"
- "patch"
ignore:
- # torch and torchvision are pinned to exact versions served from the
- # custom CUDA wheel index declared in pyproject.toml, and the two must
- # move together. Routine version bumps here would break that pairing or
- # silently pull the plain PyPI build instead, so they are upgraded by
- # hand. These conditions cover version updates only; Dependabot security
+ # torch and torchvision are locked (poetry.lock) to builds from the CUDA
+ # wheel index declared in pyproject.toml, and docs/requirements.txt pins
+ # the matching CPU builds. The two must move together, and a routine bump
+ # could pull the plain PyPI build instead, so they are upgraded by hand. These conditions cover version updates only; Dependabot security
# alerts for torch and torchvision still come through.
- dependency-name: "torch"
update-types:
@@ -53,3 +52,12 @@ updates:
github-actions:
patterns:
- "*"
+
+ # Base image of the Dockerfile.
+ - package-ecosystem: "docker"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "monday"
+ labels:
+ - "dependencies"
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
index c2e70ed9..446073ee 100644
--- a/.github/workflows/black.yml
+++ b/.github/workflows/black.yml
@@ -1,14 +1,29 @@
+# Formatting check. Branch protection on master requires the job named "lint"; keep that name.
name: Black Formater
-on: [push, pull_request]
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
permissions:
contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- - uses: psf/black@stable
\ No newline at end of file
+ with:
+ python-version: "3.13"
+ # Use the black version pinned in poetry.lock, so CI and local formatting agree.
+ - name: Check formatting with black
+ run: |
+ version=$(python -c "import tomllib; print(next(p['version'] for p in tomllib.load(open('poetry.lock','rb'))['package'] if p['name']=='black'))")
+ pipx run "black==${version}" --check --diff .
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 18d29384..148d289e 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -1,6 +1,4 @@
-# This workflow will install Python dependencies, run tests and lint with a single version of Python
-# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
-
+# Tests. Branch protection on master requires the job named "build"; keep that name.
name: BindsNET build status
on:
@@ -8,39 +6,53 @@ on:
branches: [ master ]
pull_request:
branches: [ master ]
+ workflow_dispatch:
permissions:
contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ POETRY_VERSION: "2.4.3"
+
jobs:
build:
-
+ name: build
runs-on: ubuntu-latest
-
steps:
- uses: actions/checkout@v7
- - name: Set up Python 3.13
- uses: actions/setup-python@v7
+ - name: Install Poetry
+ run: pipx install "poetry==${POETRY_VERSION}"
+ - uses: actions/setup-python@v7
with:
- python-version: 3.13
+ python-version: "3.13"
+ cache: poetry
+ - name: Install dependencies
+ run: poetry install
+ - name: Lint with flake8 (syntax errors and undefined names)
+ run: pipx run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+ - name: Test with pytest
+ run: poetry run pytest
+
+ # The other supported Python versions (pyproject.toml: >=3.11,<3.14).
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.11", "3.12"]
+ steps:
+ - uses: actions/checkout@v7
- name: Install Poetry
- env:
- POETRY_VERSION: 2.4.3
- run: |
- curl -sSL https://install.python-poetry.org | python - -y &&\
- poetry config virtualenvs.create false
+ run: pipx install "poetry==${POETRY_VERSION}"
+ - uses: actions/setup-python@v7
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: poetry
- name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install flake8 pytest
- # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- poetry install
- - name: Lint with flake8
- run: |
- # stop the build if there are Python syntax errors or undefined names
- flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
- # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+ run: poetry install
- name: Test with pytest
- run: |
- pytest
+ run: poetry run pytest
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
deleted file mode 100644
index 689080d4..00000000
--- a/.github/workflows/pythonpackage.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Python package
-
-on: [push]
-
-permissions:
- contents: read
-
-jobs:
- build:
-
- runs-on: ubuntu-latest
- strategy:
- max-parallel: 4
- matrix:
- python-version: ["3.11", "3.12", "3.13"]
-
- steps:
- - uses: actions/checkout@v7
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v7
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install Poetry
- env:
- POETRY_VERSION: 2.4.3
- run: |
- curl -sSL https://install.python-poetry.org | python - -y
- - name: Install dependencies
- run: |
- poetry install
- - name: Format with black
- run: |
- poetry run black .
- - name: Test with pytest
- run: |
- poetry run pytest
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000..d648c1bd
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,10 @@
+# Installed by `poetry run pre-commit install` (see CONTRIBUTING.md).
+# Uses the black installed by Poetry, so the version always matches poetry.lock and CI.
+repos:
+ - repo: local
+ hooks:
+ - id: black
+ name: black
+ entry: poetry run black
+ language: system
+ types: [python]
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 5326b936..cf020d29 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -1,39 +1,25 @@
-# .readthedocs.yaml
# Read the Docs configuration file
-# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
-
-# Required
+# https://docs.readthedocs.io/en/stable/config-file/v2.html
version: 2
-# Set the version of Python and other tools you might need
build:
- os: ubuntu-22.04
+ os: ubuntu-24.04
tools:
- python: "3.11"
+ python: "3.13"
-# Build documentation in the docs/ directory with Sphinx
sphinx:
builder: html
configuration: docs/source/conf.py
-
+
formats:
- epub
- pdf
-# We recommend specifying your dependencies to enable reproducible builds:
-# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+# autodoc imports bindsnet, so the package and its dependencies must be installed.
+# docs/requirements.txt installs the CPU build of torch first (much smaller than the
+# CUDA build); installing the package then keeps that torch.
python:
install:
- requirements: docs/requirements.txt
- method: pip
- path: docs/
- # extra_requirements:
- # - docs
-
-# python:
- # version: 3.8
- # install:
- # - method: pip
- # path: .
- # - requirements: docs/requirements.txt
- # system_packages: False
\ No newline at end of file
+ path: .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f254749..11c330a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,37 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
## [Unreleased]
+### Fixed
+- 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
+ CPU build of torch and the package (Python 3.13, Ubuntu 24.04);
+ `docs/pyproject.toml` removed.
+- The docs build has no warnings (was 48 on Read the Docs, 53 with the package
+ installed): docstring markup fixed in `topology.py`, `topology_features.py`,
+ `monitors.py`, `learning.py`, `nodes.py`, `encoders.py`, `plotting.py`,
+ `conversion.py`, `davis.py`, `preprocess.py`, `cue_reward.py`, `dot_simulator.py`;
+ broken links in `index.rst` and the guide; `conf.py` takes the version from the
+ installed package. Docstring text only; no code changed.
+- API reference now includes `learning.MCC_learning`, `network.topology_features`,
+ `environment.cue_reward`, `environment.dot_simulator` and
+ `analysis.dotTrace_plotter`, which were missing.
+
+### Changed
+- CI: one test workflow (`python-app.yml`: job `build` on Python 3.13 plus a 3.11/3.12
+ matrix, Poetry 2.4.3 with dependency caching, superseded runs cancelled);
+ `pythonpackage.yml` removed (it ran on every push to every branch and its
+ `black .` step reformatted instead of checking). `black.yml` checks with the black
+ version from `poetry.lock` instead of the floating `psf/black@stable`.
+- Dependabot also updates the Dockerfile base image.
+- Added `.pre-commit-config.yaml` (black from Poetry); `CONTRIBUTING.md` already told
+ contributors to install pre-commit, but there was no configuration.
+- `[tool.black] target-version` is `py311`-`py313` (was `py38`); no file changes.
+- README: dead link to Markram et al. (1997) replaced with its DOI; RL example named
+ correctly (Breakout, not Space Invaders); OpenAI gym text replaced (Gymnasium and
+ ale-py install with BindsNET); benchmark marked as from the 2018 paper; PyPI badge
+ refreshes hourly.
+
## [0.3.4 (PyPI)] - 2026-09-16
First PyPI upload since 0.2.7. It is built from the `master` branch on this date, not
diff --git a/README.md b/README.md
index d812177d..1d8ed5c1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@

-A Python package used for simulating spiking neural networks (SNNs) on CPUs or GPUs using [PyTorch](http://pytorch.org/) `Tensor` functionality.
+A Python package used for simulating spiking neural networks (SNNs) on CPUs or GPUs using [PyTorch](https://pytorch.org/) `Tensor` functionality.
BindsNET is a spiking neural network simulation library geared towards the development of biologically inspired algorithms for machine learning.
@@ -9,7 +9,7 @@ This package is used as part of ongoing research on applying SNNs, machine learn
Check out the [BindsNET examples](https://github.com/BindsNET/bindsnet/tree/master/examples) for a collection of experiments, functions for the analysis of results, plots of experiment outcomes, and more. Documentation for the package can be found [here](https://bindsnet-docs.readthedocs.io).
-[](https://pypi.org/project/bindsnet/)
+[](https://pypi.org/project/bindsnet/)
[](https://github.com/BindsNET/bindsnet/actions/workflows/python-app.yml)
[](https://github.com/BindsNET/bindsnet/actions/workflows/github-code-scanning/codeql)
[](https://bindsnet-docs.readthedocs.io/?badge=latest)
@@ -57,7 +57,7 @@ Or, to install in editable mode (allows modification of package without re-insta
pip install -e .
```
-To install the packages necessary to interface with the [OpenAI gym RL environments library](https://github.com/openai/gym), follow their instructions for installing the packages needed to run the RL environments simulator (on Linux / MacOS).
+The reinforcement-learning environments use [Gymnasium](https://gymnasium.farama.org/) with the Arcade Learning Environment ([ale-py](https://github.com/Farama-Foundation/Arcade-Learning-Environment)); both are installed with BindsNET.
### Using Docker
The `Dockerfile` installs BindsNET with the dependency versions pinned in `poetry.lock`.
@@ -104,8 +104,6 @@ Issue the following to run the tests:
python -m pytest test/
```
-Some tests will fail if Open AI `gym` is not installed on your machine.
-
## Datasets
BindsNET ships no third-party datasets; its loaders fetch them from upstream sources.
@@ -122,17 +120,17 @@ Hazan et al. 2018 scaling benchmark).
## Background
-The simulation of biologically plausible spiking neuron dynamics can be challenging. It is typically done by solving ordinary differential equations (ODEs) which describe said dynamics. PyTorch does not explicitly support the solution of differential equations (as opposed to [`brian2`](https://github.com/brian-team/brian2), for example), but we can convert the ODEs defining the dynamics into difference equations and solve them at regular, short intervals (a `dt` on the order of 1 millisecond) as an approximation. Of course, under the hood, packages like `brian2` are doing the same thing. Doing this in [`PyTorch`](http://pytorch.org/) is exciting for a few reasons:
+The simulation of biologically plausible spiking neuron dynamics can be challenging. It is typically done by solving ordinary differential equations (ODEs) which describe said dynamics. PyTorch does not explicitly support the solution of differential equations (as opposed to [`brian2`](https://github.com/brian-team/brian2), for example), but we can convert the ODEs defining the dynamics into difference equations and solve them at regular, short intervals (a `dt` on the order of 1 millisecond) as an approximation. Of course, under the hood, packages like `brian2` are doing the same thing. Doing this in [`PyTorch`](https://pytorch.org/) is exciting for a few reasons:
-1. We can use the powerful and flexible [`torch.Tensor`](http://pytorch.org/) object, a wrapper around the [`numpy.ndarray`](https://docs.scipy.org/doc/numpy-1.13.0/reference/generated/numpy.ndarray.html) which can be transferred to and from GPU devices.
+1. We can use the powerful and flexible [`torch.Tensor`](https://pytorch.org/docs/stable/tensors.html) object, an array similar to the [`numpy.ndarray`](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html) that can be moved to and from GPU devices.
-2. We can avoid "reinventing the wheel" by repurposing functions from the [`torch.nn.functional`](http://pytorch.org/docs/master/nn.html#torch-nn-functional) PyTorch submodule in our SNN architectures; e.g., convolution or pooling functions.
+2. We can avoid "reinventing the wheel" by repurposing functions from the [`torch.nn.functional`](https://pytorch.org/docs/stable/nn.functional.html) PyTorch submodule in our SNN architectures; e.g., convolution or pooling functions.
-The concept that the neuron spike ordering and their relative timing encode information is a central theme in neuroscience. [Markram et al. (1997)](http://www.caam.rice.edu/~caam415/lec_gab/g4/markram_etal98.pdf) proposed that synapses between neurons should strengthen or degrade based on this relative timing, and prior to that, [Donald Hebb](https://en.wikipedia.org/wiki/Donald_O._Hebb) proposed the theory of Hebbian learning, often simply stated as "Neurons that fire together, wire together." Markram et al.'s extension of the Hebbian theory is known as spike-timing-dependent plasticity (STDP).
+The concept that the neuron spike ordering and their relative timing encode information is a central theme in neuroscience. [Markram et al. (1997)](https://doi.org/10.1126/science.275.5297.213) proposed that synapses between neurons should strengthen or degrade based on this relative timing, and prior to that, [Donald Hebb](https://en.wikipedia.org/wiki/Donald_O._Hebb) proposed the theory of Hebbian learning, often simply stated as "Neurons that fire together, wire together." Markram et al.'s extension of the Hebbian theory is known as spike-timing-dependent plasticity (STDP).
We are interested in applying SNNs to ML and RL problems. We use STDP to modify weights of synapses connecting pairs or populations of neurons in SNNs. In the context of ML, we want to learn a setting of synapse weights which will generate data-dependent spiking activity in SNNs. This activity will allow us to subsequently perform some ML task of interest; e.g., discriminating or clustering input data. In the context of RL, we may think of the spiking neural network as an RL agent, whose spiking activity may be converted into actions in an environment's action space.
-We have provided some simple starter scripts for doing unsupervised learning (learning a fully-connected or convolutional representation via STDP), supervised learning (clamping output neurons to desired spiking behavior depending on data labels), and reinforcement learning (converting observations from the Atari game Space Invaders to input to an SNN, and converting network activity back to actions in the game).
+We have provided some simple starter scripts for doing unsupervised learning (learning a fully-connected or convolutional representation via STDP), supervised learning (clamping output neurons to desired spiking behavior depending on data labels), and reinforcement learning (converting observations from the Atari game Breakout to input to an SNN, and converting network activity back to actions in the game; see `examples/breakout`).
## Benchmarking
We simulated a network with a population of n Poisson input neurons with firing rates (in Hertz) drawn randomly from U(0, 100), connected all-to-all with a equally-sized population of leaky integrate-and-fire (LIF) neurons, with connection weights sampled from N(0,1). We varied n systematically from 250 to 10,000 in steps of 250, and ran each simulation with every library for 1,000ms with a time resolution dt = 1.0. We tested BindsNET (with CPU and GPU computation), BRIAN2, PyNEST (the Python interface to the NEST SLI interface that runs the C++NEST core simulator), ANNarchy (with CPU and GPU computation), and BRIAN2genn (the BRIAN2 front-end to the GeNN simulator).
@@ -143,7 +141,7 @@ Several packages, including BRIAN and PyNEST, allow the setting of certain globa
-All simulations run on Ubuntu 16.04 LTS with Intel(R) Xeon(R) CPU E5-2687W v3 @ 3.10GHz, 128Gb RAM @ 2133MHz, and two GeForce GTX TITAN X (GM200) GPUs. Python 3.6 is used in all cases. Clock time was recorded for each simulation run.
+These results are from the 2018 BindsNET paper. All simulations run on Ubuntu 16.04 LTS with Intel(R) Xeon(R) CPU E5-2687W v3 @ 3.10GHz, 128Gb RAM @ 2133MHz, and two GeForce GTX TITAN X (GM200) GPUs. Python 3.6 is used in all cases. Clock time was recorded for each simulation run.
## Citation
diff --git a/bindsnet/analysis/plotting.py b/bindsnet/analysis/plotting.py
index 2c2ebc04..e9f4e9f3 100644
--- a/bindsnet/analysis/plotting.py
+++ b/bindsnet/analysis/plotting.py
@@ -337,7 +337,7 @@ def plot_locally_connected_weights(
# language=rst
"""
Plot a connection weight matrix of a :code:`Connection` with `locally connected
- structure _.
+ structure `_.
:param weights: Weight matrix of Conv2dConnection object.
:param n_filters: No. of convolution kernels in use.
@@ -415,7 +415,7 @@ def plot_local_connection_2d_weights(
# language=rst
"""
Plot a connection weight matrix of a :code:`Connection` with `locally connected
- structure _.
+ structure `_.
:param lc: An object of the class LocalConnection2D
:param input_channel: The input channel to plot its corresponding weights, default is the first channel
:param output_channel: If not None, will only plot the weights corresponding to this output channel (filter)
diff --git a/bindsnet/conversion/conversion.py b/bindsnet/conversion/conversion.py
index 3febb0f2..9728b566 100644
--- a/bindsnet/conversion/conversion.py
+++ b/bindsnet/conversion/conversion.py
@@ -66,8 +66,8 @@ def forward(self, x: torch.Tensor) -> Dict[nn.Module, torch.Tensor]:
"""
Forward pass of the feature extractor.
- :param x: Input data for the ``submodule''.
- :return: A dictionary mapping
+ :param x: Input data for the ``submodule``.
+ :return: A dictionary mapping the name of each layer to its output.
"""
activations = {"input": x}
for name, module in self.submodule._modules.items():
diff --git a/bindsnet/datasets/davis.py b/bindsnet/datasets/davis.py
index c494d894..1c9a27fe 100644
--- a/bindsnet/datasets/davis.py
+++ b/bindsnet/datasets/davis.py
@@ -34,7 +34,8 @@ def __init__(
):
# language=rst
"""
- Class to read the DAVIS dataset
+ Class to read the DAVIS dataset.
+
:param root: Path to the DAVIS folder that contains JPEGImages, Annotations,
etc. folders.
:param task: Task to load the annotations, choose between semi-supervised or
diff --git a/bindsnet/datasets/preprocess.py b/bindsnet/datasets/preprocess.py
index 37ce1dc5..2a716e2f 100644
--- a/bindsnet/datasets/preprocess.py
+++ b/bindsnet/datasets/preprocess.py
@@ -62,8 +62,8 @@ def subsample(image: np.ndarray, x: int, y: int) -> np.ndarray:
class Rescale(object):
"""Rescale image and bounding box.
- Args:
- output_size (tuple or int): Desired output size. If int, square crop
+
+ :param output_size: Desired output size (tuple or int). If int, square crop
is made.
"""
diff --git a/bindsnet/encoding/encoders.py b/bindsnet/encoding/encoders.py
index c6a91e1e..6949f86e 100644
--- a/bindsnet/encoding/encoders.py
+++ b/bindsnet/encoding/encoders.py
@@ -90,7 +90,7 @@ def __init__(self, time: int, dt: float = 1.0, approx: bool = False, **kwargs):
# language=rst
"""
Creates a callable PoissonEncoder which encodes as defined in
- ``bindsnet.encoding.poisson`
+ ``bindsnet.encoding.poisson``.
:param time: Length of Poisson spike train per input variable.
:param dt: Simulation time step.
diff --git a/bindsnet/environment/cue_reward.py b/bindsnet/environment/cue_reward.py
index 113e6f55..e0b2aa60 100644
--- a/bindsnet/environment/cue_reward.py
+++ b/bindsnet/environment/cue_reward.py
@@ -11,8 +11,8 @@
class CueRewardSimulator:
"""
This simulator provides basic cues and rewards according to the
- network's choice, as described in the Backpropamine paper:
- https://openreview.net/pdf?id=r1lrAiA5Ym
+ network's choice, as described in the Backpropamine paper
+ (https://openreview.net/pdf?id=r1lrAiA5Ym).
:param epdur: int: duration (timesteps) of an episode; default = 200
:param cuebits: int: max number of bits to hold a cue (max value = 2^n for n bits)
diff --git a/bindsnet/environment/dot_simulator.py b/bindsnet/environment/dot_simulator.py
index 69ef0321..922e2719 100644
--- a/bindsnet/environment/dot_simulator.py
+++ b/bindsnet/environment/dot_simulator.py
@@ -73,16 +73,15 @@ class DotSimulator:
:param fpath: string: optional file path for saving grids to file
:param diag: Bool: allow diagonal movement.
:param bound_hand: str: bounds handling when a dot reaches the world's end.
- 'stay': dots will simply be prevented from crossing the edges.
- 'bounce': dot positions and directions will be reflected.
- 'trans': dot positions will be mirrored to the opposite edge.
- :param fit_func: str: Fitness function.
- 'euc': Single Euclidean (Pythagorean) distance value
- 'disp': Tuple of x,y displacement values
- 'rng' : Range rings--the closer the ring, the lower the number
- 'dir' : directional--+1 if moving in the right direction
- -1 if moving in the wrong direction
- 0 if neither.
+ ``'stay'``: dots are prevented from crossing the edges.
+ ``'bounce'``: dot positions and directions are reflected.
+ ``'trans'``: dot positions are mirrored to the opposite edge.
+ :param fit_func: str: fitness function.
+ ``'euc'``: single Euclidean (Pythagorean) distance value.
+ ``'disp'``: tuple of x, y displacement values.
+ ``'rng'``: range rings; the closer the ring, the lower the number.
+ ``'dir'``: directional; +1 if moving in the right direction, -1 if moving
+ in the wrong direction, 0 if neither.
:param ring_size: int: set range ring size for range ring fitness function.
:param bullseye: int: set reward for successful intercept; default = 10.0
:param teleport: Bool: teleport network dot after intercept; default = true
diff --git a/bindsnet/learning/learning.py b/bindsnet/learning/learning.py
index cf45ed9a..a4017fa7 100644
--- a/bindsnet/learning/learning.py
+++ b/bindsnet/learning/learning.py
@@ -2147,7 +2147,9 @@ def __init__(
:param reduction: Method for reducing parameter updates along the minibatch
dimension.
:param weight_decay: Coefficient controlling rate of decay of the weights each iteration.
+
Keyword arguments:
+
:param float tc_plus: Time constant for pre-synaptic firing trace.
:param float tc_minus: Time constant for post-synaptic firing trace.
:param float tc_e_trace: Time constant for the eligibility trace.
diff --git a/bindsnet/network/monitors.py b/bindsnet/network/monitors.py
index d91e6420..42cccd25 100644
--- a/bindsnet/network/monitors.py
+++ b/bindsnet/network/monitors.py
@@ -79,8 +79,8 @@ def get(self, var: str) -> torch.Tensor:
:param var: State variable recording to return.
:return: Tensor of shape ``[time, n_1, ..., n_k]``, where ``[n_1, ..., n_k]`` is the shape of the recorded state
- variable.
- Note, if time == `None`, get return the logs and empty the monitor variable
+ variable. If ``time`` is ``None``, the logs are returned and the monitor
+ is emptied.
"""
if self.clean:
@@ -119,7 +119,7 @@ def record(self) -> None:
def reset_state_variables(self) -> None:
# language=rst
"""
- Resets recordings to empty ``List``s.
+ Resets recordings to empty lists.
"""
if self.time is None:
self.recording = {v: [] for v in self.state_vars}
diff --git a/bindsnet/network/nodes.py b/bindsnet/network/nodes.py
index 1fcdd3af..6609c215 100644
--- a/bindsnet/network/nodes.py
+++ b/bindsnet/network/nodes.py
@@ -1171,7 +1171,7 @@ def set_batch_size(self, batch_size) -> None:
class IzhikevichNodes(Nodes):
# language=rst
"""
- Layer of `Izhikevich neurons`_.
+ Layer of `Izhikevich neurons `_.
"""
def __init__(
diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py
index 9c342a6c..ba5812eb 100644
--- a/bindsnet/network/topology.py
+++ b/bindsnet/network/topology.py
@@ -35,7 +35,7 @@ def __init__(
:param source: A layer of nodes from which the connection originates.
:param target: A layer of nodes to which the connection connects.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch
@@ -246,6 +246,7 @@ def insert_pipeline(self, feature, index) -> None:
# language=rst
"""
insert a feature into the pipeline
+
:param index: Index for where to insert the feature
"""
self.pipeline.insert(feature, index)
@@ -256,6 +257,7 @@ def remove_pipeline(self, feature) -> None:
# language=rst
"""
remove a feature frome the pipeline
+
:param feature: feature to be removed
"""
self.pipeline.remove(feature)
@@ -314,7 +316,7 @@ def __init__(
:param source: A layer of nodes from which the connection originates.
:param target: A layer of nodes to which the connection connects.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch
@@ -671,7 +673,7 @@ def __init__(
:param stride: stride for convolution.
:param padding: padding for convolution.
:param dilation: dilation for convolution.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch
@@ -817,7 +819,7 @@ def __init__(
:param stride: Horizontal and vertical stride for convolution.
:param padding: Horizontal and vertical padding for convolution.
:param dilation: Horizontal and vertical dilation for convolution.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch
@@ -978,7 +980,7 @@ def __init__(
:param stride: Depth-wise, horizontal, and vertical stride for convolution.
:param padding: Depth-wise, horizontal, and vertical padding for convolution.
:param dilation: Depth-wise, horizontal and vertical dilation for convolution.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch
@@ -1439,7 +1441,7 @@ def __init__(
:param kernel_size: Horizontal and vertical size of convolutional kernels.
:param stride: Horizontal and vertical stride for convolution.
:param n_filters: Number of locally connected filters per pre-synaptic region.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch
@@ -1613,18 +1615,21 @@ def __init__(
if there are `n_conv` neurons in each post-synaptic patch, then the first
`n_conv` neurons in the post-synaptic population correspond to the first
receptive field, the second ``n_conv`` to the second receptive field, and so on.
+
:param source: A layer of nodes from which the connection originates.
:param target: A layer of nodes to which the connection connects.
:param kernel_size: size of convolutional kernels.
:param stride: stride for convolution.
:param n_filters: Number of locally connected filters per pre-synaptic region.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch dimension.
:param weight_decay: Constant multiple to decay weights by on each iteration.
:param w_dtype: Data type for :code:`w` tensor
+
Keyword arguments:
+
:param LearningRule update_rule: Modifies connection parameters according to some rule.
:param torch.Tensor w: Strengths of synapses.
:param torch.Tensor b: Target population bias.
@@ -1675,6 +1680,7 @@ def __init__(
def compute(self, s: torch.Tensor) -> torch.Tensor:
"""
Compute pre-activations given spikes using layer weights.
+
:param s: Incoming spikes.
:return: Incoming spikes multiplied by synaptic weights (with or without
decaying spike activation).
@@ -1749,18 +1755,21 @@ def __init__(
if there are `n_conv` neurons in each post-synaptic patch, then the first
`n_conv` neurons in the post-synaptic population correspond to the first
receptive field, the second ``n_conv`` to the second receptive field, and so on.
+
:param source: A layer of nodes from which the connection originates.
:param target: A layer of nodes to which the connection connects.
:param kernel_size: Horizontal and vertical size of convolutional kernels.
:param stride: Horizontal and vertical stride for convolution.
:param n_filters: Number of locally connected filters per pre-synaptic region.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch dimension.
:param weight_decay: Constant multiple to decay weights by on each iteration.
:param w_dtype: Data type for :code:`w` tensor
+
Keyword arguments:
+
:param LearningRule update_rule: Modifies connection parameters according to some rule.
:param torch.Tensor w: Strengths of synapses.
:param torch.Tensor b: Target population bias.
@@ -1821,6 +1830,7 @@ def __init__(
def compute(self, s: torch.Tensor) -> torch.Tensor:
"""
Compute pre-activations given spikes using layer weights.
+
:param s: Incoming spikes.
:return: Incoming spikes multiplied by synaptic weights (with or without
decaying spike activation).
@@ -1896,18 +1906,21 @@ def __init__(
if there are `n_conv` neurons in each post-synaptic patch, then the first
`n_conv` neurons in the post-synaptic population correspond to the first
receptive field, the second ``n_conv`` to the second receptive field, and so on.
+
:param source: A layer of nodes from which the connection originates.
:param target: A layer of nodes to which the connection connects.
:param kernel_size: Horizontal, vertical, and depth-wise size of convolutional kernels.
:param stride: Horizontal, vertical, and depth-wise stride for convolution.
:param n_filters: Number of locally connected filters per pre-synaptic region.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param reduction: Method for reducing parameter updates along the minibatch dimension.
:param weight_decay: Constant multiple to decay weights by on each iteration.
:param w_dtype: Data type for :code:`w` tensor
+
Keyword arguments:
+
:param LearningRule update_rule: Modifies connection parameters according to some rule.
:param torch.Tensor w: Strengths of synapses.
:param torch.Tensor b: Target population bias.
@@ -1970,6 +1983,7 @@ def __init__(
def compute(self, s: torch.Tensor) -> torch.Tensor:
"""
Compute pre-activations given spikes using layer weights.
+
:param s: Incoming spikes.
:return: Incoming spikes multiplied by synaptic weights (with or without
decaying spike activation).
@@ -2041,14 +2055,17 @@ def __init__(
# language=rst
"""
Instantiates a :code:`MeanFieldConnection` object.
+
:param source: A layer of nodes from which the connection originates.
:param target: A layer of nodes to which the connection connects.
- :param nu: Learning rate for both pre- and post-synaptic events. It also
+ :param nu: Learning rate for both pre- and post-synaptic events. It also
accepts a pair of tensors to individualize learning rates of each neuron.
In this case, their shape should be the same size as the connection weights.
:param weight_decay: Constant multiple to decay weights by on each iteration.
:param w_dtype: Data type for :code:`w` tensor
+
Keyword arguments:
+
:param LearningRule update_rule: Modifies connection parameters according to
some rule.
:param Union[float, torch.Tensor] w: Strengths of synapses. Can be single value or tensor of size ``target``
@@ -2077,6 +2094,7 @@ def compute(self, s: torch.Tensor) -> torch.Tensor:
# language=rst
"""
Compute pre-activations given spikes using layer weights.
+
:param s: Incoming spikes.
:return: Incoming spikes multiplied by synaptic weights (with or without
decaying spike activation).
diff --git a/bindsnet/network/topology_features.py b/bindsnet/network/topology_features.py
index aa6c16f8..d8570b4f 100644
--- a/bindsnet/network/topology_features.py
+++ b/bindsnet/network/topology_features.py
@@ -53,6 +53,7 @@ def __init__(
# language=rst
"""
Instantiates a :code:`Feature` object. Will assign all incoming arguments as class variables
+
:param name: Name of the feature
:param value: Core numeric object for the feature. This parameters function will vary depending on the feature
:param value_dtype: Data type for :code:`value` tensor
@@ -395,6 +396,7 @@ def __init__(
# language=rst
"""
Will run a bernoulli trial using :code:`value` to determine if a signal will successfully traverse the synapse
+
:param name: Name of the feature
:param value: Number(s) in [0, 1] which represent the probability of a signal traversing a synapse. Tensor values
assume that probabilities will be matched to adjacent synapses in the connection. Scalars will be applied to
@@ -489,6 +491,7 @@ def __init__(
# language=rst
"""
Boolean mask which determines whether or not signals are allowed to traverse certain synapses.
+
:param name: Name of the feature
:param value: Boolean mask. :code:`True` means a signal can pass, :code:`False` means the synapse is impassable
:param sparse: Should :code:`value` parameter be sparse tensor or not
@@ -604,6 +607,7 @@ def __init__(
# language=rst
"""
Multiplies signals by scalars
+
:param name: Name of the feature
:param value: Values to scale signals by
:param value_dtype: Data type for :code:`value` tensor
@@ -611,8 +615,10 @@ def __init__(
:param norm: Value which all values in :code:`value` will sum to. Normalization of values occurs after each sample
and after the value has been updated by the learning rule (if there is one)
:param norm_frequency: How often to normalize weights:
+
* 'sample': weights normalized after each sample
* 'time step': weights normalized after each time step
+
:param learning_rule: Rule which will modify the :code:`value` after each sample
:param nu: Learning rate for the learning rule
:param reduction: Method for reducing parameter updates along the minibatch
@@ -700,6 +706,7 @@ def __init__(
# language=rst
"""
Adds scalars to signals
+
:param name: Name of the feature
:param value: Values to add to the signals
:param value_dtype: Data type for :code:`value` tensor
@@ -750,6 +757,7 @@ def __init__(
# language=rst
"""
Multiply all signals by a scalar
+
:param name: Name of the feature
:param value: Values to scale signals by
:param value_dtype: Data type for :code:`value` tensor
@@ -797,11 +805,12 @@ def __init__(
"""
Degrades propagating spikes according to :code:`degrade_function`.
Note: If :code:`parent_feature` is provided, it will override :code:`value`.
+
:param name: Name of the feature
:param value: Value used to degrade feature
:param value_dtype: Data type for :code:`value` tensor
:param degrade_function: Callable function which takes a single argument (:code:`value`) and returns a tensor or
- constant to be *subtracted* from the propagating spikes.
+ constant to be *subtracted* from the propagating spikes.
:param parent_feature: Parent feature with desired :code:`value` to inherit
:param sparse: Should :code:`value` parameter be sparse tensor or not
:param batch_size: Mini-batch size.
@@ -1086,6 +1095,7 @@ def __init__(
# language=rst
"""
Instantiates a :code:`Augment` object. Will assign all incoming arguments as class variables.
+
:param name: Name of the augment
:param parent_feature: Primary feature which the augment will modify
"""
diff --git a/docs/pyproject.toml b/docs/pyproject.toml
deleted file mode 100644
index 1261166e..00000000
--- a/docs/pyproject.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-[project]
-name = "bindsnet_docs"
-dynamic = ["version"]
-dependencies = [
- "sphinx==7.2.6",
- "sphinx_rtd_theme==1.3.0",
- "readthedocs-sphinx-search==0.3.2",
- "imagecodecs == 2023.9.18",
- "Jinja2 == 3.1.6",
- "wheel == 0.46.2",
-]
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 40e97d17..7fb494d0 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,13 +1,8 @@
-# Defining the exact version will make sure things don't break
-#sphinx==6.2.1
-#sphinx_rtd_theme==1.2.2
-#readthedocs-sphinx-search==0.1.1
-#imagecodecs == 2026.3.6
-#Jinja2 == 3.1.6
+# Documentation build (Read the Docs; see .readthedocs.yaml).
+# CPU-only torch: autodoc only needs to import bindsnet, not run it on a GPU.
+--extra-index-url https://download.pytorch.org/whl/cpu
+torch==2.14.0+cpu
+torchvision==0.29.0+cpu
sphinx==9.0.4
sphinx_rtd_theme==3.1.0
-readthedocs-sphinx-search==0.3.2
-imagecodecs == 2026.3.6
-Jinja2 == 3.1.6
-wheel == 0.48.0
diff --git a/docs/source/bindsnet.analysis.rst b/docs/source/bindsnet.analysis.rst
index 49e941d9..b0ddf74c 100644
--- a/docs/source/bindsnet.analysis.rst
+++ b/docs/source/bindsnet.analysis.rst
@@ -29,10 +29,19 @@ bindsnet.analysis.visualization module
:show-inheritance:
+bindsnet.analysis.dotTrace_plotter module
+-----------------------------------------
+
+.. automodule:: bindsnet.analysis.dotTrace_plotter
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
Module contents
---------------
.. automodule:: bindsnet.analysis
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.conversion.rst b/docs/source/bindsnet.conversion.rst
index 88781331..8757deab 100644
--- a/docs/source/bindsnet.conversion.rst
+++ b/docs/source/bindsnet.conversion.rst
@@ -33,6 +33,7 @@ Module contents
---------------
.. automodule:: bindsnet.conversion
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.datasets.rst b/docs/source/bindsnet.datasets.rst
index 99205c1a..9bc34581 100644
--- a/docs/source/bindsnet.datasets.rst
+++ b/docs/source/bindsnet.datasets.rst
@@ -65,6 +65,7 @@ Module contents
---------------
.. automodule:: bindsnet.datasets
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.encoding.rst b/docs/source/bindsnet.encoding.rst
index 06cf7fe2..e36089a9 100644
--- a/docs/source/bindsnet.encoding.rst
+++ b/docs/source/bindsnet.encoding.rst
@@ -33,6 +33,7 @@ Module contents
---------------
.. automodule:: bindsnet.encoding
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.environment.rst b/docs/source/bindsnet.environment.rst
index 37bdb88a..63ab3882 100644
--- a/docs/source/bindsnet.environment.rst
+++ b/docs/source/bindsnet.environment.rst
@@ -13,10 +13,27 @@ bindsnet.environment.environment module
:show-inheritance:
+bindsnet.environment.cue_reward module
+--------------------------------------
+
+.. automodule:: bindsnet.environment.cue_reward
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+bindsnet.environment.dot_simulator module
+-----------------------------------------
+
+.. automodule:: bindsnet.environment.dot_simulator
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
Module contents
---------------
.. automodule:: bindsnet.environment
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.evaluation.rst b/docs/source/bindsnet.evaluation.rst
index b6a20ffd..8b2a9c02 100644
--- a/docs/source/bindsnet.evaluation.rst
+++ b/docs/source/bindsnet.evaluation.rst
@@ -17,6 +17,7 @@ Module contents
---------------
.. automodule:: bindsnet.evaluation
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.learning.rst b/docs/source/bindsnet.learning.rst
index 48aeec3d..e06b732a 100644
--- a/docs/source/bindsnet.learning.rst
+++ b/docs/source/bindsnet.learning.rst
@@ -21,10 +21,19 @@ bindsnet.learning.reward module
:show-inheritance:
+bindsnet.learning.MCC_learning module
+-------------------------------------
+
+.. automodule:: bindsnet.learning.MCC_learning
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
Module contents
---------------
.. automodule:: bindsnet.learning
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.models.rst b/docs/source/bindsnet.models.rst
index 2409eae5..541dd26c 100644
--- a/docs/source/bindsnet.models.rst
+++ b/docs/source/bindsnet.models.rst
@@ -17,6 +17,7 @@ Module contents
---------------
.. automodule:: bindsnet.models
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.network.rst b/docs/source/bindsnet.network.rst
index d299abed..3496d0da 100644
--- a/docs/source/bindsnet.network.rst
+++ b/docs/source/bindsnet.network.rst
@@ -37,10 +37,19 @@ bindsnet.network.topology module
:show-inheritance:
+bindsnet.network.topology_features module
+-----------------------------------------
+
+.. automodule:: bindsnet.network.topology_features
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
Module contents
---------------
.. automodule:: bindsnet.network
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.pipeline.rst b/docs/source/bindsnet.pipeline.rst
index 0d5221c6..665bb4fc 100644
--- a/docs/source/bindsnet.pipeline.rst
+++ b/docs/source/bindsnet.pipeline.rst
@@ -41,6 +41,7 @@ Module contents
---------------
.. automodule:: bindsnet.pipeline
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.preprocessing.rst b/docs/source/bindsnet.preprocessing.rst
index ab2f866e..ccae3e6a 100644
--- a/docs/source/bindsnet.preprocessing.rst
+++ b/docs/source/bindsnet.preprocessing.rst
@@ -17,6 +17,7 @@ Module contents
---------------
.. automodule:: bindsnet.preprocessing
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/bindsnet.rst b/docs/source/bindsnet.rst
index 3719b55c..216b5dd0 100644
--- a/docs/source/bindsnet.rst
+++ b/docs/source/bindsnet.rst
@@ -34,6 +34,7 @@ Module contents
---------------
.. automodule:: bindsnet
+ :no-index:
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index e7909d18..8953df1e 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -57,24 +57,28 @@
# General information about the project.
project = "bindsnet"
-copyright = "2019, Daniel Saunders, Hananel Hazan"
+copyright = "2018-2026, BindsNET contributors"
author = "Daniel Saunders, Hananel Hazan"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
-#
-# The short X.Y version.
-# version = "0.2.5"
+from importlib.metadata import PackageNotFoundError
+from importlib.metadata import version as _pkg_version
+
+try:
+ release = _pkg_version("bindsnet")
+except PackageNotFoundError: # building without installing the package
+ release = ""
+version = ".".join(release.split(".")[:2])
# The full version, including alpha/beta/rc tags.
-# release = "0.2.5"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
-language = None
+language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@@ -103,22 +107,13 @@
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ["_static"]
+html_static_path = []
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# This is required for the alabaster theme
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
-html_sidebars = {
- "**": [
- "about.html",
- "navigation.html",
- "relations.html", # needs 'show_related': True theme option to display
- "searchbox.html",
- "donate.html",
- ]
-}
# -- Options for HTMLHelp output ------------------------------------------
diff --git a/docs/source/guide/guide_part_i.rst b/docs/source/guide/guide_part_i.rst
index 20414e76..770a8056 100644
--- a/docs/source/guide/guide_part_i.rst
+++ b/docs/source/guide/guide_part_i.rst
@@ -28,11 +28,11 @@ supports dynamics minibatch size, this argument can safely be ignored. It is use
and synaptic variables, and may provide a small speedup if specified beforehand.
The :code:`learning` argument acts to enable or disable updates to adaptive parameters of network components; e.g.,
-synapse weights or adaptive voltage thresholds. See `Using Learning Rules`_ for more details.
+synapse weights or adaptive voltage thresholds. See :ref:`guide_part_ii` for more details.
The :code:`reward_fn` argument takes in class that specifies how a scalar reward signal will be computed and fed to the
network and its components. Typically, the output of this callable class will be used in certain "reward-modulated", or
-"three-factor" learning rules. See `Using Learning Rules`_ for more details.
+"three-factor" learning rules. See :ref:`guide_part_ii` for more details.
Adding Network Components
-------------------------
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 91c9faa5..5a7cc916 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -10,15 +10,14 @@ BindsNET is built on top of the `PyTorch `_ deep learning p
of spiking neural networks (SNNs) and is geared towards machine learning and reinforcement learning.
BindsNET takes advantage of the :code:`torch.Tensor` object to build spiking neurons and connections between them, and
-simulate them on CPUs or GPUs (for strong acceleration / parallelization) without any extra work. Recently,
-:code:`torchvision.datasets` has been integrated into the library to allow the use of popular vision datasets in
+simulate them on CPUs or GPUs (for strong acceleration / parallelization) without any extra work. :code:`torchvision.datasets` is integrated into the library to allow the use of popular vision datasets in
training SNNs for computer vision tasks. Neural network functionality contained in :code:`torch.nn.functional` module is
used to implement more complex connections between populations of spiking neurons.
Spiking neural networks are sometimes referred to as the `third generation of neural networks
`_. Rather than the simple linear layers and nonlinear activation functions of deep learning neural networks, SNNs are composed of neural units which more accurately capture properties of their biological counterparts. An important difference between spiking neurons and the artificial neurons of deep learning are the former's integration of input *in time*; they are naturally short-term memory devices by their maintenance of a (possibly decaying) membrane voltage. As a result, some have argued that SNNs are particularly well-suited to model time-varying data.
-Neurons are connected together with directed edges (*synapses*) which are (in general) plastic. Synapses may have their own dynamics as well, which may or may not `depend on pre- and post-synaptic neural activity https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3395004/` or `other biological signals https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4717313/`. The modification of synaptic strengths is thought to be an important mechanism by which organisms learn. Accordingly, BindsNET provides a module (**bindsnet.learning**) which contains functions used for the updating of synapse weights.
+Neurons are connected together with directed edges (*synapses*) which are (in general) plastic. Synapses may have their own dynamics as well, which may or may not `depend on pre- and post-synaptic neural activity `_ or `other biological signals `_. The modification of synaptic strengths is thought to be an important mechanism by which organisms learn. Accordingly, BindsNET provides a module (**bindsnet.learning**) which contains functions used for the updating of synapse weights.
At its core, BindsNET provides software objects and methods which support the simulation of groups of different types of neurons (**bindsnet.network.nodes**), as well as different types of connections between them (**bindsnet.network.topology**). These may be arbitrarily combined together under a single **bindsnet.network.Network** object, which is responsible for the coordination of the simulation logic of all underlying components. On creation of a network, the user can specify a simulation timestep constant, :math:`dt`, which determines the granularity of the simulation. Choosing this parameter induces a trade-off between simulation speed and numerical precision: large values result in fast simulation, but poor simulation accuracy, and vice versa. Monitors (**bindsnet.network.monitors**) are available for recording state variables from arbitrary network components (e.g., the voltage :math:`v` of a group of neurons).
diff --git a/docs/source/modules.rst b/docs/source/modules.rst
deleted file mode 100644
index 4b5242d9..00000000
--- a/docs/source/modules.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-bindsnet
-========
-
-.. toctree::
- :maxdepth: 4
-
- bindsnet
diff --git a/pyproject.toml b/pyproject.toml
index 9d5844aa..d5cbb2bc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,7 +76,7 @@ line_length = 88
src_paths = ["bindsnet", "test"]
[tool.black]
-target-version = ['py38']
+target-version = ['py311', 'py312', 'py313']
include = '\.pyi?$'
exclude = '''
/(
From a80ac93b9b95c51bfe9793d2127531ce76e901d3 Mon Sep 17 00:00:00 2001
From: Hananel Hazan
Date: Wed, 16 Sep 2026 14:10:22 -0400
Subject: [PATCH 14/14] cleanup: apply isort and enforce it; untrack test logs,
launch.json, .old docs files
- isort with the settings already in pyproject.toml (20 files, import order
only); black.yml and pre-commit now check it with the locked version.
- Remove from git 59 TensorBoard files under logs/ (test output, already in
.gitignore) and .vscode/launch.json (local path); local copies kept.
- Delete docs/Makefile.old and docs/make.bat.old.
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/workflows/black.yml | 9 ++++-
.pre-commit-config.yaml | 7 +++-
.vscode/launch.json | 17 ---------
CHANGELOG.md | 5 +++
bindsnet/analysis/plotting.py | 2 +-
bindsnet/learning/MCC_learning.py | 6 +--
bindsnet/learning/learning.py | 1 +
bindsnet/models/models.py | 2 +-
bindsnet/network/monitors.py | 4 --
bindsnet/network/topology.py | 7 ++--
bindsnet/network/topology_features.py | 19 ++++-----
bindsnet/pipeline/base_pipeline.py | 1 +
docs/Makefile.old | 20 ----------
docs/make.bat.old | 36 ------------------
examples/benchmark/lowering_precision.py | 2 +-
.../_bench_common.py | 2 +-
examples/benchmark/sparse_vs_dense_tensors.py | 5 ++-
examples/mnist/MCC_reservoir.py | 11 +++---
examples/mnist/loc1d_mnist.py | 18 +++------
examples/mnist/loc2d_mnist.py | 20 ++++------
examples/mnist/loc3d_mnist.py | 18 +++------
.../events.out.tfevents.1656543178.TempWin | Bin 87 -> 0 bytes
.../events.out.tfevents.1656548905.TempWin | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1673646087.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1673648326.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1678117372.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1682712186.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1687464074.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1687464505.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1687736499.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1694374827.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1694374969.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1694375010.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1700165624.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1703700212.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711672057.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711672140.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711673241.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711673760.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711674764.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711675116.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711675170.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711675181.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711675321.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711675865.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711721119.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1711723694.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1720719086.Spike | Bin 87 -> 0 bytes
.../init/events.out.tfevents.1720719342.Spike | Bin 87 -> 0 bytes
.../events.out.tfevents.1656543178.TempWin | Bin 121375 -> 0 bytes
.../events.out.tfevents.1656548905.TempWin | Bin 121138 -> 0 bytes
.../runs/events.out.tfevents.1673646087.Spike | Bin 121205 -> 0 bytes
.../runs/events.out.tfevents.1673648326.Spike | Bin 121291 -> 0 bytes
.../runs/events.out.tfevents.1678117372.Spike | Bin 121088 -> 0 bytes
.../runs/events.out.tfevents.1682712186.Spike | Bin 121587 -> 0 bytes
.../runs/events.out.tfevents.1687464074.Spike | Bin 121265 -> 0 bytes
.../runs/events.out.tfevents.1687464505.Spike | Bin 121435 -> 0 bytes
.../runs/events.out.tfevents.1687736499.Spike | Bin 121543 -> 0 bytes
.../runs/events.out.tfevents.1694374827.Spike | Bin 121375 -> 0 bytes
.../runs/events.out.tfevents.1694374969.Spike | Bin 121290 -> 0 bytes
.../runs/events.out.tfevents.1694375010.Spike | Bin 121689 -> 0 bytes
.../runs/events.out.tfevents.1700165624.Spike | Bin 121290 -> 0 bytes
.../runs/events.out.tfevents.1703700212.Spike | Bin 120852 -> 0 bytes
.../runs/events.out.tfevents.1711672057.Spike | Bin 121039 -> 0 bytes
.../runs/events.out.tfevents.1711672140.Spike | Bin 121038 -> 0 bytes
.../runs/events.out.tfevents.1711673241.Spike | Bin 121380 -> 0 bytes
.../runs/events.out.tfevents.1711673760.Spike | Bin 120922 -> 0 bytes
.../runs/events.out.tfevents.1711674764.Spike | Bin 120884 -> 0 bytes
.../runs/events.out.tfevents.1711675116.Spike | Bin 121029 -> 0 bytes
.../runs/events.out.tfevents.1711675170.Spike | Bin 121188 -> 0 bytes
.../runs/events.out.tfevents.1711675181.Spike | Bin 121684 -> 0 bytes
.../runs/events.out.tfevents.1711675321.Spike | Bin 120998 -> 0 bytes
.../runs/events.out.tfevents.1711675865.Spike | Bin 121110 -> 0 bytes
.../runs/events.out.tfevents.1711721119.Spike | Bin 121243 -> 0 bytes
.../runs/events.out.tfevents.1711723694.Spike | Bin 121294 -> 0 bytes
.../runs/events.out.tfevents.1720719086.Spike | Bin 121328 -> 0 bytes
.../runs/events.out.tfevents.1720719342.Spike | Bin 121327 -> 0 bytes
test/network/test_connections.py | 7 ++--
test/network/test_learning_rule_specs.py | 2 +-
test/network/test_mstdp_florian.py | 4 +-
test/network/test_network.py | 1 +
test/network/test_perf_equivalence.py | 10 ++++-
82 files changed, 83 insertions(+), 153 deletions(-)
delete mode 100644 .vscode/launch.json
delete mode 100644 docs/Makefile.old
delete mode 100644 docs/make.bat.old
delete mode 100644 logs/init/events.out.tfevents.1656543178.TempWin
delete mode 100644 logs/init/events.out.tfevents.1656548905.TempWin
delete mode 100644 logs/init/events.out.tfevents.1673646087.Spike
delete mode 100644 logs/init/events.out.tfevents.1673648326.Spike
delete mode 100644 logs/init/events.out.tfevents.1678117372.Spike
delete mode 100644 logs/init/events.out.tfevents.1682712186.Spike
delete mode 100644 logs/init/events.out.tfevents.1687464074.Spike
delete mode 100644 logs/init/events.out.tfevents.1687464505.Spike
delete mode 100644 logs/init/events.out.tfevents.1687736499.Spike
delete mode 100644 logs/init/events.out.tfevents.1694374827.Spike
delete mode 100644 logs/init/events.out.tfevents.1694374969.Spike
delete mode 100644 logs/init/events.out.tfevents.1694375010.Spike
delete mode 100644 logs/init/events.out.tfevents.1700165624.Spike
delete mode 100644 logs/init/events.out.tfevents.1703700212.Spike
delete mode 100644 logs/init/events.out.tfevents.1711672057.Spike
delete mode 100644 logs/init/events.out.tfevents.1711672140.Spike
delete mode 100644 logs/init/events.out.tfevents.1711673241.Spike
delete mode 100644 logs/init/events.out.tfevents.1711673760.Spike
delete mode 100644 logs/init/events.out.tfevents.1711674764.Spike
delete mode 100644 logs/init/events.out.tfevents.1711675116.Spike
delete mode 100644 logs/init/events.out.tfevents.1711675170.Spike
delete mode 100644 logs/init/events.out.tfevents.1711675181.Spike
delete mode 100644 logs/init/events.out.tfevents.1711675321.Spike
delete mode 100644 logs/init/events.out.tfevents.1711675865.Spike
delete mode 100644 logs/init/events.out.tfevents.1711721119.Spike
delete mode 100644 logs/init/events.out.tfevents.1711723694.Spike
delete mode 100644 logs/init/events.out.tfevents.1720719086.Spike
delete mode 100644 logs/init/events.out.tfevents.1720719342.Spike
delete mode 100644 logs/runs/events.out.tfevents.1656543178.TempWin
delete mode 100644 logs/runs/events.out.tfevents.1656548905.TempWin
delete mode 100644 logs/runs/events.out.tfevents.1673646087.Spike
delete mode 100644 logs/runs/events.out.tfevents.1673648326.Spike
delete mode 100644 logs/runs/events.out.tfevents.1678117372.Spike
delete mode 100644 logs/runs/events.out.tfevents.1682712186.Spike
delete mode 100644 logs/runs/events.out.tfevents.1687464074.Spike
delete mode 100644 logs/runs/events.out.tfevents.1687464505.Spike
delete mode 100644 logs/runs/events.out.tfevents.1687736499.Spike
delete mode 100644 logs/runs/events.out.tfevents.1694374827.Spike
delete mode 100644 logs/runs/events.out.tfevents.1694374969.Spike
delete mode 100644 logs/runs/events.out.tfevents.1694375010.Spike
delete mode 100644 logs/runs/events.out.tfevents.1700165624.Spike
delete mode 100644 logs/runs/events.out.tfevents.1703700212.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711672057.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711672140.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711673241.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711673760.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711674764.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711675116.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711675170.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711675181.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711675321.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711675865.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711721119.Spike
delete mode 100644 logs/runs/events.out.tfevents.1711723694.Spike
delete mode 100644 logs/runs/events.out.tfevents.1720719086.Spike
delete mode 100644 logs/runs/events.out.tfevents.1720719342.Spike
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
index 446073ee..8db2090e 100644
--- a/.github/workflows/black.yml
+++ b/.github/workflows/black.yml
@@ -1,4 +1,4 @@
-# Formatting check. Branch protection on master requires the job named "lint"; keep that name.
+# Formatting and import-order check. Branch protection on master requires the job named "lint"; keep that name.
name: Black Formater
on:
@@ -22,8 +22,13 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- # Use the black version pinned in poetry.lock, so CI and local formatting agree.
+ # Use the black and isort versions pinned in poetry.lock, so CI and local
+ # formatting agree.
- name: Check formatting with black
run: |
version=$(python -c "import tomllib; print(next(p['version'] for p in tomllib.load(open('poetry.lock','rb'))['package'] if p['name']=='black'))")
pipx run "black==${version}" --check --diff .
+ - name: Check import order with isort
+ run: |
+ version=$(python -c "import tomllib; print(next(p['version'] for p in tomllib.load(open('poetry.lock','rb'))['package'] if p['name']=='isort'))")
+ pipx run "isort==${version}" --check-only --diff .
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d648c1bd..ff481b9b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,8 +1,13 @@
# Installed by `poetry run pre-commit install` (see CONTRIBUTING.md).
-# Uses the black installed by Poetry, so the version always matches poetry.lock and CI.
+# Uses the isort and black installed by Poetry, so the version always matches poetry.lock and CI.
repos:
- repo: local
hooks:
+ - id: isort
+ name: isort
+ entry: poetry run isort
+ language: system
+ types: [python]
- id: black
name: black
entry: poetry run black
diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index 91a77023..00000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
- {
- "name": "Python: Current File",
- "type": "python",
- "request": "launch",
- "program": "${file}",
- "python": "/home/hananel/miniconda3/envs/bindsNET/bin/python",
- "console": "integratedTerminal",
- "justMyCode": false
- }
- ]
-}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11c330a4..8a6708bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,11 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
`black .` step reformatted instead of checking). `black.yml` checks with the black
version from `poetry.lock` instead of the floating `psf/black@stable`.
- Dependabot also updates the Dockerfile base image.
+- Imports sorted with isort (settings already in `pyproject.toml`, never applied; 20
+ files, import order only). `black.yml` and the pre-commit hook now also check isort.
+- Removed from git: 59 TensorBoard event files under `logs/` (test output; `logs/*`
+ was already in `.gitignore`), `.vscode/launch.json` (a local interpreter path) and
+ `docs/Makefile.old`, `docs/make.bat.old`.
- Added `.pre-commit-config.yaml` (black from Poetry); `CONTRIBUTING.md` already told
contributors to install pre-commit, but there was no configuration.
- `[tool.black] target-version` is `py311`-`py313` (was `py38`); no file changes.
diff --git a/bindsnet/analysis/plotting.py b/bindsnet/analysis/plotting.py
index e9f4e9f3..4faea784 100644
--- a/bindsnet/analysis/plotting.py
+++ b/bindsnet/analysis/plotting.py
@@ -11,8 +11,8 @@
from bindsnet.utils import (
reshape_conv2d_weights,
- reshape_locally_connected_weights,
reshape_local_connection_2d_weights,
+ reshape_locally_connected_weights,
)
plt.ion()
diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py
index a6ef5538..94c867e8 100644
--- a/bindsnet/learning/MCC_learning.py
+++ b/bindsnet/learning/MCC_learning.py
@@ -1,9 +1,9 @@
-from abc import ABC, abstractmethod
-from typing import Union, Optional, Sequence
import warnings
+from abc import ABC, abstractmethod
+from typing import Optional, Sequence, Union
-import torch
import numpy as np
+import torch
from ..network.nodes import SRM0Nodes
from ..network.topology import (
diff --git a/bindsnet/learning/learning.py b/bindsnet/learning/learning.py
index a4017fa7..898bf815 100644
--- a/bindsnet/learning/learning.py
+++ b/bindsnet/learning/learning.py
@@ -9,6 +9,7 @@
from torch.nn.modules.utils import _pair
from bindsnet.utils import im2col_indices
+
from ..network.nodes import SRM0Nodes
from ..network.topology import (
AbstractConnection,
diff --git a/bindsnet/models/models.py b/bindsnet/models/models.py
index a5027e0c..d5a52309 100644
--- a/bindsnet/models/models.py
+++ b/bindsnet/models/models.py
@@ -3,8 +3,8 @@
import numpy as np
import torch
from scipy.spatial.distance import euclidean
-from torch.nn.modules.utils import _pair
from torch import device
+from torch.nn.modules.utils import _pair
from bindsnet.learning import PostPre
from bindsnet.learning.MCC_learning import DiehlAndCook as MMCDiehlAndCook
diff --git a/bindsnet/network/monitors.py b/bindsnet/network/monitors.py
index 42cccd25..00d875d1 100644
--- a/bindsnet/network/monitors.py
+++ b/bindsnet/network/monitors.py
@@ -4,10 +4,6 @@
import numpy as np
import torch
-import numpy as np
-
-from abc import ABC
-from typing import Union, Optional, Iterable, Dict
from bindsnet.network.nodes import Nodes
from bindsnet.network.topology import (
diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py
index ba5812eb..d74d5124 100644
--- a/bindsnet/network/topology.py
+++ b/bindsnet/network/topology.py
@@ -1,17 +1,16 @@
+import warnings
from abc import ABC, abstractmethod
from typing import Optional, Sequence, Tuple, Union
-import warnings
-
import numpy as np
import torch
-from torch import device
import torch.nn.functional as F
-from bindsnet.utils import im2col_indices
+from torch import device
from torch.nn import Module, Parameter
from torch.nn.modules.utils import _pair, _triple
from bindsnet.network.nodes import CSRMNodes, Nodes
+from bindsnet.utils import im2col_indices
class AbstractConnection(ABC, Module):
diff --git a/bindsnet/network/topology_features.py b/bindsnet/network/topology_features.py
index d8570b4f..8426ee62 100644
--- a/bindsnet/network/topology_features.py
+++ b/bindsnet/network/topology_features.py
@@ -1,15 +1,16 @@
+import warnings
from abc import ABC, abstractmethod
-from bindsnet.learning.learning import NoOp
-from typing import Union, Tuple, Optional, Sequence
+from typing import Optional, Sequence, Tuple, Union
import numpy as np
import torch
-import warnings
+import torch.nn as nn
+import torch.nn.functional as F
from torch import device
from torch.nn import Parameter
-import torch.nn.functional as F
-import torch.nn as nn
+
import bindsnet.learning
+from bindsnet.learning.learning import NoOp
class AbstractFeature(ABC):
@@ -90,12 +91,12 @@ def __init__(
self.is_primed = False
from ..learning.MCC_learning import (
- NoOp,
- PostPre,
- Hebbian,
- DiehlAndCook,
MSTDP,
MSTDPET,
+ DiehlAndCook,
+ Hebbian,
+ NoOp,
+ PostPre,
)
supported_rules = [
diff --git a/bindsnet/pipeline/base_pipeline.py b/bindsnet/pipeline/base_pipeline.py
index c8c38016..3180aba6 100644
--- a/bindsnet/pipeline/base_pipeline.py
+++ b/bindsnet/pipeline/base_pipeline.py
@@ -3,6 +3,7 @@
from typing import Any, Dict, Tuple
import torch
+
from bindsnet.network import Network
from bindsnet.network.monitors import Monitor
diff --git a/docs/Makefile.old b/docs/Makefile.old
deleted file mode 100644
index 42fd05a2..00000000
--- a/docs/Makefile.old
+++ /dev/null
@@ -1,20 +0,0 @@
-# Minimal makefile for Sphinx documentation
-#
-
-# You can set these variables from the command line.
-SPHINXOPTS =
-SPHINXBUILD = python -msphinx
-SPHINXPROJ = bindsnet
-SOURCEDIR = source
-BUILDDIR = build
-
-# Put it first so that "make" without argument is like "make help".
-help:
- @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-
-.PHONY: help Makefile
-
-# Catch-all target: route all unknown targets to Sphinx using the new
-# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile
- @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/make.bat.old b/docs/make.bat.old
deleted file mode 100644
index 51a31ec3..00000000
--- a/docs/make.bat.old
+++ /dev/null
@@ -1,36 +0,0 @@
-@ECHO OFF
-
-pushd %~dp0
-
-REM Command file for Sphinx documentation
-
-if "%SPHINXBUILD%" == "" (
- set SPHINXBUILD=python -msphinx
-)
-set SOURCEDIR=source
-set BUILDDIR=build
-set SPHINXPROJ=bindsnet
-
-if "%1" == "" goto help
-
-%SPHINXBUILD% >NUL 2>NUL
-if errorlevel 9009 (
- echo.
- echo.The Sphinx module was not found. Make sure you have Sphinx installed,
- echo.then set the SPHINXBUILD environment variable to point to the full
- echo.path of the 'sphinx-build' executable. Alternatively you may add the
- echo.Sphinx directory to PATH.
- echo.
- echo.If you don't have Sphinx installed, grab it from
- echo.http://sphinx-doc.org/
- exit /b 1
-)
-
-%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
-goto end
-
-:help
-%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
-
-:end
-popd
diff --git a/examples/benchmark/lowering_precision.py b/examples/benchmark/lowering_precision.py
index c3927b0c..e0a2c0ec 100644
--- a/examples/benchmark/lowering_precision.py
+++ b/examples/benchmark/lowering_precision.py
@@ -1,5 +1,5 @@
-import re
import os
+import re
import subprocess
from statistics import mean
diff --git a/examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py b/examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py
index 91c21cff..d02699d0 100644
--- a/examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py
+++ b/examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py
@@ -33,10 +33,10 @@
sys.path.insert(0, _p)
import torch
+from example_network import ExampleNetwork
from bindsnet.network.topology import MulticompartmentConnection
from bindsnet.network.topology_features import Degradation, Probability
-from example_network import ExampleNetwork
# ExampleNetwork sizes per device: 20k excitatory neurons on GPU (where the fold
# shines), a smaller net on CPU so the baseline finishes in reasonable time.
diff --git a/examples/benchmark/sparse_vs_dense_tensors.py b/examples/benchmark/sparse_vs_dense_tensors.py
index 228fdc4a..79afed86 100644
--- a/examples/benchmark/sparse_vs_dense_tensors.py
+++ b/examples/benchmark/sparse_vs_dense_tensors.py
@@ -1,6 +1,7 @@
-import torch
-import time
import argparse
+import time
+
+import torch
from bindsnet.evaluation import all_activity, assign_labels, proportion_weighting
diff --git a/examples/mnist/MCC_reservoir.py b/examples/mnist/MCC_reservoir.py
index ff91876f..acf61fd6 100644
--- a/examples/mnist/MCC_reservoir.py
+++ b/examples/mnist/MCC_reservoir.py
@@ -1,10 +1,10 @@
+import argparse
import os
+
+import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn as nn
-import argparse
-import matplotlib.pyplot as plt
-
from torchvision import transforms
from tqdm import tqdm
@@ -17,13 +17,12 @@
from bindsnet.datasets import MNIST
from bindsnet.encoding import PoissonEncoder
from bindsnet.network import Network
-from bindsnet.network.nodes import Input
-from bindsnet.network.topology_features import Probability, Weight, Mask
# Build a simple two-layer, input-output network.
from bindsnet.network.monitors import Monitor
-from bindsnet.network.nodes import LIFNodes
+from bindsnet.network.nodes import Input, LIFNodes
from bindsnet.network.topology import MulticompartmentConnection
+from bindsnet.network.topology_features import Mask, Probability, Weight
from bindsnet.utils import get_square_weights
parser = argparse.ArgumentParser()
diff --git a/examples/mnist/loc1d_mnist.py b/examples/mnist/loc1d_mnist.py
index ec652d14..0a238cae 100644
--- a/examples/mnist/loc1d_mnist.py
+++ b/examples/mnist/loc1d_mnist.py
@@ -1,26 +1,20 @@
### Toy example to test LocanConnection1D (the dataset used is MNIST but each image is raveled (each sample has shape (784,)).
-import torch
-from torch.nn.modules.utils import _pair
-
-from tqdm import tqdm
import os
-from bindsnet.network.monitors import Monitor
+from time import time as t
import torch
+from torch.nn.modules.utils import _pair
from torchvision import transforms
from tqdm import tqdm
-from time import time as t
-from torchvision import transforms
+from bindsnet.datasets import MNIST
+from bindsnet.encoding import PoissonEncoder
from bindsnet.learning import PostPre
-
-from bindsnet.network.nodes import AdaptiveLIFNodes
-from bindsnet.network.nodes import Input
+from bindsnet.network.monitors import Monitor
from bindsnet.network.network import Network
+from bindsnet.network.nodes import AdaptiveLIFNodes, Input
from bindsnet.network.topology import Connection, LocalConnection1D
-from bindsnet.encoding import PoissonEncoder
-from bindsnet.datasets import MNIST
# Hyperparameters
in_channels = 1
diff --git a/examples/mnist/loc2d_mnist.py b/examples/mnist/loc2d_mnist.py
index 37cc34f4..ed7f63c1 100644
--- a/examples/mnist/loc2d_mnist.py
+++ b/examples/mnist/loc2d_mnist.py
@@ -1,26 +1,20 @@
-import torch
-from torch.nn.modules.utils import _pair
-
-from tqdm import tqdm
import os
-from bindsnet.network.monitors import Monitor
+from time import time as t
+
import matplotlib.pyplot as plt
import torch
+from torch.nn.modules.utils import _pair
from torchvision import transforms
from tqdm import tqdm
from bindsnet.analysis.plotting import plot_local_connection_2d_weights
-
-from time import time as t
-from torchvision import transforms
+from bindsnet.datasets import MNIST
+from bindsnet.encoding import PoissonEncoder
from bindsnet.learning import PostPre
-
-from bindsnet.network.nodes import AdaptiveLIFNodes
-from bindsnet.network.nodes import Input
+from bindsnet.network.monitors import Monitor
from bindsnet.network.network import Network
+from bindsnet.network.nodes import AdaptiveLIFNodes, Input
from bindsnet.network.topology import Connection, LocalConnection2D
-from bindsnet.encoding import PoissonEncoder
-from bindsnet.datasets import MNIST
# Hyperparameters
in_channels = 1
diff --git a/examples/mnist/loc3d_mnist.py b/examples/mnist/loc3d_mnist.py
index 6ef45ab4..cb257c8f 100644
--- a/examples/mnist/loc3d_mnist.py
+++ b/examples/mnist/loc3d_mnist.py
@@ -1,27 +1,21 @@
### Toy example to test LocalConnection3D (the dataset used is MNIST but with a dimension replicated
### for each image (each sample has size (28, 28, 28))
-import torch
-from torch.nn.modules.utils import _triple
-
-from tqdm import tqdm
import os
-from bindsnet.network.monitors import Monitor
+from time import time as t
import torch
+from torch.nn.modules.utils import _triple
from torchvision import transforms
from tqdm import tqdm
-from time import time as t
-from torchvision import transforms
+from bindsnet.datasets import MNIST
+from bindsnet.encoding import PoissonEncoder
from bindsnet.learning import PostPre
-
-from bindsnet.network.nodes import AdaptiveLIFNodes
-from bindsnet.network.nodes import Input
+from bindsnet.network.monitors import Monitor
from bindsnet.network.network import Network
+from bindsnet.network.nodes import AdaptiveLIFNodes, Input
from bindsnet.network.topology import Connection, LocalConnection3D
-from bindsnet.encoding import PoissonEncoder
-from bindsnet.datasets import MNIST
# Hyperparameters
in_channels = 1
diff --git a/logs/init/events.out.tfevents.1656543178.TempWin b/logs/init/events.out.tfevents.1656543178.TempWin
deleted file mode 100644
index 210c362dfca949d650dfdc6d601358ceeca95f01..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Pi|Mq?|U4O$-iZ`h!F*8rkwJbHS#L9?Y?4!9nR9&FAiv%Z=+CH#4
fEfFpuF7C{{%#!%xF3U`L!hR9&FAiv;JM6o@)4
e5iTJv?##T*lKA4}#GJ$;Q3i$+PE-3HL;(Os0UG%L
diff --git a/logs/init/events.out.tfevents.1673646087.Spike b/logs/init/events.out.tfevents.1673646087.Spike
deleted file mode 100644
index 8a50eada91373e934c3544194371cf74b72de164..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$PX92Xc%KHPAW;!P?_%*@ksElbTSu`*J6H|L%_R9&FAiv(wi4n&=n
e2$v8ScV=E@Nqli~VoqX_Cq=sbsD4q
diff --git a/logs/init/events.out.tfevents.1678117372.Spike b/logs/init/events.out.tfevents.1678117372.Spike
deleted file mode 100644
index ba8eab497dd7641643ca822a07518901ba8f900d..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$QtyJYK|7;idC@g@}|X6EU+mZj#ESQ%N%9=RwFRTt>(BEe~R2BJ<&
dgiDBvJ2Nk{B)&K~F(&Yk_W{hC7u^5=
diff --git a/logs/init/events.out.tfevents.1687464074.Spike b/logs/init/events.out.tfevents.1687464074.Spike
deleted file mode 100644
index 2312550d924fe746f51103bbed82649cbb5ddfd6..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Pyg_D%DRBt*;@g@}|X6EU+mZj#ESQ%YdGw-lGR9&FAiv;J}PKY`!
e5iTJv?##T*lKA4}#GJ$;Q3i$+PN&y$umb??9T<=R
diff --git a/logs/init/events.out.tfevents.1687464505.Spike b/logs/init/events.out.tfevents.1687464505.Spike
deleted file mode 100644
index a61c0c85261f093a2206b53f7b61c221a848df98..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Qm6|sFG5Z08ytU
d!X?DTotc+e5?`F0n3Gr}%D`~KX@aY_7y!N%7e)X8
diff --git a/logs/init/events.out.tfevents.1687736499.Spike b/logs/init/events.out.tfevents.1687736499.Spike
deleted file mode 100644
index ee09c2c72f30f9c5b06b7c64ccda6c92d73a578f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$QCCU132)NVRT@g@}|X6EU+mZj#ESQ$+X5<4dkRTt>(BEiY)22rOa
d!X?DTotc+e5?`F0n3Gr}%D`~K>An+F2LPh>7XSbN
diff --git a/logs/init/events.out.tfevents.1694374827.Spike b/logs/init/events.out.tfevents.1694374827.Spike
deleted file mode 100644
index 4ae2c5994cffa42dc86a1b7981c35c781e0b5660..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Ru7QT4ZW`EOBiZ`h!F*8rkwJbHS#L6i03{RarR9&FAiv;JIgAjFE
eB3wdT+?jcqCGo|{i8+Zyq6`ctoc_&_5(EH&P8(eS
diff --git a/logs/init/events.out.tfevents.1694374969.Spike b/logs/init/events.out.tfevents.1694374969.Spike
deleted file mode 100644
index b193b999cc82557fb30f409fa7a34bf06d649249..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$RMKT7!8?Qc3t@g@}|X6EU+mZj#ESQ%-l9#@u!stfdXk>IS=ECs96
e65$f!;?B&=EQv2pPRvOx5@ldG;k3q|fe!%4@D|Yk
diff --git a/logs/init/events.out.tfevents.1694375010.Spike b/logs/init/events.out.tfevents.1694375010.Spike
deleted file mode 100644
index d3824aa58e73f839bc74c69e5110afe200e85d20..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$QiYRe_s?Qc3t@g@}|X6EU+mZj#ESQ%M9erP5SRTt>(BEi|43{j^g
d!X?DTotc+e5?`F0n3Gr}%D`~K>Fg68M*z?_7;*pr
diff --git a/logs/init/events.out.tfevents.1700165624.Spike b/logs/init/events.out.tfevents.1700165624.Spike
deleted file mode 100644
index f554bf97ebf892701590e166de0c1a61ce9ab859..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$RuZ=3y_8+y}GiZ`h!F*8rkwJbHS#L6i9`L-$YP<4UcE)tyUpF`AX
eiEs&VacAabmc$n)C*~v;i83&paO!dRcMAZO#T>5y
diff --git a/logs/init/events.out.tfevents.1703700212.Spike b/logs/init/events.out.tfevents.1703700212.Spike
deleted file mode 100644
index febaf04dee873bb1c74c6dd3f99fbd2b6e87cf4f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$R6PvrMXCf{_F;!P?_%*@ksElbTSu`=RzX*wnkRTt>(BEdQB3q+lk
d2$v8ScV=E@Nqli~VoqX_CNT9%quVr3-$v!F#DsxHvmMS?T_4@8}o
e2$v8ScV=E@Nqli~VoqX_CY!$Q!@_
diff --git a/logs/init/events.out.tfevents.1711672140.Spike b/logs/init/events.out.tfevents.1711672140.Spike
deleted file mode 100644
index e2410d554b7b768abe288ff70c1d148249786196..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$R(qI#E`8gDvE@g@}|X6EU+mZj#ESQ%MgSR^YCRTt>(BEc!32vMgc
d!X?DTotc+e5?`F0n3Gr}%D`~KNq_n(4*NT9%quVr4YfDE+29R9&FAiv;K4We{~*
eB3wdT+?jcqCGo|{i8+Zyq6`ctoUU-{JpcfFmK$#X
diff --git a/logs/init/events.out.tfevents.1711673760.Spike b/logs/init/events.out.tfevents.1711673760.Spike
deleted file mode 100644
index efd1469e1ce6f7b48d9ce00a0e14e5dd8c4e02fc..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$P@0h$>tjW->oc$10}GxPLZ%Tn`7tcF%VfT+_F
d;S%EF&dkd!i7!r0%toc$10}GxPLZ%Tn`7tc((ve-z3?)dhOHNN{RoL)2-B
da0zj7XXa&=#1|(g<|G!0GBBKQ^0^W?2>_;87mxq|
diff --git a/logs/init/events.out.tfevents.1711675116.Spike b/logs/init/events.out.tfevents.1711675116.Spike
deleted file mode 100644
index ae08c15c1b91469dac7967452ce3fa20edbe29eb..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Rz%$&Ph8*e&F@g@}|X6EU+mZj#ESQ#Y}8vw+X7`gxe
diff --git a/logs/init/events.out.tfevents.1711675170.Spike b/logs/init/events.out.tfevents.1711675170.Spike
deleted file mode 100644
index 68bcf48e72955f6f40c9a680553261d73b0ec068..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Q0?8{EHHr{lU;!P?_%*@ksElbTSu`)84EcjX;sxHvmMS}CM5Ja7p
d2$v8ScV=E@Nqli~VoqX_CNT9%quVr8`Igl@Y$R9&FAiv;JNwGee$
eB3wdT+?jcqCGo|{i8+Zyq6`ctoZ{HFI0FEEYa0Rp
diff --git a/logs/init/events.out.tfevents.1711675321.Spike b/logs/init/events.out.tfevents.1711675321.Spike
deleted file mode 100644
index 0d73f89e269fc81b662b9b9171620948d3d30b51..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Q>OT6B-Hr{lU;!P?_%*@ksElbTSu`+75Z8#_oRTt>(BEk7;9Ymd$
d2$v8ScV=E@Nqli~VoqX_C?;Je<
diff --git a/logs/init/events.out.tfevents.1711721119.Spike b/logs/init/events.out.tfevents.1711721119.Spike
deleted file mode 100644
index 89c0d8608cf0106f12b4c84fc31cec76b759a682..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$QOGwv?m)OgcTiZ`h!F*8rkwJbHS#LCD;@$NNwsJcLJ7YWWKiy-Q>
eM7V^wxHI!IOX7=@6LS)aL>U-PI4RFqzzYCl$QscA
diff --git a/logs/init/events.out.tfevents.1711723694.Spike b/logs/init/events.out.tfevents.1711723694.Spike
deleted file mode 100644
index 4e288a475e35566fe3ab316845f95b94a40fa9a1..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
zcmb1OfPlsI-b$Q1t>$QNZM^9y#hX-=n3<>NT9%quVr68ob~&p&R9&FAiv(wzJ4Bt9
e2$v8ScV=E@Nqli~VoqX_CU-PIPJbwEd~HTff}^{
diff --git a/logs/init/events.out.tfevents.1720719342.Spike b/logs/init/events.out.tfevents.1720719342.Spike
deleted file mode 100644
index fb8c7797f2bf0e0f73157d581e827b56e078b4ac..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 87
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