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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases).
- Dockerfile on `python:3.13-slim`, inside `requires-python = ">=3.11,<3.14"` (#810 had
moved it to 3.14); Dependabot no longer proposes minor or major Python bumps for the
image.
- Mean-field connection normalization preserves the registered weight parameter and
signs, keeps zero weights finite, and counts a shared scalar once per target neuron
when normalizing the total absolute weight. Scalar and per-target forms agree.
- API reference on Read the Docs was empty: the build never installed `bindsnet`, so
every `automodule` failed to import (`No module named 'matplotlib'`), and
`docs/pyproject.toml` downgraded Sphinx to 7.2.6. `.readthedocs.yaml` now installs a
Expand Down
13 changes: 7 additions & 6 deletions bindsnet/network/topology.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -2126,13 +2127,13 @@ def update(self, **kwargs) -> None:
def normalize(self) -> None:
# language=rst
"""
Normalize weights so each target neuron has sum of connection weights equal to
``self.norm``.
Normalize absolute weights across target neurons to ``self.norm``, preserving
signs and scalar or per-target tensor shape. A shared scalar is counted once
per target neuron. All-zero stored weights remain zero.
"""
if self.norm is not None:
self.w = self.w.view(1, self.target.n)
self.w *= self.norm / self.w.sum()
self.w = self.w.view(1, *self.target.shape)
total = self.w.abs().sum() * (self.target.n / self.w.numel())
self.w *= self.norm / total.masked_fill(total == 0, 1)

def reset_state_variables(self) -> None:
# language=rst
Expand Down
97 changes: 97 additions & 0 deletions test/network/test_meanfield_normalization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import pytest
import torch

from bindsnet.network import Network
from bindsnet.network.nodes import Input, LIFNodes
from bindsnet.network.topology import MeanFieldConnection


@pytest.mark.parametrize("weight_shape", [(), (4,), (2, 2)])
@pytest.mark.parametrize("dtype", [torch.float32, torch.float64])
@pytest.mark.parametrize("norm", [None, 0.0, 2.0])
def test_meanfield_normalization_preserves_parameter(weight_shape, dtype, norm):
target_shape = weight_shape or (3,)
weights = (
torch.arange(1, 5, dtype=dtype).reshape(weight_shape)
if weight_shape
else torch.tensor(0.25, dtype=dtype)
)
connection = MeanFieldConnection(
Input(n=2),
LIFNodes(shape=target_shape),
w=weights.clone(),
w_dtype=dtype,
norm=norm,
)
parameter = connection.w
state_keys = set(connection.state_dict())
expected = weights.clone()
if norm is not None:
expected = (
weights * (norm / 10)
if weight_shape
else torch.tensor(norm / 3, dtype=dtype)
)

connection.normalize()
assert connection.w is parameter
assert isinstance(connection.w, torch.nn.Parameter)
assert connection.w.shape == weights.shape
assert set(connection.state_dict()) == state_keys
torch.testing.assert_close(connection.w, expected)
spikes = torch.tensor([[0.0, 1.0], [1.0, 1.0]])
torch.testing.assert_close(connection.compute(spikes), 0.75 * expected)
connection.normalize()
torch.testing.assert_close(connection.w, expected)


@pytest.mark.parametrize("norm", [0.0, 2.0])
def test_network_run_normalizes_meanfield_connection(norm):
network = Network(dt=1.0)
source, target = Input(n=2), LIFNodes(n=3)
network.add_layer(source, name="source")
network.add_layer(target, name="target")
connection = MeanFieldConnection(source, target, w=torch.tensor(0.25), norm=norm)
network.add_connection(connection, source="source", target="target")
for _ in range(2):
network.run(inputs={"source": torch.ones(2, 1, 2)}, time=2)
torch.testing.assert_close(connection.w, torch.tensor(norm / target.n))
assert torch.isfinite(target.v).all()


@pytest.mark.parametrize(
"weights,expected",
[
([-1.0, -2.0], [-2 / 3, -4 / 3]),
([1.0, -1.0], [1.0, -1.0]),
([1.0, -0.999999], [2 / 1.999999, -1.999998 / 1.999999]),
],
)
def test_meanfield_normalization_preserves_signed_weights(weights, expected):
connection = MeanFieldConnection(
Input(n=2), LIFNodes(n=2), w=torch.tensor(weights), norm=2.0
)
for _ in range(2):
connection.normalize()
assert torch.isfinite(connection.w).all()
torch.testing.assert_close(connection.w, torch.tensor(expected))


@pytest.mark.parametrize("value", [-0.25, 0.25])
@pytest.mark.parametrize("target_shape", [(3,), (2, 2)])
def test_meanfield_shared_and_per_target_weights_agree(value, target_shape):
source, target = Input(n=2), LIFNodes(shape=target_shape)
shared = MeanFieldConnection(source, target, w=torch.tensor(value), norm=2.0)
per_target = MeanFieldConnection(
source, target, w=torch.full(target_shape, value), norm=2.0
)
spikes = torch.tensor([[0.0, 1.0], [1.0, 1.0]])
for _ in range(2):
shared.normalize()
per_target.normalize()
expected = torch.full(target_shape, (2.0 if value > 0 else -2.0) / target.n)
torch.testing.assert_close(shared.w.expand(target_shape), expected)
torch.testing.assert_close(per_target.w, expected)
torch.testing.assert_close(
shared.compute(spikes).expand(target_shape), per_target.compute(spikes)
)
Loading