Skip to content

Commit 9845a46

Browse files
authored
fix(dynold): STP construction, sparse-synapse drift, plasticity decay, Bellec init (#848)
fix(dynold): STP construction, sparse-synapse drift, plasticity decay, Bellec adaptation init - STP.reset_state passed (batch_or_mode, sizes) to variable_ in the wrong order, making STP unconstructable (ValueError); swapped to (sizes, batch) like STD (Critical) - sparse Exponential+stp / DualExponential called csrmv(..., method='cusparse'); csrmv no longer accepts method -> TypeError on every sparse update; removed kwarg (High) - Exponential sparse+stp branch fed raw pre_spike to csrmv instead of the STP-filtered syn_value, silently ignoring STP on sparse layout (High) - STD/STP discrete jumps read pre-decay state instead of the decayed locals (off-by-one decay); use decayed x^-, u^- and facilitated u^+ (Medium) - ALIFBellec2020 / LIF_SFA_Bellec2020 default a_initializer started SFA adaptation deeply negative; changed OneInit(-50.) -> ZeroInit() (Medium) Findings recorded in docs/issues-found-20260619-dynold.md
1 parent b78d61b commit 9845a46

9 files changed

Lines changed: 412 additions & 60 deletions

brainpy/dynold/experimental/abstract_synapses.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,14 @@ def update(self, pre_spike, post_v=None):
146146
self.conn_mask[1],
147147
s,
148148
shape=(self.pre_num, self.post_num),
149-
transpose=True,
150-
method='cusparse')
149+
transpose=True)
151150
if isinstance(self.mode, bm.BatchingMode):
152151
f = vmap(f)
153-
post_vs = f(pre_spike)
152+
# ``f`` is fed ``syn_value`` (the STP-filtered drive when an stp
153+
# component is present) so short-term plasticity actually affects
154+
# the sparse conductance; the event-based no-stp branch above
155+
# consumes the boolean ``pre_spike`` directly.
156+
post_vs = f(syn_value)
154157
else:
155158
post_vs = self._syn2post_with_dense(syn_value, self.g_max, self.conn_mask)
156159

@@ -294,8 +297,7 @@ def update(self, pre_spike, post_v=None):
294297
self.conn_mask[1],
295298
s,
296299
shape=(self.conn.pre_num, self.conn.post_num),
297-
transpose=True,
298-
method='cusparse'
300+
transpose=True
299301
)
300302
if isinstance(self.mode, bm.BatchingMode):
301303
f = vmap(f)

brainpy/dynold/experimental/abstract_synapses_test.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,36 @@ def test_sparse_no_stp_batching(self):
116116
r = bm.as_jax(syn.update(bm.ones((2, 5))))
117117
self.assertEqual(r.shape, (2, 4))
118118

119-
def test_sparse_with_stp_defect(self):
120-
# NOTE: DEFECT -- the sparse + stp path calls
121-
# bm.sparse.csrmv(..., method='cusparse'); csrmv no longer accepts
122-
# a `method` kwarg, so this raises TypeError.
119+
def test_sparse_with_stp(self):
120+
# P11-H1/H2 regression: the sparse + stp path used to (a) call
121+
# bm.sparse.csrmv(..., method='cusparse') -> TypeError, and (b) feed the
122+
# raw pre_spike (not the STP-filtered syn_value) into csrmv. It must now
123+
# run and return a finite, post-shaped conductance.
124+
import numpy as np
123125
conn = bp.conn.FixedProb(0.5)(pre_size=5, post_size=4)
124126
syn = asyn.Exponential(conn, comp_method='sparse', stp=syn_plasticity.STD(5))
125-
share.save(t=0.0, dt=bm.get_dt())
126-
with self.assertRaises(TypeError):
127-
syn.update(bm.ones(5, dtype=bool))
127+
r = _step(syn, bm.ones(5, dtype=bool))
128+
self.assertEqual(r.shape, (4,))
129+
self.assertTrue(np.all(np.isfinite(np.asarray(r))))
130+
131+
def test_sparse_stp_filters_conductance(self):
132+
# P11-H2 regression: with STD attached, the depressed (filtered) drive
133+
# must produce a strictly smaller conductance than with no STP, on the
134+
# same sparse connectivity / spikes. (Previously STP was ignored on the
135+
# sparse path, so the two were identical.)
136+
import numpy as np
137+
conn = bp.conn.FixedProb(1.0)(pre_size=5, post_size=4)
138+
spikes = bm.ones(5, dtype=bool)
139+
140+
syn_no = asyn.Exponential(conn, comp_method='sparse')
141+
r_no = np.asarray(_step(syn_no, spikes, n=1))
142+
143+
syn_stp = asyn.Exponential(conn, comp_method='sparse',
144+
stp=syn_plasticity.STD(5, U=0.5))
145+
r_stp = np.asarray(_step(syn_stp, spikes, n=1))
146+
147+
self.assertTrue(np.all(r_stp <= r_no + 1e-6))
148+
self.assertTrue(np.any(r_stp < r_no - 1e-6))
128149

