第三天显著,第四天回落
「刚好」的定价团队做了一个年费套餐页改版:把“立即购买”旁边的价格拆成月均价格,想降低用户的决策压力。实验计划跑两周,但第三天日报出来时,实验组转化率 12.0%,对照组 10.0%,双比例 z 检验的 z 值 2.1,超过 1.96。产品经理说“已经显著了”,当天把实验组全量。
第四天,全量后的转化率回到 10.1%。团队先是怀疑数据管道,后来怀疑缓存,最后才回到实验本身:第三天那个 2.1,是在连续看了三天日报之后出现的。第一天 z 是 0.8,第二天 1.4,第三天 2.1。每一天都看、看到显著就停,等于把“至少有一次越过阈值”当成“真的有效应”。
这不是统计学说错了,而是决策规则用错了。显著性回答的是“如果两组没有差异,看到当前这么大差异的概率有多低”,它不回答“差异有多大”,也不回答“现在能不能停”。
显著性不是“差异大”
双比例 z 检验比较两个成功率。设对照组样本量 n1、成功数 x1,实验组 n2、x2,两组比率为 p1=x1/n1、p2=x2/n2,合并比率 p=(x1+x2)/(n1+n2),标准误为:
se = sqrt(p * (1-p) * (1/n1 + 1/n2))
z = (p2 - p1) / se
z 的绝对值越大,说明“两组同源”的假设下越难出现当前差异。常用的 1.96 对应双侧 5% 的显著性水平。但 z 同时受两个东西影响:差异本身,以及样本量。差异不变,样本量翻四倍,z 会翻一倍。所以“显著”只说明在当前样本量下差异不太像噪声,不说明差异在业务上值得做。
| 误判类型 | 触发方式 | 典型表现 | 防护手段 |
|---|---|---|---|
| 假阳性 | 真实无差异,碰巧越过阈值 | 上线后效果消失 | 预先定样本量与显著性水平 |
| 偷看偏差 | 每天看、显著就停 | 第三天显著、第四天回落 | 固定实验时长或使用序贯检验 |
| 多重比较 | 同时看十几个指标或分组 | 总有一个“显著” | 预注册主指标,校正阈值 |
| 样本量不足 | 差异小、样本少 | 置信区间很宽,方向反复 | 按最小可检测效应算样本量 |
| 指标口径漂移 | 分母、去重、时区变化 | 差异来自统计方式而非产品 | 实验前冻结指标定义 |
| 只看主指标 | 忽略护栏指标 | 转化涨了,退款也涨了 | 主指标加护栏指标一起看 |
最小可检测效应(MDE)是实验设计阶段就要回答的问题:如果真实提升只有 1 个百分点,需要多少样本才能稳定检出?样本量不足时,即使方向对了,z 也可能很小;这时“不显著”不等于“没效果”,只等于“证据不够”。反过来,样本量极大时,0.1 个百分点的差异也会显著,但业务上可能完全不值得投入。
失败场景:看到显著就停,把噪声当成效果
回到开头。团队每天下班前看一次日报,规则是“z 绝对值超过 1.96 就全量”。这个规则的问题不在于 1.96,而在于“每天看一次”本身。假设真实差异为零,单次检验越过 1.96 的概率是 5%;连续看 14 天,至少有一次越过的概率远高于 5%。看得越勤,越容易在某个时刻“显著”。
这次事故还有第二层:团队只看了转化率。实验组的退款率从 3.0% 升到 4.2%,客单价下降 6%,但这两个指标不在日报里。转化率的提升可能来自“月均价格看起来更便宜”的误导,用户下单后才发现总价没变,于是退款。主指标显著,护栏指标恶化,合起来是负收益。
第三个细节是样本量。实验组和对照组各约 5000 人,转化率 10% 对 12%,z 约 3.2,看起来很强;但分城市看,一线城市几乎没差异,增量主要来自三线城市的一个小渠道。这个渠道当天刚好有一批低价流量进入,实验组和对照组的流量结构并不一致。分层随机化没做,分流稳定也救不了混杂。
修复方案不是“以后不看日报”,而是把决策规则前置:实验开始前写清楚主指标、护栏指标、MDE、计划样本量和最长运行时间;运行期间只看数据质量,不看效果;到期后一次性检验,或者使用序贯检验并校正阈值。看到显著就停,本质上是在用多次检验换一个好看的结果。
用 Z 与 Significant 做双比例检验
Rate 负责校验并计算单组成功率,Z 负责比较两组,Significant 只回答“是否越过阈值”。三个函数分开,是为了让调用方无法跳过样本校验,也无法把“阈值”藏进 z 的计算里。
// file: internal/experiment/stats.go
// tier: L1
// verify: go test ./internal/experiment/
package experiment
import (
"errors"
"fmt"
"math"
)
var (
// ErrInvalidSample 表示样本量为非正数,或成功数超出 0..N。
ErrInvalidSample = errors.New("experiment: invalid sample")
// ErrZeroStandardError 表示合并标准误为 0,z 值没有定义。
ErrZeroStandardError = errors.New("experiment: zero standard error")
)
// Sample 是一组实验数据:N 是样本量,Success 是成功数(点击、下单、转化)。
type Sample struct {
N int
Success int
}
// Rate 返回样本的成功率。N 必须大于 0,Success 必须落在 0..N。
// 调用方不要自己用 Success/N 绕开校验,否则零样本会被算成 0%。
func Rate(s Sample) (float64, error) {
if s.N <= 0 {
return 0, fmt.Errorf("%w: n=%d", ErrInvalidSample, s.N)
}
if s.Success < 0 || s.Success > s.N {
return 0, fmt.Errorf("%w: success=%d n=%d", ErrInvalidSample, s.Success, s.N)
}
return float64(s.Success) / float64(s.N), nil
}
// Z 用双比例 z 检验比较实验组与对照组。返回 (实验组转化率 - 对照组转化率)
// 除以合并标准误。z 为正表示实验组更高,为负表示更低。
//
// 这里使用合并比例 p=(x1+x2)/(n1+n2),标准误为
// sqrt(p*(1-p)*(1/n1+1/n2))。两组都全成功或都全失败时标准误为 0,
// 此时差异没有统计意义,返回 ErrZeroStandardError 而不是 NaN。
func Z(control, treat Sample) (float64, error) {
pc, err := Rate(control)
if err != nil {
return 0, fmt.Errorf("control: %w", err)
}
pt, err := Rate(treat)
if err != nil {
return 0, fmt.Errorf("treat: %w", err)
}
n1 := float64(control.N)
n2 := float64(treat.N)
pooled := (float64(control.Success) + float64(treat.Success)) / (n1 + n2)
se := math.Sqrt(pooled * (1 - pooled) * (1/n1 + 1/n2))
if se == 0 {
return 0, ErrZeroStandardError
}
return (pt - pc) / se, nil
}
// Significant 判断 z 的绝对值是否达到阈值。threshold 必须为正;非正阈值
// 返回 false,表示调用方没有给出有效的显著性标准,不能据此上线。
func Significant(z, threshold float64) bool {
if threshold <= 0 {
return false
}
return math.Abs(z) >= threshold
}
实现里有几个关键点:
Rate拒绝N<=0和Success越界。零样本不能算成 0%,否则新实验第一天就会得到“转化率 0%”的假数据。Z先分别校验两组,再用合并比率计算标准误。错误信息带上control:或treat:前缀,排查时能直接看出是哪一组的数据有问题。- 两组都全成功或都全失败时,合并标准误为 0。这时差异没有统计意义,返回
ErrZeroStandardError,而不是让浮点除法产生NaN或正无穷。 Significant用|z| >= threshold,因此实验组更差也会被判为显著。方向由调用方结合业务判断,函数不替业务决定“变好还是变坏”。- 非正阈值直接返回
false。阈值没配置好时不能默认显著,这是失败关闭而不是失败开放。 - 函数不读时间、不读样本量以外的上下文,因此可以用固定样本在测试里复现。偷看、多重比较和实验时长属于决策流程,不是这个纯函数能解决的。
Z 返回的是检验统计量,不是 p 值。生产系统如果要把 p 值展示给业务,需要再用正态分布尾概率换算;如果只是和固定阈值比较,直接比 z 更简单,也少一次数值误差。
测试:已知差异、对称性与零标准误
统计函数的测试要用手算得出的已知值,不能只断言“大于 0”。下面的用例覆盖正常差异、方向对称、零标准误和阈值边界。
// file: internal/experiment/stats_test.go
// tier: L1
// verify: go test ./internal/experiment/
package experiment
import (
"errors"
"math"
"testing"
)
func TestRate(t *testing.T) {
got, err := Rate(Sample{N: 1000, Success: 120})
if err != nil {
t.Fatalf("Rate = %v, want nil", err)
}
if math.Abs(got-0.12) > 1e-9 {
t.Fatalf("Rate = %.6f, want 0.12", got)
}
if got, err := Rate(Sample{N: 10, Success: 0}); err != nil || got != 0 {
t.Fatalf("Rate(0/10) = %.6f, %v; want 0, nil", got, err)
}
if got, err := Rate(Sample{N: 10, Success: 10}); err != nil || got != 1 {
t.Fatalf("Rate(10/10) = %.6f, %v; want 1, nil", got, err)
}
}
func TestRateRejectsBadSample(t *testing.T) {
for _, s := range []Sample{{N: 0}, {N: -1}, {N: 10, Success: -1}, {N: 10, Success: 11}} {
if _, err := Rate(s); !errors.Is(err, ErrInvalidSample) {
t.Fatalf("Rate(%+v) = %v, want ErrInvalidSample", s, err)
}
}
}
func TestZKnownDifference(t *testing.T) {
// p1=0.10, p2=0.12, pooled=0.11, se≈0.013993, z≈1.4293。
z, err := Z(Sample{N: 1000, Success: 100}, Sample{N: 1000, Success: 120})
if err != nil {
t.Fatalf("Z = %v, want nil", err)
}
if math.Abs(z-1.4293) > 1e-3 {
t.Fatalf("Z = %.4f, want 1.4293", z)
}
}
func TestZSymmetric(t *testing.T) {
a := Sample{N: 800, Success: 96}
b := Sample{N: 820, Success: 110}
ab, err := Z(a, b)
if err != nil {
t.Fatalf("Z(a,b) = %v, want nil", err)
}
ba, _ := Z(b, a)
if math.Abs(ab+ba) > 1e-9 {
t.Fatalf("Z(a,b)=%.6f, Z(b,a)=%.6f, want opposite signs", ab, ba)
}
}
func TestZZeroStandardError(t *testing.T) {
if _, err := Z(Sample{N: 100, Success: 0}, Sample{N: 100, Success: 0}); !errors.Is(err, ErrZeroStandardError) {
t.Fatalf("both zero = %v, want ErrZeroStandardError", err)
}
if _, err := Z(Sample{N: 100, Success: 100}, Sample{N: 100, Success: 100}); !errors.Is(err, ErrZeroStandardError) {
t.Fatalf("both full = %v, want ErrZeroStandardError", err)
}
if _, err := Z(Sample{N: 0}, Sample{N: 100, Success: 10}); !errors.Is(err, ErrInvalidSample) {
t.Fatalf("bad control = %v, want ErrInvalidSample", err)
}
}
func TestSignificant(t *testing.T) {
if !Significant(1.96, 1.96) {
t.Fatal("Significant(1.96, 1.96) = false, want true")
}
if Significant(1.959, 1.96) {
t.Fatal("Significant(1.959, 1.96) = true, want false")
}
if !Significant(-2.5, 1.96) {
t.Fatal("Significant(-2.5, 1.96) = false, want true")
}
if Significant(5, 0) || Significant(5, -1) {
t.Fatal("non-positive threshold should never be significant")
}
}
TestRate 验证 120/1000 等于 0.12,以及 0% 和 100% 两个端点。TestRateRejectsBadSample 覆盖零样本、负样本、负成功数和成功数超过样本量。TestZKnownDifference 用 100/1000 对 120/1000,手算 z 约 1.4293,断言误差在千分之一内。
TestZSymmetric 交换两组后断言 z 互为相反数,防止公式里某一组的样本量或成功数用错位置。TestZZeroStandardError 覆盖两组全 0、两组全成功,以及对照组样本量为 0 的情况。TestSignificant 覆盖恰好等于阈值、略低于阈值、负 z 和非正阈值。
这些测试只验证公式和边界,不验证“实验该不该上线”。上线决策还依赖 MDE、护栏指标和实验时长,那些属于流程约束,应该在实验平台里固化,而不是塞进一个统计函数。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/experiment/ -run 'TestRate|TestZ|TestSignificant' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRate
--- PASS: TestRate (0.00s)
=== RUN TestRateRejectsBadSample
--- PASS: TestRateRejectsBadSample (0.00s)
=== RUN TestZKnownDifference
--- PASS: TestZKnownDifference (0.00s)
=== RUN TestZSymmetric
--- PASS: TestZSymmetric (0.00s)
=== RUN TestZZeroStandardError
--- PASS: TestZZeroStandardError (0.00s)
=== RUN TestSignificant
--- PASS: TestSignificant (0.00s)
PASS
ok webapp/internal/experiment 0.270s
PASS 表示 6 个统计用例全部通过。它们锁定的是公式和输入边界;真实实验里更常见的错误不是公式写错,而是样本量没算、指标没冻结、看到显著就停。
小结
显著性回答“当前差异像不像噪声”,不回答“差异有多大”或“值不值得做”。实验设计阶段要定主指标、护栏指标、最小可检测效应、样本量和运行时长;运行期间不要反复检验,到期后一次性判断,或者使用带校正的序贯方法。
Rate、Z 和 Significant 把校验、计算和阈值判断拆开,零标准误直接报错。下一节从实验转到配置:开关也是运行时决策,同样需要边界和治理。
阅读导航:上一节:8.1 实验分流与一致性 · 下一节:8.3 配置平台与开关治理 。
常见错误与边界
- 把“显著”当成“差异大”。样本量足够大时,0.1 个百分点的差异也会显著,但业务上可能不值得做。
- 每天看日报、显著就停。重复检验会抬高假阳性,第三天显著第四天回落是典型表现。
- 实验前不算样本量。样本不足时“不显著”只代表证据不够,不能直接得出“没效果”。
- 同时看十几个指标或十几个分层。总有一个碰巧显著,主指标必须预注册并校正多重比较。
- 指标口径在实验中途变化。分母、去重规则、时区一改,差异可能来自统计方式而不是产品。
- 只看主指标,不看护栏指标。转化涨了但退款率、客单价、投诉率恶化,合起来可能是负收益。
- 把零标准误算成 0 或 NaN。两组全成功或全失败时 z 没有定义,应该显式报错。
- 用 p 值大小代替效应量。p 值小只说明证据强,不说明提升幅度足以覆盖开发和运营成本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。