fela-acml2026 / scripts /explain.py
itstheraj's picture
FELA: training code, checkpoints, and evaluation results
a24d5a6
Raw
History Blame
15.7 kB
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
import torch
import torch.nn.functional as F
sys.path.insert(0, str(Path(__file__).parent.parent))
from model_cpu_gpt2 import CPUGPT, CPUGPTBlock, CPUGPTConfig, gpt2_small_config
SAMPLE_TEXTS = [
"The quick brown fox jumps over the lazy dog",
"In the beginning was the Word, and the Word was",
"The capital of France is Paris and the capital of Germany",
]
def load_model(ckpt: str, device: str) -> CPUGPT:
cfg = gpt2_small_config(seq_len=1024)
model = CPUGPT(cfg).to(device).eval()
if not ckpt:
print("[explain] No checkpoint — random init", flush=True)
return model
if ckpt.startswith("s3://"):
import boto3
bucket, key = ckpt[5:].split("/", 1)
local = "/tmp/explain_ckpt.pt"
boto3.client("s3").download_file(bucket, key, local)
ckpt = local
elif ckpt.startswith("hf://"):
from huggingface_hub import hf_hub_download
path = ckpt[5:]
repo, filename = path.rsplit("/", 1)
ckpt = hf_hub_download(
repo_id=repo, filename=filename, token=os.environ.get("HF_TOKEN")
)
raw = torch.load(ckpt, map_location="cpu", weights_only=False)
state = raw.get("model", raw)
state = {
k.replace("module.", "").replace("_orig_mod.", ""): v for k, v in state.items()
}
if "wte.weight" in state:
ckpt_embd = state["wte.weight"].shape[1]
if ckpt_embd != cfg.n_embd:
cfg = gpt2_small_config(seq_len=1024, n_embd=ckpt_embd)
cfg.n_head = max(1, ckpt_embd // 64)
for k, v in state.items():
if "filter_td" in k:
cfg.fno_modes = v.shape[1]
break
model = CPUGPT(cfg).to(device).eval()
ms = model.state_dict()
model.load_state_dict(
{k: v for k, v in state.items() if k in ms and v.shape == ms[k].shape},
strict=False,
)
print(f"[explain] loaded {model.param_count() / 1e6:.1f}M params", flush=True)
return model
def get_tokenizer():
try:
from transformers import GPT2Tokenizer
return GPT2Tokenizer.from_pretrained("gpt2")
except Exception:
import tiktoken
return tiktoken.get_encoding("r50k_base")
def encode(tok, text: str) -> list[int]:
if hasattr(tok, "encode") and hasattr(tok, "decode"):
try:
return tok.encode(text)
except TypeError:
return tok.encode(text, add_special_tokens=False)
return tok.encode(text, add_special_tokens=False)
def decode_token(tok, tid: int) -> str:
try:
return repr(tok.decode([tid]))
except Exception:
return f"<{tid}>"
def logit_lens(
model: CPUGPT, tok, texts: list[str], device: str, top_k: int = 5
) -> dict:
print("[explain] Running logit lens ...", flush=True)
results = {}
unembed = model.lm_head.weight
for text in texts:
ids = encode(tok, text)[: model.cfg.seq_len - 1]
idx = torch.tensor([ids], dtype=torch.long, device=device)
layer_preds = []
residuals = []
hooks = []
def make_hook(i):
def h(module, inp, out):
residuals.append((i, out.detach().clone()))
return h
for i, block in enumerate(model.blocks):
hooks.append(block.register_forward_hook(make_hook(i)))
with torch.no_grad():
_ = model(idx)
for h in hooks:
h.remove()
for layer_i, resid in residuals:
normed = F.rms_norm(resid[0], (resid.shape[-1],))
logits = (normed @ unembed.T).float()
logits = 15.0 * torch.tanh(logits / 15.0)
top = logits[-1].topk(top_k)
preds = [
{"token": decode_token(tok, int(t)), "logit": round(float(v), 3)}
for t, v in zip(top.indices, top.values)
]
layer_preds.append({"layer": layer_i, "top_k": preds})
residuals_ref = residuals
input_tokens = [decode_token(tok, t) for t in ids]
results[text] = {
"input_tokens": input_tokens,
"n_tokens": len(ids),
"layers": layer_preds,
}
print(
f" '{text[:40]}' — layer-by-layer prediction at last position:", flush=True
)
for lp in layer_preds:
top1 = lp["top_k"][0]["token"]
top1_logit = lp["top_k"][0]["logit"]
print(
f" layer {lp['layer']:2d}: {top1:15s} (logit {top1_logit:.2f})",
flush=True,
)
return results
def gla_state_probe(
model: CPUGPT, tok, texts: list[str], device: str, top_k: int = 10
) -> dict:
print("[explain] Running GLA state probing ...", flush=True)
results = {}
unembed = model.lm_head.weight.float()
for text in texts:
ids = encode(tok, text)[: model.cfg.seq_len - 1]
input_tokens = [decode_token(tok, t) for t in ids]
text_results = {"input_tokens": input_tokens, "positions": []}
probe_positions = sorted({0, len(ids) // 2, len(ids) - 1})
for pos in probe_positions:
prefix_ids = ids[: pos + 1]
prefix_idx = torch.tensor([prefix_ids], dtype=torch.long, device=device)
state_captures = {}
hooks = []
gla_blocks = [(i, b) for i, b in enumerate(model.blocks) if b.is_gla]
def make_state_hook(layer_i):
pass
def make_gla_hook(layer_i):
def h(module, inp, out):
x = inp[0]
with torch.no_grad():
B, T, C = x.shape
H, D = module.n_head, module.d_head
CS = module.chunk
if T < CS:
return
q = module.q_proj(x).reshape(B, T, H, D).transpose(1, 2)
k = module.k_proj(x).reshape(B, T, H, D).transpose(1, 2)
v = module.v_proj(x).reshape(B, T, H, D).transpose(1, 2)
log_g = -F.softplus(module.g_proj(x).float()).transpose(1, 2)
n_chunks = T // CS
chunk_log_g = log_g.reshape(B, H, n_chunks, CS).sum(-1)
chunk_gate = chunk_log_g.exp()
k_k = F.elu(k.float()) + 1.0
v_f = v.float()
state = torch.zeros(
B, H, D, D, device=x.device, dtype=torch.float32
)
for c in range(n_chunks):
g = chunk_gate[:, :, c, None, None]
k_c = k_k[:, :, c * CS : (c + 1) * CS, :]
v_c = v_f[:, :, c * CS : (c + 1) * CS, :]
state = g * state + k_c.transpose(-2, -1) @ v_c
state_captures[layer_i] = state[0].cpu()
return h
for layer_i, block in gla_blocks:
hooks.append(block.mixer.register_forward_hook(make_gla_hook(layer_i)))
with torch.no_grad():
_ = model(prefix_idx)
for h in hooks:
h.remove()
pos_result = {
"pos": pos,
"token": decode_token(tok, ids[pos]),
"layers": {},
}
for layer_i, state in state_captures.items():
H, D, _ = state.shape
mean_state = state.mean(0)
U, S, Vh = torch.linalg.svd(mean_state)
top_dir = U[:, 0]
H_idx = 0
v_proj_w = model.blocks[layer_i].mixer.v_proj.weight
head_slice = v_proj_w.reshape(H, D, -1)[H_idx]
dir_full = (top_dir.to(device) @ v_proj_w[:D, :].float()).cpu()
dir_full = F.normalize(dir_full, dim=0)
sims = (unembed.cpu() @ dir_full).float()
top_vocab = sims.topk(top_k)
concepts = [
{"token": decode_token(tok, int(t)), "sim": round(float(s), 3)}
for t, s in zip(top_vocab.indices, top_vocab.values)
]
pos_result["layers"][f"layer_{layer_i}"] = {
"top_singular_value": round(float(S[0]), 4),
"dominant_concepts": concepts,
}
print(
f" pos={pos} ('{decode_token(tok, ids[pos])}') layer_{layer_i}: "
f"state SV={S[0]:.2f}, concepts: "
f"{[c['token'] for c in concepts[:3]]}",
flush=True,
)
text_results["positions"].append(pos_result)
results[text] = text_results
return results
def integrated_gradients(
model: CPUGPT, tok, texts: list[str], device: str, steps: int = 20
) -> dict:
print("[explain] Running integrated gradients ...", flush=True)
results = {}
for text in texts:
ids = encode(tok, text)[: model.cfg.seq_len - 1]
if len(ids) < 2:
continue
idx = torch.tensor([ids], dtype=torch.long, device=device)
with torch.no_grad():
logits = model(idx)
with torch.no_grad():
x = model.wte(idx)
x = F.rms_norm(x, (x.size(-1),))
for block in model.blocks:
x = block(x)
x = model.ln_out(x)
logits = model.lm_head(x).float()
logits = 15.0 * torch.tanh(logits / 15.0)
target_id = int(logits[0, -1].argmax())
target_token = decode_token(tok, target_id)
emb = model.wte(idx).detach()
baseline = torch.zeros_like(emb)
grads_accum = torch.zeros_like(emb)
for step in range(steps):
alpha = (step + 0.5) / steps
interp = baseline + alpha * (emb - baseline)
interp = interp.requires_grad_(True)
x = F.rms_norm(interp, (interp.size(-1),))
for block in model.blocks:
x = block(x)
x = model.ln_out(x)
logit_target = (model.lm_head(x).float())[0, -1, target_id]
logit_target = 15.0 * torch.tanh(logit_target / 15.0)
logit_target.backward()
grads_accum += interp.grad.detach()
ig = ((emb - baseline) * grads_accum / steps).norm(dim=-1)[0]
ig_norm = (ig / (ig.sum() + 1e-8)).tolist()
input_tokens = [decode_token(tok, t) for t in ids]
top_idx = sorted(range(len(ig_norm)), key=lambda i: ig_norm[i], reverse=True)[
:5
]
results[text] = {
"predicted_next": target_token,
"input_tokens": input_tokens,
"attributions": [round(v, 4) for v in ig_norm],
"top_influential": [
{
"pos": i,
"token": input_tokens[i],
"attribution": round(ig_norm[i], 4),
}
for i in top_idx
],
}
print(f" '{text[:40]}' → predicts {target_token}", flush=True)
for item in results[text]["top_influential"][:3]:
print(
f" [{item['pos']}] {item['token']:15s} attr={item['attribution']:.3f}",
flush=True,
)
return results
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", default="")
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
ap.add_argument("--out-dir", default="/tmp/explain")
ap.add_argument("--ig-steps", type=int, default=20)
args = ap.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
model = load_model(args.ckpt, args.device)
tok = get_tokenizer()
print(f"\n{'=' * 60}", flush=True)
print("1. LOGIT LENS", flush=True)
print("=" * 60, flush=True)
ll = logit_lens(model, tok, SAMPLE_TEXTS, args.device)
with open(out_dir / "logit_lens.json", "w") as f:
json.dump(ll, f, indent=2)
print(f"\n{'=' * 60}", flush=True)
print("2. GLA STATE PROBING", flush=True)
print("=" * 60, flush=True)
gp = gla_state_probe(model, tok, SAMPLE_TEXTS, args.device)
with open(out_dir / "gla_state_probing.json", "w") as f:
json.dump(gp, f, indent=2)
print(f"\n{'=' * 60}", flush=True)
print("3. INTEGRATED GRADIENTS", flush=True)
print("=" * 60, flush=True)
ig = integrated_gradients(
model, tok, SAMPLE_TEXTS, args.device, steps=args.ig_steps
)
with open(out_dir / "integrated_gradients.json", "w") as f:
json.dump(ig, f, indent=2)
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
text0 = SAMPLE_TEXTS[0]
n_layers = len(ll[text0]["layers"])
n_tok = ll[text0]["n_tokens"]
lens_matrix = np.zeros((n_layers, 1))
layer_labels, top1_tokens = [], []
for lp in ll[text0]["layers"]:
layer_labels.append(f"L{lp['layer']}")
top1_tokens.append(
f"{lp['top_k'][0]['token']}\n({lp['top_k'][0]['logit']:.1f})"
)
lens_matrix[lp["layer"], 0] = lp["top_k"][0]["logit"]
axes[0].barh(range(n_layers), lens_matrix[:, 0], color="#2196F3")
axes[0].set_yticks(range(n_layers))
axes[0].set_yticklabels(
[f"L{i}: {t}" for i, t in zip(range(n_layers), top1_tokens)], fontsize=6
)
axes[0].set_xlabel("Top-1 logit at last position")
axes[0].set_title(f"Logit Lens\n'{text0[:35]}...'", fontsize=9)
if text0 in ig:
attrs = ig[text0]["attributions"]
toks = ig[text0]["input_tokens"]
axes[1].bar(range(len(attrs)), attrs, color="#4CAF50")
axes[1].set_xticks(range(len(toks)))
axes[1].set_xticklabels(toks, rotation=45, ha="right", fontsize=6)
axes[1].set_ylabel("Attribution (normalized)")
axes[1].set_title(
f"Integrated Gradients → {ig[text0]['predicted_next']}", fontsize=9
)
gp0 = gp.get(text0, {})
if gp0.get("positions"):
last_pos = gp0["positions"][-1]
layers = list(last_pos["layers"].keys())
svs = [last_pos["layers"][l]["top_singular_value"] for l in layers]
axes[2].bar(range(len(layers)), svs, color="#FF5722")
axes[2].set_xticks(range(len(layers)))
axes[2].set_xticklabels(layers, rotation=45, ha="right", fontsize=8)
axes[2].set_ylabel("Top singular value")
axes[2].set_title(
f"GLA State Magnitude\npos={last_pos['pos']} ('{last_pos['token']}')",
fontsize=9,
)
plt.suptitle("FELA Mechanistic Interpretability", fontsize=11, y=1.02)
plt.tight_layout()
plt.savefig(out_dir / "explain_summary.png", dpi=150, bbox_inches="tight")
plt.close()
print(f"\n[explain] Summary plot → {out_dir}/explain_summary.png", flush=True)
except ImportError:
print("[explain] matplotlib not available — JSON only", flush=True)
print(f"\n[explain] Done. Outputs in {out_dir}/", flush=True)
for p in sorted(out_dir.iterdir()):
print(f" {p.name} ({p.stat().st_size // 1024} KB)", flush=True)
if __name__ == "__main__":
main()