Skip to main content

Resampling for machine learning

Uncertainty for a model’s test-set score without a formula: a bootstrap interval for AUC, the paired bootstrap that compares two models on the same resampled test sets, and permutation_test_score to ask whether a model has learned anything at all.
S02.8.4L2 Intermediate25 min
In review (what this means)

The maths and code on this page are checked by automated tests on every change, but an independent reader has not signed it off yet. Spot a mistake or a confusing step? Tell us.

Before this lesson: S02.8.2 Bootstrap confidence intervals S02.8.3 Permutation tests ML01.7 Evaluation metrics

What you will be able to do
  • Compute a bootstrap percentile interval for a test-set AUC by resampling the test examples.
  • Explain why two models compared on one test set need the paired bootstrap, and derive why its interval for the difference is narrower.
  • Run sklearn.model_selection.permutation_test_score and read its p-value as a test of whether a model beats chance.
  • Say which source of variation each method measures, and name bagging and permutation importance as later uses of the same ideas.

The question

On the diabetes table of S01.8, a logistic regression that uses only body mass index predicts which patients’ disease progressed (score above the median, 140.5) with test-set AUC 0.806. One that uses all ten measurements gets 0.842. The test set has 133 patients. Is the bigger model really better, or would another 133 patients have reversed the order? And how sure is the 0.842 itself?

From the last lesson

1. A permutation test with \({K = 9{,}999}\) shuffles finds none as extreme as the data. What p-value do you report?

Show answer

\({(0 + 1)/(9{,}999 + 1) = 0.0001}\), never 0.

2. Six analysts are each timed with two tools. How should a permutation test shuffle?

Show answer

Within each analyst only: swap the two labels of a pair, which flips the sign of that analyst’s difference. That gives \({2^6 = 64}\) relabellings (permutation_type="samples").

Intuition

A test set is a sample of the cases the model will meet, so its score is a statistic with a sampling distribution (S02.1.1). The model is fixed once trained; what could have come out differently is which patients landed in the test set. Resample those, rescore, and the spread of the scores is the uncertainty in the 0.842.

Top: the 133 test patients as a strip ranked by the ten-measurement model's score from 0 to 1, patients whose disease progressed as filled blue dots mostly towards the right, the others as hollow sky-blue squares mostly towards the left, with overlap in the middle. Bottom: a blue histogram of the AUC on 2,000 resampled test sets, from about 0.74 to 0.93, peaked near 0.85; a vermillion line marks the test-set AUC 0.842, labelled one draw, and a vermillion bar beneath runs from 0.769 to 0.902.
S02.8.4-F1

The test-set AUC of the ten-measurement model, 0.842, and its bootstrap distribution over 2,000 resampled test sets. The 95 percent percentile interval runs from 0.769 to 0.902.

The interval is about 0.13 wide. That is the honest size of “AUC 0.84” on 133 patients, and it is why a leaderboard gap of 0.01 on a test set of this size says nothing.

Build it up

Step 1. Freeze the model. Train once and record every test patient’s score. Resampling never refits the model: it asks how the score of this model varies with the test patients.

Step 2. Resample the test rows. Draw 133 row numbers with replacement, take those patients’ labels and scores together, and recompute the AUC. Repeat 2,000 times and read off the percentile interval of S02.8.2.

Step 3. Two models: one set of row numbers. To compare the models, use the same resampled rows for both, and record the difference of the two AUCs. This is the paired bootstrap. A resample that happens to hold easy patients flatters both models at once, and the difference cancels that luck out.

A scatter plot of 2,000 blue dots, one per resampled test set, with the body-mass-index model's AUC across from 0.66 to 0.95 and the ten-measurement model's AUC up from 0.66 to 0.95. The cloud is an oval tilted along the dashed diagonal of equal AUC, mostly above it; a big vermillion dot at (0.806, 0.842) marks the observed test set. Notes: same 133 row numbers for both models; the two AUCs move together, correlation 0.56; above the line in 86 percent: the ten-measurement model scores higher.
S02.8.4-F2

The paired bootstrap. Each dot is one resampled test set scored by both models. The cloud leans along the diagonal: when one model’s AUC is high, so is the other’s.

