""" visualize_multilayer_sae_features.py — Single-pass SAE feature visualizer for LLaVA scoped to toilet / bathroom images only. Loads only images whose IDs appear in the HuggingFace dataset "pbcong/bathroom-toilet" and satisfy the requested mode: --object_mode toilet → rows where toilet == 1 --object_mode bathroom → rows where bathroom == 1 Caption modes: --caption_mode generated (default) — model is prompted with "USER: \\nDescribe this image. \\nASSISTANT:" and the residual stream is captured before generation (prefill only). --caption_mode dataset — the caption stored in the HF row (field "caption" or "txt") is appended, matching the cc3m_dataset convention. Usage ----- python training/visualize_multilayer_sae_features.py.py \\ --sae_ckpt training/multilayer_sae_ckpt/last.ckpt \\ --image_folder /path/to/cc3m_images/train \\ --object_mode toilet \\ --caption_mode generated \\ --feature_ids 0 1 42 \\ --hook_point model.language_model.layers.19.hook_resid_post \\ --output_dir visualize/multilayer_sae_features.py \\ --device_id 0 """ import sys import os import re import io import json import html as html_lib import base64 import argparse import heapq import pickle import shutil from pathlib import Path from typing import Dict, List, Tuple, Optional from dataclasses import dataclass import torch as t import torch.distributed as dist import torch.nn.functional as F import numpy as np from PIL import Image, ImageDraw from tqdm import tqdm from torch.utils.data import Dataset, DataLoader, DistributedSampler from transformers import LlavaProcessor from transformer_lens.hook_points import HookPoint sys.path.insert(0, str(Path(__file__).parent.parent)) 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 # ───────────────────────────────────────────────────────────────────────────── # Constants # ───────────────────────────────────────────────────────────────────────────── IMG_EXTS = {".jpg", ".jpeg", ".png"} IMAGE_TOKEN_ID = 32000 # LLaVA-1.5 token N_IMAGE_PATCHES = 576 # CLIP ViT-L/14 @ 336px → 24 × 24 PATCH_GRID = 24 # ───────────────────────────────────────────────────────────────────────────── # Distributed helpers # ───────────────────────────────────────────────────────────────────────────── def setup_distributed(): """Initialize distributed process group. Returns (rank, world_size, local_rank).""" if "RANK" in os.environ and "WORLD_SIZE" 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") return rank, world_size, local_rank return 0, 1, 0 def cleanup_distributed(): if dist.is_initialized(): dist.destroy_process_group() # ───────────────────────────────────────────────────────────────────────────── # Data structures for top-k tracking # ───────────────────────────────────────────────────────────────────────────── @dataclass class ImageRecord: """A top-activating image patch.""" value: float sample_idx: int patch_idx: int # 0–575 hook_name: str @dataclass class TextRecord: """A top-activating text token with surrounding context.""" value: float sample_idx: int hook_name: str context_ids: List[int] # token IDs in context window context_acts: List[float] # feature activations for context tokens target_relative: int # index of activated token within context class TopKHeap: """Min-heap that retains only the k largest entries.""" def __init__(self, k: int): self.k = k self._heap: List[Tuple[float, int, object]] = [] self._counter = 0 def push(self, value: float, record): self._counter += 1 entry = (value, self._counter, record) if len(self._heap) < self.k: heapq.heappush(self._heap, entry) elif value > self._heap[0][0]: heapq.heapreplace(self._heap, entry) def sorted_records(self) -> list: return [r for _, _, r in sorted(self._heap, reverse=True)] def __len__(self): return len(self._heap) # ───────────────────────────────────────────────────────────────────────────── # Data loading # ───────────────────────────────────────────────────────────────────────────── class ImageFolderDataset(Dataset): """Simple dataset from a folder of images (no captions).""" def __init__(self, data_dir: str): self.paths = sorted( p for p in Path(data_dir).rglob("*") if p.suffix.lower() in IMG_EXTS ) if not self.paths: raise FileNotFoundError(f"No images found under: {data_dir}") def __len__(self): return len(self.paths) def __getitem__(self, idx): p = self.paths[idx] return {"image": Image.open(p).convert("RGB"), "imgid": p.stem, "caption": ""} class ToiletWithNegativesDataset(Dataset): """ Loads pbcong/bathroom-toilet positives (toilet_mode==1) from a local image folder, plus num_negatives random CC3M images from the same folder that are NOT in the HF dataset. caption_mode="caption": positives → "caption" field from pbcong/bathroom-toilet (the CC3M caption stored in the HF row for that image). negatives → CC3M caption looked up via the CC3M HF dataset using __key__; falls back to "" if cc3m_hf is not provided or key absent. caption_mode="generated": All captions are ""; single_pass generates them on-the-fly. """ def __init__( self, image_folder: str, object_mode: str = "toilet", caption_mode: str = "generated", num_negatives: int = 10000, cc3m_hf: Optional[str] = None, cc3m_split: str = "train", ): import random from datasets import load_dataset as _lds assert object_mode in ("toilet", "bathroom", "both"), object_mode assert caption_mode in ("caption", "generated"), caption_mode # ── Load pbcong/bathroom-toilet ────────────────────────────────────── bt_ds = _lds("pbcong/bathroom-toilet", split="train+validation") bt_labels: Dict[str, int] = {} bt_captions: Dict[str, str] = {} for row in bt_ds: imgid = str(row["image_id"]) if object_mode == "both": bt_labels[imgid] = 1 if (row.get("toilet", 0) == 1 or row.get("bathroom", 0) == 1) else 0 else: bt_labels[imgid] = row.get(object_mode, 0) bt_captions[imgid] = row.get("caption", "") or "" bt_ids = set(bt_labels.keys()) # ── Build stem → local path map ────────────────────────────────────── stem_to_path: Dict[str, str] = {} for fname in os.listdir(image_folder): if Path(fname).suffix.lower() in IMG_EXTS: stem = fname.rsplit(".", 1)[0] stem_to_path[stem] = os.path.join(image_folder, fname) # ── Positives ──────────────────────────────────────────────────────── positive_ids = [ imgid for imgid in bt_ids if bt_labels.get(imgid) == 1 and imgid in stem_to_path ] # ── Negatives ──────────────────────────────────────────────────────── non_bt_stems = [s for s in stem_to_path if s not in bt_ids] n_neg = min(num_negatives, len(non_bt_stems)) negative_stems = random.sample(non_bt_stems, n_neg) # ── Caption lookup for negatives (CC3M HF) ─────────────────────────── neg_captions: Dict[str, str] = {} if caption_mode == "caption" and cc3m_hf and "cc3m" in cc3m_hf.lower(): neg_stems_set = set(negative_stems) cc3m_ds = _lds(cc3m_hf, split=cc3m_split) for row in cc3m_ds: key = str(row.get("__key__", "")) if key in neg_stems_set: neg_captions[key] = row.get("txt", "") or "" if len(neg_captions) == len(neg_stems_set): break # found all we need # ── Assemble sample list ────────────────────────────────────────────── self.samples: List[dict] = [] for imgid in positive_ids: caption = bt_captions.get(imgid, "") if caption_mode == "caption" else "" self.samples.append({"path": stem_to_path[imgid], "imgid": imgid, "caption": caption, "label": 1}) for stem in negative_stems: caption = neg_captions.get(stem, "") if caption_mode == "caption" else "" self.samples.append({"path": stem_to_path[stem], "imgid": stem, "caption": caption, "label": 0}) if not self.samples: raise RuntimeError( f"No samples found for object_mode={object_mode!r} in {image_folder!r}." ) def __len__(self) -> int: return len(self.samples) def __getitem__(self, idx: int) -> dict: s = self.samples[idx] return { "image": Image.open(s["path"]).convert("RGB"), "imgid": s["imgid"], "caption": s["caption"], "label": s["label"], } class _IndexedWrapper(Dataset): """Wraps a dataset to include the global index in each sample.""" def __init__(self, dataset: Dataset): self.dataset = dataset def __len__(self): return len(self.dataset) def __getitem__(self, idx): return {**self.dataset[idx], "_idx": idx} def create_dataloader( args, processor: LlavaProcessor, rank: int = 0, world_size: int = 1, ) -> Tuple[Dataset, DataLoader]: """ Unified dataloader for all three visualization scripts. --data_mode: "toilet" — pbcong/bathroom-toilet positives (filtered by --object_mode) + --num_negatives random CC3M images from --image_folder that are NOT in the HF dataset. Requires --image_folder. For --caption_mode caption with negatives, also pass --hf_dataset (CC3M) so their captions can be looked up. "cc3m" — Full CC3M via --hf_dataset + --local_val_path. "coco" — COCO (yerevann/coco-karpathy) via --hf_dataset + --local_val_path. "folder" — Plain image folder via --data_dir (no captions). --caption_mode: "caption" — teacher-forced with stored caption (CC3M txt / COCO sentences / pbcong/bathroom-toilet caption field for positives; CC3M txt looked up by __key__ for toilet negatives). "generated" — caption="" in every batch item; single_pass() generates the caption first, then does a second teacher-forced activation pass. """ data_mode = getattr(args, "data_mode", "toilet") caption_mode = getattr(args, "caption_mode", "generated") if data_mode == "toilet": dataset = ToiletWithNegativesDataset( image_folder = args.image_folder, object_mode = getattr(args, "object_mode", "toilet"), caption_mode = caption_mode, num_negatives = getattr(args, "num_negatives", 10000), cc3m_hf = getattr(args, "hf_dataset", None), cc3m_split = getattr(args, "split", "train"), ) 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.model_name, batch_size = args.batch_size, num_workers = getattr(args, "num_workers", 4), ) split = getattr(args, "split", "train") dataset = (cc3m_dataset if data_mode == "cc3m" else coco_dataset)(data_cfg, split) elif data_mode == "folder": dataset = ImageFolderDataset(args.data_dir) else: raise ValueError(f"Unknown data_mode: {data_mode!r}") indexed = _IndexedWrapper(dataset) sampler = ( DistributedSampler(indexed, num_replicas=world_size, rank=rank, shuffle=False, drop_last=True) if world_size > 1 else None ) prompt = "USER: \nDescribe this image. \nASSISTANT:" def collate_fn(batch: List[dict]): batch = [b for b in batch if b is not None] if not batch: return None images = [b["image"] for b in batch] global_idxs = [b["_idx"] 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: 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"], "global_idxs": global_idxs, } dataloader = DataLoader( indexed, batch_size = args.batch_size, shuffle = False, sampler = sampler, num_workers = getattr(args, "num_workers", 4), collate_fn = collate_fn, ) return dataset, dataloader # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def find_image_token_positions(input_ids: t.Tensor) -> t.Tensor: """Return position of in each sample, or -1 if absent.""" mask = input_ids == IMAGE_TOKEN_ID has = mask.any(dim=1) pos = mask.int().argmax(dim=1) pos[~has] = -1 return pos def expanded_to_original(exp_pos: int, ip: int) -> int: """Map expanded-sequence position to original input_ids position.""" if ip < 0: return exp_pos if exp_pos < ip: return exp_pos if exp_pos < ip + N_IMAGE_PATCHES: return -1 # image patch — no original token return exp_pos - N_IMAGE_PATCHES + 1 def original_to_expanded(orig_pos: int, ip: int) -> int: """Map original input_ids position to expanded-sequence position.""" if ip < 0: return orig_pos if orig_pos < ip: return orig_pos if orig_pos == ip: return -1 # token expands to 576 patches return orig_pos + N_IMAGE_PATCHES - 1 def _short_hook(name: str) -> str: parts = name.split(".") for i, p in enumerate(parts): if p == "layers" and i + 1 < len(parts): layer_num = parts[i + 1] hook_type = ( parts[-1].replace("hook_resid_", "") if parts[-1].startswith("hook_") else parts[-1] ) return f"L{layer_num}.{hook_type}" return name[-20:] # ───────────────────────────────────────────────────────────────────────────── # Single pass # ───────────────────────────────────────────────────────────────────────────── @t.no_grad() def single_pass( model, sae, processor: LlavaProcessor, dataloader: DataLoader, device: t.device, feature_ids: List[int], args, ) -> Tuple[ Dict[int, TopKHeap], # image heaps Dict[int, TopKHeap], # text heaps Dict[int, int], # activation counts per feature ]: """ Single forward pass over the dataset. For each batch: 1. Run LLaVA forward, capturing the single specified hook activation. In ``generated`` mode the model first generates a caption per image, then a second teacher-forced forward pass with the full ``prompt + generated_caption`` is used to collect activations. 2. Run the SAE to get sparse feature activations. 3. For each target feature, update top-k heaps and activation counts. """ sae_dtype = next(sae.parameters()).dtype sae.eval() sae.to(device) image_heaps = {fi: TopKHeap(args.top_images) for fi in feature_ids} text_heaps = {fi: TopKHeap(args.top_texts) for fi in feature_ids} hook_counts: Dict[int, int] = {fi: 0 for fi in feature_ids} 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"] # List[int] # ── Generated caption mode ─────────────────────────────────────────── # Mirrors Train_Probe_SAE.py: generate → decode → rebuild prompt with # generated caption → re-process through processor → forward pass. # This avoids feeding raw gen_ids (with padding / EOS artefacts) back # into the model and guarantees a clean teacher-forced activation pass. if args.caption_mode == "generated": with t.no_grad(): gen_ids = model.generate( **model_inputs, do_sample=False, num_beams=1, use_cache=True, max_new_tokens=args.max_new_tokens, ) # (B, T_prompt + T_gen) # Decode each sample, extract only the ASSISTANT response full_texts = processor.batch_decode(gen_ids, skip_special_tokens=True) captions = [] for txt in full_texts: if "ASSISTANT:" in txt: captions.append(txt.split("ASSISTANT:")[-1].strip()) else: captions.append(txt.strip()) # Rebuild clean inputs with the generated caption appended # We need the original PIL images from the dataset images = [ dataloader.dataset.dataset[gi]["image"] # _IndexedWrapper → base dataset for gi in global_idxs ] forced_texts = [ f"USER: \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, T_forced) B = input_ids_cpu.shape[0] else: input_ids_cpu = batch["input_ids"] # (B, T_orig) acts = model.get_activation_at(model_inputs, args.hook_point) # (B, T, D) img_positions = find_image_token_positions(input_ids_cpu) # (B,) _, 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) # (B*T, 1, k) → (B, T, k) idxs = idxs.squeeze(1).reshape(B, T, -1) vals = vals.squeeze(1).reshape(B, T, -1) for fi in feature_ids: mask = (idxs == fi) feat_acts = (vals * mask.float()).sum(dim=-1) # (B, T) # Activation count for this feature above = feat_acts > args.threshold hook_counts[fi] += above.sum().item() # ── Per-sample top-k extraction ───────────────────────── for b in range(B): ip = img_positions[b].item() global_idx = global_idxs[b] acts_b = feat_acts[b] # (T,) # — Image patches — 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(), args.hook_point), ) # — Text tokens (before and after image) — 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 # Skip placeholder tokens regardless of ip detection if orig_pos < input_ids_cpu.shape[1] and input_ids_cpu[b, orig_pos].item() == IMAGE_TOKEN_ID: continue # Context window in original token space # Clamp so token (position ip) never leaks in 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() # Feature activations for context tokens 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, args.hook_point, ctx_ids, ctx_acts_list, orig_pos - ctx_s, ), ) del acts, flat, idxs, vals, feat_acts return image_heaps, text_heaps, hook_counts # ───────────────────────────────────────────────────────────────────────────── # Visualisation helpers # ───────────────────────────────────────────────────────────────────────────── def _img_to_b64(img: Image.Image, max_dim: int = 400) -> str: """Resize (thumbnail) and encode an image as base64 PNG.""" img = img.copy() img.thumbnail((max_dim, max_dim)) buf = io.BytesIO() img.save(buf, format="PNG") return base64.b64encode(buf.getvalue()).decode() def _patch_crop( img: Image.Image, patch_idx: int, context: int = 2, ) -> Image.Image: """Crop a region around the given patch from the original image.""" W, H = img.size pw, ph = W / PATCH_GRID, H / PATCH_GRID row, col = patch_idx // PATCH_GRID, patch_idx % PATCH_GRID r0 = max(0, row - context) r1 = min(PATCH_GRID, row + context + 1) c0 = max(0, col - context) c1 = min(PATCH_GRID, col + context + 1) return img.crop((int(c0 * pw), int(r0 * ph), int(c1 * pw), int(r1 * ph))) def _annotate_image(img: Image.Image, patch_idx: int) -> Image.Image: """Draw a red rectangle on the image around the given patch.""" annotated = img.copy() W, H = annotated.size pw, ph = W / PATCH_GRID, H / PATCH_GRID row, col = patch_idx // PATCH_GRID, patch_idx % PATCH_GRID draw = ImageDraw.Draw(annotated) x0, y0 = int(col * pw), int(row * ph) x1, y1 = int((col + 1) * pw), int((row + 1) * ph) width = max(2, min(W, H) // 100) draw.rectangle([x0, y0, x1, y1], outline="red", width=width) return annotated def build_image_html(records: List[ImageRecord], dataset: Dataset) -> str: """Build HTML showing top activating image patch crops.""" if not records: return "

