Coverage for transformer_lens/hook_points.py: 91%

202 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-01 16:23 +0000

1from __future__ import annotations 

2 

3"""Hook Points. 

4 

5Helpers to access activations in models. 

6""" 

7 

8from collections.abc import Callable, Sequence 

9from dataclasses import dataclass 

10from functools import partial 

11from typing import ( 

12 Any, 

13 Callable, 

14 Literal, 

15 Optional, 

16 Protocol, 

17 Sequence, 

18 Union, 

19 runtime_checkable, 

20) 

21 

22import torch 

23import torch.nn as nn 

24import torch.utils.hooks as hooks 

25from torch import Tensor 

26 

27from transformer_lens.conversion_utils.conversion_steps.base_tensor_conversion import ( 

28 BaseTensorConversion, 

29) 

30 

31 

32@dataclass 

33class LensHandle: 

34 """Dataclass that holds information about a PyTorch hook.""" 

35 

36 hook: hooks.RemovableHandle 

37 """Reference to the Hook's Removable Handle.""" 

38 

39 is_permanent: bool = False 

40 """Indicates if the Hook is Permanent.""" 

41 

42 context_level: Optional[int] = None 

43 """Context level associated with the hooks context manager for the given hook.""" 

44 

45 user_hook: Optional[Callable] = None 

46 """The original hook callable, before ``add_hook`` wraps it.""" 

47 

48 

49# Define type aliases 

50NamesFilter = Optional[Union[Callable[[str], bool], Sequence[str], str]] 

51 

52 

53class _ScaledGradientTensor: 

54 """Wrapper around gradient tensors that applies backward_scale to sum operations. 

55 

56 This works around a PyTorch bug/behavior where multiplying gradient tensors 

57 element-wise in backward hooks gives incorrect sums. 

58 """ 

59 

60 def __init__(self, tensor: Tensor, scale: float): 

61 self._tensor = tensor 

62 self._scale = scale 

63 

64 def sum(self, *args, **kwargs): 

65 """Override sum to apply scaling to the result, not the tensor.""" 

66 result = self._tensor.sum(*args, **kwargs) 

67 if isinstance(result, Tensor) and result.numel() == 1: 

68 # Scalar result - apply scale 

69 return result * self._scale 

70 return result 

71 

72 def __getattr__(self, name): 

73 """Delegate all other attributes to the wrapped tensor.""" 

74 return getattr(self._tensor, name) 

75 

76 def __repr__(self): 

77 return f"ScaledGradientTensor({self._tensor}, scale={self._scale})" 

78 

79 

80@runtime_checkable 

81class _HookFunctionProtocol(Protocol): 

82 """Protocol for hook functions.""" 

83 

84 def __call__(self, tensor: Tensor, *, hook: "HookPoint") -> Union[Any, None]: 

85 ... 

86 

87 

88HookFunction = _HookFunctionProtocol # Callable[..., _HookFunctionProtocol] 

89 

90DeviceType = Optional[torch.device] 

91_grad_t = Union[tuple[Tensor, ...], Tensor] 

92 

93 

94class _AliasedHookPoint: 

95 """ 

96 A lightweight wrapper that represents a HookPoint with an aliased name. 

97 

98 This is used when a hook is registered with multiple names (e.g., in compatibility mode 

99 where both canonical and legacy names should trigger the hook). Instead of modifying 

100 the original HookPoint's name, we create this wrapper that delegates to the original 

101 HookPoint but presents a different name to the user's hook function. 

102 """ 

103 

104 def __init__(self, alias_name: str, target: "HookPoint"): 

105 """ 

106 Create an aliased view of a HookPoint. 

107 

108 Args: 

109 alias_name: The name to present to the hook function 

110 target: The original HookPoint to delegate to 

111 """ 

112 self._alias_name = alias_name 

113 self._target = target 

114 

115 @property 

116 def name(self) -> Optional[str]: 

117 """Return the alias name.""" 

118 return self._alias_name 

119 

120 @property 

121 def ctx(self) -> dict: 

122 """Delegate to the target's context.""" 

123 return self._target.ctx 

124 

125 @property 

126 def hook_conversion(self): 

127 """Delegate to the target's hook conversion.""" 

128 return self._target.hook_conversion 

129 

130 def layer(self) -> int: 

131 """ 

132 Extract layer index from the alias name. 

133 

134 Returns the layer index for hook names like 'blocks.0.attn.hook_pattern' -> 0 

135 """ 

