Coverage for transformer_lens/HookedRootModule.py: 15%

175 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-09-21 19:27 +0000

1"""HookedRootModule. 

2 

3Base class extending :class:`torch.nn.Module` with hook-based introspection 

4utilities: wrap any module's tensors in :class:`HookPoint` wrappers, call ``setup()``, 

5and ``run_with_hooks`` / ``run_with_cache`` work on it. This is permanent 

6infrastructure — it survives the 4.0 removal of the legacy model classes and is 

7the supported way to add TransformerLens-style hooks to arbitrary ``nn.Module`` objects. 

8Lives in its own module so that downstream code (e.g. :class:`ActivationCache`) 

9can type-hint against it without the broader ``hook_points`` import surface. 

10""" 

11 

12from __future__ import annotations 

13 

14import logging 

15from collections.abc import Callable, Iterable 

16from contextlib import contextmanager 

17from functools import partial 

18from typing import Any, Literal, Optional, Union, cast 

19 

20import torch 

21import torch.nn as nn 

22from torch import Tensor 

23 

24from transformer_lens.hook_points import ( 

25 DeviceType, 

26 HookFunction, 

27 HookIntrospectionMixin, 

28 HookPoint, 

29 NamesFilter, 

30) 

31from transformer_lens.utilities import Slice, SliceInput, warn_if_mps 

32 

33 

34class HookedRootModule(HookIntrospectionMixin, nn.Module): 

35 """A class building on nn.Module to interface nicely with HookPoints. 

36 

37 Adds various nice utilities, most notably run_with_hooks to run the model with temporary hooks, 

38 and run_with_cache to run the model on some input and return a cache of all activations. 

39 

40 Notes: 

41 

42 The main footgun with PyTorch hooking is that hooks are GLOBAL state. If you add a hook to the 

43 module, and then run it a bunch of times, the hooks persist. If you debug a broken hook and add 

44 the fixed version, the broken one is still there. To solve this, run_with_hooks will remove 

45 hooks at the end by default, and I recommend using the API of this and run_with_cache. If you 

46 want to add hooks into global state, I recommend being intentional about this, and I recommend 

47 using reset_hooks liberally in your code to remove any accidentally remaining global state. 

48 

49 The main time this goes wrong is when you want to use backward hooks (to cache or intervene on 

50 gradients). In this case, you need to keep the hooks around as global state until you've run 

51 loss.backward() (and so need to disable the reset_hooks_end flag on run_with_hooks) 

52 """ 

53 

54 name: Optional[str] 

55 mod_dict: dict[str, nn.Module] 

56 hook_dict: dict[str, HookPoint] 

57 

58 def __init__(self, *args: Any): 

59 super().__init__() 

60 self.is_caching = False 

61 self.context_level = 0 

62 

63 def setup(self): 

64 """ 

65 Sets up model. 

66 

67 This function must be called in the model's `__init__` method AFTER defining all layers. It 

68 adds a parameter to each module containing its name, and builds a dictionary mapping module 

69 names to the module instances. It also initializes a hook dictionary for modules of type 

70 "HookPoint". 

71 """ 

72 self.mod_dict = {} 

73 self.hook_dict = {} 

74 for name, module in self.named_modules(): 

75 if name == "": 

76 continue 

77 module.name = name 

78 self.mod_dict[name] = module 

79 # TODO: is the bottom line the same as "if "HookPoint" in str(type(module)):" 

80 if isinstance(module, HookPoint): 

81 self.hook_dict[name] = module 

82 

83 def hook_points(self): 

84 return self.hook_dict.values() 

85 

86 def remove_all_hook_fns( 

87 self, 

88 direction: Literal["fwd", "bwd", "both"] = "both", 

89 including_permanent: bool = False, 

90 level: Optional[int] = None, 

91 ): 

92 for hp in self.hook_points(): 

93 hp.remove_hooks(direction, including_permanent=including_permanent, level=level) 

94 

95 def clear_contexts(self): 

96 for hp in self.hook_points(): 

97 hp.clear_context() 

98 

99 def reset_hooks( 

100 self, 

101 clear_contexts: bool = True, 

102 direction: Literal["fwd", "bwd", "both"] = "both", 

103 including_permanent: bool = False, 

104 level: Optional[int] = None, 

105 ): 

106 if clear_contexts: 

107 self.clear_contexts() 

108 self.remove_all_hook_fns(direction, including_permanent, level=level) 

109 self.is_caching = False 

110 

