63.5 推荐效果评估与 A/B

一句话

离线指标(NDCG/Recall)只能筛掉明显差的模型,最终判决必须靠线上 A/B;而 A/B 的第一道门槛不是显著性,是样本量——效果提升 5% 通常需要几十万到上百万用户才能测出来。

三层评估

层 指标 作用 陷阱
离线 Recall@K、NDCG@K、AUC 快速迭代、筛模型 与线上不一致(特征泄漏、分布不同)
在线(小流量) CTR、CVR、停留、点击深度 真实效果 样本不足、多重比较
长期 留存、复购、生态健康 是否可持续 观察周期长,短期指标可能相反

🔑 离线上限决定“能不能试”,线上决定“要不要上”。离线涨 10%、线上不涨是常态——离线指标衡量的是“拟合历史”,线上衡量的是“改变未来”。

代码:NDCG 与 A/B 样本量

package main

import (
	"fmt"
	"math"
)

// NDCG@k:相关性 rel,折损 1/log2(i+1)
func ndcg(rel []float64, k int) float64 {
	dcg := 0.0
	for i := 0; i < k && i < len(rel); i++ {
		dcg += rel[i] / math.Log2(float64(i+2))
	}
	// 理想排序:降序
	ideal := append([]float64{}, rel...)
	for i := 0; i < len(ideal); i++ {
		for j := i + 1; j < len(ideal); j++ {
			if ideal[j] > ideal[i] {
				ideal[i], ideal[j] = ideal[j], ideal[i]
			}
		}
	}
	idcg := 0.0
	for i := 0; i < k && i < len(ideal); i++ {
		idcg += ideal[i] / math.Log2(float64(i+2))
	}
	if idcg == 0 {
		return 0
	}
	return dcg / idcg
}

func main() {
	fmt.Println("=== NDCG@5:两种排序的质量 ===")
	rel := []float64{3, 2, 3, 0, 1, 2}
	good := []float64{3, 3, 2, 2, 1, 0}
	bad := []float64{0, 1, 3, 2, 3, 2}
	fmt.Printf("理想排序 NDCG = %.3f(基准 1.000)\n", ndcg(good, 5))
	fmt.Printf("次优排序 NDCG = %.3f\n", ndcg(rel, 5))
	fmt.Printf("糟糕排序 NDCG = %.3f\n", ndcg(bad, 5))

	fmt.Println("\n=== A/B 样本量:CTR 从 2% 提升 5%(相对)需要多少人 ===")
	const p = 0.02
	const zAlpha = 1.96 // 95% 置信
	const zBeta = 0.84  // 80% 功效
	for _, mde := range []float64{0.01, 0.05, 0.10, 0.20} { // 相对提升
		delta := p * mde
		n := 2 * (zAlpha + zBeta) * (zAlpha + zBeta) * p * (1 - p) / (delta * delta)
		fmt.Printf("相对提升 %4.0f%%(绝对 %.4f)→ 每组约 %.0f 人\n", mde*100, delta, n)
	}

	fmt.Println("\n=== 按流量算要跑多久 ===")
	const dau = 1_000_000
	for _, mde := range []float64{0.05, 0.10} {
		delta := p * mde
		n := 2 * (zAlpha + zBeta) * (zAlpha + zBeta) * p * (1 - p) / (delta * delta)
		days := 2 * n / dau // 两组各 n 人,按全量流量
		fmt.Printf("提升 %2.0f%%:每组 %.0f 人 → 全量流量约 %.1f 天(只分 10%% 流量则 %.0f 天)\n",
			mde*100, n, days, days*10)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== NDCG@5:两种排序的质量 ===
理想排序 NDCG = 1.000(基准 1.000)
次优排序 NDCG = 0.861
糟糕排序 NDCG = 0.582

=== A/B 样本量:CTR 从 2% 提升 5%(相对)需要多少人 ===
相对提升    1%(绝对 0.0002)→ 每组约 7683200 人
相对提升    5%(绝对 0.0010)→ 每组约 307328 人
相对提升   10%(绝对 0.0020)→ 每组约 76832 人
相对提升   20%(绝对 0.0040)→ 每组约 19208 人

=== 按流量算要跑多久 ===
提升  5%:每组 307328 人 → 全量流量约 0.6 天(只分 10% 流量则 6 天)
提升 10%:每组 76832 人 → 全量流量约 0.2 天(只分 10% 流量则 2 天)

读数:

相对提升 每组样本量 全量流量下的天数
20% 约 1.9 万 0.04 天
10% 约 7.5 万 0.15 天
5% 约 30 万 0.6 天
1% 约 750 万 15 天

🔑 这张表解释了两件事:① “提升 1%”的实验要跑半个月,所以小公司很难测出微小改进;② 实验流量越少、指标方差越大(如 GMV、留存),需要的样本量成倍增加。先算样本量,再决定实验周期,否则会得到“不显著”的假阴性。

A/B 的四个坑

坑 后果 解法
样本量不足就看结果 假阳性/假阴性 先算 power,跑够样本
多重比较(同时看 10 个指标) 至少一个“显著” Bonferroni/ FDR 校正
分流不均匀(用户 ID 取模偏斜) 组间基线不同 哈希分流 + AA 测试
只看短期指标 伤害长期留存 长期指标 + 护栏指标

反模式

反模式 后果
离线涨点就全量 线上不涨甚至跌
实验中途偷看结果就停 假阳性率暴涨
只报“CTR 涨了”不报护栏指标 用时长换点击,体验变差
用同一批用户反复做实验 学习效应污染
不分层(新老用户混在一起) 效应被稀释

自检

Q1 为什么离线指标涨了,线上 CTR 却不涨?

▸ 答案:三个常见原因:① 分布不一致(离线用历史日志训练,线上是新策略产生的数据);② 特征泄漏/线上线下特征不一致(63.3);③ 指标错位——离线 NDCG 衡量“排序质量”,线上 CTR 还受展示样式、位置、多样性影响。离线只能证明“模型能拟合历史”,不能证明“能改变用户行为”。

Q2 为什么“实验跑了一天就显著”通常不可信?

▸ 答案:因为样本量不足时,随机波动很容易产生“显著”差异(假阳性);而且早期用户与后期用户的行为分布不同(新奇效应、时段效应)。正确做法是先算所需样本量,跑满周期(通常至少一个完整周,覆盖工作日与周末),并看护栏指标有没有恶化。

Q3 同时看 10 个指标,为什么至少有一个“显著”是正常的?

▸ 答案:在 95% 置信水平下,单个指标有 5% 概率“假显著”;看 10 个独立指标,至少一个假显著的概率是 1-(0.95)^10 ≈ 40%。所以要么事先指定唯一主指标(其余为护栏/探索),要么做多重比较校正(Bonferroni 把阈值降到 0.005,或 FDR 控制)。

关联