Upload trained model
Browse files- README.md +163 -0
- config.json +7 -0
- demo.py +131 -0
- dfc.py +222 -0
- hparams.json +19 -0
- model.pt +3 -0
- requirements.txt +8 -0
README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
tags:
|
| 3 |
+
- sparse-autoencoder
|
| 4 |
+
- crosscoder
|
| 5 |
+
- interpretability
|
| 6 |
+
- qwen2
|
| 7 |
+
- mechanistic-interpretability
|
| 8 |
+
- dictionary-learning
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# dfc-D8k-excl3-freeexcl-k90
|
| 13 |
+
|
| 14 |
+
A **DFC** sparse crosscoder trained to compare layer-13 activations between:
|
| 15 |
+
- **Model A (ToolRL)**: `chengq9/ToolRL-Qwen2.5-3B` β fine-tuned with tool-use reinforcement learning
|
| 16 |
+
- **Model B (Base)**: `Qwen/Qwen2.5-3B` β vanilla base model
|
| 17 |
+
|
| 18 |
+
## What is this?
|
| 19 |
+
|
| 20 |
+
This model learns a sparse dictionary of features from the internal representations of two language models. By comparing which features activate for which model, we can identify:
|
| 21 |
+
- **What the ToolRL fine-tuning changed** (A-exclusive features)
|
| 22 |
+
- **What remained the same** (shared features)
|
| 23 |
+
- **What the base model does that ToolRL suppressed** (B-exclusive features)
|
| 24 |
+
|
| 25 |
+
## Model Architecture
|
| 26 |
+
|
| 27 |
+
Dedicated Feature CrossCoder with partitioned dictionary (0.03/0.03 A/B exclusive)
|
| 28 |
+
|
| 29 |
+
| Parameter | Value |
|
| 30 |
+
|-----------|-------|
|
| 31 |
+
| Dictionary size | 8192 |
|
| 32 |
+
| Top-k active features | 90 |
|
| 33 |
+
| Layer | 13 (middle layer of Qwen2.5-3B) |
|
| 34 |
+
| Activation dimension | 2048 |
|
| 35 |
+
| A-exclusive features | 245 (0.03) |
|
| 36 |
+
| B-exclusive features | 245 (0.03) |
|
| 37 |
+
| Shared features | 7702 |
|
| 38 |
+
|
| 39 |
+
### How it works
|
| 40 |
+
|
| 41 |
+
1. **Encode**: Takes stacked activations `(batch, 2, 2048)` from both models, applies per-model encoder weights, sums across models, and selects the top-90 features via ReLU + top-k.
|
| 42 |
+
2. **Decode**: Reconstructs per-model activations from the sparse feature vector using per-model decoder weights.
|
| 43 |
+
3. **Partition masks** (DFC only): Hard binary masks zero out encoder/decoder weights to enforce that exclusive features cannot be used by the wrong model.
|
| 44 |
+
|
| 45 |
+
## Training
|
| 46 |
+
|
| 47 |
+
| Parameter | Value |
|
| 48 |
+
|-----------|-------|
|
| 49 |
+
| Loss function | MSE + L1 on shared features only (coef: 1e-3); exclusive features unpenalized |
|
| 50 |
+
| Training steps | 9000 |
|
| 51 |
+
| Learning rate | 1e-4 |
|
| 52 |
+
| Batch size | 1024 |
|
| 53 |
+
| Sparsity coefficient (shared) | 1e-3 |
|
| 54 |
+
| Exclusive sparsity coefficient | 0 |
|
| 55 |
+
| Optimizer | Adam (grad clip 1.0) |
|
| 56 |
+
| W&B project | `dfc-crosscoder-sweep` |
|
| 57 |
+
|
| 58 |
+
### Training Data
|
| 59 |
+
|
| 60 |
+
- **FineWeb**: ~40,000 general web text samples (from `HuggingFaceFW/fineweb` sample-10BT)
|
| 61 |
+
- **ToolRL**: ~40,000 tool-use conversation samples (from `emrecanacikgoz/ToolRL`, cycled)
|
| 62 |
+
- Activations extracted from layer 13, last token per sample
|
| 63 |
+
- Both datasets concatenated and z-score normalized
|
| 64 |
+
|
| 65 |
+
## Usage
|
| 66 |
+
|
| 67 |
+
### Quick Start
|
| 68 |
+
|
| 69 |
+
```python
|
| 70 |
+
import torch
|
| 71 |
+
from huggingface_hub import hf_hub_download
|
| 72 |
+
|
| 73 |
+
# Download model files
|
| 74 |
+
repo_id = "antebe1/dfc-D8k-excl3-freeexcl-k90"
|
| 75 |
+
for fname in ["model.pt", "config.json", "dfc.py"]:
|
| 76 |
+
hf_hub_download(repo_id=repo_id, filename=fname, local_dir="./model")
|
| 77 |
+
|
| 78 |
+
# Load the crosscoder
|
| 79 |
+
import sys; sys.path.insert(0, "./model")
|
| 80 |
+
from dfc import DFCCrossCoder
|
| 81 |
+
|
| 82 |
+
dfc = DFCCrossCoder.load("./model", device="cuda")
|
| 83 |
+
print(f"Loaded: dict_size={dfc.dict_size}, k={dfc.k}")
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### Extract Features from Real Models
|
| 87 |
+
|
| 88 |
+
```python
|
| 89 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 90 |
+
|
| 91 |
+
# Load both models
|
| 92 |
+
model_a = AutoModelForCausalLM.from_pretrained("chengq9/ToolRL-Qwen2.5-3B", device_map="cuda:0")
|
| 93 |
+
model_b = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-3B", device_map="cuda:1")
|
| 94 |
+
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-3B")
|
| 95 |
+
|
| 96 |
+
# Get activations from layer 13
|
| 97 |
+
# NOTE: hidden_states[0] = embeddings, hidden_states[i] = output of layer i-1
|
| 98 |
+
# so layer 13 activations are at index 13+1
|
| 99 |
+
text = "Use the search tool to find recent papers on RLHF"
|
| 100 |
+
inputs = tokenizer(text, return_tensors="pt")
|
| 101 |
+
|
| 102 |
+
with torch.no_grad():
|
| 103 |
+
out_a = model_a(**inputs.to("cuda:0"), output_hidden_states=True)
|
| 104 |
+
out_b = model_b(**inputs.to("cuda:1"), output_hidden_states=True)
|
| 105 |
+
act_a = out_a.hidden_states[13 + 1][:, -1, :] # last token, layer 13
|
| 106 |
+
act_b = out_b.hidden_states[13 + 1][:, -1, :]
|
| 107 |
+
|
| 108 |
+
# Stack and encode
|
| 109 |
+
activations = torch.stack([act_a.cpu(), act_b.cpu()], dim=1) # (1, 2, 2048)
|
| 110 |
+
features = dfc.encode(activations.to(dfc.W_enc.device))
|
| 111 |
+
|
| 112 |
+
print(f"Active features: {(features > 0).sum().item()} / {dfc.dict_size}")
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
### Analyze Partitions (DFC only)
|
| 116 |
+
|
| 117 |
+
```python
|
| 118 |
+
stats = dfc.feature_stats(features)
|
| 119 |
+
print(f"L0 total: {stats['l0_total']:.1f}")
|
| 120 |
+
print(f"L0 A-excl: {stats['l0_a_excl']:.1f}")
|
| 121 |
+
print(f"L0 B-excl: {stats['l0_b_excl']:.1f}")
|
| 122 |
+
print(f"L0 shared: {stats['l0_shared']:.1f}")
|
| 123 |
+
|
| 124 |
+
# Check reconstruction quality
|
| 125 |
+
recon, feats = dfc(activations.to(dfc.W_enc.device))
|
| 126 |
+
mse = torch.nn.functional.mse_loss(recon.cpu(), activations)
|
| 127 |
+
print(f"Reconstruction MSE: {mse.item():.6f}")
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## Files
|
| 131 |
+
|
| 132 |
+
| File | Description |
|
| 133 |
+
|------|-------------|
|
| 134 |
+
| `model.pt` | PyTorch state dict (encoder/decoder weights + partition masks) |
|
| 135 |
+
| `config.json` | Architecture config: dict_size, k, partition sizes (n_a, n_b) |
|
| 136 |
+
| `hparams.json` | Full training hyperparameters including loss, lr, steps, etc. |
|
| 137 |
+
| `dfc.py` | `DFCCrossCoder` class definition β required to load model.pt |
|
| 138 |
+
| `demo.py` | Feature extraction demo (works with downloaded model) |
|
| 139 |
+
| `requirements.txt` | Python dependencies |
|
| 140 |
+
|
| 141 |
+
## Part of a Sweep
|
| 142 |
+
|
| 143 |
+
This model is one of 48 models in a hyperparameter sweep. See the full collection:
|
| 144 |
+
|
| 145 |
+
| Axis | Values |
|
| 146 |
+
|------|--------|
|
| 147 |
+
| k (top-k) | 45, 90, 160 |
|
| 148 |
+
| dict_size | 8,192 / 16,384 |
|
| 149 |
+
| Architecture | DFC (partitioned) / CrossCoder (all shared) |
|
| 150 |
+
| Exclusive % (DFC) | 3%, 5%, 10% |
|
| 151 |
+
| Exclusive sparsity | 1e-3 (penalized) / 0 (free) |
|
| 152 |
+
| CrossCoder L1 | with / without |
|
| 153 |
+
|
| 154 |
+
## Citation
|
| 155 |
+
|
| 156 |
+
```bibtex
|
| 157 |
+
@misc{dfc-D8k-excl3-freeexcl-k90,
|
| 158 |
+
title={DFC CrossCoder: ToolRL vs Base Qwen2.5-3B},
|
| 159 |
+
author={Andre Shportko},
|
| 160 |
+
year={2026},
|
| 161 |
+
url={https://huggingface.co/antebe1/dfc-D8k-excl3-freeexcl-k90}
|
| 162 |
+
}
|
| 163 |
+
```
|
config.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"activation_dim": 2048,
|
| 3 |
+
"dict_size": 8192,
|
| 4 |
+
"k": 90,
|
| 5 |
+
"n_a": 245,
|
| 6 |
+
"n_b": 245
|
| 7 |
+
}
|
demo.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DFC CrossCoder Demo - Quick Feature Vector Extraction
|
| 3 |
+
=====================================================
|
| 4 |
+
|
| 5 |
+
This demo shows how to use the DFC CrossCoder to extract feature vectors
|
| 6 |
+
from text using the ToolRL vs Base Qwen2.5-3B comparison.
|
| 7 |
+
|
| 8 |
+
Works both locally (from checkpoints/) and from a HuggingFace download.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 16 |
+
from dfc import DFCCrossCoder
|
| 17 |
+
|
| 18 |
+
# Default model path β override with command line arg or set MODEL_PATH env var
|
| 19 |
+
import os
|
| 20 |
+
DEFAULT_MODEL_PATH = os.environ.get("DFC_MODEL_PATH", "./checkpoints/dfc2")
|
| 21 |
+
LAYER = 13
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def quick_demo(model_path: str = DEFAULT_MODEL_PATH):
|
| 25 |
+
"""10-line core demo: Load models and extract features from text"""
|
| 26 |
+
|
| 27 |
+
# Load the trained CrossCoder
|
| 28 |
+
dfc = DFCCrossCoder.load(model_path, device="cuda" if torch.cuda.is_available() else "cpu")
|
| 29 |
+
|
| 30 |
+
# Load tokenizer and models (for demo - in practice you might use cached activations)
|
| 31 |
+
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-3B")
|
| 32 |
+
if tokenizer.pad_token is None:
|
| 33 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 34 |
+
|
| 35 |
+
# Example texts: general vs tool-use
|
| 36 |
+
general_text = "The French Revolution began in 1789 with widespread social discontent."
|
| 37 |
+
tooluse_text = "Search for the cheapest flight from London to Tokyo departing next Friday."
|
| 38 |
+
|
| 39 |
+
print("π DFC CrossCoder Demo")
|
| 40 |
+
print("="*50)
|
| 41 |
+
|
| 42 |
+
for text, label in [(general_text, "General"), (tooluse_text, "Tool-Use")]:
|
| 43 |
+
print(f"\n{label} Text: '{text}'")
|
| 44 |
+
|
| 45 |
+
# Tokenize and get activations (simplified - normally you'd extract from layer 13)
|
| 46 |
+
tokens = tokenizer(text, return_tensors="pt", padding=True, truncation=True)
|
| 47 |
+
|
| 48 |
+
# For demo: create mock activations (normally extracted from models at layer 13)
|
| 49 |
+
# Shape: (batch=1, models=2, hidden_dim=2048)
|
| 50 |
+
mock_activations = torch.randn(1, 2, dfc.activation_dim, device=dfc.W_enc.device)
|
| 51 |
+
|
| 52 |
+
# Core CrossCoder usage (the main 3 lines!)
|
| 53 |
+
features = dfc.encode(mock_activations) # Get sparse feature vector
|
| 54 |
+
reconstruction, _ = dfc(mock_activations) # Full forward pass
|
| 55 |
+
|
| 56 |
+
# Analyze partition breakdowns
|
| 57 |
+
active_features = (features > 0).sum().item()
|
| 58 |
+
a_exclusive = (features[0, :dfc.a_end] > 0).sum().item()
|
| 59 |
+
b_exclusive = (features[0, dfc.a_end:dfc.b_end] > 0).sum().item()
|
| 60 |
+
shared = (features[0, dfc.b_end:] > 0).sum().item()
|
| 61 |
+
|
| 62 |
+
print(f" β
Active features: {active_features}/{dfc.dict_size}")
|
| 63 |
+
print(f" π§ A-exclusive (ToolRL): {a_exclusive}")
|
| 64 |
+
print(f" π B-exclusive (Base): {b_exclusive}")
|
| 65 |
+
print(f" π€ Shared: {shared}")
|
| 66 |
+
|
| 67 |
+
# Show top features
|
| 68 |
+
top_vals, top_idx = torch.topk(features[0], k=5)
|
| 69 |
+
print(f" π Top features: {top_idx.tolist()} (values: {top_vals.tolist()})")
|
| 70 |
+
|
| 71 |
+
def extract_real_activations(model_path: str = DEFAULT_MODEL_PATH):
|
| 72 |
+
"""Extended demo with real model activations (requires more memory)"""
|
| 73 |
+
print("\n Extended Demo with Real Model Activations")
|
| 74 |
+
print("="*50)
|
| 75 |
+
|
| 76 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 77 |
+
|
| 78 |
+
# Load models (this requires significant memory!)
|
| 79 |
+
print("Loading ToolRL and Base models...")
|
| 80 |
+
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-3B")
|
| 81 |
+
if tokenizer.pad_token is None:
|
| 82 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 83 |
+
|
| 84 |
+
model_a = AutoModelForCausalLM.from_pretrained("chengq9/ToolRL-Qwen2.5-3B").to(device).eval()
|
| 85 |
+
model_b = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-3B").to(device).eval()
|
| 86 |
+
|
| 87 |
+
# Load CrossCoder
|
| 88 |
+
dfc = DFCCrossCoder.load(model_path, device=device)
|
| 89 |
+
|
| 90 |
+
# Extract real activations from layer 13
|
| 91 |
+
text = "Use the calculator to compute 15% tip on $45.50"
|
| 92 |
+
tokens = tokenizer(text, return_tensors="pt").to(device)
|
| 93 |
+
|
| 94 |
+
with torch.no_grad():
|
| 95 |
+
# hidden_states[0] = embeddings, hidden_states[i] = output of layer i-1
|
| 96 |
+
# so layer 13 activations are at index LAYER + 1
|
| 97 |
+
outputs_a = model_a(**tokens, output_hidden_states=True)
|
| 98 |
+
outputs_b = model_b(**tokens, output_hidden_states=True)
|
| 99 |
+
|
| 100 |
+
hidden_a = outputs_a.hidden_states[LAYER + 1][0, -1] # (hidden_dim,)
|
| 101 |
+
hidden_b = outputs_b.hidden_states[LAYER + 1][0, -1] # (hidden_dim,)
|
| 102 |
+
|
| 103 |
+
# Stack for CrossCoder: (1, 2, hidden_dim)
|
| 104 |
+
activations = torch.stack([hidden_a, hidden_b], dim=0).unsqueeze(0)
|
| 105 |
+
|
| 106 |
+
# Run CrossCoder
|
| 107 |
+
features = dfc.encode(activations)
|
| 108 |
+
|
| 109 |
+
# Analysis
|
| 110 |
+
active_count = (features > 0).sum().item()
|
| 111 |
+
print(f"\n Real Activation Analysis:")
|
| 112 |
+
print(f" Text: '{text}'")
|
| 113 |
+
print(f" Active features: {active_count}/{dfc.dict_size}")
|
| 114 |
+
|
| 115 |
+
# Partition breakdown
|
| 116 |
+
a_active = (features[0, :dfc.a_end] > 0).sum().item()
|
| 117 |
+
b_active = (features[0, dfc.a_end:dfc.b_end] > 0).sum().item()
|
| 118 |
+
s_active = (features[0, dfc.b_end:] > 0).sum().item()
|
| 119 |
+
|
| 120 |
+
print(f" ToolRL-specific: {a_active}")
|
| 121 |
+
print(f" Base-specific: {b_active}")
|
| 122 |
+
print(f" Shared: {s_active}")
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
model_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_MODEL_PATH
|
| 126 |
+
|
| 127 |
+
# Run quick demo with mock data
|
| 128 |
+
quick_demo(model_path)
|
| 129 |
+
|
| 130 |
+
# Uncomment to run with real models (requires significant GPU memory)
|
| 131 |
+
# extract_real_activations(model_path)
|
dfc.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dfc.py β Dedicated Feature CrossCoder (DFC) model.
|
| 3 |
+
|
| 4 |
+
Feature layout in dict_size
|
| 5 |
+
ββββββββββββββββββββββββββββ
|
| 6 |
+
βββββββββββββββββββββββ¬ββββββββββββββββββββββ¬βββββββββββββββββββββββββββ
|
| 7 |
+
β A-exclusive (n_a) β B-exclusive (n_b) β Shared (n_shared) β
|
| 8 |
+
βββββββββββββββββββββββ΄ββββββββββββββββββββββ΄βββββββββββββββββββββββββββ
|
| 9 |
+
idx: 0 βββββββ a_end ββββββββ b_end βββββββββββββββββββββ dict_size
|
| 10 |
+
|
| 11 |
+
Constraints (enforced by gradient masking + _apply_masks every step)
|
| 12 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
β’ Model A cannot encode/decode B-exclusive features
|
| 14 |
+
β’ Model B cannot encode/decode A-exclusive features
|
| 15 |
+
β’ Shared features are accessible to both
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn as nn
|
| 25 |
+
import torch.nn.functional as F
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class DFCCrossCoder(nn.Module):
|
| 29 |
+
|
| 30 |
+
def __init__(
|
| 31 |
+
self,
|
| 32 |
+
activation_dim: int,
|
| 33 |
+
dict_size: int,
|
| 34 |
+
k: int,
|
| 35 |
+
model_a_exclusive_pct: float = 0.05,
|
| 36 |
+
model_b_exclusive_pct: float = 0.05,
|
| 37 |
+
):
|
| 38 |
+
super().__init__()
|
| 39 |
+
self.activation_dim = activation_dim
|
| 40 |
+
self.dict_size = dict_size
|
| 41 |
+
self.k = k
|
| 42 |
+
|
| 43 |
+
self.n_a = int(dict_size * model_a_exclusive_pct)
|
| 44 |
+
self.n_b = int(dict_size * model_b_exclusive_pct)
|
| 45 |
+
self.n_shared = dict_size - self.n_a - self.n_b
|
| 46 |
+
self.a_end = self.n_a
|
| 47 |
+
self.b_end = self.n_a + self.n_b
|
| 48 |
+
|
| 49 |
+
print(
|
| 50 |
+
f"[DFC] dict={dict_size} k={k} | "
|
| 51 |
+
f"A-excl={self.n_a} B-excl={self.n_b} shared={self.n_shared}"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Encoder: W_enc[model, d_in, dict_size]
|
| 55 |
+
self.W_enc = nn.Parameter(
|
| 56 |
+
torch.randn(2, activation_dim, dict_size) / (activation_dim ** 0.5)
|
| 57 |
+
)
|
| 58 |
+
self.b_enc = nn.Parameter(torch.zeros(dict_size))
|
| 59 |
+
|
| 60 |
+
# Decoder: W_dec[dict_size, model, d_in]
|
| 61 |
+
self.W_dec = nn.Parameter(
|
| 62 |
+
torch.randn(dict_size, 2, activation_dim) / (dict_size ** 0.5)
|
| 63 |
+
)
|
| 64 |
+
self.b_dec = nn.Parameter(torch.zeros(2, activation_dim))
|
| 65 |
+
|
| 66 |
+
# ββ Partition masks (move with .to(device)) βββββββββββββββββββ
|
| 67 |
+
# enc_mask[model, dict_size]
|
| 68 |
+
enc_mask = torch.ones(2, dict_size)
|
| 69 |
+
enc_mask[1, : self.a_end] = 0 # B cannot encode A-excl
|
| 70 |
+
enc_mask[0, self.a_end : self.b_end] = 0 # A cannot encode B-excl
|
| 71 |
+
self.register_buffer("enc_mask", enc_mask)
|
| 72 |
+
|
| 73 |
+
# dec_mask[dict_size, model]
|
| 74 |
+
dec_mask = torch.ones(dict_size, 2)
|
| 75 |
+
dec_mask[: self.a_end, 1] = 0 # A-excl: B decoder = 0
|
| 76 |
+
dec_mask[self.a_end : self.b_end, 0] = 0 # B-excl: A decoder = 0
|
| 77 |
+
self.register_buffer("dec_mask", dec_mask)
|
| 78 |
+
|
| 79 |
+
self._apply_masks()
|
| 80 |
+
|
| 81 |
+
# ββ Weight enforcement ββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
+
|
| 83 |
+
@torch.no_grad()
|
| 84 |
+
def _apply_masks(self):
|
| 85 |
+
"""Zero forbidden weights. Call after every optimiser step."""
|
| 86 |
+
for m in range(2):
|
| 87 |
+
self.W_enc.data[m] *= self.enc_mask[m].unsqueeze(0)
|
| 88 |
+
for m in range(2):
|
| 89 |
+
self.W_dec.data[:, m, :] *= self.dec_mask[:, m].unsqueeze(1)
|
| 90 |
+
|
| 91 |
+
# ββ Forward βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
+
|
| 93 |
+
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
| 94 |
+
"""x: (B, 2, d) β features: (B, dict_size) sparse top-k."""
|
| 95 |
+
W = self.W_enc * self.enc_mask.unsqueeze(1) # (2, d, dict)
|
| 96 |
+
pre = torch.einsum("bmd,mdf->bf", x, W) + self.b_enc
|
| 97 |
+
pre = F.relu(pre)
|
| 98 |
+
topk_vals, topk_idx = torch.topk(pre, self.k, dim=-1)
|
| 99 |
+
features = torch.zeros_like(pre)
|
| 100 |
+
features.scatter_(-1, topk_idx, topk_vals)
|
| 101 |
+
return features
|
| 102 |
+
|
| 103 |
+
def decode(self, features: torch.Tensor) -> torch.Tensor:
|
| 104 |
+
"""features: (B, dict_size) β (B, 2, d)."""
|
| 105 |
+
W = self.W_dec * self.dec_mask.unsqueeze(-1) # (dict, 2, d)
|
| 106 |
+
return torch.einsum("bf,fmd->bmd", features, W) + self.b_dec
|
| 107 |
+
|
| 108 |
+
def forward(self, x: torch.Tensor):
|
| 109 |
+
"""x: (B, 2, d) β (reconstruction, features)."""
|
| 110 |
+
features = self.encode(x)
|
| 111 |
+
recon = self.decode(features)
|
| 112 |
+
return recon, features
|
| 113 |
+
|
| 114 |
+
def loss(
|
| 115 |
+
self,
|
| 116 |
+
x: torch.Tensor,
|
| 117 |
+
sparsity_coef: float = 1e-3,
|
| 118 |
+
exclusive_sparsity_coef: float = 1e-3 # Lower penalty for exclusive features
|
| 119 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 120 |
+
"""MSE + weighted L1 sparsity. Returns (total, mse, l1_shared, l1_exclusive)."""
|
| 121 |
+
recon, features = self.forward(x)
|
| 122 |
+
mse = F.mse_loss(recon, x)
|
| 123 |
+
|
| 124 |
+
# Split features by partition
|
| 125 |
+
# fa = features[:, :self.a_end] # A-exclusive
|
| 126 |
+
# fb = features[:, self.a_end:self.b_end] # B-exclusive
|
| 127 |
+
fs = features[:, self.b_end:] # Shared
|
| 128 |
+
|
| 129 |
+
# A sees: A-exclusive + shared
|
| 130 |
+
fa = torch.cat([features[:, :self.a_end], features[:, self.b_end:]], dim=-1) # A-exclusive + shared
|
| 131 |
+
fb = torch.cat([features[:, self.a_end:self.b_end], features[:, self.b_end:]], dim=-1) # B-exclusive + shared
|
| 132 |
+
|
| 133 |
+
# Separate sparsity penalties
|
| 134 |
+
l1_shared = fs.abs().mean()
|
| 135 |
+
l1_exclusive = (fa.abs().mean() + fb.abs().mean()) / 2
|
| 136 |
+
total = mse + exclusive_sparsity_coef * l1_exclusive + sparsity_coef * l1_shared
|
| 137 |
+
|
| 138 |
+
return total, mse, l1_shared, l1_exclusive
|
| 139 |
+
|
| 140 |
+
# ββ Diagnostics βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
|
| 142 |
+
@torch.no_grad()
|
| 143 |
+
def verify_partition_integrity(self) -> dict[str, float]:
|
| 144 |
+
"""Max absolute value in weights that should be zero."""
|
| 145 |
+
if self.n_a == 0 and self.n_b == 0:
|
| 146 |
+
return {"enc_max_violation": 0.0, "dec_max_violation": 0.0}
|
| 147 |
+
enc_viol = (self.W_enc.abs() * (1 - self.enc_mask).unsqueeze(1)).max().item()
|
| 148 |
+
dec_viol_a = self.W_dec[: self.a_end, 1, :].abs().max().item() if self.n_a > 0 else 0.0
|
| 149 |
+
dec_viol_b = self.W_dec[self.a_end : self.b_end, 0, :].abs().max().item() if self.n_b > 0 else 0.0
|
| 150 |
+
return {
|
| 151 |
+
"enc_max_violation": enc_viol,
|
| 152 |
+
"dec_max_violation": max(dec_viol_a, dec_viol_b),
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
@torch.no_grad()
|
| 156 |
+
def feature_stats(self, features: torch.Tensor) -> dict[str, float]:
|
| 157 |
+
"""Partition-level activation stats for a batch of features."""
|
| 158 |
+
fa = features[:, : self.a_end]
|
| 159 |
+
fb = features[:, self.a_end : self.b_end]
|
| 160 |
+
fs = features[:, self.b_end :]
|
| 161 |
+
return {
|
| 162 |
+
"l0_total": (features > 0).float().sum(dim=-1).mean().item(),
|
| 163 |
+
"l0_a_excl": (fa > 0).float().sum(dim=-1).mean().item(),
|
| 164 |
+
"l0_b_excl": (fb > 0).float().sum(dim=-1).mean().item(),
|
| 165 |
+
"l0_shared": (fs > 0).float().sum(dim=-1).mean().item(),
|
| 166 |
+
"mean_a_excl": fa.mean().item(),
|
| 167 |
+
"mean_b_excl": fb.mean().item(),
|
| 168 |
+
"mean_shared": fs.mean().item(),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
# ββ Save / Load βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 172 |
+
|
| 173 |
+
def save(self, path: str) -> None:
|
| 174 |
+
"""Save model with disk space checking and error handling."""
|
| 175 |
+
import shutil
|
| 176 |
+
import tempfile
|
| 177 |
+
|
| 178 |
+
# Check available disk space
|
| 179 |
+
free_space = shutil.disk_usage(Path(path).parent or ".").free
|
| 180 |
+
if free_space < 100_000_000: # Less than 100MB
|
| 181 |
+
raise RuntimeError(f"Insufficient disk space: {free_space / 1e9:.2f}GB available. Need at least 0.1GB.")
|
| 182 |
+
|
| 183 |
+
Path(path).mkdir(parents=True, exist_ok=True)
|
| 184 |
+
|
| 185 |
+
# Save to temporary file first, then move to avoid corruption
|
| 186 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix='.pt') as tmp_file:
|
| 187 |
+
try:
|
| 188 |
+
torch.save(self.state_dict(), tmp_file.name)
|
| 189 |
+
shutil.move(tmp_file.name, f"{path}/model.pt")
|
| 190 |
+
except Exception as e:
|
| 191 |
+
# Clean up temp file on error
|
| 192 |
+
if Path(tmp_file.name).exists():
|
| 193 |
+
Path(tmp_file.name).unlink()
|
| 194 |
+
raise RuntimeError(f"Failed to save model: {e}")
|
| 195 |
+
|
| 196 |
+
# Save config
|
| 197 |
+
config_data = dict(
|
| 198 |
+
activation_dim=self.activation_dim,
|
| 199 |
+
dict_size=self.dict_size,
|
| 200 |
+
k=self.k,
|
| 201 |
+
n_a=self.n_a,
|
| 202 |
+
n_b=self.n_b,
|
| 203 |
+
)
|
| 204 |
+
with open(f"{path}/config.json", "w") as f:
|
| 205 |
+
json.dump(config_data, f, indent=2)
|
| 206 |
+
|
| 207 |
+
print(f"[DFC] Saved β {path}")
|
| 208 |
+
|
| 209 |
+
@classmethod
|
| 210 |
+
def load(cls, path: str, device: str = "cpu") -> "DFCCrossCoder":
|
| 211 |
+
cfg = json.load(open(f"{path}/config.json"))
|
| 212 |
+
model = cls(
|
| 213 |
+
activation_dim=cfg["activation_dim"],
|
| 214 |
+
dict_size=cfg["dict_size"],
|
| 215 |
+
k=cfg["k"],
|
| 216 |
+
model_a_exclusive_pct=cfg["n_a"] / cfg["dict_size"],
|
| 217 |
+
model_b_exclusive_pct=cfg["n_b"] / cfg["dict_size"],
|
| 218 |
+
)
|
| 219 |
+
model.load_state_dict(
|
| 220 |
+
torch.load(f"{path}/model.pt", map_location=device, weights_only=True)
|
| 221 |
+
)
|
| 222 |
+
return model.to(device)
|
hparams.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architecture": "DFC",
|
| 3 |
+
"model_a": "chengq9/ToolRL-Qwen2.5-3B",
|
| 4 |
+
"model_b": "Qwen/Qwen2.5-3B",
|
| 5 |
+
"layer": 13,
|
| 6 |
+
"activation_dim": 2048,
|
| 7 |
+
"dict_size": 8192,
|
| 8 |
+
"k": 90,
|
| 9 |
+
"model_a_exclusive_pct": 0.03,
|
| 10 |
+
"model_b_exclusive_pct": 0.03,
|
| 11 |
+
"steps": 9000,
|
| 12 |
+
"lr": 1e-4,
|
| 13 |
+
"train_batch": 1024,
|
| 14 |
+
"sparsity_coef": 1e-3,
|
| 15 |
+
"exclusive_sparsity_coef": 0,
|
| 16 |
+
"loss": "MSE + L1 on shared features only (coef: 1e-3); exclusive features unpenalized",
|
| 17 |
+
"wandb_project": "dfc-crosscoder-sweep",
|
| 18 |
+
"trained_at": "2026-03-30T03:53:46+00:00"
|
| 19 |
+
}
|
model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d64ba3393b94289f071de0bd830b16971303fb52b85c3b75f04f3a81e7cc6e60
|
| 3 |
+
size 268618757
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.0.0
|
| 2 |
+
transformers>=4.30.0
|
| 3 |
+
datasets>=2.0.0
|
| 4 |
+
wandb>=0.15.0
|
| 5 |
+
tqdm>=4.64.0
|
| 6 |
+
matplotlib>=3.5.0
|
| 7 |
+
huggingface_hub>=0.16.0
|
| 8 |
+
numpy>=1.21.0
|