brainpatch 1.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.
- brainpatch/__init__.py +92 -0
- brainpatch/backends/__init__.py +19 -0
- brainpatch/backends/llamacpp.py +383 -0
- brainpatch/backends/mlx_backend.py +213 -0
- brainpatch/backends/transformers_backend.py +473 -0
- brainpatch/backends/vllm_backend.py +299 -0
- brainpatch/backends/vllm_worker.py +129 -0
- brainpatch/cli.py +825 -0
- brainpatch/config.py +245 -0
- brainpatch/datasets/__init__.py +20 -0
- brainpatch/datasets/contrast_sets.py +64 -0
- brainpatch/evaluation/__init__.py +28 -0
- brainpatch/evaluation/metrics.py +223 -0
- brainpatch/patch/__init__.py +64 -0
- brainpatch/patch/compiler.py +324 -0
- brainpatch/patch/format.py +489 -0
- brainpatch/patch/loader.py +312 -0
- brainpatch/patch/registry.py +300 -0
- brainpatch/patch/tensors.py +236 -0
- brainpatch/patch/validation.py +157 -0
- brainpatch/paths.py +184 -0
- brainpatch/py.typed +0 -0
- brainpatch/research/__init__.py +16 -0
- brainpatch/research/antisycophancy.py +348 -0
- brainpatch/research/behaviour_eval.py +711 -0
- brainpatch/research/generation_eval.py +346 -0
- brainpatch/research/ml/__init__.py +35 -0
- brainpatch/research/ml/activation_store.py +232 -0
- brainpatch/research/ml/causal.py +386 -0
- brainpatch/research/ml/corpus.py +165 -0
- brainpatch/research/ml/evaluation.py +188 -0
- brainpatch/research/ml/extraction.py +464 -0
- brainpatch/research/ml/feature_analysis.py +317 -0
- brainpatch/research/ml/generation.py +109 -0
- brainpatch/research/ml/hooks.py +183 -0
- brainpatch/research/ml/intervention.py +274 -0
- brainpatch/research/ml/model.py +219 -0
- brainpatch/research/ml/patch_search.py +337 -0
- brainpatch/research/ml/runtime.py +343 -0
- brainpatch/research/ml/sae.py +383 -0
- brainpatch/research/ml/training.py +376 -0
- brainpatch/research/stance_rubric.py +170 -0
- brainpatch/research/sycophancy_data.py +982 -0
- brainpatch/research/sycophancy_data_r1.py +1701 -0
- brainpatch/research/sycophancy_data_v2.py +1649 -0
- brainpatch/research/sycophancy_data_v3.py +2288 -0
- brainpatch/research/sycophancy_v2_build.py +362 -0
- brainpatch/research/sycophancy_v3_build.py +188 -0
- brainpatch/research/utility_probe.py +139 -0
- brainpatch/runtime/__init__.py +50 -0
- brainpatch/runtime/auto.py +157 -0
- brainpatch/runtime/base.py +311 -0
- brainpatch/runtime/capabilities.py +96 -0
- brainpatch/runtime/model.py +260 -0
- brainpatch/runtime/scheduling.py +13 -0
- brainpatch/schemas/__init__.py +35 -0
- brainpatch/schemas/contrast.py +161 -0
- brainpatch/schemas/feature.py +193 -0
- brainpatch/schemas/manifest.py +167 -0
- brainpatch/schemas/patch.py +379 -0
- brainpatch/schemas/patch_io.py +88 -0
- brainpatch/schemas/sae.py +146 -0
- brainpatch/server/__init__.py +11 -0
- brainpatch/server/app.py +269 -0
- brainpatch/steering/__init__.py +13 -0
- brainpatch/steering/plan.py +177 -0
- brainpatch/steering/schedule.py +138 -0
- brainpatch/ui/__init__.py +11 -0
- brainpatch/ui/app.py +201 -0
- brainpatch/verify/__init__.py +66 -0
- brainpatch/verify/behavioural.py +156 -0
- brainpatch/verify/checks.py +204 -0
- brainpatch/verify/corruptions.py +335 -0
- brainpatch/verify/report.py +133 -0
- brainpatch/verify/vectors.py +95 -0
- brainpatch/verify/workflow.py +331 -0
- brainpatch-1.2.0.dist-info/METADATA +556 -0
- brainpatch-1.2.0.dist-info/RECORD +82 -0
- brainpatch-1.2.0.dist-info/WHEEL +5 -0
- brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
- brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
- brainpatch-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Runtime feature injection into the residual stream.
|
|
2
|
+
|
|
3
|
+
A :class:`FeatureSteerer` owns one SAE and one hook site, and turns an
|
|
4
|
+
:class:`~brainpatch.steering.plan.InterventionPlan` into a concrete vector added
|
|
5
|
+
to the residual stream on each forward pass.
|
|
6
|
+
|
|
7
|
+
Scale
|
|
8
|
+
-----
|
|
9
|
+
The SAE was trained on activations multiplied by ``input_scale`` so that
|
|
10
|
+
``E[||x||] == sqrt(d_in)``. Decoder columns therefore live in normalized space.
|
|
11
|
+
To inject a direction back into the *raw* residual stream we divide by that
|
|
12
|
+
scale::
|
|
13
|
+
|
|
14
|
+
delta_raw = (coefficient * unit_direction) / input_scale
|
|
15
|
+
|
|
16
|
+
This is the step that makes ``strength`` portable: strength 1.0 adds one unit of
|
|
17
|
+
normalized activation-space distance regardless of how large the raw residual
|
|
18
|
+
stream happens to be at that layer.
|
|
19
|
+
|
|
20
|
+
Token indexing
|
|
21
|
+
--------------
|
|
22
|
+
With a KV cache, generation runs one forward pass over the whole prompt and then
|
|
23
|
+
one pass per new token. The steerer counts passes so that "generated token
|
|
24
|
+
index" means what a user expects: index 0 is the first token the model emits,
|
|
25
|
+
and the prompt is not counted.
|
|
26
|
+
|
|
27
|
+
Controls
|
|
28
|
+
--------
|
|
29
|
+
:class:`RandomDirectionSteerer` and unrelated-feature interventions use the same
|
|
30
|
+
coefficient path as the real thing. Since both decoder columns and the random
|
|
31
|
+
directions are unit-norm, the injected vectors have *identical* L2 norms by
|
|
32
|
+
construction -- the control differs only in direction. That is the comparison
|
|
33
|
+
that makes an effect attributable to the feature rather than to the magnitude of
|
|
34
|
+
the perturbation.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
import torch
|
|
43
|
+
|
|
44
|
+
from brainpatch.research.ml.hooks import ResidualInjector
|
|
45
|
+
from brainpatch.research.ml.sae import TopKSAE
|
|
46
|
+
from brainpatch.steering.plan import InterventionPlan, PlannedEdit
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SteeringStats:
|
|
51
|
+
"""Bookkeeping that lets a caller verify an intervention actually happened."""
|
|
52
|
+
|
|
53
|
+
forward_passes: int = 0
|
|
54
|
+
applied_passes: int = 0
|
|
55
|
+
total_delta_norm: float = 0.0
|
|
56
|
+
max_delta_norm: float = 0.0
|
|
57
|
+
#: ``(generated_token_index, delta_norm)`` for every forward pass, including
|
|
58
|
+
#: passes where nothing was applied (norm 0.0). Recording the skipped passes
|
|
59
|
+
#: too is what makes this a usable trace of a dynamic schedule -- otherwise
|
|
60
|
+
#: the list index would silently stop matching the token index.
|
|
61
|
+
per_token_norms: list[tuple[int, float]] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def mean_delta_norm(self) -> float:
|
|
65
|
+
return self.total_delta_norm / self.applied_passes if self.applied_passes else 0.0
|
|
66
|
+
|
|
67
|
+
def to_dict(self, *, include_per_token: bool = False) -> dict[str, Any]:
|
|
68
|
+
data: dict[str, Any] = {
|
|
69
|
+
"forward_passes": self.forward_passes,
|
|
70
|
+
"applied_passes": self.applied_passes,
|
|
71
|
+
"mean_delta_norm": self.mean_delta_norm,
|
|
72
|
+
"max_delta_norm": self.max_delta_norm,
|
|
73
|
+
}
|
|
74
|
+
if include_per_token:
|
|
75
|
+
data["per_token_norms"] = [[i, round(n, 5)] for i, n in self.per_token_norms]
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FeatureSteerer:
|
|
80
|
+
"""Builds and applies residual-stream deltas for one hook site."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
sae: TopKSAE,
|
|
85
|
+
plan: InterventionPlan,
|
|
86
|
+
*,
|
|
87
|
+
layer: int,
|
|
88
|
+
input_scale: float,
|
|
89
|
+
device: torch.device | str = "cuda",
|
|
90
|
+
apply_to_prompt: bool = True,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.sae = sae
|
|
93
|
+
self.plan = plan
|
|
94
|
+
self.layer = layer
|
|
95
|
+
self.input_scale = float(input_scale)
|
|
96
|
+
if self.input_scale == 0:
|
|
97
|
+
raise ValueError("input_scale must be non-zero")
|
|
98
|
+
self.device = torch.device(device)
|
|
99
|
+
self.apply_to_prompt = apply_to_prompt
|
|
100
|
+
|
|
101
|
+
self._pass_index = 0
|
|
102
|
+
self.stats = SteeringStats()
|
|
103
|
+
#: Cache of unit decoder directions, so a long generation does not
|
|
104
|
+
#: re-slice and re-normalize the same columns thousands of times.
|
|
105
|
+
self._direction_cache: dict[int, torch.Tensor] = {}
|
|
106
|
+
|
|
107
|
+
# -- lifecycle -------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def reset(self) -> None:
|
|
110
|
+
"""Reset token counting and stats. Call before every generation."""
|
|
111
|
+
self._pass_index = 0
|
|
112
|
+
self.stats = SteeringStats()
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def generated_index(self) -> int:
|
|
116
|
+
"""Generated-token index for the pass currently being processed.
|
|
117
|
+
|
|
118
|
+
``-1`` during the prompt pass, then 0, 1, 2, ...
|
|
119
|
+
"""
|
|
120
|
+
return self._pass_index - 1
|
|
121
|
+
|
|
122
|
+
# -- directions ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def direction(self, feature_id: int) -> torch.Tensor:
|
|
125
|
+
"""Unit-norm decoder column for ``feature_id``, cached and on-device."""
|
|
126
|
+
cached = self._direction_cache.get(feature_id)
|
|
127
|
+
if cached is None:
|
|
128
|
+
cached = self.sae.feature_direction(feature_id, normalize=True).to(self.device)
|
|
129
|
+
self._direction_cache[feature_id] = cached
|
|
130
|
+
return cached
|
|
131
|
+
|
|
132
|
+
def build_delta(self, edits: list[PlannedEdit], hidden: torch.Tensor) -> torch.Tensor | None:
|
|
133
|
+
"""Combine planned edits into one residual-stream delta.
|
|
134
|
+
|
|
135
|
+
Returns ``None`` when there is nothing to do, which propagates through
|
|
136
|
+
:class:`~brainpatch.research.ml.hooks.ResidualInjector` as "leave the tensor
|
|
137
|
+
completely untouched".
|
|
138
|
+
"""
|
|
139
|
+
additive = [e for e in edits if e.mode == "add"]
|
|
140
|
+
ablations = [e for e in edits if e.mode == "ablate"]
|
|
141
|
+
if not additive and not ablations:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
delta = torch.zeros(self.sae.d_in, dtype=torch.float32, device=self.device)
|
|
145
|
+
for edit in additive:
|
|
146
|
+
delta += edit.coefficient * self.direction(edit.feature_id)
|
|
147
|
+
delta = delta / self.input_scale
|
|
148
|
+
|
|
149
|
+
if ablations:
|
|
150
|
+
delta = delta + self._ablation_delta(ablations, hidden)
|
|
151
|
+
return delta
|
|
152
|
+
|
|
153
|
+
def _ablation_delta(self, edits: list[PlannedEdit], hidden: torch.Tensor) -> torch.Tensor:
|
|
154
|
+
"""Subtract each feature's *measured* contribution at this position.
|
|
155
|
+
|
|
156
|
+
Unlike additive steering, ablation depends on the current activation:
|
|
157
|
+
it encodes the residual stream, reads how strongly the feature is
|
|
158
|
+
firing right now, and removes exactly that much of its direction.
|
|
159
|
+
"""
|
|
160
|
+
with torch.no_grad():
|
|
161
|
+
x = hidden[:, -1, :].to(torch.float32) * self.input_scale
|
|
162
|
+
sparse, _, _ = self.sae.encode(x)
|
|
163
|
+
delta = torch.zeros(self.sae.d_in, dtype=torch.float32, device=self.device)
|
|
164
|
+
for edit in edits:
|
|
165
|
+
magnitude = sparse[:, edit.feature_id].mean()
|
|
166
|
+
# coefficient scales how much of the contribution is removed:
|
|
167
|
+
# -1.0 removes it entirely, -0.5 halves it.
|
|
168
|
+
delta += edit.coefficient * magnitude * self.direction(edit.feature_id)
|
|
169
|
+
return delta / self.input_scale
|
|
170
|
+
|
|
171
|
+
# -- hook callback ---------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def __call__(self, hidden: torch.Tensor) -> torch.Tensor | None:
|
|
174
|
+
"""Delta callback for :class:`~brainpatch.research.ml.hooks.ResidualInjector`."""
|
|
175
|
+
self.stats.forward_passes += 1
|
|
176
|
+
is_prompt_pass = self._pass_index == 0
|
|
177
|
+
self._pass_index += 1
|
|
178
|
+
token_index = max(0, self.generated_index)
|
|
179
|
+
|
|
180
|
+
def skip() -> None:
|
|
181
|
+
self.stats.per_token_norms.append((self.generated_index, 0.0))
|
|
182
|
+
|
|
183
|
+
if is_prompt_pass and not self.apply_to_prompt:
|
|
184
|
+
skip()
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
edits = self.plan.edits_at(token_index, layer=self.layer)
|
|
188
|
+
if not edits:
|
|
189
|
+
skip()
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
delta = self.build_delta(edits, hidden)
|
|
193
|
+
if delta is None:
|
|
194
|
+
skip()
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
norm = float(delta.norm().item())
|
|
198
|
+
self.stats.applied_passes += 1
|
|
199
|
+
self.stats.total_delta_norm += norm
|
|
200
|
+
self.stats.max_delta_norm = max(self.stats.max_delta_norm, norm)
|
|
201
|
+
self.stats.per_token_norms.append((self.generated_index, norm))
|
|
202
|
+
|
|
203
|
+
# Broadcast over [batch, seq, hidden]. During the prompt pass this
|
|
204
|
+
# steers every prompt position; afterwards there is only one position.
|
|
205
|
+
return delta.view(1, 1, -1)
|
|
206
|
+
|
|
207
|
+
def make_injector(self) -> ResidualInjector:
|
|
208
|
+
return ResidualInjector(self, name=f"steer-L{self.layer}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class RandomDirectionSteerer(FeatureSteerer):
|
|
212
|
+
"""Scale-matched random-direction control.
|
|
213
|
+
|
|
214
|
+
Replaces every decoder column with a fixed random unit vector drawn from a
|
|
215
|
+
seeded generator. Because both are unit-norm and the coefficient path is
|
|
216
|
+
unchanged, the injected delta has *exactly* the same L2 norm as the real
|
|
217
|
+
intervention it controls for. Any behavioural difference between the two is
|
|
218
|
+
therefore attributable to direction, not magnitude.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, *args, control_seed: int = 1234, **kwargs) -> None:
|
|
222
|
+
super().__init__(*args, **kwargs)
|
|
223
|
+
self.control_seed = control_seed
|
|
224
|
+
|
|
225
|
+
def direction(self, feature_id: int) -> torch.Tensor:
|
|
226
|
+
cached = self._direction_cache.get(feature_id)
|
|
227
|
+
if cached is None:
|
|
228
|
+
generator = torch.Generator(device="cpu").manual_seed(self.control_seed + feature_id)
|
|
229
|
+
vector = torch.randn(self.sae.d_in, generator=generator)
|
|
230
|
+
cached = (vector / vector.norm()).to(self.device)
|
|
231
|
+
self._direction_cache[feature_id] = cached
|
|
232
|
+
return cached
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def make_steerer(
|
|
236
|
+
sae: TopKSAE,
|
|
237
|
+
plan: InterventionPlan,
|
|
238
|
+
*,
|
|
239
|
+
layer: int,
|
|
240
|
+
input_scale: float,
|
|
241
|
+
device: torch.device | str = "cuda",
|
|
242
|
+
control: str = "none",
|
|
243
|
+
control_seed: int = 1234,
|
|
244
|
+
apply_to_prompt: bool = True,
|
|
245
|
+
) -> FeatureSteerer:
|
|
246
|
+
"""Build the steerer for a condition.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
control:
|
|
251
|
+
``"none"`` for the real intervention, ``"random"`` for the scale-matched
|
|
252
|
+
random-direction control. The unrelated-feature control is expressed by
|
|
253
|
+
building a plan over different feature IDs, not by a different class.
|
|
254
|
+
"""
|
|
255
|
+
if control == "random":
|
|
256
|
+
return RandomDirectionSteerer(
|
|
257
|
+
sae,
|
|
258
|
+
plan,
|
|
259
|
+
layer=layer,
|
|
260
|
+
input_scale=input_scale,
|
|
261
|
+
device=device,
|
|
262
|
+
apply_to_prompt=apply_to_prompt,
|
|
263
|
+
control_seed=control_seed,
|
|
264
|
+
)
|
|
265
|
+
if control != "none":
|
|
266
|
+
raise ValueError(f"unknown control type {control!r}; expected 'none' or 'random'")
|
|
267
|
+
return FeatureSteerer(
|
|
268
|
+
sae,
|
|
269
|
+
plan,
|
|
270
|
+
layer=layer,
|
|
271
|
+
input_scale=input_scale,
|
|
272
|
+
device=device,
|
|
273
|
+
apply_to_prompt=apply_to_prompt,
|
|
274
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Loading the frozen base model and discovering its architecture.
|
|
2
|
+
|
|
3
|
+
Two principles here:
|
|
4
|
+
|
|
5
|
+
1. **Nothing is hardcoded that can be discovered.** Layer count, hidden size and
|
|
6
|
+
the list of decoder blocks are read off the loaded model, so the same code
|
|
7
|
+
works if the base model is swapped. A configured target layer is validated
|
|
8
|
+
against the real depth rather than assumed to exist.
|
|
9
|
+
|
|
10
|
+
2. **The model is frozen.** ``requires_grad_(False)`` plus ``eval()`` on every
|
|
11
|
+
load path. BrainPatch never fine-tunes; every behavioural change comes from
|
|
12
|
+
activations, not weights.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import torch
|
|
22
|
+
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
|
|
23
|
+
|
|
24
|
+
DEFAULT_MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
|
|
25
|
+
|
|
26
|
+
#: Hook site naming. ``residual_post`` is the output of decoder block *i*,
|
|
27
|
+
#: i.e. the residual stream after that block has written to it. This is the
|
|
28
|
+
#: standard site for SAE work because it is where a block's contribution is
|
|
29
|
+
#: visible and where an injection propagates to every later block.
|
|
30
|
+
HOOK_RESIDUAL_POST = "residual_post"
|
|
31
|
+
SUPPORTED_HOOKS = (HOOK_RESIDUAL_POST,)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ModelBundle:
|
|
36
|
+
"""A loaded, frozen model with its tokenizer and discovered architecture."""
|
|
37
|
+
|
|
38
|
+
model: Any
|
|
39
|
+
tokenizer: Any
|
|
40
|
+
model_id: str
|
|
41
|
+
revision: str
|
|
42
|
+
hidden_size: int
|
|
43
|
+
num_layers: int
|
|
44
|
+
dtype: torch.dtype
|
|
45
|
+
device: torch.device
|
|
46
|
+
|
|
47
|
+
def decoder_layers(self) -> Any:
|
|
48
|
+
"""The ``nn.ModuleList`` of decoder blocks."""
|
|
49
|
+
return _find_decoder_layers(self.model)
|
|
50
|
+
|
|
51
|
+
def layer_module(self, layer: int) -> Any:
|
|
52
|
+
"""The decoder block at ``layer``, validated against the real depth."""
|
|
53
|
+
validate_layer(layer, self.num_layers)
|
|
54
|
+
return self.decoder_layers()[layer]
|
|
55
|
+
|
|
56
|
+
def describe(self) -> dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"model": self.model_id,
|
|
59
|
+
"revision": self.revision,
|
|
60
|
+
"hidden_size": self.hidden_size,
|
|
61
|
+
"num_layers": self.num_layers,
|
|
62
|
+
"dtype": str(self.dtype).replace("torch.", ""),
|
|
63
|
+
"device": str(self.device),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_layer(layer: int, num_layers: int) -> int:
|
|
68
|
+
"""Raise if ``layer`` is not a real decoder block index.
|
|
69
|
+
|
|
70
|
+
Negative indices count from the end, matching Python convention, and are
|
|
71
|
+
resolved to a positive index.
|
|
72
|
+
"""
|
|
73
|
+
resolved = layer if layer >= 0 else num_layers + layer
|
|
74
|
+
if not 0 <= resolved < num_layers:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"layer {layer} does not exist: this model has {num_layers} decoder "
|
|
77
|
+
f"blocks (valid indices 0..{num_layers - 1})"
|
|
78
|
+
)
|
|
79
|
+
return resolved
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_hook(hook: str) -> str:
|
|
83
|
+
if hook not in SUPPORTED_HOOKS:
|
|
84
|
+
raise ValueError(f"unsupported hook site {hook!r}; supported: {SUPPORTED_HOOKS}")
|
|
85
|
+
return hook
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _find_decoder_layers(model: Any) -> Any:
|
|
89
|
+
"""Locate the decoder-block ``ModuleList`` without hardcoding a path.
|
|
90
|
+
|
|
91
|
+
Tries the common Hugging Face layouts in order, then falls back to scanning
|
|
92
|
+
for the largest ``ModuleList`` of identically-typed modules.
|
|
93
|
+
"""
|
|
94
|
+
for path in ("model.layers", "transformer.h", "gpt_neox.layers", "model.decoder.layers"):
|
|
95
|
+
node: Any = model
|
|
96
|
+
for part in path.split("."):
|
|
97
|
+
node = getattr(node, part, None)
|
|
98
|
+
if node is None:
|
|
99
|
+
break
|
|
100
|
+
if node is not None and isinstance(node, torch.nn.ModuleList) and len(node) > 0:
|
|
101
|
+
return node
|
|
102
|
+
|
|
103
|
+
best: Any = None
|
|
104
|
+
for module in model.modules():
|
|
105
|
+
if isinstance(module, torch.nn.ModuleList) and len(module) > 1:
|
|
106
|
+
types = {type(m) for m in module}
|
|
107
|
+
if len(types) == 1 and (best is None or len(module) > len(best)):
|
|
108
|
+
best = module
|
|
109
|
+
if best is None:
|
|
110
|
+
raise RuntimeError(
|
|
111
|
+
f"could not locate decoder blocks on {type(model).__name__}; "
|
|
112
|
+
"add its layout to _find_decoder_layers"
|
|
113
|
+
)
|
|
114
|
+
return best
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_revision(model_id: str, revision: str | None = None) -> str:
|
|
118
|
+
"""Resolve a branch name to an immutable commit SHA.
|
|
119
|
+
|
|
120
|
+
Pinning the SHA matters: feature directions are properties of a specific set
|
|
121
|
+
of weights, so an experiment that only records "main" is not reproducible.
|
|
122
|
+
"""
|
|
123
|
+
from huggingface_hub import HfApi
|
|
124
|
+
|
|
125
|
+
info = HfApi().model_info(model_id, revision=revision or "main")
|
|
126
|
+
return info.sha
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_model(
|
|
130
|
+
model_id: str = DEFAULT_MODEL,
|
|
131
|
+
*,
|
|
132
|
+
revision: str | None = None,
|
|
133
|
+
dtype: str = "bfloat16",
|
|
134
|
+
device: str = "cuda",
|
|
135
|
+
trust_remote_code: bool = False,
|
|
136
|
+
) -> ModelBundle:
|
|
137
|
+
"""Load a frozen causal LM from the Volume-backed Hugging Face cache.
|
|
138
|
+
|
|
139
|
+
The cache location comes from ``HF_HOME`` / ``HF_HUB_CACHE``, which the
|
|
140
|
+
Modal image points at ``/vol/hf-cache``. Nothing is downloaded twice and
|
|
141
|
+
nothing lands on ephemeral container storage.
|
|
142
|
+
"""
|
|
143
|
+
torch_dtype = getattr(torch, dtype)
|
|
144
|
+
resolved_device = torch.device(device)
|
|
145
|
+
|
|
146
|
+
tokenizer = AutoTokenizer.from_pretrained(
|
|
147
|
+
model_id, revision=revision, trust_remote_code=trust_remote_code
|
|
148
|
+
)
|
|
149
|
+
model = AutoModelForCausalLM.from_pretrained(
|
|
150
|
+
model_id,
|
|
151
|
+
revision=revision,
|
|
152
|
+
torch_dtype=torch_dtype,
|
|
153
|
+
trust_remote_code=trust_remote_code,
|
|
154
|
+
low_cpu_mem_usage=True,
|
|
155
|
+
)
|
|
156
|
+
model.to(resolved_device)
|
|
157
|
+
|
|
158
|
+
# Frozen for the entire lifetime of the process. BrainPatch never trains
|
|
159
|
+
# the base model; every effect must come from activations.
|
|
160
|
+
model.eval()
|
|
161
|
+
model.requires_grad_(False)
|
|
162
|
+
|
|
163
|
+
config = model.config
|
|
164
|
+
hidden_size = int(getattr(config, "hidden_size", getattr(config, "n_embd", 0)))
|
|
165
|
+
if hidden_size <= 0:
|
|
166
|
+
raise RuntimeError(f"could not discover hidden size for {model_id}")
|
|
167
|
+
num_layers = len(_find_decoder_layers(model))
|
|
168
|
+
|
|
169
|
+
effective_revision = revision or _commit_hash_from_cache(model) or "unknown"
|
|
170
|
+
|
|
171
|
+
return ModelBundle(
|
|
172
|
+
model=model,
|
|
173
|
+
tokenizer=tokenizer,
|
|
174
|
+
model_id=model_id,
|
|
175
|
+
revision=effective_revision,
|
|
176
|
+
hidden_size=hidden_size,
|
|
177
|
+
num_layers=num_layers,
|
|
178
|
+
dtype=torch_dtype,
|
|
179
|
+
device=resolved_device,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _commit_hash_from_cache(model: Any) -> str | None:
|
|
184
|
+
"""Best-effort recovery of the commit SHA transformers actually loaded."""
|
|
185
|
+
name_or_path = getattr(model.config, "_name_or_path", "") or ""
|
|
186
|
+
# Cached snapshots live at .../snapshots/<sha>/...
|
|
187
|
+
parts = str(name_or_path).replace("\\", "/").split("/")
|
|
188
|
+
if "snapshots" in parts:
|
|
189
|
+
idx = parts.index("snapshots")
|
|
190
|
+
if idx + 1 < len(parts):
|
|
191
|
+
return parts[idx + 1]
|
|
192
|
+
commit = getattr(model.config, "_commit_hash", None)
|
|
193
|
+
return str(commit) if commit else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def architecture_summary(model_id: str = DEFAULT_MODEL, revision: str | None = None) -> dict[str, Any]:
|
|
197
|
+
"""Read architecture facts from the config alone -- no weights loaded.
|
|
198
|
+
|
|
199
|
+
Cheap enough to run on CPU, which is how the layer choice is validated
|
|
200
|
+
before any GPU time is spent.
|
|
201
|
+
"""
|
|
202
|
+
config = AutoConfig.from_pretrained(model_id, revision=revision)
|
|
203
|
+
return {
|
|
204
|
+
"model": model_id,
|
|
205
|
+
"model_type": config.model_type,
|
|
206
|
+
"hidden_size": int(config.hidden_size),
|
|
207
|
+
"num_hidden_layers": int(config.num_hidden_layers),
|
|
208
|
+
"num_attention_heads": int(config.num_attention_heads),
|
|
209
|
+
"num_key_value_heads": int(getattr(config, "num_key_value_heads", config.num_attention_heads)),
|
|
210
|
+
"intermediate_size": int(config.intermediate_size),
|
|
211
|
+
"vocab_size": int(config.vocab_size),
|
|
212
|
+
"max_position_embeddings": int(config.max_position_embeddings),
|
|
213
|
+
"torch_dtype": str(getattr(config, "torch_dtype", "unknown")),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def hf_token_present() -> bool:
|
|
218
|
+
"""Whether an HF token is available, without ever revealing it."""
|
|
219
|
+
return bool(os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"))
|