No image patch activations above threshold.

" cards: List[str] = [] for rank, rec in enumerate(records): sample = dataset[rec.sample_idx] img = sample["image"] imgid = sample.get("imgid", rec.sample_idx) annotated = _annotate_image(img, rec.patch_idx) crop = _patch_crop(img, rec.patch_idx) full_b64 = _img_to_b64(annotated, max_dim=350) crop_b64 = _img_to_b64(crop, max_dim=200) row, col = rec.patch_idx // PATCH_GRID, rec.patch_idx % PATCH_GRID cards.append( f'
' f'
' f' ' f' ' f'
' f'
' f' #{rank+1}   act={rec.value:.3f}   ' f' patch={rec.patch_idx} ({row},{col})' f'   {_short_hook(rec.hook_name)}   imgid={imgid}' f'
' f'
' ) return "

Top Activating Image Patches

\n" + "\n".join(cards) def build_text_html( records: List[TextRecord], processor: LlavaProcessor, ) -> str: """Build HTML showing top activating text tokens with context.""" if not records: return "

No text activations above threshold.

" # Filter out records whose target token is the placeholder records = [ rec for rec in records if not (rec.target_relative < len(rec.context_ids) and rec.context_ids[rec.target_relative] == IMAGE_TOKEN_ID) ] if not records: return "

No text activations above threshold.

" entries: List[str] = [] for rank, rec in enumerate(records): tokens: List[str] = [] for tid in rec.context_ids: if tid == IMAGE_TOKEN_ID: tokens.append("") # omit from context display else: tokens.append(processor.tokenizer.decode(tid)) max_act = max(rec.context_acts) if rec.context_acts else 1.0 if max_act <= 0: max_act = 1.0 spans: List[str] = [] for i, (tok, act) in enumerate(zip(tokens, rec.context_acts)): intensity = min(1.0, act / max_act) g = int(200 * intensity) bg = f"rgba(0,{g},0,{intensity * 0.6:.2f})" if intensity > 0.05 else "transparent" bold = "font-weight:bold;" if i == rec.target_relative else "" underline = "border-bottom:2px solid #e74c3c;" if i == rec.target_relative else "" tok_safe = html_lib.escape(tok) spans.append( f'{tok_safe}' ) entries.append( f'
' f'
#{rank+1}
' f'
' f'
{"".join(spans)}
' f'
' f' act={rec.value:.3f}   {_short_hook(rec.hook_name)}' f'
' f'
' f'
' ) return "

Top Activating Text Tokens

\n" + "\n".join(entries) # ───────────────────────────────────────────────────────────────────────────── # Per-feature HTML report # ───────────────────────────────────────────────────────────────────────────── def save_feature_report( feat_idx: int, image_records: List[ImageRecord], text_records: List[TextRecord], total_act: int, hook_point: str, dataset: Dataset, processor: LlavaProcessor, output_dir: Path, ) -> Optional[str]: """Generate and save a full HTML report for one feature.""" feat_dir = output_dir / f"feature_{feat_idx}" feat_dir.mkdir(parents=True, exist_ok=True) if total_act == 0 and not image_records and not text_records: return None image_section = build_image_html(image_records, dataset) text_section = build_text_html(text_records, processor) html = f""" Feature {feat_idx}

