Coverage for transformer_lens/tools/analysis/jacobian_lens.py: 93%

522 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-08-11 18:50 +0000

1"""Jacobian Lens (J-lens). 

2 

3The Jacobian lens characterizes an intermediate residual-stream activation by its 

4first-order causal effect on the model's output, averaged over a corpus of contexts. 

5For each layer :math:`\\ell` it fits a single :math:`d_{model} \\times d_{model}` matrix 

6 

7.. math:: 

8 

9 J_\\ell = \\mathbb{E}_{\\text{prompt}}\\left[ 

10 \\frac{1}{|V|}\\sum_{t \\in V}\\sum_{t' \\in V,\\,t' \\geq t} 

11 \\frac{\\partial h_{\\text{final},t'}}{\\partial h_{\\ell,t}} 

12 \\right] 

13 

14where ``V`` is the set of valid positions after the configured leading skip 

15and final-position exclusion. Target-position effects are summed for each 

16source; only source positions and prompts are averaged. 

17 

18mapping the output of block :math:`\\ell` to the final block's output (pre final 

19norm). Reading the lens applies the model's own final norm and unembedding: 

20:math:`\\text{lens}(h_\\ell) = W_U\\,\\mathrm{norm}(J_\\ell h_\\ell)`. 

21The logit lens is the special case :math:`J_\\ell = I`. The rows of 

22:math:`W_U J_\\ell` ("J-lens vectors") are residual-stream directions associated 

23with single vocabulary tokens, and support causal interventions: steering, 

24ablation, and exchanging one concept for another via a pseudoinverse coordinate 

25swap. 

26 

27Introduced in `Verbalizable Representations Form a Global Workspace in Language 

28Models <https://transformer-circuits.pub/2026/workspace/index.html>`_ (Gurnee et 

29al., Transformer Circuits Thread, 2026). The fitting estimator and the artifact 

30format follow Anthropic's Apache-2.0 reference implementation 

31(`anthropics/jacobian-lens <https://github.com/anthropics/jacobian-lens>`_), so 

32lenses fitted here interoperate with artifacts published on the Hugging Face Hub 

33(e.g. `neuronpedia/jacobian-lens 

34<https://huggingface.co/neuronpedia/jacobian-lens>`_); the interventions are 

35implemented from the paper's Methods section. 

36 

37Warning: 

38 Published lens artifacts are fitted on **raw** HuggingFace activations. 

39 Jacobian lens supports only a freshly booted 

40 ``TransformerBridge.boot_transformers`` model, whose weights are raw by 

41 default. Compatibility mode and direct ``process_weights`` calls change the 

42 residual basis and are refused rather than returning silently wrong 

43 readouts. The model must also be a causal decoder whose adapter supports 

44 text generation, use single-stream block outputs, and expose the standard 

45 direct ``ln_final -> d_model-width unembed`` output path. 

46 

47Example:: 

48 

49 import torch 

50 from transformer_lens.model_bridge import TransformerBridge 

51 from transformer_lens.tools.analysis import JacobianLens 

52 

53 model = TransformerBridge.boot_transformers("gpt2", device="cpu") 

54 lens = JacobianLens.from_pretrained( 

55 "neuronpedia/jacobian-lens", 

56 filename="gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt", 

57 model=model, 

58 ) 

59 result = lens.readout(model, "The Eiffel Tower is in the city of") 

60 print(result.top_tokens(model.tokenizer, k=5)[8][-1]) # layer 8, final position 

61""" 

62 

63import math 

64import warnings 

65from dataclasses import dataclass 

66from importlib.metadata import version 

67from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union 

68 

69import torch 

70from jaxtyping import Float, Int 

71from tqdm.auto import tqdm 

72 

73from transformer_lens.utilities.hf_utils import call_hf_with_retry 

74 

75TokenInput = Union[str, int] 

76 

77# --------------------------------------------------------------------------- 

78# Registry helpers 

79# --------------------------------------------------------------------------- 

80 

81_REGISTRY_CACHE: Optional[Dict[str, Any]] = None 

82 

83 

84def _load_registry() -> Dict[str, Any]: 

85 """Return the bundled artifact registry, loading it once on first call.""" 

86 global _REGISTRY_CACHE 

87 if _REGISTRY_CACHE is None: 

88 import json 

89 import pathlib 

90 

91 registry_path = pathlib.Path(__file__).with_name("jacobian_lens_registry.json") 

92 with registry_path.open() as fh: 

93 _REGISTRY_CACHE = json.load(fh) 

94 return _REGISTRY_CACHE 

95 

96 

97def _resolve_registry_entry(name_or_path: str) -> Optional[Tuple[str, str]]: 

98 """Return ``(repo_id, filename)`` if *name_or_path* matches the registry. 

99 

100 Matching is tried in two passes: 

101 1. Direct key match against short model names (e.g. ``"gemma-2-2b"``). 

102 2. Alias match against Hugging Face model IDs (e.g. ``"google/gemma-2-2b"``). 

103 

104 Returns ``None`` when there is no match so callers can fall through to the 

105 generic Hub download path. 

106 """ 

107 registry = _load_registry() 

108 if name_or_path in registry: 

109 entry = registry[name_or_path] 

110 return entry["repo_id"], entry["filename"] 

111 for entry in registry.values(): 

112 if isinstance(entry, dict) and name_or_path in entry.get("aliases", []): 

113 return entry["repo_id"], entry["filename"] 

114 return None 

115 

116 

117# Fitting excludes early positions (attention sinks with atypical residual statistics) 

118# and the final position (no next-token target), matching the reference implementation. 

119DEFAULT_SKIP_FIRST_POSITIONS = 16 

120DEFAULT_TOP_K = 10 

121_SWAP_WARN_COSINE = 0.99 

122_SWAP_ERROR_COSINE = 0.999 

123 

124# Keys written by fit() that must not appear in converted-lens metadata so that 

125# merge() can refuse to mix TL-fitted lenses with externally converted ones. 

126# Note: "target_layer" is intentionally NOT listed here — it must survive 

127# conversion so that validate_model() can detect and refuse checkpoints that 

128# were fitted against a non-final target layer. 

129_FIT_RESERVED_KEYS: frozenset = frozenset( 

130 { 

131 "transformer_lens_fit", 

132 "transformer_lens_version", 

133 "model_system", 

134 "processing", 

135 "hook_convention", 

136 "fit_dtype", 

137 "dim_batch", 

138 "max_seq_len", 

139 "skip_first_positions", 

140 } 

141) 

142 

143# Top-level payload keys that some checkpoint writers store as flat provenance 

144# (rather than nested under a "metadata" key). 

145_CHECKPOINT_FLAT_PROVENANCE: frozenset = frozenset({"model_name", "model_revision", "corpus"}) 

146 

147 

148@dataclass 

149class JacobianLensReadout: 

150 """Result of a :meth:`JacobianLens.readout` call. 

151 

152 Attributes: 

153 lens_topk_values: 

154 Per-layer retained top-k pre-softmax values, on CPU. 

155 lens_topk_indices: 

156 Per-layer retained top-k vocabulary ids, on CPU. 

157 model_topk_values: 

158 The model output's retained top-k pre-softmax values, on CPU. 

159 model_topk_indices: 

160 The model output's retained top-k vocabulary ids, on CPU. 

161 lens_logits: 

162 Optional full per-layer logits, on CPU. Present only when 

163 ``readout(return_full_logits=True)`` was requested. 

164 model_logits: 

165 Optional full model logits, on CPU. Present only when 

166 ``readout(return_full_logits=True)`` was requested. 

167 tokens: 

168 The token ids of the run prompt, ``[seq]``. 

169 positions: 

170 The (normalized, non-negative) positions the readout covers, aligned 

171 with the ``pos`` axis of retained top-k and optional full logits. 

172 use_jacobian: 

173 Whether the Jacobian transport was applied (``False`` = logit lens). 

174 """ 

175 

176 lens_topk_values: Dict[int, Float[torch.Tensor, "pos k"]] 

177 lens_topk_indices: Dict[int, Int[torch.Tensor, "pos k"]] 

178 model_topk_values: Float[torch.Tensor, "pos k"] 

179 model_topk_indices: Int[torch.Tensor, "pos k"] 

180 tokens: Int[torch.Tensor, "seq"] 

181 positions: List[int] 

182 use_jacobian: bool = True 

183 lens_logits: Optional[Dict[int, Float[torch.Tensor, "pos d_vocab"]]] = None 

184 model_logits: Optional[Float[torch.Tensor, "pos d_vocab"]] = None 

185 

186 def top_tokens(self, tokenizer: Any, k: int = 5) -> Dict[int, List[List[str]]]: 

