AQIT 0.1.0
Loading...
Searching...
No Matches
sae_train.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2"""Train a temp SAE on activations from a catalog model or fine-tuned checkpoint."""
3
4from __future__ import annotations
5
6import json
7import random
8from pathlib import Path
9from typing import Any, Iterator
10
11import torch
12import torch.nn.functional as F
13from torch.optim import Adam
14
16 compute_norm,
17 count_vectors,
18 find_chunk_paths,
19 resolve_training_acts_dir,
20 validate_acts_manifest,
21 write_collect_manifest,
22)
23from aquin.compute.sae import SparseAutoencoder
24from aquin.compute.sae_diff import load_tl_from_checkpoint
25from aquin.compute.device import empty_device_cache, resolve_compute_device
26
27QUICK_TOKENS = 100_000
28FULL_TOKENS = 2_000_000
29QUICK_MAX_STEPS = 3_000
30FULL_MAX_STEPS = 50_000
31QUICK_MAX_EPOCHS = 10
32FULL_MAX_EPOCHS = 200
33SEQ_LEN = 64
34COLLECT_BATCH = 32
35CHUNK_SIZE = 50_000
36BATCH_SIZE = 4096
37LR = 1e-4
38L1_COEFF = 10.0
39USER_SAE_ROOT = Path.home() / ".aquin" / "sae" / "user"
42def _iter_corpus_text(corpus_path: str | Path | None) -> Iterator[str]:
43 if corpus_path:
44 p = Path(corpus_path)
45 if p.suffix == ".jsonl":
46 for line in p.read_text(encoding="utf-8").splitlines():
47 if not line.strip():
48 continue
49 row = json.loads(line)
50 if isinstance(row, str):
51 yield row
52 elif isinstance(row, dict):
53 for key in ("text", "content", "instruction", "prompt"):
54 val = row.get(key)
55 if isinstance(val, str) and val.strip():
56 yield val[:2000]
57 break
58 else:
59 data = json.loads(p.read_text(encoding="utf-8"))
60 rows = data if isinstance(data, list) else [data]
61 for row in rows:
62 if isinstance(row, str):
63 yield row
64 elif isinstance(row, dict):
65 for key in ("text", "content", "instruction", "prompt"):
66 val = row.get(key)
67 if isinstance(val, str) and val.strip():
68 yield val[:2000]
69 break
70 return
71
72 from datasets import load_dataset
73
74 dataset = load_dataset("Skylion007/openwebtext", split="train", streaming=True)
75 for sample in dataset:
76 yield sample["text"][:1000]
77
78
80 model_id: str,
81 layer: int,
82 acts_dir: Path,
83 n_tokens: int,
84 *,
85 checkpoint_path: str | Path | None = None,
86 corpus_path: str | Path | None = None,
87) -> None:
88 device = resolve_compute_device()
89 acts_dir.mkdir(parents=True, exist_ok=True)
90
91 existing = sorted(acts_dir.glob("chunk_*.pt"))
92 existing_tokens = len(existing) * CHUNK_SIZE
93 if existing_tokens >= n_tokens:
94 print(f"[sae-train] found {len(existing)} chunks ({existing_tokens:,} tokens), skipping collection", flush=True)
95 return
96
97 print(f"[sae-train] collecting {n_tokens:,} activations from layer {layer}...", flush=True)
98 model = load_tl_from_checkpoint(model_id, checkpoint_path)
99 model.eval()
100
101 hook_name = f"blocks.{layer}.hook_resid_post"
102 tokenizer = model.tokenizer
103 if hasattr(tokenizer, "padding_side"):
104 tokenizer.padding_side = "right"
105
106 chunk: list[torch.Tensor] = []
107 total = existing_tokens
108 chunk_idx = len(existing)
109 buf: list[int] = []
110
111 with torch.no_grad():
112 for sample in _iter_corpus_text(corpus_path):
113 if total >= n_tokens:
114 break
115 ids = tokenizer.encode(sample, add_special_tokens=False)
116 buf.extend(ids)
117
118 while len(buf) >= SEQ_LEN * COLLECT_BATCH and total < n_tokens:
119 seqs = [buf[i * SEQ_LEN:(i + 1) * SEQ_LEN] for i in range(COLLECT_BATCH)]
120 buf = buf[SEQ_LEN * COLLECT_BATCH:]
121 tokens = torch.tensor(seqs, device=device)
122 _, cache = model.run_with_cache(
123 tokens,
124 names_filter=hook_name,
125 return_type=None,
126 )
127 acts = cache[hook_name].reshape(-1, cache[hook_name].shape[-1]).cpu()
128 chunk.append(acts)
129 total += acts.shape[0]
130
131 if sum(a.shape[0] for a in chunk) >= CHUNK_SIZE:
132 stacked = torch.cat(chunk, dim=0)[:CHUNK_SIZE]
133 torch.save(stacked, acts_dir / f"chunk_{chunk_idx}.pt")
134 chunk, chunk_idx = [], chunk_idx + 1
135 print(f"[sae-train] {total:,} / {n_tokens:,} tokens", flush=True)
136
137 if chunk:
138 torch.save(torch.cat(chunk, dim=0), acts_dir / f"chunk_{chunk_idx}.pt")
139
140 del model
141 empty_device_cache()
142 print("[sae-train] collection complete", flush=True)
143
144
145def train_sae(
146 model_id: str,
147 layer: int,
148 output_path: Path,
149 *,
150 checkpoint_path: str | Path | None = None,
151 corpus_path: str | Path | None = None,
152 quick: bool = False,
153 d_model: int | None = None,
154 n_features: int | None = None,
155 max_steps: int | None = None,
156 max_epochs: int | None = None,
157 activations_dir: str | Path | None = None,
158 balance: bool = False,
159 balance_group: str | None = None,
160) -> Path:
161 from aquin.compute.feature_analysis import load_sae
162 from aquin.compute.model_loader import get_config, resolve_model_id
163
164 short = resolve_model_id(model_id)
165 cfg = get_config(short)
166 d_model = d_model or cfg["d_model"]
167
168 if n_features is None:
169 try:
170 ref = load_sae(short, layer=layer)
171 n_features = ref.n_features
172 del ref
173 except Exception:
174 n_features = 32768
175
176 n_tokens = QUICK_TOKENS if quick else FULL_TOKENS
177 steps_cap = max_steps or (QUICK_MAX_STEPS if quick else FULL_MAX_STEPS)
178 epochs_cap = max_epochs or (QUICK_MAX_EPOCHS if quick else FULL_MAX_EPOCHS)
179
180 if activations_dir:
181 work_dir = resolve_training_acts_dir(
182 activations_dir,
183 layer,
184 balance=balance,
185 balance_group=balance_group,
186 )
187 validate_acts_manifest(work_dir, model_id=short, layer=layer)
188 n_vectors = count_vectors(work_dir)
189 print(
190 f"[sae-train] training from saved activations ({n_vectors:,} vectors) at {work_dir}",
191 flush=True,
192 )
193 if n_vectors < 10_000:
194 print(
195 "[sae-train] warning: few activation vectors — quality may be poor; "
196 "use corpus collection or a larger capture for production SAEs",
197 flush=True,
198 )
199 else:
200 work_dir = output_path.parent / f"_acts_layer{layer}"
202 short,
203 layer,
204 work_dir,
205 n_tokens,
206 checkpoint_path=checkpoint_path,
207 corpus_path=corpus_path,
208 )
209 write_collect_manifest(
210 work_dir,
211 model_id=short,
212 layer=layer,
213 model_mode="llm",
214 d_model=d_model,
215 source="corpus",
216 n_vectors=count_vectors(work_dir),
217 checkpoint_path=str(checkpoint_path) if checkpoint_path else None,
218 corpus_path=str(corpus_path) if corpus_path else None,
219 )
220
221 mean, std = compute_norm(work_dir, d_model)
222 device = resolve_compute_device()
223 sae = SparseAutoencoder(d_model=d_model, n_features=n_features).to(device)
224 opt = Adam(sae.parameters(), lr=LR)
225
226 chunks = find_chunk_paths(work_dir)
227 if not chunks:
228 raise FileNotFoundError(f"No activation chunks in {work_dir}")
229 first = torch.load(chunks[0], map_location="cpu", weights_only=False)
230 with torch.no_grad():
231 sae.b_pre.data = ((first[:10_000] - mean) / std).to(device).mean(0)
232 del first
233
234 step = 0
235 print(
236 f"[sae-train] training on {len(chunks)} chunks "
237 f"(quick={quick}, steps_cap={steps_cap}, epochs_cap={epochs_cap})",
238 flush=True,
239 )
240 for epoch in range(1, epochs_cap + 1):
241 random.shuffle(chunks)
242 for chunk_path in chunks:
243 acts = torch.load(chunk_path, map_location="cpu", weights_only=False)
244 acts = (acts - mean) / std
245 n = acts.shape[0]
246 perm = torch.randperm(n)
247 acts = acts[perm]
248
249 for start in range(0, n - BATCH_SIZE, BATCH_SIZE):
250 batch = acts[start:start + BATCH_SIZE].to(device)
251 f, x_hat = sae(batch)
252 loss = F.mse_loss(x_hat, batch) + L1_COEFF * f.abs().mean()
253 opt.zero_grad()
254 loss.backward()
255 opt.step()
256 sae._normalise_decoder()
257 step += 1
258
259 if step % 500 == 0:
260 dead = (f.max(0).values == 0).sum().item()
261 l0 = (f > 0).float().sum(-1).mean().item()
262 recon = F.mse_loss(x_hat, batch).item()
263 print(
264 f"[sae-train] epoch={epoch} step={step} recon={recon:.4f} "
265 f"L0={l0:.1f} dead={dead}/{n_features}",
266 flush=True,
267 )
268
269 if step >= steps_cap:
270 break
271 del acts
272 if step >= steps_cap:
273 break
274 if step >= steps_cap:
275 break
276
277 print(f"[sae-train] finished {step} steps ({epoch} epochs)", flush=True)
278
279 output_path.parent.mkdir(parents=True, exist_ok=True)
280 sae.save(output_path)
281 norm_path = output_path.parent / f"norm_layer{layer}.pt"
282 torch.save({"mean": mean.cpu(), "std": std.cpu()}, norm_path)
283 meta = {
284 "model_id": short,
285 "layer": layer,
286 "checkpoint_path": str(checkpoint_path) if checkpoint_path else None,
287 "quick": quick,
288 "steps": step,
289 "max_steps": steps_cap,
290 "epochs": epoch,
291 "n_features": n_features,
292 "d_model": d_model,
293 }
294 output_path.with_suffix(".meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
295 print(f"[sae-train] saved {output_path}", flush=True)
296 return output_path
297
298
299def default_user_sae_path(model_id: str, name: str, layer: int) -> Path:
300 from aquin.compute.model_loader import resolve_model_id
301
302 short = resolve_model_id(model_id)
303 safe = name.replace("/", "--").replace(" ", "_")
304 return USER_SAE_ROOT / short / safe / f"sae_layer{layer}.pt"
Path default_user_sae_path(str model_id, str name, int layer)
Definition sae_train.py:303
Iterator[str] _iter_corpus_text(str|Path|None corpus_path)
Definition sae_train.py:46
None collect_activations(str model_id, int layer, Path acts_dir, int n_tokens, *, str|Path|None checkpoint_path=None, str|Path|None corpus_path=None)
Definition sae_train.py:91