evalmetry 1.0.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.
evalmetry/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Per-layer internal signal collection alongside an lm-eval benchmark run.
2
+
3
+ Scoring is lm-eval's, unchanged.
4
+ This package only pulls extra research signals out of the same forward passes and writes them in a fixed, self-describing format.
5
+
6
+ The reading entry point is `load_signals`; `describe_schema` prints what the columns mean.
7
+ """
8
+
9
+ from .storage import SCHEMA_VERSION, describe_schema, load_signals, read_manifest, read_sample_metrics
10
+
11
+ __all__ = ["SCHEMA_VERSION", "describe_schema", "load_signals", "read_manifest", "read_sample_metrics"]
12
+
13
+ from .hooks import HookSpec, HookContext
14
+
15
+ __all__ += ["HookSpec", "HookContext"]
16
+
17
+ from .models import ModelBundle, fingerprint_files
18
+
19
+ __all__ += ["ModelBundle", "fingerprint_files", "RunConfig", "run"]
20
+
21
+
22
+ def __getattr__(name):
23
+ """Load evaluation entrypoints lazily so `python -m evalmetry.main` works."""
24
+ if name in {"RunConfig", "run"}:
25
+ from .main import RunConfig, cmd_run
26
+ return {"RunConfig": RunConfig, "run": cmd_run}[name]
27
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
evalmetry/adapters.py ADDED
@@ -0,0 +1,576 @@
1
+ """Where the interesting modules live inside a given model.
2
+
3
+ There is exactly one hook implementation and one set of reducers.
4
+ What differs between architectures is almost entirely *where things are*: the block list, the final norm, the head and whether it has a bias, logit softcapping, and how GQA groups key/value heads.
5
+ Those differences are data, held here, so that `recorder.py` and `reducers.py` never branch on a model type.
6
+
7
+ Adding a model means adding a few lines to `MODEL_PATHS`, not writing new hooks.
8
+ If per-model hooks existed, the reducer calls would be duplicated once per model and a change to one of them would quietly not apply to the others - while every run still finished successfully.
9
+
10
+ Dispatch is on `config.model_type`.
11
+ An unregistered model type fails at startup rather than being guessed at; `--adapter` is the escape hatch for `trust_remote_code` models whose module paths are non-standard.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Callable
18
+
19
+ import torch
20
+ from torch import nn
21
+
22
+
23
+ # --------------------------------------------------------------------------
24
+ # Per-architecture module paths
25
+ # --------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ModulePaths:
30
+ """Dotted attribute paths, relative to the unwrapped model.
31
+
32
+ Attributes:
33
+ blocks: the `nn.ModuleList` of transformer blocks.
34
+ final_norm: the norm applied just before the head.
35
+ lm_head: the unembedding. May be several candidate paths, tried in order,
36
+ for an architecture whose head is not named the same thing in both
37
+ pinned transformers versions. GPT-NeoX is the only such case today.
38
+ attention: attention submodule, relative to one block.
39
+ value_proj: value projection, relative to one block.
40
+ On architectures that fuse q, k and v into one linear layer this is that layer, and `value_layout` says how to cut the value part out of its output.
41
+ value_layout: how to read value vectors out of `value_proj`'s output.
42
+
43
+ - "separate": the output is already only values, (.., n_kv*head_dim).
44
+ - "fused_qkv_contiguous": [all q | all k | all v], so the values are the last n_kv*head_dim columns.
45
+ Phi-3 does this.
46
+ - "fused_qkv_per_head": the output reshapes to (.., heads, 3*head_dim) and q/k/v are interleaved *within each head*, so the values are the last head_dim of every head's block.
47
+ GPT-NeoX does this, and slicing the tail of the flat row instead would silently return a mixture of other heads' queries and keys.
48
+ last_hidden_is_normed: whether `output_hidden_states=True` returns a *final-normed* last element.
49
+ HF pre-norm decoders do, and getting this wrong makes the logit lens apply the final norm twice on the last layer only - which looks plausible in a plot and is wrong.
50
+
51
+ Example:
52
+ >>> MODEL_PATHS["llama"].blocks
53
+ 'model.layers'
54
+ """
55
+
56
+ blocks: str
57
+ final_norm: str
58
+ lm_head: str | tuple[str, ...]
59
+ attention: str = "self_attn"
60
+ value_proj: str = "self_attn.v_proj"
61
+ value_layout: str = "separate"
62
+ last_hidden_is_normed: bool = True
63
+
64
+
65
+ #: The standard HF pre-norm decoder layout, shared by most Llama-derived models.
66
+ _LLAMA_STYLE = ModulePaths(blocks="model.layers", final_norm="model.norm", lm_head="lm_head")
67
+
68
+ MODEL_PATHS: dict[str, ModulePaths] = {
69
+ "llama": _LLAMA_STYLE,
70
+ "mistral": _LLAMA_STYLE,
71
+ "mixtral": _LLAMA_STYLE,
72
+ "qwen2": _LLAMA_STYLE,
73
+ "qwen3": _LLAMA_STYLE,
74
+ "qwen3_moe": _LLAMA_STYLE,
75
+ "gemma": _LLAMA_STYLE,
76
+ "gemma2": _LLAMA_STYLE,
77
+ "gemma3_text": _LLAMA_STYLE,
78
+ "olmo2": _LLAMA_STYLE,
79
+ "cohere": _LLAMA_STYLE,
80
+ "smollm3": _LLAMA_STYLE,
81
+ # Qwen3.5 is a hybrid: only some blocks carry `self_attn`, the rest use a linear-attention module with a different internal layout.
82
+ # The residual path is unaffected - `model.layers` still emits L+1 hidden states - so the logit lens and the similarity matrix work as usual; the attention and value-norm signals simply have no rows for the linear-attention blocks, which is what a sparse `block` axis is for.
83
+ # Only `qwen3_5_text` is registered, the same split Gemma 3 uses. HFLM loads a Qwen3.5 repo through AutoModelForCausalLM as `Qwen3_5ForCausalLM`, which reports `qwen3_5_text`.
84
+ # The wrapper `qwen3_5` is reported only by `Qwen3_5ForConditionalGeneration`, whose blocks sit at `model.language_model.layers` and whose head counts live in `text_config`, so these paths would never resolve on it.
85
+ "qwen3_5_text": _LLAMA_STYLE,
86
+ # Gemma 4 is the exception to that split. transformers maps a `gemma4` repo config to `Gemma4ForConditionalGeneration` for AutoModelForCausalLM as well, so HFLM loads the multimodal wrapper and the loaded model reports `gemma4`.
87
+ # Its text blocks sit under the wrapper's `language_model`, the head is the wrapper's own, and head counts and softcapping live on the text config, which `resolve_adapter` reads.
88
+ # A text-only checkpoint loads as `Gemma4ForCausalLM`, reports `gemma4_text`, and is Llama-shaped.
89
+ # Its blocks differ in attention shape - full-attention heads are twice as wide as sliding ones - and the last `num_kv_shared_layers` blocks reuse an earlier block's keys and values and own no `v_proj`: they carry attention weights but no value-norm rows.
90
+ "gemma4_text": _LLAMA_STYLE,
91
+ "gemma4": ModulePaths(
92
+ blocks="model.language_model.layers",
93
+ final_norm="model.language_model.norm",
94
+ lm_head="lm_head",
95
+ ),
96
+ "phi": ModulePaths(
97
+ blocks="model.layers",
98
+ # Phi calls its final norm `final_layernorm`, not `norm`.
99
+ final_norm="model.final_layernorm",
100
+ lm_head="lm_head",
101
+ ),
102
+ "phi3": ModulePaths(
103
+ blocks="model.layers",
104
+ final_norm="model.norm",
105
+ lm_head="lm_head",
106
+ # Phi-3 fuses q/k/v into one projection, laid out as [q | k | v].
107
+ value_proj="self_attn.qkv_proj",
108
+ value_layout="fused_qkv_contiguous",
109
+ ),
110
+ "gpt_neox": ModulePaths(
111
+ blocks="gpt_neox.layers",
112
+ final_norm="gpt_neox.final_layer_norm",
113
+ # Renamed in transformers 5.x; 4.x, which this project also pins, still has
114
+ # `embed_out`. Everything else about GPT-NeoX survived the rename.
115
+ lm_head=("lm_head", "embed_out"),
116
+ attention="attention",
117
+ # GPT-NeoX interleaves q/k/v inside each head, not across the row.
118
+ value_proj="attention.query_key_value",
119
+ value_layout="fused_qkv_per_head",
120
+ ),
121
+ }
122
+
123
+
124
+ # --------------------------------------------------------------------------
125
+ # Adapter
126
+ # --------------------------------------------------------------------------
127
+
128
+
129
+ @dataclass
130
+ class ModelAdapter:
131
+ """The resolved answer to "where is what" for one loaded model.
132
+
133
+ Attributes:
134
+ model_type: `config.model_type`, the key this adapter was resolved by.
135
+ blocks: transformer blocks, index 0..L-1.
136
+ decoder: the module that owns the block list and returns `hidden_states` - `model.model` on a Llama-style checkpoint.
137
+ Hooking it is how the residual stream is captured on both the loglikelihood and the generate path, so both get the *same* L+1 tensors.
138
+ attn_modules: attention module per block; None for a block that has no attention (hybrid architectures), which makes the `block` axis non-contiguous.
139
+ v_projs: value projection per block; None where absent.
140
+ decode_stack: `(n_layers, n_pos, d) -> (n_layers, n_pos, vocab)`, the model's own path from hidden state to logits.
141
+ extract_value: `(tensor, block_idx) -> values`, pulls the value vectors out of whatever `value_proj` emits, per `ModulePaths.value_layout` and that block's attention shape.
142
+ n_blocks: L.
143
+ n_residual: L+1, matching what `output_hidden_states=True` returns.
144
+ n_heads / n_kv_heads / head_dim: attention shape.
145
+ `n_kv_heads` and `head_dim` are None when blocks differ; `block_shapes` then holds each block's.
146
+ vocab_size: size of the head's output.
147
+ tie_word_embeddings: recorded because on tied models `layer 0` decodes back to the input token, which must not be read as a prediction.
148
+ paths: the `ModulePaths` used, recorded in the manifest.
149
+ lm_head_path: the head path that actually resolved, which is what the manifest
150
+ should say when the entry offered several candidates.
151
+ block_shapes: `(n_heads, n_kv_heads, head_dim)` per block.
152
+ """
153
+
154
+ model_type: str
155
+ decoder: nn.Module
156
+ blocks: list[nn.Module]
157
+ attn_modules: list[nn.Module | None]
158
+ v_projs: list[nn.Module | None]
159
+ decode_stack: Callable[[torch.Tensor], torch.Tensor]
160
+ extract_value: Callable[..., torch.Tensor]
161
+ n_blocks: int
162
+ n_residual: int
163
+ n_heads: int
164
+ n_kv_heads: int | None
165
+ head_dim: int | None
166
+ vocab_size: int
167
+ tie_word_embeddings: bool
168
+ paths: ModulePaths
169
+ lm_head_path: str = ""
170
+ # Root is used only to delimit real custom observations, never to inspect containers.
171
+ root_model: nn.Module | None = None
172
+ block_shapes: list[tuple[int, int, int]] = field(default_factory=list)
173
+
174
+ def attention_shape(self, block_idx: int) -> tuple[int, int, int]:
175
+ """`(n_heads, n_kv_heads, head_dim)` of one block.
176
+
177
+ Example:
178
+ >>> adapter.attention_shape(5) # doctest: +SKIP
179
+ (8, 2, 512)
180
+ """
181
+ if self.block_shapes:
182
+ return self.block_shapes[block_idx]
183
+ return (self.n_heads, self.n_kv_heads, self.head_dim)
184
+
185
+ def value_shapes(self) -> dict[int, tuple[int, int, int]]:
186
+ """Per-block shapes of the blocks that have a value projection, only when blocks differ.
187
+
188
+ Empty on a uniform model, so its reducer configuration and manifest keep the scalar form
189
+ every existing run was recorded with.
190
+ """
191
+ if self.n_kv_heads is not None and self.head_dim is not None:
192
+ return {}
193
+ return {i: shape for i, shape in enumerate(self.block_shapes) if self.v_projs[i] is not None}
194
+
195
+ def manifest_entry(self) -> dict[str, Any]:
196
+ """What goes into the manifest about the decode path.
197
+
198
+ Example:
199
+ >>> adapter.manifest_entry()["final_norm_path"] # doctest: +SKIP
200
+ 'model.norm'
201
+ """
202
+ entry = {
203
+ "model_type": self.model_type,
204
+ "n_blocks": self.n_blocks,
205
+ "n_hidden_states": self.n_residual,
206
+ "layer_index_convention": "residual_input",
207
+ "attn_index_convention": "block_output",
208
+ "decoder_path": self.paths.blocks.rsplit(".", 1)[0],
209
+ "value_proj_path": self.paths.value_proj,
210
+ "value_layout": self.paths.value_layout,
211
+ "final_norm_path": self.paths.final_norm,
212
+ "lm_head_path": self.lm_head_path or self.paths.lm_head,
213
+ "last_hidden_is_normed": self.paths.last_hidden_is_normed,
214
+ "tie_word_embeddings": self.tie_word_embeddings,
215
+ "vocab_size": self.vocab_size,
216
+ "n_heads": self.n_heads,
217
+ "n_kv_heads": self.n_kv_heads,
218
+ "head_dim": self.head_dim,
219
+ "blocks_with_attention": [i for i, m in enumerate(self.attn_modules) if m is not None],
220
+ }
221
+ if self.n_kv_heads is None or self.head_dim is None:
222
+ entry["attention_shapes_by_block"] = [list(shape) for shape in self.block_shapes]
223
+ entry["blocks_with_value_proj"] = [i for i, v in enumerate(self.v_projs) if v is not None]
224
+ return entry
225
+
226
+
227
+ # --------------------------------------------------------------------------
228
+ # Resolution
229
+ # --------------------------------------------------------------------------
230
+
231
+
232
+ def unwrap_model(model: nn.Module) -> nn.Module:
233
+ """Peel wrappers until the real transformer is exposed.
234
+
235
+ A `PeftModel` puts one or two extra objects between us and the block list, so the module paths have to be resolved against the unwrapped object.
236
+ The hooks themselves are unaffected: a LoRA-replaced `v_proj` still reports the merged base+delta output, because we capture the module's output.
237
+
238
+ Only known wrapper types are unwrapped.
239
+ Anything looser - "unwrap while the object has `.base_model`", say - also unwraps ordinary models, because every `PreTrainedModel` has that attribute; that would peel a `GPTNeoXForCausalLM` down to its decoder and then fail to resolve any path against it.
240
+
241
+ Example:
242
+ >>> unwrap_model(peft_model) is peft_model.get_base_model() # doctest: +SKIP
243
+ True
244
+ >>> unwrap_model(llama_for_causal_lm) is llama_for_causal_lm # doctest: +SKIP
245
+ True
246
+ """
247
+ parallel_wrappers = {"DataParallel", "DistributedDataParallel"}
248
+ peft_wrappers = {"LoraModel", "AdaLoraModel", "IA3Model", "LoHaModel", "LoKrModel"}
249
+
250
+ seen: set[int] = set()
251
+ current = model
252
+ while id(current) not in seen:
253
+ seen.add(id(current))
254
+ name = type(current).__name__
255
+ if hasattr(current, "get_base_model"): # peft.PeftModel
256
+ current = current.get_base_model()
257
+ elif name in parallel_wrappers and hasattr(current, "module"):
258
+ current = current.module
259
+ elif name in peft_wrappers and hasattr(current, "model"):
260
+ current = current.model
261
+ else:
262
+ break
263
+ return current
264
+
265
+
266
+ def _resolve_path(root: nn.Module, path: str | tuple[str, ...]) -> Any:
267
+ """Follow a dotted attribute path, or raise a message naming what is missing.
268
+
269
+ Example:
270
+ >>> _resolve_path(model, "model.norm") # doctest: +SKIP
271
+ LlamaRMSNorm((4096,), eps=1e-05)
272
+ """
273
+ return _resolve_named(root, path)[1]
274
+
275
+
276
+ def _resolve_named(root: nn.Module, path: str | tuple[str, ...]) -> tuple[str, Any]:
277
+ """Resolve the first candidate path that exists, and say which one answered.
278
+
279
+ Several candidates exist because a path is not always the same in both pinned
280
+ transformers versions: GPT-NeoX's head is `embed_out` in 4.x and `lm_head` in 5.x.
281
+ Resolving by name rather than by version number keeps one registry entry valid in
282
+ both environments, and the manifest records the name that actually resolved rather
283
+ than the list of guesses. A single string stays a single string, so a model whose
284
+ path is simply wrong still fails at startup with one name in the message.
285
+ """
286
+ candidates = (path,) if isinstance(path, str) else tuple(path)
287
+ missing: list[str] = []
288
+ for candidate in candidates:
289
+ current: Any = root
290
+ for part in candidate.split("."):
291
+ if not hasattr(current, part):
292
+ missing.append(
293
+ f"{candidate!r} ({type(current).__name__} has no attribute {part!r})")
294
+ break
295
+ current = getattr(current, part)
296
+ else:
297
+ return candidate, current
298
+ raise AttributeError(
299
+ f"cannot resolve {' or '.join(missing)} on {type(root).__name__}. "
300
+ "Register the correct paths in adapters.MODEL_PATHS, or pass --adapter."
301
+ )
302
+
303
+
304
+ def _build_decode_stack(
305
+ final_norm: nn.Module, lm_head: nn.Module, paths: ModulePaths, config: Any
306
+ ) -> Callable[[torch.Tensor], torch.Tensor]:
307
+ """Build the hidden-state -> logits function, reusing the model's own modules.
308
+
309
+ We deliberately do not reimplement this path.
310
+ Architectures differ in what sits between the final norm and the logits (head bias, logit softcapping, output scaling), and a reimplementation would disagree with the model's real logits at the last layer - which is precisely the check that tells us the lens is wired up correctly.
311
+ Both of those extras are read off the config rather than the model type: `final_logit_softcapping` (Gemma 2, 3 and 4) and `logit_scale` (Cohere).
312
+ `config` is the text config: a multimodal wrapper (Gemma 4) keeps softcapping there, not on its own config.
313
+
314
+ The one place we deviate is the last element: HF returns it already final-normed, so feeding it through the norm again would norm it twice.
315
+
316
+ The returned function always expects the **whole** L+1 stack, in residual order, because that is what tells it which element is the pre-normed last one.
317
+
318
+ Example:
319
+ >>> decode = _build_decode_stack(norm, head, paths, config) # doctest: +SKIP
320
+ >>> decode(torch.randn(33, 1, 4096)).shape
321
+ torch.Size([33, 1, 151936])
322
+ """
323
+ softcap = getattr(config, "final_logit_softcapping", None)
324
+ # Cohere scales its logits - upstream calls it "the main diff from Llama" - and it is
325
+ # the only registered architecture that does. Leaving it out makes the lens disagree
326
+ # with the model by a constant factor at every layer, which is what
327
+ # `check_decode_identity` refuses at startup rather than letting it into a plot.
328
+ scale = getattr(config, "logit_scale", None)
329
+
330
+ def decode_stack(stack: torch.Tensor) -> torch.Tensor:
331
+ if paths.last_hidden_is_normed:
332
+ # Norm everything but the last element, which already has it.
333
+ head_input = torch.cat([final_norm(stack[:-1]), stack[-1:]], dim=0)
334
+ else:
335
+ head_input = final_norm(stack)
336
+ logits = lm_head(head_input)
337
+ if scale:
338
+ logits = logits * scale
339
+ if softcap:
340
+ logits = softcap * torch.tanh(logits / softcap)
341
+ return logits
342
+
343
+ return decode_stack
344
+
345
+
346
+ def _build_value_extractor(
347
+ paths: ModulePaths, n_heads: int, n_kv_heads: int, head_dim: int
348
+ ) -> Callable[[torch.Tensor], torch.Tensor]:
349
+ """Build the function that turns `value_proj`'s output into value vectors.
350
+
351
+ Kept here rather than in the hook, because which columns hold the values is a per-architecture fact - exactly the kind of thing `adapters.py` exists to hold as data.
352
+
353
+ Returns:
354
+ `(.., width) -> (.., n_kv_heads * head_dim)`.
355
+
356
+ Example:
357
+ >>> paths = ModulePaths("l", "n", "h", value_layout="fused_qkv_per_head")
358
+ >>> extract = _build_value_extractor(paths, n_heads=2, n_kv_heads=2, head_dim=2)
359
+ >>> # two heads, each laid out as [q0 q1 | k0 k1 | v0 v1]
360
+ >>> extract(torch.arange(12).reshape(1, 12)).tolist()
361
+ [[4, 5, 10, 11]]
362
+ """
363
+ value_width = n_kv_heads * head_dim
364
+
365
+ def separate(tensor: torch.Tensor) -> torch.Tensor:
366
+ if tensor.shape[-1] != value_width:
367
+ raise ValueError(
368
+ f"expected the value projection to emit {value_width} columns "
369
+ f"(n_kv_heads * head_dim) but it emitted {tensor.shape[-1]}; "
370
+ "check value_proj / value_layout for this architecture"
371
+ )
372
+ return tensor
373
+
374
+ def fused_contiguous(tensor: torch.Tensor) -> torch.Tensor:
375
+ # [ all queries | all keys | all values ]
376
+ return tensor[..., -value_width:]
377
+
378
+ def fused_per_head(tensor: torch.Tensor) -> torch.Tensor:
379
+ # (.., heads * 3 * head_dim) -> (.., heads, 3 * head_dim), values last.
380
+ reshaped = tensor.view(*tensor.shape[:-1], n_heads, 3 * head_dim)
381
+ return reshaped[..., 2 * head_dim :].reshape(*tensor.shape[:-1], n_heads * head_dim)
382
+
383
+ extractors = {
384
+ "separate": separate,
385
+ "fused_qkv_contiguous": fused_contiguous,
386
+ "fused_qkv_per_head": fused_per_head,
387
+ }
388
+ if paths.value_layout not in extractors:
389
+ raise KeyError(
390
+ f"unknown value_layout {paths.value_layout!r}; "
391
+ f"known: {sorted(extractors)}"
392
+ )
393
+ return extractors[paths.value_layout]
394
+
395
+
396
+ def _attention_shapes(config: Any, n_blocks: int) -> list[tuple[int, int, int]]:
397
+ """`(n_heads, n_kv_heads, head_dim)` for every block, read from the (text) config.
398
+
399
+ transformers 5.x gives every config a `per_layer_config` view, uniform model or not; a layer entry leaves `head_dim` None when the model derives it (OLMo-2), which falls back to `hidden_size // n_heads` exactly as the global rule does.
400
+ On a config whose layers really differ (Gemma 4) the global `head_dim` cannot be read at all: it raises an error that is not an AttributeError, so `getattr` with a default does not catch it. Older configs without the view take the global rule.
401
+ """
402
+ try:
403
+ per_layer = config.per_layer_config
404
+ except AttributeError:
405
+ per_layer = None
406
+ if per_layer is not None:
407
+ global_heads = int(getattr(config, "num_attention_heads", 1))
408
+ shapes = []
409
+ for index in range(n_blocks):
410
+ layer = per_layer[index]
411
+ heads = int(getattr(layer, "num_attention_heads", None) or global_heads)
412
+ kv_heads = int(getattr(layer, "num_key_value_heads", None) or heads)
413
+ head_dim = int(getattr(layer, "head_dim", None) or config.hidden_size // heads)
414
+ shapes.append((heads, kv_heads, head_dim))
415
+ return shapes
416
+ n_heads = int(getattr(config, "num_attention_heads", 1))
417
+ n_kv_heads = int(getattr(config, "num_key_value_heads", None) or n_heads)
418
+ head_dim = int(getattr(config, "head_dim", None) or config.hidden_size // n_heads)
419
+ return [(n_heads, n_kv_heads, head_dim)] * n_blocks
420
+
421
+
422
+ def resolve_adapter(model: nn.Module, adapter_name: str | None = None, *, paths: ModulePaths | None = None, require_decode: bool = True) -> ModelAdapter:
423
+ """Build the `ModelAdapter` for a loaded model.
424
+
425
+ Args:
426
+ model: the model as lm-eval loaded it, wrappers and all.
427
+ paths: external module paths, without mutating the global registry.
428
+ require_decode: False skips final norm/head resolution for non-lens signals.
429
+ adapter_name: force a specific `MODEL_PATHS` entry.
430
+ The escape hatch for `trust_remote_code` models whose paths do not match their `model_type`.
431
+
432
+ Raises:
433
+ KeyError: the model type is not registered.
434
+ We fail at startup rather than guessing, because a wrong guess produces signals attached to the wrong modules and nothing downstream would notice.
435
+
436
+ Example:
437
+ >>> adapter = resolve_adapter(model) # doctest: +SKIP
438
+ >>> adapter.n_blocks, adapter.n_residual
439
+ (32, 33)
440
+ """
441
+ base = unwrap_model(model)
442
+ config = base.config
443
+ model_type = adapter_name or getattr(config, "model_type", None)
444
+ if paths is None and model_type not in MODEL_PATHS:
445
+ raise KeyError(
446
+ f"no adapter registered for model_type {model_type!r}. "
447
+ f"Known: {sorted(MODEL_PATHS)}. "
448
+ "Add an entry to adapters.MODEL_PATHS or pass --adapter <known type>."
449
+ )
450
+ paths = paths if paths is not None else MODEL_PATHS[model_type]
451
+ # A multimodal wrapper keeps head counts, vocabulary and softcapping on its text config;
452
+ # on a text model `get_text_config()` is the config itself.
453
+ text_config = config.get_text_config() if hasattr(config, "get_text_config") else config
454
+
455
+ # "model.layers" -> the block list lives on "model", which is the module that returns hidden_states.
456
+ # Deriving it avoids a second path to maintain.
457
+ decoder_path = paths.blocks.rsplit(".", 1)[0]
458
+ decoder = _resolve_path(base, decoder_path) if decoder_path else base
459
+ blocks = list(_resolve_path(base, paths.blocks))
460
+ final_norm = _resolve_path(base, paths.final_norm) if require_decode else None
461
+ lm_head_path, lm_head = (_resolve_named(base, paths.lm_head) if require_decode else ("", None))
462
+
463
+ # A block without attention (hybrid architectures) is allowed; it simply contributes no row on the `block` axis.
464
+ attn_modules: list[nn.Module | None] = []
465
+ v_projs: list[nn.Module | None] = []
466
+ for block in blocks:
467
+ attn_modules.append(_optional_path(block, paths.attention))
468
+ v_projs.append(_optional_path(block, paths.value_proj))
469
+
470
+ block_shapes = _attention_shapes(text_config, len(blocks))
471
+ uniform = len(set(block_shapes)) <= 1
472
+ n_heads = int(getattr(text_config, "num_attention_heads", 1))
473
+ if uniform and block_shapes:
474
+ n_heads, n_kv_heads, head_dim = block_shapes[0]
475
+ elif uniform:
476
+ n_kv_heads = int(getattr(text_config, "num_key_value_heads", None) or n_heads)
477
+ head_dim = int(getattr(text_config, "head_dim", None) or text_config.hidden_size // n_heads)
478
+ else:
479
+ n_kv_heads = head_dim = None
480
+ extractors = {shape: _build_value_extractor(paths, *shape) for shape in set(block_shapes)}
481
+ uniform_extractor = (_build_value_extractor(paths, n_heads, n_kv_heads, head_dim)
482
+ if uniform else None)
483
+
484
+ def extract_value(tensor: torch.Tensor, block_idx: int | None = None) -> torch.Tensor:
485
+ if uniform_extractor is not None:
486
+ return uniform_extractor(tensor)
487
+ if block_idx is None:
488
+ raise ValueError("this model's blocks differ in attention shape; pass the block index")
489
+ return extractors[block_shapes[block_idx]](tensor)
490
+
491
+ return ModelAdapter(
492
+ model_type=model_type,
493
+ decoder=decoder,
494
+ blocks=blocks,
495
+ attn_modules=attn_modules,
496
+ v_projs=v_projs,
497
+ decode_stack=_build_decode_stack(final_norm, lm_head, paths, text_config) if require_decode else _decode_unavailable,
498
+ extract_value=extract_value,
499
+ n_blocks=len(blocks),
500
+ n_residual=len(blocks) + 1,
501
+ n_heads=n_heads,
502
+ n_kv_heads=n_kv_heads,
503
+ head_dim=head_dim,
504
+ vocab_size=int(getattr(lm_head, "out_features", text_config.vocab_size)),
505
+ tie_word_embeddings=bool(getattr(text_config, "tie_word_embeddings", False)),
506
+ paths=paths,
507
+ lm_head_path=lm_head_path,
508
+ root_model=base,
509
+ block_shapes=block_shapes,
510
+ )
511
+
512
+
513
+ def _decode_unavailable(stack):
514
+ """Fail if a caller uses a decode path that was deliberately not resolved."""
515
+ raise ValueError("logit lens was not selected; resolve the adapter with require_decode=True")
516
+
517
+
518
+ def _optional_path(root: nn.Module, path: str) -> nn.Module | None:
519
+ """Like `_resolve_path`, but returns None instead of raising."""
520
+ try:
521
+ return _resolve_path(root, path)
522
+ except AttributeError:
523
+ return None
524
+
525
+
526
+ def check_decode_identity(
527
+ adapter: ModelAdapter,
528
+ model: nn.Module,
529
+ input_ids: torch.Tensor,
530
+ tolerance: float = 1e-2,
531
+ ) -> dict[str, float]:
532
+ """Verify the lens decode path reproduces the model's real logits.
533
+
534
+ Runs one forward pass, decodes the full residual stack exactly the way the logit lens does, and compares the last layer's result against the logits the model itself produced.
535
+ If they disagree, the final norm is in the wrong place or the head was resolved incorrectly - and every logit lens number in the run would be quietly wrong.
536
+
537
+ This doubles as the usability test for quantized models: if the identity holds, the hooks are seeing dequantized activations and the run is usable.
538
+
539
+ The tolerance is **relative to the logit scale**, not absolute.
540
+ Logit magnitudes differ enormously between models - about 21 for SmolLM2-135M and about 837 for pythia-160m - so a fixed absolute bound is really a different test on every model, and one loose enough for pythia in bf16 would be far too loose for SmolLM2.
541
+ The two paths use the same weights and inputs and differ only in how the matmul is tiled, so what is being allowed for here is accumulation order, which scales with the values.
542
+
543
+ Args:
544
+ input_ids: a small batch, e.g. `torch.tensor([[1, 2, 3]])` on the model's device.
545
+ tolerance: allowed difference as a fraction of the largest logit.
546
+
547
+ Returns:
548
+ `{"max_abs_diff", "max_rel_diff", "logit_scale"}`, all recorded in the manifest so a marginal run can be judged after the fact.
549
+
550
+ Raises:
551
+ RuntimeError: if the relative difference exceeds `tolerance`.
552
+ A misplaced final norm changes the vector completely, so it fails by orders of magnitude rather than marginally.
553
+
554
+ Example:
555
+ >>> check_decode_identity(adapter, model, ids) # doctest: +SKIP
556
+ {'max_abs_diff': 0.0014, 'max_rel_diff': 1.7e-06, 'logit_scale': 837.05}
557
+ """
558
+ with torch.no_grad():
559
+ out = model(input_ids, output_hidden_states=True)
560
+ # (n_layers, batch, d): the same stack the logit lens builds.
561
+ stack = torch.stack([hidden[:, -1, :] for hidden in out.hidden_states], dim=0)
562
+ ours = adapter.decode_stack(stack)[-1] # (batch, vocab)
563
+ theirs = out.logits[:, -1, :]
564
+ absolute = float((ours.float() - theirs.float()).abs().max())
565
+ # max(1.0, ...) keeps a degenerate all-zero-logit model from dividing by something tiny and turning a harmless difference into a failure.
566
+ scale = max(1.0, float(theirs.float().abs().max()))
567
+ relative = absolute / scale
568
+ if relative > tolerance:
569
+ raise RuntimeError(
570
+ f"logit lens decode path does not match the model's own logits: "
571
+ f"max abs diff {absolute:.4g} against a logit scale of {scale:.4g} "
572
+ f"is {relative:.3g} relative, over the {tolerance} tolerance. "
573
+ f"Check final_norm/lm_head paths and last_hidden_is_normed for "
574
+ f"model_type {adapter.model_type!r}."
575
+ )
576
+ return {"max_abs_diff": absolute, "max_rel_diff": relative, "logit_scale": scale}