187 """Decode the top-``k`` tokens per layer and position. 

188 

189 Args: 

190 tokenizer: The model's tokenizer (``model.tokenizer``). 

191 k: Number of top tokens to decode per (layer, position). 

192 

193 Returns: 

194 ``{layer: [ [top-k strings] per position ]}``, positions aligned with 

195 :attr:`positions`. 

196 """ 

197 out: Dict[int, List[List[str]]] = {} 

198 retained = next(iter(self.lens_topk_indices.values())).shape[-1] 

199 if not 1 <= k <= retained: 

200 raise ValueError(f"k must be between 1 and the retained top_k={retained}, got {k}") 

201 for layer, ids in self.lens_topk_indices.items(): 

202 out[layer] = [[tokenizer.decode([t]) for t in row[:k].tolist()] for row in ids] 

203 return out 

204 

205 

206class JacobianLens: 

207 """A fitted Jacobian lens: one transport matrix per source layer. 

208 

209 Layer convention (matching the reference implementation and the published 

210 artifacts): index ``l`` refers to the **output of block** ``l`` at the 

211 Bridge-native hook ``blocks.{l}.hook_out``. ``J[l]`` maps that activation 

212 to the final block's output, pre final norm. The final layer itself is never 

213 fitted (its transport is the identity), so 

214 ``source_layers == [0, ..., n_layers - 2]`` for a full fit. 

215 

216 Attributes: 

217 jacobians: ``{layer: [d_model, d_model]}`` transport matrices, fp32, CPU. 

218 n_prompts: Number of prompts averaged into the fit. 

219 d_model: Residual stream width the lens was fitted for. 

220 metadata: Optional provenance (model name, TransformerLens version, fit 

221 hyperparameters). Preserved by :meth:`save`/:meth:`load`; artifacts 

222 from the reference implementation load with empty metadata. 

223 """ 

224 

225 def __init__( 

226 self, 

227 jacobians: Dict[int, Float[torch.Tensor, "d_model d_model"]], 

228 *, 

229 n_prompts: int, 

230 d_model: int, 

231 metadata: Optional[Dict[str, Any]] = None, 

232 ) -> None: 

233 if not jacobians: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 raise ValueError("jacobians must contain at least one layer") 

235 for layer, matrix in jacobians.items(): 

236 if matrix.shape != (d_model, d_model): 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 raise ValueError( 

238 f"jacobians[{layer}] has shape {tuple(matrix.shape)}, " 

239 f"expected ({d_model}, {d_model})" 

240 ) 

241 self.jacobians: Dict[int, torch.Tensor] = { 

242 int(layer): matrix.detach().float().cpu() for layer, matrix in jacobians.items() 

243 } 

244 self.n_prompts = int(n_prompts) 

245 self.d_model = int(d_model) 

246 self.metadata: Dict[str, Any] = dict(metadata or {}) 

247 self._device_jacobians: Dict[Tuple[int, torch.device], torch.Tensor] = {} 

248 

249 @property 

250 def source_layers(self) -> List[int]: 

251 """Sorted list of layers this lens has transport matrices for.""" 

252 return sorted(self.jacobians) 

253 

254 def __repr__(self) -> str: 

255 layers = self.source_layers 

256 return ( 

257 f"JacobianLens(layers={layers[0]}..{layers[-1]} ({len(layers)}), " 

258 f"d_model={self.d_model}, n_prompts={self.n_prompts})" 

259 ) 

260 

261 # ------------------------------------------------------------------ # 

262 # persistence # 

263 # ------------------------------------------------------------------ # 

264 

265 def save(self, path: str, *, dtype: torch.dtype = torch.float16) -> None: 

266 """Save the lens in the reference implementation's artifact format. 

267 

268 The four official keys (``J``, ``n_prompts``, ``source_layers``, 

269 ``d_model``) are written unchanged so the file stays loadable by the 

270 reference package; TransformerLens provenance is stored under an 

271 additive ``metadata`` key. 

272 

273 Args: 

274 path: Destination ``.pt`` path. 

275 dtype: Storage dtype. Defaults to fp16 like the reference 

276 implementation — Jacobian entries are order-one, so the smaller 

277 dtype costs little precision and halves the artifact on disk. 

278 """ 

279 _validate_metadata(self.metadata) 

280 payload: Dict[str, Any] = { 

281 "J": {layer: matrix.to(dtype) for layer, matrix in self.jacobians.items()}, 

282 "n_prompts": self.n_prompts, 

283 "source_layers": self.source_layers, 

284 "d_model": self.d_model, 

285 } 

286 if self.metadata: 

287 payload["metadata"] = self.metadata 

288 torch.save(payload, path) 

289 

290 @classmethod 

291 def load(cls, path: str) -> "JacobianLens": 

292 """Load a lens artifact or fit checkpoint saved in a supported schema. 

293 

294 Two file schemas are accepted: 

295 

296 **Artifact** (the reference format, written by :meth:`save` or the 

297 Anthropic reference package): must contain a ``J`` key mapping layer 

298 indices to transport matrices, plus ``n_prompts``, ``d_model``, and an 

299 optional ``metadata`` dict. 

300 

301 **Fit checkpoint** (running-sum format, written by the reference 

302 implementation's ``write_checkpoint()`` during fitting): must contain 

303 a ``jacobian_sum`` key mapping layer indices to *running-sum* matrices 

304 (i.e. the sum over prompts, not yet divided by the prompt count), plus 

305 ``n_done``. ``d_model`` is inferred from the first matrix's shape; 

306 no explicit ``d_model`` key is required or expected. The per-layer 

307 means are reconstructed on load. A 

308 ``converted_from: "jacobian_lens_checkpoint"`` key is added to 

309 metadata so :meth:`merge` refuses to silently combine checkpoints with 

310 natively TL-fitted lenses. Fit-reserved provenance keys 

311 (``transformer_lens_fit``, etc.) are stripped; scalar fields that can 

312 be serialised under ``weights_only=True`` are preserved. 

313 Tensor-valued metadata fields that would fail :func:`_validate_metadata` 

314 are recorded by name in a ``dropped_fields`` list. 

315 

316 Fit checkpoint schema (reference ``write_checkpoint()`` format) 

317 --------------------------------------------------------------- 

318 The reference implementation writes exactly six top-level keys; all 

319 other keys in the payload are ignored:: 

320 

321 { 

322 "jacobian_sum": {<layer int>: <float32 tensor [d, d]>, ...}, 

323 "n_done": <int>, # prompts accumulated into jacobian_sum 

324 "next_idx": <int>, # next prompt index (informational) 

325 "source_layers": [<int>, ...], # documented layer indices (informational) 

326 "target_layer": <int>, # target layer — harvested into metadata 

327 "skip_first": <int>, # leading positions skipped (informational) 

328 # optional flat provenance accepted from alternative checkpoint writers: 

329 "model_name": <str>, 

330 "model_revision": <str>, 

331 "corpus": <str>, 

332 # optional nested provenance accepted from alternative writers: 

333 "metadata": {<str>: <scalar/list/dict>, ...}, 

334 } 

335 

336 Args: 

337 path: Path to the ``.pt`` file. 

338 

339 Raises: 

340 ValueError: If the file lacks both a ``J`` key (artifact) and a 

341 ``jacobian_sum`` key (checkpoint), or if a checkpoint records a 

342 non-positive ``n_prompts``. 

343 """ 

344 payload = torch.load(path, map_location="cpu", weights_only=True) 

345 if "J" in payload: 

346 return cls( 

347 {int(layer): matrix for layer, matrix in payload["J"].items()}, 

348 n_prompts=int(payload.get("n_prompts", 0)), 

349 d_model=int(payload["d_model"]), 

350 metadata=payload.get("metadata"), 

351 ) 

352 if "jacobian_sum" in payload: 

353 return cls._from_checkpoint_payload(path, payload) 

354 raise ValueError( 

355 f"{path} does not look like a Jacobian lens artifact or fit checkpoint. " 

356 "Expected a 'J' key (artifact) or 'jacobian_sum' key (fit checkpoint). " 

357 "See JacobianLens.load() for the supported file schemas." 

358 ) 

359 

360 @classmethod 

361 def _from_checkpoint_payload(cls, path: str, payload: Dict[str, Any]) -> "JacobianLens": 

362 """Reconstruct a JacobianLens from a fit-checkpoint payload. 

363 

364 Divides the running Jacobian sums by ``n_prompts``, strips fit-reserved 

365 provenance keys, harvests safe scalar metadata, records dropped tensor 

366 fields, and marks the result as converted so :meth:`merge` refuses to 

367 mix it with natively TL-fitted lenses. 

368 """ 

