Coverage for transformer_lens/tools/analysis/projection_kernel.py: 89%

324 statements  

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

1"""Projection Kernel utilities for comparing linear subspaces. 

2 

3The Projection Kernel (PK) between subspaces with orthonormal bases ``U`` and 

4``V`` is ``||U.T @ V||_F^2``. It is invariant to basis choices within either 

5subspace and equals the sum of squared principal-angle cosines. 

6""" 

7 

8from __future__ import annotations 

9 

10import math 

11from dataclasses import dataclass 

12from numbers import Real 

13from typing import Any, List, Literal, Optional, Sequence, Tuple, cast 

14 

15import torch 

16from jaxtyping import Bool, Float, Int 

17 

18from transformer_lens.tools.analysis._model_state import compute_dtype, require_int 

19from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( 

20 _linalg_on_cpu_if_mps, 

21) 

22 

23AttentionRole = Literal["Q", "K", "V", "O"] 

24LayerOrder = Literal["forward", "all"] 

25HeadKind = Literal["query", "kv"] 

26 

27_PAIRWISE_TEMP_BYTES = 64 * 1024 * 1024 

28 

29 

30@dataclass(frozen=True) 

31class SubspaceBasis: 

32 """An explicitly ranked orthonormal basis extracted from a matrix. 

33 

34 Attributes: 

35 basis: Orthonormal column-space basis, ``[ambient_dim, rank]``. 

36 singular_values: All reduced-SVD singular values, in descending order. 

37 rank: Number of retained basis directions. 

38 measured_rank: Numerical rank before optional caller truncation. 

39 rtol: Effective relative rank tolerance. 

40 threshold: Absolute singular-value threshold used for rank measurement. 

41 input_shape: Shape of the matrix from which the basis was extracted. 

42 """ 

43 

44 basis: Float[torch.Tensor, "ambient rank"] 

45 singular_values: Float[torch.Tensor, "spectrum"] 

46 rank: int 

47 measured_rank: int 

48 rtol: float 

49 threshold: float 

50 input_shape: Tuple[int, int] 

51 

52 @property 

53 def ambient_dim(self) -> int: 

54 """Dimension of the space containing the subspace.""" 

55 return self.basis.shape[0] 

56 

57 

58@dataclass(frozen=True) 

59class ProjectionKernelResult: 

60 """Projection Kernel score and its principal-angle decomposition.""" 

61 

62 score: Float[torch.Tensor, ""] 

63 normalized: Float[torch.Tensor, ""] 

64 cosines: Float[torch.Tensor, "principal_angle"] 

65 angles: Float[torch.Tensor, "principal_angle"] 

66 rank_a: int 

67 rank_b: int 

68 ambient_dim: int 

69 

70 

71@dataclass(frozen=True) 

72class RandomSubspaceReference: 

73 """Analytic PK moments for independent random equal-rank subspaces.""" 

74 

75 ambient_dim: int 

76 rank: int 

77 mean: float 

78 variance: float 

79 

80 

81@dataclass(frozen=True) 

82class AttentionHeadRef: 

83 """Structured identity for one attention-head weight subspace.""" 

84 

85 layer: int 

86 head: int 

87 role: AttentionRole 

88 kind: HeadKind 

89 

90 @property 

91 def label(self) -> str: 

92 """Return the conventional TransformerLens layer/head label.""" 

93 return f"L{self.layer}H{self.head}" 

94 

95 

96@dataclass(frozen=True) 

97class HeadAffinityPair: 

98 """One ranked source-target head pair.""" 

99 

100 source: AttentionHeadRef 

101 target: AttentionHeadRef 

102 score: float 

103 normalized: float 

104 

105 

106@dataclass(frozen=True) 

107class HeadAffinityResult: 

108 """Projection Kernel affinities between two attention-head roles. 

109 

110 Score tensors have shape 

111 ``[source_layer, source_head, target_layer, target_head]``. Layer index 

112 tuples map tensor positions to original model block numbers. 

113 

114 ``source_ranks`` and ``target_ranks`` are measured numerical ranks for each 

115 head before optional truncation. Scalar ``source_rank`` and ``target_rank`` 

116 are the retained basis widths used for their respective roles. 

117 """ 

