📑 本页目录(点开跳转)
12 · 评估与 A/B 实验
⏱ 25 分钟 | ⭐⭐ 工作中用得最多的一节
🎯 一句话
离线指标决定你上不上线,在线实验决定你留不留下。 两者经常不一致——理解「为什么不一致」,是初级和高级工程师最大的分水岭。
📊 第一部分:离线指标
分成三类,别搞混
- RMSE, MAE
- 👉 只在显式评分场景有意义。工业界基本不用了
- AUC, GAUC, LogLoss
- 👉 衡量「模型打出来的分数好不好」
- ⚠️ AUC/GAUC 管排序,LogLoss 管概率数值——不是一件事
- Recall@K, Precision@K, NDCG@K, MRR, HitRate@K
- 👉 衡量「排出来的列表好不好」
⚠️ 第 ② 类里藏着一个高频混淆,先说清再往下看:AUC / GAUC 只看「正样本有没有排在负样本前面」, 所以对分数做任何单调变换(全体乘 0.1、全体加 0.05)AUC 纹丝不动——它对「概率的绝对值准不准」是瞎的。 一个把 pCTR 整体高估一倍的模型,AUC 可以毫无变化。
只关心「先给谁看」的推荐场景无所谓;但一旦要把这个分数乘进钱里、或者拿它卡阈值做决策, 你要看的就是 LogLoss 和校准曲线,而不是 AUC。 🔗 这件事的完整后果(含一次 pCTR 高估导致的真实事故)在 15b · 广告:从推荐到竞价 第七节; 校准怎么量(ECE、校准表、Platt / isotonic)在 《模型上线之后》02 · 离线好不等于线上好 第七节。
⭐ AUC:精排的第一指标
定义:随机取一个正样本和一个负样本,模型给正样本打分更高的概率。
AUC = 0.5 → 瞎猜
AUC = 0.7 → 推荐系统里算差
AUC = 0.75 → 一般
AUC = 0.80 → 不错
AUC = 0.85+ → 很好(或者……你穿越了,去查特征)⚠️
💡 推荐系统里 AUC 提升 0.001 (0.1个千分点) 就是有意义的改进
别拿 CV/NLP 的直觉来看这个数字
🔥 GAUC:比 AUC 更该看的指标
AUC 的致命问题:它把所有用户混在一起算。
用户A(重度用户):点了 100 个,预测分都在 0.8-0.9
用户B(轻度用户):点了 2 个,预测分都在 0.1-0.2
全局 AUC 很高 ✅ —— 因为 A 的正样本分数 > B 的负样本分数
但这个"高"毫无意义 —— 你从来不需要比较 A 和 B 的物品!
GAUC = 按用户分组算 AUC,再加权平均:
$$\text{GAUC} = \frac{\sum_u w_u \cdot \text{AUC}_u}{\sum_u w_u}, \quad w_u = \text{用户 } u \text{ 的曝光数}$$
💡 人话:「在每个用户自己的候选列表里,模型排得对不对?」 —— 这才是线上真实发生的事。
import numpy as np
from sklearn.metrics import roc_auc_score
def gauc(user_ids, y_true, y_pred):
total_w, total_auc = 0.0, 0.0
for u in np.unique(user_ids):
m = user_ids == u
yt = y_true[m]
if len(np.unique(yt)) < 2: # 全正或全负,算不了 AUC,跳过
continue
w = m.sum()
total_auc += w * roc_auc_score(yt, y_pred[m])
total_w += w
return total_auc / total_w if total_w else 0.0
📌 面试高频:「AUC 和 GAUC 的区别?为什么推荐系统更看 GAUC?」 答案就是上面那段。
⭐ NDCG:排序质量的黄金标准
为什么需要它:Precision@10 认为「排第 1」和「排第 10」一样好。显然不对。
DCG@K = Σ (2^rel_i - 1) / log₂(i + 1)
i=1..K
↑ ↑
物品的相关度 位置折损(越靠后贡献越小)
NDCG@K = DCG@K / IDCG@K ← 除以"理想排序"的 DCG,归一化到 0~1
def ndcg_at_k(ranked_relevances, k=10):
"""ranked_relevances: 按模型排序后,每个位置的真实相关度"""
r = np.asarray(ranked_relevances, dtype=float)[:k]
discounts = np.log2(np.arange(2, len(r) + 2))
dcg = np.sum((2 ** r - 1) / discounts)
ideal = np.sort(np.asarray(ranked_relevances, dtype=float))[::-1][:k]
idcg = np.sum((2 ** ideal - 1) / np.log2(np.arange(2, len(ideal) + 2)))
return dcg / idcg if idcg > 0 else 0.0
各指标速查
| 指标 | 公式直觉 | 什么时候用 |
|---|---|---|
| Recall@K | 相关物品有多少被召回了 | ⭐ 召回阶段的主指标 |
| Precision@K | 推的 K 个里有多少是对的 | 展示位有限时 |
| HitRate@K | 前 K 个里有没有命中(0/1) | 序列推荐常用 |
| MRR | 第一个正确答案的位置的倒数 | 只关心第一个命中 |
| NDCG@K | 考虑位置和相关度等级 | ⭐ 排序质量的通用标准 |
| MAP | 各位置 Precision 的平均 | 多个正样本时 |
| AUC / GAUC | 正样本排在负样本前的概率 | ⭐ 精排 CTR 模型 |
| LogLoss | 概率预测的准确度 | 需要概率校准时(如广告计费) |
🕳️ 离线评估的五个致命陷阱 ⭐⭐
陷阱 1:随机切分数据集
用未来预测过去 → 离线指标虚高。 ✅ 按时间切分,或用 Leave-One-Out(留最后一次)。
陷阱 2:负采样评测
(第 9 节讲过)采 100 个随机负样本评测 → 指标虚高且模型排名会错。 ✅ 全量排序评测。
陷阱 3:曝光偏差(Exposure Bias)⭐ 最根本的问题
你的测试集里只有「系统曾经推过的物品」。
系统从没推过的物品,用户会不会喜欢?——你的数据里没有答案。
→ 离线评估实际上是在问「新模型有多像老模型」
→ 一个和老模型完全不同但更好的模型,离线指标可能反而更差!
✅ 部分缓解: - 留 1% 流量做随机曝光,收集无偏数据集专门用于评估 - 反事实评估 / Off-Policy Evaluation:IPS(倾向得分加权)、Doubly Robust
陷阱 4:位置偏差
点击率里混着「因为排在第一位所以被点」的成分。 ✅ 训练时把位置作为特征输入,预测时统一填固定值。
陷阱 5:只看总体指标
总体 AUC 涨了,但可能是:新用户跌了、老用户涨了;或者热门涨了、长尾跌了。 ✅ 必须分组看:新/老用户、活跃度分层、热门/长尾物品、不同类目。
🧪 第二部分:A/B 实验(线上才算数)
基本原理
全部用户
│
├──── 50% ────► 【对照组 Control】 跑老策略
│
└──── 50% ────► 【实验组 Treatment】跑新策略
│
跑 7-14 天
│
比较核心指标
│
统计显著? → 全量上线 / 回滚
关键前提:分流必须随机且稳定。
# 标准做法:哈希用户 ID,保证同一用户始终进同一组
bucket = int(hashlib.md5(f"{exp_id}_{user_id}".encode()).hexdigest(), 16) % 100
group = "treatment" if bucket < 50 else "control"
⚠️ 注意 exp_id 要参与哈希,否则多个实验会用同一个分桶 → 实验之间互相污染。
📐 分层实验 / 流量复用(Google Overlapping Experiments)
问题:公司有 200 个实验要同时跑,流量不够分。
同一个用户可以同时命中 A、C、D 每层独立随机哈希(用不同的 salt)→ 层间正交,互不影响
⚠️ 同一层内的实验必须互斥(不能一个用户同时跑两个召回实验)。
📈 该看什么指标(分三档)
【核心指标】决定上不上线,1-2 个就够
· 人均使用时长
· 次日/7日留存
· 人均消费内容数
· GMV / 转化率(电商)
【护栏指标 Guardrail】不能跌,跌了一票否决 ⭐
· 页面加载延迟 P99
· 崩溃率、错误率
· 内容多样性
· 举报率 / 负反馈率
· 创作者侧指标(新作者曝光量)
· 收入(如果实验不是为了提收入)
【诊断指标】用来解释「为什么涨/跌」
· CTR、完播率、点赞率
· 各召回路的贡献占比
· 各类目的曝光分布
🚨 护栏指标是最容易被新人忽略、也最容易出事故的地方。 一个 CTR +3% 但延迟 +200ms、举报率 +50% 的实验,必须回滚。
🔢 统计显著性(别跳过这段)
问题:CTR 从 5.0% 涨到 5.1%,是真的涨了还是随机波动?
import numpy as np
from scipy import stats
def ab_test(clicks_a, imps_a, clicks_b, imps_b, alpha=0.05):
"""两组转化率的显著性检验(双比例 z 检验)"""
p_a, p_b = clicks_a / imps_a, clicks_b / imps_b
p_pool = (clicks_a + clicks_b) / (imps_a + imps_b)
se = np.sqrt(p_pool * (1 - p_pool) * (1 / imps_a + 1 / imps_b))
z = (p_b - p_a) / se
p_value = 2 * (1 - stats.norm.cdf(abs(z)))
# 相对提升 + 95% 置信区间
lift = (p_b - p_a) / p_a
se_diff = np.sqrt(p_a*(1-p_a)/imps_a + p_b*(1-p_b)/imps_b)
ci = (( (p_b-p_a) - 1.96*se_diff)/p_a, ((p_b-p_a) + 1.96*se_diff)/p_a)
return {
"control": f"{p_a:.4%}", "treatment": f"{p_b:.4%}",
"lift": f"{lift:+.2%}", "lift_95CI": f"[{ci[0]:+.2%}, {ci[1]:+.2%}]",
"p_value": round(p_value, 5),
"significant": p_value < alpha,
}
print(ab_test(clicks_a=50_000, imps_a=1_000_000,
clicks_b=51_000, imps_b=1_000_000))
需要多少样本? 先算清楚再开实验:
def sample_size(baseline_rate, mde, alpha=0.05, power=0.8):
"""
mde: 最小可检测提升(相对值),如 0.02 表示想检出 2% 的相对提升
返回:每组需要多少样本
"""
from scipy.stats import norm
p1 = baseline_rate
p2 = baseline_rate * (1 + mde)
z_a, z_b = norm.ppf(1 - alpha/2), norm.ppf(power)
p_bar = (p1 + p2) / 2
n = (z_a*np.sqrt(2*p_bar*(1-p_bar)) + z_b*np.sqrt(p1*(1-p1)+p2*(1-p2)))**2 / (p2-p1)**2
return int(np.ceil(n))
# CTR 5%,想检出 2% 的相对提升
print(sample_size(0.05, 0.02)) # 每组需要约 75 万曝光
💡 这个计算能救你:如果算出来需要 3 个月才能攒够样本,那这个实验就别做了, 或者换一个更敏感的指标 / 更大的流量。
🚨 A/B 实验的七宗罪
| 罪 | 描述 | 后果 |
|---|---|---|
| 1. 偷看结果 (Peeking) ⭐ | 每天看一眼,一显著就停 | 假阳性率从 5% 飙到 30%+ |
| 2. 多重比较 | 看 50 个指标,总有几个显著 | 至少要做 Bonferroni 校正 |
| 3. 新奇效应 | 新东西前几天就是新鲜 | 至少跑满 1 个完整周(含周末) |
| 4. 分流不均 | AA 测试都有差异 | 每次实验先跑 AA 测试验证 ⭐ |
| 5. 网络效应 | 社交产品里实验组影响对照组 | 改用聚类分流/时间片轮转 |
| 6. 幸存者偏差 | 只看留下的用户,流失的没算 | 分母要用实验开始时的全部用户 |
| 7. 只看短期 | 标题党短期 CTR 高,长期掉留存 | 做长期实验 / 反转实验 |
⭐ 必做:AA 测试
在正式实验前,把用户随机分两组,两组跑完全一样的策略。
如果 AA 测试就发现显著差异 → 你的分流系统有 bug,这时候做的任何 A/B 实验都是垃圾。
⭐ 推荐:反转实验(Holdback)
新策略全量上线后,留 1–5% 的用户永远跑老策略,持续观察几个月。
这是唯一能检测「长期效应」的方法——很多策略短期涨、三个月后掉。
🔄 离线和在线不一致,怎么办?(真实工作流程)
离线 AUC 涨了 0.3%,上线 CTR 没动 —— 怎么排查?
① 检查特征穿越 → 离线用了线上拿不到的特征?
② 检查离线/在线一致 → 同一个样本,两边打分一样吗?(必查!)
③ 检查候选集差异 → 离线在小候选集上评,线上是全库?
④ 检查约束差异 → 线上有多样性/频控/广告位,离线没有?
⑤ 检查流量分布 → 离线数据是历史全量,线上是当前时段?
⑥ 检查样本量 → 实验跑够了吗?置信区间包含 0 吗?
🔧 第 ② 条是最常见的原因,也最容易查: 拿 1000 条线上真实请求,用离线模型跑一遍,对比打分。差异 > 1e-5 就是有问题。 这个检查叫 「一致性校验 / Consistency Check」,应该做成上线前的自动化流程。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 上线之后 12 | A/B 的深水区:peeking 为什么把假阳性推到 30%、SRM(样本比例失衡)怎么查、多重比较怎么校正 |
| 上线之后 13 | 有网络效应或改动无法分流时,A/B 做不了——用 DID / 合成控制 / 断点回归代替 |
| 上线之后 14 | 陷阱 5「只看总体指标」的完整版:每个分组都涨、总体却跌,是怎么发生的 |
✅ 检查点
- AUC 和 GAUC 的区别?为什么推荐系统更看 GAUC?
- NDCG 比 Precision@K 好在哪?
- 什么是曝光偏差?它为什么让离线评估从根本上不可靠?
- 护栏指标是什么?举 3 个例子。
- 什么是 AA 测试?为什么必须做?
- 「偷看结果」为什么危险?
- 离线涨、线上不涨,你会先查什么?
👀 答案
1. AUC 把所有用户混在一起算,会奖励「区分不同用户」这种线上用不到的能力。GAUC 按用户分组算再加权平均,衡量的是「在每个用户自己的候选列表里排得对不对」,这才是线上真实场景。 2. NDCG 有位置折损(排第 1 比排第 10 贡献大)和相关度等级,Precision@K 把 K 个位置一视同仁。 3. 训练/测试数据里只有系统曾经推过的物品,没推过的没有标签。所以离线评估实际在衡量「新模型有多像老模型」,一个更好但不同的模型可能离线指标反而差。 4. 不能变差的底线指标。例:延迟 P99、崩溃率、举报率、多样性、创作者曝光、收入。 5. 两组跑完全一样的策略。如果 AA 就显著差异,说明分流系统有 bug,之后所有实验结论都不可信。 6. 每天看、一显著就停 → 相当于做了多次检验,假阳性率从 5% 涨到 30%+。应预先定好样本量和实验时长。 7. 先查离线/在线一致性校验(拿线上真实请求用离线模型跑,对比打分)。这是最常见也最容易查的原因。🛑 可以停在这里
⚡ 走神救援
离线指标:精排看 GAUC(分用户算,比 AUC 靠谱),召回看 Recall@K,排序质量看 NDCG。五大陷阱:随机切分、负采样评测、曝光偏差(最根本)、位置偏差、只看总体。在线 A/B:分层实验复用流量、核心+护栏+诊断三档指标、必做 AA 测试、别偷看结果、跑满一周、用反转实验看长期。离线在线不一致先查一致性校验。⭐ 先算样本量再开实验:CTR 5% 想检出 2% 的相对提升,每组要约 75 万曝光;如果算出来得跑三个月,这个实验就不该做——换更敏感的指标或更大流量。⭐ 分流哈希必须带上 exp_id,否则多个实验共用同一套分桶、结论互相污染;分层实验里同一层内的实验必须互斥,层与层之间用不同 salt 才正交。⚠️ 护栏指标是一票否决项,不是参考项:CTR +3% 但 P99 延迟 +200ms、举报率 +50% 的实验,必须回滚。⚠️ 别拿 CV/NLP 的数量级直觉看 AUC——推荐里 AUC 涨 0.001(一个千分点)就是有意义的改进,反过来看到 0.85+ 先怀疑特征穿越。⭐ 曝光偏差治不好只能缓解:留 1% 随机曝光流量攒一份无偏评估集,或用 IPS / Doubly Robust 做反事实评估。
下一节 👉 13-冷启动与探索利用.md