369 n_prompts = int(payload.get("n_done", payload.get("n_prompts", 0))) 

370 if n_prompts <= 0: 

371 raise ValueError( 

372 f"{path} is a fit checkpoint with n_prompts={n_prompts}; " 

373 "a positive prompt count is required to reconstruct the Jacobian mean." 

374 ) 

375 if not payload["jacobian_sum"]: 

376 raise ValueError( 

377 f"{path} is a fit checkpoint with an empty jacobian_sum; " 

378 "at least one layer matrix is required to reconstruct d_model." 

379 ) 

380 first_matrix = next(iter(payload["jacobian_sum"].values())) 

381 d_model = first_matrix.shape[0] 

382 jacobians = { 

383 int(layer): matrix.float() / n_prompts 

384 for layer, matrix in payload["jacobian_sum"].items() 

385 } 

386 

387 # Collect raw provenance: first from nested "metadata", then supplement 

388 # with flat top-level keys that some checkpoint writers place directly 

389 # in the payload (model_name, model_revision, corpus). 

390 raw_meta: Dict[str, Any] = dict(payload.get("metadata") or {}) 

391 for key in _CHECKPOINT_FLAT_PROVENANCE: 

392 if key in payload and key not in raw_meta: 

393 raw_meta[key] = payload[key] 

394 # target_layer lives at the top level in the reference checkpoint format; 

395 # harvest it into metadata so validate_model() can check the fitting target. 

396 if "target_layer" in payload and "target_layer" not in raw_meta: 

397 raw_meta["target_layer"] = payload["target_layer"] 

398 

399 # Build clean metadata: drop fit-reserved keys, record tensor-valued 

400 # fields that _validate_metadata would reject (they cannot survive a 

401 # weights_only=True reload), and keep everything else that validates. 

402 dropped_fields: List[str] = [] 

403 clean_meta: Dict[str, Any] = {} 

404 for key, value in raw_meta.items(): 

405 if key in _FIT_RESERVED_KEYS: 

406 continue 

407 if isinstance(value, torch.Tensor): 

408 dropped_fields.append(f"{key}: shape={tuple(value.shape)} dtype={value.dtype}") 

409 continue 

410 try: 

411 _validate_metadata({key: value}) 

412 clean_meta[key] = value 

413 except ValueError: 

414 dropped_fields.append(key) 

415 

416 clean_meta["converted_from"] = "jacobian_lens_checkpoint" 

417 if dropped_fields: 

418 clean_meta["dropped_fields"] = dropped_fields 

419 

420 return cls(jacobians, n_prompts=n_prompts, d_model=d_model, metadata=clean_meta) 

421 

422 @classmethod 

423 def from_pretrained( 

424 cls, 

425 name_or_path: str, 

426 *, 

427 filename: str = "lens.pt", 

428 revision: Optional[str] = None, 

429 model: Any = None, 

430 ) -> "JacobianLens": 

431 """Load a lens from a local path, a short model name, or a Hub repo. 

432 

433 Resolution order 

434 ---------------- 

435 1. **Local file** — if *name_or_path* is an existing ``.pt`` file, 

436 load it directly. 

437 2. **Local directory** — if *name_or_path* is a directory, load 

438 ``<name_or_path>/<filename>``. 

439 3. **Registry short name or HF model ID** — if *name_or_path* matches 

440 a key or alias in the bundled ``jacobian_lens_registry.json`` (e.g. 

441 ``"gemma-2-2b"`` or ``"google/gemma-2-2b"``), the corresponding 

442 artifact in ``neuronpedia/jacobian-lens`` is fetched automatically. 

443 The *filename* argument is ignored in this case because the registry 

444 already encodes the correct subpath. 

445 4. **Explicit Hub repo** — otherwise *name_or_path* is treated as a Hub 

446 repo id and *filename* is used as-is, preserving full backward 

447 compatibility (e.g. ``from_pretrained("neuronpedia/jacobian-lens", 

448 filename="gpt2-small/jlens/...")``). 

449 

450 Args: 

451 name_or_path: A local ``.pt`` file, a local directory, a short 

452 model name such as ``"gemma-2-2b"`` or ``"llama3.1-8b"``, a 

453 Hugging Face model ID such as ``"google/gemma-2-2b"``, or an 

454 explicit Hub repo id paired with *filename*. 

455 filename: File (or subpath) inside a local directory or an explicit 

456 Hub repo. Ignored when *name_or_path* resolves via the 

457 registry. 

458 revision: Optional Hub revision (branch, tag, or commit) to pin. 

459 When omitted, the Hub repository's mutable default branch is 

460 followed; pin a commit hash for reproducible analyses. 

461 model: If given, :meth:`validate_model` is called so dimension or 

462 weight-processing mismatches fail here rather than at first use. 

463 

464 Returns: 

465 The loaded (and, if ``model`` was given, validated) lens. 

466 

467 Examples:: 

468 

469 # Short model name — no need to remember the HF subpath 

470 lens = JacobianLens.from_pretrained("gemma-2-2b", model=model) 

471 

472 # HF model ID also works 

473 lens = JacobianLens.from_pretrained("google/gemma-2-2b", model=model) 

474 

475 # Explicit Hub repo + subpath (backward-compatible) 

476 lens = JacobianLens.from_pretrained( 

477 "neuronpedia/jacobian-lens", 

478 filename="gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt", 

479 model=model, 

480 ) 

481 """ 

482 import os 

483 

484 if os.path.isfile(name_or_path): 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true

485 lens = cls.load(name_or_path) 

486 elif os.path.isdir(name_or_path): 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true

487 lens = cls.load(os.path.join(name_or_path, filename)) 

488 else: 

489 from huggingface_hub import hf_hub_download 

490 

491 resolved = _resolve_registry_entry(name_or_path) 

492 if resolved is not None: 

493 repo_id, resolved_filename = resolved 

494 else: 

495 repo_id, resolved_filename = name_or_path, filename 

496 

497 local_path = call_hf_with_retry( 

498 hf_hub_download, 

499 repo_id=repo_id, 

500 filename=resolved_filename, 

501 revision=revision, 

502 ) 

503 lens = cls.load(local_path) 

504 if model is not None: 

505 lens.validate_model(model) 

506 return lens 

507 

508 @classmethod 

509 def merge(cls, lenses: Sequence["JacobianLens"]) -> "JacobianLens": 

510 """Combine lenses fitted on disjoint prompt slices. 

511 

512 The per-layer matrices are averaged weighted by each lens's 

513 ``n_prompts``, matching the reference implementation, so fitting can be 

514 parallelized across processes or machines and merged afterwards. 

515 Provenance must match across shards (apart from ``n_prompts``), so a 

516 merge cannot silently relabel matrices fitted with different models, 

517 corpora, dtypes, or estimator settings. The merged count replaces the 

518 per-shard count. 

519 

520 Args: 

521 lenses: Lenses that agree exactly on ``source_layers`` and 

522 ``d_model``. 

523 

524 Raises: 

525 ValueError: On an empty sequence or mismatched lenses. 

526 """ 

527 if not lenses: 

528 raise ValueError("cannot merge an empty sequence of lenses") 

529 invalid_counts = [ 

530 (index, lens.n_prompts) for index, lens in enumerate(lenses) if lens.n_prompts <= 0 

531 ] 

532 if invalid_counts: 

533 raise ValueError( 

534 "every lens passed to merge() must have positive n_prompts; " 

535 f"invalid shards: {invalid_counts}" 

536 ) 

537 first = lenses[0] 

538 for lens in lenses: 

539 _validate_metadata(lens.metadata) 

540 first_provenance = { 

541 key: value for key, value in first.metadata.items() if key != "n_prompts" 

542 } 

543 for other in lenses[1:]: 

544 if other.source_layers != first.source_layers or other.d_model != first.d_model: 

545 raise ValueError( 

546 "all lenses being merged must share the same source_layers and d_model" 

547 ) 

548 other_provenance = { 

549 key: value for key, value in other.metadata.items() if key != "n_prompts" 

550 } 

551 if other_provenance != first_provenance: 

552 raise ValueError( 

553 "all lenses being merged must share the same provenance metadata " 

554 "apart from n_prompts" 

555 ) 

556 total = sum(lens.n_prompts for lens in lenses) 

557 merged = { 

558 layer: torch.stack([lens.jacobians[layer] * lens.n_prompts for lens in lenses]).sum( 

559 dim=0 

560 ) 

561 / total 

562 for layer in first.source_layers 

563 } 

564 metadata = dict(first.metadata) 

565 if metadata: 

566 metadata["n_prompts"] = total 

567 return cls(merged, n_prompts=total, d_model=first.d_model, metadata=metadata) 

