Skip to content
Charles Shao
Go back

推荐全链路离线评估:单看召回 HitRate 或排序 AUC 都会骗你

views

前面几篇把推荐漏斗的四段——召回融合排序重排——全串起来了。这最后一篇回到工程师最关心的问题:这一整套系统到底好不好?怎么用一套可信的离线指标把它量出来?

一句话:全链路离线评估把训练好的「多路召回 + 排序」整条链路串起来,在 test 集上跑一遍,算出用户真正会看到的 Top-N 列表的指标。这才是反映系统真实效果的数字。

为什么重要:单独看召回的 HitRate 或排序的 AUC,都会骗你。召回再高,排序把好东西压到第 50 位也白搭;排序 AUC 再漂亮,候选池里压根没有相关 item 也无力回天。只有全链路指标,才能回答「用户打开 App 看到的前 10 个,到底准不准」

Table of contents

Open Table of contents

〇、先建立直觉:一个比喻

把推荐系统想象成一家书店帮你找书

环节书店比喻推荐系统
召回店员从 10 万本书里,先粗筛出 200 本「你大概会喜欢」的搬到桌上多路召回,从全库捞 200 候选
排序再把这 200 本按「你最可能买」从高到低摆好排序模型给候选打分排序
Top-N最后只把最前面的 10 本递到你手上取 Top-10 展示
评估事后看:你真正想买的那本,在不在这 10 本里、排第几全链路离线评估

全链路离线评估就是那个「事后核对」的角色。它不改进店员、也不改进摆书的人,它只负责拿真实答案对一对,告诉你这套流程到底准不准,以及——如果不准,是粗筛环节的锅,还是摆书环节的锅

记住这个比喻里的两个「锅」:召回的锅 = 想要的书根本没被搬上桌;排序的锅 = 书在桌上但被摆到了最后。脚本的三组对照指标,就是为了分清这两个锅。


一、为什么需要全链路评估

推荐系统是多级漏斗:全库 → 召回 → 排序 → 重排 → 最终展示。每一级单独看指标,都有盲区。

只看这个指标它的盲区真实后果
召回 HitRate@200相关 item 进了候选池,但不知道排第几召回命中了,排序把它压到第 180 位,用户永远翻不到
排序 AUC(脱离候选池)衡量的是「给定候选,正负样本排序对不对」,与候选池质量无关AUC 0.85 很漂亮,但候选池里根本没有用户想要的 item
排序 AUC(样本分布偏差)用的是负采样得到的样本,分布和线上召回出来的候选不一样离线 AUC 高,线上 Top-N 却很差(样本选择偏差 SSB)

核心洞察链路的最终效果 ≠ 各级指标的简单叠加。召回和排序会相互影响——召回改了,排序面对的候选分布就变了。要回答「系统到底好不好」,唯一可信的方式是把整条链路跑通,在用户真正会看到的 Top-N 上算指标

这正是全链路评估干的事:它不训练任何东西,只加载已训练好的产物,把召回和排序串起来跑一遍,产出三组可对照的指标。


二、整体流程

脚本的执行分四步:加载产物 → 阶段 1 仅召回 → 阶段 2 召回+排序 → 对照组(召回直出)。

全链路评估流程:加载产物(itemcf/popular/ranker/feature_store)后,阶段 1 仅召回每用户取 200 候选算召回指标;阶段 2 候选摊平成大表拼特征、一次性 batch 打分、按用户重排取 Top-10 算全链路指标;对照组把召回结果直接截断 Top-10 跳过排序,作为 baseline

三组指标用同一个 ground truth、同一批用户算出来,因此可以直接横向对比——这是脚本设计的精髓。

