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
 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
 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
@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 = field(init=False, repr=False)
    W_rec: np.ndarray = field(init=False, repr=False)
    W_out: np.ndarray = field(init=False, repr=False)

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

    def __post_init__(self):
        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):
        """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, target: np.ndarray | None = None) -> dict:
        """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 = {"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, targets: np.ndarray) -> 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 = inputs.shape[0]
        for t in range(T):
            result = self.step(inputs[t], target=targets[t])
            total_loss += result.get("loss", 0.0)
        return total_loss / T

    def predict_sequence(self, inputs: np.ndarray) -> np.ndarray:
        """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
84
85
86
87
88
89
90
91
def reset(self):
    """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
 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
def step(self, x: np.ndarray, target: np.ndarray | None = None) -> dict:
    """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 = {"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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def train_sequence(self, inputs: np.ndarray, targets: np.ndarray) -> 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 = inputs.shape[0]
    for t in range(T):
        result = self.step(inputs[t], target=targets[t])
        total_loss += 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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def predict_sequence(self, inputs: np.ndarray) -> np.ndarray:
    """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
21
22
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
@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 = field(init=False, repr=False)
    _v: np.ndarray = field(init=False, repr=False)
    _spikes: np.ndarray = field(init=False, repr=False)
    _trace: np.ndarray = field(init=False, repr=False)

    def __post_init__(self):
        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):
        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) -> np.ndarray:
        """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):
        """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
58
59
60
61
62
63
64
65
66
67
68
69
def step(self, x: np.ndarray) -> np.ndarray:
    """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
71
72
73
74
75
76
77
78
79
80
def apply_learning_signal(self, signal: np.ndarray):
    """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
 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
@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):
        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):
        for layer in self.layers:
            layer.reset()

    def step(self, x: np.ndarray, target: np.ndarray | None = None) -> dict:
        """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 = {"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, targets: np.ndarray) -> float:
        """Train on one sequence, return mean loss."""
        self.reset()
        total_loss = 0.0
        T = inputs.shape[0]
        for t in range(T):
            result = self.step(inputs[t], target=targets[t])
            total_loss += 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
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
def step(self, x: np.ndarray, target: np.ndarray | None = None) -> dict:
    """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 = {"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
149
150
151
152
153
154
155
156
157
def train_sequence(self, inputs: np.ndarray, targets: np.ndarray) -> float:
    """Train on one sequence, return mean loss."""
    self.reset()
    total_loss = 0.0
    T = inputs.shape[0]
    for t in range(T):
        result = self.step(inputs[t], target=targets[t])
        total_loss += result.get("loss", 0.0)
    return total_loss / T