568 

569 # ------------------------------------------------------------------ # 

570 # model validation # 

571 # ------------------------------------------------------------------ # 

572 

573 def validate_model(self, model: Any) -> "JacobianLens": 

574 """Check that ``model`` matches this lens; raise loudly if not. 

575 

576 Requires a raw causal ``TransformerBridge`` with the standard direct 

577 final-norm/unembed path, verifies recorded model provenance, residual 

578 width and layer range, and enforces the published final-block target 

579 convention. 

580 

581 Args: 

582 model: A raw ``TransformerBridge``. 

583 

584 Returns: 

585 ``self``, for chaining. 

586 

587 Raises: 

588 TypeError: If model is not a ``TransformerBridge``. 

589 ValueError: On model provenance or ``d_model`` mismatch, 

590 out-of-range source layers, compatibility mode, unsupported 

591 attention/output paths, or a non-final target convention. 

592 """ 

593 _require_raw_bridge(model) 

594 artifact_model_name = self.metadata.get("model_name") 

595 current_model_name = getattr(model.cfg, "model_name", None) 

596 if artifact_model_name is not None and artifact_model_name != current_model_name: 

597 raise ValueError( 

598 f"lens was fitted for model {artifact_model_name!r}, but the supplied " 

599 f"model is {current_model_name!r}." 

600 ) 

601 artifact_revision = self.metadata.get("model_revision") 

602 current_revision = _get_model_revision(model) 

603 if artifact_revision is not None and artifact_revision != current_revision: 

604 raise ValueError( 

605 f"lens was fitted for model revision {artifact_revision!r}, but the " 

606 f"supplied model revision is {current_revision!r}." 

607 ) 

608 d_model = model.cfg.d_model 

609 if d_model != self.d_model: 

610 raise ValueError( 

611 f"lens was fitted for d_model={self.d_model}, but the model has " 

612 f"d_model={d_model} — this lens belongs to a different model." 

613 ) 

614 n_layers = model.cfg.n_layers 

615 final_layer = n_layers - 1 

616 out_of_range = [layer for layer in self.source_layers if not 0 <= layer < final_layer] 

617 if out_of_range: 

618 raise ValueError( 

619 f"lens has source layers {out_of_range} outside the model's " 

620 f"0..{final_layer - 1} source range — this lens belongs to a different model." 

621 ) 

622 target_layer = int(self.metadata.get("target_layer", final_layer)) 

623 if target_layer != final_layer: 

624 raise ValueError( 

625 f"lens targets layer {target_layer}, but readout supports only the " 

626 f"published final-layer convention ({final_layer}); refit without " 

627 "a custom target layer." 

628 ) 

629 return self 

630 

631 # ------------------------------------------------------------------ # 

632 # reading # 

633 # ------------------------------------------------------------------ # 

634 

635 def clear_device_cache(self) -> None: 

636 """Release lazily cached Jacobian copies on accelerator devices.""" 

637 self._device_jacobians.clear() 

638 

639 def _matrix_on(self, layer: int, device: Union[str, torch.device]) -> torch.Tensor: 

640 """Return one cached fp32 Jacobian copy for a layer/device pair.""" 

641 if layer not in self.jacobians: 641 ↛ 642line 641 didn't jump to line 642 because the condition on line 641 was never true

642 raise ValueError( 

643 f"layer {layer} is not in this lens's source layers " 

644 f"({self.source_layers[0]}..{self.source_layers[-1]})" 

645 ) 

646 resolved_device = torch.device(device) 

647 key = (layer, resolved_device) 

648 matrix = self._device_jacobians.get(key) 

649 if matrix is None: 

650 matrix = self.jacobians[layer].to(device=resolved_device, dtype=torch.float32) 

651 self._device_jacobians[key] = matrix 

652 return matrix 

653 

654 def transport( 

655 self, 

656 residual: Float[torch.Tensor, "... d_model"], 

657 layer: int, 

658 ) -> Float[torch.Tensor, "... d_model"]: 

659 """Map layer-``layer`` activations into the final block's output basis. 

660 

661 Computes ``J[layer] @ h`` per activation vector, in fp32. 

662 

663 Args: 

664 residual: Activations from the output of block ``layer``. 

665 layer: Source layer index. 

666 """ 

667 matrix = self._matrix_on(layer, residual.device) 

668 return residual.float() @ matrix.T 

669 

670 @torch.no_grad() 

671 def readout( 

672 self, 

673 model: Any, 

674 input: Union[str, Int[torch.Tensor, "batch seq"]], 

675 *, 

676 layers: Optional[Sequence[int]] = None, 

677 positions: Optional[Sequence[int]] = None, 

678 use_jacobian: bool = True, 

679 top_k: int = DEFAULT_TOP_K, 

680 return_full_logits: bool = False, 

681 ) -> JacobianLensReadout: 

682 """Read per-layer vocabulary logits for a prompt. 

683 

684 Runs the model once with caching, transports the residual stream at each 

685 requested layer through ``J[layer]`` (or the identity when 

686 ``use_jacobian=False`` — the logit lens), and applies the model's own 

687 final norm, unembedding, architecture logit scaling, and logit soft cap. 

688 

689 Args: 

690 model: A raw ``TransformerBridge``. 

691 input: A prompt string, or a ``[1, seq]`` token tensor. 

692 layers: Layers to read. Defaults to every fitted layer plus the 

693 final layer. The final layer (``n_layers - 1``) is always read 

694 with the identity transport — by construction its lens equals 

695 the model's own output distribution. 

696 positions: Token positions to read (negative indices allowed). 

697 Defaults to all positions. 

698 use_jacobian: Apply the Jacobian transport. ``False`` gives the 

699 logit-lens baseline through the identical code path. 

700 top_k: Number of values and vocabulary ids retained per layer and 

701 position. Defaults to 10. 

702 return_full_logits: Also retain full vocabulary tensors on CPU. 

703 This is opt-in because a 64-token Gemma readout across all 

704 layers is roughly 1.7 GB. 

705 

706 Returns: 

707 A :class:`JacobianLensReadout`. 

708 

709 Raises: 

710 ValueError: If the model fails :meth:`validate_model`, ``input`` is 

711 batched, ``top_k`` is invalid, or a requested layer has no 

712 transport matrix. 

713 """ 

714 self.validate_model(model) 

715 tokens = model.to_tokens(input) if isinstance(input, str) else input 

716 if tokens.ndim != 2 or tokens.shape[0] != 1: 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true

717 raise ValueError(f"readout expects a single prompt; got shape {tuple(tokens.shape)}") 

718 n_layers = model.cfg.n_layers 

719 final_layer = n_layers - 1 

720 if layers is None: 

721 layers = self.source_layers + [final_layer] 

722 layers = [_normalize_layer(layer, n_layers) for layer in layers] 

723 for layer in layers: 

724 if use_jacobian and layer != final_layer and layer not in self.jacobians: 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true

725 raise ValueError( 

726 f"layer {layer} is not in this lens's source layers; " 

727 f"available: {self.source_layers} (+{final_layer} as identity)" 

728 ) 

729 

730 if top_k < 1: 730 ↛ 731line 730 didn't jump to line 731 because the condition on line 730 was never true

731 raise ValueError(f"top_k must be at least 1, got {top_k}") 

732 seq_len = tokens.shape[1] 

733 norm_positions = _normalize_positions(positions, seq_len) 

734 

735 hook_names = { 

736 layer: _resid_post_hook_name(layer) for layer in layers if layer != final_layer 

737 } 

738 wanted = set(hook_names.values()) 

739 logits, cache = model.run_with_cache(tokens, names_filter=lambda name: name in wanted) 

740 selected_model_logits = logits[0, norm_positions, :].float() 

741 if top_k > selected_model_logits.shape[-1]: 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true

742 raise ValueError( 

743 f"top_k={top_k} exceeds the model vocabulary size " 

744 f"{selected_model_logits.shape[-1]}" 

745 ) 

746 model_topk = selected_model_logits.topk(top_k, dim=-1) 

747 full_model_logits = selected_model_logits.cpu() if return_full_logits else None 

748 lens_topk_values: Dict[int, torch.Tensor] = {} 

749 lens_topk_indices: Dict[int, torch.Tensor] = {} 

750 full_lens_logits: Optional[Dict[int, torch.Tensor]] = {} if return_full_logits else None 

751 for layer in layers: 

752 if layer == final_layer: 

753 layer_logits = selected_model_logits 

754 layer_topk = model_topk 

755 else: 

756 activation = cache[hook_names[layer]] 

757 _validate_residual_activation( 

758 activation, 

759 d_model=model.cfg.d_model, 

760 hook_name=hook_names[layer], 

761 ) 

