Dictionary of Applied Machine Learning · bootstrap

bootstrap — Python demo

Numerical companion to the entry bootstrap: it recomputes what the entry states and prints one line per check

Three years of daily minimum and maximum temperature at Krems an der Donau stand in for the dataset D. The blocks below read those days as realizations of i.i.d. RVs, replace their unknown probability distribution by the empirical distribution of D, and draw from it. Self-contained (numpy/matplotlib only, data fetched from the GeoSphere Austria archive), fixed seed.

Run it without installing anything:
uv run https://dictionaryofml.org/terms/bootstrap.py
uv downloads this script and the pinned NumPy and Matplotlib it needs, then runs it; the script fetches any input file it uses. To keep the output files, download bootstrap.py into a folder and run uv run bootstrap.py there. With NumPy and Matplotlib already installed, python3 bootstrap.py, from any directory — it writes its output files into the current directory. Fixed seeds, so the printed numbers reproduce exactly. Download bootstrap.py · Notebook · Open in Colab

The script, block by block

One cell per block of the script: the code, and what that code printed when it last ran here

setup

"""
bootstrap.py — numerical companion to the glossary entry 'bootstrap'.

Three years of daily minimum and maximum temperature at Krems an der
Donau stand in for the dataset D. The blocks below read those days as
realizations of i.i.d. RVs, replace their unknown probability
distribution by the empirical distribution of D, and draw from it.
Self-contained (numpy/matplotlib only, data fetched from the GeoSphere
Austria archive), fixed seed.

Blocks
------
[B-data]    1096 days of (tmin, tmax) at Krems, 2022-2024. This cloud of
            points is the dataset D; the entry's picture of it is the
            scatterplot of the left panel.
[B-empdist] The empirical distribution P^(D) puts mass 1/m on each of
            the m days. Drawing m days from it is drawing from D with
            replacement, and a draw holds about 0.63 m distinct days.
            P^(D) is a complete distribution, so it can be sampled as
            often as wanted: drawing 100 m days from it is no problem,
            and the mean of such a draw converges to the mean of D, not
            to the mean of the unknown P. The gain is replicates, not
            information.
[B-fit]     ERM with the squared error loss fits tmax = w0 + w1 tmin
            on D, which is least squares. Refitting on
            B = 500 bootstrap datasets gives B slopes whose 2.5th to
            97.5th percentile range is a confidence interval for the
            slope; it shrinks like 1/sqrt(m) when m is cut to a quarter.
[B-testci]  A threshold rule predicts a frost night (tmin < 0) from the
            day's tmax. The rule is learned once on a training set and
            its accuracy measured on a held-out test set; resampling
            that test set B times, with the rule held fixed, turns the
            single accuracy into a confidence interval.
[B-smooth]  Smearing each day with a Gaussian kernel turns the m spikes
            of P^(D) into a density estimate, drawn as the contours of
            the right panel. Sampling that density instead of the spikes
            is the smoothed bootstrap: it produces days that D does not
            contain, which the plain bootstrap never does.

Outputs
-------
bootstrap_krems.csv    : tmin, tmax of the 1096 days (figure data).
bootstrap_fit.csv      : the learned line as one two-point segment.
bootstrap_lines.csv    : 25 bootstrap lines, two-point segments separated
                         by empty lines.
bootstrap_density.csv  : contour polylines of the smeared distribution,
                         same separation.
bootstrap.png          : preview figure (checking only).

Data generated by pythondemos/bootstrap.py.
"""

import json
import urllib.request
import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from pathlib import Path

OUT_DIR = Path(__file__).parent
ARCHIVE = "https://dataset.api.hub.geosphere.at/v1/station/historical/"
STATION = 3805                      # Krems an der Donau
NR_BOOTSTRAP = 500
SEED = 20220101

rng = np.random.default_rng(SEED)
report = []


def check(name, ok):
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")


def fetch(parameters, start, end):
    """Daily parameters of one station from the GeoSphere Austria archive."""
    url = (f"{ARCHIVE}klima-v2-1d?parameters={','.join(parameters)}"
           f"&station_ids={STATION}&start={start}&end={end}")
    with urllib.request.urlopen(url, timeout=180) as resp:
        payload = json.load(resp)
    values = payload["features"][0]["properties"]["parameters"]
    return np.stack([np.array(values[p]["data"], dtype=float)
                     for p in parameters], axis=1)


