|
| 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})" |
0 commit comments