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

692 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-21 19:27 +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 warnings 

64from dataclasses import dataclass 

65from importlib.metadata import version 

66from typing import ( 

67 Any, 

68 Callable, 

69 Dict, 

70 List, 

71 Mapping, 

72 MutableMapping, 

73 Optional, 

74 Sequence, 

75 Tuple, 

76 Union, 

77) 

78 

79import torch 

80from jaxtyping import Float, Int 

81from tqdm.auto import tqdm 

82 

83from transformer_lens.ActivationCache import ActivationCache 

84from transformer_lens.tools.analysis._model_state import require_eval_mode 

85from transformer_lens.tools.analysis.jacobian_lens_coordinate_patch import ( 

86 CoordinatePatch, 

87 solve_coordinate_patch, 

88 solve_coordinate_patch_positions, 

89) 

90from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( 

91 DEFAULT_K, 

92 JSpaceDecomposition, 

93 JSpaceOccupancy, 

94 JSpaceVarianceProfile, 

95 _diagnose_intervention_pair, 

96 estimate_occupancy, 

97 get_sparse_decomposition, 

98) 

99from transformer_lens.utilities.hf_utils import call_hf_with_retry 

100 

101TokenInput = Union[str, int] 

102 

103# Backward-provider seam for the fit drive loop. A provider takes the target 

104# residual, the source residuals it differentiates against, one batched one-hot 

105# cotangent, and the ``retain_graph`` flag, and returns one gradient per source 

106# (the same tuple ``torch.autograd.grad`` returns). Keeping the backward step 

107# behind this narrow callable lets the estimator-independent driver run an 

108# alternate estimator without touching its capture / cotangent-batching / 

109# averaging machinery; the ordinary J-lens path passes :func:`_ordinary_vjp`. 

110BackwardProvider = Callable[ 

111 [torch.Tensor, List[torch.Tensor], torch.Tensor, bool], 

112 Tuple[torch.Tensor, ...], 

113] 

114 

115# --------------------------------------------------------------------------- 

116# Registry helpers 

117# --------------------------------------------------------------------------- 

118 

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

120 

121 

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

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

124 global _REGISTRY_CACHE 

125 if _REGISTRY_CACHE is None: 

126 import json 

127 import pathlib 

128 

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

130 with registry_path.open() as fh: 

131 _REGISTRY_CACHE = json.load(fh) 

132 return _REGISTRY_CACHE 

133 

134 

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

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

137 

138 Matching is tried in two passes: 

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

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

141 

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

143 generic Hub download path. 

144 """ 

145 registry = _load_registry() 

146 if name_or_path in registry: 

147 entry = registry[name_or_path] 

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

149 for entry in registry.values(): 

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

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

152 return None 

153 

154 

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

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

157DEFAULT_SKIP_FIRST_POSITIONS = 16 

158DEFAULT_TOP_K = 10 

159 

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

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

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

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

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

165_FIT_RESERVED_KEYS: frozenset = frozenset( 

166 { 

167 "transformer_lens_fit", 

168 "transformer_lens_version", 

169 "model_system", 

170 "processing", 

171 "hook_convention", 

172 "fit_dtype", 

173 "dim_batch", 

174 "max_seq_len", 

175 "skip_first_positions", 

176 } 

177) 

178 

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

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

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

182 

183 

184@dataclass 

185class JacobianLensReadout: 

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

187 

188 Attributes: 

189 lens_topk_values: 

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

191 lens_topk_indices: 

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

193 model_topk_values: 

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

195 model_topk_indices: 

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

197 lens_logits: 

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

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

200 model_logits: 

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

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

203 tokens: 

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

205 positions: 

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

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

208 use_jacobian: 

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

210 """ 

211 

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

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

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

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

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

217 positions: List[int] 

218 use_jacobian: bool = True 

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

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

221 

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

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

224 

225 Args: 

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

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

228 

229 Returns: 

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

231 :attr:`positions`. 

232 """ 

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

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

235 if not 1 <= k <= retained: 

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

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

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

239 return out 

240 

241 

242class JacobianLens: 

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

244 

245 Layer convention (matching the reference implementation and the published 

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

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

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

249 fitted (its transport is the identity), so 

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

251 

252 Attributes: 

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

254 n_prompts: Number of prompts averaged into the fit. 

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

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

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

258 from the reference implementation load with empty metadata. 

259 """ 

260 

261 def __init__( 

262 self, 

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

264 *, 

265 n_prompts: int, 

266 d_model: int, 

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

268 ) -> None: 

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

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

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

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

273 raise ValueError( 

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

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

276 ) 

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

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

279 } 

280 self.n_prompts = int(n_prompts) 

281 self.d_model = int(d_model) 

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

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

284 self._dictionary_cache: Dict[Tuple[int, torch.device], torch.Tensor] = {} 

285 self._unembedding_snapshots: Dict[torch.device, torch.Tensor] = {} 

286 

287 @property 

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

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

290 return sorted(self.jacobians) 

291 

292 def __repr__(self) -> str: 

293 layers = self.source_layers 

294 return ( 

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

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

297 ) 

298 

299 # ------------------------------------------------------------------ # 

300 # persistence # 

301 # ------------------------------------------------------------------ # 

302 

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

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

305 

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

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

308 reference package; TransformerLens provenance is stored under an 

309 additive ``metadata`` key. 

310 

311 Args: 

312 path: Destination ``.pt`` path. 

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

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

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

316 """ 

317 _validate_metadata(self.metadata) 

318 payload: Dict[str, Any] = { 

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

320 "n_prompts": self.n_prompts, 

321 "source_layers": self.source_layers, 

322 "d_model": self.d_model, 

323 } 

324 if self.metadata: 

325 payload["metadata"] = self.metadata 

326 torch.save(payload, path) 

327 

328 @classmethod 

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

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

331 

332 Two file schemas are accepted: 

333 

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

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

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

337 optional ``metadata`` dict. 

338 

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

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

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

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

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

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

345 means are reconstructed on load. A 

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

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

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

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

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

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

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

353 

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

355 --------------------------------------------------------------- 

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

357 other keys in the payload are ignored:: 

358 

359 { 

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

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

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

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

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

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

366 # optional flat provenance accepted from alternative checkpoint writers: 

367 "model_name": <str>, 

368 "model_revision": <str>, 

369 "corpus": <str>, 

370 # optional nested provenance accepted from alternative writers: 

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

372 } 

373 

374 Args: 

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

376 

377 Raises: 

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

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

380 non-positive ``n_prompts``. 

381 """ 

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

383 if "J" in payload: 

384 return cls( 

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

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

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

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

389 ) 

390 if "jacobian_sum" in payload: 

391 return cls._from_checkpoint_payload(path, payload) 

392 raise ValueError( 

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

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

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

396 ) 

397 

398 @classmethod 

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

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

401 

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

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

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

405 mix it with natively TL-fitted lenses. 