762 residual = activation[0, norm_positions, :] 

763 transported = self.transport(residual, layer) if use_jacobian else residual.float() 

764 layer_logits = _unembed(model, transported) 

765 layer_topk = layer_logits.topk(top_k, dim=-1) 

766 lens_topk_values[layer] = layer_topk.values.cpu() 

767 lens_topk_indices[layer] = layer_topk.indices.cpu() 

768 if full_lens_logits is not None: 

769 if layer == final_layer: 

770 assert full_model_logits is not None 

771 full_lens_logits[layer] = full_model_logits 

772 else: 

773 full_lens_logits[layer] = layer_logits.cpu() 

774 return JacobianLensReadout( 

775 lens_topk_values=lens_topk_values, 

776 lens_topk_indices=lens_topk_indices, 

777 model_topk_values=model_topk.values.cpu(), 

778 model_topk_indices=model_topk.indices.cpu(), 

779 tokens=tokens[0].cpu(), 

780 positions=norm_positions, 

781 use_jacobian=use_jacobian, 

782 lens_logits=full_lens_logits, 

783 model_logits=full_model_logits, 

784 ) 

785 

786 @torch.no_grad() 

787 def lens_vectors( 

788 self, 

789 model: Any, 

790 tokens: Union[TokenInput, Sequence[TokenInput]], 

791 layer: int, 

792 ) -> Float[torch.Tensor, "n d_model"]: 

793 """Residual-stream directions for vocabulary tokens at a layer. 

794 

795 The J-lens vector for token ``t`` is row ``t`` of ``W_U J[layer]`` 

796 expressed in layer-``layer`` residual coordinates: 

797 ``v_t = J[layer]^T W_U[:, t]``. 

798 

799 Args: 

800 model: The model supplying ``W_U``. 

801 tokens: A token string / id, or a sequence of them. Strings must 

802 encode to a single token. 

803 layer: Source layer for the vectors. 

804 

805 Returns: 

806 One vector per token, fp32, on the model's device. 

807 """ 

808 self.validate_model(model) 

809 layer = _normalize_layer(layer, model.cfg.n_layers) 

810 token_ids = _to_token_ids(model, tokens) 

811 unembed_columns = model.W_U[:, token_ids].float() # [d_model, n] 

812 matrix = self._matrix_on(layer, unembed_columns.device) 

813 return (matrix.T @ unembed_columns).T 

814 

815 # ------------------------------------------------------------------ # 

816 # interventions # 

817 # ------------------------------------------------------------------ # 

818 

819 def steering_hooks( 

820 self, 

821 model: Any, 

822 token: TokenInput, 

823 layers: Sequence[int], 

824 *, 

825 alpha: float = 4.0, 

826 positions: Optional[Sequence[int]] = None, 

827 ) -> List[Tuple[str, Any]]: 

828 """Hooks that steer the residual stream along a token's J-lens vector. 

829 

830 At each layer the unit-normalized lens vector is added, scaled by 

831 ``alpha`` times the activation's **median** per-position residual norm: 

832 ``h <- h + alpha * median||h|| * v̂``. This norm-matched 

833 parameterization follows the steering description in the reference 

834 implementation's experiment protocols; the paper's minimal form is 

835 the unscaled ``h <- h + alpha * v_t``, recoverable by passing the raw 

836 :meth:`lens_vectors` output to your own hook. The median (not mean) is 

837 used so attention-sink positions — whose residual norms run orders of 

838 magnitude above typical positions — do not inflate the scale. 

839 

840 Args: 

841 model: The model the hooks will run on. 

842 token: The concept token to steer toward. 

843 layers: Layers to intervene at. 

844 alpha: Steering strength scalar; ``0`` disables. Because of the 

845 norm-matched scale, values of order 1 already perturb the 

846 stream by roughly its own magnitude. 

847 positions: Chunk-local positions to steer (negative indices allowed 

848 and normalized on every hook invocation). Defaults to all. 

849 

850 Returns: 

851 ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)`` or 

852 ``model.run_with_hooks(fwd_hooks=...)``. 

853 """ 

854 self.validate_model(model) 

855 hooks = [] 

856 for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: 

857 direction = self.lens_vectors(model, token, layer)[0] 

858 unit = _unit_rows(direction.unsqueeze(0), layer=layer)[0] 

859 device_units: Dict[torch.device, torch.Tensor] = {} 

860 

861 def transform( 

862 selected: Float[torch.Tensor, "batch pos d_model"], 

863 unit: torch.Tensor = unit, 

864 device_units: Dict[torch.device, torch.Tensor] = device_units, 

865 ) -> Float[torch.Tensor, "batch pos d_model"]: 

866 local_unit = _cached_on_device(unit, device_units, selected.device) 

867 scale = alpha * selected.float().norm(dim=-1).median() 

868 return selected.float() + scale * local_unit 

869 

870 hooks.append( 

871 ( 

872 _resid_post_hook_name(layer), 

873 _make_intervention_hook(transform, positions, model.cfg.d_model), 

874 ) 

875 ) 

876 return hooks 

877 

878 def ablation_hooks( 

879 self, 

880 model: Any, 

881 tokens: Union[TokenInput, Sequence[TokenInput]], 

882 layers: Sequence[int], 

883 *, 

884 positions: Optional[Sequence[int]] = None, 

885 ) -> List[Tuple[str, Any]]: 

886 """Hooks that project token directions out of the residual stream. 

887 

888 For each token's unit lens vector ``v̂``: ``h <- h - (h·v̂) v̂``, 

889 applied sequentially when several tokens are given. 

890 

891 Args: 

892 model: The model the hooks will run on. 

893 tokens: Concept token(s) to suppress. 

894 layers: Layers to intervene at. 

895 positions: Chunk-local positions to ablate (negative indices allowed 

896 and normalized on every hook invocation). Defaults to all. 

897 

898 Returns: 

899 ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)``. 

900 """ 

901 self.validate_model(model) 

902 hooks = [] 

903 for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: 

904 vectors = self.lens_vectors(model, tokens, layer) 

905 units = _unit_rows(vectors, layer=layer) 

906 device_units: Dict[torch.device, torch.Tensor] = {} 

907 

908 def transform( 

909 selected: Float[torch.Tensor, "batch pos d_model"], 

910 units: torch.Tensor = units, 

911 device_units: Dict[torch.device, torch.Tensor] = device_units, 

912 ) -> Float[torch.Tensor, "batch pos d_model"]: 

913 local_units = _cached_on_device(units, device_units, selected.device) 

914 result = selected.float() 

915 for unit in local_units: 

916 coeff = result @ unit 

917 result = result - coeff.unsqueeze(-1) * unit 

918 return result 

919 

920 hooks.append( 

921 ( 

922 _resid_post_hook_name(layer), 

923 _make_intervention_hook(transform, positions, model.cfg.d_model), 

924 ) 

925 ) 

926 return hooks 

927 

928 def swap_hooks( 

929 self, 

930 model: Any, 

931 source_token: TokenInput, 

932 target_token: TokenInput, 

933 layers: Sequence[int], 

934 *, 

935 alpha: float = 1.0, 

936 positions: Optional[Sequence[int]] = None, 

937 ) -> List[Tuple[str, Any]]: 

938 """Hooks that swap two concepts' coordinates in lens space. 

939 

940 The paper's patching-in-lens-coordinates intervention: with 

941 ``V = [v_s, v_t]`` and lens coordinates ``c = V⁺ h`` (pseudoinverse), 

942 the update is ``h <- h + alpha * V (sigma(c) - c)`` where ``sigma`` 

943 exchanges the two coordinates. The component of ``h`` orthogonal to 

944 ``span{v_s, v_t}`` is untouched. ``alpha=2`` is the paper's 

945 "double-strength" swap. 

946 

947 Args: 

948 model: The model the hooks will run on. 

949 source_token: The concept to remove (e.g. ``" France"``). 

950 target_token: The concept to install (e.g. ``" China"``). 

951 layers: Layers to intervene at (the paper clamps the swap across an 

952 intermediate-layer band). 

953 alpha: Swap strength. 

954 positions: Chunk-local positions to swap (negative indices allowed 

955 and normalized on every hook invocation). Defaults to all. 

956 

957 Returns: 

958 ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)``. 

959 """ 

960 self.validate_model(model) 

961 source_id, target_id = _to_token_ids(model, [source_token, target_token]) 

962 if source_id == target_id: 

963 raise ValueError( 

964 "source_token and target_token resolve to the same token id; " 

965 "a coordinate swap would be a silent no-op" 

966 ) 

967 

968 hooks = [] 

969 for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: 

