sae: loader + build_sae_data + eval_sae (cross-uplift metric); weights_only=False for torch 2.13
Browse files- scripts/build_sae_data.py +88 -0
- scripts/eval_sae.py +168 -0
- src/mxf/sae.py +61 -0
scripts/build_sae_data.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build an SAE-feature post-train dataset in the exact vec-bank format pretrain.py consumes.
|
| 2 |
+
|
| 3 |
+
Per feature: direction = unit(W_enc[:,f]) (encoder column, probe side — the same side eval_sae.py
|
| 4 |
+
rewards); targets = the feature's top --targets corpus windows (by per-window peak act) decoded to
|
| 5 |
+
text. Features are split (seeded, --eval-frac) into train/eval and ONLY train features enter
|
| 6 |
+
vecs.f32/records.jsonl — the eval half is reserved for cross-uplift (eval_sae.py --split
|
| 7 |
+
<out>/split.json). build_stats.json carries n_examples so scripts/rl.py --data-dir <out> also
|
| 8 |
+
works on this bank unchanged.
|
| 9 |
+
|
| 10 |
+
python scripts/build_sae_data.py --out-dir data/sae --targets 3
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import random
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
from transformers import AutoTokenizer
|
| 20 |
+
|
| 21 |
+
from mxf.config import D_MODEL, MODEL
|
| 22 |
+
from mxf.sae import load_max_acts, load_sae
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main():
|
| 26 |
+
ap = argparse.ArgumentParser()
|
| 27 |
+
ap.add_argument("--sae-path", default=None, help="ae.pt path; default: hf_hub_download")
|
| 28 |
+
ap.add_argument("--maxacts-path", default=None, help="max-acts .pt path; default: hf_hub_download")
|
| 29 |
+
ap.add_argument("--out-dir", default="data/sae")
|
| 30 |
+
ap.add_argument("--n-features", type=int, default=0, help="0 = ALL alive features; else seeded sample")
|
| 31 |
+
ap.add_argument("--targets", type=int, default=3, help="top corpus windows per feature")
|
| 32 |
+
ap.add_argument("--min-act", type=float, default=0.0, help="drop features with corpus peak <= this")
|
| 33 |
+
ap.add_argument("--eval-frac", type=float, default=0.5)
|
| 34 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 35 |
+
a = ap.parse_args()
|
| 36 |
+
os.makedirs(a.out_dir, exist_ok=True)
|
| 37 |
+
rng = random.Random(a.seed)
|
| 38 |
+
|
| 39 |
+
tok = AutoTokenizer.from_pretrained(MODEL)
|
| 40 |
+
sae = load_sae(a.sae_path) # cpu fp32
|
| 41 |
+
data = load_max_acts(a.maxacts_path)
|
| 42 |
+
tokens, acts = data["max_tokens"], data["max_acts"] # [F, N, L]
|
| 43 |
+
assert acts.shape[0] == sae.d_sae, f"max-acts F={acts.shape[0]} != SAE F={sae.d_sae}"
|
| 44 |
+
dataset_max = acts.amax(dim=(1, 2)) # [F] corpus peak per feature
|
| 45 |
+
|
| 46 |
+
alive = (dataset_max > a.min_act).nonzero(as_tuple=True)[0].tolist()
|
| 47 |
+
if a.n_features and a.n_features < len(alive):
|
| 48 |
+
alive = rng.sample(alive, a.n_features)
|
| 49 |
+
rng.shuffle(alive)
|
| 50 |
+
n_eval = int(len(alive) * a.eval_frac)
|
| 51 |
+
ev, train = sorted(alive[:n_eval]), sorted(alive[n_eval:])
|
| 52 |
+
json.dump({"seed": a.seed, "eval_frac": a.eval_frac, "min_act": a.min_act,
|
| 53 |
+
"train": train, "eval": ev}, open(f"{a.out_dir}/split.json", "w"))
|
| 54 |
+
print(f"{len(alive)} alive features (min_act {a.min_act}) -> {len(train)} train / {len(ev)} eval",
|
| 55 |
+
flush=True)
|
| 56 |
+
|
| 57 |
+
dirs = torch.nn.functional.normalize(sae.W_enc, dim=0) # [d, F] unit encoder columns
|
| 58 |
+
rows, skipped = [], 0
|
| 59 |
+
for j, f in enumerate(train):
|
| 60 |
+
peak = acts[f].amax(dim=-1) # [N] per-window peaks
|
| 61 |
+
for w in peak.argsort(descending=True)[: a.targets].tolist():
|
| 62 |
+
if peak[w] <= 0:
|
| 63 |
+
break # desc order: rest are dead too
|
| 64 |
+
text = tok.decode(tokens[f, w].tolist(), skip_special_tokens=True).strip()
|
| 65 |
+
if len(text) < 3:
|
| 66 |
+
skipped += 1
|
| 67 |
+
continue
|
| 68 |
+
rows.append((f, text, peak[w].item()))
|
| 69 |
+
if (j + 1) % 5000 == 0:
|
| 70 |
+
print(f"{j + 1}/{len(train)} features, {len(rows)} records", flush=True)
|
| 71 |
+
|
| 72 |
+
assert rows, "no records minted — check --min-act / max-acts file"
|
| 73 |
+
vecs = np.memmap(f"{a.out_dir}/vecs.f32", dtype=np.float32, mode="w+",
|
| 74 |
+
shape=(len(rows), D_MODEL))
|
| 75 |
+
with open(f"{a.out_dir}/records.jsonl", "w") as recs:
|
| 76 |
+
for n, (f, text, act) in enumerate(rows): # one row per (feature, target)
|
| 77 |
+
vecs[n] = dirs[:, f].numpy()
|
| 78 |
+
recs.write(json.dumps({"vec_idx": n, "target_text": text, "feature": f,
|
| 79 |
+
"act": round(act, 3)}) + "\n")
|
| 80 |
+
vecs.flush()
|
| 81 |
+
stats = {"n_examples": len(rows), "n_train_features": len(train), "n_eval_features": len(ev),
|
| 82 |
+
"targets_per_feature": a.targets, "skipped_short": skipped, "seed": a.seed}
|
| 83 |
+
json.dump(stats, open(f"{a.out_dir}/build_stats.json", "w"), indent=1)
|
| 84 |
+
print(f"BUILD_SAE_DATA_DONE {stats}", flush=True)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
if __name__ == "__main__":
|
| 88 |
+
main()
|
scripts/eval_sae.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 7: CROSS-UPLIFT eval — does cluster-direction training transfer to unseen SAE features?
|
| 2 |
+
|
| 3 |
+
Per held-out feature: condition on unit(W_enc[:,f]) (inject@INJECT_LAYER at the marker), generate
|
| 4 |
+
greedy + best-of-N at T via HF generate, then score every text STANDALONE through the CLEAN base
|
| 5 |
+
(adapter disabled, no injection) at READ_LAYER: SAE-encode feature f, max over kept positions
|
| 6 |
+
(pos-0 attention-sink + norm-outlier guards — the old SAE line's exact protocol, so numbers are
|
| 7 |
+
directly comparable). dataset_max = the feature's corpus peak from the max-acts dump.
|
| 8 |
+
|
| 9 |
+
python scripts/eval_sae.py --adapter checkpoints/pretrain/final --split data/sae/split.json \
|
| 10 |
+
--best-of 16 --out results/xuplift_pretrain.json
|
| 11 |
+
python scripts/eval_sae.py --adapter none --n-features 256 --out results/xuplift_base.json
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import contextlib
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import random
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
from peft import PeftModel
|
| 21 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 22 |
+
|
| 23 |
+
from mxf.config import INJECT_LAYER, MODEL, READ_LAYER, STEER_COEFF
|
| 24 |
+
from mxf.inject import get_layer, hooked, make_inject_hook, read_resid
|
| 25 |
+
from mxf.prompts import build_prompt_ids
|
| 26 |
+
from mxf.sae import load_max_acts, load_sae
|
| 27 |
+
|
| 28 |
+
NORM_FILTER_MULT = 10.0 # drop positions with resid norm > mult * batch median (sink guard)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@torch.no_grad()
|
| 32 |
+
def generate(model, tok, prompt_ids, marker, dirs, a, device, greedy):
|
| 33 |
+
"""One batched HF generate() with per-row direction injection. All prompts are the identical
|
| 34 |
+
token sequence, so `marker` is prefill-absolute; decode steps (len-1 forwards under KV cache)
|
| 35 |
+
skip the hook — see make_inject_hook."""
|
| 36 |
+
B = dirs.shape[0]
|
| 37 |
+
ids = torch.tensor([prompt_ids] * B, dtype=torch.long, device=device)
|
| 38 |
+
attn = torch.ones_like(ids, dtype=torch.bool)
|
| 39 |
+
hook = make_inject_hook([dirs[i : i + 1] for i in range(B)], [[marker]] * B,
|
| 40 |
+
STEER_COEFF, device, torch.bfloat16)
|
| 41 |
+
kw = dict(max_new_tokens=a.max_new_tokens, min_new_tokens=a.min_new_tokens,
|
| 42 |
+
pad_token_id=tok.pad_token_id, do_sample=not greedy, use_cache=True)
|
| 43 |
+
if not greedy:
|
| 44 |
+
kw.update(temperature=a.temperature, top_p=1.0, top_k=0)
|
| 45 |
+
with hooked(get_layer(model, INJECT_LAYER), hook):
|
| 46 |
+
out = model.generate(input_ids=ids, attention_mask=attn, **kw)
|
| 47 |
+
stop = {tok.eos_token_id, tok.pad_token_id} # <|im_end|> or <|endoftext|> (also pad)
|
| 48 |
+
texts = []
|
| 49 |
+
for row in out:
|
| 50 |
+
comp = row[len(prompt_ids):].tolist()
|
| 51 |
+
stops = [j for j, t in enumerate(comp) if t in stop]
|
| 52 |
+
texts.append(tok.decode(comp[: stops[0]] if stops else comp,
|
| 53 |
+
skip_special_tokens=True).strip())
|
| 54 |
+
return texts
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@torch.no_grad()
|
| 58 |
+
def score(texts, feats, model, clean, tok, sae, device, a):
|
| 59 |
+
"""enc_act[i] = max_t relu((x_t-b_dec)·W_enc[:,f_i]+b_enc[f_i]) at READ_LAYER — standalone
|
| 60 |
+
re-tokenization (no chat template), clean base. Empty/all-filtered rows score 0."""
|
| 61 |
+
r = torch.zeros(len(texts))
|
| 62 |
+
valid = [i for i, t in enumerate(texts) if t.strip()]
|
| 63 |
+
prev = tok.padding_side
|
| 64 |
+
tok.padding_side = "right" # position 0 must be the first real token
|
| 65 |
+
try:
|
| 66 |
+
for s in range(0, len(valid), a.score_batch):
|
| 67 |
+
idxs = valid[s : s + a.score_batch]
|
| 68 |
+
enc = tok([texts[i] for i in idxs], return_tensors="pt", padding=True, truncation=True,
|
| 69 |
+
max_length=a.max_new_tokens + 32, add_special_tokens=False).to(device)
|
| 70 |
+
if enc["input_ids"].shape[1] == 0:
|
| 71 |
+
continue
|
| 72 |
+
with clean():
|
| 73 |
+
h, mask = read_resid(model, READ_LAYER, dict(enc), pool="all") # fp32 [b,T,d],[b,T]
|
| 74 |
+
norms = h.norm(dim=-1)
|
| 75 |
+
keep = mask & (norms <= NORM_FILTER_MULT * norms[mask].median())
|
| 76 |
+
keep[:, 0] = False
|
| 77 |
+
b = torch.arange(len(idxs), device=device)
|
| 78 |
+
per = sae.encode_features(h, [feats[i] for i in idxs])[b, :, b] # row i, feature i: [b,T]
|
| 79 |
+
best = per.masked_fill(~keep, 0.0).max(1).values # relu>=0: 0-fill == old no-kept guard
|
| 80 |
+
for row, i in enumerate(idxs):
|
| 81 |
+
r[i] = best[row].item()
|
| 82 |
+
finally:
|
| 83 |
+
tok.padding_side = prev
|
| 84 |
+
return r
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main():
|
| 88 |
+
ap = argparse.ArgumentParser()
|
| 89 |
+
ap.add_argument("--adapter", required=True, help="maxact-fast checkpoint dir, or 'none' for base")
|
| 90 |
+
ap.add_argument("--sae-path", default=None, help="ae.pt path; default: hf_hub_download")
|
| 91 |
+
ap.add_argument("--maxacts-path", default=None, help="max-acts .pt path; default: hf_hub_download")
|
| 92 |
+
ap.add_argument("--split", default=None, help="split.json from build_sae_data; uses its 'eval' half")
|
| 93 |
+
ap.add_argument("--n-features", type=int, default=0,
|
| 94 |
+
help="cap the split's eval list; without --split, seeded sample of alive features")
|
| 95 |
+
ap.add_argument("--best-of", type=int, default=16)
|
| 96 |
+
ap.add_argument("--temperature", type=float, default=1.0)
|
| 97 |
+
ap.add_argument("--batch-features", type=int, default=8, help="sampled gen batch = this * best-of")
|
| 98 |
+
ap.add_argument("--max-new-tokens", type=int, default=96)
|
| 99 |
+
ap.add_argument("--min-new-tokens", type=int, default=16)
|
| 100 |
+
ap.add_argument("--score-batch", type=int, default=128)
|
| 101 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 102 |
+
ap.add_argument("--out", required=True)
|
| 103 |
+
a = ap.parse_args()
|
| 104 |
+
assert a.split or a.n_features > 0, "need --split or --n-features"
|
| 105 |
+
torch.manual_seed(a.seed)
|
| 106 |
+
device = "cuda:0"
|
| 107 |
+
|
| 108 |
+
tok = AutoTokenizer.from_pretrained(MODEL)
|
| 109 |
+
if tok.pad_token is None:
|
| 110 |
+
tok.pad_token = tok.eos_token
|
| 111 |
+
prompt_ids, mpos = build_prompt_ids(tok)
|
| 112 |
+
marker = mpos[0]
|
| 113 |
+
|
| 114 |
+
dataset_max = load_max_acts(a.maxacts_path)["max_acts"].amax(dim=(1, 2)) # [F] corpus peaks
|
| 115 |
+
if a.split:
|
| 116 |
+
feats = json.load(open(a.split))["eval"]
|
| 117 |
+
if a.n_features:
|
| 118 |
+
feats = feats[: a.n_features]
|
| 119 |
+
else:
|
| 120 |
+
alive = (dataset_max > 0).nonzero(as_tuple=True)[0].tolist()
|
| 121 |
+
feats = sorted(random.Random(a.seed).sample(alive, min(a.n_features, len(alive))))
|
| 122 |
+
|
| 123 |
+
sae = load_sae(a.sae_path, device)
|
| 124 |
+
model = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16,
|
| 125 |
+
attn_implementation="sdpa", device_map={"": device})
|
| 126 |
+
if a.adapter != "none":
|
| 127 |
+
model = PeftModel.from_pretrained(model, a.adapter)
|
| 128 |
+
model.eval()
|
| 129 |
+
# scoring must see the clean base; with adapter='none' there is nothing to disable
|
| 130 |
+
clean = model.disable_adapter if a.adapter != "none" else contextlib.nullcontext
|
| 131 |
+
print(f"{len(feats)} eval features | adapter {a.adapter} | marker @{marker}", flush=True)
|
| 132 |
+
|
| 133 |
+
results = []
|
| 134 |
+
for s in range(0, len(feats), a.batch_features):
|
| 135 |
+
fb = feats[s : s + a.batch_features]
|
| 136 |
+
g_texts = generate(model, tok, prompt_ids, marker, sae.enc_dirs(fb), a, device, greedy=True)
|
| 137 |
+
flat = [f for f in fb for _ in range(a.best_of)]
|
| 138 |
+
s_texts = generate(model, tok, prompt_ids, marker, sae.enc_dirs(flat), a, device, greedy=False)
|
| 139 |
+
g_act = score(g_texts, fb, model, clean, tok, sae, device, a)
|
| 140 |
+
s_act = score(s_texts, flat, model, clean, tok, sae, device, a).view(len(fb), a.best_of)
|
| 141 |
+
for i, f in enumerate(fb):
|
| 142 |
+
bi = int(s_act[i].argmax())
|
| 143 |
+
results.append({"feature": int(f), "dataset_max": dataset_max[f].item(),
|
| 144 |
+
"greedy_text": g_texts[i], "greedy_act": g_act[i].item(),
|
| 145 |
+
"best_text": s_texts[i * a.best_of + bi], "best_act": s_act[i, bi].item()})
|
| 146 |
+
print(f"{len(results)}/{len(feats)} features", flush=True)
|
| 147 |
+
|
| 148 |
+
dmax = torch.tensor([max(r["dataset_max"], 1e-6) for r in results])
|
| 149 |
+
g = torch.tensor([r["greedy_act"] for r in results])
|
| 150 |
+
b = torch.tensor([r["best_act"] for r in results])
|
| 151 |
+
summary = {
|
| 152 |
+
"adapter": a.adapter, "n_features": len(results), "best_of": a.best_of,
|
| 153 |
+
"temperature": a.temperature,
|
| 154 |
+
"greedy/normalized_act": (g / dmax).mean().item(),
|
| 155 |
+
"greedy/normalized_act_median": (g / dmax).median().item(),
|
| 156 |
+
"greedy/beat_frac": (g > dmax).float().mean().item(),
|
| 157 |
+
f"best_of_{a.best_of}/normalized_act": (b / dmax).mean().item(),
|
| 158 |
+
f"best_of_{a.best_of}/normalized_act_median": ((b / dmax).median().item()),
|
| 159 |
+
f"best_of_{a.best_of}/beat_frac": (b > dmax).float().mean().item(),
|
| 160 |
+
}
|
| 161 |
+
print(json.dumps(summary, indent=2))
|
| 162 |
+
os.makedirs(os.path.dirname(a.out) or ".", exist_ok=True)
|
| 163 |
+
json.dump({"summary": summary, "results": results}, open(a.out, "w"))
|
| 164 |
+
print("EVAL_SAE_DONE", flush=True)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
main()
|
src/mxf/sae.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BatchTopK SAE (adamkarvonen/qwen3-8b-saes, dictionary_learning format) — minimal loader.
|
| 2 |
+
|
| 3 |
+
The conditioning/reward direction for feature f is the UNIT ENCODER COLUMN unit(W_enc[:,f])
|
| 4 |
+
(probe side — same space as the cluster-probe directions, drops into inject@1/read@L as-is).
|
| 5 |
+
Scoring uses raw per-sample encoder activations, never the batch-topk gate: we want a smooth
|
| 6 |
+
reward signal below the firing threshold.
|
| 7 |
+
"""
|
| 8 |
+
import torch
|
| 9 |
+
from huggingface_hub import hf_hub_download
|
| 10 |
+
|
| 11 |
+
from mxf.config import D_MODEL, READ_LAYER
|
| 12 |
+
|
| 13 |
+
SAE_REPO = "adamkarvonen/qwen3-8b-saes"
|
| 14 |
+
SAE_FILENAME = f"saes_Qwen_Qwen3-8B_batch_top_k/resid_post_layer_{READ_LAYER}/trainer_2/ae.pt"
|
| 15 |
+
MAX_ACTS_REPO = "adamkarvonen/sae_max_acts" # dataset repo
|
| 16 |
+
MAX_ACTS_FILENAME = (f"acts_Qwen_Qwen3-8B_layer_{READ_LAYER}_trainer_2_"
|
| 17 |
+
"layer_percent_75_context_length_32.pt")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class BatchTopKSAE:
|
| 21 |
+
"""W_enc [d,F], W_dec [F,d], b_enc [F], b_dec [d]."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, W_enc, W_dec, b_enc, b_dec):
|
| 24 |
+
self.W_enc, self.W_dec, self.b_enc, self.b_dec = W_enc, W_dec, b_enc, b_dec
|
| 25 |
+
self.d_in, self.d_sae = W_enc.shape
|
| 26 |
+
|
| 27 |
+
def encode_features(self, acts_BLD, feature_ids):
|
| 28 |
+
"""Pre-topk post-ReLU encoder activations relu((x-b_dec)@W_enc[:,f]+b_enc[f]).
|
| 29 |
+
acts_BLD [B,L,d] -> [B,L,len(feature_ids)]."""
|
| 30 |
+
idx = torch.as_tensor(feature_ids, device=self.W_enc.device)
|
| 31 |
+
return torch.relu((acts_BLD - self.b_dec) @ self.W_enc[:, idx] + self.b_enc[idx])
|
| 32 |
+
|
| 33 |
+
def enc_dirs(self, feature_ids):
|
| 34 |
+
"""Unit encoder columns [len(ids), d] — the conditioning/reward directions."""
|
| 35 |
+
idx = torch.as_tensor(feature_ids, device=self.W_enc.device)
|
| 36 |
+
return torch.nn.functional.normalize(self.W_enc[:, idx].T, dim=-1)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_sae(path=None, device="cpu", dtype=torch.float32):
|
| 40 |
+
"""path=None resolves via hf_hub_download (uses HF_HOME cache on the box)."""
|
| 41 |
+
path = path or hf_hub_download(repo_id=SAE_REPO, filename=SAE_FILENAME)
|
| 42 |
+
params = torch.load(path, map_location="cpu", weights_only=False)
|
| 43 |
+
key_map = {"encoder.weight": "W_enc", "decoder.weight": "W_dec", "encoder.bias": "b_enc",
|
| 44 |
+
"bias": "b_dec", "b_dec": "b_dec"} # dictionary_learning aliases for b_dec
|
| 45 |
+
t = {key_map[k]: v.to(dtype) for k, v in params.items() if k in key_map}
|
| 46 |
+
sae = BatchTopKSAE(t["W_enc"].T.contiguous().to(device), # nn.Linear stores [out, in]
|
| 47 |
+
t["W_dec"].T.contiguous().to(device),
|
| 48 |
+
t["b_enc"].to(device), t["b_dec"].to(device))
|
| 49 |
+
assert sae.d_in == D_MODEL, f"SAE d_in {sae.d_in} != D_MODEL {D_MODEL}"
|
| 50 |
+
nrm = sae.W_dec.norm(dim=1)
|
| 51 |
+
assert torch.allclose(nrm, torch.ones_like(nrm), atol=1e-2), "decoder rows must be unit norm"
|
| 52 |
+
return sae
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_max_acts(path=None):
|
| 56 |
+
"""{"max_tokens": [F,N,L] long, "max_acts": [F,N,L] float} on cpu. path=None resolves via HF."""
|
| 57 |
+
path = path or hf_hub_download(repo_id=MAX_ACTS_REPO, filename=MAX_ACTS_FILENAME,
|
| 58 |
+
repo_type="dataset")
|
| 59 |
+
data = torch.load(path, map_location="cpu", weights_only=False)
|
| 60 |
+
assert "max_tokens" in data and "max_acts" in data, f"unexpected keys: {list(data.keys())}"
|
| 61 |
+
return data
|