Skip to content
The Lumenary

Math laboratory

Polya's urn: a martingale with a random limit

Computes exactly the distribution of red balls in Polya's urn after n draws (uniform on 1..n+1) and checks the martingale identity E[M_(n+1) | R_n = r] = M_n with fractions for every r and n <= 30. A seeded simulation of 1000 urns shows each fraction settling and the limits spreading out uniformly. The exact part is a finite check; the convergence is Doob's theorem.

Back to the labExact computationStatistical evidence
Polya's urn: a martingale with a random limitPythonExact computationStatistical evidenceComputationally tested
"""Polya's urn: a martingale that converges to a random limit.

An urn starts with one red and one blue ball. At each step draw a ball,
return it, and add another of the same colour. The fraction of red balls
M_n = R_n / (n + 2) is a martingale. We verify exactly (with fractions) that
the martingale property holds and that after n draws the number of red balls
is uniformly distributed on {1, ..., n + 1}; so the almost-sure limit of M_n,
which exists by Doob's convergence theorem, is uniform on [0, 1]. A seeded
simulation shows individual urns settling down at different values.
"""

import random
from fractions import Fraction

def red_distribution(n):
    """Exact law of the number of red balls after n draws, as {red: prob}."""
    if n < 0:
        raise ValueError("n must be non-negative")
    dist = {1: Fraction(1)}
    for step in range(n):
        total = step + 2
        new = {}
        for red, p in dist.items():
            up = p * Fraction(red, total)
            new[red + 1] = new.get(red + 1, 0) + up
            new[red] = new.get(red, 0) + p - up
        dist = new
    return dist

def martingale_defect(n, red):
    """E[M_(n+1) | R_n = red] - M_n, exactly (it should be 0)."""
    total = n + 2
    p_up = Fraction(red, total)
    expected = (p_up * Fraction(red + 1, total + 1)
                + (1 - p_up) * Fraction(red, total + 1))
    return expected - Fraction(red, total)

def run_urn(steps, rng):
    """Fractions of red balls after each of the given number of draws."""
    red, total, path = 1, 2, []
    for _ in range(steps):
        if rng.random() < red / total:
            red += 1
        total += 1
        path.append(red / total)
    return path

def main():
    print("Exact law of R_n (number of red balls) after n draws:")
    for n in [1, 2, 3, 10]:
        dist = red_distribution(n)
        values = ", ".join(str(v) for v in sorted(set(dist.values())))
        print(f"  n = {n:>2}: R_n takes each value 1..{n + 1} with prob. {values}")
    worst = max(abs(martingale_defect(n, r))
                for n in range(31) for r in range(1, n + 2))
    print("max |E[M_(n+1) | R_n] - M_n| over n <= 30, all r:", worst)
    print()
    rng = random.Random(1923)
    print("Six urns, fraction of red after 10, 100, 1000, 4000 draws:")
    for i in range(6):
        path = run_urn(4000, rng)
        cells = "  ".join(f"{path[k - 1]:.3f}" for k in [10, 100, 1000, 4000])
        print(f"  urn {i + 1}: {cells}")
    limits = [run_urn(300, rng)[-1] for _ in range(1000)]
    deciles = [0] * 10
    for x in limits:
        deciles[min(int(x * 10), 9)] += 1
    print("1000 urns after 300 draws, counts per tenth of [0, 1]:")
    print("  ", deciles, "(uniform: about 100 each)")
    print()
    print("Epistemic status: the uniform law and the martingale identity are")
    print("exact computations for n <= 30 (the lesson proves them for all n).")
    print("The urn runs are seeded simulations: statistical illustration of")
    print("Doob's theorem (almost-sure convergence), not a proof of it.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in