Step 4. A different question: has it learned anything? A permutation test (S02.8.3) shuffles the labels against the features, refits the model on each shuffle, and asks whether the real score beats scores that are pure chance. That tests the null hypothesis of no relation at all, not the size of the score.

The formal statement

The AUC (ML01.7.3) of scores \(s\) on \(n_1\) positives and \(n_0\) negatives is the share of (positive, negative) pairs in the right order:

\[ \text{AUC} = \frac{1}{n_1 n_0} \sum_{i:\,y_i = 1}\;\sum_{j:\,y_j = 0} I(s_i > s_j), \]

where \({I(s_i > s_j)}\) is 1 when the positive scores higher, one half for a tie, and 0 when the negative scores higher. It is the Mann-Whitney \(U\) divided by \({n_1 n_0}\) (S02.7.4). In a resample a row drawn twice counts in twice as many pairs.

Why pairing narrows the interval. Write \(\hat{A}_1\) and \(\hat{A}_2\) for the two models’ AUCs on a resampled test set. For any two random variables (M06.10),

\[ \begin{aligned} &\operatorname{Var}(\hat{A}_2 - \hat{A}_1) \\ &\quad= \operatorname{Var}(\hat{A}_1) + \operatorname{Var}(\hat{A}_2) \\ &\quad\quad - 2\operatorname{Cov}(\hat{A}_1, \hat{A}_2). \end{aligned} \]

Scored on the same rows, the two AUCs have positive covariance, so the difference varies less than it would if the two were independent. Scored on separately drawn rows, the covariance is 0 and the variances simply add. With the bootstrap standard errors 0.0377 and 0.0334 and correlation 0.56 found below, the three terms are

\[ \begin{aligned} 0.0377^2 &= 0.00142, \\ 0.0334^2 &= 0.00112, \\ 2(0.56)(0.0377)(0.0334) &= 0.00141 . \end{aligned} \]

Paired, the standard error of the difference is

\[ \begin{aligned} &\sqrt{0.00142 + 0.00112 - 0.00141} \\ &\quad= 0.034 ; \end{aligned} \]

separate, it is \({\sqrt{0.00142 + 0.00112}} = 0.050\).

Notepermutation_test_score

For a model, data \(\mat{X}\) and labels \(\vect{y}\), compute the cross-validated score \(S_0\). Then for \({k = 1, \dots, K}\) shuffle \(\vect{y}\), refit and score by the same cross-validation, giving \(S_k\). With \(C\) the number of shuffled scores at least \(S_0\),

\[ \text{p-value} = \frac{C + 1}{K + 1}, \]

the \({(b + 1)/(K + 1)}\) rule of S02.8.3. The null hypothesis is that features and labels are independent: the model has nothing to learn.

Go deeper: what these intervals leave out

The bootstrap of the test set holds the trained model fixed. A different training sample would give a different model and a different score, a second source of variation that resampling the test set cannot see. Measuring it means refitting: cross-validation repeated with different splits, or bootstrapping the training set and refitting each time, which is what bagging does on purpose to average that variation away (ML02.2). Statements such as “model 2 beats model 1 on this test set” are about these fitted models; “method 2 beats method 1” needs the refits too.

Worked example

An AUC by hand. Six test examples; e1, e3 and e4 progressed (positives), e2, e5 and e6 did not. Model A scores them 0.9, 0.7, 0.6, 0.4, 0.3 and 0.2 for e1 to e6. There are \({3 \times 3 = 9}\) (positive, negative) pairs. e1 (0.9) beats all three negatives. e3 (0.6) and e4 (0.4) each beat e5 (0.3) and e6 (0.2) but lose to e2 (0.7). So 7 of 9 pairs are right: \({\text{AUC} = 7/9 = 0.778}\).