136 if self._alias_name is None: 

137 raise ValueError("Name cannot be None") 

138 split_name = self._alias_name.split(".") 

139 return int(split_name[1]) 

140 

141 

142class HookPoint(nn.Module): 

143 """ 

144 A helper class to access intermediate activations in a PyTorch model (inspired by Garcon). 

145 

146 HookPoint is a dummy module that acts as an identity function by default. By wrapping any 

147 intermediate activation in a HookPoint, it provides a convenient way to add PyTorch hooks. 

148 """ 

149 

150 def __init__(self): 

151 super().__init__() 

152 self.fwd_hooks: list[LensHandle] = [] 

153 self.bwd_hooks: list[LensHandle] = [] 

154 self.ctx = {} 

155 

156 # A variable giving the hook's name (from the perspective of the root 

157 # module) - this is set by the root module at setup. 

158 self.name: Optional[str] = None 

159 

160 # Hook conversion for input and output transformations 

161 self.hook_conversion: Optional[BaseTensorConversion] = None 

162 

163 # Backward gradient scale factor (for compatibility between architectures) 

164 # This scales the SUM of gradients, not element-wise (to avoid PyTorch bugs) 

165 self.backward_scale: float = 1.0 

166 

167 def __repr__(self) -> str: 

168 bits = [f"name={self.name!r}"] if self.name is not None else [] 

169 if self.fwd_hooks: 

170 bits.append(f"{len(self.fwd_hooks)} fwd") 

171 if self.bwd_hooks: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 bits.append(f"{len(self.bwd_hooks)} bwd") 

173 return f"HookPoint({', '.join(bits)})" if bits else "HookPoint()" 

174 

175 def add_perma_hook(self, hook: HookFunction, dir: Literal["fwd", "bwd"] = "fwd") -> None: 

176 self.add_hook(hook, dir=dir, is_permanent=True) 

177 

178 def add_hook( 

179 self, 

180 hook: HookFunction, 

181 dir: Literal["fwd", "bwd"] = "fwd", 

182 is_permanent: bool = False, 

183 level: Optional[int] = None, 

184 prepend: bool = False, 

185 alias_names: Optional[list[str]] = None, 

186 ) -> None: 

187 """ 

188 Hook format is fn(activation, hook_name) 

189 Change it into PyTorch hook format (this includes input and output, 

190 which are the same for a HookPoint) 

191 If prepend is True, add this hook before all other hooks 

192 If alias_names is provided, the hook will be called once for each alias name, 

193 receiving a temporary HookPoint-like object with that name instead of self 

194 (useful for compatibility mode aliases) 

195 """ 

196 

197 def full_hook( 

198 module: torch.nn.Module, 

199 module_input: Any, 

200 module_output: Any, 

201 ): 

202 if ( 

203 dir == "bwd" 

204 ): # For a backwards hook, module_output is a tuple of (grad,) - I don't know why. 

205 module_output = module_output[0] 

206 

207 # Apply backward scaling if needed (wrap tensor to scale sum operations) 

208 if self.backward_scale != 1.0: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true

209 module_output = _ScaledGradientTensor(module_output, self.backward_scale) 

210 

211 # Apply input conversion if hook_conversion exists 

212 if self.hook_conversion is not None: 

213 module_output = self.hook_conversion.convert(module_output) 

214 

215 # Apply the hook for each name (or just once with canonical name) 

216 if alias_names is not None: 

217 # Call the hook once for each alias name 

218 # Create a simple wrapper that acts like a HookPoint but with a different name 

219 hook_result = None 

220 hook_changed_output = False 

221 for alias_name in alias_names: 

222 # Create a view of this HookPoint with the alias name 

223 hook_with_alias = _AliasedHookPoint(alias_name, self) 

224 hook_result = hook(module_output, hook=hook_with_alias) # type: ignore[arg-type] 

225 

226 # If the hook modified the output, use that for subsequent calls 

227 if hook_result is not None: 

228 module_output = hook_result 

229 hook_changed_output = True 

230 if hook_changed_output: 230 ↛ 237line 230 didn't jump to line 237 because the condition on line 230 was always true

231 hook_result = module_output 

232 else: 

233 # Call the hook once with the canonical name (self) 

234 hook_result = hook(module_output, hook=self) 

235 

236 # Apply output reversion if hook_conversion exists and hook returned a value 

