Skip to content
The Lumenary

Math laboratory

Polya's theorem: return probabilities in one, two and three dimensions

Computes P(S_2n = 0) for simple random walk in dimensions 1, 2 and 3 and the partial sums of the expected number of returns: they diverge like sqrt(n) and log(n) in dimensions 1 and 2 and converge in dimension 3. A seeded simulation of 1,000 walks of 1,000 steps shows how many return. Finite computations illustrate, but cannot prove, divergence or convergence.

Back to the labNumerical approximationStatistical evidence
Polya's theorem: return probabilities in one, two and three dimensionsPythonNumerical approximationStatistical evidenceComputationally tested
"""Polya's theorem in numbers: does a random walk come home?

Simple random walk on Z^d returns to the origin with probability 1 when
d = 1 or 2 and with probability less than 1 when d = 3. The criterion is
whether the expected number of returns, sum_n P(S_2n = 0), is infinite. We
compute these return probabilities: exactly from C(2n, n)/4^n in d = 1 and its
square in d = 2, and from the trinomial formula in d = 3. The partial sums
grow like sqrt(n) and log(n) in d = 1 and 2, but level off in d = 3. A numpy
simulation of 1,000 walks of 1,000 steps adds the fraction that came back.
"""

import math

import numpy as np

def return_probs_1d(n_max):
    """[P(S_2n = 0) for n = 0..n_max] in d = 1, via a_(n+1) = a_n (2n+1)/(2n+2)."""
    a = [1.0]
    for n in range(n_max):
        a.append(a[-1] * (2 * n + 1) / (2 * n + 2))
    return a

def return_prob_3d(n):
    """P(S_2n = 0) in d = 3: (1/6^(2n)) C(2n, n) sum_(i+j+k=n) (n!/(i! j! k!))^2.

    Computed with logarithms of factorials (math.lgamma).
    """
    lf = [math.lgamma(m + 1) for m in range(2 * n + 1)]
    log_c = lf[2 * n] - 2 * lf[n]
    total = 0.0
    for i in range(n + 1):
        for j in range(n - i + 1):
            k = n - i - j
            total += math.exp(2 * (lf[n] - lf[i] - lf[j] - lf[k])
                              + log_c - 2 * n * math.log(6))
    return total

def simulate_returns(d, walks=1000, steps=1000, seed=1921):
    """Fraction of walks in Z^d that revisit the origin within ``steps``."""
    rng = np.random.default_rng(seed + d)
    axis = rng.integers(0, d, size=(walks, steps))
    sign = rng.choice(np.array([-1, 1], dtype=np.int16), size=(walks, steps))
    at_origin = np.ones((walks, steps), dtype=bool)
    for c in range(d):
        position = np.cumsum(np.where(axis == c, sign, 0), axis=1)
        at_origin &= position == 0
    return float(at_origin.any(axis=1).mean())

def main():
    a = return_probs_1d(10000)
    print("Expected number of returns up to time 2n: sum of P(S_2k = 0), k = 1..n")
    print(f"{'n':>7} {'d = 1':>9} {'2 sqrt(n/pi)':>13} {'d = 2':>8}"
          f" {'ln(n)/pi':>9}")
    for n in [10, 100, 1000, 10000]:
        s1 = sum(a[1:n + 1])
        s2 = sum(x * x for x in a[1:n + 1])
        print(f"{n:>7} {s1:>9.2f} {2 * math.sqrt(n / math.pi):>13.2f}"
              f" {s2:>8.3f} {math.log(n) / math.pi:>9.3f}")
    u3 = [return_prob_3d(n) for n in range(1, 61)]
    print("d = 3: partial sums at n = 10, 20, 40, 60:",
          ", ".join(f"{sum(u3[:n]):.3f}" for n in [10, 20, 40, 60]))
    tail = sum(2 * (3 / (4 * math.pi * n)) ** 1.5 for n in range(61, 200001))
    print(f"       plus the asymptotic tail estimate {tail:.3f}"
          f" gives about {sum(u3) + tail:.3f}")
    print("       (exact value 0.516..., so the return probability is")
    print("       0.516/1.516 = 0.34 < 1: the walk is transient)")
    print()
    print("Simulation: fraction of 1000 walks back at the origin by step 1000")
    for d in [1, 2, 3]:
        print(f"  d = {d}: {simulate_returns(d):.3f}")
    print()
    print("Epistemic status: the return probabilities are computed exactly up")
    print("to floating-point rounding, but a partial sum can never show that a")
    print("series diverges; the divergence for d = 1, 2 is proved in Lean and")
    print("convergence for d = 3 follows from P(S_2n = 0) <= C n^(-3/2). The")
    print("simulation is statistical evidence about finite walks only.")

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

Used in