chanind commited on
Commit
c832800
·
verified ·
1 Parent(s): f581a06

Upload folder using huggingface_hub

Browse files
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
+ }