Skip to content

Commit 3f6bd3a

Browse files
feat(ocl): add Synaptic Intelligence (SI) strategy
Synaptic Intelligence (SI) is a regularisation-based continual learning strategy that accumulates per-parameter importance online from optimisation trajectories, then penalises changes to parameters that were important for previous tasks (Zenke et al., 2017). Ported from the `blurry-ocl` draft branch and adapted to the current `capymoa.ocl` module layout. Also fixes the `update_trajectory` parameter names, which had drifted from their actual pre/post-step meaning during earlier draft cleanup, and adds `reset_optimizer_state`, a small utility SI needs to clear optimiser momentum at task boundaries. Assisted-by: claude-code:claude-sonnet-5
1 parent c503ee3 commit 3f6bd3a

4 files changed

Lines changed: 349 additions & 4 deletions

File tree

src/capymoa/ocl/strategy/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
from ._rar import RAR
88
from . import l2p
99
from ._ewc import EWC
10+
from ._si import SI
1011

11-
__all__ = ["ExperienceReplay", "SLDA", "NCM", "GDumb", "RAR", "l2p", "EWC"]
12+
__all__ = ["ExperienceReplay", "SLDA", "NCM", "GDumb", "RAR", "l2p", "EWC", "SI"]

src/capymoa/ocl/strategy/_si.py

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
from typing import Iterable, Iterator, Optional, Sequence
2+
3+
import torch
4+
from torch import Tensor, nn
5+
6+
from capymoa.base import BatchClassifier
7+
from capymoa.ocl.events import Dispatcher, Handler
8+
from capymoa.ocl.evaluation.events import TestTaskBegin, TrainTaskBegin
9+
from capymoa.ocl.util._buffer_list import BufferList
10+
from capymoa.ocl.util._optim import reset_optimizer_state
11+
from capymoa.stream._stream import Schema
12+
13+
NEG_INF = float("-inf")
14+
15+
16+
def trainable_params(model: nn.Module) -> Iterator[Tensor]:
17+
"""Yields the model's parameters that require gradients."""
18+
return (p for p in model.parameters() if p.requires_grad)
19+
20+
21+
def weighted_l2_reg(
22+
params: Iterable[Tensor],
23+
anchor_params: Iterable[Tensor],
24+
importance: Iterable[Tensor],
25+
device: torch.device,
26+
) -> Tensor:
27+
"""Compute an SI-style weighted L2 regularisation term."""
28+
l2 = torch.tensor(0.0, device=device)
29+
for param, anchor_param, param_importance in zip(
30+
params, anchor_params, importance, strict=True
31+
):
32+
assert param.shape == anchor_param.shape
33+
l2 += (param_importance * (param - anchor_param) ** 2).sum()
34+
return 0.5 * l2
35+
36+
37+
@torch.no_grad()
38+
def update_trajectory(
39+
trajectory: Sequence[Tensor],
40+
pre_step_params: Iterable[Tensor],
41+
post_step_params: Iterable[Tensor],
42+
gradients: Iterable[Tensor],
43+
) -> None:
44+
r"""Update the parameter's cumulative trajectory for Synaptic Intelligence.
45+
46+
Should be called after the optimizer step, using the gradients from before that
47+
step. This function implements Equation 2 from [#f1]_:
48+
49+
.. math::
50+
51+
\begin{aligned} \int_{t^{\mu-1}}^{t^\mu} \boldsymbol{g}(\boldsymbol{\theta}(t))
52+
\cdot \boldsymbol{\theta}^{\prime}(t) d t & =\sum_k \int_{t^{\mu-1}}^{t^\mu}
53+
g_k(\theta(t)) \theta_k^{\prime}(t) d t \\ & \equiv-\sum_k \omega_k^\mu,
54+
\end{aligned}
55+
56+
where:
57+
58+
* :math:`\boldsymbol{g}(\boldsymbol{\theta}(t))` is the gradient of the loss with
59+
respect to the parameters at optimization step :math:`t`.
60+
* :math:`\boldsymbol{\theta}^{\prime}(t)` is the difference between the parameters
61+
at optimization step :math:`t` (``post_step_params``) and the parameters at
62+
:math:`t-1` (``pre_step_params``).
63+
64+
This function updates the trajectory :math:`\omega_k^\mu` for each parameter
65+
:math:`k` after each optimization step.
66+
67+
:param trajectory: Sequence of tensors storing the cumulative trajectory for each
68+
parameter. Updated in-place.
69+
:param pre_step_params: Parameters before the optimizer step.
70+
:param post_step_params: Parameters after the optimizer step.
71+
:param gradients: Gradients of the loss with respect to the parameters before the
72+
optimizer step.
73+
"""
74+
for traj, pre_param, post_param, grad in zip(
75+
trajectory,
76+
pre_step_params,
77+
post_step_params,
78+
gradients,
79+
strict=True,
80+
):
81+
# The negative sign ensures we measure the *decrease* in loss.
82+
# Trajectory (w) = -grad * delta_theta
83+
step_contribution = -grad * (post_param - pre_param)
84+
traj.add_(step_contribution)
85+
86+
87+
@torch.no_grad()
88+
def update_importance_weights_(
89+
importance: Sequence[Tensor],
90+
trajectory: Iterable[Tensor],
91+
start_task_params: Iterable[Tensor],
92+
end_task_params: Iterable[Tensor],
93+
damping: float = 0.1,
94+
) -> None:
95+
"""In-place update of the SI importance buffers.
96+
97+
Calculates the new importance matrix (Omega) at the end of a task.
98+
"""
99+
for omega, traj, start_param, end_param in zip(
100+
importance, trajectory, start_task_params, end_task_params, strict=True
101+
):
102+
# Importance is the accumulated trajectory normalized by the total change
103+
# in the parameter over the whole task (plus a damping factor for numerical
104+
# stability).
105+
param_shift_squared = (end_param - start_param).pow(2)
106+
task_importance = traj / (param_shift_squared + damping)
107+
108+
# Accumulate importance across sequential tasks
109+
omega.add_(task_importance)
110+
111+
112+
@torch.no_grad()
113+
def copy_grads_(module: nn.Module, dst: Sequence[Tensor]) -> None:
114+
"""Copy gradients from a module's parameters into a pre-allocated list."""
115+
for param, dst_tensor in zip(trainable_params(module), dst, strict=True):
116+
assert param.grad is not None
117+
dst_tensor.copy_(param.grad.detach())
118+
119+
120+
@torch.no_grad()
121+
def copy_params_(module: nn.Module, dst: Sequence[Tensor]) -> None:
122+
"""Copy parameters from a module into a pre-allocated list."""
123+
for param, dst_tensor in zip(trainable_params(module), dst, strict=True):
124+
dst_tensor.copy_(param.detach())
125+
126+
127+
@torch.no_grad()
128+
def reset_trajectory_(trajectory: Sequence[Tensor]) -> None:
129+
"""In-place zeroing of the SI trajectory buffers."""
130+
for traj in trajectory:
131+
traj.zero_()
132+
133+
134+
class SI(BatchClassifier, nn.Module, Handler):
135+
"""Synaptic Intelligence learner.
136+
137+
Synaptic Intelligence (SI) is a regularisation-based continual learning strategy
138+
that accumulates per-parameter importance online from optimization trajectories,
139+
then penalises changes to parameters that were important for previous tasks [#f1]_.
140+
141+
Alternative implementations:
142+
143+
* `Avalanche Lib <https://github.com/ContinualAI/avalanche/blob/eb075be393e1f458b2c352514ff6c17b5a2c0f4e/avalanche/training/plugins/synaptic_intelligence.py>`__
144+
* `FACIL <https://github.com/mmasana/FACIL/blob/e09d2c83320a1aa945a6157d4875437515824dc9/src/approach/path_integral.py>`__
145+
146+
.. [#f1] Zenke, F., Poole, B., & Ganguli, S. (2017). Continual Learning Through
147+
Synaptic Intelligence. International Conference on Machine Learning, 3987–3995.
148+
"""
149+
150+
def __init__(
151+
self,
152+
schema: Schema,
153+
model: torch.nn.Module,
154+
optimiser: torch.optim.Optimizer,
155+
lambda_: float,
156+
damping: float = 0.1,
157+
device: torch.device = torch.device("cpu"),
158+
mask_test: bool = False,
159+
mask_train: bool = False,
160+
task_mask: Optional[Tensor] = None,
161+
) -> None:
162+
"""Construct an SI learner.
163+
164+
:param schema: Stream schema used by the classifier interface.
165+
:param model: Torch model that outputs class logits.
166+
:param optimiser: Optimiser used to update ``model`` parameters.
167+
:param lambda_: Weight of the SI regularisation term.
168+
:param damping: Damping factor added to the denominator when calculating
169+
importance weights.
170+
:param device: Compute device.
171+
:param mask_test: Whether to apply per-task masking during testing. This is a
172+
task incremental scenario.
173+
:param mask_train: Whether to apply per-task masking during training. This is
174+
also known as the labels trick.
175+
:param task_mask: Optional per-task mask applied to output logits.
176+
:raises ValueError: If task-specific masking is requested without ``task_mask``.
177+
"""
178+
super().__init__(schema, 0)
179+
nn.Module.__init__(self)
180+
if (mask_train or mask_test) and task_mask is None:
181+
raise ValueError(
182+
"Task schedule must be provided for task incremental or labels trick scenarios."
183+
)
184+
if lambda_ < 0:
185+
raise ValueError("lambda_ must be non-negative.")
186+
if damping <= 0:
187+
raise ValueError("damping must be positive.")
188+
189+
self.device = device
190+
191+
# Hyperparameters
192+
self._lambda = lambda_
193+
self._eps = damping
194+
self._mask_train = mask_train
195+
self._mask_test = mask_test
196+
197+
# Modules
198+
self._optimiser = optimiser
199+
self._model = model
200+
self._criterion = torch.nn.CrossEntropyLoss()
201+
202+
# Allocate buffers for SI regularisation
203+
self._buf_anchor = BufferList(
204+
[p.clone().detach() for p in trainable_params(model)]
205+
)
206+
self._buf_importance = BufferList(
207+
[torch.zeros_like(p) for p in trainable_params(model)]
208+
)
209+
self._buf_pre_step_params = BufferList(
210+
[torch.zeros_like(p) for p in trainable_params(model)]
211+
)
212+
self._buf_trajectory = BufferList(
213+
[torch.zeros_like(p) for p in trainable_params(model)]
214+
)
215+
self._buf_grads = BufferList(
216+
[torch.zeros_like(p) for p in trainable_params(model)]
217+
)
218+
219+
# Task tracking
220+
self._train_task = 0
221+
self._test_task = 0
222+
if task_mask is None:
223+
self._task_mask = None
224+
else:
225+
self._task_mask = nn.Buffer(task_mask)
226+
227+
# Move all model parameters and buffers to the specified device
228+
self.to(device)
229+
230+
def batch_train(self, x: Tensor, y: Tensor) -> None:
231+
self._model.train()
232+
233+
# Compute unregularised loss and gradients
234+
self._optimiser.zero_grad()
235+
y_hat = self._train_forward(x)
236+
loss = self._criterion(y_hat, y)
237+
loss.backward()
238+
239+
# Capture parameters before the optimiser step
240+
copy_params_(self._model, self._buf_pre_step_params)
241+
242+
# Save unregularised gradients needed for the path integral
243+
copy_grads_(self._model, self._buf_grads)
244+
245+
# Add SI regularisation loss (only applies after the first task)
246+
if self._train_task > 0:
247+
reg_loss = self._lambda * weighted_l2_reg(
248+
trainable_params(self._model),
249+
self._buf_anchor,
250+
self._buf_importance,
251+
device=self.device,
252+
)
253+
reg_loss.backward()
254+
255+
# Apply the optimiser step
256+
self._optimiser.step()
257+
258+
# Update the trajectory using the unregularised gradients and parameter changes
259+
update_trajectory(
260+
trajectory=self._buf_trajectory,
261+
pre_step_params=self._buf_pre_step_params,
262+
post_step_params=trainable_params(self._model),
263+
gradients=self._buf_grads,
264+
)
265+
266+
@torch.no_grad()
267+
def batch_predict_proba(self, x: Tensor) -> Tensor:
268+
self._model.eval()
269+
y_hat = self._test_forward(x)
270+
return torch.softmax(y_hat, dim=1)
271+
272+
def attach_with(self, source: Dispatcher) -> "SI":
273+
source.subscribe(TrainTaskBegin, self._on_train_task_begin)
274+
source.subscribe(TestTaskBegin, self._on_test_task_begin)
275+
return self
276+
277+
def _on_train_task_begin(self, event: TrainTaskBegin) -> None:
278+
reset_optimizer_state(self._optimiser)
279+
self._train_task = event.train_task
280+
281+
if self._train_task > 0:
282+
# Consolidate importance weights using the trajectory from the previous task
283+
update_importance_weights_(
284+
importance=self._buf_importance,
285+
trajectory=self._buf_trajectory,
286+
start_task_params=self._buf_anchor,
287+
end_task_params=trainable_params(self._model),
288+
damping=self._eps,
289+
)
290+
291+
# Update anchors to the current model parameters
292+
copy_params_(self._model, self._buf_anchor)
293+
294+
# Reset trajectory for the new task
295+
reset_trajectory_(self._buf_trajectory)
296+
297+
def _on_test_task_begin(self, event: TestTaskBegin) -> None:
298+
self._test_task = event.test_task
299+
300+
def _test_forward(self, x: Tensor) -> Tensor:
301+
"""Compute logits for inference, optionally applying a test-task mask."""
302+
y_hat = self._model(x)
303+
if self._task_mask is not None and self._mask_test:
304+
y_hat = y_hat.masked_fill(self._task_mask[self._test_task] == 0, NEG_INF)
305+
return y_hat
306+
307+
def _train_forward(self, x: Tensor) -> Tensor:
308+
"""Compute logits for training, optionally applying a train-task mask."""
309+
y_hat = self._model(x)
310+
if self._task_mask is not None and self._mask_train:
311+
y_hat = y_hat.masked_fill(self._task_mask[self._train_task] == 0, NEG_INF)
312+
return y_hat
313+
314+
def __str__(self) -> str:
315+
return f"SI(lambda_={self._lambda}, eps={self._eps})"

