Dictionary of Applied Machine Learning · probability distribution

probability distribution — Python demo

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

One block per paragraph of the entry (marked [P...]): each block verifies numerically what the corresponding statement asserts. Self-contained (numpy/matplotlib only), fixed seed.

Run it with python3 probdist.py, from any directory — it writes its output files into the current directory. Requires NumPy and Matplotlib only, and uses fixed seeds, so the printed numbers reproduce exactly. Download probdist.py

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

"""
probdist.py — numerical companion to the glossary entry
'probability distribution'.

One block per paragraph of the entry (marked [P...]): each block verifies
numerically what the corresponding statement asserts. Self-contained
(numpy/matplotlib only), fixed seed.

The method used throughout is the simplest one available: it reads a data
set of numbers and returns their average, which is the hypothesis that
minimizes the average squared error loss over all constant hypotheses. That
keeps every quantity below available in closed form, so a check compares a
measured number against a formula rather than against another simulation.

Blocks
------
[P-method]  One distribution, many data sets, a different output each time:
            the outputs of the method have a spread of their own, and that
            spread shrinks as sigma/sqrt(m) with the data set size m.
[P-typical] The distribution decides which data points are typical (relative
            frequencies of a growing sample converge to it), and the two
            numbers attached to the method — the training error on the data
            set it was given, and the risk on a fresh data point — are random
            as well. Their gap shrinks like 2*sigma^2/m, which is what
            generalization asserts here; the check is against the Monte
            Carlo error of the estimate, not a hand-picked tolerance.
[P-specify] How a distribution is specified: a binary RV by the single
            probability P(y = 0), and a continuous real-valued RV by a pdf p,
            for which P(x in [a, b]) ~ p(a)|b - a| on a short interval.

Outputs
-------
probdist.png : preview figure (checking only).

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

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

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

MU, SIGMA = 1.0, 2.0            # the common distribution: N(MU, SIGMA^2)
NR_DATASETS = 4000


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


def method(data):
    """The ML method: the average, i.e. the constant hypothesis minimizing
    the average squared error loss on `data`."""
    return float(np.mean(data))

P-method

One distribution, many data sets, a different output each time: the outputs of the method have a spread of their own, and that spread shrinks as sigma/sqrt(m) with the data set size m.

print("[P-method] one distribution, many data sets, a different output each time")
outputs = {}
for m in (25, 400):
    outputs[m] = np.array([method(rng.normal(MU, SIGMA, m))
                           for _ in range(NR_DATASETS)])
spread = {m: float(np.std(out)) for m, out in outputs.items()}
print(f"    output spread: {spread[25]:.4f} at m=25, {spread[400]:.4f} at m=400"
      f"  (sigma/sqrt(m) = {SIGMA / np.sqrt(25):.4f}, "
      f"{SIGMA / np.sqrt(400):.4f})")
check("two data sets from the same distribution give different outputs",
      outputs[25][0] != outputs[25][1])
check("the spread of the outputs shrinks with the data set size",
      spread[400] < spread[25])
for m in (25, 400):
    check(f"the spread matches sigma/sqrt(m) within 5% at m={m}",
          abs(spread[m] - SIGMA / np.sqrt(m)) / (SIGMA / np.sqrt(m)) < 0.05)
[P-method] one distribution, many data sets, a different output each time
    output spread: 0.4034 at m=25, 0.0979 at m=400  (sigma/sqrt(m) = 0.4000, 0.1000)
  [ok] two data sets from the same distribution give different outputs
  [ok] the spread of the outputs shrinks with the data set size
  [ok] the spread matches sigma/sqrt(m) within 5% at m=25
  [ok] the spread matches sigma/sqrt(m) within 5% at m=400

P-typical

The distribution decides which data points are typical (relative frequencies of a growing sample converge to it), and the two numbers attached to the method — the training error on the data set it was given, and the risk on a fresh data point — are random as well. Their gap shrinks like 2*sigma^2/m, which is what generalization asserts here; the check is against the Monte Carlo error of the estimate, not a hand-picked tolerance.

print("[P-typical] typical data points, and the two random numbers of the method")
p_true = np.array([0.5, 0.3, 0.2])              # distribution on {0, 1, 2}
errs = []
for m in (10**2, 10**4, 10**6):
    draws = rng.choice(3, size=m, p=p_true)
    errs.append(np.max(np.abs(np.bincount(draws, minlength=3) / m - p_true)))
print(f"    max |frequency - p| for m=1e2,1e4,1e6: "
      f"{errs[0]:.4f}, {errs[1]:.4f}, {errs[2]:.4f}")
check("relative frequencies converge to the distribution", errs[0] > errs[2])
check("frequencies at m=1e6 within 2e-3 of the distribution", errs[2] < 2e-3)

# For the average as hypothesis, both numbers are available in closed form:
# the training error is the sample variance, and the risk on a fresh data
# point is SIGMA^2 + (output - MU)^2. Their expected gap is 2*SIGMA^2/m.
gaps = {}
for m in (25, 400):
    data = rng.normal(MU, SIGMA, (NR_DATASETS, m))
    out = data.mean(axis=1)
    trainerr = ((data - out[:, None]) ** 2).mean(axis=1)
    risk = SIGMA ** 2 + (out - MU) ** 2
    gaps[m] = float(np.mean(risk - trainerr))
    # the gap is itself averaged over data sets, so it carries a Monte Carlo
    # error; comparing against the formula only makes sense relative to that
    stderr = float(np.std(risk - trainerr) / np.sqrt(NR_DATASETS))
    print(f"    m={m:>3}: training error {trainerr.mean():.4f}, "
          f"risk {risk.mean():.4f}, gap {gaps[m]:.4f} +- {stderr:.4f} "
          f"(2*sigma^2/m = {2 * SIGMA ** 2 / m:.4f})")
    check(f"the gap agrees with 2*sigma^2/m at m={m} "
          f"(within three standard errors)",
          abs(gaps[m] - 2 * SIGMA ** 2 / m) < 3 * stderr)
check("training error and risk move closer as m grows", gaps[400] < gaps[25])
[P-typical] typical data points, and the two random numbers of the method
    max |frequency - p| for m=1e2,1e4,1e6: 0.1200, 0.0010, 0.0004
  [ok] relative frequencies converge to the distribution
  [ok] frequencies at m=1e6 within 2e-3 of the distribution
    m= 25: training error 3.8156, risk 4.1630, gap 0.3474 +- 0.0179 (2*sigma^2/m = 0.3200)
  [ok] the gap agrees with 2*sigma^2/m at m=25 (within three standard errors)
    m=400: training error 3.9861, risk 4.0104, gap 0.0243 +- 0.0046 (2*sigma^2/m = 0.0200)
  [ok] the gap agrees with 2*sigma^2/m at m=400 (within three standard errors)
  [ok] training error and risk move closer as m grows

P-specify

How a distribution is specified: a binary RV by the single probability P(y = 0), and a continuous real-valued RV by a pdf p, for which P(x in [a, b]) ~ p(a)|b - a| on a short interval.

print("[P-specify] one probability specifies a binary RV; a pdf a continuous one")
p0 = 0.73
y = (rng.uniform(size=10**6) >= p0).astype(int)   # P(y = 0) = p0
f0 = float(np.mean(y == 0))
check("empirical P(y = 0) recovers p0 = 0.73", abs(f0 - p0) < 2e-3)
check("P(y = 1) = 1 - P(y = 0)", np.isclose(np.mean(y == 1), 1 - f0))

pdf = lambda t: np.exp(-t ** 2 / 2) / np.sqrt(2 * np.pi)
x = rng.standard_normal(10**7)
a = 0.5
rel_errs = []
for width in (0.5, 0.1, 0.02):
    p_emp = np.mean((x >= a) & (x <= a + width))
    rel_errs.append(abs(p_emp - pdf(a) * width) / p_emp)
print(f"    relative approximation error for |b-a|=0.5,0.1,0.02: "
      f"{rel_errs[0]:.3f}, {rel_errs[1]:.3f}, {rel_errs[2]:.3f}")
check("the pdf approximation improves as the interval shrinks",
      rel_errs[0] > rel_errs[1] > rel_errs[2])
check("relative error below 1% for |b - a| = 0.02", rel_errs[2] < 0.01)

# ------------------------------------------------------------ preview
fig, ax = plt.subplots(1, 2, figsize=(8.4, 3.0))
for m, style in ((25, "--"), (400, "-")):
    ax[0].hist(outputs[m], bins=60, histtype="step", density=True,
               color="k", linestyle=style, label=f"m = {m}")
ax[0].set_xlabel("output of the method (the average)")
ax[0].set_ylabel("density over data sets")
ax[0].set_title("outputs of one method over 4000 data sets")
ax[0].legend(frameon=False)

t = np.linspace(-4, 4, 400)
ax[1].plot(t, pdf(t), "k-")
ax[1].fill_between(t, pdf(t), where=(t >= a) & (t <= a + 0.5),
                   facecolor="none", hatch="///", edgecolor="k")
ax[1].set_xlabel("value of the RV")
ax[1].set_ylabel("probability density p")
ax[1].set_title("P(x in [a, b]) is the shaded area")
fig.tight_layout()
fig.savefig("probdist.png", dpi=110)

print(f"\n{sum(ok for _, ok in report)}/{len(report)} checks passed")
assert all(ok for _, ok in report)
[P-specify] one probability specifies a binary RV; a pdf a continuous one
  [ok] empirical P(y = 0) recovers p0 = 0.73
  [ok] P(y = 1) = 1 - P(y = 0)
    relative approximation error for |b-a|=0.5,0.1,0.02: 0.175, 0.028, 0.006
  [ok] the pdf approximation improves as the interval shrinks
  [ok] relative error below 1% for |b - a| = 0.02

13/13 checks passed
Preview figure produced by probdist.py
The preview figure the block P-specify writes when the script runs