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/__init__.py +67 -0
- ablate/_data/__init__.py +0 -0
- ablate/_data/harmful.txt +43 -0
- ablate/_data/harmless.txt +43 -0
- ablate/cli.py +170 -0
- ablate/config.py +110 -0
- ablate/data.py +131 -0
- ablate/evaluate.py +138 -0
- ablate/extract.py +133 -0
- ablate/harness.py +98 -0
- ablate/hooks.py +79 -0
- ablate/hub.py +163 -0
- ablate/judges.py +177 -0
- ablate/model.py +100 -0
- ablate/optimize.py +178 -0
- ablate/pipeline.py +343 -0
- ablate/subspace.py +103 -0
- ablate/utils.py +129 -0
- ablate/weights.py +71 -0
- ablate_llm-0.2.0.dist-info/METADATA +389 -0
- ablate_llm-0.2.0.dist-info/RECORD +25 -0
- ablate_llm-0.2.0.dist-info/WHEEL +5 -0
- ablate_llm-0.2.0.dist-info/entry_points.txt +2 -0
- ablate_llm-0.2.0.dist-info/licenses/LICENSE +21 -0
- ablate_llm-0.2.0.dist-info/top_level.txt +1 -0
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)
|