406 """ 

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

408 if n_prompts <= 0: 

409 raise ValueError( 

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

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

412 ) 

413 if not payload["jacobian_sum"]: 

414 raise ValueError( 

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

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

417 ) 

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

419 d_model = first_matrix.shape[0] 

420 jacobians = { 

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

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

423 } 

424 

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

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

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

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

429 for key in _CHECKPOINT_FLAT_PROVENANCE: 

430 if key in payload and key not in raw_meta: 

431 raw_meta[key] = payload[key] 

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

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

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

435 raw_meta["target_layer"] = payload["target_layer"] 

436 

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

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

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

440 dropped_fields: List[str] = [] 

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

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

443 if key in _FIT_RESERVED_KEYS: 

444 continue 

445 if isinstance(value, torch.Tensor): 

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

447 continue 

448 try: 

449 _validate_metadata({key: value}) 

450 clean_meta[key] = value 

451 except ValueError: 

452 dropped_fields.append(key) 

453 

454 clean_meta["converted_from"] = "jacobian_lens_checkpoint" 

455 if dropped_fields: 

456 clean_meta["dropped_fields"] = dropped_fields 

457 

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

459 

460 @classmethod 

461 def from_pretrained( 

462 cls, 

463 name_or_path: str, 

464 *, 

465 filename: str = "lens.pt", 

466 revision: Optional[str] = None, 

467 model: Any = None, 

468 ) -> "JacobianLens": 

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

470 

471 Resolution order 

472 ---------------- 

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

474 load it directly. 

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

476 ``<name_or_path>/<filename>``. 

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

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

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

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

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

482 already encodes the correct subpath. 

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

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

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

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

487 

488 Args: 

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

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

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

492 explicit Hub repo id paired with *filename*. 

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

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

495 registry. 

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

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

498 followed; pin a commit hash for reproducible analyses. 

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

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

501 

502 Returns: 

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

504 

505 Examples:: 

506 

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

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

509 

510 # HF model ID also works 

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

512 

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

514 lens = JacobianLens.from_pretrained( 

515 "neuronpedia/jacobian-lens", 

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

517 model=model, 

518 ) 

519 """ 

520 import os 

521 

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

523 lens = cls.load(name_or_path) 

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

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

526 else: 

527 from huggingface_hub import hf_hub_download 

528 

529 resolved = _resolve_registry_entry(name_or_path) 

530 if resolved is not None: 

531 repo_id, resolved_filename = resolved 

532 else: 

533 repo_id, resolved_filename = name_or_path, filename 

534 

535 local_path = call_hf_with_retry( 

536 hf_hub_download, 

537 repo_id=repo_id, 

538 filename=resolved_filename, 

539 revision=revision, 

540 ) 

541 lens = cls.load(local_path) 

542 if model is not None: 

543 lens.validate_model(model) 

544 return lens 

545 

546 @classmethod 

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

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

549 

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

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

552 parallelized across processes or machines and merged afterwards. 

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

554 merge cannot silently relabel matrices fitted with different models, 

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

556 per-shard count. 

557 

558 Args: 

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

560 ``d_model``. 

561 

562 Raises: 

563 ValueError: On an empty sequence or mismatched lenses. 

564 """ 

565 if not lenses: 

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

567 invalid_counts = [ 

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

569 ] 

570 if invalid_counts: 

571 raise ValueError( 

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

573 f"invalid shards: {invalid_counts}" 

574 ) 

575 first = lenses[0] 

576 for lens in lenses: 

577 _validate_metadata(lens.metadata) 

578 first_provenance = { 

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

580 } 

581 for other in lenses[1:]: 

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

583 raise ValueError( 

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

585 ) 

586 other_provenance = { 

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

588 } 

589 if other_provenance != first_provenance: 

590 raise ValueError( 

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

592 "apart from n_prompts" 

593 ) 

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

595 merged = { 

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

597 dim=0 

598 ) 

599 / total 

600 for layer in first.source_layers 

601 } 

602 metadata = dict(first.metadata) 

603 if metadata: 

604 metadata["n_prompts"] = total 

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

606 

607 # ------------------------------------------------------------------ # 

608 # model validation # 

609 # ------------------------------------------------------------------ # 

610 

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

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

613 

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

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

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

617 convention. 

618 

619 Args: 

620 model: A raw ``TransformerBridge``. 

621 

622 Returns: 

623 ``self``, for chaining. 

624 

625 Raises: 

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

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

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

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

630 """ 

631 _require_raw_bridge(model) 

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

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

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

635 raise ValueError( 

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

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

638 ) 

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

640 current_revision = _get_model_revision(model) 

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

642 raise ValueError( 

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

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

645 ) 

646 d_model = model.cfg.d_model 

647 if d_model != self.d_model: 

648 raise ValueError( 

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

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

651 ) 

652 n_layers = model.cfg.n_layers 

653 final_layer = n_layers - 1 

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

655 if out_of_range: 

656 raise ValueError( 

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

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

659 ) 

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

661 if target_layer != final_layer: 

662 raise ValueError( 

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

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

665 "a custom target layer." 

666 ) 

667 return self 

668 

669 # ------------------------------------------------------------------ # 

670 # reading # 

671 # ------------------------------------------------------------------ # 

672 

673 def clear_device_cache(self) -> None: 

674 """Release cached Jacobians, dictionaries, and unembedding snapshots on devices.""" 

675 self._device_jacobians.clear() 

676 self._dictionary_cache.clear() 

677 self._unembedding_snapshots.clear() 

678 

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

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

681 if layer not in self.jacobians: 

682 raise ValueError( 

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

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

685 ) 

686 resolved_device = torch.device(device) 

687 key = (layer, resolved_device) 

688 matrix = self._device_jacobians.get(key) 

689 if matrix is None: 

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

691 self._device_jacobians[key] = matrix 

692 return matrix 

693 