Two grids of pairs. Left, Model A, all six: rows are the positives e1 0.9, e3 0.6, e4 0.4 and columns the negatives e2 0.7, e5 0.3, e6 0.2; cells hold 1 on green when the row scores higher and 0 on purple otherwise; the two 0s are e3 and e4 against e2; 7 of 9 pairs right, AUC equals 7/9 equals 0.778. Right, Model A, one resample, rows drawn e1, e2, e2, e4, e5, e6: rows e1 and e4, columns e2, e2, e5, e6; e4 loses to both copies of e2; 6 of 8 pairs right, AUC star equals 0.750. Below: model B on the same rows, 8 of 8 equals 1.000; difference B minus A in this resample, 0.250.
S02.8.4-F3

The worked example as a trace: the pairs grid on the six examples, then on one resample, where the duplicated e2 appears twice and e3 is missing.

One resample. Suppose the draws are e1, e2, e2, e4, e5, e6. Now the positives are e1 and e4, and the negatives e2 (twice), e5 and e6: \({2 \times 4 = 8}\) pairs. e1 wins all four; e4 loses to both copies of e2 and beats e5 and e6. So \({\text{AUC}^* = 6/8 = 0.750}\).

Pairing. A second model B scores the six 0.8, 0.5, 0.7, 0.6, 0.4 and 0.1. Every positive is above every negative, on the full set and on the resample: AUC 1. On the resample the difference is \({1 - 0.750 = 0.250}\); on the full set it was \({1 - 0.778 = 0.222}\). Repeating this for thousands of resamples, always with one set of rows for both models, gives the paired bootstrap distribution of the difference.

Code

Two models on one test set, as in the question:

import numpy as np
from scipy import stats
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold, permutation_test_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

table = load_diabetes(as_frame=True, scaled=False).frame
X = table.drop(columns="target")
z = (table["target"] > table["target"].median()).astype(int).to_numpy()   # progressed: 1
X_tr, X_te, z_tr, z_te = train_test_split(X, z, test_size=0.3, random_state=0, stratify=z)

one = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_tr[["bmi"]], z_tr)
ten = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_tr, z_tr)
s1 = one.predict_proba(X_te[["bmi"]])[:, 1]
s2 = ten.predict_proba(X_te)[:, 1]

def auc(score, label, axis=-1):
    """Share of (positive, negative) pairs in the right order, from ranks (Mann-Whitney U)."""
    r = stats.rankdata(score, axis=axis)
    n1 = label.sum(axis=axis)
    n0 = label.shape[axis] - n1
    return ((r * label).sum(axis=axis) - n1 * (n1 + 1) / 2) / (n1 * n0)

print(f"{z.sum()} of {len(z)} progressed; test set {len(z_te)} with {z_te.sum()} positives")
print(f"AUC: body mass index {auc(s1, z_te):.3f}, ten measurements {auc(s2, z_te):.3f}")
221 of 442 progressed; test set 133 with 66 positives
AUC: body mass index 0.806, ten measurements 0.842

The bootstrap, paired: one array of row numbers serves both models.

K = 2000
rows = np.random.default_rng(0).integers(0, len(z_te), size=(K, len(z_te)))
a1 = auc(s1[rows], z_te[rows], axis=1)
a2 = auc(s2[rows], z_te[rows], axis=1)
diff = a2 - a1
for name, reps in (("body mass index", a1), ("ten measurements", a2), ("difference", diff)):
    lo, hi = np.percentile(reps, [2.5, 97.5])
    print(f"{name:>16}: {lo:.3f} to {hi:.3f}, SE {reps.std(ddof=1):.4f}")
print(f"correlation of the two AUCs {np.corrcoef(a1, a2)[0, 1]:.2f}; "
      f"simpler model at least as good in {np.mean(diff <= 0):.1%} of resamples")

other = np.random.default_rng(1).integers(0, len(z_te), size=(K, len(z_te)))
separate = auc(s2[other], z_te[other], axis=1) - a1     # the mistake: separate rows for each model
print(f"separate resamples: {np.percentile(separate, 2.5):.3f} to {np.percentile(separate, 97.5):.3f}")
 body mass index: 0.728 to 0.877, SE 0.0377
ten measurements: 0.769 to 0.902, SE 0.0334
      difference: -0.030 to 0.101, SE 0.0335
correlation of the two AUCs 0.56; simpler model at least as good in 14.3% of resamples
separate resamples: -0.060 to 0.138

