5.1 统计分析基础 本节摘要:scipy.stats 是 SciPy 的统计模块,核心是分布对象:norm、t 等分布各自拥有 pdf、cdf、ppf、rvs、fit 五个方法,覆盖概率计算、分位数查询、随机抽样与参数拟合。describe 提供描述统计摘要,t 检验、卡方检验、KS 检验完成假设检验。本节把分布对象与假设检验串起来,并讲清 p 值的正确读法与常见误用。 5.1 统计分析基础 本节导读 阅读完本节,你应当能够: 用分布对象的 pdf、cdf、ppf 完成概率与分位数计算,用 rvs 抽样、用 fit 拟合参数; 读懂 describe 输出的每一项统计量,包括偏度与峰度; 独立完成 t 检验、卡方检验与 KS 检验,并说清各自的适用前提;
本节摘要:scipy.stats 是 SciPy 的统计模块,核心是分布对象:norm、t 等分布各自拥有 pdf、cdf、ppf、rvs、fit 五个方法,覆盖概率计算、分位数查询、随机抽样与参数拟合。describe 提供描述统计摘要,t 检验、卡方检验、KS 检验完成假设检验。本节把分布对象与假设检验串起来,并讲清 p 值的正确读法与常见误用。

阅读完本节,你应当能够:
拿到一批实验数据,多数人的第一反应是算均值和标准差。但这两个数字回答不了真正的问题:这批数据大概服从什么分布?两组数据之间的差异是真实的还是抽样波动?回答这些问题,正是 scipy.stats 的看家本领。
我们从一个能跑的最小例子开始,把分布对象用起来,再讲它背后的道理。别急着背函数签名,先感受一下"一个分布就是一个对象"这件事有多顺手。
scipy.stats 把每个概率分布封装成一个对象。以标准正态分布 norm 为例,五行代码覆盖概率计算的全部常见需求:
import numpy as np from scipy import stats norm = stats.norm # 标准正态分布对象 print(norm.pdf(1.96)) # 概率密度:1.96 处的密度值 print(norm.cdf(1.96)) # 累积分布:P(X <= 1.96) print(norm.ppf(0.975)) # 分位数:cdf 的反函数 rng = np.random.default_rng(42) print(norm.rvs(size=5, random_state=rng)) # 随机抽样
pdf 回答"这个取值有多可能",cdf 回答"不超过某个值的概率有多大",ppf 回答"给定累积概率,对应的分位点在哪",rvs 生成随机样本。五件套里还有 fit,稍后单独讲。这四个方法加上 fit,就是分布对象的全部家当。
t 分布用法完全一样:stats.t(df=9) 先指定自由度,再调用上述方法。自由度不同,分布的胖瘦就不同——自由度越小,尾部越厚。这正是小样本下用 t 分布而不用正态分布的原因:样本少时,我们对均值的估计更没把握,尾部得留得更厚才诚实。ppf(0.975) 算出的临界值,在 t 分布下比在正态分布下更大,就是这个道理。
分布对象之前,先给数据做个体检。describe 把一组数据的常规统计量一次打包:
data = stats.norm.rvs(size=100, random_state=1) print(stats.describe(data))
输出里有 nobs(样本量)、minmax(最小与最大值)、mean(均值)、variance(方差,注意是除以 n-1 的样本方差)、skewness(偏度)、kurtosis(峰度)。偏度描述分布左右是否对称,正值表示右尾更长;kurtosis 描述尾部厚薄,SciPy 给的是超额峰度,正态分布对应 0。这两个量在数据体检里容易被忽略,但做正态性判断时很有用:偏度绝对值大于 1,基本可以怀疑分布不对称。
大多数时候我们不知道数据的真实参数。fit 用极大似然估计从数据里反推:
rng = np.random.default_rng(7) data = stats.norm.rvs(loc=3.0, scale=1.5, size=500, random_state=rng) mu, sigma = stats.norm.fit(data) print(mu, sigma) # 接近 3.0 和 1.5
数据量越大,估计越接近真值,这是极大似然估计的一致性保证的。fit 对初值不敏感的情形居多,但遇到复杂分布(比如混合分布、有边界参数的分布)可能收敛到局部最优,那时就要靠后面章节的优化知识来兜底了。
假设检验的思路是:先假设没有差异(零假设),再看数据在假设下出现的概率。概率太小,就拒绝零假设。scipy.stats 里常用的有三件:
# 独立样本 t 检验:两组均值是否不同 a = stats.norm.rvs(loc=0.5, scale=1.0, size=40, random_state=1) b = stats.norm.rvs(loc=0.0, scale=1.0, size=40, random_state=2) res = stats.ttest_ind(a, b, equal_var=True) print(res.statistic, res.pvalue) # 卡方检验:分类变量是否独立 obs = np.array([[30, 20], [25, 25]]) print(stats.chi2_contingency(obs).pvalue) # KS 检验:样本是否服从某个分布 x = stats.norm.rvs(size=200, random_state=3) print(stats.kstest(x, 'norm').pvalue)
t 检验的前提是两组各自正态、方差齐性;卡方检验要求期望频数足够大(一般不小于 5);KS 检验比较经验分布与理论分布的差距,对连续数据最合适。前提不满足时结果会失真——这正是 5.4 实战里要先做正态性检验的原因。
选检验方法不是看哪个 p 值小,而是先核对前提。下面这张图把最常见的选型路径画出来:

