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()