☰
统计检验方法详解:从t检验到方差分析,用Python实现显著性判断
2026/10/17 13:44:16 网站建设 项目流程

两组用户的平均停留时长分别是3.2分钟和3.5分钟,差了0.3分钟,这个差异到底是因为产品改版真实生效了,还是纯粹来自抽样波动?类似的场景每天都在发生:AB测试结果的显著性判断、不同方案转化率的对比、实验组和对照组指标的变化,背后全都指向同一个问题——这批数据到底"有没有显著差异"。

我刚开始做数据分析那几年,遇到最多的情况不是不会调统计函数,而是面对一组数据,不知道应该用哪个检验方法。后来我把常用方法全部梳理了一遍,总结出日常项目中出现频率最高的八类统计检验:正态性检验、单样本t检验、独立样本t检验、配对样本t检验、单因素方差分析、卡方检验、Mann-Whitney U检验、Wilcoxon符号秩检验。这篇文章一方面讲清楚每个方法的使用逻辑,另一方面给出可以直接复制的Python实现。多数代码基于SciPy和statsmodels,注释里会说明适用条件和容易踩的坑,方便直接套用。

1. 先定方向再写代码:统计检验的选用逻辑

1.1 原假设与p值的通俗翻译

统计检验整个体系,本质上是一套"反证法"思维。我们先假设原假设H0成立——通常意味着"没有差异"或"没有关联",然后看当前数据在原假设成立的前提下出现的概率有多低。

这个概率就是p值。p值小于0.05(或者你设定的显著性水平),意味着在原假设成立的条件下,看到当前数据或者更极端情况的概率小于百分之五。普通情况下我们认为这样的小概率事件不太可能发生,于是选择拒绝原假设,认为差异是统计显著的。

一个常见的误解是把p值理解为"做错的概率",或者"两组差异大于实际差异的概率",都不是。p值只是在说:如果数据之间其实没区别,你看到这种区别的可能性有多大。这事想清楚了,后面解读结果就不容易跑偏。

1.2 选检验的两条轴:数据类型与比较目的

面对一份数据,先不要急着翻统计函数。先回答两个问题:数据是什么类型?你想回答什么问题?

数据类型大概是三类:

  • 连续变量:比如时长、金额、温度,可以求均值、标准差。
  • 分类变量:比如注册/未注册、男性/女性/其他,只能数频次。
  • 有序变量:比如满意度1到5分、等级A/B/C,顺序有意义,但差值不一定等距。

比较目的也分几种:

  • 把一组数据和某个已知标准对比,比如"当前系统耗时是否高于我们定的目标值"。
  • 两组数据互相对比,比如"新页面和旧页面的点击率有没有区别"。
  • 三组以上数据整体对比,比如"三种活动策略的收益是否一致"。
  • 两个分类变量之间的独立性,比如"不同渠道的用户是否愿意付费"。
  • 两个连续变量之间的相关性,比如"使用时长和付费金额有没有关联"。

把数据类型和目的叠在一起,基本就能锁定方法。我把第三小节到第八小节要讲的八类检验,按这个逻辑汇总成一张表:

检验目的数据类型分布前提对应方法本文章节
数据是否服从正态分布一组连续变量无Shapiro-Wilk检验第3节
单组均值与标准值比较一组连续变量正态或大样本单样本t检验第4节
两组独立样本均值比较两组连续变量正态、方差齐独立样本t检验第4节
同一组对象前后对比两组配对连续变量正态配对样本t检验第4节
多组均值整体比较三组及以上连续变量正态、方差齐单因素方差分析第5节
分类变量之间是否独立频数列联表期望频数不低于5卡方检验第6节
两组独立数据分布比较连续或有序变量无正态要求Mann-Whitney U检验第7节
配对数据差异比较配对连续或有序变量无正态要求Wilcoxon符号秩检验第7节

这张表基本就是我的日常工作速查表。其中第8节还会额外补充相关性检验——它和"差异比较"不是一回事,但工作中实在太常用,我顺手也把实现写出来了。

1.3 参数检验和非参数检验的路线分叉

严格按照上表走,第一步通常会遇到一个岔路口:数据到底正不正态?

如果数据大致服从正态分布,优先考虑参数检验,比如t检验、方差分析。它们的统计功效更高,对真实差异更敏感。

