Coverage for transformer_lens/utilities/statistics.py: 89%

129 statements  

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

1"""Model-free statistics over a per-example axis of an analysis result. 

2 

3No model access: standard errors, a vectorized percentile bootstrap, a paired 

4sign-flip permutation test and an exact Clopper-Pearson interval for success 

5rates, plus the seeded-generator convention shared with sparse probing so every 

6consumer derives independent, reproducible streams the same way. 

7""" 

8 

9from __future__ import annotations 

10 

11import hashlib 

12import itertools 

13import math 

14from dataclasses import dataclass 

15from typing import Optional, Sequence, Tuple 

16 

17import torch 

18 

19_ALTERNATIVES = ("two-sided", "greater", "less") 

20_STATISTICS = ("mean", "median") 

21 

22 

23def derive_generator(seed: int, *labels: object) -> torch.Generator: 

24 """CPU generator keyed on ``seed`` and any labels (metric name, arm, repeat...). 

25 

26 SHA-256 rather than ``hash`` keeps the derived seed stable across processes; 

27 the mask keeps it in torch's accepted int64 range. 

28 """ 

29 key = ":".join([str(seed), *(str(label) for label in labels)]).encode() 

30 derived = int.from_bytes(hashlib.sha256(key).digest()[:8], "little") & 0x7FFFFFFFFFFFFFFF 

31 return torch.Generator(device="cpu").manual_seed(derived) 

32 

33 

34def _to_last(x: torch.Tensor, dim: int) -> torch.Tensor: 

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

36 raise TypeError("expected a floating-point tensor") 

37 if x.ndim == 0: 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true

38 raise ValueError("expected at least one dimension to resample over") 

39 return x.movedim(dim, -1) 

40 

41 

42def standard_error(x: torch.Tensor, *, dim: int = -1) -> torch.Tensor: 

43 """Standard error of the mean along ``dim`` (unbiased std over sqrt(n)); needs n >= 2.""" 

44 moved = _to_last(x, dim) 

45 n = moved.shape[-1] 

46 if n < 2: 

47 raise ValueError(f"standard_error needs at least 2 samples along dim {dim}, got {n}") 

48 return moved.std(dim=-1, unbiased=True) / math.sqrt(n) 

49 

50 