src/capymoa/ocl/util/_optim.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from collections import defaultdict
2+
3+
from torch.optim import Optimizer
4+
5+
6+
def reset_optimizer_state(optimizer: Optimizer) -> None:
7+
"""Reset an optimizer's per-parameter state (e.g. momentum buffers) in-place."""
8+
optimizer.__setstate__({"state": defaultdict(dict)})

tests/ocl/test_strategy.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from capymoa.ocl.datasets import TinySplitMNIST # noqa: E402
1515
from capymoa.ocl.evaluation import ocl_train_eval_loop # noqa: E402
1616

17-
from capymoa.ocl.strategy import ExperienceReplay, SLDA, NCM, GDumb, RAR, EWC # noqa: E402
17+
from capymoa.ocl.strategy import ExperienceReplay, SLDA, NCM, GDumb, RAR, EWC, SI # noqa: E402
1818
from capymoa.stream import Schema # noqa: E402
1919

2020
import torch # noqa: E402
@@ -53,13 +53,16 @@ def pre_processor() -> nn.Module:
5353

5454

5555
def new_constructor(
56-
learner: Type[Classifier], lr: float, **kwargs
56+
learner: Type[Classifier],
57+
lr: float,
58+
optimiser_type: Type[torch.optim.Optimizer] = torch.optim.SGD,
59+
**kwargs,
5760
) -> Callable[[Schema], Classifier]:
5861
"""Create a new learner instance with the given hyperparameters."""
5962

6063
def constructor(schema: Schema, **extra_kwargs) -> Classifier:
6164
model = Perceptron(schema)
62-
optimiser = torch.optim.SGD(model.parameters(), lr=lr)
65+
optimiser = optimiser_type(model.parameters(), lr=lr)
6366
return learner(
6467
schema,
6568
model,
@@ -135,6 +138,24 @@ def _new_rar(schema):
135138
Result(71.99, 47.20, 25.3),
136139
task_mask=True,
137140
),
141+
Case(
142+
"SI",
143+
new_constructor(SI, lr=0.004, lambda_=96, optimiser_type=torch.optim.Adam),
144+
Result(38.5, 25.8, 16.1),
145+
),
146+
Case(
147+
"SI(mask_train=True, mask_test=True)",
148+
new_constructor(
149+
SI,
150+
lr=0.006,
151+
lambda_=2.5,
152+
optimiser_type=torch.optim.Adam,
153+
mask_train=True,
154+
mask_test=True,
155+
),
156+
Result(96.5, 80.8, 34.4),
157+
task_mask=True,
158+
),
138159
]
139160

140161

0 commit comments

Comments
 (0)