237 if hook_result is not None and self.hook_conversion is not None: 

238 hook_result = self.hook_conversion.revert(hook_result) 

239 

240 # For backward hooks, PyTorch expects the return to be a tuple of (grad,) 

241 if dir == "bwd" and hook_result is not None: 

242 return ( 

243 hook_result 

244 if isinstance(hook_result, tuple) and len(hook_result) == 1 

245 else (hook_result,) 

246 ) 

247 

248 return hook_result 

249 

250 # annotate the `full_hook` with the string representation of the `hook` function 

251 if isinstance(hook, partial): 

252 # partial.__repr__() can be extremely slow if arguments contain large objects, which 

253 # is common when caching tensors. 

254 full_hook.__name__ = f"partial({hook.func.__repr__()},...)" 

255 else: 

256 full_hook.__name__ = hook.__repr__() 

257 

258 if dir == "fwd": 

259 pt_handle = self.register_forward_hook(full_hook, prepend=prepend) 

260 visible_hooks = self.fwd_hooks 

261 elif dir == "bwd": 261 ↛ 282line 261 didn't jump to line 282 because the condition on line 261 was always true

262 # Wrap full_hook's bare Tensor return in tuple for PyTorch's backward API 

263 def _bwd_hook_wrapper( 

264 module: torch.nn.Module, 

265 grad_input: Any, 

266 grad_output: Any, 

267 ): 

268 result = full_hook(module, grad_input, grad_output) 

269 if result is None: 

270 return None 

271 if isinstance(result, tuple): 271 ↛ 273line 271 didn't jump to line 273 because the condition on line 271 was always true

272 return result 

273 return (result,) 

274 

275 if isinstance(hook, partial): 

276 _bwd_hook_wrapper.__name__ = f"partial({hook.func.__repr__()},...)" 

277 else: 

278 _bwd_hook_wrapper.__name__ = hook.__repr__() 

279 pt_handle = self.register_full_backward_hook(_bwd_hook_wrapper, prepend=prepend) 

280 visible_hooks = self.bwd_hooks 

281 else: 

282 raise ValueError(f"Invalid direction {dir}") 

283 

284 handle = LensHandle(pt_handle, is_permanent, level, user_hook=hook) 

285 

286 if prepend: 

287 # we could just pass this as an argument in PyTorch 2.0, but for now we manually do this... 

288 visible_hooks.insert(0, handle) 

289 

290 else: 

291 visible_hooks.append(handle) 

292 

293 def has_hooks( 

294 self, 

295 dir: Literal["fwd", "bwd", "both"] = "both", 

296 including_permanent: bool = True, 

297 level: Optional[int] = None, 

298 ) -> bool: 

299 """Check if this HookPoint has any active hooks. 

300 

301 Args: 

302 dir: Direction of hooks to check ("fwd", "bwd", or "both") 

303 including_permanent: Whether to include permanent hooks in the check 

304 level: Only check hooks at this context level (None for all levels) 

305 

306 Returns: 

307 True if any matching hooks are found, False otherwise 

308 """ 

309 

310 def _has_hooks_in_direction(handles: list[LensHandle]) -> bool: 

311 for handle in handles: 

312 # Check if this hook matches our criteria 

313 if not including_permanent and handle.is_permanent: 

314 continue 

315 if level is not None and handle.context_level != level: 

316 continue 

317 return True 

318 return False 

319 

320 if dir == "fwd": 

321 return _has_hooks_in_direction(self.fwd_hooks) 

322 elif dir == "bwd": 

323 return _has_hooks_in_direction(self.bwd_hooks) 

324 elif dir == "both": 324 ↛ 329line 324 didn't jump to line 329 because the condition on line 324 was always true

325 return _has_hooks_in_direction(self.fwd_hooks) or _has_hooks_in_direction( 

326 self.bwd_hooks 

327 ) 

328 else: 

329 raise ValueError(f"Invalid direction {dir}") 

330 

331 def remove_hooks( 

332 self, 

333 dir: Literal["fwd", "bwd", "both"] = "fwd", 

334 including_permanent: bool = False, 

335 level: Optional[int] = None, 

336 ) -> None: 

337 def _remove_hooks(handles: list[LensHandle]) -> list[LensHandle]: 

338 output_handles = [] 

339 for handle in handles: 

340 if including_permanent: 

341 handle.hook.remove() 

