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 控制)。