| """ |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
|
|
| |
| |
| |
|
|
| IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tiff"} |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
|
|
| 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"], |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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}") |
|
|
| |
| |
| |
| |
| |
| 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: <image>\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: <image>\nDescribe this image. \nASSISTANT: {b['caption']}" |
| if b.get("caption") else prompt) |
| for b in batch |
| ] |
| else: |
| |
| 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, |
| } |
|
|
| dataloader = DataLoader( |
| dataset, |
| batch_size = args.batch_size, |
| shuffle = False, |
| sampler = sampler, |
| num_workers = args.num_workers, |
| collate_fn = collate_fn, |
| ) |
| return dataset, dataloader |
|
|
|
|
| |
| |
| |
|
|
| @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) |
| bias = sae.pre_encoder_bias.to(device) |
|
|
| 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 |
| all_acts.append(torch.relu(hidden).cpu()) |
| return torch.cat(all_acts, dim=0) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| _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: <image> … 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: |
| mask[i, L_prompt : L_prompt + L_ans] = True |
| return mask |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| ap = argparse.ArgumentParser( |
| description="Compare SAE activations between original and modified LLaVA.", |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| ) |
|
|
| |
| 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") |
|
|
| |
| 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.") |
|
|
| |
| 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." |
| ), |
| ) |
|
|
| |
| ap.add_argument("--image_path", default=None, |
| help="[single] Path to a single image file.") |
|
|
| |
| 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.") |
|
|
| |
| 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.") |
|
|
| |
| 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).") |
|
|
| |
| ap.add_argument( |
| "--measure_distribution", |
| default="jensen_shannon_divergence", |
| choices=["kl_divergence", "jensen_shannon_divergence"], |
| help="Divergence measure for sub-threshold feature distributions.", |
| ) |
|
|
| |
| 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)." |
| ), |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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).") |
|
|
| |
| 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})" |
| ) |
|
|
| |
| sae = load_sae_model( |
| args.sae_ckpt, model_type="llava", hook_type="text", device=device |
| ) |
| sae.eval() |
| sae_dtype = next(sae.parameters()).dtype |
|
|
| |
| 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) |
|
|
| |
| |
| |
| pad_side = getattr(processor.tokenizer, "padding_side", "right") |
| prompt_template = "USER: <image>\nDescribe this image. \nASSISTANT:" |
| L_prompt: int = None |
|
|
| |
| 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}" |
| ) |
|
|
| |
| all_results: Dict[str, dict] = {} |
| layer_meta = {} |
| accumulators = {} |
|
|
| 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}") |
|
|
| |
| 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 |
|
|
| |
| 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") |
| 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 |
| ) |
|
|
| |
| |
| |
| |
| |
| 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: <image>\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), |
| } |
|
|
| |
| 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))], |
| ) |
|
|
| |
| |
| |
| 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) |
| n_real = attn_mask.sum().long() |
|
|
| |
| 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 |
|
|
| acts_orig = cache_orig[hp] |
| acts_mod = cache_mod[hp] |
| B, T, D = acts_orig.shape |
|
|
| |
| 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 |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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": |
| |
| acc["sub_abs_diff_sum"] += ( |
| diff_sub.abs().sum(dim=-1).sum().double() |
| ) |
| else: |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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() |
|
|