111 def check_and_add_hook( 

112 self, 

113 hook_point: HookPoint, 

114 hook_point_name: str, 

115 hook: HookFunction, 

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

117 is_permanent: bool = False, 

118 level: Optional[int] = None, 

119 prepend: bool = False, 

120 ) -> None: 

121 """Runs checks on the hook, and then adds it to the hook point""" 

122 

123 self.check_hooks_to_add( 

124 hook_point, 

125 hook_point_name, 

126 hook, 

127 dir=dir, 

128 is_permanent=is_permanent, 

129 prepend=prepend, 

130 ) 

131 hook_point.add_hook(hook, dir=dir, is_permanent=is_permanent, level=level, prepend=prepend) 

132 

133 def check_hooks_to_add( 

134 self, 

135 hook_point: HookPoint, 

136 hook_point_name: str, 

137 hook: HookFunction, 

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

139 is_permanent: bool = False, 

140 prepend: bool = False, 

141 ) -> None: 

142 """Override this function to add checks on which hooks should be added""" 

143 pass 

144 

145 def add_hook( 

146 self, 

147 name: Union[str, Callable[[str], bool]], 

148 hook: HookFunction, 

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

150 is_permanent: bool = False, 

151 level: Optional[int] = None, 

152 prepend: bool = False, 

153 ) -> None: 

154 if isinstance(name, str): 

155 hook_point = self.mod_dict[name] 

156 assert isinstance( 

157 hook_point, HookPoint 

158 ) # TODO does adding assert meaningfully slow down performance? I've added them for type checking purposes. 

159 self.check_and_add_hook( 

160 hook_point, 

161 name, 

162 hook, 

163 dir=dir, 

164 is_permanent=is_permanent, 

165 level=level, 

166 prepend=prepend, 

167 ) 

168 else: 

169 # Otherwise, name is a Boolean function on names 

170 for hook_point_name, hp in self.hook_dict.items(): 

171 if name(hook_point_name): 

172 self.check_and_add_hook( 

173 hp, 

174 hook_point_name, 

175 hook, 

176 dir=dir, 

177 is_permanent=is_permanent, 

178 level=level, 

179 prepend=prepend, 

180 ) 

181 

182 def add_perma_hook( 

183 self, 

184 name: Union[str, Callable[[str], bool]], 

185 hook: HookFunction, 

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

187 ) -> None: 

188 self.add_hook(name, hook, dir=dir, is_permanent=True) 

189 

190 def _enable_hook_with_name(self, name: str, hook: Callable, dir: Literal["fwd", "bwd"]): 

191 """This function takes a key for the mod_dict and enables the related hook for that module 

192 

193 Args: 

194 name (str): The module name 

195 hook (Callable): The hook to add 

196 dir (Literal["fwd", "bwd"]): The direction for the hook 

197 """ 

198 hook_point_module = self.mod_dict[name] 

199 if not hasattr(hook_point_module, "add_hook"): 

200 raise TypeError(f"Expected a module with add_hook, got {type(hook_point_module)}") 

201 if isinstance(hook_point_module, torch.Tensor): 

202 raise TypeError( 

203 "Module set as Tensor for some reason!" 

204 ) # mypy seems to think these could be tensors after a torch update no idea why, or if this is possible 

205 module_with_hook = cast(HookPoint, hook_point_module) 

206 module_with_hook.add_hook(hook, dir=dir, level=self.context_level) 

207 

208 def _enable_hooks_for_points( 

209 self, 

210 hook_points: Iterable[tuple[str, HookPoint]], 

211 enabled: Callable, 

212 hook: Callable, 

213 dir: Literal["fwd", "bwd"], 

214 ): 

215 """Enables hooks for a list of points 

216 

217 Args: 

218 hook_points (Dict[str, HookPoint]): The hook points 

219 enabled (Callable): _description_ 

220 hook (Callable): _description_ 

221 dir (Literal["fwd", "bwd"]): _description_ 

222 """ 

223 for hook_name, hook_point in hook_points: 

224 if enabled(hook_name): 

225 hook_point.add_hook(hook, dir=dir, level=self.context_level) 

226 

227 def _enable_hook(self, name: Union[str, Callable], hook: Callable, dir: Literal["fwd", "bwd"]): 

228 """Enables an individual hook on a hook point 

229 

230 Args: 

231 name (str): The name of the hook 

232 hook (Callable): The actual hook 

233 dir (Literal["fwd", "bwd"], optional): The direction of the hook. Defaults to "fwd". 

234 """ 

235 if isinstance(name, str): 

236 self._enable_hook_with_name(name=name, hook=hook, dir=dir) 

237 else: 

238 self._enable_hooks_for_points( 

239 hook_points=self.hook_dict.items(), enabled=name, hook=hook, dir=dir 

240 ) 

