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

317 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-10 05:10 +0000

1"""Logit lens and vocabulary readout. 

2 

3The `logit lens <https://www.lesswrong.com/posts/AcKRB8wDpdaN6v6ru/interpreting-gpt-the-logit-lens>`_ 

4reads every intermediate residual-stream state through the model's own final 

5norm and unembedding, so each layer can be asked "what would the model predict 

6if it stopped here?". :func:`logit_lens` does that in one call over a cache's 

7accumulated residual stream; :func:`logit_readout` is the kernel underneath it 

8and reads *any* ``d_model`` vector (a head output, a steering direction, a 

9sparse-coder decoder column) through the same path. 

10 

11Both go through the real ``ln_final`` module, ``W_U``, ``b_U`` and the 

12adapter's post-unembedding transform (Gemma-style softcap, Cohere logit scale), 

13so the final stack entry reproduces the model's own logits on a raw bridge and 

14the model's log-probabilities in compatibility mode (``center_unembed`` shifts 

15raw logits by a per-position constant). Logits are produced chunk by chunk, so 

16``[layers, batch, pos, d_vocab]`` is never materialized unless the full 

17vocabulary is explicitly requested. 

18 

19Example:: 

20 

21 from transformer_lens.model_bridge import TransformerBridge 

22 from transformer_lens.tools.analysis import logit_lens 

23 

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

25 result = logit_lens(model, "The Eiffel Tower is in the city of", targets=" Paris") 

26 for label, rank in zip(result.labels, result.rank_trajectory(" Paris").tolist()): 

27 print(f"{label:>12}: rank {rank}") 

28 

29 # Rank of the actual next token at every position and layer: a per-row target. 

30 tokens = model.to_tokens("The Eiffel Tower is in the city of Paris") 

31 nxt = logit_lens(model, tokens[:, :-1], positions=None, targets=tokens[:, 1:]) 

32 nxt.rank_trajectory() # [layers, batch, pos] 

33""" 

34 

35import itertools 

36from dataclasses import dataclass 

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

38 

39import torch 

40from jaxtyping import Float, Int 

41 

42from transformer_lens.ActivationCache import ActivationCache 

43from transformer_lens.tools.analysis._model_state import require_eval_mode 

44from transformer_lens.utilities import Slice, SliceInput 

45from transformer_lens.utilities.logits_utils import logits_to_df 

46from transformer_lens.utilities.quantization import require_readable_weight 

47 

48# Shared targets: one string / id or a sequence of them. A tensor means per-row 

49# targets (see logit_readout). 

50TokenInput = Union[str, int, Sequence[Union[str, int]], torch.Tensor] 

51 

52_RETURN_TYPES = ("logits", "log_probs", "probs") 

53_INDEX_DTYPES = (torch.int64, torch.int32, torch.int16, torch.int8, torch.uint8) 

54# Value-column names, matching utilities.logits_utils.logits_to_df. 

55_VALUE_COLUMNS = {"logits": "logit", "log_probs": "log_prob", "probs": "probability"} 

56 

57# Normalization types for which the model has a final norm the lens must apply. 

58_FINAL_NORM_TYPES = ("LN", "LNPre", "RMS", "RMSPre") 

59 

60# Block variants without a ``resid_mid`` hook; mirrors direct_logit_attribution. 

61_HYBRID_VARIANT_NAMES = ("mamba", "ssm", "mixer", "linear_attn") 

62 

63 

64@dataclass 

65class LogitReadout: 

66 """Result of :func:`logit_readout`. 

67 

68 Exactly one of the three selectors expands the trailing axis of ``values``: 

69 ``targets`` gives ``[..., n_targets]`` (``[..., 1]`` for per-row targets), 

70 ``top_k`` gives ``[..., k]`` with ``top_ids``, ``vocab`` gives 

71 ``[..., len(vocab)]``; with none of them the trailing axis is the full 

72 vocabulary. ``entropy`` and ``target_ranks`` are always computed over the 

73 full vocabulary, whatever was selected. 

74 

75 Attributes: 

76 values: The requested quantity (see ``return_type``) with the leading 

77 dims of the input vectors and the selected trailing axis. 

78 return_type: ``"logits"``, ``"log_probs"`` or ``"probs"``. 

79 entropy: Full-vocabulary entropy of the softmax, in nats, ``[...]``. 

80 top_ids: Token ids aligned with ``values`` when ``top_k`` was used. 

81 vocab_ids: The requested subset ids when ``vocab`` was used. 

82 target_ids: Shared targets as ``[n_targets]``; per-row targets as a 

83 tensor with the leading dims of the vectors (one id per row). 

84 target_ranks: Zero-based full-vocabulary rank of each target (0 is the 

85 argmax; ties count only strictly larger logits), aligned with 

86 ``values``. 

87 per_row_targets: Whether ``targets`` was given per row (a tensor). 

88 applied_ln: Whether the model's final norm was applied to the vectors. 

89 applied_output_transform: Whether the adapter's post-unembedding 

90 transform was invoked (it is the identity for most architectures). 

91 """ 

