From 67573d05cff6badb92be4bb97d13fc9c210540dd Mon Sep 17 00:00:00 2001 From: AHMETHAKANBEZIR1 Date: Wed, 21 Oct 2026 16:11:08 +0300 Subject: [PATCH 1/3] fix: preserve mean-field weights during normalization Co-authored-by: OpenAI Codex --- CHANGELOG.md | 2 + bindsnet/network/topology.py | 6 +-- test/network/test_meanfield_normalization.py | 53 ++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 test/network/test_meanfield_normalization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5179fe8c..fe83f0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases). - Dockerfile on `python:3.13-slim`, inside `requires-python = ">=3.11,<3.14"` (#810 had moved it to 3.14); Dependabot no longer proposes minor or major Python bumps for the image. +- Mean-field connection normalization preserves the registered weight parameter and + accepts both shared scalar and per-target tensor weights. - API reference on Read the Docs was empty: the build never installed `bindsnet`, so every `automodule` failed to import (`No module named 'matplotlib'`), and `docs/pyproject.toml` downgraded Sphinx to 7.2.6. `.readthedocs.yaml` now installs a diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index aa43c5cc..c329cf8d 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -2126,13 +2126,11 @@ def update(self, **kwargs) -> None: def normalize(self) -> None: # language=rst """ - Normalize weights so each target neuron has sum of connection weights equal to - ``self.norm``. + Normalize stored weights to sum to ``self.norm``, preserving their scalar or + per-target tensor shape. """ if self.norm is not None: - self.w = self.w.view(1, self.target.n) self.w *= self.norm / self.w.sum() - self.w = self.w.view(1, *self.target.shape) def reset_state_variables(self) -> None: # language=rst diff --git a/test/network/test_meanfield_normalization.py b/test/network/test_meanfield_normalization.py new file mode 100644 index 00000000..4d021275 --- /dev/null +++ b/test/network/test_meanfield_normalization.py @@ -0,0 +1,53 @@ +import pytest +import torch + +from bindsnet.network import Network +from bindsnet.network.nodes import Input, LIFNodes +from bindsnet.network.topology import MeanFieldConnection + + +@pytest.mark.parametrize("weight_shape", [(), (4,), (2, 2)]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.float64]) +@pytest.mark.parametrize("norm", [None, 2.0]) +def test_meanfield_normalization_preserves_parameter(weight_shape, dtype, norm): + target_shape = weight_shape or (3,) + weights = ( + torch.arange(1, 5, dtype=dtype).reshape(weight_shape) + if weight_shape + else torch.tensor(0.25, dtype=dtype) + ) + connection = MeanFieldConnection( + Input(n=2), + LIFNodes(shape=target_shape), + w=weights.clone(), + w_dtype=dtype, + norm=norm, + ) + parameter = connection.w + state_keys = set(connection.state_dict()) + expected = weights.clone() + if norm is not None: + expected = weights * 0.2 if weight_shape else torch.tensor(2.0, dtype=dtype) + + connection.normalize() + assert connection.w is parameter + assert isinstance(connection.w, torch.nn.Parameter) + assert connection.w.shape == weights.shape + assert set(connection.state_dict()) == state_keys + torch.testing.assert_close(connection.w, expected) + spikes = torch.tensor([[0.0, 1.0], [1.0, 1.0]]) + torch.testing.assert_close(connection.compute(spikes), 0.75 * expected) + connection.normalize() + torch.testing.assert_close(connection.w, expected) + + +def test_network_run_normalizes_meanfield_connection(): + network = Network(dt=1.0) + source, target = Input(n=2), LIFNodes(n=3) + network.add_layer(source, name="source") + network.add_layer(target, name="target") + connection = MeanFieldConnection(source, target, w=torch.tensor(0.25), norm=2.0) + network.add_connection(connection, source="source", target="target") + network.run(inputs={"source": torch.ones(2, 1, 2)}, time=2) + torch.testing.assert_close(connection.w, torch.tensor(2.0)) + assert torch.isfinite(target.v).all() From 85709ff3602b4b8bce6250989e8b5be11bea4e13 Mon Sep 17 00:00:00 2001 From: AHMETHAKANBEZIR1 Date: Wed, 21 Oct 2026 16:17:07 +0300 Subject: [PATCH 2/3] fix: keep zero mean-field weights finite on repeated normalization Co-authored-by: OpenAI Codex --- CHANGELOG.md | 3 ++- bindsnet/network/topology.py | 5 +++-- test/network/test_meanfield_normalization.py | 18 +++++++++++------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe83f0f1..a81c5518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,8 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases). moved it to 3.14); Dependabot no longer proposes minor or major Python bumps for the image. - Mean-field connection normalization preserves the registered weight parameter and - accepts both shared scalar and per-target tensor weights. + accepts both shared scalar and per-target tensor weights. Zero weights stay finite + during repeated normalization, including with a zero normalization target. - API reference on Read the Docs was empty: the build never installed `bindsnet`, so every `automodule` failed to import (`No module named 'matplotlib'`), and `docs/pyproject.toml` downgraded Sphinx to 7.2.6. `.readthedocs.yaml` now installs a diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index c329cf8d..b2862585 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -2127,10 +2127,11 @@ def normalize(self) -> None: # language=rst """ Normalize stored weights to sum to ``self.norm``, preserving their scalar or - per-target tensor shape. + per-target tensor shape. All-zero stored weights remain zero. """ if self.norm is not None: - self.w *= self.norm / self.w.sum() + total = self.w.sum().masked_fill(self.w.abs().sum() == 0, 1) + self.w *= self.norm / total def reset_state_variables(self) -> None: # language=rst diff --git a/test/network/test_meanfield_normalization.py b/test/network/test_meanfield_normalization.py index 4d021275..ae484205 100644 --- a/test/network/test_meanfield_normalization.py +++ b/test/network/test_meanfield_normalization.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize("weight_shape", [(), (4,), (2, 2)]) @pytest.mark.parametrize("dtype", [torch.float32, torch.float64]) -@pytest.mark.parametrize("norm", [None, 2.0]) +@pytest.mark.parametrize("norm", [None, 0.0, 2.0]) def test_meanfield_normalization_preserves_parameter(weight_shape, dtype, norm): target_shape = weight_shape or (3,) weights = ( @@ -27,7 +27,9 @@ def test_meanfield_normalization_preserves_parameter(weight_shape, dtype, norm): state_keys = set(connection.state_dict()) expected = weights.clone() if norm is not None: - expected = weights * 0.2 if weight_shape else torch.tensor(2.0, dtype=dtype) + expected = ( + weights * (norm / 10) if weight_shape else torch.tensor(norm, dtype=dtype) + ) connection.normalize() assert connection.w is parameter @@ -41,13 +43,15 @@ def test_meanfield_normalization_preserves_parameter(weight_shape, dtype, norm): torch.testing.assert_close(connection.w, expected) -def test_network_run_normalizes_meanfield_connection(): +@pytest.mark.parametrize("norm", [0.0, 2.0]) +def test_network_run_normalizes_meanfield_connection(norm): network = Network(dt=1.0) source, target = Input(n=2), LIFNodes(n=3) network.add_layer(source, name="source") network.add_layer(target, name="target") - connection = MeanFieldConnection(source, target, w=torch.tensor(0.25), norm=2.0) + connection = MeanFieldConnection(source, target, w=torch.tensor(0.25), norm=norm) network.add_connection(connection, source="source", target="target") - network.run(inputs={"source": torch.ones(2, 1, 2)}, time=2) - torch.testing.assert_close(connection.w, torch.tensor(2.0)) - assert torch.isfinite(target.v).all() + for _ in range(2): + network.run(inputs={"source": torch.ones(2, 1, 2)}, time=2) + torch.testing.assert_close(connection.w, torch.tensor(norm)) + assert torch.isfinite(target.v).all() From 53dd2e31001d7a550e2c549583ef94a87479e7cf Mon Sep 17 00:00:00 2001 From: AHMETHAKANBEZIR1 Date: Fri, 23 Oct 2026 04:50:33 +0300 Subject: [PATCH 3/3] fix: normalize mean-field absolute weights across targets Co-authored-by: OpenAI Codex --- CHANGELOG.md | 4 +- bindsnet/network/topology.py | 12 +++--- test/network/test_meanfield_normalization.py | 44 +++++++++++++++++++- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81c5518..510caaa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,8 +43,8 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases). moved it to 3.14); Dependabot no longer proposes minor or major Python bumps for the image. - Mean-field connection normalization preserves the registered weight parameter and - accepts both shared scalar and per-target tensor weights. Zero weights stay finite - during repeated normalization, including with a zero normalization target. + signs, keeps zero weights finite, and counts a shared scalar once per target neuron + when normalizing the total absolute weight. Scalar and per-target forms agree. - API reference on Read the Docs was empty: the build never installed `bindsnet`, so every `automodule` failed to import (`No module named 'matplotlib'`), and `docs/pyproject.toml` downgraded Sphinx to 7.2.6. `.readthedocs.yaml` now installs a diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index b2862585..ed0bab9b 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -2087,7 +2087,8 @@ def __init__( tensor of same size as w :param Union[float, torch.Tensor] wmax: Maximum allowed value(s) on the connection weights. Single value, or tensor of same size as w - :param float norm: Total weight per target neuron normalization constant. + :param float norm: Total absolute weight across target neurons. A shared scalar + weight is counted once per target neuron. """ super().__init__(source, target, nu, weight_decay, **kwargs) @@ -2126,12 +2127,13 @@ def update(self, **kwargs) -> None: def normalize(self) -> None: # language=rst """ - Normalize stored weights to sum to ``self.norm``, preserving their scalar or - per-target tensor shape. All-zero stored weights remain zero. + Normalize absolute weights across target neurons to ``self.norm``, preserving + signs and scalar or per-target tensor shape. A shared scalar is counted once + per target neuron. All-zero stored weights remain zero. """ if self.norm is not None: - total = self.w.sum().masked_fill(self.w.abs().sum() == 0, 1) - self.w *= self.norm / total + total = self.w.abs().sum() * (self.target.n / self.w.numel()) + self.w *= self.norm / total.masked_fill(total == 0, 1) def reset_state_variables(self) -> None: # language=rst diff --git a/test/network/test_meanfield_normalization.py b/test/network/test_meanfield_normalization.py index ae484205..7beefcd9 100644 --- a/test/network/test_meanfield_normalization.py +++ b/test/network/test_meanfield_normalization.py @@ -28,7 +28,9 @@ def test_meanfield_normalization_preserves_parameter(weight_shape, dtype, norm): expected = weights.clone() if norm is not None: expected = ( - weights * (norm / 10) if weight_shape else torch.tensor(norm, dtype=dtype) + weights * (norm / 10) + if weight_shape + else torch.tensor(norm / 3, dtype=dtype) ) connection.normalize() @@ -53,5 +55,43 @@ def test_network_run_normalizes_meanfield_connection(norm): network.add_connection(connection, source="source", target="target") for _ in range(2): network.run(inputs={"source": torch.ones(2, 1, 2)}, time=2) - torch.testing.assert_close(connection.w, torch.tensor(norm)) + torch.testing.assert_close(connection.w, torch.tensor(norm / target.n)) assert torch.isfinite(target.v).all() + + +@pytest.mark.parametrize( + "weights,expected", + [ + ([-1.0, -2.0], [-2 / 3, -4 / 3]), + ([1.0, -1.0], [1.0, -1.0]), + ([1.0, -0.999999], [2 / 1.999999, -1.999998 / 1.999999]), + ], +) +def test_meanfield_normalization_preserves_signed_weights(weights, expected): + connection = MeanFieldConnection( + Input(n=2), LIFNodes(n=2), w=torch.tensor(weights), norm=2.0 + ) + for _ in range(2): + connection.normalize() + assert torch.isfinite(connection.w).all() + torch.testing.assert_close(connection.w, torch.tensor(expected)) + + +@pytest.mark.parametrize("value", [-0.25, 0.25]) +@pytest.mark.parametrize("target_shape", [(3,), (2, 2)]) +def test_meanfield_shared_and_per_target_weights_agree(value, target_shape): + source, target = Input(n=2), LIFNodes(shape=target_shape) + shared = MeanFieldConnection(source, target, w=torch.tensor(value), norm=2.0) + per_target = MeanFieldConnection( + source, target, w=torch.full(target_shape, value), norm=2.0 + ) + spikes = torch.tensor([[0.0, 1.0], [1.0, 1.0]]) + for _ in range(2): + shared.normalize() + per_target.normalize() + expected = torch.full(target_shape, (2.0 if value > 0 else -2.0) / target.n) + torch.testing.assert_close(shared.w.expand(target_shape), expected) + torch.testing.assert_close(per_target.w, expected) + torch.testing.assert_close( + shared.compute(spikes).expand(target_shape), per_target.compute(spikes) + )