| """ |
| hyper_lora.py — AR-conditioned hypernetwork for the nanoMoE v11 hybrid. |
| |
| What this does |
| -------------- |
| Given a single conditioning vector z (an NLA *Activation Reconstructor* output, |
| e.g. Qwen2.5-7B L20 -> 3584-d), a small hypernetwork emits a full adapter for a |
| FROZEN model_hybrid.GPT and injects it. The base never trains; only the |
| hypernetwork does. This is the "more expressive" target you picked: |
| |
| spectral layers : LoRA on in_proj, c_proj, and the implicit-kernel generator |
| MLP (kernel_mlp.0/2/4) + FiLM (per-channel additive delta) |
| on the kernel envelope (log_alpha) and gain (scale) |
| attention layers: LoRA on q_proj, k_proj, v_proj, c_proj |
| FFN : optional LoRA on c_fc/c_proj (off by default — it adds |
| capacity but dilutes the Fourier-native story; flip |
| adapt_ffn=True if the spectral path underfits) |
| |
| Because the kernel is *generated* by kernel_mlp, adapting it needs no dense |
| spatial dW and no inverse DFT: we perturb the generator's params + the envelope, |
| and causal_fft_conv runs exactly as before. That is the architecture-native win. |
| |
| Pipeline at train time |
| ----------------------- |
| z = AR(task_descriptor_text) # your AR, computed upstream |
| deltas = hyper(z) # this module |
| model.set_deltas(deltas) # distribute into the wrapped base |
| logits, loss = model(x, y) # x,y are examples from THAT task |
| loss.backward() # grads -> hyper only |
| |
| One z corresponds to one task; the (x, y) batch are examples from that task that |
| share the adapter. To train, you need (z, oracle-adapter) or (z, task-data) |
| pairs — see build_hyper_dataset.py for generating the descriptor/example side. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| from dataclasses import dataclass, field |
|
|
| import torch |
| import torch.nn as nn |
| from torch.nn import functional as F |
|
|
| from model_hybrid import GPT, ImplicitKernel |
|
|
|
|
| |
| |
| |
|
|
| class LoRALinear(nn.Module): |
| """Wraps a frozen nn.Linear. The low-rank factors are NOT parameters — they |
| are written each step from the hypernetwork output (so autograd flows back |
| into the hypernetwork, not into A/B). With no delta set, == the base.""" |
|
|
| def __init__(self, base: nn.Linear, rank: int, alpha: float | None = None): |
| super().__init__() |
| self.in_features = base.in_features |
| self.out_features = base.out_features |
| self.rank = rank |
| self.scaling = (alpha if alpha is not None else rank) / rank |
| |
| self.register_buffer("weight", base.weight.detach().clone()) |
| if base.bias is not None: |
| self.register_buffer("bias", base.bias.detach().clone()) |
| else: |
| self.bias = None |
| self._A = None |
| self._B = None |
|
|
| def set_delta(self, A: torch.Tensor | None, B: torch.Tensor | None): |
| self._A, self._B = A, B |
|
|
| def forward(self, x): |
| y = F.linear(x, self.weight, self.bias) |
| if self._A is not None: |
| |
| y = y + self.scaling * F.linear(F.linear(x, self._A), self._B) |
| return y |
|
|
|
|
| class FiLMKernel(nn.Module): |
| """Re-implements ImplicitKernel.forward verbatim but adds two per-channel |
| deltas: dlog_alpha (envelope / memory horizon) and dscale (gain). Reuses the |
| *original* kernel's buffers, frozen params, and (LoRA-wrapped) kernel_mlp.""" |
|
|
| def __init__(self, orig: ImplicitKernel): |
| super().__init__() |
| self.orig = orig |
| self._dlog_alpha = None |
| self._dscale = None |
|
|
| def set_delta(self, dlog_alpha, dscale): |
| self._dlog_alpha, self._dscale = dlog_alpha, dscale |
|
|
| def forward(self, T, device): |
| k = self.orig |
| t = torch.arange(T, device=device, dtype=torch.float32) / k.block_size |
| ang = 2 * math.pi * t[:, None] * k.freqs[None, :].to(device) |
| feats = torch.cat([torch.sin(ang), torch.cos(ang)], dim=-1) |
| h = k.kernel_mlp(feats) |
| log_alpha = k.kernel_log_alpha |
| if self._dlog_alpha is not None: |
| log_alpha = log_alpha + self._dlog_alpha |
| env = torch.exp(-torch.exp(log_alpha)[None, :] * t[:, None]) |
| h = h * env |
| scale = k.kernel_scale |
| if self._dscale is not None: |
| scale = scale + self._dscale |
| h = h / (h.norm(dim=0, keepdim=True) + 1e-6) * scale[None, :] |
| return h.t() |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class SiteSpec: |
| name: str |
| kind: str |
| in_dim: int = 0 |
| out_dim: int = 0 |
| dim: int = 0 |
| rank: int = 0 |
|
|
|
|
| @dataclass |
| class AdaptConfig: |
| rank_kernel: int = 8 |
| rank_mixer: int = 8 |
| rank_attn: int = 8 |
| rank_ffn: int = 8 |
| adapt_ffn: bool = False |
| film: bool = True |
|
|
|
|
| def _swap(parent: nn.Module, child_name: str, new: nn.Module): |
| setattr(parent, child_name, new) |
|
|
|
|
| def wrap_model(model: GPT, cfg: AdaptConfig): |
| """In-place: replace target Linears with LoRALinear, kernels with FiLMKernel. |
| Returns (sites, mods) where `mods` maps each site name to the live module — |
| so injection never has to re-resolve a (post-wrap) dotted path.""" |
| sites: list[SiteSpec] = [] |
| mods: dict[str, nn.Module] = {} |
|
|
| def wrap_linear(parent, attr, rank, prefix): |
| base = getattr(parent, attr) |
| assert isinstance(base, nn.Linear), f"{prefix}.{attr} is {type(base)}" |
| lo = LoRALinear(base, rank) |
| _swap(parent, attr, lo) |
| name = f"{prefix}.{attr}" |
| sites.append(SiteSpec(name, "lora", |
| in_dim=base.in_features, out_dim=base.out_features, |
| rank=rank)) |
| mods[name] = lo |
|
|
| for i, block in enumerate(model.transformer.h): |
| bp = f"h.{i}" |
| mixer = block.mixer |
| if block.is_attn: |
| for attr in ("q_proj", "k_proj", "v_proj", "c_proj"): |
| wrap_linear(mixer, attr, cfg.rank_attn, f"{bp}.mixer") |
| else: |
| wrap_linear(mixer, "in_proj", cfg.rank_mixer, f"{bp}.mixer") |
| wrap_linear(mixer, "c_proj", cfg.rank_mixer, f"{bp}.mixer") |
| |
| kmlp = mixer.kernel.kernel_mlp |
| for idx in (0, 2, 4): |
| wrap_linear(kmlp, str(idx), cfg.rank_kernel, f"{bp}.mixer.kernel.kernel_mlp") |
| |
| orig_kernel = mixer.kernel |
| film = FiLMKernel(orig_kernel) |
| _swap(mixer, "kernel", film) |
| if cfg.film: |
| name = f"{bp}.mixer.kernel" |
| sites.append(SiteSpec(name, "film", dim=orig_kernel.dim)) |
| mods[name] = film |
| if cfg.adapt_ffn: |
| wrap_linear(block.mlp, "c_fc", cfg.rank_ffn, f"{bp}.mlp") |
| wrap_linear(block.mlp, "c_proj", cfg.rank_ffn, f"{bp}.mlp") |
|
|
| |
| for p in model.parameters(): |
| p.requires_grad_(False) |
| return sites, mods |
|
|
|
|
| |
| |
| |
|
|
| class HyperLoRA(nn.Module): |
| """T2L-style and SMALL: a shared trunk over z, a per-site embedding, and ONE |
| bottlenecked head per *shape group* (e.g. all q_proj across layers share a |
| head). The per-site embedding is what specialises a shared head to each site, |
| so the head count is ~#module-types, not #sites. This is the difference |
| between a hypernetwork larger than the base and one a fraction of its size.""" |
|
|
| def __init__(self, sites: list[SiteSpec], d_z: int, |
| d_trunk: int = 512, d_site: int = 64, d_mid: int = 128, |
| dropout: float = 0.0): |
| super().__init__() |
| self.sites = sites |
| self.trunk = nn.Sequential( |
| nn.Linear(d_z, d_trunk), nn.GELU(), nn.Dropout(dropout), |
| nn.Linear(d_trunk, d_trunk), nn.GELU(), |
| ) |
| self.site_emb = nn.ParameterDict() |
| self.heads = nn.ModuleDict() |
| self.group_of: dict[str, str] = {} |
| self.out_of: dict[str, int] = {} |
|
|
| def group_key(s: SiteSpec) -> str: |
| if s.kind == "lora": |
| return f"lora_{s.in_dim}_{s.out_dim}_{s.rank}" |
| return f"film_{s.dim}" |
|
|
| for s in sites: |
| key = s.name.replace(".", "__") |
| self.site_emb[key] = nn.Parameter(torch.randn(d_site) * 0.02) |
| g = group_key(s) |
| self.group_of[s.name] = g |
| out = s.rank * (s.in_dim + s.out_dim) if s.kind == "lora" else 2 * s.dim |
| self.out_of[s.name] = out |
| if g not in self.heads: |
| head = nn.Sequential( |
| nn.Linear(d_trunk + d_site, d_mid), nn.GELU(), |
| nn.Linear(d_mid, out), |
| ) |
| final = head[-1] |
| if s.kind == "lora": |
| |
| |
| |
| |
| |
| |
| a = s.rank * s.in_dim |
| nn.init.normal_(final.weight[:a], std=0.02) |
| nn.init.zeros_(final.weight[a:]) |
| nn.init.zeros_(final.bias) |
| else: |
| nn.init.zeros_(final.weight); nn.init.zeros_(final.bias) |
| self.heads[g] = head |
|
|
| def forward(self, z: torch.Tensor) -> dict: |
| """z: (d_z,) for one task. Returns {site_name: payload}.""" |
| if z.dim() == 2: |
| assert z.shape[0] == 1, "one task (one z) per call; pass z[0] for a batch of 1" |
| z = z[0] |
| h = self.trunk(z) |
| out = {} |
| for s in self.sites: |
| key = s.name.replace(".", "__") |
| feat = torch.cat([h, self.site_emb[key]], dim=-1) |
| raw = self.heads[self.group_of[s.name]](feat) |
| if s.kind == "lora": |
| a = s.rank * s.in_dim |
| A = raw[:a].view(s.rank, s.in_dim) |
| B = raw[a:].view(s.out_dim, s.rank) |
| out[s.name] = ("lora", A, B) |
| else: |
| out[s.name] = ("film", raw[: s.dim], raw[s.dim:]) |
| return out |
|
|
|
|
| |
| |
| |
|
|
| class AdaptedModel(nn.Module): |
| """Wraps a frozen, already-wrapped GPT. set_deltas() pushes one task's |
| adapter in; forward() then runs the specialised model.""" |
|
|
| def __init__(self, model: GPT, mods: dict[str, nn.Module]): |
| super().__init__() |
| self.model = model |
| self._index = mods |
|
|
| def set_deltas(self, deltas: dict | None): |
| for name, mod in self._index.items(): |
| if deltas is None: |
| mod.set_delta(None, None) |
| continue |
| payload = deltas[name] |
| mod.set_delta(payload[1], payload[2]) |
|
|
| def forward(self, idx, targets=None): |
| return self.model(idx, targets) |
|
|
|
|
| def build(model: GPT, d_z: int, cfg: AdaptConfig | None = None, |
| d_trunk: int = 512, dropout: float = 0.0): |
| """Convenience: wrap `model` in place, build the hypernetwork for it.""" |
| cfg = cfg or AdaptConfig() |
| sites, mods = wrap_model(model, cfg) |
| hyper = HyperLoRA(sites, d_z=d_z, d_trunk=d_trunk, dropout=dropout) |
| adapted = AdaptedModel(model, mods) |
| return adapted, hyper, sites |
|
|
|
|
| |
| |
| |
|
|
| def _self_test(): |
| from model_hybrid import GPTConfig |
| torch.manual_seed(0) |
| dev = "cuda" if torch.cuda.is_available() else "cpu" |
| print(f"== hyper_lora self-test (device={dev}) ==") |
|
|
| cfg_m = GPTConfig(block_size=64, vocab_size=256, n_layer=6, n_head=4, |
| n_embd=64, mixer="hybrid", attn_every=3, kv_heads=1, |
| kernel_d_hidden=32, kernel_n_fourier=8) |
| base = GPT(cfg_m).to(dev) |
|
|
| |
| x = torch.randint(0, cfg_m.vocab_size, (2, cfg_m.block_size), device=dev) |
| y = torch.randint(0, cfg_m.vocab_size, (2, cfg_m.block_size), device=dev) |
| base.eval() |
| with torch.no_grad(): |
| clean_logits, _ = base(x, y) |
|
|
| d_z = 128 |
| adapted, hyper, sites = build(base, d_z=d_z, cfg=AdaptConfig(adapt_ffn=True)) |
| adapted, hyper = adapted.to(dev), hyper.to(dev) |
| n_lora = sum(s.kind == "lora" for s in sites) |
| n_film = sum(s.kind == "film" for s in sites) |
| print(f"[1/5] discovered {len(sites)} sites ({n_lora} LoRA, {n_film} FiLM)") |
| hp = sum(p.numel() for p in hyper.parameters()) |
| print(f" hypernetwork params: {hp/1e6:.3f}M") |
|
|
| |
| z = torch.randn(d_z, device=dev) |
| adapted.eval() |
| with torch.no_grad(): |
| adapted.set_deltas(hyper(z)) |
| adapted_logits, _ = adapted(x, y) |
| drift = (adapted_logits - clean_logits).abs().max().item() |
| assert drift < 1e-4, f"identity-at-init broken: drift {drift}" |
| print(f"[2/5] identity-at-init OK (max drift {drift:.2e} < 1e-4)") |
|
|
| |
| with torch.no_grad(): |
| for p in hyper.heads.parameters(): |
| p.normal_(0, 0.02) |
| adapted.set_deltas(hyper(z)) |
| moved_logits, _ = adapted(x, y) |
| moved = (moved_logits - clean_logits).abs().max().item() |
| assert moved > 1e-3, f"adapter had no effect: {moved}" |
| print(f"[3/5] adapter changes output OK (max delta {moved:.3e})") |
|
|
| |
| adapted.train(); hyper.train() |
| deltas = hyper(z) |
| adapted.set_deltas(deltas) |
| _, loss = adapted(x, y) |
| loss.backward() |
| hyper_with_grad = [n for n, p in hyper.named_parameters() |
| if p.grad is not None and torch.isfinite(p.grad).all()] |
| hyper_total = sum(1 for _ in hyper.parameters()) |
| base_with_grad = [n for n, p in adapted.model.named_parameters() |
| if p.grad is not None] |
| assert len(hyper_with_grad) == hyper_total, \ |
| f"only {len(hyper_with_grad)}/{hyper_total} hyper params got finite grad" |
| assert not base_with_grad, f"FROZEN base leaked grad into: {base_with_grad[:3]}" |
| print(f"[4/5] grad flow OK ({hyper_total} hyper params grad'd, base frozen)") |
|
|
| |
| hyper.zero_grad() |
| with torch.no_grad(): |
| z2 = torch.randn(d_z, device=dev) |
| adapted.set_deltas(hyper(z)); l_a, _ = adapted(x) |
| adapted.set_deltas(hyper(z2)); l_b, _ = adapted(x) |
| sep = (l_a - l_b).abs().max().item() |
| assert sep > 1e-4, f"different z gave identical adapters: {sep}" |
| print(f"[5/5] z-conditioning OK (two z differ by {sep:.3e})") |
|
|
| print("ALL HYPER_LORA SELF-TESTS PASSED.") |
|
|
|
|
| if __name__ == "__main__": |
| _self_test() |
|
|