970 vectors = self.lens_vectors(model, [source_id, target_id], layer) 

971 units = _unit_rows(vectors, layer=layer) 

972 cosine = abs(float((units[0] @ units[1]).item())) 

973 if not math.isfinite(cosine) or cosine >= _SWAP_ERROR_COSINE: 

974 raise ValueError( 

975 f"swap vectors at layer {layer} are numerically near-parallel " 

976 f"(abs cosine={cosine:.6f}); choose better-separated concepts" 

977 ) 

978 if cosine >= _SWAP_WARN_COSINE: 

979 warnings.warn( 

980 f"swap vectors at layer {layer} are poorly conditioned " 

981 f"(abs cosine={cosine:.6f}); the intervention may be amplified", 

982 UserWarning, 

983 stacklevel=2, 

984 ) 

985 basis = vectors.T # [d, 2] 

986 pinv = torch.linalg.pinv(basis) # [2, d] 

987 device_basis: Dict[torch.device, torch.Tensor] = {} 

988 device_pinv: Dict[torch.device, torch.Tensor] = {} 

989 

990 def transform( 

991 selected: Float[torch.Tensor, "batch pos d_model"], 

992 basis: torch.Tensor = basis, 

993 pinv: torch.Tensor = pinv, 

994 device_basis: Dict[torch.device, torch.Tensor] = device_basis, 

995 device_pinv: Dict[torch.device, torch.Tensor] = device_pinv, 

996 ) -> Float[torch.Tensor, "batch pos d_model"]: 

997 local_basis = _cached_on_device(basis, device_basis, selected.device) 

998 local_pinv = _cached_on_device(pinv, device_pinv, selected.device) 

999 coords = selected.float() @ local_pinv.T # [..., 2] 

1000 delta = alpha * ((coords[..., [1, 0]] - coords) @ local_basis.T) 

1001 return selected.float() + delta 

1002 

1003 hooks.append( 

1004 ( 

1005 _resid_post_hook_name(layer), 

1006 _make_intervention_hook(transform, positions, model.cfg.d_model), 

1007 ) 

1008 ) 

1009 return hooks 

1010 

1011 # ------------------------------------------------------------------ # 

1012 # fitting # 

1013 # ------------------------------------------------------------------ # 

1014 

1015 @classmethod 

1016 def fit( 

1017 cls, 

1018 model: Any, 

1019 prompts: Sequence[str], 

1020 *, 

1021 corpus: str, 

1022 source_layers: Optional[Sequence[int]] = None, 

1023 dim_batch: int = 8, 

1024 max_seq_len: int = 128, 

1025 skip_first_positions: int = DEFAULT_SKIP_FIRST_POSITIONS, 

1026 show_progress: bool = True, 

1027 metadata: Optional[Dict[str, Any]] = None, 

1028 ) -> "JacobianLens": 

1029 """Fit a Jacobian lens on a hooked model. 

1030 

1031 Implements the reference estimator exactly. For each prompt: one forward 

1032 pass (the prompt replicated ``dim_batch`` times along the batch axis), 

1033 then ``ceil(d_model / dim_batch)`` backward passes. Each backward plants 

1034 a one-hot cotangent for one output dimension at *every* valid target 

1035 position simultaneously — causal attention guarantees the gradient at 

1036 source position ``t`` is then the sum over target positions 

1037 ``t' >= t`` with no explicit masking. Rows are averaged over valid 

1038 source positions (the first ``skip_first_positions`` and the final 

1039 position are excluded), and prompts contribute equally to the final 

1040 mean. There is no randomness: the computation is deterministic given 

1041 the prompts. 

1042 

1043 The reference implementation reports that fit quality saturates 

1044 quickly — on the order of 100 prompts of 128 tokens is usable; the 

1045 published lenses use up to 1000. Use :meth:`merge` to parallelize 

1046 across prompt slices. 

1047 

1048 Args: 

1049 model: A raw ``TransformerBridge``. Model parameters are temporarily 

1050 frozen (``requires_grad=False``) during fitting and restored 

1051 after. Cotangents and activation gradients use the model dtype; 

1052 fit with a float32 model for the highest-fidelity estimator. 

1053 prompts: Prompt strings. Prompts too short to contain a valid 

1054 position (``seq_len <= skip_first_positions + 1``) are skipped 

1055 with a warning and do not count toward ``n_prompts``. 

1056 corpus: Stable identifier for the prompt corpus or slice, recorded 

1057 in artifact provenance. 

1058 source_layers: Layers to fit. Defaults to every layer below 

1059 the final layer. Negative indices count from ``n_layers``. 

1060 dim_batch: Output dimensions per backward pass. Higher is faster 

1061 but replicates the prompt ``dim_batch`` times in memory; total 

1062 backward FLOPs are unchanged. 

1063 max_seq_len: Prompts are truncated to this many tokens. 

1064 skip_first_positions: Leading positions excluded from the source 

1065 average. 

1066 show_progress: Show a tqdm progress bar over prompts. 

1067 metadata: Extra provenance merged into :attr:`metadata`. 

1068 

1069 Returns: 

1070 The fitted :class:`JacobianLens`. 

1071 

1072 Raises: 

1073 TypeError: If model is not a ``TransformerBridge``. 

1074 ValueError: On compatibility mode, invalid provenance or layer 

1075 indices, or if no prompt was long enough to fit on. 

1076 """ 

1077 _require_raw_bridge(model) 

1078 if not isinstance(corpus, str) or not corpus.strip(): 1078 ↛ 1079line 1078 didn't jump to line 1079 because the condition on line 1078 was never true

1079 raise ValueError("corpus must be a non-empty provenance identifier") 

1080 n_layers = model.cfg.n_layers 

1081 d_model = model.cfg.d_model 

1082 resolved_target = n_layers - 1 

1083 if source_layers is None: 

1084 resolved_sources = list(range(resolved_target)) 

1085 else: 

1086 resolved_sources = sorted( 

1087 {_normalize_layer(layer, n_layers) for layer in source_layers} 

1088 ) 

1089 if not resolved_sources: 1089 ↛ 1090line 1089 didn't jump to line 1090 because the condition on line 1089 was never true

1090 raise ValueError("source_layers is empty") 

1091 if resolved_sources[-1] >= resolved_target: 1091 ↛ 1092line 1091 didn't jump to line 1092 because the condition on line 1091 was never true

1092 raise ValueError( 

1093 f"every source layer must be below target_layer={resolved_target}; " 

1094 f"got {resolved_sources}" 

1095 ) 

1096 if dim_batch < 1: 1096 ↛ 1097line 1096 didn't jump to line 1097 because the condition on line 1096 was never true

1097 raise ValueError(f"dim_batch must be >= 1, got {dim_batch}") 

1098 if skip_first_positions < 0: 

1099 raise ValueError(f"skip_first_positions must be >= 0, got {skip_first_positions}") 

1100 fit_dtype = model.W_U.dtype 

1101 if fit_dtype in (torch.float16, torch.bfloat16): 

1102 warnings.warn( 

1103 f"fitting in {fit_dtype} accumulates Jacobian gradients at reduced " 

1104 "precision; use a float32 TransformerBridge for the highest-fidelity fit", 

1105 UserWarning, 

1106 stacklevel=2, 

1107 ) 

1108 

1109 jacobian_sum = { 

1110 layer: torch.zeros(d_model, d_model, dtype=torch.float32) for layer in resolved_sources 

1111 } 

1112 n_done = 0 

1113 iterator = tqdm(prompts, desc="fitting J-lens", disable=not show_progress) 

1114 with _frozen_parameters(model): 

1115 for prompt in iterator: 

1116 tokens = model.to_tokens(prompt)[:, :max_seq_len] 

1117 seq_len = tokens.shape[1] 

1118 if seq_len <= skip_first_positions + 1: 

1119 warnings.warn( 

1120 f"skipping prompt with only {seq_len} tokens " 

1121 f"(need > {skip_first_positions + 1})", 

1122 stacklevel=2, 

1123 ) 

1124 continue 

1125 per_prompt = _jacobian_for_prompt( 

1126 model, 

1127 tokens, 

1128 source_layers=resolved_sources, 

1129 dim_batch=dim_batch, 

1130 skip_first_positions=skip_first_positions, 

1131 ) 

1132 for layer in resolved_sources: 

1133 jacobian_sum[layer] += per_prompt[layer] 

1134 n_done += 1 

1135 if n_done == 0: 

1136 raise ValueError( 

1137 "every prompt was too short to contribute valid positions; nothing was fitted" 

1138 ) 

1139 