129150
def test_reset_state(self):
130151
conn = bp.conn.All2All()(pre_size=4, post_size=4)
@@ -188,15 +209,17 @@ def test_dh_dg_rhs(self):
188209
g = bm.ones(3)
189210
np.testing.assert_allclose(bm.as_jax(syn.dg(g, 0., h)), -bm.as_jax(g) / 10. + bm.as_jax(h))
190211

191-
def test_sparse_defect(self):
192-
# NOTE: DEFECT -- DualExponential's sparse path always calls
212+
def test_sparse(self):
213+
# P11-H1 regression: DualExponential's sparse path used to always call
193214
# bm.sparse.csrmv(..., method='cusparse'); csrmv no longer accepts a
194-
# `method` kwarg, so this raises TypeError.
215+
# `method` kwarg, so this raised TypeError. It must now run and return a
216+
# finite, post-shaped conductance.
217+
import numpy as np
195218
conn = bp.conn.FixedProb(0.5)(pre_size=5, post_size=4)
196219
syn = asyn.DualExponential(conn, comp_method='sparse')
197-
share.save(t=0.0, dt=bm.get_dt())
198-
with self.assertRaises(TypeError):
199-
syn.update(bm.ones(5))
220+
r = _step(syn, bm.ones(5))
221+
self.assertEqual(r.shape, (4,))
222+
self.assertTrue(np.all(np.isfinite(np.asarray(r))))
200223

201224
def test_reset_state(self):
202225
conn = bp.conn.All2All()(pre_size=4, post_size=4)

brainpy/dynold/experimental/syn_plasticity.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ def reset_state(self, batch_size=None):
9292

9393
def update(self, pre_spike):
9494
x = self.integral(self.x.value, share.load('t'), share.load('dt'))
95-
self.x.value = bm.where(pre_spike, x - self.U * self.x, x)
95+
# The depression jump must be applied to the value *at spike arrival*,
96+
# i.e. the recovered/decayed local ``x`` (= x^-), not the pre-decay
97+
# ``self.x`` from the previous step (P11-M1).
98+
self.x.value = bm.where(pre_spike, x - self.U * x, x)
9699
return self.x.value
97100

98101