92 

93 values: Float[torch.Tensor, "... selected"] 

94 return_type: str 

95 entropy: Float[torch.Tensor, "..."] 

96 top_ids: Optional[Int[torch.Tensor, "... k"]] = None 

97 vocab_ids: Optional[Int[torch.Tensor, "v"]] = None 

98 target_ids: Optional[torch.Tensor] = None 

99 target_ranks: Optional[Int[torch.Tensor, "... t"]] = None 

100 per_row_targets: bool = False 

101 applied_ln: bool = True 

102 applied_output_transform: bool = True 

103 

104 @property 

105 def selected_ids(self) -> Optional[torch.Tensor]: 

106 """Token ids along the trailing axis of ``values`` when it is not the full vocabulary.""" 

107 if self.target_ids is not None: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 return self.target_ids 

109 if self.vocab_ids is not None: 

110 return self.vocab_ids 

111 return self.top_ids 

112 

113 def row_ids(self, index: Tuple[int, ...]) -> Optional[torch.Tensor]: 

114 """Trailing-axis token ids for one row of ``values`` (``None`` = full vocabulary).""" 

115 if self.top_ids is not None: 

116 return self.top_ids[index] 

117 if self.target_ids is not None and self.per_row_targets: 

118 return self.target_ids[index].reshape(1) 

119 return self.selected_ids 

120 

121 def decode(self, tokenizer: Any) -> Any: 

122 """Decode the trailing-axis token ids to strings, keeping the leading structure. 

123 

124 ``top_k`` and per-row readouts decode per row; shared ``targets`` / 

125 ``vocab`` readouts decode the id list once. Full-vocabulary readouts 

126 have no id list to decode. 

127 """ 

128 decode = getattr(tokenizer, "decode", None) 

129 if not callable(decode): 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 raise TypeError("tokenizer must provide a callable decode method") 

131 if self.top_ids is not None: 

132 return _decode_nested(self.top_ids, decode) 

133 if self.target_ids is not None and self.per_row_targets: 133 ↛ 135line 133 didn't jump to line 135 because the condition on line 133 was always true

134 return _decode_nested(self.target_ids, decode) 

135 ids = self.selected_ids 

136 if ids is None: 

137 raise ValueError( 

138 "decode needs a targets, vocab or top_k readout; this one is full-vocabulary" 

139 ) 

140 return [str(decode([int(i)])) for i in ids.tolist()] 

141 

142 

143@dataclass 

144class LogitLensResult: 

145 """Result of :func:`logit_lens`. 

146 

147 Attributes: 

148 readout: The :class:`LogitReadout` over the accumulated stack. Its 

149 leading axis is the stack entry, aligned with ``labels``; the 

150 remaining leading dims are ``[batch, pos]`` after the requested 

151 ``positions`` / ``batch_slice`` (an integer selection drops its axis). 

152 labels: Stack entry labels from ``accumulated_resid`` (``"0_pre"``, 

153 ``"3_mid"``, ``"final_post"``). 

154 has_batch_dim: Whether a batch axis follows the stack axis. 

155 target_names: For shared targets, the string passed for each target 

156 (``None`` where an id was passed), so :meth:`rank_trajectory` 

157 accepts the strings again. 

158 """ 

159 

160 readout: LogitReadout 

161 labels: List[str] 

162 has_batch_dim: bool 

163 target_names: Optional[List[Optional[str]]] = None 

164 

165 @property 

166 def values(self) -> torch.Tensor: 

167 """Shortcut for ``readout.values``.""" 

168 return self.readout.values 

169 

170 def rank_trajectory(self, target: Union[str, int, None] = None) -> torch.Tensor: 

