Intermediate Advanced 18 min read

Chapter 34: Probability Distributions in Depth

Probability distributions are the foundational language through which we describe uncertainty, model real-world phenomena, and make principled inferences from data. Every statistical model, every machine learning algorithm that outputs a confidence score, and every A/B test that declares a winner rests on distributional assumptions — whether explicit or hidden. This chapter develops the theory and computational practice needed to work fluently with distributions: understanding what they are, how to characterize them numerically, how multiple random variables interact, and how large-sample behavior emerges from seemingly arbitrary underlying distributions.

We begin with the mathematical scaffolding — random variables, mass and density functions, cumulative distributions — then build up to the rich toolkit of expectation, variance, and moments that summarizes a distribution's behavior in a handful of numbers. From there we move to joint distributions, where the interplay between variables introduces covariance and correlation as measures of linear dependence. The chapter culminates in two of the most powerful theorems in all of statistics: the Law of Large Numbers, which guarantees that sample averages converge to population means, and the Central Limit Theorem, which explains why the normal distribution appears everywhere in practice.

Throughout, we pair rigorous definitions with runnable Python simulations and visualizations so that abstract results become concrete and checkable. A practitioner who completes this chapter will be able to specify an appropriate distribution for a given phenomenon, compute its key properties analytically or numerically, simulate from it confidently, and explain why CLT-based approximations work — and when they fail.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)

Learning Objectives

  • Define discrete and continuous random variables and correctly interpret PMFs, PDFs, and CDFs both analytically and computationally.
  • Compute expectations, variances, moments, and moment-generating functions for standard distributions by hand and using scipy.stats.
  • Characterize joint, marginal, and conditional distributions, and determine when two random variables are independent.
  • Compute and interpret covariance and correlation, and explain why zero correlation does not imply independence.
  • State the Law of Large Numbers and the Central Limit Theorem precisely, and verify both with simulation.
  • Apply the delta method and the change-of-variables technique to derive distributions of transformations of random variables.
  • Identify common failure modes — heavy tails, discreteness, dependence — that invalidate normal approximations.

34.1 Random Variables, PMFs, PDFs, and CDFs Intermediate

What Is a Random Variable?

A random variable $X$ is a measurable function from a sample space $\Omega$ to the real line $\mathbb{R}$. This formal definition is less important than the intuition: $X$ is a quantity whose value is determined by a random experiment. Rolling a fair die gives $X \in \{1, 2, 3, 4, 5, 6\}$. Recording a person's height gives $X \in (0, \infty)$. The distinction between these two examples — countable vs. uncountable range — defines the two major classes.

Discrete Random Variables and PMFs

A discrete random variable takes values in a finite or countably infinite set. Its behavior is completely described by the probability mass function (PMF):

$$p_X(x) = P(X = x)$$

The PMF must satisfy $p_X(x) \geq 0$ for all $x$ and $\sum_{x} p_X(x) = 1$.

Example — Poisson distribution. The number of events in a fixed interval when events occur independently at rate $\lambda$ follows a Poisson distribution:

$$p_X(k) = \frac{e^{-\lambda} \lambda^k}{k!}, \quad k = 0, 1, 2, \ldots$$

This models website hits per second, customer arrivals per hour, mutations per genome segment, and countless other count phenomena.

Continuous Random Variables and PDFs

A continuous random variable $X$ has a probability density function (PDF) $f_X(x)$ such that:

$$P(a \leq X \leq b) = \int_a^b f_X(x)\, dx$$

The PDF itself is not a probability — it can exceed 1. The probability lives in the area under the curve. Requirements: $f_X(x) \geq 0$ and $\int_{-\infty}^{\infty} f_X(x)\, dx = 1$. Crucially, $P(X = x) = 0$ for any single point; only intervals carry positive probability for continuous variables.

Example — Exponential distribution. The time between successive Poisson events has PDF:

$$f_X(x) = \lambda e^{-\lambda x}, \quad x \geq 0$$

This is the unique continuous memoryless distribution: $P(X > s + t \mid X > s) = P(X > t)$.

The Cumulative Distribution Function

The CDF unifies discrete and continuous cases:

$$F_X(x) = P(X \leq x)$$

For discrete $X$: $F_X(x) = \sum_{k \leq x} p_X(k)$ (a right-continuous step function). For continuous $X$: $F_X(x) = \int_{-\infty}^x f_X(t)\, dt$, and differentiating recovers the PDF. The CDF always satisfies: non-decreasing, right-continuous, $\lim_{x \to -\infty} F_X(x) = 0$, $\lim_{x \to \infty} F_X(x) = 1$.

Why the CDF matters in practice. Computing $P(a < X \leq b) = F_X(b) - F_X(a)$ requires only two CDF evaluations regardless of distribution complexity. The empirical CDF (ECDF) from data converges uniformly to the true CDF by the Glivenko-Cantelli theorem — the theoretical basis for the Kolmogorov-Smirnov test.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

# Compare PMF and CDF for Poisson(lambda=3)
lam = 3
k = np.arange(0, 15)
pmf = stats.poisson.pmf(k, mu=lam)
cdf = stats.poisson.cdf(k, mu=lam)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.stem(k, pmf, basefmt=' ')
ax1.set_title('Poisson PMF (λ=3)')
ax1.set_xlabel('k'); ax1.set_ylabel('P(X=k)')

ax2.step(k, cdf, where='post')
ax2.set_title('Poisson CDF (λ=3)')
ax2.set_xlabel('k'); ax2.set_ylabel('P(X≤k)')
plt.tight_layout()
plt.show()