118 

119 scores: Float[torch.Tensor, "source_layer source_head target_layer target_head"] 

120 normalized: Float[torch.Tensor, "source_layer source_head target_layer target_head"] 

121 valid_mask: Bool[torch.Tensor, "source_layer source_head target_layer target_head"] 

122 source_role: AttentionRole 

123 target_role: AttentionRole 

124 source_layer_indices: Tuple[int, ...] 

125 target_layer_indices: Tuple[int, ...] 

126 source_head_kind: HeadKind 

127 target_head_kind: HeadKind 

128 source_ranks: Int[torch.Tensor, "source_layer source_head"] 

129 target_ranks: Int[torch.Tensor, "target_layer target_head"] 

130 source_rank: int 

131 target_rank: int 

132 rank: Optional[int] 

133 rtol: float 

134 

135 def top_pairs(self, k: int = 20, *, normalized: bool = False) -> List[HeadAffinityPair]: 

136 """Return the highest-scoring valid pairs with deterministic tie order.""" 

137 k = require_int(k, "k") 

138 

139 selected_scores = self.normalized if normalized else self.scores 

140 entries: List[HeadAffinityPair] = [] 

141 for source_layer, source_head, target_layer, target_head in ( 141 ↛ 167line 141 didn't jump to line 167 because the loop on line 141 didn't complete

142 torch.nonzero(self.valid_mask, as_tuple=False).cpu().tolist() 

143 ): 

144 source = AttentionHeadRef( 

145 layer=self.source_layer_indices[source_layer], 

146 head=source_head, 

147 role=self.source_role, 

148 kind=self.source_head_kind, 

149 ) 

150 target = AttentionHeadRef( 

151 layer=self.target_layer_indices[target_layer], 

152 head=target_head, 

153 role=self.target_role, 

154 kind=self.target_head_kind, 

155 ) 

156 entries.append( 

157 HeadAffinityPair( 

158 source=source, 

159 target=target, 

160 score=float(self.scores[source_layer, source_head, target_layer, target_head]), 

161 normalized=float( 

162 self.normalized[source_layer, source_head, target_layer, target_head] 

163 ), 

164 ) 

165 ) 

166 

167 def sort_key(pair: HeadAffinityPair) -> Tuple[float, int, int, int, int]: 

168 value = pair.normalized if normalized else pair.score 

169 return ( 

170 -value, 

171 pair.source.layer, 

172 pair.source.head, 

173 pair.target.layer, 

174 pair.target.head, 

175 ) 

176 

177 entries.sort(key=sort_key) 

178 return entries[:k] 

179 

180 

181def _validate_rtol(rtol: Optional[float], shape: Tuple[int, int], dtype: torch.dtype) -> float: 

182 if rtol is None: 

183 return max(shape) * torch.finfo(compute_dtype(dtype)).eps 

184 if isinstance(rtol, bool) or not isinstance(rtol, Real): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise ValueError(f"rtol must be a finite non-negative real number, got {rtol!r}") 

186 value = float(rtol) 

187 if not math.isfinite(value) or value < 0: 

188 raise ValueError(f"rtol must be a finite non-negative real number, got {rtol!r}") 

189 return value 

190 

191 

192def _validate_rank(rank: Optional[int], max_rank: int) -> Optional[int]: 

193 if rank is None: 

194 return None 

195 return require_int(rank, "rank", maximum=max_rank) 

196 

197 

198def orthonormal_subspace( 

199 matrix: Float[torch.Tensor, "ambient width"], 

200 *, 

201 rank: Optional[int] = None, 

202 rtol: Optional[float] = None, 

203) -> SubspaceBasis: 