def least_squares_line(x, y):
    """Slope and intercept of the least-squares fit of y on x."""
    design = np.stack([np.ones_like(x), x], axis=1)
    weights = np.linalg.lstsq(design, y, rcond=None)[0]
    return weights[1], weights[0]


def write_segments(name, header, segments):
    """Polylines, one blank line between them (pgfplots 'empty line=jump')."""
    with open(OUT_DIR / name, "w") as f:
        f.write(header + "\n")
        for k, seg in enumerate(segments):
            if k:
                f.write("\n")
            for px, py in seg:
                f.write(f"{px:.4f},{py:.4f}\n")

B-data

1096 days of (tmin, tmax) at Krems, 2022-2024. This cloud of points is the dataset D; the entry's picture of it is the scatterplot of the left panel.

days = fetch(["tlmin", "tlmax"], "2022-01-01", "2024-12-31")
tmin, tmax = days[:, 0], days[:, 1]
m = tmin.size
print(f"[B-data] {m} days at Krems an der Donau, 2022-2024; "
      f"tmin {tmin.min():.1f} to {tmin.max():.1f}, "
      f"tmax {tmax.min():.1f} to {tmax.max():.1f} degrees")
check("[B-data] three full years, no gaps", m == 1096 and not np.isnan(days).any())
with open(OUT_DIR / "bootstrap_krems.csv", "w") as f:
    f.write("tmin,tmax\n")
    for a, b in zip(tmin, tmax):
        f.write(f"{a:.1f},{b:.1f}\n")
[B-data] 1096 days at Krems an der Donau, 2022-2024; tmin -13.5 to 22.0, tmax -3.6 to 35.7 degrees
  [ok] [B-data] three full years, no gaps

B-empdist

The empirical distribution P^(D) puts mass 1/m on each of the m days. Drawing m days from it is drawing from D with replacement, and a draw holds about 0.63 m distinct days. P^(D) is a complete distribution, so it can be sampled as often as wanted: drawing 100 m days from it is no problem, and the mean of such a draw converges to the mean of D, not to the mean of the unknown P. The gain is replicates, not information.

print("[B-empdist] P^(D) puts mass 1/m on each day; drawing from it is "
      "drawing from D with replacement")
idx = rng.integers(0, m, size=m)
check("[B-empdist] a draw of m days holds about 0.63 m distinct days",
      abs(np.unique(idx).size / m - (1 - np.exp(-1.0))) < 0.02)
check("[B-empdist] every drawn day is a day of D",
      np.all(np.isin(tmin[idx], tmin)))
huge = rng.integers(0, m, size=100 * m)
check("[B-empdist] a draw of 100 m days is no problem, and its mean "
      "converges to the mean of D, not to anything new",
      abs(tmax[huge].mean() - tmax.mean()) < 0.1)
check("[B-empdist] such a draw still contains no day outside D",
      np.unique(tmax[huge]).size <= np.unique(tmax).size)
[B-empdist] P^(D) puts mass 1/m on each day; drawing from it is drawing from D with replacement
  [ok] [B-empdist] a draw of m days holds about 0.63 m distinct days
  [ok] [B-empdist] every drawn day is a day of D
  [ok] [B-empdist] a draw of 100 m days is no problem, and its mean converges to the mean of D, not to anything new
  [ok] [B-empdist] such a draw still contains no day outside D

B-fit

ERM with the squared error loss fits tmax = w0 + w1 tmin on D, which is least squares. Refitting on B = 500 bootstrap datasets gives B slopes whose 2.5th to 97.5th percentile range is a confidence interval for the slope; it shrinks like 1/sqrt(m) when m is cut to a quarter.

slope, intercept = least_squares_line(tmin, tmax)
print(f"[B-fit] tmax = {intercept:.2f} + {slope:.2f} tmin on all {m} days")
slopes = np.empty(NR_BOOTSTRAP)
lines = []
for b in range(NR_BOOTSTRAP):
    draw = rng.integers(0, m, size=m)
    slopes[b], icept = least_squares_line(tmin[draw], tmax[draw])
    if b < 25:
        lines.append([(tmin.min(), icept + slopes[b] * tmin.min()),
                      (tmin.max(), icept + slopes[b] * tmin.max())])
lo, hi = np.percentile(slopes, [2.5, 97.5])
print(f"[B-fit] 95% confidence interval for the slope: [{lo:.3f}, {hi:.3f}]")
check("[B-fit] the interval covers the slope learned from all of D",
      lo <= slope <= hi)