241 

242 @contextmanager 

243 def hooks( 

244 self, 

245 fwd_hooks: list[tuple[Union[str, Callable], Callable]] = [], 

246 bwd_hooks: list[tuple[Union[str, Callable], Callable]] = [], 

247 reset_hooks_end: bool = True, 

248 clear_contexts: bool = False, 

249 ): 

250 """ 

251 A context manager for adding temporary hooks to the model. 

252 

253 Args: 

254 fwd_hooks: List[Tuple[name, hook]], where name is either the name of a hook point or a 

255 Boolean function on hook names and hook is the function to add to that hook point. 

256 bwd_hooks: Same as fwd_hooks, but for the backward pass. 

257 reset_hooks_end (bool): If True, removes all hooks added by this context manager when the context manager exits. 

258 clear_contexts (bool): If True, clears hook contexts whenever hooks are reset. 

259 

260 Example: 

261 

262 .. code-block:: python 

263 

264 with model.hooks(fwd_hooks=my_hooks): 

265 hooked_loss = model(text, return_type="loss") 

266 """ 

267 try: 

268 self.context_level += 1 

269 

270 for name, hook in fwd_hooks: 

271 self._enable_hook(name=name, hook=hook, dir="fwd") 

272 for name, hook in bwd_hooks: 

273 self._enable_hook(name=name, hook=hook, dir="bwd") 

274 yield self 

275 finally: 

276 if reset_hooks_end: 

277 self.reset_hooks( 

278 clear_contexts, including_permanent=False, level=self.context_level 

279 ) 

280 self.context_level -= 1 

281 

282 def run_with_hooks( 

283 self, 

284 *model_args: Any, # TODO: unsure about whether or not this Any typing is correct or not; may need to be replaced with something more specific? 

285 fwd_hooks: list[tuple[Union[str, Callable], Callable]] = [], 

286 bwd_hooks: list[tuple[Union[str, Callable], Callable]] = [], 

287 reset_hooks_end: bool = True, 

288 clear_contexts: bool = False, 

289 **model_kwargs: Any, 

290 ): 

291 """ 

292 Runs the model with specified forward and backward hooks. 

293 

294 Args: 

295 fwd_hooks (List[Tuple[Union[str, Callable], Callable]]): A list of (name, hook), where name is 

296 either the name of a hook point or a boolean function on hook names, and hook is the 

297 function to add to that hook point. Hooks with names that evaluate to True are added 

298 respectively. 

299 bwd_hooks (List[Tuple[Union[str, Callable], Callable]]): Same as fwd_hooks, but for the 

300 backward pass. 

301 reset_hooks_end (bool): If True, all hooks are removed at the end, including those added 

302 during this run. Default is True. 

303 clear_contexts (bool): If True, clears hook contexts whenever hooks are reset. Default is 

304 False. 

305 *model_args: Positional arguments for the model. 

306 **model_kwargs: Keyword arguments for the model's forward function. See your related 

307 models forward pass for details as to what sort of arguments you can pass through. 

308 

309 Note: 

310 If you want to use backward hooks, set `reset_hooks_end` to False, so the backward hooks 

311 remain active. This function only runs a forward pass. 

312 """ 

313 if len(bwd_hooks) > 0 and reset_hooks_end: 

314 logging.warning( 

315 "WARNING: Hooks will be reset at the end of run_with_hooks. This removes the backward hooks before a backward pass can occur." 

316 ) 

317 

318 with self.hooks(fwd_hooks, bwd_hooks, reset_hooks_end, clear_contexts) as hooked_model: 

319 return hooked_model.forward(*model_args, **model_kwargs) 

320 

321 def add_caching_hooks( 

322 self, 

323 names_filter: NamesFilter = None, 

324 incl_bwd: bool = False, 

325 device: DeviceType = None, # TODO: unsure about whether or not this device typing is correct or not? 

326 remove_batch_dim: bool = False, 

327 cache: Optional[dict] = None, 

328 ) -> dict: 

329 """Adds hooks to the model to cache activations. Note: It does NOT actually run the model to get activations, that must be done separately. 

330 

331 Args: 

332 names_filter (NamesFilter, optional): Which activations to cache. Can be a list of strings (hook names) or a filter function mapping hook names to booleans. Defaults to lambda name: True. 

333 incl_bwd (bool, optional): Whether to also do backwards hooks. Defaults to False. 

334 device (_type_, optional): The device to store on. Defaults to same device as model. 

335 remove_batch_dim (bool, optional): Whether to remove the batch dimension (only works for batch_size==1). Defaults to False. 

336 cache (Optional[dict], optional): The cache to store activations in, a new dict is created by default. Defaults to None. 

337 

338 Returns: 

339 cache (dict): The cache where activations will be stored. 

340 """ 

