poincare-hyper / src /continual.py
DHDRL's picture
Rename continual.py to src/continual.py
f8c6752 verified
Raw
History Blame Contribute Delete
6.03 kB
"""
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()