File size: 6,846 Bytes
ba7ef34 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """Replicate the REAL arm of the common-split chain experiment over seeds.
The paper's null step is measured over twelve seeds; the real transition it is
compared against was drawn once. A reviewer's objection follows directly: with
n=1 on the treatment arm there is no real-arm distribution, so "the null step
reproduces 86% of the real drift" is a statement about one draw.
This runs extra REAL chains from the *same* base SAE saved by
`jumprelu_sft_chain.py`, so the real arm gets a seed distribution of its own,
and (optionally) re-runs the NULL chains with decoder weights saved so that the
two clouds can be compared directionally rather than only in magnitude.
Seeding follows `jumprelu_sft_chain.py` exactly -- torch.manual_seed(1000*seed+i)
-- so real seed 0 reproduces the chain already on disk. Real seed j and null
seed j therefore share an RNG stream while training on different activations:
that is common random numbers, a paired design that removes shuffle-order
variance from the real-minus-null difference.
python3 scripts/real_arm_replicate.py --layer 18 --real-seeds 0 1 2 3 \
--null-seeds 0 1 2 3 --save-weights --out real_arm_L18
"""
import argparse
import json
import pathlib
import sys
import time
import pandas as pd
import torch
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, ROOT_DEFAULT)
from jumprelu_sft_chain import (DRIFT, SFT_STAGES, SAE, evaluate, fit, loaders,
normalize_decoder)
from sae_tuned_lens.readout import (compose_lens, load_readout, matched_and_null,
token_overlap, top_tokens)
from sae_tuned_lens.tuned_lens import load_lens
# Paths below were absolute in the authors' environment. Set SAE_RL_ROOT to the
# directory holding drift_run/ and sae_rl/, or edit ROOT_DEFAULT.
import os as _os
ROOT_DEFAULT = _os.environ.get("SAE_RL_ROOT", ".")
ARMS = {"TopK_k64": dict(arch="topk", k=64, lam=0.0),
"JumpReLU": dict(arch="jumprelu", k=None, lam=3e-4)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--layer", type=int, default=18)
ap.add_argument("--arm", default="TopK_k64")
ap.add_argument("--base", type=str, default=None,
help="base SAE state_dict; defaults to the run that made it")
ap.add_argument("--real-seeds", type=int, nargs="*", default=[0, 1, 2, 3])
ap.add_argument("--null-seeds", type=int, nargs="*", default=[])
ap.add_argument("--epochs", type=int, default=20)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--batch-size", type=int, default=512)
ap.add_argument("--save-weights", action="store_true",
help="save decoder matrices at every step, for the "
"cloud-vs-cloud direction test")
ap.add_argument("--out", type=str, required=True)
ap.add_argument("--device", default="cuda")
args = ap.parse_args()
default_base = {6: "jr_L6_topk", 12: "jr_L12_topk",
18: "jumprelu_sft_topk", 23: "jr_L23_topk"}
if args.base is None:
args.base = str(DRIFT / default_base[args.layer] /
f"{args.arm}_L{args.layer}_base.pt")
out = DRIFT / args.out
out.mkdir(parents=True, exist_ok=True)
wdir = out / "weights"
if args.save_weights:
wdir.mkdir(exist_ok=True)
logf = open(out / "run.log", "a", buffering=1)
def log(m):
print(m, flush=True)
logf.write(m + "\n")
dev, cfg = args.device, ARMS[args.arm]
torch.backends.cuda.matmul.allow_tf32 = True
t0 = time.time()
log(f"\n{'='*70}\nreal-arm replication | layer {args.layer} | {args.arm}")
log(f"base {args.base}")
log(f"real seeds {args.real_seeds} | null seeds {args.null_seeds} | "
f"{args.epochs} epochs/step")
base_state = torch.load(args.base, map_location=dev)
base_tr, base_va = loaders("instruct_base", args.layer, args.batch_size, dev)
U = load_readout(str(DRIFT / "qwen05b/model.safetensors"), fold_final_norm=True,
device=dev)
P = compose_lens(U, load_lens(
str(DRIFT / f"drift_out/tuned_lens_layer{args.layer}.pt")).A.to(dev))
sae0 = SAE(cfg["arch"], cfg["k"]).to(dev)
sae0.load_state_dict(base_state)
Wd0 = sae0.decoder.weight.data.T.contiguous()
top0 = top_tokens(Wd0, P, k=10)
bmse, bl0, bdead = evaluate(sae0, base_va, dev)
log(f"base: val_mse={bmse:.6f} L0={bl0:.1f} dead={100*bdead:.1f}%")
if args.save_weights:
torch.save(Wd0.cpu(), wdir / "base.pt")
rows = []
def record(**kw):
rows.append(kw)
pd.DataFrame(rows).to_csv(out / "real_arm_replicate.csv", index=False)
def run_chain(chain, seed):
stages = ["instruct_base"] * len(SFT_STAGES) if chain == "null" else SFT_STAGES
state = base_state
for i, stage in enumerate(stages, start=1):
tr, va = (base_tr, base_va) if chain == "null" else \
loaders(stage, args.layer, args.batch_size, dev)
torch.manual_seed(1000 * seed + i)
s = SAE(cfg["arch"], cfg["k"]).to(dev)
s.load_state_dict(state)
log(f" -- {chain} seed{seed} step {i}/{len(stages)} ({stage})")
state, vmse, kept = fit(s, tr, va, args.epochs, args.lr, cfg["lam"],
dev, lambda m: None)
m = SAE(cfg["arch"], cfg["k"]).to(dev)
m.load_state_dict(state)
Wd = m.decoder.weight.data.T.contiguous()
r = matched_and_null(Wd0, Wd, None)
tok = token_overlap(top0, top_tokens(Wd, P, k=10))
_, l0, dead = evaluate(m, base_va, dev)
log(f" dec_cos={r['cos_matched_mean']:.4f} "
f"tok_J={tok['tok_jaccard_matched']:.4f} L0={l0:.1f} "
f"dead={100*dead:.1f}% kept_ep={kept}")
if args.save_weights:
torch.save(Wd.cpu(), wdir / f"{chain}_s{seed}_step{i}.pt")
record(layer=args.layer, arm=args.arm, chain=chain, seed=seed,
step=i, stage=stage, dec_cos=r["cos_matched_mean"],
drift=1.0 - r["cos_matched_mean"],
cos_null_pairing=r["cos_null_mean"],
drift_norm=r["drift_norm"],
tok_jaccard=tok["tok_jaccard_matched"],
frac_identical_top1=tok["frac_identical_top1"],
val_mse=vmse, mean_l0=l0, dead_frac=dead, kept_epoch=kept)
for sd in args.real_seeds:
run_chain("real", sd)
for sd in args.null_seeds:
run_chain("null", sd)
(out / "config.json").write_text(json.dumps(vars(args), indent=2))
log(f"\ndone in {(time.time()-t0)/60:.1f} min -> {out/'real_arm_replicate.csv'}")
if __name__ == "__main__":
main()
|