步骤做什么关键代码
加载产物读入 4 个 pickle:两路召回器、排序器、特征仓库load_pickle(art / "...")
构造 ground truthtest 集里每个用户真实交互过的 item 集合test.groupby("user_id")["item_id"].apply(set)
阶段 1 仅召回每个用户召回 200 候选,算召回阶段指标multi.recall(u, k_each, k_final)
阶段 2 召回+排序候选拼特征 → 打分 → 重排取 Top-10ranker.predict(scored)
对照组召回结果直接截断 Top-10,不经排序items[:final_n]

跑完后日志长这样

脚本是纯日志输出(无文件产出),你会在终端看到类似这样的三段(数值为示意):

[召回] 阶段指标 (前 200 候选):
  HitRate@10: 0.0608
  Recall@200: 0.5020
[召回 + 排序] 全链路指标 (Top-10):
  NDCG@10: 0.0512   ← 过了排序
[对照] 仅召回直出 Top-10 (跳过排序):
  NDCG@10: 0.0431   ← 没过排序, 作为 baseline

看日志的诀窍:把最后两段的 NDCG@10 一对比,0.0512 > 0.0431 说明排序确实把命中 item 往前提了——这就是排序的价值被量化出来的样子。


三、四个评估指标(带手算示例)

四个指标都由 evaluate_recall 计算。设某用户 Top-K 推荐为 topk,真实交互集合为 truth

        for u in eval_users:
            truth = ground_truth[u]
            topk = [item for item, _ in recommendations[u][:k]]
            all_recommended.update(topk)

            hit_set = set(topk) & truth
            if hit_set:
                hits += 1
            recalls.append(len(hit_set) / len(truth))

            # NDCG
            dcg = 0.0
            for rank, item in enumerate(topk, start=1):
                if item in truth:
                    dcg += 1.0 / math.log2(rank + 1)
            ideal_n = min(len(truth), k)
            idcg = sum(1.0 / math.log2(r + 1) for r in range(1, ideal_n + 1))
            ndcgs.append(dcg / idcg if idcg > 0 else 0.0)src/eval/recall_metrics.py
指标公式衡量什么关心顺序吗
HitRate@K命中≥1 个的用户数 / 总用户数多少比例的用户「至少猜中一个」
Recall@K|Top-K ∩ truth| / |truth| 的用户平均用户真实喜欢的 item,被捞回来的比例
NDCG@KDCG / IDCG,命中越靠前得分越高命中 item 的排序位置好不好
Coverage@K所有被推荐过的 item 去重数 / 全库 item 数推荐多样性(系统是否只推头部热门)

手算示例

假设某用户真实喜欢 truth = {C}(LOO 切分下通常只有 1 个),两套 Top-5 推荐:

推荐 A:  [C, X, X, X, X]     ← 命中的 C 排在第 1 位
推荐 B:  [X, X, X, X, C]     ← 命中的 C 排在第 5 位
指标推荐 A推荐 B说明
HitRate@511两者都命中了 C → 都算「命中」
Recall@51/1 = 1.01/1 = 1.0truth 只有 1 个且被捞回 → 都满分
NDCG@51/log₂(2) ÷ 1 = 1.001/log₂(6) ÷ 1 = 0.43只有它能区分 A 和 B

关键结论HitRate/Recall 只看「有没有命中」,A 和 B 完全打平;只有 NDCG 看位置,A(命中排第 1)远高于 B(命中排第 5)。排序阶段做的事正是「把命中的 C 从第 5 位提到第 1 位」,所以排序的价值几乎全体现在 NDCG 的提升上

Coverage 直觉

Coverage 不针对单个用户,而是看整个推荐系统给所有用户推的 item,去重后覆盖了全库的多大比例

全库 10000 个 item
所有用户的 Top-K 拼起来去重后 = 6936 个不同 item
Coverage = 6936 / 10000 = 69.36%

Coverage 高 = 长尾 item 也有机会被推(多样性好);Coverage 低 = 系统翻来覆去只推那几百个头部热门(信息茧房风险)。