341 if device is not None: 

342 warn_if_mps(device) 

343 if cache is None: 

344 cache = {} 

345 

346 if names_filter is None: 

347 names_filter = lambda name: True 

348 elif isinstance(names_filter, str): 

349 filter_str = names_filter 

350 names_filter = lambda name: name == filter_str 

351 elif isinstance(names_filter, list): 

352 filter_list = names_filter 

353 names_filter = lambda name: name in filter_list 

354 

355 assert callable(names_filter), "names_filter must be a callable" 

356 

357 self.is_caching = True 

358 

359 def save_hook(tensor: Tensor, hook: HookPoint, is_backward: bool): 

360 assert hook.name is not None 

361 hook_name = hook.name 

362 if is_backward: 

363 hook_name += "_grad" 

364 if remove_batch_dim: 

365 cache[hook_name] = tensor.detach().to(device)[0] 

366 else: 

367 cache[hook_name] = tensor.detach().to(device) 

368 

369 for name, hp in self.hook_dict.items(): 

370 if names_filter(name): 

371 hp.add_hook(partial(save_hook, is_backward=False), "fwd") 

372 if incl_bwd: 

373 hp.add_hook(partial(save_hook, is_backward=True), "bwd") 

374 return cache 

375 

376 def run_with_cache( 

377 self, 

378 *model_args: Any, 

379 names_filter: NamesFilter = None, 

380 device: DeviceType = None, 

381 remove_batch_dim: bool = False, 

382 incl_bwd: bool = False, 

383 reset_hooks_end: bool = True, 

384 clear_contexts: bool = False, 

385 pos_slice: Optional[Union[Slice, SliceInput]] = None, 

386 **model_kwargs: Any, 

387 ): 

388 """ 

389 Runs the model and returns the model output and a Cache object. 

390 

391 Args: 

392 *model_args: Positional arguments for the model. 

393 names_filter (NamesFilter, optional): A filter for which activations to cache. Accepts None, str, 

394 list of str, or a function that takes a string and returns a bool. Defaults to None, which 

395 means cache everything. 

396 device (str or torch.Device, optional): The device to cache activations on. Defaults to the 

397 model device. WARNING: Setting a different device than the one used by the model leads to 

398 significant performance degradation. 

399 remove_batch_dim (bool, optional): If True, removes the batch dimension when caching. Only 

400 makes sense with batch_size=1 inputs. Defaults to False. 

401 incl_bwd (bool, optional): If True, calls backward on the model output and caches gradients 

402 as well. Assumes that the model outputs a scalar (e.g., return_type="loss"). Custom loss 

403 functions are not supported. Defaults to False. 

404 reset_hooks_end (bool, optional): If True, removes all hooks added by this function at the 

405 end of the run. Defaults to True. 

406 clear_contexts (bool, optional): If True, clears hook contexts whenever hooks are reset. 

407 Defaults to False. 

408 pos_slice: 

409 The slice to apply to the cache output. Defaults to None, do nothing. 

410 **model_kwargs: Keyword arguments for the model's forward function. See your related 

411 models forward pass for details as to what sort of arguments you can pass through. 

412 

413 Returns: 

414 tuple: A tuple containing the model output and a Cache object. 

415 

416 """ 

417 

418 pos_slice = Slice.unwrap(pos_slice) 

419 

420 cache_dict, fwd, bwd = self.get_caching_hooks( 

421 names_filter, 

422 incl_bwd, 

423 device, 

424 remove_batch_dim=remove_batch_dim, 

425 pos_slice=pos_slice, 

426 ) 

427 

428 with self.hooks( 

429 fwd_hooks=fwd, 

430 bwd_hooks=bwd, 

431 reset_hooks_end=reset_hooks_end, 

432 clear_contexts=clear_contexts, 

433 ): 

434 model_out = self(*model_args, **model_kwargs) 

435 if incl_bwd: 

436 model_out.backward() 

437 

438 return model_out, cache_dict 

439 

440 def get_caching_hooks( 

441 self, 

442 names_filter: NamesFilter = None, 

443 incl_bwd: bool = False, 

444 device: DeviceType = None, 

445 remove_batch_dim: bool = False, 

446 cache: Optional[dict] = None, 

447 pos_slice: Optional[Union[Slice, SliceInput]] = None, 

448 ) -> tuple[dict, list, list]: 

