| """ |
| visualize_probe_features.py — Multi-layer probe feature visualizer for LLaVA. |
| |
| Uses a single forward pass per batch to cache residual-stream activations at ALL |
| target layers simultaneously (shared SAE d_sae), then runs the SAE per-layer on |
| the cached activations. This is ~Nx faster than the old per-layer approach |
| (where N = number of layers). |
| |
| For each batch: |
| 1. (Optional) Generate captions with the model (once, shared across layers). |
| 2. One forward pass caching activations at every target layer's hook point. |
| 3. For each layer: SAE → sparse features → update top-k heaps. |
| |
| Generates ONE self-contained interactive HTML: |
| Layers ▶ Top Features (id + probe weight) ▶ Top Image Patches / Text Tokens |
| |
| Data modes (--data_mode): |
| toilet — only images from HF "pbcong/bathroom-toilet" that match |
| --object_mode (toilet | bathroom). Supports --caption_mode. |
| Uses visualize_multilayer_sae_features.create_dataloader. |
| cc3m — full CC3M or COCO dataset via HF or a plain image folder. |
| Uses visualize_multilayer_sae_features.create_dataloader. |
| |
| Usage |
| ----- |
| # Toilet subset |
| python training/visualize_probe_features.py \\ |
| --data_mode toilet \\ |
| --sae_ckpt training/multilayer_sae_ckpt/last.ckpt \\ |
| --probe_dir training/multilayer_sae_ckpt \\ |
| --image_folder /path/to/cc3m_images/train \\ |
| --object_mode toilet \\ |
| --caption_mode generated \\ |
| --layers 0 1 2 3 4 5 6 \\ |
| --output_dir outputs/probe_features_toilet \\ |
| --device_id 0 |
| |
| # Full CC3M (HF dataset) |
| python training/visualize_probe_features.py \\ |
| --data_mode cc3m \\ |
| --sae_ckpt training/multilayer_sae_ckpt/last.ckpt \\ |
| --probe_dir training/multilayer_sae_ckpt \\ |
| --hf_dataset pixparse/cc3m-wds \\ |
| --local_val_path /path/to/cc3m_images/train \\ |
| --split train \\ |
| --layers 0 1 2 3 4 5 6 \\ |
| --output_dir outputs/probe_features_cc3m \\ |
| --device_id 0 |
| |
| # Plain image folder |
| python training/visualize_probe_features.py \\ |
| --data_mode cc3m \\ |
| --data_dir /path/to/images \\ |
| --sae_ckpt training/multilayer_sae_ckpt/last.ckpt \\ |
| --probe_dir training/multilayer_sae_ckpt \\ |
| --layers 0 1 2 3 4 5 6 \\ |
| --output_dir outputs/probe_features_folder \\ |
| --device_id 0 |
| """ |
|
|
| import sys |
| import os |
| import json |
| import argparse |
| import pickle |
| import shutil |
| from pathlib import Path |
| from typing import Dict, List, Optional, Tuple |
|
|
| import torch |
| import torch.distributed as dist |
| from tqdm import tqdm |
|
|
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| |
| import importlib.util as _ilu |
|
|
|
|
| def _load_module(name: str, path: Path): |
| spec = _ilu.spec_from_file_location(name, path) |
| mod = _ilu.module_from_spec(spec) |
| sys.modules[name] = mod |
| spec.loader.exec_module(mod) |
| return mod |
|
|
|
|
| _scripts_dir = Path(__file__).parent |
| _vtf = _load_module("visualize_multilayer_sae_features", _scripts_dir / "visualize_multilayer_sae_features.py") |
|
|
| |
| setup_distributed = _vtf.setup_distributed |
| cleanup_distributed = _vtf.cleanup_distributed |
| TopKHeap = _vtf.TopKHeap |
| build_image_html = _vtf.build_image_html |
| build_text_html = _vtf.build_text_html |
| create_dataloader = _vtf.create_dataloader |
| ImageRecord = _vtf.ImageRecord |
| TextRecord = _vtf.TextRecord |
| find_image_token_positions = _vtf.find_image_token_positions |
| expanded_to_original = _vtf.expanded_to_original |
| original_to_expanded = _vtf.original_to_expanded |
| N_IMAGE_PATCHES = _vtf.N_IMAGE_PATCHES |
| IMAGE_TOKEN_ID = _vtf.IMAGE_TOKEN_ID |
|
|
| from hallucination.extra_materials.mechanistic_interp.probe.probing import LinearProbe |
| from sae.SAE_Tools import load_sae_model, get_sae_activations, get_loader |
| from model.llava.hooked_llava import HookedSAELlavaConditionalGeneration |
| from sae.Training_Utils import str_to_torch_dtype |
| from transformers import LlavaProcessor |
|
|
|
|
| |
| |
| |
|
|
| def load_probe_top_features( |
| probe_dir: str, |
| layer: int, |
| top_k: int, |
| input_dim: int = 65536, |
| ) -> Tuple[List[int], List[float]]: |
| """Return (feature_ids, probe_weights) for the top-k probe features.""" |
| 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) |
| return top.indices.cpu().tolist(), weights[top.indices].cpu().tolist() |
|
|
|
|
| |
| |
| |
|
|
| @torch.no_grad() |
| def multi_layer_pass( |
| model, sae, processor, dataloader, device, |
| layer_probe_info: Dict[int, Tuple[List[int], List[float]]], |
| args, |
| ) -> Dict[int, dict]: |
| """ |
| Single pass over the dataloader, hooking ALL target layers at once. |
| |
| For each batch: |
| 1. (Optional) Generate caption with original model. |
| 2. One forward pass caching residual-stream activations at every target layer. |
| 3. For each layer: SAE → sparse features → update top-k heaps. |
| |
| Returns {layer: {feature_ids, probe_weights, image_heaps, text_heaps, |
| hook_counts, hook_point}}. |
| """ |
| sae_dtype = next(sae.parameters()).dtype |
| sae.eval() |
| sae.to(device) |
|
|
| |
| hook_points: Dict[int, str] = {} |
| target_hooks: set = set() |
| layer_data: Dict[int, dict] = {} |
|
|
| for layer, (feat_ids, feat_wts) in layer_probe_info.items(): |
| hp = f"model.language_model.layers.{layer}.hook_resid_post" |
| hook_points[layer] = hp |
| target_hooks.add(hp) |
| layer_data[layer] = { |
| "image_heaps": {fi: TopKHeap(args.top_images) for fi in feat_ids}, |
| "text_heaps": {fi: TopKHeap(args.top_texts) for fi in feat_ids}, |
| "hook_counts": {fi: 0 for fi in feat_ids}, |
| } |
|
|
| def _make_hook_fn(cache: dict): |
| def hook_fn(act, hook): |
| if hook.name in target_hooks: |
| 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)) |
| ) |
|
|
| for batch_idx, batch in enumerate(tqdm(dataloader, total=n_batches, desc="Scanning")): |
| if args.max_batches is not None and batch_idx >= args.max_batches: |
| break |
|
|
| B = batch["input_ids"].shape[0] |
| model_inputs = { |
| k: batch[k].to(device) |
| for k in ("input_ids", "attention_mask", "pixel_values") |
| } |
| global_idxs = batch["global_idxs"] |
|
|
| |
| if args.caption_mode == "generated": |
| gen_ids = model.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 |
| ] |
| images = [ |
| dataloader.dataset.dataset[gi]["image"] for gi in global_idxs |
| ] |
| forced_texts = [ |
| f"USER: <image>\nDescribe this image. \nASSISTANT: {cap}" |
| for cap in captions |
| ] |
| re_processed = processor( |
| images=images, text=forced_texts, |
| return_tensors="pt", padding=True, |
| ) |
| model_inputs = { |
| "input_ids": re_processed["input_ids"].to(device), |
| "attention_mask": re_processed["attention_mask"].to(device), |
| "pixel_values": re_processed["pixel_values"].to(device), |
| } |
| input_ids_cpu = re_processed["input_ids"] |
| B = input_ids_cpu.shape[0] |
| else: |
| input_ids_cpu = batch["input_ids"] |
|
|
| |
| cache: dict = {} |
| model.run_with_hooks( |
| model_inputs, |
| fwd_hooks=[(lambda n: n in target_hooks, _make_hook_fn(cache))], |
| ) |
|
|
| img_positions = find_image_token_positions(input_ids_cpu) |
|
|
| |
| for layer, (feat_ids, _) in layer_probe_info.items(): |
| hp = hook_points[layer] |
| if hp not in cache: |
| continue |
|
|
| acts = cache[hp] |
| _, T, D = acts.shape |
| flat = acts.reshape(B * T, 1, D).to(device, dtype=sae_dtype) |
| loader = get_loader(flat, batch_size=min(args.sae_batch, B * T)) |
| idxs, vals = get_sae_activations(sae, loader, device, no_tqdm=True) |
| idxs = idxs.squeeze(1).reshape(B, T, -1) |
| vals = vals.squeeze(1).reshape(B, T, -1) |
|
|
| ld = layer_data[layer] |
| image_heaps = ld["image_heaps"] |
| text_heaps = ld["text_heaps"] |
| hook_counts = ld["hook_counts"] |
|
|
| for fi in feat_ids: |
| mask = (idxs == fi) |
| feat_acts = (vals * mask.float()).sum(dim=-1) |
| above = feat_acts > args.threshold |
| hook_counts[fi] += above.sum().item() |
|
|
| for b in range(B): |
| ip = img_positions[b].item() |
| global_idx = global_idxs[b] |
| acts_b = feat_acts[b] |
|
|
| |
| if ip >= 0: |
| img_acts = acts_b[ip : ip + N_IMAGE_PATCHES] |
| n_img = (img_acts > args.threshold).sum().item() |
| if n_img > 0: |
| k_img = min(args.top_images, n_img) |
| topk_v, topk_i = img_acts.topk(k_img) |
| for v, pidx in zip(topk_v, topk_i): |
| if v.item() < args.threshold: |
| break |
| image_heaps[fi].push( |
| v.item(), |
| ImageRecord(v.item(), global_idx, |
| pidx.item(), hp), |
| ) |
|
|
| |
| text_ranges: List[Tuple[int, int]] = [] |
| if ip >= 0: |
| if ip > 0: |
| text_ranges.append((0, ip)) |
| if ip + N_IMAGE_PATCHES < T: |
| text_ranges.append((ip + N_IMAGE_PATCHES, T)) |
| else: |
| text_ranges.append((0, T)) |
|
|
| for rng_start, rng_end in text_ranges: |
| txt_acts = acts_b[rng_start:rng_end] |
| n_txt = (txt_acts > args.threshold).sum().item() |
| if n_txt == 0: |
| continue |
| k_txt = min(args.top_texts, n_txt) |
| topk_v, topk_i = txt_acts.topk(k_txt) |
|
|
| for v, rel_i in zip(topk_v, topk_i): |
| if v.item() < args.threshold: |
| break |
| exp_pos = rng_start + rel_i.item() |
| orig_pos = expanded_to_original(exp_pos, ip) |
| if orig_pos < 0: |
| continue |
| if (orig_pos < input_ids_cpu.shape[1] |
| and input_ids_cpu[b, orig_pos].item() == IMAGE_TOKEN_ID): |
| continue |
|
|
| seq_len = input_ids_cpu.shape[1] |
| ctx_s = max(0, orig_pos - args.buffer) |
| ctx_e = min(seq_len, orig_pos + args.buffer + 1) |
| if ip >= 0: |
| if orig_pos < ip: |
| ctx_e = min(ctx_e, ip) |
| elif orig_pos > ip: |
| ctx_s = max(ctx_s, ip + 1) |
| ctx_ids = input_ids_cpu[b, ctx_s:ctx_e].tolist() |
|
|
| ctx_acts_list: List[float] = [] |
| for op in range(ctx_s, ctx_e): |
| ep = original_to_expanded(op, ip) |
| if ep < 0 or ep >= T: |
| ctx_acts_list.append(0.0) |
| else: |
| ctx_acts_list.append(feat_acts[b, ep].item()) |
|
|
| text_heaps[fi].push( |
| v.item(), |
| TextRecord( |
| v.item(), global_idx, hp, |
| ctx_ids, ctx_acts_list, |
| orig_pos - ctx_s, |
| ), |
| ) |
|
|
| del flat, idxs, vals |
|
|
| del cache |
|
|
| |
| results: Dict[int, dict] = {} |
| for layer, (feat_ids, feat_wts) in layer_probe_info.items(): |
| ld = layer_data[layer] |
| results[layer] = { |
| "feature_ids": feat_ids, |
| "probe_weights": feat_wts, |
| "image_heaps": ld["image_heaps"], |
| "text_heaps": ld["text_heaps"], |
| "hook_counts": ld["hook_counts"], |
| "hook_point": hook_points[layer], |
| } |
| return results |
|
|
|
|
| |
| |
| |
|
|
| _CSS = """ |
| <style> |
| * { box-sizing: border-box; } |
| body { |
| font-family: 'Segoe UI', Arial, sans-serif; |
| background: #f0f2f5; |
| margin: 0; padding: 0; |
| } |
| h1 { |
| background: #2c3e50; color: #fff; |
| margin: 0; padding: 18px 28px; |
| font-size: 1.25rem; |
| } |
| .container { max-width: 1280px; margin: auto; padding: 20px; } |
| |
| /* ── Layer accordion ───────────────────────────────────────────────── */ |
| .layer-block { |
| background: #fff; |
| border: 1px solid #cdd3da; |
| border-radius: 8px; |
| margin-bottom: 14px; |
| overflow: hidden; |
| } |
| .layer-header { |
| display: flex; align-items: center; gap: 12px; |
| background: #2c3e50; color: #fff; |
| padding: 12px 18px; cursor: pointer; |
| user-select: none; |
| } |
| .layer-header:hover { background: #3d5166; } |
| .layer-header .arrow { transition: transform .2s; font-size: 0.85rem; } |
| .layer-header.open .arrow { transform: rotate(90deg); } |
| .layer-header .layer-title { font-size: 1rem; font-weight: 600; } |
| .layer-header .layer-meta { font-size: 0.8rem; color: #aec6e3; margin-left: auto; } |
| .layer-body { display: none; padding: 14px; } |
| .layer-body.open { display: block; } |
| |
| /* ── Feature list ──────────────────────────────────────────────────── */ |
| .feature-list { display: flex; flex-direction: column; gap: 10px; } |
| .feature-block { border: 1px solid #dde1e7; border-radius: 6px; overflow: hidden; } |
| .feature-header { |
| display: flex; align-items: center; gap: 10px; |
| background: #f7f8fa; padding: 9px 14px; |
| cursor: pointer; user-select: none; |
| } |
| .feature-header:hover { background: #edf0f4; } |
| .feature-header .arrow { font-size: 0.75rem; transition: transform .2s; } |
| .feature-header.open .arrow { transform: rotate(90deg); } |
| .feat-id { |
| font-weight: 700; font-size: 0.95rem; color: #2c3e50; |
| background: #dce9f7; border-radius: 4px; padding: 2px 8px; |
| } |
| .feat-rank { font-size: 0.78rem; color: #888; } |
| .feat-weight { |
| margin-left: auto; font-size: 0.82rem; color: #155a8a; |
| background: #e8f4fd; border-radius: 4px; padding: 2px 8px; |
| } |
| .feat-acts { font-size: 0.78rem; color: #555; } |
| .feature-body { display: none; padding: 12px 14px; background: #fefefe; } |
| .feature-body.open { display: block; } |
| |
| /* ── Image / text sections ─────────────────────────────────────────── */ |
| .section-title { |
| font-size: 0.88rem; font-weight: 600; color: #34495e; |
| margin: 10px 0 6px; |
| padding-bottom: 4px; border-bottom: 1px solid #e2e6ea; |
| } |
| .image-card { |
| border: 1px solid #ddd; border-radius: 5px; |
| padding: 8px; margin: 6px 0; background: #fff; |
| } |
| .image-card img { max-height: 240px; } |
| .text-card { |
| border: 1px solid #ddd; border-radius: 5px; |
| padding: 7px 10px; margin: 5px 0; background: #fff; |
| display: flex; align-items: flex-start; gap: 8px; |
| } |
| .text-rank { font-weight: bold; color: #888; min-width: 30px; |
| padding-top: 2px; font-size: 0.82rem; } |
| .text-context { font-family: monospace; font-size: 0.88rem; |
| line-height: 1.6; word-break: break-word; } |
| .meta { font-size: 0.75rem; color: #777; margin-top: 3px; } |
| </style> |
| """ |
|
|
| _JS = """ |
| <script> |
| function toggle(bodyId, headerEl) { |
| const body = document.getElementById(bodyId); |
| const open = body.classList.toggle('open'); |
| headerEl.classList.toggle('open', open); |
| } |
| </script> |
| """ |
|
|
|
|
| def _feature_html( |
| layer_idx: int, |
| rank: int, |
| feat_id: int, |
| probe_weight: float, |
| image_records, |
| text_records, |
| total_act: int, |
| dataset, |
| processor, |
| ) -> str: |
| body_id = f"feat_L{layer_idx}_F{feat_id}" |
|
|
| img_html = build_image_html(image_records, dataset) |
| txt_html = build_text_html(text_records, processor) |
|
|
| |
| img_html = img_html.replace( |
| "<h2>Top Activating Image Patches</h2>", |
| '<div class="section-title">Top Activating Image Patches</div>', 1, |
| ) |
| txt_html = txt_html.replace( |
| "<h2>Top Activating Text Tokens</h2>", |
| '<div class="section-title">Top Activating Text Tokens</div>', 1, |
| ) |
|
|
| return f""" |
| <div class="feature-block"> |
| <div class="feature-header" onclick="toggle('{body_id}', this)"> |
| <span class="arrow">►</span> |
| <span class="feat-rank">#{rank}</span> |
| <span class="feat-id">Feature {feat_id}</span> |
| <span class="feat-weight">probe w = {probe_weight:+.4f}</span> |
| <span class="feat-acts"> activations: {total_act}</span> |
| </div> |
| <div class="feature-body" id="{body_id}"> |
| {img_html} |
| {txt_html} |
| </div> |
| </div> |
| """ |
|
|
|
|
| def build_interactive_html( |
| layer_results: Dict[int, dict], |
| dataset, |
| processor, |
| probe_dir: str, |
| data_mode: str, |
| ) -> str: |
| layer_blocks: List[str] = [] |
|
|
| for layer_idx in sorted(layer_results.keys()): |
| res = layer_results[layer_idx] |
| feat_ids = res["feature_ids"] |
| probe_wts = res["probe_weights"] |
| image_heaps = res["image_heaps"] |
| text_heaps = res["text_heaps"] |
| hook_counts = res["hook_counts"] |
| hook_point = res["hook_point"] |
|
|
| layer_body_id = f"layer_{layer_idx}_body" |
|
|
| feature_blocks: List[str] = [] |
| for rank, (fi, pw) in enumerate(zip(feat_ids, probe_wts), start=1): |
| img_recs = image_heaps[fi].sorted_records() if fi in image_heaps else [] |
| txt_recs = text_heaps[fi].sorted_records() if fi in text_heaps else [] |
| total = hook_counts.get(fi, 0) |
| feature_blocks.append( |
| _feature_html(layer_idx, rank, fi, pw, img_recs, txt_recs, |
| total, dataset, processor) |
| ) |
|
|
| n_feats = len(feat_ids) |
| total_acts = sum(hook_counts.get(fi, 0) for fi in feat_ids) |
|
|
| layer_blocks.append(f""" |
| <div class="layer-block"> |
| <div class="layer-header" onclick="toggle('{layer_body_id}', this)"> |
| <span class="arrow">►</span> |
| <span class="layer-title">Layer {layer_idx}</span> |
| <span class="layer-meta"> |
| {hook_point} | |
| {n_feats} features | |
| {total_acts} total activations |
| </span> |
| </div> |
| <div class="layer-body" id="{layer_body_id}"> |
| <div class="feature-list">{"".join(feature_blocks)}</div> |
| </div> |
| </div> |
| """) |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Probe Feature Visualizer — Multi-Layer</title> |
| {_CSS} |
| </head> |
| <body> |
| <h1>Probe Feature Visualizer — Multi-Layer SAE Activations</h1> |
| <div class="container"> |
| <p style="color:#555;font-size:0.88rem;"> |
| Probe dir: <code>{probe_dir}</code> | data mode: <code>{data_mode}</code><br> |
| Click a <strong>Layer</strong> to expand its top probe features. |
| Click a <strong>Feature</strong> to see top-activating image patches and text tokens. |
| </p> |
| {"".join(layer_blocks)} |
| </div> |
| {_JS} |
| </body> |
| </html>""" |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| ap = argparse.ArgumentParser( |
| description="Multi-layer probe feature visualizer for LLaVA.", |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| ) |
|
|
| |
| ap.add_argument( |
| "--data_mode", required=True, choices=["toilet", "cc3m", "coco", "folder"], |
| help=( |
| "toilet: pbcong/bathroom-toilet positives + CC3M negatives (needs --image_folder). " |
| "cc3m: full CC3M via --hf_dataset + --local_val_path. " |
| "coco: COCO via --hf_dataset + --local_val_path. " |
| "folder: plain image folder via --data_dir." |
| ), |
| ) |
|
|
| |
| ap.add_argument("--sae_ckpt", required=True) |
| ap.add_argument("--model_name", default="llava-hf/llava-1.5-7b-hf") |
| ap.add_argument("--device_id", type=int, default=0) |
| ap.add_argument("--dtype", default="float16") |
|
|
| |
| ap.add_argument("--probe_dir", required=True, |
| help="Dir containing probe_model.language_model.layers.*.pt files.") |
| ap.add_argument("--probe_input_dim", type=int, default=65536, |
| help="SAE width (LinearProbe input_dim).") |
| ap.add_argument("--layers", type=int, nargs="+", default=list(range(7)), |
| help="LLM layers to process.") |
| ap.add_argument("--top_probe_k", type=int, default=10, |
| help="Top-k probe-weight features to visualize per layer.") |
|
|
| |
| ap.add_argument("--image_folder", default=None, |
| help="[toilet] Local CC3M image folder (positives + negative pool).") |
| ap.add_argument("--object_mode", default="toilet", choices=["toilet", "bathroom", "both"], |
| help="[toilet] Positive class: toilet==1, bathroom==1, or both (toilet==1 OR bathroom==1).") |
| ap.add_argument("--num_negatives", type=int, default=10000, |
| help="[toilet] Number of random CC3M negatives to include.") |
|
|
| |
| ap.add_argument( |
| "--caption_mode", default="generated", choices=["generated", "caption"], |
| help=( |
| "generated: model generates caption first; teacher-forced pass collects acts. " |
| "caption: use stored caption (CC3M txt / COCO sentences / " |
| "pbcong/bathroom-toilet caption field)." |
| ), |
| ) |
| ap.add_argument("--max_new_tokens", type=int, default=128, |
| help="Max tokens to generate per image (only with --caption_mode generated).") |
|
|
| |
| ap.add_argument("--hf_dataset", default=None, |
| help="HF dataset path (CC3M / COCO / also for toilet-negative captions).") |
| ap.add_argument("--local_val_path", default=None, |
| help="[cc3m/coco] Local image root for the HF dataset split.") |
| ap.add_argument("--data_dir", default=None, |
| help="[folder] Plain image folder.") |
| ap.add_argument("--split", default="train", |
| help="HF dataset split.") |
|
|
| |
| ap.add_argument("--num_workers", type=int, default=4) |
|
|
| |
| ap.add_argument("--batch_size", type=int, default=4) |
| ap.add_argument("--sae_batch", type=int, default=4096) |
| ap.add_argument("--threshold", type=float, default=1e-3) |
| ap.add_argument("--max_batches", type=int, default=None) |
|
|
| |
| ap.add_argument("--output_dir", default="outputs/probe_features") |
| ap.add_argument("--top_images", type=int, default=10) |
| ap.add_argument("--top_texts", type=int, default=10) |
| ap.add_argument("--buffer", type=int, default=10) |
|
|
| args = ap.parse_args() |
|
|
| |
| 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).") |
| if args.data_mode == "folder" and not args.data_dir: |
| ap.error("--data_mode folder requires --data_dir.") |
|
|
| |
| rank, world_size, local_rank = setup_distributed() |
| 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} | data_mode: {args.data_mode}") |
|
|
| |
| sae = load_sae_model(args.sae_ckpt, model_type="llava", hook_type="text", device=device) |
| sae.eval() |
|
|
| |
| model = HookedSAELlavaConditionalGeneration.from_pretrained(args.model_name) |
| model.to(device, dtype=dtype) |
| model.eval() |
| processor = LlavaProcessor.from_pretrained(args.model_name) |
|
|
| |
| probe_info: Dict[int, Tuple[List[int], List[float]]] = {} |
| for layer in args.layers: |
| feat_ids, feat_wts = load_probe_top_features( |
| args.probe_dir, layer, args.top_probe_k, args.probe_input_dim, |
| ) |
| probe_info[layer] = (feat_ids, feat_wts) |
| if rank == 0: |
| print(f" Layer {layer:2d} top features: {feat_ids}") |
|
|
| |
| dataset, dataloader = create_dataloader(args, processor, rank, world_size) |
| if rank == 0: |
| print(f"Dataset: {len(dataset)} samples, {len(dataloader)} batches/rank.") |
|
|
| |
| raw_results = multi_layer_pass( |
| model, sae, processor, dataloader, device, probe_info, args, |
| ) |
|
|
| |
| layer_results: Dict[int, dict] = {} |
|
|
| for layer in args.layers: |
| res = raw_results[layer] |
| feat_ids = res["feature_ids"] |
| image_heaps = res["image_heaps"] |
| text_heaps = res["text_heaps"] |
| hook_counts = res["hook_counts"] |
|
|
| if is_distributed: |
| output_dir = Path(args.output_dir) |
| tmp_dir = output_dir / f".ddp_tmp_L{layer}" |
| if rank == 0: |
| tmp_dir.mkdir(parents=True, exist_ok=True) |
| dist.barrier() |
|
|
| with open(tmp_dir / f"rank_{rank}.pkl", "wb") as f: |
| pickle.dump({ |
| "image_heaps": {fi: image_heaps[fi].sorted_records() for fi in feat_ids}, |
| "text_heaps": {fi: text_heaps[fi].sorted_records() for fi in feat_ids}, |
| "hook_counts": hook_counts, |
| }, f) |
| dist.barrier() |
|
|
| if rank == 0: |
| merged_img: Dict[int, TopKHeap] = {fi: TopKHeap(args.top_images) for fi in feat_ids} |
| merged_txt: Dict[int, TopKHeap] = {fi: TopKHeap(args.top_texts) for fi in feat_ids} |
| merged_cnt: Dict[int, int] = {fi: 0 for fi in feat_ids} |
|
|
| for r in range(world_size): |
| with open(tmp_dir / f"rank_{r}.pkl", "rb") as f: |
| data = pickle.load(f) |
| for fi in feat_ids: |
| for rec in data["image_heaps"][fi]: |
| merged_img[fi].push(rec.value, rec) |
| for rec in data["text_heaps"][fi]: |
| merged_txt[fi].push(rec.value, rec) |
| merged_cnt[fi] += data["hook_counts"][fi] |
|
|
| image_heaps = merged_img |
| text_heaps = merged_txt |
| hook_counts = merged_cnt |
| shutil.rmtree(tmp_dir) |
|
|
| if rank == 0: |
| layer_results[layer] = { |
| "feature_ids": feat_ids, |
| "probe_weights": res["probe_weights"], |
| "image_heaps": image_heaps, |
| "text_heaps": text_heaps, |
| "hook_counts": hook_counts, |
| "hook_point": res["hook_point"], |
| } |
| for fi in feat_ids: |
| print(f" feature {fi}: {hook_counts.get(fi, 0)} activations") |
|
|
| |
| if rank == 0: |
| output_dir = Path(args.output_dir) |
| output_dir.mkdir(parents=True, exist_ok=True) |
|
|
| print("\nBuilding interactive HTML …") |
| html = build_interactive_html( |
| layer_results, dataset, processor, args.probe_dir, args.data_mode, |
| ) |
| out_path = output_dir / "probe_features.html" |
| out_path.write_text(html, encoding="utf-8") |
| print(f"Done. Open: {out_path}") |
|
|
| summary = {} |
| for layer, res in layer_results.items(): |
| summary[f"layer_{layer}"] = { |
| str(fi): { |
| "probe_weight": pw, |
| "total_activations": res["hook_counts"].get(fi, 0), |
| } |
| for fi, pw in zip(res["feature_ids"], res["probe_weights"]) |
| } |
| with open(output_dir / "summary.json", "w") as f: |
| json.dump(summary, f, indent=2) |
|
|
| cleanup_distributed() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|