@@ -166,16 +169,19 @@ def __init__(
166169
self.reset_state(self.mode)
167170

168171
def reset_state(self, batch_size=None):
169-
self.x = variable_(jnp.ones, batch_size, self.num)
170-
self.u = variable_(OneInit(self.U), batch_size, self.num)
172+
self.x = variable_(jnp.ones, self.num, batch_size)
173+
self.u = variable_(OneInit(self.U), self.num, batch_size)
171174

172175
du = lambda self, u, t: self.U - u / self.tau_f
173176
dx = lambda self, x, t: (1 - x) / self.tau_d
174177

175178
def update(self, pre_spike):
176179
u, x = self.integral(self.u.value, self.x.value, share.load('t'), bm.get_dt())
177-
u = bm.where(pre_spike, u + self.U * (1 - self.u), u)
178-
x = bm.where(pre_spike, x - u * self.x, x)
180+
# Tsodyks-Markram jumps act on the values *at spike arrival* (the decayed
181+
# locals u^-/x^-), and the depression of x uses the facilitated u^+
182+
# (P11-M1): u^+ = u^- + U(1 - u^-); x^+ = x^- - u^+ x^-.
183+
u = bm.where(pre_spike, u + self.U * (1 - u), u)
184+
x = bm.where(pre_spike, x - u * x, x)
179185
self.x.value = x
180186
self.u.value = u
181187
return self.x.value * self.u.value

brainpy/dynold/experimental/syn_plasticity_test.py

Lines changed: 29 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,10 @@
1818
Exercises the experimental short-term plasticity components ``STD`` (fully
1919
functional) and ``STP``.
2020
21-
.. note::
22-
23-
``STP`` is currently **unconstructable**: its ``reset_state`` calls
24-
``variable_(jnp.ones, batch_size, self.num)`` with the ``batch_or_mode``
25-
and ``sizes`` arguments swapped relative to ``STD``. When ``__init__``
26-
calls ``reset_state(self.mode)`` the ``Mode`` object lands in the
27-
``sizes`` slot and ``to_size`` raises ``ValueError: Cannot make a size
28-
for NonBatchingMode``. The DEFECT is pinned in
29-
``TestSTP.test_stp_construction_is_broken`` below; the rest of STP's
30-
behaviour (the ``du``/``dx`` ODE RHS and ``update``) is exercised through
31-
a manually corrected instance.
21+
``STP`` previously could not be constructed: its ``reset_state`` called
22+
``variable_(jnp.ones, batch_size, self.num)`` with the ``batch_or_mode`` and
23+
``sizes`` arguments swapped relative to ``STD`` (P11-C1). That is now fixed and
24+
the construction / update behaviour is exercised directly below.
3225
"""
3326

3427
import unittest
@@ -88,29 +81,33 @@ def setUp(self):
8881
bm.random.seed(123)
8982
bm.set_dt(0.1)
9083

91-
def test_stp_construction_is_broken(self):
92-
# NOTE: DEFECT -- STP.reset_state has swapped (batch_or_mode, sizes)
93-
# arguments to variable_, so constructing STP with the default
94-
# NonBatchingMode raises ValueError ("Cannot make a size for ...Mode").
95-
# STD.reset_state uses the correct order; STP should mirror it.
96-
with self.assertRaises(ValueError):
97-
sp.STP(4)
84+
def test_stp_construction_ok(self):
85+
# P11-C1 regression: STP.reset_state used to pass (batch_or_mode, sizes)
86+
# to variable_ in the wrong order, so constructing STP with the default
87+
# NonBatchingMode raised ValueError ("Cannot make a size for ...Mode").
88+
# It must now construct cleanly with x=ones, u=U.
89+
stp = sp.STP(4, U=0.15, tau_f=1500., tau_d=200.)
90+
self.assertEqual(stp.num, 4)
91+
self.assertEqual(stp.x.shape, (4,))
92+
self.assertEqual(stp.u.shape, (4,))
93+
np.testing.assert_allclose(bm.as_jax(stp.x.value), np.ones(4))
94+
np.testing.assert_allclose(bm.as_jax(stp.u.value), np.full(4, 0.15))
95+
96+
def test_stp_update_state_changes(self):
97+
# P11-C1 regression: a constructed STP must update without error and
98+
# respond to a presynaptic spike (u facilitates, x depresses).
99+
stp = sp.STP(4, U=0.15, tau_f=1500., tau_d=200.)
100+
share.save(t=0.0, dt=bm.dt)
101+
x_before = bm.as_jax(stp.x.value).copy()
102+
u_before = bm.as_jax(stp.u.value).copy()
103+
r = bm.as_jax(stp.update(bm.ones(4, dtype=bool)))
104+
self.assertEqual(r.shape, (4,))
105+
self.assertTrue(np.all(bm.as_jax(stp.u.value) >= u_before - 1e-6))
106+
self.assertTrue(np.all(bm.as_jax(stp.x.value) <= x_before + 1e-6))
98107

99108
def _make_stp(self, num=4, U=0.15, tau_f=1500., tau_d=200.):
100-
"""Build a working STP instance, working around the reset_state defect."""
101-
stp = sp.STP.__new__(sp.STP)
102-
SynSTPNS.__init__(stp)
103-
stp.pre_size = tools.to_size(num)
104-
stp.num = tools.size2num(stp.pre_size)
105-
stp.tau_f = parameter(tau_f, stp.num)
106-
stp.tau_d = parameter(tau_d, stp.num)
107-
stp.U = parameter(U, stp.num)
108-
stp.method = 'exp_auto'
109-
stp.integral = odeint(JointEq([stp.du, stp.dx]), method=stp.method)
110-
# correct argument order (mirrors STD.reset_state)
111-
stp.x = variable_(jnp.ones, stp.num, None)
112-
stp.u = variable_(OneInit(stp.U), stp.num, None)
113-
return stp
109+
"""Build an STP instance directly (now that the constructor works)."""
110+
return sp.STP(num, U=U, tau_f=tau_f, tau_d=tau_d)
114111

115112
def test_du_dx_rhs(self):
116113
stp = self._make_stp()

brainpy/dynold/neurons/reduced_models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,7 +1309,7 @@ def __init__(
13091309

13101310
# initializers
13111311
V_initializer: Union[Initializer, Callable, ArrayType] = OneInit(-70.),
1312-
a_initializer: Union[Initializer, Callable, ArrayType] = OneInit(-50.),
1312+
a_initializer: Union[Initializer, Callable, ArrayType] = ZeroInit(),
13131313

13141314
# parameter for training
13151315
spike_fun: Callable = bm.surrogate.relu_grad,
@@ -1472,7 +1472,7 @@ def __init__(
14721472

14731473
# initializers
14741474
V_initializer: Union[Initializer, Callable, ArrayType] = OneInit(-70.),
1475-
a_initializer: Union[Initializer, Callable, ArrayType] = OneInit(-50.),
1475+
a_initializer: Union[Initializer, Callable, ArrayType] = ZeroInit(),
14761476

14771477
# parameter for training
14781478
spike_fun: Callable = bm.surrogate.relu_grad,

brainpy/dynold/neurons/reduced_neurons_test.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,26 @@ def test_training_shape(self, neuron):
8383
progress_bar=False)
8484
runner.run(10.)
8585
self.assertTupleEqual(runner.mon['V'].shape, (1, 100, 10))
86+
87+
88+
class TestBellecAdaptation(parameterized.TestCase):
89+
"""P11-M2 regression: the SFA adaptation variable ``a`` must start at rest.
90+
91+
The threshold adaptation contributes ``beta * a`` to the effective firing
92+
threshold (``V_th + beta * a``). The historical default ``OneInit(-50.)``
93+
started ``a`` deeply negative, dropping the effective threshold by tens of
94+
mV for thousands of ms and making a cold-started neuron fire spuriously.
95+
The default must be a rest value (~0).
96+
"""
97+
98+
@parameterized.named_parameters(
99+
{'testcase_name': 'ALIFBellec2020', 'neuron': 'ALIFBellec2020'},
100+
{'testcase_name': 'LIF_SFA_Bellec2020', 'neuron': 'LIF_SFA_Bellec2020'},
101+
)
102+
def test_default_adaptation_starts_at_rest(self, neuron):
103+
bm.random.seed(0)
104+
model = getattr(reduced_models, neuron)(size=4)
105+
model.reset_state()
106+
a0 = bm.as_jax(model.a.value)
107+
# adaptation starts at zero (no spurious sub-threshold offset)
108+
self.assertTrue(bool(bm.all(a0 == 0.)))

brainpy/dynold/synplast/short_term_plasticity.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ def reset_state(self, batch_size=None):
9595

9696
def update(self, pre_spike):
9797
x = self.integral(self.x.value, share['t'], share['dt'])
98-
self.x.value = jnp.where(pre_spike, x - self.U * self.x, x)
98+
# The depression jump must be applied to the value *at spike arrival*,
99+
# i.e. the recovered/decayed local ``x`` (= x^-), not the pre-decay
100+
# ``self.x`` from the previous step (P11-M1).
101+
self.x.value = jnp.where(pre_spike, x - self.U * x, x)
99102

100103
def filter(self, g):
101104
if jnp.shape(g) != self.x.shape:
@@ -191,8 +194,11 @@ def derivative(self):
191194

192195
def update(self, pre_spike):
193196
u, x = self.integral(self.u.value, self.x.value, share['t'], share['dt'])
194-
u = jnp.where(pre_spike, u + self.U * (1 - self.u), u)
195-
x = jnp.where(pre_spike, x - u * self.x, x)
197+
# Tsodyks-Markram jumps act on the values *at spike arrival* (the decayed
198+
# locals u^-/x^-), and the depression of x uses the facilitated u^+
199+
# (P11-M1): u^+ = u^- + U(1 - u^-); x^+ = x^- - u^+ x^-.
200+
u = jnp.where(pre_spike, u + self.U * (1 - u), u)
201+
x = jnp.where(pre_spike, x - u * x, x)
196202
self.x.value = x
197203
self.u.value = u
198204

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2025 BrainX Ecosystem Limited. All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
# ==============================================================================
16+
"""Tests for ``brainpy.dynold.synplast.short_term_plasticity`` (STD / STP).
17+
18+
These STP components are attached as the ``stp=`` slot of a ``TwoEndConn``
19+
synapse; ``register_master`` allocates their state from the master's
20+
pre-synaptic group. The regressions here pin P11-M1: the discrete
21+
Tsodyks-Markram jumps must act on the value *at spike arrival* (the decayed
22+
local), not the pre-decay state held over from the previous step.
23+
"""
24+
25+
import unittest
26+
27+
import numpy as np
28+
29+
import brainpy as bp
30+
import brainpy.math as bm
31+
from brainpy.context import share
32+
33+
34+
def _make_std(num=4, tau=200., U=0.07):
35+
"""Build an STD bound to a real master synapse."""
36+
pre = bp.neurons.LIF(num)
37+
post = bp.neurons.LIF(num)
38+
syn = bp.synapses.Exponential(pre, post, bp.connect.One2One(),
39+
stp=bp.synplast.STD(tau=tau, U=U),
40+
comp_method='dense')
41+
return syn.stp
42+
43+
44+
def _make_stp(num=4, U=0.15, tau_f=1500., tau_d=200.):
45+
pre = bp.neurons.LIF(num)
46+
post = bp.neurons.LIF(num)
47+
syn = bp.synapses.Exponential(pre, post, bp.connect.One2One(),
48+
stp=bp.synplast.STP(U=U, tau_f=tau_f, tau_d=tau_d),
49+
comp_method='dense')
50+
return syn.stp
51+
52+
53+
class TestSTD(unittest.TestCase):
54+
def setUp(self):
55+
bm.random.seed(0)
56+
bm.set_dt(0.1)
57+
58+
def test_first_spike_from_rest(self):
59+
std = _make_std(3, tau=200., U=0.07)
60+
share.save(t=0.0, dt=bm.dt)
61+
std.update(bm.ones(3, dtype=bool))
62+
# from rest x=1 -> x^+ = 1 - U = 0.93 (decay over one dt is negligible)
63+
np.testing.assert_allclose(bm.as_jax(std.x.value), np.full(3, 1 - 0.07), atol=2e-3)
64+
65+
def test_jump_uses_decayed_state(self):
66+
# P11-M1: depress, let x recover for one step (no spike), then spike. The
67+
# depression must scale with the *decayed* x (= x^- at spike arrival),
68+
# i.e. x^+ = x_dec - U*x_dec, NOT x_dec - U*x_prev.
69+
U, tau, dt = 0.5, 50., bm.dt
70+
std = _make_std(1, tau=tau, U=U)
71+
share.save(t=0.0, dt=dt)
72+
std.update(bm.ones(1, dtype=bool)) # x drops to ~1-U
73+
x_prev = float(bm.as_jax(std.x.value)[0])
74+
share.save(t=float(dt), dt=dt)
75+
std.update(bm.ones(1, dtype=bool)) # recover one dt, then spike
76+
x_after = float(bm.as_jax(std.x.value)[0])
77+
78+
# decayed value at spike arrival
79+
x_dec = x_prev + (1 - x_prev) / tau * float(dt)
80+
expected_correct = x_dec - U * x_dec
81+
expected_buggy = x_dec - U * x_prev
82+
self.assertAlmostEqual(x_after, expected_correct, places=5)
83+
# the two differ enough (recovery over dt) that the buggy form is rejected
84+
self.assertNotAlmostEqual(expected_correct, expected_buggy, places=7)
85+
86+
87+
class TestSTP(unittest.TestCase):
88+
def setUp(self):
89+
bm.random.seed(0)
90+
bm.set_dt(0.1)
91+
92+
def test_jump_uses_decayed_state(self):
93+
# P11-M1: u^+ = u^- + U(1-u^-) and x^+ = x^- - u^+ x^- must use the
94+
# decayed (current-time) locals, not the previous-step Variables.
95+
U, tau_f, tau_d, dt = 0.5, 100., 50., bm.dt
96+
stp = _make_stp(1, U=U, tau_f=tau_f, tau_d=tau_d)
97+
share.save(t=0.0, dt=dt)
98+
stp.update(bm.ones(1, dtype=bool))
99+
u_prev = float(bm.as_jax(stp.u.value)[0])
100+
x_prev = float(bm.as_jax(stp.x.value)[0])
101+
share.save(t=float(dt), dt=dt)
102+
stp.update(bm.ones(1, dtype=bool))
103+
u_after = float(bm.as_jax(stp.u.value)[0])
104+
x_after = float(bm.as_jax(stp.x.value)[0])
105+
106+
# decayed locals at spike arrival (exp_auto integrates exactly here)
107+
u_dec = u_prev + (U - u_prev / tau_f) * float(dt)
108+
x_dec = x_prev + (1 - x_prev) / tau_d * float(dt)
109+
u_correct = u_dec + U * (1 - u_dec)
110+
x_correct = x_dec - u_correct * x_dec
111+
self.assertAlmostEqual(u_after, u_correct, places=4)
112+
self.assertAlmostEqual(x_after, x_correct, places=4)
113+
# buggy variants (using the pre-decay Variables) must be distinguishable
114+
u_buggy = u_dec + U * (1 - u_prev)
115+
self.assertNotAlmostEqual(u_correct, u_buggy, places=6)
116+
117+
118+
if __name__ == '__main__':
119+
unittest.main()

0 commit comments

Comments
 (0)