ICAExplorer / server /sae_probe.py
sida's picture
Deploy ICA explorer app
34d520a
Raw
History Blame Contribute Delete
11.7 kB
from __future__ import annotations
import tomllib
from functools import lru_cache
from pathlib import Path
from typing import Any
import numpy as np
import torch
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from .probe import _decode_token_text, _layer_key_to_hidden_index
V6_ROOT = Path(__file__).resolve().parents[1]
CONFIG_ROOT = V6_ROOT / "configs" / "comparisons"
def list_sae_layers(model_name: str) -> list[str]:
config = load_sae_config(model_name)
model_config = load_comparison_config(model_name).get("model", {})
n_layers = int(model_config.get("num_hidden_layers") or 0)
layer_checkpoints = config.get("layer_checkpoints")
if isinstance(layer_checkpoints, dict) and layer_checkpoints:
indices = sorted(int(key) for key in layer_checkpoints)
else:
indices = list(range(n_layers))
return [f"layer_{idx:02d}" for idx in indices]
def load_comparison_config(model_name: str) -> dict[str, Any]:
path = CONFIG_ROOT / f"{model_name}.toml"
if not path.is_file():
raise FileNotFoundError(f"No SAE comparison config for {model_name!r}: {path}")
return tomllib.loads(path.read_text(encoding="utf-8"))
def load_sae_config(model_name: str) -> dict[str, Any]:
data = load_comparison_config(model_name)
if "sae" not in data:
raise KeyError(f"Comparison config for {model_name!r} has no [sae] section.")
return dict(data["sae"])
def interpret_text_sae_probe(
*,
model: torch.nn.Module,
tokenizer: Any,
text: str,
model_name: str,
layer: str,
top_k: int,
max_length: int,
) -> dict[str, Any]:
if layer == "embedding":
raise ValueError("SAE Explorer currently supports transformer layers, not embedding.")
layer_index = int(layer.removeprefix("layer_"))
hidden_index = _layer_key_to_hidden_index(layer, num_transformer_layers=int(model.config.num_hidden_layers))
full_ids = tokenizer.encode(text, add_special_tokens=True)
truncated = len(full_ids) > max_length
encoded = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
input_ids = encoded["input_ids"].to(next(model.parameters()).device)
attention_mask = encoded.get("attention_mask")
if attention_mask is not None:
attention_mask = attention_mask.to(input_ids.device)
with torch.inference_mode():
outputs = model(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True,
use_cache=False,
)
hidden_states = outputs.hidden_states
if hidden_states is None:
raise RuntimeError("Model did not return hidden states.")
hidden = hidden_states[hidden_index][0].to(dtype=torch.float32)
sae = load_sae(model_name, layer_index, hidden_size=int(hidden.shape[-1]), device=hidden.device)
pre, acts = sae_forward(hidden, sae=sae)
idx, vals = topk_features(acts, top_k=top_k)
ids = input_ids[0].detach().cpu().tolist()
idx_cpu = idx.detach().cpu().tolist()
vals_cpu = vals.detach().cpu().tolist()
pre_cpu = pre.detach().cpu()
tokens = []
for pos, token_id in enumerate(ids):
top = []
for j in range(len(idx_cpu[pos])):
feature = int(idx_cpu[pos][j])
top.append(
{
"feature": feature,
"activation": float(vals_cpu[pos][j]),
"preactivation": float(pre_cpu[pos, feature].item()),
}
)
tokens.append(
{
"position": pos,
"token_id": int(token_id),
"token": tokenizer.convert_ids_to_tokens(int(token_id)),
"token_text": _decode_token_text(tokenizer, int(token_id)),
"top": top,
}
)
return {
"model_name": model_name,
"layer": layer,
"top_k": int(top_k),
"max_length": int(max_length),
"tokens": tokens,
"seq_len": len(tokens),
"truncated": truncated,
"sae_width": int(sae["w_enc"].shape[1]),
"activation_rule": sae["activation_rule"],
}
@lru_cache(maxsize=64)
def _load_sae_cpu(model_name: str, layer: int, hidden_size: int) -> dict[str, Any]:
config = load_sae_config(model_name)
path = _resolve_sae_weights_path(config, layer=layer)
weights = _load_sae_weights(path, config=config)
w_dec = _decoder_weight(weights, hidden_size=hidden_size, preferred_key=config.get("decoder_key")).to(dtype=torch.float32)
w_enc = _encoder_weight(weights, hidden_size=hidden_size, d_sae=int(w_dec.shape[0])).to(dtype=torch.float32).contiguous()
b_enc = _vector_weight(weights, names=("b_enc", "encoder.bias"), length=int(w_enc.shape[1]))
b_dec = _vector_weight(weights, names=("b_dec", "decoder.bias", "bias"), length=hidden_size)
threshold = weights.get("threshold")
return {
"w_enc": w_enc,
"b_enc": b_enc.to(dtype=torch.float32).contiguous(),
"b_dec": b_dec.to(dtype=torch.float32).contiguous(),
"threshold": threshold.to(dtype=torch.float32).contiguous() if threshold is not None else None,
"top_k": _sae_top_k(config),
"activation_rule": _activation_rule_label(config),
}
def load_sae(model_name: str, layer: int, *, hidden_size: int, device: torch.device) -> dict[str, Any]:
cpu = _load_sae_cpu(model_name, layer, hidden_size)
return {
key: value.to(device=device, dtype=torch.float32, non_blocking=True) if isinstance(value, torch.Tensor) else value
for key, value in cpu.items()
}
def sae_forward(hidden: torch.Tensor, *, sae: dict[str, Any]) -> tuple[torch.Tensor, torch.Tensor]:
centered = hidden.to(dtype=torch.float32) - sae["b_dec"].unsqueeze(0)
pre = centered @ sae["w_enc"] + sae["b_enc"].unsqueeze(0)
threshold = sae["threshold"]
if threshold is not None:
return pre, torch.where(pre > threshold.unsqueeze(0), pre, torch.zeros_like(pre))
top_k = sae["top_k"]
if top_k is not None:
k = min(int(top_k), int(pre.shape[1]))
values, indices = torch.topk(pre, k=k, dim=1)
acts = torch.zeros_like(pre)
acts.scatter_(1, indices, torch.clamp(values, min=0.0))
return pre, acts
return pre, torch.relu(pre)
def topk_features(acts: torch.Tensor, *, top_k: int) -> tuple[torch.Tensor, torch.Tensor]:
k = min(int(top_k), int(acts.shape[1]))
if k <= 0:
raise ValueError("top_k must be positive.")
vals, idx = torch.topk(acts, k=k, dim=1)
return idx, vals
def _resolve_sae_weights_path(config: dict[str, Any], *, layer: int) -> str:
layer_checkpoints = config.get("layer_checkpoints")
if isinstance(layer_checkpoints, dict) and str(layer) in layer_checkpoints:
filename = str(layer_checkpoints[str(layer)])
else:
filename = str(config["checkpoint_template"]).format(layer=layer)
return hf_hub_download(repo_id=str(config["repo_id"]), filename=filename, revision=config.get("revision"))
def _load_sae_weights(path: str, *, config: dict[str, Any]) -> dict[str, torch.Tensor]:
checkpoint_format = str(config.get("checkpoint_format", "")).lower()
if checkpoint_format == "safetensors":
return load_file(path, device="cpu")
if checkpoint_format == "npz":
with np.load(path) as arrays:
return {key: torch.from_numpy(arrays[key]) for key in arrays.files}
if checkpoint_format in {"torch", "pt", "pth"}:
try:
loaded = torch.load(path, map_location="cpu", weights_only=True)
except TypeError:
loaded = torch.load(path, map_location="cpu")
if not isinstance(loaded, dict):
raise TypeError(f"Expected torch checkpoint {path} to contain a dict, got {type(loaded).__name__}.")
return _flatten_tensor_dict(loaded)
raise ValueError(f"Unsupported SAE checkpoint_format {checkpoint_format!r}.")
def _flatten_tensor_dict(payload: dict[str, Any], prefix: str = "") -> dict[str, torch.Tensor]:
flattened: dict[str, torch.Tensor] = {}
for key, value in payload.items():
name = f"{prefix}.{key}" if prefix else str(key)
if isinstance(value, torch.Tensor):
flattened[name] = value
flattened[str(key)] = value
elif isinstance(value, dict):
flattened.update(_flatten_tensor_dict(value, name))
return flattened
def _encoder_weight(weights: dict[str, torch.Tensor], *, hidden_size: int, d_sae: int) -> torch.Tensor:
for key in ("W_enc", "encoder.weight", "W_enc.weight", "encoder.W_enc"):
tensor = weights.get(key)
if tensor is not None:
return _orient_encoder(tensor, hidden_size=hidden_size, d_sae=d_sae)
return _decoder_weight(weights, hidden_size=hidden_size).T.contiguous()
def _decoder_weight(
weights: dict[str, torch.Tensor],
*,
hidden_size: int,
preferred_key: object = None,
) -> torch.Tensor:
if preferred_key is not None:
key = str(preferred_key)
if key not in weights:
raise KeyError(f"Configured decoder_key {key!r} not found. Available keys: {sorted(weights)}")
return _orient_decoder(weights[key], hidden_size=hidden_size)
for key in ("W_dec", "decoder.weight", "W_dec.weight", "decoder.W_dec"):
tensor = weights.get(key)
if tensor is not None:
return _orient_decoder(tensor, hidden_size=hidden_size)
candidates = [tensor for tensor in weights.values() if tensor.ndim == 2 and hidden_size in tensor.shape]
if len(candidates) == 1:
return _orient_decoder(candidates[0], hidden_size=hidden_size)
raise KeyError(f"Could not identify SAE decoder weight. Available keys: {sorted(weights)}")
def _vector_weight(weights: dict[str, torch.Tensor], *, names: tuple[str, ...], length: int) -> torch.Tensor:
for name in names:
tensor = weights.get(name)
if tensor is not None:
if int(tensor.numel()) != int(length):
raise ValueError(f"SAE vector {name!r} has length {tensor.numel()}, expected {length}.")
return tensor.reshape(length).to(dtype=torch.float32)
return torch.zeros(length, dtype=torch.float32)
def _orient_decoder(tensor: torch.Tensor, *, hidden_size: int) -> torch.Tensor:
if int(tensor.shape[1]) == hidden_size:
return tensor
if int(tensor.shape[0]) == hidden_size:
return tensor.T
raise ValueError(f"Decoder weight shape {tuple(tensor.shape)} does not contain hidden size {hidden_size}.")
def _orient_encoder(tensor: torch.Tensor, *, hidden_size: int, d_sae: int) -> torch.Tensor:
if tuple(tensor.shape) == (hidden_size, d_sae):
return tensor
if tuple(tensor.shape) == (d_sae, hidden_size):
return tensor.T
if int(tensor.shape[0]) == hidden_size:
return tensor
if int(tensor.shape[1]) == hidden_size:
return tensor.T
raise ValueError(f"Encoder weight shape {tuple(tensor.shape)} does not match hidden={hidden_size}, d_sae={d_sae}.")
def _sae_top_k(config: dict[str, Any]) -> int | None:
if config.get("top_k") is not None:
return int(config["top_k"])
repo = str(config.get("repo_id") or "")
if "GPT2-Small-OAI-v5-32k-resid-post-SAEs" in repo:
return 32
return None
def _activation_rule_label(config: dict[str, Any]) -> str:
if config.get("top_k") is not None:
return f"topk-{int(config['top_k'])}"
if str(config.get("activation") or "").lower() == "topk":
return "topk"
return "relu_or_threshold"