Feature {feat_idx}

Hook{hook_point}
Total activations{total_act}
Image records{len(image_records)}
Text records{len(text_records)}
{image_section} {text_section} """ report_path = feat_dir / "report.html" report_path.write_text(html, encoding="utf-8") return str(report_path) # ───────────────────────────────────────────────────────────────────────────── # Index page # ───────────────────────────────────────────────────────────────────────────── def save_index_page( feature_ids: List[int], report_paths: Dict[int, Optional[str]], hook_counts: Dict[int, int], hook_point: str, output_dir: Path, ): rows: List[str] = [] for fi in feature_ids: path = report_paths.get(fi) total = hook_counts.get(fi, 0) if path: rel = os.path.relpath(path, output_dir) link = f'feature_{fi}' else: link = f'feature_{fi} (no activations)' rows.append(f"{fi}{total}{link}") html = f""" SAE Feature Index

SAE Feature Index

Hook: {hook_point}

{"".join(rows)}
FeatureTotal ActsReport
""" (output_dir / "index.html").write_text(html, encoding="utf-8") # ───────────────────────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser( description="Single-pass SAE feature visualizer for LLaVA.", ) # Model / SAE 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") # ── Data mode ──────────────────────────────────────────────────────────── ap.add_argument( "--data_mode", default="toilet", choices=["toilet", "cc3m", "coco", "folder"], help=( "toilet: pbcong/bathroom-toilet positives + CC3M negatives. " "cc3m: full CC3M via --hf_dataset + --local_val_path. " "coco: COCO via --hf_dataset + --local_val_path. " "folder: plain image folder via --data_dir." ), ) # ── Data — toilet ──────────────────────────────────────────────────────── 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.") # ── Data — CC3M / COCO / toilet-negative captions ──────────────────────── ap.add_argument("--hf_dataset", default=None, help="HF dataset path (CC3M / COCO / also used 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.") # ── Caption mode ───────────────────────────────────────────────────────── 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("--num_workers", type=int, default=4) # Features (required) ap.add_argument("--feature_ids", type=int, nargs="+", required=True, help="SAE feature IDs to visualize.") # Processing ap.add_argument("--batch_size", type=int, default=4, help="DataLoader batch size. Keep small (4-8) for LLaVA-7B.") ap.add_argument("--sae_batch", type=int, default=4096, help="Sub-batch size for SAE processing.") ap.add_argument("--threshold", type=float, default=1e-3, help="Minimum activation to count / record.") ap.add_argument("--max_batches", type=int, default=None, help="Limit number of batches (useful for testing).") 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("--hook_point", type=str, required=True, help="Exact hook name to visualize, e.g. " "'model.language_model.layers.19.hook_resid_post'.") # Visualisation ap.add_argument("--output_dir", default="outputs/features") ap.add_argument("--top_images", type=int, default=20, help="Number of top image patches to show per feature.") ap.add_argument("--top_texts", type=int, default=20, help="Number of top text tokens to show per feature.") ap.add_argument("--buffer", type=int, default=10, help="Token context window radius for text display.") 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.") # ── Distributed setup ──────────────────────────────────────────────────── rank, world_size, local_rank = setup_distributed() is_distributed = world_size > 1 device = t.device( f"cuda:{local_rank}" if is_distributed else f"cuda:{args.device_id}" if t.cuda.is_available() else "cpu" ) dtype = str_to_torch_dtype(args.dtype) if rank == 0: print(f"Running with {world_size} GPU(s)") # ── Load SAE ───────────────────────────────────────────────────────────── sae = load_sae_model( args.sae_ckpt, model_type="llava", hook_type="text", device=device, ) sae.eval() # ── Load model + processor ─────────────────────────────────────────────── model = HookedSAELlavaConditionalGeneration.from_pretrained(args.model_name) model.to(device, dtype=dtype) model.eval() processor = LlavaProcessor.from_pretrained(args.model_name) # ── Load data ──────────────────────────────────────────────────────────── dataset, dataloader = create_dataloader(args, processor, rank, world_size) if rank == 0: print(f"Dataset: {len(dataset)} samples, {len(dataloader)} batches/rank.") print(f"Features to visualize: {args.feature_ids}") # ── Single pass (each rank processes its shard) ────────────────────────── image_heaps, text_heaps, hook_counts = single_pass( model, sae, processor, dataloader, device, args.feature_ids, args, ) # ── Gather results from all ranks ──────────────────────────────────────── output_dir = Path(args.output_dir) if is_distributed: t.cuda.empty_cache() tmp_dir = output_dir / ".ddp_tmp" 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 args.feature_ids}, "text_heaps": {fi: text_heaps[fi].sorted_records() for fi in args.feature_ids}, "hook_counts": hook_counts, }, f) dist.barrier() if rank == 0: merged_img = {fi: TopKHeap(args.top_images) for fi in args.feature_ids} merged_txt = {fi: TopKHeap(args.top_texts) for fi in args.feature_ids} merged_cnt: Dict[int, int] = {fi: 0 for fi in args.feature_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 args.feature_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) # ── Only rank 0 generates reports ──────────────────────────────────────── if rank == 0: output_dir.mkdir(parents=True, exist_ok=True) report_paths: Dict[int, Optional[str]] = {} summary: Dict[str, dict] = {} for fi in args.feature_ids: print(f"Feature {fi} ...", end=" ") img_recs = image_heaps[fi].sorted_records() txt_recs = text_heaps[fi].sorted_records() total = hook_counts[fi] path = save_feature_report( fi, img_recs, txt_recs, total, args.hook_point, dataset, processor, output_dir, ) report_paths[fi] = path summary[str(fi)] = {"total_activations": total, "hook_point": args.hook_point} print("saved." if path else "skipped (no activations).") save_index_page(args.feature_ids, report_paths, hook_counts, args.hook_point, output_dir) with open(output_dir / "summary.json", "w") as f: json.dump(summary, f, indent=2) print(f"\nDone. Open {output_dir}/index.html to browse features.") cleanup_distributed() if __name__ == "__main__": main()