File size: 8,328 Bytes
3b22208 | 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 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | """Archetypal MLP transcoder, RA-SAE-aligned.
Reference: Fel et al., "Archetypal SAEs", and our `ArchetypalSAEs.pdf`.
Per-feature decoder direction is parameterized as:
D_i = (W_i @ C) + Ξ_i # row i of W_dec, in R^D
ββββ archetypal ββββ βββ relaxation βββ
where:
- `C β R^{N_pool Γ D}` is the *frozen* archetype pool --> K-means centroids of
the FFN's **output** activations (`mlp_out`), since the decoder writes into
output space and we want each atom to be a real-data convex combination of
the *target* distribution. The pool is built once before training and
registered as a buffer. The trainer projects drained `mlp_in` tokens through
the frozen FFN to get `mlp_out`, then runs MiniBatchKMeans on that.
- `W β R^{H Γ N_pool}` is a per-feature row-stochastic weight matrix; we
parameterize it as `topk_softmax(A_logits, k)` so each feature's row is
exactly K-sparse and sums to 1 (a sparse simplex).
- `Ξ β R^{H Γ D}` is a *relaxation* slack term, β2-norm bounded by `delta`
per row via in-place projection (`renorm_decoder_`). Ξ΄=0 β pure archetypal
(max stability); Ξ΄ββ β free decoder (no archetypal constraint at all).
RA-SAE Figure 4 shows the recon-vs-stability tradeoff in Ξ΄.
Init: by default each row of A_logits is one-hot at column `i % N_pool`,
so atom `i` starts as exactly centroid `i % N_pool` (== W = eye(H, N_pool)
in the paper's notation). This is the most-archetypal possible state at
step 0 and gives a clean training trajectory.
The encoder is unchanged from MLPTranscoder (ReLU on a linear projection).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
@dataclass
class ArchetypalConfig:
d_in: int = 768
d_hidden: int = 6144
n_pool: int = 32_000 # RA-SAE default; was 10k in earlier baby
top_k: int = 64 # per-archetype convex-combo sparsity (decoder side)
a_logits_init_std: float = 0.01 # only used when init_eye=False
init_eye: bool = True # one-hot per atom at column i % n_pool
delta: float = 0.0 # max βΞ_iββ; 0 = pure archetypal
# Encoder activation choice. None (default) = ReLU + L1 (the original
# ReLU+L1 SAE setup). int K (e.g. 64, 128, 256) = Prisma-style TopK
# encoder: per token, keep the top-K pre-activations and zero the rest,
# then apply ReLU. This directly enforces L0 = K and removes the L1
# hyperparameter as the sparsity dial.
encoder_topk: int | None = None
def topk_softmax(logits: torch.Tensor, k: int) -> torch.Tensor:
"""Differentiable sparse softmax: softmax over top-k entries per row, zeros elsewhere.
Gradient flows through the top-k values via the standard softmax backward;
`topk_idx` is treated as a constant. If a logit drops out of the top-k from
one step to the next, it stops receiving gradients the standard top-K
sparsity trade-off, fine for our setting.
"""
topk_vals, topk_idx = logits.topk(k, dim=-1)
sm = topk_vals.softmax(dim=-1)
out = torch.zeros_like(logits)
return out.scatter(-1, topk_idx, sm)
class ArchetypalTranscoder(nn.Module):
def __init__(self, cfg: ArchetypalConfig, data_pool: torch.Tensor):
super().__init__()
if data_pool.shape != (cfg.n_pool, cfg.d_in):
raise ValueError(
f"data_pool shape {tuple(data_pool.shape)} != ({cfg.n_pool}, {cfg.d_in})"
)
self.cfg = cfg
# Encoder --> identical to MLPTranscoder.
self.W_enc = nn.Parameter(torch.empty(cfg.d_hidden, cfg.d_in))
self.b_enc = nn.Parameter(torch.zeros(cfg.d_hidden))
nn.init.kaiming_uniform_(self.W_enc, a=5**0.5)
# Output bias.
self.b_dec = nn.Parameter(torch.zeros(cfg.d_in))
# Mixing logits A_logits β R^{H Γ N_pool}. Two init regimes:
# - eye: one-hot at column (i % n_pool) β atom i starts as that centroid.
# After top-K softmax that single position has weight 1.
# - random: small Gaussian. Atoms start as roughly uniform mixtures over
# top-K random pool entries.
self.A_logits = nn.Parameter(torch.zeros(cfg.d_hidden, cfg.n_pool))
with torch.no_grad():
if cfg.init_eye:
row_idx = torch.arange(cfg.d_hidden)
col_idx = row_idx % cfg.n_pool
# Scale init logit so the post-softmax top weight is ~99%
# regardless of top_k. Derivation:
# top_val = e^L / (e^L + (k-1)) β₯ 0.99
# β e^L β₯ 99(k-1) β L β₯ ln(99Β·(k-1))
# We use ln(100Β·k) which gives β99% for any k β₯ 1, and stays
# small enough that the optimizer can still redistribute mass
# during training (~ a few hundred steps to migrate an atom).
init_logit = math.log(100.0 * max(cfg.top_k, 1))
self.A_logits[row_idx, col_idx] = init_logit
else:
self.A_logits.normal_(mean=0.0, std=cfg.a_logits_init_std)
# Relaxation slack term Ξ β R^{H Γ D}, init zero.
self.Lambda = nn.Parameter(torch.zeros(cfg.d_hidden, cfg.d_in))
# Frozen archetype pool --> sampled once at training start, never updated.
self.register_buffer("data_pool", data_pool.detach().clone().to(torch.float32))
# ---------------------------------------------------------------- helpers
@property
def W_dec(self) -> torch.Tensor:
"""[D, H] decoder weight = (top-K-softmax(A_logits) @ pool).T + Ξ.T.
Returned in [d_in, d_hidden] orientation to match `MLPTranscoder.W_dec`,
so downstream code (logging, metrics) is interface-compatible.
"""
A = topk_softmax(self.A_logits, self.cfg.top_k)
archetypal = (A @ self.data_pool).T # [D, H]
relax = self.Lambda.T # [D, H]
return archetypal + relax
@torch.no_grad()
def renorm_decoder_(self) -> None:
"""In-place project Ξ rows onto the βΒ·ββ β€ delta ball.
Called after every optimizer step (parity with `MLPTranscoder.renorm_decoder_`).
With `delta == 0` this hard-zeros Ξ --> the pure-archetypal regime.
With `delta > 0` this is the bounded-Ξ projection from RA-SAE step 4.
Note: we do NOT project the simplex on `A_logits` because our
`topk_softmax` activation enforces row-stochasticity *inside the
forward pass*, not as a parameter projection. A_logits is free to
roam; the decoder is always built from a top-K simplex by construction.
"""
if self.cfg.delta <= 0.0:
self.Lambda.zero_()
return
# Per-row L2 norms of Ξ.
norms = self.Lambda.norm(dim=-1, keepdim=True) # [H, 1]
# Multiplicative scale: 1.0 if already inside the ball, otherwise Ξ΄ / βΒ·β.
scale = torch.clamp(self.cfg.delta / norms.clamp_min(1e-12), max=1.0)
self.Lambda.mul_(scale)
# ----------------------------------------------------------------- forward
def encode(self, x: torch.Tensor) -> torch.Tensor:
pre = F.linear(x, self.W_enc, self.b_enc)
if self.cfg.encoder_topk is None:
# Original ReLU+L1 path: ReLU on pre-activations, L0 emerges from L1 pressure.
return F.relu(pre)
# TopK encoder (Prisma-style): per token, keep the top-K pre-activations
# (passing through ReLU on the kept ones to ensure non-negativity), zero
# the rest. Directly enforces L0 = encoder_topk by construction. This
# decouples sparsity from the L1 coefficient.
k = min(self.cfg.encoder_topk, pre.shape[-1])
vals, idx = pre.topk(k, dim=-1)
z = torch.zeros_like(pre)
z.scatter_(-1, idx, F.relu(vals))
return z
def decode(self, z: torch.Tensor) -> torch.Tensor:
return F.linear(z, self.W_dec, self.b_dec)
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
z = self.encode(x)
x_hat = self.decode(z)
return x_hat, z
|