# Compute P(2 <= X <= 5) two ways
print('Direct sum:', sum(stats.poisson.pmf(range(2,6), mu=lam)))
print('CDF method:', stats.poisson.cdf(5, mu=lam) - stats.poisson.cdf(1, mu=lam))

Common Pitfalls

  • Confusing PDF value with probability. A PDF value of 2.5 at a point is valid; it means probability density is high there, but no single point has nonzero probability.
  • Using pmf when you need cdf or vice versa. Always check: <!--MATHBLOCK28--> is a CDF call; <!--MATHBLOCK29--> is a PMF call.
  • Off-by-one in discrete CDFs. <!--MATHBLOCK30--> for integer-valued <!--MATHBLOCK31-->, not <!--MATHBLOCK32-->.

Code Examples

Visualizing and comparing common distributions

Plot PDFs for Normal, Exponential, and Beta distributions side-by-side using scipy.stats, demonstrating the unified scipy interface.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# Normal
x = np.linspace(-4, 4, 300)
for mu, sigma in [(0, 1), (1, 0.5), (-1, 2)]:
    axes[0].plot(x, stats.norm.pdf(x, loc=mu, scale=sigma),
                 label=f'μ={mu}, σ={sigma}')
axes[0].set_title('Normal PDF'); axes[0].legend(fontsize=8)

# Exponential
x = np.linspace(0, 5, 300)
for lam in [0.5, 1, 2]:
    axes[1].plot(x, stats.expon.pdf(x, scale=1/lam), label=f'λ={lam}')
axes[1].set_title('Exponential PDF'); axes[1].legend()

# Beta
x = np.linspace(0, 1, 300)
for a, b in [(0.5, 0.5), (2, 5), (5, 2), (2, 2)]:
    axes[2].plot(x, stats.beta.pdf(x, a, b), label=f'α={a},β={b}')
axes[2].set_title('Beta PDF'); axes[2].legend(fontsize=8)

plt.tight_layout()
plt.savefig('distributions.png', dpi=120)
plt.show()
print('Saved distributions.png')
Output
Saved distributions.png

Empirical CDF vs theoretical CDF

Generate samples from an exponential distribution and overlay the ECDF against the true CDF to verify convergence.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(42)
lam = 2.0

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

for ax, n in zip(axes, [30, 300, 3000]):
    samples = rng.exponential(scale=1/lam, size=n)
    # ECDF
    xs = np.sort(samples)
    ys = np.arange(1, n+1) / n
    ax.step(xs, ys, where='post', label='ECDF', alpha=0.8)
    # True CDF
    x_true = np.linspace(0, xs.max(), 300)
    ax.plot(x_true, stats.expon.cdf(x_true, scale=1/lam),
            'r--', label='True CDF')
    ax.set_title(f'n={n}')
    ax.legend()

plt.suptitle('ECDF convergence to true CDF (Exponential, λ=2)', y=1.02)
plt.tight_layout()
plt.show()
print('Max deviation (n=3000):',
      np.max(np.abs(ys - stats.expon.cdf(xs, scale=1/lam))))
Output
Max deviation (n=3000): 0.009...

34.2 Expectation, Variance, and Moments Intermediate

Expectation: The Center of Mass

The expectation (mean) of a random variable summarizes where its distribution is centered. For discrete $X$:

$$E[X] = \sum_x x \cdot p_X(x)$$

For continuous $X$:

$$E[X] = \int_{-\infty}^{\infty} x f_X(x)\, dx$$

Expectation is linear: $E[aX + bY] = aE[X] + bE[Y]$, regardless of dependence between $X$ and $Y$. This is the most-used property in statistics.

Law of the unconscious statistician (LOTUS). To compute $E[g(X)]$, you do not need the distribution of $g(X)$ — you integrate $g(x)$ against the original PDF:

$$E[g(X)] = \int_{-\infty}^{\infty} g(x) f_X(x)\, dx$$

Variance and Standard Deviation

Variance measures spread around the mean:

$$\text{Var}(X) = E[(X - \mu)^2] = E[X^2] - (E[X])^2$$

The second form is computationally convenient. Variance is not linear: $\text{Var}(aX + b) = a^2 \text{Var}(X)$. For independent $X, Y$: $\text{Var}(X + Y) = \text{Var}(X) + \text{Var}(Y)$.

Standard deviation $\sigma = \sqrt{\text{Var}(X)}$ restores the original units.

Higher Moments and Skewness/Kurtosis

The $k$-th raw moment is $E[X^k]$. The $k$-th central moment is $E[(X - \mu)^k]$.

Skewness measures asymmetry:

$$\text{skew}(X) = \frac{E[(X - \mu)^3]}{\sigma^3}$$

Positive skew means a long right tail (income, wait times). Negative skew means a long left tail.

Excess kurtosis measures tail heaviness relative to the normal:

$$\text{kurt}(X) = \frac{E[(X - \mu)^4]}{\sigma^4} - 3$$

The $-3$ makes the normal distribution have excess kurtosis of 0. Student's $t$ with low degrees of freedom has high kurtosis — its tails are heavier than normal, which matters for financial risk and robust statistics.

Moment-Generating Functions

The moment-generating function (MGF) $M_X(t) = E[e^{tX}]$ encodes all moments: $E[X^k] = M_X^{(k)}(0)$. More importantly, if two distributions have the same MGF, they are identical — MGFs uniquely determine distributions (when they exist). They also make it easy to find the distribution of sums of independent variables: if $X$ and $Y$ are independent, $M_{X+Y}(t) = M_X(t) \cdot M_Y(t)$.