check("[B-fit] the interval is narrow, a few percent of the slope",
      (hi - lo) / slope < 0.12)
quarter = rng.choice(m, size=m // 4, replace=False)
narrow = np.array([least_squares_line(*(lambda d: (tmin[quarter][d],
                                                   tmax[quarter][d]))(
    rng.integers(0, m // 4, size=m // 4)))[0] for _ in range(NR_BOOTSTRAP)])
wide = np.percentile(narrow, 97.5) - np.percentile(narrow, 2.5)
print(f"[B-fit] on a quarter of the days the interval is {wide / (hi - lo):.1f} "
      "times as wide")
check("[B-fit] cutting m to a quarter roughly doubles the interval",
      1.6 < wide / (hi - lo) < 2.6)
# the learned line goes in its own file: the book figure draws it thick and
# the replicates thin, so one file with one style would hide the difference
write_segments("bootstrap_fit.csv", "tmin,tmax",
               [[(tmin.min(), intercept + slope * tmin.min()),
                 (tmin.max(), intercept + slope * tmin.max())]])
write_segments("bootstrap_lines.csv", "tmin,tmax", lines)
[B-fit] tmax = 10.04 + 1.10 tmin on all 1096 days
[B-fit] 95% confidence interval for the slope: [1.067, 1.131]
  [ok] [B-fit] the interval covers the slope learned from all of D
  [ok] [B-fit] the interval is narrow, a few percent of the slope
[B-fit] on a quarter of the days the interval is 2.1 times as wide
  [ok] [B-fit] cutting m to a quarter roughly doubles the interval

B-testci

A threshold rule predicts a frost night (tmin < 0) from the day's tmax. The rule is learned once on a training set and its accuracy measured on a held-out test set; resampling that test set B times, with the rule held fixed, turns the single accuracy into a confidence interval.

frost = (tmin < 0.0).astype(int)
order = rng.permutation(m)
train, test = order[:800], order[800:]
grid = np.linspace(tmax.min(), tmax.max(), 400)
errors = [(np.mean((tmax[train] <= t).astype(int) != frost[train]), t)
          for t in grid]
threshold = min(errors)[1]
predict = lambda x: (x <= threshold).astype(int)
acc_test = float(np.mean(predict(tmax[test]) == frost[test]))
print(f"[B-testci] threshold {threshold:.1f} degrees learned on "
      f"{train.size} days; accuracy on the {test.size} test days "
      f"{acc_test:.3f}")
check("[B-testci] the learned rule is better than always predicting "
      "no frost", acc_test > 1 - frost[test].mean())
accs = np.array([float(np.mean(predict(tmax[test][d]) == frost[test][d]))
                 for d in rng.integers(0, test.size,
                                       size=(NR_BOOTSTRAP, test.size))])
acc_lo, acc_hi = np.percentile(accs, [2.5, 97.5])
print(f"[B-testci] 95% confidence interval for that accuracy: "
      f"[{acc_lo:.3f}, {acc_hi:.3f}]")
check("[B-testci] the interval covers the measured accuracy",
      acc_lo <= acc_test <= acc_hi)
check("[B-testci] it is several points wide, which the single number "
      "does not show", 0.02 < acc_hi - acc_lo < 0.12)
check("[B-testci] the hypothesis never changes: only the test set is "
      "redrawn", predict(np.array([threshold])).item() == 1)
[B-testci] threshold 7.2 degrees learned on 800 days; accuracy on the 296 test days 0.858
  [ok] [B-testci] the learned rule is better than always predicting no frost
[B-testci] 95% confidence interval for that accuracy: [0.821, 0.897]
  [ok] [B-testci] the interval covers the measured accuracy
  [ok] [B-testci] it is several points wide, which the single number does not show
  [ok] [B-testci] the hypothesis never changes: only the test set is redrawn

B-smooth

Smearing each day with a Gaussian kernel turns the m spikes of P^(D) into a density estimate, drawn as the contours of the right panel. Sampling that density instead of the spikes is the smoothed bootstrap: it produces days that D does not contain, which the plain bootstrap never does.

h_min, h_max = 1.4, 1.8                       # kernel widths, degrees
gx = np.linspace(tmin.min() - 3, tmin.max() + 3, 160)
gy = np.linspace(tmax.min() - 3, tmax.max() + 3, 160)
GX, GY = np.meshgrid(gx, gy)
dens = np.zeros_like(GX)
for a, b in zip(tmin, tmax):
    dens += np.exp(-0.5 * (((GX - a) / h_min) ** 2 + ((GY - b) / h_max) ** 2))
dens /= m * 2 * np.pi * h_min * h_max
cell = (gx[1] - gx[0]) * (gy[1] - gy[0])
print(f"[B-smooth] kernel widths {h_min} and {h_max} degrees; the smeared "
      f"density integrates to {dens.sum() * cell:.3f}")
check("[B-smooth] the density integrates to one", abs(dens.sum() * cell - 1) < 0.02)
counts, edges = np.histogram(tmin, bins=20)
mode_tmin = 0.5 * (edges[counts.argmax()] + edges[counts.argmax() + 1])
peak_tmin = gx[np.unravel_index(dens.argmax(), dens.shape)[1]]
check(f"[B-smooth] its peak ({peak_tmin:.1f} degrees) sits where the days "
      f"pile up ({mode_tmin:.1f}), not at the median ({np.median(tmin):.1f})",
      abs(peak_tmin - mode_tmin) < 1.5)
smoothed = (tmin[rng.integers(0, m, size=20000)]
            + h_min * rng.standard_normal(20000))
check("[B-smooth] the smoothed bootstrap produces days outside D, which "
      "the plain bootstrap never does",
      not np.any(np.isin(smoothed, tmin)))
levels = np.array([0.1, 0.4, 0.7]) * dens.max()

# ------------------------------------------------------------- preview
fig, ax = plt.subplots(1, 3, figsize=(13.2, 3.6))
ax[0].plot(tmin, tmax, ".", color="0.55", markersize=2.5, label="day")
for seg in lines[:25]:
    ax[0].plot([seg[0][0], seg[1][0]], [seg[0][1], seg[1][1]],
               "-", color="0.3", linewidth=0.4)
ax[0].plot([tmin.min(), tmin.max()],
           [intercept + slope * tmin.min(), intercept + slope * tmin.max()],
           "k-", linewidth=1.8, label="learned line")
ax[0].plot([], [], "-", color="0.3", linewidth=0.4, label="bootstrap lines")
ax[0].set_xlabel("minimum temperature (degC)")
ax[0].set_ylabel("maximum temperature (degC)")
ax[0].set_title("[B-fit] the dataset and the spread of the fits", fontsize=10)
ax[0].legend(frameon=False, fontsize=8, loc="upper left")
ax[1].plot(tmin, tmax, ".", color="0.75", markersize=2)
contours = ax[1].contour(GX, GY, dens, levels=levels, colors="black",
                         linewidths=0.9)
# the book figure draws the same contours, so its polylines are taken from
# this panel rather than from a second, throwaway axes
segments = [seg for level in contours.allsegs for seg in level if len(seg) > 8]
write_segments("bootstrap_density.csv", "tmin,tmax", segments)
print(f"[B-smooth] {len(segments)} contour polylines written")
ax[1].set_xlabel("minimum temperature (degC)")
ax[1].set_ylabel("maximum temperature (degC)")
ax[1].set_title("[B-smooth] the same days, smeared into a density",
                fontsize=10)
ax[2].hist(slopes, bins=30, color="white", edgecolor="black")
ax[2].axvline(slope, color="black", linestyle="-", linewidth=1.5,
              label="slope on all days")
ax[2].axvline(lo, color="black", linestyle="--", linewidth=1.0,
              label="95% interval")
ax[2].axvline(hi, color="black", linestyle="--", linewidth=1.0)
ax[2].set_xlabel("slope of the bootstrap line")
ax[2].set_ylabel("number of bootstrap datasets")
ax[2].set_title("[B-fit] the slopes of 500 replicates", fontsize=10)
ax[2].legend(frameon=False, fontsize=8)
fig.tight_layout()
fig.savefig(OUT_DIR / "bootstrap.png", dpi=110)
print(f"\n{sum(ok for _, ok in report)}/{len(report)} checks passed")
assert all(ok for _, ok in report)
[B-smooth] kernel widths 1.4 and 1.8 degrees; the smeared density integrates to 1.000
  [ok] [B-smooth] the density integrates to one
  [ok] [B-smooth] its peak (2.6 degrees) sits where the days pile up (3.4), not at the median (6.8)
  [ok] [B-smooth] the smoothed bootstrap produces days outside D, which the plain bootstrap never does
[B-smooth] 4 contour polylines written

15/15 checks passed
Preview figure produced by bootstrap.py
The preview figure the block B-smooth writes when the script runs