171 """Full-vocabulary rank of one target at every stack entry, ``[layer, ...]``. 

172 

173 ``target`` is the string passed to ``targets`` or a token id; ``None`` 

174 selects the only target when exactly one was requested, and is the only 

175 form for per-row targets. 

176 """ 

177 ranks = self.readout.target_ranks 

178 ids = self.readout.target_ids 

179 if ranks is None or ids is None: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 raise ValueError("rank_trajectory needs a lens run with targets=...") 

181 if self.readout.per_row_targets: 

182 if target is not None: 

183 raise ValueError("per-row targets have one target per row; call rank_trajectory()") 

184 return ranks[..., 0] 

185 if target is None: 

186 if ids.numel() != 1: 

187 raise ValueError("pass target= when more than one target was requested") 

188 return ranks[..., 0] 

189 if isinstance(target, str): 

190 names = self.target_names or [] 

191 if target not in names: 

192 known = [n for n in names if n is not None] 

193 raise ValueError( 

194 f"unknown target {target!r}; string lookup needs the string as passed " 

195 f"(known: {known}); pass the token id otherwise" 

196 ) 

197 return ranks[..., names.index(target)] 

198 matches = (ids == int(target)).nonzero(as_tuple=True)[0] 

199 if matches.numel() == 0: 

200 raise ValueError(f"token id {target} was not among the requested targets") 

201 return ranks[..., int(matches[0])] 

202 

203 def top_tokens(self, tokenizer: Any) -> Dict[str, Any]: 

204 """Decoded top-k tokens per stack entry, ``{label: nested lists}``.""" 

205 if self.readout.top_ids is None: 

206 raise ValueError("top_tokens needs a lens run with top_k=...") 

207 return { 

208 label: _decode_nested(self.readout.top_ids[i], tokenizer.decode) 

209 for i, label in enumerate(self.labels) 

210 } 

211 

212 def to_dataframe(self, tokenizer: Optional[Any] = None, top_k: Optional[int] = None) -> Any: 

213 """One row per (stack entry, batch, position, token) as a pandas DataFrame. 

214 

215 Rows within a (layer, batch, pos) group are sorted by descending value and 

216 ``top_k`` keeps the leading ones. Full-vocabulary ``"logits"`` readouts 

217 go through :func:`transformer_lens.utilities.logits_utils.logits_to_df` 

218 (columns ``logit``, ``log_prob``, ``probability``); every other readout 

219 carries ``token_index``, optional ``token_string`` and one value column 

220 named like ``logits_to_df`` names it (``logit`` / ``log_prob`` / 

221 ``probability``). 

222 """ 

223 import pandas as pd 

224 

225 values = self.readout.values 

226 lead = tuple(values.shape[1:-1]) 

227 axis_names = ("batch", "pos") if self.has_batch_dim else ("pos",) 

228 column = _VALUE_COLUMNS[self.readout.return_type] 

229 frames = [] 

230 for i, label in enumerate(self.labels): 

231 for idx in itertools.product(*[range(n) for n in lead]): 

232 row = values[(i, *idx)].detach().cpu() 

233 row_ids = self.readout.row_ids((i, *idx)) 

234 if row_ids is None and self.readout.return_type == "logits": 

235 frame = logits_to_df(row, tokenizer, top_k) 

236 else: 

237 ids = torch.arange(row.shape[-1]) if row_ids is None else row_ids.cpu() 

238 order = torch.argsort(row, descending=True) 

239 if top_k is not None: 

240 order = order[:top_k] 

241 frame = pd.DataFrame({"token_index": ids[order].tolist()}) 

242 if tokenizer is not None: 

243 frame["token_string"] = [tokenizer.decode([t]) for t in ids[order].tolist()] 

244 frame[column] = row[order].tolist() 

245 frame.insert(0, "layer", label) 

246 for axis, value in enumerate(idx): 

247 name = axis_names[axis] if axis < len(axis_names) else f"dim{axis}" 

248 frame.insert(1 + axis, name, value) 

249 frames.append(frame) 

250 return pd.concat(frames, ignore_index=True) 

251 

252 

253def _decode_nested(ids: torch.Tensor, decode: Callable[[List[int]], str]) -> Any: 

254 if ids.ndim == 0: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

255 return str(decode([int(ids)])) 

256 if ids.ndim == 1: 

257 return [str(decode([int(i)])) for i in ids.tolist()] 

258 return [_decode_nested(row, decode) for row in ids] 

259 

260 

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

