从零构建统计学核心:Python 实现 PDF、CDF 与逆向采样
这是一部关于如何从零构建统计学核心算法的深度技术指南。
为了真正达到“通俗易懂”且“内容详实”的要求,我将这篇内容扩展为六个核心章节。我们将不仅仅停留在代码层面,而是深入到数学直觉、算法原理、工程实现以及实际应用场景中。
我们将以书中的“金毛猎犬体重分布”为核心案例,贯穿全文,带你通过 Python 重新发现正态分布的奥秘。
从零构建统计学引擎:Python 实现正态分布的全景指南
前言:打破“黑盒”的诱惑
在数据科学、金融工程以及机器学习的日常工作中,我们太习惯于“拿来主义”。
想计算概率?调用 scipy.stats.norm.cdf。
想生成随机数?调用 numpy.random.normal。
这些成熟的库就像一个个封装完美的“黑盒”,高效、稳定,但也切断了我们与底层逻辑的联系。当你只需一行代码就能得到结果时,你往往会忽略结果背后的数学代价和算法原理。
为什么要从零开始?
并不是为了在生产环境中重复造轮子,而是为了获得一种“透视能力”。当你理解了如何用基础的加减乘除和循环来构建一个正态分布模型时,你对概率的理解将不再停留在公式表面。
本文将带你完成一次代码与数学的探险。我们将只使用 Python 最基础的 math 库,手写实现以下三大核心功能:
- 概率密度函数 (PDF):描述数据的形状。
- 累积分布函数 (CDF):从形状计算概率(涉及数值积分与误差函数)。
- 逆累积分布函数 (Inverse CDF):从概率反推数值(涉及逆变换采样与蒙特卡洛模拟)。
第一章:不确定性的形状 —— 概率密度函数 (PDF)
1.1 什么是“正态”?
在自然界中,很多事情的分布规律都呈现出一种“中间高、两头低”的钟形曲线。金毛猎犬的体重就是最完美的例子:
- 大多数金毛的体重都集中在平均值附近。
- 特别轻(营养不良)或特别重(过度肥胖)的狗都很少见。
这种分布在数学上被称为正态分布(Normal Distribution),或高斯分布。它由两个参数决定命运:
- 均值 (μ\muμ, mean):曲线的中心位置。
- 标准差 (σ\sigmaσ, std_dev):曲线的胖瘦。σ\sigmaσ 越大,数据越分散,曲线越扁平。
1.2 核心公式的 Python 翻译
正态分布的概率密度函数公式看起来很吓人,但拆解开来,它只是一个指数函数:
[ f(x) = \underbrace{\frac{1}{\sqrt{2\pi}\sigma}}{\text{归一化系数}} \cdot \underbrace{\exp\left( -\frac{(x-\mu)2}{2\sigma2} \right)}{\text{钟形曲线核心}} ]
让我们像翻译英文一样,把它翻译成 Python 代码。
import math
def normal_pdf(x: float, mean: float, std_dev: float) -> float:
"""
计算正态分布在 x 处的概率密度 (PDF)。
参数:
x: 也就是我们要观察的变量(例如:某只狗的体重)
mean: 均值 (mu)
std_dev: 标准差 (sigma)
返回:
该点的概率密度高度
"""
# 第一部分:前面的系数,保证曲线下面积为 1
# 2 * pi * sigma^2 开根号
coefficient = 1.0 / math.sqrt(2.0 * math.pi * (std_dev ** 2))
# 第二部分:指数部分,决定了钟形的形状
# (x - mean)^2 / (2 * sigma^2)
exponent_term = -((x - mean) ** 2) / (2.0 * (std_dev ** 2))
return coefficient * math.exp(exponent_term)
1.3 一个常见的误区
初学者最容易犯的错误:
调用 normal_pdf(61, ...) 得到一个数字(比如 0.08),然后说:“这只狗体重刚好是 61 磅的概率是 8%。”
这是错的!
对于连续变量(如体重、时间、温度),任何一个确切点的概率在数学上都等于 0。
normal_pdf返回的是高度(Density),而不是概率(Probability)。- 概率是面积。要得到概率,我们必须计算一段区间内的面积。
这就引出了我们的下一个挑战:如何计算曲线下的面积?
第二章:积分的艺术 —— 数值近似与矩形填充法
2.1 为什么需要积分?
假设我们要回答这个问题:“一只金毛猎犬的体重在 61 磅到 62 磅之间的概率是多少?”
已知数据:
- 平均体重
mean = 64.43磅 - 标准差
std_dev = 2.99磅
数学上,我们需要计算 PDF 曲线在 x=61x=61x=61 到 x=62x=62x=62 之间的面积。这就是定积分:
[ P(61 < X < 62) = \int_{61}^{62} f(x) ,dx ]
但是,如果我们的 Python 环境里没有微积分库,该怎么办?
2.2 切面包的智慧:黎曼和
我们可以使用古老而直观的**黎曼和(Riemann Sum)**思想。
想象曲线下的面积是一块形状不规则的面包。我们无法直接计算它的体积,但我们可以把它切成无数个极薄的方形切片。算出每一片方形面包的面积,加起来,就是总面积的近似值。
这就是矩形填充法。
2.3 算法升级:中点法则 (Midpoint Rule)
最简单的切法是取左端点或右端点的高度,但这样误差较大。更聪明的做法是取中点的高度。
算法步骤:
- 切分:把区间 [a,b][a, b][a,b] 切成 nnn 个小段。
- 计算宽度:每一段的宽度 Δx=(b−a)/n\Delta x = (b-a)/nΔx=(b−a)/n。
- 找中点:找到每一小段中间的那个点 xmidx_{mid}xmid。
- 求高:计算该中点处的 PDF 高度 f(xmid)f(x_{mid})f(xmid)。
- 累加:面积 ≈∑(高×宽)\approx \sum (\text{高} \times \text{宽})≈∑(高×宽)。
2.4 代码实现:手写积分器
这是一个通用的积分函数,它不仅能算正态分布,能算任何函数的积分。
def approximate_integral(a: float, b: float, n: int, f):
"""
使用中点矩形填充法计算定积分。
参数:
a: 积分下限(开始的体重)
b: 积分上限(结束的体重)
n: 切多少刀(矩形数量,越多越准)
f: 被积函数(在这里就是我们的 normal_pdf)
"""
# 计算每个矩形的宽度
delta_x = (b - a) / n
total_sum = 0.0
# 循环计算每一个矩形
for i in range(1, n + 1):
# 核心几何逻辑:
# 第1个矩形中点 = a + 0.5 * delta_x
# 第2个矩形中点 = a + 1.5 * delta_x
# ...
# 第i个矩形中点公式推导:
midpoint = a + (i - 0.5) * delta_x
# 获取该位置的高度(密度)
height = f(midpoint)
# 累加高度
total_sum += height
# 总面积 = 总高度和 * 宽度
# (提取公因式 delta_x 以减少乘法运算次数)
return total_sum * delta_x
2.5 实战演练:计算金毛的概率
现在,我们把第一章的 PDF 和第二章的积分器结合起来。
# 设定金毛的参数
mean_weight = 64.43
std_deviation = 2.99
# 定义一个临时的 lambda 函数,固定住 mean 和 std_dev,只留 x 作为变量
pdf_func = lambda x: normal_pdf(x, mean_weight, std_deviation)
# 计算 61 到 62 磅之间的概率
# 我们尝试切 1000 刀,以保证精度
probability = approximate_integral(a=61, b=62, n=1000, f=pdf_func)
print(f"计算结果:{probability}")
# 输出: 0.082534...
结论: 这只金毛猎犬体重落在 61-62 磅区间的概率大约是 8.25%。
第三章:数学的捷径 —— 累积分布函数 (CDF)
3.1 积分太慢了?
虽然“矩形填充法”很直观,但它有一个致命弱点:慢。
如果你想要极高的精度,可能需要切 100万个矩形,这意味着计算机要循环 100万次。在处理大规模数据时,这是不可接受的。
数学家们找到了正态分布积分的解析解。虽然正态分布的原函数无法用初等函数表示,但可以用一个特殊的函数来描述:误差函数 (Error Function, erf)。
3.2 什么是误差函数 (erf)?
不要被名字吓到。erf(x) 本质上就是数学家为了偷懒而预先算好的一张“超级积分表”。
在 Python 的 math 库中,math.erf 已经经过了极致的底层优化,计算速度比我们要写 for 循环快几千倍。
利用 erf,累积分布函数 (CDF) 的公式如下:
[ F(x) = \frac{1}{2} \left[ 1 + \text{erf}\left( \frac{x - \mu}{\sigma \sqrt{2}} \right) \right] ]
CDF 的含义是:随机变量 XXX 小于等于某个值 xxx 的概率。
即 F(x)=P(X≤x)F(x) = P(X \le x)F(x)=P(X≤x)。
3.3 代码实现:一行抵千行
def normal_cdf(x: float, mean: float, std_dev: float) -> float:
"""
计算正态分布的累积概率 P(X <= x)。
不再需要循环,直接利用 math.erf 进行数学计算。
"""
# 标准化过程:将 x 转换为标准正态分布的 z-score 形式
# 注意分母有个根号2,这是 erf 定义的一部分
scaled_x = (x - mean) / (std_dev * math.sqrt(2.0))
# 套用公式
return (1.0 + math.erf(scaled_x)) / 2.0
3.4 降维打击:用减法代替积分
有了 CDF,计算区间概率变得异常简单。
想知道 P(61<X<62)P(61 < X < 62)P(61<X<62)?
只需要算:(小于62的概率)减去 (小于61的概率)。
[ P(a < X < b) = F(b) - F(a) ]
# 使用 CDF 计算同样的区间
p_fast = normal_cdf(62, mean_weight, std_deviation) - normal_cdf(61, mean_weight, std_deviation)
print(f"CDF 方法结果: {p_fast}")
# 输出: 0.082534...
结果与矩形填充法几乎一致,但计算量从 1000 次运算降低到了 2 次函数调用。这就是数学工具的力量。
第四章:逆向工程 —— 逆累积分布函数 (Inverse CDF)
4.1 提出一个反向问题
前面的计算都是:“给定一个体重,求概率。”
但在实际应用中,我们经常遇到反向问题:“给定一个概率,求体重。”
例如:
- “我想筛选出最重的 5% 的金毛猎犬,门槛体重应该是多少?”
- “工程师说这个气罐有 99% 的概率不会泄漏,那个安全压力值是多少?”
这就需要用到 逆 CDF,通常也称为 分位函数 (Quantile Function / PPF)。
4.2 寻找逆函数
我们需要解方程:p=F(x)p = F(x)p=F(x),求 xxx。
回顾 CDF 公式,我们需要把 xxx 像剥洋葱一样剥出来:
- p=12[1+erf(z)]p = \frac{1}{2} [ 1 + \text{erf}(z) ]p=21[1+erf(z)]
- 2p=1+erf(z)2p = 1 + \text{erf}(z)2p=1+erf(z)
- 2p−1=erf(z)2p - 1 = \text{erf}(z)2p−1=erf(z)
- z=erfinv(2p−1)z = \text{erfinv}(2p - 1)z=erfinv(2p−1) <-- 关键步骤:erf 的逆函数
- x=μ+σ2⋅zx = \mu + \sigma\sqrt{2} \cdot zx=μ+σ2⋅z
Python 的标准 math 库虽然有 erf,但遗憾的是没有提供 erfinv。我们需要借助科学计算库 scipy。
4.3 代码实现:预测的工具
from scipy.special import erfinv
def inv_normal_cdf(p: float, mean: float, std_dev: float) -> float:
"""
逆 CDF:给定累积概率 p (0到1之间),反推对应的数值 x。
例如:输入 p=0.95,返回第 95 百分位的体重。
"""
# 边界检查(可选,但推荐)
if p <= 0 or p >= 1:
raise ValueError("概率 p 必须在 (0, 1) 之间")
# 套用反解出来的公式
term = erfinv(2.0 * p - 1.0)
return mean + (std_dev * math.sqrt(2.0) * term)
4.4 解决实际问题
回到“最重的 5%”的问题。这意味着这只狗的体重超过了 95% 的同类。
即 p=0.95p = 0.95p=0.95。
threshold = inv_normal_cdf(0.95, mean_weight, std_deviation)
print(f"最重的 5% 的金毛,体重至少为: {threshold:.2f} 磅")
# 输出约为 69.35 磅
第五章:上帝掷骰子 —— 蒙特卡洛模拟与采样
5.1 计算机如何生成随机数?
计算机通常只能生成一种最原始的随机数:均匀分布 (Uniform Distribution)。
也就是 random.random() 或 random.uniform(0, 1)。它生成的每一个数在 0 到 1 之间出现的概率是相等的。
但是,现实世界不是均匀的。金毛的体重是正态分布的。
我们如何把“均匀的泥土”塑造成“正态的雕像”?
5.2 逆变换采样 (Inverse Transform Sampling)
答案就在我们刚刚写的 逆 CDF 函数里。这是一个非常深刻且美妙的数学性质:
如果你把一个均匀分布的随机数 U∼(0,1)U \sim (0,1)U∼(0,1) 喂给逆 CDF 函数 F−1(U)F^{-1}(U)F−1(U),输出的结果 XXX 就会严格服从 FFF 所描述的分布。
这就好比逆 CDF 是一个模具。把均匀的液体倒进去,出来的就是正态分布形状的固体。
5.3 代码实现:造物主模式
让我们利用这个原理,模拟生成 1000 只金毛猎犬的体重数据。这在工程上被称为蒙特卡洛模拟。
import random
def generate_golden_retrievers(n: int, mean: float, std: float):
"""
模拟生成 n 只金毛猎犬的体重数据
"""
weights = []
for _ in range(n):
# 1. 掷骰子:生成 0 到 1 之间的随机概率
random_p = random.uniform(0.0, 1.0)
# 2. 查表:通过逆 CDF 将概率转化为具体的体重
weight = inv_normal_cdf(random_p, mean, std)
weights.append(weight)
return weights
# 生成 1000 只狗
population = generate_golden_retrievers(1000, mean_weight, std_deviation)
# 打印前 5 只看看
print("模拟的前 5 只狗体重:", [round(w, 2) for w in population[:5]])
5.4 验证模拟结果
为了证明我们的“造物”是成功的,我们可以简单统计一下这 1000 只虚拟狗的平均体重。理论上,它应该非常接近我们设定的 64.43。
average_simulated = sum(population) / len(population)
print(f"模拟群体的平均体重: {average_simulated:.2f}")
# 结果通常会非常接近 64.43,例如 64.38 或 64.51
第六章:总结与展望
6.1 我们学到了什么?
通过这篇文章,我们没有依赖任何高级统计库,仅凭 Python 基础和数学公式,就构建了一套完整的正态分布处理系统:
- PDF 让我们理解了数据的分布密度。
- 数值积分 让我们明白了概率本质上是面积,并学会了用离散的方法逼近连续的世界。
- CDF 与 erf 向我们展示了数学解析解如何极大地提升计算效率。
- 逆 CDF 赋予了我们预测(分位数)和创造(随机采样)的能力。
6.2 下一步是什么?
掌握了这些原理,你现在可以自信地去阅读更复杂的代码了。当你下次看到 scipy.stats.norm.rvs() 时,你会知道它底层其实就在做我们第五章做的事情——生成均匀随机数,然后通过逆变换映射。
此外,这种思维方式可以扩展到任何分布。无论是指数分布(预测零件寿命)、泊松分布(预测呼叫中心流量),还是贝塔分布(A/B 测试),其核心逻辑都是通用的:
定义密度 -> 积分求概率 -> 求逆生成样本。
这就是统计计算的底层之美。希望这篇指南能成为你通往更深奥数据科学世界的坚实阶梯。
更多推荐



所有评论(0)