如果数据明显偏斜、样本量很小、或者存在不少极端值,就要转到非参数检验,比如Mann-Whitney U和Wilcoxon符号秩检验。这类方法基于秩次而不是原始数值,没那么容易被异常值带偏,代价是检测真实差异的灵敏度会稍微降低。

判断正态的方式不只有"看"——看直方图、QQ图,也可以用第3节的正态性检验做定量判断。两条路结合着用,比自己瞎猜强很多。

2. 环境准备与样本构造:没有数据就自己造一份

2.1 需要装的依赖库

本文的代码主要基于三个库:

  • NumPy:生成随机样本、做数组运算;
  • SciPy:提供绝大多数统计检验函数,本文主角;
  • statsmodels:补充多重比较、Tukey事后检验等功能;
  • pandas:整理数据、构造列联表更顺手。

安装方式很简单:

pip install numpy pandas scipy statsmodels

建议使用3.9以上的Python环境。SciPy在1.9版本之后,部分检验函数从返回元组改成了返回类似命名元组的对象,取值方式稍微有变化,我下文会提请注意。版本不同导致API不一致,是老生常谈的坑。

2.2 用随机种子造一份可复现的样本

为了演示,我直接生成三组实验数据,模拟三个不同版本的活动页面在某指标上的表现。固定随机种子,确保你复现出来的结果和我一致。

import numpy as np import pandas as pd from scipy import stats np.random.seed(2024) # 模拟三组用户的行为指标,比如人均点击量 group_a = np.random.normal(100, 15, 30) # 均值100,标准差15,样本量30 group_b = np.random.normal(110, 15, 30) # 均值110,可以认为真实有差异 group_c = np.random.normal(95, 18, 30) # 均值95,标准差更大一些 data = pd.DataFrame({ "label": ["A"] * 30 + ["B"] * 30 + ["C"] * 30, "value": np.concatenate([group_a, group_b, group_c]) })

为什么要特意生成标准差不同的数据?因为后面讲独立样本t检验时,正好用得上方差齐性的讨论。现实数据里两组完全等方差的情况反而不多见,提前把这种变量埋进去,后面讲注意事项时就不用重新造数。

2.3 为什么先看描述统计和可视化

代码写进检验函数之前,先花两分钟看一眼描述统计。这一步虽然简单,却经常能发现明显问题:某个组有缺失值、某个组的分布严重偏斜、甚至数据标签对错了。

print(data.groupby("label")["value"].describe())

箱线图也是个高效工具:

import matplotlib.pyplot as plt data.boxplot(column="value", by="label") plt.show()

我见过不少人直接跳过这一步就跑t检验,后来发现异常值全来自某个样本录入错误,检验结果完全失真。描述统计和可视化不是研究员的例行公事,它们是帮你发现"脏数据"的第一道防线。

3. 正态性门槛:决定走参数检验还是非参数路线

3.1 Shapiro-Wilk检验怎么用

正态性检验我日常主力用Shapiro-Wilk检验,它对中小样本的表现比较可靠。它的原假设是:样本来自正态分布。也就是说,p值大于0.05时,我们没有足够证据拒绝正态性,可以继续使用参数检验。

# 分别检验三个组 for label, group in data.groupby("label")["value"]: w_stat, p_value = stats.shapiro(group) print(f"组 {label}: W={w_stat:.4f}, p={p_value:.4f}")

由于前面固定了随机种子,只要数据没改,这组的结果就是确定的。通常你会看到三组的p值都大于0.05,说明用正态分布描述这些数据没有明显毛病,后续的t检验和方差分析在正态前提上是站得住的。

3.2 大样本陷阱与辅助判断

一个容易漏掉的细节:Shapiro-Wilk在样本量很大的时候会对"轻微偏离正态"极度敏感。当数据量到达几百上千,即使分布只是稍微有点偏斜,p值也往往会变得非常小,导致检验频繁拒绝正态。

这时候不要急着给数据判死刑。更合理的做法是配合直方图或QQ图做主观判断——如果图形上分布仍然近似钟形,只有尾部有一点点歪,那么大样本下的偏斜对t检验的结果影响通常有限。t检验对大样本的偏离其实有一定容忍度,这依赖中心极限定理对样本均值近似正态的保证。