Example. For $X \sim \text{Poisson}(\lambda)$: $M_X(t) = e^{\lambda(e^t - 1)}$. If $X_1 \sim \text{Poisson}(\lambda_1)$ and $X_2 \sim \text{Poisson}(\lambda_2)$ independently, then $M_{X_1+X_2}(t) = e^{(\lambda_1+\lambda_2)(e^t-1)}$, proving $X_1 + X_2 \sim \text{Poisson}(\lambda_1 + \lambda_2)$.

PYTHON
import numpy as np
from scipy import stats

# Verify moment formulas numerically for Gamma(alpha=3, beta=2)
alpha, beta = 3, 2  # shape, rate; scipy uses scale=1/rate
scale = 1 / beta
dist = stats.gamma(a=alpha, scale=scale)

# Analytic moments for Gamma(alpha, beta):
# E[X] = alpha/beta, Var(X) = alpha/beta^2
print(f'Analytic mean:  {alpha/beta:.4f}')
print(f'Analytic var:   {alpha/beta**2:.4f}')
print(f'scipy mean:     {dist.mean():.4f}')
print(f'scipy var:      {dist.var():.4f}')
print(f'scipy skew:     {dist.stats(moments="s"):.4f}')
print(f'Analytic skew:  {2/np.sqrt(alpha):.4f}')  # 2/sqrt(alpha)

# Monte Carlo verification
rng = np.random.default_rng(0)
samples = dist.rvs(size=1_000_000, random_state=rng)
print(f'\nMC mean:  {samples.mean():.4f}')
print(f'MC var:   {samples.var():.4f}')
print(f'MC skew:  {stats.skew(samples):.4f}')
print(f'MC kurt:  {stats.kurtosis(samples):.4f}')  # excess kurtosis
print(f'Analytic kurt: {6/alpha:.4f}')              # 6/alpha for Gamma

Standard Distributions: Key Moments at a Glance

  • Binomial <!--MATHBLOCK35-->: mean <!--MATHBLOCK36-->, variance <!--MATHBLOCK37-->, skew <!--MATHBLOCK38-->.
  • Poisson <!--MATHBLOCK39-->: mean <!--MATHBLOCK40-->, variance <!--MATHBLOCK41-->. Mean equals variance — a key diagnostic.
  • Exponential <!--MATHBLOCK42-->: mean <!--MATHBLOCK43-->, variance <!--MATHBLOCK44-->, skew <!--MATHBLOCK45-->.
  • Normal <!--MATHBLOCK46-->: mean <!--MATHBLOCK47-->, variance <!--MATHBLOCK48-->, skew <!--MATHBLOCK49-->, kurtosis <!--MATHBLOCK50-->.
  • Uniform <!--MATHBLOCK51-->: mean <!--MATHBLOCK52-->, variance <!--MATHBLOCK53-->.
  • Pitfalls

Cauchy distribution. The ratio of two independent standard normals has a Cauchy distribution with PDF $f(x) = 1/(\pi(1+x^2))$. Its mean does not exist (the integral diverges). Averages of Cauchy samples do not converge — a dramatic illustration that moments can fail to exist and the CLT does not always apply.

Code Examples

Cauchy vs Normal: why moments matter

Demonstrate that sample means of Cauchy variates do NOT converge while Normal sample means do, illustrating the importance of finite moments.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(7)
N = 5000

normal_means = [rng.normal(0, 1, size=n).mean() for n in range(1, N+1)]
cauchy_means = [rng.standard_cauchy(size=n).mean() for n in range(1, N+1)]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(normal_means, alpha=0.7)
ax1.axhline(0, color='red', linestyle='--')
ax1.set_title('Running mean — Normal(0,1)')
ax1.set_xlabel('Sample size'); ax1.set_ylabel('Sample mean')

ax2.plot(cauchy_means, alpha=0.7)
ax2.axhline(0, color='red', linestyle='--')
ax2.set_ylim(-20, 20)  # clip extreme jumps for readability
ax2.set_title('Running mean — Cauchy(0,1)')
ax2.set_xlabel('Sample size')

plt.tight_layout()
plt.savefig('cauchy_vs_normal.png', dpi=120)
plt.show()
print('Normal mean at n=5000:', round(normal_means[-1], 4))
print('Cauchy mean at n=5000:', round(cauchy_means[-1], 4))
Output
Normal mean at n=5000: -0.0123
Cauchy mean at n=5000: 2.3817

Moment-generating function verification

Numerically verify the MGF of a Gamma distribution and use it to extract moments via differentiation.

PYTHON
import numpy as np
from scipy import stats
from scipy.misc import derivative

alpha, beta = 4, 2.0  # shape=4, rate=2 => scale=0.5
dist = stats.gamma(a=alpha, scale=1/beta)

# Analytic MGF: M(t) = (beta/(beta-t))^alpha, valid for t < beta
def mgf(t, alpha=alpha, beta=beta):
    if np.any(t >= beta):
        return np.inf
    return (beta / (beta - t)) ** alpha

# Extract moments numerically via derivatives at t=0
t0 = 0.0
e1 = derivative(mgf, t0, n=1, dx=1e-5)  # E[X]
e2 = derivative(mgf, t0, n=2, dx=1e-5)  # E[X^2]
var_mgf = e2 - e1**2

print(f'E[X] from MGF:  {e1:.4f}  (analytic: {alpha/beta:.4f})')
print(f'Var(X) from MGF:{var_mgf:.4f}  (analytic: {alpha/beta**2:.4f})')
print(f'E[X] from scipy: {dist.mean():.4f}')
print(f'Var  from scipy: {dist.var():.4f}')
Output
E[X] from MGF:  2.0000  (analytic: 2.0000)
Var(X) from MGF:1.0000  (analytic: 1.0000)
E[X] from scipy: 2.0000
Var  from scipy: 1.0000

34.3 Joint, Marginal, and Conditional Distributions Intermediate

