ceselder Claude Fable 5 commited on
Commit
64c6b48
·
1 Parent(s): 28ad77d

sae: loader + build_sae_data + eval_sae (cross-uplift metric); weights_only=False for torch 2.13

Browse files
Files changed (3) hide show
  1. scripts/build_sae_data.py +88 -0
  2. scripts/eval_sae.py +168 -0
  3. 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