342 elif (not handle.is_permanent) and (level is None or handle.context_level == level): 

343 handle.hook.remove() 

344 else: 

345 output_handles.append(handle) 

346 return output_handles 

347 

348 if dir == "fwd" or dir == "both": 

349 self.fwd_hooks = _remove_hooks(self.fwd_hooks) 

350 if dir == "bwd" or dir == "both": 

351 self.bwd_hooks = _remove_hooks(self.bwd_hooks) 

352 if dir not in ["fwd", "bwd", "both"]: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 raise ValueError(f"Invalid direction {dir}") 

354 

355 def clear_context(self): 

356 del self.ctx 

357 self.ctx = {} 

358 

359 def enable_reshape( 

360 self, 

361 hook_conversion: Optional[BaseTensorConversion] = None, 

362 ) -> None: 

363 """ 

364 Enable reshape functionality for this hook point using a BaseTensorConversion. 

365 

366 Args: 

367 hook_conversion: BaseTensorConversion instance to handle input/output transformations. 

368 The convert() method will be used for input transformation, 

369 and the revert() method will be used for output transformation. 

370 """ 

371 self.hook_conversion = hook_conversion 

372 

373 def forward(self, x: Tensor) -> Tensor: 

374 return x 

375 

376 def layer(self): 

377 # Returns the layer index if the name has the form 'blocks.{layer}.{...}' 

378 # Helper function that's mainly useful on HookedTransformer 

379 # If it doesn't have this form, raises an error - 

380 if self.name is None: 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 raise ValueError("Name cannot be None") 

382 split_name = self.name.split(".") 

383 return int(split_name[1]) 

384 

385 

386# %% 

387class HookIntrospectionMixin: 

388 """``list_hooks()`` mixins for any class exposing a ``hook_dict``. 

389 

390 Accessed via ``getattr`` so subclasses can provide ``hook_dict`` as either 

391 an instance attribute (``HookedRootModule``) or a ``@property`` (``TransformerBridge``). 

392 """ 

393 

394 def list_hooks( 

395 self, 

396 name_filter: NamesFilter = None, 

397 dir: Literal["fwd", "bwd", "both"] = "both", 

398 including_permanent: bool = True, 

399 ) -> dict[str, list[LensHandle]]: 

400 """Return attached hooks grouped by HookPoint name; empty HookPoints are omitted. 

401 

402 Args: 

403 name_filter: A hook name, list of names, or predicate. ``None`` matches all. 

404 dir: Restrict to forward, backward, or both directions. 

405 including_permanent: If False, drop permanent hooks from the result. 

406 """ 

407 if name_filter is None: 

408 matches: Callable[[str], bool] = lambda _: True 

409 elif callable(name_filter): 

410 matches = name_filter 

411 elif isinstance(name_filter, str): 

412 target = name_filter 

413 matches = lambda n: n == target 

414 else: 

415 allowed = set(name_filter) 

416 matches = lambda n: n in allowed 

417 

418 out: dict[str, list[LensHandle]] = {} 

419 hook_dict: dict[str, HookPoint] = getattr(self, "hook_dict") 

420 for name, hp in hook_dict.items(): 

421 if not matches(name): 

422 continue 

423 handles: list[LensHandle] = [] 

424 if dir in ("fwd", "both"): 

425 handles.extend(hp.fwd_hooks) 

426 if dir in ("bwd", "both"): 

427 handles.extend(hp.bwd_hooks) 

428 if not including_permanent: 

429 handles = [h for h in handles if not h.is_permanent] 

430 if handles: 

431 out[name] = handles 

432 return out 

433 

434 

435# HookedRootModule moved to transformer_lens.HookedRootModule (3.0). Import it from 

436# its dedicated module. Importing from here is deprecated and will trigger a warning. 

437def __getattr__(name: str): 

438 if name == "HookedRootModule": 

439 import warnings 

440 

441 from transformer_lens.HookedRootModule import HookedRootModule 

442 

443 warnings.warn( 

444 "Importing HookedRootModule from transformer_lens.hook_points is " 

445 "deprecated and will be removed in TransformerLens 4.0. Import it from " 

446 "transformer_lens (preferred) or transformer_lens.HookedRootModule instead.", 

447 DeprecationWarning, 

448 stacklevel=2, 

449 ) 

450 return HookedRootModule 

451 raise AttributeError(f"module {__name__!r} has no attribute {name!r}")