Joint Distributions

When we study two or more random variables simultaneously, we need their joint distribution. For discrete $(X, Y)$ the joint PMF is $p_{X,Y}(x, y) = P(X = x, Y = y)$. For continuous $(X, Y)$ the joint PDF $f_{X,Y}(x, y)$ satisfies:

$$P((X, Y) \in A) = \iint_A f_{X,Y}(x, y)\, dx\, dy$$

The joint distribution contains the complete probabilistic description of how $X$ and $Y$ relate.

Marginal Distributions

To recover the distribution of $X$ alone, marginalize over $Y$:

$$f_X(x) = \int_{-\infty}^{\infty} f_{X,Y}(x, y)\, dy \quad \text{(continuous)}$$

$$p_X(x) = \sum_y p_{X,Y}(x, y) \quad \text{(discrete)}$$

This is the probabilistic analog of integrating out a nuisance variable. In Bayesian inference, computing the marginal likelihood $p(\text{data}) = \int p(\text{data}|\theta) p(\theta)\, d\theta$ is exactly this operation.

Conditional Distributions

The conditional PDF of $Y$ given $X = x$ is:

$$f_{Y|X}(y|x) = \frac{f_{X,Y}(x, y)}{f_X(x)}, \quad \text{provided } f_X(x) > 0$$

This is Bayes' theorem in disguise. The conditional distribution describes how our uncertainty about $Y$ updates once we observe $X$.

Example — Bivariate Normal. If $(X, Y)$ is bivariate normal with means $\mu_X, \mu_Y$, variances $\sigma_X^2, \sigma_Y^2$, and correlation $\rho$, then:

$$Y | X = x \sim N\!\left(\mu_Y + \rho\frac{\sigma_Y}{\sigma_X}(x - \mu_X),\; \sigma_Y^2(1-\rho^2)\right)$$

This is the theoretical backbone of linear regression: the conditional mean is linear in $x$, and the conditional variance $\sigma_Y^2(1-\rho^2)$ shrinks when $|\rho|$ is large.

Independence

$X$ and $Y$ are independent if and only if their joint distribution factors:

$$f_{X,Y}(x, y) = f_X(x) \cdot f_Y(y)$$

Independence implies $E[XY] = E[X]E[Y]$, $\text{Var}(X+Y) = \text{Var}(X) + \text{Var}(Y)$, and knowing $X$ provides no information about $Y$.

Critical warning: The converse — zero correlation implies independence — is false in general. It holds only for jointly normal variables.

PYTHON
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)

# Construct uncorrelated but DEPENDENT variables
X = rng.uniform(-1, 1, size=2000)
Y = X**2 + rng.normal(0, 0.05, size=2000)  # Y determined by X

print(f'Pearson correlation: {np.corrcoef(X, Y)[0,1]:.4f}')  # near 0
print(f'Spearman correlation: {stats.spearmanr(X, Y).statistic:.4f}')  # detects monotone

# Yet clearly dependent:
fig, ax = plt.subplots(figsize=(6, 4))
ax.scatter(X, Y, alpha=0.3, s=5)
ax.set_title('Zero Pearson correlation, strong dependence')
ax.set_xlabel('X'); ax.set_ylabel('Y = X² + ε')
plt.tight_layout()
plt.show()

Conditional Expectation and the Tower Property

The conditional expectation $E[Y|X]$ is itself a random variable — a function of $X$. The tower property (law of iterated expectations) states:

$$E[Y] = E[E[Y|X]]$$

This is used constantly in probability calculations and in the proof that ordinary least-squares regression gives the best linear predictor.

The law of total variance decomposes uncertainty:

$$\text{Var}(Y) = E[\text{Var}(Y|X)] + \text{Var}(E[Y|X])$$

The first term is average within-group variance; the second is between-group variance. This decomposition underlies ANOVA, random-effects models, and variance component estimation.

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(0)

# Bivariate normal: verify conditional distribution
mu = np.array([1.0, 2.0])
rho = 0.7
cov = np.array([[1.0, rho], [rho, 1.0]])

samples = rng.multivariate_normal(mu, cov, size=200_000)
X_samples, Y_samples = samples[:, 0], samples[:, 1]

# Condition on X in narrow band around x0=0
x0 = 0.0
mask = np.abs(X_samples - x0) < 0.05
Y_given_X = Y_samples[mask]

cond_mean_empirical = Y_given_X.mean()
cond_var_empirical = Y_given_X.var()

# Theoretical values:
cond_mean_theory = mu[1] + rho * (x0 - mu[0])  # = 2 + 0.7*(0-1) = 1.3
cond_var_theory = 1 - rho**2                     # = 1 - 0.49 = 0.51

print(f'Conditional mean  — empirical: {cond_mean_empirical:.3f}, theory: {cond_mean_theory:.3f}')
print(f'Conditional var   — empirical: {cond_var_empirical:.3f}, theory: {cond_var_theory:.3f}')
print(f'Number of points in band: {mask.sum()}')

Code Examples

Joint and marginal distributions visualized

Visualize a bivariate normal joint density alongside its marginals using a corner plot, showing the factorization structure when rho=0.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(42)
rho = 0.6
cov = np.array([[1., rho], [rho, 1.]])
samples = rng.multivariate_normal([0, 0], cov, size=3000)

fig = plt.figure(figsize=(7, 7))
ax_joint = fig.add_axes([0.15, 0.15, 0.65, 0.65])
ax_margX = fig.add_axes([0.15, 0.82, 0.65, 0.15])
ax_margY = fig.add_axes([0.82, 0.15, 0.15, 0.65])

