Download experiments/analysis/evaluate_downstream.py from JackYoung27/writesae-ckpts: direct link, hf CLI and curl.
- Browser
- Download file 27.8 kB
-
https://huggingface.co/JackYoung27/writesae-ckpts/resolve/main/experiments/analysis/evaluate_downstream.py
- Command line
-
hf download hf://JackYoung27/writesae-ckpts/experiments/analysis/evaluate_downstream.py
-
curl -L -o evaluate_downstream.py https://huggingface.co/JackYoung27/writesae-ckpts/resolve/main/experiments/analysis/evaluate_downstream.py
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) | |
| 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 | |
| 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"] | |
| 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 | |
| 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, | |
| } | |
| 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 | |
| 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, | |
| } | |
| 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) | |