chanind commited on
Commit
8b42e36
·
verified ·
1 Parent(s): 2211eec

Upload folder using huggingface_hub

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