51def bootstrap_ci( 

52 x: torch.Tensor, 

53 *, 

54 dim: int = -1, 

55 statistic: str = "mean", 

56 confidence: float = 0.95, 

57 n_resamples: int = 1000, 

58 generator: Optional[torch.Generator] = None, 

59 chunk_size: int = 256, 

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

61 """Percentile bootstrap interval of ``statistic`` along ``dim``, vectorized over the rest. 

62 

63 Resamples with replacement using a CPU ``generator`` (``derive_generator(0)`` 

64 when omitted), ``chunk_size`` resamples at a time so memory stays at 

65 ``chunk_size × n`` per leading cell. Returns ``(low, high)`` with ``dim`` 

66 removed. 

67 

68 A percentile bootstrap cannot express uncertainty a sample does not contain: 

69 a constant vector resamples to itself and the interval collapses to a point 

70 whatever ``n`` is, and intervals on 4-8 samples are wide and skewed. For 

71 binary success rates prefer an exact interval (:func:`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).to(torch.promote_types(x.dtype, torch.float32)) 

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) 

96 

97 

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. 

108 

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"``. A null mean within a relative ``100 * eps`` of the observed one 

116 counts as a tie, absorbing summation-order error; there is no absolute floor, so 

117 near-zero observed effects get essentially no tolerance. Returns a tensor with 

118 ``dim`` removed. 

119 """ 

120 if alternative not in _ALTERNATIVES: 

121 raise ValueError(f"alternative must be one of {_ALTERNATIVES}, got {alternative!r}") 

122 if n_permutations < 1: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 raise ValueError("n_permutations must be >= 1") 

124 moved = _to_last(effects, dim).to(torch.promote_types(effects.dtype, torch.float32)) 

125 n = moved.shape[-1] 

126 if n < 1: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 raise ValueError("cannot permute an empty axis") 

128 observed = moved.mean(dim=-1, keepdim=True) # [..., 1] 

129 if n <= exact_max_n: 

130 signs = torch.tensor( 

131 list(itertools.product((1.0, -1.0), repeat=n)), dtype=moved.dtype, device=moved.device 

132 ) # [2^n, n] 

133 null = (moved.unsqueeze(-2) * signs).mean(dim=-1) # [..., 2^n] 

134 hits = _count_hits(null, observed, alternative) 

135 return hits / signs.shape[0] 

136 gen = generator if generator is not None else derive_generator(0) 

137 hits_total = torch.zeros(moved.shape[:-1], dtype=moved.dtype, device=moved.device) 

138 chunk = 256 

139 for start in range(0, n_permutations, chunk): 

140 count = min(chunk, n_permutations - start) 

141 signs = ( 

142 torch.randint(0, 2, (count, n), generator=gen).to(moved.device).to(moved.dtype) * 2 - 1 

143 ) 

144 null = (moved.unsqueeze(-2) * signs).mean(dim=-1) 

145 hits_total = hits_total + _count_hits(null, observed, alternative) 

146 return (hits_total + 1.0) / (n_permutations + 1.0) 

147 

148 

149def _count_hits(null: torch.Tensor, observed: torch.Tensor, alternative: str) -> torch.Tensor: 

150 # Relative to the observed statistic: tolerate reduction-order ties without an absolute floor. 

151 tolerance = observed.abs() * (100 * torch.finfo(null.dtype).eps) 

152 if alternative == "two-sided": 

153 hit = null.abs() >= observed.abs() - tolerance 

154 elif alternative == "greater": 

155 hit = null >= observed - tolerance 

156 else: 

157 hit = null <= observed + tolerance 

158 return hit.sum(dim=-1).to(null.dtype) 

159 

160 

161@dataclass(frozen=True) 

162class SuccessRateInterval: 

163 """An exact Clopper-Pearson confidence interval around a success rate. 

164 

165 ``n_trials`` and ``n_successes`` are the counts behind the bounds. 

166 """ 

167 

168 point_estimate: float 

169 ci_low: float 

170 ci_high: float 

171 n_trials: int 

172 n_successes: int 

173 confidence: float 

174 

175 

176# A cap: bisection exits once float midpoints stop moving, typically after 53-90 halvings. 

177_CLOPPER_PEARSON_STEPS = 200 

178 

179 

180def _binomial_cdf(k: int, n: int, p: float) -> float: 

181 """Exact ``P(X <= k)`` for ``X ~ Binomial(n, p)``. 

182 

183 Sums ``exp(log_term - peak)`` in log space: ``comb(n, i)`` and ``p ** i`` overflow at large 

184 ``n``, and a recurrence from ``(1 - p) ** n`` underflows once ``n log(1 - p) < -745``. 

185 """ 

186 if k < 0: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 return 0.0 

188 if k >= n: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true

189 return 1.0 

190 if p <= 0.0: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 return 1.0 

192 if p >= 1.0: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true

193 return 0.0 

194 log_p, log_q = math.log(p), math.log1p(-p) 

195 log_n_factorial = math.lgamma(n + 1) 

196 log_terms = [ 

197 log_n_factorial - math.lgamma(i + 1) - math.lgamma(n - i + 1) + i * log_p + (n - i) * log_q 

198 for i in range(k + 1) 

199 ] 

200 peak = max(log_terms) 

201 return min(1.0, math.exp(peak) * math.fsum(math.exp(term - peak) for term in log_terms)) 

202 

203 

204def _clopper_pearson_bound(n_successes: int, n_trials: int, tail: float, *, upper: bool) -> float: 

205 """Solve one Clopper-Pearson bound by bisecting the exact binomial CDF. 

206 

207 Lower solves ``P(X >= k) = tail``, upper ``P(X <= k) = tail``; swapping their bracket 

208 directions collapses every interval to ``[0, 1]``. 

209 """ 

210 if upper and n_successes == n_trials: 

211 return 1.0 

212 if not upper and n_successes == 0: 

213 return 0.0 

214 low, high = 0.0, 1.0 

215 for _ in range(_CLOPPER_PEARSON_STEPS): 215 ↛ 232line 215 didn't jump to line 232 because the loop on line 215 didn't complete

216 midpoint = (low + high) / 2.0 

217 # Every later step would re-test this same midpoint and leave the result unchanged. 

218 if midpoint == low or midpoint == high: 

219 break 

220 if upper: 

221 # P(X <= k) decreases in p, so a CDF above the tail puts the bound above the midpoint. 

222 if _binomial_cdf(n_successes, n_trials, midpoint) > tail: 

223 low = midpoint 

224 else: 

225 high = midpoint 

226 else: 

227 # P(X >= k) increases in p, so a value above the tail puts the bound below the midpoint. 

228 if 1.0 - _binomial_cdf(n_successes - 1, n_trials, midpoint) > tail: 

229 high = midpoint 

230 else: 

231 low = midpoint 

232 return (low + high) / 2.0 

233 

234 

235def success_rate_ci( 

236 successes: Sequence[bool], 

237 *, 

238 confidence: float = 0.95, 

239) -> SuccessRateInterval: 

240 """Computes an exact Clopper-Pearson confidence interval for a success rate. 

241 

242 Unlike a percentile bootstrap, which collapses to ``[0, 0]`` on an all-failure sample 

243 regardless of size, the interval widens as the trial count shrinks (``[0.0, 0.459]`` for 

244 zero successes out of six). Deterministic: no resampling and no seed. 

245 

246 Raises: 

247 ValueError: If ``successes`` is empty. 

248 """ 

249 if len(successes) == 0: 

250 raise ValueError("successes must be a non-empty sequence") 

251 n_trials = len(successes) 

252 n_successes = sum(1 for success in successes if success) 

253 tail = (1.0 - confidence) / 2.0 

254 return SuccessRateInterval( 

255 point_estimate=n_successes / n_trials, 

256 ci_low=_clopper_pearson_bound(n_successes, n_trials, tail, upper=False), 

257 ci_high=_clopper_pearson_bound(n_successes, n_trials, tail, upper=True), 

258 n_trials=n_trials, 

259 n_successes=n_successes, 

260 confidence=confidence, 

261 )