Coverage for transformer_lens/utilities/statistics.py: 89%
79 statements
« prev ^ index » next coverage.py v7.10.1, created at 2026-10-10 05:10 +0000
« prev ^ index » next coverage.py v7.10.1, created at 2026-10-10 05:10 +0000
1"""Model-free statistics over a per-example axis of an analysis result.
3Pure torch, no model access: standard errors, a vectorized percentile bootstrap
4and a paired sign-flip permutation test, plus the seeded-generator convention
5shared with sparse probing so every consumer derives independent, reproducible
6streams the same way.
7"""
9from __future__ import annotations
11import hashlib
12import itertools
13import math
14from typing import Optional, Tuple
16import torch
18_ALTERNATIVES = ("two-sided", "greater", "less")
19_STATISTICS = ("mean", "median")
22def derive_generator(seed: int, *labels: object) -> torch.Generator:
23 """CPU generator keyed on ``seed`` and any labels (metric name, arm, repeat...).
25 SHA-256 rather than ``hash`` keeps the derived seed stable across processes;
26 the mask keeps it in torch's accepted int64 range.
27 """
28 key = ":".join([str(seed), *(str(label) for label in labels)]).encode()
29 derived = int.from_bytes(hashlib.sha256(key).digest()[:8], "little") & 0x7FFFFFFFFFFFFFFF
30 return torch.Generator(device="cpu").manual_seed(derived)
33def _to_last(x: torch.Tensor, dim: int) -> torch.Tensor:
34 if not isinstance(x, torch.Tensor) or not x.is_floating_point(): 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 raise TypeError("expected a floating-point tensor")
36 if x.ndim == 0: 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 raise ValueError("expected at least one dimension to resample over")
38 return x.movedim(dim, -1)
41def standard_error(x: torch.Tensor, *, dim: int = -1) -> torch.Tensor:
42 """Standard error of the mean along ``dim`` (unbiased std over sqrt(n)); needs n >= 2."""
43 moved = _to_last(x, dim)
44 n = moved.shape[-1]
45 if n < 2:
46 raise ValueError(f"standard_error needs at least 2 samples along dim {dim}, got {n}")
47 return moved.std(dim=-1, unbiased=True) / math.sqrt(n)
50def bootstrap_ci(
51 x: torch.Tensor,
52 *,
53 dim: int = -1,
54 statistic: str = "mean",
55 confidence: float = 0.95,
56 n_resamples: int = 1000,
57 generator: Optional[torch.Generator] = None,
58 chunk_size: int = 256,
59) -> Tuple[torch.Tensor, torch.Tensor]:
60 """Percentile bootstrap interval of ``statistic`` along ``dim``, vectorized over the rest.
62 Resamples with replacement using a CPU ``generator`` (``derive_generator(0)``
63 when omitted), ``chunk_size`` resamples at a time so memory stays at
64 ``chunk_size × n`` per leading cell. Returns ``(low, high)`` with ``dim``
65 removed.
67 A percentile bootstrap cannot express uncertainty a sample does not contain:
68 a constant vector resamples to itself and the interval collapses to a point
69 whatever ``n`` is, and intervals on 4-8 samples are wide and skewed. For
70 binary success rates prefer an exact interval
71 (:func:`~transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark.success_rate_ci`).
72 """
73 if statistic not in _STATISTICS:
74 raise ValueError(f"statistic must be one of {_STATISTICS}, got {statistic!r}")
75 if not 0.0 < confidence < 1.0:
76 raise ValueError(f"confidence must be in (0, 1), got {confidence}")
77 if n_resamples < 1 or chunk_size < 1: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 raise ValueError("n_resamples and chunk_size must be >= 1")
79 moved = _to_last(x, dim)
80 n = moved.shape[-1]
81 if n < 1: 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true
82 raise ValueError("cannot bootstrap an empty axis")
83 gen = generator if generator is not None else derive_generator(0)
84 reduce = torch.mean if statistic == "mean" else lambda t, dim: t.median(dim=dim).values
85 stats = []
86 for start in range(0, n_resamples, chunk_size):
87 count = min(chunk_size, n_resamples - start)
88 idx = torch.randint(0, n, (count, n), generator=gen).to(moved.device)
89 resampled = moved[..., idx] # [..., count, n]
90 stats.append(reduce(resampled, dim=-1))
91 draws = torch.cat(stats, dim=-1).float() # [..., n_resamples]
92 alpha = (1.0 - confidence) / 2.0
93 low = torch.quantile(draws, alpha, dim=-1)
94 high = torch.quantile(draws, 1.0 - alpha, dim=-1)
95 return low.to(x.dtype), high.to(x.dtype)
98def sign_flip_permutation_pvalue(
99 effects: torch.Tensor,
100 *,
101 dim: int = -1,
102 n_permutations: int = 1000,
103 generator: Optional[torch.Generator] = None,
104 alternative: str = "two-sided",
105 exact_max_n: int = 12,
106) -> torch.Tensor:
107 """Paired sign-flip permutation p-value that the mean effect along ``dim`` is zero.
109 The null is that each per-example effect is symmetric about zero, so every
110 pattern of sign flips is equally likely. With ``n <= exact_max_n`` examples
111 all ``2**n`` patterns are enumerated and the p-value is exact and
112 deterministic; otherwise ``n_permutations`` random patterns are drawn from
113 the CPU ``generator`` and the p-value is ``(hits + 1) / (n_permutations + 1)``.
114 ``alternative`` is ``"two-sided"`` (``|null| >= |observed|``), ``"greater"``
115 or ``"less"``. Returns a tensor with ``dim`` removed.
116 """
117 if alternative not in _ALTERNATIVES:
118 raise ValueError(f"alternative must be one of {_ALTERNATIVES}, got {alternative!r}")
119 if n_permutations < 1: 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 raise ValueError("n_permutations must be >= 1")
121 moved = _to_last(effects, dim).float()
122 n = moved.shape[-1]
123 if n < 1: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 raise ValueError("cannot permute an empty axis")
125 observed = moved.mean(dim=-1, keepdim=True) # [..., 1]
126 if n <= exact_max_n:
127 signs = torch.tensor(
128 list(itertools.product((1.0, -1.0), repeat=n)), dtype=moved.dtype, device=moved.device
129 ) # [2^n, n]
130 null = (moved.unsqueeze(-2) * signs).mean(dim=-1) # [..., 2^n]
131 hits = _count_hits(null, observed, alternative)
132 return hits / signs.shape[0]
133 gen = generator if generator is not None else derive_generator(0)
134 hits_total = torch.zeros(moved.shape[:-1], dtype=moved.dtype, device=moved.device)
135 chunk = 256
136 for start in range(0, n_permutations, chunk):
137 count = min(chunk, n_permutations - start)
138 signs = (
139 torch.randint(0, 2, (count, n), generator=gen).to(moved.device).to(moved.dtype) * 2 - 1
140 )
141 null = (moved.unsqueeze(-2) * signs).mean(dim=-1)
142 hits_total = hits_total + _count_hits(null, observed, alternative)
143 return (hits_total + 1.0) / (n_permutations + 1.0)
146def _count_hits(null: torch.Tensor, observed: torch.Tensor, alternative: str) -> torch.Tensor:
147 if alternative == "two-sided":
148 hit = null.abs() >= observed.abs()
149 elif alternative == "greater":
150 hit = null >= observed
151 else:
152 hit = null <= observed
153 return hit.sum(dim=-1).to(null.dtype)