另外提醒一点:如果你用分段数据做假设检验,正态性检验应该按组分别做,而不是把所有数据合在一起做一次。合并后会把组间差异混进分布判断里,得到的结果没有参考价值。

3.3 正态性不通过时的下一步

如果检验结果确实明确显示偏斜严重,有几个选择:

  • 使用非参数替代方法,比如Mann-Whitney U检验或Wilcoxon检验;
  • 对数据进行变换,如取对数、开根号,让变换后的数据更接近正态;
  • 保留参数检验但谨慎解读,当样本量足够大时,t检验通常仍然不失稳健。

我个人的习惯是:能变换数据就变换,变换之后再用一次Shapiro-Wilk确认;如果变换也不行,就直接上非参数检验。变换数据的好处是保留原始数据的量纲和解释性相对容易;坏处是结果要回推到原始尺度时会有点绕。看你的业务场景取舍。

4. t检验三兄弟:均值比较的三个常用变体

4.1 单样本t检验

单样本t检验解决的最简单问题:一组数据的均值是否等于某个参考值。

举个例子,你的客服平均响应时间目标是小于等于2分钟。现在采集了30个工单的响应时间,想知道当前水平是否真的符合目标。原假设是:总体均值等于2分钟。

# 模拟一组响应时间数据,均值大约在1.9左右 response_time = np.random.normal(1.9, 0.4, 30) t_stat, p_value = stats.ttest_1samp(response_time, popmean=2.0) print(f"t={t_stat:.4f}, p={p_value:.4f}")

这里popmean=2.0就是我们的目标参考值。p值小于0.05就拒绝原假设,说明当前平均响应时间不等于2分钟;如果p值大于0.05,说明没有足够证据认为它偏离目标。

很多人会把这个检验误当成"检验均值是否低于目标值",严格来说这涉及单侧检验。如果你关心的是"是否比目标更好"或者"是否比目标更差",需要设置alternative='less'或alternative='greater'。双侧检验不问方向,只问有没有差异。

4.2 独立样本t检验与方差齐性

独立样本t检验解决的是:两组独立数据之间的均值是否存在显著差异。这里"独立"是关键,意思是两组样本来自不同的对象,A组和B组之间没有一一对应的关系。

# 当前A组和B组 t_stat, p_value = stats.ttest_ind(group_a, group_b) print(f"t={t_stat:.4f}, p={p_value:.4f}")

我在2.2节特意给B组设了均值110,所以这个检验大概率会给出显著的p值。这正好演示了"真实差异能否被足够样本量检测出来"的过程。

但这里有个前提参数容易被忽略:equal_var。默认情况下SciPy的ttest_ind假设两组方差相等。如果两组方差异常悬殊,应该把它设置为equal_var=False,对应Welch t检验。判断方差是否齐性可以提前做一个Levene检验:

from scipy.stats import levene lev_stat, lev_p = levene(group_a, group_b) print(f"Levene检验: p={lev_p:.4f}")

如果Levene检验的p值小于0.05,说明方差不齐,跑t检验时记得用equal_var=False。Welch t检验不假设方差相等,自由度也会做相应修正,在现实中通常比普通t检验更稳健,我越来越多地直接用它。

4.3 配对样本t检验

配对t检验针对的是"同一批对象的前后对比"。比如记录同一批用户在使用新功能前后的使用时长,或者同一批学生在培训前后的考试成绩。每个对象有两个关联的测量值,不能把它们当成两组独立数据来处理。

# 模拟25个用户的训练前和训练后成绩 before = np.random.normal(70, 10, 25) after = before + np.random.normal(5, 4, 25) # 成绩平均提升了5分 t_stat, p_value = stats.ttest_rel(before, after) print(f"t={t_stat:.4f}, p={p_value:.4f}")

同学期成绩天然有正相关,所以不能简单把"培训前70分"和"培训后75分"当成两组独立的人。把配对信息拿掉,会低估检验的灵敏度,也就是统计功效变差。ttest_rel利用的就是每对数据之间的差值信息,把个体差异先削掉,再做单样本的均值检验。

4.4 t检验的误用边界

我见过最经典的误用场景有两个。

