Upload folder using huggingface_hub
Browse files- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/DECISIONS.md +153 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/README.md +5 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/config.json +42 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/export/config.json +42 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/export/experts.safetensors +3 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/export/top.safetensors +3 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/interface.py +59 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/log.jsonl +0 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/model.py +462 -0
- gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/summary.json +13 -0
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/DECISIONS.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# H-SAE torch port — decisions and deviations
|
| 2 |
+
|
| 3 |
+
`benchmark/methods/train/hsae.py` is a PyTorch port of the authors' JAX/Equinox
|
| 4 |
+
H-SAE (Muchane, Pal & Veitch, arXiv:2506.01197), vendored byte-for-byte at
|
| 5 |
+
`synth_sae_bench_baseline/third_party/hsae_muchane/moe_eqx.py`. Parity against
|
| 6 |
+
that file is tested in `tests/test_train_hsae.py` (forward activations, routing
|
| 7 |
+
order, loss, raw gradients, and four full optimizer steps agree to ~1e-7 on CPU
|
| 8 |
+
fp32; the tests assert 1e-5). Everything below is a place where the port or the
|
| 9 |
+
run configuration knowingly differs from `moe_eqx.py` or from the authors' run.
|
| 10 |
+
|
| 11 |
+
## Numerics / implementation (same maths, different kernels)
|
| 12 |
+
|
| 13 |
+
- **Dense instead of gathered expert matmuls.** `moe_eqx.py` gathers
|
| 14 |
+
`W_down[top_k_indices]` (`(k, s, d)` per example) and `W_up[top_k_indices]`.
|
| 15 |
+
At batch 4096, k = 75, d = 2304 that gather is 11 GB per step, so the port
|
| 16 |
+
computes `x @ W_down.reshape(m*s, d).T` for *all* experts and gathers the
|
| 17 |
+
`(B, k, s)` routed subspace codes, and decodes with a scattered `(B, m*s)`
|
| 18 |
+
code matrix times `W_up`. The top-level decode likewise scatters the routed
|
| 19 |
+
values into a `(B, m)` matrix and multiplies by `D.T` instead of gathering
|
| 20 |
+
`D[:, idx]`. Forward values and gradients are identical up to reduction order
|
| 21 |
+
(verified by the parity test); the wasted FLOPs are cheap relative to the
|
| 22 |
+
Gemma forward.
|
| 23 |
+
- **`naive_top_k` via a stable descending sort.** The upstream iterative
|
| 24 |
+
argmax scan returns the k largest values in descending order with ties
|
| 25 |
+
resolved to the lowest index. `torch.sort(descending=True, stable=True)` has
|
| 26 |
+
exactly that order; the test checks it against an iterative argmax on
|
| 27 |
+
tie-heavy input and against JAX (exact index-and-order equality, including a
|
| 28 |
+
zero input row where all k routed slots are ties at 0).
|
| 29 |
+
- **bf16 autocast (CUDA only) for the four big matmuls**: top encoder,
|
| 30 |
+
`W_down` projection, top decoder, `W_up` lift. Offset-ReLU, TopK, the
|
| 31 |
+
expert-level (`(B, k, a, s)`) computations, masking, losses and the
|
| 32 |
+
orthogonality penalty (a `(m, m)` matmul on unit-normalised rows) stay fp32
|
| 33 |
+
(TF32 as set by the driver). Master weights and Adam state are fp32. The
|
| 34 |
+
authors trained in fp32 on TPU/A100 (JAX default matmul precision); the
|
| 35 |
+
parity test runs with autocast disabled. `autocast_bf16=False` restores
|
| 36 |
+
exact fp32.
|
| 37 |
+
- **`clip_by_global_norm`** is implemented directly as `g *= min(1, c/||g||)`
|
| 38 |
+
(optax semantics) rather than `torch.nn.utils.clip_grad_norm_`, whose
|
| 39 |
+
`1e-6` denominator fudge is absent in optax.
|
| 40 |
+
- **Adam** is `torch.optim.Adam(betas=(0.9, 0.999), eps=1e-8)`, which matches
|
| 41 |
+
`optax.adam(b1=0.9)` defaults (`b2=0.999, eps=1e-8, eps_root=0`) including
|
| 42 |
+
bias correction; the LR is written into the param group every step from the
|
| 43 |
+
pure-function schedule `lr_at(step, total_steps)`. Verified by the 4-step
|
| 44 |
+
parity test at lr_peak = 1e-2 (weights agree to 4e-7 after moving by 0.2).
|
| 45 |
+
- **LR schedule at step 0.** optax evaluates the linear warm-up in float32,
|
| 46 |
+
where `lr_init = 1e-11` is below the ULP of the peak, so the JAX schedule
|
| 47 |
+
yields exactly 0.0 on the first step; the port yields 1e-11. The parity test
|
| 48 |
+
allows 1e-10 absolute on the LR. (The warm-up is effectively "from 0".)
|
| 49 |
+
- **Regulariser warm-up and LR schedule are indexed by the driver's 0-based
|
| 50 |
+
`step`**, matching optax's optimizer count (first update uses the step-0
|
| 51 |
+
values) and Kirill's `regularizer_warmup_frac(step)`. `decay_steps =
|
| 52 |
+
total_steps - warmup` as in the upstream `get_lr_schedule`; if the run is
|
| 53 |
+
shorter than the warm-up (e.g. the 4M-token smoke run: 976 steps) the whole
|
| 54 |
+
run is warm-up and the cosine part is never reached.
|
| 55 |
+
- **Init.** `he_uniform` (uniform with variance `2/fan_in`) with fan_in = d for
|
| 56 |
+
`E` and `W_down`, s for the expert encoders; `W_down` rows and expert
|
| 57 |
+
encoder rows unit-normalised; decoders are the transposes at init. Like
|
| 58 |
+
upstream, the top decoder is *not* unit-normalised at init — it becomes so
|
| 59 |
+
after the first `normalize_decoders()` (so the loss drops at step 1 for that
|
| 60 |
+
reason alone). Seeds are torch seeds; no claim of bitwise equality with
|
| 61 |
+
`jax.random.PRNGKey`.
|
| 62 |
+
- **The upstream `cross_orthogonality_penalty` quirk is reproduced.**
|
| 63 |
+
`jnp.diag(ED)` there is the 1-D diagonal, so `ED - jnp.diag(ED)` broadcasts
|
| 64 |
+
`ED[i, j] - ED[j, j]` rather than zeroing the diagonal as in the paper's
|
| 65 |
+
eq. 3.4. The port keeps the code's behaviour (the parity test would fail
|
| 66 |
+
otherwise).
|
| 67 |
+
- **L1 term** is on *all* top codes (post offset-ReLU) and on all `k` routed
|
| 68 |
+
slots' raw leaky codes (unmasked), exactly as in `loss_fn` (the paper says
|
| 69 |
+
"outside the top k"; the code does not).
|
| 70 |
+
- **`use_bias=False`** only (the authors' setting; the bias branch in
|
| 71 |
+
`moe_eqx.py` loads a file from their filesystem and is dead code).
|
| 72 |
+
- Not ported: `tensorstore_data_generator` and the upstream `run_moe_eqx.py`
|
| 73 |
+
loop (data pipeline, wandb, FSDP, the "2 epochs" quirk). The driver supplies
|
| 74 |
+
data; the schedule is defined over the driver's `total_steps`.
|
| 75 |
+
|
| 76 |
+
## Readout / export conventions (not in `moe_eqx.py`)
|
| 77 |
+
|
| 78 |
+
- **Flattened ids**: `0..m-1` experts, `m + j*a + l` child `l` of expert `j`
|
| 79 |
+
(identical to Kirill's `common.py` and the authors' dashboard).
|
| 80 |
+
`native_ancestors() = {child: {expert}}`, `latent_level()` = 0 / 1.
|
| 81 |
+
- **`encode(x)`** applies `x / (||x||_2 + 1e-6)` per example (the authors'
|
| 82 |
+
`_prefetch` normalisation, no centring), then returns expert activations
|
| 83 |
+
(routed top value if > 0, else 0) and child activations
|
| 84 |
+
`relu(masked low code)`. A routed-but-negative leaky child code (slope 0.01)
|
| 85 |
+
does contribute to the reconstruction during training but is reported as
|
| 86 |
+
*not firing* (Kirill's frozen firing semantics, `eval_adapter.py`). `l0` is
|
| 87 |
+
the count of these non-negative flattened activations that are > 0; it is
|
| 88 |
+
structurally at most `2k`.
|
| 89 |
+
- **`W_dec`**: expert rows `D[:, j]` (unit norm); child rows
|
| 90 |
+
`W_up[j] @ D_j[:, l]` (the authors' dashboard convention, Kirill's
|
| 91 |
+
"primary" child direction; the "parent + child" sensitivity variant is not
|
| 92 |
+
exported but is `W_dec[j] + W_dec[child]`).
|
| 93 |
+
- The exported model runs in fp32 (`autocast_bf16=False` on load).
|
| 94 |
+
- Export = `top.safetensors` (E, D), `experts.safetensors` (W_down, W_up,
|
| 95 |
+
expert enc/dec) and `config.json`; two files so each stays under the
|
| 96 |
+
driver's 200 MB copy limit (experts ≈ 151 MB at the target size).
|
| 97 |
+
|
| 98 |
+
## Configuration vs the authors' run
|
| 99 |
+
|
| 100 |
+
- **Width**: m = 2048 experts x a = 15 children = 32768 flattened latents to
|
| 101 |
+
match the twin's width. The authors used 8k–32k experts with 16 or 64
|
| 102 |
+
sublatents; 15 (not 16) is forced by the 32768 total. Subspace dim s = 4 is
|
| 103 |
+
the authors' value for the 16-sublatent setting ("when the number of
|
| 104 |
+
sublatents per expert is 16, the subspace dimension is 4"); Kirill used 4 too.
|
| 105 |
+
- **k = 75** (authors: 32) so that active nodes per token = up to k experts +
|
| 106 |
+
k children ≈ 150, the twin's L0. The realised L0 is reported by `step`
|
| 107 |
+
(`l0`, `l0_top`, `l0_child`) — an expert routed with a top value of 0 (fewer
|
| 108 |
+
than k codes above the offset `1/sqrt(d)`) and negative leaky child codes
|
| 109 |
+
reduce it below 150.
|
| 110 |
+
- **Batch 4096 tokens** (driver; authors 32512) and **500M tokens** (authors:
|
| 111 |
+
1B vectors x 4 epochs). The LR schedule (peak 5e-4, 1000-step linear
|
| 112 |
+
warm-up, cosine to 10 %, clip 0.75) and the regulariser warm-up (1000 steps)
|
| 113 |
+
are kept at the authors' *step* counts even though a step now sees 8x fewer
|
| 114 |
+
tokens; the peak LR is not rescaled. Rationale: with Adam the per-step update
|
| 115 |
+
size is set by the LR, not the batch; the authors' values were not tuned for
|
| 116 |
+
this batch, and a sqrt-scaling argument would lower the peak to ~1.8e-4, but
|
| 117 |
+
there is no evidence for either choice here, so the defaults stay the
|
| 118 |
+
authors' and `overrides` can change `lr_peak` / `lr_warmup_steps`.
|
| 119 |
+
- **Data**: Gemma-2-2b layer 12 resid_post, twin corpus (authors: layer 20,
|
| 120 |
+
Wikipedia, first 256 tokens per article). Inputs are L2-normalised per
|
| 121 |
+
example as in the authors' pipeline, so the reconstruction MSE (`mse`, sum
|
| 122 |
+
over d, mean over batch — the authors' definition) is on unit-norm vectors.
|
| 123 |
+
- **Dead-feature metric** (`dead_frac`, `dead_child_frac`): fraction of
|
| 124 |
+
experts / child slots that have not fired in the last
|
| 125 |
+
`dead_window_steps = 250` steps (~1M tokens at batch 4096, the paper's dead
|
| 126 |
+
criterion). Counters start at the window, so never-fired latents count as
|
| 127 |
+
dead from step 0. There is no dead-feature intervention (the authors rely on
|
| 128 |
+
the orthogonality penalty; none in the code).
|
| 129 |
+
- **Checkpoint** = model weights, Adam state, dead counters, step count; the
|
| 130 |
+
schedule is a pure function of `(step, total_steps)` so no extra state is
|
| 131 |
+
needed. Resume is bitwise on CPU; on CUDA the index backward uses atomic
|
| 132 |
+
adds, so a resumed run is deterministic only up to floating-point ordering.
|
| 133 |
+
|
| 134 |
+
## Driver change
|
| 135 |
+
|
| 136 |
+
- `driver.py` image: added `scikit-learn` — `benchmark/__init__.py` imports
|
| 137 |
+
`sklearn` (via `grading.py`/`locate.py`), so importing any
|
| 138 |
+
`benchmark.methods.train.*` module inside the container failed without it.
|
| 139 |
+
|
| 140 |
+
## Smoke run (`smoke_hsae`, A100-80GB, 4M tokens = 976 steps, all inside the LR warm-up)
|
| 141 |
+
|
| 142 |
+
- Throughput: 3,997,696 tokens in 112 s of training loop (~35.7k tokens/s
|
| 143 |
+
end-to-end including the Gemma-2-2b forward; 138 s wall including model
|
| 144 |
+
load) -> a 500M-token run is ~3.9 h on an A100-80GB.
|
| 145 |
+
- Realised L0 (non-zero flattened latents per token): 150.0 at step 200,
|
| 146 |
+
146.8 at 600, 128.5 at step 976 (64.3 experts + 64.2 children). L0 is
|
| 147 |
+
capped at 2k = 150 and *decreases* during training as fewer than k top codes
|
| 148 |
+
clear the `1/sqrt(d)` offset; where it settles in a full run is unknown.
|
| 149 |
+
- Loss 0.285, reconstruction MSE 0.247 on unit-norm inputs (FVU 0.247) at
|
| 150 |
+
step 976 with lr still ramping (5e-4 reached at the last step); no dead
|
| 151 |
+
experts, 0.35 % dead child slots. The orthogonality term is ~1e-6 (the
|
| 152 |
+
upstream penalty is a Frobenius norm divided by m^2 - m, so at m = 2048 it
|
| 153 |
+
is tiny by construction; reported as `ortho_raw` too).
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# hsae_32k_k15_seed0
|
| 2 |
+
|
| 3 |
+
Architecture `hsae` trained on google/gemma-2-2b layer 12 (`model.layers.12`, resid_post) with the twin-SAE recipe: 499,998,720 tokens of chanind/pile-uncopyrighted-gemma-1024-abbrv-2B, batch 4096 tokens, context 1024.
|
| 4 |
+
|
| 5 |
+
Load with `model.py::load_exported(export_dir)` from this folder (see `interface.py`); `encode(x)` takes raw residual activations and returns the flattened latents; `native_ancestors()` gives the architecture's own parent claims. Interpretation decisions vs the paper are in `DECISIONS.md`; `config.json`/`log.jsonl` are the training config and metrics.
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/config.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"arch": "hsae",
|
| 3 |
+
"run_name": "hsae_32k_k15_seed0",
|
| 4 |
+
"n_tokens": 500000000,
|
| 5 |
+
"batch_tokens": 4096,
|
| 6 |
+
"total_steps": 122070,
|
| 7 |
+
"overrides": {
|
| 8 |
+
"k": 8
|
| 9 |
+
},
|
| 10 |
+
"seed": 0,
|
| 11 |
+
"init_seed": 0,
|
| 12 |
+
"group": [
|
| 13 |
+
"hsae_32k_k15_seed0",
|
| 14 |
+
"tree_sae_32k_k15_seed0",
|
| 15 |
+
"hsae_forest_32k_k15_seed0"
|
| 16 |
+
],
|
| 17 |
+
"trainer_config": {
|
| 18 |
+
"d_in": 2304,
|
| 19 |
+
"num_experts": 2048,
|
| 20 |
+
"atoms_per_subspace": 15,
|
| 21 |
+
"subspace_dim": 4,
|
| 22 |
+
"k": 8,
|
| 23 |
+
"l1_penalty": 0.001,
|
| 24 |
+
"ortho_penalty": 0.1,
|
| 25 |
+
"top_recon_coeff": 0.1,
|
| 26 |
+
"reg_warmup_steps": 1000,
|
| 27 |
+
"lr_init": 1e-11,
|
| 28 |
+
"lr_peak": 0.0005,
|
| 29 |
+
"lr_warmup_steps": 1000,
|
| 30 |
+
"lr_cosine_alpha": 0.1,
|
| 31 |
+
"norm_clip": 0.75,
|
| 32 |
+
"adam_b1": 0.9,
|
| 33 |
+
"adam_b2": 0.999,
|
| 34 |
+
"adam_eps": 1e-08,
|
| 35 |
+
"eps": 1e-06,
|
| 36 |
+
"normalize_inputs": true,
|
| 37 |
+
"autocast_bf16": true,
|
| 38 |
+
"dead_window_steps": 250,
|
| 39 |
+
"seed": 0,
|
| 40 |
+
"n_latents": 32768
|
| 41 |
+
}
|
| 42 |
+
}
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/export/config.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"arch": "hsae",
|
| 3 |
+
"config": {
|
| 4 |
+
"d_in": 2304,
|
| 5 |
+
"num_experts": 2048,
|
| 6 |
+
"atoms_per_subspace": 15,
|
| 7 |
+
"subspace_dim": 4,
|
| 8 |
+
"k": 8,
|
| 9 |
+
"l1_penalty": 0.001,
|
| 10 |
+
"ortho_penalty": 0.1,
|
| 11 |
+
"top_recon_coeff": 0.1,
|
| 12 |
+
"reg_warmup_steps": 1000,
|
| 13 |
+
"lr_init": 1e-11,
|
| 14 |
+
"lr_peak": 0.0005,
|
| 15 |
+
"lr_warmup_steps": 1000,
|
| 16 |
+
"lr_cosine_alpha": 0.1,
|
| 17 |
+
"norm_clip": 0.75,
|
| 18 |
+
"adam_b1": 0.9,
|
| 19 |
+
"adam_b2": 0.999,
|
| 20 |
+
"adam_eps": 1e-08,
|
| 21 |
+
"eps": 1e-06,
|
| 22 |
+
"normalize_inputs": true,
|
| 23 |
+
"autocast_bf16": true,
|
| 24 |
+
"dead_window_steps": 250,
|
| 25 |
+
"seed": 0
|
| 26 |
+
},
|
| 27 |
+
"n_latents": 32768,
|
| 28 |
+
"n_steps_done": 122070,
|
| 29 |
+
"latent_layout": "ids 0..m-1 experts; id m + j*a + l = child l of expert j",
|
| 30 |
+
"weights": {
|
| 31 |
+
"top.safetensors": [
|
| 32 |
+
"top_encoder (m,d)",
|
| 33 |
+
"top_decoder (d,m)"
|
| 34 |
+
],
|
| 35 |
+
"experts.safetensors": [
|
| 36 |
+
"W_down (m,s,d)",
|
| 37 |
+
"W_up (m,d,s)",
|
| 38 |
+
"expert_encoder (m,a,s)",
|
| 39 |
+
"expert_decoder (m,s,a)"
|
| 40 |
+
]
|
| 41 |
+
}
|
| 42 |
+
}
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/export/experts.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:31d1fa5284113836a2a3608df8ae5c8ffea2ea59a532b711d6943c5656898af0
|
| 3 |
+
size 151978336
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/export/top.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:640a3d3c351d4bb6595ab5891fb9f33665cc4386a9bdbd2015e33676fce022bb
|
| 3 |
+
size 37748912
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/interface.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Interface every trainable hierarchy SAE implements for ``driver.py``.
|
| 2 |
+
|
| 3 |
+
The driver owns: the activation stream (twin recipe: 500M tokens of the
|
| 4 |
+
twin's corpus, batches of 4096 tokens, Gemma-2-2b layer 12), step counting,
|
| 5 |
+
checkpoint/resume on the Modal volume, metric logging, and the final export.
|
| 6 |
+
Everything architecture-specific — losses, optimizer, LR schedule, decoder
|
| 7 |
+
renormalisation, hierarchy updates, reallocation, dead-feature handling —
|
| 8 |
+
lives inside ``step``.
|
| 9 |
+
|
| 10 |
+
Exports are consumed by the benchmark through :class:`ExportedSAE`: a single
|
| 11 |
+
concatenated latent index space (all levels / layers / experts flattened),
|
| 12 |
+
``encode(x) -> (batch, n_latents)`` non-negative activations, and the
|
| 13 |
+
architecture's own ancestor claims ``native_ancestors()`` as
|
| 14 |
+
``{child_index: {ancestor_indices}}`` (direct parents; the benchmark applies
|
| 15 |
+
transitive closure).
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any, Protocol
|
| 22 |
+
|
| 23 |
+
import torch
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class HierarchyTrainer(Protocol):
|
| 27 |
+
d_in: int
|
| 28 |
+
n_latents: int # size of the flattened latent index space
|
| 29 |
+
|
| 30 |
+
def step(self, x: torch.Tensor, step: int, total_steps: int) -> dict[str, float]:
|
| 31 |
+
"""One optimizer step on a ``(batch, d_in)`` float32 CUDA batch.
|
| 32 |
+
|
| 33 |
+
Returns scalar metrics (must include ``"loss"`` and ``"l0"``, where
|
| 34 |
+
``l0`` is the mean number of active latents per token across the
|
| 35 |
+
whole flattened index space).
|
| 36 |
+
"""
|
| 37 |
+
...
|
| 38 |
+
|
| 39 |
+
def state_dict(self) -> dict[str, Any]: ...
|
| 40 |
+
|
| 41 |
+
def load_state_dict(self, state: dict[str, Any]) -> None: ...
|
| 42 |
+
|
| 43 |
+
def export(self, out_dir: Path) -> None:
|
| 44 |
+
"""Write everything :class:`ExportedSAE` needs to ``out_dir``."""
|
| 45 |
+
...
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ExportedSAE(Protocol):
|
| 49 |
+
n_latents: int
|
| 50 |
+
W_dec: torch.Tensor # (n_latents, d_in) decoder directions (for cosine baselines)
|
| 51 |
+
|
| 52 |
+
@torch.no_grad()
|
| 53 |
+
def encode(self, x: torch.Tensor) -> torch.Tensor: ...
|
| 54 |
+
|
| 55 |
+
def native_ancestors(self) -> dict[int, set[int]]: ...
|
| 56 |
+
|
| 57 |
+
def latent_level(self) -> list[int]:
|
| 58 |
+
"""Level / layer / expert-vs-child tag per flattened latent (for reporting)."""
|
| 59 |
+
...
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/log.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/model.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""H-SAE (Muchane, Pal & Veitch, arXiv:2506.01197) — PyTorch trainer for ``driver.py``.
|
| 2 |
+
|
| 3 |
+
A faithful torch port of the authors' JAX/Equinox implementation vendored at
|
| 4 |
+
``synth_sae_bench_baseline/third_party/hsae_muchane/moe_eqx.py`` (commit
|
| 5 |
+
``3fa346cb``): ``MixtureOfExperts_v2`` (top-level offset-ReLU + iterative
|
| 6 |
+
TopK routing, per-expert subspace projection, TopK-1 child selection with
|
| 7 |
+
parent gating), ``loss_fn`` (reconstruction + top-only reconstruction + L1 +
|
| 8 |
+
cross-orthogonality with linear regulariser warm-up), the authors'
|
| 9 |
+
per-example L2 input normalisation, and ``update_model`` (decoder-tangent
|
| 10 |
+
gradient projection, global-norm clip 0.75, Adam(b1=0.9) with linear warm-up
|
| 11 |
+
from 1e-11 and cosine decay to 10% of peak, unit-norm decoder columns after
|
| 12 |
+
every step). Every deliberate deviation is listed in ``HSAE_DECISIONS.md``.
|
| 13 |
+
|
| 14 |
+
Flattened latent index space (Kirill's ``common.py`` / the authors' dashboard):
|
| 15 |
+
ids ``0..m-1`` are experts, id ``m + j*a + l`` is child ``l`` of expert ``j``.
|
| 16 |
+
|
| 17 |
+
Parameter layout (identical to the JAX fields)::
|
| 18 |
+
|
| 19 |
+
top_encoder E (m, d) top codes = offset_relu(E x)
|
| 20 |
+
top_decoder D (d, m) unit-norm columns (after each step)
|
| 21 |
+
W_down (m, s, d) x_sub_j = W_down[j] x
|
| 22 |
+
W_up (m, d, s) lift back: W_up[j] x_sub_j
|
| 23 |
+
expert_encoder E_j (m, a, s) low codes = leaky_offset_relu(E_j x_sub_j)
|
| 24 |
+
expert_decoder D_j (m, s, a) unit-norm columns (after each step)
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import math
|
| 31 |
+
from dataclasses import asdict, dataclass, replace
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any
|
| 34 |
+
|
| 35 |
+
import torch
|
| 36 |
+
import torch.nn.functional as F
|
| 37 |
+
from torch import Tensor, nn
|
| 38 |
+
|
| 39 |
+
UPSTREAM_EPS = 1e-6 # moe_eqx.py: eps (normalisation and orthogonality penalty)
|
| 40 |
+
WEIGHT_KEYS = ("top_encoder", "top_decoder", "W_down", "W_up", "expert_encoder", "expert_decoder")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class HSAEConfig:
|
| 45 |
+
d_in: int = 2304
|
| 46 |
+
num_experts: int = 2048 # m
|
| 47 |
+
atoms_per_subspace: int = 15 # a -> m + m*a = 32768 flattened latents
|
| 48 |
+
subspace_dim: int = 4 # s (authors: 4 for ~16 sublatents/expert, else 8)
|
| 49 |
+
k: int = 75 # top-level TopK: <= k experts + <= k children active per token
|
| 50 |
+
l1_penalty: float = 1e-3
|
| 51 |
+
ortho_penalty: float = 1e-1
|
| 52 |
+
top_recon_coeff: float = 1e-1 # the hard-coded 1E-1 * reconstruction_loss_top
|
| 53 |
+
reg_warmup_steps: int = 1000 # regularisers ramp linearly from 0
|
| 54 |
+
lr_init: float = 1e-11
|
| 55 |
+
lr_peak: float = 5e-4
|
| 56 |
+
lr_warmup_steps: int = 1000
|
| 57 |
+
lr_cosine_alpha: float = 0.1 # cosine decays to alpha * peak
|
| 58 |
+
norm_clip: float = 0.75
|
| 59 |
+
adam_b1: float = 0.9
|
| 60 |
+
adam_b2: float = 0.999
|
| 61 |
+
adam_eps: float = 1e-8
|
| 62 |
+
eps: float = UPSTREAM_EPS
|
| 63 |
+
normalize_inputs: bool = True
|
| 64 |
+
autocast_bf16: bool = True # bf16 for the four big matmuls on CUDA only
|
| 65 |
+
dead_window_steps: int = 250 # ~1M tokens at batch 4096 (paper's dead criterion)
|
| 66 |
+
seed: int = 0
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def n_latents(self) -> int:
|
| 70 |
+
return self.num_experts * (1 + self.atoms_per_subspace)
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def offset(self) -> float:
|
| 74 |
+
return 1.0 / math.sqrt(self.d_in)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
# Pure functions ported from moe_eqx.py
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
def normalize_inputs(x: Tensor, eps: float = UPSTREAM_EPS) -> Tensor:
|
| 81 |
+
"""Authors' per-example L2 normalisation ``x / (||x||_2 + eps)`` (no centring)."""
|
| 82 |
+
return x / (x.norm(dim=-1, keepdim=True) + eps)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def leaky_offset_relu(x: Tensor, negative_slope: float, offset: float) -> Tensor:
|
| 86 |
+
return torch.where(x >= offset, x, negative_slope * x)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def naive_top_k(data: Tensor, k: int) -> tuple[Tensor, Tensor]:
|
| 90 |
+
"""Upstream ``naive_top_k`` semantics for a ``(B, m)`` batch: the k largest
|
| 91 |
+
values in descending order, ties resolved to the lowest index (iterative
|
| 92 |
+
``argmax``). A stable descending sort yields exactly that order."""
|
| 93 |
+
_, order = torch.sort(data, dim=1, descending=True, stable=True)
|
| 94 |
+
idx = order[:, :k]
|
| 95 |
+
return torch.gather(data, 1, idx), idx
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def cross_orthogonality_penalty(encoder: Tensor, decoder: Tensor, eps: float = UPSTREAM_EPS) -> Tensor:
|
| 99 |
+
"""Byte-faithful port, including the upstream quirk that ``jnp.diag(ED)`` is
|
| 100 |
+
the 1-D diagonal broadcast over rows (``ED[i, j] - ED[j, j]``), not the
|
| 101 |
+
diagonal matrix of the paper's eq. 3.4."""
|
| 102 |
+
e = encoder.T # (d, m)
|
| 103 |
+
d = decoder.T # (m, d)
|
| 104 |
+
e = e / (e.norm(dim=0, keepdim=True) + eps)
|
| 105 |
+
d = d / (d.norm(dim=1, keepdim=True) + eps)
|
| 106 |
+
ed = d @ e # (m, m)
|
| 107 |
+
dim = ed.shape[0]
|
| 108 |
+
return torch.linalg.norm(ed - torch.diagonal(ed)) / (dim**2 - dim)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def lr_at(step: int, total_steps: int, cfg: HSAEConfig) -> float:
|
| 112 |
+
"""optax ``join_schedules([linear_schedule, cosine_decay_schedule], [warmup])``
|
| 113 |
+
evaluated at the optimizer count ``step`` (0-based: first update uses lr_init).
|
| 114 |
+
``decay_steps = total_steps - warmup`` as in upstream ``get_lr_schedule``."""
|
| 115 |
+
warmup = cfg.lr_warmup_steps
|
| 116 |
+
if warmup > 0 and step < warmup:
|
| 117 |
+
return cfg.lr_init + (cfg.lr_peak - cfg.lr_init) * (step / warmup)
|
| 118 |
+
decay_steps = max(total_steps - warmup, 1)
|
| 119 |
+
count = min(max(step - warmup, 0), decay_steps)
|
| 120 |
+
cosine = 0.5 * (1.0 + math.cos(math.pi * count / decay_steps))
|
| 121 |
+
return cfg.lr_peak * ((1.0 - cfg.lr_cosine_alpha) * cosine + cfg.lr_cosine_alpha)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def reg_warmup_frac(step: int, cfg: HSAEConfig) -> float:
|
| 125 |
+
if cfg.reg_warmup_steps <= 0:
|
| 126 |
+
return 1.0
|
| 127 |
+
return min(1.0, step / cfg.reg_warmup_steps)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Model
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
class HSAE(nn.Module):
|
| 134 |
+
def __init__(self, cfg: HSAEConfig, device: str | torch.device = "cpu") -> None:
|
| 135 |
+
super().__init__()
|
| 136 |
+
self.cfg = cfg
|
| 137 |
+
m, d, s, a = cfg.num_experts, cfg.d_in, cfg.subspace_dim, cfg.atoms_per_subspace
|
| 138 |
+
gen = torch.Generator(device="cpu").manual_seed(cfg.seed)
|
| 139 |
+
|
| 140 |
+
def he_uniform(shape: tuple[int, ...], fan_in: int) -> Tensor:
|
| 141 |
+
limit = math.sqrt(6.0 / fan_in) # jax he_uniform: variance 2/fan_in, uniform
|
| 142 |
+
return (torch.rand(shape, generator=gen, dtype=torch.float32) * 2 - 1) * limit
|
| 143 |
+
|
| 144 |
+
top_enc = he_uniform((m, d), d)
|
| 145 |
+
w_down = he_uniform((m, s, d), d)
|
| 146 |
+
w_down = w_down / w_down.norm(dim=-1, keepdim=True)
|
| 147 |
+
exp_enc = he_uniform((m, a, s), s)
|
| 148 |
+
exp_enc = exp_enc / exp_enc.norm(dim=-1, keepdim=True)
|
| 149 |
+
# Tied transposes at init (upstream); trained as independent parameters.
|
| 150 |
+
# NOTE: like upstream, the top decoder is NOT unit-normalised at init —
|
| 151 |
+
# normalize_decoders() runs after the first optimizer step.
|
| 152 |
+
self.top_encoder = nn.Parameter(top_enc.to(device))
|
| 153 |
+
self.top_decoder = nn.Parameter(top_enc.T.contiguous().to(device))
|
| 154 |
+
self.W_down = nn.Parameter(w_down.to(device))
|
| 155 |
+
self.W_up = nn.Parameter(w_down.transpose(1, 2).contiguous().to(device))
|
| 156 |
+
self.expert_encoder = nn.Parameter(exp_enc.to(device))
|
| 157 |
+
self.expert_decoder = nn.Parameter(exp_enc.transpose(1, 2).contiguous().to(device))
|
| 158 |
+
|
| 159 |
+
# -- weights I/O -------------------------------------------------------
|
| 160 |
+
@torch.no_grad()
|
| 161 |
+
def load_arrays(self, arrays: dict[str, Any]) -> None:
|
| 162 |
+
for key in WEIGHT_KEYS:
|
| 163 |
+
getattr(self, key).copy_(torch.as_tensor(arrays[key], dtype=torch.float32))
|
| 164 |
+
|
| 165 |
+
def arrays(self) -> dict[str, Tensor]:
|
| 166 |
+
return {key: getattr(self, key).detach().float().cpu().contiguous() for key in WEIGHT_KEYS}
|
| 167 |
+
|
| 168 |
+
# -- forward -----------------------------------------------------------
|
| 169 |
+
def _autocast(self):
|
| 170 |
+
use = self.cfg.autocast_bf16 and self.top_encoder.is_cuda
|
| 171 |
+
return torch.autocast("cuda", dtype=torch.bfloat16, enabled=use)
|
| 172 |
+
|
| 173 |
+
def forward(self, x: Tensor) -> dict[str, Tensor]:
|
| 174 |
+
"""``x`` is the already-normalised ``(B, d)`` float32 batch. Mirrors
|
| 175 |
+
``vmap(encode) -> mask_codes -> vmap(decode)``; low-level quantities are
|
| 176 |
+
in routed-slot order ``(B, k, ...)`` exactly like upstream."""
|
| 177 |
+
cfg = self.cfg
|
| 178 |
+
m, s, k = cfg.num_experts, cfg.subspace_dim, cfg.k
|
| 179 |
+
B = x.shape[0]
|
| 180 |
+
offset = cfg.offset
|
| 181 |
+
|
| 182 |
+
with self._autocast():
|
| 183 |
+
pre = x @ self.top_encoder.T # (B, m)
|
| 184 |
+
subspace_all = x @ self.W_down.reshape(m * s, -1).T # (B, m*s)
|
| 185 |
+
pre = pre.float()
|
| 186 |
+
top_codes = leaky_offset_relu(pre, negative_slope=0.0, offset=offset)
|
| 187 |
+
top_k_values, top_k_indices = naive_top_k(top_codes, k) # (B, k)
|
| 188 |
+
|
| 189 |
+
# expert path in fp32 (tiny tensors)
|
| 190 |
+
sub_idx = (top_k_indices[:, :, None] * s + torch.arange(s, device=x.device)).reshape(B, k * s)
|
| 191 |
+
subspace = torch.gather(subspace_all, 1, sub_idx).float().reshape(B, k, s)
|
| 192 |
+
sel_enc = self.expert_encoder[top_k_indices] # (B, k, a, s)
|
| 193 |
+
low_pre = torch.einsum("bkas,bks->bka", sel_enc, subspace)
|
| 194 |
+
low_codes = leaky_offset_relu(low_pre, negative_slope=1e-2, offset=offset)
|
| 195 |
+
|
| 196 |
+
# mask_codes: parent gate (top value > 0) and TopK-1 child by raw argmax
|
| 197 |
+
submask = (top_k_values > 0).to(low_codes.dtype) # (B, k)
|
| 198 |
+
child_argmax = torch.argmax(low_codes, dim=-1) # (B, k), first index on ties
|
| 199 |
+
low_mask = F.one_hot(child_argmax, cfg.atoms_per_subspace).to(low_codes.dtype)
|
| 200 |
+
masked_low = low_codes * (low_mask * submask[:, :, None])
|
| 201 |
+
|
| 202 |
+
# decode
|
| 203 |
+
z_top = torch.zeros(B, m, device=x.device, dtype=top_codes.dtype).scatter(1, top_k_indices, top_k_values)
|
| 204 |
+
sel_dec = self.expert_decoder[top_k_indices] # (B, k, s, a)
|
| 205 |
+
decoded_sub = torch.einsum("bksa,bka->bks", sel_dec, masked_low) # (B, k, s)
|
| 206 |
+
z_sub = torch.zeros(B, m * s, device=x.device, dtype=decoded_sub.dtype).scatter(
|
| 207 |
+
1, sub_idx, decoded_sub.reshape(B, k * s)
|
| 208 |
+
)
|
| 209 |
+
with self._autocast():
|
| 210 |
+
x_hat_top = z_top @ self.top_decoder.T # (B, d)
|
| 211 |
+
low_recon = z_sub @ self.W_up.permute(0, 2, 1).reshape(m * s, -1) # (B, d)
|
| 212 |
+
x_hat_top = x_hat_top.float()
|
| 213 |
+
x_hat = x_hat_top + low_recon.float()
|
| 214 |
+
return {
|
| 215 |
+
"top_codes": top_codes,
|
| 216 |
+
"top_k_values": top_k_values,
|
| 217 |
+
"top_k_indices": top_k_indices,
|
| 218 |
+
"low_codes": low_codes,
|
| 219 |
+
"masked_low": masked_low,
|
| 220 |
+
"child_argmax": child_argmax,
|
| 221 |
+
"submask": submask,
|
| 222 |
+
"x_hat": x_hat,
|
| 223 |
+
"x_hat_top": x_hat_top,
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
def loss(self, x: Tensor, l1_penalty: float, ortho_penalty: float) -> tuple[Tensor, dict[str, Tensor]]:
|
| 227 |
+
"""Upstream ``loss_fn`` on a normalised batch (penalties already warmed up)."""
|
| 228 |
+
cfg = self.cfg
|
| 229 |
+
out = self(x)
|
| 230 |
+
B = x.shape[0]
|
| 231 |
+
recon = ((x - out["x_hat"]) ** 2).sum(-1).mean()
|
| 232 |
+
recon_top = ((x - out["x_hat_top"]) ** 2).sum(-1).mean()
|
| 233 |
+
l1 = l1_penalty * (out["top_codes"].abs().sum() + out["low_codes"].abs().sum()) / B
|
| 234 |
+
ortho = cross_orthogonality_penalty(self.top_encoder, self.top_decoder, cfg.eps)
|
| 235 |
+
total = recon + l1 + ortho_penalty * ortho + cfg.top_recon_coeff * recon_top
|
| 236 |
+
dec_norms = self.expert_decoder.detach().norm(dim=1)
|
| 237 |
+
stats = {
|
| 238 |
+
"loss": total,
|
| 239 |
+
"reconstruction_loss": recon,
|
| 240 |
+
"reconstruction_loss_top": recon_top,
|
| 241 |
+
"total_l1_loss": l1,
|
| 242 |
+
"total_ortho_loss": ortho_penalty * ortho,
|
| 243 |
+
"ortho_raw": ortho,
|
| 244 |
+
"decoder_max_norm": dec_norms.max(),
|
| 245 |
+
"decoder_min_norm": dec_norms.min(),
|
| 246 |
+
}
|
| 247 |
+
return total, {**stats, **out}
|
| 248 |
+
|
| 249 |
+
# -- flattened latents ---------------------------------------------------
|
| 250 |
+
def flatten(self, out: dict[str, Tensor]) -> Tensor:
|
| 251 |
+
"""``(B, m + m*a)`` non-negative activations: expert ids first, then
|
| 252 |
+
child ``m + j*a + l``. Children use ``relu(masked_low)`` (a selected but
|
| 253 |
+
negative leaky code does not fire; it still enters the reconstruction)."""
|
| 254 |
+
cfg = self.cfg
|
| 255 |
+
m, a = cfg.num_experts, cfg.atoms_per_subspace
|
| 256 |
+
idx = out["top_k_indices"]
|
| 257 |
+
B, k = idx.shape
|
| 258 |
+
top = torch.zeros(B, m, device=idx.device, dtype=torch.float32).scatter(
|
| 259 |
+
1, idx, out["top_k_values"] * out["submask"]
|
| 260 |
+
)
|
| 261 |
+
child = torch.zeros(B, m * a, device=idx.device, dtype=torch.float32)
|
| 262 |
+
child_idx = (idx[:, :, None] * a + torch.arange(a, device=idx.device)).reshape(B, k * a)
|
| 263 |
+
child = child.scatter(1, child_idx, out["masked_low"].clamp_min(0.0).reshape(B, k * a))
|
| 264 |
+
return torch.cat([top, child], dim=1)
|
| 265 |
+
|
| 266 |
+
# -- decoder maintenance (upstream update_model) --------------------------
|
| 267 |
+
@torch.no_grad()
|
| 268 |
+
def project_away_grads(self) -> None:
|
| 269 |
+
"""Remove the component of each decoder-column gradient parallel to the
|
| 270 |
+
(normalised) column itself, so the update is tangent to the unit sphere."""
|
| 271 |
+
D, g = self.top_decoder, self.top_decoder.grad
|
| 272 |
+
if g is not None:
|
| 273 |
+
dn = D / D.norm(dim=0, keepdim=True)
|
| 274 |
+
g.sub_((g * dn).sum(0, keepdim=True) * dn)
|
| 275 |
+
Dj, gj = self.expert_decoder, self.expert_decoder.grad
|
| 276 |
+
if gj is not None:
|
| 277 |
+
dn = Dj / Dj.norm(dim=1, keepdim=True)
|
| 278 |
+
gj.sub_((gj * dn).sum(1, keepdim=True) * dn)
|
| 279 |
+
|
| 280 |
+
@torch.no_grad()
|
| 281 |
+
def normalize_decoders(self) -> None:
|
| 282 |
+
self.top_decoder.div_(self.top_decoder.norm(dim=0, keepdim=True))
|
| 283 |
+
self.expert_decoder.div_(self.expert_decoder.norm(dim=1, keepdim=True))
|
| 284 |
+
|
| 285 |
+
# -- decoder directions ---------------------------------------------------
|
| 286 |
+
@torch.no_grad()
|
| 287 |
+
def decoder_directions(self) -> Tensor:
|
| 288 |
+
"""``(m + m*a, d)``: ``D[:, j]`` for experts, ``W_up[j] @ D_j[:, l]`` for
|
| 289 |
+
children (the authors' dashboard convention, mapped to activation space)."""
|
| 290 |
+
m, a = self.cfg.num_experts, self.cfg.atoms_per_subspace
|
| 291 |
+
top = self.top_decoder.T
|
| 292 |
+
child = torch.einsum("mds,msa->mad", self.W_up, self.expert_decoder).reshape(m * a, -1)
|
| 293 |
+
return torch.cat([top, child], dim=0).float().contiguous()
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ---------------------------------------------------------------------------
|
| 297 |
+
# Trainer
|
| 298 |
+
# ---------------------------------------------------------------------------
|
| 299 |
+
class HSAETrainer:
|
| 300 |
+
def __init__(self, cfg: HSAEConfig, device: str = "cuda") -> None:
|
| 301 |
+
self.cfg = cfg
|
| 302 |
+
self.device = torch.device(device)
|
| 303 |
+
self.d_in = cfg.d_in
|
| 304 |
+
self.n_latents = cfg.n_latents
|
| 305 |
+
self.model = HSAE(cfg, device=self.device)
|
| 306 |
+
self.opt = torch.optim.Adam(
|
| 307 |
+
self.model.parameters(), lr=cfg.lr_init, betas=(cfg.adam_b1, cfg.adam_b2), eps=cfg.adam_eps
|
| 308 |
+
)
|
| 309 |
+
w = cfg.dead_window_steps
|
| 310 |
+
self.steps_since_fired = torch.full((cfg.num_experts,), w, dtype=torch.long, device=self.device)
|
| 311 |
+
self.child_steps_since_fired = torch.full(
|
| 312 |
+
(cfg.num_experts, cfg.atoms_per_subspace), w, dtype=torch.long, device=self.device
|
| 313 |
+
)
|
| 314 |
+
self.n_steps_done = 0
|
| 315 |
+
|
| 316 |
+
def config_dict(self) -> dict[str, Any]:
|
| 317 |
+
return {**asdict(self.cfg), "n_latents": self.n_latents}
|
| 318 |
+
|
| 319 |
+
@torch.no_grad()
|
| 320 |
+
def _clip_global_norm(self) -> float:
|
| 321 |
+
"""optax ``clip_by_global_norm``: scale all grads by ``min(1, c / ||g||)``."""
|
| 322 |
+
grads = [p.grad for p in self.model.parameters() if p.grad is not None]
|
| 323 |
+
norm = torch.linalg.vector_norm(torch.stack([g.norm() for g in grads]))
|
| 324 |
+
coef = torch.clamp(self.cfg.norm_clip / norm, max=1.0)
|
| 325 |
+
for g in grads:
|
| 326 |
+
g.mul_(coef)
|
| 327 |
+
return float(norm)
|
| 328 |
+
|
| 329 |
+
def step(self, x: Tensor, step: int, total_steps: int) -> dict[str, float]:
|
| 330 |
+
cfg = self.cfg
|
| 331 |
+
x = x.to(self.device, torch.float32)
|
| 332 |
+
if cfg.normalize_inputs:
|
| 333 |
+
x = normalize_inputs(x, cfg.eps)
|
| 334 |
+
frac = reg_warmup_frac(step, cfg)
|
| 335 |
+
lr = lr_at(step, total_steps, cfg)
|
| 336 |
+
for group in self.opt.param_groups:
|
| 337 |
+
group["lr"] = lr
|
| 338 |
+
|
| 339 |
+
self.model.train()
|
| 340 |
+
loss, aux = self.model.loss(x, cfg.l1_penalty * frac, cfg.ortho_penalty * frac)
|
| 341 |
+
self.opt.zero_grad(set_to_none=True)
|
| 342 |
+
loss.backward()
|
| 343 |
+
self.model.project_away_grads()
|
| 344 |
+
grad_norm = self._clip_global_norm()
|
| 345 |
+
self.opt.step()
|
| 346 |
+
self.model.normalize_decoders()
|
| 347 |
+
self.n_steps_done += 1
|
| 348 |
+
|
| 349 |
+
with torch.no_grad():
|
| 350 |
+
flat = self.model.flatten(aux)
|
| 351 |
+
m = cfg.num_experts
|
| 352 |
+
active = flat > 0
|
| 353 |
+
fired_top = active[:, :m].any(0)
|
| 354 |
+
fired_child = active[:, m:].reshape(-1, m, cfg.atoms_per_subspace).any(0)
|
| 355 |
+
self.steps_since_fired += 1
|
| 356 |
+
self.steps_since_fired[fired_top] = 0
|
| 357 |
+
self.child_steps_since_fired += 1
|
| 358 |
+
self.child_steps_since_fired[fired_child] = 0
|
| 359 |
+
metrics = {
|
| 360 |
+
"loss": float(loss),
|
| 361 |
+
"l0": float(active.sum(1).float().mean()),
|
| 362 |
+
"l0_top": float(active[:, :m].sum(1).float().mean()),
|
| 363 |
+
"l0_child": float(active[:, m:].sum(1).float().mean()),
|
| 364 |
+
"mse": float(aux["reconstruction_loss"]), # sum over d, mean over batch (authors')
|
| 365 |
+
"mse_top": float(aux["reconstruction_loss_top"]),
|
| 366 |
+
"fvu": float(aux["reconstruction_loss"] / (x**2).sum(-1).mean()),
|
| 367 |
+
"l1": float(aux["total_l1_loss"]),
|
| 368 |
+
"ortho": float(aux["total_ortho_loss"]),
|
| 369 |
+
"ortho_raw": float(aux["ortho_raw"]),
|
| 370 |
+
"dead_frac": float((self.steps_since_fired >= cfg.dead_window_steps).float().mean()),
|
| 371 |
+
"dead_child_frac": float((self.child_steps_since_fired >= cfg.dead_window_steps).float().mean()),
|
| 372 |
+
"grad_norm": grad_norm,
|
| 373 |
+
"lr": lr,
|
| 374 |
+
"reg_frac": frac,
|
| 375 |
+
}
|
| 376 |
+
return metrics
|
| 377 |
+
|
| 378 |
+
def state_dict(self) -> dict[str, Any]:
|
| 379 |
+
return {
|
| 380 |
+
"config": asdict(self.cfg),
|
| 381 |
+
"model": self.model.state_dict(),
|
| 382 |
+
"opt": self.opt.state_dict(),
|
| 383 |
+
"steps_since_fired": self.steps_since_fired,
|
| 384 |
+
"child_steps_since_fired": self.child_steps_since_fired,
|
| 385 |
+
"n_steps_done": self.n_steps_done,
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
def load_state_dict(self, state: dict[str, Any]) -> None:
|
| 389 |
+
self.model.load_state_dict(state["model"])
|
| 390 |
+
self.opt.load_state_dict(state["opt"])
|
| 391 |
+
self.steps_since_fired.copy_(state["steps_since_fired"].to(self.device))
|
| 392 |
+
self.child_steps_since_fired.copy_(state["child_steps_since_fired"].to(self.device))
|
| 393 |
+
self.n_steps_done = int(state["n_steps_done"])
|
| 394 |
+
|
| 395 |
+
def export(self, out_dir: Path) -> None:
|
| 396 |
+
from safetensors.torch import save_file
|
| 397 |
+
|
| 398 |
+
out_dir = Path(out_dir)
|
| 399 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 400 |
+
arrays = self.model.arrays()
|
| 401 |
+
# two files so each stays well under the driver's 200MB copy limit
|
| 402 |
+
save_file({k: arrays[k] for k in ("top_encoder", "top_decoder")}, str(out_dir / "top.safetensors"))
|
| 403 |
+
save_file(
|
| 404 |
+
{k: arrays[k] for k in ("W_down", "W_up", "expert_encoder", "expert_decoder")},
|
| 405 |
+
str(out_dir / "experts.safetensors"),
|
| 406 |
+
)
|
| 407 |
+
(out_dir / "config.json").write_text(json.dumps({
|
| 408 |
+
"arch": "hsae",
|
| 409 |
+
"config": asdict(self.cfg),
|
| 410 |
+
"n_latents": self.n_latents,
|
| 411 |
+
"n_steps_done": self.n_steps_done,
|
| 412 |
+
"latent_layout": "ids 0..m-1 experts; id m + j*a + l = child l of expert j",
|
| 413 |
+
"weights": {
|
| 414 |
+
"top.safetensors": ["top_encoder (m,d)", "top_decoder (d,m)"],
|
| 415 |
+
"experts.safetensors": ["W_down (m,s,d)", "W_up (m,d,s)", "expert_encoder (m,a,s)", "expert_decoder (m,s,a)"],
|
| 416 |
+
},
|
| 417 |
+
}, indent=1))
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def build_trainer(d_in: int, overrides: dict, device: str = "cuda") -> HSAETrainer:
|
| 421 |
+
cfg = replace(HSAEConfig(d_in=d_in), **overrides)
|
| 422 |
+
return HSAETrainer(cfg, device=device)
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
# ---------------------------------------------------------------------------
|
| 426 |
+
# Exported model
|
| 427 |
+
# ---------------------------------------------------------------------------
|
| 428 |
+
class ExportedHSAE:
|
| 429 |
+
def __init__(self, model: HSAE) -> None:
|
| 430 |
+
self.model = model.eval()
|
| 431 |
+
self.cfg = model.cfg
|
| 432 |
+
self.n_latents = model.cfg.n_latents
|
| 433 |
+
self.W_dec = model.decoder_directions()
|
| 434 |
+
|
| 435 |
+
@torch.no_grad()
|
| 436 |
+
def encode(self, x: Tensor) -> Tensor:
|
| 437 |
+
"""Raw residual activations ``(B, d)`` -> ``(B, n_latents)`` non-negative
|
| 438 |
+
flattened activations (normalisation applied exactly as in training)."""
|
| 439 |
+
x = x.to(self.W_dec.device, torch.float32)
|
| 440 |
+
if self.cfg.normalize_inputs:
|
| 441 |
+
x = normalize_inputs(x, self.cfg.eps)
|
| 442 |
+
return self.model.flatten(self.model(x))
|
| 443 |
+
|
| 444 |
+
def native_ancestors(self) -> dict[int, set[int]]:
|
| 445 |
+
m, a = self.cfg.num_experts, self.cfg.atoms_per_subspace
|
| 446 |
+
return {m + j * a + l: {j} for j in range(m) for l in range(a)}
|
| 447 |
+
|
| 448 |
+
def latent_level(self) -> list[int]:
|
| 449 |
+
m, a = self.cfg.num_experts, self.cfg.atoms_per_subspace
|
| 450 |
+
return [0] * m + [1] * (m * a)
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def load_exported(export_dir: Path, device: str = "cuda") -> ExportedHSAE:
|
| 454 |
+
from safetensors.torch import load_file
|
| 455 |
+
|
| 456 |
+
export_dir = Path(export_dir)
|
| 457 |
+
meta = json.loads((export_dir / "config.json").read_text())
|
| 458 |
+
cfg = replace(HSAEConfig(**meta["config"]), autocast_bf16=False) # evaluate in fp32
|
| 459 |
+
model = HSAE(cfg, device=device)
|
| 460 |
+
arrays = {**load_file(str(export_dir / "top.safetensors")), **load_file(str(export_dir / "experts.safetensors"))}
|
| 461 |
+
model.load_arrays(arrays)
|
| 462 |
+
return ExportedHSAE(model)
|
gemma-2-2b/layer-12/hsae/hsae_32k_k15_seed0/summary.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run_name": "hsae_32k_k15_seed0",
|
| 3 |
+
"arch": "hsae",
|
| 4 |
+
"steps": 122070,
|
| 5 |
+
"tokens": 499998720,
|
| 6 |
+
"seconds": 31551.99144744873,
|
| 7 |
+
"export": "/vol/train/hsae_32k_k15_seed0/export",
|
| 8 |
+
"group": [
|
| 9 |
+
"hsae_32k_k15_seed0",
|
| 10 |
+
"tree_sae_32k_k15_seed0",
|
| 11 |
+
"hsae_forest_32k_k15_seed0"
|
| 12 |
+
]
|
| 13 |
+
}
|