Skip to content

O(1) Memory Online Learning

Train SNNs on arbitrarily long sequences without BPTT memory overhead.

E-prop Trainer

sc_neurocore.online_learning.eprop.EpropTrainer dataclass

E-prop online trainer for a single-layer recurrent SNN.

Parameters

n_inputs : int Input dimension. n_neurons : int Number of LIF neurons. n_outputs : int Output dimension. tau_mem : float Membrane time constant (ms). tau_trace : float Eligibility trace decay time constant (ms). threshold : float Spike threshold. lr : float Learning rate. dt : float Timestep (ms).

Source code in src/sc_neurocore/online_learning/eprop.py
Python
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
@dataclass
class EpropTrainer:
    """E-prop online trainer for a single-layer recurrent SNN.

    Parameters
    ----------
    n_inputs : int
        Input dimension.
    n_neurons : int
        Number of LIF neurons.
    n_outputs : int
        Output dimension.
    tau_mem : float
        Membrane time constant (ms).
    tau_trace : float
        Eligibility trace decay time constant (ms).
    threshold : float
        Spike threshold.
    lr : float
        Learning rate.
    dt : float
        Timestep (ms).
    """

    n_inputs: int
    n_neurons: int
    n_outputs: int
    tau_mem: float = 20.0
    tau_trace: float = 20.0
    threshold: float = 1.0
    lr: float = 0.01
    dt: float = 1.0

    # Learned weights
    W_in: np.ndarray[Any, Any] = field(init=False, repr=False)
    W_rec: np.ndarray[Any, Any] = field(init=False, repr=False)
    W_out: np.ndarray[Any, Any] = field(init=False, repr=False)

    # Internal state
    _v: np.ndarray[Any, Any] = field(init=False, repr=False)
    _spikes: np.ndarray[Any, Any] = field(init=False, repr=False)
    _trace_in: np.ndarray[Any, Any] = field(init=False, repr=False)
    _trace_rec: np.ndarray[Any, Any] = field(init=False, repr=False)
    _eligibility_in: np.ndarray[Any, Any] = field(init=False, repr=False)
    _eligibility_rec: np.ndarray[Any, Any] = field(init=False, repr=False)

    def __post_init__(self) -> None:
        rng = np.random.RandomState(42)
        scale_in = np.sqrt(2.0 / self.n_inputs)
        scale_rec = np.sqrt(2.0 / self.n_neurons)
        self.W_in = rng.randn(self.n_neurons, self.n_inputs) * scale_in
        self.W_rec = rng.randn(self.n_neurons, self.n_neurons) * scale_rec
        np.fill_diagonal(self.W_rec, 0)  # no self-connections
        self.W_out = rng.randn(self.n_outputs, self.n_neurons) * np.sqrt(2.0 / self.n_neurons)
        self.reset()

    def reset(self) -> None:
        """Reset all internal state and eligibility traces."""
        self._v = np.zeros(self.n_neurons)
        self._spikes = np.zeros(self.n_neurons)
        self._trace_in = np.zeros((self.n_neurons, self.n_inputs))
        self._trace_rec = np.zeros((self.n_neurons, self.n_neurons))
        self._eligibility_in = np.zeros((self.n_neurons, self.n_inputs))
        self._eligibility_rec = np.zeros((self.n_neurons, self.n_neurons))

    def step(
        self, x: np.ndarray[Any, Any], target: np.ndarray[Any, Any] | None = None
    ) -> dict[str, Any]:
        """Process one timestep with optional learning.

        Parameters
        ----------
        x : ndarray of shape (n_inputs,)
            Input spike vector or rates.
        target : ndarray of shape (n_outputs,), optional
            Target output for computing learning signal. If None, no learning.

        Returns
        -------
        dict with keys: 'spikes', 'output', 'loss' (if target given)
        """
        alpha = np.exp(-self.dt / self.tau_mem)
        kappa = np.exp(-self.dt / self.tau_trace)

        # LIF dynamics
        current = self.W_in @ x + self.W_rec @ self._spikes
        self._v = alpha * self._v + (1 - alpha) * current
        new_spikes = (self._v >= self.threshold).astype(np.float64)
        self._v -= new_spikes * self.threshold

        # Surrogate gradient: pseudo-derivative of spike function
        pseudo_deriv = 1.0 / (1.0 + np.abs(self._v - self.threshold) * 5) ** 2

        # Update eligibility traces (low-pass filtered outer products)
        self._trace_in = kappa * self._trace_in + np.outer(pseudo_deriv, x)
        self._trace_rec = kappa * self._trace_rec + np.outer(pseudo_deriv, self._spikes)
        self._eligibility_in = kappa * self._eligibility_in + self._trace_in
        self._eligibility_rec = kappa * self._eligibility_rec + self._trace_rec

        self._spikes = new_spikes

        # Readout
        output = self.W_out @ self._spikes

        result: dict[str, Any] = {"spikes": self._spikes.copy(), "output": output}

        if target is not None:
            error = output - target
            loss = 0.5 * float(np.sum(error**2))
            result["loss"] = loss

            # Learning signal: broadcast error through output weights
            learning_signal = self.W_out.T @ error  # (n_neurons,)

            # Three-factor update: learning_signal * eligibility
            dW_in = np.outer(learning_signal, np.ones(self.n_inputs)) * self._eligibility_in
            dW_rec = np.outer(learning_signal, np.ones(self.n_neurons)) * self._eligibility_rec
            dW_out = np.outer(error, self._spikes)

            self.W_in -= self.lr * dW_in
            self.W_rec -= self.lr * dW_rec
            np.fill_diagonal(self.W_rec, 0)
            self.W_out -= self.lr * dW_out

        return result

    def train_sequence(self, inputs: np.ndarray[Any, Any], targets: np.ndarray[Any, Any]) -> float:
        """Train on one sequence, return mean loss.

        Parameters
        ----------
        inputs : ndarray of shape (T, n_inputs)
        targets : ndarray of shape (T, n_outputs)

        Returns
        -------
        float
            Mean loss over sequence.
        """
        self.reset()
        total_loss = 0.0
        T: int = int(inputs.shape[0])
        for t in range(T):
            result = self.step(inputs[t], target=targets[t])
            total_loss += float(result.get("loss", 0.0))
        return total_loss / T

    def predict_sequence(self, inputs: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
        """Run inference on a sequence without learning.

        Parameters
        ----------
        inputs : ndarray of shape (T, n_inputs)

        Returns
        -------
        ndarray of shape (T, n_outputs)
        """
        self.reset()
        T = inputs.shape[0]
        outputs = np.zeros((T, self.n_outputs))
        for t in range(T):
            result = self.step(inputs[t])
            outputs[t] = result["output"]
        return outputs

    @property
    def memory_per_step(self) -> int:
        """Memory usage per timestep in parameters (O(1) in T)."""
        return (
            self.n_neurons  # membrane voltages
            + self.n_neurons  # spikes
            + self.n_neurons * self.n_inputs * 2  # traces + eligibilities (in)
            + self.n_neurons * self.n_neurons * 2  # traces + eligibilities (rec)
        )

memory_per_step property

Memory usage per timestep in parameters (O(1) in T).

reset()

Reset all internal state and eligibility traces.

Source code in src/sc_neurocore/online_learning/eprop.py
Python
86
87
88
89
90
91
92
93
def reset(self) -> None:
    """Reset all internal state and eligibility traces."""
    self._v = np.zeros(self.n_neurons)
    self._spikes = np.zeros(self.n_neurons)
    self._trace_in = np.zeros((self.n_neurons, self.n_inputs))
    self._trace_rec = np.zeros((self.n_neurons, self.n_neurons))
    self._eligibility_in = np.zeros((self.n_neurons, self.n_inputs))
    self._eligibility_rec = np.zeros((self.n_neurons, self.n_neurons))

step(x, target=None)

Process one timestep with optional learning.

Parameters

x : ndarray of shape (n_inputs,) Input spike vector or rates. target : ndarray of shape (n_outputs,), optional Target output for computing learning signal. If None, no learning.

Returns

dict with keys: 'spikes', 'output', 'loss' (if target given)

Source code in src/sc_neurocore/online_learning/eprop.py
Python
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def step(
    self, x: np.ndarray[Any, Any], target: np.ndarray[Any, Any] | None = None
) -> dict[str, Any]:
    """Process one timestep with optional learning.

    Parameters
    ----------
    x : ndarray of shape (n_inputs,)
        Input spike vector or rates.
    target : ndarray of shape (n_outputs,), optional
        Target output for computing learning signal. If None, no learning.

    Returns
    -------
    dict with keys: 'spikes', 'output', 'loss' (if target given)
    """
    alpha = np.exp(-self.dt / self.tau_mem)
    kappa = np.exp(-self.dt / self.tau_trace)

    # LIF dynamics
    current = self.W_in @ x + self.W_rec @ self._spikes
    self._v = alpha * self._v + (1 - alpha) * current
    new_spikes = (self._v >= self.threshold).astype(np.float64)
    self._v -= new_spikes * self.threshold

    # Surrogate gradient: pseudo-derivative of spike function
    pseudo_deriv = 1.0 / (1.0 + np.abs(self._v - self.threshold) * 5) ** 2

    # Update eligibility traces (low-pass filtered outer products)
    self._trace_in = kappa * self._trace_in + np.outer(pseudo_deriv, x)
    self._trace_rec = kappa * self._trace_rec + np.outer(pseudo_deriv, self._spikes)
    self._eligibility_in = kappa * self._eligibility_in + self._trace_in
    self._eligibility_rec = kappa * self._eligibility_rec + self._trace_rec

    self._spikes = new_spikes

    # Readout
    output = self.W_out @ self._spikes

    result: dict[str, Any] = {"spikes": self._spikes.copy(), "output": output}

    if target is not None:
        error = output - target
        loss = 0.5 * float(np.sum(error**2))
        result["loss"] = loss

        # Learning signal: broadcast error through output weights
        learning_signal = self.W_out.T @ error  # (n_neurons,)

        # Three-factor update: learning_signal * eligibility
        dW_in = np.outer(learning_signal, np.ones(self.n_inputs)) * self._eligibility_in
        dW_rec = np.outer(learning_signal, np.ones(self.n_neurons)) * self._eligibility_rec
        dW_out = np.outer(error, self._spikes)

        self.W_in -= self.lr * dW_in
        self.W_rec -= self.lr * dW_rec
        np.fill_diagonal(self.W_rec, 0)
        self.W_out -= self.lr * dW_out

    return result

train_sequence(inputs, targets)

Train on one sequence, return mean loss.

Parameters

inputs : ndarray of shape (T, n_inputs) targets : ndarray of shape (T, n_outputs)

Returns

float Mean loss over sequence.

Source code in src/sc_neurocore/online_learning/eprop.py
Python
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def train_sequence(self, inputs: np.ndarray[Any, Any], targets: np.ndarray[Any, Any]) -> float:
    """Train on one sequence, return mean loss.

    Parameters
    ----------
    inputs : ndarray of shape (T, n_inputs)
    targets : ndarray of shape (T, n_outputs)

    Returns
    -------
    float
        Mean loss over sequence.
    """
    self.reset()
    total_loss = 0.0
    T: int = int(inputs.shape[0])
    for t in range(T):
        result = self.step(inputs[t], target=targets[t])
        total_loss += float(result.get("loss", 0.0))
    return total_loss / T

predict_sequence(inputs)

Run inference on a sequence without learning.

Parameters

inputs : ndarray of shape (T, n_inputs)

Returns

ndarray of shape (T, n_outputs)

Source code in src/sc_neurocore/online_learning/eprop.py
Python
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def predict_sequence(self, inputs: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
    """Run inference on a sequence without learning.

    Parameters
    ----------
    inputs : ndarray of shape (T, n_inputs)

    Returns
    -------
    ndarray of shape (T, n_outputs)
    """
    self.reset()
    T = inputs.shape[0]
    outputs = np.zeros((T, self.n_outputs))
    for t in range(T):
        result = self.step(inputs[t])
        outputs[t] = result["output"]
    return outputs

Online LIF Layer

sc_neurocore.online_learning.online_trainer.OnlineLIFLayer dataclass

Single LIF layer with online (eligibility-based) learning.

Parameters

n_inputs : int n_neurons : int tau_mem : float Membrane time constant. threshold : float lr : float Learning rate for local weight updates.

Source code in src/sc_neurocore/online_learning/online_trainer.py
Python
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@dataclass
class OnlineLIFLayer:
    """Single LIF layer with online (eligibility-based) learning.

    Parameters
    ----------
    n_inputs : int
    n_neurons : int
    tau_mem : float
        Membrane time constant.
    threshold : float
    lr : float
        Learning rate for local weight updates.
    """

    n_inputs: int
    n_neurons: int
    tau_mem: float = 20.0
    threshold: float = 1.0
    lr: float = 0.01
    dt: float = 1.0

    W: np.ndarray[Any, Any] = field(init=False, repr=False)
    _v: np.ndarray[Any, Any] = field(init=False, repr=False)
    _spikes: np.ndarray[Any, Any] = field(init=False, repr=False)
    _trace: np.ndarray[Any, Any] = field(init=False, repr=False)

    def __post_init__(self) -> None:
        rng = np.random.RandomState(42)
        self.W = rng.randn(self.n_neurons, self.n_inputs) * np.sqrt(2.0 / self.n_inputs)
        self.reset()

    def reset(self) -> None:
        self._v = np.zeros(self.n_neurons)
        self._spikes = np.zeros(self.n_neurons)
        self._trace = np.zeros((self.n_neurons, self.n_inputs))

    def step(self, x: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
        """Forward one timestep. Returns spike vector."""
        alpha = np.exp(-self.dt / self.tau_mem)
        current = self.W @ x
        self._v = alpha * self._v + (1 - alpha) * current
        self._spikes = (self._v >= self.threshold).astype(np.float64)
        self._v -= self._spikes * self.threshold

        # Update eligibility trace
        pseudo = 1.0 / (1.0 + np.abs(self._v - self.threshold) * 5) ** 2
        self._trace = 0.95 * self._trace + np.outer(pseudo, x)
        return self._spikes

    def apply_learning_signal(self, signal: np.ndarray[Any, Any]) -> None:
        """Apply a top-down learning signal to update weights.

        Parameters
        ----------
        signal : ndarray of shape (n_neurons,)
            Per-neuron learning signal (e.g., error backprojected from output).
        """
        dW = np.outer(signal, np.ones(self.n_inputs)) * self._trace
        self.W -= self.lr * dW

step(x)

Forward one timestep. Returns spike vector.

Source code in src/sc_neurocore/online_learning/online_trainer.py
Python
60
61
62
63
64
65
66
67
68
69
70
71
def step(self, x: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
    """Forward one timestep. Returns spike vector."""
    alpha = np.exp(-self.dt / self.tau_mem)
    current = self.W @ x
    self._v = alpha * self._v + (1 - alpha) * current
    self._spikes = (self._v >= self.threshold).astype(np.float64)
    self._v -= self._spikes * self.threshold

    # Update eligibility trace
    pseudo = 1.0 / (1.0 + np.abs(self._v - self.threshold) * 5) ** 2
    self._trace = 0.95 * self._trace + np.outer(pseudo, x)
    return self._spikes

apply_learning_signal(signal)

Apply a top-down learning signal to update weights.

Parameters

signal : ndarray of shape (n_neurons,) Per-neuron learning signal (e.g., error backprojected from output).

Source code in src/sc_neurocore/online_learning/online_trainer.py
Python
73
74
75
76
77
78
79
80
81
82
def apply_learning_signal(self, signal: np.ndarray[Any, Any]) -> None:
    """Apply a top-down learning signal to update weights.

    Parameters
    ----------
    signal : ndarray of shape (n_neurons,)
        Per-neuron learning signal (e.g., error backprojected from output).
    """
    dW = np.outer(signal, np.ones(self.n_inputs)) * self._trace
    self.W -= self.lr * dW

Online Trainer

sc_neurocore.online_learning.online_trainer.OnlineTrainer dataclass

Feedforward online trainer: stacks OnlineLIFLayers with eligibility learning.

Parameters

layer_sizes : list of int [n_input, n_hidden1, ..., n_output] tau_mem : float threshold : float lr : float

Source code in src/sc_neurocore/online_learning/online_trainer.py
Python
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
@dataclass
class OnlineTrainer:
    """Feedforward online trainer: stacks OnlineLIFLayers with eligibility learning.

    Parameters
    ----------
    layer_sizes : list of int
        [n_input, n_hidden1, ..., n_output]
    tau_mem : float
    threshold : float
    lr : float
    """

    layer_sizes: list[int]
    tau_mem: float = 20.0
    threshold: float = 1.0
    lr: float = 0.01

    layers: list[OnlineLIFLayer] = field(init=False, repr=False)

    def __post_init__(self) -> None:
        self.layers = []
        for i in range(len(self.layer_sizes) - 1):
            self.layers.append(
                OnlineLIFLayer(
                    n_inputs=self.layer_sizes[i],
                    n_neurons=self.layer_sizes[i + 1],
                    tau_mem=self.tau_mem,
                    threshold=self.threshold,
                    lr=self.lr,
                )
            )

    def reset(self) -> None:
        for layer in self.layers:
            layer.reset()

    def step(
        self, x: np.ndarray[Any, Any], target: np.ndarray[Any, Any] | None = None
    ) -> dict[str, Any]:
        """Forward one timestep through all layers with optional learning.

        Parameters
        ----------
        x : ndarray of shape (n_input,)
        target : ndarray of shape (n_output,), optional

        Returns
        -------
        dict with 'output' (final layer spikes) and optionally 'loss'
        """
        h = x
        for layer in self.layers:
            h = layer.step(h)

        result: dict[str, Any] = {"output": h.copy()}

        if target is not None:
            error = h - target
            result["loss"] = 0.5 * float(np.sum(error**2))
            # Propagate learning signal backward through layers
            signal = error
            for layer in reversed(self.layers):
                layer.apply_learning_signal(signal)
                signal = layer.W.T @ signal  # project to previous layer

        return result

    def train_sequence(self, inputs: np.ndarray[Any, Any], targets: np.ndarray[Any, Any]) -> float:
        """Train on one sequence, return mean loss."""
        self.reset()
        total_loss = 0.0
        T: int = int(inputs.shape[0])
        for t in range(T):
            result = self.step(inputs[t], target=targets[t])
            total_loss += float(result.get("loss", 0.0))
        return total_loss / T

    @property
    def n_layers(self) -> int:
        return len(self.layers)

    @property
    def memory_per_step(self) -> int:
        """Total parameters stored per timestep (O(1) in T)."""
        return sum(
            layer.n_neurons + layer.n_neurons + layer.n_neurons * layer.n_inputs
            for layer in self.layers
        )

memory_per_step property

Total parameters stored per timestep (O(1) in T).

step(x, target=None)

Forward one timestep through all layers with optional learning.

Parameters

x : ndarray of shape (n_input,) target : ndarray of shape (n_output,), optional

Returns

dict with 'output' (final layer spikes) and optionally 'loss'

Source code in src/sc_neurocore/online_learning/online_trainer.py
Python
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def step(
    self, x: np.ndarray[Any, Any], target: np.ndarray[Any, Any] | None = None
) -> dict[str, Any]:
    """Forward one timestep through all layers with optional learning.

    Parameters
    ----------
    x : ndarray of shape (n_input,)
    target : ndarray of shape (n_output,), optional

    Returns
    -------
    dict with 'output' (final layer spikes) and optionally 'loss'
    """
    h = x
    for layer in self.layers:
        h = layer.step(h)

    result: dict[str, Any] = {"output": h.copy()}

    if target is not None:
        error = h - target
        result["loss"] = 0.5 * float(np.sum(error**2))
        # Propagate learning signal backward through layers
        signal = error
        for layer in reversed(self.layers):
            layer.apply_learning_signal(signal)
            signal = layer.W.T @ signal  # project to previous layer

    return result

train_sequence(inputs, targets)

Train on one sequence, return mean loss.

Source code in src/sc_neurocore/online_learning/online_trainer.py
Python
153
154
155
156
157
158
159
160
161
def train_sequence(self, inputs: np.ndarray[Any, Any], targets: np.ndarray[Any, Any]) -> float:
    """Train on one sequence, return mean loss."""
    self.reset()
    total_loss = 0.0
    T: int = int(inputs.shape[0])
    for t in range(T):
        result = self.step(inputs[t], target=targets[t])
        total_loss += float(result.get("loss", 0.0))
    return total_loss / T