第一个是把配对数据当独立样本处理。比如同一个用户在不同周期的行为数据,明明是强相关的,却用ttest_ind硬跑一遍。这样做的后果是标准误被高估,本来可能显著的结果变得不显著,白白错过真实效果。

第二个误用是忽略多重比较。组数多了之后,有些人会直接拿三组数据两两之间各做一次t检验。这会产生"多重比较膨胀"的问题:比较次数越多,碰巧出现假阳性的概率越大。三组两两对比一共3次检验,在显著性水平0.05下,至少出现一次假阳性的概率已经接近14%,这种事情后面第5节还会详细讲。

5. 多组均值之间的整体比较:单因素方差分析

5.1 为什么不能拿三组数据两两做t检验

假如有A、B、C三个组,有人会直接跑三次t检验:A对B、A对C、B对C。每一组比较都声称"显著性水平0.05",听起来每步都严谨,但整体上的错误率会迅速膨胀。

可以做个大概估算:如果三次检验互相独立,每次犯第一类错误的概率为0.05,那么三次检验中至少犯一次错误的概率约为1减去0.95的三次方,大约14.3%。组数越多,这个值越离谱。

方差分析(ANOVA)的价值,就是用一个F检验整体回答"这么多组的均值到底是否完全相等"。它先把总变异拆成组间变异和组内变异两部分,组间变化如果远大于组内随机波动,F值就会变大,对应的p值变小,于是我们有证据认为至少有一组和其他组不同。

5.2 f_oneway实现与解读

三个组的整体比较,直接一行代码:

f_stat, p_value = stats.f_oneway(group_a, group_b, group_c) print(f"F={f_stat:.4f}, p={p_value:.4f}")

f_oneway会输出F统计量和p值。p值小于0.05,说明至少有一组均值和其他组不同,但我们不知道是哪一组。这就是方差分析的作用边界:它管整体,不管细节。

这里再多说一句:做方差分析前最好还是看一下各组方差是否接近。如果组间方差差异很大,f_oneway的结果可能不稳健。这种情况下可以选择Welch方差分析,或者转用非参数的Kruskal-Wallis检验。后者的思路类似Mann-Whitney U检验,但可以处理多组数据。

5.3 事后比较与替代方案

ANOVA显著之后,接着就是问"差异到底出在哪组之间"。这时候只需要做一次多组间的两两比较,但要用修正后的方法控制整体错误率。

statsmodels里最常用的是Tukey HSD(Honestly Significant Difference):

from statsmodels.stats.multicomp import pairwise_tukeyhsd all_values = data["value"].values all_labels = data["label"].values tukey_result = pairwise_tukeyhsd(endog=all_values, groups=all_labels, alpha=0.05) print(tukey_result)

输出会给出每两组的均值差、p值以及置信区间,还自带是否拒绝原假设的判断。Tukey方法专门为"整体ANOVA显著后找差异"设计,比盲目做多次t检验可靠得多。

如果再严谨一点,方差不齐时可以考虑Games-Howell事后检验;不想深挖的话,至少也要用Bonferroni校正把显著性水平除以比较次数。第9节会再讲这些。

6. 分类变量之间的独立性:卡方检验

6.1 卡方检验到底在检验什么

前面的方法都在处理连续变量,换到分类变量时就要换思路。比如三个版本的落地页,分别有注册和未注册的用户数,我们关心的是:版本类型和用户是否注册,这两个分类变量之间存在关联吗?

这时把数据整理成列联表,每个格子记录频数。原假设是:行变量和列变量相互独立,也就是"注册率在不同版本之间没有差异"。

卡方检验的原理,是把每个格子的实际频数和"在独立假设下应有的期望频数"做对比。实际频数偏离期望频数越远,卡方统计量越大,p值越小,说明变量之间更可能有关联。

6.2 列联表构造与chi2_contingency实操

假设三个版本分别有45、35、40人注册,未注册分别有55、65、60人:

contingency_table = pd.DataFrame( { "registered": [45, 35, 40], "not_registered": [55, 65, 60] }, index=["variant_A", "variant_B", "variant_C"] ) from scipy.stats import chi2_contingency chi2_stat, p_value, dof, expected = chi2_contingency(contingency_table) print(f"卡方统计量={chi2_stat:.4f}, p={p_value:.4f}, 自由度={dof}")

