Compound & Nonparametric Distributions

Python · R  ·  Download module

The Catalog

Folder 8 and the close of the Statistical Distributions catalog. Every law before this one had a fixed, finite parameter — a rate, a shape, a covariance matrix. Here the parameter grows up. A compound law sums a random number of random jumps; a nonparametric prior is a distribution over entire distributions, or over entire functions. These are the laws behind flexible modern modelling: aggregate insurance losses, mixture models with an unknown number of clusters, and Bayesian regression with no fixed functional form.

ObjectWhat the "parameter" becomesBuilt by
Tweedie (compound Poisson-Gamma) a random sum of a random number of jumps NPoissonN\sim\text{Poisson}, then NN Gamma jumps
Dirichlet process an entire random distribution stick-breaking — repeated Beta(1,α)\text{Beta}(1,\alpha) snaps
Chinese Restaurant Process a random partition — clusters, unbounded in number sequential seating
Gaussian process an entire random function a kernel, then Cholesky

Validating an object with no density

None of these objects has an ordinary density to compare against a library, so the validation strategy changes with them: each is checked through its construction and its moments, and — because they are processes rather than numbers — drawn. The Tweedie's simulated mean, variance and zero-mass are matched against the closed forms (μ=3.0\mu=3.0, ϕμp=8.699\phi\mu^p=8.699, Pr(Y=0)=0.0753\Pr(Y=0)=0.0753); the stick-breaking weights against their exact expectations E[w1]=1/(1+α)\mathbb{E}[w_1]=1/(1+\alpha) and E[w2]=α/(1+α)2\mathbb{E}[w_2]=\alpha/(1+\alpha)^2; and the Chinese Restaurant Process's simulated cluster counts against the exact E[#clusters]=i=1nα/(α+i1)\mathbb{E}[\#\text{clusters}]=\sum_{i=1}^{n}\alpha/(\alpha+i-1) across three decades of nn.

The compound law

The compound law. Sum NPoisson(λ)N\sim\text{Poisson}(\lambda) independent Gamma jumps and the result — the Tweedie, or compound Poisson-Gamma — has an atom at zero (whenever N=0N=0) and a continuous positive part, which is exactly the shape of aggregate insurance claims: many policies with no claim, a long right tail among those that do. Its variance follows the power law Var=ϕμp\operatorname{Var}=\phi\mu^{p}, and the notebooks show the reparameterisation explicitly, reporting the underlying Poisson rate and Gamma shape and scale that the (μ,ϕ,p)(\mu,\phi,p) triple implies.

Y=i=1NXi,NPoisson(λ), XiGammaVar(Y)=ϕμp,  1<p<2Y=\sum_{i=1}^{N}X_i,\quad N\sim\text{Poisson}(\lambda),\ X_i\sim\text{Gamma}\qquad\Longrightarrow\qquad \operatorname{Var}(Y)=\phi\mu^{p},\ \ 1<p<2

A random distribution

A random distribution. Stick-breaking takes a unit stick and repeatedly snaps off a Beta(1,α)\text{Beta}(1,\alpha) fraction, producing weights that sum to one — a random discrete distribution sitting on random atoms. That is the Dirichlet process, drawn here four times over so the randomness of the measure itself is visible, and its clustering view is the Chinese Restaurant Process, whose occupied-table count grows like αlogn\alpha\log n. This is the prior behind mixture models that learn how many components they need instead of being told.

wk=Vkj<k(1Vj),VkBeta(1,α)(stick-breaking / GEM)w_k=V_k\prod_{j<k}(1-V_j),\qquad V_k\sim\text{Beta}(1,\alpha)\qquad\text{(stick-breaking / GEM)}

A random function

A random function. A Gaussian process puts a Gaussian prior on function values, coupled through a kernel, so that any finite set of inputs has a multivariate-Normal joint — the direct continuation of Folder 6. The kernel is the modelling choice, and the notebooks make that concrete by drawing prior functions under RBF and Matérn kernels at the same variance but different regularity, and by displaying the kernel matrix itself: that matrix is the function's covariance. Conditioning on data gives a closed-form posterior, shown with its uncertainty band tight at the observations and widening in the gaps and beyond, plus posterior function draws and a two-dimensional input surface — a random landscape.

What each construction costs

The closing timing table prices the three constructions honestly. The Tweedie and stick-breaking vectorise and run in the millions and hundreds of thousands of draws per second; the CRP is inherently sequential — each customer's seat depends on every seat before it — and runs at hundreds of draws per second; and the GP is Cholesky-bound at O(m3)O(m^3) in the number of inputs, which is precisely why the scalable GP literature (inducing points, sparse kernels) exists at all.

Notebooks

Downloads

Module — Source Code

"""
compound_np.py -- From-scratch COMPOUND & NONPARAMETRIC distributions.

Backs the notebooks in  Z-Statistical Distributions-Compound-Nonparametric
(Folder 8 of the Statistical Distributions catalog -- the final folder).

Here the "parameter" grows up: from a fixed vector to an entire random
DISTRIBUTION or an entire random FUNCTION.
  * COMPOUND -- the Tweedie (compound Poisson-Gamma): a random sum of a random
    number of Gamma jumps, giving a distribution with a point mass at zero and a
    continuous positive part.
  * NONPARAMETRIC -- the Dirichlet process / stick-breaking (GEM), a prior over
    probability distributions, and the Gaussian process, a prior over functions.

Everything is implemented from the definitions using elementary math and the
Gamma / Normal samplers built in the earlier folders. Where a law has no ordinary
density (Tweedie, the DP) we validate through its CONSTRUCTION and its moments.

Highlights (the from-scratch algorithms):
    Tweedie           : N ~ Poisson(lambda), Y = sum of N Gamma jumps
    GEM / stick-break : w_k = beta_k prod_{j<k}(1-beta_j),  beta_k ~ Beta(1, alpha)
    Chinese Restaurant: sequential seating with prob proportional to table size / alpha
    Gaussian process  : f ~ N(0, K) via Cholesky of a kernel matrix; closed-form posterior
"""

import numpy as np


# ===========================================================================  #
#  COMPOUND -- Tweedie (compound Poisson-Gamma), 1 < p < 2                       #
# ===========================================================================  #

def tweedie_params(mu, phi, p):
    """Map the Tweedie (mean mu, dispersion phi, power p) to the compound
    Poisson-Gamma pieces: Poisson rate lambda, Gamma shape a, Gamma scale theta."""
    lam = mu ** (2 - p) / (phi * (2 - p))
    a = (2 - p) / (p - 1)
    theta = phi * (p - 1) * mu ** (p - 1)
    return lam, a, theta

def tweedie_rng(mu, phi, p, n, rng):
    """N ~ Poisson(lambda); Y = sum of N i.i.d. Gamma(a, theta) jumps (Y=0 when N=0).
    (The Poisson count uses numpy's sampler; the Poisson RNG itself was built from
    scratch in Folder 1, the Gamma jumps from the Marsaglia-Tsang sampler below.)"""
    lam, a, theta = tweedie_params(mu, phi, p)
    N = rng.poisson(lam, n)
    Ntot = int(N.sum())
    jumps = theta * _gamma_shape(a, Ntot, rng) if Ntot > 0 else np.zeros(0)
    Y = np.zeros(n)
    idx = np.repeat(np.arange(n), N)
    np.add.at(Y, idx, jumps)
    return Y

def tweedie_mean_var(mu, phi, p):
    return dict(mean=mu, var=phi * mu ** p)

def tweedie_p0(mu, phi, p):
    """Probability of an exact zero: P(N=0) = exp(-lambda)."""
    lam, _, _ = tweedie_params(mu, phi, p)
    return np.exp(-lam)


# ===========================================================================  #
#  NONPARAMETRIC (i) -- Dirichlet process: stick-breaking (GEM) & the CRP        #
# ===========================================================================  #

def gem_rng(alpha, K, n, rng):
    """Stick-breaking weights (GEM(alpha)), truncated at K sticks, for n draws.
    beta_k ~ Beta(1, alpha) = 1 - U^{1/alpha};  w_k = beta_k * prod_{j<k}(1-beta_j).
    Returns (n, K); each row is a probability vector (up to the truncation remainder)."""
    b = 1.0 - rng.random((n, K)) ** (1.0 / alpha)            # Beta(1, alpha), closed form
    one = np.ones((n, 1))
    rem = np.cumprod(1 - b, axis=1)                          # prod_{j<=k}(1-beta_j)
    rem_shift = np.hstack([one, rem[:, :-1]])                # prod_{j<k}(1-beta_j)
    return b * rem_shift

def crp_rng(alpha, n_customers, rng):
    """Chinese Restaurant Process: customer i joins an occupied table with probability
    proportional to its size, or starts a new one with probability proportional to alpha.
    Returns the table assignment of each customer and the final table sizes."""
    sizes = []
    assign = np.zeros(n_customers, int)
    for i in range(n_customers):
        w = np.array(sizes + [alpha], float); w /= w.sum()
        j = np.searchsorted(np.cumsum(w), rng.random())
        if j == len(sizes):
            sizes.append(1)
        else:
            sizes[j] += 1
        assign[i] = j
    return assign, np.array(sizes)

def dp_expected_clusters(alpha, n):
    """E[#clusters] = sum_{i=1}^n alpha/(alpha+i-1)  (~ alpha*log(1+n/alpha))."""
    i = np.arange(1, n + 1)
    return np.sum(alpha / (alpha + i - 1))

def dp_random_measure(alpha, atoms_rng, K, rng):
    """One draw of a Dirichlet-process random distribution truncated at K: stick-breaking
    weights on atoms drawn from the base measure atoms_rng(K). Returns (locations, weights)."""
    w = gem_rng(alpha, K, 1, rng)[0]
    locs = atoms_rng(K)
    return locs, w


# ===========================================================================  #
#  NONPARAMETRIC (ii) -- the Gaussian process (a prior over functions)          #
# ===========================================================================  #

def rbf_kernel(X1, X2, lengthscale=1.0, variance=1.0):
    X1 = np.atleast_2d(X1); X2 = np.atleast_2d(X2)
    d2 = np.sum(X1 ** 2, 1)[:, None] + np.sum(X2 ** 2, 1)[None, :] - 2 * X1 @ X2.T
    return variance * np.exp(-0.5 * np.maximum(d2, 0) / lengthscale ** 2)

def matern32_kernel(X1, X2, lengthscale=1.0, variance=1.0):
    X1 = np.atleast_2d(X1); X2 = np.atleast_2d(X2)
    d2 = np.sum(X1 ** 2, 1)[:, None] + np.sum(X2 ** 2, 1)[None, :] - 2 * X1 @ X2.T
    r = np.sqrt(np.maximum(d2, 0)); s = np.sqrt(3) * r / lengthscale
    return variance * (1 + s) * np.exp(-s)

def matern12_kernel(X1, X2, lengthscale=1.0, variance=1.0):
    X1 = np.atleast_2d(X1); X2 = np.atleast_2d(X2)
    d2 = np.sum(X1 ** 2, 1)[:, None] + np.sum(X2 ** 2, 1)[None, :] - 2 * X1 @ X2.T
    return variance * np.exp(-np.sqrt(np.maximum(d2, 0)) / lengthscale)

def gp_prior_sample(X, kernel, n, rng, jitter=1e-8):
    """Draw n functions from the GP prior at inputs X: f ~ N(0, K), K = kernel(X,X),
    sampled by Cholesky. Returns (n, len(X))."""
    X = np.atleast_2d(X); m = X.shape[0]
    K = kernel(X, X) + jitter * np.eye(m)
    L = np.linalg.cholesky(K)
    Z = _std_normal(n * m, rng).reshape(n, m)
    return Z @ L.T

def gp_posterior(Xtr, ytr, Xte, kernel, noise=1e-6):
    """Closed-form GP-regression posterior at test inputs Xte given training (Xtr, ytr):
    mean = Ks^T (K+sigma^2 I)^{-1} y,  cov = Kss - Ks^T (K+sigma^2 I)^{-1} Ks.

    Solved through the Cholesky factor rather than by forming K^{-1}
    (Rasmussen & Williams, Alg. 2.1). Same answer, but stable when K is
    ill-conditioned -- which a smooth kernel on closely spaced inputs always
    is -- and it returns an exactly symmetric covariance."""
    Xtr = np.atleast_2d(Xtr); Xte = np.atleast_2d(Xte)
    K = kernel(Xtr, Xtr) + noise * np.eye(Xtr.shape[0])
    Ks = kernel(Xtr, Xte); Kss = kernel(Xte, Xte)
    L = np.linalg.cholesky(K)
    alpha = np.linalg.solve(L.T, np.linalg.solve(L, ytr))   # K^{-1} y, without K^{-1}
    V = np.linalg.solve(L, Ks)                              # L^{-1} Ks
    mean = Ks.T @ alpha
    cov = Kss - V.T @ V
    return mean, cov

def gp_posterior_sample(Xtr, ytr, Xte, kernel, n, rng, noise=1e-6, jitter=1e-8):
    mean, cov = gp_posterior(Xtr, ytr, Xte, kernel, noise)
    L = np.linalg.cholesky(cov + jitter * np.eye(cov.shape[0]))
    Z = _std_normal(n * len(mean), rng).reshape(n, len(mean))
    return mean + Z @ L.T


# --------------------------------------------------------------------------- #
#  Private RNG primitives                                                        #
# --------------------------------------------------------------------------- #

def _std_normal(n, rng):
    m = (n + 1) // 2
    u1 = rng.random(m); u2 = rng.random(m)
    r = np.sqrt(-2 * np.log(u1)); z = np.empty(2 * m)
    z[:m] = r * np.cos(2 * np.pi * u2); z[m:] = r * np.sin(2 * np.pi * u2)
    return z[:n]

def _gamma_shape(k, n, rng):
    n = int(n)
    if n == 0:
        return np.zeros(0)
    if k < 1:
        g = _gamma_shape(k + 1.0, n, rng); u = rng.random(n)
        return g * u ** (1.0 / k)
    d = k - 1.0 / 3.0; c = 1.0 / np.sqrt(9.0 * d)
    res = np.empty(n); todo = np.ones(n, bool)
    while todo.any():
        m = int(todo.sum()); x = _std_normal(m, rng)
        v = (1.0 + c * x) ** 3; u = rng.random(m)
        ok = (v > 0) & (np.log(u) < 0.5 * x ** 2 + d - d * v + d * np.log(np.where(v > 0, v, 1.0)))
        cur = np.where(todo)[0]; acc = cur[ok]
        res[acc] = d * v[ok]; todo[acc] = False
    return res

References