对照这张图,大部分选型问题都能解决。表 5-1 再给一份速查:
| 检验 | 回答的问题 | 主要前提 | 典型场景 |
|---|---|---|---|
| ttest_ind | 两组均值是否不同 | 正态、方差齐 | 实验组与对照组比较 |
| ttest_rel | 配对前后是否不同 | 差值近似正态 | 同批对象治疗前后 |
| chi2_contingency | 两个分类变量是否独立 | 期望频数不小于 5 | 性别与产品偏好 |
| kstest | 样本是否服从某分布 | 连续数据、参数已知 | 正态性验证 |
| ks_2samp | 两组分布形状是否相同 | 连续数据 | 两批样本分布比较 |
| shapiro | 单组是否正态 | 样本量适中 | 检验 t 检验前提 |
p 值的严格定义是:在零假设为真的前提下,观测到当前或更极端结果的概率。它回答"如果两组其实没有差异,出现这么大数据差的可能性有多大",而不是"两组有差异的概率有多大"——这两个说法看着像,实质差得远。
最常见的误用有三类。第一,把 p 值当效应大小:p 小于 0.05 只说明差异难以用抽样波动解释,不代表差异有实际意义,样本量大了以后,鸡毛蒜皮的差异也能显著。第二,多重比较不做校正:跑二十个检验,纯随机也能撞出一个 p 小于 0.05,需要按 Bonferroni 等方法校正。第三,p 值大于 0.05 就宣称"没有差异":检验不出差异不等于差异不存在,可能只是样本量不够,此时该报告效应量和置信区间,而不是一句"不显著"。
⚠️ 常见坑:先看数据再选检验、选了显著的那个再写报告,这叫 p 值黑客行为。规范的流程是预先定好显著性水平与检验方法,数据到手直接执行。
💡 关键直觉:分布对象五件套是理解一切统计量的钥匙——置信区间就是 ppf 算的,p 值就是 cdf 算的,随机抽样就是 rvs 算的。把五件套玩熟,统计模块一半的魔法就消失了。
分布对象的方法签名整齐,但参数绝不是"均值加方差"这么简单。绝大多数分布接受三个参数:shape 决定分布的形态,loc 控制整体平移,scale 控制拉伸与压缩。正态分布里 loc 恰是均值、scale 恰是标准差,教材举的例子都是它,结果让很多人形成错觉,以为所有分布都长这样。真实情况是:指数分布的 scale 就是均值,loc 是左端点;均匀分布的 loc 与 scale 分别就是左右端点;伽马分布自带一个 shape 参数,位置与尺度另算。参数用错最常见的两种后果:一是密度曲线整体平移错位,叠在数据直方图上怎么看怎么别扭;二是 fit 的返回读错。fit 返回参数的顺序是固定的,正态是 loc、scale,伽马是 shape、loc、scale,贝塔是 a、b、loc、scale——顺序记错,拟合白做。
# 指数分布:loc 是起点,scale 是均值 exp = stats.expon(scale=2.0) print(exp.mean(), exp.ppf(0.5)) # 均值 2.0,中位数约 1.386
排查参数错误最省事的办法不是盯着数字看,而是画图:把拟合出的密度曲线叠在数据直方图上,形状、位置对不对一眼就看得出来。
fit 用极大似然估计,但不是每次都能一步到位。第一类坑是边界参数:伽马分布要求 shape 与 scale 为正,小样本下极大似然解可能冲到边界附近,给出离谱的参数。解决办法是固定已知的、只估未知的——fit 的 floc、fscale 正是干这个的,比如数据起点明确是零,就写 stats.gamma.fit(data, floc=0),把 loc 钉死,其余参数照常估计。第二类坑是初值:对偏态重的分布,默认初值可能把优化带进局部最优,此时用 loc、scale 关键字给一个接近真值的初值,收敛又快又稳。第三类坑是模型本身不对:两簇正态混在一起的数据,拿单峰分布去 fit,拟合出的参数没有意义,得先聚类再分组拟合,或者借助第 2 章的优化工具做专门的混合模型估计。
⚠️ 常见坑:fit 对样本量的要求比直觉高。三十个点拟合正态还能看,拟合带两个 shape 参数的分布,估计量的方差大得吓人,别拿小样本的拟合参数下结论。
假设检验有两类错误:零假设为真却拒绝,是第一类错误,p 值管的就是它;零假设为假却没拒绝,是第二类错误。功效等于一减去第二类错误率,回答的问题是:差异如果真的存在,这次检验有多大概率把它找出来?这个数字在 p 值里看不到,却决定一次实验值不值得做。两组均值差一个标准差、每组三十个样本的设计,功效只有七成出头——差异真实存在时,仍有近三成概率检不出。想提到九成,每组样本量要加到五十左右。这正是"p 值不显著不等于没有差异"的机制来源:很可能是功效不够,而不是差异不存在。
工程上正确的顺序是先算功效再动手:定下想检出的最小效应量、显著性水平与目标功效,反推需要的样本量。反过来,大样本下任何微小差异都会显著,所以效应量必须跟 p 值一起报告,这条纪律在 5.4 实战里还会再见到。
把前面所有东西拼成一段能跑的代码:造一批伽马分布数据,用 fit 反推参数,再用 KS 检验验证分布假设。
data = stats.gamma.rvs(2.0, loc=1.0, scale=0.8, size=200, random_state=5) shape, loc, scale = stats.gamma.fit(data) print(shape, loc, scale) # 接近 2.0、1.0、0.8 ks = stats.kstest(data, 'gamma', args=(shape, loc, scale)) print(ks.pvalue) # 拟合后做 KS 检验
这里有个容易漏的细节:kstest 的 args 参数顺序与 fit 的返回顺序一致,gamma 都是 shape、loc、scale,直接解包传进去即可。还要提醒一句:用同一批数据又拟合又检验,属于"事后检验",p 值会偏乐观;严谨的做法是把数据切成两半,一半拟合、一半检验。
下一节我们把视线从单点数据转到点集:KDTree 怎么让最近邻查询快几个数量级,Delaunay 剖分又怎么把点云变成网格。