204 """Extract an explicitly ranked orthonormal column-space basis. 

205 

206 Low-precision inputs are promoted to float32 before the reduced SVD. With no 

207 explicit ``rtol``, numerical rank uses the compute-SVD error scale relative 

208 to the largest singular value, so the default does not depend on the storage 

209 dtype — e.g. a 4096-row half-precision input gets ``4096 * float32_eps`` 

210 (about ``4.9e-4``), tight enough to flag an exactly repeated column as 

211 rank-deficient. An explicit ``rank`` truncates the measured subspace but may 

212 not exceed its measured rank. 

213 

214 Args: 

215 matrix: Finite floating-point matrix with shape ``[ambient_dim, width]``. 

216 rank: Optional number of leading singular directions to retain. 

217 rtol: Optional non-negative relative singular-value threshold. 

218 

219 Returns: 

220 Basis, complete singular spectrum, and rank metadata. 

221 

222 Raises: 

223 ValueError: If the matrix or rank policy is invalid. 

224 """ 

225 if not isinstance(matrix, torch.Tensor): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 raise ValueError(f"matrix must be a torch.Tensor, got {type(matrix).__name__}") 

227 if matrix.ndim != 2: 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true

228 raise ValueError(f"matrix must be two-dimensional, got shape {tuple(matrix.shape)}") 

229 if matrix.shape[0] == 0 or matrix.shape[1] == 0: 

230 raise ValueError(f"matrix dimensions must be non-empty, got shape {tuple(matrix.shape)}") 

231 if not torch.is_floating_point(matrix): 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 raise ValueError(f"matrix must have a real floating-point dtype, got {matrix.dtype}") 

233 if not bool(torch.isfinite(matrix).all()): 

234 raise ValueError("matrix must contain only finite values") 

235 

236 input_shape = (matrix.shape[0], matrix.shape[1]) 

237 requested_rank = _validate_rank(rank, min(input_shape)) 

238 effective_rtol = _validate_rtol(rtol, input_shape, matrix.dtype) 

239 work = matrix.to(dtype=compute_dtype(matrix.dtype)) 

240 left, singular_values, _ = torch.linalg.svd(work, full_matrices=False) 

241 threshold = float(singular_values[0].item()) * effective_rtol 

242 measured_rank = int((singular_values > threshold).sum().item()) 

243 if measured_rank == 0: 

244 raise ValueError( 

245 "matrix numerical rank is zero " 

246 f"for shape {input_shape}, dtype {matrix.dtype}, and threshold {threshold:.6g}" 

247 ) 

248 if requested_rank is not None and requested_rank > measured_rank: 

249 raise ValueError( 

250 f"requested rank {requested_rank} exceeds measured rank {measured_rank} " 

251 f"at threshold {threshold:.6g}" 

252 ) 

253 

254 selected_rank = measured_rank if requested_rank is None else requested_rank 

255 return SubspaceBasis( 

256 basis=left[:, :selected_rank], 

257 singular_values=singular_values, 

258 rank=selected_rank, 

259 measured_rank=measured_rank, 

260 rtol=effective_rtol, 

261 threshold=threshold, 

262 input_shape=input_shape, 

263 ) 

264 

265 

266def _validate_subspace(value: SubspaceBasis, name: str) -> None: 

267 if not isinstance(value, SubspaceBasis): 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 raise ValueError(f"{name} must be a SubspaceBasis, got {type(value).__name__}") 

269 basis = value.basis 

270 if not isinstance(basis, torch.Tensor) or basis.ndim != 2: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 raise ValueError(f"{name}.basis must be a two-dimensional tensor") 

272 if not torch.is_floating_point(basis) or not bool(torch.isfinite(basis).all()): 

273 raise ValueError(f"{name}.basis must be finite and real floating-point") 

274 singular_values = value.singular_values 

275 if ( 

276 not isinstance(singular_values, torch.Tensor) 

277 or singular_values.ndim != 1 

278 or not torch.is_floating_point(singular_values) 

279 or not bool(torch.isfinite(singular_values).all()) 

280 ): 

281 raise ValueError(f"{name}.singular_values must be a finite floating-point vector") 

282 if singular_values.device != basis.device: 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true

283 raise ValueError(f"{name}.singular_values must be on the basis device") 

