Skip to content

Instantly share code, notes, and snippets.

@Butanium
Created April 6, 2025 00:09
Show Gist options
  • Select an option

  • Save Butanium/b48628011a8c87834046fc32e8427b60 to your computer and use it in GitHub Desktop.

Select an option

Save Butanium/b48628011a8c87834046fc32e8427b60 to your computer and use it in GitHub Desktop.
"""
Single File implementation of cross-model single layer crosscoder training inspired by
https://github.com/jkminder/dictionary_learning and https://github.com/saprmarks/dictionary_learning
Supports training multiple crosscoders on the same inputs and collect activations in a buffer.
"""
import torch.nn as nn
import torch as th
from torch.nn.functional import relu
from warnings import warn
import einops
from huggingface_hub import PyTorchModelHubMixin
from collections import defaultdict
from pathlib import Path
import json
import torch as th
from torch.utils.data import DataLoader, random_split
import matplotlib.pyplot as plt
from tqdm import tqdm
import gc
from nnterp.nnsight_utils import get_layer, get_layer_output
import numpy as np
import wandb
from coolname import generate_slug
from time import time
import multiprocessing as mp
from queue import Empty
class Encoder(nn.Module):
"""
A cross-coder encoder
"""
def __init__(
self,
activation_dim,
dict_size,
num_layers=None,
same_init_for_all_layers: bool = False,
norm_init_scale: float | None = None,
encoder_layers: list[int] | None = None,
):
super().__init__()
if encoder_layers is None:
if num_layers is None:
raise ValueError(
"Either encoder_layers or num_layers must be specified"
)
encoder_layers = list(range(num_layers))
else:
num_layers = len(encoder_layers)
self.encoder_layers = encoder_layers
self.activation_dim = activation_dim
self.dict_size = dict_size
self.num_layers = num_layers
if same_init_for_all_layers:
weight = nn.init.kaiming_uniform_(th.empty(activation_dim, dict_size))
weight = weight.repeat(num_layers, 1, 1)
else:
weight = nn.init.kaiming_uniform_(
th.empty(num_layers, activation_dim, dict_size)
)
if norm_init_scale is not None:
weight = weight / weight.norm(dim=1, keepdim=True) * norm_init_scale
self.weight = nn.Parameter(weight)
self.bias = nn.Parameter(th.zeros(dict_size))
def forward(
self,
x: th.Tensor,
select_features: list[int] | None = None,
) -> th.Tensor: # (batch_size, activation_dim)
"""
Convert activations to features for each layer
Args:
x: (batch_size, n_layers, activation_dim)
Returns:
f: (batch_size, dict_size)
"""
x = x[:, self.encoder_layers]
if select_features is not None:
w = self.weight[:, :, select_features]
bias = self.bias[select_features]
else:
w = self.weight
bias = self.bias
f = th.einsum("bld, ldf -> blf", x, w)
return relu(f.sum(dim=1) + bias)
class CrossCoderDecoder(nn.Module):
"""
A crosscoder decoder
"""
def __init__(
self,
activation_dim,
dict_size,
num_layers,
same_init_for_all_layers: bool = True,
norm_init_scale: float | None = None,
init_with_weight: th.Tensor | None = None,
):
super().__init__()
self.activation_dim = activation_dim
self.dict_size = dict_size
self.num_layers = num_layers
self.bias = nn.Parameter(th.zeros(num_layers, activation_dim))
if init_with_weight is not None:
self.weight = nn.Parameter(init_with_weight)
else:
if same_init_for_all_layers:
weight = nn.init.kaiming_uniform_(th.empty(dict_size, activation_dim))
weight = weight.repeat(num_layers, 1, 1)
else:
weight = nn.init.kaiming_uniform_(
th.empty(num_layers, dict_size, activation_dim)
)
if norm_init_scale is not None:
weight = weight / weight.norm(dim=2, keepdim=True) * norm_init_scale
self.weight = nn.Parameter(weight)
def forward(
self,
f: th.Tensor,
select_features: list[int] | None = None,
add_bias: bool = True,
) -> th.Tensor: # (batch_size, n_layers, activation_dim)
# f: (batch_size, n_layers, dict_size)
"""
Convert features to activations for each layer
Args:
f: (batch_size, dict_size)
Returns:
x: (batch_size, n_layers, activation_dim)
"""
if select_features is not None:
w = self.weight[:, select_features]
else:
w = self.weight
x = th.einsum("bf, lfd -> bld", f, w)
if add_bias:
x += self.bias
return x
class CrossCoder(PyTorchModelHubMixin, nn.Module):
"""
encoder: shape (num_layers, activation_dim, dict_size)
decoder: shape (num_layers, dict_size, activation_dim)
"""
def __init__(
self,
activation_dim,
dict_size,
num_layers,
same_init_for_all_layers=False,
norm_init_scale: float | None = None,
init_with_transpose=True,
encoder_layers: list[int] | None = None,
num_decoder_layers: int | None = None,
):
"""
Args:
same_init_for_all_layers: if True, initialize all layers with the same vector
norm_init_scale: if not None, initialize the weights with a norm of this value
init_with_transpose: if True, initialize the decoder weights with the transpose of the encoder weights
encoder_layers: list of layers to use for the encoder. If None, num_layers must be specified.
num_decoder_layers: Number of decoder layers. If None, use num_layers.
"""
super().__init__()
if num_decoder_layers is None:
num_decoder_layers = num_layers
self.activation_dim = activation_dim
self.dict_size = dict_size
self.num_layers = num_layers
self.encoder = Encoder(
activation_dim,
dict_size,
num_layers,
same_init_for_all_layers=same_init_for_all_layers,
norm_init_scale=norm_init_scale,
encoder_layers=encoder_layers,
)
if init_with_transpose:
decoder_weight = einops.rearrange(
self.encoder.weight.data.clone(),
"num_layers activation_dim dict_size -> num_layers dict_size activation_dim",
)
else:
decoder_weight = None
self.decoder = CrossCoderDecoder(
activation_dim,
dict_size,
num_decoder_layers,
same_init_for_all_layers=same_init_for_all_layers,
init_with_weight=decoder_weight,
norm_init_scale=norm_init_scale,
)
def encode(
self, x: th.Tensor, **kwargs
) -> th.Tensor: # (batch_size, n_layers, dict_size)
# x: (batch_size, n_layers, activation_dim)
return self.encoder(x, **kwargs)
def get_activations(
self, x: th.Tensor, select_features: list[int] | None = None, **kwargs
) -> th.Tensor:
f = self.encode(x, select_features=select_features, **kwargs)
if select_features is not None:
dw = self.decoder.weight[:, select_features]
else:
dw = self.decoder.weight
return f * dw.norm(dim=2).sum(dim=0, keepdim=True)
def decode(
self, f: th.Tensor, **kwargs
) -> th.Tensor: # (batch_size, n_layers, activation_dim)
# f: (batch_size, n_layers, dict_size)
return self.decoder(f, **kwargs)
def forward(self, x: th.Tensor, output_features=False):
"""
Forward pass of the cross-coder.
x : activations to be encoded and decoded
output_features : if True, return the encoded features as well as the decoded x
"""
f = self.encode(x)
x_hat = self.decode(f)
if output_features:
# Scale features by decoder column norms
f_scaled = f * self.decoder.weight.norm(dim=2).sum(
dim=0, keepdim=True
) # Also sum across layers for the loss
return x_hat, f_scaled
else:
return x_hat
@classmethod
def from_pretrained(
cls,
path: str,
dtype: th.dtype = th.float32,
device: th.device | None = None,
from_hub: bool = False,
**kwargs,
):
"""
Load a pretrained cross-coder from a file.
"""
if from_hub:
return super().from_pretrained(path, device=device, dtype=dtype, **kwargs)
state_dict = th.load(path, map_location="cpu", weights_only=True)
if "encoder.weight" not in state_dict:
warn(
"Cross-coder state dict was saved while torch.compiled was enabled. Fixing..."
)
state_dict = {k.split("_orig_mod.")[1]: v for k, v in state_dict.items()}
num_layers, activation_dim, dict_size = state_dict["encoder.weight"].shape
cross_coder = cls(activation_dim, dict_size, num_layers)
cross_coder.load_state_dict(state_dict)
if device is not None:
cross_coder = cross_coder.to(device)
return cross_coder.to(dtype=dtype)
def resample_neurons(self, deads, activations):
# https://transformer-circuits.pub/2023/monosemantic-features/index.html#appendix-autoencoder-resampling
# compute loss for each activation
# impl from https://github.com/saprmarks/dictionary_learning
losses = (
(activations - self.forward(activations)).norm(dim=-1).mean(dim=-1).square()
)
# sample input to create encoder/decoder weights from
n_resample = min([deads.sum(), losses.shape[0]])
print("Resampling", n_resample, "neurons")
indices = th.multinomial(losses, num_samples=n_resample, replacement=False)
sampled_vecs = activations[indices] # (n_resample, num_layers, activation_dim)
# get norm of the living neurons
# encoder.weight: (num_layers, activation_dim, dict_size)
# decoder.weight: (num_layers, dict_size, activation_dim)
alive_norm = self.encoder.weight[:, :, ~deads].norm(dim=-2)
alive_norm = alive_norm.mean(dim=-1) # (num_layers)
# convert to (num_layers, 1, 1)
alive_norm = einops.repeat(alive_norm, "num_layers -> num_layers 1 1")
# resample first n_resample dead neurons
deads[deads.nonzero()[n_resample:]] = False
self.encoder.weight[:, :, deads] = (
sampled_vecs.permute(1, 2, 0) * alive_norm * 0.05
)
sampled_vecs = sampled_vecs.permute(1, 0, 2)
self.decoder.weight[:, deads, :] = th.nn.functional.normalize(
sampled_vecs, dim=-1
)
self.encoder.bias[deads] = 0.0
def get_activations(prompts, base_model, chat_model, layer=14):
toks = chat_model.tokenizer.apply_chat_template(
prompts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=1024,
return_dict=True,
)
attn_mask = toks.attention_mask.bool()
with chat_model.trace(toks):
chat_out = get_layer_output(chat_model, layer)[attn_mask].save()
get_layer(chat_model, layer).output.stop()
with base_model.trace(toks):
base_out = get_layer_output(base_model, layer)[attn_mask].save()
get_layer(base_model, layer).output.stop()
# activations: (batch_size, seq_len, d), with mask: (num_acts, d)
# return (num_acts,2, d)
return th.cat([base_out.unsqueeze(1), chat_out.unsqueeze(1)], dim=1)
class IndexableDataset(th.utils.data.Dataset):
def __init__(self, iterative_dataset):
self.data = list(iterative_dataset)
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
class ActivationBuffer:
"""
Memory-efficient buffer for activations that yields batches on demand
and refreshes when depleted.
"""
def __init__(
self,
base_model,
chat_model,
get_activations_fn,
dataset,
buffer_size=30000, # approximate number of contexts to store in the buffer
batch_size=64, # size of batches to yield
refresh_batch_size=8, # size of batches to process when adding to buffer
layer=14, # layer to extract activations from
device="cpu", # device to store activations on
recompute=True, # recompute activations when buffer is exhausted
):
self.base_model = base_model
self.chat_model = chat_model
self.get_activations_fn = get_activations_fn
self.dataset = dataset
self.buffer_size = buffer_size
self.batch_size = batch_size
self.refresh_batch_size = refresh_batch_size
self.layer = layer
self.device = device
self.recompute = recompute
self.activation_dim = chat_model._model.config.hidden_size
# Initialize empty buffer
self.activations = th.empty(0, 2, self.activation_dim, device=device)
self.read = th.zeros(0).bool()
# Create dataloader for refreshing
self.dataloader = DataLoader(
IndexableDataset(dataset),
batch_size=refresh_batch_size,
shuffle=True,
collate_fn=lambda x: x,
)
self.dataloader_iter = iter(self.dataloader)
def __iter__(self):
return self
def __next__(self):
"""Return a batch of activations"""
with th.no_grad():
# If buffer is less than half full, refresh
if (~self.read).sum() < self.buffer_size // 2:
self.refresh()
# Return a batch
unreads = (~self.read).nonzero().squeeze()
if len(unreads.shape) == 0: # Handle case with only one unread
unreads = unreads.unsqueeze(0)
# Get random batch of unread indices
batch_size = min(self.batch_size, len(unreads))
idxs = unreads[
th.randperm(len(unreads), device=unreads.device)[:batch_size]
]
self.read[idxs] = True
return self.activations[idxs]
def refresh(self):
"""Refresh the buffer with new activations"""
if not self.recompute and len(self.activations) == self.buffer_size:
self.read = th.zeros(
len(self.activations), dtype=th.bool, device=self.device
)
return
gc.collect()
th.cuda.empty_cache()
# Keep unread activations
self.activations = self.activations[~self.read]
current_idx = len(self.activations)
# Create new buffer with appropriate size
new_activations = th.empty(
self.buffer_size, 2, self.activation_dim, device=self.device
)
# Copy existing activations
if current_idx > 0:
new_activations[:current_idx] = self.activations
self.activations = new_activations
# Progress tracking
pbar = tqdm(
total=self.buffer_size, initial=current_idx, desc="Refreshing buffer"
)
# Fill buffer with new activations
while current_idx < self.buffer_size:
with th.no_grad():
# Get next batch from dataloader
try:
batch = next(self.dataloader_iter)
except StopIteration:
# Reset dataloader if we've gone through the dataset
self.dataloader_iter = iter(self.dataloader)
batch = next(self.dataloader_iter)
# Get activations for this batch
act = self.get_activations_fn(
batch, self.base_model, self.chat_model, self.layer
)
# Add to buffer
remaining_space = self.buffer_size - current_idx
act_to_add = min(len(act), remaining_space)
self.activations[current_idx : current_idx + act_to_add] = act[
:act_to_add
].to(self.device)
current_idx += act_to_add
pbar.update(act_to_add)
if current_idx >= self.buffer_size:
break
pbar.close()
# Reset read markers
self.read = th.zeros(len(self.activations), dtype=th.bool, device=self.device)
class TrainingConfig:
"""Configuration for training process."""
def __init__(
self,
lr=1e-5,
warmup_steps=1000,
resample_steps=None,
batch_size=64,
buffer_size=30000,
refresh_batch_size=8,
max_tokens=10000,
max_tokens_val=10000,
validate_every=5_000_000, # Validate every N tokens
device=None,
layer=14,
wandb_project="crosscoder-ipol",
wandb_entity=None,
wandb_run_name=None,
use_wandb=True,
checkpoint_dir="checkpoints",
checkpoint_every=1_000_000, # Save checkpoint every N tokens
resume_from=None, # Path to checkpoint directory to resume from
run_name=None, # Name for the experiment run
):
self.lr = lr
self.warmup_steps = warmup_steps
self.resample_steps = resample_steps
self.batch_size = batch_size
self.buffer_size = buffer_size
self.refresh_batch_size = refresh_batch_size
self.max_tokens = max_tokens
self.max_tokens_val = max_tokens_val
self.validate_every = validate_every
self.layer = layer
self.device = (
device
if device is not None
else ("cuda" if th.cuda.is_available() else "cpu")
)
# Wandb config
self.wandb_project = wandb_project
self.wandb_entity = wandb_entity
self.wandb_run_name = wandb_run_name
self.use_wandb = use_wandb
# Checkpoint config
self.checkpoint_dir = Path(checkpoint_dir)
self.checkpoint_every = checkpoint_every
self.resume_from = Path(resume_from) if resume_from else None
self.last_checkpoint_tokens = 0 # Track the token count of last checkpoint
self.last_validation_tokens = 0 # Track the token count of last validation
# Run name for experiment
if run_name is None:
timestamp = str(int(time()))
coolname = generate_slug(2)
self.run_name = f"{timestamp}_{coolname}"
else:
self.run_name = run_name
class CoderConfig:
"""Configuration for CrossCoder model architecture."""
def __init__(
self,
activation_dim,
dict_size,
num_layers=2,
l1_penalty=5e-4,
same_init_for_all_layers=False,
norm_init_scale=0.005,
init_with_transpose=True,
name=None,
):
self.activation_dim = activation_dim
self.dict_size = dict_size
self.num_layers = num_layers
self.l1_penalty = l1_penalty
self.same_init_for_all_layers = same_init_for_all_layers
self.norm_init_scale = norm_init_scale
self.init_with_transpose = init_with_transpose
self.name = name or f"coder_{dict_size}_{num_layers}_{l1_penalty}"
def get_stats(crosscoder, batch, return_alive=False):
"""
Compute detailed statistics for both 2D and 3D tensors.
batch shape: either [batch, d_model] or [batch, layer, d_model]
"""
with th.no_grad():
x_hat, features = crosscoder(batch, output_features=True)
# L0 (features/sample)
l0 = (features != 0).float().sum(dim=-1).mean().item()
# Fraction of dead features
alive = (features > 1e-4).any(dim=0)
assert alive.shape == (features.shape[-1],)
frac_deads = 1 - (alive.sum() / features.shape[-1]).item()
stats = {
"l0": l0,
"frac_deads": frac_deads,
"loss": th.nn.MSELoss()(x_hat, batch).item(),
}
# Variance explained calculation
if batch.dim() == 2:
# For 2D tensors: [batch, d_model]
total_variance = th.var(batch, dim=0).sum()
residual_variance = th.var(batch - x_hat, dim=0).sum()
stats["frac_variance_explained"] = (
1 - residual_variance / total_variance
).item()
else:
# For 3D tensors: [batch, layer, d_model]
total_variance_per_layer = []
residual_variance_per_layer = []
for l in range(batch.shape[1]):
total_var_l = th.var(batch[:, l, :], dim=0).sum()
resid_var_l = th.var(batch[:, l, :] - x_hat[:, l, :], dim=0).sum()
total_variance_per_layer.append(total_var_l)
residual_variance_per_layer.append(resid_var_l)
# Per-layer variance explained
stats[f"cl{l}_frac_variance_explained"] = (
1 - residual_variance_per_layer[-1] / total_variance_per_layer[-1]
).item()
# Overall variance explained
total_variance = sum(total_variance_per_layer)
residual_variance = sum(residual_variance_per_layer)
stats["frac_variance_explained"] = (
1 - residual_variance / total_variance
).item()
if return_alive:
stats["alive"] = alive
return stats
def init_wandb(config):
"""Initialize wandb in a subprocess."""
wandb.init(**config)
return wandb.run.id
def wandb_logger_process(config, log_queue, stop_event):
"""Process that handles wandb logging."""
run = wandb.init(**config)
while not stop_event.is_set() or not log_queue.empty():
try:
log_data = log_queue.get(timeout=1.0)
wandb.log(**log_data)
except Empty:
continue
run.finish()
class WandbLogger:
"""Handles wandb logging in a separate process."""
def __init__(self, config):
self.log_queue = mp.Queue()
self.stop_event = mp.Event()
self.process = mp.Process(
target=wandb_logger_process, args=(config, self.log_queue, self.stop_event)
)
self.process.start()
def log(self, metrics, step=None):
"""Send metrics to the logger process."""
self.log_queue.put({"data": metrics, "step": step})
def finish(self):
"""Stop the logger process."""
self.stop_event.set()
self.process.join()
def train_crosscoder(training_config, coder_configs, buffer, validation_buffer=None):
"""Train multiple CrossCoder models with the given configurations and data buffers in parallel."""
device = training_config.device
models = []
optimizers = []
schedulers = []
steps_since_active_list = []
wandb_loggers = []
# Initialize all models, optimizers, and schedulers
for coder_config in coder_configs:
print(f"\nInitializing CrossCoder model: {coder_config.name}")
# Initialize wandb logger
if training_config.use_wandb:
wandb_config = {
"project": training_config.wandb_project,
"entity": training_config.wandb_entity,
"name": (
f"{training_config.wandb_run_name}_{coder_config.name}"
if training_config.wandb_run_name
else coder_config.name
),
"config": {
"activation_dim": coder_config.activation_dim,
"dict_size": coder_config.dict_size,
"num_layers": coder_config.num_layers,
"lr": training_config.lr,
"l1_penalty": coder_config.l1_penalty,
"warmup_steps": training_config.warmup_steps,
"resample_steps": training_config.resample_steps,
"batch_size": training_config.batch_size,
"buffer_size": training_config.buffer_size,
"layer": training_config.layer,
},
"group": (
training_config.wandb_run_name
if training_config.wandb_run_name
else None
),
}
logger = WandbLogger(wandb_config)
wandb_loggers.append(logger)
# Initialize CrossCoder model
crosscoder = CrossCoder(
coder_config.activation_dim,
coder_config.dict_size,
coder_config.num_layers,
same_init_for_all_layers=coder_config.same_init_for_all_layers,
norm_init_scale=coder_config.norm_init_scale,
init_with_transpose=coder_config.init_with_transpose,
).to(device)
models.append(crosscoder)
# Initialize optimizer
optimizer = th.optim.Adam(crosscoder.parameters(), lr=training_config.lr)
optimizers.append(optimizer)
# Initialize scheduler
def warmup_fn(step):
if training_config.resample_steps is None:
return min(step / training_config.warmup_steps, 1.0)
else:
return min(
(step % training_config.resample_steps)
/ training_config.warmup_steps,
1.0,
)
scheduler = th.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=warmup_fn)
schedulers.append(scheduler)
# Initialize steps_since_active if using resampling
if training_config.resample_steps is not None:
steps_since_active = th.zeros(coder_config.dict_size, dtype=int).to(device)
steps_since_active_list.append(steps_since_active)
# Initialize training loop variables
step = 0
num_tokens = 0
all_stats = [{"train_losses": [], "val_metrics": []} for _ in models]
# Load checkpoint if resuming
if training_config.resume_from and training_config.resume_from.exists():
checkpoint_path = training_config.resume_from / "latest.pt"
if checkpoint_path.exists():
step, num_tokens, loaded_steps_since_active, loaded_stats = load_checkpoint(
checkpoint_path, models, optimizers, schedulers, device
)
if loaded_steps_since_active is not None:
steps_since_active_list = loaded_steps_since_active
all_stats = loaded_stats
print(f"Resumed training from step {step} ({num_tokens} tokens)")
# Main training loop
pbar = tqdm(
total=training_config.max_tokens, initial=num_tokens, desc="Training all models"
)
loss_fn = th.nn.MSELoss()
while num_tokens < training_config.max_tokens:
# Get batch and move to device
batch = next(buffer).to(device)
batch_tokens = batch.shape[0]
num_tokens += batch_tokens
# Train all models in parallel
for i, (model, optimizer, scheduler, coder_config) in enumerate(
zip(models, optimizers, schedulers, coder_configs)
):
model.train()
optimizer.zero_grad()
# Forward pass
x_hat, features = model(batch, output_features=True)
# Compute loss
l2_loss = loss_fn(x_hat, batch)
l1_loss = coder_config.l1_penalty * features.norm(p=1, dim=-1).mean()
loss = l2_loss + l1_loss
# Backward and optimize
loss.backward()
optimizer.step()
scheduler.step()
# Log training metrics
if training_config.use_wandb and step % 10 == 0:
if len(batch) > 1:
train_stats = get_stats(model, batch)
wandb_loggers[i].log(
{
"train/loss": loss.item(),
"train/l2_loss": l2_loss.item(),
"train/l1_loss": l1_loss.item(),
"train/learning_rate": scheduler.get_last_lr()[0],
"train/l0": train_stats["l0"],
"train/frac_deads": train_stats["frac_deads"],
"train/frac_variance_explained": train_stats[
"frac_variance_explained"
],
},
step=step,
)
# Handle dead neurons and resampling
if training_config.resample_steps is not None:
deads = (features <= 1e-4).all(dim=0)
steps_since_active_list[i][deads] += 1
steps_since_active_list[i][~deads] = 0
if step % 50 == 0:
dead_count = (
(
steps_since_active_list[i]
> training_config.resample_steps // 2
)
.sum()
.item()
)
if i == 0: # Update progress bar with first model's stats
pbar.set_postfix(
{
"loss": f"{loss.item():.4f}",
"dead": f"{dead_count}/{coder_config.dict_size}",
}
)
# Perform neuron resampling
if step % training_config.resample_steps == 0 and step > 0:
dead_mask = (
steps_since_active_list[i] > training_config.resample_steps // 2
)
if dead_mask.sum() > 0:
print(
f"\nResampling {dead_mask.sum().item()} neurons at step {step} for model {coder_config.name}"
)
model.resample_neurons(dead_mask, batch)
if training_config.use_wandb:
wandb_loggers[i].log(
{"train/resampled_neurons": dead_mask.sum().item()},
step=step,
)
elif (
step % 50 == 0 and i == 0
): # Update progress bar with first model's stats
pbar.set_postfix({"loss": f"{loss.item():.4f}"})
# Save checkpoint based on tokens
tokens_since_checkpoint = num_tokens - training_config.last_checkpoint_tokens
if tokens_since_checkpoint >= training_config.checkpoint_every:
checkpoint_dir = training_config.checkpoint_dir
save_checkpoint(
checkpoint_dir,
step,
num_tokens,
models,
optimizers,
schedulers,
steps_since_active_list,
all_stats,
coder_configs,
training_config,
)
training_config.last_checkpoint_tokens = num_tokens
# Validation based on tokens
tokens_since_validation = num_tokens - training_config.last_validation_tokens
if (
validation_buffer is not None
and tokens_since_validation >= training_config.validate_every
and num_tokens > 0
):
print(f"\nRunning validation at {num_tokens} tokens...")
for i, (model, coder_config) in enumerate(zip(models, coder_configs)):
model.eval()
val_stats = defaultdict(list)
alive = None
with th.no_grad():
num_tokens_val = 0
while num_tokens_val < training_config.max_tokens_val:
try:
val_batch = next(validation_buffer).to(device)
# print(val_batch.shape, num_tokens_val)
if len(val_batch) > 1:
batch_stats = get_stats(
model, val_batch, return_alive=True
)
for k, v in batch_stats.items():
if k != "alive":
val_stats[k].append(v)
else:
if alive is None:
alive = v
else:
alive = alive | v
num_tokens_val += val_batch.shape[0]
except StopIteration:
break
# Average validation metrics
avg_val_stats = {k: np.mean(v) for k, v in val_stats.items()}
all_stats[i]["val_metrics"].append(avg_val_stats)
# Log validation metrics
if training_config.use_wandb:
wandb_loggers[i].log(
{
"val/l2_loss": avg_val_stats["loss"],
"val/l0": avg_val_stats["l0"],
"val/frac_deads": avg_val_stats["frac_deads"],
"val/frac_variance_explained": avg_val_stats[
"frac_variance_explained"
],
"val/frac_dead": (~alive).float().mean().item(),
},
step=step,
)
# Print validation results
print(f"\nModel: {coder_config.name}")
print(f" L2 loss = {avg_val_stats['loss']:.6f}")
print(
f" Variance explained = {avg_val_stats['frac_variance_explained']:.2%}"
)
print(f" L0 (features/sample) = {avg_val_stats['l0']:.1f}")
print(
f" Fraction of dead features = {avg_val_stats['frac_deads']:.2%}"
)
training_config.last_validation_tokens = num_tokens
step += 1
pbar.update(batch_tokens)
pbar.close()
# Save final checkpoint
checkpoint_dir = (
training_config.checkpoint_dir / f"run_{training_config.wandb_run_name}"
if training_config.wandb_run_name
else training_config.checkpoint_dir
)
save_checkpoint(
checkpoint_dir,
step,
num_tokens,
models,
optimizers,
schedulers,
steps_since_active_list,
all_stats,
coder_configs,
training_config,
)
# Clean up wandb loggers
if training_config.use_wandb:
for logger in wandb_loggers:
logger.finish()
return models, all_stats
def save_checkpoint(
checkpoint_dir,
step,
num_tokens,
models,
optimizers,
schedulers,
steps_since_active_list,
all_stats,
coder_configs,
training_config,
):
"""Save training checkpoint."""
run_dir = Path(checkpoint_dir) / training_config.run_name
run_dir.mkdir(parents=True, exist_ok=True)
# Create models directory
models_dir = Path(checkpoint_dir).parent / "models" / training_config.run_name
models_dir.mkdir(parents=True, exist_ok=True)
# Save full checkpoint with all models and training state
checkpoint = {
"step": step,
"num_tokens": num_tokens,
"optimizers": [opt.state_dict() for opt in optimizers],
"schedulers": [sched.state_dict() for sched in schedulers],
"steps_since_active": (
[steps.cpu() for steps in steps_since_active_list]
if steps_since_active_list
else None
),
"all_stats": all_stats,
"coder_configs": [vars(config) for config in coder_configs],
"training_config": vars(training_config),
}
checkpoint_path = run_dir / f"{num_tokens}_toks.pt"
th.save(checkpoint, checkpoint_path)
# Save latest checkpoint symlink
latest_path = run_dir / "latest.pt"
if latest_path.exists():
latest_path.unlink()
latest_path.symlink_to(f"{num_tokens}_toks.pt")
# Save individual model files in models directory
for model, config in zip(models, coder_configs):
model_dir = models_dir / config.name
model_dir.mkdir(parents=True, exist_ok=True)
model_path = model_dir / f"{num_tokens}_toks.pt"
th.save(model.state_dict(), model_path)
# Create latest model symlink
latest_model_path = model_dir / "latest.pt"
if latest_model_path.exists():
latest_model_path.unlink()
latest_model_path.symlink_to(f"{num_tokens}_toks.pt")
print(f"\nSaved checkpoint at {num_tokens} tokens to {checkpoint_path}")
print(f"Saved individual models to {models_dir}/[model_name]/{num_tokens}_toks.pt")
def load_checkpoint(checkpoint_path, models, optimizers, schedulers, device):
"""Load training checkpoint."""
print(f"Loading checkpoint from {checkpoint_path}")
checkpoint = th.load(checkpoint_path, map_location=device)
# Load model states
# Load models from individual files in models directory
models_dir = Path(checkpoint_path).parent.parent / "models"
for model, config in zip(
models, [CoderConfig(**c) for c in checkpoint["coder_configs"]]
):
model_path = models_dir / config.name / f"{checkpoint['num_tokens']}_toks.pt"
model.load_state_dict(th.load(model_path, map_location=device))
# Load optimizer states
for opt, state_dict in zip(optimizers, checkpoint["optimizers"]):
opt.load_state_dict(state_dict)
# Load scheduler states
for sched, state_dict in zip(schedulers, checkpoint["schedulers"]):
sched.load_state_dict(state_dict)
# Load steps_since_active if it exists
steps_since_active_list = None
if checkpoint["steps_since_active"] is not None:
steps_since_active_list = [
steps.to(device) for steps in checkpoint["steps_since_active"]
]
return (
checkpoint["step"],
checkpoint["num_tokens"],
steps_since_active_list,
checkpoint["all_stats"],
)
def train(base_model, chat_model, dataset, training_config, coder_configs):
"""Train multiple CrossCoder models with the given models, dataset, and configurations."""
train_dataset, val_dataset = random_split(dataset, [0.9, 0.1])
# Create training and validation buffers
print("Creating activation buffers...")
train_buffer = ActivationBuffer(
base_model=base_model,
chat_model=chat_model,
get_activations_fn=get_activations,
dataset=train_dataset,
buffer_size=training_config.buffer_size,
batch_size=training_config.batch_size,
refresh_batch_size=training_config.refresh_batch_size,
layer=training_config.layer,
device="cpu", # Store on CPU, transfer to GPU as needed
)
# Create smaller validation buffer
val_buffer = ActivationBuffer(
base_model=base_model,
chat_model=chat_model,
get_activations_fn=get_activations,
dataset=val_dataset,
buffer_size=training_config.buffer_size,
batch_size=training_config.batch_size,
refresh_batch_size=training_config.refresh_batch_size,
layer=training_config.layer,
device="cpu",
recompute=False,
)
# Train the models
print("Starting training...")
models, all_stats = train_crosscoder(
training_config, coder_configs, train_buffer, val_buffer
)
# Save the models and stats
save_dir = (
Path(training_config.checkpoint_dir).parent
/ "models"
/ training_config.run_name
)
save_dir.mkdir(exist_ok=True)
for model, stats, config in zip(models, all_stats, coder_configs):
model_path = save_dir / f"{config.name}.pt"
stats_path = save_dir / f"{config.name}_stats.json"
th.save(model.state_dict(), model_path)
with open(stats_path, "w") as f:
json.dump(stats, f)
print(f"Saved model and stats for {config.name}")
return models, all_stats
# Run the training if this script is executed directly
if __name__ == "__main__":
import argparse
from datasets import load_dataset
from nnterp import load_model
parser = argparse.ArgumentParser(description="Train multiple CrossCoder models")
# Model arguments
parser.add_argument(
"--base_model_name",
type=str,
default="Qwen/Qwen2.5-0.5B",
help="The base model name",
)
parser.add_argument(
"--chat_model_name",
type=str,
default="Qwen/Qwen2.5-0.5B-Instruct",
help="The chat model name",
)
# Add test flag
parser.add_argument(
"--test",
action="store_true",
help="Run in test mode with minimal steps",
)
# Training configuration
parser.add_argument("--lr", type=float, default=1e-4, help="Learning rate")
parser.add_argument(
"--batch_size", type=int, default=1024, help="Training batch size"
)
parser.add_argument(
"--buffer_size",
type=int,
default=1_000_000,
help="Number of activations to keep in memory",
)
parser.add_argument(
"--refresh_batch_size",
type=int,
default=64,
help="Batch size for refreshing activation buffer",
)
parser.add_argument(
"--max-tokens",
type=int,
default=50_000_000,
help="Maximum number of training tokens",
)
parser.add_argument(
"--max-tokens-val",
type=int,
default=1_000_000,
help="Maximum number of validation tokens",
)
parser.add_argument(
"--validate-every",
type=int,
default=5_000_000,
help="Validation frequency in tokens",
)
parser.add_argument(
"--layer", type=int, default=14, help="Layer to extract activations from"
)
parser.add_argument(
"--device",
type=str,
default=None,
help="Device to use (cuda/cpu). Defaults to cuda if available.",
)
parser.add_argument("--seed", type=int, default=42, help="Random seed")
# Checkpoint configuration
parser.add_argument(
"--checkpoint-dir",
type=str,
default="checkpoints",
help="Directory to save checkpoints",
)
parser.add_argument(
"--checkpoint-every",
type=int,
default=1_000_000,
help="Save checkpoint every N tokens",
)
parser.add_argument(
"--resume-from", type=str, help="Path to checkpoint directory to resume from"
)
args = parser.parse_args()
run_name = str(int(time())) + "_" + generate_slug(2)
# Modify parameters if in test mode
if args.test:
args.max_tokens = 1000
args.max_tokens_val = 100
args.validate_every = 500 # Validate every 500 tokens in test mode
args.checkpoint_every = 200 # Checkpoint every 200 tokens in test mode
args.buffer_size = 1000
run_name = "test_" + run_name
print("Running in test mode with reduced parameters")
# Set device
if args.device is None:
args.device = "cuda" if th.cuda.is_available() else "cpu"
print(f"Loading models from {args.base_model_name} and {args.chat_model_name}...")
# Initialize models
base_model = load_model(args.base_model_name, torch_dtype=th.float32)
chat_model = load_model(args.chat_model_name, torch_dtype=th.float32)
# Load dataset
print("Loading dataset...")
dataset = load_dataset("lmsys/lmsys-chat-1m", split="train")["conversation"]
# Create training configuration
training_config = TrainingConfig(
lr=args.lr,
batch_size=args.batch_size,
buffer_size=args.buffer_size,
refresh_batch_size=args.refresh_batch_size,
max_tokens=args.max_tokens,
max_tokens_val=args.max_tokens_val,
validate_every=args.validate_every,
layer=args.layer,
device=args.device,
checkpoint_dir=args.checkpoint_dir,
checkpoint_every=args.checkpoint_every,
resume_from=args.resume_from,
run_name=run_name,
wandb_project="crosscoder-ipol-test" if args.test else "crosscoder-ipol",
)
# Create multiple coder configurations for different experiments
activation_dim = chat_model._model.config.hidden_size
coder_configs = [
CoderConfig(
activation_dim=activation_dim,
dict_size=16000,
num_layers=2,
l1_penalty=3e-2,
name=f"{run_name}_16k3e-2",
),
CoderConfig(
activation_dim=activation_dim,
dict_size=32000,
num_layers=2,
l1_penalty=3e-2,
name=f"{run_name}_32k3e-2",
),
CoderConfig(
activation_dim=activation_dim,
dict_size=16000,
num_layers=2,
l1_penalty=5e-2,
name=f"{run_name}_16k5e-2",
),
CoderConfig(
activation_dim=activation_dim,
dict_size=32000,
num_layers=2,
l1_penalty=5e-2,
name=f"{run_name}_32k5e-2",
),
]
th.manual_seed(args.seed)
np.random.seed(args.seed)
# Train the models
models, stats = train(
base_model, chat_model, dataset, training_config, coder_configs
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment