| """Build an SAE-feature post-train dataset in the exact vec-bank format pretrain.py consumes. |
| |
| Per feature: direction = unit(W_enc[:,f]) (encoder column, probe side — the same side eval_sae.py |
| rewards); targets = the feature's top --targets corpus windows (by per-window peak act) decoded to |
| text. Features are split (seeded, --eval-frac) into train/eval and ONLY train features enter |
| vecs.f32/records.jsonl — the eval half is reserved for cross-uplift (eval_sae.py --split |
| <out>/split.json). build_stats.json carries n_examples so scripts/rl.py --data-dir <out> also |
| works on this bank unchanged. |
| |
| python scripts/build_sae_data.py --out-dir data/sae --targets 3 |
| """ |
| import argparse |
| import json |
| import os |
| import random |
|
|
| import numpy as np |
| import torch |
| from transformers import AutoTokenizer |
|
|
| from mxf.config import D_MODEL, MODEL |
| from mxf.sae import load_max_acts, load_sae |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--sae-path", default=None, help="ae.pt path; default: hf_hub_download") |
| ap.add_argument("--maxacts-path", default=None, help="max-acts .pt path; default: hf_hub_download") |
| ap.add_argument("--out-dir", default="data/sae") |
| ap.add_argument("--n-features", type=int, default=0, help="0 = ALL alive features; else seeded sample") |
| ap.add_argument("--targets", type=int, default=3, help="top corpus windows per feature") |
| ap.add_argument("--min-act", type=float, default=0.0, help="drop features with corpus peak <= this") |
| ap.add_argument("--eval-frac", type=float, default=0.5) |
| ap.add_argument("--seed", type=int, default=0) |
| a = ap.parse_args() |
| os.makedirs(a.out_dir, exist_ok=True) |
| rng = random.Random(a.seed) |
|
|
| tok = AutoTokenizer.from_pretrained(MODEL) |
| sae = load_sae(a.sae_path) |
| data = load_max_acts(a.maxacts_path) |
| tokens, acts = data["max_tokens"], data["max_acts"] |
| assert acts.shape[0] == sae.d_sae, f"max-acts F={acts.shape[0]} != SAE F={sae.d_sae}" |
| dataset_max = acts.amax(dim=(1, 2)) |
|
|
| alive = (dataset_max > a.min_act).nonzero(as_tuple=True)[0].tolist() |
| if a.n_features and a.n_features < len(alive): |
| alive = rng.sample(alive, a.n_features) |
| rng.shuffle(alive) |
| n_eval = int(len(alive) * a.eval_frac) |
| ev, train = sorted(alive[:n_eval]), sorted(alive[n_eval:]) |
| json.dump({"seed": a.seed, "eval_frac": a.eval_frac, "min_act": a.min_act, |
| "train": train, "eval": ev}, open(f"{a.out_dir}/split.json", "w")) |
| print(f"{len(alive)} alive features (min_act {a.min_act}) -> {len(train)} train / {len(ev)} eval", |
| flush=True) |
|
|
| dirs = torch.nn.functional.normalize(sae.W_enc, dim=0) |
| rows, skipped = [], 0 |
| for j, f in enumerate(train): |
| peak = acts[f].amax(dim=-1) |
| for w in peak.argsort(descending=True)[: a.targets].tolist(): |
| if peak[w] <= 0: |
| break |
| text = tok.decode(tokens[f, w].tolist(), skip_special_tokens=True).strip() |
| if len(text) < 3: |
| skipped += 1 |
| continue |
| rows.append((f, text, peak[w].item())) |
| if (j + 1) % 5000 == 0: |
| print(f"{j + 1}/{len(train)} features, {len(rows)} records", flush=True) |
|
|
| assert rows, "no records minted — check --min-act / max-acts file" |
| vecs = np.memmap(f"{a.out_dir}/vecs.f32", dtype=np.float32, mode="w+", |
| shape=(len(rows), D_MODEL)) |
| with open(f"{a.out_dir}/records.jsonl", "w") as recs: |
| for n, (f, text, act) in enumerate(rows): |
| vecs[n] = dirs[:, f].numpy() |
| recs.write(json.dumps({"vec_idx": n, "target_text": text, "feature": f, |
| "act": round(act, 3)}) + "\n") |
| vecs.flush() |
| stats = {"n_examples": len(rows), "n_train_features": len(train), "n_eval_features": len(ev), |
| "targets_per_feature": a.targets, "skipped_short": skipped, "seed": a.seed} |
| json.dump(stats, open(f"{a.out_dir}/build_stats.json", "w"), indent=1) |
| print(f"BUILD_SAE_DATA_DONE {stats}", flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|