Buckets:

Rishik001's picture
download
raw
30.2 kB
"""
SAE-based topic classification during generation
=====================================================
Pipeline:
1. Load model + per-layer SAEs (Qwen TopK or Gemma Scope JumpReLU)
2. Load topic data, split train/test
3. Generate from all prompts, storing hidden states at every layer & step
4. Run SAEs on stored hidden states → per-sample firing rates per layer
5. Discover best layer + top-K features via cross-topic variance
6. Build binary feature vectors, train MLP, evaluate
Install (torch must be installed separately for your CUDA version):
pip install transformers datasets scikit-learn pandas safetensors
pip install flash-attn --no-build-isolation
"""
import argparse
import json
import os
import sys
import types
from datetime import datetime
from pathlib import Path
# torchaudio has a CUDA ABI mismatch with the current PyTorch build; stub it out
# before transformers imports it (via loss_rnnt) so the fallback attention path works.
if "torchaudio" not in sys.modules:
import importlib.machinery
_ta_stub = types.ModuleType("torchaudio")
_ta_stub.__spec__ = importlib.machinery.ModuleSpec("torchaudio", None)
sys.modules["torchaudio"] = _ta_stub
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file as safetensors_load
from sklearn.metrics import precision_recall_fscore_support
from transformers import AutoModelForCausalLM, AutoTokenizer
# Local imports here
from models import JumpReLUSAE, TopKSAE, TopicClassifier
from utils import (
build_dataset_df,
build_labeled_set,
filter_dead_samples,
load_topic_data,
print_results,
split_data,
)
# ==========================================================================
# CONFIG
# ==========================================================================
DEFAULT_MODEL_SIZES = ["gemma-3-1b-it"]
CONFIGS = {
"0.6B": dict(
model_name="Qwen/Qwen3-0.6B-Base",
sae_repo="Qwen/SAE-Res-Qwen3-0.6B-Base-W32K-L0_100",
sae_format="qwen",
top_k_sae=100,
layer_stride=1,
),
"1.7B": dict(
model_name="Qwen/Qwen3-1.7B-Base",
sae_repo="Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_100",
sae_format="qwen",
top_k_sae=100,
layer_stride=1,
),
"1.7B-Instruct": dict(
model_name="Qwen/Qwen3-1.7B",
sae_repo="Qwen/SAE-Res-Qwen3-1.7B-Base-W32K-L0_100",
sae_format="qwen",
top_k_sae=100,
layer_stride=1,
),
"8B": dict(
model_name="Qwen/Qwen3-8B-Base",
sae_repo="Qwen/SAE-Res-Qwen3-8B-Base-W64K-L0_100",
sae_format="qwen",
top_k_sae=100,
layer_stride=2, # skip every other layer to save memory
),
# Gemma Scope v1 (Gemma 2) — residual-stream JumpReLU SAEs, only 3 IT layers available
"gemma-2-9b-it": dict(
model_name="google/gemma-2-9b-it",
sae_repo="google/gemma-scope-9b-it-res",
sae_format="gemma_scope_1",
sae_width="16k",
sae_l0="47", # available l0s for width_16k: 14, 26, 47, 88, 186
sae_layers=[9, 20, 31], # only these 3 layers have IT SAEs in this repo
layer_stride=1,
),
# Gemma Scope v2 (Gemma 3) — residual-stream JumpReLU SAEs, all layers covered
"gemma-3-1b-it": dict(
model_name="google/gemma-3-1b-it",
sae_repo="google/gemma-scope-2-1b-it",
sae_format="gemma_scope_2",
sae_width="16k",
sae_l0="small", # l0≈10 active features per token; "big" gives l0≈40
layer_stride=1,
),
}
N_POS = 5000 # max samples per topic
SEED = 42 # global RNG seed
BATCH_SIZE = 64 # generation batch size
CAPTURE_MODE = "prefill" # "decode" | "prefill" | "prefill+decode"
N_TOP_FEATURES = 100 # SAE features selected per layer
TRAIN_RATIO = 0.8 # fraction of data used for training
MLP_HIDDEN = 64 # hidden units in the classifier
MLP_EPOCHS = 30 # training epochs
MLP_LR = 1e-3 # AdamW learning rate
MLP_BATCH_SIZE = 512 # mini-batch size during MLP training
MAX_GEN_TOKENS = 512 # max new tokens per generation
MAX_INPUT_TOKENS = 2048 # prompt truncation length
CHECKPOINT_DIR = "checkpoints_shuffled_512_prefill"
# Path to prompts.jsonl from topic-data-gen; falls back to HF datasets if missing.
JSONL_DATA_PATH = Path("prompts.jsonl")
# HuggingFace dataset specs used when JSONL_DATA_PATH is absent.
DATASETS = {
"medical": {
"hf_id": "starmpcc/Asclepius-Synthetic-Clinical-Notes",
"config": None,
"split": "train",
"columns": ["question"],
"label": "Asclepius clinical questions",
},
"insurance": {
"hf_id": "bitext/Bitext-insurance-llm-chatbot-training-dataset",
"config": None,
"split": "train",
"columns": ["instruction"],
"label": "Bitext Insurance questions",
},
}
# ==========================================================================
# MODEL LOADING
# ==========================================================================
def configure_runtime_speed():
"""Enable TF32 and high-precision matmul for faster CUDA computation."""
if torch.cuda.is_available():
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
try:
torch.set_float32_matmul_precision("high")
except Exception:
pass
def load_model(name, device, dtype):
"""Load a causal LM with the best available attention backend, return (model, tokenizer)."""
tok = AutoTokenizer.from_pretrained(name, trust_remote_code=True)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
# left-padding keeps the real tokens right-aligned so generation starts at the correct position
tok.padding_side = "left"
model = None
last_error = None
for attn_impl in ("flash_attention_2", "sdpa", None):
try:
kwargs = {"torch_dtype": dtype, "trust_remote_code": True}
if attn_impl is not None:
kwargs["attn_implementation"] = attn_impl
model = (
AutoModelForCausalLM.from_pretrained(name, **kwargs).to(device).eval()
)
print(f" attention: {attn_impl or 'default'}")
break
except Exception as exc:
last_error = exc
if attn_impl is not None:
print(f" attention {attn_impl} unavailable; falling back")
if model is None:
raise RuntimeError(f"failed to load model {name}") from last_error
model.config.use_cache = True
print(
f" model: {name} | {model.config.num_hidden_layers} layers, "
f"d={model.config.hidden_size}"
)
return model, tok
def load_all_saes(repo, layers, expected_d_model, k, device):
"""Download and instantiate one TopKSAE per layer from a Qwen-Scope HuggingFace repo."""
saes = {}
d_sae = 0 # set inside loop; defined here so it's always bound after
for layer in layers:
print(f" loading SAE layer {layer}/{max(layers)} ...", end="\r")
path = hf_hub_download(repo_id=repo, filename=f"layer{layer}.sae.pt")
# weights_only=True prevents arbitrary code execution from pickle payloads
params = torch.load(path, map_location="cpu", weights_only=True)
d_sae, d_model = params["W_enc"].shape
if d_model != expected_d_model:
raise ValueError(
f"SAE layer {layer} in {repo} has d_model={d_model}, "
f"but model hidden_size={expected_d_model}"
)
sae = TopKSAE(d_model, d_sae, k)
with torch.no_grad():
# cast to float32 — SAE checkpoints are often bfloat16/float16
sae.W_enc.copy_(params["W_enc"].float())
sae.b_enc.copy_(params["b_enc"].float())
saes[layer] = sae.to(device).eval()
print(f" loaded {len(saes)} SAEs (d_sae={d_sae}, top_k={k})" + " " * 30)
return saes
def load_gemma_scope_saes(cfg, layers, expected_d_model, device):
"""Download and instantiate JumpReLUSAEs from a Gemma Scope repo.
Handles both v1 (.npz, Gemma 2) and v2 (.safetensors, Gemma 3) formats.
Both formats store W_enc as (d_model, d_sae) — opposite of the Qwen convention.
"""
saes = {}
repo = cfg["sae_repo"]
sae_format = cfg["sae_format"]
width = cfg.get("sae_width", "16k")
l0 = cfg.get("sae_l0", "47")
d_sae = 0 # set inside loop; defined here so it's always bound after
for layer in layers:
print(f" loading SAE layer {layer}/{max(layers)} ...", end="\r")
if sae_format == "gemma_scope_1":
# Gemma Scope v1 path: layer_{N}/width_{W}/average_l0_{L}/params.npz
filename = f"layer_{layer}/width_{width}/average_l0_{l0}/params.npz"
path = hf_hub_download(repo_id=repo, filename=filename)
raw = np.load(path)
# W_enc shape is (d_model, d_sae) in Gemma Scope — note the transpose vs Qwen
W_enc = torch.from_numpy(np.array(raw["W_enc"])).float()
b_enc = torch.from_numpy(np.array(raw["b_enc"])).float()
b_dec = torch.from_numpy(np.array(raw["b_dec"])).float()
threshold = torch.from_numpy(np.array(raw["threshold"])).float()
else:
# Gemma Scope v2 path: resid_post_all/layer_{N}_width_{W}_l0_{L}/params.safetensors
filename = (
f"resid_post_all/layer_{layer}_width_{width}_l0_{l0}/params.safetensors"
)
path = hf_hub_download(repo_id=repo, filename=filename)
raw = safetensors_load(path, device="cpu")
W_enc = raw["w_enc"].float()
b_enc = raw["b_enc"].float()
b_dec = raw["b_dec"].float()
threshold = raw["threshold"].float()
d_model, d_sae = W_enc.shape
if d_model != expected_d_model:
raise ValueError(
f"SAE layer {layer} in {repo} has d_model={d_model}, "
f"but model hidden_size={expected_d_model}"
)
sae = JumpReLUSAE(d_model, d_sae)
with torch.no_grad():
sae.W_enc.copy_(W_enc)
sae.b_enc.copy_(b_enc)
sae.b_dec.copy_(b_dec)
sae.threshold.copy_(threshold)
saes[layer] = sae.to(device).eval()
print(f" loaded {len(saes)} JumpReLU SAEs (d_sae={d_sae})" + " " * 30)
return saes
# ==========================================================================
# GENERATION + HIDDEN STATE CAPTURE
# ==========================================================================
@torch.inference_mode()
def generate_and_capture(texts, model, tok, layers, device, batch_size=BATCH_SIZE, capture_mode="decode"):
"""Generate from each text, capturing hidden states at all layers.
capture_mode controls which hidden states are stored per sample:
"decode" — one vector per generated token (existing behaviour)
"prefill" — last-position hidden state of the prompt only; no generation
"prefill+decode" — prefill last position prepended to all decode-step vectors
Returns:
hidden_states: dict[layer] -> list of (n_steps, d_model) float16 numpy arrays
generations: list of decoded generation strings (empty strings for prefill-only)
"""
n = len(texts)
hidden_states = {layer: [] for layer in layers}
generations = []
n_batches = (n + batch_size - 1) // batch_size
hooks = []
captured = {}
def make_hook(layer_idx):
# layer_idx is a function argument, so it's captured by value correctly;
# using it directly from the loop variable would cause all hooks to share the last value
def _hook(_module, _input, output):
# output may be a plain tensor (Gemma3) or a tuple; output[0] on a plain
# tensor picks only the first sample, not the hidden states, so unwrap explicitly
hs = output[0] if isinstance(output, tuple) else output
captured[layer_idx] = hs.float()
return _hook
for layer in layers:
h = model.model.layers[layer].register_forward_hook(make_hook(layer))
hooks.append(h)
try:
for batch_idx in range(n_batches):
start = batch_idx * batch_size
batch_texts = texts[start : start + batch_size]
bs = len(batch_texts)
print(f" batch {batch_idx + 1}/{n_batches} ({bs} samples)", end="\r")
chat_template_applied = tok.chat_template is not None
if chat_template_applied:
batch_texts = [
tok.apply_chat_template(
[{"role": "user", "content": t}],
tokenize=False,
add_generation_prompt=True,
)
for t in batch_texts
]
enc = tok(
batch_texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=MAX_INPUT_TOKENS,
# chat template already inserts BOS; avoid duplicating it
add_special_tokens=not chat_template_applied,
).to(device)
sample_hidden = {layer: [[] for _ in range(bs)] for layer in layers}
gen_tokens = [[] for _ in range(bs)]
active = torch.ones(bs, dtype=torch.bool, device=device)
# prefill: run the full prompt through the model once
captured.clear()
out = model(**enc, use_cache=True)
past_kv = out.past_key_values
next_tokens = out.logits[:, -1, :].argmax(dim=-1, keepdim=True)
# store the last-position prefill hidden state when requested
if capture_mode in ("prefill", "prefill+decode"):
for layer in layers:
h = captured[layer]
if h.dim() == 3:
h = h[:, -1, :] # (bs, d_model) — last prompt position
if h.shape[0] == 1 and bs > 1:
h = h.expand(bs, -1)
for i in range(bs):
sample_hidden[layer][i].append(h[i].cpu().half())
for _step in range(MAX_GEN_TOKENS if capture_mode != "prefill" else 0):
active = active & (next_tokens.squeeze(-1) != tok.eos_token_id)
if not active.any():
break
for i in range(bs):
if active[i]:
gen_tokens[i].append(next_tokens[i, 0].item())
# decode step: single new token per active sample
captured.clear()
out = model(next_tokens, past_key_values=past_kv, use_cache=True)
past_kv = out.past_key_values
next_tokens = out.logits[:, -1, :].argmax(dim=-1, keepdim=True)
for layer in layers:
h = captured[layer]
# squeeze sequence dim if model returned (batch, 1, d_model)
if h.dim() == 3:
h = h[:, -1, :]
# some model variants broadcast a single hidden state across the batch
if h.shape[0] == 1 and bs > 1:
h = h.expand(bs, -1)
for i in range(bs):
if active[i]:
sample_hidden[layer][i].append(h[i].cpu().half())
for layer in layers:
for i in range(bs):
steps = sample_hidden[layer][i]
if steps:
hidden_states[layer].append(torch.stack(steps).numpy())
else:
# dead sample: EOS was the very first token
d_model = model.config.hidden_size
hidden_states[layer].append(
np.zeros((0, d_model), dtype=np.float16)
)
for i in range(bs):
generations.append(tok.decode(gen_tokens[i], skip_special_tokens=True))
del past_kv, out, enc, sample_hidden, gen_tokens
if device.startswith("cuda"):
torch.cuda.empty_cache()
finally:
for h in hooks:
h.remove()
print(f" done ({n} samples, {len(layers)} layers)" + " " * 20)
return hidden_states, generations
# ==========================================================================
# SAE FIRING RATES
# ==========================================================================
def compute_firing_rates(hidden_states_for_layer, sae, device):
"""Compute per-feature binary firing matrix from stored hidden states.
Returns (n_samples, d_sae) fired_matrix and (d_sae,) mean firing rate vector.
A feature is considered fired for a sample if it was active at any generation step.
Works with both TopKSAE and JumpReLUSAE via the shared fired_mask() interface.
"""
n = len(hidden_states_for_layer)
d_sae = sae.d_sae
fired_matrix = np.zeros((n, d_sae), dtype=np.float32)
for i in range(n):
hs = hidden_states_for_layer[i]
if hs.shape[0] == 0:
continue
h_tensor = torch.from_numpy(hs).float().to(device)
# (n_steps, d_sae) bool → collapse across steps: fired if active at any step
step_mask = sae.fired_mask(h_tensor)
fired_matrix[i] = step_mask.any(dim=0).cpu().numpy().astype(np.float32)
firing_rate = fired_matrix.mean(axis=0)
return fired_matrix, firing_rate
# ==========================================================================
# FEATURE DISCOVERY
# ==========================================================================
def discover_features(firing_rates_by_topic, k=N_TOP_FEATURES, layer_override=None):
"""Find the layer + k features with highest cross-topic variance in firing rates."""
topics = list(firing_rates_by_topic.keys())
layers = list(firing_rates_by_topic[topics[0]].keys())
layer_scores = {}
if layer_override is not None:
stacked = np.stack([firing_rates_by_topic[t][layer_override] for t in topics])
var = stacked.var(axis=0)
return layer_override, np.argsort(var)[::-1][:k], {}
best_layer, best_score, best_feats = None, -1, None
for layer in layers:
stacked = np.stack([firing_rates_by_topic[t][layer] for t in topics])
var = stacked.var(axis=0)
top_k_var = np.sort(var)[::-1][:k]
score = float(top_k_var.mean())
layer_scores[layer] = score
if score > best_score:
best_score = score
best_layer = layer
best_feats = np.argsort(var)[::-1][:k]
return best_layer, best_feats, layer_scores
# ==========================================================================
# MLP TRAINING & EVALUATION
# ==========================================================================
def train_mlp(features, labels, n_classes, device):
"""Train TopicClassifier with AdamW and cross-entropy, printing loss every epoch."""
n_in = features.shape[1]
mlp = TopicClassifier(n_in, n_classes, MLP_HIDDEN).to(device)
# fused=True uses the CUDA fused kernel; requires CUDA and float params
use_fused = device.startswith("cuda")
opt = torch.optim.AdamW(mlp.parameters(), lr=MLP_LR, fused=use_fused)
loss_fn = nn.CrossEntropyLoss()
X = torch.tensor(features, dtype=torch.float32).to(device)
y = torch.tensor(labels, dtype=torch.long).to(device)
n = X.shape[0]
loss_history = []
mlp.train()
for epoch in range(MLP_EPOCHS):
perm = torch.randperm(n, device=device)
total_loss, n_batches = 0.0, 0
for s in range(0, n, MLP_BATCH_SIZE):
idx = perm[s : s + MLP_BATCH_SIZE]
loss = loss_fn(mlp(X[idx]), y[idx])
opt.zero_grad()
loss.backward()
opt.step()
total_loss += loss.item()
n_batches += 1
epoch_loss = total_loss / n_batches
loss_history.append(epoch_loss)
print(f" epoch {epoch + 1}/{MLP_EPOCHS} loss={epoch_loss:.4f}")
mlp.eval()
return mlp, loss_history
def compute_per_topic_metrics(preds, labels, topics):
"""Compute precision, recall, F1 per topic using sklearn, return as dict."""
raw = precision_recall_fscore_support(
labels, preds, labels=list(range(len(topics))), zero_division="warn"
)
# unpack as arrays so Pyright knows they're indexable
precision, recall, f1, support = (np.asarray(x) for x in raw)
metrics = {}
for i, topic in enumerate(topics):
metrics[topic] = {
"precision": float(precision[i]),
"recall": float(recall[i]),
"f1": float(f1[i]),
"n_test": int(support[i]),
}
return metrics
# ==========================================================================
# MAIN PIPELINE
# ==========================================================================
def run_for_model_size(model_size):
"""Run the full SAE topic-classification pipeline for one model size."""
cfg = CONFIGS[model_size]
device = "cuda" if torch.cuda.is_available() else "cpu"
dtype = torch.bfloat16 if device.startswith("cuda") else torch.float32
configure_runtime_speed()
print("=" * 72)
print(f"LOAD {cfg['model_name']} + SAEs ({cfg['sae_format']}) on {device}")
print("=" * 72)
model, tok = load_model(cfg["model_name"], device, dtype)
# sae_layers overrides layer_stride when only select layers have SAEs (e.g. gemma-scope-9b-it-res)
if "sae_layers" in cfg:
layers = list(cfg["sae_layers"])
else:
layers = list(
range(0, model.config.num_hidden_layers, int(cfg.get("layer_stride", 1)))
)
if cfg["sae_format"] == "qwen":
saes = load_all_saes(
cfg["sae_repo"], layers, model.config.hidden_size, cfg["top_k_sae"], device
)
else:
saes = load_gemma_scope_saes(cfg, layers, model.config.hidden_size, device)
# -- Stage 1: load & split data ------------------------------------------
print("\n" + "=" * 72)
print("STAGE 1 load topic data")
print("=" * 72)
data = load_topic_data(DATASETS, N_POS, SEED, jsonl_path=JSONL_DATA_PATH)
train_data, test_data = split_data(data, TRAIN_RATIO, seed=SEED)
topics = list(data.keys())
for topic in topics:
print(
f" {topic}: {len(train_data[topic])} train / {len(test_data[topic])} test"
)
# -- Stage 2: generate & capture hidden states ----------------------------
print("\n" + "=" * 72)
print("STAGE 2 generate & capture hidden states (all layers)")
print("=" * 72)
train_texts, train_labels = build_labeled_set(train_data, topics)
test_texts, test_labels = build_labeled_set(test_data, topics)
print(f" generating from {len(train_texts)} train prompts (capture_mode={CAPTURE_MODE}) ...")
train_hidden, train_gens = generate_and_capture(
train_texts, model, tok, layers, device, capture_mode=CAPTURE_MODE
)
print(f" generating from {len(test_texts)} test prompts (capture_mode={CAPTURE_MODE}) ...")
test_hidden, test_gens = generate_and_capture(
test_texts, model, tok, layers, device, capture_mode=CAPTURE_MODE
)
train_hidden, train_labels, train_texts, train_gens, n_dead_train = (
filter_dead_samples(
train_hidden, train_labels, train_texts, train_gens, layers, "train"
)
)
test_hidden, test_labels, test_texts, test_gens, n_dead_test = filter_dead_samples(
test_hidden, test_labels, test_texts, test_gens, layers, "test"
)
total_bytes = sum(
hs.nbytes
for layer in layers
for split in (train_hidden, test_hidden)
for hs in split[layer]
)
print(f" stored hidden states: {total_bytes / 1e9:.2f} GB")
# -- Stage 3: run SAEs, compute firing rates, discover features -----------
print("\n" + "=" * 72)
print("STAGE 3 SAE firing rates & feature discovery")
print("=" * 72)
# keep fired matrices for all layers until discover_features picks the best one
train_fired = {} # layer -> (n_train, d_sae) binary matrix
firing_rates_by_topic = {t: {} for t in topics}
for layer in layers:
print(f" processing L{layer} ...", end="\r")
fired_matrix, _ = compute_firing_rates(train_hidden[layer], saes[layer], device)
train_fired[layer] = fired_matrix
for i, topic in enumerate(topics):
mask = train_labels == i
firing_rates_by_topic[topic][layer] = fired_matrix[mask].mean(axis=0)
print(f" done ({len(layers)} layers)" + " " * 30)
best_layer, best_feats, layer_scores = discover_features(firing_rates_by_topic)
assert best_feats is not None, (
"discover_features returned no features (empty layers?)"
)
print(f" best layer: L{best_layer}")
print(f" selected {len(best_feats)} features: {best_feats.tolist()}")
# -- Stage 4: build feature vectors & train MLP ---------------------------
print("\n" + "=" * 72)
print("STAGE 4 train & evaluate MLP")
print("=" * 72)
# slice only the discovered feature columns from the binary fired matrix
train_feat_vectors = train_fired[best_layer][:, best_feats]
# only run the SAE on the test set for the best layer — no need to process all layers
test_fired_matrix, _ = compute_firing_rates(
test_hidden[best_layer], saes[best_layer], device
)
test_feat_vectors = test_fired_matrix[:, best_feats]
print(
f" feature matrix: train {train_feat_vectors.shape}, test {test_feat_vectors.shape}"
)
print(" training MLP ...")
torch.manual_seed(SEED)
mlp, loss_history = train_mlp(train_feat_vectors, train_labels, len(topics), device)
with torch.no_grad():
X_test = torch.tensor(test_feat_vectors, dtype=torch.float32).to(device)
preds = mlp(X_test).argmax(dim=-1).cpu().numpy()
per_topic_metrics = compute_per_topic_metrics(preds, test_labels, topics)
print_results(preds, test_labels, per_topic_metrics)
# -- Save checkpoint -------------------------------------------------------
acc = float((preds == test_labels).mean())
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
run_prefix = f"{CHECKPOINT_DIR}/{model_size}_{ts}"
ref_layer = layers[0]
train_df = build_dataset_df(
train_texts, train_gens, train_labels, topics, train_hidden[ref_layer]
)
test_df = build_dataset_df(
test_texts, test_gens, test_labels, topics, test_hidden[ref_layer]
)
train_df.to_parquet(f"{run_prefix}_train.parquet", index=False)
test_df.to_parquet(f"{run_prefix}_test.parquet", index=False)
# feature vectors saved as parquet for easy inspection
feat_col_names = pd.Index([f"feat_{int(fid)}" for fid in best_feats])
feat_df = pd.DataFrame(train_feat_vectors).set_axis(feat_col_names, axis=1)
feat_df["label"] = train_labels
feat_df.to_parquet(f"{run_prefix}_train_features.parquet", index=False)
feat_test_df = pd.DataFrame(test_feat_vectors).set_axis(feat_col_names, axis=1)
feat_test_df["label"] = test_labels
feat_test_df.to_parquet(f"{run_prefix}_test_features.parquet", index=False)
# move to CPU before saving so the checkpoint loads without CUDA
torch.save(mlp.cpu().state_dict(), f"{run_prefix}_mlp.pt")
# all config + scores + metadata in a single JSON
meta = {
"model_size": model_size,
"model_name": cfg["model_name"],
"sae_repo": cfg["sae_repo"],
"sae_format": cfg["sae_format"],
"top_k_sae": cfg.get("top_k_sae"),
"layer_stride": cfg.get("layer_stride", 1),
"topics": topics,
"n_pos": N_POS,
"seed": SEED,
"train_ratio": TRAIN_RATIO,
"batch_size": BATCH_SIZE,
"capture_mode": CAPTURE_MODE,
"max_gen_tokens": MAX_GEN_TOKENS,
"max_input_tokens": MAX_INPUT_TOKENS,
"mlp_hidden": MLP_HIDDEN,
"mlp_epochs": MLP_EPOCHS,
"mlp_lr": MLP_LR,
"best_layer": best_layer,
"feature_ids": best_feats.tolist(),
"n_features": len(best_feats),
"layer_scores": layer_scores,
"accuracy": acc,
"per_topic_metrics": per_topic_metrics,
"loss_history": loss_history,
"dead_samples_train": n_dead_train,
"dead_samples_test": n_dead_test,
"train_samples": len(train_labels),
"test_samples": len(test_labels),
"timestamp": ts,
}
with open(f"{run_prefix}_meta.json", "w") as f:
json.dump(meta, f, indent=2)
print(f"\n saved: {run_prefix}_mlp.pt")
print(f" {run_prefix}_meta.json")
print(f" {run_prefix}_train.parquet / _test.parquet")
print(f" {run_prefix}_train_features.parquet / _test_features.parquet")
print("\n" + "=" * 72)
print(f"Done: {model_size}.")
print("=" * 72)
def parse_model_sizes(raw):
"""Parse and validate a comma-separated list of model size strings."""
model_sizes = [item.strip() for item in raw.split(",") if item.strip()]
unknown = [item for item in model_sizes if item not in CONFIGS]
if unknown:
raise ValueError(f"unknown model size(s): {unknown}; valid: {sorted(CONFIGS)}")
return model_sizes
def main():
"""Entry point: parse args and run pipeline for each requested model size."""
parser = argparse.ArgumentParser(description="SAE topic classification.")
parser.add_argument(
"--model-sizes",
default=",".join(DEFAULT_MODEL_SIZES),
help=f"Comma-separated model sizes. Valid: {', '.join(CONFIGS)}",
)
args = parser.parse_args()
for model_size in parse_model_sizes(args.model_sizes):
run_for_model_size(model_size)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
30.2 kB
·
Xet hash:
f7646eb6df7724fd1d155d1008d23917f510d8f021c2cefce9fa6364c0f7ce25

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.