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.
Files changed (82) hide show
  1. brainpatch/__init__.py +92 -0
  2. brainpatch/backends/__init__.py +19 -0
  3. brainpatch/backends/llamacpp.py +383 -0
  4. brainpatch/backends/mlx_backend.py +213 -0
  5. brainpatch/backends/transformers_backend.py +473 -0
  6. brainpatch/backends/vllm_backend.py +299 -0
  7. brainpatch/backends/vllm_worker.py +129 -0
  8. brainpatch/cli.py +825 -0
  9. brainpatch/config.py +245 -0
  10. brainpatch/datasets/__init__.py +20 -0
  11. brainpatch/datasets/contrast_sets.py +64 -0
  12. brainpatch/evaluation/__init__.py +28 -0
  13. brainpatch/evaluation/metrics.py +223 -0
  14. brainpatch/patch/__init__.py +64 -0
  15. brainpatch/patch/compiler.py +324 -0
  16. brainpatch/patch/format.py +489 -0
  17. brainpatch/patch/loader.py +312 -0
  18. brainpatch/patch/registry.py +300 -0
  19. brainpatch/patch/tensors.py +236 -0
  20. brainpatch/patch/validation.py +157 -0
  21. brainpatch/paths.py +184 -0
  22. brainpatch/py.typed +0 -0
  23. brainpatch/research/__init__.py +16 -0
  24. brainpatch/research/antisycophancy.py +348 -0
  25. brainpatch/research/behaviour_eval.py +711 -0
  26. brainpatch/research/generation_eval.py +346 -0
  27. brainpatch/research/ml/__init__.py +35 -0
  28. brainpatch/research/ml/activation_store.py +232 -0
  29. brainpatch/research/ml/causal.py +386 -0
  30. brainpatch/research/ml/corpus.py +165 -0
  31. brainpatch/research/ml/evaluation.py +188 -0
  32. brainpatch/research/ml/extraction.py +464 -0
  33. brainpatch/research/ml/feature_analysis.py +317 -0
  34. brainpatch/research/ml/generation.py +109 -0
  35. brainpatch/research/ml/hooks.py +183 -0
  36. brainpatch/research/ml/intervention.py +274 -0
  37. brainpatch/research/ml/model.py +219 -0
  38. brainpatch/research/ml/patch_search.py +337 -0
  39. brainpatch/research/ml/runtime.py +343 -0
  40. brainpatch/research/ml/sae.py +383 -0
  41. brainpatch/research/ml/training.py +376 -0
  42. brainpatch/research/stance_rubric.py +170 -0
  43. brainpatch/research/sycophancy_data.py +982 -0
  44. brainpatch/research/sycophancy_data_r1.py +1701 -0
  45. brainpatch/research/sycophancy_data_v2.py +1649 -0
  46. brainpatch/research/sycophancy_data_v3.py +2288 -0
  47. brainpatch/research/sycophancy_v2_build.py +362 -0
  48. brainpatch/research/sycophancy_v3_build.py +188 -0
  49. brainpatch/research/utility_probe.py +139 -0
  50. brainpatch/runtime/__init__.py +50 -0
  51. brainpatch/runtime/auto.py +157 -0
  52. brainpatch/runtime/base.py +311 -0
  53. brainpatch/runtime/capabilities.py +96 -0
  54. brainpatch/runtime/model.py +260 -0
  55. brainpatch/runtime/scheduling.py +13 -0
  56. brainpatch/schemas/__init__.py +35 -0
  57. brainpatch/schemas/contrast.py +161 -0
  58. brainpatch/schemas/feature.py +193 -0
  59. brainpatch/schemas/manifest.py +167 -0
  60. brainpatch/schemas/patch.py +379 -0
  61. brainpatch/schemas/patch_io.py +88 -0
  62. brainpatch/schemas/sae.py +146 -0
  63. brainpatch/server/__init__.py +11 -0
  64. brainpatch/server/app.py +269 -0
  65. brainpatch/steering/__init__.py +13 -0
  66. brainpatch/steering/plan.py +177 -0
  67. brainpatch/steering/schedule.py +138 -0
  68. brainpatch/ui/__init__.py +11 -0
  69. brainpatch/ui/app.py +201 -0
  70. brainpatch/verify/__init__.py +66 -0
  71. brainpatch/verify/behavioural.py +156 -0
  72. brainpatch/verify/checks.py +204 -0
  73. brainpatch/verify/corruptions.py +335 -0
  74. brainpatch/verify/report.py +133 -0
  75. brainpatch/verify/vectors.py +95 -0
  76. brainpatch/verify/workflow.py +331 -0
  77. brainpatch-1.2.0.dist-info/METADATA +556 -0
  78. brainpatch-1.2.0.dist-info/RECORD +82 -0
  79. brainpatch-1.2.0.dist-info/WHEEL +5 -0
  80. brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
  81. brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
  82. brainpatch-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,383 @@
