去年有回,运营同学抱着一份数据来找我:三个落地页版本,各跑了小半个月,回收了每版 300 条左右的用户评分,开门见山就问“到底哪个版本该上线”。我的第一反应不是去看均值谁高谁低,而是先问了自己一句:这三组数据的差异,到底是版本本身带来的,还是抽样波动自己折腾出来的?要回答这个问题,靠的就是方差分析(ANOVA),具体到这个场景是单因素 ANOVA;如果再把流量渠道也扯进来一起看,那就得用上两因素 ANOVA。这篇我就把这两种方法一次讲透:计算原理、软件实操、结果解读,外加一堆教科书里不爱写的坑。
如果你手头也遇到“三组以上均值做比较”的问题,无论是用户评分、页面停留时长、实验测量数据还是农学产量数据,这篇文章可以直接当作操作手册来看。我尽量少堆公式,多讲人话,保证你看完能自己跑出结果,也知道该怎么跟别人解释清楚。
1. 为什么“三组均值不一样”不能靠两两 t 检验
1.1 三组数据,做了三次 t 检验,错在哪
先把最经典的问题放前面。很多人拿到三组数据,第一反应是“A 和 B 比一比、A 和 C 比一比、B 和 C 比一比”,三次 t 检验下来,有显著差异的就算赢。
这个做法表面看没问题,实际隐患很大。每一次 t 检验都把显著性水平 α 定在 0.05,也就是检验一次,有 5% 的概率把“本来没差异”的判断成“有差异”,统计学上叫第一类错误(假阳性)。可当你连续做 3 次检验的时候,至少出现一次假阳性的概率就不再是 5% 了,而是:
1 - (1 - 0.05)³ ≈ 14.3%
如果你有 6 组数据,两两比较的次数是 C(6,2)=15 次,这个概率会飙升到 1 - 0.95¹⁵ ≈ 53.7%,跟抛硬币差不多了。换句话说,数据里本来全是噪声,你靠多次 t 检验也能“搅”出一个显著结果来。
这种效应在流行病学里叫“多重比较问题”,在 A/B 测试流程里叫“误差膨胀”。方差分析第一个作用就是把所有组的均值放在同一个模型里做一次全局检验,让整体的第一类错误率控制在设定水平附近,而不是拆成很多次比较去碰运气。
1.2 ANOVA 的零假设和它想省掉的麻烦
单因素 ANOVA 的零假设写出来很简单:
H₀:μ₁ = μ₂ = μ₃
也就是假设三个版本的总体均值完全相等。备择假设是“至少有一组的总体均值不等于其他组”,注意这里是“至少有一组不同”,不是说每两组都不同。
这个设计的巧妙在于:我先用一次检验回答“分组到底有没有用”,如果有用,再去做事后检验(post hoc test)找具体是哪几组之间有差异;如果全局检验都不显著,就不需要再去做两两比较,自然也就绕开了多重比较误差膨胀的问题。
所以每次看到“三组及以上均值比较”的需求,我都会建议直接走 ANOVA,而不是把 t 检验做三遍。这也解释了为什么方差分析是所有多组比较问题的“第一道大门”。
2. 单因素 ANOVA 的计算逻辑:组间变异除以组内变异
2.1 平方和的拆分:SST = SSA + SSE
很多教材喜欢一上来给公式,我换个方式讲。方差分析的核心就一句话:把数据的总波动拆成“组间差异引起的波动”和“组内本来就有的波动”两部分。
举个例子,三组落地页的购买意愿评分,每个版本看 10 个人。把 30 个分数混在一起算一个总体均值,每个分数和总体均值的差,叫总离差。总离差的平方和记作 SST(总平方和)。
这个总离差其实可以拆成两段:
- 该组均值和总均值之间的差,记作组间离差,反映的是“版本”这个分组因素造成的影响;
- 该分数和自己组均值之间的差,记作组内离差,反映的是同组内部个体之间的随机波动。
写成式子就是:
SST = SSA + SSE
其中 SSA 是组间平方和(sum of squares between),SSE 是组内平方和(sum of squares error,也叫误差平方和)。
如果三个版本的用户评分真的因为版本不同而产生系统性差异,那么 SSA 会相对大;如果版本之间其实没区别,只是随机噪声在捣乱,那么组间离差和组内离差本质上差不多大,SSA 相对 SSE 就没有明显优势。
所以 ANOVA 的直觉判断标准就变成:组间变异够不够大,大到不能轻易用随机波动来解释。
2.2 自由度、均方和 F 值
直接比较 SSA 和 SSE 的大小不太公平,因为它们的“规模”不一样。组间平方和只由组数决定,组内平方和由样本总量决定。所以要引入自由度,把平方和除以各自的自由度,得到“均方”(Mean Square)。
具体规则:
- 组间自由度:k - 1,k 是组数;
- 组内自由度:N - k,N 是总样本量;
- 总自由度:N - 1。
于是:
MSA = SSA / (k - 1)
MSE = SSE / (N - k)
F 值就是把这两个均方放在一起比:
F = MSA / MSE
F 值越大,说明组间变异相对于组内变异越突出,也就越有理由怀疑“各组均值相等”这个零假设。注意,F 值是一个比值,它天然不受样本量大小的直接干扰,因为自由度已经被考虑进去了。
2.3 ANOVA 表的结构:F 值的归宿
常见统计软件输出的 ANOVA 表大致长这样:
| 来源 | 平方和 SS | 自由度 df | 均方 MS | F | p 值 |
|---|---|---|---|---|---|
| 组间 | SSA | k-1 | MSA | MSA/MSE | p |
| 组内 | SSE | N-k | MSE | ||
| 总计 | SST | N-1 |
p 值代表的是:在零假设成立的前提下,出现当前这么大 F 值(甚至更大)的概率。p 值小于 0.05,我们就说“组间差异在 0.05 水平下显著”,也就是分组这个因素确实对均值产生了影响。
到这里,单因素 ANOVA 的原理部分就闭环了:一组数据、一个分组变量、一个连续因变量,用 F 检验判断组间均值是否相等。
3. Python 跑单因素 ANOVA:从数据到 Tukey 事后检验
3.1 准备长格式数据
在 Python 里跑 ANOVA,最常用的库是 statsmodels 和 scipy。先说数据格式,这是新手最容易卡住的地方:所有统计模型都要求“长格式”数据,也就是每一行代表一个观测样本,一列是因变量,一列是分组变量。
这是我构造的一组模拟数据,三个版本(A、B、C),每个版本 10 条用户购买意愿评分,分数范围 1 到 10:
import pandas as pd df = pd.DataFrame({ 'version': ['A'] * 10 + ['B'] * 10 + ['C'] * 10, 'score': [8.9, 7.6, 8.3, 9.1, 7.8, 8.6, 8.0, 8.4, 7.9, 8.5, 7.2, 8.4, 7.0, 7.8, 7.5, 6.9, 8.1, 7.3, 7.7, 7.4, 6.8, 7.6, 6.5, 7.2, 6.9, 7.8, 6.6, 7.1, 6.7, 7.4] }) print(df.groupby('version')['score'].agg(['count', 'mean', 'std']))运行之后你会看到:
- A 版均值最高,约 8.31;
- B 版约 7.53;
- C 版约 7.06;
- 各组标准差在 0.6 到 0.8 之间,波动程度接近。
先别急着下结论,均值高低只代表样本,不代表总体。下一步用 ANOVA 看差异是否显著。
3.2 方差分析与结果解读
直接用 statsmodels 的 OLS 配合 anova_lm 来算:
from statsmodels.formula.api import ols from statsmodels.stats.anova import anova_lm model = ols('score ~ C(version)', data=df).fit() anova_table = anova_lm(model) print(anova_table)输出结果类似这样:
| 来源 | df | sum_sq | mean_sq | F | PR(>F) |
|---|---|---|---|---|---|
| C(version) | 2 | 7.97 | 3.99 | 18.3 | <0.001 |
| Residual | 27 | 5.89 | 0.22 |
这里 p 值远小于 0.05,结论是:三个版本的总体均值不全相等,版本因素显著影响用户评分。
但“不全相等”到底是谁和谁不相等?A 和 B 差 0.78,A 和 C 差 1.25,B 和 C 差 0.47,这三个差值哪些是真实的、哪些可能是噪声?这就轮到事后检验出场了。
3.3 Tukey HSD:ANOVA 显著之后,到底谁和谁不同
事后检验有很多种,最常用的是 Tukey HSD(Honestly Significant Difference)。它的好处是控制了所有两两比较的整体错误率,同时比 Bonferroni 校正更不保守,适合“所有组两两之间都想比一比”的场景。
from statsmodels.stats.multicomp import pairwise_tukeyhsd tukey = pairwise_tukeyhsd(endog=df['score'], groups=df['version'], alpha=0.05) print(tukey)结果核心信息:
- A vs B:均值差约 0.78,p 值小于 0.05,显著;
- A vs C:均值差约 1.25,p 值小于 0.001,显著;
- B vs C:均值差约 0.47,p 值大于 0.05,不显著。
这个案例特别适合说明一个常见误解:ANOVA 显著不代表每组两两之间都显著。B 和 C 之间虽然从均值数字上看 B 比 C 高了 0.47 分,但真实差异不足以排除抽样波动的可能。所以最后的业务结论是:A 版显著优于 B 版和 C 版,B 版和 C 版之间没有明显差异,建议直接上 A 版。
3.4 效应量:别只盯 p 值
p 值回答“是否有差异”,效应量回答“差异有多大”。在单因素 ANOVA 里最常用的是 eta-squared(η²):
η² = SSA / SST
用上面的结果就是 7.97 / (7.97 + 5.89) ≈ 0.575,意味着“版本”这个因素解释了约 57.5% 的评分变异。这是一个很大的效应量。
业务汇报时我一般会把 p 值和效应量一起说:“版本对评分有显著影响(F(2,27)=18.3, p<0.001, η²=0.575),其中 A 版显著优于另外两个版本”。这样既回答了“是否显著”,也回答了“影响多大”,比单扔一个 p 值有信息量得多。
4. 两因素 ANOVA:主效应与交互效应的实战拆解
4.1 版本之外再加流量渠道,分析问题变成三个
单因素 ANOVA 只处理一个分组因素,可实际业务里影响结果的因素往往不止一个。还是刚才的例子,落地页版本有 A、B、C 三个,用户来源渠道有搜索、广告、社交三个。这时候我们关心的问题就从“版本有没有差异”变成三个:
- 版本主效应:不同版本之间,评分有没有显著差异?
- 渠道主效应:不同流量渠道之间,评分有没有显著差异?
- 交互效应:版本和渠道是不是“搭配着起作用”?
交互效应是两因素 ANOVA 区别于单因素的关键看点。它回答的问题是:某个版本是否只在特定渠道下才表现好,而不是在所有渠道下都一致地好。
我构造一个 3×3 的模拟示例,每组 10 个样本,总样本量 90。数据用 NumPy 生成,并固定随机种子保证可复现:
import numpy as np import pandas as pd np.random.seed(2024) version = np.repeat(['A', 'B', 'C'], 30) channel = np.tile(np.repeat(['search', 'ad', 'social'], 10), 3) base = {'A': 8.2, 'B': 7.6, 'C': 7.1} interaction = { ('A', 'search'): 0.2, ('A', 'ad'): -0.2, ('A', 'social'): 0.0, ('B', 'search'): -0.4, ('B', 'ad'): 0.5, ('B', 'social'): -0.1, ('C', 'search'): 0.0, ('C', 'ad'): -0.1, ('C', 'social'): 0.1, } score = np.array([ base[v] + interaction[(v, c)] + np.random.normal(0, 0.6) for v, c in zip(version, channel) ]) df2 = pd.DataFrame({'version': version, 'channel': channel, 'score': score})先看各分组均值,用透视表:
print(df2.pivot_table(index='version', columns='channel', values='score', aggfunc='mean'))大致会得到下面这样的格局:
| 版本 | 搜索 | 广告 | 社交 |
|---|---|---|---|
| A | 8.4 | 8.0 | 8.2 |
| B | 7.2 | 8.1 | 7.5 |
| C | 7.1 | 7.0 | 7.2 |
注意看 B 版:在搜索渠道只有约 7.2,在广告渠道却到了 8.1,比 A 版还略高一点。这就是交互效应的直观体现:B 版不是全面差,只是在某些渠道下差。
4.2 Python 实现两因素 ANOVA
用 statsmodels 的 ols 构建包含交互项的模型:
from statsmodels.stats.anova import anova_lm model2 = ols('score ~ C(version) * C(channel)', data=df2).fit() anova2 = anova_lm(model2, typ=2) print(anova2)typ=2 表示使用 Type II 平方和。平衡设计下 Type I、II、III 结果几乎一致,但为了严谨我习惯直接写 typ=2。
输出表里会看到三行核心结果:
- C(version):版本主效应,F 值显著;
- C(channel):渠道主效应,p 值通常不显著;
- C(version):C(channel):交互效应,p 值显著。
这里我先提醒一句:交互效应显著的情况下,要非常小心地解读主效应。比如版本主效应显著,只能说明“平均来看 A 最好”,但这掩盖了“在广告渠道,B 与 A 几乎打平”这个事实。如果业务方只听到“A 最好”就直接放弃 B 在广告渠道的投放,那这个分析结论就是有误导性的。
4.3 交互效应显著时怎么读表
交互效应显著时,正确的做法是把数据按其中一个因素拆开,再分别看另一个因素。比如按渠道拆开,在每个渠道内部单独跑单因素 ANOVA,看看版本的差异方向是否一致。这个操作叫简单效应分析(simple effects analysis)。
代码思路是这样:
for ch in df2['channel'].unique(): sub = df2[df2['channel'] == ch] model_sub = ols('score ~ C(version)', data=sub).fit() table_sub = anova_lm(model_sub) print(f'渠道: {ch}') print(table_sub) print('---')结果会告诉你:
- 在搜索渠道,版本差异非常显著,A 遥遥领先;
- 在广告渠道,版本差异可能变得不显著,B 甚至略高于 A;
- 在社交渠道,版本差异也存在,但幅度比搜索渠道小。
这组结果才是对业务真正有用的信息。上线策略不再是简单的“全部切 A”,而是“搜索和社交渠道主推 A,广告渠道可以继续测试 B 或做分渠道实验”。交互效应把“哪个版本更好”从一个一刀切问题,变成了“哪个版本在哪个场景下更好”的精细化问题。
4.4 简单效应分析:把复杂结论拆给业务看
再强调一遍,简单效应分析并不是只拿这几个分组 p 值就完事了。如果要做严格的统计推断,特别是写论文或做正式报告,需要对简单效应做多重比较校正,或者使用估计边际均值(estimated marginal means)做后续检验。业务探索阶段,先画交互图再拆组,能把大部分关键结论看清楚。
画交互图的思路是:横轴是渠道,纵轴是评分均值,不同版本用不同颜色或线型连起来:
import seaborn as sns import matplotlib.pyplot as plt sns.pointplot(data=df2, x='channel', y='score', hue='version', dodge=True) plt.show()如果几条线交叉明显,交互效应就大概率显著;如果几条线平行,则交互效应基本可以忽略。这个图比任何数字都直观,我每次分析前都会先画一张。
5. 先确认假设再下结论:正态性、方差齐性与独立性
5.1 三条基本假设,分别由谁保证
ANOVA 不是无条件的。它有三条基本假设,任何一条崩溃都可能导致 F 值和 p 值失真。
第一条是独立性。观测之间要相互独立,这个人打了 8 分不影响另一个人打几分。独立性主要由实验设计保证,比如随机分配用户到不同版本、同一用户只进入一个组。如果同一批用户反复打分,那数据就不是独立的,普通 ANOVA 不适用。
第二条是正态性。各组数据近似服从正态分布,或者说残差近似正态。样本量比较大的时候,中心极限定理可以帮忙,每组只有几个样本时正态性就很重要。
第三条是方差齐性。各组的总体方差大致相等。如果一组方差是另一组的几倍,那么 F 检验会被方差大的组带偏,显著结果可能不可靠。
5.2 正态性与方差齐性的检验
用 Python 做检验很简单。正态性用 Shapiro-Wilk 检验,分组件逐个测:
from scipy import stats for v in df['version'].unique(): stat, p = stats.shapiro(df.loc[df['version'] == v, 'score']) print(f'{v}: W = {stat:.4f}, p = {p:.4f}')方差齐性用 Levene 检验:
group_data = [df.loc[df['version'] == v, 'score'].values for v in df['version'].unique()] stat, p = stats.levene(*group_data) print(f'Levene: W = {stat:.4f}, p = {p:.4f}')判断标准是:p 值大于 0.05,不拒绝原假设,可以认为满足正态性和方差齐性。我一般还会看一眼箱线图或 Q-Q 图,因为统计检验本身对样本量很敏感,样本一大,很容易测出“统计显著的非正态”,虽然实际偏离程度并不影响结论。
5.3 数据不满足假设时,有哪些退路
如果正态性、方差齐性明显不满足,有几种替代方案:
第一种是 Welch ANOVA,它不要求方差齐性,是普通 ANOVA 的稳健替代。scipy 的 f_oneway 带上 equal_var=False 就是 Welch 版本:
from scipy import stats a = df.loc[df['version'] == 'A', 'score'] b = df.loc[df['version'] == 'B', 'score'] c = df.loc[df['version'] == 'C', 'score'] stats.f_oneway(a, b, c, equal_var=False)第二种是 Kruskal-Wallis 检验,非参数方法,不要求正态性也不要求方差齐性,比较的是各组的中位数或分布位置:
stats.kruskal(a, b, c)事后检验可以配合 Dunn 检验,用 scikit-posthocs 包做 Bonferroni 校正:
# pip install scikit-posthocs import scikit_posthocs as sp sp.posthoc_dunn(df, val_col='score', group_col='version', p_adjust='bonferroni')第三种是对数据进行变换,比如 Box-Cox 变换,把偏态数据尽量掰成近似正态,然后再做 ANOVA。实际业务里,评分型数据一般用 Welch 或 Kruskal-Wallis 就能解决问题,不必过度纠结。
6. 两年跑 ANOVA 踩过的坑(按出现频率排序)
6.1 不平衡数据下,Type I/II/III 平方和选错
三组样本量完全一样时,Type I、II、III 平方和的结果几乎没有差别,所以很多人根本不知道这类问题。但现实数据经常是不平衡的,比如广告渠道用户多、社交渠道用户少,各组样本量差一倍。这时候 statsmodels 的 anova_lm 默认用 Type I,结果会依赖因素进入模型的顺序,先放 version 和先放 channel 可能得到不同结论。
我的建议是:探索性分析直接用 typ=2;如果交互项显著且你要解释主效应,再考虑 typ=3,但更稳妥的做法是绕开主效应,直接做简单效应的分组分析,别跟平方和类型死磕。
6.2 把重复测量数据当成组间两因素
两因素 ANOVA 的另一个高频坑是我见过有人拿“同一批用户的多次测量结果”直接塞进 ols 模型。比如 30 个用户,每个用户同时体验了 A、B、C 三个版本,记录了三个评分。这种情况下同一个人的三个评分根本不独立,直接做普通两因素 ANOVA 自由度全错,p 值大概率虚低。
这种数据对应的是重复测量设计或被试内设计,需要用混合效应模型(mixed effects model)或专门的重复测量 ANOVA。判断标准就一条:数据里有没有“同一个体出现在两个及以上条件组中”?有,就不要用普通组间 ANOVA。
6.3 转化率直接塞进 ANOVA
还有一次我看到同事把转化率(点击/曝光这种二分类结果)按渠道算了一堆数值直接做了 ANOVA。严格来说,转化率是二项分布数据,不是连续正态数据,直接用 ANOVA 会违反假设。更合理的方法是做卡方检验、逻辑回归,或对比例数据做合适的转换。
不是所有数字都能当因变量。ANOVA 的因变量最好是连续的、近似正态的测量值:评分、时长、金额、产量等。遇到 0/1 转化数据,先买张票去逻辑回归那边,别硬挤方差分析。
6.4 事后检验不校正,等于白做
回到文章开头的问题。即使 ANOVA 显著,也不能直接拿 LSD 两两 t 检验的结果交差,尤其当组数多于三组时。LSD 完全不控制多重比较,结论容易虚胖。我的默认选择是 Tukey HSD;如果实验设计里有明确对照组,用 Dunnett;如果比较次数少且想保守一点,用 Bonferroni。反正记住一句话:组数一多,事后检验必须校正,别裸奔。
6.5 把“统计显著”当成“业务显著”
最后这个坑几乎人人都踩过。p 值小于 0.05 只说明这个差异不太像随机波动,不代表这个差异在实际业务里一定值得上线。样本量足够大时,0.2 分的评分差异也可能统计显著,但运营同学看完会说,“这 0.2 分有什么意义?”
所以我做总结时一定会看效应量和实际业务量级。A 版比 B 版高 0.78 分,如果这 0.78 分对应的是更高的下单意愿和复购率,那就有上线的价值;如果只是问卷评分,转化漏斗下游根本接不住这个差异,那统计显著也只能先放一放。
最后分享一个小习惯:我现在拿到任何多组比较需求,第一步永远是画图,箱线图看分布、均值图看趋势、交互图看组合效应,全部看完才跑模型。好的统计结论永远建立在对数据的直觉理解之上,ANOVA 只是给这个直觉提供一个不心虚的数学依据。这个习惯帮我避免了很多“结果显著但方向离谱”的尴尬,希望你也能用上。