writesae-ckpts / experiments /analysis /evaluate_downstream.py
JackYoung27's picture
Include source, paper, and minimal usage instructions
89434f5 verified
Raw History Blame Contribute Delete
27.8 kB
#!/usr/bin/env python3
"""Downstream causal evaluation for Matrix SAEs.
Measures how well SAE-reconstructed GDN recurrent states preserve model behavior.
Split each sequence into a prefix (builds up state) and suffix (measured).
Replace the recurrent state for one layer with the SAE reconstruction, then
compare suffix perplexity against the unmodified baseline.
This isolates the SAE's information loss: if reconstruction is perfect, suffix
perplexity matches baseline exactly.
Architecture note: Qwen3.5's GDN layers only use cached recurrent states when
seq_len == 1 (token-by-token decoding). For multi-token suffix processing, we
patch chunk_gated_delta_rule to inject the initial state directly.
"""
import json
import time
from contextlib import contextmanager
from typing import TypedDict
from pathlib import Path
import numpy as np
import torch
from tqdm import tqdm # type: ignore[import-untyped]
from sae import BilinearMatrixSAE, FlatSAE, MatrixSAE, build_sae_from_config
TrainableSAE = FlatSAE | MatrixSAE | BilinearMatrixSAE
# Runtime container holding a loaded SAE plus tag/type/train-mse metadata.
# Distinct from core.types.SAEConfig (training/checkpoint config schema).
class LoadedSAEEntry(TypedDict):
tag: str
sae: TrainableSAE
sae_type: str
train_mse: float | None
def load_sae_from_checkpoint(
ckpt_path: str,
config_path: str,
device: str = "cuda",
):
"""Load a trained SAE from checkpoint, returning (sae, cfg, train_mse)."""
cfg = json.loads(Path(config_path).read_text())
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=True)
sae = build_sae_from_config(cfg, state_dict=ckpt["model_state_dict"])
sae.load_state_dict(ckpt["model_state_dict"])
sae = sae.to(device).eval()
train_mse = ckpt.get("val_mse")
return sae, cfg, train_mse
def reconstruct_state_head(sae: TrainableSAE, state_head: torch.Tensor, sae_type: str) -> torch.Tensor:
"""Reconstruct a single head's state (d_k, d_v) through the SAE.
Args:
sae: trained SAE model
state_head: (d_k, d_v) tensor, the recurrent state for one head
sae_type: "flat", "rank1", "bilinear", or "bilinear_tied"
Returns:
Reconstructed (d_k, d_v) tensor
"""
d_k, d_v = state_head.shape
if sae_type == "flat":
x = state_head.reshape(1, d_k * d_v)
out = sae(x)
return out.reconstruction.reshape(d_k, d_v)
else:
x = state_head.unsqueeze(0)
out = sae(x)
return out.reconstruction.squeeze(0)
@contextmanager
def _patch_gdn_initial_states(model, gdn_layer_indices: list[int], states: dict[int, torch.Tensor]):
"""Temporarily patch GDN layers' chunk_gated_delta_rule to inject initial states.
The Qwen3.5 GDN layer calls chunk_gated_delta_rule with initial_state=None
when seq_len > 1. This patches all specified GDN layers so the suffix pass
correctly continues from the prefix's accumulated recurrent states.
Args:
model: HuggingFace CausalLM with model.model.layers
gdn_layer_indices: list of layer indices to patch
states: {layer_idx: (batch, n_heads, d_k, d_v)} tensors to inject
"""
model_layers = model.model.layers if hasattr(model, "model") else model.layers
originals = {}
for idx in gdn_layer_indices:
if idx not in states:
continue
gdn = model_layers[idx].linear_attn
originals[idx] = gdn.chunk_gated_delta_rule
state = states[idx]
def _make_patched(fn, s):
def patched(*args, **kwargs):
kwargs["initial_state"] = s
return fn(*args, **kwargs)
return patched
gdn.chunk_gated_delta_rule = _make_patched(originals[idx], state)
try:
yield
finally:
for idx, fn in originals.items():
model_layers[idx].linear_attn.chunk_gated_delta_rule = fn
@torch.no_grad()
def compute_split_perplexity(
model,
input_ids: torch.Tensor,
split_pos: int,
gdn_layer_indices: list[int],
target_layer_idx: int | None = None,
sae: TrainableSAE | None = None,
sae_type: str | None = None,
head_idx: int = 0,
) -> dict[str, float | int]:
"""Compute perplexity on the suffix of a sequence, optionally with SAE state reconstruction.
Protocol:
1. Run prefix (tokens[:split_pos]) with use_cache=True to build all caches
2. Extract recurrent states for ALL GDN layers from the cache
3. If sae is provided, reconstruct target_layer's head_idx state through the SAE
4. Patch ALL GDN layers' chunk_gated_delta_rule to inject their prefix states
5. Run suffix with the patched model + attention KV cache
6. Compute cross-entropy loss on suffix tokens
All GDN layers are patched so the suffix pass correctly continues from each
layer's accumulated state. Only the target layer gets SAE reconstruction.
Args:
model: HuggingFace CausalLM model
input_ids: (batch, seq_len) token ids
split_pos: position to split prefix/suffix
gdn_layer_indices: all GDN layer indices in the model
target_layer_idx: GDN layer to apply SAE on (None = no SAE, pure baseline)
sae: trained SAE model (None = no modification)
sae_type: SAE type string for dispatch
head_idx: which head to reconstruct (default 0)
Returns:
dict with keys: loss, perplexity, n_tokens
"""
batch_size, seq_len = input_ids.shape
assert split_pos > 0 and split_pos < seq_len
prefix = input_ids[:, :split_pos]
suffix = input_ids[:, split_pos:]
prefix_out = model(input_ids=prefix, use_cache=True)
cache = prefix_out.past_key_values
gdn_states = {}
for idx in gdn_layer_indices:
layer_cache = cache.layers[idx]
if hasattr(layer_cache, "recurrent_states") and layer_cache.recurrent_states is not None:
gdn_states[idx] = layer_cache.recurrent_states.clone()
if sae is not None and target_layer_idx is not None and sae_type is not None:
if target_layer_idx in gdn_states:
state = gdn_states[target_layer_idx]
for b in range(batch_size):
original_head = state[b, head_idx].float()
reconstructed = reconstruct_state_head(sae, original_head, sae_type)
state[b, head_idx] = reconstructed.to(state.dtype)
with _patch_gdn_initial_states(model, gdn_layer_indices, gdn_states):
suffix_out = model(
input_ids=suffix,
past_key_values=cache,
use_cache=False,
labels=suffix,
)
loss = suffix_out.loss
n_tokens = suffix.shape[1] - 1
return {
"loss": loss.item(),
"perplexity": float(torch.exp(loss).item()),
"n_tokens": n_tokens,
}
def _get_gdn_layer_indices(model) -> list[int]:
"""Get indices of GDN (linear_attention) layers from model config."""
config = getattr(model.config, "text_config", model.config)
return [i for i, t in enumerate(config.layer_types) if t == "linear_attention"]
@torch.no_grad()
def evaluate_downstream(
model,
tokenizer,
corpus_batches: list[torch.Tensor],
layer_idx: int,
sae_configs: list[LoadedSAEEntry],
head_idx: int = 0,
split_fraction: float = 0.5,
device: str = "cuda",
) -> dict[str, object]:
"""Run the full downstream evaluation for one layer across multiple SAEs.
Args:
model: HuggingFace CausalLM
tokenizer: tokenizer (unused, kept for API consistency)
corpus_batches: list of (batch_size, seq_len) token tensors
layer_idx: GDN layer index to evaluate
sae_configs: list of dicts with keys: tag, sae, sae_type, train_mse
head_idx: which head SAEs are trained on
split_fraction: fraction of sequence used as prefix (default 0.5)
device: compute device
Returns:
dict with baseline and per-SAE results
"""
seq_len = corpus_batches[0].shape[1]
split_pos = int(seq_len * split_fraction)
n_sequences = sum(b.shape[0] for b in corpus_batches)
gdn_layers = _get_gdn_layer_indices(model)
print(f"Downstream evaluation: layer {layer_idx}, head {head_idx}")
print(f" {n_sequences} sequences x {seq_len} tokens, split at position {split_pos}")
print(f" {len(gdn_layers)} GDN layers patched for state forwarding")
print(f" Evaluating {len(sae_configs)} SAE checkpoints + baseline")
results: dict[str, object] = {
"layer": layer_idx,
"head": head_idx,
"seq_len": seq_len,
"split_pos": split_pos,
"n_sequences": n_sequences,
"gdn_layers": gdn_layers,
}
print("\nComputing baseline perplexity (original state, no SAE)...")
baseline_losses = []
baseline_tokens = 0
t0 = time.time()
for batch in tqdm(corpus_batches, desc="Baseline"):
batch = batch.to(device)
for i in range(batch.shape[0]):
seq = batch[i:i+1]
r = compute_split_perplexity(
model, seq, split_pos,
gdn_layer_indices=gdn_layers,
)
baseline_losses.append(r["loss"] * r["n_tokens"])
baseline_tokens += r["n_tokens"]
torch.cuda.empty_cache()
baseline_avg_loss = sum(baseline_losses) / max(baseline_tokens, 1)
baseline_ppl = float(np.exp(baseline_avg_loss))
baseline_time = time.time() - t0
results["baseline"] = {
"loss": baseline_avg_loss,
"perplexity": baseline_ppl,
"n_tokens": baseline_tokens,
"time_s": round(baseline_time, 1),
}
print(f" Baseline perplexity: {baseline_ppl:.2f} (loss={baseline_avg_loss:.4f}, {baseline_time:.1f}s)")
results["sae_results"] = {}
for sae_cfg in sae_configs:
tag = sae_cfg["tag"]
sae = sae_cfg["sae"]
sae_type = sae_cfg["sae_type"]
train_mse = sae_cfg.get("train_mse")
print(f"\nEvaluating: {tag}")
sae_losses = []
sae_tokens = 0
t0 = time.time()
for batch in tqdm(corpus_batches, desc=f" {tag}"):
batch = batch.to(device)
for i in range(batch.shape[0]):
seq = batch[i:i+1]
r = compute_split_perplexity(
model, seq, split_pos,
gdn_layer_indices=gdn_layers,
target_layer_idx=layer_idx, sae=sae,
sae_type=sae_type, head_idx=head_idx,
)
sae_losses.append(r["loss"] * r["n_tokens"])
sae_tokens += r["n_tokens"]
torch.cuda.empty_cache()
sae_avg_loss = sum(sae_losses) / max(sae_tokens, 1)
sae_ppl = float(np.exp(sae_avg_loss))
delta_pct = (sae_ppl - baseline_ppl) / baseline_ppl * 100
sae_time = time.time() - t0
results["sae_results"][tag] = {
"loss": sae_avg_loss,
"perplexity": sae_ppl,
"delta_pct": delta_pct,
"train_mse": train_mse,
"n_tokens": sae_tokens,
"time_s": round(sae_time, 1),
}
print(f" Perplexity: {sae_ppl:.2f} (delta={delta_pct:+.2f}%, loss={sae_avg_loss:.4f}, {sae_time:.1f}s)")
return results
@torch.no_grad()
def compute_split_perplexity_allheads(
model,
input_ids: torch.Tensor,
split_pos: int,
gdn_layer_indices: list[int],
target_layer_idx: int | None = None,
sae: TrainableSAE | None = None,
sae_type: str | None = None,
n_heads: int = 32,
) -> dict[str, float | int]:
"""Like compute_split_perplexity but reconstructs ALL heads through one SAE.
Uses the same head-0-trained SAE for every head. This amplifies the
structured-vs-flat MSE gap by 32x compared to single-head replacement.
Args:
model: HuggingFace CausalLM model
input_ids: (batch, seq_len) token ids
split_pos: position to split prefix/suffix
gdn_layer_indices: all GDN layer indices in the model
target_layer_idx: GDN layer to apply SAE on (None = no SAE, pure baseline)
sae: trained SAE model (None = no modification)
sae_type: SAE type string for dispatch
n_heads: number of heads to reconstruct
Returns:
dict with keys: loss, perplexity, n_tokens
"""
batch_size, seq_len = input_ids.shape
assert split_pos > 0 and split_pos < seq_len
prefix = input_ids[:, :split_pos]
suffix = input_ids[:, split_pos:]
prefix_out = model(input_ids=prefix, use_cache=True)
cache = prefix_out.past_key_values
gdn_states = {}
for idx in gdn_layer_indices:
layer_cache = cache.layers[idx]
if hasattr(layer_cache, "recurrent_states") and layer_cache.recurrent_states is not None:
gdn_states[idx] = layer_cache.recurrent_states.clone()
if sae is not None and target_layer_idx is not None and sae_type is not None:
if target_layer_idx in gdn_states:
state = gdn_states[target_layer_idx]
actual_n_heads = min(n_heads, state.shape[1])
for b in range(batch_size):
for h in range(actual_n_heads):
original_head = state[b, h].float()
reconstructed = reconstruct_state_head(sae, original_head, sae_type)
state[b, h] = reconstructed.to(state.dtype)
with _patch_gdn_initial_states(model, gdn_layer_indices, gdn_states):
suffix_out = model(
input_ids=suffix,
past_key_values=cache,
use_cache=False,
labels=suffix,
)
loss = suffix_out.loss
n_tokens = suffix.shape[1] - 1
return {
"loss": loss.item(),
"perplexity": float(torch.exp(loss).item()),
"n_tokens": n_tokens,
}
@torch.no_grad()
def evaluate_downstream_allheads(
model,
tokenizer,
corpus_batches: list[torch.Tensor],
layer_idx: int,
sae_configs: list[LoadedSAEEntry],
n_heads: int = 32,
split_fraction: float = 0.5,
device: str = "cuda",
) -> dict[str, object]:
"""Run downstream evaluation replacing ALL heads through each SAE.
Same protocol as evaluate_downstream but reconstructs every head's state
through the (head-0-trained) SAE. This amplifies reconstruction error
into a larger perplexity delta.
Args:
model: HuggingFace CausalLM
tokenizer: tokenizer (unused, kept for API consistency)
corpus_batches: list of (batch_size, seq_len) token tensors
layer_idx: GDN layer index to evaluate
sae_configs: list of dicts with keys: tag, sae, sae_type, train_mse
n_heads: number of heads to reconstruct (default 32)
split_fraction: fraction of sequence used as prefix (default 0.5)
device: compute device
Returns:
dict with baseline and per-SAE results
"""
seq_len = corpus_batches[0].shape[1]
split_pos = int(seq_len * split_fraction)
n_sequences = sum(b.shape[0] for b in corpus_batches)
gdn_layers = _get_gdn_layer_indices(model)
print(f"Downstream evaluation (ALL HEADS): layer {layer_idx}, {n_heads} heads")
print(f" {n_sequences} sequences x {seq_len} tokens, split at position {split_pos}")
print(f" {len(gdn_layers)} GDN layers patched for state forwarding")
print(f" Evaluating {len(sae_configs)} SAE checkpoints + baseline")
results: dict[str, object] = {
"layer": layer_idx,
"n_heads_replaced": n_heads,
"all_heads": True,
"shared_head0_sae_across_heads": True,
"approximation_note": (
"Each checkpoint was trained on head 0 and then applied to every head in the "
"target layer. Treat this as a stress test, not as per-head matched reconstruction."
),
"seq_len": seq_len,
"split_pos": split_pos,
"n_sequences": n_sequences,
"gdn_layers": gdn_layers,
}
print("\nComputing baseline perplexity (original state, no SAE)...")
baseline_losses = []
baseline_tokens = 0
t0 = time.time()
for batch in tqdm(corpus_batches, desc="Baseline"):
batch = batch.to(device)
for i in range(batch.shape[0]):
seq = batch[i:i+1]
r = compute_split_perplexity_allheads(
model, seq, split_pos,
gdn_layer_indices=gdn_layers,
)
baseline_losses.append(r["loss"] * r["n_tokens"])
baseline_tokens += r["n_tokens"]
torch.cuda.empty_cache()
baseline_avg_loss = sum(baseline_losses) / max(baseline_tokens, 1)
baseline_ppl = float(np.exp(baseline_avg_loss))
baseline_time = time.time() - t0
results["baseline"] = {
"loss": baseline_avg_loss,
"perplexity": baseline_ppl,
"n_tokens": baseline_tokens,
"time_s": round(baseline_time, 1),
}
print(f" Baseline perplexity: {baseline_ppl:.2f} (loss={baseline_avg_loss:.4f}, {baseline_time:.1f}s)")
results["sae_results"] = {}
for sae_cfg in sae_configs:
tag = sae_cfg["tag"]
sae = sae_cfg["sae"]
sae_type = sae_cfg["sae_type"]
train_mse = sae_cfg.get("train_mse")
print(f"\nEvaluating (all {n_heads} heads): {tag}")
sae_losses = []
sae_tokens = 0
t0 = time.time()
for batch in tqdm(corpus_batches, desc=f" {tag}"):
batch = batch.to(device)
for i in range(batch.shape[0]):
seq = batch[i:i+1]
r = compute_split_perplexity_allheads(
model, seq, split_pos,
gdn_layer_indices=gdn_layers,
target_layer_idx=layer_idx, sae=sae,
sae_type=sae_type, n_heads=n_heads,
)
sae_losses.append(r["loss"] * r["n_tokens"])
sae_tokens += r["n_tokens"]
torch.cuda.empty_cache()
sae_avg_loss = sum(sae_losses) / max(sae_tokens, 1)
sae_ppl = float(np.exp(sae_avg_loss))
delta_pct = (sae_ppl - baseline_ppl) / baseline_ppl * 100
sae_time = time.time() - t0
results["sae_results"][tag] = {
"loss": sae_avg_loss,
"perplexity": sae_ppl,
"delta_pct": delta_pct,
"train_mse": train_mse,
"n_tokens": sae_tokens,
"time_s": round(sae_time, 1),
}
print(f" Perplexity: {sae_ppl:.2f} (delta={delta_pct:+.2f}%, loss={sae_avg_loss:.4f}, {sae_time:.1f}s)")
return results
@torch.no_grad()
def compute_split_perplexity_perhead_matched(
model,
input_ids: torch.Tensor,
split_pos: int,
gdn_layer_indices: list[int],
target_layer_idx: int | None = None,
head_saes: dict[int, tuple] | None = None,
n_heads: int = 16,
) -> dict:
"""Like compute_split_perplexity_allheads but uses a MATCHED SAE per head.
Instead of one head-0 SAE applied to all heads, each head gets its own
SAE trained on that head's data. This gives a clean per-head reconstruction
without cross-head spectral mismatch.
Args:
model: HuggingFace CausalLM model
input_ids: (batch, seq_len) token ids
split_pos: position to split prefix/suffix
gdn_layer_indices: all GDN layer indices in the model
target_layer_idx: GDN layer to apply SAEs on (None = no SAE, pure baseline)
head_saes: {head_idx: (sae, sae_type)} dict mapping each head to its SAE
n_heads: number of heads to reconstruct
Returns:
dict with keys: loss, perplexity, n_tokens
"""
batch_size, seq_len = input_ids.shape
assert split_pos > 0 and split_pos < seq_len
prefix = input_ids[:, :split_pos]
suffix = input_ids[:, split_pos:]
prefix_out = model(input_ids=prefix, use_cache=True)
cache = prefix_out.past_key_values
gdn_states = {}
for idx in gdn_layer_indices:
layer_cache = cache.layers[idx]
if hasattr(layer_cache, "recurrent_states") and layer_cache.recurrent_states is not None:
gdn_states[idx] = layer_cache.recurrent_states.clone()
if head_saes is not None and target_layer_idx is not None:
if target_layer_idx in gdn_states:
state = gdn_states[target_layer_idx]
actual_n_heads = min(n_heads, state.shape[1])
for b in range(batch_size):
for h in range(actual_n_heads):
if h not in head_saes:
continue
sae, sae_type = head_saes[h]
original_head = state[b, h].float()
reconstructed = reconstruct_state_head(sae, original_head, sae_type)
state[b, h] = reconstructed.to(state.dtype)
with _patch_gdn_initial_states(model, gdn_layer_indices, gdn_states):
suffix_out = model(
input_ids=suffix,
past_key_values=cache,
use_cache=False,
labels=suffix,
)
loss = suffix_out.loss
n_tokens = suffix.shape[1] - 1
return {
"loss": loss.item(),
"perplexity": float(torch.exp(loss).item()),
"n_tokens": n_tokens,
}
@torch.no_grad()
def evaluate_downstream_perhead_matched(
model,
tokenizer,
corpus_batches: list[torch.Tensor],
layer_idx: int,
sae_type_configs: dict[str, dict[int, tuple]],
n_heads: int = 16,
split_fraction: float = 0.5,
device: str = "cuda",
baseline_result: dict | None = None,
) -> dict:
"""Run downstream evaluation with per-head matched SAEs.
Each SAE type gets 16 SAEs (one per head), each trained on that head's data.
All 16 heads are reconstructed simultaneously through their matched SAEs.
Args:
model: HuggingFace CausalLM
tokenizer: tokenizer (unused, kept for API consistency)
corpus_batches: list of (batch_size, seq_len) token tensors
layer_idx: GDN layer index to evaluate
sae_type_configs: {sae_type_tag: {head_idx: (sae, sae_type_str)}}
e.g. {"flat": {0: (sae0, "flat"), 1: (sae1, "flat"), ...}}
n_heads: number of heads (default 16)
split_fraction: fraction of sequence used as prefix (default 0.5)
device: compute device
Returns:
dict with baseline and per-SAE-type results
"""
seq_len = corpus_batches[0].shape[1]
split_pos = int(seq_len * split_fraction)
n_sequences = sum(b.shape[0] for b in corpus_batches)
gdn_layers = _get_gdn_layer_indices(model)
print(f"Downstream evaluation (PER-HEAD MATCHED): layer {layer_idx}, {n_heads} heads")
print(f" {n_sequences} sequences x {seq_len} tokens, split at position {split_pos}")
print(f" {len(gdn_layers)} GDN layers patched for state forwarding")
print(f" Evaluating {len(sae_type_configs)} SAE types + baseline")
results: dict[str, object] = {
"layer": layer_idx,
"n_heads_replaced": n_heads,
"all_heads": True,
"per_head_matched": True,
"seq_len": seq_len,
"split_pos": split_pos,
"n_sequences": n_sequences,
"gdn_layers": gdn_layers,
}
if baseline_result is None:
print("\nComputing baseline perplexity (original state, no SAE)...")
baseline_losses = []
baseline_tokens = 0
t0 = time.time()
for batch in tqdm(corpus_batches, desc="Baseline"):
batch = batch.to(device)
for i in range(batch.shape[0]):
seq = batch[i:i+1]
r = compute_split_perplexity_perhead_matched(
model, seq, split_pos,
gdn_layer_indices=gdn_layers,
)
baseline_losses.append(r["loss"] * r["n_tokens"])
baseline_tokens += r["n_tokens"]
torch.cuda.empty_cache()
baseline_avg_loss = sum(baseline_losses) / max(baseline_tokens, 1)
baseline_ppl = float(np.exp(baseline_avg_loss))
baseline_time = time.time() - t0
results["baseline"] = {
"loss": baseline_avg_loss,
"perplexity": baseline_ppl,
"n_tokens": baseline_tokens,
"time_s": round(baseline_time, 1),
}
print(
f" Baseline perplexity: {baseline_ppl:.2f} "
f"(loss={baseline_avg_loss:.4f}, {baseline_time:.1f}s)"
)
else:
baseline_avg_loss = float(baseline_result["loss"])
baseline_ppl = float(baseline_result["perplexity"])
results["baseline"] = dict(baseline_result)
print(
f"\nReusing baseline perplexity: {baseline_ppl:.2f} "
f"(loss={baseline_avg_loss:.4f})"
)
results["sae_results"] = {}
for tag, head_saes in sae_type_configs.items():
n_loaded = len(head_saes)
print(f"\nEvaluating (per-head matched, {n_loaded}/{n_heads} heads): {tag}")
sae_losses = []
sae_tokens = 0
t0 = time.time()
for batch in tqdm(corpus_batches, desc=f" {tag}"):
batch = batch.to(device)
for i in range(batch.shape[0]):
seq = batch[i:i+1]
r = compute_split_perplexity_perhead_matched(
model, seq, split_pos,
gdn_layer_indices=gdn_layers,
target_layer_idx=layer_idx,
head_saes=head_saes,
n_heads=n_heads,
)
sae_losses.append(r["loss"] * r["n_tokens"])
sae_tokens += r["n_tokens"]
torch.cuda.empty_cache()
sae_avg_loss = sum(sae_losses) / max(sae_tokens, 1)
sae_ppl = float(np.exp(sae_avg_loss))
delta_pct = (sae_ppl - baseline_ppl) / baseline_ppl * 100
sae_time = time.time() - t0
results["sae_results"][tag] = {
"loss": sae_avg_loss,
"perplexity": sae_ppl,
"delta_pct": delta_pct,
"train_mse": None,
"n_heads_loaded": n_loaded,
"n_tokens": sae_tokens,
"time_s": round(sae_time, 1),
}
print(f" Perplexity: {sae_ppl:.2f} (delta={delta_pct:+.2f}%, loss={sae_avg_loss:.4f}, {sae_time:.1f}s)")
return results
def format_results_table(results: dict) -> str:
"""Format evaluation results as a readable table."""
lines = []
layer = results["layer"]
n_seq = results["n_sequences"]
seq_len = results["seq_len"]
split_pos = results["split_pos"]
if results.get("all_heads"):
n_heads = results.get("n_heads_replaced", "?")
if results.get("per_head_matched"):
lines.append(f"Downstream Evaluation (PER-HEAD MATCHED, {n_heads} HEADS): layer {layer}, {n_seq} sequences x {seq_len} tokens (split at {split_pos})")
else:
lines.append(f"Downstream Evaluation (ALL {n_heads} HEADS): layer {layer}, {n_seq} sequences x {seq_len} tokens (split at {split_pos})")
if results.get("approximation_note"):
lines.append(f"Note: {results['approximation_note']}")
else:
lines.append(f"Downstream Evaluation: layer {layer}, {n_seq} sequences x {seq_len} tokens (split at {split_pos})")
lines.append(f"Baseline perplexity: {results['baseline']['perplexity']:.2f}")
lines.append("")
lines.append(f"{'SAE':<45} | {'Perplexity':>10} | {'Delta (%)':>10} | {'MSE (train)':>12}")
lines.append("-" * 85)
for tag, r in sorted(results["sae_results"].items()):
mse_str = f"{r['train_mse']:.2e}" if r["train_mse"] is not None else "N/A"
lines.append(
f"{tag:<45} | {r['perplexity']:>10.2f} | {r['delta_pct']:>+9.2f}% | {mse_str:>12}"
)
return "\n".join(lines)