Statistical Analysis for ML Experiments
Test Selection
Decision Table: Which Test to Use
| Scenario |
Test |
Assumptions |
When to Use |
| Compare 2 models, paired runs |
Paired permutation test |
None (non-parametric) |
Default choice for ML |
| Compare 2 models, unpaired |
Mann-Whitney U |
Independent samples |
Different datasets/splits |
| Compare 2 means, normal data |
Paired t-test |
Normality, paired |
Large N (>30) runs |
| Compare >2 models |
Friedman test |
Paired, ordinal |
Ranking across datasets |
| Post-hoc after Friedman |
Nemenyi test |
Same as Friedman |
Pairwise model comparison |
| Compare proportions |
McNemar's test |
Paired binary |
Classification correct/incorrect |
| Correlation of metrics |
Spearman's rho |
Monotonic relationship |
Rank-based, robust to outliers |
| Effect of hyperparameters |
ANOVA / Kruskal-Wallis |
See specific test |
Factorial ablation design |
Decision Table: Confidence Interval Method
| Situation |
Method |
N Seeds |
| Metric approximately normal |
t-interval |
>= 5 |
| Unknown distribution |
Bootstrap percentile |
>= 10 |
| Small N, heavy tails |
BCa bootstrap |
>= 15 |
| Proportion (accuracy) |
Wilson interval |
Any |
Bootstrap Confidence Intervals
import numpy as np
from scipy import stats
def bootstrap_ci(
data: np.ndarray,
statistic=np.mean,
n_bootstrap: int = 10_000,
confidence: float = 0.95,
method: str = "percentile",
seed: int = 42,
) -> tuple[float, float, float]:
"""Compute bootstrap confidence interval.
Args:
data: 1D array of observations (e.g., accuracy per seed).
statistic: Function to compute on each resample.
method: 'percentile' or 'bca'.
Returns:
(point_estimate, lower, upper)
"""
rng = np.random.default_rng(seed)
observed = statistic(data)
n = len(data)
boot_stats = np.array([
statistic(rng.choice(data, size=n, replace=True))
for _ in range(n_bootstrap)
])
alpha = 1 - confidence
if method == "percentile":
lower = np.percentile(boot_stats, 100 * alpha / 2)
upper = np.percentile(boot_stats, 100 * (1 - alpha / 2))
elif method == "bca":
# Bias-correction
z0 = stats.norm.ppf(np.mean(boot_stats < observed))
# Acceleration (jackknife)
jackknife = np.array([
statistic(np.delete(data, i)) for i in range(n)
])
jack_mean = jackknife.mean()
acc = np.sum((jack_mean - jackknife) ** 3) / (
6 * np.sum((jack_mean - jackknife) ** 2) ** 1.5
)
# Adjusted percentiles
z_alpha = stats.norm.ppf(alpha / 2)
z_1alpha = stats.norm.ppf(1 - alpha / 2)
p_lower = stats.norm.cdf(z0 + (z0 + z_alpha) / (1 - acc * (z0 + z_alpha)))
p_upper = stats.norm.cdf(z0 + (z0 + z_1alpha) / (1 - acc * (z0 + z_1alpha)))
lower = np.percentile(boot_stats, 100 * p_lower)
upper = np.percentile(boot_stats, 100 * p_upper)
else:
raise ValueError(f"Unknown method: {method}")
return observed, lower, upper
# Usage
accuracies = np.array([92.1, 92.5, 91.8, 92.3, 92.0, 91.9, 92.4, 92.2, 92.6, 91.7])
point, lo, hi = bootstrap_ci(accuracies, method="bca")
print(f"Accuracy: {point:.2f} [{lo:.2f}, {hi:.2f}] (95% BCa CI)")
Paired Permutation Test
def paired_permutation_test(
scores_a: np.ndarray,
scores_b: np.ndarray,
n_permutations: int = 100_000,
seed: int = 42,
) -> tuple[float, float]:
"""Two-sided paired permutation test.
Returns:
(observed_diff, p_value) where diff = mean(a) - mean(b).
"""
rng = np.random.default_rng(seed)
diffs = scores_a - scores_b
observed = np.mean(diffs)
count = 0
for _ in range(n_permutations):
signs = rng.choice([-1, 1], size=len(diffs))
perm_diff = np.mean(diffs * signs)
if abs(perm_diff) >= abs(observed):
count += 1
p_value = count / n_permutations
return observed, p_value
# Usage
model_a = np.array([92.1, 92.5, 91.8, 92.3, 92.0])
model_b = np.array([91.5, 91.8, 91.2, 91.9, 91.4])
diff, p = paired_permutation_test(model_a, model_b)
print(f"Mean diff: {diff:.2f}, p-value: {p:.4f}")
Multiple Comparison Corrections
When to Correct
| Scenario |
Correct? |
Method |
| Single primary metric, 2 models |
No |
Single test suffices |
| 1 model vs 5 baselines |
Yes |
Holm-Bonferroni |
| 5 metrics, 1 comparison |
Yes |
Holm-Bonferroni |
| Exploratory ablation (many combos) |
Yes |
Benjamini-Hochberg (FDR) |
| Pre-registered single comparison |
No |
Pre-registration controls |
Implementation
from statsmodels.stats.multitest import multipletests
def correct_pvalues(
p_values: list[float],
method: str = "holm", # 'bonferroni', 'holm', 'fdr_bh'
alpha: float = 0.05,
) -> dict:
"""Apply multiple comparison correction.
Methods:
'bonferroni': Conservative. alpha/n per test.
'holm': Step-down Bonferroni. Less conservative, controls FWER.
'fdr_bh': Benjamini-Hochberg. Controls false discovery rate.
"""
reject, corrected, _, _ = multipletests(p_values, alpha=alpha, method=method)
return {
"reject": reject.tolist(),
"corrected_p": corrected.tolist(),
"method": method,
}
# Usage: comparing model against 4 baselines
raw_p = [0.01, 0.04, 0.03, 0.08]
result = correct_pvalues(raw_p, method="holm")
# result['reject'] -> [True, True, True, False]
# result['corrected_p'] -> [0.04, 0.08, 0.09, 0.08]
Power Analysis
from statsmodels.stats.power import TTestPower
def compute_required_seeds(
effect_size: float,
alpha: float = 0.05,
power: float = 0.8,
) -> int:
"""How many seeds needed to detect a given effect size.
effect_size: Cohen's d = (mean_a - mean_b) / pooled_std
Small: 0.2, Medium: 0.5, Large: 0.8
Typical ML scenario:
If accuracy diff ~0.5% and std ~0.3%, d = 0.5/0.3 = 1.67 (large).
If accuracy diff ~0.2% and std ~0.3%, d = 0.2/0.3 = 0.67 (medium).
"""
analysis = TTestPower()
n = analysis.solve_power(
effect_size=effect_size,
alpha=alpha,
power=power,
alternative="two-sided",
)
return int(np.ceil(n))
# How many seeds to detect 0.5% accuracy diff with 0.3% std?
d = 0.5 / 0.3 # ~1.67
n_seeds = compute_required_seeds(d)
print(f"Need {n_seeds} seeds per model") # ~5
# Smaller effect: 0.2% diff, 0.3% std
d_small = 0.2 / 0.3 # ~0.67
n_seeds_small = compute_required_seeds(d_small)
print(f"Need {n_seeds_small} seeds per model") # ~20
Ablation Study Design
Reporting Ablation Results
| Component Removed |
Accuracy |
Delta |
p-value |
| None (full model) |
92.3 +/- 0.3 |
-- |
-- |
| - Attention |
90.1 +/- 0.4 |
-2.2 |
0.001 |
| - Residual |
91.8 +/- 0.3 |
-0.5 |
0.032 |
| - Dropout |
92.1 +/- 0.3 |
-0.2 |
0.241 |
Gotchas and Anti-Patterns
P-Hacking via Metric Selection
- Choosing the metric that gives significance after seeing results invalidates the test.
- Fix: Pre-register primary metric. Report all metrics regardless of significance.
Underpowered Experiments
- 3 seeds is almost never enough. Most ML experiments need 5-20 seeds depending on variance.
- Running power analysis post-hoc to explain away non-significance is invalid.
- Fix: Run power analysis before experiments. Report effect sizes alongside p-values.
Invalid Independence Assumptions
- Runs on overlapping data splits are not independent. K-fold results are correlated.
- Training with different seeds on the same data violates independence for unpaired tests.
- Fix: Use paired tests when runs share data. Use corrected variance estimators for k-fold.
Misuse of T-Tests
- T-tests assume normality. With 3-5 seeds, you cannot verify this.
- T-tests on bounded metrics (accuracy in [0,1]) are technically wrong but often okay in practice for mid-range values.
- Fix: Default to non-parametric tests (permutation, Wilcoxon). Use t-tests only with N >= 30 or verified normality.
Reporting Pitfalls
- "Statistically significant" without effect size is meaningless. A 0.01% improvement can be significant with enough runs.
- Confidence intervals are more informative than p-values. Always report both.
- Standard deviation vs standard error: SD measures spread of runs, SE measures uncertainty of the mean. Report SD for reproducibility, SE or CI for comparison.
1---2name: statistical-analysis-23description: Hypothesis testing, power analysis, ablation design, and multiple comparison corrections for ML experiment evaluation.4---5
6# Statistical Analysis for ML Experiments
7
8## Test Selection
9
10### Decision Table: Which Test to Use
11
12| Scenario | Test | Assumptions | When to Use |
13|----------|------|-------------|-------------|
14| Compare 2 models, paired runs | Paired permutation test | None (non-parametric) | Default choice for ML |
15| Compare 2 models, unpaired | Mann-Whitney U | Independent samples | Different datasets/splits |
16| Compare 2 means, normal data | Paired t-test | Normality, paired | Large N (>30) runs |
17| Compare >2 models | Friedman test | Paired, ordinal | Ranking across datasets |
18| Post-hoc after Friedman | Nemenyi test | Same as Friedman | Pairwise model comparison |
19| Compare proportions | McNemar's test | Paired binary | Classification correct/incorrect |
20| Correlation of metrics | Spearman's rho | Monotonic relationship | Rank-based, robust to outliers |
21| Effect of hyperparameters | ANOVA / Kruskal-Wallis | See specific test | Factorial ablation design |
22
23### Decision Table: Confidence Interval Method
24
25| Situation | Method | N Seeds |
26|-----------|--------|---------|
27| Metric approximately normal | t-interval | >= 5 |
28| Unknown distribution | Bootstrap percentile | >= 10 |
29| Small N, heavy tails | BCa bootstrap | >= 15 |
30| Proportion (accuracy) | Wilson interval | Any |
31
32## Bootstrap Confidence Intervals
33
34```python
35import numpy as np
36from scipy import stats
37
38def bootstrap_ci(
39 data: np.ndarray,
40 statistic=np.mean,
41 n_bootstrap: int = 10_000,
42 confidence: float = 0.95,
43 method: str = "percentile",
44 seed: int = 42,
45) -> tuple[float, float, float]:
46 """Compute bootstrap confidence interval.
47
48 Args:
49 data: 1D array of observations (e.g., accuracy per seed).
50 statistic: Function to compute on each resample.
51 method: 'percentile' or 'bca'.
52
53 Returns:
54 (point_estimate, lower, upper)
55 """
56 rng = np.random.default_rng(seed)
57 observed = statistic(data)
58 n = len(data)
59
60 boot_stats = np.array([
61 statistic(rng.choice(data, size=n, replace=True))
62 for _ in range(n_bootstrap)
63 ])
64
65 alpha = 1 - confidence
66 if method == "percentile":
67 lower = np.percentile(boot_stats, 100 * alpha / 2)
68 upper = np.percentile(boot_stats, 100 * (1 - alpha / 2))
69 elif method == "bca":
70 # Bias-correction
71 z0 = stats.norm.ppf(np.mean(boot_stats < observed))
72 # Acceleration (jackknife)
73 jackknife = np.array([
74 statistic(np.delete(data, i)) for i in range(n)
75 ])
76 jack_mean = jackknife.mean()
77 acc = np.sum((jack_mean - jackknife) ** 3) / (
78 6 * np.sum((jack_mean - jackknife) ** 2) ** 1.5
79 )
80 # Adjusted percentiles
81 z_alpha = stats.norm.ppf(alpha / 2)
82 z_1alpha = stats.norm.ppf(1 - alpha / 2)
83 p_lower = stats.norm.cdf(z0 + (z0 + z_alpha) / (1 - acc * (z0 + z_alpha)))
84 p_upper = stats.norm.cdf(z0 + (z0 + z_1alpha) / (1 - acc * (z0 + z_1alpha)))
85 lower = np.percentile(boot_stats, 100 * p_lower)
86 upper = np.percentile(boot_stats, 100 * p_upper)
87 else:
88 raise ValueError(f"Unknown method: {method}")
89
90 return observed, lower, upper
91
92# Usage
93accuracies = np.array([92.1, 92.5, 91.8, 92.3, 92.0, 91.9, 92.4, 92.2, 92.6, 91.7])
94point, lo, hi = bootstrap_ci(accuracies, method="bca")
95print(f"Accuracy: {point:.2f} [{lo:.2f}, {hi:.2f}] (95% BCa CI)")
96```
97
98## Paired Permutation Test
99
100```python
101def paired_permutation_test(
102 scores_a: np.ndarray,
103 scores_b: np.ndarray,
104 n_permutations: int = 100_000,
105 seed: int = 42,
106) -> tuple[float, float]:
107 """Two-sided paired permutation test.
108
109 Returns:
110 (observed_diff, p_value) where diff = mean(a) - mean(b).
111 """
112 rng = np.random.default_rng(seed)
113 diffs = scores_a - scores_b
114 observed = np.mean(diffs)
115
116 count = 0
117 for _ in range(n_permutations):
118 signs = rng.choice([-1, 1], size=len(diffs))
119 perm_diff = np.mean(diffs * signs)
120 if abs(perm_diff) >= abs(observed):
121 count += 1
122
123 p_value = count / n_permutations
124 return observed, p_value
125
126# Usage
127model_a = np.array([92.1, 92.5, 91.8, 92.3, 92.0])
128model_b = np.array([91.5, 91.8, 91.2, 91.9, 91.4])
129diff, p = paired_permutation_test(model_a, model_b)
130print(f"Mean diff: {diff:.2f}, p-value: {p:.4f}")
131```
132
133## Multiple Comparison Corrections
134
135### When to Correct
136
137| Scenario | Correct? | Method |
138|----------|----------|--------|
139| Single primary metric, 2 models | No | Single test suffices |
140| 1 model vs 5 baselines | Yes | Holm-Bonferroni |
141| 5 metrics, 1 comparison | Yes | Holm-Bonferroni |
142| Exploratory ablation (many combos) | Yes | Benjamini-Hochberg (FDR) |
143| Pre-registered single comparison | No | Pre-registration controls |
144
145### Implementation
146
147```python
148from statsmodels.stats.multitest import multipletests
149
150def correct_pvalues(
151 p_values: list[float],
152 method: str = "holm", # 'bonferroni', 'holm', 'fdr_bh'
153 alpha: float = 0.05,
154) -> dict:
155 """Apply multiple comparison correction.
156
157 Methods:
158 'bonferroni': Conservative. alpha/n per test.
159 'holm': Step-down Bonferroni. Less conservative, controls FWER.
160 'fdr_bh': Benjamini-Hochberg. Controls false discovery rate.
161 """
162 reject, corrected, _, _ = multipletests(p_values, alpha=alpha, method=method)
163 return {
164 "reject": reject.tolist(),
165 "corrected_p": corrected.tolist(),
166 "method": method,
167 }
168
169# Usage: comparing model against 4 baselines
170raw_p = [0.01, 0.04, 0.03, 0.08]
171result = correct_pvalues(raw_p, method="holm")
172# result['reject'] -> [True, True, True, False]
173# result['corrected_p'] -> [0.04, 0.08, 0.09, 0.08]
174```
175
176## Power Analysis
177
178```python
179from statsmodels.stats.power import TTestPower
180
181def compute_required_seeds(
182 effect_size: float,
183 alpha: float = 0.05,
184 power: float = 0.8,
185) -> int:
186 """How many seeds needed to detect a given effect size.
187
188 effect_size: Cohen's d = (mean_a - mean_b) / pooled_std
189 Small: 0.2, Medium: 0.5, Large: 0.8
190
191 Typical ML scenario:
192 If accuracy diff ~0.5% and std ~0.3%, d = 0.5/0.3 = 1.67 (large).
193 If accuracy diff ~0.2% and std ~0.3%, d = 0.2/0.3 = 0.67 (medium).
194 """
195 analysis = TTestPower()
196 n = analysis.solve_power(
197 effect_size=effect_size,
198 alpha=alpha,
199 power=power,
200 alternative="two-sided",
201 )
202 return int(np.ceil(n))
203
204# How many seeds to detect 0.5% accuracy diff with 0.3% std?
205d = 0.5 / 0.3 # ~1.67
206n_seeds = compute_required_seeds(d)
207print(f"Need {n_seeds} seeds per model") # ~5
208
209# Smaller effect: 0.2% diff, 0.3% std
210d_small = 0.2 / 0.3 # ~0.67
211n_seeds_small = compute_required_seeds(d_small)
212print(f"Need {n_seeds_small} seeds per model") # ~20
213```
214
215## Ablation Study Design
216
217### Reporting Ablation Results
218
219| Component Removed | Accuracy | Delta | p-value |
220|-------------------|----------|-------|---------|
221| None (full model) | 92.3 +/- 0.3 | -- | -- |
222| - Attention | 90.1 +/- 0.4 | -2.2 | 0.001 |
223| - Residual | 91.8 +/- 0.3 | -0.5 | 0.032 |
224| - Dropout | 92.1 +/- 0.3 | -0.2 | 0.241 |
225
226## Gotchas and Anti-Patterns
227
228### P-Hacking via Metric Selection
229- Choosing the metric that gives significance after seeing results invalidates the test.
230- **Fix**: Pre-register primary metric. Report all metrics regardless of significance.
231
232### Underpowered Experiments
233- 3 seeds is almost never enough. Most ML experiments need 5-20 seeds depending on variance.
234- Running power analysis post-hoc to explain away non-significance is invalid.
235- **Fix**: Run power analysis before experiments. Report effect sizes alongside p-values.
236
237### Invalid Independence Assumptions
238- Runs on overlapping data splits are not independent. K-fold results are correlated.
239- Training with different seeds on the same data violates independence for unpaired tests.
240- **Fix**: Use paired tests when runs share data. Use corrected variance estimators for k-fold.
241
242### Misuse of T-Tests
243- T-tests assume normality. With 3-5 seeds, you cannot verify this.
244- T-tests on bounded metrics (accuracy in [0,1]) are technically wrong but often okay in practice for mid-range values.
245- **Fix**: Default to non-parametric tests (permutation, Wilcoxon). Use t-tests only with N >= 30 or verified normality.
246
247### Reporting Pitfalls
248- "Statistically significant" without effect size is meaningless. A 0.01% improvement can be significant with enough runs.
249- Confidence intervals are more informative than p-values. Always report both.
250- Standard deviation vs standard error: SD measures spread of runs, SE measures uncertainty of the mean. Report SD for reproducibility, SE or CI for comparison.