Upload folder using huggingface_hub
Browse files- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/DECISIONS.md +178 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/README.md +5 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/config.json +56 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/export/config.json +100 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/export/hierarchy.json +0 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/export/weights.safetensors +3 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/interface.py +59 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/log.jsonl +0 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/model.py +820 -0
- gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/summary.json +8 -0
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/DECISIONS.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HSAE-Forest (Gemma-2-2b scale) — interpretation and deviation record
|
| 2 |
+
|
| 3 |
+
Module: `benchmark/methods/train/hsae_forest.py`. Paper: Luo et al., "From Atoms
|
| 4 |
+
to Trees", arXiv:2602.11881 (no official code). Starting point: Kirill's
|
| 5 |
+
clean-room synthetic implementation
|
| 6 |
+
`synth_sae_bench_baseline/experiments/synthetic/synthsaebench/hsae_forest/{model.py,LEDGER.md}`.
|
| 7 |
+
"Ledger" below = that LEDGER.md. Every item is a paper-exact port unless marked
|
| 8 |
+
**kept (ledger choice)** or **deviation**.
|
| 9 |
+
|
| 10 |
+
## Architecture (paper Sec. 3)
|
| 11 |
+
- 3 levels, widths (8192, 16384, 32768), d_in 2304; finest level = twin's 32k. Flat
|
| 12 |
+
index space = level 0, then level 1, then level 2. (Paper used 4 levels
|
| 13 |
+
2048..16384 on Gemma-2-2b; width choice is ours, ledger M2 analogue.)
|
| 14 |
+
- Each level is an independent two-layer JumpReLU SAE with per-feature threshold,
|
| 15 |
+
`b_dec` subtracted before encoding (ledger "harness convention"; paper omits
|
| 16 |
+
`b_dec`).
|
| 17 |
+
- Decoder rows renormalised to unit norm after every optimizer step (paper Sec. 3.3).
|
| 18 |
+
- Init: decoder rows kaiming-uniform then rescaled to norm 0.1, encoder = decoder^T,
|
| 19 |
+
biases 0, threshold 0.01 (**kept (ledger P4)**, sae-lens conventions; the paper
|
| 20 |
+
does not state init).
|
| 21 |
+
|
| 22 |
+
## Losses (paper Sec. 3.1)
|
| 23 |
+
- `L_SAE,l = ||recon_l - x||^2` (sum over d, mean over batch) `+ lambda_l * L0_l`
|
| 24 |
+
with the sae-lens Step STE (rectangular kernel, bandwidth 0.05) for the L0 term
|
| 25 |
+
and the JumpReLU STE for the threshold gradient of the MSE term (**kept (ledger P4)**).
|
| 26 |
+
- Parent-child loss `sum_i ||f_i - sum_{j in C_i} f_j||^2` over parents with a
|
| 27 |
+
non-empty child set, computed on CLEAN (unperturbed) features, `rho = 0.01`
|
| 28 |
+
(paper Sec. 3.2 value). Evaluated via the exact algebraic expansion of the
|
| 29 |
+
ledger (bit-equivalent to the naive per-parent form; tested against a naive
|
| 30 |
+
reference in `tests/test_train_hsae_forest.py`, value and gradients).
|
| 31 |
+
- **Engineering change vs Kirill**: the sibling cross term
|
| 32 |
+
`2 sum_{j<j'} a_j a_j' (d_j . d_j')` is a custom autograd function evaluated in
|
| 33 |
+
pair chunks (`pc_pair_chunk`, default 32768) instead of materialising
|
| 34 |
+
`(batch, n_pairs)` tensors under autograd. Same math; peak memory is
|
| 35 |
+
`O(batch * chunk)` even when a hub parent collects thousands of children.
|
| 36 |
+
The sibling-pair enumeration is vectorised (no Python loop over parents).
|
| 37 |
+
|
| 38 |
+
## Perturbation (paper Sec. 3.2)
|
| 39 |
+
- `r = 0.05`. **Kept (ledger M1)**: substitute WITH probability r
|
| 40 |
+
(`z ~ Bern(r)`, parent contribution replaced by its children's sum when z = 1);
|
| 41 |
+
the printed equation has the roles of z and 1-z swapped (erratum per ledger).
|
| 42 |
+
- One Bernoulli draw per parent feature per step, shared across the batch
|
| 43 |
+
("sampled independently across features at each training step"); only parents
|
| 44 |
+
with a non-empty child set; never the last level; applied only inside the
|
| 45 |
+
reconstruction MSE of that level (ledger P8/P10). Dedicated RNG (`seed + 1`),
|
| 46 |
+
state checkpointed.
|
| 47 |
+
- Engineering: only the substituted parents' / their children's columns are
|
| 48 |
+
gathered for the two extra matmuls (Kirill masked the full-width matrices).
|
| 49 |
+
|
| 50 |
+
## Hierarchy update (paper Sec. 3.3, App. F.1)
|
| 51 |
+
- Every 5000 gradient steps, no-grad: each child picks the parent with the highest
|
| 52 |
+
encoder-vector cosine similarity (paper default metric); children whose best
|
| 53 |
+
similarity lies in the bottom 20% quantile of that boundary's best scores are
|
| 54 |
+
left unassigned (-1). Adjacent levels only; single parent per child.
|
| 55 |
+
- All children unassigned until the first update (ledger P6); one extra update on
|
| 56 |
+
the final weights, performed inside `export()` (ledger P6). Dead children are
|
| 57 |
+
assigned like any other (paper does not special-case them).
|
| 58 |
+
- Similarity computed in child chunks of 8192 (memory), fp32.
|
| 59 |
+
|
| 60 |
+
## Sparsity controller (paper App. A.2)
|
| 61 |
+
- Per level target L0 = 150 (twin's k). EMA of batch L0 with momentum 0.999;
|
| 62 |
+
desired change `-eta (L0_ema - target)` with `eta = 0.001`; lambda is scaled by
|
| 63 |
+
`exp(kappa * (actual - desired) / target)` clamped to [0.98, 1.02] per step,
|
| 64 |
+
`kappa = 1`, `lambda_init = 0.05`, lambda clamped to [1e-8, 1e3]
|
| 65 |
+
(**kept (ledger P3)**: the paper gives eta and the EMA momentum but not the
|
| 66 |
+
update magnitude; direction matches App. A.2: faster-than-target reduction ->
|
| 67 |
+
lambda down, denser than expected -> lambda up).
|
| 68 |
+
- Batch L0 is computed in fp32 from `acts > 0` (not from the STE surrogate).
|
| 69 |
+
|
| 70 |
+
## Dead latents — **deviation** from the paper's ghost gradients (Sec. 3.3)
|
| 71 |
+
- A latent is dead if it has not fired (act > 0) in the last 10M tokens
|
| 72 |
+
(`tokens_since_fired` buffer over the flat index space, checkpointed).
|
| 73 |
+
- Aux-k loss (Gao et al. 2024 / sae-lens TopK convention): the top `aux_k = d_in // 2 = 1152`
|
| 74 |
+
dead latents per token, ranked by ReLU'd pre-activation, reconstruct the
|
| 75 |
+
detached residual `x - recon_l` of their level's clean reconstruction through
|
| 76 |
+
`W_dec` (no `b_dec`); loss `(aux_recon - residual)^2` summed over d, mean over
|
| 77 |
+
batch, scaled by `min(n_dead / aux_k, 1)`, coefficient `aux_coeff = 1/32`.
|
| 78 |
+
Rationale: ghost grads are under-specified in the paper; aux-k is the
|
| 79 |
+
standard modern replacement and has the same purpose (give dead latents a
|
| 80 |
+
reconstruction gradient). Kirill skipped dead handling (rare on synthetic data).
|
| 81 |
+
|
| 82 |
+
## Input scaling (paper App. A.1)
|
| 83 |
+
- Fixed global scalar `alpha = sqrt(d_in) / E||x||_2` ("RMS norm expectation of
|
| 84 |
+
sqrt(d)" read as `E||alpha x||_2 = sqrt(d)`, ledger Amendment 1), estimated as
|
| 85 |
+
a running mean over the first `scale_estimate_tokens = 65536` raw tokens (16
|
| 86 |
+
batches; the provisional value is used for those steps), then frozen and
|
| 87 |
+
stored. Applied inside `step` and inside the exported `encode` (which takes
|
| 88 |
+
RAW activations). Exported `W_dec` is divided by alpha so it is in raw units.
|
| 89 |
+
|
| 90 |
+
## Optimisation (paper App. A.1, ledger P2)
|
| 91 |
+
- Adam betas (0, 0.995) (paper), peak lr 3e-4 (**kept (ledger P2)**; paper does
|
| 92 |
+
not state it), cosine schedule with linear warm-up over the first 10% of steps
|
| 93 |
+
and decay to zero (paper), computed statelessly from `(step, total_steps)`;
|
| 94 |
+
global grad-norm clip 1.0 (ledger). Batch 4096 tokens (driver; paper used 1024).
|
| 95 |
+
- Compute: forward/backward under `torch.autocast("cuda", bf16)`; fp32 master
|
| 96 |
+
weights and Adam state; pre-activations, JumpReLU/Step STEs, MSE, PC loss,
|
| 97 |
+
L0 statistics and the controller in fp32 (the custom sibling-cross function
|
| 98 |
+
disables autocast and casts its inputs to fp32).
|
| 99 |
+
|
| 100 |
+
## Checkpoint / export
|
| 101 |
+
- `state_dict`: model params + buffers (lambdas, L0 EMA, parent indices,
|
| 102 |
+
dead-latent counters, scale stats and frozen flag, hierarchy-update count),
|
| 103 |
+
Adam state, perturbation RNG state, last step, config. `load_state_dict`
|
| 104 |
+
deep-copies the optimizer state (PyTorch aliases matching tensors otherwise).
|
| 105 |
+
- Export: `weights.safetensors` (per-level W_enc/W_dec/b_enc/b_dec/threshold,
|
| 106 |
+
parent indices, dead counters), `hierarchy.json` (parent index per child per
|
| 107 |
+
boundary, -1 unassigned), `config.json` (config, scale, offsets, final
|
| 108 |
+
hierarchy stats, lambdas, L0 EMA, dead fractions). The weights file (~1 GB)
|
| 109 |
+
exceeds the driver's 200 MB copy-back limit and stays on the Modal volume.
|
| 110 |
+
|
| 111 |
+
## Known caveats
|
| 112 |
+
- JumpReLU thresholds are `exp(log_threshold)` with lr 3e-4, so thresholds can
|
| 113 |
+
grow at most ~e^(lr) per step; leaving the dense regime takes O(10k) steps
|
| 114 |
+
(ledger Amendment 2 observed the same on synthetic data). With 122k steps for
|
| 115 |
+
500M tokens this is expected to be fine, but the L0 trajectory should be
|
| 116 |
+
checked early in the full run.
|
| 117 |
+
- The exact PC loss couples parents to dead children: a parent whose children
|
| 118 |
+
are all dead is pushed toward zero activation by `rho * a_i^2`. Paper-exact;
|
| 119 |
+
not mitigated.
|
| 120 |
+
|
| 121 |
+
## Smoke run (smoke_hsae_forest, A100-80GB, 4M tokens = 976 steps, overrides
|
| 122 |
+
## hierarchy_update_every=300, dead_tokens=1M to exercise every code path)
|
| 123 |
+
- 11.0k tokens/s steady state (incl. Gemma forward); 24.2 GB peak CUDA memory.
|
| 124 |
+
- Final per-level batch L0 6158 / 8727 / 10093, lambdas railed at the 1e3 clamp,
|
| 125 |
+
mean threshold (level 2) 0.0109 -> 0.0134: the dense regime was not left in
|
| 126 |
+
976 steps (LR schedule compressed to the run length), consistent with the
|
| 127 |
+
Adam-rate-limited log-threshold caveat above. `threshold_lr_mult` (default
|
| 128 |
+
1.0) is provided as an override should the full run's early checkpoints show
|
| 129 |
+
the same stall.
|
| 130 |
+
|
| 131 |
+
## Gemma 500M run: threshold_lr_mult = 20 (decided 2026-09-14)
|
| 132 |
+
|
| 133 |
+
The first 500M launch with the ledger's threshold parametrisation stayed
|
| 134 |
+
dense: at step 8000 (inside the 10% LR warm-up) mean thresholds had only
|
| 135 |
+
moved 0.010 -> 0.022, total L0 was ~19.5k, all three lambdas were railed at
|
| 136 |
+
their 1e3 cap and MSE was rising (36 -> 126) because the L0 penalty
|
| 137 |
+
dominated. Adam bounds the log-threshold step to ~lr per step, so this is a
|
| 138 |
+
parametrisation speed limit, not a controller bug. The run was killed and
|
| 139 |
+
relaunched with `threshold_lr_mult: 20`; everything else unchanged.
|
| 140 |
+
|
| 141 |
+
## PC loss: dense path for hub parents (implementation change, 2026-09-15)
|
| 142 |
+
|
| 143 |
+
Symptom in the 500M run (`forest_v3`): each hierarchy update assigned more
|
| 144 |
+
children to a few hub parents, and the sibling cross term of the PC-loss
|
| 145 |
+
expansion (`_SiblingCross`, cost `O(batch * n_sibling_pairs)`) grew with the
|
| 146 |
+
square of the hub sizes: step time went from 0.09 s to ~4 s by step 50k.
|
| 147 |
+
|
| 148 |
+
Change (pure implementation change: the loss `sum_i ||f_i - sum_{j in C_i} f_j||^2`,
|
| 149 |
+
its value and all its gradients are unchanged; `state_dict`/checkpoint/export
|
| 150 |
+
formats untouched, runs resume as before):
|
| 151 |
+
- Parents with at least `pc_dense_min_children` (default 8) children are
|
| 152 |
+
evaluated per parent as `||[-a_i, a_{C_i}] @ [d_i; D_{C_i}]||_F^2` with a
|
| 153 |
+
padded batched matmul (`_DenseParentChild`, parents grouped by child count so
|
| 154 |
+
padding wastes < 2x, processed in memory-bounded chunks, products recomputed
|
| 155 |
+
in the backward). Cost is `O(batch * |C_i| * d)` per hub, i.e. linear in the
|
| 156 |
+
number of children (bounded overall by one decoder matmul), independent of
|
| 157 |
+
sibling pairs.
|
| 158 |
+
- Parents with fewer children stay on the algebraic expansion, whose pair count
|
| 159 |
+
is now bounded by `(pc_dense_min_children - 1) / 2 * n_assigned`.
|
| 160 |
+
- Why not a purely sparse (active-latents-only) formulation: the exact gradient
|
| 161 |
+
of the PC loss w.r.t. an INACTIVE child activation is `-2 (f_i - sum_j f_j) . d_j`,
|
| 162 |
+
non-zero whenever its parent or a sibling is active, and the JumpReLU
|
| 163 |
+
threshold STE consumes it (rectangle window around the threshold). A
|
| 164 |
+
gather-only implementation would silently drop that threshold gradient; the
|
| 165 |
+
dense per-hub form keeps it exact while still being linear in hub size.
|
| 166 |
+
- The perturbation path (`training_losses`) was already linear: it gathers the
|
| 167 |
+
substituted parents' and their children's columns for two plain matmuls.
|
| 168 |
+
Unchanged.
|
| 169 |
+
- Verified in `tests/test_train_hsae_forest.py` against the original code path
|
| 170 |
+
(kept as `ForestSAE._pc_loss_reference`): value and gradients w.r.t. both
|
| 171 |
+
activation tensors and both decoders agree to < 1e-12 in float64 for hub
|
| 172 |
+
thresholds 1 (all dense) .. 1000 (all pairs), plus the whole training
|
| 173 |
+
objective / parameter gradients with the perturbation and aux losses active.
|
| 174 |
+
- CPU micro-benchmark (`d_in=32`, boundary with 3 hubs x 200 children + ~250
|
| 175 |
+
small parents, batch 64, fp32, forward+backward, 2 threads): old 176 ms/iter
|
| 176 |
+
(59.9k sibling pairs) vs new 6.5 ms/iter (27x). Scaling with hub size (3 hubs
|
| 177 |
+
x 50/100/200/400 children): old 7.5 / 26 / 170 / 377 ms, new 2.8 / 2.6 / 3.4 /
|
| 178 |
+
3.7 ms -- quadratic vs linear in hub size.
|
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# hsae_forest_32k_seed0_v3
|
| 2 |
+
|
| 3 |
+
Architecture `hsae_forest` 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_forest/hsae_forest_32k_seed0_v3/config.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"arch": "hsae_forest",
|
| 3 |
+
"run_name": "hsae_forest_32k_seed0_v3",
|
| 4 |
+
"n_tokens": 500000000,
|
| 5 |
+
"batch_tokens": 4096,
|
| 6 |
+
"total_steps": 122070,
|
| 7 |
+
"overrides": {
|
| 8 |
+
"threshold_lr_mult": 5
|
| 9 |
+
},
|
| 10 |
+
"seed": 0,
|
| 11 |
+
"trainer_config": {
|
| 12 |
+
"d_in": 2304,
|
| 13 |
+
"widths": [
|
| 14 |
+
8192,
|
| 15 |
+
16384,
|
| 16 |
+
32768
|
| 17 |
+
],
|
| 18 |
+
"target_l0": 150.0,
|
| 19 |
+
"rho": 0.01,
|
| 20 |
+
"perturb_r": 0.05,
|
| 21 |
+
"hierarchy_update_every": 5000,
|
| 22 |
+
"exclusion_quantile": 0.2,
|
| 23 |
+
"lambda_init": 0.05,
|
| 24 |
+
"controller_eta": 0.001,
|
| 25 |
+
"l0_ema_momentum": 0.999,
|
| 26 |
+
"controller_kappa": 1.0,
|
| 27 |
+
"controller_clamp": [
|
| 28 |
+
0.98,
|
| 29 |
+
1.02
|
| 30 |
+
],
|
| 31 |
+
"lambda_bounds": [
|
| 32 |
+
1e-08,
|
| 33 |
+
1000.0
|
| 34 |
+
],
|
| 35 |
+
"jumprelu_bandwidth": 0.05,
|
| 36 |
+
"jumprelu_init_threshold": 0.01,
|
| 37 |
+
"decoder_init_norm": 0.1,
|
| 38 |
+
"lr": 0.0003,
|
| 39 |
+
"adam_betas": [
|
| 40 |
+
0.0,
|
| 41 |
+
0.995
|
| 42 |
+
],
|
| 43 |
+
"warmup_frac": 0.1,
|
| 44 |
+
"grad_clip": 1.0,
|
| 45 |
+
"threshold_lr_mult": 5,
|
| 46 |
+
"dead_tokens": 10000000,
|
| 47 |
+
"aux_coeff": 0.03125,
|
| 48 |
+
"aux_k": 1152,
|
| 49 |
+
"scale_estimate_tokens": 65536,
|
| 50 |
+
"pc_pair_chunk": 32768,
|
| 51 |
+
"pc_dense_min_children": 8,
|
| 52 |
+
"pc_dense_chunk_elems": 268435456,
|
| 53 |
+
"autocast": true,
|
| 54 |
+
"seed": 0
|
| 55 |
+
}
|
| 56 |
+
}
|
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/export/config.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"arch": "hsae_forest",
|
| 3 |
+
"paper": "Luo et al., arXiv:2602.11881",
|
| 4 |
+
"config": {
|
| 5 |
+
"d_in": 2304,
|
| 6 |
+
"widths": [
|
| 7 |
+
8192,
|
| 8 |
+
16384,
|
| 9 |
+
32768
|
| 10 |
+
],
|
| 11 |
+
"target_l0": 150.0,
|
| 12 |
+
"rho": 0.01,
|
| 13 |
+
"perturb_r": 0.05,
|
| 14 |
+
"hierarchy_update_every": 5000,
|
| 15 |
+
"exclusion_quantile": 0.2,
|
| 16 |
+
"lambda_init": 0.05,
|
| 17 |
+
"controller_eta": 0.001,
|
| 18 |
+
"l0_ema_momentum": 0.999,
|
| 19 |
+
"controller_kappa": 1.0,
|
| 20 |
+
"controller_clamp": [
|
| 21 |
+
0.98,
|
| 22 |
+
1.02
|
| 23 |
+
],
|
| 24 |
+
"lambda_bounds": [
|
| 25 |
+
1e-08,
|
| 26 |
+
1000.0
|
| 27 |
+
],
|
| 28 |
+
"jumprelu_bandwidth": 0.05,
|
| 29 |
+
"jumprelu_init_threshold": 0.01,
|
| 30 |
+
"decoder_init_norm": 0.1,
|
| 31 |
+
"lr": 0.0003,
|
| 32 |
+
"adam_betas": [
|
| 33 |
+
0.0,
|
| 34 |
+
0.995
|
| 35 |
+
],
|
| 36 |
+
"warmup_frac": 0.1,
|
| 37 |
+
"grad_clip": 1.0,
|
| 38 |
+
"threshold_lr_mult": 5,
|
| 39 |
+
"dead_tokens": 10000000,
|
| 40 |
+
"aux_coeff": 0.03125,
|
| 41 |
+
"aux_k": 1152,
|
| 42 |
+
"scale_estimate_tokens": 65536,
|
| 43 |
+
"pc_pair_chunk": 32768,
|
| 44 |
+
"pc_dense_min_children": 8,
|
| 45 |
+
"pc_dense_chunk_elems": 268435456,
|
| 46 |
+
"autocast": true,
|
| 47 |
+
"seed": 0
|
| 48 |
+
},
|
| 49 |
+
"n_latents": 57344,
|
| 50 |
+
"level_offsets": [
|
| 51 |
+
0,
|
| 52 |
+
8192,
|
| 53 |
+
24576,
|
| 54 |
+
57344
|
| 55 |
+
],
|
| 56 |
+
"scale": 0.3360775411128998,
|
| 57 |
+
"scale_frozen": true,
|
| 58 |
+
"scale_estimate_tokens_seen": 65536,
|
| 59 |
+
"last_step": 122069,
|
| 60 |
+
"n_hierarchy_updates": 25,
|
| 61 |
+
"final_hierarchy_stats": {
|
| 62 |
+
"boundary_0": {
|
| 63 |
+
"n_assigned": 13107,
|
| 64 |
+
"n_children": 16384,
|
| 65 |
+
"cutoff": 0.21655727922916412,
|
| 66 |
+
"n_changed_vs_previous": 5,
|
| 67 |
+
"assigned_best_cos_mean": 0.6290030479431152,
|
| 68 |
+
"parents_with_children": 7946,
|
| 69 |
+
"children_per_parent_max": 761,
|
| 70 |
+
"n_sibling_pairs": 295359
|
| 71 |
+
},
|
| 72 |
+
"boundary_1": {
|
| 73 |
+
"n_assigned": 26214,
|
| 74 |
+
"n_children": 32768,
|
| 75 |
+
"cutoff": 0.24024911224842072,
|
| 76 |
+
"n_changed_vs_previous": 3,
|
| 77 |
+
"assigned_best_cos_mean": 0.6191921234130859,
|
| 78 |
+
"parents_with_children": 15030,
|
| 79 |
+
"children_per_parent_max": 3465,
|
| 80 |
+
"n_sibling_pairs": 6011919
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
"final_lambdas": [
|
| 84 |
+
0.6942468881607056,
|
| 85 |
+
0.6768897175788879,
|
| 86 |
+
0.6708834171295166
|
| 87 |
+
],
|
| 88 |
+
"final_l0_ema": [
|
| 89 |
+
150.22886657714844,
|
| 90 |
+
150.12648010253906,
|
| 91 |
+
150.096923828125
|
| 92 |
+
],
|
| 93 |
+
"frac_dead_per_level": [
|
| 94 |
+
0.0380859375,
|
| 95 |
+
0.06402587890625,
|
| 96 |
+
0.085540771484375
|
| 97 |
+
],
|
| 98 |
+
"weights_file": "weights.safetensors",
|
| 99 |
+
"hierarchy_file": "hierarchy.json"
|
| 100 |
+
}
|
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/export/hierarchy.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/export/weights.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9d38e9a1b6a62196c801fefabde6d6add8701ef2c9a8561b195e06bd59d1190c
|
| 3 |
+
size 1058304520
|
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/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_forest/hsae_forest_32k_seed0_v3/log.jsonl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/model.py
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HSAE-Forest trainer for Gemma-2-2b layer-12 activations (``driver.py`` arch ``hsae_forest``).
|
| 2 |
+
|
| 3 |
+
Luo et al., "From Atoms to Trees" (arXiv:2602.11881): ``L`` parallel,
|
| 4 |
+
weight-independent JumpReLU SAEs of increasing width over the same input,
|
| 5 |
+
coupled by (i) the parent-child loss ``rho * sum_i ||f_i - sum_{j in C_i} f_j||^2``,
|
| 6 |
+
(ii) train-time stochastic substitution of a parent's contribution by its
|
| 7 |
+
children's (perturbation rate ``r``), and (iii) a periodic no-grad hierarchy
|
| 8 |
+
update that re-assigns every child to its encoder-cosine argmax parent, with
|
| 9 |
+
the bottom 20% of best-similarities left unassigned. Per-level lambda is set
|
| 10 |
+
by the paper's velocity-based sparsity controller (Appendix A.2).
|
| 11 |
+
|
| 12 |
+
Ported from Kirill's synthetic-scale clean-room implementation
|
| 13 |
+
(``synth_sae_bench_baseline/.../hsae_forest/model.py``); every interpretation
|
| 14 |
+
and deviation is listed in ``HSAE_FOREST_DECISIONS.md`` next to this file.
|
| 15 |
+
|
| 16 |
+
Flattened latent index space: level 0 latents, then level 1, then level 2.
|
| 17 |
+
``encode`` of the export accepts RAW residual activations (the frozen input
|
| 18 |
+
scale is applied inside).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import copy
|
| 24 |
+
import json
|
| 25 |
+
import math
|
| 26 |
+
from dataclasses import asdict, dataclass, fields
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Any
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
from torch import nn
|
| 32 |
+
|
| 33 |
+
WEIGHTS_FILE = "weights.safetensors"
|
| 34 |
+
CONFIG_FILE = "config.json"
|
| 35 |
+
HIERARCHY_FILE = "hierarchy.json"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# --------------------------------------------------------------------------- config
|
| 39 |
+
@dataclass
|
| 40 |
+
class ForestConfig:
|
| 41 |
+
d_in: int = 2304
|
| 42 |
+
widths: tuple[int, ...] = (8192, 16384, 32768)
|
| 43 |
+
target_l0: float = 150.0
|
| 44 |
+
# paper mechanisms (Sec. 3.1-3.3)
|
| 45 |
+
rho: float = 0.01
|
| 46 |
+
perturb_r: float = 0.05
|
| 47 |
+
hierarchy_update_every: int = 5000
|
| 48 |
+
exclusion_quantile: float = 0.20
|
| 49 |
+
# sparsity controller (paper App. A.2: eta, EMA; magnitudes per ledger P3)
|
| 50 |
+
lambda_init: float = 0.05
|
| 51 |
+
controller_eta: float = 0.001
|
| 52 |
+
l0_ema_momentum: float = 0.999
|
| 53 |
+
controller_kappa: float = 1.0
|
| 54 |
+
controller_clamp: tuple[float, float] = (0.98, 1.02)
|
| 55 |
+
lambda_bounds: tuple[float, float] = (1e-8, 1e3)
|
| 56 |
+
# JumpReLU internals (ledger P4: sae-lens harness conventions)
|
| 57 |
+
jumprelu_bandwidth: float = 0.05
|
| 58 |
+
jumprelu_init_threshold: float = 0.01
|
| 59 |
+
decoder_init_norm: float = 0.1
|
| 60 |
+
# optimisation (paper App. A.1 + ledger P2)
|
| 61 |
+
lr: float = 3e-4
|
| 62 |
+
adam_betas: tuple[float, float] = (0.0, 0.995)
|
| 63 |
+
warmup_frac: float = 0.1
|
| 64 |
+
grad_clip: float = 1.0
|
| 65 |
+
threshold_lr_mult: float = 1.0 # lr multiplier for log_threshold params (1.0 = ledger P4 behaviour)
|
| 66 |
+
# dead-latent handling (aux-k loss; deviation from the paper's ghost grads)
|
| 67 |
+
dead_tokens: int = 10_000_000
|
| 68 |
+
aux_coeff: float = 1.0 / 32.0
|
| 69 |
+
aux_k: int = 0 # 0 -> d_in // 2
|
| 70 |
+
# input scaling: alpha with E||alpha x||_2 = sqrt(d_in), from the first batches
|
| 71 |
+
scale_estimate_tokens: int = 65_536
|
| 72 |
+
# engineering
|
| 73 |
+
pc_pair_chunk: int = 32_768
|
| 74 |
+
# parents with >= this many children take the dense per-parent path of the
|
| 75 |
+
# PC loss (cost linear in #children); smaller parents use the sibling-pair
|
| 76 |
+
# expansion. Same loss either way; see HSAE_FOREST_DECISIONS.md.
|
| 77 |
+
pc_dense_min_children: int = 8
|
| 78 |
+
pc_dense_chunk_elems: int = 1 << 28 # fp32 elements per dense-path work chunk (~1 GB)
|
| 79 |
+
autocast: bool = True
|
| 80 |
+
seed: int = 0
|
| 81 |
+
|
| 82 |
+
def __post_init__(self) -> None:
|
| 83 |
+
self.widths = tuple(int(w) for w in self.widths)
|
| 84 |
+
self.controller_clamp = tuple(float(v) for v in self.controller_clamp)
|
| 85 |
+
self.lambda_bounds = tuple(float(v) for v in self.lambda_bounds)
|
| 86 |
+
self.adam_betas = tuple(float(v) for v in self.adam_betas)
|
| 87 |
+
if self.aux_k <= 0:
|
| 88 |
+
self.aux_k = self.d_in // 2
|
| 89 |
+
|
| 90 |
+
def to_dict(self) -> dict[str, Any]:
|
| 91 |
+
return asdict(self)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _make_config(d_in: int, overrides: dict[str, Any]) -> ForestConfig:
|
| 95 |
+
names = {f.name for f in fields(ForestConfig)}
|
| 96 |
+
unknown = set(overrides) - names
|
| 97 |
+
if unknown:
|
| 98 |
+
raise KeyError(f"unknown hsae_forest overrides: {sorted(unknown)}")
|
| 99 |
+
return ForestConfig(d_in=d_in, **overrides)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# --------------------------------------------------------------------------- JumpReLU STE
|
| 103 |
+
# Identical to sae-lens ``JumpReLU`` / ``Step`` (rectangular kernel), inlined to
|
| 104 |
+
# avoid importing sae-lens inside the training container.
|
| 105 |
+
def _rectangle(x: torch.Tensor) -> torch.Tensor:
|
| 106 |
+
return ((x > -0.5) & (x < 0.5)).to(x)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class _JumpReLU(torch.autograd.Function):
|
| 110 |
+
@staticmethod
|
| 111 |
+
def forward(ctx: Any, x: torch.Tensor, threshold: torch.Tensor, bandwidth: float) -> torch.Tensor:
|
| 112 |
+
ctx.save_for_backward(x, threshold)
|
| 113 |
+
ctx.bandwidth = bandwidth
|
| 114 |
+
return x * (x > threshold)
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
def backward(ctx: Any, grad_output: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, None]:
|
| 118 |
+
x, threshold = ctx.saved_tensors
|
| 119 |
+
bw = ctx.bandwidth
|
| 120 |
+
x_grad = (x > threshold) * grad_output
|
| 121 |
+
threshold_grad = torch.sum(-(threshold / bw) * _rectangle((x - threshold) / bw) * grad_output, dim=0)
|
| 122 |
+
return x_grad, threshold_grad, None
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class _Step(torch.autograd.Function):
|
| 126 |
+
@staticmethod
|
| 127 |
+
def forward(ctx: Any, x: torch.Tensor, threshold: torch.Tensor, bandwidth: float) -> torch.Tensor:
|
| 128 |
+
ctx.save_for_backward(x, threshold)
|
| 129 |
+
ctx.bandwidth = bandwidth
|
| 130 |
+
return (x > threshold).to(x)
|
| 131 |
+
|
| 132 |
+
@staticmethod
|
| 133 |
+
def backward(ctx: Any, grad_output: torch.Tensor) -> tuple[None, torch.Tensor, None]:
|
| 134 |
+
x, threshold = ctx.saved_tensors
|
| 135 |
+
bw = ctx.bandwidth
|
| 136 |
+
threshold_grad = torch.sum(-(1.0 / bw) * _rectangle((x - threshold) / bw) * grad_output, dim=0)
|
| 137 |
+
return None, threshold_grad, None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# --------------------------------------------------------------------------- sibling cross term
|
| 141 |
+
class _SiblingCross(torch.autograd.Function):
|
| 142 |
+
"""Per-sample ``sum_{(j1,j2) sibling pairs} a[:,j1] a[:,j2] (d_j1 . d_j2)``.
|
| 143 |
+
|
| 144 |
+
Same value/gradient as materialising ``(B, n_pairs)`` tensors under
|
| 145 |
+
autograd (Kirill's version), but evaluated in pair chunks so peak memory is
|
| 146 |
+
``O(B * chunk)`` regardless of how many children a hub parent collects.
|
| 147 |
+
"""
|
| 148 |
+
|
| 149 |
+
@staticmethod
|
| 150 |
+
@torch.amp.custom_fwd(device_type="cuda", cast_inputs=torch.float32)
|
| 151 |
+
def forward(ctx: Any, acts: torch.Tensor, w_dec: torch.Tensor, j1: torch.Tensor, j2: torch.Tensor, chunk: int) -> torch.Tensor:
|
| 152 |
+
out = acts.new_zeros(acts.shape[0])
|
| 153 |
+
for s in range(0, j1.numel(), chunk):
|
| 154 |
+
i1, i2 = j1[s : s + chunk], j2[s : s + chunk]
|
| 155 |
+
v = (w_dec[i1] * w_dec[i2]).sum(-1)
|
| 156 |
+
out += (acts[:, i1] * acts[:, i2] * v).sum(-1)
|
| 157 |
+
ctx.save_for_backward(acts, w_dec, j1, j2)
|
| 158 |
+
ctx.chunk = chunk
|
| 159 |
+
return out
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
@torch.amp.custom_bwd(device_type="cuda")
|
| 163 |
+
def backward(ctx: Any, g: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, None, None, None]:
|
| 164 |
+
acts, w_dec, j1, j2 = ctx.saved_tensors
|
| 165 |
+
chunk = ctx.chunk
|
| 166 |
+
g_acts = torch.zeros_like(acts)
|
| 167 |
+
g_w = torch.zeros_like(w_dec)
|
| 168 |
+
gcol = g[:, None].to(acts.dtype)
|
| 169 |
+
for s in range(0, j1.numel(), chunk):
|
| 170 |
+
i1, i2 = j1[s : s + chunk], j2[s : s + chunk]
|
| 171 |
+
v = (w_dec[i1] * w_dec[i2]).sum(-1)
|
| 172 |
+
a1, a2 = acts[:, i1], acts[:, i2]
|
| 173 |
+
g_acts.index_add_(1, i1, gcol * a2 * v)
|
| 174 |
+
g_acts.index_add_(1, i2, gcol * a1 * v)
|
| 175 |
+
q = (gcol * a1 * a2).sum(0)
|
| 176 |
+
g_w.index_add_(0, i1, q[:, None] * w_dec[i2])
|
| 177 |
+
g_w.index_add_(0, i2, q[:, None] * w_dec[i1])
|
| 178 |
+
return g_acts, g_w, None, None, None
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# --------------------------------------------------------------------------- dense hub-parent term
|
| 182 |
+
def _dense_gather(
|
| 183 |
+
a_p: torch.Tensor, a_c: torch.Tensor, d_p: torch.Tensor, d_c: torch.Tensor,
|
| 184 |
+
parents: torch.Tensor, idx: torch.Tensor, valid: torch.Tensor,
|
| 185 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 186 |
+
"""Per parent ``p`` in ``parents``: ``A_p = [-a_p, a_{C_p}]`` (B, S+1) and
|
| 187 |
+
``D_p = [d_p; d_{C_p}]`` (S+1, d), children padded to ``S`` with zero
|
| 188 |
+
decoder rows (``idx`` already clamped into range, ``valid`` marks real children)."""
|
| 189 |
+
B = a_p.shape[0]
|
| 190 |
+
h, S = idx.shape
|
| 191 |
+
a_kids = a_c[:, idx.reshape(-1)].reshape(B, h, S).transpose(0, 1) # (h, B, S)
|
| 192 |
+
a_full = torch.cat([-a_p[:, parents].T[:, :, None], a_kids], dim=2) # (h, B, S+1)
|
| 193 |
+
d_kids = d_c[idx] * valid[:, :, None].to(d_c.dtype) # (h, S, d)
|
| 194 |
+
d_full = torch.cat([d_p[parents][:, None, :], d_kids], dim=1) # (h, S+1, d)
|
| 195 |
+
return a_full, d_full
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class _DenseParentChild(torch.autograd.Function):
|
| 199 |
+
"""``sum_t sum_{p in parents} ||a_tp d_p - sum_{j in C_p} a_tj d_j||^2`` evaluated
|
| 200 |
+
directly per parent as ``||A_p @ D_p||_F^2`` with a padded batched matmul.
|
| 201 |
+
|
| 202 |
+
Cost is ``O(B * (|C_p| + 1) * d)`` per parent -- linear in the number of
|
| 203 |
+
children, never in sibling pairs -- and bounded by one decoder matmul in
|
| 204 |
+
total. Gradients are the exact dense ones (including w.r.t. inactive
|
| 205 |
+
activations, which the JumpReLU threshold STE consumes), identical to the
|
| 206 |
+
algebraic-expansion path. Parents are processed in chunks of ``n_chunk`` so
|
| 207 |
+
peak memory is ``O(n_chunk * B * (d + S))``; the backward recomputes the
|
| 208 |
+
per-chunk products instead of storing them.
|
| 209 |
+
"""
|
| 210 |
+
|
| 211 |
+
@staticmethod
|
| 212 |
+
@torch.amp.custom_fwd(device_type="cuda", cast_inputs=torch.float32)
|
| 213 |
+
def forward(
|
| 214 |
+
ctx: Any, a_p: torch.Tensor, a_c: torch.Tensor, d_p: torch.Tensor, d_c: torch.Tensor,
|
| 215 |
+
parents: torch.Tensor, idx: torch.Tensor, valid: torch.Tensor, n_chunk: int,
|
| 216 |
+
) -> torch.Tensor:
|
| 217 |
+
ctx.save_for_backward(a_p, a_c, d_p, d_c, parents, idx, valid)
|
| 218 |
+
ctx.n_chunk = n_chunk
|
| 219 |
+
total = a_p.new_zeros(())
|
| 220 |
+
for s in range(0, parents.numel(), n_chunk):
|
| 221 |
+
a_full, d_full = _dense_gather(a_p, a_c, d_p, d_c, parents[s : s + n_chunk], idx[s : s + n_chunk], valid[s : s + n_chunk])
|
| 222 |
+
total = total + torch.bmm(a_full, d_full).pow(2).sum()
|
| 223 |
+
return total
|
| 224 |
+
|
| 225 |
+
@staticmethod
|
| 226 |
+
@torch.amp.custom_bwd(device_type="cuda")
|
| 227 |
+
def backward(ctx: Any, g: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, None, None, None, None]:
|
| 228 |
+
a_p, a_c, d_p, d_c, parents, idx, valid = ctx.saved_tensors
|
| 229 |
+
n_chunk = ctx.n_chunk
|
| 230 |
+
B, n_c = a_c.shape
|
| 231 |
+
d = d_c.shape[1]
|
| 232 |
+
g_ap = torch.zeros_like(a_p)
|
| 233 |
+
g_ac = torch.zeros_like(a_c)
|
| 234 |
+
g_dp = torch.zeros_like(d_p)
|
| 235 |
+
g_dc = torch.zeros_like(d_c)
|
| 236 |
+
two_g = 2.0 * g.to(a_p.dtype)
|
| 237 |
+
for s in range(0, parents.numel(), n_chunk):
|
| 238 |
+
P, I, V = parents[s : s + n_chunk], idx[s : s + n_chunk], valid[s : s + n_chunk]
|
| 239 |
+
a_full, d_full = _dense_gather(a_p, a_c, d_p, d_c, P, I, V)
|
| 240 |
+
u = torch.bmm(a_full, d_full) * two_g # (h, B, d) = 2 g * residual
|
| 241 |
+
ga = torch.bmm(u, d_full.transpose(1, 2)) # (h, B, S+1); pad columns are 0 (zero rows)
|
| 242 |
+
gd = torch.bmm(a_full.transpose(1, 2), u) # (h, S+1, d)
|
| 243 |
+
g_ap.index_add_(1, P, -ga[:, :, 0].T)
|
| 244 |
+
g_ac.index_add_(1, I.reshape(-1), ga[:, :, 1:].permute(1, 0, 2).reshape(B, -1))
|
| 245 |
+
g_dp.index_add_(0, P, gd[:, 0, :])
|
| 246 |
+
gd_kids = gd[:, 1:, :] * V[:, :, None].to(gd.dtype) # drop the padding rows' (spurious) grads
|
| 247 |
+
g_dc.index_add_(0, I.reshape(-1), gd_kids.reshape(-1, d))
|
| 248 |
+
return g_ap, g_ac, g_dp, g_dc, None, None, None, None
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# --------------------------------------------------------------------------- model
|
| 252 |
+
class ForestLevel(nn.Module):
|
| 253 |
+
"""One JumpReLU SAE level (per-feature threshold, b_dec pre-subtracted)."""
|
| 254 |
+
|
| 255 |
+
def __init__(self, d_in: int, width: int, cfg: ForestConfig) -> None:
|
| 256 |
+
super().__init__()
|
| 257 |
+
w_dec = torch.empty(width, d_in)
|
| 258 |
+
nn.init.kaiming_uniform_(w_dec)
|
| 259 |
+
w_dec = w_dec / w_dec.norm(dim=-1, keepdim=True) * cfg.decoder_init_norm
|
| 260 |
+
self.W_dec = nn.Parameter(w_dec)
|
| 261 |
+
self.W_enc = nn.Parameter(w_dec.T.clone().contiguous())
|
| 262 |
+
self.b_enc = nn.Parameter(torch.zeros(width))
|
| 263 |
+
self.b_dec = nn.Parameter(torch.zeros(d_in))
|
| 264 |
+
self.log_threshold = nn.Parameter(torch.full((width,), math.log(cfg.jumprelu_init_threshold)))
|
| 265 |
+
self.bandwidth = cfg.jumprelu_bandwidth
|
| 266 |
+
|
| 267 |
+
@property
|
| 268 |
+
def threshold(self) -> torch.Tensor:
|
| 269 |
+
return self.log_threshold.exp()
|
| 270 |
+
|
| 271 |
+
def pre_acts(self, x: torch.Tensor) -> torch.Tensor:
|
| 272 |
+
return ((x - self.b_dec) @ self.W_enc + self.b_enc).float()
|
| 273 |
+
|
| 274 |
+
def acts(self, pre: torch.Tensor) -> torch.Tensor:
|
| 275 |
+
return _JumpReLU.apply(pre, self.threshold, self.bandwidth)
|
| 276 |
+
|
| 277 |
+
def l0_step(self, pre: torch.Tensor) -> torch.Tensor:
|
| 278 |
+
return _Step.apply(pre, self.threshold, self.bandwidth).sum(dim=-1)
|
| 279 |
+
|
| 280 |
+
def decode(self, acts: torch.Tensor) -> torch.Tensor:
|
| 281 |
+
return (acts @ self.W_dec).float() + self.b_dec
|
| 282 |
+
|
| 283 |
+
@torch.no_grad()
|
| 284 |
+
def renormalize_decoder(self) -> None:
|
| 285 |
+
self.W_dec.data /= self.W_dec.data.norm(dim=-1, keepdim=True).clamp(min=1e-8)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
class ForestSAE(nn.Module):
|
| 289 |
+
def __init__(self, cfg: ForestConfig) -> None:
|
| 290 |
+
super().__init__()
|
| 291 |
+
self.cfg = cfg
|
| 292 |
+
self.levels = nn.ModuleList([ForestLevel(cfg.d_in, w, cfg) for w in cfg.widths])
|
| 293 |
+
n_levels = len(cfg.widths)
|
| 294 |
+
for b, child_w in enumerate(cfg.widths[1:]):
|
| 295 |
+
self.register_buffer(f"parent_idx_{b}", torch.full((child_w,), -1, dtype=torch.int64))
|
| 296 |
+
self.register_buffer("lambdas", torch.full((n_levels,), cfg.lambda_init))
|
| 297 |
+
self.register_buffer("l0_ema", torch.full((n_levels,), cfg.target_l0))
|
| 298 |
+
self.register_buffer("n_hierarchy_updates", torch.zeros((), dtype=torch.int64))
|
| 299 |
+
# dead-latent tracking over the flattened index space
|
| 300 |
+
self.register_buffer("tokens_since_fired", torch.zeros(sum(cfg.widths), dtype=torch.int64))
|
| 301 |
+
# input scale alpha = sqrt(d) / E||x||_2, estimated from the first batches
|
| 302 |
+
self.register_buffer("norm_sum", torch.zeros((), dtype=torch.float64))
|
| 303 |
+
self.register_buffer("norm_count", torch.zeros((), dtype=torch.int64))
|
| 304 |
+
self.register_buffer("scale", torch.ones(()))
|
| 305 |
+
self.register_buffer("scale_frozen", torch.zeros((), dtype=torch.bool))
|
| 306 |
+
self._pc_cache: dict[int, dict[str, Any]] = {}
|
| 307 |
+
|
| 308 |
+
# ---- bookkeeping -------------------------------------------------------
|
| 309 |
+
@property
|
| 310 |
+
def n_levels(self) -> int:
|
| 311 |
+
return len(self.cfg.widths)
|
| 312 |
+
|
| 313 |
+
@property
|
| 314 |
+
def n_boundaries(self) -> int:
|
| 315 |
+
return len(self.cfg.widths) - 1
|
| 316 |
+
|
| 317 |
+
@property
|
| 318 |
+
def offsets(self) -> list[int]:
|
| 319 |
+
out = [0]
|
| 320 |
+
for w in self.cfg.widths:
|
| 321 |
+
out.append(out[-1] + w)
|
| 322 |
+
return out
|
| 323 |
+
|
| 324 |
+
def parent_idx(self, boundary: int) -> torch.Tensor:
|
| 325 |
+
return getattr(self, f"parent_idx_{boundary}")
|
| 326 |
+
|
| 327 |
+
def _apply(self, fn, recurse=True): # type: ignore[override]
|
| 328 |
+
self._pc_cache = {}
|
| 329 |
+
return super()._apply(fn, recurse)
|
| 330 |
+
|
| 331 |
+
def load_state_dict(self, *args: Any, **kwargs: Any): # type: ignore[override]
|
| 332 |
+
result = super().load_state_dict(*args, **kwargs)
|
| 333 |
+
self._pc_cache = {}
|
| 334 |
+
return result
|
| 335 |
+
|
| 336 |
+
def dead_masks(self) -> list[torch.Tensor]:
|
| 337 |
+
dead = self.tokens_since_fired >= self.cfg.dead_tokens
|
| 338 |
+
off = self.offsets
|
| 339 |
+
return [dead[off[l] : off[l + 1]] for l in range(self.n_levels)]
|
| 340 |
+
|
| 341 |
+
# ---- input scale ---------------------------------------------------------
|
| 342 |
+
@torch.no_grad()
|
| 343 |
+
def update_scale(self, x_raw: torch.Tensor) -> None:
|
| 344 |
+
if bool(self.scale_frozen):
|
| 345 |
+
return
|
| 346 |
+
self.norm_sum += x_raw.float().norm(dim=-1).double().sum()
|
| 347 |
+
self.norm_count += x_raw.shape[0]
|
| 348 |
+
mean_norm = self.norm_sum / self.norm_count.double()
|
| 349 |
+
self.scale.fill_(float(math.sqrt(self.cfg.d_in) / mean_norm))
|
| 350 |
+
if int(self.norm_count) >= self.cfg.scale_estimate_tokens:
|
| 351 |
+
self.scale_frozen.fill_(True)
|
| 352 |
+
|
| 353 |
+
# ---- hierarchy update (no-grad; paper Sec. 3.3 "Similarity-Based Hierarchy Update")
|
| 354 |
+
@torch.no_grad()
|
| 355 |
+
def hierarchy_update(self, child_chunk: int = 8192) -> dict[str, Any]:
|
| 356 |
+
stats: dict[str, Any] = {}
|
| 357 |
+
for b in range(self.n_boundaries):
|
| 358 |
+
parent, child = self.levels[b], self.levels[b + 1]
|
| 359 |
+
e_parent = nn.functional.normalize(parent.W_enc.T.float(), dim=-1) # (n_p, d)
|
| 360 |
+
e_child = nn.functional.normalize(child.W_enc.T.float(), dim=-1) # (n_c, d)
|
| 361 |
+
bests, argmaxes = [], []
|
| 362 |
+
for s in range(0, e_child.shape[0], child_chunk):
|
| 363 |
+
sim = e_child[s : s + child_chunk] @ e_parent.T
|
| 364 |
+
best, argmax = sim.max(dim=-1)
|
| 365 |
+
bests.append(best)
|
| 366 |
+
argmaxes.append(argmax)
|
| 367 |
+
best = torch.cat(bests)
|
| 368 |
+
argmax = torch.cat(argmaxes)
|
| 369 |
+
cutoff = torch.quantile(best, self.cfg.exclusion_quantile)
|
| 370 |
+
assigned = best >= cutoff # bottom-quantile best-scores stay unassigned
|
| 371 |
+
idx = torch.where(assigned, argmax, torch.full_like(argmax, -1))
|
| 372 |
+
previous = self.parent_idx(b).clone()
|
| 373 |
+
self.parent_idx(b).copy_(idx)
|
| 374 |
+
branching = torch.bincount(idx[assigned], minlength=self.cfg.widths[b])
|
| 375 |
+
stats[f"boundary_{b}"] = {
|
| 376 |
+
"n_assigned": int(assigned.sum()),
|
| 377 |
+
"n_children": int(idx.numel()),
|
| 378 |
+
"cutoff": float(cutoff),
|
| 379 |
+
"n_changed_vs_previous": int((idx != previous).sum()),
|
| 380 |
+
"assigned_best_cos_mean": float(best[assigned].mean()) if bool(assigned.any()) else None,
|
| 381 |
+
"parents_with_children": int((branching > 0).sum()),
|
| 382 |
+
"children_per_parent_max": int(branching.max()),
|
| 383 |
+
"n_sibling_pairs": int((branching.double() * (branching.double() - 1) / 2).sum()),
|
| 384 |
+
}
|
| 385 |
+
self.n_hierarchy_updates += 1
|
| 386 |
+
self._pc_cache = {}
|
| 387 |
+
return stats
|
| 388 |
+
|
| 389 |
+
# ---- parent-child loss ---------------------------------------------------
|
| 390 |
+
@staticmethod
|
| 391 |
+
def _sibling_pairs(js: torch.Tensor, ps: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor] | tuple[None, None]:
|
| 392 |
+
"""All sibling pairs ``(j, j')``, ``j`` before ``j'``, of children ``js`` sorted by parent ``ps``."""
|
| 393 |
+
n = ps.numel()
|
| 394 |
+
if n == 0:
|
| 395 |
+
return None, None
|
| 396 |
+
dev = ps.device
|
| 397 |
+
change = torch.ones(n, dtype=torch.bool, device=dev)
|
| 398 |
+
change[1:] = ps[1:] != ps[:-1]
|
| 399 |
+
run_id = torch.cumsum(change.long(), 0) - 1
|
| 400 |
+
run_sizes = torch.bincount(run_id)
|
| 401 |
+
run_end = torch.cumsum(run_sizes, 0)
|
| 402 |
+
pos = torch.arange(n, device=dev)
|
| 403 |
+
counts = run_end[run_id] - pos - 1 # pairs (pos, pos') with pos' > pos in the same run
|
| 404 |
+
total = int(counts.sum())
|
| 405 |
+
if total == 0:
|
| 406 |
+
return None, None
|
| 407 |
+
j1_pos = torch.repeat_interleave(pos, counts)
|
| 408 |
+
pair_start = torch.cumsum(counts, 0) - counts
|
| 409 |
+
within = torch.arange(total, device=dev) - torch.repeat_interleave(pair_start, counts)
|
| 410 |
+
j2_pos = j1_pos + 1 + within
|
| 411 |
+
return js[j1_pos], js[j2_pos]
|
| 412 |
+
|
| 413 |
+
def _build_pc_structure(self, boundary: int, dense_min_children: int | None) -> dict[str, Any]:
|
| 414 |
+
"""Assignment-dependent index structure of one boundary.
|
| 415 |
+
|
| 416 |
+
Parents with fewer than ``dense_min_children`` children ("small") are
|
| 417 |
+
evaluated by the algebraic expansion (``has_child``/``j``/``pj`` masks and
|
| 418 |
+
the sibling pairs ``j1``/``j2`` cover ONLY those); parents with at least
|
| 419 |
+
that many ("hubs") are listed in ``dense_groups`` for the per-parent
|
| 420 |
+
dense path. ``dense_min_children=None`` puts every parent on the
|
| 421 |
+
expansion path (the original implementation, kept for the reference).
|
| 422 |
+
"""
|
| 423 |
+
p_idx = self.parent_idx(boundary)
|
| 424 |
+
n_p, n_c = self.cfg.widths[boundary], self.cfg.widths[boundary + 1]
|
| 425 |
+
dev = p_idx.device
|
| 426 |
+
assigned = p_idx >= 0
|
| 427 |
+
has_child = torch.zeros(n_p, dtype=torch.bool, device=dev)
|
| 428 |
+
struct: dict[str, Any] = {"any_assigned": bool(assigned.any()), "j1": None, "j2": None, "has_child": has_child, "dense_groups": []}
|
| 429 |
+
if not struct["any_assigned"]:
|
| 430 |
+
return struct
|
| 431 |
+
j = torch.nonzero(assigned, as_tuple=True)[0]
|
| 432 |
+
pj = p_idx[j]
|
| 433 |
+
order = torch.argsort(pj, stable=True)
|
| 434 |
+
js, ps = j[order], pj[order] # children sorted by parent
|
| 435 |
+
counts = torch.bincount(pj, minlength=n_p) # children per parent
|
| 436 |
+
if dense_min_children is None:
|
| 437 |
+
small = torch.ones_like(js, dtype=torch.bool)
|
| 438 |
+
else:
|
| 439 |
+
small = counts[ps] < dense_min_children
|
| 440 |
+
js_s, ps_s = js[small], ps[small]
|
| 441 |
+
has_child[ps_s] = True
|
| 442 |
+
struct["j"], struct["pj"] = js_s, ps_s
|
| 443 |
+
struct["j1"], struct["j2"] = self._sibling_pairs(js_s, ps_s)
|
| 444 |
+
if dense_min_children is not None:
|
| 445 |
+
hubs = torch.nonzero(counts >= dense_min_children, as_tuple=True)[0]
|
| 446 |
+
if hubs.numel() > 0:
|
| 447 |
+
starts = torch.cumsum(counts, 0) - counts # first position of each parent's run in js
|
| 448 |
+
sizes = counts[hubs]
|
| 449 |
+
order_h = torch.argsort(sizes, descending=True, stable=True)
|
| 450 |
+
hubs, sizes = hubs[order_h], sizes[order_h]
|
| 451 |
+
sizes_l = sizes.tolist()
|
| 452 |
+
# group parents of similar child count so padding wastes < 2x
|
| 453 |
+
groups: list[tuple[int, int]] = []
|
| 454 |
+
start = 0
|
| 455 |
+
for k in range(1, len(sizes_l) + 1):
|
| 456 |
+
if k == len(sizes_l) or sizes_l[k] * 2 < sizes_l[start]:
|
| 457 |
+
groups.append((start, k))
|
| 458 |
+
start = k
|
| 459 |
+
for g0, g1 in groups:
|
| 460 |
+
P, sz = hubs[g0:g1], sizes[g0:g1]
|
| 461 |
+
h, S = P.numel(), int(sz[0])
|
| 462 |
+
idx = torch.full((h, S), n_c, dtype=torch.int64, device=dev)
|
| 463 |
+
rows = torch.repeat_interleave(torch.arange(h, device=dev), sz)
|
| 464 |
+
cols = torch.arange(int(sz.sum()), device=dev) - torch.repeat_interleave(torch.cumsum(sz, 0) - sz, sz)
|
| 465 |
+
idx[rows, cols] = js[starts[P][rows] + cols]
|
| 466 |
+
valid = idx < n_c
|
| 467 |
+
struct["dense_groups"].append({"parents": P, "idx": idx.clamp(max=n_c - 1), "valid": valid, "S": S})
|
| 468 |
+
return struct
|
| 469 |
+
|
| 470 |
+
def _pc_structure(self, boundary: int) -> dict[str, Any]:
|
| 471 |
+
"""Structure for the configured hub threshold, cached between hierarchy updates."""
|
| 472 |
+
cached = self._pc_cache.get(boundary)
|
| 473 |
+
if cached is None:
|
| 474 |
+
cached = self._build_pc_structure(boundary, self.cfg.pc_dense_min_children)
|
| 475 |
+
self._pc_cache[boundary] = cached
|
| 476 |
+
return cached
|
| 477 |
+
|
| 478 |
+
def _pc_expansion(self, boundary: int, struct: dict[str, Any], acts_parent: torch.Tensor, acts_child: torch.Tensor) -> torch.Tensor:
|
| 479 |
+
"""Per-sample ``sum_i ||a_i d_i - sum_{j in C_i} a_j d_j||^2`` over the parents covered by
|
| 480 |
+
``struct``'s masks, via the exact expansion
|
| 481 |
+
``a_i^2||d_i||^2 - 2 sum_j a_i a_j (d_i.d_j) + sum_j a_j^2||d_j||^2 + 2 sum_{j<j'} a_j a_j' (d_j.d_j')``."""
|
| 482 |
+
parent, child = self.levels[boundary], self.levels[boundary + 1]
|
| 483 |
+
d_parent_sq = parent.W_dec.pow(2).sum(-1)
|
| 484 |
+
term1 = (acts_parent.pow(2) * (d_parent_sq * struct["has_child"])).sum(-1)
|
| 485 |
+
if not struct["any_assigned"]:
|
| 486 |
+
return term1
|
| 487 |
+
j, pj = struct["j"], struct["pj"]
|
| 488 |
+
dots_pc = (child.W_dec[j] * parent.W_dec[pj]).sum(-1)
|
| 489 |
+
a_c = acts_child[:, j]
|
| 490 |
+
term2 = (acts_parent[:, pj] * a_c * dots_pc).sum(-1)
|
| 491 |
+
term3 = (a_c.pow(2) * child.W_dec[j].pow(2).sum(-1)).sum(-1)
|
| 492 |
+
total = term1 - 2.0 * term2 + term3
|
| 493 |
+
if struct["j1"] is not None:
|
| 494 |
+
total = total + 2.0 * _SiblingCross.apply(acts_child, child.W_dec, struct["j1"], struct["j2"], self.cfg.pc_pair_chunk)
|
| 495 |
+
return total
|
| 496 |
+
|
| 497 |
+
def pc_loss_boundary(self, boundary: int, acts_parent: torch.Tensor, acts_child: torch.Tensor) -> torch.Tensor:
|
| 498 |
+
"""``mean_b sum_i ||a_i d_i - sum_{j in C_i} a_j d_j||^2`` over parents with children.
|
| 499 |
+
|
| 500 |
+
Parents with fewer than ``cfg.pc_dense_min_children`` children use the
|
| 501 |
+
sibling-pair expansion (``_pc_expansion``); hub parents use the dense
|
| 502 |
+
per-parent form (``_DenseParentChild``, cost linear in #children). Both
|
| 503 |
+
give the same value and gradients (see ``_pc_loss_reference``).
|
| 504 |
+
"""
|
| 505 |
+
struct = self._pc_structure(boundary)
|
| 506 |
+
total = self._pc_expansion(boundary, struct, acts_parent, acts_child).mean()
|
| 507 |
+
if struct["dense_groups"]:
|
| 508 |
+
parent, child = self.levels[boundary], self.levels[boundary + 1]
|
| 509 |
+
B, d = acts_parent.shape[0], self.cfg.d_in
|
| 510 |
+
dense = acts_parent.new_zeros(())
|
| 511 |
+
for grp in struct["dense_groups"]:
|
| 512 |
+
n_chunk = max(1, self.cfg.pc_dense_chunk_elems // (B * (d + grp["S"] + 1)))
|
| 513 |
+
dense = dense + _DenseParentChild.apply(
|
| 514 |
+
acts_parent, acts_child, parent.W_dec, child.W_dec, grp["parents"], grp["idx"], grp["valid"], n_chunk
|
| 515 |
+
)
|
| 516 |
+
total = total + dense / B
|
| 517 |
+
return total
|
| 518 |
+
|
| 519 |
+
def _pc_loss_reference(self, boundary: int, acts_parent: torch.Tensor, acts_child: torch.Tensor) -> torch.Tensor:
|
| 520 |
+
"""Original implementation (every parent on the sibling-pair expansion; cost
|
| 521 |
+
``O(batch * n_sibling_pairs)``). Kept, uncached, as the test reference."""
|
| 522 |
+
struct = self._build_pc_structure(boundary, None)
|
| 523 |
+
return self._pc_expansion(boundary, struct, acts_parent, acts_child).mean()
|
| 524 |
+
|
| 525 |
+
# ---- training losses -----------------------------------------------------
|
| 526 |
+
def training_losses(self, x: torch.Tensor, generator: torch.Generator | None, dead_masks: list[torch.Tensor], n_dead: list[int]) -> dict[str, Any]:
|
| 527 |
+
cfg = self.cfg
|
| 528 |
+
pres = [level.pre_acts(x) for level in self.levels]
|
| 529 |
+
acts = [level.acts(pre) for level, pre in zip(self.levels, pres, strict=True)]
|
| 530 |
+
recons = [level.decode(a) for level, a in zip(self.levels, acts, strict=True)]
|
| 531 |
+
|
| 532 |
+
# Perturbation (ledger M1: substitute WITH probability r), one Bernoulli
|
| 533 |
+
# draw per parent per step shared across the batch; only parents with
|
| 534 |
+
# children; never the last level; only inside the reconstruction MSE.
|
| 535 |
+
mse_terms = []
|
| 536 |
+
for l, level in enumerate(self.levels):
|
| 537 |
+
recon = recons[l]
|
| 538 |
+
if cfg.perturb_r > 0 and l < self.n_boundaries:
|
| 539 |
+
p_idx = self.parent_idx(l)
|
| 540 |
+
assigned = p_idx >= 0
|
| 541 |
+
if bool(assigned.any()):
|
| 542 |
+
n_p = cfg.widths[l]
|
| 543 |
+
z = torch.rand(n_p, device=x.device, generator=generator) < cfg.perturb_r
|
| 544 |
+
has_child = torch.zeros(n_p, dtype=torch.bool, device=x.device)
|
| 545 |
+
has_child[p_idx[assigned]] = True
|
| 546 |
+
sub = torch.nonzero(z & has_child, as_tuple=True)[0]
|
| 547 |
+
if sub.numel() > 0:
|
| 548 |
+
sub_mask = torch.zeros(n_p, dtype=torch.bool, device=x.device)
|
| 549 |
+
sub_mask[sub] = True
|
| 550 |
+
cidx = torch.nonzero(assigned & sub_mask[p_idx.clamp(min=0)], as_tuple=True)[0]
|
| 551 |
+
child_level = self.levels[l + 1]
|
| 552 |
+
parent_part = (acts[l][:, sub] @ level.W_dec[sub]).float()
|
| 553 |
+
child_part = (acts[l + 1][:, cidx] @ child_level.W_dec[cidx]).float()
|
| 554 |
+
recon = recon - parent_part + child_part
|
| 555 |
+
mse_terms.append((recon - x).pow(2).sum(-1).mean())
|
| 556 |
+
|
| 557 |
+
l0_losses = [level.l0_step(pre).mean() for level, pre in zip(self.levels, pres, strict=True)]
|
| 558 |
+
sae_loss = sum(mse + self.lambdas[l] * l0 for l, (mse, l0) in enumerate(zip(mse_terms, l0_losses, strict=True)))
|
| 559 |
+
pc = sum(self.pc_loss_boundary(b, acts[b], acts[b + 1]) for b in range(self.n_boundaries))
|
| 560 |
+
aux = self._aux_loss(x, pres, recons, dead_masks, n_dead)
|
| 561 |
+
total = sae_loss + cfg.rho * pc + cfg.aux_coeff * aux
|
| 562 |
+
with torch.no_grad():
|
| 563 |
+
batch_l0 = torch.stack([(a > 0).float().sum(-1).mean() for a in acts])
|
| 564 |
+
fired = torch.cat([(a > 0).any(dim=0) for a in acts])
|
| 565 |
+
return {
|
| 566 |
+
"loss": total,
|
| 567 |
+
"mse_terms": [t.detach() for t in mse_terms],
|
| 568 |
+
"pc_loss": pc.detach() if torch.is_tensor(pc) else torch.tensor(float(pc)),
|
| 569 |
+
"aux_loss": aux.detach() if torch.is_tensor(aux) else torch.tensor(float(aux)),
|
| 570 |
+
"batch_l0": batch_l0,
|
| 571 |
+
"fired": fired,
|
| 572 |
+
"acts": acts,
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
def _aux_loss(self, x: torch.Tensor, pres: list[torch.Tensor], recons: list[torch.Tensor], dead_masks: list[torch.Tensor], n_dead: list[int]) -> torch.Tensor | float:
|
| 576 |
+
"""Aux-k residual reconstruction on dead latents (Gao et al. 2024 style;
|
| 577 |
+
deviation from the paper's ghost grads). Top ``aux_k`` dead latents by
|
| 578 |
+
ReLU'd pre-activation reconstruct the detached residual of the clean
|
| 579 |
+
reconstruction; no ``b_dec``; scaled by ``min(n_dead / aux_k, 1)``."""
|
| 580 |
+
cfg = self.cfg
|
| 581 |
+
total: torch.Tensor | float = 0.0
|
| 582 |
+
for l, level in enumerate(self.levels):
|
| 583 |
+
if n_dead[l] == 0:
|
| 584 |
+
continue
|
| 585 |
+
k = min(cfg.aux_k, n_dead[l])
|
| 586 |
+
residual = (x - recons[l]).detach()
|
| 587 |
+
masked = torch.where(dead_masks[l][None, :], torch.relu(pres[l]), torch.full_like(pres[l], -torch.inf))
|
| 588 |
+
top = masked.topk(k, dim=-1, sorted=False)
|
| 589 |
+
aux_acts = torch.zeros_like(pres[l]).scatter_(-1, top.indices, top.values)
|
| 590 |
+
aux_recon = (aux_acts @ level.W_dec).float()
|
| 591 |
+
scale = min(n_dead[l] / cfg.aux_k, 1.0)
|
| 592 |
+
total = total + scale * (aux_recon - residual).pow(2).sum(-1).mean()
|
| 593 |
+
return total
|
| 594 |
+
|
| 595 |
+
# ---- sparsity controller (paper App. A.2; magnitudes per ledger P3) --------
|
| 596 |
+
@torch.no_grad()
|
| 597 |
+
def controller_step(self, batch_l0: torch.Tensor) -> None:
|
| 598 |
+
cfg = self.cfg
|
| 599 |
+
m = cfg.l0_ema_momentum
|
| 600 |
+
prev = self.l0_ema.clone()
|
| 601 |
+
self.l0_ema.mul_(m).add_((1 - m) * batch_l0.float())
|
| 602 |
+
desired_delta = -cfg.controller_eta * (prev - cfg.target_l0)
|
| 603 |
+
actual_delta = self.l0_ema - prev
|
| 604 |
+
surplus = (actual_delta - desired_delta) / max(cfg.target_l0, 1.0)
|
| 605 |
+
factor = torch.exp(cfg.controller_kappa * surplus).clamp(*cfg.controller_clamp)
|
| 606 |
+
self.lambdas.mul_(factor).clamp_(*cfg.lambda_bounds)
|
| 607 |
+
|
| 608 |
+
@torch.no_grad()
|
| 609 |
+
def renormalize_decoders(self) -> None:
|
| 610 |
+
for level in self.levels:
|
| 611 |
+
level.renormalize_decoder()
|
| 612 |
+
|
| 613 |
+
@torch.no_grad()
|
| 614 |
+
def update_dead_tracking(self, fired: torch.Tensor, n_tokens: int) -> None:
|
| 615 |
+
self.tokens_since_fired += n_tokens
|
| 616 |
+
self.tokens_since_fired[fired] = 0
|
| 617 |
+
|
| 618 |
+
def parents_per_boundary(self) -> list[list[int]]:
|
| 619 |
+
return [self.parent_idx(b).tolist() for b in range(self.n_boundaries)]
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
# --------------------------------------------------------------------------- trainer
|
| 623 |
+
def lr_multiplier(step: int, total_steps: int, warmup_frac: float) -> float:
|
| 624 |
+
warmup = max(1, int(total_steps * warmup_frac))
|
| 625 |
+
if step < warmup:
|
| 626 |
+
return (step + 1) / warmup
|
| 627 |
+
progress = (step - warmup) / max(1, total_steps - warmup)
|
| 628 |
+
return 0.5 * (1.0 + math.cos(math.pi * min(progress, 1.0)))
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
class HSAEForestTrainer:
|
| 632 |
+
def __init__(self, cfg: ForestConfig, device: str = "cuda") -> None:
|
| 633 |
+
self.cfg = cfg
|
| 634 |
+
self.device = torch.device(device)
|
| 635 |
+
self.d_in = cfg.d_in
|
| 636 |
+
self.n_latents = sum(cfg.widths)
|
| 637 |
+
self.model = ForestSAE(cfg).to(self.device)
|
| 638 |
+
thr = [p for n, p in self.model.named_parameters() if n.endswith("log_threshold")]
|
| 639 |
+
rest = [p for n, p in self.model.named_parameters() if not n.endswith("log_threshold")]
|
| 640 |
+
self.optimizer = torch.optim.Adam(
|
| 641 |
+
[{"params": rest, "lr_mult": 1.0}, {"params": thr, "lr_mult": cfg.threshold_lr_mult}],
|
| 642 |
+
lr=cfg.lr, betas=cfg.adam_betas,
|
| 643 |
+
)
|
| 644 |
+
self.perturb_gen = torch.Generator(device=self.device)
|
| 645 |
+
self.perturb_gen.manual_seed(cfg.seed + 1)
|
| 646 |
+
self.last_step = -1
|
| 647 |
+
self.last_hierarchy_stats: dict[str, Any] = {}
|
| 648 |
+
self.use_autocast = cfg.autocast and self.device.type == "cuda"
|
| 649 |
+
|
| 650 |
+
def config_dict(self) -> dict[str, Any]:
|
| 651 |
+
return self.cfg.to_dict()
|
| 652 |
+
|
| 653 |
+
def step(self, x: torch.Tensor, step: int, total_steps: int) -> dict[str, float]:
|
| 654 |
+
cfg, model = self.cfg, self.model
|
| 655 |
+
x = x.to(self.device, dtype=torch.float32, non_blocking=True)
|
| 656 |
+
model.update_scale(x)
|
| 657 |
+
x = x * model.scale
|
| 658 |
+
lr = cfg.lr * lr_multiplier(step, total_steps, cfg.warmup_frac)
|
| 659 |
+
for group in self.optimizer.param_groups:
|
| 660 |
+
group["lr"] = lr * group["lr_mult"]
|
| 661 |
+
|
| 662 |
+
dead_masks = model.dead_masks()
|
| 663 |
+
n_dead = torch.stack([m.sum() for m in dead_masks]).tolist()
|
| 664 |
+
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=self.use_autocast):
|
| 665 |
+
out = model.training_losses(x, self.perturb_gen, dead_masks, n_dead)
|
| 666 |
+
out["loss"].backward()
|
| 667 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
|
| 668 |
+
self.optimizer.step()
|
| 669 |
+
self.optimizer.zero_grad(set_to_none=True)
|
| 670 |
+
model.renormalize_decoders()
|
| 671 |
+
model.controller_step(out["batch_l0"])
|
| 672 |
+
model.update_dead_tracking(out["fired"], int(x.shape[0]))
|
| 673 |
+
if (step + 1) % cfg.hierarchy_update_every == 0:
|
| 674 |
+
self.last_hierarchy_stats = model.hierarchy_update()
|
| 675 |
+
print(json.dumps({"hierarchy_update": step + 1, **self.last_hierarchy_stats}), flush=True)
|
| 676 |
+
self.last_step = step
|
| 677 |
+
|
| 678 |
+
batch_l0 = out["batch_l0"].tolist()
|
| 679 |
+
metrics: dict[str, float] = {
|
| 680 |
+
"loss": float(out["loss"].detach()),
|
| 681 |
+
"l0": float(sum(batch_l0)),
|
| 682 |
+
"pc_loss": float(out["pc_loss"]),
|
| 683 |
+
"aux_loss": float(out["aux_loss"]),
|
| 684 |
+
"frac_dead": float(sum(n_dead)) / self.n_latents,
|
| 685 |
+
"lr": lr,
|
| 686 |
+
"scale": float(model.scale),
|
| 687 |
+
}
|
| 688 |
+
if self.device.type == "cuda":
|
| 689 |
+
metrics["cuda_max_mem_gb"] = torch.cuda.max_memory_allocated(self.device) / 1e9
|
| 690 |
+
lambdas, emas = model.lambdas.tolist(), model.l0_ema.tolist()
|
| 691 |
+
mses = [float(t) for t in out["mse_terms"]]
|
| 692 |
+
for l in range(model.n_levels):
|
| 693 |
+
metrics[f"l0/level{l}"] = batch_l0[l]
|
| 694 |
+
metrics[f"l0_ema/level{l}"] = emas[l]
|
| 695 |
+
metrics[f"lambda/level{l}"] = lambdas[l]
|
| 696 |
+
metrics[f"mse/level{l}"] = mses[l]
|
| 697 |
+
metrics[f"frac_dead/level{l}"] = n_dead[l] / cfg.widths[l]
|
| 698 |
+
metrics[f"threshold_mean/level{l}"] = float(model.levels[l].threshold.mean())
|
| 699 |
+
return metrics
|
| 700 |
+
|
| 701 |
+
# ---- checkpointing -------------------------------------------------------
|
| 702 |
+
def state_dict(self) -> dict[str, Any]:
|
| 703 |
+
return {
|
| 704 |
+
"model": self.model.state_dict(),
|
| 705 |
+
"optimizer": self.optimizer.state_dict(),
|
| 706 |
+
"perturb_gen": self.perturb_gen.get_state(),
|
| 707 |
+
"last_step": self.last_step,
|
| 708 |
+
"config": self.cfg.to_dict(),
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
def load_state_dict(self, state: dict[str, Any]) -> None:
|
| 712 |
+
self.model.load_state_dict(state["model"])
|
| 713 |
+
# Optimizer.load_state_dict aliases state tensors that already match the
|
| 714 |
+
# param dtype/device (no copy), so deep-copy to keep the source intact.
|
| 715 |
+
self.optimizer.load_state_dict(copy.deepcopy(state["optimizer"]))
|
| 716 |
+
self.perturb_gen.set_state(state["perturb_gen"].cpu())
|
| 717 |
+
self.last_step = int(state.get("last_step", -1))
|
| 718 |
+
|
| 719 |
+
# ---- export --------------------------------------------------------------
|
| 720 |
+
@torch.no_grad()
|
| 721 |
+
def export(self, out_dir: Path) -> None:
|
| 722 |
+
from safetensors.torch import save_file
|
| 723 |
+
|
| 724 |
+
out_dir = Path(out_dir)
|
| 725 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 726 |
+
# Ledger P6 / paper Sec. 3.3: one extra hierarchy update on the final weights.
|
| 727 |
+
final_stats = self.model.hierarchy_update()
|
| 728 |
+
tensors: dict[str, torch.Tensor] = {}
|
| 729 |
+
for l, level in enumerate(self.model.levels):
|
| 730 |
+
tensors[f"level{l}.W_enc"] = level.W_enc.detach().float().cpu().contiguous()
|
| 731 |
+
tensors[f"level{l}.W_dec"] = level.W_dec.detach().float().cpu().contiguous()
|
| 732 |
+
tensors[f"level{l}.b_enc"] = level.b_enc.detach().float().cpu().contiguous()
|
| 733 |
+
tensors[f"level{l}.b_dec"] = level.b_dec.detach().float().cpu().contiguous()
|
| 734 |
+
tensors[f"level{l}.threshold"] = level.threshold.detach().float().cpu().contiguous()
|
| 735 |
+
for b in range(self.model.n_boundaries):
|
| 736 |
+
tensors[f"parent_idx_{b}"] = self.model.parent_idx(b).cpu().contiguous()
|
| 737 |
+
tensors["tokens_since_fired"] = self.model.tokens_since_fired.cpu().contiguous()
|
| 738 |
+
save_file(tensors, str(out_dir / WEIGHTS_FILE))
|
| 739 |
+
parents = self.model.parents_per_boundary()
|
| 740 |
+
(out_dir / HIERARCHY_FILE).write_text(json.dumps({"widths": list(self.cfg.widths), "parents": parents}))
|
| 741 |
+
dead = self.model.dead_masks()
|
| 742 |
+
(out_dir / CONFIG_FILE).write_text(json.dumps({
|
| 743 |
+
"arch": "hsae_forest",
|
| 744 |
+
"paper": "Luo et al., arXiv:2602.11881",
|
| 745 |
+
"config": self.cfg.to_dict(),
|
| 746 |
+
"n_latents": self.n_latents,
|
| 747 |
+
"level_offsets": self.model.offsets,
|
| 748 |
+
"scale": float(self.model.scale),
|
| 749 |
+
"scale_frozen": bool(self.model.scale_frozen),
|
| 750 |
+
"scale_estimate_tokens_seen": int(self.model.norm_count),
|
| 751 |
+
"last_step": self.last_step,
|
| 752 |
+
"n_hierarchy_updates": int(self.model.n_hierarchy_updates),
|
| 753 |
+
"final_hierarchy_stats": final_stats,
|
| 754 |
+
"final_lambdas": self.model.lambdas.tolist(),
|
| 755 |
+
"final_l0_ema": self.model.l0_ema.tolist(),
|
| 756 |
+
"frac_dead_per_level": [float(m.float().mean()) for m in dead],
|
| 757 |
+
"weights_file": WEIGHTS_FILE,
|
| 758 |
+
"hierarchy_file": HIERARCHY_FILE,
|
| 759 |
+
}, indent=1))
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def build_trainer(d_in: int, overrides: dict[str, Any], device: str = "cuda") -> HSAEForestTrainer:
|
| 763 |
+
cfg = _make_config(d_in, dict(overrides or {}))
|
| 764 |
+
return HSAEForestTrainer(cfg, device=device)
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
# --------------------------------------------------------------------------- export consumer
|
| 768 |
+
class ExportedForest:
|
| 769 |
+
"""``ExportedSAE`` over the flattened (level 0, level 1, level 2) index space."""
|
| 770 |
+
|
| 771 |
+
def __init__(self, cfg: ForestConfig, tensors: dict[str, torch.Tensor], parents: list[list[int]], scale: float, device: str) -> None:
|
| 772 |
+
self.cfg = cfg
|
| 773 |
+
self.widths = list(cfg.widths)
|
| 774 |
+
self.scale = float(scale)
|
| 775 |
+
self.device = torch.device(device)
|
| 776 |
+
n_levels = len(self.widths)
|
| 777 |
+
self.W_enc = [tensors[f"level{l}.W_enc"].to(self.device) for l in range(n_levels)]
|
| 778 |
+
self.b_enc = [tensors[f"level{l}.b_enc"].to(self.device) for l in range(n_levels)]
|
| 779 |
+
self.b_dec = [tensors[f"level{l}.b_dec"].to(self.device) for l in range(n_levels)]
|
| 780 |
+
self.threshold = [tensors[f"level{l}.threshold"].to(self.device) for l in range(n_levels)]
|
| 781 |
+
w_dec_scaled = torch.cat([tensors[f"level{l}.W_dec"] for l in range(n_levels)]).to(self.device)
|
| 782 |
+
# Decoder directions in RAW activation units: x ~= sum_j a_j (d_j / alpha) + b_dec / alpha.
|
| 783 |
+
self.W_dec = w_dec_scaled / self.scale
|
| 784 |
+
self.parents = parents
|
| 785 |
+
self.n_latents = sum(self.widths)
|
| 786 |
+
self.offsets = [0]
|
| 787 |
+
for w in self.widths:
|
| 788 |
+
self.offsets.append(self.offsets[-1] + w)
|
| 789 |
+
|
| 790 |
+
@torch.no_grad()
|
| 791 |
+
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
| 792 |
+
xs = x.to(self.device, dtype=torch.float32) * self.scale
|
| 793 |
+
outs = []
|
| 794 |
+
for l in range(len(self.widths)):
|
| 795 |
+
pre = (xs - self.b_dec[l]) @ self.W_enc[l] + self.b_enc[l]
|
| 796 |
+
outs.append(pre * (pre > self.threshold[l]))
|
| 797 |
+
return torch.cat(outs, dim=-1)
|
| 798 |
+
|
| 799 |
+
def native_ancestors(self) -> dict[int, set[int]]:
|
| 800 |
+
out: dict[int, set[int]] = {}
|
| 801 |
+
for b, plist in enumerate(self.parents):
|
| 802 |
+
child_off, parent_off = self.offsets[b + 1], self.offsets[b]
|
| 803 |
+
for j, p in enumerate(plist):
|
| 804 |
+
if p >= 0:
|
| 805 |
+
out[child_off + j] = {parent_off + p}
|
| 806 |
+
return out
|
| 807 |
+
|
| 808 |
+
def latent_level(self) -> list[int]:
|
| 809 |
+
return [l for l, w in enumerate(self.widths) for _ in range(w)]
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
def load_exported(export_dir: Path, device: str = "cuda") -> ExportedForest:
|
| 813 |
+
from safetensors.torch import load_file
|
| 814 |
+
|
| 815 |
+
export_dir = Path(export_dir)
|
| 816 |
+
meta = json.loads((export_dir / CONFIG_FILE).read_text())
|
| 817 |
+
cfg = ForestConfig(**meta["config"])
|
| 818 |
+
tensors = load_file(str(export_dir / meta.get("weights_file", WEIGHTS_FILE)))
|
| 819 |
+
parents = json.loads((export_dir / meta.get("hierarchy_file", HIERARCHY_FILE)).read_text())["parents"]
|
| 820 |
+
return ExportedForest(cfg, tensors, parents, meta["scale"], device)
|
gemma-2-2b/layer-12/hsae_forest/hsae_forest_32k_seed0_v3/summary.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run_name": "hsae_forest_32k_seed0_v3",
|
| 3 |
+
"arch": "hsae_forest",
|
| 4 |
+
"steps": 122070,
|
| 5 |
+
"tokens": 499998720,
|
| 6 |
+
"seconds": 15422.808210134506,
|
| 7 |
+
"export": "/vol/train/hsae_forest_32k_seed0_v3/export"
|
| 8 |
+
}
|