diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f49c077..79035b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ see the [GitHub releases / tags](https://github.com/BindsNET/bindsnet/releases). `wmin` and `wmax` are now created on the device of the given `w`; before, they were always on the CPU. Single-value and per-weight bounds both work. Results do not change. +- `LocalConnection1D`, `LocalConnection2D` and `LocalConnection3D` accept a given `w` + and per-weight `wmin`/`wmax`, as the other connections do; their docstrings now say so. + Before, a given `w` raised `AttributeError` (the shape check used `out_channels`, + which these classes do not have, instead of `n_filters`), and per-weight bounds + raised "Boolean value of Tensor with more than one value is ambiguous" (the bound + check had no `.any()`). Both used to crash, so no working code changes results. ## [0.3.5] - 2026-10-29 diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index 409c4953..328277b7 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -1803,8 +1803,10 @@ def __init__( :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. - :param float wmin: Minimum allowed value on the connection weights. - :param float wmax: Maximum allowed value on the connection weights. + :param Union[float, torch.Tensor] wmin: Minimum allowed value(s) on the connection weights. Single value, or + 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. """ @@ -1836,12 +1838,12 @@ def __init__( else: assert w.shape == ( self.in_channels, - self.out_channels * self.conv_size, + self.n_filters * self.conv_size, self.kernel_size, ), error w = self.cast_dtype_if_needed(w, w_dtype) - if self.wmin != -np.inf or self.wmax != np.inf: + if (self.wmin != -np.inf).any() or (self.wmax != np.inf).any(): w = torch.clamp(w, self.wmin, self.wmax) self.w = Parameter(w, requires_grad=False) @@ -1945,8 +1947,10 @@ def __init__( :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. - :param float wmin: Minimum allowed value on the connection weights. - :param float wmax: Maximum allowed value on the connection weights. + :param Union[float, torch.Tensor] wmin: Minimum allowed value(s) on the connection weights. Single value, or + 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. """ @@ -1988,12 +1992,12 @@ def __init__( else: assert w.shape == ( self.in_channels, - self.out_channels * self.conv_prod, + self.n_filters * self.conv_prod, self.kernel_prod, ), error w = self.cast_dtype_if_needed(w, w_dtype) - if self.wmin != -np.inf or self.wmax != np.inf: + if (self.wmin != -np.inf).any() or (self.wmax != np.inf).any(): w = torch.clamp(w, self.wmin, self.wmax) self.w = Parameter(w, requires_grad=False) @@ -2098,8 +2102,10 @@ def __init__( :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. - :param float wmin: Minimum allowed value on the connection weights. - :param float wmax: Maximum allowed value on the connection weights. + :param Union[float, torch.Tensor] wmin: Minimum allowed value(s) on the connection weights. Single value, or + 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. """ @@ -2143,12 +2149,12 @@ def __init__( else: assert w.shape == ( self.in_channels, - self.out_channels * self.conv_prod, + self.n_filters * self.conv_prod, self.kernel_prod, ), error w = self.cast_dtype_if_needed(w, w_dtype) - if self.wmin != -np.inf or self.wmax != np.inf: + if (self.wmin != -np.inf).any() or (self.wmax != np.inf).any(): w = torch.clamp(w, self.wmin, self.wmax) self.w = Parameter(w, requires_grad=False) diff --git a/test/network/test_connections.py b/test/network/test_connections.py index fdb21c78..6e62da73 100644 --- a/test/network/test_connections.py +++ b/test/network/test_connections.py @@ -977,3 +977,91 @@ def test_connection_from_gpu_weights_clamps_and_learns_on_gpu(wmin, wmax): net.run(inputs={"in": (torch.rand(20, 1, 4) < 0.5).cuda()}, time=20) assert conn.w.is_cuda assert ((conn.w >= 0) & (conn.w <= 1)).all() + + +_LOCAL_CONNECTIONS_ND = { + "LocalConnection1D": ( + LocalConnection1D, + (2, 12), + (2, 4), + {"kernel_size": 3, "stride": 3, "n_filters": 2}, + ), + "LocalConnection2D": ( + LocalConnection2D, + (2, 8, 8), + (3, 3, 3), + {"kernel_size": 4, "stride": 2, "n_filters": 3}, + ), + "LocalConnection3D": ( + LocalConnection3D, + (1, 6, 6, 6), + (2, 3, 3, 3), + {"kernel_size": 2, "stride": 2, "n_filters": 2}, + ), +} + + +@pytest.mark.parametrize("bounds", ["scalar-bounds", "per-weight-bounds"]) +@pytest.mark.parametrize("name", list(_LOCAL_CONNECTIONS_ND)) +def test_local_connection_nd_given_weights_and_per_weight_bounds(name, bounds): + """LocalConnection1D/2D/3D accept a given w and per-weight bounds, clamp w into + them at construction, and keep it there while learning. + + Caught: a given w raised AttributeError (the shape check used self.out_channels, + which these classes never set), and per-weight bounds raised "Boolean value of + Tensor with more than one value is ambiguous" (the bound check had no .any()). + """ + cls, src_shape, tgt_shape, kwargs = _LOCAL_CONNECTIONS_ND[name] + shape = cls(Input(shape=src_shape), LIFNodes(shape=tgt_shape), **kwargs).w.shape + n = int(torch.Size(shape).numel()) + if bounds == "scalar-bounds": + wmin, wmax = 0.0, 1.0 + lo, hi = torch.zeros(shape), torch.ones(shape) + else: + # a different upper bound for each weight + wmin, wmax = torch.zeros(shape), torch.linspace(0.2, 1.0, n).view(shape) + lo, hi = wmin, wmax + w = torch.linspace(-1.0, 2.0, n).view(shape) # values outside the bounds + + source = Input(shape=src_shape, traces=True) + target = LIFNodes(shape=tgt_shape, traces=True) + conn = cls( + source, + target, + w=w, + wmin=wmin, + wmax=wmax, + update_rule=PostPre, + nu=(1.0, 1.0), + **kwargs, + ) + assert ((conn.w >= lo) & (conn.w <= hi)).all() + + net = Network(dt=1.0) + net.add_layer(source, "in") + net.add_layer(target, "out") + net.add_connection(conn, "in", "out") + torch.manual_seed(0) + net.run(inputs={"in": torch.rand(20, 1, *src_shape) < 0.5}, time=20) + assert ((conn.w >= lo) & (conn.w <= hi)).all() + + +@pytest.mark.parametrize("name", list(_LOCAL_CONNECTIONS_ND)) +def test_local_connection_nd_random_weights_within_per_weight_bounds(name): + """Without a given w, LocalConnection1D/2D/3D start inside per-weight bounds. + + Caught: per-weight bounds raised "Boolean value of Tensor with more than one + value is ambiguous" (the bound check had no .any()). + """ + cls, src_shape, tgt_shape, kwargs = _LOCAL_CONNECTIONS_ND[name] + shape = cls(Input(shape=src_shape), LIFNodes(shape=tgt_shape), **kwargs).w.shape + wmax = torch.linspace(0.1, 0.5, int(torch.Size(shape).numel())).view(shape) + torch.manual_seed(0) + conn = cls( + Input(shape=src_shape), + LIFNodes(shape=tgt_shape), + wmin=torch.zeros(shape), + wmax=wmax, + **kwargs, + ) + assert ((conn.w >= 0) & (conn.w <= wmax)).all()