1140 fit_metadata: Dict[str, Any] = { 

1141 "model_name": getattr(model.cfg, "model_name", None), 

1142 "model_revision": _get_model_revision(model), 

1143 "transformer_lens_version": version("transformer-lens"), 

1144 "model_system": "TransformerBridge", 

1145 "processing": { 

1146 "compatibility_mode": False, 

1147 "weight_basis": "raw_huggingface", 

1148 }, 

1149 "hook_convention": "blocks.{layer}.hook_out", 

1150 "corpus": corpus, 

1151 "n_prompts": n_done, 

1152 "fit_dtype": str(fit_dtype).removeprefix("torch."), 

1153 "target_layer": resolved_target, 

1154 "dim_batch": dim_batch, 

1155 "max_seq_len": max_seq_len, 

1156 "skip_first_positions": skip_first_positions, 

1157 "transformer_lens_fit": True, 

1158 } 

1159 reserved = sorted(set(fit_metadata).intersection(metadata or {})) 

1160 if reserved: 1160 ↛ 1161line 1160 didn't jump to line 1161 because the condition on line 1160 was never true

1161 raise ValueError(f"metadata cannot override fit provenance keys: {reserved}") 

1162 full_metadata = dict(metadata or {}) 

1163 full_metadata.update(fit_metadata) 

1164 _validate_metadata(full_metadata) 

1165 return cls( 

1166 {layer: jacobian_sum[layer] / n_done for layer in resolved_sources}, 

1167 n_prompts=n_done, 

1168 d_model=d_model, 

1169 metadata=full_metadata, 

1170 ) 

1171 

1172 

1173# ---------------------------------------------------------------------- # 

1174# helpers # 

1175# ---------------------------------------------------------------------- # 

1176 

1177 

1178def _resid_post_hook_name(layer: int) -> str: 

1179 """Bridge-native hook for the output of block ``layer``.""" 

1180 return f"blocks.{layer}.hook_out" 

1181 

1182 

1183def _get_model_revision(model: Any) -> Optional[str]: 

1184 """Return the resolved Hugging Face commit recorded on a booted model.""" 

1185 original_model = getattr(model, "original_model", None) 

1186 hf_config = getattr(original_model, "config", None) 

1187 revision = getattr(hf_config, "_commit_hash", None) 

1188 return revision if isinstance(revision, str) and revision else None 

1189 

1190 

1191def _require_raw_bridge(model: Any) -> None: 

1192 """Require the causal raw-Bridge contract used by fit and readout.""" 

1193 from transformer_lens.model_bridge import TransformerBridge 

1194 

1195 if not isinstance(model, TransformerBridge): 

1196 raise TypeError( 

1197 "JacobianLens supports TransformerBridge only; load a fresh model with " 

1198 "TransformerBridge.boot_transformers(...)." 

1199 ) 

1200 if getattr(model, "compatibility_mode", False): 

1201 raise ValueError( 

1202 "compatibility mode is enabled on this TransformerBridge and changes " 

1203 "the residual basis. Use a freshly booted " 

1204 "TransformerBridge.boot_transformers(...) model with raw weights." 

1205 ) 

1206 if getattr(model, "_weights_processed", False): 

1207 raise ValueError( 

1208 "process_weights was called on this TransformerBridge and changed the " 

1209 "raw HuggingFace weight basis. Use a freshly booted " 

1210 "TransformerBridge.boot_transformers(...) model." 

1211 ) 

1212 adapter = model.adapter 

1213 if not adapter.supports_generation: 

1214 raise ValueError( 

1215 "JacobianLens requires a causal decoder-only Bridge whose adapter " 

1216 f"supports text generation; {type(adapter).__name__} declares " 

1217 "supports_generation=False." 

1218 ) 

1219 adapter.validate_output_logits_transform() 

1220 attention_dir = getattr(model.cfg, "attention_dir", "causal") 

1221 if attention_dir != "causal": 

1222 raise ValueError( 

1223 "JacobianLens requires causal attention because its estimator relies on " 

1224 "causality to exclude target positions before each source position; " 

1225 f"got attention_dir={attention_dir!r}." 

1226 ) 

1227 total_ut_steps = int(getattr(model.cfg, "total_ut_steps", 1) or 1) 

1228 if total_ut_steps != 1: 

1229 raise ValueError( 

1230 "JacobianLens requires each physical block hook to fire once per forward; " 

1231 f"this looped-depth Bridge runs total_ut_steps={total_ut_steps}." 

1232 ) 

1233 component_mapping = adapter.get_component_mapping() 

1234 required_components = ("blocks", "ln_final", "unembed") 

1235 missing_components = [ 

1236 component 

1237 for component in required_components 

1238 if component not in component_mapping or not hasattr(model, component) 

1239 ] 

1240 if missing_components: 

1241 raise ValueError( 

1242 "JacobianLens requires the standard direct ln_final -> unembed output path; " 

1243 f"this Bridge is missing {missing_components}." 

1244 ) 

1245 blocks_component = component_mapping["blocks"] 

1246 if not getattr(blocks_component, "hook_out_is_single_residual_stream", False): 

1247 raise ValueError( 

1248 "JacobianLens requires single-stream [batch, position, d_model] block " 

1249 f"outputs; {type(blocks_component).__name__} does not provide that contract." 

1250 ) 

1251 if "project_out" in component_mapping: 

1252 raise ValueError( 

1253 "JacobianLens does not yet support a final output projection between " 

1254 "the residual stream and unembedding." 

1255 ) 

1256 unembed_width = model.W_U.shape[0] 

1257 if unembed_width != model.cfg.d_model: 

1258 raise ValueError( 

1259 "JacobianLens requires a direct d_model-width unembedding after ln_final; " 

1260 f"got W_U input width {unembed_width} for d_model={model.cfg.d_model}. " 

1261 "Architectures with a final output projection are not yet supported." 

1262 ) 

1263 

1264 

1265def _validate_metadata(metadata: Dict[str, Any]) -> None: 

1266 """Reject values that ``torch.load(weights_only=True)`` cannot reload.""" 

1267 

1268 def validate(value: Any, path: str) -> None: 

1269 if value is None or type(value) in (bool, int, float, str): 

1270 return 

1271 if type(value) in (list, tuple): 

1272 for index, item in enumerate(value): 

1273 validate(item, f"{path}[{index}]") 

1274 return 

1275 if type(value) is dict: 

1276 for key, item in value.items(): 

1277 if type(key) is not str: 

1278 raise ValueError( 

1279 f"{path} has non-string key {key!r}; metadata keys must be strings" 

1280 ) 

1281 validate(item, f"{path}.{key}") 

1282 return 

1283 raise ValueError( 

1284 f"{path} has unsupported type {type(value).__name__}; use only " 

1285 "None, bool, int, float, str, lists, tuples, and string-keyed dicts" 

1286 ) 

1287 

1288 validate(metadata, "metadata") 

1289 

1290 

1291def _normalize_positions(positions: Optional[Sequence[int]], seq_len: int) -> List[int]: 

1292 """Normalize negative chunk-local positions and raise before indexing.""" 

1293 if positions is None: 

1294 return list(range(seq_len)) 

1295 normalized = [position + seq_len if position < 0 else position for position in positions] 

1296 out_of_range = [position for position in normalized if not 0 <= position < seq_len] 

1297 if out_of_range: 

1298 raise ValueError( 

1299 f"positions {out_of_range} out of range for an activation chunk of length {seq_len}" 

1300 ) 

1301 return normalized 

1302 

1303 

1304def _cached_on_device( 

1305 tensor: torch.Tensor, 

1306 cache: Dict[torch.device, torch.Tensor], 

1307 device: Union[str, torch.device], 

1308) -> torch.Tensor: 

1309 """Cache a small fp32 intervention tensor on each activation device.""" 

1310 resolved_device = torch.device(device) 

1311 local = cache.get(resolved_device) 

1312 if local is None: 1312 ↛ 1315line 1312 didn't jump to line 1315 because the condition on line 1312 was always true

1313 local = tensor.to(device=resolved_device, dtype=torch.float32) 

1314 cache[resolved_device] = local 

1315 return local 

1316 

1317 

1318def _unit_rows(vectors: torch.Tensor, *, layer: int) -> torch.Tensor: 

1319 """Normalize intervention vectors and reject zero/non-finite directions.""" 

1320 vectors = vectors.float() 

1321 norms = vectors.norm(dim=-1, keepdim=True) 

1322 if (~torch.isfinite(norms) | (norms <= torch.finfo(torch.float32).eps)).any(): 1322 ↛ 1323line 1322 didn't jump to line 1323 because the condition on line 1322 was never true

1323 raise ValueError(f"lens vectors at layer {layer} contain a zero or non-finite direction") 

1324 return vectors / norms 

