ablate-llm 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ablate/pipeline.py ADDED
@@ -0,0 +1,343 @@
1
+ """High-level orchestrator tying the whole abliteration pipeline together."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from dataclasses import asdict
7
+ from typing import List, Optional
8
+
9
+ from typing import Union
10
+
11
+ import torch
12
+
13
+ from . import data, extract, subspace as subspace_mod, utils, weights
14
+ from .config import AblationConfig, RunConfig, SubspaceConfig
15
+ from .evaluate import EvalResult, evaluate
16
+ from .harness import HarnessResult, run_harness
17
+ from .hooks import AblationHooks
18
+ from .model import LM
19
+ from .optimize import SearchResult, SubspaceSearchResult, optimize, optimize_subspace
20
+
21
+
22
+ def _metrics_of(result: EvalResult) -> dict:
23
+ return {
24
+ "refusal_rate": round(result.refusal_rate, 4),
25
+ "mean_kl": round(result.mean_kl, 4),
26
+ "coherence": round(result.coherence, 4),
27
+ }
28
+
29
+
30
+ class Ablator:
31
+ """End-to-end interface for extracting, applying, searching, and baking
32
+ refusal-direction ablations.
33
+
34
+ Typical use::
35
+
36
+ abl = Ablator("Qwen/Qwen2.5-0.5B-Instruct")
37
+ abl.extract() # find candidate directions
38
+ result = abl.search() # optimize layer / alpha / band
39
+ print(result.result)
40
+ abl.bake(abl.best_config) # fold into weights
41
+ abl.save("uncensored-model")
42
+ """
43
+
44
+ def __init__(self, model_name: str, device=None, dtype=None, trust_remote_code=False):
45
+ self.lm = LM.load(model_name, device=device, dtype=dtype, trust_remote_code=trust_remote_code)
46
+ self.directions: Optional[torch.Tensor] = None
47
+ self.subspace: Optional[torch.Tensor] = None
48
+ self.subspace_info: Optional[dict] = None
49
+ self.best_config: Optional[Union[AblationConfig, SubspaceConfig]] = None
50
+ self.last_metrics: Optional[dict] = None
51
+ self._baked = False
52
+
53
+ # -- resolve the basis a config refers to ------------------------------- #
54
+ def _basis_for(self, config: Union[AblationConfig, SubspaceConfig]) -> torch.Tensor:
55
+ if isinstance(config, SubspaceConfig):
56
+ assert self.subspace is not None, "call extract_subspace() first"
57
+ return self.subspace[: config.n_directions]
58
+ assert self.directions is not None, "call extract() first"
59
+ return self.directions[config.direction_layer]
60
+
61
+ # -- data --------------------------------------------------------------- #
62
+ def _default_data(self, cfg: RunConfig):
63
+ harmful = data.sample(data.load_builtin("harmful"), None, cfg.seed)
64
+ harmless = data.sample(data.load_builtin("harmless"), None, cfg.seed)
65
+ h_train, h_eval = data.split(harmful, cfg.n_harmful, cfg.n_eval_harmful, cfg.seed)
66
+ s_train, s_eval = data.split(harmless, cfg.n_harmless, cfg.n_eval_benign, cfg.seed)
67
+ return h_train, s_train, h_eval, s_eval
68
+
69
+ # -- extraction --------------------------------------------------------- #
70
+ def extract(
71
+ self,
72
+ harmful: Optional[List[str]] = None,
73
+ harmless: Optional[List[str]] = None,
74
+ method: str = "diff_of_means",
75
+ positions: int = 1,
76
+ batch_size: int = 8,
77
+ ) -> torch.Tensor:
78
+ if harmful is None:
79
+ harmful = data.load_builtin("harmful")
80
+ if harmless is None:
81
+ harmless = data.load_builtin("harmless")
82
+ self.directions = extract.extract_directions(
83
+ self.lm, harmful, harmless, method=method, positions=positions, batch_size=batch_size
84
+ )
85
+ return self.directions
86
+
87
+ def extract_subspace(
88
+ self,
89
+ harmful: Optional[List[str]] = None,
90
+ harmless: Optional[List[str]] = None,
91
+ method: str = "band",
92
+ n_directions: int = 4,
93
+ layer: Optional[int] = None,
94
+ positions: int = 1,
95
+ batch_size: int = 8,
96
+ ) -> torch.Tensor:
97
+ """Extract an orthonormal refusal *subspace* (multi-direction ablation)."""
98
+ if harmful is None:
99
+ harmful = data.load_builtin("harmful")
100
+ if harmless is None:
101
+ harmless = data.load_builtin("harmless")
102
+ self.subspace, self.subspace_info = subspace_mod.extract_subspace(
103
+ self.lm, harmful, harmless, method=method, n_directions=n_directions,
104
+ layer=layer, positions=positions, batch_size=batch_size,
105
+ )
106
+ return self.subspace
107
+
108
+ # -- single-config evaluation ------------------------------------------ #
109
+ def evaluate(
110
+ self,
111
+ config: AblationConfig,
112
+ harmful_eval: List[str],
113
+ benign_eval: List[str],
114
+ max_new_tokens: int = 64,
115
+ kl_weight: float = 1.0,
116
+ keep_samples: bool = True,
117
+ ) -> EvalResult:
118
+ assert self.directions is not None, "call extract() first"
119
+ return evaluate(
120
+ self.lm,
121
+ self.directions[config.direction_layer],
122
+ config,
123
+ harmful_eval,
124
+ benign_eval,
125
+ max_new_tokens=max_new_tokens,
126
+ kl_weight=kl_weight,
127
+ keep_samples=keep_samples,
128
+ )
129
+
130
+ # -- optimization ------------------------------------------------------- #
131
+ def search(
132
+ self,
133
+ harmful_eval: Optional[List[str]] = None,
134
+ benign_eval: Optional[List[str]] = None,
135
+ n_trials: int = 30,
136
+ kl_weight: float = 1.0,
137
+ max_new_tokens: int = 48,
138
+ seed: int = 0,
139
+ ) -> SearchResult:
140
+ assert self.directions is not None, "call extract() first"
141
+ if harmful_eval is None:
142
+ harmful_eval = data.split(data.load_builtin("harmful"), 40, 24, seed)[1]
143
+ if benign_eval is None:
144
+ benign_eval = data.split(data.load_builtin("harmless"), 40, 24, seed)[1]
145
+ res = optimize(
146
+ self.lm,
147
+ self.directions,
148
+ harmful_eval,
149
+ benign_eval,
150
+ n_trials=n_trials,
151
+ kl_weight=kl_weight,
152
+ max_new_tokens=max_new_tokens,
153
+ seed=seed,
154
+ )
155
+ self.best_config = res.config
156
+ self.last_metrics = _metrics_of(res.result)
157
+ return res
158
+
159
+ def search_subspace(
160
+ self,
161
+ harmful_eval: Optional[List[str]] = None,
162
+ benign_eval: Optional[List[str]] = None,
163
+ n_trials: int = 30,
164
+ kl_weight: float = 1.0,
165
+ max_new_tokens: int = 48,
166
+ seed: int = 0,
167
+ ) -> SubspaceSearchResult:
168
+ """KL-guided search over (n_directions, alpha, layer-band) for the subspace."""
169
+ assert self.subspace is not None, "call extract_subspace() first"
170
+ if harmful_eval is None:
171
+ harmful_eval = data.split(data.load_builtin("harmful"), 40, 24, seed)[1]
172
+ if benign_eval is None:
173
+ benign_eval = data.split(data.load_builtin("harmless"), 40, 24, seed)[1]
174
+ res = optimize_subspace(
175
+ self.lm, self.subspace, harmful_eval, benign_eval,
176
+ n_trials=n_trials, kl_weight=kl_weight, max_new_tokens=max_new_tokens, seed=seed,
177
+ )
178
+ self.best_config = res.config
179
+ self.last_metrics = _metrics_of(res.result)
180
+ return res
181
+
182
+ # -- benchmark harness with a judge ------------------------------------- #
183
+ def harness(
184
+ self,
185
+ prompts: List[str],
186
+ judge: Optional[object] = None,
187
+ config: Optional[Union[AblationConfig, SubspaceConfig]] = None,
188
+ max_new_tokens: int = 128,
189
+ ) -> HarnessResult:
190
+ """Run a harmful benchmark through a judge. Pass ``config`` (or rely on
191
+ ``best_config``) to score the ablated model; pass ``config=None`` and no
192
+ ``best_config`` for the baseline."""
193
+ cfg = config or self.best_config
194
+ basis = self._basis_for(cfg) if cfg is not None else None
195
+ return run_harness(self.lm, prompts, judge=judge, basis=basis, config=cfg,
196
+ max_new_tokens=max_new_tokens)
197
+
198
+ # -- generation (with a live, non-destructive ablation) ----------------- #
199
+ def generate(self, prompts: List[str], config=None, max_new_tokens: int = 128) -> List[str]:
200
+ config = config or self.best_config
201
+ if config is None:
202
+ return self.lm.generate(prompts, max_new_tokens=max_new_tokens)
203
+ with AblationHooks(self.lm, self._basis_for(config), config):
204
+ return self.lm.generate(prompts, max_new_tokens=max_new_tokens)
205
+
206
+ # -- baking + saving ---------------------------------------------------- #
207
+ def bake(self, config=None, include_embedding: bool = True) -> None:
208
+ """Permanently fold the chosen direction/subspace into the model weights."""
209
+ config = config or self.best_config
210
+ assert config is not None, "provide a config or run search()/search_subspace() first"
211
+ weights.bake_subspace(self.lm.model, self._basis_for(config), include_embedding)
212
+ self._baked = True
213
+
214
+ def save(self, path: str) -> None:
215
+ os.makedirs(path, exist_ok=True)
216
+ self.lm.model.save_pretrained(path)
217
+ self.lm.tokenizer.save_pretrained(path)
218
+ meta = {
219
+ "base_model": self.lm.name,
220
+ "baked": self._baked,
221
+ "best_config": self.best_config.to_dict() if self.best_config else None,
222
+ }
223
+ with open(os.path.join(path, "ablate_meta.json"), "w") as f:
224
+ json.dump(meta, f, indent=2)
225
+
226
+ def save_directions(self, path: str) -> None:
227
+ assert self.directions is not None
228
+ torch.save(self.directions, path)
229
+
230
+ # -- publish to the HuggingFace Hub ------------------------------------- #
231
+ def push_to_hub(
232
+ self,
233
+ repo_id: str,
234
+ token: Optional[str] = None,
235
+ private: bool = True,
236
+ metrics: Optional[dict] = None,
237
+ license: str = "apache-2.0",
238
+ bake_if_needed: bool = True,
239
+ ) -> str:
240
+ """Bake (if needed) and upload the abliterated model + a generated model
241
+ card to ``repo_id``. Returns the repo URL.
242
+
243
+ ``token`` falls back to ``HF_TOKEN`` / ``HUGGINGFACE_TOKEN`` env vars.
244
+ """
245
+ from .hub import push_to_hub as _push
246
+
247
+ if not self._baked and bake_if_needed:
248
+ assert self.best_config is not None, "run a search or pass a config before pushing"
249
+ self.bake(self.best_config)
250
+
251
+ cfg = self.best_config.to_dict() if self.best_config else {}
252
+ method = (
253
+ f"rank-{self.best_config.n_directions} subspace ablation (baked)"
254
+ if isinstance(self.best_config, SubspaceConfig)
255
+ else "single-direction ablation (baked)"
256
+ )
257
+ return _push(
258
+ self.lm.model, self.lm.tokenizer, repo_id=repo_id, token=token,
259
+ base_model=self.lm.name, method=method, config=cfg,
260
+ metrics=metrics or self.last_metrics, private=private, license=license,
261
+ )
262
+
263
+
264
+ def _load_source(source: Optional[str], kind: str, n: Optional[int], seed: int) -> List[str]:
265
+ """Resolve a data source string to a prompt list.
266
+
267
+ ``source`` may be ``None`` (built-in ``harmful``/``harmless`` set), a named
268
+ shortcut (``advbench``/``harmbench``/``jailbreakbench``/``alpaca``), or
269
+ ``"hf:<dataset>:<column>[:<config>]"``.
270
+ """
271
+ if source is None:
272
+ return data.sample(data.load_builtin(kind), n, seed)
273
+ named = {
274
+ "advbench": data.load_advbench,
275
+ "harmbench": data.load_harmbench,
276
+ "jailbreakbench": data.load_jailbreakbench,
277
+ "alpaca": data.load_alpaca_benign,
278
+ }
279
+ if source in named:
280
+ return named[source](n=n)
281
+ if source.startswith("hf:"):
282
+ parts = source.split(":")
283
+ dataset, column = parts[1], parts[2]
284
+ config = parts[3] if len(parts) > 3 else None
285
+ return data.load_hf(dataset, column=column, config=config, n=n, shuffle_seed=seed)
286
+ raise ValueError(f"unrecognized data source: {source!r}")
287
+
288
+
289
+ def run(cfg: RunConfig):
290
+ """Fully automated pipeline driven by a RunConfig (used by the CLI).
291
+
292
+ Supports single-direction or subspace ablation, built-in or HuggingFace data
293
+ sources, and optional push to the Hub. Returns the search result.
294
+ """
295
+ utils.set_seed(cfg.seed)
296
+ abl = Ablator(cfg.model_name, device=cfg.device, dtype=cfg.dtype)
297
+
298
+ harmful = _load_source(cfg.harmful_source, "harmful", cfg.n_harmful + cfg.n_eval_harmful, cfg.seed)
299
+ harmless = _load_source(cfg.harmless_source, "harmless", cfg.n_harmless + cfg.n_eval_benign, cfg.seed)
300
+ h_train, h_eval = data.split(harmful, cfg.n_harmful, cfg.n_eval_harmful, cfg.seed)
301
+ s_train, s_eval = data.split(harmless, cfg.n_harmless, cfg.n_eval_benign, cfg.seed)
302
+
303
+ if cfg.subspace:
304
+ abl.extract_subspace(harmful=h_train, harmless=s_train, method=cfg.subspace_method,
305
+ n_directions=cfg.n_directions, positions=cfg.extract_positions,
306
+ batch_size=cfg.batch_size)
307
+ result = abl.search_subspace(harmful_eval=h_eval, benign_eval=s_eval, n_trials=cfg.n_trials,
308
+ kl_weight=cfg.kl_weight, max_new_tokens=cfg.max_new_tokens, seed=cfg.seed)
309
+ else:
310
+ abl.extract(harmful=h_train, harmless=s_train, positions=cfg.extract_positions, batch_size=cfg.batch_size)
311
+ result = abl.search(harmful_eval=h_eval, benign_eval=s_eval, n_trials=cfg.n_trials,
312
+ kl_weight=cfg.kl_weight, max_new_tokens=cfg.max_new_tokens, seed=cfg.seed)
313
+
314
+ os.makedirs(cfg.output_dir, exist_ok=True)
315
+ with open(os.path.join(cfg.output_dir, "result.json"), "w") as f:
316
+ json.dump(
317
+ {
318
+ "run_config": asdict(cfg),
319
+ "subspace_info": abl.subspace_info,
320
+ "best_config": result.config.to_dict(),
321
+ "metrics": {
322
+ "refusal_rate": result.result.refusal_rate,
323
+ "mean_kl": result.result.mean_kl,
324
+ "coherence": result.result.coherence,
325
+ "objective": result.result.objective,
326
+ },
327
+ "samples": result.result.samples,
328
+ },
329
+ f,
330
+ indent=2,
331
+ )
332
+
333
+ if cfg.save_model or cfg.push_to_hub:
334
+ abl.bake(result.config)
335
+ if cfg.save_model:
336
+ abl.save(os.path.join(cfg.output_dir, "model"))
337
+
338
+ if cfg.push_to_hub:
339
+ url = abl.push_to_hub(cfg.push_to_hub, token=cfg.hf_token, private=cfg.private,
340
+ metrics=abl.last_metrics)
341
+ print(f"\nPushed abliterated model to: {url}")
342
+
343
+ return result
ablate/subspace.py ADDED
@@ -0,0 +1,103 @@
1
+ """Multi-direction (subspace) ablation.
2
+
3
+ A single refusal direction is often insufficient: safety behaviour is
4
+ redundantly encoded across layers, heads, and multiple residual directions.
5
+ Here we extract an *orthonormal basis* ``Q`` of shape ``(k, D)`` spanning a
6
+ refusal **subspace**, and project the whole subspace out of the residual stream:
7
+
8
+ h' = h - alpha * (h Qᵀ) Q # removes the component of h in span(Q)
9
+
10
+ ``k == 1`` recovers ordinary single-direction ablation.
11
+
12
+ Two extraction strategies:
13
+
14
+ * ``"band"`` — the diff-of-means direction from each of the ``k`` strongest
15
+ layers, orthonormalized (order-preserving Gram-Schmidt). Directions from
16
+ different layers live in the same residual space, so their span is a
17
+ meaningful multi-layer refusal subspace. Truncating ``Q[:j]`` keeps the ``j``
18
+ strongest.
19
+ * ``"pca"`` — the top-``k`` principal components of the pooled, mean-centered
20
+ activations at one layer, sign-aligned to the harmful side. Components are
21
+ variance-ordered, so truncation is principled.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import Dict, List, Optional, Tuple
26
+
27
+ import torch
28
+
29
+ from .extract import collect_activations
30
+ from .model import LM
31
+
32
+
33
+ def as_basis(x: torch.Tensor) -> torch.Tensor:
34
+ """Coerce a direction ``(D,)`` or basis ``(k, D)`` to a 2-D basis."""
35
+ return x.unsqueeze(0) if x.dim() == 1 else x
36
+
37
+
38
+ def orthonormalize(vecs: torch.Tensor) -> torch.Tensor:
39
+ """Order-preserving modified Gram-Schmidt.
40
+
41
+ Input ``(k, D)``; output ``(r, D)`` with orthonormal rows, ``r <= k`` (near
42
+ linearly-dependent rows are dropped). Row order is preserved so that
43
+ ``basis[:j]`` keeps the ``j`` most important directions.
44
+ """
45
+ out: List[torch.Tensor] = []
46
+ for v in vecs.float():
47
+ w = v.clone()
48
+ for u in out:
49
+ w = w - (w @ u) * u
50
+ n = w.norm()
51
+ if n > 1e-6:
52
+ out.append(w / n)
53
+ if not out:
54
+ return vecs[:0]
55
+ return torch.stack(out, dim=0)
56
+
57
+
58
+ def project_subspace(h: torch.Tensor, basis: torch.Tensor, alpha: float = 1.0) -> torch.Tensor:
59
+ """``h - alpha * (h Qᵀ) Q`` for orthonormal ``Q`` ``(k, D)`` (or a ``(D,)`` vector)."""
60
+ Q = as_basis(basis).to(dtype=h.dtype, device=h.device)
61
+ coeffs = h @ Q.T # (..., k)
62
+ return h - alpha * (coeffs @ Q)
63
+
64
+
65
+ def extract_subspace(
66
+ lm: LM,
67
+ harmful_prompts: List[str],
68
+ harmless_prompts: List[str],
69
+ method: str = "band",
70
+ n_directions: int = 4,
71
+ layer: Optional[int] = None,
72
+ positions: int = 1,
73
+ batch_size: int = 8,
74
+ show_progress: bool = True,
75
+ ) -> Tuple[torch.Tensor, Dict]:
76
+ """Return ``(basis (k, D), info)``. ``basis`` rows are orthonormal and
77
+ ordered by importance."""
78
+ h_act = collect_activations(lm, harmful_prompts, positions, batch_size, show_progress=show_progress)
79
+ s_act = collect_activations(lm, harmless_prompts, positions, batch_size, show_progress=show_progress)
80
+ raw = h_act.mean(dim=1) - s_act.mean(dim=1) # (L+1, D) per-layer diff-of-means
81
+ norms = raw.norm(dim=-1)
82
+
83
+ if method == "band":
84
+ # Candidate layers exclude embedding (0) and the final layer.
85
+ cand = list(range(1, raw.shape[0] - 1))
86
+ cand.sort(key=lambda i: float(-norms[i]))
87
+ chosen = cand[:n_directions]
88
+ vecs = torch.stack([raw[i] for i in chosen], dim=0) # ordered strongest-first
89
+ basis = orthonormalize(vecs)
90
+ return basis, {"method": "band", "layers": chosen, "n_directions": int(basis.shape[0])}
91
+
92
+ if method == "pca":
93
+ L = int(layer) if layer is not None else int(norms[1:-1].argmax().item()) + 1
94
+ X = torch.cat([h_act[L], s_act[L]], dim=0)
95
+ Xc = X - X.mean(dim=0, keepdim=True)
96
+ _, _, Vh = torch.linalg.svd(Xc, full_matrices=False)
97
+ comps = Vh[:n_directions] # (k, D), variance-ordered, already orthonormal
98
+ dm = h_act[L].mean(0) - s_act[L].mean(0)
99
+ comps = torch.stack([c if torch.dot(c, dm) >= 0 else -c for c in comps], dim=0)
100
+ basis = orthonormalize(comps)
101
+ return basis, {"method": "pca", "layer": L, "n_directions": int(basis.shape[0])}
102
+
103
+ raise ValueError(f"unknown subspace method: {method}")
ablate/utils.py ADDED
@@ -0,0 +1,129 @@
1
+ """Device / dtype helpers, seeding, and architecture adapters.
2
+
3
+ The architecture adapters are what let the rest of the toolkit stay
4
+ model-agnostic: given any HuggingFace causal-LM they locate the list of decoder
5
+ layers and the weight matrices that *write into the residual stream* (embedding,
6
+ attention output projection, MLP output projection). Everything downstream —
7
+ activation capture, runtime hooks, weight baking — is expressed in terms of
8
+ these accessors.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import random
14
+ from typing import List
15
+
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn as nn
19
+
20
+ # Let unsupported MPS ops fall back to CPU instead of crashing.
21
+ os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
22
+
23
+ try: # transformers' GPT2-style Conv1D layer (weight is stored transposed).
24
+ from transformers.pytorch_utils import Conv1D
25
+ except Exception: # pragma: no cover - very old transformers
26
+ Conv1D = tuple() # isinstance(x, ()) is always False
27
+
28
+
29
+ def pick_device(preferred: str | None = None) -> torch.device:
30
+ if preferred:
31
+ return torch.device(preferred)
32
+ if torch.cuda.is_available():
33
+ return torch.device("cuda")
34
+ if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
35
+ return torch.device("mps")
36
+ return torch.device("cpu")
37
+
38
+
39
+ def pick_dtype(device: torch.device, preferred: str | None = None) -> torch.dtype:
40
+ if preferred:
41
+ return getattr(torch, preferred)
42
+ if device.type == "cuda":
43
+ return torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
44
+ # float32 is the safe choice on CPU/MPS for tiny models on 16GB machines.
45
+ return torch.float32
46
+
47
+
48
+ def set_seed(seed: int) -> None:
49
+ random.seed(seed)
50
+ np.random.seed(seed)
51
+ torch.manual_seed(seed)
52
+ if torch.cuda.is_available():
53
+ torch.cuda.manual_seed_all(seed)
54
+
55
+
56
+ # --------------------------------------------------------------------------- #
57
+ # Architecture adapters
58
+ # --------------------------------------------------------------------------- #
59
+ def get_decoder_layers(model: nn.Module) -> nn.ModuleList:
60
+ """Return the ModuleList of transformer decoder blocks for common families."""
61
+ m = model
62
+ # Llama / Mistral / Qwen2 / SmolLM2 / Gemma / Phi ...
63
+ if hasattr(m, "model") and hasattr(m.model, "layers"):
64
+ return m.model.layers
65
+ # GPT-2 / GPT-Neo
66
+ if hasattr(m, "transformer") and hasattr(m.transformer, "h"):
67
+ return m.transformer.h
68
+ # GPT-NeoX / Pythia
69
+ if hasattr(m, "gpt_neox") and hasattr(m.gpt_neox, "layers"):
70
+ return m.gpt_neox.layers
71
+ # Fallback: some models expose .model.decoder.layers (OPT)
72
+ if hasattr(m, "model") and hasattr(m.model, "decoder") and hasattr(m.model.decoder, "layers"):
73
+ return m.model.decoder.layers
74
+ raise ValueError(
75
+ f"Could not locate decoder layers for {type(model).__name__}. "
76
+ "Add an adapter in utils.get_decoder_layers."
77
+ )
78
+
79
+
80
+ def num_layers(model: nn.Module) -> int:
81
+ return len(get_decoder_layers(model))
82
+
83
+
84
+ def hidden_size(model: nn.Module) -> int:
85
+ return int(model.config.hidden_size) if hasattr(model.config, "hidden_size") else int(model.config.n_embd)
86
+
87
+
88
+ def get_embedding(model: nn.Module) -> nn.Embedding:
89
+ return model.get_input_embeddings()
90
+
91
+
92
+ def _first_attr(module: nn.Module, names: List[str]):
93
+ for n in names:
94
+ if hasattr(module, n):
95
+ return getattr(module, n)
96
+ return None
97
+
98
+
99
+ def get_residual_writers(layer: nn.Module) -> List[nn.Module]:
100
+ """Return the modules inside one decoder block that write to the residual
101
+ stream: the attention output projection and the MLP output projection.
102
+
103
+ These are exactly the matrices we orthogonalize when baking a direction into
104
+ the weights.
105
+ """
106
+ writers: List[nn.Module] = []
107
+
108
+ attn = _first_attr(layer, ["self_attn", "attn", "attention"])
109
+ if attn is not None:
110
+ o = _first_attr(attn, ["o_proj", "c_proj", "dense", "out_proj"])
111
+ if o is not None:
112
+ writers.append(o)
113
+
114
+ mlp = _first_attr(layer, ["mlp", "feed_forward", "ffn"])
115
+ if mlp is not None:
116
+ down = _first_attr(mlp, ["down_proj", "c_proj", "dense_4h_to_h", "fc_out", "w2"])
117
+ if down is not None:
118
+ writers.append(down)
119
+
120
+ if not writers:
121
+ raise ValueError(
122
+ f"Could not locate residual-writing projections in {type(layer).__name__}. "
123
+ "Add names to utils.get_residual_writers."
124
+ )
125
+ return writers
126
+
127
+
128
+ def is_conv1d(module: nn.Module) -> bool:
129
+ return isinstance(Conv1D, type) and isinstance(module, Conv1D)
ablate/weights.py ADDED
@@ -0,0 +1,71 @@
1
+ """Weight orthogonalization ("baking" a direction or subspace into the model).
2
+
3
+ Runtime hooks are ideal for search, but for a shippable model we fold the
4
+ projection directly into every weight matrix that writes to the residual stream,
5
+ so the model can *never* express the subspace ``span(Q)`` and no hook is needed:
6
+
7
+ W_out <- (I - QᵀQ) W_out (attention / MLP output projections)
8
+ E <- E (I - QᵀQ) (token embeddings, per row)
9
+
10
+ ``Q`` is an orthonormal basis ``(k, D)``; ``k == 1`` is single-direction baking.
11
+ Supports both ``nn.Linear`` (Llama/Mistral/Qwen/SmolLM/Pythia) and GPT-2's
12
+ ``Conv1D`` (weight stored transposed).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+
19
+ from . import utils
20
+ from .subspace import as_basis, orthonormalize
21
+
22
+
23
+ @torch.no_grad()
24
+ def _orthogonalize_module(module: nn.Module, Q: torch.Tensor) -> None:
25
+ """Remove ``span(Q)`` from a residual-writing module's output."""
26
+ W = module.weight.data
27
+ Qd = Q.to(dtype=W.dtype, device=W.device) # (k, D)
28
+
29
+ if utils.is_conv1d(module):
30
+ # GPT-2 Conv1D: y = x @ W, W is (in, out=D). Output dim is dim 1.
31
+ # W <- W (I - QᵀQ) = W - (W Qᵀ) Q
32
+ module.weight.data = W - (W @ Qd.T) @ Qd
33
+ else:
34
+ # nn.Linear: y = W x (+ b), W is (out=D, in). Output dim is dim 0.
35
+ # W <- (I - QᵀQ) W = W - Qᵀ (Q W)
36
+ module.weight.data = W - Qd.T @ (Qd @ W)
37
+
38
+ bias = getattr(module, "bias", None)
39
+ if bias is not None:
40
+ b = bias.data
41
+ bq = Qd.to(b.dtype)
42
+ bias.data = b - bq.T @ (bq @ b)
43
+
44
+
45
+ @torch.no_grad()
46
+ def _orthogonalize_embedding(emb: nn.Embedding, Q: torch.Tensor) -> None:
47
+ W = emb.weight.data # (vocab, D): each row is a residual vector
48
+ Qd = Q.to(dtype=W.dtype, device=W.device)
49
+ emb.weight.data = W - (W @ Qd.T) @ Qd
50
+
51
+
52
+ @torch.no_grad()
53
+ def bake_subspace(model: nn.Module, basis: torch.Tensor, include_embedding: bool = True) -> None:
54
+ """Permanently orthogonalize ``model``'s weights against ``span(basis)``.
55
+
56
+ Mutates ``model`` in place. ``basis`` may be a ``(D,)`` direction or a
57
+ ``(k, D)`` basis; it is orthonormalized defensively.
58
+ """
59
+ Q = orthonormalize(as_basis(basis))
60
+ if Q.shape[0] == 0:
61
+ return
62
+ if include_embedding:
63
+ _orthogonalize_embedding(utils.get_embedding(model), Q)
64
+ for layer in utils.get_decoder_layers(model):
65
+ for writer in utils.get_residual_writers(layer):
66
+ _orthogonalize_module(writer, Q)
67
+
68
+
69
+ # Backward-compatible single-direction alias.
70
+ def bake_direction(model: nn.Module, direction: torch.Tensor, include_embedding: bool = True) -> None:
71
+ bake_subspace(model, direction, include_embedding)