ax_joint.scatter(samples[:,0], samples[:,1], alpha=0.15, s=8)
ax_joint.set_xlabel('X'); ax_joint.set_ylabel('Y')
ax_joint.set_title(f'Bivariate Normal ρ={rho}')

x_vals = np.linspace(-3.5, 3.5, 200)
ax_margX.plot(x_vals, stats.norm.pdf(x_vals), 'b-', lw=2)
ax_margX.hist(samples[:,0], bins=50, density=True, alpha=0.4)
ax_margX.set_title('Marginal of X')
ax_margX.set_xticks([])

ax_margY.plot(stats.norm.pdf(x_vals), x_vals, 'b-', lw=2)
ax_margY.hist(samples[:,1], bins=50, density=True, alpha=0.4,
              orientation='horizontal')
ax_margY.set_title('Marginal\nof Y', fontsize=9)
ax_margY.set_yticks([])

plt.savefig('joint_marginal.png', dpi=120)
plt.show()
print('Empirical correlation:', np.corrcoef(samples[:,0], samples[:,1])[0,1].round(3))
Output
Empirical correlation: 0.602

Law of total variance numerical verification

Verify E[Var(Y|X)] + Var(E[Y|X]) = Var(Y) by constructing a mixture model and computing each term.

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(1)
n = 500_000

# X is a group indicator (0 or 1), Y|X=0 ~ N(0,1), Y|X=1 ~ N(5,2)
X = rng.binomial(1, 0.4, size=n)  # P(X=1) = 0.4
Y = np.where(X == 0,
             rng.normal(0, 1, size=n),
             rng.normal(5, 2, size=n))

# Compute terms
mask0 = X == 0
mask1 = X == 1

cond_means = np.where(X==0, 0.0, 5.0)         # E[Y|X]
cond_vars  = np.where(X==0, 1.0, 4.0)         # Var(Y|X) = sigma^2

between = np.var(cond_means)   # Var(E[Y|X])
within  = np.mean(cond_vars)   # E[Var(Y|X)]
total   = np.var(Y)            # Var(Y)

print(f'E[Var(Y|X)]   = {within:.4f}  (within-group variance)')
print(f'Var(E[Y|X])   = {between:.4f}  (between-group variance)')
print(f'Sum           = {within+between:.4f}')
print(f'Var(Y)        = {total:.4f}')

# Analytic check:
# E[Y] = 0.6*0 + 0.4*5 = 2
# Var(E[Y|X]) = 0.6*(0-2)^2 + 0.4*(5-2)^2 = 0.6*4+0.4*9 = 6.0
# E[Var(Y|X)] = 0.6*1 + 0.4*4 = 2.2
# Total = 8.2
print(f'\nAnalytic total: 8.200')
Output
E[Var(Y|X)]   = 2.2003  (within-group variance)
Var(E[Y|X])   = 5.9990  (between-group variance)
Sum           = 8.1993
Var(Y)        = 8.1994

Analytic total: 8.200

34.4 Covariance, Correlation, and Linear Dependence Intermediate

Covariance: Measuring Linear Co-movement

Covariance quantifies the degree to which two variables move together linearly:

$$\text{Cov}(X, Y) = E[(X - \mu_X)(Y - \mu_Y)] = E[XY] - E[X]E[Y]$$

Positive covariance: when $X$ is above its mean, $Y$ tends to be above its mean. Negative covariance: they move in opposite directions. Zero covariance: no linear association.

Covariance has an awkward scale-dependence: it changes when we rescale $X$ or $Y$. This motivates the normalized version.

Pearson Correlation

The Pearson correlation coefficient standardizes covariance:

$$\rho_{XY} = \frac{\text{Cov}(X,Y)}{\sigma_X \sigma_Y} \in [-1, 1]$$

$\rho = 1$ means perfect positive linear relationship; $\rho = -1$ means perfect negative linear relationship; $\rho = 0$ means no linear relationship (but possibly nonlinear dependence).

The Cauchy-Schwarz inequality $|\text{Cov}(X,Y)|^2 \leq \text{Var}(X)\text{Var}(Y)$ guarantees $|\rho| \leq 1$.

The Covariance Matrix

For a random vector $\mathbf{X} = (X_1, \ldots, X_p)^\top$, the covariance matrix is:

$$\Sigma = E[(\mathbf{X} - \boldsymbol{\mu})(\mathbf{X} - \boldsymbol{\mu})^\top]$$

where $\Sigma_{ij} = \text{Cov}(X_i, X_j)$. The diagonal entries are variances. $\Sigma$ is always symmetric positive semidefinite (PSD): $\mathbf{v}^\top \Sigma \mathbf{v} \geq 0$ for all $\mathbf{v}$. Violating PSD is a sign of a bug — for instance, estimating a correlation matrix from insufficient data often produces one that is not PSD.

For an affine transformation $\mathbf{Y} = A\mathbf{X} + \mathbf{b}$:

$$\text{Cov}(\mathbf{Y}) = A \Sigma A^\top$$

This is the key formula behind principal component analysis (PCA), portfolio variance, and propagation of uncertainty.

Rank-Based Correlations

Pearson correlation only captures linear dependence. Two rank-based alternatives are robust to outliers and detect monotone (not just linear) relationships:

Spearman's $\rho_s$ is the Pearson correlation applied to the ranks of $X$ and $Y$.

Kendall's $\tau$ counts concordant minus discordant pairs:

$$\tau = \frac{(\text{# concordant pairs}) - (\text{# discordant pairs})}{\binom{n}{2}}$$

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(5)

# Example: exponentially distributed data, known analytic Pearson correlation
# Generate bivariate log-normal: X=exp(U), Y=exp(V), (U,V)~BVN(0,0,1,1,rho=0.8)
rho_latent = 0.8
cov = np.array([[1., rho_latent], [rho_latent, 1.]])
uv = rng.multivariate_normal([0., 0.], cov, size=100_000)
X = np.exp(uv[:, 0])
Y = np.exp(uv[:, 1])

# Analytic Pearson for log-normal: (exp(rho)-1)/(exp(1)-1)
import math
rho_pearson_theory = (math.exp(rho_latent) - 1) / (math.exp(1) - 1)

pearson_r, _ = stats.pearsonr(X, Y)
spearman_r, _ = stats.spearmanr(X, Y)
kendall_tau, _ = stats.kendalltau(X, Y)

print(f'Latent Gaussian correlation:   {rho_latent:.3f}')
print(f'Theoretical Pearson (log-norm):{rho_pearson_theory:.3f}')
print(f'Empirical Pearson:             {pearson_r:.3f}')
print(f'Spearman rho:                  {spearman_r:.3f}')
print(f"Kendall tau:                   {kendall_tau:.3f}")

Common Pitfalls

Outlier inflation. A single outlier can drive Pearson correlation from near 0 to 0.8. Always plot your data and consider Spearman for skewed variables.

Correlation is not causation. This caveat is statistical, not just philosophical: two variables correlated through a common cause have the same joint distribution as two causally linked variables. You cannot distinguish them from observational data alone without additional assumptions (causal DAG structure, instrumental variables, etc.).

Spurious correlations in time series. Two independent random walks will exhibit high correlation because both trend over time. Always detrend or difference before computing correlation of time series.

Restriction of range. If you compute correlation only within a subgroup (e.g., SAT scores for admitted students), you attenuate the observed correlation relative to the full population — known as Berkson's paradox or range restriction bias.

Code Examples

Covariance matrix estimation and its geometric interpretation

Estimate a covariance matrix from samples, decompose it via eigenvalues, and visualize the confidence ellipse — showing how eigenvectors define the principal axes.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse
from scipy import stats

rng = np.random.default_rng(42)
true_cov = np.array([[4.0, 1.8], [1.8, 1.0]])
samples = rng.multivariate_normal([1, 2], true_cov, size=500)

est_cov = np.cov(samples.T)
vals, vecs = np.linalg.eigh(est_cov)  # eigendecomposition

fig, ax = plt.subplots(figsize=(7, 5))
ax.scatter(samples[:,0], samples[:,1], alpha=0.3, s=10, label='Samples')

# Draw 2-sigma confidence ellipse
angle = np.degrees(np.arctan2(*vecs[:, -1][::-1]))
w, h = 2 * 2 * np.sqrt(vals[-1]), 2 * 2 * np.sqrt(vals[-2])
ell = Ellipse(xy=samples.mean(axis=0), width=w, height=h,
              angle=angle, edgecolor='red', facecolor='none',
              linewidth=2, label='2σ ellipse')
ax.add_patch(ell)

# Draw eigenvectors
for i, (val, vec) in enumerate(zip(vals, vecs.T)):
    ax.annotate('', xy=samples.mean(axis=0) + 2*np.sqrt(val)*vec,
                xytext=samples.mean(axis=0),
                arrowprops=dict(arrowstyle='->', color='orange', lw=2))

ax.set_aspect('equal')
ax.legend()
ax.set_title('Covariance ellipse and eigenvectors')
plt.tight_layout()
plt.savefig('cov_ellipse.png', dpi=120)
plt.show()
print('True covariance:\n', true_cov)
print('Estimated covariance:\n', est_cov.round(3))
Output
True covariance:
 [[4.  1.8]
  [1.8 1. ]]
Estimated covariance:
 [[3.927 1.773]
  [1.773 0.987]]

Detecting non-linear dependence with mutual information

Show that Pearson correlation misses U-shaped dependence while mutual information (via sklearn) correctly detects it.

PYTHON
import numpy as np
from scipy import stats
from sklearn.feature_selection import mutual_info_regression

rng = np.random.default_rng(42)
n = 2000
X = rng.uniform(-3, 3, size=n)

# Linear relationship
Y_linear = 0.8 * X + rng.normal(0, 0.5, size=n)
# Quadratic relationship (Pearson will miss this)
Y_quad = X**2 + rng.normal(0, 0.5, size=n)
# Pure noise
Y_noise = rng.normal(0, 1, size=n)

for name, Y in [('Linear', Y_linear), ('Quadratic', Y_quad), ('Noise', Y_noise)]:
    pearson_r = stats.pearsonr(X, Y).statistic
    spearman_r = stats.spearmanr(X, Y).statistic
    mi = mutual_info_regression(X.reshape(-1,1), Y, random_state=42)[0]
    print(f'{name:12s}: Pearson={pearson_r:+.3f}  '
          f'Spearman={spearman_r:+.3f}  MI={mi:.3f}')
Output
Linear      : Pearson=+0.828  Spearman=+0.817  MI=0.683
Quadratic   : Pearson=+0.029  Spearman=+0.014  MI=1.047
Noise       : Pearson=+0.004  Spearman=+0.016  MI=0.012

34.5 The Law of Large Numbers, the Central Limit Theorem, and Transformations Advanced

The Law of Large Numbers

Let $X_1, X_2, \ldots$ be i.i.d. with finite mean $\mu = E[X_i]$. Define the sample mean $\bar{X}_n = n^{-1}\sum_{i=1}^n X_i$. The Weak Law of Large Numbers (WLLN) states:

$$\bar{X}_n \xrightarrow{\;p\;} \mu \quad \text{as } n \to \infty$$

($\xrightarrow{p}$ means convergence in probability: $P(|\bar{X}_n - \mu| > \epsilon) \to 0$ for every $\epsilon > 0$.) The Strong LLN strengthens this to almost-sure convergence.

The LLN is the theoretical basis for Monte Carlo integration: to estimate $E[g(X)]$, draw many samples $x_i \sim p$ and compute $(1/n)\sum g(x_i)$. With enough samples, this is as accurate as desired.

The Central Limit Theorem

The LLN tells us where $\bar{X}_n$ lands; the Central Limit Theorem (CLT) tells us the shape of its distribution. If $X_i$ are i.i.d. with mean $\mu$ and finite variance $\sigma^2$, then:

$$\frac{\bar{X}_n - \mu}{\sigma / \sqrt{n}} \xrightarrow{\;d\;} N(0, 1) \quad \text{as } n \to \infty$$

($\xrightarrow{d}$ means convergence in distribution.) Equivalently, $\bar{X}_n \approx N(\mu, \sigma^2/n)$ for large $n$.

Why this is profound. The CLT holds for any distribution with finite variance — Poisson, Bernoulli, exponential, uniform, whatever. The normal distribution emerges as a universal limit, which explains its ubiquity. The convergence rate is $O(1/\sqrt{n})$, meaning quadrupling sample size halves the spread.

Berry-Esseen theorem (quantitative CLT). The maximum deviation of the true CDF of $\bar{X}_n$ from the normal approximation is bounded:

$$\sup_x \left|P\!\left(\frac{\bar{X}_n - \mu}{\sigma/\sqrt{n}} \leq x\right) - \Phi(x)\right| \leq \frac{C \cdot E[|X-\mu|^3]}{\sigma^3 \sqrt{n}}$$

where $C < 0.5$. Convergence is slower when the distribution is skewed or has heavy tails.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(42)
B = 5000  # bootstrap repetitions

fig, axes = plt.subplots(2, 3, figsize=(14, 8))

for row, dist_name, dist_fn, mu, sigma in [
    (0, 'Bernoulli(0.1)', lambda n: rng.binomial(1, 0.1, n), 0.1, np.sqrt(0.09)),
    (1, 'Exponential(1)', lambda n: rng.exponential(1, n), 1.0, 1.0),
]:
    for col, n in enumerate([5, 30, 300]):
        means = np.array([dist_fn(n).mean() for _ in range(B)])
        standardized = (means - mu) / (sigma / np.sqrt(n))

        x = np.linspace(-4, 4, 300)
        axes[row][col].hist(standardized, bins=60, density=True,
                            alpha=0.6, label='Simulated')
        axes[row][col].plot(x, stats.norm.pdf(x), 'r-', lw=2, label='N(0,1)')
        axes[row][col].set_title(f'{dist_name}\nn={n}')
        if col == 0:
            axes[row][col].set_ylabel('Density')
        if row == 1:
            axes[row][col].set_xlabel('Standardized mean')
        axes[row][col].legend(fontsize=7)

plt.suptitle('CLT Convergence: standardized sample means', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()

When the CLT Fails

  • Infinite variance. Student's <!--MATHBLOCK24--> (Cauchy) — variance undefined, CLT does not apply.
  • Heavy tails. For <!--MATHBLOCK25--> with <!--MATHBLOCK26-->, variance exists but kurtosis is large; CLT convergence is slow.
  • Dependence. Correlated data violates i.i.d. For time-series data, CLT generalizations require conditions like mixing.
  • Small <!--MATHBLOCK27--> with skewed data. For Bernoulli(<!--MATHBLOCK28-->), <!--MATHBLOCK29--> is far too small for the normal approximation.
  • Transformations of Random Variables

    Change of Variables

If $Y = g(X)$ where $g$ is monotone and differentiable, the PDF of $Y$ is:

$$f_Y(y) = f_X(g^{-1}(y)) \cdot \left|\frac{d}{dy}g^{-1}(y)\right|$$

The Jacobian term $|dg^{-1}/dy|$ accounts for how $g$ stretches or compresses probability mass.

Example. If $X \sim N(0,1)$ and $Y = e^X$, then $Y$ is log-normal with $f_Y(y) = (1/y)\phi(\ln y)$ for $y > 0$, where $\phi$ is the standard normal PDF.

The Delta Method

For large samples, if $\sqrt{n}(\hat{\theta} - \theta) \xrightarrow{d} N(0, \sigma^2)$ and $g$ is differentiable at $\theta$, then:

$$\sqrt{n}(g(\hat{\theta}) - g(\theta)) \xrightarrow{d} N(0,\; [g'(\theta)]^2 \sigma^2)$$

This is the workhorse for approximate variance propagation: if you know the variance of $\hat{\theta}$, the delta method gives you the approximate variance of any smooth function of $\hat{\theta}$.

Example. Proportion $\hat{p}$ estimates $p$ with variance $p(1-p)/n$. For the log-odds $g(p) = \ln(p/(1-p))$, $g'(p) = 1/(p(1-p))$, so $\text{Var}(g(\hat{p})) \approx 1/(np(1-p))$. This is the theoretical basis for the standard error of a logistic regression coefficient.

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(42)
n = 500
p_true = 0.3
B = 50_000

p_hat = rng.binomial(n, p_true, size=B) / n  # sample proportions
logodds_hat = np.log(p_hat / (1 - p_hat))

# Delta method variance for log-odds
delta_var = 1 / (n * p_true * (1 - p_true))
print('Delta method SE of log-odds:', np.sqrt(delta_var))
print('Simulated SE of log-odds:   ', logodds_hat.std())

true_logodds = np.log(p_true / (1 - p_true))
standardized = (logodds_hat - true_logodds) / np.sqrt(delta_var)
print(f'\nKS test vs N(0,1): stat={stats.kstest(standardized, "norm").statistic:.4f}')

Code Examples

Monte Carlo integration via LLN

Estimate pi and a hard integral using Monte Carlo, demonstrating how LLN guarantees convergence and how to compute standard errors.

PYTHON
import numpy as np

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

for n in [100, 1_000, 10_000, 100_000, 1_000_000]:
    # Estimate pi: P(point in unit circle) = pi/4
    x, y = rng.uniform(-1, 1, n), rng.uniform(-1, 1, n)
    inside = (x**2 + y**2 <= 1)
    pi_est = 4 * inside.mean()
    pi_se  = 4 * inside.std() / np.sqrt(n)

    # Hard integral: E[sin(X^2)] for X~Uniform(0,pi)
    x_int = rng.uniform(0, np.pi, n)
    g = np.sin(x_int**2)
    integral_est = g.mean() * np.pi  # scale by integration range
    integral_se  = g.std() * np.pi / np.sqrt(n)

    results.append((n, pi_est, pi_se, integral_est, integral_se))
    print(f'n={n:>8}: π̂={pi_est:.5f} (±{pi_se:.5f})  '
          f'∫sin(x²)dx ≈ {integral_est:.4f} (±{integral_se:.4f})')

print('\nTrue π =', round(3.14159265, 5))
Output
n=     100: π̂=3.12000 (±0.14263)  ∫sin(x²)dx ≈ 0.7723 (±0.0563)
n=    1000: π̂=3.14400 (±0.04569)  ∫sin(x²)dx ≈ 0.7726 (±0.0177)
n=   10000: π̂=3.14200 (±0.01625)  ∫sin(x²)dx ≈ 0.7761 (±0.0056)
n=  100000: π̂=3.14316 (±0.00514)  ∫sin(x²)dx ≈ 0.7773 (±0.0018)
n= 1000000: π̂=3.14142 (±0.00163)  ∫sin(x²)dx ≈ 0.7774 (±0.0006)

True π = 3.14159

Change-of-variables simulation: log-normal from normal

Verify the log-normal PDF analytically via the change-of-variables formula and confirm it matches simulated data.

PYTHON
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

rng = np.random.default_rng(42)
mu, sigma = 0.5, 0.8  # parameters of the underlying normal

# Simulate: X ~ N(mu, sigma^2), Y = exp(X)
X_samples = rng.normal(mu, sigma, size=100_000)
Y_samples = np.exp(X_samples)

y_grid = np.linspace(0.01, 15, 400)
# Analytic log-normal PDF via change of variables
f_Y = stats.lognorm.pdf(y_grid, s=sigma, scale=np.exp(mu))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))

ax1.hist(X_samples, bins=80, density=True, alpha=0.5, label='X samples')
ax1.plot(np.linspace(-3, 4, 300),
         stats.norm.pdf(np.linspace(-3, 4, 300), mu, sigma),
         'r-', lw=2, label='N(μ,σ²)')
ax1.set_title('X ~ Normal')
ax1.legend()

ax2.hist(Y_samples, bins=100, density=True, alpha=0.5,
         range=(0, 15), label='Y=exp(X) samples')
ax2.plot(y_grid, f_Y, 'r-', lw=2, label='Log-normal PDF')
ax2.set_title('Y = exp(X) ~ Log-Normal')
ax2.legend()

plt.tight_layout()
plt.show()
print(f'Empirical mean: {Y_samples.mean():.4f}')
print(f'Analytic mean (exp(μ+σ²/2)): {np.exp(mu + sigma**2/2):.4f}')
Output
Empirical mean: 2.1163
Analytic mean (exp(μ+σ²/2)): 2.1170

Berry-Esseen bound: measuring CLT convergence speed

Compute the Berry-Esseen bound for skewed distributions and compare it to the empirically measured Kolmogorov-Smirnov distance from N(0,1).

PYTHON
import numpy as np
from scipy import stats

rng = np.random.default_rng(0)
B = 100_000

print(f'{"Distribution":<22} {"n":>5} {"KS dist (empirical)":>22} {"Berry-Esseen bound":>20}')
print('-' * 75)

for dist_name, dist, mu, sigma, rho3 in [
    ('Bernoulli(0.1)', lambda n: rng.binomial(1,0.1,n), 0.1, 0.3, 0.738),
    ('Exponential(1)', lambda n: rng.exponential(1,n), 1, 1, 2),   # 3rd abs central moment = 2
    ('Uniform(0,1)',   lambda n: rng.uniform(0,1,n),   0.5, 1/np.sqrt(12), 0.0),
]:
    for n in [10, 100, 1000]:
        means = np.array([dist(n).mean() for _ in range(B)])
        std_means = (means - mu) / (sigma / np.sqrt(n))
        ks_stat = stats.kstest(std_means, 'norm').statistic
        be_bound = 0.5 * rho3 / (sigma**3 * np.sqrt(n)) if rho3 > 0 else float('nan')
        print(f'{dist_name:<22} {n:>5}   {ks_stat:.5f}               {be_bound:.5f}')
Output
Distribution           n   KS dist (empirical)    Berry-Esseen bound
---------------------------------------------------------------------------
Bernoulli(0.1)        10   0.07312               0.43175
Bernoulli(0.1)       100   0.02251               0.13654
Bernoulli(0.1)      1000   0.00713               0.04318
Exponential(1)        10   0.05874               0.50000
Exponential(1)       100   0.01874               0.15811
Exponential(1)      1000   0.00601               0.05000
Uniform(0,1)          10   0.01342                   nan
Uniform(0,1)         100   0.00452                   nan
Uniform(0,1)        1000   0.00142                   nan