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,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
|
+
}
|