""" Compare editing-method effects on the toilet readout, per layer. Idea ──── For each model variant (base, lora, efuf, nullu) read the *toilet* attention probe score σ(logit) ∈ [0,1] at every layer, on bathroom-only images, and compare each method to base: cell[method, l] = mean_images( σ_toilet_l(method) − σ_toilet_l(base) ) NO gradient steering — the *edit itself* is the intervention. To isolate the representational effect of the edit (not a change in what the model says), every method reads the SAME fixed context per image: the BASE model's generated caption (or --forced_text). The probe pools over those caption tokens; only the model weights differ between rows. The base row is therefore exactly 0 (reference). Images: bathroom-only (bathroom=1 & toilet=0), split into two populations by the base model's hallucination flag (base_mentions_object on --base_prompt): • NON-hallucinating (base did NOT say toilet) • HALLUCINATING (base DID say toilet) One figure, two panels (non | halluc), each a (methods × layers) heatmap; base − base = 0. Models are loaded ONE AT A TIME (base first, to fix the captions), so we never hold four 7B models on the GPU simultaneously. Saves: heatmap PNG + the raw (methods × layers) matrices as JSON. """ import argparse import gc import json import os import random import numpy as np import torch as t from PIL import Image from transformers import AutoConfig, LlavaProcessor import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from model.llava.hooked_llava import HookedSAELlavaConditionalGeneration from mechanistic_interp.sequence_probe import sequence_layer_probes_from_checkpoint _HOOK_SUFFIX = {"pre": "hook_resid_pre", "mid": "hook_resid_mid", "post": "hook_resid_post"} # ── Model-edit helpers (canonical home; gradient_ascent.py / train_probe_latent.py # import apply_efuf_edit / apply_nullu_edit from here). ────────────────────────── # EFUF overwrites the multimodal projector; map each .pth key -> (submodule, attr). _EFUF_PROJ_KEYS = { "model.mm_projector.0.weight": ("linear_1", "weight"), "model.mm_projector.0.bias": ("linear_1", "bias"), "model.mm_projector.2.weight": ("linear_2", "weight"), "model.mm_projector.2.bias": ("linear_2", "bias"), } # Nullu splices mlp.down_proj.weight; accept the common HF key prefixes. _DOWN_PROJ_PREFIXES = ("model.layers", "language_model.model.layers", "language_model.layers", "model.language_model.layers") _DOWN_PROJ_SUFFIX = ".mlp.down_proj.weight" def apply_efuf_edit(model, ckpt_path): """Overwrite the multimodal projector in-place from an EFUF .pth checkpoint.""" raw = t.load(ckpt_path, map_location="cpu", weights_only=True) missing = [k for k in _EFUF_PROJ_KEYS if k not in raw] if missing: raise KeyError(f"EFUF ckpt {ckpt_path} missing {missing}; has {list(raw.keys())}") proj = model.multi_modal_projector with t.no_grad(): for key, (lin, attr) in _EFUF_PROJ_KEYS.items(): tgt = getattr(getattr(proj, lin), attr) tgt.data.copy_(raw[key].to(device=tgt.device, dtype=tgt.dtype)) return len(_EFUF_PROJ_KEYS) def _match_down_proj_layer(key): """Return the layer index if `key` is an mlp.down_proj.weight, else None.""" if not key.endswith(_DOWN_PROJ_SUFFIX): return None for prefix in _DOWN_PROJ_PREFIXES: if key.startswith(prefix + "."): mid = key[len(prefix) + 1: -len(_DOWN_PROJ_SUFFIX)] if mid.isdigit(): return int(mid) return None def apply_nullu_edit(model, lm_layers, ckpt_dir, lowest, highest): """Splice Nullu-edited mlp.down_proj.weight into layers [lowest, highest) in-place.""" import glob from safetensors.torch import safe_open wanted = set(range(lowest, highest)) found = {} shards = sorted(glob.glob(os.path.join(ckpt_dir, "*.safetensors"))) if shards: for sh in shards: with safe_open(sh, framework="pt") as f: for key in f.keys(): idx = _match_down_proj_layer(key) if idx is not None and idx in wanted and idx not in found: found[idx] = f.get_tensor(key) if len(found) == len(wanted): break else: for bf in sorted(glob.glob(os.path.join(ckpt_dir, "*.bin"))): sd = t.load(bf, map_location="cpu", weights_only=True) for key, tns in sd.items(): idx = _match_down_proj_layer(key) if idx is not None and idx in wanted and idx not in found: found[idx] = tns if len(found) == len(wanted): break missing = wanted - set(found) if missing: raise KeyError(f"Nullu dir {ckpt_dir} missing down_proj for layers {sorted(missing)}") with t.no_grad(): for idx, w in found.items(): tgt = lm_layers[idx].mlp.down_proj.weight tgt.data.copy_(w.to(device=tgt.device, dtype=tgt.dtype)) return len(found) # ── Model-variant loading (base / lora / nullu / efuf), per attribution_patching ── def _decoder_layers(model): lm = getattr(model, "language_model", None) or getattr(getattr(model, "model", None), "language_model", None) inner = getattr(lm, "model", None) if inner is not None and hasattr(inner, "layers"): return inner.layers return lm.layers def build_model(variant, args, dtype, device): """Build one model variant. PROBES are loaded separately (base-trained); only the MODEL weights change here. Caller is responsible for freeing the previous model.""" if variant == "lora": from model.llava.hooked_lora_llava import HookedLoRALlava model = HookedLoRALlava.from_pretrained( args.model_name, torch_dtype=dtype, device_map={"": device}).eval() lora_dir = args.lora_path if os.path.isdir(args.lora_path) else os.path.dirname(args.lora_path) model.load_lora_adapter(lora_dir, merge=True) print(f"[compare] model=lora (merged {lora_dir})") else: model = HookedSAELlavaConditionalGeneration.from_pretrained( args.model_name, torch_dtype=dtype, device_map={"": device}).eval() if variant == "efuf": n = apply_efuf_edit(model, args.efuf_path) print(f"[compare] model=efuf ({n} proj tensors, {args.efuf_path})") elif variant == "nullu": n = apply_nullu_edit(model, _decoder_layers(model), args.nullu_path, args.nullu_lowest, args.nullu_highest) print(f"[compare] model=nullu ({n} layers [{args.nullu_lowest},{args.nullu_highest}), {args.nullu_path})") else: print(f"[compare] model=base") return model def free_model(model): del model gc.collect() if t.cuda.is_available(): t.cuda.empty_cache() def hp_name(layer: int, hook_type: str) -> str: return f"model.language_model.layers.{layer}.{_HOOK_SUFFIX[hook_type]}" def caption_slice(attn_mask, asst_text, processor, max_seq_tokens): """(start, end) indices of the caption tokens at the tail of the real region, matching training's a[:max_seq_tokens].""" seq_len = int(attn_mask.sum().item()) cap_len = len(processor.tokenizer(asst_text, add_special_tokens=False)["input_ids"]) if cap_len <= 0: return None start = seq_len - cap_len end = start + min(cap_len, max_seq_tokens) if start < 0: return None return start, end @t.no_grad() def generate_caption(model, processor, image, question, device, max_new_tokens): prompt = f"USER: \n{question}\nASSISTANT:" inp = processor(images=[image], text=[prompt], return_tensors="pt").to(device) out = model.generate(**inp, do_sample=False, num_beams=1, use_cache=True, max_new_tokens=max_new_tokens) cap = processor.batch_decode(out, skip_special_tokens=True)[0] return cap.split("ASSISTANT:")[-1].strip() def probe_logit(probe_module, layer, seq_feats): """Scalar logit for one image from one layer's attention probe. seq_feats: (1,T,d).""" kpm = t.zeros(seq_feats.shape[:2], dtype=t.bool, device=seq_feats.device) # no padding return probe_module.probes[probe_module._idx[layer]](seq_feats, kpm).squeeze(0) def probe_score(probe_module, layer, seq_feats): """Bounded probe score σ(logit) = P(concept present) ∈ [0,1].""" return t.sigmoid(probe_logit(probe_module, layer, seq_feats)) @t.no_grad() def toilet_scores_per_layer(model, processor, toilet, img, asst, question, device, hps, layers, max_seq_tokens): """Forward `img` with the fixed ASSISTANT answer `asst`, return σ_toilet at every layer (np array of length n_layers), or None if the caption slice is invalid.""" forced = f"USER: \n{question}\nASSISTANT: {asst}" fwd = processor(images=[img], text=[forced], return_tensors="pt").to(device) sl = caption_slice(fwd["attention_mask"], asst, processor, max_seq_tokens) if sl is None: return None s0, s1 = sl acts = {} def cap(name): def _fn(act, hook): acts[name] = act return _fn model.run_with_hooks(fwd, fwd_hooks=[(hp, cap(hp)) for hp in hps]) out = np.empty(len(layers), dtype=np.float64) for l in layers: feats = acts[hps[l]][:, s0:s1].float() out[l] = float(probe_score(toilet, l, feats).item()) return out def plot_method_layer(mats, methods, layers, out, title, cbar_label="Δ toilet score σ (method − base)"): """mats: {group_name: (n_methods, n_layers) np array}. One panel per group, shared symmetric diverging scale + single colorbar. methods on y, layers on x.""" groups = [g for g in mats if mats[g] is not None] ncol = len(groups) fig, axes = plt.subplots(1, ncol, figsize=(0.30 * len(layers) + 2.0, 0.55 * len(methods) + 2.0), squeeze=False) allv = np.concatenate([mats[g][np.isfinite(mats[g])].ravel() for g in groups]) vmax = float(np.nanmax(np.abs(allv))) if allv.size else 1.0 vmax = max(vmax, 1e-6) im = None for k, g in enumerate(groups): ax = axes[0][k] im = ax.imshow(mats[g], origin="upper", cmap="RdBu_r", vmin=-vmax, vmax=vmax, aspect="auto") ax.set_title(g) ax.set_xlabel("layer") ax.set_yticks(range(len(methods))) ax.set_yticklabels(methods) ax.set_xticks(range(0, len(layers), 4)) cbar = fig.colorbar(im, ax=axes, fraction=0.025, pad=0.02) cbar.set_label(cbar_label) fig.suptitle(title, fontsize=12) os.makedirs(os.path.dirname(out), exist_ok=True) fig.savefig(out, dpi=150, bbox_inches="tight") plt.close(fig) print(f"[compare] saved {out}") def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--model_name", default="llava-hf/llava-1.5-7b-hf") ap.add_argument("--device_id", type=int, default=0) ap.add_argument("--dtype", default="bfloat16", choices=["float32", "float16", "bfloat16"]) ap.add_argument("--methods", nargs="+", default=["base", "lora", "efuf", "nullu"], choices=["base", "lora", "efuf", "nullu"], help="Variants to compare. 'base' is the reference (its row is all 0); " "it is always loaded first to fix the captions even if omitted here.") # Edit checkpoints (only loaded if the method is requested). ap.add_argument("--lora_path", default="/data/caotue/multilayer-sae/adv_gen_outputs/run_bathroom_toilet_v2/lora_adapter") ap.add_argument("--efuf_path", default="/data/caotue/multilayer-sae/EFUF/efuf/checkpoints/llava_vicuna_7b/bathroom_toilet_paper_10ep/epoch_002.pth") ap.add_argument("--nullu_path", default="/data/caotue/nullu/edited_models/LLaVA-7B-top4-0-32-bathroom_toilet") ap.add_argument("--nullu_lowest", type=int, default=8) ap.add_argument("--nullu_highest", type=int, default=32) # Probe — base-trained toilet readout. ap.add_argument("--toilet_probe", default="/data/caotue/latent_probes/seqprobes_toilet/post/seqprobe.pth") ap.add_argument("--image_folder", default="/data/caotue/CC3M-Dataset/cc3m_images") ap.add_argument("--samples_json", default="mechanistic_interp/toilet_bathroom/samples.json") ap.add_argument("--category", default="bathroom_only", choices=["bathroom_only", "bathroom_toilet", "toilet_only", "others"], help="Dataset to evaluate. bathroom_only = toilet ABSENT (base mention = " "hallucination); bathroom_toilet / toilet_only = toilet PRESENT (Δσ<0 = " "edit suppresses a real toilet); others = unrelated negatives from " "--others_jsonl (single 'unrelated' panel; Δσ≠0 = edit spuriously moves toilet).") ap.add_argument("--others_jsonl", default="mechanistic_interp/neg_cc3m_5k.json", help="For --category others: {train:[ids], validation:[ids]} of CC3M stems.") ap.add_argument("--others_split", default="validation", choices=["validation", "train"], help="Which split of --others_jsonl to use (default validation = held out " "from the 4-variant probe's negatives).") ap.add_argument("--base_prompt", default="Describe this image.", help="prompt_results key whose base_mentions_object flag splits the two populations.") ap.add_argument("--population", default="both", choices=["all", "both", "said", "unsaid", "halluc", "non"], help="How to group the category: 'all' = ONE set, no base-mention split; " "said/halluc = base DID mention toilet; unsaid/non = did not; both = both panels.") ap.add_argument("--id_col", default="image_id") ap.add_argument("--question", default="Describe this image.", help="The text fed to every model (USER turn).") ap.add_argument("--forced_text", default="This image features a bathroom with a", help="Constant ASSISTANT answer used as the fixed context for ALL " "images/methods (the probe reads these tokens). Set to '' to instead " "use the base model's per-image generated caption.") ap.add_argument("--num_images", type=int, default=100, help="Random-subsample cap PER population (0 = ALL). Shuffled by --seed.") ap.add_argument("--seed", type=int, default=0) ap.add_argument("--hook_type", default="post", choices=["pre", "mid", "post"]) ap.add_argument("--max_new_tokens", type=int, default=64) ap.add_argument("--max_seq_tokens", type=int, default=64) ap.add_argument("--out", default="mechanistic_interp/graph/compare_baselines_toilet.png") ap.add_argument("--out_json", default="mechanistic_interp/graph/compare_baselines_toilet.json") args = ap.parse_args() dtype = {"float32": t.float32, "float16": t.float16, "bfloat16": t.bfloat16}[args.dtype] device = f"cuda:{args.device_id}" if t.cuda.is_available() else "cpu" n_layers = AutoConfig.from_pretrained(args.model_name).text_config.num_hidden_layers layers = list(range(n_layers)) hps = [hp_name(l, args.hook_type) for l in layers] # base must run first (it fixes the per-image captions); keep requested order otherwise. methods = ["base"] + [m for m in args.methods if m != "base"] print(f"[compare] device={device} dtype={dtype} layers={n_layers}") print(f"[compare] methods={methods} readout=toilet ({args.toilet_probe})") print(f"[compare] context={'forced: ' + repr(args.forced_text) if args.forced_text else 'base caption per image'}") # ── Select images for the chosen dataset ────────────────────────────────────── stem_to_file = {} for root, _, files in os.walk(args.image_folder): for fn in files: if fn.lower().endswith((".jpg", ".jpeg", ".png", ".webp")): stem_to_file[os.path.splitext(fn)[0]] = os.path.join(root, fn) if args.category == "others": # Unrelated negatives from neg_cc3m_5k.json — no base-mention split, one panel. neg = json.load(open(args.others_jsonl)) raw = neg.get(args.others_split, []) ids = [s for s in (os.path.splitext(os.path.basename(x))[0] for x in raw) if s in stem_to_file] groups = {"unrelated": ids} else: data = json.load(open(args.samples_json)) cat_items = [it for it in data if it.get("category") == args.category] if args.population == "all": # ONE combined set for the category — no base-mention split. ids = [s for it in cat_items if (s := os.path.splitext(os.path.basename(it[args.id_col]))[0]) in stem_to_file] groups = {args.category: ids} else: # Population labels depend on whether the category actually contains a toilet: # toilet ABSENT -> base mention = hallucination # toilet PRESENT -> base mention = correct; missing it = a miss has_toilet = args.category != "bathroom_only" lbl = ({True: "toilet-said (correct)", False: "toilet-missed"} if has_toilet else {True: "hallucinating", False: "non-hallucinating"}) groups = {lbl[True]: [], lbl[False]: []} for it in cat_items: flag = it["prompt_results"].get(args.base_prompt, {}).get("base_mentions_object") if flag not in (True, False): continue stem = os.path.splitext(os.path.basename(it[args.id_col]))[0] if stem in stem_to_file: groups[lbl[flag]].append(stem) sel = {"both": [True, False], "said": [True], "halluc": [True], "unsaid": [False], "non": [False]}[args.population] groups = {lbl[f]: groups[lbl[f]] for f in sel} for g in groups: if args.num_images and args.num_images > 0: random.Random(args.seed).shuffle(groups[g]) groups[g] = groups[g][:args.num_images] print(f"[compare] {args.category} images: " + ", ".join(f"{g}={len(ids)}" for g, ids in groups.items())) toilet = sequence_layer_probes_from_checkpoint(args.toilet_probe, device) toilet.eval() processor = LlavaProcessor.from_pretrained(args.model_name) # caption_by_stem fixed by the base pass; per_img[method][stem] = σ_toilet per layer. caption_by_stem = {} img_cache = {} per_img = {m: {} for m in methods} def load_img(stem): if stem not in img_cache: try: img_cache[stem] = Image.open(stem_to_file[stem]).convert("RGB") except Exception: img_cache[stem] = None return img_cache[stem] for m in methods: model = build_model(m, args, dtype, device) for g, ids in groups.items(): for i, stem in enumerate(ids): img = load_img(stem) if img is None: continue if m == "base": asst = args.forced_text or generate_caption( model, processor, img, args.question, device, args.max_new_tokens) caption_by_stem[stem] = asst else: asst = caption_by_stem.get(stem) if asst is None: # base failed on this image → skip everywhere continue scores = toilet_scores_per_layer(model, processor, toilet, img, asst, args.question, device, hps, layers, args.max_seq_tokens) if scores is not None: per_img[m][stem] = scores print(f"[compare] {m}: scored {len([s for s in ids if s in per_img[m]])}/{len(ids)} ({g})") free_model(model) # ── Build (methods × layers) Δ matrices per group: method − base, paired per image ── mats = {} for g, ids in groups.items(): if not ids: mats[g] = None continue M = np.full((len(methods), n_layers), np.nan) for mi, m in enumerate(methods): paired = [per_img[m][s] - per_img["base"][s] for s in ids if s in per_img[m] and s in per_img["base"]] if paired: M[mi] = np.mean(paired, axis=0) mats[g] = M # Base model's ABSOLUTE toilet σ per layer (so the score can be compared across prompts). base_abs = {} for g, ids in groups.items(): arr = [per_img["base"][s] for s in ids if s in per_img["base"]] base_abs[g] = (np.mean(arr, axis=0).tolist() if arr else None) # ── Save JSON ──────────────────────────────────────────────────────────────── os.makedirs(os.path.dirname(args.out_json), exist_ok=True) with open(args.out_json, "w") as f: json.dump({"methods": methods, "n_layers": n_layers, "hook_type": args.hook_type, "context": args.forced_text if args.forced_text else "base_caption", "n_images": {g: len(ids) for g, ids in groups.items()}, "base_abs_toilet": base_abs, "delta": {g: (None if mats[g] is None else np.where(np.isnan(mats[g]), None, mats[g]).tolist()) for g in groups}}, f) print(f"[compare] saved {args.out_json}") # ── Heatmap ────────────────────────────────────────────────────────────────── ctx = "forced" if args.forced_text else "base caption" plot_method_layer( mats, methods, layers, args.out, title=f"toilet readout: method − base, per layer — {args.category} ({ctx} context, {args.hook_type})") if __name__ == "__main__": main()