""" 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): # traj is (T, C, H, W) 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 # Guard: mixed C/H/W without an explicit filter -> clear error, not # a bare torch.stack crash. Mixing different channel counts in one # batch would require padding empty channels, which this project's # contracts forbid (see model.py's channel-agnostic encoder docstring # and the "no fabrication" principle used throughout data_pbdb*.py). shapes = {tuple(self.buffer[i].shape[1:]) for i in eligible} # (C,H,W) 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 # freeze a snapshot of parameters after each domain 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() # re-compute grads if needed; assume loss still has graph or re-forward # For simplicity we expect the caller to have done loss.backward() already # and we just read .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()