《互联网产品的服务端架构·交易、增长与平台化》8.2 指标、显著性与误判

定价页实验跑到第三天,转化率差异的 z 值达到 2.1,团队当天全量;第四天数据回落到基线。文章解释显著性为什么不等于差异大、样本量与最小可检测效应怎么定、偷看和多重比较如何抬高假阳性,并给出双比例 z 检验的 Go 实现与完整的真实测试输出。

第三天显著,第四天回落

「刚好」的定价团队做了一个年费套餐页改版:把“立即购买”旁边的价格拆成月均价格,想降低用户的决策压力。实验计划跑两周,但第三天日报出来时,实验组转化率 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 值小只说明证据强,不说明提升幅度足以覆盖开发和运营成本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查