The interval for the gain, \({-0.030}\) to 0.101, contains 0: on 133 patients the data cannot rule out that the two models rank equally well, though the bigger model wins in 86 percent of resamples. Whether the ten-measurement model learned anything at all is a sharper question:

cv = StratifiedKFold(5, shuffle=True, random_state=0)
score, null, p = permutation_test_score(make_pipeline(StandardScaler(), LogisticRegression()), X, z,
                                        scoring="roc_auc", cv=cv, n_permutations=100, random_state=0)
print(f"cross-validated AUC {score:.3f}; shuffled labels: mean {null.mean():.3f}, largest {null.max():.3f}; "
      f"p-value {p:.4f}")
cross-validated AUC 0.843; shuffled labels: mean 0.499, largest 0.572; p-value 0.0099
A blue histogram, titled Has the model learned anything?, of the five-fold cross-validated AUC of the ten-measurement model refitted on 100 shuffles of the labels, spread from about 0.37 to 0.57 around 0.5, largest 0.572. A vermillion line far to the right at 0.843 is labelled real labels. Text in purple: p-value (0 plus 1) over 101 equals 0.0099.
S02.8.4-F4

permutation_test_score: with shuffled labels the model scores about 0.5, chance. The real score beats all 100 shuffles, so the p-value is the smallest this run allows, 1/101.

Explore

The module’s resampling machine is in lesson S02.8.1. Its two modes are the two tools of this lesson in miniature: resampling rows to measure how much a statistic varies, and shuffling labels to ask whether a difference is more than chance.

Pitfalls

Two histograms of the difference in AUC, ten measurements minus body mass index, over 2,000 resamples, with a dashed line at 0. Left, marked with a cross and titled Two separate resamples: a wide histogram from about minus 0.1 to 0.18, interval minus 0.060 to 0.138, SE 0.050. Right, marked with a tick and titled One resample for both: a narrower histogram from about minus 0.06 to 0.14, interval minus 0.030 to 0.101, SE 0.034.
S02.8.4-F5

Wrong beside right. Resampling the test set separately for each model throws away the pairing and makes the difference look about one and a half times as uncertain as it is.

“The two intervals overlap, so the models are equally good.”

Two separate intervals, 0.728 to 0.877 and 0.769 to 0.902, overlap heavily, yet the question is about the difference, and both models were scored on the same patients. Bootstrap the difference with one set of rows for both. Comparing separate intervals, or resampling each model’s test set on its own, treats the shared patients as if they were different people, the unpaired mistake of S02.7.1.

“A small p-value from permutation_test_score means the model is good.”

It means the model is better than chance, which a model with AUC 0.6 can also be. The size of the score, with its interval, says how good. Ojala and Garriga add that this test cannot tell whether the model uses the relations between features; it only checks for some link between features and labels.

“Bootstrap the test set after tuning on it.”

If the test set chose the model or its threshold, its score is optimistic before any resampling, and the bootstrap faithfully reproduces that optimism. Resampling measures noise, not bias: tune on validation data and keep the test set for the final, single score (ML01.2).

Frontier note: where resampling goes next in machine learning

Bagging (ML02.2) fits one model per bootstrap resample of the training set and averages them, turning the variation this lesson measures into a lower-variance prediction; the rows a resample leaves out, about 37 percent by lesson S02.8.1, give a free “out-of-bag” test set. Permutation importance (ML04.9, R02.3) shuffles one feature column of the test set and measures how much the score drops: a permutation of a single column instead of the labels. Koehn applied the paired bootstrap to test sentences to compare machine-translation systems, and the same recipe serves any metric computed on a shared test set.

Check yourself

Recall

1. When we bootstrap a test-set AUC, what is held fixed and what is resampled?

Show answer

The trained model, and so every test example’s score, is fixed. The test examples are resampled, each with its label and score together.

2. What is the paired bootstrap?

Show answer

Resampling the test rows once per replicate and scoring both models on the same rows, so that the replicate is the difference of their scores on one resampled test set.

3. Why is the paired interval for a difference narrower than one from separate resamples?

Show answer

The variance of the difference is the sum of the two variances minus twice their covariance, and on shared rows the covariance is positive. Separate resamples set it to 0.

