hallucination / mechanistic_interp /per_layer_effect.py
ToiTenBao's picture
Upload hallucination folder
a2ffd07 verified
Raw
History Blame Contribute Delete
35.4 kB
"""
Per-layer ΔW effect — matched fixed-prefix probe.
Input pattern matches ``probe_output_trace.py``:
* ``--prompt`` → the user-side question (e.g. "Describe this image.")
* ``--fixed_assistant_prefix`` → the assistant-side text teacher-forced after the prompt
For every filter-passing image, teacher-force ``prompt + " " +
fixed_assistant_prefix`` once under base weights, once per ``L_int`` under
ΔW@L_int (single-layer), and once under ΔW@all-layers. **No autoregressive
decoding** — the residuals come from a single forward pass per condition.
At every selected capture layer ``L_cap``:
residual @ K assistant-prefix positions → FrozenSAEEncoder → (K, d_sae)
pool over K (max|mean, ``--pool``) → (d_sae,)
* gather the layer's selected features and aggregate (mean|max,
``--agg``) over the top-k feature dimension → ``per_layer_effect_L*.png``
* apply the per-layer linear probe head (``probes.{L}.weight``,
``probes.{L}.bias``) → ``per_layer_effect_probe_L*.png``
The 32 ``L_int`` panels are **split across multiple PNGs** (``_GROUP_SIZE``
panels per PNG, default 4, 2×2 layout) so each panel is large enough to
read. Files land in ``{graph_dir}/{image_id}/`` as
``per_layer_effect_L00-03.png`` … ``per_layer_effect_L28-31.png`` and the
matching ``per_layer_effect_probe_L*.png`` set.
A companion summary plot ``per_layer_effect_all.png`` is also emitted: a
1×2 layout (features left, probes right) comparing base vs ΔW@all-layers
simultaneously.
For the same prompt + assistant-prefix + image, the probe numbers reported
here are identical to ``probe_output_trace.py`` (same SAE wrapper, same
max-pool, same float32 GPU torch.dot procedure).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import traceback
from typing import Dict, List, Tuple
# Make `hallucination.*` importable for sae.SAE_Tools etc.
_PARENT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if _PARENT not in sys.path:
sys.path.insert(0, _PARENT)
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from tqdm import tqdm
from transformers import LlavaProcessor
from mechanistic_interp.constants import (
AGG_CHOICES,
CATEGORY_CHOICES,
COLOR_BASE,
COLOR_LORA,
POOL_CHOICES,
PROBE_OUTPUT_CHOICES,
PROMPT_TEMPLATE,
)
from mechanistic_interp.delta_w_feature_trace import (
build_hook_name,
build_image_index,
filter_samples,
teacher_forced_capture,
)
from mechanistic_interp.lora_delta import applied_lora_pairs, load_lora_pairs
from experiment.training.finetune_adv import FrozenSAEEncoder
from model.llava.hooked_llava import HookedSAELlavaConditionalGeneration
# ── SAE encode + per-feature gather + probe head (single residual) ───────────
@torch.no_grad()
def _sae_dense_at(resid: torch.Tensor, sae, pool: str, device) -> torch.Tensor:
"""SAE-encode a ``(K, D)`` residual slice via ``FrozenSAEEncoder``
(JumpReLU dense), pool over K, return ``(d_sae,)`` device float32.
Mirrors ``probe_output_trace.capture_probe_p`` exactly — same encoder,
same pool, same float32 device tensor — so the downstream GPU
``torch.dot(dense, w) + b`` reports identical numbers to
``probe_output_trace`` for the same residual.
"""
if resid.numel() == 0:
d_sae = getattr(sae, "d_sae", None) or getattr(getattr(sae, "cfg", None), "d_sae", 0)
return torch.zeros(int(d_sae), dtype=torch.float32, device=device)
latents = sae(resid.to(device)) # (K, d_sae)
if pool == "max":
pooled = latents.max(dim=0).values # (d_sae,)
else:
pooled = latents.mean(dim=0) # (d_sae,)
return pooled.float()
@torch.no_grad()
def _capture_acts_and_probes(
*, model, sae, device, dtype_attn,
full_ids, pixel_values, new_len: int,
layers: List[int], hook_type: str, pool: str,
selected: Dict[int, List[int]],
probes: Dict[int, Dict[str, torch.Tensor]],
) -> Tuple[Dict[int, torch.Tensor], Dict[int, float]]:
"""Teacher-force once, hook every selected capture layer at
``hook_resid_<hook_type>``, slice the last ``new_len`` positions (the
assistant-prefix tokens), pool, and return:
* ``acts[L]`` — (top_k_L,) gathered feature values, CPU float32
* ``probe_out[L]`` — float, probe logit = w[L] @ pooled + b[L]
(sigmoid applied later at plot time so the
``--probe_output`` flag still works).
Same slice + pool + GPU float32 ``torch.dot`` as
``probe_output_trace.capture_probe_p``.
"""
hook_names = {build_hook_name(L, hook_type) for L in layers}
cache = teacher_forced_capture(model, full_ids, pixel_values, hook_names, dtype_attn)
acts: Dict[int, torch.Tensor] = {}
probe_out: Dict[int, float] = {}
for L in layers:
hp = build_hook_name(L, hook_type)
cache_t = cache.get(hp)
if cache_t is None:
continue
slice_ = cache_t[0, -new_len:] # (K, D)
dense = _sae_dense_at(slice_, sae, pool, device) # (d_sae,) device f32
feats = selected.get(L, [])
if feats:
fid = torch.tensor(feats, dtype=torch.long, device=dense.device)
acts[L] = dense.index_select(0, fid).detach().cpu().clone()
else:
acts[L] = torch.zeros(0, dtype=torch.float32)
p = probes.get(L)
if p is not None:
w = p["w"].to(device=dense.device, dtype=torch.float32)
b = p["b"].to(device=dense.device, dtype=torch.float32)
logit = torch.dot(dense, w) + b
probe_out[L] = float(logit)
else:
probe_out[L] = float("nan")
return acts, probe_out
# ── Probe head ───────────────────────────────────────────────────────────────
_PROBE_KEY_RE = re.compile(r"probes\.(\d+)\.weight$")
def load_probe_heads(probes_path: str) -> Dict[int, Dict[str, torch.Tensor]]:
"""Return ``{L: {'w': (d_sae,), 'b': scalar}}`` from a probes state-dict.
Mirrors the parse used by ``select_features.select_by_probe`` — bias is
stored as a 0-d tensor on CPU; weight is float32 (1, d_sae) squeezed to
(d_sae,).
"""
sd = torch.load(probes_path, map_location="cpu", weights_only=False)
if hasattr(sd, "state_dict"):
sd = sd.state_dict()
out: Dict[int, Dict[str, torch.Tensor]] = {}
for k, v in sd.items():
m = _PROBE_KEY_RE.match(k)
if m is None:
continue
L = int(m.group(1))
out.setdefault(L, {})["w"] = v.squeeze(0).float().cpu()
for k, v in sd.items():
if k.endswith(".bias") and k.startswith("probes."):
L = int(k.split(".")[1])
if L in out:
out[L]["b"] = v.float().cpu().reshape(())
# Default missing biases to 0 just in case the dict was partial.
for L, d in out.items():
d.setdefault("b", torch.zeros((), dtype=torch.float32))
return out
# ── Aggregation ──────────────────────────────────────────────────────────────
def _agg_layer_map(
act_map: Dict[int, torch.Tensor], layers: List[int], agg: str,
) -> np.ndarray:
"""Reduce ``{L: (top_k_L,)}`` to a (n_layers,) array of scalars.
``mean`` averages the top-k features; ``max`` takes their peak.
Missing/empty entries become NaN.
"""
out = np.full(len(layers), np.nan, dtype=np.float32)
for i, L in enumerate(layers):
t = act_map.get(L)
if t is None or t.numel() == 0:
continue
tf = t.float()
out[i] = float(tf.mean()) if agg == "mean" else float(tf.max())
return out
# ── Per-sample driver ────────────────────────────────────────────────────────
@torch.no_grad()
def trace_one_sample(
*,
sample,
image_index,
prompt,
fixed_assistant_prefix: str,
processor,
model,
sae,
lora_pairs,
lora_scale,
selected: Dict[int, List[int]],
layers: List[int],
hook_type: str,
pool: str,
device,
dtype_attn,
probes: Dict[int, Dict[str, torch.Tensor]],
):
image_id = sample["image_id"]
image = image_index.get(str(image_id))
if image is None:
return None, f"image not found in HF split for {image_id}"
# Tokenize prompt-only and prompt+assistant-prefix; the K assistant-prefix
# positions are the last (matched_ids.shape[1] - prompt_len) tokens.
# Same recipe as probe_output_trace.trace_one_image.
text = PROMPT_TEMPLATE.format(question=prompt)
inputs = processor(images=image, text=text, return_tensors="pt").to(device)
prompt_len = int(inputs["input_ids"].shape[1])
pixel_values = inputs["pixel_values"]
full_text = text + " " + fixed_assistant_prefix
full_inputs = processor(images=image, text=full_text,
return_tensors="pt").to(device)
matched_ids = full_inputs["input_ids"]
new_len = int(matched_ids.shape[1] - prompt_len)
if new_len <= 0:
return None, "prefix tokenized to 0 new tokens"
matched_text = processor.tokenizer.decode(matched_ids[0, prompt_len:])
def _capture():
return _capture_acts_and_probes(
model=model, sae=sae, device=device, dtype_attn=dtype_attn,
full_ids=matched_ids, pixel_values=pixel_values, new_len=new_len,
layers=layers, hook_type=hook_type, pool=pool,
selected=selected, probes=probes,
)
# 1) Base.
base_acts, base_probe = _capture()
# 2) ΔW@L_int sweep — single-layer intervention.
lora_acts: Dict[int, Dict[int, torch.Tensor]] = {}
lora_probe: Dict[int, Dict[int, float]] = {}
for L_int in tqdm(layers, desc=f" L_int sweep ({image_id})", leave=False):
if not selected.get(L_int):
continue
with applied_lora_pairs(
model, lora_pairs, lora_scale,
components="all", layers=[L_int], language_only=True,
lowmem=False, save_device="cpu",
):
a, p = _capture()
lora_acts[L_int] = a
lora_probe[L_int] = p
# 3) ΔW@all-layers — every selected layer simultaneously.
lora_all_acts: Dict[int, torch.Tensor] = {}
lora_all_probe: Dict[int, float] = {}
if any("language_model" in mp for mp in lora_pairs):
with applied_lora_pairs(
model, lora_pairs, lora_scale,
components="all", layers=layers, language_only=True,
lowmem=True,
):
lora_all_acts, lora_all_probe = _capture()
return {
"image_id": image_id,
"category": sample.get("category"),
"prompt": prompt,
"fixed_assistant_prefix": fixed_assistant_prefix,
"matched_text": matched_text,
"new_len": new_len,
"base_acts": base_acts,
"lora_acts": lora_acts,
"lora_all_acts": lora_all_acts,
"base_probe": base_probe,
"lora_probe": lora_probe,
"lora_all_probe": lora_all_probe,
"feature_ids_per_layer": {L: selected[L] for L in layers if L in selected},
}, None
# ── Plotting ─────────────────────────────────────────────────────────────────
# Panels per PNG. 32 L_int values → 8 PNGs at GROUP_SIZE=4. Layout is 2×2
# per PNG so each panel is large enough to read individual layer values.
_GROUP_SIZE = 4
_GROUP_ROWS = 2
_GROUP_COLS = 2
def _layer_groups(layers: List[int], group_size: int = _GROUP_SIZE) -> List[List[int]]:
"""Yield consecutive chunks of ``layers`` of size ``group_size``."""
return [layers[i: i + group_size] for i in range(0, len(layers), group_size)]
def _group_tag(group: List[int]) -> str:
"""``[0,1,2,3]`` → ``'L00-03'`` (zero-padded so filenames sort)."""
if not group:
return "Lempty"
return f"L{group[0]:02d}-{group[-1]:02d}"
def _render_per_layer_grid(
*,
out_path: str,
layers: List[int],
panel_layers: List[int],
base_curve: np.ndarray,
lora_curves: Dict[int, np.ndarray],
image_id: str,
suptitle_extra: str,
y_label: str,
):
"""One PNG covering ``panel_layers`` (one panel per L_int) with the
full ``layers`` set on the x-axis. Y-axis auto-scales per panel so
early-layer detail isn't swallowed by late-layer spikes.
``base_curve`` has shape ``(len(layers),)``; ``lora_curves[L_int]``
same. NaNs render as gaps. Aggregation choice (mean/max or sigmoid
probe) is decided by the caller.
"""
if not layers or not panel_layers:
return
rows = _GROUP_ROWS
cols = _GROUP_COLS
fig, axes = plt.subplots(
rows, cols,
figsize=(cols * 4.5, rows * 4.0),
squeeze=False, sharex=True, sharey=False,
)
xs = np.arange(len(layers))
xtick_step = max(1, len(layers) // 8)
xtick_idx = np.arange(0, len(layers), xtick_step)
base_line = lora_line = None
for idx, L_int in enumerate(panel_layers):
r = idx // cols
c = idx % cols
ax = axes[r][c]
lora_curve = lora_curves.get(
L_int, np.full(len(layers), np.nan, dtype=np.float32),
)
bl, = ax.plot(
xs, base_curve,
color=COLOR_BASE, linestyle="-", marker="o",
markersize=4, linewidth=1.6, label="base",
)
ll, = ax.plot(
xs, lora_curve,
color=COLOR_LORA, linestyle="--", marker="s",
markersize=4, linewidth=1.6, label="ΔW@L_int",
)
base_line = bl
lora_line = ll
if L_int in layers:
ax.axvline(
layers.index(L_int), color="black", linestyle=":",
linewidth=0.9, alpha=0.5,
)
ax.set_title(f"L_int = {L_int}", fontsize=11, pad=3)
ax.grid(True, linestyle=":", alpha=0.45)
ax.set_xticks(xtick_idx)
ax.set_xticklabels([str(layers[i]) for i in xtick_idx], fontsize=9)
ax.tick_params(axis="y", labelsize=9)
if r == rows - 1:
ax.set_xlabel("capture layer L", fontsize=10)
if c == 0:
ax.set_ylabel(y_label, fontsize=10)
for idx in range(len(panel_layers), rows * cols):
r = idx // cols
c = idx % cols
axes[r][c].axis("off")
if base_line is not None and lora_line is not None:
fig.legend(
[base_line, lora_line], ["base", "ΔW@L_int"],
loc="upper center", ncol=2, bbox_to_anchor=(0.5, 0.975),
fontsize=11, frameon=False,
)
fig.suptitle(
f"{image_id} — per-layer ΔW effect, fixed-prefix probe "
f"(L_int ∈ {{{', '.join(str(L) for L in panel_layers)}}}) "
f"{suptitle_extra}",
fontsize=11, y=0.995,
)
fig.subplots_adjust(top=0.90, hspace=0.30, wspace=0.22)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
fig.savefig(out_path, dpi=120, bbox_inches="tight")
plt.close(fig)
def _render_per_layer_groups(
*,
out_dir: str,
filename_stem: str,
layers: List[int],
base_curve: np.ndarray,
lora_curves: Dict[int, np.ndarray],
image_id: str,
suptitle_extra: str,
y_label: str,
) -> List[str]:
"""Iterate ``_layer_groups`` and emit one PNG per group.
Returns the list of paths written so the caller can log them.
"""
written = []
for group in _layer_groups(layers):
tag = _group_tag(group)
out_path = os.path.join(out_dir, f"{filename_stem}_{tag}.png")
_render_per_layer_grid(
out_path=out_path,
layers=layers,
panel_layers=group,
base_curve=base_curve,
lora_curves=lora_curves,
image_id=image_id,
suptitle_extra=suptitle_extra,
y_label=y_label,
)
if os.path.exists(out_path):
written.append(out_path)
return written
def plot_per_layer_effect(
*,
out_dir: str,
layers: List[int],
base_acts: Dict[int, torch.Tensor],
lora_acts: Dict[int, Dict[int, torch.Tensor]],
image_id: str,
matched_text: str,
agg: str,
) -> List[str]:
"""Top-k feature activation, aggregated per layer with ``--agg``.
Emits one PNG per ``L_int`` group under ``out_dir`` and returns the
list of paths actually written.
"""
base_curve = _agg_layer_map(base_acts, layers, agg)
lora_curves = {
L_int: _agg_layer_map(lora_acts.get(L_int, {}), layers, agg)
for L_int in layers
}
return _render_per_layer_groups(
out_dir=out_dir,
filename_stem="per_layer_effect",
layers=layers,
base_curve=base_curve,
lora_curves=lora_curves,
image_id=image_id,
suptitle_extra=f"(agg={agg}; prefix: {matched_text.strip()[:90]!r})",
y_label=f"{agg}(top-k feats)",
)
def _probe_curve(
probe_map: Dict[int, float], layers: List[int], probe_output: str,
) -> np.ndarray:
"""Collapse ``{L: logit}`` to ``(n_layers,)`` of float, applying
sigmoid if ``probe_output == 'prob'``. NaN cells stay NaN."""
out = np.full(len(layers), np.nan, dtype=np.float32)
for i, L in enumerate(layers):
v = probe_map.get(L) if probe_map else None
if v is None:
continue
z = float(v)
if not np.isfinite(z):
continue
if probe_output == "prob":
# Stable sigmoid: protect against overflow on large |z|.
out[i] = float(1.0 / (1.0 + np.exp(-z))) if z >= 0 else float(
np.exp(z) / (1.0 + np.exp(z))
)
else:
out[i] = z
return out
def plot_per_layer_effect_probe(
*,
out_dir: str,
layers: List[int],
base_probe: Dict[int, float],
lora_probe: Dict[int, Dict[int, float]],
image_id: str,
matched_text: str,
probe_output: str,
) -> List[str]:
"""Per-layer linear-probe output (probability or logit) across L_cap,
one panel per L_int. Same group splitting as ``plot_per_layer_effect``.
"""
base_curve = _probe_curve(base_probe, layers, probe_output)
lora_curves = {
L_int: _probe_curve(lora_probe.get(L_int, {}), layers, probe_output)
for L_int in layers
}
y_label = "p(toilet)" if probe_output == "prob" else "probe logit"
return _render_per_layer_groups(
out_dir=out_dir,
filename_stem="per_layer_effect_probe",
layers=layers,
base_curve=base_curve,
lora_curves=lora_curves,
image_id=image_id,
suptitle_extra=f"(probe={probe_output}; prefix: {matched_text.strip()[:90]!r})",
y_label=y_label,
)
def plot_all_layer_effect(
*,
out_path: str,
layers: List[int],
base_acts: Dict[int, torch.Tensor],
lora_all_acts: Dict[int, torch.Tensor],
base_probe: Dict[int, float],
lora_all_probe: Dict[int, float],
image_id: str,
matched_text: str,
agg: str,
probe_output: str,
):
"""Summary comparison plot: 1×2 layout, features (left) + probes (right),
each showing ``base`` vs ``ΔW@all-layers`` across capture layers.
Companion to the per-L_int split PNGs — lets readers see the full
intervention's effect at a glance, then return to the per-L_int views
to locate which layer is responsible.
"""
if not layers:
return
feat_base = _agg_layer_map(base_acts, layers, agg)
feat_lora = _agg_layer_map(lora_all_acts, layers, agg)
prob_base = _probe_curve(base_probe, layers, probe_output)
prob_lora = _probe_curve(lora_all_probe, layers, probe_output)
fig, axes = plt.subplots(
1, 2, figsize=(13.0, 4.6),
squeeze=False, sharex=True, sharey=False,
)
xs = np.arange(len(layers))
xtick_step = max(1, len(layers) // 8)
xtick_idx = np.arange(0, len(layers), xtick_step)
# ── Features panel ──────────────────────────────────────────────────
ax_f = axes[0][0]
ax_f.plot(xs, feat_base, color=COLOR_BASE, linestyle="-", marker="o",
markersize=4, linewidth=1.8, label="base")
ax_f.plot(xs, feat_lora, color=COLOR_LORA, linestyle="--", marker="s",
markersize=4, linewidth=1.8, label="ΔW@all-layers")
ax_f.set_title("Features", fontsize=12, pad=4)
ax_f.set_xlabel("capture layer L", fontsize=11)
ax_f.set_ylabel(f"{agg}(top-k feats)", fontsize=11)
ax_f.grid(True, linestyle=":", alpha=0.45)
ax_f.set_xticks(xtick_idx)
ax_f.set_xticklabels([str(layers[i]) for i in xtick_idx], fontsize=9)
ax_f.tick_params(axis="y", labelsize=9)
# ── Probes panel ────────────────────────────────────────────────────
ax_p = axes[0][1]
ax_p.plot(xs, prob_base, color=COLOR_BASE, linestyle="-", marker="o",
markersize=4, linewidth=1.8, label="base")
ax_p.plot(xs, prob_lora, color=COLOR_LORA, linestyle="--", marker="s",
markersize=4, linewidth=1.8, label="ΔW@all-layers")
probe_y_label = "p(toilet)" if probe_output == "prob" else "probe logit"
ax_p.set_title("Probes", fontsize=12, pad=4)
ax_p.set_xlabel("capture layer L", fontsize=11)
ax_p.set_ylabel(probe_y_label, fontsize=11)
ax_p.grid(True, linestyle=":", alpha=0.45)
ax_p.set_xticks(xtick_idx)
ax_p.set_xticklabels([str(layers[i]) for i in xtick_idx], fontsize=9)
ax_p.tick_params(axis="y", labelsize=9)
handles, labels_ = ax_f.get_legend_handles_labels()
fig.legend(
handles, labels_,
loc="upper center", ncol=2, bbox_to_anchor=(0.5, 0.975),
fontsize=11, frameon=False,
)
fig.suptitle(
f"{image_id} — ΔW@all-layers vs base, fixed-prefix probe "
f"(agg={agg}, probe={probe_output}; prefix: {matched_text.strip()[:90]!r})",
fontsize=11, y=0.995,
)
fig.subplots_adjust(top=0.86, wspace=0.22)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
fig.savefig(out_path, dpi=120, bbox_inches="tight")
plt.close(fig)
def plot_sample(result: dict, graph_dir: str, agg: str, probe_output: str):
image_id = result["image_id"]
layers = sorted(result["feature_ids_per_layer"].keys())
sample_dir = os.path.join(graph_dir, str(image_id))
os.makedirs(sample_dir, exist_ok=True)
matched_text = result.get("matched_text", "")
try:
written = plot_per_layer_effect(
out_dir=sample_dir,
layers=layers,
base_acts=result["base_acts"],
lora_acts=result["lora_acts"],
image_id=str(image_id),
matched_text=matched_text,
agg=agg,
)
for p in written:
print(f" saved {os.path.abspath(p)}")
except Exception as e:
print(f" per_layer_effect plot failed for {image_id}: {e}")
traceback.print_exc()
base_probe = result.get("base_probe") or {}
lora_probe = result.get("lora_probe") or {}
if base_probe and lora_probe:
try:
written = plot_per_layer_effect_probe(
out_dir=sample_dir,
layers=layers,
base_probe=base_probe,
lora_probe=lora_probe,
image_id=str(image_id),
matched_text=matched_text,
probe_output=probe_output,
)
for p in written:
print(f" saved {os.path.abspath(p)}")
except Exception as e:
print(f" per_layer_effect_probe plot failed for {image_id}: {e}")
traceback.print_exc()
# Companion summary: ΔW@all-layers vs base, features + probes side by side.
lora_all_acts = result.get("lora_all_acts") or {}
lora_all_probe = result.get("lora_all_probe") or {}
if lora_all_acts and lora_all_probe and base_probe:
all_path = os.path.join(sample_dir, "per_layer_effect_all.png")
try:
plot_all_layer_effect(
out_path=all_path,
layers=layers,
base_acts=result["base_acts"],
lora_all_acts=lora_all_acts,
base_probe=base_probe,
lora_all_probe=lora_all_probe,
image_id=str(image_id),
matched_text=matched_text,
agg=agg,
probe_output=probe_output,
)
if os.path.exists(all_path):
print(f" saved {os.path.abspath(all_path)}")
except Exception as e:
print(f" per_layer_effect_all plot failed for {image_id}: {e}")
traceback.print_exc()
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
p = argparse.ArgumentParser()
p.add_argument("--features_json", required=True,
help="Output of select_features.py (per-layer top-k).")
p.add_argument("--samples_json", default="mechanistic_interp/toilet-bathroom/lora_adapter/samples.json")
p.add_argument("--prompt", default="Describe this image.")
p.add_argument("--hf_dataset", default="pbcong/bathroom-toilet")
p.add_argument("--hf_split", default="validation")
p.add_argument("--id_col", default="image_id")
p.add_argument("--adapter_path",
default="mechanistic_interp/toilet-bathroom/lora_adapter/adapter_model.safetensors")
p.add_argument("--adapter_cfg",
default="mechanistic_interp/toilet-bathroom/lora_adapter/adapter_config.json")
p.add_argument("--sae_ckpt", required=True)
p.add_argument("--model_name", default="llava-hf/llava-1.5-7b-hf")
p.add_argument("--device", default="cuda:0")
p.add_argument("--dtype", default="bfloat16",
choices=["float32", "float16", "bfloat16"])
p.add_argument("--n_samples", type=int, default=0,
help="Max filter-passing samples to process. 0 (default) = all.")
p.add_argument("--hook_type", default="post", choices=["pre", "mid", "post"])
p.add_argument("--pool", choices=POOL_CHOICES, default="max",
help="Pool over the K assistant-prefix positions before "
"applying the probe / gathering features. Must match "
"probe-training pool ('max' matches train_probe_gen).")
p.add_argument("--fixed_assistant_prefix", required=True,
help="Assistant-side text teacher-forced after the question; "
"every condition (base / ΔW@L_int / ΔW@all) sees the same "
"tokens. Example: 'In this bathroom there is a shower, a "
"sink and'")
p.add_argument("--out_dir", required=True)
p.add_argument("--graph_dir", default="graph",
help="Plots written to {graph_dir}/{image_id}/: "
"per_layer_effect_L*.png (features) + "
"per_layer_effect_probe_L*.png (probes) + "
"per_layer_effect_all.png (base vs ΔW@all summary). "
"Pass --no_plots to skip.")
p.add_argument("--no_plots", action="store_true",
help="Skip inline plotting.")
p.add_argument("--agg", choices=AGG_CHOICES, default="max",
help="Reducer over the top-k feature dim per (L_int, L_cap). "
"'max' (default) for peak intensity; 'mean' for average.")
p.add_argument("--object_name", default="toilet",
help="Primary object name for category filtering (e.g., 'toilet', 'oven', 'tv').")
p.add_argument("--object2_name", default=None,
help="Second object name for category filtering (e.g., 'bathroom' when "
"object_name='toilet'). If provided, enables category choices like "
"'{object1}_only', '{object2}_only', '{object1}_{object2}'.")
p.add_argument("--category", choices=CATEGORY_CHOICES, default="any",
help="Filter samples by per-sample 'category' field. "
"'any' (default) or '{object1}_only' | '{object2}_only' | '{object1}_{object2}' "
"when --object2_name is provided.")
p.add_argument("--probes_path",
default="mechanistic_interp/probes/probes_gen_bathroom_toilet.pt",
help="Per-layer linear probe state-dict "
"(probes.{L}.weight: (1, d_sae), probes.{L}.bias: (1,)). "
"Used for the per_layer_effect_probe.png plot.")
p.add_argument("--probe_output", choices=PROBE_OUTPUT_CHOICES, default="prob",
help="Probe output to plot. 'prob' (default) applies sigmoid "
"and shows p(toilet) in [0,1]; 'logit' plots the raw "
"linear-probe score.")
args = p.parse_args()
dtype_map = {"float32": torch.float32, "float16": torch.float16, "bfloat16": torch.bfloat16}
dtype = dtype_map[args.dtype]
os.makedirs(args.out_dir, exist_ok=True)
torch.set_grad_enabled(False)
# ── Selected features ───────────────────────────────────────────────────
with open(args.features_json) as f:
feat_json = json.load(f)
selected: Dict[int, List[int]] = {}
for k, v in feat_json.items():
if not k.startswith("layer_"):
continue
L = int(k.split("_")[1])
selected[L] = list(map(int, v["features"]))
layers = sorted(selected.keys())
print(f"Selected features for {len(layers)} layers (top_k = {len(selected[layers[0]])})")
# ── Filtered samples ────────────────────────────────────────────────────
# gen_mode='scratch' bypasses the base=T/lora=F predicate that only makes
# sense in the prefix-decode flow — here we teacher-force a fixed prefix.
keep = filter_samples(args.samples_json, args.prompt, args.category,
gen_mode="scratch", allowed_categories=CATEGORY_CHOICES)
print(f"Filter category={args.category}: {len(keep)} samples "
f"(prompt={args.prompt!r}, prefix={args.fixed_assistant_prefix!r})")
if args.n_samples > 0:
keep = keep[: args.n_samples]
print(f"Processing first {len(keep)} (capped by --n_samples={args.n_samples})")
else:
print(f"Processing all {len(keep)} samples (--n_samples=0)")
# ── Model + LoRA + SAE ──────────────────────────────────────────────────
print("Loading model …")
processor = LlavaProcessor.from_pretrained(args.model_name)
model = HookedSAELlavaConditionalGeneration.from_pretrained(
args.model_name, attn_implementation="eager",
).to(args.device, dtype=dtype).eval()
cfg = json.loads(open(args.adapter_cfg).read())
lora_scale = cfg["lora_alpha"] / cfg["r"]
pairs = load_lora_pairs(args.adapter_path)
n_lm_pairs = sum(1 for mp in pairs if "language_model" in mp)
print(f"LoRA (A,B) pairs: {len(pairs)} (language_model: {n_lm_pairs}) | scale={lora_scale}")
print(f"Loading FrozenSAEEncoder from {args.sae_ckpt} (JumpReLU dense; "
"matches probe-training SAE)")
sae = FrozenSAEEncoder.from_checkpoint(args.sae_ckpt, torch.device(args.device))
print(f"Loading probes ({args.probes_path}) …")
probes = load_probe_heads(args.probes_path)
missing = [L for L in layers if L not in probes]
if missing:
print(f" WARN: no probe head for layers {missing} — those panels show NaN.")
# Move once to device, matching probe_output_trace.py:404-405 — the
# per-call float32 dot product runs on GPU without extra .to() churn.
for L, d in probes.items():
d["w"] = d["w"].to(args.device)
d["b"] = d["b"].to(args.device)
print(f" loaded {len(probes)} probe heads (moved to {args.device})")
# ── HF image index ──────────────────────────────────────────────────────
needed_ids = {str(s["image_id"]) for s in keep}
image_index = build_image_index(args.hf_dataset, args.hf_split, args.id_col)
have = needed_ids & set(image_index.keys())
print(f"HF images indexed: {len(image_index)} | needed: {len(needed_ids)} | resolved: {len(have)}")
# ── Per-sample loop ─────────────────────────────────────────────────────
summary = []
n_ok = n_skip = 0
for s in tqdm(keep, desc="samples"):
result, err = trace_one_sample(
sample=s,
image_index=image_index,
prompt=args.prompt,
fixed_assistant_prefix=args.fixed_assistant_prefix,
processor=processor,
model=model,
sae=sae,
lora_pairs=pairs,
lora_scale=lora_scale,
selected=selected,
layers=layers,
hook_type=args.hook_type,
pool=args.pool,
device=args.device,
dtype_attn=torch.long,
probes=probes,
)
if result is None:
n_skip += 1
print(f" skip {s['image_id']}: {err}")
continue
result["agg"] = args.agg
result["probe_output"] = args.probe_output
out_path = os.path.join(args.out_dir, f"{result['image_id']}.pt")
torch.save(result, out_path)
summary.append({"image_id": result["image_id"], "path": out_path})
n_ok += 1
if not args.no_plots:
plot_sample(
result, args.graph_dir,
agg=args.agg, probe_output=args.probe_output,
)
with open(os.path.join(args.out_dir, "summary.json"), "w") as f:
json.dump({
"config": vars(args),
"n_ok": n_ok,
"n_skipped": n_skip,
"samples": summary,
}, f, indent=2)
print(f"Done. ok={n_ok} skipped={n_skip}. Output → {args.out_dir}")
if __name__ == "__main__":
main()