这个例子中,如果p值较大(大于0.05),说明注册比例在不同版本之间没有统计显著的差异;如果p值小于0.05,就说明版本和注册行为之间存在关联。注意expected输出的是期望频数表,它有一张"卡方值很大"的指示作用,下面马上讲。

6.3 期望频数不足与Fisher精确检验

使用卡方检验有一个经典限制:单元格的期望频数不能太小。经验法则是所有期望频数最好大于等于5,如果有超过20%的格子期望频数小于5,卡方统计量的近似效果就会变差。

遇到这种情况,2x2列联表可以换Fisher精确检验,小样本下它直接计算精确概率,没有近似误差:

from scipy.stats import fisher_exact table_2x2 = [[20, 10], [5, 30]] # 行是渠道A/渠道B,列是购买/未购买 odds_ratio, p_value = fisher_exact(table_2x2) print(f"优势比={odds_ratio:.4f}, p={p_value:.4f}")

格子更多的列联表一般考虑合并类别,或者用似然比卡方。我在实际项目中遇到期望频数不足时,第一反应往往不是翻更复杂的检验,而是去问业务方能不能把类别重新合并成有意义的粒度。类别合并本身也是一次决策,闭着眼睛合并反而可能把真实信号磨掉。

7. 不满足正态假设时的两条退路:非参数检验

7.1 Mann-Whitney U检验

正态假设撑不住的时候,最常用的是Mann-Whitney U检验。它用秩次代替原始数值,把所有数据混在一起排大小,再分别看两组的秩和是否偏离预期。

u_stat, p_value = stats.mannwhitneyu(group_a, group_b, alternative="two-sided") print(f"U={u_stat:.4f}, p={p_value:.4f}")

和t检验相比,Mann-Whitney U不要求数据服从正态分布,对离群值也更不敏感。如果两组数据的分布形状大致相似,它可以被解释为中位数之间的差异检验。但要注意:如果两组数据的分布形状差异很大,显著结果可能来自"形状不同",而不是"位置上确有差异",解读时要小心。

新版SciPy中mannwhitneyu返回的是对象而不是简单元组,可以用u_stat.pvalue的方式取值,老版本则直接解包。代码里两种写法都能兼容吗?不完全兼容,保险的做法是按你环境里的版本来。打印res对象看结构就行。

7.2 Wilcoxon符号秩检验

如果你手里是配对数据,比如同一批用户的训练前后成绩,而差值明显不正态,那就用Wilcoxon符号秩检验。它和配对t检验逻辑相似,只是把"差值"换成了"差值的秩"。

w_stat, p_value = stats.wilcoxon(before, after) print(f"W={w_stat:.4f}, p={p_value:.4f}")

这个方法关注的是前后差值分布是否围绕0对称,也就是"干预是否产生了系统性变化"。它是配对t检验最常用的非参数替代,适用范围包含连续数据,也可以用于有序分类数据。

7.3 非参数检验的适用边界

非参数检验不是万能药。它牺牲了一部分对真实差异的敏感度,当数据确实服从正态分布时,同样的样本量下,参数检验更容易拒绝原假设。换句话说,数据正常时优先用t检验;数据不正常时硬用t检验又可能吃饱亏。先看分布,再选路线,这一步别省。

我用过很多次非参数检验,最常遇到的真实场景是业务指标重度右偏。比如用户停留时长,总有少量用户挂着页面很久,分布尾巴特别长。这种情况下取对数后可能近似正态,或者直接Mann-Whitney U走起,都比硬跑t检验靠谱。

8. 容易漏掉的第九种:相关性检验

8.1 Pearson相关

第3节到第7节讲的都是"差异比较",相关性检验回答的是另一个问题:两组连续变量之间是否存在线性关联,以及关联的强度。

# 模拟两个相关变量 x = np.random.normal(50, 10, 60) y = 2 * x + np.random.normal(0, 12, 60) # y随x线性增长 r_stat, p_value = stats.pearsonr(x, y) print(f"Pearson r={r_stat:.4f}, p={p_value:.4f}")

r的范围在-1到1之间,接近1表示强正相关,接近-1表示强负相关,接近0表示没有明显线性关系。p值回答的是"这个相关是否显著不等于0"。