4. What null hypothesis does permutation_test_score test, and how is its p-value computed?

Show answer

That features and labels are independent. With \(C\) of \(K\) shuffled-label scores at least the real one, the p-value is \({(C + 1)/(K + 1)}\).

5. What source of variation does a bootstrap of the test set miss?

Show answer

The variation from training: a different training sample would give a different model. Measuring that needs refitting, as in repeated cross-validation or bagging.

Exercises

1. ★ A test set has two positives with scores 0.8 and 0.3 and three negatives with scores 0.6, 0.2 and 0.1. Compute the AUC.

Show solution

There are \({2 \times 3 = 6}\) pairs. 0.8 beats all three negatives; 0.3 beats 0.2 and 0.1 but not 0.6. So 5 of 6 pairs are right: AUC \({= 5/6 = 0.833}\).

2. ★★ In the exercise above, a resample draws the rows with scores 0.8, 0.8, 0.6, 0.2 and 0.2 (the positive at 0.8 twice, the negative at 0.2 twice). Compute the resample’s AUC.

Show solution

Positives: 0.8 and 0.8. Negatives: 0.6, 0.2 and 0.2. That is \({2 \times 3 = 6}\) pairs, and each copy of 0.8 beats every negative, so all 6 are right: \({\text{AUC}^* = 1}\). The weak positive at 0.3 was left out, and the resample flatters the model.

3. ★★★ Two models’ bootstrap AUCs have standard errors 0.030 and 0.040. Find the standard error of their difference if the AUCs are scored on separate resamples, and if they are paired with correlation 0.8. How much narrower is the paired 95 percent interval, roughly?

Show solution

Separate:

\[ \begin{aligned} &\sqrt{0.030^2 + 0.040^2} \\ &\quad= \sqrt{0.0025} = 0.050 . \end{aligned} \]

Paired: the covariance term is \({2(0.8)(0.030)(0.040)} = 0.00192\), so

\[ \begin{aligned} &\sqrt{0.0009 + 0.0016 - 0.00192} \\ &\quad= \sqrt{0.00058} = 0.024 . \end{aligned} \]

A 95 percent interval is roughly four standard errors wide, so about 0.20 against 0.096: pairing roughly halves its width.

Summary card

A test-set score is a statistic: resample the test rows to see how much it could move.

The anchor figure again: the 133 test patients ranked by score, and the histogram of AUC on 2,000 resampled test sets with the observed 0.842 and the interval 0.769 to 0.902.
S02.8.4-F1

AUC 0.842, interval 0.769 to 0.902.

  • Bootstrap a metric. Resample test rows with their labels and scores; recompute.
  • Compare two models. Same resampled rows for both; interval for the difference.
  • Learned anything? permutation_test_score, p-value \({(C + 1)/(K + 1)}\).
  • Fixed model. Test-set resampling ignores training variation.

Sources and next steps

Sources. Each was opened and checked against this lesson on 15 November 2026.

  • Philipp Koehn, Statistical significance tests for machine translation evaluation, Proceedings of EMNLP 2004, pages 388 to 395. Section 6, Paired Bootstrap Resampling, draws new test sets with replacement, scores both systems on each, and counts how often one beats the other.
  • Markus Ojala and Gemma C. Garriga, Permutation tests for studying classifier performance, Journal of Machine Learning Research 11 (2010), 1833 to 1863. Definition 1 gives the permutation p-value with one added to numerator and denominator; section 3.1 defines Test 1, which permutes the labels, and Test 2, which permutes features within each class.
  • scikit-learn, sklearn.model_selection.permutation_test_score: the arguments cv, scoring, n_permutations (default 100) and random_state, the p-value \({(C + 1)/(n_{\text{permutations}} + 1)}\), and that it implements Test 1 of Ojala and Garriga.

Next. Module S02.9, Multiple testing, asks what happens to p-values like these when many models, features or metrics are tested at once. Bagging, the bootstrap applied to the training set, is in ML02.2.

This lesson unlocks

S02.9 Multiple testing ML02.2 Bagging and random forests

Your progress is saved in this browser only. No account, no tracking.
Back to top