⚠️ 本项目用 leave-one-out 切分,即每个用户最后一次正反馈做 test。这种情况下大多数用户 truth 只有 1 个 item,Recall@K 退化成 0/1,数值上接近 HitRate@K(上面的示例就是这种情形)。


四、脚本实现逐段拆解

4.1 加载已训练产物

脚本本身不训练任何模型,只把前序脚本的输出加载进来:

    itemcf = load_pickle(art / "recall_itemcf.pkl")
    pop = load_pickle(art / "recall_popular.pkl")
    ranker = load_pickle(art / "ranker_lgb.pkl")
    fs = load_pickle(art / "feature_store.pkl")

    multi = MultiRecaller(
        recallers={"itemcf": itemcf, "popular": pop},
        weights=cfg["recall"]["weights"],
    )scripts/05_offline_eval.py
产物来自内容
recall_itemcf.pkl / recall_popular.pkl召回训练脚本两路召回器(ItemCF / 流行度)
ranker_lgb.pkl排序训练脚本LightGBM 排序模型
feature_store.pkl排序训练脚本特征仓库:user/item 画像、train 集统计、genre 列名等

MultiRecaller 的融合权重(itemcf: 1.0popular: 0.3)直接从 config 读,与训练时完全一致——评估必须复现线上配置,否则指标没有参考意义。

4.2 构造 ground truth

gt 长这样(每个用户 → 他在 test 集里真实交互过的 item 集合,这就是「标准答案」):