694 def transport( 

695 self, 

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

697 layer: int, 

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

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

700 

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

702 

703 Args: 

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

705 layer: Source layer index. 

706 """ 

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

708 return residual.float() @ matrix.T 

709 

710 @torch.no_grad() 

711 def readout( 

712 self, 

713 model: Any, 

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

715 *, 

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

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

718 use_jacobian: bool = True, 

719 top_k: int = DEFAULT_TOP_K, 

720 return_full_logits: bool = False, 

721 ) -> JacobianLensReadout: 

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

723 

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

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

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

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

728 

729 Args: 

730 model: A raw ``TransformerBridge``. 

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

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

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

734 with the identity transport — by construction its lens equals 

735 the model's own output distribution. 

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

737 Defaults to all positions. 

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

739 logit-lens baseline through the identical code path. 

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

741 position. Defaults to 10. 

742 return_full_logits: Also retain full vocabulary tensors on CPU. 

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

744 layers is roughly 1.7 GB. 

745 

746 Returns: 

747 A :class:`JacobianLensReadout`. 

748 

749 Raises: 

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

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

752 transport matrix. 

753 """ 

754 self.validate_model(model) 

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

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

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

758 n_layers = model.cfg.n_layers 

759 final_layer = n_layers - 1 

760 if layers is None: 

761 layers = self.source_layers + [final_layer] 

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

763 for layer in layers: 

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

765 raise ValueError( 

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

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

768 ) 

769 

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

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

772 seq_len = tokens.shape[1] 

773 norm_positions = _normalize_positions(positions, seq_len) 

774 

775 hook_names = { 

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

777 } 

778 wanted = set(hook_names.values()) 

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

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

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

782 raise ValueError( 

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

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

785 ) 

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

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

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

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

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

791 for layer in layers: 

792 if layer == final_layer: 

793 layer_logits = selected_model_logits 

794 layer_topk = model_topk 

795 else: 

796 activation = cache[hook_names[layer]] 

797 _validate_residual_activation( 

798 activation, 

799 d_model=model.cfg.d_model, 

800 hook_name=hook_names[layer], 

801 ) 

802 residual = activation[0, norm_positions, :] 

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

804 layer_logits = _unembed(model, transported) 

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

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

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

808 if full_lens_logits is not None: 

809 if layer == final_layer: 

810 assert full_model_logits is not None 

811 full_lens_logits[layer] = full_model_logits 

812 else: 

813 full_lens_logits[layer] = layer_logits.cpu() 

814 return JacobianLensReadout( 

815 lens_topk_values=lens_topk_values, 

816 lens_topk_indices=lens_topk_indices, 

817 model_topk_values=model_topk.values.cpu(), 

818 model_topk_indices=model_topk.indices.cpu(), 

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

820 positions=norm_positions, 

821 use_jacobian=use_jacobian, 

822 lens_logits=full_lens_logits, 

823 model_logits=full_model_logits, 

824 ) 

825 

826 @torch.no_grad() 

827 def lens_vectors( 

828 self, 

829 model: Any, 

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

831 layer: int, 

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

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

834 

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

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

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

838 

839 Args: 

840 model: The model supplying ``W_U``. 

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

842 encode to a single token. 

843 layer: Source layer for the vectors. 

844 

845 Returns: 

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

847 """ 

848 self.validate_model(model) 

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

850 token_ids = _to_token_ids(model, tokens) 

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

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

853 return (matrix.T @ unembed_columns).T 

854 

855 @torch.no_grad() 

856 def lens_vector_dictionary( 

857 self, model: Any, layer: int 

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

859 """Full-vocabulary J-lens dictionary at ``layer``: ``[d_vocab, d_model]``. 

860 

861 Row ``t`` is the J-lens vector ``v_t = J[layer]^T W_U[:, t]`` -- this is 

862 :meth:`lens_vectors` over the entire vocabulary. The result is cached while the 

863 model's unembedding is unchanged so a sparse decomposition can reuse it; 

864 :meth:`clear_device_cache` releases it. 

865 

866 The dictionary is vocabulary-sized and cached on the model's device 

867 (``d_vocab * d_model`` fp32 values, on the order of gigabytes for a large 

868 vocabulary), one entry per requested layer. One detached copy of ``W_U`` is 

869 retained per device to detect changes without transferring weights to the host. 

870 

871 Args: 

872 model: The model supplying ``W_U``. 

873 layer: Source layer for the dictionary (must be a fitted source layer). 

874 

875 Returns: 

876 The dictionary, fp32, on the model's device. 

877 """ 

878 self.validate_model(model) 

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

880 unembed = model.W_U 

881 device = torch.device(unembed.device) 

882 snapshot = self._unembedding_snapshots.get(device) 

883 if snapshot is None or not torch.equal(snapshot, unembed): 

884 self._unembedding_snapshots[device] = unembed.detach().clone() 

885 stale_keys = [key for key in self._dictionary_cache if key[1] == device] 

886 for key in stale_keys: 

887 del self._dictionary_cache[key] 

888 

889 key = (layer, device) 

890 dictionary = self._dictionary_cache.get(key) 

891 if dictionary is None: 

892 matrix = self._matrix_on(layer, device) # [d_model, d_model] 

893 dictionary = (matrix.T @ unembed.float()).T # [d_vocab, d_model] 

894 self._dictionary_cache[key] = dictionary 

895 return dictionary 

896 

897 @torch.no_grad() 

898 def decompose( 

899 self, 

900 model: Any, 

901 activation_or_prompt: Union[torch.Tensor, str], 

902 layer: int, 

903 *, 

904 position: Optional[int] = None, 

905 k: int = DEFAULT_K, 

906 algorithm: str = "nonnegative_orthogonal_matching_pursuit", 

907 ) -> JSpaceDecomposition: 

908 """Decompose an activation into its J-space content at ``layer``. 

909 

910 ``activation_or_prompt`` is either: 

911 

912 - a raw activation vector of shape ``[d_model]`` (leave ``position`` as ``None``), or 

913 - a prompt -- a string or a ``[1, seq]`` token tensor -- in which case ``position`` 

914 selects the token whose ``blocks.{layer}.hook_out`` activation is decomposed. 

915 

916 The full-vocabulary dictionary at ``layer`` is built (and cached) via 

917 :meth:`lens_vector_dictionary`, then :func:`get_sparse_decomposition` solves for a 

918 ``k``-sparse nonnegative combination of J-lens vectors. 

919 

920 Args: 

921 model: A raw ``TransformerBridge``. 

922 activation_or_prompt: An activation vector, or a prompt (string / token tensor). 

923 layer: Source layer (must be a fitted source layer). 

924 position: Token position when a prompt is given; must be ``None`` for a raw 

925 activation vector. 

926 k: Upper bound on the number of J-lens vectors to select; selection stops early 

927 once no unselected vector is materially positively correlated, so fewer may be 

928 returned. 

929 algorithm: Coefficient-update rule; see :func:`get_sparse_decomposition`. 

930 

931 Returns: 

932 A :class:`JSpaceDecomposition`. Its ``support`` (token ids here) holds only the 

933 numerically active J-lens vectors and ``selected_support`` every selected vector, 

934 with ``support.numel() <= selected_support.numel() <= k``. ``support`` and its 

935 token-decoding tensors are on CPU; the vector-valued outputs stay on the model's 

936 device. 

937 

938 Raises: 

939 ValueError: On an invalid model, a mismatched activation shape, a batched prompt, 

940 an unfitted layer, or an invalid ``k`` / ``algorithm``. 

941 RuntimeError: If the default nonnegative least-squares solver cannot validate its 

942 result against the KKT conditions. 

943 """ 

944 activation, resolved_layer = self._resolve_activation( 

945 model, activation_or_prompt, layer, position 

946 ) 

947 dictionary = self.lens_vector_dictionary(model, resolved_layer) 

948 return get_sparse_decomposition( 

949 activation.float().to(dictionary.device), dictionary, k, algorithm=algorithm 

950 ) 

951 

952 @torch.no_grad() 

953 def coordinate_patch( 

954 self, 

955 model: Any, 

956 activation_or_prompt: Union[torch.Tensor, str], 

957 layer: int, 

958 source_token: TokenInput, 

959 target_token: TokenInput, 

960 *, 

961 position: Optional[int] = None, 

962 decomposition: Optional[JSpaceDecomposition] = None, 

963 k: int = DEFAULT_K, 

964 mode: str = "substitute", 

965 alpha: float = 1.0, 

966 algorithm: str = "nonnegative_orthogonal_matching_pursuit", 

967 ) -> CoordinatePatch: 

968 """Patch sparse J-space coordinates for an activation at ``layer``. 

969 

970 The activation may be a raw ``[d_model]`` vector or a prompt paired with ``position``, 

971 exactly as in :meth:`decompose`. ``source_token`` must occur in the active sparse support. 

972 ``substitute`` replaces the target coordinate with the source coordinate and zeros the 

973 source; ``swap`` exchanges them. Other coordinates and ``x - reconstruction`` are fixed. 

974 

975 A supplied ``decomposition`` avoids repeating the vocabulary-scale sparse solve after its 

976 activation and dictionary compatibility have been validated. 

977 

978 Args: 

979 model: A raw ``TransformerBridge``. 

980 activation_or_prompt: An activation vector, or a prompt (string / token tensor). 

981 layer: Source layer (must be a fitted source layer). 

982 source_token: Active source concept, as a single-token string or token id. 

983 target_token: Distinct target concept, as a single-token string or token id. 

984 position: Token position when a prompt is given; ``None`` for a raw activation. 

985 decomposition: Optional compatible decomposition of this activation and dictionary. 

986 k: Sparse-solver upper bound when ``decomposition`` is not supplied. 

987 mode: ``"substitute"`` or ``"swap"``. 

988 alpha: Finite interpolation strength; zero is an exact no-op. 

989 algorithm: Sparse coefficient-update rule when solving a fresh decomposition. 

990 

991 Returns: 

992 A :class:`CoordinatePatch` containing the edited frame, diagnostics, and activation. 

993 """ 

994 activation, resolved_layer = self._resolve_activation( 

995 model, activation_or_prompt, layer, position 

996 ) 

997 dictionary = self.lens_vector_dictionary(model, resolved_layer) 

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

999 return solve_coordinate_patch( 

1000 activation.float().to(dictionary.device), 

1001 dictionary, 

1002 source_id, 

1003 target_id, 

1004 decomposition=decomposition, 

1005 k=k, 

1006 mode=mode, 

1007 alpha=alpha, 

1008 algorithm=algorithm, 

1009 ) 

1010 

1011 def _resolve_activation( 

1012 self, 

1013 model: Any, 

1014 activation_or_prompt: Union[torch.Tensor, str], 

1015 layer: int, 

1016 position: Optional[int], 

1017 ) -> Tuple[torch.Tensor, int]: 

1018 """Validate the model and layer, then resolve either a raw ``[d_model]`` activation or a 

1019 prompt plus ``position`` to the activation vector to analyse. 

1020 

1021 Returns ``(activation, resolved_layer)``. Shared by :meth:`decompose`, 

1022 :meth:`coordinate_patch`, and :meth:`occupancy` so all accept the same input forms with 

1023 identical validation. 

1024 """ 

1025 self.validate_model(model) 

1026 resolved_layer = _normalize_layer(layer, model.cfg.n_layers) 

1027 if resolved_layer not in self.jacobians: 

1028 raise ValueError( 

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

1030 f"available: {self.source_layers}" 

1031 ) 

1032 

1033 if position is None: 

1034 if not isinstance(activation_or_prompt, torch.Tensor): 

1035 raise ValueError( 

1036 "analysis expects a raw activation tensor when position is None; pass a " 

1037 "prompt together with a position to analyze a model activation" 

1038 ) 

1039 activation = activation_or_prompt 

1040 if activation.ndim != 1 or activation.shape[0] != self.d_model: 

1041 raise ValueError( 

1042 f"activation must be 1-D of length d_model={self.d_model}, " 

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

1044 ) 

1045 else: 

1046 if isinstance(activation_or_prompt, torch.Tensor) and activation_or_prompt.ndim == 1: 

1047 raise ValueError( 

1048 "position is only valid with a prompt (string or [1, seq] tokens); " 

1049 "pass a raw 1-D activation with position=None instead" 

1050 ) 

1051 tokens = ( 

1052 model.to_tokens(activation_or_prompt) 

1053 if isinstance(activation_or_prompt, str) 

1054 else activation_or_prompt 

1055 ) 

1056 if tokens.ndim != 2 or tokens.shape[0] != 1: 

1057 raise ValueError( 

1058 f"analysis expects a single prompt; got shape {tuple(tokens.shape)}" 

1059 ) 

1060 hook_name = _resid_post_hook_name(resolved_layer) 

1061 _, cache = model.run_with_cache(tokens, names_filter=lambda name: name == hook_name) 

1062 norm_position = _normalize_positions([position], tokens.shape[1])[0] 

1063 activation = cache[hook_name][0, norm_position, :] 

1064 return activation, resolved_layer 

1065 

1066 @torch.no_grad() 

1067 def occupancy( 

1068 self, 

1069 model: Any, 

1070 activation_or_prompt: Union[torch.Tensor, str], 

1071 layer: int, 

1072 *, 

1073 position: Optional[int] = None, 

1074 max_atoms: int = DEFAULT_K, 

1075 num_control_dictionaries: int = 32, 

1076 seed: int = 0, 

1077 ) -> JSpaceOccupancy: 

1078 """Estimate how many J-lens vectors are meaningfully active in an activation at ``layer``. 

1079 

1080 Resolves ``activation_or_prompt`` (a raw ``[d_model]`` vector, or a prompt plus 

1081 ``position``) exactly as :meth:`decompose`, builds the cached full-vocabulary dictionary 

1082 via :meth:`lens_vector_dictionary`, and calls :func:`estimate_occupancy`. 

1083 

1084 Args: 

1085 model: A raw ``TransformerBridge``. 

1086 activation_or_prompt: An activation vector, or a prompt (string / token tensor). 

1087 layer: Source layer (must be a fitted source layer). 

1088 position: Token position when a prompt is given; ``None`` for a raw activation. 

1089 max_atoms: Maximum number of J-lens vectors to consider. 

1090 num_control_dictionaries: Number of random control dictionaries to average over. 

1091 seed: Seed for the random control dictionaries (reproducibility). 

1092 

1093 Returns: 

1094 A :class:`JSpaceOccupancy`. 

1095 """ 

1096 activation, resolved_layer = self._resolve_activation( 

1097 model, activation_or_prompt, layer, position 

1098 ) 

1099 dictionary = self.lens_vector_dictionary(model, resolved_layer) 

1100 return estimate_occupancy( 

1101 activation.float().to(dictionary.device), 

1102 dictionary, 

1103 max_atoms=max_atoms, 

1104 num_control_dictionaries=num_control_dictionaries, 

1105 seed=seed, 

1106 ) 

1107 

1108 @torch.no_grad() 

1109 def fraction_of_variance( 

1110 self, 

1111 model: Any, 

1112 prompts: Union[str, torch.Tensor, Sequence[Union[str, torch.Tensor]]], 

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

1114 *, 

1115 k: int = DEFAULT_K, 

1116 skip_first: int = 16, 

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

1118 show_progress: bool = False, 

1119 ) -> JSpaceVarianceProfile: 

1120 """Profile the J-space share of activation variance over a prompt corpus. 

1121 

1122 Each prompt is run once (caching ``blocks.{layer}.hook_out`` for every requested layer). 

1123 At each sampled position the activation is decomposed and its J-space variance fraction 

1124 ``||j_space_component||^2 / ||activation||^2`` is recorded. The numerator is the 

1125 ``j_space_component`` -- the orthogonal projection of the activation onto the span of the 

1126 selected support (the paper's appendix "J-space component"), *not* the nonnegative 

1127 ``reconstruction``; the two coincide only when every selected atom stays active. Per layer 

1128 the profile reports the median of those fractions and the pooled ratio 

1129 ``sum(||j_space_component||^2) / sum(||activation||^2)`` (the paper's "fraction of total 

1130 variance"). 

1131 

1132 A layer that samples no positions -- every prompt shorter than ``skip_first``, or only 

1133 zero-norm activations -- contributes no fractions: its ``median`` and ``pooled`` are 

1134 ``float("nan")`` and its ``per_position`` tensor is empty. 

1135 

1136 Args: 

1137 model: A raw ``TransformerBridge``. 

1138 prompts: A prompt, or a sequence of prompts. Each token tensor must represent exactly 

1139 one prompt and have shape ``[1, seq]``. 

1140 layers: Source layers to profile; defaults to all fitted ``source_layers``. 

1141 k: Number of J-lens vectors per decomposition. 

1142 skip_first: Non-negative index before which positions are skipped (mirrors the fit's 

1143 early-position skip); not used for sampling when ``positions`` is given. 

1144 positions: Explicit positions to sample instead of ``skip_first`` onward. 

1145 show_progress: Show a tqdm progress bar over prompts. 

1146 

1147 Returns: 

1148 A :class:`JSpaceVarianceProfile`. 

1149 

1150 Raises: 

1151 ValueError: On an invalid model, an unfitted layer, an empty corpus, a negative 

1152 ``skip_first``, or a token tensor that does not have shape ``[1, seq]``. 

1153 """ 

1154 self.validate_model(model) 

1155 if skip_first < 0: 

1156 raise ValueError(f"skip_first must be non-negative, got {skip_first}") 

1157 if layers is None: 

1158 resolved_layers = list(self.source_layers) 

1159 else: 

1160 resolved_layers = [_normalize_layer(layer, model.cfg.n_layers) for layer in layers] 

1161 for layer in resolved_layers: 

1162 if layer not in self.jacobians: 

1163 raise ValueError( 

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

1165 f"available: {self.source_layers}" 

1166 ) 

1167 prompt_list: List[Union[str, torch.Tensor]] = ( 

1168 [prompts] if isinstance(prompts, (str, torch.Tensor)) else list(prompts) 

1169 ) 

1170 if not prompt_list: 

1171 raise ValueError("prompts must be a non-empty prompt or sequence of prompts") 

1172 

1173 hook_names = {layer: _resid_post_hook_name(layer) for layer in resolved_layers} 

1174 wanted_hooks = set(hook_names.values()) 

1175 dictionaries = { 

1176 layer: self.lens_vector_dictionary(model, layer) for layer in resolved_layers 

1177 } 

1178 fractions: Dict[int, List[float]] = {layer: [] for layer in resolved_layers} 

1179 pooled_j_space: Dict[int, float] = {layer: 0.0 for layer in resolved_layers} 

1180 pooled_total: Dict[int, float] = {layer: 0.0 for layer in resolved_layers} 

1181 

1182 for prompt in tqdm(prompt_list, desc="J-space variance", disable=not show_progress): 

1183 tokens = model.to_tokens(prompt) if isinstance(prompt, str) else prompt 

1184 if tokens.ndim != 2 or tokens.shape[0] != 1: 

1185 raise ValueError( 

1186 "fraction_of_variance expects each tokenized prompt to have shape " 

1187 f"[1, seq], got {tuple(tokens.shape)}" 

1188 ) 

1189 _, cache = model.run_with_cache(tokens, names_filter=lambda name: name in wanted_hooks) 

1190 seq_len = tokens.shape[1] 

1191 sampled = ( 

1192 list(range(skip_first, seq_len)) 

1193 if positions is None 

1194 else _normalize_positions(positions, seq_len) 

1195 ) 

1196 for layer in resolved_layers: 

1197 dictionary = dictionaries[layer] 

1198 activations = cache[hook_names[layer]][0] # [seq, d_model] 

1199 for position in sampled: 

1200 activation = activations[position].float().to(dictionary.device) 

1201 total = float(activation @ activation) 

1202 if total <= 0.0: 1202 ↛ 1203line 1202 didn't jump to line 1203 because the condition on line 1202 was never true

1203 continue 

1204 decomposition = get_sparse_decomposition(activation, dictionary, k) 

1205 j_space = float( 

1206 decomposition.j_space_component @ decomposition.j_space_component 

1207 ) 

1208 fractions[layer].append(j_space / total) 

1209 pooled_j_space[layer] += j_space 

1210 pooled_total[layer] += total 

1211 

1212 median = { 

1213 layer: float(torch.tensor(fractions[layer]).median()) 

1214 if fractions[layer] 

1215 else float("nan") 

1216 for layer in resolved_layers 

1217 } 

1218 pooled = { 

1219 layer: pooled_j_space[layer] / pooled_total[layer] 

1220 if pooled_total[layer] > 0 

1221 else float("nan") 

1222 for layer in resolved_layers 

1223 } 

1224 per_position = {layer: torch.tensor(fractions[layer]) for layer in resolved_layers} 

1225 return JSpaceVarianceProfile( 

1226 layers=resolved_layers, median=median, pooled=pooled, per_position=per_position 

1227 ) 

1228 

1229 # ------------------------------------------------------------------ # 

1230 # interventions # 

1231 # ------------------------------------------------------------------ # 

1232 

1233 def steering_hooks( 

1234 self, 

1235 model: Any, 

1236 token: TokenInput, 

1237 layers: Sequence[int], 

1238 *, 

1239 alpha: float = 4.0, 

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

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

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

1243 

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

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

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

1247 parameterization follows the steering description in the reference 

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

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

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

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

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

1253 

1254 Args: 

1255 model: The model the hooks will run on. 

1256 token: The concept token to steer toward. 

1257 layers: Layers to intervene at. 

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

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

1260 stream by roughly its own magnitude. 

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

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

1263 

1264 Returns: 

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

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

1267 """ 

1268 self.validate_model(model) 

1269 hooks = [] 

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

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

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

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

1274 

1275 def transform( 

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

1277 unit: torch.Tensor = unit, 

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

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

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

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

1282 return selected.float() + scale * local_unit 

1283 

1284 hooks.append( 

1285 ( 

1286 _resid_post_hook_name(layer), 

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

1288 ) 

1289 ) 

1290 return hooks 

1291 

1292 def ablation_hooks( 

1293 self, 

1294 model: Any, 

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

1296 layers: Sequence[int], 

1297 *, 

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

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

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

1301 

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

1303 applied sequentially when several tokens are given. 

1304 

1305 Args: 

1306 model: The model the hooks will run on. 

1307 tokens: Concept token(s) to suppress. 

1308 layers: Layers to intervene at. 

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

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

1311 

1312 Returns: 

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

1314 """ 

1315 self.validate_model(model) 

1316 hooks = [] 

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

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

1319 units = _unit_rows(vectors, layer=layer) 

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

1321 

1322 def transform( 

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

1324 units: torch.Tensor = units, 

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

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

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

1328 result = selected.float() 

1329 for unit in local_units: 

1330 coeff = result @ unit 

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

1332 return result 

1333 

1334 hooks.append( 

1335 ( 

1336 _resid_post_hook_name(layer), 

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

1338 ) 

1339 ) 

1340 return hooks 

1341 

1342 def swap_hooks( 

1343 self, 

1344 model: Any, 

1345 source_token: TokenInput, 

1346 target_token: TokenInput, 

1347 layers: Sequence[int], 

1348 *, 

1349 alpha: float = 1.0, 

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

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

1352 """Hooks that swap two concepts' *live* coordinates in lens space. 

1353 

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

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

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

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

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

1359 "double-strength" swap. 

1360 

1361 This transform re-reads ``c`` from the activation seen by every hook. 

1362 It is therefore an involution when applied repeatedly in a subspace 

1363 whose coordinates are preserved between layers: a second application 

1364 can undo the first. For the paper's multi-layer clamp protocol, use 

1365 :meth:`swap_clamp_hooks` with activations cached from the clean run. 

1366 

1367 Args: 

1368 model: The model the hooks will run on. 

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

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

1371 layers: Layers to intervene at. 

1372 alpha: Swap strength. 

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

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

1375 

1376 Returns: 

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

1378 """ 

1379 self.validate_model(model) 

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

1381 if source_id == target_id: 

1382 raise ValueError( 

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

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

1385 ) 

1386 

1387 hooks = [] 

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

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

1390 units = _unit_rows(vectors, layer=layer) 

1391 _diagnose_intervention_pair( 

1392 units, description=f"swap vectors at layer {layer}", stacklevel=3 

1393 ) 

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

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

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

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

1398 

1399 def transform( 

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

1401 basis: torch.Tensor = basis, 

1402 pinv: torch.Tensor = pinv, 

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

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

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

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

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

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

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

1410 return selected.float() + delta 

1411 

1412 hooks.append( 

1413 ( 

1414 _resid_post_hook_name(layer), 

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

1416 ) 

1417 ) 

1418 return hooks 

1419 

1420 def swap_clamp_hooks( 

1421 self, 

1422 model: Any, 

1423 source_token: TokenInput, 

1424 target_token: TokenInput, 

1425 layers: Sequence[int], 

1426 clean_cache: Union[ActivationCache, Mapping[str, torch.Tensor]], 

1427 *, 

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

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

1430 """Hooks that clamp lens coordinates to their clean-run exchange. 

1431 

1432 For each layer, this projects the corresponding activation from 

1433 ``clean_cache`` into that layer's lens basis, exchanges its source and 

1434 target coordinates once, and holds the live activation at that fixed 

1435 target. Unlike :meth:`swap_hooks`, the update is idempotent at each 

1436 layer: ``h <- h + V (c_target - V⁺h)``. 

1437 

1438 Args: 

1439 model: The model the hooks will run on. 

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

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

1442 layers: Layers to intervene at. 

1443 clean_cache: Activations from an unmodified ``run_with_cache`` at 

1444 each requested layer's ``blocks.{layer}.hook_out`` name. Either 

1445 the ``ActivationCache`` it returns by default or the plain dict 

1446 from ``return_cache_object=False`` is accepted. 

1447 positions: Chunk-local positions to clamp (negative indices allowed 

1448 and normalized against the clean activations). Defaults to all. 

1449 

1450 Returns: 

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

1452 """ 

1453 self.validate_model(model) 

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

1455 if source_id == target_id: 

1456 raise ValueError( 

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

1458 "a coordinate clamp would be a silent no-op" 

1459 ) 

1460 

1461 hooks = [] 

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

1463 hook_name = _resid_post_hook_name(layer) 

1464 if hook_name not in clean_cache: 

1465 raise ValueError(f"clean_cache is missing activation {hook_name!r}") 

1466 clean = clean_cache[hook_name] 

1467 _validate_residual_activation( 

1468 clean, d_model=model.cfg.d_model, hook_name=f"clean_cache[{hook_name!r}]" 

1469 ) 

1470 normalized = _normalize_positions(positions, clean.shape[1]) 

1471 clean_selected = clean if positions is None else clean[:, normalized, :] 

1472 

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

1474 units = _unit_rows(vectors, layer=layer) 

1475 _diagnose_intervention_pair( 

1476 units, description=f"swap vectors at layer {layer}", stacklevel=3 

1477 ) 

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

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

1480 target_coords = (clean_selected.float() @ pinv.T)[..., [1, 0]] 

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

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

1483 device_targets: Dict[torch.device, torch.Tensor] = {} 

1484 

1485 def transform( 

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

1487 basis: torch.Tensor = basis, 

1488 pinv: torch.Tensor = pinv, 

1489 target_coords: torch.Tensor = target_coords, 

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

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

1492 device_targets: Dict[torch.device, torch.Tensor] = device_targets, 

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

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

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

1496 local_targets = _cached_on_device(target_coords, device_targets, selected.device) 

1497 if local_targets.shape[1] != selected.shape[1] or local_targets.shape[0] not in ( 

1498 1, 

1499 selected.shape[0], 

1500 ): 

1501 raise ValueError( 

1502 "clean_cache activation shape is incompatible with the live activation: " 

1503 f"target coordinates have shape {tuple(local_targets.shape)}, " 

1504 f"live activation has shape {tuple(selected.shape)}" 

1505 ) 

1506 coords = selected.float() @ local_pinv.T 

1507 delta = (local_targets - coords) @ local_basis.T 

1508 return selected.float() + delta 

1509 

1510 hooks.append( 

1511 ( 

1512 hook_name, 

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

1514 ) 

1515 ) 

1516 return hooks 

1517 

1518 def coordinate_patch_hooks( 

1519 self, 

1520 model: Any, 

1521 source_token: TokenInput, 

1522 target_token: TokenInput, 

1523 layers: Sequence[int], 

1524 *, 

1525 positions: Sequence[int], 

1526 decomposition_cache: Optional[ 

1527 MutableMapping[Tuple[int, int, int], JSpaceDecomposition] 

1528 ] = None, 

1529 k: int = DEFAULT_K, 

1530 mode: str = "substitute", 

1531 alpha: float = 1.0, 

1532 algorithm: str = "nonnegative_orthogonal_matching_pursuit", 

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

1534 """Hooks that anchor-patch one J-space coordinate live, per forward-pass position. 

1535 

1536 Unlike :meth:`coordinate_patch`, which edits one already-captured activation offline, 

1537 this installs a forward hook that solves :func:`solve_coordinate_patch` independently for 

1538 every ``(batch_idx, position)`` pair at each requested layer -- a vocabulary-scale sparse 

1539 decomposition per pair, per hook firing, unless ``decomposition_cache`` supplies one 

1540 already validated for that ``(layer, batch_idx, position)`` key. 

1541 

1542 Args: 

1543 model: The model the hooks will run on. 

1544 source_token: Active source concept, as a single-token string or token id. 

1545 target_token: Distinct target concept, as a single-token string or token id. 

1546 layers: Layers to intervene at. 

1547 positions: Chunk-local positions to patch (negative indices allowed and normalized on 

1548 every hook invocation). Required -- there is no full-sequence default, because a 

1549 silent default would trigger a vocabulary-scale solve at every position. 

1550 decomposition_cache: Optional caller-owned mapping from ``(layer, batch_idx, 

1551 position)`` to a previously validated 

1552 :class:`~transformer_lens.tools.analysis.jacobian_lens_decomposition.JSpaceDecomposition`. 

1553 A hit skips the vocabulary-scale scan; a miss solves and populates the cache. 

1554 Purely a performance path -- correctness does not depend on it. 

1555 k: Sparse-solver upper bound on a cache miss. 

1556 mode: ``"substitute"`` or ``"swap"``. 

1557 alpha: Finite interpolation strength; zero is an exact no-op. 

1558 algorithm: Sparse coefficient-update rule on a cache miss. 

1559 

1560 Returns: 

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

1562 

1563 Raises: 

1564 ValueError: If ``positions`` is empty, if ``source_token`` and ``target_token`` 

1565 resolve to the same id, or if ``source_token`` is not in the top-``k`` active 

1566 support of every patched ``(batch_idx, position)`` pair *at the moment its hook 

1567 fires* -- the whole forward pass fails rather than silently patching a subset. 

1568 This precondition is stronger and more order-dependent than "active on a clean 

1569 forward pass": in a band of layers an earlier hook's patch edits the residual 

1570 that a later layer re-decomposes, and ``substitute``/``swap`` zero or move the 

1571 source coordinate, so the source can be removed from a later layer's active 

1572 support even though it was active on an unhooked pass. Stacking layers or 

1573 positions therefore makes this progressively harder to satisfy. 

1574 

1575 Warns: 

1576 UserWarning: Once per call, naming the number of layers and positions that will 

1577 perform a live vocabulary-scale solve on every cache miss. 

1578 """ 

1579 self.validate_model(model) 

1580 if not positions: 

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

1582 resolved_layers = [_normalize_layer(layer, model.cfg.n_layers) for layer in layers] 

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

1584 if source_id == target_id: 

1585 raise ValueError( 

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

1587 "a coordinate patch would be a silent no-op" 

1588 ) 

1589 warnings.warn( 

1590 f"coordinate_patch_hooks installs {len(resolved_layers)} layer(s) x " 

1591 f"{len(positions)} position(s) of coordinate-patch hooks; every (batch, position) " 

1592 "pair not already present in decomposition_cache performs a vocabulary-scale sparse " 

1593 "decomposition on every forward pass", 

1594 UserWarning, 

1595 stacklevel=2, 

1596 ) 

1597 

1598 requested = tuple(positions) 

1599 hooks = [] 

1600 for layer in resolved_layers: 

1601 dictionary = self.lens_vector_dictionary(model, layer) 

1602 

1603 def hook_fn( 

1604 activation: Float[torch.Tensor, "batch pos d_model"], 

1605 hook: Any, 

1606 layer: int = layer, 

1607 dictionary: torch.Tensor = dictionary, 

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

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

1610 _validate_residual_activation( 

1611 activation, d_model=model.cfg.d_model, hook_name=hook_name 

1612 ) 

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

1614 selected = activation[:, normalized, :].float().to(dictionary.device) 

1615 patched, _ = solve_coordinate_patch_positions( 

1616 selected, 

1617 dictionary, 

1618 normalized, 

1619 source_id, 

1620 target_id, 

1621 layer=layer, 

1622 decomposition_cache=decomposition_cache, 

1623 k=k, 

1624 mode=mode, 

1625 alpha=alpha, 

1626 algorithm=algorithm, 

1627 ) 

1628 output = activation.clone() 

1629 output[:, normalized, :] = patched.to( 

1630 device=activation.device, dtype=activation.dtype 

1631 ) 

1632 return output 

1633 

1634 hooks.append((_resid_post_hook_name(layer), hook_fn)) 

1635 return hooks 

1636 

1637 # ------------------------------------------------------------------ # 

1638 # fitting # 

1639 # ------------------------------------------------------------------ # 

1640 

1641 @classmethod 

1642 def fit( 

1643 cls, 

1644 model: Any, 

1645 prompts: Sequence[str], 

1646 *, 

1647 corpus: str, 

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

1649 dim_batch: int = 8, 

1650 max_seq_len: int = 128, 

1651 skip_first_positions: int = DEFAULT_SKIP_FIRST_POSITIONS, 

1652 show_progress: bool = True, 

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

1654 ) -> "JacobianLens": 

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

1656 

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

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

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

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

1661 position simultaneously — causal attention guarantees the gradient at 

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

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

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

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

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

1667 the prompts. 

1668 

1669 The reference implementation reports that fit quality saturates 

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

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

1672 across prompt slices. 

1673 

1674 Args: 

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

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

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

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

1679 model and all of its submodules must be in evaluation mode. 

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

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

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

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

1684 in artifact provenance. 

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

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

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

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

1689 backward FLOPs are unchanged. 

1690 max_seq_len: Prompts are truncated to this many tokens. 

1691 skip_first_positions: Leading positions excluded from the source 

1692 average. 

1693 show_progress: Show a tqdm progress bar over prompts. 

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

1695 

1696 Returns: 

1697 The fitted :class:`JacobianLens`. 

1698 

1699 Raises: 

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

1701 ValueError: On compatibility mode, training mode, invalid provenance 

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

1703 """ 

1704 _require_raw_bridge(model) 

1705 require_eval_mode(model, operation="JacobianLens.fit()") 

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

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

1708 n_layers = model.cfg.n_layers 

1709 d_model = model.cfg.d_model 

1710 resolved_target = n_layers - 1 

1711 if source_layers is None: 

1712 resolved_sources = list(range(resolved_target)) 

1713 else: 

1714 resolved_sources = sorted( 

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

1716 ) 

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

1718 raise ValueError("source_layers is empty") 

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

1720 raise ValueError( 

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

1722 f"got {resolved_sources}" 

1723 ) 

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

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

1726 if skip_first_positions < 0: 

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

1728 fit_dtype = model.W_U.dtype 

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

1730 warnings.warn( 

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

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

1733 UserWarning, 

1734 stacklevel=2, 

1735 ) 

1736 

1737 transport_matrices, n_done = _fit_transport_matrices( 

1738 model, 

1739 prompts, 

1740 source_layers=resolved_sources, 

1741 dim_batch=dim_batch, 

1742 max_seq_len=max_seq_len, 

1743 skip_first_positions=skip_first_positions, 

1744 show_progress=show_progress, 

1745 backward_provider=_ordinary_vjp, 

1746 ) 

1747 

1748 fit_metadata: Dict[str, Any] = { 

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

1750 "model_revision": _get_model_revision(model), 

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

1752 "model_system": "TransformerBridge", 

1753 "processing": { 

1754 "compatibility_mode": False, 

1755 "weight_basis": "raw_huggingface", 

1756 }, 

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

1758 "corpus": corpus, 

1759 "n_prompts": n_done, 

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

1761 "target_layer": resolved_target, 

1762 "dim_batch": dim_batch, 

1763 "max_seq_len": max_seq_len, 

1764 "skip_first_positions": skip_first_positions, 

1765 "transformer_lens_fit": True, 

1766 } 

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

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

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

1770 full_metadata = dict(metadata or {}) 

1771 full_metadata.update(fit_metadata) 

1772 _validate_metadata(full_metadata) 

1773 return cls( 

1774 transport_matrices, 

1775 n_prompts=n_done, 

1776 d_model=d_model, 

1777 metadata=full_metadata, 

1778 ) 

1779 

1780 

1781# ---------------------------------------------------------------------- # 

1782# helpers # 

1783# ---------------------------------------------------------------------- # 

1784 

1785 

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

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

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

1789 

1790 

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

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

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

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

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

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

1797 

1798 

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

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

1801 from transformer_lens.model_bridge import TransformerBridge 

1802 

1803 if not isinstance(model, TransformerBridge): 

1804 raise TypeError( 

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

1806 "TransformerBridge.boot_transformers(...)." 

1807 ) 

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

1809 raise ValueError( 

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

1811 "the residual basis. Use a freshly booted " 

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

1813 ) 

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

1815 raise ValueError( 

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

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

1818 "TransformerBridge.boot_transformers(...) model." 

1819 ) 

1820 adapter = model.adapter 

1821 if not adapter.supports_generation: 

1822 raise ValueError( 

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

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

1825 "supports_generation=False." 

1826 ) 

1827 adapter.validate_output_logits_transform() 

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

1829 if attention_dir != "causal": 

1830 raise ValueError( 

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

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

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

1834 ) 

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

1836 if total_ut_steps != 1: 

1837 raise ValueError( 

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

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

1840 ) 

1841 component_mapping = adapter.get_component_mapping() 

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

1843 missing_components = [ 

1844 component 

1845 for component in required_components 

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

1847 ] 

1848 if missing_components: 

1849 raise ValueError( 

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

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

1852 ) 

1853 blocks_component = component_mapping["blocks"] 

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

1855 raise ValueError( 

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

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

1858 ) 

1859 if "project_out" in component_mapping: 

1860 raise ValueError( 

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

1862 "the residual stream and unembedding." 

1863 ) 

1864 unembed_width = model.W_U.shape[0] 

1865 if unembed_width != model.cfg.d_model: 

1866 raise ValueError( 

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

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

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

1870 ) 

1871 

1872 

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

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

1875 

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

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

1878 return 

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

1880 for index, item in enumerate(value): 

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

1882 return 

1883 if type(value) is dict: 

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

1885 if type(key) is not str: 

1886 raise ValueError( 

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

1888 ) 

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

1890 return 

1891 raise ValueError( 

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

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

1894 ) 

1895 

1896 validate(metadata, "metadata") 

1897 

1898 

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

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

1901 if positions is None: 

1902 return list(range(seq_len)) 

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

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

1905 if out_of_range: 

1906 raise ValueError( 

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

1908 ) 

1909 return normalized 

1910 

1911 

1912def _cached_on_device( 

1913 tensor: torch.Tensor, 

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

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

1916) -> torch.Tensor: 

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

1918 resolved_device = torch.device(device) 

1919 local = cache.get(resolved_device) 

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

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

1922 cache[resolved_device] = local 

1923 return local 

1924 

1925 

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

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

1928 vectors = vectors.float() 

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

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

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

1932 return vectors / norms 

1933 

1934 

1935def _make_intervention_hook( 

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

1937 positions: Optional[Sequence[int]], 

1938 d_model: int, 

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

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

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

1942 if requested == (): 1942 ↛ anywhereline 1942 didn't jump anywhere: it always raised an exception.

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

1944 

1945 def hook_fn( 

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

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

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

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

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

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

1952 transformed = transform(selected) 

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

1954 raise ValueError( 

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

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

1957 ) 

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

1959 if requested is None: 

1960 return transformed 

1961 output = activation.clone() 

1962 output[:, normalized, :] = transformed 

1963 return output 

1964 

1965 return hook_fn 

1966 

1967 

1968def _unembed( 

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

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

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

1972 

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

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

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

1976 """ 

1977 unembed_weight = model.W_U 

1978 compute_dtype = unembed_weight.dtype 

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

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

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

1982 

1983 

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

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

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

1987 tokens = [tokens] 

1988 ids: List[int] = [] 

1989 for token in tokens: 

1990 if isinstance(token, str): 

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

1992 elif isinstance(token, bool): 

1993 raise ValueError(f"token {token!r} must be a string or integer token id, not bool") 

1994 else: 

1995 ids.append(int(token)) 

1996 if not ids: 

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

1998 d_vocab = model.W_U.shape[1] 

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

2000 if invalid: 

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

2002 return ids 

2003 

2004 

2005def _validate_residual_activation( 

2006 activation: torch.Tensor, 

2007 *, 

2008 d_model: int, 

2009 hook_name: str, 

2010) -> None: 

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

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

2013 raise ValueError( 

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

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

2016 ) 

2017 

2018 

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

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

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

2022 if not 0 <= resolved < n_layers: 

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

2024 return resolved 

2025 

2026 

2027class _frozen_parameters: 

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

2029 

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

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

2032 source layer and the target layer. 

2033 """ 

2034 

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

2036 self.model = model 

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

2038 

2039 def __enter__(self) -> None: 

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

2041 for param, _ in self.saved: 

2042 param.requires_grad_(False) 

2043 

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

2045 for param, flag in self.saved: 

2046 param.requires_grad_(flag) 

2047 

2048 

2049def _ordinary_vjp( 

2050 target: torch.Tensor, 

2051 sources: List[torch.Tensor], 

2052 cotangent: torch.Tensor, 

2053 retain_graph: bool, 

2054) -> Tuple[torch.Tensor, ...]: 

2055 """Ordinary vector-Jacobian product backing the J-lens estimator. 

2056 

2057 Thin wrapper over ``torch.autograd.grad`` so the drive loop takes its 

2058 backward step through the :data:`BackwardProvider` seam. This is the 

2059 identity-preserving provider: routing the ordinary fit through it changes no 

2060 numerics. 

2061 """ 

2062 return torch.autograd.grad( 

2063 outputs=target, 

2064 inputs=sources, 

2065 grad_outputs=cotangent, 

2066 retain_graph=retain_graph, 

2067 ) 

2068 

2069 

2070def _jacobian_for_prompt( 

2071 model: Any, 

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

2073 *, 

2074 source_layers: List[int], 

2075 dim_batch: int, 

2076 skip_first_positions: int, 

2077 backward_provider: BackwardProvider, 

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

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

2080 

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

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

2083 graph there. 

2084 

2085 The backward pass is taken through ``backward_provider`` rather than calling 

2086 ``torch.autograd.grad`` directly, so the capture / cotangent-batching / 

2087 averaging mechanics are shared by any estimator. The ordinary J-lens path 

2088 passes :func:`_ordinary_vjp`, which reproduces the original numerics exactly. 

2089 """ 

2090 d_model = model.cfg.d_model 

2091 target_layer = model.cfg.n_layers - 1 

2092 seq_len = tokens.shape[1] 

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

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

2095 

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

2097 root_name = _resid_post_hook_name(min(source_layers)) 

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

2099 

2100 def capture_fn( 

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

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

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

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

2105 activation.requires_grad_(True) 

2106 captured[hook.name] = activation 

2107 return activation 

2108 

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

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

2111 model(replicated, return_type=None) 

2112 

2113 target = captured[_resid_post_hook_name(target_layer)] 

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

2115 device = target.device 

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

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

2118 

2119 jacobians = { 

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

2121 } 

2122 cotangent = torch.zeros_like(target) 

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

2124 for pass_index in range(n_passes): 

2125 dim_start = pass_index * dim_batch 

2126 n_dims = min(dim_batch, d_model - dim_start) 

2127 cotangent.zero_() 

2128 cotangent[ 

2129 batch_index[:n_dims, None], 

2130 positions_index[None, :], 

2131 dim_start + batch_index[:n_dims, None], 

2132 ] = 1.0 

2133 grads = backward_provider( 

2134 target, 

2135 sources, 

2136 cotangent, 

2137 pass_index < n_passes - 1, 

2138 ) 

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

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

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

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

2143 del grads 

2144 return jacobians 

2145 

2146 

2147def _fit_transport_matrices( 

2148 model: Any, 

2149 prompts: Sequence[str], 

2150 *, 

2151 source_layers: List[int], 

2152 dim_batch: int, 

2153 max_seq_len: int, 

2154 skip_first_positions: int, 

2155 show_progress: bool, 

2156 backward_provider: BackwardProvider, 

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

2158 """Estimator-independent J-lens drive loop. 

2159 

2160 Owns the mechanics shared by every estimator: the frozen-parameter 

2161 lifecycle, the per-prompt forward/backward accumulation (source/target 

2162 residual capture, one-hot cotangent batching, ``dim_batch`` chunking, 

2163 valid-position selection, and source-position averaging all live in 

2164 :func:`_jacobian_for_prompt`), the prompt-accumulation running sum, and the 

2165 final division into per-prompt means. Only the backward step varies: it is 

2166 taken through ``backward_provider``, so an alternate estimator reuses this 

2167 loop unchanged. Callers own input validation, provenance, and lens 

2168 construction. 

2169 

2170 Args: 

2171 model: A raw ``TransformerBridge`` whose parameters are frozen for the 

2172 duration of the loop. 

2173 prompts: Prompt strings; prompts too short to contain a valid position 

2174 (``seq_len <= skip_first_positions + 1``) are skipped with a warning 

2175 and do not count toward the returned prompt total. 

2176 source_layers: Resolved, in-range source layers to fit. 

2177 dim_batch: Output dimensions per backward pass. 

2178 max_seq_len: Prompts are truncated to this many tokens. 

2179 skip_first_positions: Leading positions excluded from the source average. 

2180 show_progress: Show a tqdm progress bar over prompts. 

2181 backward_provider: Backward-step seam; pass :func:`_ordinary_vjp` for the 

2182 ordinary J-lens numerics. 

2183 

2184 Returns: 

2185 ``(transport_matrices, n_prompts)`` where ``transport_matrices`` maps each 

2186 source layer to its prompt-averaged ``[d_model, d_model]`` matrix and 

2187 ``n_prompts`` is the number of prompts that contributed. 

2188 

2189 Raises: 

2190 ValueError: If no prompt was long enough to contribute valid positions. 

2191 """ 

2192 d_model = model.cfg.d_model 

2193 jacobian_sum = { 

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

2195 } 

2196 n_done = 0 

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

2198 with _frozen_parameters(model): 

2199 for prompt in iterator: 

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

2201 seq_len = tokens.shape[1] 

2202 if seq_len <= skip_first_positions + 1: 

2203 warnings.warn( 

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

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

2206 stacklevel=3, 

2207 ) 

2208 continue 

2209 per_prompt = _jacobian_for_prompt( 

2210 model, 

2211 tokens, 

2212 source_layers=source_layers, 

2213 dim_batch=dim_batch, 

2214 skip_first_positions=skip_first_positions, 

2215 backward_provider=backward_provider, 

2216 ) 

2217 for layer in source_layers: 

2218 jacobian_sum[layer] += per_prompt[layer] 

2219 n_done += 1 

2220 if n_done == 0: 

2221 raise ValueError( 

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

2223 ) 

2224 means = {layer: jacobian_sum[layer] / n_done for layer in source_layers} 

2225 return means, n_done