284 if not isinstance(value.input_shape, tuple) or len(value.input_shape) != 2: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 raise ValueError(f"{name}.input_shape must contain two positive dimensions") 

286 for dimension in value.input_shape: 

287 require_int(dimension, f"{name}.input_shape dimension") 

288 if singular_values.shape[0] != min(value.input_shape): 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 raise ValueError(f"{name}.singular_values length must equal min(input_shape)") 

290 if require_int(value.rank, f"{name}.rank") != basis.shape[1]: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 raise ValueError(f"{name}.rank must equal its positive basis width") 

292 require_int( 

293 value.measured_rank, 

294 f"{name}.measured_rank", 

295 minimum=value.rank, 

296 maximum=min(value.input_shape), 

297 ) 

298 if value.input_shape[0] != basis.shape[0]: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 raise ValueError(f"{name}.input_shape must share its basis ambient dimension") 

300 if ( 300 ↛ 306line 300 didn't jump to line 306 because the condition on line 300 was never true

301 isinstance(value.rtol, bool) 

302 or not isinstance(value.rtol, Real) 

303 or not math.isfinite(value.rtol) 

304 or value.rtol < 0 

305 ): 

306 raise ValueError(f"{name}.rtol must be finite and non-negative") 

307 if ( 307 ↛ 313line 307 didn't jump to line 313 because the condition on line 307 was never true

308 isinstance(value.threshold, bool) 

309 or not isinstance(value.threshold, Real) 

310 or not math.isfinite(value.threshold) 

311 or value.threshold < 0 

312 ): 

313 raise ValueError(f"{name}.threshold must be finite and non-negative") 

314 

315 

316def _clamp_projection_scores(scores: torch.Tensor, upper_bound: int) -> torch.Tensor: 

317 """Clamp roundoff-scale PK bound violations and reject larger violations.""" 

318 tolerance = 100 * torch.finfo(scores.dtype).eps * max(1, upper_bound) 

319 minimum = float(scores.detach().min().item()) 

320 maximum = float(scores.detach().max().item()) 

321 if minimum < -tolerance or maximum > upper_bound + tolerance: 

322 raise ValueError( 

323 "Projection Kernel score lies outside its theoretical bounds: " 

324 f"observed [{minimum:.6g}, {maximum:.6g}], expected [0, {upper_bound}] " 

325 f"within tolerance {tolerance:.6g}" 

326 ) 

327 return scores.clamp(min=0.0, max=float(upper_bound)) 

328 

329 

330def _clamp_principal_cosines(cosines: torch.Tensor) -> torch.Tensor: 

331 """Clamp roundoff-scale cosine violations and reject larger violations.""" 

332 tolerance = 100 * torch.finfo(cosines.dtype).eps * max(1, cosines.numel()) 

333 minimum = float(cosines.detach().min().item()) 

334 maximum = float(cosines.detach().max().item()) 

335 if minimum < -tolerance or maximum > 1 + tolerance: 

336 raise ValueError( 

337 "Principal-angle cosine lies outside its theoretical bounds: " 

338 f"observed [{minimum:.6g}, {maximum:.6g}], expected [0, 1] " 

339 f"within tolerance {tolerance:.6g}" 

340 ) 

341 return cosines.clamp(min=0.0, max=1.0) 

342 

343 

344def _singular_values(matrix: torch.Tensor) -> torch.Tensor: 

345 """Compute singular values with an explicit MPS CPU fallback.""" 

346 return _linalg_on_cpu_if_mps(torch.linalg.svdvals, matrix) 

347 

348 

349def projection_kernel( 

350 subspace_a: SubspaceBasis, 

351 subspace_b: SubspaceBasis, 

352 *, 

353 check_orthonormal: bool = True, 

354) -> ProjectionKernelResult: 

355 """Measure overlap between two explicitly extracted subspaces. 

356 

357 Raw PK lies in ``[0, min(rank_a, rank_b)]``. The normalized value is 

358 ``PK / sqrt(rank_a * rank_b)``, the cosine between the two projection 

359 matrices. Principal angles are returned in radians. 

360 """ 

