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.
- topoexploit-0.1.0.dist-info/METADATA +523 -0
- topoexploit-0.1.0.dist-info/RECORD +23 -0
- topoexploit-0.1.0.dist-info/WHEEL +5 -0
- topoexploit-0.1.0.dist-info/entry_points.txt +6 -0
- topoexploit-0.1.0.dist-info/licenses/LICENSE +661 -0
- topoexploit-0.1.0.dist-info/top_level.txt +1 -0
- topogpt3/__init__.py +125 -0
- topogpt3/__main__.py +49 -0
- topogpt3/api_server.py +1178 -0
- topogpt3/continuation.py +114 -0
- topogpt3/ewc.py +369 -0
- topogpt3/exploitgym_config.py +130 -0
- topogpt3/exploitgym_loader.py +416 -0
- topogpt3/hodge_cm.py +160 -0
- topogpt3/inference.py +731 -0
- topogpt3/inference_hrm.py +1502 -0
- topogpt3/jlens.py +958 -0
- topogpt3/lens_model.py +389 -0
- topogpt3/merged_config.py +187 -0
- topogpt3/model.py +3824 -0
- topogpt3/structured_output.py +251 -0
- topogpt3/train.py +1939 -0
- topogpt3/vanilla_control.py +240 -0
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()
|