Pearson相关的使用前提是两变量近似正态分布,且关系接近线性。数据明显偏态或存在极端离群值时,一个异常点就可能把相关系数拉到很高,这时候更倾向用Spearman。

8.2 Spearman秩相关

Spearman相关把原始值换算成秩次,再做一次Pearson相关计算。它检验的是单调关系,不管是线性还是非线性,只要一个变量随另一个变量稳定上升或下降,就能被捕捉到。

rho_stat, p_value = stats.spearmanr(x, y) print(f"Spearman rho={rho_stat:.4f}, p={p_value:.4f}")

实际业务指标里,很多关系并不严格线性。比如用户活跃天数和付费金额之间,往往是边缘效应递减的关系,线性相关可能捕捉不到,Spearman却能稳定给出显著的正向关联。

8.3 相关不等于因果

这句话被说烂了,但每次看到结果还是会有人忽略。两组变量相关,可能只是因为它们共同受第三个变量影响。比如夏天冰淇淋销量和溺水人数都上升,两者存在正相关,但不能说冰淇淋导致溺水。

在项目里解释相关性结果时,我一般会额外检查业务逻辑和时间先后关系:原因通常先于结果发生,纯粹的横截面相关只能作为线索,不能直接交给决策。要把一个相关性结论升级成因果结论,需要更严格的实验设计,这是比任何统计函数都难的部分。

9. 踩坑经验:p值之外还需要关心什么

9.1 先画图再跑检验

统计检验是对数据做"减法",可视化则是对数据做"加法"。一张箱线图能告诉你有很多离群点,一个散点图能告诉你"本来该是分组差异,结果组内差异更大"。这些信息在p值里不会自动体现。

我现在拿到一批数据,顺序永远是:描述统计、箱线图或直方图、正式检验。画图花不了几分钟,却能避免把脏数据直接送进检验流程以后,得到一个看似精确、实则没意义的p值。

9.2 样本量大时,显著不等于重要

p值对样本量极其敏感。样本量足够大的时候,极其微小的差异也能显著。比如A组平均时长100.1秒,B组100.2秒,0.1秒的差异在几千样本量下完全可能算出p值小于0.05,但业务上这个0.1秒基本不改变任何决策。

这种时候建议算效应量。以两组均值为例,Cohen's d可以大致理解为"两组均值差相当于多少个标准差":

def cohens_d(x, y): nx, ny = len(x), len(y) df = nx + ny - 2 pooled_std = np.sqrt( ((nx - 1) * np.std(x, ddof=1) ** 2 + (ny - 1) * np.std(y, ddof=1) ** 2) / df ) return (np.mean(x) - np.mean(y)) / pooled_std d_value = cohens_d(group_a, group_b) print(f"Cohen's d = {d_value:.4f}")

一般以0.2、0.5、0.8作为小、中、大效应的经验分界。显著性和效应量一起看,结论才真正可用。

9.3 多重比较校正和p-hacking的红线

如果一次分析里同时做了多个检验,比如对10个指标分别跑t检验,就面临多重比较问题。10个指标、显著性水平0.05,完全无差异的情况下,出现至少一个假阳性的概率也接近40%。

用statsmodels做一次简单校正:

from statsmodels.stats.multitest import multipletests p_values = [0.03, 0.04, 0.01, 0.20, 0.45] reject, pvals_corrected, _, _ = multipletests(p_values, alpha=0.05, method="bonferroni") print(reject) print(pvals_corrected)

Bonferroni方法简单粗暴,把显著水平除以检验次数,有点过于保守。更平衡的选择是FDR校正,method参数换成"fdr_bh"即可。项目里有多个假设同时要验证的时候,我会优先用FDR。

除了统计方法,更要紧的是做事底线。我见过有同事为了"跑出好看的结果",反复更换检验方法、删除离群点、直到p值小于0.05。这种操作在统计上叫p-hacking,结果看起来干净,实际上已经失去可重复性。做分析的底线应该是:先定好检验方案和样本排除规则,再动手跑数,而不是先看结果再倒推方案。我自己的习惯是每次把脚本、随机种子、数据版本全部留下,下次重新跑能复现,才算数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询