361 if not isinstance(check_orthonormal, bool): 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true

362 raise ValueError(f"check_orthonormal must be a Boolean, got {check_orthonormal!r}") 

363 _validate_subspace(subspace_a, "subspace_a") 

364 _validate_subspace(subspace_b, "subspace_b") 

365 if subspace_a.ambient_dim != subspace_b.ambient_dim: 

366 raise ValueError( 

367 "subspaces must have equal ambient dimensions, got " 

368 f"{subspace_a.ambient_dim} and {subspace_b.ambient_dim}" 

369 ) 

370 if subspace_a.basis.device != subspace_b.basis.device: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 raise ValueError( 

372 "subspace bases must be on the same device, got " 

373 f"{subspace_a.basis.device} and {subspace_b.basis.device}" 

374 ) 

375 

376 dtype = torch.promote_types(subspace_a.basis.dtype, subspace_b.basis.dtype) 

377 dtype = compute_dtype(dtype) 

378 first = subspace_a.basis.to(dtype=dtype) 

379 second = subspace_b.basis.to(dtype=dtype) 

380 if check_orthonormal: 

381 eps = torch.finfo(dtype).eps 

382 tolerance = 10 * max(first.shape[0], first.shape[1], second.shape[1]) * eps 

383 first_identity = torch.eye(first.shape[1], dtype=dtype, device=first.device) 

384 second_identity = torch.eye(second.shape[1], dtype=dtype, device=second.device) 

385 if not torch.allclose(first.T @ first, first_identity, rtol=tolerance, atol=tolerance): 

386 raise ValueError("subspace_a basis columns must be orthonormal") 

387 if not torch.allclose(second.T @ second, second_identity, rtol=tolerance, atol=tolerance): 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true

388 raise ValueError("subspace_b basis columns must be orthonormal") 

389 

390 overlap = first.T @ second 

391 score = _clamp_projection_scores(overlap.square().sum(), min(subspace_a.rank, subspace_b.rank)) 

392 cosines = _clamp_principal_cosines(_singular_values(overlap)) 

393 angles = torch.acos(cosines) 

394 denominator = math.sqrt(subspace_a.rank * subspace_b.rank) 

395 return ProjectionKernelResult( 

396 score=score, 

397 normalized=score / denominator, 

398 cosines=cosines, 

399 angles=angles, 

400 rank_a=subspace_a.rank, 

401 rank_b=subspace_b.rank, 

402 ambient_dim=subspace_a.ambient_dim, 

403 ) 

404 

405 

406def random_projection_kernel_moments(ambient_dim: int, rank: int) -> RandomSubspaceReference: 

407 """Return PK moments for independent Haar-distributed rank-``rank`` planes. 

408 

409 These idealized descriptive moments are not calibrated p-values for trained 

410 model weights, whose head subspaces are dependent and anisotropic. 

411 """ 

412 ambient_dim = require_int(ambient_dim, "ambient_dim", minimum=2) 

413 rank = require_int(rank, "rank", maximum=ambient_dim) 

414 

415 mean = rank**2 / ambient_dim 

416 variance = ( 

417 2 

418 * rank**2 

419 * (ambient_dim - rank) ** 2 

420 / (ambient_dim**2 * (ambient_dim - 1) * (ambient_dim + 2)) 

421 ) 

422 return RandomSubspaceReference( 

423 ambient_dim=ambient_dim, 

424 rank=rank, 

425 mean=float(mean), 

426 variance=float(variance), 

427 ) 

428 

429 

430def _pairwise_projection_kernel( 

431 source_bases: torch.Tensor, 

432 target_bases: torch.Tensor, 

433 *, 

434 max_temp_bytes: int = _PAIRWISE_TEMP_BYTES, 

435) -> torch.Tensor: 

436 """Compute pairwise PK scores while bounding the overlap-tensor allocation.""" 

437 if source_bases.ndim != 3 or target_bases.ndim != 3: 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

438 raise ValueError("source_bases and target_bases must be three-dimensional") 

