Upload README.md with huggingface_hub
Browse files
README.md
CHANGED
|
@@ -1,487 +1,487 @@
|
|
| 1 |
-
---
|
| 2 |
-
license: other
|
| 3 |
-
license_name: hl3-bds-cl-eco-extr-ffd-media-mil-my-sup-sv-tal-usta-xuar
|
| 4 |
-
license_link: https://firstdonoharm.dev/version/3/0/bds-cl-eco-extr-ffd-media-mil-my-sup-sv-tal-usta-xuar.html
|
| 5 |
-
tags:
|
| 6 |
-
- sparse-autoencoder
|
| 7 |
-
- mechanistic-interpretability
|
| 8 |
-
- biosafety
|
| 9 |
-
- biorefusalaudit
|
| 10 |
-
- gemma
|
| 11 |
-
- gemma4
|
| 12 |
-
- sae
|
| 13 |
-
base_model: google/gemma-4-E2B-it
|
| 14 |
-
datasets:
|
| 15 |
-
- cais/wmdp-corpora
|
| 16 |
-
- SolshineCode/biorefusalaudit-eval-public
|
| 17 |
-
language:
|
| 18 |
-
- en
|
| 19 |
-
---
|
| 20 |
-
|
| 21 |
-
# BioRefusalAudit — Gemma 4 E2B-IT Contrastive Bio-Safety SAE (v1)
|
| 22 |
-
|
| 23 |
-
A TopK Sparse Autoencoder (SAE) fine-tuned on biology-domain residual-stream activations
|
| 24 |
-
from `google/gemma-4-E2B-it` at layer 17, with a mean contrastive objective that pushes
|
| 25 |
-
hazard-adjacent and benign biological feature profiles apart in activation space.
|
| 26 |
-
|
| 27 |
-
Trained on Colab T4 (Tesla T4, 15.6 GB VRAM) as part of the
|
| 28 |
-
[BioRefusalAudit](https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon) project
|
| 29 |
-
(AIxBio Hackathon 2026, Track 3: Biosecurity Tools, sponsored by Fourth Eon Bio).
|
| 30 |
-
|
| 31 |
-
---
|
| 32 |
-
|
| 33 |
-
## Architecture
|
| 34 |
-
|
| 35 |
-
| Parameter | Value |
|
| 36 |
-
|-----------|-------|
|
| 37 |
-
| Type | TopK Sparse Autoencoder |
|
| 38 |
-
| d_model | 1536 (Gemma 4 E2B text hidden size at layer 17) |
|
| 39 |
-
| d_sae | 6144 (4× expansion) |
|
| 40 |
-
| k (sparsity) | 32 active features per token position |
|
| 41 |
-
| Hook layer | Layer 17 (residual stream, post-MLP) |
|
| 42 |
-
| Base model | google/gemma-4-E2B-it |
|
| 43 |
-
| Encoder / Decoder | `nn.Linear` layers with learned biases |
|
| 44 |
-
|
| 45 |
-
**Important:** the encoder and decoder are standard `nn.Linear` modules (not raw `nn.Parameter` matrices). When loading state dicts from earlier drafts or other repos, confirm the key names match (`W_enc.weight`, `W_enc.bias`, `W_dec.weight`, `W_dec.bias`).
|
| 46 |
-
|
| 47 |
-
---
|
| 48 |
-
|
| 49 |
-
## Weights
|
| 50 |
-
|
| 51 |
-
| File | Description |
|
| 52 |
-
|------|-------------|
|
| 53 |
-
| `sae_weights_final.pt` | **Recommended.** Final checkpoint after 2000 steps. |
|
| 54 |
-
| `sae_weights_step_500.pt` | Intermediate — contrastive signal still converging. |
|
| 55 |
-
| `sae_weights_step_1000.pt` | Peak contrastive loss step before reconstruction dominates. |
|
| 56 |
-
| `sae_weights_step_1500.pt` | Intermediate. |
|
| 57 |
-
| `sae_weights_step_2000.pt` | Same as `sae_weights_final.pt`; included for clarity. |
|
| 58 |
-
|
| 59 |
-
---
|
| 60 |
-
|
| 61 |
-
## Training
|
| 62 |
-
|
| 63 |
-
- **Dataset:** `cais/wmdp-corpora` bio-retain-corpus (benign biology, ~5,000 documents)
|
| 64 |
-
+ local BioRefusalAudit 75-prompt eval set (22 hazard-adjacent prompts)
|
| 65 |
-
- **Contrastive objective:** Mean contrastive — cosine similarity between the mean feature
|
| 66 |
-
profile of hazard-adjacent tokens and the mean feature profile of benign tokens.
|
| 67 |
-
`L_contrastive = cos_sim(mean(z_hazard), mean(z_benign))` — minimized so the two groups
|
| 68 |
-
push apart.
|
| 69 |
-
- **Total loss:** `L = L_recon + 0.04 * L_sparsity + 0.1 * L_contrastive`
|
| 70 |
-
- **Steps:** 2,000 — `MAX_STEPS=2000`, `BATCH_SIZE=4`, `LR=3e-4`
|
| 71 |
-
- **Optimizer:** AdamW
|
| 72 |
-
- **Hardware:** Colab Tesla T4 (15.6 GB VRAM), ~35 min wall time
|
| 73 |
-
- **Decoder constraint:** Decoder columns projected back to unit sphere after each step
|
| 74 |
-
(`normalize_decoder()`) and gradient component parallel to decoder columns removed
|
| 75 |
-
(`project_grad()`), following the Anthropic SAE training recipe.
|
| 76 |
-
- **Chat template:** Training prompts wrapped via `tokenizer.apply_chat_template` so the
|
| 77 |
-
RLHF safety circuit activates during collection. Raw text would be out-of-distribution.
|
| 78 |
-
|
| 79 |
-
### Training outcome
|
| 80 |
-
|
| 81 |
-
| Metric | Start | Step 1000 | Final (step 2000) |
|
| 82 |
-
|--------|-------|-----------|-------------------|
|
| 83 |
-
| l_recon | ~3.2 | ~0.8 | **0.557** |
|
| 84 |
-
| l_sparsity | — | — | (tracked) |
|
| 85 |
-
| l_contrastive | ~0.7 | — | **~0** (collapsed) |
|
| 86 |
-
| L0 (mean active) | 32.0 | 32.0 | 32.0 |
|
| 87 |
-
|
| 88 |
-
The contrastive loss collapsed to near-zero by step ~1000–1500. This is a known failure
|
| 89 |
-
mode when the positive/negative corpus is too small for the NT-Xent / cosine-similarity
|
| 90 |
-
objective to maintain separation — the SAE learns to map all inputs to near-identical
|
| 91 |
-
directions, satisfying the reconstruction objective while the contrastive margin vanishes.
|
| 92 |
-
The reconstruction loss (l_recon=0.557) shows the SAE is encoding the residual stream, but
|
| 93 |
-
bio-feature separation is not guaranteed. **Treat the contrastive fine-tuning as a proof of
|
| 94 |
-
concept; use the companion Gemma Scope community SAE for production bio-safety audits until
|
| 95 |
-
a larger corpus run is available.**
|
| 96 |
-
|
| 97 |
-
---
|
| 98 |
-
|
| 99 |
-
## Step-by-step loading
|
| 100 |
-
|
| 101 |
-
### 1. Install dependencies
|
| 102 |
-
|
| 103 |
-
```bash
|
| 104 |
-
pip install torch transformers bitsandbytes accelerate huggingface_hub
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
### 2. Define the TopKSAE class
|
| 108 |
-
|
| 109 |
-
The state dict uses `nn.Linear` key names. You must define the class this way:
|
| 110 |
-
|
| 111 |
-
```python
|
| 112 |
-
import torch
|
| 113 |
-
import torch.nn as nn
|
| 114 |
-
import torch.nn.functional as F
|
| 115 |
-
|
| 116 |
-
class TopKSAE(nn.Module):
|
| 117 |
-
def __init__(self, d_model: int = 1536, d_sae: int = 6144, k: int = 32):
|
| 118 |
-
super().__init__()
|
| 119 |
-
self.k = k
|
| 120 |
-
self.W_enc = nn.Linear(d_model, d_sae, bias=True)
|
| 121 |
-
self.W_dec = nn.Linear(d_sae, d_model, bias=True)
|
| 122 |
-
|
| 123 |
-
def forward(self, x):
|
| 124 |
-
"""
|
| 125 |
-
Args:
|
| 126 |
-
x: (..., d_model) float tensor
|
| 127 |
-
Returns:
|
| 128 |
-
x_hat: reconstruction, same shape as x
|
| 129 |
-
z: sparse feature activations (..., d_sae) — k nonzero per position
|
| 130 |
-
pre: pre-topk encoder output (..., d_sae)
|
| 131 |
-
"""
|
| 132 |
-
pre = self.W_enc(x)
|
| 133 |
-
topk_vals, topk_idx = torch.topk(pre, self.k, dim=-1)
|
| 134 |
-
z = torch.zeros_like(pre)
|
| 135 |
-
z.scatter_(-1, topk_idx, F.relu(topk_vals))
|
| 136 |
-
x_hat = self.W_dec(z)
|
| 137 |
-
return x_hat, z, pre
|
| 138 |
-
|
| 139 |
-
def encode(self, x):
|
| 140 |
-
"""Return only sparse feature vector z."""
|
| 141 |
-
_, z, _ = self.forward(x)
|
| 142 |
-
return z
|
| 143 |
-
```
|
| 144 |
-
|
| 145 |
-
### 3. Download and load the weights
|
| 146 |
-
|
| 147 |
-
```python
|
| 148 |
-
from huggingface_hub import hf_hub_download
|
| 149 |
-
|
| 150 |
-
# Download the final checkpoint
|
| 151 |
-
weights_path = hf_hub_download(
|
| 152 |
-
repo_id="Solshine/gemma4-e2b-bio-sae-v1",
|
| 153 |
-
filename="sae_weights_final.pt",
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
-
sae = TopKSAE(d_model=1536, d_sae=6144, k=32)
|
| 157 |
-
sae.load_state_dict(torch.load(weights_path, map_location="cpu"))
|
| 158 |
-
sae.eval()
|
| 159 |
-
print(f"SAE loaded. Parameters: {sum(p.numel() for p in sae.parameters()):,}")
|
| 160 |
-
# → SAE loaded. Parameters: 18,882,048
|
| 161 |
-
```
|
| 162 |
-
|
| 163 |
-
### 4. Load Gemma 4 E2B-IT with 4-bit quantization
|
| 164 |
-
|
| 165 |
-
Gemma 4 is a multimodal model (`Gemma4ForConditionalGeneration`). The text backbone lives
|
| 166 |
-
at `model.language_model` inside the outer model. Use `device_map={"": 0}` (integer device
|
| 167 |
-
index) — do **not** use `"auto"` or `{"": "cuda"}` (string) with bitsandbytes on Windows;
|
| 168 |
-
both silently route to CPU.
|
| 169 |
-
|
| 170 |
-
```python
|
| 171 |
-
import torch
|
| 172 |
-
from transformers import AutoTokenizer, BitsAndBytesConfig
|
| 173 |
-
|
| 174 |
-
# Try CausalLM first; fall back to the multimodal class if the model type isn't registered
|
| 175 |
-
try:
|
| 176 |
-
from transformers import AutoModelForCausalLM
|
| 177 |
-
model = AutoModelForCausalLM.from_pretrained(
|
| 178 |
-
"google/gemma-4-E2B-it",
|
| 179 |
-
quantization_config=BitsAndBytesConfig(
|
| 180 |
-
load_in_4bit=True,
|
| 181 |
-
bnb_4bit_quant_type="nf4",
|
| 182 |
-
bnb_4bit_use_double_quant=True,
|
| 183 |
-
bnb_4bit_compute_dtype=torch.float16,
|
| 184 |
-
),
|
| 185 |
-
device_map={"": 0}, # integer index — never the string "cuda"
|
| 186 |
-
low_cpu_mem_usage=True,
|
| 187 |
-
)
|
| 188 |
-
except Exception:
|
| 189 |
-
from transformers import AutoModelForImageTextToText
|
| 190 |
-
model = AutoModelForImageTextToText.from_pretrained(
|
| 191 |
-
"google/gemma-4-E2B-it",
|
| 192 |
-
quantization_config=BitsAndBytesConfig(
|
| 193 |
-
load_in_4bit=True,
|
| 194 |
-
bnb_4bit_quant_type="nf4",
|
| 195 |
-
bnb_4bit_use_double_quant=True,
|
| 196 |
-
bnb_4bit_compute_dtype=torch.float16,
|
| 197 |
-
),
|
| 198 |
-
device_map={"": 0},
|
| 199 |
-
low_cpu_mem_usage=True,
|
| 200 |
-
)
|
| 201 |
-
|
| 202 |
-
model.eval()
|
| 203 |
-
tokenizer = AutoTokenizer.from_pretrained("google/gemma-4-E2B-it")
|
| 204 |
-
tokenizer.pad_token = tokenizer.eos_token
|
| 205 |
-
```
|
| 206 |
-
|
| 207 |
-
### 5. Attach the residual-stream hook at layer 17
|
| 208 |
-
|
| 209 |
-
Gemma 4's transformer layers live at `model.language_model.layers` (inside the multimodal
|
| 210 |
-
wrapper). The helper below handles multiple known layout variants:
|
| 211 |
-
|
| 212 |
-
```python
|
| 213 |
-
def get_layer(model, layer_idx: int = 17):
|
| 214 |
-
"""Locate transformer block list across Gemma 2/3/4 text-only & multimodal layouts."""
|
| 215 |
-
for path in (
|
| 216 |
-
"model.language_model", # Gemma 4 ForConditionalGeneration
|
| 217 |
-
"language_model.model", # Gemma 3 ForConditionalGeneration (older)
|
| 218 |
-
"language_model",
|
| 219 |
-
"model",
|
| 220 |
-
"transformer",
|
| 221 |
-
):
|
| 222 |
-
obj = model
|
| 223 |
-
try:
|
| 224 |
-
for attr in path.split("."):
|
| 225 |
-
obj = getattr(obj, attr)
|
| 226 |
-
except AttributeError:
|
| 227 |
-
continue
|
| 228 |
-
if hasattr(obj, "layers"):
|
| 229 |
-
return obj.layers[layer_idx]
|
| 230 |
-
raise AttributeError(f"Could not locate layers in {type(model).__name__}")
|
| 231 |
-
|
| 232 |
-
captured = [None]
|
| 233 |
-
|
| 234 |
-
def hook_fn(module, inputs, outputs):
|
| 235 |
-
# Overwrite (not append) — appending fills VRAM fast during autoregressive generation
|
| 236 |
-
captured[0] = (outputs[0] if isinstance(outputs, tuple) else outputs).detach()
|
| 237 |
-
|
| 238 |
-
handle = get_layer(model, 17).register_forward_hook(hook_fn)
|
| 239 |
-
```
|
| 240 |
-
|
| 241 |
-
### 6. Collect activations and run the SAE
|
| 242 |
-
|
| 243 |
-
```python
|
| 244 |
-
prompt = "Describe the mechanism by which influenza binds to host cells."
|
| 245 |
-
|
| 246 |
-
# Always use the Gemma chat template — raw text is out of distribution for an IT model
|
| 247 |
-
messages = [{"role": "user", "content": prompt}]
|
| 248 |
-
formatted = tokenizer.apply_chat_template(
|
| 249 |
-
messages, tokenize=False, add_generation_prompt=True
|
| 250 |
-
)
|
| 251 |
-
|
| 252 |
-
inputs = tokenizer(formatted, return_tensors="pt", truncation=True, max_length=512).to("cuda")
|
| 253 |
-
|
| 254 |
-
with torch.no_grad():
|
| 255 |
-
_ = model(**inputs) # forward pass fires the hook
|
| 256 |
-
acts = captured[0] # (1, seq_len, 1536)
|
| 257 |
-
|
| 258 |
-
# Run through the SAE (cast to float32 — NF4 activations are fp16)
|
| 259 |
-
x = acts.squeeze(0).float() # (seq_len, 1536)
|
| 260 |
-
with torch.no_grad():
|
| 261 |
-
x_hat, z, pre = sae(x) # z: (seq_len, 6144), 32 nonzero per row
|
| 262 |
-
|
| 263 |
-
# Top-5 most active features (averaged across sequence positions)
|
| 264 |
-
mean_z = z.mean(0) # (6144,)
|
| 265 |
-
top_features = mean_z.topk(5)
|
| 266 |
-
print("Top-5 features (index, mean activation):")
|
| 267 |
-
for idx, val in zip(top_features.indices.tolist(), top_features.values.tolist()):
|
| 268 |
-
print(f" Feature {idx:5d}: {val:.4f}")
|
| 269 |
-
|
| 270 |
-
handle.remove() # clean up hook when done
|
| 271 |
-
```
|
| 272 |
-
|
| 273 |
-
---
|
| 274 |
-
|
| 275 |
-
## Integration with BioRefusalAudit pipeline
|
| 276 |
-
|
| 277 |
-
If you are using the full BioRefusalAudit CLI, pass this SAE via:
|
| 278 |
-
|
| 279 |
-
```bash
|
| 280 |
-
python -m biorefusalaudit.cli run \
|
| 281 |
-
--model google/gemma-4-E2B-it \
|
| 282 |
-
--eval-set data/eval_set_public/eval_set_public_v1.jsonl \
|
| 283 |
-
--out runs/gemma4-oursae-v1 \
|
| 284 |
-
--sae-source custom \
|
| 285 |
-
--sae-release Solshine/gemma4-e2b-bio-sae-v1 \
|
| 286 |
-
--k 32 \
|
| 287 |
-
--d-model 1536 \
|
| 288 |
-
--d-sae 6144 \
|
| 289 |
-
--architecture topk \
|
| 290 |
-
--layer 17 \
|
| 291 |
-
--quantize 4bit \
|
| 292 |
-
--no-llm-judges \
|
| 293 |
-
--max-new-tokens 80 \
|
| 294 |
-
--dump-activations
|
| 295 |
-
```
|
| 296 |
-
|
| 297 |
-
The `--sae-source custom` path in `sae_adapter.py` will:
|
| 298 |
-
1. Detect that `Solshine/gemma4-e2b-bio-sae-v1` is an HF repo ID (contains `/`, is not a local path)
|
| 299 |
-
2. Try `sae_weights.pt` → `sae_weights.safetensors` → scan repo for any `.pt`/`.safetensors`
|
| 300 |
-
3. Download `sae_weights_final.pt` (alphabetically first `.pt` in the repo) via `hf_hub_download`
|
| 301 |
-
4. Load into a `TopKSAE(d_model=1536, d_sae=6144, k=32)` instance
|
| 302 |
-
|
| 303 |
-
For the full pass-1 → auto-tune catalog → pass-2 → fit-T pipeline, use the convenience script:
|
| 304 |
-
|
| 305 |
-
```bash
|
| 306 |
-
bash scripts/run_gemma4_oursae_pipeline.sh 2>&1 | tee runs/gemma4-oursae-pipeline.log
|
| 307 |
-
```
|
| 308 |
-
|
| 309 |
-
---
|
| 310 |
-
|
| 311 |
-
## Evaluation Results
|
| 312 |
-
|
| 313 |
-
Results from running this SAE through the full BioRefusalAudit pipeline on the 75-prompt
|
| 314 |
-
public evaluation set (`eval_set_public_v1.jsonl`), covering benign biology, dual-use
|
| 315 |
-
biology, and hazard-adjacent prompts across four framings (direct, educational, roleplay,
|
| 316 |
-
obfuscated). Model: `google/gemma-4-E2B-it`, 4-bit NF4, 80-token budget.
|
| 317 |
-
|
| 318 |
-
### Feature activation (pass1, no catalog)
|
| 319 |
-
|
| 320 |
-
1285 out of 1500 bio_content feature checks fired across all 75 prompts, with mean activation
|
| 321 |
-
14.71 on active tokens. The WMDP-trained features generalize to the BioRefusalAudit eval set.
|
| 322 |
-
|
| 323 |
-
### Auto-tuned catalog (Cohen's d, post-pass1)
|
| 324 |
-
|
| 325 |
-
`scripts/auto_tune_catalog.py` selects the top-20 features per category by Cohen's d
|
| 326 |
-
between hazard-adjacent and benign activation profiles:
|
| 327 |
-
|
| 328 |
-
| Category | Top Cohen's d | Features selected |
|
| 329 |
-
|---|---:|---:|
|
| 330 |
-
| bio_content | 1.81 | 20 |
|
| 331 |
-
| hazard_adjacent | 1.60 | 20 |
|
| 332 |
-
| refusal_circuitry | 1.52 | 20 |
|
| 333 |
-
|
| 334 |
-
33 unique features total (some features appear in multiple categories).
|
| 335 |
-
Catalog: `data/feature_catalog/gemma-4-e2b-our-sae-v1.json`.
|
| 336 |
-
|
| 337 |
-
### Divergence metric results (pass2, 80-tok)
|
| 338 |
-
|
| 339 |
-
**Calibration note — important.** The BioRefusalAudit pipeline fits calibration matrix T
|
| 340 |
-
from pass1 activations. When pass1 runs without a catalog (as it must to bootstrap the
|
| 341 |
-
catalog), all feature vectors are zero → `fit_calibration.py` produces T=0 → D=1.000 for
|
| 342 |
-
every prompt regardless of actual SAE activations. This is a known pipeline ordering issue,
|
| 343 |
-
now guarded in `biorefusalaudit/scoring/divergence.py` (falls back to T_prior when fitted T
|
| 344 |
-
is degenerate). Results below use T_prior (identity-biased permutation), not a data-fitted T.
|
| 345 |
-
T_prior produces higher absolute D than a fitted T would.
|
| 346 |
-
|
| 347 |
-
| Tier | n | Mean D | Std | comply | refuse | hedge |
|
| 348 |
-
|---|---:|---:|---:|---:|---:|---:|
|
| 349 |
-
| `benign_bio` | 23 | 0.614 | 0.321 | 57% | 43% | 0% |
|
| 350 |
-
| `dual_use_bio` | 30 | 0.703 | 0.295 | 70% | 30% | 0% |
|
| 351 |
-
| `hazard_adjacent` | 22 | 0.647 | 0.306 | 59% | 36% | 5% |
|
| 352 |
-
| **overall** | **75** | **0.659** | **0.309** | | | |
|
| 353 |
-
|
| 354 |
-
**Comparison to Gemma Scope community SAE** (Gemma 2 2B-IT, fitted T, 80-tok — different
|
| 355 |
-
model, different calibration, not directly comparable):
|
| 356 |
-
|
| 357 |
-
| Tier | Gemma Scope baseline | This SAE (T_prior) |
|
| 358 |
-
|---|---:|---:|
|
| 359 |
-
| `benign_bio` | 0.362 | 0.614 |
|
| 360 |
-
| `dual_use_bio` | 0.406 | 0.703 |
|
| 361 |
-
| `hazard_adjacent` | 0.404 | 0.647 |
|
| 362 |
-
|
| 363 |
-
The delta reflects T miscalibration and the model difference (Gemma 4 vs. Gemma 2), not a
|
| 364 |
-
meaningful SAE quality gap. A properly fitted T from a behavioral corpus is needed to
|
| 365 |
-
interpret absolute D magnitudes.
|
| 366 |
-
|
| 367 |
-
**Surface label finding.** `benign_bio` refuses at 43% — the over-refusal pattern documented
|
| 368 |
-
in BioRefusalAudit §4.6: Gemma 4 E2B's safety circuit fires on biosecurity-adjacent content
|
| 369 |
-
regardless of hazard tier. This SAE confirms the same pattern as the Gemma Scope community
|
| 370 |
-
SAE baseline.
|
| 371 |
-
|
| 372 |
-
---
|
| 373 |
-
|
| 374 |
-
## Training your own bio-safety SAE
|
| 375 |
-
|
| 376 |
-
The training notebook is at
|
| 377 |
-
[notebooks/colab_gemma4_sae_training.ipynb](https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon/blob/main/notebooks/colab_gemma4_sae_training.ipynb).
|
| 378 |
-
It runs end-to-end on a free Colab T4 in ~35 minutes.
|
| 379 |
-
|
| 380 |
-
**Quick start:**
|
| 381 |
-
1. Open the notebook in Google Colab (Runtime → Change runtime type → T4 GPU)
|
| 382 |
-
2. Add `HF_TOKEN` (write scope) and `WANDB_API_KEY` to Colab Secrets (🔑 icon)
|
| 383 |
-
3. Run All — the notebook will:
|
| 384 |
-
- Install `transformers` from source (Gemma 4 requires the latest main branch)
|
| 385 |
-
- Load `google/gemma-4-E2B-it` in NF4 4-bit quantization
|
| 386 |
-
- Stream training data from `cais/wmdp-corpora` (bio-retain-corpus, public)
|
| 387 |
-
- Train 2,000 steps with reconstruction + sparsity + mean contrastive loss
|
| 388 |
-
- Upload final checkpoint to your HF account as `<your-username>/gemma4-e2b-bio-sae-v1`
|
| 389 |
-
|
| 390 |
-
**Key implementation details that make it work on Colab:**
|
| 391 |
-
|
| 392 |
-
| Problem | Fix |
|
| 393 |
-
|---------|-----|
|
| 394 |
-
| Gemma 4 multimodal layer path | `pick_layer()` with 5-path fallback + `named_modules()` slow-path scan |
|
| 395 |
-
| Decoder collapse (all features becoming equal) | `normalize_decoder()` + `project_grad()` each step |
|
| 396 |
-
| OOD inputs from raw corpus text | Wrap all prompts with `tokenizer.apply_chat_template` |
|
| 397 |
-
| VRAM fill during generation | Hook overwrites `captured[0]` instead of appending to a list |
|
| 398 |
-
| Contrastive loss instability | Mean contrastive (cosine sim of mean profiles) instead of NT-Xent |
|
| 399 |
-
|
| 400 |
-
---
|
| 401 |
-
|
| 402 |
-
## Caveats
|
| 403 |
-
|
| 404 |
-
- **Contrastive collapse.** The contrastive loss reached ~0 by step ~1500. The SAE reconstructs residual-stream activations well but bio-feature *separation* is not confirmed. Verification requires running `auto_tune_catalog.py` and checking Cohen's d per category against the Gemma Scope baseline.
|
| 405 |
-
- **Small corpus (v1).** Training used ~5,000 WMDP documents (benign) + 22 hazard-adjacent prompts. Too few hazard-adjacent examples to sustain the contrastive margin. This is the binding constraint — not compute, not architecture. **Fixed in v2** (see below).
|
| 406 |
-
- **2000-step limit (v1).** Capped at 2000 steps; L_contrastive collapsed by step 1000. Final checkpoint reconstructs well but bio-feature separation is not confirmed. **Fixed in v2:** 5000 steps with real hazard corpus.
|
| 407 |
-
- **No Neuronpedia validation.** Individual feature interpretability is unverified.
|
| 408 |
-
- **4× expansion.** d_sae/d_model = 4.0, below Gemma Scope's 8×. Wider SAEs likely capture more bio-specific features.
|
| 409 |
-
- **Gemma 4 multimodal wrapper.** Hook path is `model.language_model.layers[17]` — **not** `model.model.layers[17]` (Gemma 3 path). The `get_layer()` helper above handles this automatically.
|
| 410 |
-
|
| 411 |
-
### v2 training run (in progress)
|
| 412 |
-
|
| 413 |
-
Access to `cais/wmdp-bio-forget-corpus` was granted on 2026-04-26. The v2 notebook
|
| 414 |
-
(`notebooks/colab_gemma4_sae_training.ipynb`) now loads 5,000 papers from that corpus as
|
| 415 |
-
the hazard-adjacent class, balanced against 5,000 benign documents from the retain corpus,
|
| 416 |
-
for 5,000 training steps. This directly addresses the corpus-size bottleneck. Results will
|
| 417 |
-
be published as `Solshine/gemma4-e2b-bio-sae-v2` on completion.
|
| 418 |
-
|
| 419 |
-
### What would further improve this SAE
|
| 420 |
-
|
| 421 |
-
The corpus-size problem is now addressed for the primary bottleneck. Remaining priorities, in
|
| 422 |
-
order of impact:
|
| 423 |
-
|
| 424 |
-
1. **More hazard-adjacent examples (partially addressed in v2).** 22 prompts is not enough to anchor a stable contrastive
|
| 425 |
-
direction. 500–1000 genuine hazard-adjacent activation examples (from actual model
|
| 426 |
-
responses, not just prompts) would likely sustain the contrastive margin through training.
|
| 427 |
-
This requires access to institutional CBRN datasets — the kind held by organizations like
|
| 428 |
-
Gryphon Scientific, NTI Bio, Johns Hopkins Center for Health Security, or government
|
| 429 |
-
biosecurity agencies. We are actively seeking partnerships with these organizations and
|
| 430 |
-
would welcome introductions from anyone in that space.
|
| 431 |
-
|
| 432 |
-
2. **A proper base-vs-RLHF activation corpus.** Following the methodology of Secret Agenda
|
| 433 |
-
(arXiv:2509.20393): collect residual-stream activations from the base model and the
|
| 434 |
-
instruction-tuned model on identical prompts, then train the SAE to separate "what the
|
| 435 |
-
safety fine-tune changed" from "what was already there." This is a data-collection problem
|
| 436 |
-
that requires running both model variants on the same hardware at scale.
|
| 437 |
-
|
| 438 |
-
3. **More compute for training.** A full SAE fine-tune at Anthropic/EleutherAI scale (100K+
|
| 439 |
-
steps, A100 or H100) would not help if the corpus is still 22 hazard-adjacent prompts —
|
| 440 |
-
the gradient signal simply isn't there. But a 10K-step run on a properly sized corpus
|
| 441 |
-
(~10K hazard-adjacent samples) would be a reasonable next experiment and is feasible on a
|
| 442 |
-
single A100 in a few hours. If you have access to institutional compute or CBRN datasets
|
| 443 |
-
and want to run this experiment, please open an issue on the
|
| 444 |
-
[BioRefusalAudit repo](https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon) or
|
| 445 |
-
reach out directly.
|
| 446 |
-
|
| 447 |
-
4. **Wider SAE.** 8× or 16× expansion (d_sae = 12288 or 24576) with a larger k would give
|
| 448 |
-
more features to specialize. This is a secondary bottleneck behind corpus size.
|
| 449 |
-
|
| 450 |
-
---
|
| 451 |
-
|
| 452 |
-
## Cross-Architecture Context
|
| 453 |
-
|
| 454 |
-
This SAE targets Gemma 4 E2B-IT (2B parameters, multimodal, released April 2025). Compared
|
| 455 |
-
to the Gemma 2 2B SAE in the same pipeline (`runs/sae-training-gemma2-5000steps/`):
|
| 456 |
-
|
| 457 |
-
| | Gemma 4 E2B | Gemma 2 2B |
|
| 458 |
-
|---|---|---|
|
| 459 |
-
| d_model | 1536 | 2304 |
|
| 460 |
-
| Hook layer | 17 | 12 |
|
| 461 |
-
| Hook path | `model.language_model.layers` | `model.layers` |
|
| 462 |
-
| SAE size | ~19 MB | ~28 MB |
|
| 463 |
-
| Training steps | 2,000 (Colab T4) | 5,000 (local GPU) |
|
| 464 |
-
|
| 465 |
-
---
|
| 466 |
-
|
| 467 |
-
## Citation
|
| 468 |
-
|
| 469 |
-
```bibtex
|
| 470 |
-
@misc{deleeuw2026biorefusalaudit,
|
| 471 |
-
title = {BioRefusalAudit: Measuring Refusal Depth in LLMs
|
| 472 |
-
via SAE Feature Divergence},
|
| 473 |
-
author = {DeLeeuw, Caleb},
|
| 474 |
-
year = {2026},
|
| 475 |
-
howpublished = {AIxBio Hackathon 2026, Track 3: Biosecurity Tools},
|
| 476 |
-
url = {https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon}
|
| 477 |
-
}
|
| 478 |
-
```
|
| 479 |
-
|
| 480 |
-
---
|
| 481 |
-
|
| 482 |
-
## License
|
| 483 |
-
|
| 484 |
-
Code and weights released under the
|
| 485 |
-
[Hippocratic License 3.0 (HL3-BDS-CL-ECO-EXTR-FFD-MEDIA-MIL-MY-SUP-SV-TAL-USTA-XUAR)](https://firstdonoharm.dev/version/3/0/bds-cl-eco-extr-ffd-media-mil-my-sup-sv-tal-usta-xuar.html).
|
| 486 |
-
You may use these weights for biosecurity research, AI safety research, and defensive
|
| 487 |
-
interpretability work. You may not use them to facilitate harm.
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: other
|
| 3 |
+
license_name: hl3-bds-cl-eco-extr-ffd-media-mil-my-sup-sv-tal-usta-xuar
|
| 4 |
+
license_link: https://firstdonoharm.dev/version/3/0/bds-cl-eco-extr-ffd-media-mil-my-sup-sv-tal-usta-xuar.html
|
| 5 |
+
tags:
|
| 6 |
+
- sparse-autoencoder
|
| 7 |
+
- mechanistic-interpretability
|
| 8 |
+
- biosafety
|
| 9 |
+
- biorefusalaudit
|
| 10 |
+
- gemma
|
| 11 |
+
- gemma4
|
| 12 |
+
- sae
|
| 13 |
+
base_model: google/gemma-4-E2B-it
|
| 14 |
+
datasets:
|
| 15 |
+
- cais/wmdp-corpora
|
| 16 |
+
- SolshineCode/biorefusalaudit-eval-public
|
| 17 |
+
language:
|
| 18 |
+
- en
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
# BioRefusalAudit — Gemma 4 E2B-IT Contrastive Bio-Safety SAE (v1)
|
| 22 |
+
|
| 23 |
+
A TopK Sparse Autoencoder (SAE) fine-tuned on biology-domain residual-stream activations
|
| 24 |
+
from `google/gemma-4-E2B-it` at layer 17, with a mean contrastive objective that pushes
|
| 25 |
+
hazard-adjacent and benign biological feature profiles apart in activation space.
|
| 26 |
+
|
| 27 |
+
Trained on Colab T4 (Tesla T4, 15.6 GB VRAM) as part of the
|
| 28 |
+
[BioRefusalAudit](https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon) project
|
| 29 |
+
(AIxBio Hackathon 2026, Track 3: Biosecurity Tools, sponsored by Fourth Eon Bio).
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## Architecture
|
| 34 |
+
|
| 35 |
+
| Parameter | Value |
|
| 36 |
+
|-----------|-------|
|
| 37 |
+
| Type | TopK Sparse Autoencoder |
|
| 38 |
+
| d_model | 1536 (Gemma 4 E2B text hidden size at layer 17) |
|
| 39 |
+
| d_sae | 6144 (4× expansion) |
|
| 40 |
+
| k (sparsity) | 32 active features per token position |
|
| 41 |
+
| Hook layer | Layer 17 (residual stream, post-MLP) |
|
| 42 |
+
| Base model | google/gemma-4-E2B-it |
|
| 43 |
+
| Encoder / Decoder | `nn.Linear` layers with learned biases |
|
| 44 |
+
|
| 45 |
+
**Important:** the encoder and decoder are standard `nn.Linear` modules (not raw `nn.Parameter` matrices). When loading state dicts from earlier drafts or other repos, confirm the key names match (`W_enc.weight`, `W_enc.bias`, `W_dec.weight`, `W_dec.bias`).
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## Weights
|
| 50 |
+
|
| 51 |
+
| File | Description |
|
| 52 |
+
|------|-------------|
|
| 53 |
+
| `sae_weights_final.pt` | **Recommended.** Final checkpoint after 2000 steps. |
|
| 54 |
+
| `sae_weights_step_500.pt` | Intermediate — contrastive signal still converging. |
|
| 55 |
+
| `sae_weights_step_1000.pt` | Peak contrastive loss step before reconstruction dominates. |
|
| 56 |
+
| `sae_weights_step_1500.pt` | Intermediate. |
|
| 57 |
+
| `sae_weights_step_2000.pt` | Same as `sae_weights_final.pt`; included for clarity. |
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## Training
|
| 62 |
+
|
| 63 |
+
- **Dataset:** `cais/wmdp-corpora` bio-retain-corpus (benign biology, ~5,000 documents)
|
| 64 |
+
+ local BioRefusalAudit 75-prompt eval set (22 hazard-adjacent prompts)
|
| 65 |
+
- **Contrastive objective:** Mean contrastive — cosine similarity between the mean feature
|
| 66 |
+
profile of hazard-adjacent tokens and the mean feature profile of benign tokens.
|
| 67 |
+
`L_contrastive = cos_sim(mean(z_hazard), mean(z_benign))` — minimized so the two groups
|
| 68 |
+
push apart.
|
| 69 |
+
- **Total loss:** `L = L_recon + 0.04 * L_sparsity + 0.1 * L_contrastive`
|
| 70 |
+
- **Steps:** 2,000 — `MAX_STEPS=2000`, `BATCH_SIZE=4`, `LR=3e-4`
|
| 71 |
+
- **Optimizer:** AdamW
|
| 72 |
+
- **Hardware:** Colab Tesla T4 (15.6 GB VRAM), ~35 min wall time
|
| 73 |
+
- **Decoder constraint:** Decoder columns projected back to unit sphere after each step
|
| 74 |
+
(`normalize_decoder()`) and gradient component parallel to decoder columns removed
|
| 75 |
+
(`project_grad()`), following the Anthropic SAE training recipe.
|
| 76 |
+
- **Chat template:** Training prompts wrapped via `tokenizer.apply_chat_template` so the
|
| 77 |
+
RLHF safety circuit activates during collection. Raw text would be out-of-distribution.
|
| 78 |
+
|
| 79 |
+
### Training outcome
|
| 80 |
+
|
| 81 |
+
| Metric | Start | Step 1000 | Final (step 2000) |
|
| 82 |
+
|--------|-------|-----------|-------------------|
|
| 83 |
+
| l_recon | ~3.2 | ~0.8 | **0.557** |
|
| 84 |
+
| l_sparsity | — | — | (tracked) |
|
| 85 |
+
| l_contrastive | ~0.7 | — | **~0** (collapsed) |
|
| 86 |
+
| L0 (mean active) | 32.0 | 32.0 | 32.0 |
|
| 87 |
+
|
| 88 |
+
The contrastive loss collapsed to near-zero by step ~1000–1500. This is a known failure
|
| 89 |
+
mode when the positive/negative corpus is too small for the NT-Xent / cosine-similarity
|
| 90 |
+
objective to maintain separation — the SAE learns to map all inputs to near-identical
|
| 91 |
+
directions, satisfying the reconstruction objective while the contrastive margin vanishes.
|
| 92 |
+
The reconstruction loss (l_recon=0.557) shows the SAE is encoding the residual stream, but
|
| 93 |
+
bio-feature separation is not guaranteed. **Treat the contrastive fine-tuning as a proof of
|
| 94 |
+
concept; use the companion Gemma Scope community SAE for production bio-safety audits until
|
| 95 |
+
a larger corpus run is available.**
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## Step-by-step loading
|
| 100 |
+
|
| 101 |
+
### 1. Install dependencies
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
pip install torch transformers bitsandbytes accelerate huggingface_hub
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### 2. Define the TopKSAE class
|
| 108 |
+
|
| 109 |
+
The state dict uses `nn.Linear` key names. You must define the class this way:
|
| 110 |
+
|
| 111 |
+
```python
|
| 112 |
+
import torch
|
| 113 |
+
import torch.nn as nn
|
| 114 |
+
import torch.nn.functional as F
|
| 115 |
+
|
| 116 |
+
class TopKSAE(nn.Module):
|
| 117 |
+
def __init__(self, d_model: int = 1536, d_sae: int = 6144, k: int = 32):
|
| 118 |
+
super().__init__()
|
| 119 |
+
self.k = k
|
| 120 |
+
self.W_enc = nn.Linear(d_model, d_sae, bias=True)
|
| 121 |
+
self.W_dec = nn.Linear(d_sae, d_model, bias=True)
|
| 122 |
+
|
| 123 |
+
def forward(self, x):
|
| 124 |
+
"""
|
| 125 |
+
Args:
|
| 126 |
+
x: (..., d_model) float tensor
|
| 127 |
+
Returns:
|
| 128 |
+
x_hat: reconstruction, same shape as x
|
| 129 |
+
z: sparse feature activations (..., d_sae) — k nonzero per position
|
| 130 |
+
pre: pre-topk encoder output (..., d_sae)
|
| 131 |
+
"""
|
| 132 |
+
pre = self.W_enc(x)
|
| 133 |
+
topk_vals, topk_idx = torch.topk(pre, self.k, dim=-1)
|
| 134 |
+
z = torch.zeros_like(pre)
|
| 135 |
+
z.scatter_(-1, topk_idx, F.relu(topk_vals))
|
| 136 |
+
x_hat = self.W_dec(z)
|
| 137 |
+
return x_hat, z, pre
|
| 138 |
+
|
| 139 |
+
def encode(self, x):
|
| 140 |
+
"""Return only sparse feature vector z."""
|
| 141 |
+
_, z, _ = self.forward(x)
|
| 142 |
+
return z
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
### 3. Download and load the weights
|
| 146 |
+
|
| 147 |
+
```python
|
| 148 |
+
from huggingface_hub import hf_hub_download
|
| 149 |
+
|
| 150 |
+
# Download the final checkpoint
|
| 151 |
+
weights_path = hf_hub_download(
|
| 152 |
+
repo_id="Solshine/gemma4-e2b-bio-sae-v1",
|
| 153 |
+
filename="sae_weights_final.pt",
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
sae = TopKSAE(d_model=1536, d_sae=6144, k=32)
|
| 157 |
+
sae.load_state_dict(torch.load(weights_path, map_location="cpu"))
|
| 158 |
+
sae.eval()
|
| 159 |
+
print(f"SAE loaded. Parameters: {sum(p.numel() for p in sae.parameters()):,}")
|
| 160 |
+
# → SAE loaded. Parameters: 18,882,048
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
### 4. Load Gemma 4 E2B-IT with 4-bit quantization
|
| 164 |
+
|
| 165 |
+
Gemma 4 is a multimodal model (`Gemma4ForConditionalGeneration`). The text backbone lives
|
| 166 |
+
at `model.language_model` inside the outer model. Use `device_map={"": 0}` (integer device
|
| 167 |
+
index) — do **not** use `"auto"` or `{"": "cuda"}` (string) with bitsandbytes on Windows;
|
| 168 |
+
both silently route to CPU.
|
| 169 |
+
|
| 170 |
+
```python
|
| 171 |
+
import torch
|
| 172 |
+
from transformers import AutoTokenizer, BitsAndBytesConfig
|
| 173 |
+
|
| 174 |
+
# Try CausalLM first; fall back to the multimodal class if the model type isn't registered
|
| 175 |
+
try:
|
| 176 |
+
from transformers import AutoModelForCausalLM
|
| 177 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 178 |
+
"google/gemma-4-E2B-it",
|
| 179 |
+
quantization_config=BitsAndBytesConfig(
|
| 180 |
+
load_in_4bit=True,
|
| 181 |
+
bnb_4bit_quant_type="nf4",
|
| 182 |
+
bnb_4bit_use_double_quant=True,
|
| 183 |
+
bnb_4bit_compute_dtype=torch.float16,
|
| 184 |
+
),
|
| 185 |
+
device_map={"": 0}, # integer index — never the string "cuda"
|
| 186 |
+
low_cpu_mem_usage=True,
|
| 187 |
+
)
|
| 188 |
+
except Exception:
|
| 189 |
+
from transformers import AutoModelForImageTextToText
|
| 190 |
+
model = AutoModelForImageTextToText.from_pretrained(
|
| 191 |
+
"google/gemma-4-E2B-it",
|
| 192 |
+
quantization_config=BitsAndBytesConfig(
|
| 193 |
+
load_in_4bit=True,
|
| 194 |
+
bnb_4bit_quant_type="nf4",
|
| 195 |
+
bnb_4bit_use_double_quant=True,
|
| 196 |
+
bnb_4bit_compute_dtype=torch.float16,
|
| 197 |
+
),
|
| 198 |
+
device_map={"": 0},
|
| 199 |
+
low_cpu_mem_usage=True,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
model.eval()
|
| 203 |
+
tokenizer = AutoTokenizer.from_pretrained("google/gemma-4-E2B-it")
|
| 204 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
### 5. Attach the residual-stream hook at layer 17
|
| 208 |
+
|
| 209 |
+
Gemma 4's transformer layers live at `model.language_model.layers` (inside the multimodal
|
| 210 |
+
wrapper). The helper below handles multiple known layout variants:
|
| 211 |
+
|
| 212 |
+
```python
|
| 213 |
+
def get_layer(model, layer_idx: int = 17):
|
| 214 |
+
"""Locate transformer block list across Gemma 2/3/4 text-only & multimodal layouts."""
|
| 215 |
+
for path in (
|
| 216 |
+
"model.language_model", # Gemma 4 ForConditionalGeneration
|
| 217 |
+
"language_model.model", # Gemma 3 ForConditionalGeneration (older)
|
| 218 |
+
"language_model",
|
| 219 |
+
"model",
|
| 220 |
+
"transformer",
|
| 221 |
+
):
|
| 222 |
+
obj = model
|
| 223 |
+
try:
|
| 224 |
+
for attr in path.split("."):
|
| 225 |
+
obj = getattr(obj, attr)
|
| 226 |
+
except AttributeError:
|
| 227 |
+
continue
|
| 228 |
+
if hasattr(obj, "layers"):
|
| 229 |
+
return obj.layers[layer_idx]
|
| 230 |
+
raise AttributeError(f"Could not locate layers in {type(model).__name__}")
|
| 231 |
+
|
| 232 |
+
captured = [None]
|
| 233 |
+
|
| 234 |
+
def hook_fn(module, inputs, outputs):
|
| 235 |
+
# Overwrite (not append) — appending fills VRAM fast during autoregressive generation
|
| 236 |
+
captured[0] = (outputs[0] if isinstance(outputs, tuple) else outputs).detach()
|
| 237 |
+
|
| 238 |
+
handle = get_layer(model, 17).register_forward_hook(hook_fn)
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
### 6. Collect activations and run the SAE
|
| 242 |
+
|
| 243 |
+
```python
|
| 244 |
+
prompt = "Describe the mechanism by which influenza binds to host cells."
|
| 245 |
+
|
| 246 |
+
# Always use the Gemma chat template — raw text is out of distribution for an IT model
|
| 247 |
+
messages = [{"role": "user", "content": prompt}]
|
| 248 |
+
formatted = tokenizer.apply_chat_template(
|
| 249 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
inputs = tokenizer(formatted, return_tensors="pt", truncation=True, max_length=512).to("cuda")
|
| 253 |
+
|
| 254 |
+
with torch.no_grad():
|
| 255 |
+
_ = model(**inputs) # forward pass fires the hook
|
| 256 |
+
acts = captured[0] # (1, seq_len, 1536)
|
| 257 |
+
|
| 258 |
+
# Run through the SAE (cast to float32 — NF4 activations are fp16)
|
| 259 |
+
x = acts.squeeze(0).float() # (seq_len, 1536)
|
| 260 |
+
with torch.no_grad():
|
| 261 |
+
x_hat, z, pre = sae(x) # z: (seq_len, 6144), 32 nonzero per row
|
| 262 |
+
|
| 263 |
+
# Top-5 most active features (averaged across sequence positions)
|
| 264 |
+
mean_z = z.mean(0) # (6144,)
|
| 265 |
+
top_features = mean_z.topk(5)
|
| 266 |
+
print("Top-5 features (index, mean activation):")
|
| 267 |
+
for idx, val in zip(top_features.indices.tolist(), top_features.values.tolist()):
|
| 268 |
+
print(f" Feature {idx:5d}: {val:.4f}")
|
| 269 |
+
|
| 270 |
+
handle.remove() # clean up hook when done
|
| 271 |
+
```
|
| 272 |
+
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
## Integration with BioRefusalAudit pipeline
|
| 276 |
+
|
| 277 |
+
If you are using the full BioRefusalAudit CLI, pass this SAE via:
|
| 278 |
+
|
| 279 |
+
```bash
|
| 280 |
+
python -m biorefusalaudit.cli run \
|
| 281 |
+
--model google/gemma-4-E2B-it \
|
| 282 |
+
--eval-set data/eval_set_public/eval_set_public_v1.jsonl \
|
| 283 |
+
--out runs/gemma4-oursae-v1 \
|
| 284 |
+
--sae-source custom \
|
| 285 |
+
--sae-release Solshine/gemma4-e2b-bio-sae-v1 \
|
| 286 |
+
--k 32 \
|
| 287 |
+
--d-model 1536 \
|
| 288 |
+
--d-sae 6144 \
|
| 289 |
+
--architecture topk \
|
| 290 |
+
--layer 17 \
|
| 291 |
+
--quantize 4bit \
|
| 292 |
+
--no-llm-judges \
|
| 293 |
+
--max-new-tokens 80 \
|
| 294 |
+
--dump-activations
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
The `--sae-source custom` path in `sae_adapter.py` will:
|
| 298 |
+
1. Detect that `Solshine/gemma4-e2b-bio-sae-v1` is an HF repo ID (contains `/`, is not a local path)
|
| 299 |
+
2. Try `sae_weights.pt` → `sae_weights.safetensors` → scan repo for any `.pt`/`.safetensors`
|
| 300 |
+
3. Download `sae_weights_final.pt` (alphabetically first `.pt` in the repo) via `hf_hub_download`
|
| 301 |
+
4. Load into a `TopKSAE(d_model=1536, d_sae=6144, k=32)` instance
|
| 302 |
+
|
| 303 |
+
For the full pass-1 → auto-tune catalog → pass-2 → fit-T pipeline, use the convenience script:
|
| 304 |
+
|
| 305 |
+
```bash
|
| 306 |
+
bash scripts/run_gemma4_oursae_pipeline.sh 2>&1 | tee runs/gemma4-oursae-pipeline.log
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
## Evaluation Results
|
| 312 |
+
|
| 313 |
+
Results from running this SAE through the full BioRefusalAudit pipeline on the 75-prompt
|
| 314 |
+
public evaluation set (`eval_set_public_v1.jsonl`), covering benign biology, dual-use
|
| 315 |
+
biology, and hazard-adjacent prompts across four framings (direct, educational, roleplay,
|
| 316 |
+
obfuscated). Model: `google/gemma-4-E2B-it`, 4-bit NF4, 80-token budget.
|
| 317 |
+
|
| 318 |
+
### Feature activation (pass1, no catalog)
|
| 319 |
+
|
| 320 |
+
1285 out of 1500 bio_content feature checks fired across all 75 prompts, with mean activation
|
| 321 |
+
14.71 on active tokens. The WMDP-trained features generalize to the BioRefusalAudit eval set.
|
| 322 |
+
|
| 323 |
+
### Auto-tuned catalog (Cohen's d, post-pass1)
|
| 324 |
+
|
| 325 |
+
`scripts/auto_tune_catalog.py` selects the top-20 features per category by Cohen's d
|
| 326 |
+
between hazard-adjacent and benign activation profiles:
|
| 327 |
+
|
| 328 |
+
| Category | Top Cohen's d | Features selected |
|
| 329 |
+
|---|---:|---:|
|
| 330 |
+
| bio_content | 1.81 | 20 |
|
| 331 |
+
| hazard_adjacent | 1.60 | 20 |
|
| 332 |
+
| refusal_circuitry | 1.52 | 20 |
|
| 333 |
+
|
| 334 |
+
33 unique features total (some features appear in multiple categories).
|
| 335 |
+
Catalog: `data/feature_catalog/gemma-4-e2b-our-sae-v1.json`.
|
| 336 |
+
|
| 337 |
+
### Divergence metric results (pass2, 80-tok)
|
| 338 |
+
|
| 339 |
+
**Calibration note — important.** The BioRefusalAudit pipeline fits calibration matrix T
|
| 340 |
+
from pass1 activations. When pass1 runs without a catalog (as it must to bootstrap the
|
| 341 |
+
catalog), all feature vectors are zero → `fit_calibration.py` produces T=0 → D=1.000 for
|
| 342 |
+
every prompt regardless of actual SAE activations. This is a known pipeline ordering issue,
|
| 343 |
+
now guarded in `biorefusalaudit/scoring/divergence.py` (falls back to T_prior when fitted T
|
| 344 |
+
is degenerate). Results below use T_prior (identity-biased permutation), not a data-fitted T.
|
| 345 |
+
T_prior produces higher absolute D than a fitted T would.
|
| 346 |
+
|
| 347 |
+
| Tier | n | Mean D | Std | comply | refuse | hedge |
|
| 348 |
+
|---|---:|---:|---:|---:|---:|---:|
|
| 349 |
+
| `benign_bio` | 23 | 0.614 | 0.321 | 57% | 43% | 0% |
|
| 350 |
+
| `dual_use_bio` | 30 | 0.703 | 0.295 | 70% | 30% | 0% |
|
| 351 |
+
| `hazard_adjacent` | 22 | 0.647 | 0.306 | 59% | 36% | 5% |
|
| 352 |
+
| **overall** | **75** | **0.659** | **0.309** | | | |
|
| 353 |
+
|
| 354 |
+
**Comparison to Gemma Scope community SAE** (Gemma 2 2B-IT, fitted T, 80-tok — different
|
| 355 |
+
model, different calibration, not directly comparable):
|
| 356 |
+
|
| 357 |
+
| Tier | Gemma Scope baseline | This SAE (T_prior) |
|
| 358 |
+
|---|---:|---:|
|
| 359 |
+
| `benign_bio` | 0.362 | 0.614 |
|
| 360 |
+
| `dual_use_bio` | 0.406 | 0.703 |
|
| 361 |
+
| `hazard_adjacent` | 0.404 | 0.647 |
|
| 362 |
+
|
| 363 |
+
The delta reflects T miscalibration and the model difference (Gemma 4 vs. Gemma 2), not a
|
| 364 |
+
meaningful SAE quality gap. A properly fitted T from a behavioral corpus is needed to
|
| 365 |
+
interpret absolute D magnitudes.
|
| 366 |
+
|
| 367 |
+
**Surface label finding.** `benign_bio` refuses at 43% — the over-refusal pattern documented
|
| 368 |
+
in BioRefusalAudit §4.6: Gemma 4 E2B's safety circuit fires on biosecurity-adjacent content
|
| 369 |
+
regardless of hazard tier. This SAE confirms the same pattern as the Gemma Scope community
|
| 370 |
+
SAE baseline.
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
## Training your own bio-safety SAE
|
| 375 |
+
|
| 376 |
+
The training notebook is at
|
| 377 |
+
[notebooks/colab_gemma4_sae_training.ipynb](https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon/blob/main/notebooks/colab_gemma4_sae_training.ipynb).
|
| 378 |
+
It runs end-to-end on a free Colab T4 in ~35 minutes.
|
| 379 |
+
|
| 380 |
+
**Quick start:**
|
| 381 |
+
1. Open the notebook in Google Colab (Runtime → Change runtime type → T4 GPU)
|
| 382 |
+
2. Add `HF_TOKEN` (write scope) and `WANDB_API_KEY` to Colab Secrets (🔑 icon)
|
| 383 |
+
3. Run All — the notebook will:
|
| 384 |
+
- Install `transformers` from source (Gemma 4 requires the latest main branch)
|
| 385 |
+
- Load `google/gemma-4-E2B-it` in NF4 4-bit quantization
|
| 386 |
+
- Stream training data from `cais/wmdp-corpora` (bio-retain-corpus, public)
|
| 387 |
+
- Train 2,000 steps with reconstruction + sparsity + mean contrastive loss
|
| 388 |
+
- Upload final checkpoint to your HF account as `<your-username>/gemma4-e2b-bio-sae-v1`
|
| 389 |
+
|
| 390 |
+
**Key implementation details that make it work on Colab:**
|
| 391 |
+
|
| 392 |
+
| Problem | Fix |
|
| 393 |
+
|---------|-----|
|
| 394 |
+
| Gemma 4 multimodal layer path | `pick_layer()` with 5-path fallback + `named_modules()` slow-path scan |
|
| 395 |
+
| Decoder collapse (all features becoming equal) | `normalize_decoder()` + `project_grad()` each step |
|
| 396 |
+
| OOD inputs from raw corpus text | Wrap all prompts with `tokenizer.apply_chat_template` |
|
| 397 |
+
| VRAM fill during generation | Hook overwrites `captured[0]` instead of appending to a list |
|
| 398 |
+
| Contrastive loss instability | Mean contrastive (cosine sim of mean profiles) instead of NT-Xent |
|
| 399 |
+
|
| 400 |
+
---
|
| 401 |
+
|
| 402 |
+
## Caveats
|
| 403 |
+
|
| 404 |
+
- **Contrastive collapse.** The contrastive loss reached ~0 by step ~1500. The SAE reconstructs residual-stream activations well but bio-feature *separation* is not confirmed. Verification requires running `auto_tune_catalog.py` and checking Cohen's d per category against the Gemma Scope baseline.
|
| 405 |
+
- **Small corpus (v1).** Training used ~5,000 WMDP documents (benign) + 22 hazard-adjacent prompts. Too few hazard-adjacent examples to sustain the contrastive margin. This is the binding constraint — not compute, not architecture. **Fixed in v2** (see below).
|
| 406 |
+
- **2000-step limit (v1).** Capped at 2000 steps; L_contrastive collapsed by step 1000. Final checkpoint reconstructs well but bio-feature separation is not confirmed. **Fixed in v2:** 5000 steps with real hazard corpus.
|
| 407 |
+
- **No Neuronpedia validation.** Individual feature interpretability is unverified.
|
| 408 |
+
- **4× expansion.** d_sae/d_model = 4.0, below Gemma Scope's 8×. Wider SAEs likely capture more bio-specific features.
|
| 409 |
+
- **Gemma 4 multimodal wrapper.** Hook path is `model.language_model.layers[17]` — **not** `model.model.layers[17]` (Gemma 3 path). The `get_layer()` helper above handles this automatically.
|
| 410 |
+
|
| 411 |
+
### v2 training run (in progress)
|
| 412 |
+
|
| 413 |
+
Access to `cais/wmdp-bio-forget-corpus` was granted on 2026-04-26. The v2 notebook
|
| 414 |
+
(`notebooks/colab_gemma4_sae_training.ipynb`) now loads 5,000 papers from that corpus as
|
| 415 |
+
the hazard-adjacent class, balanced against 5,000 benign documents from the retain corpus,
|
| 416 |
+
for 5,000 training steps. This directly addresses the corpus-size bottleneck. Results will
|
| 417 |
+
be published as `Solshine/gemma4-e2b-bio-sae-v2` on completion.
|
| 418 |
+
|
| 419 |
+
### What would further improve this SAE
|
| 420 |
+
|
| 421 |
+
The corpus-size problem is now addressed for the primary bottleneck. Remaining priorities, in
|
| 422 |
+
order of impact:
|
| 423 |
+
|
| 424 |
+
1. **More hazard-adjacent examples (partially addressed in v2).** 22 prompts is not enough to anchor a stable contrastive
|
| 425 |
+
direction. 500–1000 genuine hazard-adjacent activation examples (from actual model
|
| 426 |
+
responses, not just prompts) would likely sustain the contrastive margin through training.
|
| 427 |
+
This requires access to institutional CBRN datasets — the kind held by organizations like
|
| 428 |
+
Gryphon Scientific, NTI Bio, Johns Hopkins Center for Health Security, or government
|
| 429 |
+
biosecurity agencies. We are actively seeking partnerships with these organizations and
|
| 430 |
+
would welcome introductions from anyone in that space.
|
| 431 |
+
|
| 432 |
+
2. **A proper base-vs-RLHF activation corpus.** Following the methodology of Secret Agenda
|
| 433 |
+
(arXiv:2509.20393): collect residual-stream activations from the base model and the
|
| 434 |
+
instruction-tuned model on identical prompts, then train the SAE to separate "what the
|
| 435 |
+
safety fine-tune changed" from "what was already there." This is a data-collection problem
|
| 436 |
+
that requires running both model variants on the same hardware at scale.
|
| 437 |
+
|
| 438 |
+
3. **More compute for training.** A full SAE fine-tune at Anthropic/EleutherAI scale (100K+
|
| 439 |
+
steps, A100 or H100) would not help if the corpus is still 22 hazard-adjacent prompts —
|
| 440 |
+
the gradient signal simply isn't there. But a 10K-step run on a properly sized corpus
|
| 441 |
+
(~10K hazard-adjacent samples) would be a reasonable next experiment and is feasible on a
|
| 442 |
+
single A100 in a few hours. If you have access to institutional compute or CBRN datasets
|
| 443 |
+
and want to run this experiment, please open an issue on the
|
| 444 |
+
[BioRefusalAudit repo](https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon) or
|
| 445 |
+
reach out directly.
|
| 446 |
+
|
| 447 |
+
4. **Wider SAE.** 8× or 16× expansion (d_sae = 12288 or 24576) with a larger k would give
|
| 448 |
+
more features to specialize. This is a secondary bottleneck behind corpus size.
|
| 449 |
+
|
| 450 |
+
---
|
| 451 |
+
|
| 452 |
+
## Cross-Architecture Context
|
| 453 |
+
|
| 454 |
+
This SAE targets Gemma 4 E2B-IT (2B parameters, multimodal, released April 2025). Compared
|
| 455 |
+
to the Gemma 2 2B SAE in the same pipeline (`runs/sae-training-gemma2-5000steps/`):
|
| 456 |
+
|
| 457 |
+
| | Gemma 4 E2B | Gemma 2 2B |
|
| 458 |
+
|---|---|---|
|
| 459 |
+
| d_model | 1536 | 2304 |
|
| 460 |
+
| Hook layer | 17 | 12 |
|
| 461 |
+
| Hook path | `model.language_model.layers` | `model.layers` |
|
| 462 |
+
| SAE size | ~19 MB | ~28 MB |
|
| 463 |
+
| Training steps | 2,000 (Colab T4) | 5,000 (local GPU) |
|
| 464 |
+
|
| 465 |
+
---
|
| 466 |
+
|
| 467 |
+
## Citation
|
| 468 |
+
|
| 469 |
+
```bibtex
|
| 470 |
+
@misc{deleeuw2026biorefusalaudit,
|
| 471 |
+
title = {BioRefusalAudit: Measuring Refusal Depth in LLMs
|
| 472 |
+
via SAE Feature Divergence},
|
| 473 |
+
author = {DeLeeuw, Caleb},
|
| 474 |
+
year = {2026},
|
| 475 |
+
howpublished = {AIxBio Hackathon 2026, Track 3: Biosecurity Tools},
|
| 476 |
+
url = {https://github.com/SolshineCode/Deleeuw-AI-x-Bio-hackathon}
|
| 477 |
+
}
|
| 478 |
+
```
|
| 479 |
+
|
| 480 |
+
---
|
| 481 |
+
|
| 482 |
+
## License
|
| 483 |
+
|
| 484 |
+
Code and weights released under the
|
| 485 |
+
[Hippocratic License 3.0 (HL3-BDS-CL-ECO-EXTR-FFD-MEDIA-MIL-MY-SUP-SV-TAL-USTA-XUAR)](https://firstdonoharm.dev/version/3/0/bds-cl-eco-extr-ffd-media-mil-my-sup-sv-tal-usta-xuar.html).
|
| 486 |
+
You may use these weights for biosecurity research, AI safety research, and defensive
|
| 487 |
+
interpretability work. You may not use them to facilitate harm.
|