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