""" compare_activations.py — Compare SAE activation statistics between original and modified LLaVA models on probe-selected features. For each layer in --layers: 1. Loads the linear probe(s) from a folder, selects top-k features by probe weight. 2. Identifies sub features: all features NOT in the top-k set. 3. Runs forward passes through BOTH models on the same inputs. 4. Computes: - Per-feature activation statistics for top-k probe features (mean, std, mean |diff| between original and modified). - Aggregate |activation diff| across sub-threshold features. - Per-position JSD between original and modified activation distributions over sub-threshold features. Data: HF "pbcong/toilet_bathroom" filtered by --filter_mode. DDP: Supported via torchrun. Usage ----- # Bathroom-only images, single GPU python training/compare_activations.py \\ --original_model llava-hf/llava-1.5-7b-hf \\ --modified_model /path/to/modified_model \\ --sae_ckpt training/multilayer_sae_ckpt/last.ckpt \\ --probe_dir training/multilayer_sae_ckpt \\ --image_folder /path/to/cc3m_images/train \\ --filter_mode bathroom \\ --layers 0 1 2 3 4 5 6 \\ --output_dir outputs/compare_activations \\ --device_id 0 # Multi-GPU via torchrun torchrun --nproc_per_node=8 -m training.compare_activations \\ --original_model llava-hf/llava-1.5-7b-hf \\ --modified_model /path/to/modified_model \\ --sae_ckpt training/multilayer_sae_ckpt/last.ckpt \\ --probe_dir training/multilayer_sae_ckpt \\ --image_folder /path/to/cc3m_images/train \\ --filter_mode bathroom \\ --layers 0 1 2 3 4 5 6 \\ --output_dir outputs/compare_activations """ import sys import os import json import argparse import datetime from pathlib import Path from typing import Dict, List, Tuple import torch import torch.distributed as dist from torch.utils.data import Dataset, DataLoader, DistributedSampler from PIL import Image from tqdm import tqdm sys.path.insert(0, str(Path(__file__).parent.parent)) from hallucination.extra_materials.mechanistic_interp.probe.probing import LinearProbe from sae.SAE_Tools import load_sae_model from sae.autoencoder.Utils import standardize as sae_standardize from model.llava.hooked_llava import ( HookedSAELlavaConditionalGeneration, load_nullu_model, ) from model.llava.hooked_lora_llava import HookedLoRALlava from sae.Training_Utils import str_to_torch_dtype from transformers import LlavaProcessor # ───────────────────────────────────────────────────────────────────────────── # Distributed helpers # ───────────────────────────────────────────────────────────────────────────── def setup_distributed(timeout_hours: int = 8): if "RANK" in os.environ: rank = int(os.environ["RANK"]) world_size = int(os.environ["WORLD_SIZE"]) local_rank = int(os.environ["LOCAL_RANK"]) dist.init_process_group( backend="nccl", timeout=datetime.timedelta(hours=timeout_hours), ) return rank, world_size, local_rank return 0, 1, 0 def cleanup_distributed(): if dist.is_initialized(): dist.destroy_process_group() # ───────────────────────────────────────────────────────────────────────────── # Constants # ───────────────────────────────────────────────────────────────────────────── IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tiff"} # ───────────────────────────────────────────────────────────────────────────── # Dataset # ───────────────────────────────────────────────────────────────────────────── class SingleImageDataset(Dataset): """Single image for a quick one-off comparison pass.""" def __init__(self, image_path: str): path = Path(image_path) if not path.exists(): raise FileNotFoundError(f"Image not found: {image_path}") self.samples = [{"path": str(path), "imgid": path.stem, "caption": ""}] def __len__(self): return 1 def __getitem__(self, idx): s = self.samples[idx] return {"image": Image.open(s["path"]).convert("RGB"), "imgid": s["imgid"], "caption": s["caption"]} class FilteredBathroomToiletDataset(Dataset): """ HF pbcong/bathroom-toilet filtered by bathroom/toilet labels. filter_mode: bathroom — bathroom == 1 (regardless of toilet value) toilet — toilet == 1 (regardless of bathroom value) both — toilet == 1 OR bathroom == 1 all — no filtering (every row with a matching local image) caption_mode: generated — caption=""; collate_fn uses prompt-only text. caption — caption taken from HF "caption" field (CC3M caption). """ def __init__( self, image_folder: str, filter_mode: str, hf_dataset: str = "pbcong/bathroom-toilet", caption_mode: str = "generated", ): from datasets import load_dataset as _load_dataset assert caption_mode in ("generated", "caption"), caption_mode ds = _load_dataset(hf_dataset, split="train+validation") stem_to_path: Dict[str, str] = {} for fname in os.listdir(image_folder): if Path(fname).suffix.lower() in IMG_EXTS: stem_to_path[fname.rsplit(".", 1)[0]] = os.path.join( image_folder, fname ) self.samples: List[dict] = [] for row in ds: bathroom = row.get("bathroom", 0) toilet = row.get("toilet", 0) if filter_mode == "bathroom" and bathroom != 1: continue elif filter_mode == "toilet" and toilet != 1: continue elif filter_mode == "both" and not (toilet == 1 or bathroom == 1): continue # "all": include every row img_id = str(row["image_id"]) if img_id not in stem_to_path: continue caption = (row.get("caption", "") or "") if caption_mode == "caption" else "" self.samples.append( {"path": stem_to_path[img_id], "imgid": img_id, "caption": caption} ) if not self.samples: raise RuntimeError( f"No images found for filter_mode={filter_mode!r} " f"in {image_folder!r}." ) def __len__(self): return len(self.samples) def __getitem__(self, idx): s = self.samples[idx] return { "image": Image.open(s["path"]).convert("RGB"), "imgid": s["imgid"], "caption": s["caption"], } # ───────────────────────────────────────────────────────────────────────────── # Unified dataloader # ───────────────────────────────────────────────────────────────────────────── def create_dataloader(args, processor, rank: int = 0, world_size: int = 1): """ Unified dataloader supporting three data modes. --data_mode: single — one image at --image_path (caption_mode ignored). toilet — pbcong/bathroom-toilet filtered by --filter_mode. Requires --image_folder. cc3m — full CC3M via --hf_dataset + --local_val_path. coco — COCO via --hf_dataset + --local_val_path. --caption_mode: generated — original model generates the full sequence; both models then run a teacher-forced forward pass on that sequence so activations at every token position (prompt + answer) can be compared. caption — both models run a forward pass on prompt + stored caption (CC3M txt / COCO sentences / pbcong/bathroom-toilet caption field), capturing activations at every token position. """ data_mode = getattr(args, "data_mode", "toilet") caption_mode = getattr(args, "caption_mode", "generated") if data_mode == "single": dataset = SingleImageDataset(args.image_path) elif data_mode == "toilet": dataset = FilteredBathroomToiletDataset( image_folder = args.image_folder, filter_mode = getattr(args, "filter_mode", "bathroom"), hf_dataset = getattr(args, "hf_dataset", "pbcong/bathroom-toilet"), caption_mode = caption_mode, ) elif data_mode in ("cc3m", "coco"): from sae.SAE_Trainer import DataConfig from sae.Load_Data import cc3m_dataset, coco_dataset data_cfg = DataConfig( hf_dataset = args.hf_dataset, local_val_path = args.local_val_path, local_train_path = args.local_val_path, processor = args.original_model, batch_size = args.batch_size, num_workers = args.num_workers, ) split = getattr(args, "split", "train") dataset = (cc3m_dataset if data_mode == "cc3m" else coco_dataset)(data_cfg, split) else: raise ValueError(f"Unknown data_mode: {data_mode!r}") # single mode: dataset has exactly 1 sample. DistributedSampler with # drop_last=True would truncate floor(1/world_size)*world_size = 0 items, # leaving every rank with an empty loader. Instead let all ranks share the # same 1-sample loader; the reduce sums identical contributions so means # remain correct. sampler = ( DistributedSampler(dataset, num_replicas=world_size, rank=rank, shuffle=False, drop_last=True) if world_size > 1 and data_mode != "single" else None ) prompt = "USER: \nDescribe this image. \nASSISTANT:" def collate_fn(batch): batch = [b for b in batch if b is not None] if not batch: return None images = [b["image"] for b in batch] if caption_mode == "caption": texts = [ (f"USER: \nDescribe this image. \nASSISTANT: {b['caption']}" if b.get("caption") else prompt) for b in batch ] else: # generated mode: prompt-only for the generation step texts = [prompt] * len(batch) processed = processor( images=images, text=texts, return_tensors="pt", padding=True ) return { "input_ids": processed["input_ids"], "attention_mask": processed["attention_mask"], "pixel_values": processed["pixel_values"], "_images": images, # raw PIL images for re-processing after generation } dataloader = DataLoader( dataset, batch_size = args.batch_size, shuffle = False, sampler = sampler, num_workers = args.num_workers, collate_fn = collate_fn, ) return dataset, dataloader # ───────────────────────────────────────────────────────────────────────────── # SAE activation extraction (memory-efficient targeted extraction) # ───────────────────────────────────────────────────────────────────────────── @torch.no_grad() def get_targeted_sae_activations(sae, flat_acts, feature_ids, device, sae_batch=4096): """ Extract pre-topk ReLU'd SAE activations for *specific* features only. Instead of computing the full (N, d_sae) tensor, this selects the relevant rows of the encoder weight matrix and computes only (N, len(feature_ids)). Returns: (N, n_features) tensor on CPU. """ feature_ids_t = torch.tensor(feature_ids, dtype=torch.long) W_target = sae.encoder.weight[feature_ids_t].to(device) # (n_features, d_in) bias = sae.pre_encoder_bias.to(device) # (d_in,) all_acts = [] for i in range(0, flat_acts.shape[0], sae_batch): batch = flat_acts[i : i + sae_batch].to(device) if sae.cfg.standardize: batch, _ = sae_standardize(batch) hidden = (batch - bias) @ W_target.T # (chunk, n_features) all_acts.append(torch.relu(hidden).cpu()) return torch.cat(all_acts, dim=0) # ───────────────────────────────────────────────────────────────────────────── # JSD # ───────────────────────────────────────────────────────────────────────────── def jensen_shannon_divergence(p, q, eps=1e-10): """ Per-row Jensen-Shannon divergence. p, q: (N, D) non-negative tensors. Each row is normalised to a probability distribution before computing JSD. Returns: (N,) tensor of JSD values in [0, ln2]. """ p = p.float() + eps q = q.float() + eps p = p / p.sum(dim=-1, keepdim=True) q = q / q.sum(dim=-1, keepdim=True) m = (p + q) / 2 kl_pm = (p * (p / m).log()).sum(dim=-1) kl_qm = (q * (q / m).log()).sum(dim=-1) return (kl_pm + kl_qm) / 2 # ───────────────────────────────────────────────────────────────────────────── # KL divergence # ───────────────────────────────────────────────────────────────────────────── def kl_divergence(p, q, eps=1e-10): """ Per-row Kullback-Leibler divergence. p, q: (N, D) non-negative tensors. Each row is normalised to a probability distribution before computing KL divergence. Returns: (N,) tensor of KL divergence values. """ p = p.float() + eps q = q.float() + eps p = p / p.sum(dim=-1, keepdim=True) q = q / q.sum(dim=-1, keepdim=True) kl = (p * (p / q).log()).sum(dim=-1) return kl # ───────────────────────────────────────────────────────────────────────────── # Print helpers # ───────────────────────────────────────────────────────────────────────────── _ARROW_EPS = 1e-6 def _dir_arrow(new_val: float, ref_val: float) -> str: """Direction of change relative to ref_val. ↓ suppressed (new < ref) ↑ enhanced (new > ref) = unchanged — both values are effectively zero """ if ref_val < _ARROW_EPS and new_val < _ARROW_EPS: return "—" if new_val < ref_val - _ARROW_EPS: return "↓" if new_val > ref_val + _ARROW_EPS: return "↑" return "=" def _build_answer_mask(input_ids, attention_mask, L_prompt, pad_side): """Per-position bool mask that is True only on answer tokens — i.e., strictly after the 'USER: … ASSISTANT:' prompt prefix — and False on image + prompt tokens and on padding. Same prompt template is assumed for every sample (constant L_prompt). Supports both right- and left-padded sequences. """ B, T = input_ids.shape attn = attention_mask.detach().cpu() mask = torch.zeros(B, T, dtype=torch.bool) for i in range(B): R_i = int(attn[i].sum()) L_ans = max(0, R_i - L_prompt) if L_ans == 0: continue if pad_side == "left": mask[i, T - L_ans : T] = True else: # right pad (default) mask[i, L_prompt : L_prompt + L_ans] = True return mask # ───────────────────────────────────────────────────────────────────────────── # Probe loading # ───────────────────────────────────────────────────────────────────────────── def load_probe_features( probe_dir: str, layer: int, top_k: int, input_dim: int = 65536, ) -> Tuple[List[int], List[float], List[int]]: """ Load a linear probe and partition features into top-k and the rest. Returns: topk_ids — feature indices with the k highest probe weights topk_weights — corresponding probe weight values sub_ids — all remaining feature indices not in top-k """ ckp_path = ( Path(probe_dir) / f"probe_model.language_model.layers.{layer}.hook_resid_post.pt" ) if not ckp_path.exists(): raise FileNotFoundError(f"Probe checkpoint not found: {ckp_path}") ckpt = torch.load(ckp_path, map_location="cpu") probe = LinearProbe(input_dim=input_dim, num_outputs=1) probe.load_state_dict(ckpt) probe.eval() weights = probe.weights.squeeze() # (input_dim,) top = torch.topk(weights, k=top_k) topk_ids = top.indices.cpu().tolist() topk_weights = weights[top.indices].cpu().tolist() topk_set = set(topk_ids) sub_ids = [i for i in range(input_dim) if i not in topk_set] return topk_ids, topk_weights, sub_ids # ───────────────────────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser( description="Compare SAE activations between original and modified LLaVA.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) # ── Models ─────────────────────────────────────────────────────────────── ap.add_argument("--original_model", default="llava-hf/llava-1.5-7b-hf") ap.add_argument("--modified_model", required=True, help="HF model name or local path for the modified (LoRA) model.") ap.add_argument("--nullu_model_path", default=None, help="Path to Nullu's edited checkpoint directory " "(e.g. Nullu/output/edited_model/LLaVA-7B-top4-0-32-test). " "If provided, a third Nullu-edited model is added to the comparison.") ap.add_argument("--nullu_lowest_layer", type=int, default=16, help="Inclusive lower bound of Nullu edited layer range.") ap.add_argument("--nullu_highest_layer", type=int, default=32, help="Exclusive upper bound of Nullu edited layer range.") ap.add_argument("--sae_ckpt", required=True) ap.add_argument("--device_id", type=int, default=0) ap.add_argument("--dtype", default="float16") # ── Probe ──────────────────────────────────────────────────────────────── ap.add_argument("--probe_dir", required=True, help="Dir with probe_model.language_model.layers.*.pt files.") ap.add_argument("--probe_input_dim", type=int, default=65536) ap.add_argument("--layers", type=int, nargs="+", default=list(range(7))) ap.add_argument("--top_probe_k", type=int, default=1000, help="Number of top probe features to track (by probe weight). " "All are saved to JSON; only --top_print_k are printed.") ap.add_argument("--top_print_k", type=int, default=20, help="Number of top features to print per-row in the log. " "The rest are still computed and saved to JSON.") # ── Data mode ──────────────────────────────────────────────────────────── ap.add_argument( "--data_mode", default="toilet", choices=["single", "toilet", "cc3m", "coco"], help=( "single: one image at --image_path. " "toilet: pbcong/bathroom-toilet filtered by --filter_mode (needs --image_folder). " "cc3m: CC3M via --hf_dataset + --local_val_path. " "coco: COCO via --hf_dataset + --local_val_path." ), ) # ── Data — single image ────────────────────────────────────────────────── ap.add_argument("--image_path", default=None, help="[single] Path to a single image file.") # ── Data — toilet ──────────────────────────────────────────────────────── ap.add_argument("--image_folder", default=None, help="[toilet] Local folder with CC3M (or other) images.") ap.add_argument("--filter_mode", default="bathroom", choices=["bathroom", "toilet", "both", "all"], help="[toilet] bathroom: bath==1; toilet: toilet==1; " "both: toilet==1 OR bath==1; all: no filtering.") ap.add_argument("--hf_dataset", default="pbcong/bathroom-toilet", help="[toilet] HF dataset name, or CC3M/COCO path for cc3m/coco mode.") # ── Data — CC3M / COCO ─────────────────────────────────────────────────── ap.add_argument("--local_val_path", default=None, help="[cc3m/coco] Local image root for the HF dataset split.") ap.add_argument("--split", default="train", help="[cc3m/coco] HF dataset split.") # ── Caption mode ───────────────────────────────────────────────────────── ap.add_argument( "--caption_mode", default="generated", choices=["generated", "caption"], help=( "generated: original model generates full sequence; both models then " "run a teacher-forced forward pass on it (activations at every token). " "caption: both models run a forward pass on prompt + stored caption " "(activations at every token)." ), ) ap.add_argument("--max_new_tokens", type=int, default=256, help="Max tokens to generate per image (only with --caption_mode generated).") # ── Distribution measure ────────────────────────────────────────────────── ap.add_argument( "--measure_distribution", default="jensen_shannon_divergence", choices=["kl_divergence", "jensen_shannon_divergence"], help="Divergence measure for sub-threshold feature distributions.", ) # ── Diff norm ───────────────────────────────────────────────────────────── ap.add_argument( "--diff_norm", default="l1", choices=["l1", "l2"], help=( "Norm used for the Diff column and sub-threshold aggregate diff. " "l1: per-feature mean|Δ| (top-k) / per-position 1/N Σ‖Δ‖₁ (sub-threshold). " "l2: per-feature RMSE (top-k) / per-position 1/N Σ‖Δ‖₂ (sub-threshold)." ), ) # ── Processing ─────────────────────────────────────────────────────────── ap.add_argument("--batch_size", type=int, default=4) ap.add_argument("--sae_batch", type=int, default=4096) ap.add_argument("--num_workers", type=int, default=4) ap.add_argument("--max_batches", type=int, default=None) # ── Output ─────────────────────────────────────────────────────────────── ap.add_argument("--output_dir", default="outputs/compare_activations") ap.add_argument("--dist_timeout_hours", type=int, default=8, help="NCCL/store timeout in hours for distributed runs.") args = ap.parse_args() if args.data_mode == "single" and not args.image_path: ap.error("--data_mode single requires --image_path.") if args.data_mode == "toilet" and not args.image_folder: ap.error("--data_mode toilet requires --image_folder.") if args.data_mode in ("cc3m", "coco") and not args.hf_dataset: ap.error(f"--data_mode {args.data_mode} requires --hf_dataset (+ --local_val_path).") # ── Distributed setup ──────────────────────────────────────────────────── rank, world_size, local_rank = setup_distributed(args.dist_timeout_hours) is_distributed = world_size > 1 device = torch.device( f"cuda:{local_rank}" if is_distributed else f"cuda:{args.device_id}" if torch.cuda.is_available() else "cpu" ) dtype = str_to_torch_dtype(args.dtype) if rank == 0: print(f"Device: {device} | world_size: {world_size}") print(f"Original model: {args.original_model}") print(f"Modified model: {args.modified_model}") if args.nullu_model_path: print( f"Nullu model: {args.nullu_model_path} " f"(layers {args.nullu_lowest_layer}-{args.nullu_highest_layer})" ) # ── Load SAE ───────────────────────────────────────────────────────────── sae = load_sae_model( args.sae_ckpt, model_type="llava", hook_type="text", device=device ) sae.eval() sae_dtype = next(sae.parameters()).dtype # ── Load both LLaVA models ─────────────────────────────────────────────── if rank == 0: print("Loading original model …") model_orig = HookedSAELlavaConditionalGeneration.from_pretrained( args.original_model ) model_orig.to(device, dtype=dtype).eval() if rank == 0: print("Loading modified model …") model_mod = HookedLoRALlava.from_pretrained(args.original_model) model_mod.load_lora_adapter(adapter_path=args.modified_model, merge=True) model_mod.to(device, dtype=dtype).eval() model_nullu = None if args.nullu_model_path: if rank == 0: print( f"Loading nullu model … (layers " f"{args.nullu_lowest_layer}-{args.nullu_highest_layer})" ) model_nullu = load_nullu_model( lowest_layer=args.nullu_lowest_layer, highest_layer=args.nullu_highest_layer, edited_model_path=args.nullu_model_path, base_model_name=args.original_model, torch_dtype=dtype, device=device, ) model_nullu.eval() processor = LlavaProcessor.from_pretrained(args.original_model) # Padding side + prompt template — used to build an answer-only token mask # so all stats are accumulated strictly over generated/caption answer # tokens (image + prompt tokens and padding are excluded). pad_side = getattr(processor.tokenizer, "padding_side", "right") prompt_template = "USER: \nDescribe this image. \nASSISTANT:" L_prompt: int = None # lazily computed on the first batch # ── Dataset & DataLoader ───────────────────────────────────────────────── dataset, dataloader = create_dataloader(args, processor, rank, world_size) if rank == 0: print( f"Dataset: {len(dataset)} images, " f"{len(dataloader)} batches/rank, " f"data_mode={args.data_mode}" ) # ── Pre-load all probes & init accumulators ────────────────────────────── all_results: Dict[str, dict] = {} layer_meta = {} # layer -> {topk_ids, topk_wts, sub_ids, all_ids, n_topk, n_sub, hook_point} accumulators = {} # layer -> dict of running tensors for layer in args.layers: hook_point = f"model.language_model.layers.{layer}.hook_resid_post" topk_ids, topk_wts, sub_ids = load_probe_features( args.probe_dir, layer, args.top_probe_k, args.probe_input_dim, ) all_ids = topk_ids + sub_ids n_topk = len(topk_ids) n_sub = len(sub_ids) layer_meta[layer] = dict( hook_point=hook_point, topk_ids=topk_ids, topk_wts=topk_wts, sub_ids=sub_ids, all_ids=all_ids, n_topk=n_topk, n_sub=n_sub, ) accumulators[layer] = dict( topk_orig_sum = torch.zeros(n_topk, dtype=torch.float64), topk_orig_sq = torch.zeros(n_topk, dtype=torch.float64), topk_mod_sum = torch.zeros(n_topk, dtype=torch.float64), topk_mod_sq = torch.zeros(n_topk, dtype=torch.float64), sub_abs_diff_sum = torch.zeros(1, dtype=torch.float64), sub_div_sum = torch.zeros(1, dtype=torch.float64), sub_div_sq_sum = torch.zeros(1, dtype=torch.float64), total_positions = torch.zeros(1, dtype=torch.long), ) if model_nullu is not None: accumulators[layer].update( topk_nullu_sum = torch.zeros(n_topk, dtype=torch.float64), topk_nullu_sq = torch.zeros(n_topk, dtype=torch.float64), sub_nullu_abs_diff_sum = torch.zeros(1, dtype=torch.float64), sub_nullu_div_sum = torch.zeros(1, dtype=torch.float64), sub_nullu_div_sq_sum = torch.zeros(1, dtype=torch.float64), ) if rank == 0: print(f"\n{'='*60}") print(f"[Layer {layer}] hook = {hook_point}") print(f" top-{args.top_probe_k} features (printing top-{args.top_print_k}): {topk_ids[:args.top_print_k]}") print(f" sub features (all except top-{args.top_probe_k}): {n_sub}") # Hook pattern that captures all target layer residual streams in one pass target_hook_points = {m["hook_point"] for m in layer_meta.values()} def make_hook_fn(cache: dict): def hook_fn(act, hook): if hook.name in target_hook_points: cache[hook.name] = act.detach().cpu() return hook_fn # ── Single-pass batch loop ──────────────────────────────────────────────── n_batches = ( len(dataloader) if args.max_batches is None else min(args.max_batches, len(dataloader)) ) pbar = tqdm(dataloader, total=n_batches, disable=(rank != 0), desc="Batches") for batch_idx, batch in enumerate(pbar): if args.max_batches is not None and batch_idx >= args.max_batches: break if batch is None: continue raw_images = batch.pop("_images") # List[PIL.Image] model_inputs = { k: batch[k].to(device) for k in ("input_ids", "attention_mask") } model_inputs["pixel_values"] = batch["pixel_values"].to( device, dtype=dtype ) # ── Generated mode: generate ONCE per batch ────────────────────────── # Use the original model to generate the full sequence, then # teacher-force BOTH models with that sequence. Stats are then # accumulated only on the answer tokens (post-"ASSISTANT:"); # image + prompt tokens are masked out below. if args.caption_mode == "generated": with torch.no_grad(): gen_ids = model_orig.generate( **model_inputs, do_sample=False, num_beams=1, use_cache=True, max_new_tokens=args.max_new_tokens, ) full_texts = processor.batch_decode(gen_ids, skip_special_tokens=True) captions = [ txt.split("ASSISTANT:")[-1].strip() if "ASSISTANT:" in txt else txt.strip() for txt in full_texts ] forced_texts = [ f"USER: \nDescribe this image. \nASSISTANT: {cap}" for cap in captions ] re_proc = processor( images=raw_images, text=forced_texts, return_tensors="pt", padding=True, ) model_inputs = { "input_ids": re_proc["input_ids"].to(device), "attention_mask": re_proc["attention_mask"].to(device), "pixel_values": re_proc["pixel_values"].to(device, dtype=dtype), } # ── One forward pass per model, all target layers cached at once ───── cache_orig: dict = {} cache_mod: dict = {} cache_nullu: dict = {} with torch.no_grad(): model_orig.run_with_hooks( model_inputs, fwd_hooks=[(lambda n: n in target_hook_points, make_hook_fn(cache_orig))], ) model_mod.run_with_hooks( model_inputs, fwd_hooks=[(lambda n: n in target_hook_points, make_hook_fn(cache_mod))], ) if model_nullu is not None: model_nullu.run_with_hooks( model_inputs, fwd_hooks=[(lambda n: n in target_hook_points, make_hook_fn(cache_nullu))], ) # Answer-only mask: True at positions for the answer tokens # (post-"ASSISTANT:") and False on image + prompt tokens and padding. # Stats below are accumulated only over these positions. if L_prompt is None: _pp = processor( images=[raw_images[0]], text=[prompt_template], return_tensors="pt", padding=False, ) L_prompt = int(_pp["input_ids"].shape[1]) answer_mask_2d = _build_answer_mask( model_inputs["input_ids"], model_inputs["attention_mask"], L_prompt, pad_side, ) attn_mask = answer_mask_2d.view(-1) # (B*T,) on CPU n_real = attn_mask.sum().long() # ── Accumulate stats per layer from cached activations ──────────────── for layer in args.layers: meta = layer_meta[layer] acc = accumulators[layer] hp = meta["hook_point"] if hp not in cache_orig or hp not in cache_mod: continue # hook didn't fire (shouldn't happen) acts_orig = cache_orig[hp] # (B, T, D) on CPU acts_mod = cache_mod[hp] B, T, D = acts_orig.shape # Mask out padding positions flat_orig = acts_orig.reshape(B * T, D)[attn_mask].to(sae_dtype) flat_mod = acts_mod.reshape(B * T, D)[attn_mask].to(sae_dtype) all_ids = meta["all_ids"] n_topk = meta["n_topk"] n_sub = meta["n_sub"] if not all_ids: acc["total_positions"] += n_real continue sae_orig = get_targeted_sae_activations( sae, flat_orig, all_ids, device, args.sae_batch ) sae_mod = get_targeted_sae_activations( sae, flat_mod, all_ids, device, args.sae_batch ) sae_nullu = None if model_nullu is not None and hp in cache_nullu: flat_nullu = cache_nullu[hp].reshape(B * T, D)[attn_mask].to(sae_dtype) sae_nullu = get_targeted_sae_activations( sae, flat_nullu, all_ids, device, args.sae_batch ) # Top-k feature stats orig_topk = sae_orig[:, :n_topk].double() mod_topk = sae_mod[:, :n_topk].double() acc["topk_orig_sum"] += orig_topk.sum(dim=0) acc["topk_orig_sq"] += (orig_topk**2).sum(dim=0) acc["topk_mod_sum"] += mod_topk.sum(dim=0) acc["topk_mod_sq"] += (mod_topk**2).sum(dim=0) if sae_nullu is not None: nullu_topk = sae_nullu[:, :n_topk].double() acc["topk_nullu_sum"] += nullu_topk.sum(dim=0) acc["topk_nullu_sq"] += (nullu_topk**2).sum(dim=0) # Sub-threshold stats if n_sub > 0: orig_sub = sae_orig[:, n_topk:] mod_sub = sae_mod[:, n_topk:] diff_sub = orig_sub - mod_sub if args.diff_norm == "l1": # L1: sum of per-position L1 norms → 1/N Σ_n Σ_f |a_nf − a'_nf| acc["sub_abs_diff_sum"] += ( diff_sub.abs().sum(dim=-1).sum().double() ) else: # L2: sum of per-position L2 norms → 1/N Σ_n ‖a_n − a'_n‖₂ acc["sub_abs_diff_sum"] += ( (diff_sub ** 2).sum(dim=-1).sqrt().sum().double() ) div_fn = ( jensen_shannon_divergence if args.measure_distribution == "jensen_shannon_divergence" else kl_divergence ) div_vals = div_fn(orig_sub, mod_sub) acc["sub_div_sum"] += div_vals.sum().double() acc["sub_div_sq_sum"] += (div_vals**2).sum().double() if sae_nullu is not None: nullu_sub = sae_nullu[:, n_topk:] diff_nullu = orig_sub - nullu_sub if args.diff_norm == "l1": acc["sub_nullu_abs_diff_sum"] += ( diff_nullu.abs().sum(dim=-1).sum().double() ) else: acc["sub_nullu_abs_diff_sum"] += ( (diff_nullu ** 2).sum(dim=-1).sqrt().sum().double() ) div_vals_nullu = div_fn(orig_sub, nullu_sub) acc["sub_nullu_div_sum"] += div_vals_nullu.sum().double() acc["sub_nullu_div_sq_sum"] += (div_vals_nullu**2).sum().double() acc["total_positions"] += n_real # ── DDP reduce + aggregate per layer ───────────────────────────────────── for layer in args.layers: meta = layer_meta[layer] acc = accumulators[layer] if is_distributed: for key in acc: t = acc[key].to(device) dist.reduce(t, dst=0) acc[key] = t.cpu() if rank == 0: N = acc["total_positions"].item() if N == 0: print(f" WARNING: layer {layer} — no positions processed.") continue hook_point = meta["hook_point"] topk_ids = meta["topk_ids"] topk_wts = meta["topk_wts"] n_topk = meta["n_topk"] n_sub = meta["n_sub"] layer_result = { "hook_point": hook_point, "total_positions": N, "top_features": {}, "sub_threshold": {}, } print(f"\n{'='*60}") print(f"[Layer {layer}] hook = {hook_point}") has_nullu = model_nullu is not None if has_nullu: print( f"\n {'Feature':>10} {'ProbeW':>8} " f"{'OrigMean':>10} {'ModMean':>11} {'Δ Mod':>10} " f"{'NulluMean':>12} {'Δ Nullu':>10}" ) print( f" {'-'*10} {'-'*8} {'-'*10} {'-'*11} {'-'*10} " f"{'-'*12} {'-'*10}" ) else: print(f"\n {'Feature':>10} {'ProbeW':>8} " f"{'OrigMean':>10} {'ModMean':>11} {'Δ Mean':>10}") print(f" {'-'*10} {'-'*8} {'-'*10} {'-'*11} {'-'*10}") for j, (fid, pw) in enumerate(zip(topk_ids, topk_wts)): o_mean = acc["topk_orig_sum"][j].item() / N m_mean = acc["topk_mod_sum"][j].item() / N o_var = max(0, acc["topk_orig_sq"][j].item() / N - o_mean**2) m_var = max(0, acc["topk_mod_sq"][j].item() / N - m_mean**2) delta = o_mean - m_mean m_arrow = _dir_arrow(m_mean, o_mean) feat_entry = { "rank": j + 1, "probe_weight": pw, "original_mean": o_mean, "original_std": o_var**0.5, "modified_mean": m_mean, "modified_std": m_var**0.5, "delta_mean": delta, "mod_direction": m_arrow, } if has_nullu: n_mean = acc["topk_nullu_sum"][j].item() / N n_var = max(0, acc["topk_nullu_sq"][j].item() / N - n_mean**2) delta_n = o_mean - n_mean n_arrow = _dir_arrow(n_mean, o_mean) feat_entry.update({ "nullu_mean": n_mean, "nullu_std": n_var**0.5, "delta_nullu": delta_n, "nullu_direction": n_arrow, }) layer_result["top_features"][str(fid)] = feat_entry if j < args.top_print_k: if has_nullu: print( f" {fid:>10} {pw:>+8.4f} " f"{o_mean:>10.4f} {m_mean:>9.4f} {m_arrow} {delta:>+10.4f} " f"{n_mean:>10.4f} {n_arrow} {delta_n:>+10.4f}" ) else: print( f" {fid:>10} {pw:>+8.4f} " f"{o_mean:>10.4f} {m_mean:>9.4f} {m_arrow} {delta:>+10.4f}" ) elif j == args.top_print_k: print(f" ... ({n_topk - args.top_print_k} more features saved to JSON)") if n_sub > 0: mean_div = acc["sub_div_sum"].item() / N div_var = max(0, acc["sub_div_sq_sum"].item() / N - mean_div**2) denom = N * n_sub mean_abs_diff = acc["sub_abs_diff_sum"].item() / denom measure_name = args.measure_distribution diff_label = "mean_l1_per_feature" if args.diff_norm == "l1" else "mean_l2_per_feature" layer_result["sub_threshold"] = { "n_features": n_sub, diff_label: mean_abs_diff, "diff_norm": args.diff_norm, "divergence_measure": measure_name, "mean_divergence": mean_div, "std_divergence": div_var**0.5, } diff_print_label = ( "1/(N·F) Σ|Δ| (L1)" if args.diff_norm == "l1" else "1/(N·√F) Σ‖Δ‖₂ (L2)" ) print( f"\n Sub features ({n_sub} features, " f"all except top-{args.top_probe_k}):" ) print(f" [Mod vs Orig] {diff_print_label}: {mean_abs_diff:.6f}") print(f" [Mod vs Orig] mean {measure_name}: {mean_div:.6f} " f"± {div_var**0.5:.6f}") if has_nullu: mean_div_n = acc["sub_nullu_div_sum"].item() / N div_var_n = max(0, acc["sub_nullu_div_sq_sum"].item() / N - mean_div_n**2) mean_abs_diff_n = acc["sub_nullu_abs_diff_sum"].item() / denom layer_result["sub_threshold"].update({ f"nullu_{diff_label}": mean_abs_diff_n, "nullu_mean_divergence": mean_div_n, "nullu_std_divergence": div_var_n**0.5, }) print(f" [Nullu vs Orig] {diff_print_label}: {mean_abs_diff_n:.6f}") print(f" [Nullu vs Orig] mean {measure_name}: {mean_div_n:.6f} " f"± {div_var_n**0.5:.6f}") all_results[f"layer_{layer}"] = layer_result # ── Save results ───────────────────────────────────────────────────────── if rank == 0: output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) result = { "config": { "original_model": args.original_model, "modified_model": args.modified_model, "nullu_model_path": args.nullu_model_path, "nullu_lowest_layer": args.nullu_lowest_layer if args.nullu_model_path else None, "nullu_highest_layer": args.nullu_highest_layer if args.nullu_model_path else None, "sae_ckpt": args.sae_ckpt, "probe_dir": args.probe_dir, "layers": args.layers, "top_probe_k": args.top_probe_k, "data_mode": args.data_mode, "filter_mode": getattr(args, "filter_mode", None), "hf_dataset": args.hf_dataset, "caption_mode": args.caption_mode, "measure_distribution": args.measure_distribution, "diff_norm": args.diff_norm, "n_images": len(dataset), }, "layers": all_results, } out_path = output_dir / "comparison.json" with open(out_path, "w") as f: json.dump(result, f, indent=2) print(f"\nResults saved to {out_path}") cleanup_distributed() if __name__ == "__main__": main()