Parametric significance tests for comparing learning algorithms.
A parametric test assumes an explicit distributional form for the data. The tests here assume the quantity being tested (the per-fold or per-dataset score difference, or the within-group residual) is drawn from a normal distribution. When that assumption is reasonable these tests are the most powerful option available; when it fails -- heavy tails, a handful of datasets, accuracies saturating near 1.0, or an outlier dataset -- they become anti-conservative and report "significant" far too often. In that regime use the rank-based tests in nonparametric instead.
Contents:
paired_t_test Student's paired t-test on matched scores
corrected_paired_t_test Paired t-test with the Nadeau & Bengio variance
correction for resampled / cross-validated scores
one_way_anova Omnibus F-test across k independent groups
PairedStats Result container returned by the paired tests
SignificanceLevel WIN / LOSS / TIE verdict enumNotes
Choosing a test. Two algorithms scored on the same folds or the same datasets are paired -- use paired_t_test. If those scores come from resampling that reuses training data (k-fold CV, repeated random splits), the folds are not independent, the ordinary t-test's variance estimate is too small and its Type I error rate is badly inflated; use corrected_paired_t_test. For more than two algorithms an omnibus test comes first: one_way_anova for independent groups, or -- much more commonly in machine learning, where the same datasets are reused across algorithms -- friedman_test.
Multiplicity. Running every pairwise t-test over k algorithms performs k(k-1)/2 tests, so the probability of at least one false positive grows quickly. Always feed the resulting p-values through one of the procedures in corrections.
References
See Also
>>> import numpy as np
>>> from tuiml.evaluation.statistics import paired_t_test
>>> model_a = np.array([0.85, 0.87, 0.83, 0.86, 0.84])
>>> model_b = np.array([0.82, 0.84, 0.81, 0.83, 0.82])
>>> stats = paired_t_test(model_a, model_b, significance_level=0.05)
>>> round(float(stats.p_value), 4)
0.0004
>>> stats.x_better()
True
Classes
Verdict of a two-sided paired comparison between two algorithms.
x) of the test.Attributes
TIE
0. The null hypothesis was not rejected (:math:`p \geq \alpha`). This is not evidence that the two algorithms are equal, only that the data are too few or too noisy to distinguish them.
WIN
1. The null hypothesis was rejected and x is the better algorithm. "Better" is decided by the higher_better flag of the test: with higher_better=True (accuracy, F1) this means mean(x) > mean(y); with higher_better=False (error rate, RMSE) it means mean(x) < mean(y).
LOSS
-1. The null hypothesis was rejected and y is the better algorithm.
Notes
WIN and LOSS partition the same rejection region; the enum records which tail the observed difference fell into. Do not read a WIN at \alpha = 0.05 as a one-sided claim at \alpha = 0.025.>>> import numpy as np
>>> from tuiml.evaluation.statistics import SignificanceLevel, paired_t_test
>>> a = np.array([0.85, 0.87, 0.83, 0.86, 0.84])
>>> b = np.array([0.82, 0.84, 0.81, 0.83, 0.82])
>>> paired_t_test(a, b).significance is SignificanceLevel.WIN
True
>>> paired_t_test(b, a).significance is SignificanceLevel.LOSS
True
>>> SignificanceLevel.TIE.value
0
Complete result of a paired comparison between two algorithms.
paired_t_test, corrected_paired_t_test and wilcoxon_signed_rank_test -- so that the three are drop-in interchangeable. It bundles the descriptive statistics of both samples, the test statistic, the two-sided p-value and the WIN/LOSS/TIE verdict.Attributes
x_mean
y_mean
x_std
ddof=1 (unbiased, divides by :math:`n - 1`).
y_std
ddof=1).
diff_mean
x_mean - y_mean, not the other way round.
diff_std
paired_t_test and wilcoxon_signed_rank_test this is the ordinary ddof=1 standard deviation of :math:`d_i`. For corrected_paired_t_test it is instead the corrected standard error :math:`\sqrt{(1/n + n_{test}/n_{train})\, s_d^2}`, which is why the two are not comparable across tests.
t_statistic
p_value
1 - p_value is not the probability that the difference is real. Lies in :math:`[0, 1]`.
correlation
0.0 when either sample is constant.
significance
significance_level supplied to the test: WIN (x better), LOSS (y better) or TIE (null not rejected). See SignificanceLevel.
n
n can be smaller than the input length.
Notes
The verdict is computed with a strict comparison, p_value < significance_level; a p-value exactly equal to \alpha is reported as TIE.
Because the whole object is derived from one significance_level, re-reading significance under a different \alpha is invalid -- re-run the test, or threshold p_value yourself.
See Also
>>> import numpy as np
>>> from tuiml.evaluation.statistics import paired_t_test
>>> a = np.array([0.85, 0.87, 0.83, 0.86, 0.84])
>>> b = np.array([0.82, 0.84, 0.81, 0.83, 0.82])
>>> stats = paired_t_test(a, b)
>>> stats.n
5
>>> round(float(stats.diff_mean), 4)
0.026
>>> round(float(stats.t_statistic), 4)
10.6145
>>> round(float(stats.p_value), 4)
0.0004
>>> stats.is_significant(), stats.x_better(), stats.y_better()
(True, True, False)
Methods
is_significant
(self) -> bool
is_significant
(self) -> bool
Report whether the null hypothesis of no difference was rejected.
Returns
significant
True if p_value fell below the significance level supplied to the test (equivalently, significance is not TIE). False means the evidence was insufficient -- not that the two algorithms perform equally.
Functions
Student's paired t-test comparing two algorithms on matched scores.
x and observation i of y must refer to the same fold, split or dataset; the shared difficulty of that fold then cancels out of d_i, which is what makes the paired design far more sensitive than comparing two independent samples.Hypotheses
The test is two-sided:
- H_0: \mu_d = 0 -- the two algorithms have the same expected
- H_1: \mu_d \neq 0 -- their expected scores differ, in either
Theory
With \bar{d} the mean and s_d the ddof=1 standard deviation of the n differences, the statistic is
which follows a Student t-distribution with \nu = n - 1 degrees of freedom under H_0. The two-sided p-value is p = 2\,[1 - F_{\nu}(|t|)].
Parameters
x
y
x. Must have the same length as x.
significance_level
higher_better
True for accuracy, F1, AUC; False for error rate, RMSE, log-loss. Only affects which of WIN/LOSS is reported, never the statistic or the p-value.
Returns
stats
t_statistic, the two-sided p_value, the means and standard deviations of both samples, diff_mean, correlation, the significance verdict and the effective sample size n. Read stats.p_value < alpha or stats.is_significant() for the decision and stats.x_better() for its direction.
Raises
ValueError
x and y have different lengths, if fewer than 2 observations are supplied, or if fewer than 2 complete pairs survive NaN removal.
Notes
Assumptions.
-
Paired data --
x[i]andy[i]measured on the same fold or
- The differences d_i are approximately normally distributed.
- The differences are independent across i. This is the
corrected_paired_t_test.
- Scores are on an interval scale and commensurable across datasets.
Complexity. O(n) time and memory.
Handling of degenerate input. Pairs containing NaN in either vector are dropped. If every difference is identical (zero standard error), the test returns t = 0 and p = 1 rather than dividing by zero -- note this makes a perfect, perfectly consistent win look like a tie.
When to prefer something else. Use wilcoxon_signed_rank_test when normality is doubtful or n is small -- [Demsar2006]_ recommends it as the default for comparing two classifiers over multiple datasets. Use corrected_paired_t_test for cross-validated or repeated-resampling scores. For three or more algorithms run an omnibus test first (friedman_test) rather than all pairwise t-tests.
References
See Also
corrected_paired_t_test
Same test with the Nadeau & Bengio variance correction for overlapping training sets.
wilcoxon_signed_rank_test
Non-parametric counterpart; drops the normality assumption.
one_way_anova
Extension to more than two independent groups.
holm_correction
Adjust the p-values when many pairs are tested.
>>> import numpy as np
>>> from tuiml.evaluation.statistics import paired_t_test
>>> model_a_acc = np.array([0.85, 0.87, 0.83, 0.86, 0.84])
>>> model_b_acc = np.array([0.82, 0.84, 0.81, 0.83, 0.82])
>>> stats = paired_t_test(model_a_acc, model_b_acc, significance_level=0.05)
>>> round(float(stats.t_statistic), 4)
10.6145
>>> round(float(stats.p_value), 4)
0.0004
>>> stats.is_significant()
True
>>> stats.x_better()
True
With an error-style metric, flip higher_better so the verdict points at
the algorithm with the lower score:
>>> err_a = np.array([0.15, 0.13, 0.17, 0.14, 0.16])
>>> err_b = np.array([0.18, 0.16, 0.19, 0.17, 0.18])
>>> paired_t_test(err_a, err_b, higher_better=False).x_better()
True
Corrected resampled paired t-test for cross-validated scores.
Hypotheses
Identical to paired_t_test and still two-sided:
- H_0: \mu_d = 0 -- both algorithms have the same expected
- H_1: \mu_d \neq 0.
Theory
With s_d^2 the ordinary ddof=1 variance of the differences, the corrected statistic replaces s_d^2/n by
giving
compared against a t-distribution with n - 1 degrees of freedom. The extra n_{test}/n_{train} term is the price of reusing training data; it never vanishes with more resampling rounds, which is why simply repeating cross-validation cannot buy unlimited significance.
Parameters
x
y
x (same folds, same order).
n_train
n_test
significance_level
higher_better
True when larger scores are better (accuracy), False for error metrics. Affects only the direction of the verdict.
Returns
stats
paired_t_test, with two differences worth noting: t_statistic and p_value are the corrected ones, and diff_std holds the corrected standard error :math:`\sqrt{\widehat{\sigma}^2}` rather than a standard deviation, so it is not comparable to the diff_std of the uncorrected test.
Raises
ValueError
x and y have different lengths, or if fewer than 2 complete pairs survive NaN removal.
Notes
Assumptions.
- Paired, element-wise aligned scores from a resampling scheme with a
- Approximately normal differences, as for the uncorrected test.
-
n_trainandn_testdescribe a single round, not the totals
Complexity. O(n) time and memory.
When to prefer it. Use this instead of paired_t_test whenever the scores come from k-fold CV, repeated k-fold CV, or 5x2 CV -- i.e. almost every model comparison run on a single dataset. If the n scores are one-per-dataset over n genuinely distinct datasets there is no training-set overlap and the plain paired t-test is appropriate. [BouckaertFrank2004]_ found 10x10-fold CV with this correction to give the best replicability among the schemes they compared.
Degenerate input. If the corrected variance is zero the function returns t = 0 and p = 1.
References
See Also
10-fold cross-validation on 1000 examples, so n_train=900 and
n_test=100 per round:
>>> import numpy as np
>>> from tuiml.evaluation.statistics import (
... corrected_paired_t_test, paired_t_test)
>>> fold_a = np.array([0.85, 0.87, 0.83, 0.86, 0.84])
>>> fold_b = np.array([0.82, 0.84, 0.81, 0.83, 0.82])
>>> stats = corrected_paired_t_test(fold_a, fold_b, n_train=900, n_test=100)
>>> round(float(stats.t_statistic), 4)
8.5105
>>> round(float(stats.p_value), 4)
0.001
The correction always shrinks the statistic relative to the uncorrected
test, so it can only ever make a result less significant:
>>> plain = paired_t_test(fold_a, fold_b)
>>> bool(abs(stats.t_statistic) < abs(plain.t_statistic))
True
One-way ANOVA: omnibus F-test across :math:`k` independent groups.
friedman_test.Hypotheses
- H_0: \mu_1 = \mu_2 = \dots = \mu_k -- every group has the
- H_1: at least one \mu_i differs. Rejecting says nothing
Theory
The total variability is split into a between-group and a within-group part. With \bar{X} the grand mean, n_i and \bar{X}_i the size and mean of group i, and N = \sum_i n_i:
Under H_0, F follows an F-distribution with (k-1, N-k) degrees of freedom. Large F means the group means are spread out relative to the noise within groups. The p-value is the upper tail P(F_{k-1,\,N-k} > F) -- one-sided by construction, because only large F contradicts H_0, even though the underlying alternative is two-sided in each mean.
Parameters
*groups
one_way_anova(a, b, c)). Groups may have different lengths and are treated as mutually independent samples -- entries are not paired across groups.
significance_level
f_statistic or p_value. Keyword-only.
Returns
f_statistic
0.0 when the within-group mean square is zero. Values near 1 are what :math:`H_0` predicts.
p_value
significant
p_value < significance_level. True means "the groups are not all equal"; it does not identify the winner.
Raises
ValueError
Notes
Assumptions.
- Independence between and within groups. This is the assumption that
friedman_test (or a repeated measures design) there.
- Normality of the residuals within each group.
- Homoscedasticity -- equal variance across groups. ANOVA tolerates
Complexity. O(N) in the total number of observations.
After a rejection. A significant omnibus result licenses post-hoc pairwise comparisons, but each of those is a fresh test, so their p-values must be adjusted -- see holm_correction.
References
See Also
>>> import numpy as np
>>> from tuiml.evaluation.statistics import one_way_anova
>>> group_a = np.array([0.85, 0.87, 0.83])
>>> group_b = np.array([0.82, 0.84, 0.81])
>>> group_c = np.array([0.75, 0.78, 0.74])
>>> f_stat, p_value, significant = one_way_anova(group_a, group_b, group_c)
>>> round(float(f_stat), 4)
19.5
>>> round(float(p_value), 4)
0.0024
>>> bool(significant)
True