| """ |
| knowledge_suppression_trace.py — internal SAE-activation comparison. |
| |
| For each bathroom-only image (object perceptually absent, scene present): |
| |
| 1. The base model free-greedy-decodes K tokens. This sequence is the |
| **matched input** shared across all methods. Using one fixed |
| sequence isolates the effect of *weights* from the confound of each |
| method generating a different continuation. |
| |
| 2. Each method (base / ours = ΔW@all from the LoRA adapter / Nullu) is |
| teacher-forced on the matched K-token sequence under a fresh |
| forward pass. |
| |
| 3. At every selected layer, the residual stream is captured **only at |
| the K generated-text positions** (sliced from the tail, so any |
| image-token expansion in the middle of the sequence is irrelevant |
| to the slice). The residuals are SAE-encoded and the pre-selected |
| "confident toilet" features (per layer) are gathered. |
| |
| 4. Per-layer scalar = aggregator over (K positions × top_k features). |
| A single PNG per image plots one curve per method; a population |
| summary aggregates across all images. |
| |
| Why text positions only: we hypothesise the toilet *knowledge* lives in |
| the LLM's text-side computation. So we probe at the residuals carrying |
| the assistant's continuation, not at the image-patch positions. |
| |
| No τ_c threshold — the claim is comparative: at every layer, the |
| "ours" curve should sit below Nullu/EFUF. Output-suppression methods |
| (Nullu/EFUF) leave a mid-network hump in the trajectory; a method that |
| removes the knowledge from the weights should keep the curve flat |
| throughout. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import sys |
| import traceback |
| from contextlib import contextmanager |
| from typing import Dict, List |
|
|
| |
| _PARENT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| if _PARENT not in sys.path: |
| sys.path.insert(0, _PARENT) |
| _REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| if _REPO not in sys.path: |
| sys.path.insert(0, _REPO) |
|
|
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import numpy as np |
| import torch |
| from tqdm import tqdm |
| from transformers import LlavaProcessor |
|
|
| from mechanistic_interp.delta_w_feature_trace import ( |
| CATEGORY_CHOICES, |
| PROMPT_TEMPLATE, |
| build_hook_name, |
| build_image_index, |
| filter_samples, |
| sae_lookup, |
| teacher_forced_capture, |
| ) |
| from mechanistic_interp.lora_delta import applied_lora_pairs, load_lora_pairs |
| from model.llava.hooked_llava import HookedSAELlavaConditionalGeneration |
| from sae.SAE_Tools import load_sae_model |
|
|
|
|
| |
|
|
| COLOR_BASE = "#1f77b4" |
| COLOR_OURS = "#2ca02c" |
| COLOR_NULLU = "#ff7f0e" |
| COLOR_EFUF = "#9467bd" |
|
|
| METHOD_ORDER = ("base", "ours", "nullu", "efuf") |
| METHOD_STYLES = { |
| "base": dict(color=COLOR_BASE, linestyle="-", marker="o", label="base"), |
| "ours": dict(color=COLOR_OURS, linestyle="--", marker="s", label="ΔW@all (ours)"), |
| "nullu": dict(color=COLOR_NULLU, linestyle="-.", marker="^", label="Nullu"), |
| "efuf": dict(color=COLOR_EFUF, linestyle=":", marker="D", label="EFUF"), |
| } |
|
|
| AGG_CHOICES = ("max", "mean") |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| _NULLU_LAYER_PARAM_NAMES = ( |
| "input_layernorm.weight", |
| "post_attention_layernorm.weight", |
| "self_attn.q_proj.weight", |
| "self_attn.k_proj.weight", |
| "self_attn.v_proj.weight", |
| "self_attn.o_proj.weight", |
| "mlp.gate_proj.weight", |
| "mlp.up_proj.weight", |
| "mlp.down_proj.weight", |
| ) |
|
|
|
|
| def _get_layer_param(model, layer_idx: int, param_name: str) -> torch.nn.Parameter: |
| """Resolve `model.model.language_model.layers[layer_idx].<param_name>`.""" |
| mod = model.model.language_model.layers[layer_idx] |
| for attr in param_name.split("."): |
| mod = getattr(mod, attr) |
| return mod |
|
|
|
|
| def _load_nullu_layer_weights( |
| nullu_model_path: str, layer_indices: List[int] |
| ) -> Dict[int, Dict[str, "torch.Tensor"]]: |
| """Read Nullu's edited per-layer weights from a HF-format directory. Mirrors |
| ``Nullu/scripts/eval_relation.py:_splice_edited_layers``: walks the |
| safetensors shard map and pulls every key matching ``model.layers.{L}.*`` |
| for L in ``layer_indices``. Returns ``{L: {param_name: cpu_tensor}}``. |
| """ |
| import os |
| from safetensors import safe_open |
|
|
| index_path = os.path.join(nullu_model_path, "model.safetensors.index.json") |
| single_path = os.path.join(nullu_model_path, "model.safetensors") |
| prefixes = tuple(f"model.layers.{L}." for L in layer_indices) |
|
|
| out: Dict[int, Dict[str, "torch.Tensor"]] = {L: {} for L in layer_indices} |
|
|
| def _stash_key(key: str, tensor: "torch.Tensor"): |
| |
| if not key.startswith("model.layers."): |
| return |
| rest = key[len("model.layers."):] |
| layer_str, _, param_name = rest.partition(".") |
| try: |
| L = int(layer_str) |
| except ValueError: |
| return |
| if L not in out: |
| return |
| if param_name in _NULLU_LAYER_PARAM_NAMES: |
| out[L][param_name] = tensor.detach().cpu() |
|
|
| if os.path.exists(index_path): |
| with open(index_path) as f: |
| weight_map = json.load(f)["weight_map"] |
| |
| shards: Dict[str, List[str]] = {} |
| for key in weight_map.keys(): |
| if key.startswith(prefixes): |
| shards.setdefault(weight_map[key], []).append(key) |
| for shard, keys in shards.items(): |
| with safe_open(os.path.join(nullu_model_path, shard), |
| framework="pt", device="cpu") as f: |
| for key in keys: |
| _stash_key(key, f.get_tensor(key)) |
| elif os.path.exists(single_path): |
| with safe_open(single_path, framework="pt", device="cpu") as f: |
| for key in f.keys(): |
| if key.startswith(prefixes): |
| _stash_key(key, f.get_tensor(key)) |
| else: |
| raise FileNotFoundError( |
| f"No safetensors in {nullu_model_path}. " |
| f"Expected model.safetensors.index.json or model.safetensors." |
| ) |
|
|
| missing = {L: [p for p in _NULLU_LAYER_PARAM_NAMES if p not in out[L]] |
| for L in layer_indices} |
| missing = {L: ps for L, ps in missing.items() if ps} |
| if missing: |
| raise RuntimeError( |
| f"Nullu ckpt missing per-layer params:\n " + |
| "\n ".join(f"L{L}: {ps}" for L, ps in missing.items()) |
| ) |
| return out |
|
|
|
|
| @contextmanager |
| def applied_nullu_layers( |
| model, |
| edited_cpu: Dict[int, Dict[str, "torch.Tensor"]], |
| original_cpu: Dict[int, Dict[str, "torch.Tensor"]], |
| ): |
| """Swap Nullu's full edited decoder layers (8-32 by default) in-place on |
| the existing GPU model; restore originals on exit. Never holds a second |
| 7B model on GPU.""" |
| try: |
| for L, params in edited_cpu.items(): |
| for pname, src in params.items(): |
| tgt = _get_layer_param(model, L, pname) |
| tgt.data.copy_(src.to(device=tgt.device, dtype=tgt.dtype)) |
| yield |
| finally: |
| for L, params in original_cpu.items(): |
| for pname, src in params.items(): |
| tgt = _get_layer_param(model, L, pname) |
| tgt.data.copy_(src.to(device=tgt.device, dtype=tgt.dtype)) |
|
|
|
|
| |
| |
| |
| |
| |
| EFUF_KEY_MAP = { |
| "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", |
| } |
|
|
|
|
| def _load_efuf_projector_weights(ckpt_path: str) -> Dict[str, "torch.Tensor"]: |
| """Load EFUF's edited mm_projector weights, mapped to HF names. Returns |
| ``{'linear_1.weight': T, 'linear_1.bias': T, 'linear_2.weight': T, 'linear_2.bias': T}`` |
| on CPU.""" |
| ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False) |
| sd = ckpt["model"] if isinstance(ckpt, dict) and "model" in ckpt else ckpt |
| out = {} |
| for liuhao_key, hf_name in EFUF_KEY_MAP.items(): |
| if liuhao_key not in sd: |
| raise KeyError( |
| f"EFUF ckpt {ckpt_path!r} missing {liuhao_key!r}. " |
| f"Available: {[k for k in sd.keys() if 'projector' in k]}" |
| ) |
| out[hf_name] = sd[liuhao_key].detach().cpu().clone() |
| return out |
|
|
|
|
| @contextmanager |
| def applied_efuf_projector( |
| model, edited_cpu: Dict[str, torch.Tensor], original_cpu: Dict[str, torch.Tensor], |
| ): |
| """Swap the HF multi_modal_projector's 4 weights with EFUF's edited |
| versions, then restore the originals on exit. CPU→GPU one-shot copies |
| into existing GPU tensors — never holds two model copies on GPU. |
| """ |
| mmp = model.model.multi_modal_projector |
| submap = { |
| "linear_1.weight": mmp.linear_1.weight, |
| "linear_1.bias": mmp.linear_1.bias, |
| "linear_2.weight": mmp.linear_2.weight, |
| "linear_2.bias": mmp.linear_2.bias, |
| } |
| try: |
| for k, src in edited_cpu.items(): |
| tgt = submap[k] |
| tgt.data.copy_(src.to(device=tgt.device, dtype=tgt.dtype)) |
| yield |
| finally: |
| for k, src in original_cpu.items(): |
| tgt = submap[k] |
| tgt.data.copy_(src.to(device=tgt.device, dtype=tgt.dtype)) |
|
|
|
|
| |
|
|
| @torch.no_grad() |
| def capture_text_pos_acts( |
| *, |
| model, |
| sae, |
| sae_batch, |
| device, |
| dtype_attn, |
| full_ids: torch.Tensor, |
| pixel_values, |
| new_len: int, |
| layers: List[int], |
| hook_type: str, |
| selected: Dict[int, List[int]], |
| ) -> Dict[int, torch.Tensor]: |
| """Teacher-force ``full_ids`` (1, T_input) once, hook every selected |
| layer, then take the last ``new_len`` residual positions (the assistant's |
| K generated tokens — unambiguously after any image-token expansion). |
| SAE-encode and gather ``selected[L]``. Returns ``{L: (new_len, top_k_L)}``. |
| """ |
| hook_names = {build_hook_name(L, hook_type) for L in layers} |
| cache = teacher_forced_capture(model, full_ids, pixel_values, hook_names, dtype_attn) |
| acts: Dict[int, torch.Tensor] = {} |
| for L in layers: |
| hp = build_hook_name(L, hook_type) |
| cache_t = cache.get(hp) |
| if cache_t is None: |
| continue |
| slice_ = cache_t[0, -new_len:] |
| feats = selected.get(L, []) |
| if feats: |
| acts[L] = sae_lookup(slice_, feats, sae, sae_batch, device) |
| else: |
| acts[L] = torch.zeros(new_len, 0) |
| return acts |
|
|
|
|
| def per_layer_scalar( |
| act_map: Dict[int, torch.Tensor], layers: List[int], agg: str |
| ) -> np.ndarray: |
| """Collapse ``{L: (K, top_k)}`` → ``(n_layers,)`` per-layer scalar.""" |
| out = np.full(len(layers), np.nan, dtype=np.float32) |
| for i, L in enumerate(layers): |
| t = act_map.get(L) |
| if t is None or t.numel() == 0: |
| continue |
| tf = t.float() |
| out[i] = float(tf.max()) if agg == "max" else float(tf.mean()) |
| return out |
|
|
|
|
| |
|
|
| @torch.no_grad() |
| def trace_one_image( |
| *, |
| sample, |
| image_index, |
| prompt, |
| processor, |
| model, |
| sae, |
| lora_pairs, |
| lora_scale, |
| nullu_payload, |
| efuf_payload, |
| selected, |
| layers, |
| hook_type, |
| gen_tokens, |
| sae_batch, |
| device, |
| dtype_attn, |
| fixed_assistant_prefix: str = "", |
| ): |
| image_id = sample["image_id"] |
| image = image_index.get(str(image_id)) |
| if image is None: |
| return None, f"image not found in HF split for {image_id}" |
|
|
| text = PROMPT_TEMPLATE.format(question=prompt) |
| inputs = processor(images=image, text=text, return_tensors="pt").to(device) |
| prompt_ids = inputs["input_ids"] |
| pixel_values = inputs["pixel_values"] |
| prompt_len = int(prompt_ids.shape[1]) |
|
|
| if fixed_assistant_prefix: |
| |
| |
| |
| |
| full_text = text + " " + fixed_assistant_prefix |
| full_inputs = processor(images=image, text=full_text, |
| return_tensors="pt").to(device) |
| matched_ids = full_inputs["input_ids"] |
| new_len = int(matched_ids.shape[1] - prompt_len) |
| if new_len <= 0: |
| return None, "fixed_assistant_prefix tokenized to 0 new tokens" |
| matched_text = processor.tokenizer.decode(matched_ids[0, prompt_len:]) |
| else: |
| |
| gen = model.generate( |
| **inputs, |
| do_sample=False, num_beams=1, use_cache=True, |
| max_new_tokens=gen_tokens, |
| ) |
| matched_ids = gen[:, : prompt_len + gen_tokens] |
| new_len = int(matched_ids.shape[1] - prompt_len) |
| if new_len <= 0: |
| return None, "base produced no new tokens" |
| matched_text = processor.tokenizer.decode(matched_ids[0, prompt_len:]) |
|
|
| out = { |
| "image_id": image_id, |
| "category": sample.get("category"), |
| "matched_text": matched_text, |
| "new_len": new_len, |
| "feature_ids_per_layer": {L: selected[L] for L in layers if L in selected}, |
| "acts": {}, |
| } |
|
|
| def _capture(): |
| return capture_text_pos_acts( |
| model=model, sae=sae, sae_batch=sae_batch, |
| device=device, dtype_attn=dtype_attn, |
| full_ids=matched_ids, pixel_values=pixel_values, new_len=new_len, |
| layers=layers, hook_type=hook_type, selected=selected, |
| ) |
|
|
| |
| out["acts"]["base"] = _capture() |
|
|
| |
| if any("language_model" in mp for mp in lora_pairs): |
| with applied_lora_pairs( |
| model, lora_pairs, lora_scale, |
| components="all", layers=layers, language_only=True, lowmem=True, |
| ): |
| out["acts"]["ours"] = _capture() |
|
|
| |
| if nullu_payload is not None: |
| with applied_nullu_layers( |
| model, |
| edited_cpu=nullu_payload["edited_cpu"], |
| original_cpu=nullu_payload["original_cpu"], |
| ): |
| out["acts"]["nullu"] = _capture() |
|
|
| |
| if efuf_payload is not None: |
| with applied_efuf_projector( |
| model, |
| edited_cpu=efuf_payload["edited_cpu"], |
| original_cpu=efuf_payload["original_cpu"], |
| ): |
| out["acts"]["efuf"] = _capture() |
|
|
| return out, None |
|
|
|
|
| |
|
|
| def _draw_curves(ax, scalars: Dict[str, np.ndarray], layers, xs): |
| for key in METHOD_ORDER: |
| ys = scalars.get(key) |
| if ys is None: |
| continue |
| style = METHOD_STYLES[key] |
| ax.plot( |
| xs, ys, |
| color=style["color"], linestyle=style["linestyle"], marker=style["marker"], |
| linewidth=2, markersize=4, label=style["label"], |
| ) |
| ax.set_xticks(xs) |
| ax.set_xticklabels([str(L) for L in layers], fontsize=8) |
| ax.set_xlabel("capture layer") |
| ax.legend(loc="best") |
| ax.grid(True, linestyle=":", alpha=0.4) |
|
|
|
|
| def plot_per_sample(out, layers, graph_dir, agg): |
| image_id = out["image_id"] |
| sample_dir = os.path.join(graph_dir, str(image_id)) |
| os.makedirs(sample_dir, exist_ok=True) |
|
|
| xs = np.arange(len(layers)) |
| scalars = { |
| key: per_layer_scalar(out["acts"][key], layers, agg) |
| for key in out["acts"] |
| } |
|
|
| fig, ax = plt.subplots(figsize=(11, 5)) |
| _draw_curves(ax, scalars, layers, xs) |
| ax.set_ylabel(f"per-layer scalar [{agg} over (K positions × top-k features)]") |
| title = ( |
| f"{image_id} — internal toilet-feature activation (matched input)\n" |
| f"matched continuation: {out['matched_text'].strip()[:80]!r}" |
| ) |
| ax.set_title(title, fontsize=10, loc="left") |
| out_path = os.path.join(sample_dir, "internal_activation_trace.png") |
| fig.savefig(out_path, dpi=110, bbox_inches="tight") |
| plt.close(fig) |
| return out_path |
|
|
|
|
| def plot_summary(per_image_scalars, layers, graph_dir, agg, n_images): |
| fig, ax = plt.subplots(figsize=(11, 5)) |
| xs = np.arange(len(layers)) |
| for key in METHOD_ORDER: |
| lst = per_image_scalars.get(key) or [] |
| if not lst: |
| continue |
| arr = np.stack(lst, axis=0) |
| med = np.nanmedian(arr, axis=0) |
| lo = np.nanpercentile(arr, 25, axis=0) |
| hi = np.nanpercentile(arr, 75, axis=0) |
| style = METHOD_STYLES[key] |
| ax.plot( |
| xs, med, |
| color=style["color"], linestyle=style["linestyle"], marker=style["marker"], |
| linewidth=2, markersize=4, label=f"{style['label']} (N={len(lst)})", |
| ) |
| ax.fill_between(xs, lo, hi, color=style["color"], alpha=0.15) |
| ax.set_xticks(xs) |
| ax.set_xticklabels([str(L) for L in layers], fontsize=8) |
| ax.set_xlabel("capture layer") |
| ax.set_ylabel(f"per-layer scalar [{agg} over (K positions × top-k features)]") |
| ax.set_title( |
| f"Population summary across {n_images} bathroom-only images " |
| f"(median ± IQR)", |
| fontsize=11, loc="left", |
| ) |
| ax.legend(loc="best") |
| ax.grid(True, linestyle=":", alpha=0.4) |
| out_path = os.path.join(graph_dir, "_summary.png") |
| os.makedirs(graph_dir, exist_ok=True) |
| fig.savefig(out_path, dpi=120, bbox_inches="tight") |
| plt.close(fig) |
| return out_path |
|
|
|
|
| |
|
|
| def main(): |
| p = argparse.ArgumentParser() |
| p.add_argument("--features_json", required=True, |
| help="Per-layer top-k confident toilet features " |
| "(same format as select_features.py output).") |
| p.add_argument("--samples_json", |
| default="mechanistic_interp/toilet-bathroom/lora_adapter/samples.json") |
| p.add_argument("--prompt", default="Describe this image.") |
| p.add_argument("--hf_dataset", default="pbcong/bathroom-toilet") |
| p.add_argument("--hf_split", default="validation") |
| p.add_argument("--id_col", default="image_id") |
|
|
| p.add_argument("--adapter_path", |
| default="mechanistic_interp/toilet-bathroom/lora_adapter/adapter_model.safetensors") |
| p.add_argument("--adapter_cfg", |
| default="mechanistic_interp/toilet-bathroom/lora_adapter/adapter_config.json") |
| p.add_argument("--sae_ckpt", required=True) |
|
|
| p.add_argument("--nullu_model_path", default=None, |
| help="Path to Nullu's edited model dir. If unset, Nullu is skipped.") |
| p.add_argument("--nullu_lowest_layer", type=int, default=16) |
| p.add_argument("--nullu_highest_layer", type=int, default=32) |
| p.add_argument("--efuf_ckpt", default=None, |
| help="Path to an EFUF epoch_XXX.pth (liuhaotian-format state-dict " |
| "containing model.mm_projector.{0,2}.{weight,bias}). " |
| "If unset, the EFUF curve is omitted.") |
|
|
| p.add_argument("--model_name", default="llava-hf/llava-1.5-7b-hf") |
| p.add_argument("--device", default="cuda:0") |
| p.add_argument("--dtype", default="bfloat16", |
| choices=["float32", "float16", "bfloat16"]) |
|
|
| p.add_argument("--n_samples", type=int, default=0, |
| help="0 = all category-matching samples.") |
| p.add_argument("--gen_tokens", type=int, default=32, |
| help="K — length of the matched base continuation. " |
| "Ignored when --fixed_assistant_prefix is non-empty.") |
| p.add_argument("--fixed_assistant_prefix", default="", |
| help="If non-empty, skip base free-generation and use this " |
| "string as the assistant-side text under every method. " |
| "K = number of tokens it produces. Example: " |
| "\"In this image there is a toilet\"") |
| p.add_argument("--hook_type", default="post", choices=["pre", "mid", "post"]) |
| p.add_argument("--sae_batch", type=int, default=2048) |
| p.add_argument("--category", choices=CATEGORY_CHOICES, default="bathroom_only", |
| help="Default 'bathroom_only': D_{A,¬c} from the spec.") |
| p.add_argument("--agg", choices=AGG_CHOICES, default="max", |
| help="Per-layer aggregator. 'max' = peak across (positions × features); " |
| "'mean' = average across the same set.") |
|
|
| p.add_argument("--out_dir", required=True) |
| p.add_argument("--graph_dir", required=True) |
| args = p.parse_args() |
|
|
| dtype_map = {"float32": torch.float32, "float16": torch.float16, "bfloat16": torch.bfloat16} |
| dtype = dtype_map[args.dtype] |
|
|
| os.makedirs(args.out_dir, exist_ok=True) |
| os.makedirs(args.graph_dir, exist_ok=True) |
| torch.set_grad_enabled(False) |
|
|
| |
| with open(args.features_json) as f: |
| feat_json = json.load(f) |
| selected: Dict[int, List[int]] = {} |
| for k, v in feat_json.items(): |
| if not k.startswith("layer_"): |
| continue |
| L = int(k.split("_")[1]) |
| selected[L] = list(map(int, v["features"])) |
| layers = sorted(selected.keys()) |
| if not layers: |
| raise ValueError("no per-layer features in features_json") |
| print(f"Confident toilet features for {len(layers)} layers " |
| f"(top_k={len(selected[layers[0]])})") |
|
|
| |
| |
| |
| |
| keep = filter_samples(args.samples_json, args.prompt, args.category, gen_mode="scratch") |
| print(f"Filter category={args.category}: {len(keep)} samples") |
| if args.n_samples > 0: |
| keep = keep[: args.n_samples] |
| print(f"Capped to first {len(keep)} (--n_samples={args.n_samples})") |
|
|
| |
| print("Loading model …") |
| processor = LlavaProcessor.from_pretrained(args.model_name) |
| model = HookedSAELlavaConditionalGeneration.from_pretrained( |
| args.model_name, attn_implementation="eager", |
| ).to(args.device, dtype=dtype).eval() |
|
|
| cfg = json.loads(open(args.adapter_cfg).read()) |
| lora_scale = cfg["lora_alpha"] / cfg["r"] |
| pairs = load_lora_pairs(args.adapter_path) |
| n_lm = sum(1 for mp in pairs if "language_model" in mp) |
| print(f"LoRA pairs: {len(pairs)} (language_model: {n_lm}) | scale={lora_scale}") |
|
|
| nullu_payload = None |
| if args.nullu_model_path: |
| n_total = model.config.text_config.num_hidden_layers |
| if not (0 <= args.nullu_lowest_layer < args.nullu_highest_layer <= n_total): |
| raise ValueError( |
| f"need 0 <= nullu_lowest_layer < nullu_highest_layer <= {n_total}; " |
| f"got {args.nullu_lowest_layer}-{args.nullu_highest_layer}" |
| ) |
| nullu_idxs = list(range(args.nullu_lowest_layer, args.nullu_highest_layer)) |
| print(f"Loading Nullu full layer splice for layers " |
| f"{nullu_idxs[0]}-{nullu_idxs[-1]} (inclusive) — " |
| f"{len(_NULLU_LAYER_PARAM_NAMES)} params × {len(nullu_idxs)} layers") |
| edited_cpu = _load_nullu_layer_weights(args.nullu_model_path, nullu_idxs) |
| original_cpu = { |
| L: { |
| pname: _get_layer_param(model, L, pname).detach().cpu().clone() |
| for pname in _NULLU_LAYER_PARAM_NAMES |
| } |
| for L in nullu_idxs |
| } |
| |
| for L in nullu_idxs: |
| for pname in _NULLU_LAYER_PARAM_NAMES: |
| want = original_cpu[L][pname].shape |
| got = edited_cpu[L][pname].shape |
| if want != got: |
| raise ValueError( |
| f"Nullu L{L} {pname}: edited shape {tuple(got)} != " |
| f"model shape {tuple(want)}" |
| ) |
| nullu_payload = { |
| "edited_cpu": edited_cpu, |
| "original_cpu": original_cpu, |
| "layer_indices": nullu_idxs, |
| } |
|
|
| efuf_payload = None |
| if args.efuf_ckpt: |
| print(f"Loading EFUF mm_projector weights from {args.efuf_ckpt}") |
| edited_proj = _load_efuf_projector_weights(args.efuf_ckpt) |
| mmp = model.model.multi_modal_projector |
| original_proj = { |
| "linear_1.weight": mmp.linear_1.weight.detach().cpu().clone(), |
| "linear_1.bias": mmp.linear_1.bias.detach().cpu().clone(), |
| "linear_2.weight": mmp.linear_2.weight.detach().cpu().clone(), |
| "linear_2.bias": mmp.linear_2.bias.detach().cpu().clone(), |
| } |
| |
| for k, t in edited_proj.items(): |
| if t.shape != original_proj[k].shape: |
| raise ValueError( |
| f"EFUF {k} shape {tuple(t.shape)} does not match HF model " |
| f"shape {tuple(original_proj[k].shape)}" |
| ) |
| efuf_payload = {"edited_cpu": edited_proj, "original_cpu": original_proj} |
|
|
| print("Loading SAE …") |
| sae = load_sae_model(args.sae_ckpt, model_type="llava", hook_type="text", device=args.device) |
|
|
| image_index = build_image_index(args.hf_dataset, args.hf_split, args.id_col) |
| needed = {str(s["image_id"]) for s in keep} |
| have = needed & set(image_index.keys()) |
| print(f"HF images indexed: {len(image_index)} | needed: {len(needed)} | resolved: {len(have)}") |
|
|
| |
| per_image_scalars: Dict[str, list] = {k: [] for k in METHOD_ORDER} |
| summary = [] |
| n_ok = n_skip = 0 |
| for s in tqdm(keep, desc="samples"): |
| out, err = trace_one_image( |
| sample=s, image_index=image_index, prompt=args.prompt, |
| processor=processor, model=model, sae=sae, |
| lora_pairs=pairs, lora_scale=lora_scale, |
| nullu_payload=nullu_payload, |
| efuf_payload=efuf_payload, |
| selected=selected, layers=layers, |
| hook_type=args.hook_type, gen_tokens=args.gen_tokens, |
| sae_batch=args.sae_batch, device=args.device, dtype_attn=torch.long, |
| fixed_assistant_prefix=args.fixed_assistant_prefix, |
| ) |
| if out is None: |
| n_skip += 1 |
| print(f" skip {s['image_id']}: {err}") |
| continue |
|
|
| out_path = os.path.join(args.out_dir, f"{out['image_id']}.pt") |
| torch.save(out, out_path) |
| summary.append({"image_id": out["image_id"], "path": out_path}) |
| n_ok += 1 |
|
|
| try: |
| png = plot_per_sample(out, layers, args.graph_dir, args.agg) |
| print(f" saved {png}") |
| except Exception as e: |
| print(f" plot_per_sample failed for {out['image_id']}: {e}") |
| traceback.print_exc() |
|
|
| for key in METHOD_ORDER: |
| if key in out["acts"]: |
| per_image_scalars[key].append(per_layer_scalar(out["acts"][key], layers, args.agg)) |
|
|
| if n_ok > 0: |
| try: |
| png = plot_summary(per_image_scalars, layers, args.graph_dir, args.agg, n_ok) |
| print(f" saved {png}") |
| except Exception as e: |
| print(f" plot_summary failed: {e}") |
| traceback.print_exc() |
|
|
| with open(os.path.join(args.out_dir, "summary.json"), "w") as f: |
| json.dump({ |
| "config": vars(args), |
| "n_ok": n_ok, |
| "n_skipped": n_skip, |
| "samples": summary, |
| }, f, indent=2) |
|
|
| print(f"Done. ok={n_ok} skipped={n_skip}. Output → {args.out_dir}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|