262 """Resolve strings / ids into a list of single-token ids.""" 

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

264 tokens = [tokens] 

265 ids: List[int] = [] 

266 for token in tokens: 

267 if isinstance(token, str): 

268 ids.append(int(model.to_single_token(token))) 

269 elif isinstance(token, int) and not isinstance(token, bool): 269 ↛ 272line 269 didn't jump to line 272 because the condition on line 269 was always true

270 ids.append(token) 

271 else: 

272 raise TypeError(f"tokens must be strings or ints, got {type(token).__name__}") 

273 return ids 

274 

275 

276def _target_names(targets: Optional[TokenInput]) -> Optional[List[Optional[str]]]: 

277 """Positional record of the strings in a shared-targets argument.""" 

278 if targets is None or isinstance(targets, torch.Tensor): 

279 return None 

280 if isinstance(targets, (str, int)): 

281 return [targets if isinstance(targets, str) else None] 

282 return [t if isinstance(t, str) else None for t in targets] 

283 

284 

285def _final_norm(model: Any) -> Optional[torch.nn.Module]: 

286 """The module to apply before unembedding, or ``None`` when the model has no final norm. 

287 

288 Raises rather than guessing: an unfolded affine norm applied through the 

289 cached-scale path, or an unknown normalization type passed through 

290 untouched, would both read the stream wrong silently. 

291 """ 

292 norm_type = getattr(model.cfg, "normalization_type", None) 

293 if norm_type is None: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 return None 

295 if norm_type not in _FINAL_NORM_TYPES: 

296 raise ValueError( 

297 f"logit readout does not know how to apply normalization_type={norm_type!r}; " 

298 "pass apply_ln=False to read the raw stream, which will not reproduce the model." 

299 ) 

300 ln_final = getattr(model, "ln_final", None) 

301 if not isinstance(ln_final, torch.nn.Module): 

302 raise ValueError( 

303 f"{getattr(model.cfg, 'model_name', 'this model')} declares " 

304 f"normalization_type={norm_type!r} but exposes no `ln_final` component, so the " 

305 "final norm cannot be applied exactly. Add the alias to its adapter; " 

306 "apply_ln=False reads the raw stream instead, which only works when the " 

307 "unembedding consumes d_model vectors directly and does not reproduce the model." 

308 ) 

309 return ln_final 

310 

311 

312def _require_decoder_unembed(model: Any) -> None: 

313 modules = getattr(model, "_modules", {}) 

314 if "encoder_blocks" in modules or "decoder_blocks" in modules: 

315 raise NotImplementedError( 

316 "logit lens does not support encoder-decoder bridges yet; the decoder stack " 

317 "with cross-attention needs its own accumulated residual stream." 

318 ) 

319 if getattr(model, "unembed", None) is None: 

320 raise NotImplementedError( 

321 "logit readout needs an `unembed` component; encoder-only bridges expose a " 

322 "task head instead of W_U." 

323 ) 

324 

325 

