File size: 6,032 Bytes
ae73c7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
"""
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()