| """ |
| Continual learning for the hierarchical Poincaré harness. |
| |
| Best practical combination for this architecture: |
| 1. Experience Replay of normalized trajectories (or their latents) |
| 2. Diagonal Fisher EWC to protect parameters important for previous domains |
| 3. Optional soft hyperbolic distillation (old model predictions stay close) |
| |
| Hyperbolic geometry already supplies hierarchical capacity (new domains can |
| occupy different radii / branches). Replay + EWC prevent weight overwriting. |
| This combination is the most robust and fully compatible with the existing |
| 8-D Poincaré multi-step model and the Optuna best hyperparameters. |
| """ |
| from __future__ import annotations |
| import copy |
| from collections import deque |
| from typing import Deque, Dict, List, Optional, Tuple |
|
|
| import torch |
| import torch.nn as nn |
|
|
|
|
| class ReplayBuffer: |
| """Fixed-size buffer of trajectories for experience replay.""" |
| def __init__(self, capacity: int = 256): |
| self.capacity = capacity |
| self.buffer: Deque[torch.Tensor] = deque(maxlen=capacity) |
|
|
| def add(self, traj: torch.Tensor): |
| """traj: (T, C, H, W) or (B, T, C, H, W)""" |
| if traj.dim() == 5: |
| for i in range(traj.size(0)): |
| self.buffer.append(traj[i].detach().cpu()) |
| else: |
| self.buffer.append(traj.detach().cpu()) |
|
|
| def sample( |
| self, |
| n: int, |
| channels: Optional[int] = None, |
| spatial: Optional[Tuple[int, int]] = None, |
| ) -> Optional[torch.Tensor]: |
| if len(self.buffer) == 0: |
| return None |
|
|
| eligible = [] |
| for i, traj in enumerate(self.buffer): |
| |
| if channels is not None and traj.shape[1] != channels: |
| continue |
| if spatial is not None: |
| if traj.shape[2] != spatial[0] or traj.shape[3] != spatial[1]: |
| continue |
| eligible.append(i) |
|
|
| if not eligible: |
| return None |
|
|
| |
| |
| |
| |
| |
| shapes = {tuple(self.buffer[i].shape[1:]) for i in eligible} |
| if len(shapes) > 1: |
| raise ValueError( |
| f"ReplayBuffer.sample() found mixed trajectory shapes {shapes} " |
| f"among eligible entries. Pass channels= (and spatial= if " |
| f"needed) to select a homogeneous subset -- mixing different " |
| f"channel counts in one batch is not supported (would require " |
| f"padding empty channels, which this project forbids)." |
| ) |
|
|
| n = min(n, len(eligible)) |
| chosen = torch.randperm(len(eligible))[:n].tolist() |
| return torch.stack([self.buffer[eligible[j]] for j in chosen], dim=0) |
|
|
| def counts_by_channels(self) -> Dict[int, int]: |
| counts: Dict[int, int] = {} |
| for traj in self.buffer: |
| c = int(traj.shape[1]) |
| counts[c] = counts.get(c, 0) + 1 |
| return counts |
|
|
| def __len__(self): |
| return len(self.buffer) |
|
|
|
|
| def fit_normalizer_for_domain(ds, max_fit: int = 48, mode: str = "zscore"): |
| from .normalization import FieldNormalizer |
|
|
| n = min(len(ds), max_fit) |
| samples = [] |
| for i in range(n): |
| item = ds[i] |
| fields = item["fields"] if isinstance(item, dict) else item |
| samples.append(fields) |
| data = torch.stack(samples, dim=0) |
| norm = FieldNormalizer(mode=mode).fit(data) |
| c = int(data.shape[2]) if data.dim() == 5 else int(data.shape[1]) |
| print(f"[norm] fitted domain normalizer on {n} trajs, C={c}") |
| return norm |
|
|
|
|
| class DiagonalEWC: |
| def __init__(self, model: nn.Module, lambda_ewc: float = 1000.0): |
| self.lambda_ewc = lambda_ewc |
| self.fisher: Dict[str, torch.Tensor] = {} |
| self.optpar: Dict[str, torch.Tensor] = {} |
| self._n_accum = 0 |
| |
| for n, p in model.named_parameters(): |
| if p.requires_grad: |
| self.optpar[n] = p.data.clone().cpu() |
| self.fisher[n] = torch.zeros_like(p.data, device="cpu") |
|
|
| @torch.no_grad() |
| def accumulate_fisher(self, model: nn.Module, loss: torch.Tensor): |
| model.zero_grad() |
| |
| |
| |
| for n, p in model.named_parameters(): |
| if p.grad is not None and n in self.fisher: |
| self.fisher[n] += (p.grad.data.cpu() ** 2) |
| self._n_accum += 1 |
|
|
| def finalize_domain(self, model: nn.Module): |
| if self._n_accum > 0: |
| for n in self.fisher: |
| self.fisher[n] /= self._n_accum |
| self.fisher[n] = self.fisher[n].clamp(min=1e-6) |
| for n, p in model.named_parameters(): |
| if n in self.optpar: |
| self.optpar[n] = p.data.clone().cpu() |
| self._n_accum = 0 |
|
|
| def ewc_loss(self, model: nn.Module) -> torch.Tensor: |
| loss = torch.tensor(0.0, device=next(model.parameters()).device) |
| for n, p in model.named_parameters(): |
| if n in self.fisher: |
| f = self.fisher[n].to(p.device) |
| o = self.optpar[n].to(p.device) |
| loss = loss + (f * (p - o) ** 2).sum() |
| return self.lambda_ewc * loss |
|
|
|
|
| def hyperbolic_distillation_loss( |
| student_latents: torch.Tensor, |
| teacher_latents: torch.Tensor, |
| poincare_module, |
| ) -> torch.Tensor: |
| return poincare_module.dist( |
| student_latents.reshape(-1, student_latents.size(-1)), |
| teacher_latents.reshape(-1, teacher_latents.size(-1)), |
| ).mean() |
|
|