439 if source_bases.shape[1] != target_bases.shape[1]: 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true

440 raise ValueError("source_bases and target_bases must share an ambient dimension") 

441 if source_bases.device != target_bases.device or source_bases.dtype != target_bases.dtype: 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true

442 raise ValueError("source_bases and target_bases must share a device and dtype") 

443 max_temp_bytes = require_int(max_temp_bytes, "max_temp_bytes") 

444 

445 source_count, _, source_rank = source_bases.shape 

446 target_count, _, target_rank = target_bases.shape 

447 scores = torch.empty( 

448 source_count, target_count, dtype=source_bases.dtype, device=source_bases.device 

449 ) 

450 bytes_per_overlap = source_rank * target_rank * source_bases.element_size() 

451 pair_capacity = max(1, max_temp_bytes // bytes_per_overlap) 

452 source_tile = max(1, min(source_count, pair_capacity // max(1, target_count))) 

453 target_tile = max(1, min(target_count, pair_capacity // source_tile)) 

454 

455 for source_start in range(0, source_count, source_tile): 

456 source_stop = min(source_start + source_tile, source_count) 

457 for target_start in range(0, target_count, target_tile): 

458 target_stop = min(target_start + target_tile, target_count) 

459 overlap = torch.einsum( 

460 "adr,bds->abrs", 

461 source_bases[source_start:source_stop], 

462 target_bases[target_start:target_stop], 

463 ) 

464 scores[source_start:source_stop, target_start:target_stop] = overlap.square().sum( 

465 dim=(-2, -1) 

466 ) 

467 return scores 

468 

469 

470def _architecture_name(model: Any) -> str: 

471 cfg = getattr(model, "cfg", None) 

472 architecture = getattr(cfg, "original_architecture", None) 

473 return str(architecture) if architecture is not None else type(model).__name__ 

474 

475 

476def _read_role_matrix(model: Any, block: Any, layer: int, role: AttentionRole) -> torch.Tensor: 

477 attn = getattr(block, "attn", None) 

478 if attn is None: 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 raise ValueError(f"attention block {layer} does not expose an attn component") 

480 attribute = f"W_{role}" 

481 try: 

482 matrix = getattr(attn, attribute) 

483 except NotImplementedError as error: 

484 raise NotImplementedError( 

485 f"{_architecture_name(model)} cannot expose role {role} at layer {layer}: {error}" 

486 ) from error 

487 except (AttributeError, RuntimeError, ValueError) as error: 

488 raise ValueError( 

489 f"{_architecture_name(model)} cannot expose role {role} at layer {layer}: {error}" 

490 ) from error 

491 if not isinstance(matrix, torch.Tensor): 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true

492 raise ValueError( 

493 f"role {role} at layer {layer} must be a tensor, got {type(matrix).__name__}" 

494 ) 

495 if matrix.ndim != 3: 

496 raise ValueError( 

497 f"role {role} at layer {layer} expected a three-dimensional per-head weight, " 

498 f"got shape {tuple(matrix.shape)}" 

499 ) 

500 if role == "O": 

501 matrix = matrix.transpose(-2, -1) 

502 if not torch.is_floating_point(matrix): 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true

503 raise ValueError(f"role {role} at layer {layer} must have a floating-point dtype") 

504 if not bool(torch.isfinite(matrix).all()): 

505 raise ValueError(f"role {role} at layer {layer} must contain only finite values") 

506 return matrix.detach() 

507 

508 

509def _validate_role_shapes( 

510 matrices: Sequence[torch.Tensor], layers: Sequence[int], role: AttentionRole 

511) -> Tuple[int, int, int]: 

512 expected = (matrices[0].shape[0], matrices[0].shape[1], matrices[0].shape[2]) 

513 if min(expected) < 1: 

514 raise ValueError( 

515 f"role {role} at layer {layers[0]} must have non-empty head, d_model, and width " 

516 f"dimensions, got shape {expected}" 

517 ) 

518 for matrix, layer in zip(matrices[1:], layers[1:]): 

519 if tuple(matrix.shape) != expected: 

520 raise ValueError( 

521 f"role {role} at layer {layer} has shape {tuple(matrix.shape)}, expected " 

522 f"the consistent [heads, d_model, width] shape {expected}" 

523 ) 

524 return expected 

525 

526 

527def _extract_bases( 

528 matrices: Sequence[torch.Tensor], 

529 layers: Sequence[int], 

530 role: AttentionRole, 

531 *, 

532 selected_rank: int, 

533 rtol: float, 

534 dtype: torch.dtype, 

535 device: torch.device, 

536) -> Tuple[torch.Tensor, torch.Tensor]: 

537 layer_bases: List[torch.Tensor] = [] 

538 layer_ranks: List[List[int]] = [] 

539 for matrix, layer in zip(matrices, layers): 

540 head_bases: List[torch.Tensor] = [] 

541 head_ranks: List[int] = [] 

542 for head in range(matrix.shape[0]): 

543 try: 

544 subspace = orthonormal_subspace( 

545 matrix[head].to(dtype=dtype), rank=selected_rank, rtol=rtol 

546 ) 

547 except ValueError as error: 

548 raise ValueError( 

549 f"Could not extract role {role} at layer {layer}, head {head}: {error}" 

550 ) from error 

551 head_bases.append(subspace.basis.to(device=device)) 

552 head_ranks.append(subspace.measured_rank) 

553 layer_bases.append(torch.stack(head_bases)) 

554 layer_ranks.append(head_ranks) 

555 return torch.stack(layer_bases), torch.tensor(layer_ranks, dtype=torch.long, device=device) 

556 

557 

558def attention_head_subspace_affinity( 

559 model: Any, 

560 *, 

561 source_role: str = "O", 

562 target_role: str, 

563 layer_order: str = "forward", 

564 rank: Optional[int] = None, 

565 rtol: Optional[float] = None, 

566) -> HeadAffinityResult: 

567 """Compute OQ, OK, or OV Projection Kernel affinities for a TransformerBridge. 

568 

569 K/V axes preserve native key-value heads on grouped-query attention models; 

570 they are never expanded to query-head count. Hybrid models include only 

571 attention blocks and report their original block indices. 

572 

573 Args: 

574 model: A TransformerBridge exposing readable per-head attention weights. 

575 source_role: Source role; v1 supports only ``"O"``. 

576 target_role: One of ``"Q"``, ``"K"``, or ``"V"``. 

577 layer_order: ``"forward"`` keeps strict earlier-to-later pairs; ``"all"`` 

578 keeps every pair. 

579 rank: Optional common truncation rank. By default every head must be full 

580 column rank. 

581 rtol: Optional relative numerical-rank tolerance. 

582 

583 Returns: 

584 Affinity tensors, validity mask, original layer indices, and rank metadata. 

585 """ 

586 if source_role != "O": 

587 raise ValueError(f"source_role must be 'O' in v1, got {source_role!r}") 

588 if target_role not in ("Q", "K", "V"): 

589 raise ValueError(f"target_role must be one of ['Q', 'K', 'V'], got {target_role!r}") 

590 if layer_order not in ("forward", "all"): 

591 raise ValueError(f"layer_order must be one of ['forward', 'all'], got {layer_order!r}") 

592 validated_source_role = cast(AttentionRole, source_role) 

593 validated_target_role = cast(AttentionRole, target_role) 

594 validated_layer_order = cast(LayerOrder, layer_order) 

595 blocks_with = getattr(model, "blocks_with", None) 

596 if not callable(blocks_with): 596 ↛ 597line 596 didn't jump to line 597 because the condition on line 596 was never true

597 raise ValueError("model must be a TransformerBridge exposing blocks_with('attn')") 

598 attention_blocks = list(blocks_with("attn")) 

599 if not attention_blocks: 

600 raise ValueError("No attention layers found — cannot compute head subspace affinity.") 

601 

602 layer_indices = [int(layer) for layer, _ in attention_blocks] 

603 source_matrices = [ 

604 _read_role_matrix(model, block, layer, validated_source_role) 

605 for layer, block in attention_blocks 

606 ] 

607 target_matrices = [ 

608 _read_role_matrix(model, block, layer, validated_target_role) 

609 for layer, block in attention_blocks 

610 ] 

611 source_heads, source_ambient, source_width = _validate_role_shapes( 

612 source_matrices, layer_indices, validated_source_role 

613 ) 

614 target_heads, target_ambient, target_width = _validate_role_shapes( 

615 target_matrices, layer_indices, validated_target_role 

616 ) 

617 if source_ambient != target_ambient: 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true

618 raise ValueError( 

619 f"roles {validated_source_role} and {validated_target_role} must share d_model, got " 

620 f"{source_ambient} and {target_ambient}" 

621 ) 

622 

623 matrices = source_matrices + target_matrices 

624 dtype = matrices[0].dtype 

625 for matrix in matrices[1:]: 

626 dtype = torch.promote_types(dtype, matrix.dtype) 

627 dtype = compute_dtype(dtype) 

628 effective_rtol = _validate_rtol(rtol, (source_ambient, max(source_width, target_width)), dtype) 

629 requested_rank = _validate_rank(rank, min(source_ambient, source_width, target_width)) 

630 source_rank = source_width if requested_rank is None else requested_rank 

631 target_rank = target_width if requested_rank is None else requested_rank 

632 

633 cfg = getattr(model, "cfg", None) 

634 configured_device = getattr(cfg, "device", None) 

635 result_device = ( 

636 torch.device(configured_device) 

637 if configured_device is not None 

638 else source_matrices[0].device 

639 ) 

640 source_bases, source_ranks = _extract_bases( 

641 source_matrices, 

642 layer_indices, 

643 validated_source_role, 

644 selected_rank=source_rank, 

645 rtol=effective_rtol, 

646 dtype=dtype, 

647 device=result_device, 

648 ) 

649 target_bases, target_ranks = _extract_bases( 

650 target_matrices, 

651 layer_indices, 

652 validated_target_role, 

653 selected_rank=target_rank, 

654 rtol=effective_rtol, 

655 dtype=dtype, 

656 device=result_device, 

657 ) 

658 

659 layer_count = len(layer_indices) 

660 flat_source = source_bases.reshape(layer_count * source_heads, source_ambient, source_rank) 

661 flat_target = target_bases.reshape(layer_count * target_heads, target_ambient, target_rank) 

662 scores = _pairwise_projection_kernel(flat_source, flat_target).reshape( 

663 layer_count, source_heads, layer_count, target_heads 

664 ) 

665 scores = _clamp_projection_scores(scores, min(source_rank, target_rank)) 

666 normalized = scores / math.sqrt(source_rank * target_rank) 

667 if validated_layer_order == "forward": 

668 layer_tensor = torch.tensor(layer_indices, device=result_device) 

669 layer_mask = layer_tensor[:, None] < layer_tensor[None, :] 

670 valid_mask = layer_mask[:, None, :, None].expand_as(scores).contiguous() 

671 else: 

672 valid_mask = torch.ones_like(scores, dtype=torch.bool) 

673 scores = torch.where(valid_mask, scores, torch.zeros_like(scores)) 

674 normalized = torch.where(valid_mask, normalized, torch.zeros_like(normalized)) 

675 

676 target_kind: HeadKind = "query" if validated_target_role == "Q" else "kv" 

677 return HeadAffinityResult( 

678 scores=scores, 

679 normalized=normalized, 

680 valid_mask=valid_mask, 

681 source_role=validated_source_role, 

682 target_role=validated_target_role, 

683 source_layer_indices=tuple(layer_indices), 

684 target_layer_indices=tuple(layer_indices), 

685 source_head_kind="query", 

686 target_head_kind=target_kind, 

687 source_ranks=source_ranks, 

688 target_ranks=target_ranks, 

689 source_rank=source_rank, 

690 target_rank=target_rank, 

691 rank=rank, 

692 rtol=effective_rtol, 

693 )