326def _validate_selectors( 

327 model: Any, 

328 *, 

329 return_type: str, 

330 vocab: Optional[Union[Sequence[int], torch.Tensor]], 

331 targets: Optional[TokenInput], 

332 top_k: Optional[int], 

333 chunk_size: int, 

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

335 """Cheap argument checks shared by both entry points; run before any forward pass. 

336 

337 Returns ``(vocab_ids, target_ids, d_vocab)`` where ``target_ids`` is a flat 

338 ``[n_targets]`` tensor for shared targets or the caller's tensor (long) for 

339 per-row targets. 

340 """ 

341 if return_type not in _RETURN_TYPES: 

342 raise ValueError(f"return_type must be one of {_RETURN_TYPES}, got {return_type!r}") 

343 if sum(x is not None for x in (vocab, targets, top_k)) > 1: 

344 raise ValueError("pass at most one of vocab, targets and top_k") 

345 if chunk_size < 1: 

346 raise ValueError(f"chunk_size must be >= 1, got {chunk_size}") 

347 if vocab is None and targets is None and top_k is None: 

348 return None, None, -1 

349 d_vocab = int(model.W_U.shape[1]) # read only when a selector needs the bound 

350 if top_k is not None and not 1 <= top_k <= d_vocab: 

351 raise ValueError(f"top_k must be in [1, {d_vocab}], got {top_k}") 

352 for tensor, name in ((vocab, "vocab"), (targets, "targets")): 

353 if isinstance(tensor, torch.Tensor) and tensor.dtype not in _INDEX_DTYPES: 

354 raise TypeError(f"{name} tensors must hold integer token ids, got dtype {tensor.dtype}") 

355 vocab_ids: Optional[torch.Tensor] = None 

356 if vocab is not None: 

357 vocab_ids = torch.as_tensor(vocab, dtype=torch.long).reshape(-1) 

358 target_ids: Optional[torch.Tensor] = None 

359 if isinstance(targets, torch.Tensor): 

360 target_ids = targets.reshape(1) if targets.ndim == 0 else targets 

361 target_ids = target_ids.to(torch.long) 

362 elif targets is not None: 

363 target_ids = torch.tensor(_to_token_ids(model, targets), dtype=torch.long) 

364 for ids, name in ((vocab_ids, "vocab"), (target_ids, "targets")): 

365 if ids is not None and ( 

366 ids.numel() == 0 or int(ids.min()) < 0 or int(ids.max()) >= d_vocab 

367 ): 

368 raise ValueError(f"{name} ids must be within [0, {d_vocab})") 

369 return vocab_ids, target_ids, d_vocab 

370 

371 

372def _apply_output_transform(model: Any, logits: torch.Tensor) -> Tuple[torch.Tensor, bool]: 

373 adapter = getattr(model, "adapter", None) 

374 transform = getattr(adapter, "apply_output_logits_transform", None) 

375 if not callable(transform): 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true

376 return logits, False 

377 return transform(logits), True 

378 

379 

380def _run_ln_final( 

381 ln_final: torch.nn.Module, flat: Float[torch.Tensor, "n d_model"] 

382) -> torch.Tensor: 

383 # Norm components contract on [batch, pos, d_model]; stats are per vector, so 

384 # the row axis can be presented as the position axis. 

385 return ln_final(flat.unsqueeze(0)).squeeze(0) 

386 

387 

388def _broadcast_targets(targets: torch.Tensor, lead: Tuple[int, ...]) -> torch.Tensor: 

389 """Right-align a per-row target tensor with the vectors' leading dims.""" 

390 if targets.ndim > len(lead): 

391 raise ValueError( 

392 f"per-row targets of shape {tuple(targets.shape)} have more dims than the " 

393 f"vectors' leading dims {lead}" 

394 ) 

395 shaped = targets.reshape((1,) * (len(lead) - targets.ndim) + tuple(targets.shape)) 

396 try: 

397 return shaped.expand(*lead) 

398 except RuntimeError as err: 

399 raise ValueError( 

400 f"per-row targets of shape {tuple(targets.shape)} do not broadcast to the " 

401 f"vectors' leading dims {lead}; they are right-aligned, so a per-example " 

402 f"target over several positions must be shaped [batch, 1]" 

403 ) from err 

404 

405 

406def logit_readout( 

407 model: Any, 

408 vectors: Float[torch.Tensor, "... d_model"], 

409 *, 

410 apply_ln: bool = True, 

411 vocab: Optional[Union[Sequence[int], torch.Tensor]] = None, 

412 targets: Optional[TokenInput] = None, 

413 return_type: str = "logits", 

414 top_k: Optional[int] = None, 

415 chunk_size: int = 64, 

416 enable_grad: bool = False, 

417) -> LogitReadout: 

418 """Read ``d_model`` vectors through the model's final norm and unembedding. 

419 

420 Computes ``transform(ln_final(x) @ W_U + b_U)`` for every vector, where 

421 ``transform`` is the adapter's post-unembedding transform, in chunks of 

422 ``chunk_size`` rows so at most ``chunk_size × d_vocab`` logits exist at once. 

423 Everything derived from the full vocabulary (the softmax normalizer, 

424 ``entropy``, ``target_ranks``) is computed per chunk and discarded; only the 

425 selected columns are kept. 

426 

427 Args: 

428 model: A ``TransformerBridge`` in eval mode. 

429 vectors: Residual-stream-shaped vectors, any leading dims. 

430 apply_ln: Apply the model's ``ln_final`` module first (recomputed 

431 statistics per vector). ``False`` reads the raw vectors and does not 

432 reproduce the model. 

433 vocab: Token ids to keep along the trailing axis. 

434 targets: Tokens whose values and full-vocabulary ranks to report. 

435 Strings (single tokens) or ints, or a sequence of them, are 

436 *shared* across every row. A tensor is *per row*: it is 

437 right-aligned and broadcast to the vectors' leading dims (for 

438 ``[batch, pos, d_model]`` vectors pass ``[batch, pos]`` next-token 

439 ids, or ``[batch, 1]`` for one id per example) and the trailing 

440 axis of ``values`` has length 1. Note the convention differs from 

441 ``ActivationCache.logit_attrs``, which reads a 1-D tensor as 

442 per-example first; here alignment is purely right-to-left. 

443 return_type: ``"logits"``, ``"log_probs"`` or ``"probs"``. 

444 top_k: Keep the ``k`` largest-logit tokens per vector, with their ids. 

445 chunk_size: Rows of ``vectors`` processed per unembedding matmul. 

446 enable_grad: Keep autograd on (for trainable lenses and attribution); 

447 default runs under ``torch.no_grad()``. 

448 

449 Returns: 

450 A :class:`LogitReadout`. 

451 

452 Raises: 

453 ValueError: On a bad ``return_type``, more than one selector, a 

454 ``chunk_size`` below 1, a wrong trailing dimension, empty vectors, 

455 per-row targets that do not broadcast, or a model whose final norm 

456 cannot be applied exactly. 

457 NotImplementedError: For encoder-decoder and encoder-only bridges. 

458 """ 

459 require_eval_mode(model, operation="logit_readout") 

460 _require_decoder_unembed(model) 

461 if not isinstance(vectors, torch.Tensor) or not vectors.is_floating_point(): 461 ↛ 462line 461 didn't jump to line 462 because the condition on line 461 was never true

462 raise TypeError("vectors must be a floating-point tensor") 

463 ln_final = _final_norm(model) if apply_ln else None 

464 vocab_ids, target_ids, _ = _validate_selectors( 

465 model, 

466 return_type=return_type, 

467 vocab=vocab, 

468 targets=targets, 

469 top_k=top_k, 

470 chunk_size=chunk_size, 

471 ) 

472 d_model = int(model.cfg.d_model) 

473 if vectors.shape[-1] != d_model: 

474 raise ValueError(f"vectors must end in d_model={d_model}, got shape {tuple(vectors.shape)}") 

475 lead = tuple(vectors.shape[:-1]) 

476 if vectors.numel() == 0: 

477 raise ValueError(f"vectors has no rows to read (shape {tuple(vectors.shape)})") 

478 

479 W_U = require_readable_weight(model.W_U, operation="read logits through W_U", owner=model) 

480 b_U = model.b_U 

481 

482 per_row = isinstance(targets, torch.Tensor) and targets.ndim >= 1 

483 row_targets: Optional[torch.Tensor] = None 

484 if per_row: 

485 assert target_ids is not None 

486 target_ids = _broadcast_targets(target_ids, lead) 

487 row_targets = target_ids.reshape(-1).to(W_U.device) 

488 select = vocab_ids if vocab_ids is not None else (None if per_row else target_ids) 

489 select_dev = None if select is None else select.to(W_U.device) 

490 

491 flat = vectors.reshape(-1, d_model).to(device=W_U.device, dtype=W_U.dtype) 

492 out_values: List[torch.Tensor] = [] 

493 out_top_ids: List[torch.Tensor] = [] 

494 out_ranks: List[torch.Tensor] = [] 

495 out_entropy: List[torch.Tensor] = [] 

496 applied_transform = False 

497 

498 grad_ctx = torch.enable_grad() if enable_grad else torch.no_grad() 

499 with grad_ctx: 

500 for start in range(0, flat.shape[0], chunk_size): 

501 chunk = flat[start : start + chunk_size] 

502 if ln_final is not None: 

503 chunk = _run_ln_final(ln_final, chunk) 

504 logits = chunk @ W_U + b_U 

505 logits, applied_transform = _apply_output_transform(model, logits) 

506 log_probs = torch.log_softmax(logits.float(), dim=-1) 

507 out_entropy.append(-(log_probs.exp() * log_probs).sum(dim=-1)) 

508 if return_type == "logits": 

509 quantity = logits 

510 elif return_type == "log_probs": 

511 quantity = log_probs 

512 else: 

513 quantity = log_probs.exp() 

514 if row_targets is not None: 

515 rows = row_targets[start : start + chunk_size].unsqueeze(-1) # [n, 1] 

516 target_logits = torch.gather(logits, -1, rows) 

517 out_ranks.append((logits > target_logits).sum(dim=-1, keepdim=True)) 

518 out_values.append(torch.gather(quantity, -1, rows)) 

519 elif target_ids is not None: 

520 assert select_dev is not None 

521 target_logits = logits[:, select_dev].unsqueeze(-1) # [n, t, 1] 

522 out_ranks.append((logits.unsqueeze(1) > target_logits).sum(dim=-1)) 

523 out_values.append(quantity[:, select_dev]) 

524 elif top_k is not None: 

525 top = torch.topk(logits, k=top_k, dim=-1, sorted=True) 

526 out_top_ids.append(top.indices) 

527 out_values.append(torch.gather(quantity, -1, top.indices)) 

528 elif select_dev is not None: 

529 out_values.append(quantity[:, select_dev]) 

530 else: 

531 out_values.append(quantity) 

532 

533 return LogitReadout( 

534 values=torch.cat(out_values, dim=0).reshape(*lead, -1), 

535 return_type=return_type, 

536 entropy=torch.cat(out_entropy, dim=0).reshape(lead), 

537 top_ids=torch.cat(out_top_ids, dim=0).reshape(*lead, -1) if out_top_ids else None, 

538 vocab_ids=vocab_ids, 

539 target_ids=target_ids, 

540 target_ranks=torch.cat(out_ranks, dim=0).reshape(*lead, -1) if out_ranks else None, 

541 per_row_targets=per_row, 

542 applied_ln=ln_final is not None, 

543 applied_output_transform=applied_transform, 

544 ) 

545 

546 

547def _residual_hook_names(n_layers: int, incl_mid: bool) -> List[str]: 

548 names = [] 

549 for layer in range(n_layers): 

550 names.append(f"blocks.{layer}.hook_resid_pre") 

551 if incl_mid: 

552 names.append(f"blocks.{layer}.hook_resid_mid") 

553 names.append(f"blocks.{n_layers - 1}.hook_resid_post") 

554 return names 

555 

556 

557def _resolve_layers(layers: Optional[Sequence[int]], labels: List[str]) -> List[int]: 

558 if layers is None: 

559 return list(range(len(labels))) 

560 resolved = [] 

561 for layer in layers: 

562 index = layer + len(labels) if layer < 0 else layer 

563 if not 0 <= index < len(labels): 

564 raise ValueError( 

565 f"layer {layer} is out of range for the accumulated stack; valid entries are " 

566 f"0..{len(labels) - 1} = {labels}" 

567 ) 

568 resolved.append(index) 

569 return resolved 

570 

571 

572def logit_lens( 

573 model: Union[Any, ActivationCache], 

574 input: Union[str, List[str], torch.Tensor, None] = None, 

575 *, 

576 cache: Optional[ActivationCache] = None, 

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

578 incl_mid: bool = False, 

579 positions: SliceInput = -1, 

580 batch_slice: SliceInput = None, 

581 lens: Optional[Callable[[torch.Tensor, int], torch.Tensor]] = None, 

582 apply_ln: bool = True, 

583 vocab: Optional[Union[Sequence[int], torch.Tensor]] = None, 

584 targets: Optional[TokenInput] = None, 

585 return_type: str = "logits", 

586 top_k: Optional[int] = None, 

587 chunk_size: int = 64, 

588 enable_grad: bool = False, 

589) -> LogitLensResult: 

590 """Read the accumulated residual stream through the final norm and unembedding. 

591 

592 Builds the stack with 

593 :meth:`~transformer_lens.ActivationCache.ActivationCache.accumulated_resid`, 

594 optionally applies a per-entry ``lens`` translator, applies the final norm 

595 through :meth:`~transformer_lens.ActivationCache.ActivationCache.apply_ln_to_stack` 

596 with recomputed statistics, and hands the result to :func:`logit_readout`. 

597 The final stack entry reproduces the model's logits (raw bridge) or 

598 log-probabilities (compatibility mode). 

599 

600 Args: 

601 model: A ``TransformerBridge``, or an :class:`ActivationCache` (then 

602 ``input`` must be omitted). 

603 input: Prompt(s) or token tensor to run when no ``cache`` is given. Only 

604 the residual-stream hooks are cached. A batch of strings is padded 

605 by the tokenizer, so with the default ``positions=-1`` the shorter 

606 prompts are read at a pad position; pass a token tensor (or 

607 per-prompt positions) when prompts differ in length. 

608 cache: An existing cache to read instead of running the model. 

609 layers: Indices into the accumulated stack (negative allowed); ``None`` 

610 keeps every entry. 

611 incl_mid: Include ``resid_mid`` entries. Raises on hybrid stacks whose 

612 SSM blocks have no ``resid_mid``. 

613 positions: Position selection (``Slice`` semantics); default last token. 

614 batch_slice: Batch selection (``Slice`` semantics). 

615 lens: Optional ``f(entry, index) -> entry`` applied to each stack entry 

616 before the norm, e.g. a tuned-lens translator. Combine with 

617 ``enable_grad=True`` to fit or attribute through it. 

618 targets: As in :func:`logit_readout`; a per-row tensor is aligned with 

619 the stack's ``[batch, pos]`` dims *after* ``positions`` and 

620 ``batch_slice`` are applied (``[batch, pos]`` next-token ids with 

621 ``positions=None``, ``[batch]`` with the default last position). 

622 apply_ln, vocab, return_type, top_k, chunk_size, enable_grad: As in 

623 :func:`logit_readout`. 

624 

625 Returns: 

626 A :class:`LogitLensResult`. 

627 """ 

628 if isinstance(model, ActivationCache): 

629 if input is not None or cache is not None: 

630 raise ValueError("pass either a cache or a model with input, not both") 

631 cache = model 

632 model = cache.model 

633 require_eval_mode(model, operation="logit_lens") 

634 _require_decoder_unembed(model) 

635 # Everything cheap is checked before the forward pass is paid for. 

636 ln_final = _final_norm(model) if apply_ln else None 

637 _validate_selectors( 

638 model, 

639 return_type=return_type, 

640 vocab=vocab, 

641 targets=targets, 

642 top_k=top_k, 

643 chunk_size=chunk_size, 

644 ) 

645 if incl_mid: 

646 layer_types = model.layer_types() if hasattr(model, "layer_types") else [] 

647 hybrid = [t for t in layer_types if any(p in _HYBRID_VARIANT_NAMES for p in t.split("+"))] 

648 if hybrid: 

649 raise ValueError( 

650 f"incl_mid=True needs a resid_mid hook on every block, but this stack has " 

651 f"block types {sorted(set(hybrid))} without one; use incl_mid=False." 

652 ) 

653 if cache is None: 

654 if input is None: 

655 raise ValueError("logit_lens needs an input to run or a cache to read") 

656 _, cache = model.run_with_cache( 

657 input, names_filter=_residual_hook_names(int(model.cfg.n_layers), incl_mid) 

658 ) 

659 assert cache is not None 

660 

661 pos_slice = positions if isinstance(positions, Slice) else Slice(positions) 

662 batch = batch_slice if isinstance(batch_slice, Slice) else Slice(batch_slice) 

663 stack, labels = cache.accumulated_resid( 

664 layer=None, incl_mid=incl_mid, apply_ln=False, pos_slice=pos_slice, return_labels=True 

665 ) 

666 keep = _resolve_layers(layers, labels) 

667 stack = stack[keep] 

668 labels = [labels[i] for i in keep] 

669 if cache.has_batch_dim: 

670 stack = batch.apply(stack, dim=1) 

671 has_batch_dim = cache.has_batch_dim and batch.mode != "int" 

672 grad_ctx = torch.enable_grad() if enable_grad else torch.no_grad() 

673 with grad_ctx: 

674 if lens is not None: 

675 stack = torch.stack([lens(stack[i], keep[i]) for i in range(stack.shape[0])], dim=0) 

676 if ln_final is not None: 

677 # Recomputed-statistics path through the ln_final module (the #1076 fix). 

678 stack = cache.apply_ln_to_stack( 

679 stack, 

680 layer=None, 

681 pos_slice=pos_slice, 

682 has_batch_dim=has_batch_dim, 

683 recompute_ln=True, 

684 ) 

685 readout = logit_readout( 

686 model, 

687 stack, 

688 apply_ln=False, 

689 vocab=vocab, 

690 targets=targets, 

691 return_type=return_type, 

692 top_k=top_k, 

693 chunk_size=chunk_size, 

694 enable_grad=enable_grad, 

695 ) 

696 readout.applied_ln = ln_final is not None 

697 return LogitLensResult( 

698 readout=readout, 

699 labels=labels, 

700 has_batch_dim=has_batch_dim, 

701 target_names=_target_names(targets), 

702 )