449 """Creates hooks to cache activations. Note: It does not add the hooks to the model. 

450 

451 Args: 

452 names_filter (NamesFilter, optional): Which activations to cache. Can be a list of strings (hook names) or a filter function mapping hook names to booleans. Defaults to lambda name: True. 

453 incl_bwd (bool, optional): Whether to also do backwards hooks. Defaults to False. 

454 device (_type_, optional): The device to store on. Keeps on the same device as the layer if None. 

455 remove_batch_dim (bool, optional): Whether to remove the batch dimension (only works for batch_size==1). Defaults to False. 

456 cache (Optional[dict], optional): The cache to store activations in, a new dict is created by default. Defaults to None. 

457 

458 Returns: 

459 cache (dict): The cache where activations will be stored. 

460 fwd_hooks (list): The forward hooks. 

461 bwd_hooks (list): The backward hooks. Empty if incl_bwd is False. 

462 """ 

463 if device is not None: 

464 warn_if_mps(device) 

465 if cache is None: 

466 cache = {} 

467 

468 pos_slice = Slice.unwrap(pos_slice) 

469 

470 if names_filter is None: 

471 names_filter = lambda name: True 

472 elif isinstance(names_filter, str): 

473 filter_str = names_filter 

474 names_filter = lambda name: name == filter_str 

475 elif isinstance(names_filter, list): 

476 filter_list = names_filter 

477 names_filter = lambda name: name in filter_list 

478 elif callable(names_filter): 

479 names_filter = names_filter 

480 else: 

481 raise ValueError("names_filter must be a string, list of strings, or function") 

482 assert callable(names_filter) # Callable[[str], bool] 

483 

484 self.is_caching = True 

485 

486 def save_hook(tensor: Tensor, hook: HookPoint, is_backward: bool = False): 

487 # for attention heads the pos dimension is the third from last 

488 if hook.name is None: 

489 raise RuntimeError("Hook should have been provided a name") 

490 

491 hook_name = hook.name 

492 if is_backward: 

493 hook_name += "_grad" 

494 resid_stream = tensor.detach().to(device) 

495 if remove_batch_dim: 

496 resid_stream = resid_stream[0] 

497 

498 if ( 

499 hook.name.endswith("hook_q") 

500 or hook.name.endswith("hook_k") 

501 or hook.name.endswith("hook_v") 

502 or hook.name.endswith("hook_z") 

503 or hook.name.endswith("hook_result") 

504 ): 

505 pos_dim = -3 

506 else: 

507 # for all other components the pos dimension is the second from last 

508 # including the attn scores where the dest token is the second from last 

509 pos_dim = -2 

510 

511 if ( 

512 tensor.dim() >= -pos_dim 

513 ): # check if the residual stream has a pos dimension before trying to slice 

514 resid_stream = pos_slice.apply(resid_stream, dim=pos_dim) 

515 cache[hook_name] = resid_stream 

516 

517 fwd_hooks = [] 

518 bwd_hooks = [] 

519 for name, _ in self.hook_dict.items(): 

520 if names_filter(name): 

521 fwd_hooks.append((name, partial(save_hook, is_backward=False))) 

522 if incl_bwd: 

523 bwd_hooks.append((name, partial(save_hook, is_backward=True))) 

524 

525 return cache, fwd_hooks, bwd_hooks 

526 

527 def cache_all( 

528 self, 

529 cache: Optional[dict], 

530 incl_bwd: bool = False, 

531 device: DeviceType = None, 

532 remove_batch_dim: bool = False, 

533 ): 

534 logging.warning( 

535 "cache_all is deprecated and will eventually be removed, use add_caching_hooks or run_with_cache" 

536 ) 

537 self.add_caching_hooks( 

538 names_filter=lambda name: True, 

539 cache=cache, 

540 incl_bwd=incl_bwd, 

541 device=device, 

542 remove_batch_dim=remove_batch_dim, 

543 ) 

544 

545 def cache_some( 

546 self, 

547 cache: Optional[dict], 

548 names: Callable[[str], bool], 

549 incl_bwd: bool = False, 

550 device: DeviceType = None, 

551 remove_batch_dim: bool = False, 

552 ): 

553 """Cache a list of hook provided by names, Boolean function on names""" 

554 logging.warning( 

555 "cache_some is deprecated and will eventually be removed, use add_caching_hooks or run_with_cache" 

556 ) 

557 self.add_caching_hooks( 

558 names_filter=names, 

559 cache=cache, 

560 incl_bwd=incl_bwd, 

561 device=device, 

562 remove_batch_dim=remove_batch_dim, 

563 )