1325 

1326 

1327def _make_intervention_hook( 

1328 transform: Callable[[torch.Tensor], torch.Tensor], 

1329 positions: Optional[Sequence[int]], 

1330 d_model: int, 

1331) -> Callable[..., torch.Tensor]: 

1332 """Apply a transform with shared position, dtype, and device hardening.""" 

1333 requested = None if positions is None else tuple(positions) 

1334 if requested == (): 1334 ↛ 1335line 1334 didn't jump to line 1335 because the condition on line 1334 was never true

1335 raise ValueError("positions must contain at least one index") 

1336 

1337 def hook_fn( 

1338 activation: Float[torch.Tensor, "batch pos d_model"], hook: Any 

1339 ) -> Float[torch.Tensor, "batch pos d_model"]: 

1340 hook_name = getattr(hook, "name", "intervention hook") 

1341 _validate_residual_activation(activation, d_model=d_model, hook_name=hook_name) 

1342 normalized = _normalize_positions(requested, activation.shape[1]) 

1343 selected = activation if requested is None else activation[:, normalized, :] 

1344 transformed = transform(selected) 

1345 if transformed.shape != selected.shape: 1345 ↛ 1346line 1345 didn't jump to line 1346 because the condition on line 1345 was never true

1346 raise ValueError( 

1347 f"intervention returned shape {tuple(transformed.shape)}, " 

1348 f"expected {tuple(selected.shape)}" 

1349 ) 

1350 transformed = transformed.to(device=activation.device, dtype=activation.dtype) 

1351 if requested is None: 

1352 return transformed 

1353 output = activation.clone() 

1354 output[:, normalized, :] = transformed 

1355 return output 

1356 

1357 return hook_fn 

1358 

1359 

1360def _unembed( 

1361 model: Any, residual: Float[torch.Tensor, "pos d_model"] 

1362) -> Float[torch.Tensor, "pos d_vocab"]: 

1363 """Apply the model's own final norm, unembedding, logit scale, and soft cap. 

1364 

1365 The norm/unembed components contract on ``[batch, pos, d_model]``, so the 

1366 position rows are passed through with a singleton batch axis. The residual 

1367 is moved to the unembedding's device for sharded/multi-GPU models. 

1368 """ 

1369 unembed_weight = model.W_U 

1370 compute_dtype = unembed_weight.dtype 

1371 batched = residual.to(device=unembed_weight.device, dtype=compute_dtype).unsqueeze(0) 

1372 logits = model.unembed(model.ln_final(batched)).squeeze(0) 

1373 return model.adapter.apply_output_logits_transform(logits).float() 

1374 

1375 

1376def _to_token_ids(model: Any, tokens: Union[TokenInput, Sequence[TokenInput]]) -> List[int]: 

1377 """Convert token strings / ids into a list of single-token ids.""" 

1378 if isinstance(tokens, (str, int)): 

1379 tokens = [tokens] 

1380 ids: List[int] = [] 

1381 for token in tokens: 

1382 if isinstance(token, str): 

1383 ids.append(model.to_single_token(token)) 

1384 else: 

1385 ids.append(int(token)) 

1386 if not ids: 

1387 raise ValueError("tokens must contain at least one token") 

1388 d_vocab = model.W_U.shape[1] 

1389 invalid = [token_id for token_id in ids if not 0 <= token_id < d_vocab] 

1390 if invalid: 

1391 raise ValueError(f"token ids {invalid} out of range for vocabulary size {d_vocab}") 

1392 return ids 

1393 

1394 

1395def _validate_residual_activation( 

1396 activation: torch.Tensor, 

1397 *, 

1398 d_model: int, 

1399 hook_name: str, 

1400) -> None: 

1401 """Fail before interpreting a non-standard block output as a residual stream.""" 

1402 if activation.ndim != 3 or activation.shape[-1] != d_model: 

1403 raise ValueError( 

1404 f"{hook_name} must have shape [batch, position, {d_model}], " 

1405 f"got {tuple(activation.shape)}" 

1406 ) 

1407 

1408 

1409def _normalize_layer(layer: int, n_layers: int) -> int: 

1410 """Resolve negative layer indices and bounds-check.""" 

1411 resolved = layer + n_layers if layer < 0 else layer 

1412 if not 0 <= resolved < n_layers: 

1413 raise ValueError(f"layer {layer} out of range for a {n_layers}-layer model") 

1414 return resolved 

1415 

1416 

1417class _frozen_parameters: 

1418 """Context manager: freeze all parameters, restore their flags on exit. 

1419 

1420 Freezing keeps the autograd graph rooted at the residual stream rather than 

1421 at the weights, so fitting retains only the blocks between the earliest 

1422 source layer and the target layer. 

1423 """ 

1424 

1425 def __init__(self, model: Any) -> None: 

1426 self.model = model 

1427 self.saved: List[Tuple[torch.nn.Parameter, bool]] = [] 

1428 

1429 def __enter__(self) -> None: 

1430 self.saved = [(param, param.requires_grad) for param in self.model.parameters()] 

1431 for param, _ in self.saved: 

1432 param.requires_grad_(False) 

1433 

1434 def __exit__(self, *exc: Any) -> None: 

1435 for param, flag in self.saved: 

1436 param.requires_grad_(flag) 

1437 

1438 

1439def _jacobian_for_prompt( 

1440 model: Any, 

1441 tokens: Int[torch.Tensor, "one seq"], 

1442 *, 

1443 source_layers: List[int], 

1444 dim_batch: int, 

1445 skip_first_positions: int, 

1446) -> Dict[int, Float[torch.Tensor, "d_model d_model"]]: 

1447 """Exact per-prompt Jacobian rows via batched one-hot cotangents. 

1448 

1449 Assumes parameters are already frozen (see :class:`_frozen_parameters`) so 

1450 that marking the earliest source activation ``requires_grad`` roots the 

1451 graph there. 

1452 """ 

1453 d_model = model.cfg.d_model 

1454 target_layer = model.cfg.n_layers - 1 

1455 seq_len = tokens.shape[1] 

1456 valid_positions = list(range(skip_first_positions, seq_len - 1)) 

1457 replicated = tokens.expand(dim_batch, -1) 

1458 

1459 captured: Dict[str, torch.Tensor] = {} 

1460 root_name = _resid_post_hook_name(min(source_layers)) 

1461 hook_layers = sorted(set(source_layers) | {target_layer}) 

1462 

1463 def capture_fn( 

1464 activation: Float[torch.Tensor, "batch pos d_model"], hook: Any 

1465 ) -> Float[torch.Tensor, "batch pos d_model"]: 

1466 _validate_residual_activation(activation, d_model=d_model, hook_name=hook.name) 

1467 if hook.name == root_name and not activation.requires_grad: 

1468 activation.requires_grad_(True) 

1469 captured[hook.name] = activation 

1470 return activation 

1471 

1472 fwd_hooks = [(_resid_post_hook_name(layer), capture_fn) for layer in hook_layers] 

1473 with torch.enable_grad(), model.hooks(fwd_hooks=fwd_hooks): 

1474 model(replicated, return_type=None) 

1475 

1476 target = captured[_resid_post_hook_name(target_layer)] 

1477 sources = [captured[_resid_post_hook_name(layer)] for layer in source_layers] 

1478 device = target.device 

1479 positions_index = torch.tensor(valid_positions, device=device) 

1480 batch_index = torch.arange(dim_batch, device=device) 

1481 

1482 jacobians = { 

1483 layer: torch.zeros(d_model, d_model, dtype=torch.float32) for layer in source_layers 

1484 } 

1485 cotangent = torch.zeros_like(target) 

1486 n_passes = -(-d_model // dim_batch) # ceil division 

1487 for pass_index in range(n_passes): 

1488 dim_start = pass_index * dim_batch 

1489 n_dims = min(dim_batch, d_model - dim_start) 

1490 cotangent.zero_() 

1491 cotangent[ 

1492 batch_index[:n_dims, None], 

1493 positions_index[None, :], 

1494 dim_start + batch_index[:n_dims, None], 

1495 ] = 1.0 

1496 grads = torch.autograd.grad( 

1497 outputs=target, 

1498 inputs=sources, 

1499 grad_outputs=cotangent, 

1500 retain_graph=pass_index < n_passes - 1, 

1501 ) 

1502 for layer, grad in zip(source_layers, grads): 

1503 # each gradient lives on its layer's device under sharded/device_map setups 

1504 rows = grad[:n_dims, positions_index.to(grad.device), :].float().mean(dim=1) 

1505 jacobians[layer][dim_start : dim_start + n_dims, :] = rows.cpu() 

1506 del grads 

1507 return jacobians