topoexploit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
topogpt3/jlens.py ADDED
@@ -0,0 +1,958 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import math
5
+ import os
6
+ import time
7
+ from collections.abc import Callable, Iterable, Sequence
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ import torch
12
+ from torch import nn
13
+
14
+ from .lens_model import LensModel
15
+
16
+
17
+ __all__ = [
18
+ "ActivationRecorder",
19
+ "JacobianLens",
20
+ "SliceData",
21
+ "TopoGPT3JLensFitConfig",
22
+ "TopoGPT3JLensAppConfig",
23
+ "compute_slice",
24
+ "fit",
25
+ "jacobian_for_prompt",
26
+ "text_slice",
27
+ "valid_position_mask",
28
+ ]
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ SKIP_FIRST_N_POSITIONS = 16
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TopoGPT3JLensFitConfig:
38
+ """Centralized configuration for Jacobian lens fitting.
39
+
40
+ Every value consumed downstream resides here. Adding a new tunable means
41
+ extending this class; no other module should embed literals.
42
+ """
43
+
44
+ source_layers: tuple[int, ...] | None = None
45
+ target_layer: int | None = None
46
+ dim_batch: int = 8
47
+ max_seq_len: int = 128
48
+ skip_first: int = SKIP_FIRST_N_POSITIONS
49
+ checkpoint_path: str | None = None
50
+ checkpoint_every: int | None = 1
51
+ resume: bool = True
52
+ seed: int = 42
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class TopoGPT3JLensAppConfig:
57
+ """Centralized configuration for Jacobian lens application.
58
+
59
+ Every value consumed downstream resides here. Adding a new tunable means
60
+ extending this class; no other module should embed literals.
61
+ """
62
+
63
+ layers: tuple[int, ...] | None = None
64
+ positions: tuple[int, ...] | None = None
65
+ max_seq_len: int = 512
66
+ use_jacobian: bool = True
67
+
68
+
69
+ class ActivationRecorder:
70
+ """Captures residual-stream tensors at the given block indices.
71
+
72
+ Registers a forward hook on each requested block on ``__enter__`` and
73
+ removes them on ``__exit__``. On the next forward pass each block's output
74
+ is stored in ``activations``, keyed by block index. Stored tensors are
75
+ not detached, so they can be passed straight to ``torch.autograd.grad``.
76
+
77
+ Args:
78
+ blocks: The sequence of residual blocks (e.g. ``model.layers``).
79
+ at: Block indices to record at.
80
+ start_graph_at: If given, the captured tensor at this index is marked
81
+ ``requires_grad_(True)`` before downstream blocks see it. When the
82
+ model's parameters all have ``requires_grad=False``, this makes the
83
+ captured residual the leaf that roots the autograd graph, so the
84
+ retained graph spans only this block onward.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ blocks: Sequence[nn.Module],
90
+ at: Iterable[int],
91
+ *,
92
+ start_graph_at: int | None = None,
93
+ ) -> None:
94
+ self._blocks = blocks
95
+ self._indices = sorted(set(at))
96
+ self._start_graph_at = start_graph_at
97
+ if start_graph_at is not None and start_graph_at not in self._indices:
98
+ self._indices = sorted({*self._indices, start_graph_at})
99
+ self.activations: dict[int, torch.Tensor] = {}
100
+ self._handles: list[torch.utils.hooks.RemovableHandle] = []
101
+
102
+ def _make_hook(self, index: int) -> Callable[..., None]:
103
+ is_graph_root = index == self._start_graph_at
104
+
105
+ def hook(module: nn.Module, inputs: Any, output: Any) -> None:
106
+ tensor = output if torch.is_tensor(output) else output[0]
107
+ if is_graph_root:
108
+ tensor.requires_grad_(True)
109
+ self.activations[index] = tensor
110
+
111
+ return hook
112
+
113
+ def __enter__(self) -> ActivationRecorder:
114
+ try:
115
+ for index in self._indices:
116
+ self._handles.append(
117
+ self._blocks[index].register_forward_hook(self._make_hook(index))
118
+ )
119
+ except Exception:
120
+ for handle in self._handles:
121
+ handle.remove()
122
+ self._handles = []
123
+ raise
124
+ return self
125
+
126
+ def __exit__(self, *exc: Any) -> None:
127
+ for handle in self._handles:
128
+ handle.remove()
129
+ self._handles = []
130
+
131
+
132
+ def valid_position_mask(
133
+ seq_len: int, *, skip_first: int = SKIP_FIRST_N_POSITIONS
134
+ ) -> torch.Tensor:
135
+ """Boolean mask over sequence positions to include in the Jacobian average.
136
+
137
+ Early positions are dominated by attention-sink behaviour and the final
138
+ position has no next-token target, so both are excluded.
139
+
140
+ Args:
141
+ seq_len: Length of the tokenized prompt.
142
+ skip_first: Number of leading positions to exclude.
143
+
144
+ Returns:
145
+ Boolean tensor of shape ``[seq_len]``.
146
+
147
+ Raises:
148
+ ValueError: If ``skip_first`` is negative or the prompt is too short to
149
+ leave any valid positions.
150
+ """
151
+ if skip_first < 0:
152
+ raise ValueError(f"skip_first must be >= 0, got {skip_first}")
153
+ mask = torch.zeros(seq_len, dtype=torch.bool)
154
+ mask[skip_first : seq_len - 1] = True
155
+ if mask.sum() == 0:
156
+ raise ValueError(
157
+ f"prompt too short: seq_len={seq_len}, need > {skip_first + 1} tokens"
158
+ )
159
+ return mask
160
+
161
+
162
+ def _check_layer_indices(
163
+ source_layers: Sequence[int] | None, target_layer: int | None, n_layers: int
164
+ ) -> tuple[list[int], int]:
165
+ """Resolve None/negative layer indices, bounds-check, enforce source < target."""
166
+ target = n_layers - 1 if target_layer is None else target_layer
167
+ if target < 0:
168
+ target += n_layers
169
+ if not 0 <= target < n_layers:
170
+ raise ValueError(
171
+ f"target_layer={target_layer} out of range for {n_layers} layers"
172
+ )
173
+ if source_layers is None:
174
+ return list(range(target)), target
175
+ sources = sorted({layer + n_layers if layer < 0 else layer for layer in source_layers})
176
+ if not sources or sources[0] < 0 or sources[-1] >= n_layers:
177
+ raise ValueError(
178
+ f"source_layers {sorted(source_layers)} out of range for {n_layers} layers"
179
+ )
180
+ if sources[-1] >= target:
181
+ raise ValueError(
182
+ f"source_layers must all be < target_layer={target}; got max={sources[-1]}"
183
+ )
184
+ return sources, target
185
+
186
+
187
+ def jacobian_for_prompt(
188
+ model: LensModel,
189
+ prompt: str,
190
+ source_layers: Sequence[int],
191
+ *,
192
+ target_layer: int | None = None,
193
+ dim_batch: int = 8,
194
+ max_seq_len: int = 128,
195
+ skip_first: int = SKIP_FIRST_N_POSITIONS,
196
+ ) -> tuple[dict[int, torch.Tensor], int, int]:
197
+ """Compute the per-layer Jacobian estimator ``J_l`` for one prompt.
198
+
199
+ Runs one forward pass on the prompt replicated ``dim_batch`` times along
200
+ the batch axis, retains the graph, then runs ``ceil(d_model / dim_batch)``
201
+ backward passes against it. Each backward computes ``dim_batch`` rows of
202
+ ``J_l`` at once: batch element ``b`` carries a one-hot cotangent at output
203
+ dimension ``dim_start + b``, set at every valid target position.
204
+
205
+ Args:
206
+ model: The model to compute Jacobians for.
207
+ prompt: Input text.
208
+ source_layers: Layer indices ``l`` to compute ``J_l`` at.
209
+ target_layer: Layer to take gradients with respect to. Defaults to the
210
+ final layer; negative indices count from the end.
211
+ dim_batch: Output dimensions computed per backward pass.
212
+ max_seq_len: Truncate the prompt to this many tokens.
213
+ skip_first: Leading positions to exclude.
214
+
215
+ Returns:
216
+ ``(jacobians, seq_len, n_valid_positions)``. ``jacobians`` maps each
217
+ source layer to a ``[d_model, d_model]`` fp32 CPU tensor.
218
+ """
219
+ n_layers, d_model = model.n_layers, model.d_model
220
+ source_layers, target_layer = _check_layer_indices(
221
+ source_layers, target_layer, n_layers
222
+ )
223
+
224
+ input_ids = model.encode(prompt, max_length=max_seq_len)
225
+ seq_len = input_ids.shape[1]
226
+ position_mask = valid_position_mask(seq_len, skip_first=skip_first)
227
+ n_valid_positions = int(position_mask.sum())
228
+
229
+ jacobians = {
230
+ layer: torch.zeros(d_model, d_model, dtype=torch.float32)
231
+ for layer in source_layers
232
+ }
233
+ n_passes = math.ceil(d_model / dim_batch)
234
+
235
+ with (
236
+ ActivationRecorder(
237
+ model.layers,
238
+ at=[*source_layers, target_layer],
239
+ start_graph_at=min(source_layers),
240
+ ) as recorder,
241
+ torch.enable_grad(),
242
+ ):
243
+ replicated_ids = input_ids.expand(dim_batch, -1)
244
+ model.forward(replicated_ids)
245
+ target_activation = recorder.activations[target_layer]
246
+ source_activations = [recorder.activations[layer] for layer in source_layers]
247
+
248
+ valid_positions = position_mask.nonzero(as_tuple=True)[0].to(
249
+ target_activation.device
250
+ )
251
+ batch_indices = torch.arange(dim_batch, device=target_activation.device)
252
+ cotangent = torch.zeros_like(target_activation)
253
+
254
+ for pass_idx, dim_start in enumerate(range(0, d_model, dim_batch)):
255
+ n_dims_this_pass = min(dim_batch, d_model - dim_start)
256
+ cotangent.zero_()
257
+ cotangent[
258
+ batch_indices[:n_dims_this_pass, None],
259
+ valid_positions[None, :],
260
+ dim_start + batch_indices[:n_dims_this_pass, None],
261
+ ] = 1.0
262
+ grads = torch.autograd.grad(
263
+ outputs=target_activation,
264
+ inputs=source_activations,
265
+ grad_outputs=cotangent,
266
+ retain_graph=(pass_idx < n_passes - 1),
267
+ )
268
+ for layer, grad in zip(source_layers, grads, strict=True):
269
+ positions_on_device = valid_positions.to(grad.device, non_blocking=True)
270
+ rows = (
271
+ grad[:n_dims_this_pass, positions_on_device, :]
272
+ .float()
273
+ .mean(dim=1)
274
+ )
275
+ jacobians[layer][dim_start : dim_start + n_dims_this_pass, :] = (
276
+ rows.cpu()
277
+ )
278
+ del grads
279
+
280
+ return jacobians, seq_len, n_valid_positions
281
+
282
+
283
+ def _atomic_save(obj: object, path: str) -> None:
284
+ """``torch.save`` to a temp file then ``os.replace`` so a crash never
285
+ leaves a half-written checkpoint."""
286
+ tmp_path = f"{path}.tmp.{os.getpid()}"
287
+ torch.save(obj, tmp_path)
288
+ os.replace(tmp_path, path)
289
+
290
+
291
+ def fit(
292
+ model: LensModel,
293
+ prompts: Sequence[str],
294
+ *,
295
+ source_layers: Sequence[int] | None = None,
296
+ target_layer: int | None = None,
297
+ dim_batch: int = 8,
298
+ max_seq_len: int = 128,
299
+ skip_first: int = SKIP_FIRST_N_POSITIONS,
300
+ checkpoint_path: str | None = None,
301
+ checkpoint_every: int | None = 1,
302
+ resume: bool = True,
303
+ ) -> JacobianLens:
304
+ """Fit ``J_l`` over a list of prompts and return a JacobianLens.
305
+
306
+ Per-prompt Jacobians from ``jacobian_for_prompt`` are accumulated as a
307
+ running mean. If ``checkpoint_path`` is set, the running sum is written
308
+ every ``checkpoint_every`` prompts (atomic) and resumed from on restart.
309
+
310
+ Args:
311
+ model: The model to fit on.
312
+ prompts: Text prompts to average over.
313
+ source_layers: Layers to fit at. Defaults to every layer below
314
+ ``target_layer``; negative indices count from the end.
315
+ target_layer: See ``jacobian_for_prompt``.
316
+ dim_batch: See ``jacobian_for_prompt``.
317
+ max_seq_len: Truncate each prompt to this many tokens.
318
+ skip_first: See ``jacobian_for_prompt``.
319
+ checkpoint_path: If set, write a resumable checkpoint here.
320
+ checkpoint_every: Write checkpoint every N prompts (default 1).
321
+ resume: If True and checkpoint_path exists, resume from it.
322
+
323
+ Returns:
324
+ The fitted JacobianLens.
325
+
326
+ Raises:
327
+ ValueError: If no prompts are long enough to fit on, or if checkpoint
328
+ settings mismatch.
329
+ """
330
+ n_layers, d_model = model.n_layers, model.d_model
331
+ source_layers, target_layer = _check_layer_indices(
332
+ source_layers, target_layer, n_layers
333
+ )
334
+
335
+ logger.info(
336
+ "fit: n_layers=%d d_model=%d, fitting %d source layers "
337
+ "(target=L%d) on %d prompts",
338
+ n_layers,
339
+ d_model,
340
+ len(source_layers),
341
+ target_layer,
342
+ len(prompts),
343
+ )
344
+
345
+ jacobian_sum: dict[int, torch.Tensor]
346
+ n_done: int
347
+ next_idx: int
348
+ if resume and checkpoint_path is not None and os.path.exists(checkpoint_path):
349
+ state = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
350
+ for key, expected in (
351
+ ("source_layers", source_layers),
352
+ ("target_layer", target_layer),
353
+ ("skip_first", skip_first),
354
+ ):
355
+ if key in state and state[key] != expected:
356
+ raise ValueError(
357
+ f"checkpoint at {checkpoint_path} was fitted with {key}="
358
+ f"{state[key]!r}, not {expected!r}; pass resume=False to discard it"
359
+ )
360
+ jacobian_sum, n_done, next_idx = (
361
+ state["jacobian_sum"],
362
+ state["n_done"],
363
+ state["next_idx"],
364
+ )
365
+ logger.info(
366
+ " resuming from checkpoint: %d/%d prompts processed",
367
+ next_idx,
368
+ len(prompts),
369
+ )
370
+ else:
371
+ jacobian_sum = {
372
+ layer: torch.zeros(d_model, d_model, dtype=torch.float32)
373
+ for layer in source_layers
374
+ }
375
+ n_done = 0
376
+ next_idx = 0
377
+
378
+ def write_checkpoint() -> None:
379
+ if checkpoint_path is not None:
380
+ _atomic_save(
381
+ {
382
+ "jacobian_sum": jacobian_sum,
383
+ "n_done": n_done,
384
+ "next_idx": next_idx,
385
+ "source_layers": source_layers,
386
+ "target_layer": target_layer,
387
+ "skip_first": skip_first,
388
+ },
389
+ checkpoint_path,
390
+ )
391
+
392
+ sqrt_d = math.sqrt(d_model)
393
+ for prompt_idx, prompt in enumerate(prompts):
394
+ if prompt_idx < next_idx:
395
+ continue
396
+ start_time = time.perf_counter()
397
+ try:
398
+ per_prompt_J, _seq_len, _n_valid = jacobian_for_prompt(
399
+ model,
400
+ prompt,
401
+ source_layers,
402
+ target_layer=target_layer,
403
+ dim_batch=dim_batch,
404
+ max_seq_len=max_seq_len,
405
+ skip_first=skip_first,
406
+ )
407
+ except ValueError as exc:
408
+ logger.warning(" skipping prompt %d: %s", prompt_idx, exc)
409
+ next_idx = prompt_idx + 1
410
+ continue
411
+
412
+ prompt_norm = (
413
+ max(per_prompt_J[layer].norm().item() for layer in source_layers) / sqrt_d
414
+ )
415
+ if n_done > 0:
416
+ mean_rel_change = max(
417
+ (
418
+ (
419
+ per_prompt_J[layer]
420
+ - jacobian_sum[layer] / n_done
421
+ ).norm()
422
+ / ((n_done + 1) * (jacobian_sum[layer] / n_done).norm())
423
+ ).item()
424
+ for layer in source_layers
425
+ )
426
+ else:
427
+ mean_rel_change = float("nan")
428
+
429
+ for layer in source_layers:
430
+ jacobian_sum[layer] += per_prompt_J[layer]
431
+ n_done += 1
432
+ next_idx = prompt_idx + 1
433
+
434
+ logger.info(
435
+ " prompt %d/%d n_valid=%d %.0fs "
436
+ "max||J||/sqrt(d)=%.3f max_d_mean=%.2e",
437
+ prompt_idx + 1,
438
+ len(prompts),
439
+ _n_valid,
440
+ time.perf_counter() - start_time,
441
+ prompt_norm,
442
+ mean_rel_change,
443
+ )
444
+ if checkpoint_every is not None and next_idx % checkpoint_every == 0:
445
+ write_checkpoint()
446
+
447
+ write_checkpoint()
448
+ if n_done == 0:
449
+ raise ValueError("no prompts were long enough to fit on")
450
+ jacobian_mean = {
451
+ layer: jacobian_sum[layer] / n_done for layer in source_layers
452
+ }
453
+ logger.info("fit: done, %d prompts", n_done)
454
+ return JacobianLens(
455
+ jacobians=jacobian_mean, n_prompts=n_done, d_model=d_model
456
+ )
457
+
458
+
459
+ class JacobianLens:
460
+ """A fitted Jacobian lens: per-layer ``J_l`` matrices and the readout method.
461
+
462
+ Attributes:
463
+ jacobians: ``{layer_index: Tensor[d_model, d_model]}``. Each ``J_l``
464
+ maps the residual at layer ``l`` into the final-layer basis.
465
+ source_layers: Sorted list of fitted layer indices.
466
+ n_prompts: Number of prompts the lens was averaged over.
467
+ d_model: Residual-stream width.
468
+ """
469
+
470
+ def __init__(
471
+ self,
472
+ jacobians: dict[int, torch.Tensor],
473
+ *,
474
+ n_prompts: int,
475
+ d_model: int,
476
+ ) -> None:
477
+ self.jacobians = {layer: J.float() for layer, J in jacobians.items()}
478
+ self.source_layers = sorted(self.jacobians)
479
+ self.n_prompts = n_prompts
480
+ self.d_model = d_model
481
+
482
+ def __repr__(self) -> str:
483
+ return (
484
+ f"JacobianLens(d_model={self.d_model}, n_prompts={self.n_prompts}, "
485
+ f"source_layers=[{self.source_layers[0]}..{self.source_layers[-1]}] "
486
+ f"({len(self.source_layers)} layers))"
487
+ )
488
+
489
+ def save(self, path: str, *, dtype: torch.dtype = torch.float16) -> None:
490
+ """Save to ``path``. Jacobians are stored as ``dtype`` (default fp16)."""
491
+ torch.save(
492
+ {
493
+ "J": {
494
+ layer: J.to(dtype) for layer, J in self.jacobians.items()
495
+ },
496
+ "n_prompts": self.n_prompts,
497
+ "source_layers": self.source_layers,
498
+ "d_model": self.d_model,
499
+ },
500
+ path,
501
+ )
502
+
503
+ @classmethod
504
+ def load(cls, path: str) -> JacobianLens:
505
+ """Load a lens previously written by ``save``."""
506
+ checkpoint = torch.load(path, map_location="cpu", weights_only=True)
507
+ if "J" not in checkpoint:
508
+ raise ValueError(
509
+ f"{path} is not a JacobianLens file "
510
+ f"(found keys {sorted(checkpoint)!r}; a fit() checkpoint?)"
511
+ )
512
+ return cls(
513
+ jacobians=checkpoint["J"],
514
+ n_prompts=checkpoint["n_prompts"],
515
+ d_model=checkpoint["d_model"],
516
+ )
517
+
518
+ @classmethod
519
+ def from_pretrained(
520
+ cls,
521
+ name_or_path: str,
522
+ *,
523
+ filename: str = "lens.pt",
524
+ revision: str | None = None,
525
+ ) -> JacobianLens:
526
+ """Load a lens from a local file, a local directory, or a HuggingFace
527
+ Hub ``repo_id``.
528
+
529
+ ``filename`` is the path inside the directory or repo; ignored when
530
+ ``name_or_path`` is itself a file. ``revision`` selects a Hub branch,
531
+ tag, or commit.
532
+ """
533
+ if os.path.isfile(name_or_path):
534
+ return cls.load(name_or_path)
535
+ if not os.path.isdir(name_or_path):
536
+ from huggingface_hub import snapshot_download
537
+ name_or_path = snapshot_download(
538
+ name_or_path, allow_patterns=[filename], revision=revision
539
+ )
540
+ return cls.load(os.path.join(name_or_path, filename))
541
+
542
+ @classmethod
543
+ def merge(cls, lenses: Sequence[JacobianLens]) -> JacobianLens:
544
+ """Combine lenses fitted on disjoint prompt subsets into one
545
+ (``n_prompts``-weighted mean of the inputs).
546
+
547
+ Args:
548
+ lenses: Lenses to merge. Must agree on ``source_layers`` and
549
+ ``d_model``.
550
+
551
+ Raises:
552
+ ValueError: If ``lenses`` is empty or the inputs disagree on shape.
553
+ """
554
+ if not lenses:
555
+ raise ValueError("merge() needs at least one lens")
556
+ first = lenses[0]
557
+ for other in lenses[1:]:
558
+ if (
559
+ other.source_layers != first.source_layers
560
+ or other.d_model != first.d_model
561
+ ):
562
+ raise ValueError("lenses disagree on source_layers / d_model")
563
+ n_total = sum(lens.n_prompts for lens in lenses)
564
+ merged: dict[int, torch.Tensor] = {}
565
+ for layer in first.source_layers:
566
+ weighted_sum = sum(
567
+ lens.jacobians[layer] * lens.n_prompts for lens in lenses
568
+ )
569
+ merged[layer] = weighted_sum / n_total
570
+ return cls(
571
+ jacobians=merged, n_prompts=n_total, d_model=first.d_model
572
+ )
573
+
574
+ def transport(self, residual: torch.Tensor, layer: int) -> torch.Tensor:
575
+ """Map a residual at ``layer`` into the final-layer basis: ``J_l @ h``.
576
+
577
+ Args:
578
+ residual: Tensor of shape ``[..., d_model]``.
579
+ layer: Source layer index (must be in ``source_layers``).
580
+ """
581
+ J_bar = self.jacobians[layer].to(residual.device)
582
+ return residual @ J_bar.T
583
+
584
+ @torch.no_grad()
585
+ def apply(
586
+ self,
587
+ model: LensModel,
588
+ prompt: str,
589
+ *,
590
+ layers: Sequence[int] | None = None,
591
+ positions: Sequence[int] | None = None,
592
+ max_seq_len: int = 512,
593
+ use_jacobian: bool = True,
594
+ ) -> tuple[dict[int, torch.Tensor], torch.Tensor, torch.Tensor]:
595
+ """Run ``model`` on ``prompt`` and return lens logits at ``positions``.
596
+
597
+ Args:
598
+ model: The model to read out from.
599
+ prompt: Input text.
600
+ layers: Layers to read out at. Defaults to all of
601
+ ``source_layers``. Must be a subset of ``source_layers`` when
602
+ ``use_jacobian`` is True.
603
+ positions: Token positions to read out (Python indexing into the
604
+ sequence; negative indices count from the end). None returns
605
+ every position.
606
+ max_seq_len: Truncate the prompt to this many tokens.
607
+ use_jacobian: If False, skip the ``J_l`` transport (vanilla
608
+ logit-lens baseline).
609
+
610
+ Returns:
611
+ A triple ``(lens_logits, model_logits, input_ids)``. ``lens_logits``
612
+ maps each requested layer to a ``[n_positions, vocab_size]`` tensor;
613
+ ``model_logits`` is the model's actual final-layer logits at the
614
+ same positions (same shape).
615
+
616
+ Raises:
617
+ ValueError: If any requested layer is out of range for the model,
618
+ or (with use_jacobian) not in source_layers.
619
+ """
620
+ if layers is None:
621
+ layers = self.source_layers
622
+ out_of_range = sorted(
623
+ layer for layer in set(layers) if not 0 <= layer < model.n_layers
624
+ )
625
+ if out_of_range:
626
+ raise ValueError(
627
+ f"layers {out_of_range} out of range for a "
628
+ f"{model.n_layers}-layer model"
629
+ )
630
+ unknown = set(layers) - set(self.source_layers)
631
+ if use_jacobian and unknown:
632
+ raise ValueError(
633
+ f"layers {sorted(unknown)} not in source_layers; "
634
+ f"fitted layers are {self.source_layers}"
635
+ )
636
+ final_layer = model.n_layers - 1
637
+ record_at = sorted(set(layers) | {final_layer})
638
+
639
+ input_ids = model.encode(prompt, max_length=max_seq_len)
640
+ with ActivationRecorder(model.layers, at=record_at) as recorder:
641
+ model.forward(input_ids)
642
+ activations = {
643
+ i: recorder.activations[i].detach() for i in record_at
644
+ }
645
+
646
+ def select(layer: int) -> torch.Tensor:
647
+ full = activations[layer][0]
648
+ return (
649
+ full if positions is None else full[list(positions)]
650
+ ).float()
651
+
652
+ lens_logits: dict[int, torch.Tensor] = {}
653
+ for layer in layers:
654
+ residual = select(layer)
655
+ if use_jacobian:
656
+ residual = self.transport(residual, layer)
657
+ lens_logits[layer] = model.unembed(residual).float().cpu()
658
+
659
+ model_logits = model.unembed(select(final_layer)).float().cpu()
660
+ return lens_logits, model_logits, input_ids
661
+
662
+
663
+ @dataclass
664
+ class SliceData:
665
+ """Text-format slice data: top-K token predictions per (position, layer).
666
+
667
+ ``layers`` always includes the model's final layer (the actual model
668
+ output) so divergences from lens-transported earlier layers are visible.
669
+
670
+ Attributes:
671
+ seq_len: Number of token positions in the slice.
672
+ layers: Layer indices shown (includes final layer).
673
+ prompt: The input prompt text.
674
+ input_ids: Tensor ``[1, seq_len]`` of token IDs.
675
+ token_strs: Decoded strings for each token position.
676
+ top_ids: ``[seq_len, n_layers, top_n]`` top token IDs per cell.
677
+ top_probs: ``[seq_len, n_layers, top_n]`` softmax probabilities.
678
+ top_token_strs: ``[seq_len, n_layers, top_n]`` decoded token strings
679
+ for each prediction. Empty string if tokenizer was unavailable.
680
+ """
681
+
682
+ seq_len: int
683
+ layers: list[int]
684
+ prompt: str
685
+ input_ids: torch.Tensor
686
+ token_strs: list[str]
687
+ top_ids: torch.Tensor # [seq_len, n_layers, top_n]
688
+ top_probs: torch.Tensor # [seq_len, n_layers, top_n]
689
+ top_token_strs: list[list[list[str]]] = field(default_factory=list)
690
+ top_n: int = field(default=5, init=False)
691
+
692
+ def __post_init__(self) -> None:
693
+ self.top_n = self.top_ids.shape[-1]
694
+ if not self.top_token_strs:
695
+ self.top_token_strs = [
696
+ [
697
+ [f"<{int(tid)}>" for tid in pos_layer]
698
+ for pos_layer in pos
699
+ ]
700
+ for pos in self.top_ids.tolist()
701
+ ]
702
+
703
+
704
+ @torch.no_grad()
705
+ def compute_slice(
706
+ model: LensModel,
707
+ lens: JacobianLens,
708
+ prompt: str,
709
+ *,
710
+ top_n: int = 5,
711
+ max_seq_len: int = 512,
712
+ ) -> SliceData:
713
+ """Compute a position x layer slice of top-K token predictions.
714
+
715
+ For each layer in the fitted lens, projects the residual at each position
716
+ through the Jacobian into the final-layer basis, then unembeds to get
717
+ logits and softmax probabilities. Returns the top-N predicted token IDs
718
+ and their probabilities per (position, layer) cell.
719
+
720
+ Args:
721
+ model: The model to read out from.
722
+ lens: A fitted JacobianLens.
723
+ prompt: Input text.
724
+ top_n: Top tokens to keep per (position, layer) cell.
725
+ max_seq_len: Truncate the prompt to this many tokens.
726
+
727
+ Returns:
728
+ A SliceData instance with arrays indexed ``[seq_len, n_layers, top_n]``.
729
+ """
730
+ final_layer = model.n_layers - 1
731
+ fitted_layers = lens.source_layers
732
+ if final_layer not in fitted_layers:
733
+ layers = sorted([*fitted_layers, final_layer])
734
+ else:
735
+ layers = fitted_layers
736
+
737
+ input_ids = model.encode(prompt, max_length=max_seq_len)
738
+ seq_len = input_ids.shape[1]
739
+
740
+ tokenizer = getattr(model, "tokenizer", None)
741
+ if tokenizer is not None:
742
+ token_strs = [tokenizer.decode([t]) for t in input_ids[0].tolist()]
743
+ else:
744
+ token_strs = [f"<tok {t}>" for t in input_ids[0].tolist()]
745
+
746
+ with ActivationRecorder(model.layers, at=layers) as recorder:
747
+ model.forward(input_ids)
748
+ activations = {layer: recorder.activations[layer].detach() for layer in layers}
749
+
750
+ top_ids = torch.zeros(seq_len, len(layers), top_n, dtype=torch.long, device="cpu")
751
+ top_probs = torch.zeros(seq_len, len(layers), top_n, dtype=torch.float32, device="cpu")
752
+
753
+ for col, layer in enumerate(layers):
754
+ residual = activations[layer][0].float()
755
+ if layer in lens.jacobians:
756
+ residual = lens.transport(residual, layer)
757
+ logits = model.unembed(residual).float().cpu()
758
+ probs = torch.softmax(logits, dim=-1)
759
+ top = probs.topk(top_n, dim=-1)
760
+ top_ids[:, col] = top.indices.cpu()
761
+ top_probs[:, col] = top.values.cpu()
762
+
763
+ top_token_strs: list[list[list[str]]] = []
764
+ for pos in range(seq_len):
765
+ pos_layers: list[list[str]] = []
766
+ for col in range(len(layers)):
767
+ layer_preds: list[str] = []
768
+ for k in range(top_n):
769
+ tid = int(top_ids[pos, col, k])
770
+ if tokenizer is not None:
771
+ layer_preds.append(tokenizer.decode([tid]))
772
+ else:
773
+ layer_preds.append(f"<{tid}>")
774
+ pos_layers.append(layer_preds)
775
+ top_token_strs.append(pos_layers)
776
+
777
+ return SliceData(
778
+ seq_len=seq_len,
779
+ layers=layers,
780
+ prompt=prompt,
781
+ input_ids=input_ids.cpu(),
782
+ token_strs=token_strs,
783
+ top_ids=top_ids,
784
+ top_probs=top_probs,
785
+ top_token_strs=top_token_strs,
786
+ )
787
+
788
+
789
+ def text_slice(slice_data: SliceData, tokenizer: Any = None, n_cols: int = 3) -> str:
790
+ """Render a SliceData as a readable text table showing decoded words.
791
+
792
+ For each token position, shows what each layer predicts as the next token.
793
+ The first column shows the actual input token; subsequent columns show the
794
+ top-1 prediction at each layer with its softmax probability. Token strings
795
+ are read from ``slice_data.top_token_strs`` (always populated by
796
+ ``compute_slice``).
797
+
798
+ Args:
799
+ slice_data: The slice to render.
800
+ tokenizer: Legacy parameter, ignored. Top token strings are already
801
+ stored in ``slice_data.top_token_strs``.
802
+ n_cols: Number of layer columns to show (default 3).
803
+
804
+ Returns:
805
+ A multi-line string table.
806
+ """
807
+ lines: list[str] = []
808
+ _ = tokenizer # unused — token strings live in slice_data.top_token_strs
809
+ n_layers = len(slice_data.layers)
810
+ step = max(1, n_layers // n_cols)
811
+ display_layers = slice_data.layers[::step]
812
+ if display_layers[-1] != slice_data.layers[-1]:
813
+ display_layers.append(slice_data.layers[-1])
814
+
815
+ header = f"{'pos':>4} {'input token':<20}"
816
+ for layer in display_layers:
817
+ header += f" {'L' + str(layer) + ' top-1':<25}"
818
+ lines.append(header)
819
+ lines.append("-" * len(header))
820
+
821
+ for pos in range(slice_data.seq_len):
822
+ tok_str = slice_data.token_strs[pos]
823
+ if len(tok_str) > 18:
824
+ tok_str = tok_str[:15] + "..."
825
+ row = f"{pos:>4} {tok_str:<20}"
826
+ for col_idx, layer in enumerate(display_layers):
827
+ try:
828
+ col = slice_data.layers.index(layer)
829
+ prob = float(slice_data.top_probs[pos, col, 0])
830
+ pred_str = slice_data.top_token_strs[pos][col][0]
831
+ if len(pred_str) > 14:
832
+ pred_str = pred_str[:11] + "..."
833
+ row += f" {pred_str:<16} {prob:.2%} "
834
+ except (ValueError, IndexError):
835
+ row += f" {'N/A':<25}"
836
+ lines.append(row)
837
+
838
+ lines.append("")
839
+ return "\n".join(lines)
840
+
841
+
842
+ def _demo_jlens() -> None:
843
+ """Run a full jacobian lens demo loading real weights from checkpoint."""
844
+ import argparse
845
+
846
+ parser = argparse.ArgumentParser(
847
+ description="TopoGPT3 Jacobian Lens: inspect layer-wise predictions"
848
+ )
849
+ parser.add_argument(
850
+ "--checkpoint", "-c",
851
+ default="checkpoints_topogpt3/last",
852
+ help="checkpoint directory (default: checkpoints_topogpt3/last)",
853
+ )
854
+ parser.add_argument(
855
+ "--prompt", "-p",
856
+ default="def fibonacci(n):\n ",
857
+ help="prompt to analyze",
858
+ )
859
+ parser.add_argument(
860
+ "--prompts", "-P",
861
+ type=int,
862
+ default=2,
863
+ help="number of auto-generated prompts to fit on (default: 2, use more for better J)",
864
+ )
865
+ parser.add_argument(
866
+ "--dim-batch", "-b",
867
+ type=int,
868
+ default=8,
869
+ help="output dimensions per backward pass (default: 8)",
870
+ )
871
+ parser.add_argument(
872
+ "--max-seq-len",
873
+ type=int,
874
+ default=128,
875
+ help="max prompt length (default: 128)",
876
+ )
877
+ parser.add_argument(
878
+ "--top-n",
879
+ type=int,
880
+ default=5,
881
+ help="top-K predictions per cell (default: 5)",
882
+ )
883
+ parser.add_argument(
884
+ "--layers", "-l",
885
+ type=int,
886
+ nargs="*",
887
+ help="source layers to fit (default: all except final)",
888
+ )
889
+ parser.add_argument(
890
+ "--device", "-d",
891
+ default=None,
892
+ help="device (default: auto)",
893
+ )
894
+ args = parser.parse_args()
895
+
896
+ from .lens_model import TopoGPT3LensModel, TopoGPT3LensConfig
897
+
898
+ cfg = TopoGPT3LensConfig.probe_checkpoint(args.checkpoint)
899
+ device = args.device or cfg.device
900
+ if device == "cuda" and not torch.cuda.is_available():
901
+ device = "cpu"
902
+
903
+ print(f"Loading model from {args.checkpoint} (d_model={cfg.d_model}, n_layers={cfg.n_layers}, device={device})")
904
+ t0 = time.perf_counter()
905
+ model = TopoGPT3LensModel.from_checkpoint(args.checkpoint, device=device)
906
+ print(f" loaded {sum(p.numel() for p in model._model.parameters()):,} parameters in {time.perf_counter()-t0:.1f}s")
907
+
908
+ n_layers = model.n_layers
909
+ source_layers = args.layers or list(range(n_layers - 1))
910
+ target_layer = n_layers - 1
911
+
912
+ fit_prompts = [
913
+ "def fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)",
914
+ "def factorial(n):\n if n <= 1:\n return 1\n return n * factorial(n-1)",
915
+ "def is_prime(n):\n if n < 2:\n return False\n for i in range(2, int(n**0.5) + 1):\n if n % i == 0:\n return False\n return True",
916
+ "def quicksort(arr):\n if len(arr) <= 1:\n return arr\n pivot = arr[0]\n left = [x for x in arr[1:] if x <= pivot]\n right = [x for x in arr[1:] if x > pivot]\n return quicksort(left) + [pivot] + quicksort(right)",
917
+ ][:args.prompts]
918
+
919
+ print(f"\nFitting Jacobian lens on {len(fit_prompts)} prompts, {len(source_layers)} source layers...")
920
+ t0 = time.perf_counter()
921
+ lens = fit(
922
+ model, fit_prompts,
923
+ source_layers=source_layers,
924
+ target_layer=target_layer,
925
+ dim_batch=args.dim_batch,
926
+ max_seq_len=args.max_seq_len,
927
+ )
928
+ print(f" fitted in {time.perf_counter()-t0:.1f}s: {lens}")
929
+
930
+ print("\nJacobian norms (diagonal dominance indicates reliable transport):")
931
+ print(" layer ||J|| max_diag min_diag ||J - I||")
932
+ print(" " + "-" * 42)
933
+ for layer in source_layers:
934
+ J = lens.jacobians[layer]
935
+ diag = J.diag()
936
+ diff = (J - torch.eye(cfg.d_model, device=J.device)).norm().item()
937
+ print(f" {layer:>6} {J.norm().item():>10.2f} {diag.max().item():>10.4f} {diag.min().item():>10.4f} {diff:>10.2f}")
938
+
939
+ print(f"\nComputing slice for prompt: {args.prompt!r}")
940
+ slice_data = compute_slice(
941
+ model, lens, args.prompt,
942
+ top_n=args.top_n,
943
+ max_seq_len=args.max_seq_len,
944
+ )
945
+
946
+ decoder = getattr(model, "tokenizer", None)
947
+ print(text_slice(slice_data, tokenizer=decoder))
948
+
949
+ print("\nSummary:")
950
+ print(f" Fitted on {lens.n_prompts} prompts, {len(source_layers)} layers")
951
+ transport_strs = [f"L{layer}->L{target_layer}" for layer in source_layers]
952
+ print(f" Layer transport: {transport_strs}")
953
+ print(f" Prompt: {args.prompt!r} ({slice_data.seq_len} tokens)")
954
+ print(" To explore interactively: from topogpt3.jlens import compute_slice, text_slice")
955
+
956
+
957
+ if __name__ == "__main__":
958
+ _demo_jlens()