1
+ """Top-K sparse autoencoder.
2
+
3
+ Forward pass::
4
+
5
+ z_pre = W_enc @ (x - b_dec) + b_enc
6
+ z = TopK(ReLU(z_pre)) # AT MOST k non-zeros -- see below
7
+ x_hat = W_dec @ z + b_dec
8
+
9
+ Design choices, and why each one is load-bearing for *intervention* rather than
10
+ just for reconstruction quality:
11
+
12
+ **Top-K instead of an L1 penalty.**
13
+ L1 sparsity requires tuning a coefficient whose right value depends on the
14
+ activation scale, and it shrinks every activation toward zero, biasing
15
+ magnitudes. Top-K caps L0 at ``k``, so sparsity is a hyperparameter rather
16
+ than an outcome, and activations are unbiased.
17
+
18
+ **L0 is an upper bound, not an identity.**
19
+ ``torch.topk`` always returns exactly ``k`` indices, but the ReLU in front
20
+ of it means some of the selected *values* can be zero -- whenever fewer
21
+ than ``k`` encoder pre-activations are positive for a token. Those entries
22
+ scatter zeros into the sparse tensor, so the reconstruction is correct and
23
+ the measured L0 (counted as ``feature_acts > 0``) correctly falls below
24
+ ``k``. What is *not* correct is treating a selected index as evidence the
25
+ feature fired: see :meth:`TopKSAE.update_liveness`, which filters on the
26
+ value rather than the index.
27
+
28
+ **Pre-encoder bias subtraction (``x - b_dec``).**
29
+ Centres the input on the decoder's own bias so the dictionary models
30
+ deviations from the mean activation rather than spending capacity on it.
31
+
32
+ **Unit-norm decoder columns.**
33
+ Without this constraint the network can halve every decoder column and
34
+ double every activation with no change in the loss. That is fatal here, not
35
+ merely untidy: a BrainPatch says "add strength 1.5 along feature 1207's
36
+ direction", and if the direction's length is arbitrary then so is the
37
+ strength. Unit norms make ``strength`` mean a fixed distance in activation
38
+ space.
39
+
40
+ **AuxK dead-feature revival.**
41
+ Top-K SAEs reliably kill features. AuxK (from the OpenAI Top-K SAE work)
42
+ asks the currently-dead features to reconstruct the residual error, which
43
+ gives them gradient signal without perturbing the main objective. Disabled
44
+ by setting ``auxk_alpha = 0``.
45
+
46
+ **Gradient projection on the decoder.**
47
+ Renormalising decoder columns after an optimizer step leaves a component of
48
+ the gradient that only changed the norm -- work the projection undoes.
49
+ Removing the parallel component first makes the constrained optimisation
50
+ behave.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from dataclasses import dataclass
56
+ from typing import Any
57
+
58
+ import torch
59
+ import torch.nn as nn
60
+ import torch.nn.functional as F
61
+
62
+ from brainpatch.schemas.sae import SAEConfig
63
+
64
+
65
+ @dataclass
66
+ class SAEOutput:
67
+ """Everything one forward pass produces, for loss and for metrics."""
68
+
69
+ reconstruction: torch.Tensor
70
+ feature_acts: torch.Tensor
71
+ """Sparse activations, ``[batch, d_sae]``, **at most** ``k`` non-zero per row.
72
+
73
+ Fewer than ``k`` when a token has fewer than ``k`` positive encoder
74
+ pre-activations. This is the tensor to count L0 from.
75
+ """
76
+ topk_indices: torch.Tensor
77
+ """``[batch, k]`` indices returned by ``torch.topk``.
78
+
79
+ Always exactly ``k`` wide. An index appearing here does **not** mean the
80
+ feature fired -- pair it with :attr:`topk_values` and require a strictly
81
+ positive value.
82
+ """
83
+ topk_values: torch.Tensor
84
+ """``[batch, k]`` selected values. Non-negative, and **may contain zeros**."""
85
+ pre_acts: torch.Tensor
86
+ """Dense post-ReLU, pre-TopK activations, needed by AuxK."""
87
+
88
+ def active_mask(self) -> torch.Tensor:
89
+ """``[batch, k]`` boolean: which Top-K selections actually fired."""
90
+ return self.topk_values > 0
91
+
92
+ def l0(self) -> torch.Tensor:
93
+ """Per-row count of strictly positive feature activations."""
94
+ return (self.feature_acts > 0).sum(dim=-1)
95
+
96
+
97
+ class TopKSAE(nn.Module):
98
+ """A Top-K sparse autoencoder over residual-stream activations."""
99
+
100
+ def __init__(self, config: SAEConfig) -> None:
101
+ super().__init__()
102
+ config.validate()
103
+ self.config = config
104
+ self.d_in = config.d_in
105
+ self.d_sae = config.d_sae
106
+ self.k = config.k
107
+
108
+ self.W_enc = nn.Parameter(torch.empty(config.d_sae, config.d_in))
109
+ self.b_enc = nn.Parameter(torch.zeros(config.d_sae))
110
+ self.W_dec = nn.Parameter(torch.empty(config.d_in, config.d_sae))
111
+ self.b_dec = nn.Parameter(torch.zeros(config.d_in))
112
+
113
+ # Feature liveness tracking. Registered as a buffer so it round-trips
114
+ # through checkpoints and resume actually resumes the dead-feature state.
115
+ self.register_buffer("tokens_since_fired", torch.zeros(config.d_sae, dtype=torch.long))
116
+ self.register_buffer("fire_count", torch.zeros(config.d_sae, dtype=torch.long))
117
+ self.register_buffer("tokens_seen", torch.zeros((), dtype=torch.long))
118
+
119
+ self._init_weights()
120
+
121
+ # -- initialisation --------------------------------------------------------
122
+
123
+ def _init_weights(self) -> None:
124
+ generator = torch.Generator().manual_seed(self.config.seed)
125
+ # Kaiming-uniform-ish scale for the decoder, then tie the encoder to it.
126
+ bound = 1.0 / (self.d_in**0.5)
127
+ w_dec = torch.empty(self.d_in, self.d_sae).uniform_(-bound, bound, generator=generator)
128
+ w_dec = w_dec / w_dec.norm(dim=0, keepdim=True).clamp_min(1e-8)
129
+ with torch.no_grad():
130
+ self.W_dec.copy_(w_dec)
131
+ if self.config.tied_init:
132
+ self.W_enc.copy_(w_dec.T.clone())
133
+ else:
134
+ self.W_enc.copy_(
135
+ torch.empty(self.d_sae, self.d_in).uniform_(-bound, bound, generator=generator)
136
+ )
137
+
138
+ @torch.no_grad()
139
+ def set_decoder_bias_to_mean(self, sample: torch.Tensor) -> None:
140
+ """Initialise ``b_dec`` to the corpus mean.
141
+
142
+ Starting the decoder bias at the data mean means the dictionary begins
143
+ by modelling deviations rather than spending its first thousand steps
144
+ learning where the centre of the distribution is.
145
+ """
146
+ self.b_dec.copy_(sample.to(self.b_dec.dtype).mean(dim=0))
147
+
148
+ # -- forward ---------------------------------------------------------------
149
+
150
+ def encode_pre(self, x: torch.Tensor) -> torch.Tensor:
151
+ """Dense pre-activations (before ReLU and Top-K)."""
152
+ return F.linear(x - self.b_dec, self.W_enc, self.b_enc)
153
+
154
+ def encode(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
155
+ """Return ``(sparse_acts, topk_indices, topk_values)``."""
156
+ pre = F.relu(self.encode_pre(x))
157
+ values, indices = torch.topk(pre, self.k, dim=-1)
158
+ sparse = torch.zeros_like(pre)
159
+ sparse.scatter_(-1, indices, values)
160
+ return sparse, indices, values
161
+
162
+ def decode(self, feature_acts: torch.Tensor) -> torch.Tensor:
163
+ """Reconstruct from sparse feature activations."""
164
+ return F.linear(feature_acts, self.W_dec, self.b_dec)
165
+
166
+ def forward(self, x: torch.Tensor) -> SAEOutput:
167
+ pre = F.relu(self.encode_pre(x))
168
+ values, indices = torch.topk(pre, self.k, dim=-1)
169
+ sparse = torch.zeros_like(pre)
170
+ sparse.scatter_(-1, indices, values)
171
+ return SAEOutput(
172
+ reconstruction=self.decode(sparse),
173
+ feature_acts=sparse,
174
+ topk_indices=indices,
175
+ topk_values=values,
176
+ pre_acts=pre,
177
+ )
178
+
179
+ # -- feature directions ----------------------------------------------------
180
+
181
+ def feature_direction(self, feature_id: int, *, normalize: bool = True) -> torch.Tensor:
182
+ """The decoder column for one feature -- the vector an intervention adds.
183
+
184
+ With ``normalize=True`` (default) the returned vector has unit L2 norm
185
+ even if the training-time constraint has drifted, so a caller's
186
+ ``strength`` always means the same distance.
187
+ """
188
+ if not 0 <= feature_id < self.d_sae:
189
+ raise IndexError(f"feature {feature_id} out of range for dictionary of size {self.d_sae}")
190
+ direction = self.W_dec[:, feature_id]
191
+ if normalize:
192
+ direction = direction / direction.norm().clamp_min(1e-8)
193
+ return direction
194
+
195
+ def decoder_norms(self) -> torch.Tensor:
196
+ """L2 norm of every decoder column."""
197
+ return self.W_dec.norm(dim=0)
198
+
199
+ # -- constraints -----------------------------------------------------------
200
+
201
+ @torch.no_grad()
202
+ def normalize_decoder(self) -> None:
203
+ """Rescale decoder columns to unit norm."""
204
+ if not self.config.normalize_decoder:
205
+ return
206
+ self.W_dec.div_(self.W_dec.norm(dim=0, keepdim=True).clamp_min(1e-8))
207
+
208
+ @torch.no_grad()
209
+ def project_decoder_grad(self) -> None:
210
+ """Remove the component of ``W_dec.grad`` parallel to each column.
211
+
212
+ Called between ``backward()`` and ``step()``. Without it, the optimizer
213
+ spends part of every update changing column norms that
214
+ :meth:`normalize_decoder` then immediately undoes.
215
+ """
216
+ if not self.config.normalize_decoder or self.W_dec.grad is None:
217
+ return
218
+ w = self.W_dec
219
+ g = self.W_dec.grad
220
+ parallel = (g * w).sum(dim=0, keepdim=True) * w / w.norm(dim=0, keepdim=True).clamp_min(1e-8) ** 2
221
+ g.sub_(parallel)
222
+
223
+ # -- liveness --------------------------------------------------------------
224
+
225
+ @torch.no_grad()
226
+ def update_liveness(
227
+ self,
228
+ indices: torch.Tensor,
229
+ values: torch.Tensor,
230
+ batch_tokens: int,
231
+ ) -> None:
232
+ """Update fire counts and the dead-feature clock.
233
+
234
+ Only selections with a **strictly positive** value count as firings.
235
+
236
+ ``torch.topk`` always returns ``k`` indices, but the ReLU in front of it
237
+ means those can include zero-valued entries whenever a token has fewer
238
+ than ``k`` positive pre-activations. Counting the raw indices would
239
+ inflate :attr:`fire_count`, and -- worse -- would reset
240
+ :attr:`tokens_since_fired` for a feature that did not fire, hiding it
241
+ from :meth:`dead_mask` and therefore from AuxK revival. A permanently
242
+ silent feature could then be reported alive forever.
243
+
244
+ Parameters
245
+ ----------
246
+ indices, values:
247
+ ``SAEOutput.topk_indices`` and ``SAEOutput.topk_values``, same shape.
248
+ batch_tokens:
249
+ Rows in this batch; advances the dead-feature clock.
250
+ """
251
+ if indices.shape != values.shape:
252
+ raise ValueError(
253
+ f"indices and values must have the same shape, got "
254
+ f"{tuple(indices.shape)} and {tuple(values.shape)}"
255
+ )
256
+
257
+ active = indices.reshape(-1)[values.reshape(-1) > 0]
258
+
259
+ counts = torch.bincount(active, minlength=self.d_sae)
260
+ self.fire_count += counts.to(self.fire_count.dtype)
261
+
262
+ self.tokens_since_fired += batch_tokens
263
+ if active.numel() > 0:
264
+ fired = torch.zeros(self.d_sae, dtype=torch.bool, device=indices.device)
265
+ fired[active] = True
266
+ self.tokens_since_fired[fired] = 0
267
+
268
+ self.tokens_seen += batch_tokens
269
+
270
+ def dead_mask(self) -> torch.Tensor:
271
+ """Boolean mask of features silent for longer than the dead window.
272
+
273
+ "Silent" means no strictly positive activation, not merely "not selected
274
+ by Top-K" -- see :meth:`update_liveness`.
275
+ """
276
+ return self.tokens_since_fired > self.config.dead_feature_window
277
+
278
+ def num_dead(self) -> int:
279
+ return int(self.dead_mask().sum().item())
280
+
281
+ # -- losses ----------------------------------------------------------------
282
+
283
+ def auxk_loss(self, x: torch.Tensor, out: SAEOutput) -> torch.Tensor:
284
+ """Reconstruct the residual error using only dead features.
285
+
286
+ Returns a zero scalar when AuxK is disabled or nothing is dead yet, so
287
+ the caller can add it unconditionally.
288
+ """
289
+ if self.config.auxk_alpha <= 0:
290
+ return x.new_zeros(())
291
+ dead = self.dead_mask()
292
+ n_dead = int(dead.sum().item())
293
+ if n_dead == 0:
294
+ return x.new_zeros(())
295
+
296
+ k_aux = min(self.config.auxk_k, n_dead)
297
+ residual = (x - out.reconstruction).detach()
298
+
299
+ masked_pre = out.pre_acts.masked_fill(~dead.unsqueeze(0), 0.0)
300
+ values, indices = torch.topk(masked_pre, k_aux, dim=-1)
301
+ sparse = torch.zeros_like(masked_pre)
302
+ # Zero-valued selections are harmless here: scattering 0.0 into a zeros
303
+ # tensor is a no-op and contributes nothing to the linear map below. The
304
+ # zero-selection hazard is confined to liveness accounting, which counts
305
+ # occurrences rather than summing values.
306
+ sparse.scatter_(-1, indices, values)
307
+
308
+ aux_reconstruction = F.linear(sparse, self.W_dec) # no bias: modelling the residual
309
+ return F.mse_loss(aux_reconstruction, residual)
310
+
311
+ # -- serialization ---------------------------------------------------------
312
+
313
+ def state_dict_with_config(self) -> dict[str, Any]:
314
+ return {"config": self.config.to_dict(), "state_dict": self.state_dict()}
315
+
316
+ @classmethod
317
+ def from_checkpoint(cls, checkpoint: dict[str, Any], *, device: str = "cpu") -> "TopKSAE":
318
+ """Rebuild an SAE from a checkpoint dict, config included."""
319
+ config = SAEConfig.from_dict(checkpoint["config"])
320
+ sae = cls(config)
321
+ sae.load_state_dict(checkpoint["state_dict"])
322
+ sae.to(device)
323
+ sae.eval()
324
+ return sae
325
+
326
+
327
+ def reconstruction_metrics(x: torch.Tensor, out: SAEOutput) -> dict[str, float]:
328
+ """Quality metrics for one batch.
329
+
330
+ ``explained_variance`` is computed against the per-dimension variance of the
331
+ batch, i.e. ``1 - Var(x - x_hat) / Var(x)``. It can go negative for a
332
+ freshly initialised SAE, which is meaningful and not clipped away.
333
+
334
+ ``l0`` counts strictly positive entries of ``feature_acts``, so it is the
335
+ *true* mean L0 and is bounded above by ``k`` rather than equal to it. A
336
+ reported ``l0`` below ``k`` means some tokens had fewer than ``k`` positive
337
+ encoder pre-activations, which is legitimate and worth noticing.
338
+
339
+ ``mean_active_value`` averages over strictly positive selections only;
340
+ including zero-valued Top-K entries would bias it toward zero exactly when
341
+ the SAE is sparsest.
342
+ """
343
+ with torch.no_grad():
344
+ x = x.float()
345
+ recon = out.reconstruction.float()
346
+ residual = x - recon
347
+
348
+ mse = residual.pow(2).mean().item()
349
+ total_var = x.var(dim=0, unbiased=False).sum().item()
350
+ resid_var = residual.var(dim=0, unbiased=False).sum().item()
351
+ explained = 1.0 - (resid_var / total_var) if total_var > 0 else float("nan")
352
+
353
+ x_norm = x.norm(dim=-1)
354
+ cos = F.cosine_similarity(x, recon, dim=-1).mean().item()
355
+
356
+ # True L0: strictly positive activations, bounded above by k.
357
+ l0_per_row = out.l0().float()
358
+ l0 = l0_per_row.mean().item()
359
+
360
+ # Fraction of Top-K slots that selected a zero. Non-zero here means the
361
+ # dictionary is saturating below k for some tokens; it is also the
362
+ # condition under which naive index-based liveness accounting would be
363
+ # wrong, so it is worth logging rather than inferring.
364
+ active = out.active_mask()
365
+ zero_selection_rate = 1.0 - active.float().mean().item()
366
+ positive_values = out.topk_values[active]
367
+ mean_active_value = (
368
+ positive_values.mean().item() if positive_values.numel() > 0 else 0.0
369
+ )
370
+
371
+ return {
372
+ "mse": mse,
373
+ "normalized_mse": mse / x.pow(2).mean().item() if x.pow(2).mean().item() > 0 else float("nan"),
374
+ "explained_variance": explained,
375
+ "cosine_similarity": cos,
376
+ "l0": l0,
377
+ "l0_min": l0_per_row.min().item(),
378
+ "l0_max": l0_per_row.max().item(),
379
+ "zero_selection_rate": zero_selection_rate,
380
+ "mean_input_norm": x_norm.mean().item(),
381
+ "mean_recon_norm": recon.norm(dim=-1).mean().item(),
382
+ "mean_active_value": mean_active_value,
383
+ }