gt = {
    1:   {2858},          # 用户 1 真实喜欢 item 2858 (LOO: 只 1 个)
    2:   {1196},
    3:   {593},
    ...
}
变量含义
gt{user_id: {真实交互的 item 集合}}评估的「标准答案」,来自 test 集
recall_k200召回给排序的候选数(recall_for_rank
final_n10排序后取 Top-N 算最终指标(final_topn
total_itemstrain 中 item 去重数Coverage 的分母

total_itemstrain 的 item 数而非全库,因为召回器只可能召回训练时见过的 item,用 train 口径算 coverage 才公平。

4.3 阶段 1 — 仅召回

逐用户调 MultiRecaller.recallk_eachk_final 都设成 200,把每路召回 200、融合后取前 200 的结果存进 recall_onlymulti.recall 返回三元组 (item_id, 融合分, source_scores),评估只需要 (item, score)

# multi.recall(u, ...) 返回:
[(2858, 0.0312, {"itemcf": 1.5}), (1196, 0.0291, {"itemcf": 0.8, "popular": 1.0}), ...]
# 用 _ 丢掉第三项后 recall_only[u] 变成:
[(2858, 0.0312), (1196, 0.0291), ...]

这一步在 topk_list = [10, 50, 200] 上算指标——召回一次 200,可以同时切出 @10/@50/@200 三个口径,这是「召回最大 K」的常见技巧(切片 [:k] 而已,无需重复召回)。

4.4 阶段 2 — 召回 + 排序

这是脚本的核心。分三小步:摊平候选 → 拼特征打分 → 按用户重排。

    with timer("排序阶段 (一次性 batch 打分)", log):
        rows = []
        for u, items in recall_only.items():
            for item_id, _score in items:
                rows.append((u, item_id))
        cand_df = pd.DataFrame(rows, columns=["user_id", "item_id"])
        cand_df["ts"] = pd.Timestamp("1970-01-01")  # 占位, 当前无时间特征
        cand_df["label"] = 0  # 占位

        scored, _ = assemble_samples(
            cand_df, fs["user_profile"], fs["item_profile"],
            fs["user_stats"], fs["item_stats"], fs["user_genre_pref"],
            fs["genre_cols"],
        )
        scored["score"] = ranker.predict(scored)scripts/05_offline_eval.py

为什么要「摊平」? 因为模型批量打分远快于逐用户循环。recall_only{user: [候选...]} 的嵌套结构,把它展开成一张扁平的二维表 cand_df

摊平前 (recall_only):              摊平后 (cand_df):
{                                  user_id  item_id  ts          label
  1: [(2858,..),(1196,..),...],      1      2858     1970-01-01    0
  2: [(593,..), (608,..), ...],      1      1196     1970-01-01    0
  ...                                 1      ...      ...           0
}                                     2      593      1970-01-01    0
                          (共 ≈ 用户数 × 200 行)
子步骤做什么为什么这样
摊平{user: [候选...]} 展开成一张 (user_id, item_id) 大表为了一次性 batch 打分,而不是逐用户调模型
占位列ts 填 epoch、label 填 0assemble_samples 接口需要这两列,但本阶段用不到
拼特征feature_store 里的画像/统计 merge 出完整特征表特征构造逻辑与训练时同一个函数,保证一致
打分ranker.predict(scored) 给每个 (user,item) 一个 CTR 分LightGBM 对整张表向量化打分,极快

性能要点~测试用户数 × 200 个候选一次性喂给模型,而不是循环 predict。LightGBM 批量打分是向量化的,这能把排序阶段从分钟级压到秒级。

打完分后,按用户分组重排,取 Top-N:每个用户的 200 候选按模型分降序、取前 10,得到最终推荐列表 reranked,在 k_list=[10] 上算全链路指标。

注意这里发生了重新排序:阶段 1 候选是按「召回融合分」排的,阶段 2 则按「排序模型分」重排。同一批 200 候选,两种排法选出的 Top-10 通常不同——差异正是排序模型的作用。

4.5 对照组 — 召回直出 Top-N

直接把召回的前 10(按融合分截断,跳过排序)当最终结果。这是一组至关重要的对照:它和阶段 2 用的是同一批候选、同一个 K=10,唯一区别是有没有过排序模型。两者一对比,就能量化排序到底贡献了多少

同样的 200 候选:
  ┌─ 按「召回融合分」取前 10  → [对照]     NDCG@10 = 0.0431  (baseline)
  └─ 按「排序模型分」取前 10  → [召回+排序] NDCG@10 = 0.0512  (↑ 排序的增量)

五、三组指标的对照解读

脚本最终打印三组指标,正确的读法是横向对比

对照项候选数是否过排序主要看回答的问题
[召回] @200200Recall@200候选池的天花板:相关 item 进池了吗
[对照] 召回直出 @10200→10否(按召回分截断)NDCG@10不排序,直接拿召回分排,效果如何(baseline)
[召回+排序] @10200→10NDCG@10 / HitRate@10完整链路的真实效果

怎么判断系统健康

  1. [召回+排序] @10 应该 ≥ [对照] @10——尤其是 NDCG。如果排序后反而更差,说明排序模型有问题(特征穿越、训练/推理特征不一致、或负采样分布偏差太大)。
  2. [召回] @200 是上限——全链路 Recall@10 永远不可能超过 Recall@200。如果 Recall@200 本身就低,问题在召回,排序再强也没用。
  3. 排序的增益主要在 NDCG——排序不增加候选,所以命中数是固定的;排序做的是把命中 item 往前提,因此 NDCG@10 的提升是排序价值最直接的体现。

一张决策图

瓶颈定位决策图:先看 Recall@200 高吗——低则瓶颈在召回(想要的书没搬上桌,回去优化召回);高则再看「召回+排序@10 ≥ 对照@10」吗——否则瓶颈在排序(书在桌上但摆到最后,回去优化排序),是则链路健康两级都在发挥作用

三组对照的本质,是帮你定位瓶颈在哪一级——对应比喻里的两个「锅」。

决策逻辑Recall@200 低 → 优化召回;Recall@200 高但 [召回+排序]@10 没比 [对照]@10 好 → 优化排序。


六、容易踩的坑

说明脚本怎么处理
占位列误用ts/label 是为了凑 assemble_samples 接口的占位值,绝不能进特征当前特征里不含时间;label=0 仅占位,评估用 gt 而非这个 label
特征穿越评估时若用 test 数据算统计特征,就是作弊特征全部来自 feature_store(只用 train 算)
训练/推理特征不一致训练和评估若用不同的特征构造逻辑,指标失真两处都调同一个 assemble_samples
coverage 分母口径用全库 item 当分母会低估 coveragetrain["item_id"].nunique()
逐用户 predict几万用户 × 200 候选逐个调模型会非常慢摊平成一张大表,一次性 batch 打分
LOO 下指标偏低每用户 truth 只 1 个,Recall 上限就是命中率属正常现象,横向对比仍有效

「特征穿越」为什么是头号大忌? 它指的是评估/训练时不小心用到了未来信息,导致离线指标虚高、上线暴跌。这里的防线是:所有统计特征(用户历史交互数、item 流行度等)都只在 train 上算好、存进 feature_store,评估时无论面对哪个用户都原样查表,绝不碰 test。(防穿越的完整讨论见排序。)


七、在系统中的位置与生产 checklist

在整条链路里的位置

数据流水线位置:01 数据 → 02 切分,切分后分别喂给 03 训练召回和 04 训练排序,两者产物汇入 05 全链路评估(总裁判),评估结果再以指标反馈回流到 03 和 04 指导迭代

全链路评估是离线迭代的总裁判:它不产出模型,只产出用于决策的数字。

任何对召回或排序的改动,最终都要回到这里看全链路指标是否真的变好——这是避免「局部指标涨、整体效果跌」的唯一防线。

⚠️ SSB(样本选择偏差)提醒:排序模型训练用的是负采样样本,而评估时面对的是真实召回出来的候选,两者分布不同。[召回+排序]@10 比训练时的 AUC 更接近线上真相——永远以全链路指标为准做上线决策

生产 checklist

检查点问题
评估配置一致召回权重、k 值是否与线上配置完全一致?
特征防穿越统计特征是否只用 train、绝不碰 test?
特征逻辑统一训练与评估是否共用同一套特征构造代码?
三组对照齐全是否同时产出「仅召回 / 召回直出 / 召回+排序」三组,便于定位瓶颈?
批量打分排序是否一次性 batch 打分,而非逐用户循环?
ground truth 来源是否用独立的 test 集(LOO 的最后一次正反馈)?
指标方向[召回+排序]@10 是否 ≥ [对照]@10(尤其 NDCG)?
上限意识Recall@200 是否被当作全链路的天花板来看待?

TL;DR

全链路离线评估把训练好的「多路召回 + LightGBM 排序」串成完整链路,在 test 集上跑出用户真正会看到的 Top-N 的指标。它不训练任何模型,只加载产物并产出三组可对照的指标

  1. [召回] @10/50/200 —— 候选池天花板,看 Recall@200
  2. [对照] 召回直出 @10 —— 跳过排序的 baseline;
  3. [召回+排序] @10 —— 全链路真实效果。

评估指标里 NDCG@10 最能体现排序价值(排序不增候选,只把命中 item 往前提)。判断健康的两条铁律:Recall@200 低就回去优化召回(想要的书没搬上桌);[召回+排序]@10 没超过 [对照]@10 就回去优化排序(书在桌上但摆到了最后)。永远以全链路指标(而非单看召回 HitRate 或排序 AUC)作上线决策。


这就是整个推荐系统系列的收官。从召回全景出发,一路走过 ItemCF热门召回双塔多路融合排序过滤重排,到这里用一套可信的离线指标把它们串起来量化——一个能自洽迭代的推荐系统闭环就完整了。


views
Share this post on:

Previous Post
IAB TCF 与 GPP 深挖:每个 AdTech 工程师都绕不开的『同意』协议
Next Post
过滤与重排:推荐链路最不性感、却最影响体感的最后一道闸门