Skip to content
Charles Shao
Go back

推荐排序精排:账本只记了「喜欢」,最反直觉的坑是负样本得自己造

views

前五篇讲完了漏斗的第一段——召回。挑片员们(ItemCFPopular双塔)和领班(多路融合)忙活半天,把全店粗筛成 200 部候选送到柜台。这一篇进入漏斗第二段——排序(Ranking / 精排)。延续「录像带出租店」的比喻,这次的主角是店里的「老板」:他要亲自给每部候选片打一个「你会喜欢的概率」分,再挑出最可能命中的 10 部摆到你眼前。

Table of contents

Open Table of contents

一句话理解

排序 = 录像带店的「老板」。

挑片员们(ItemCF、热门榜…)和领班(多路融合)忙活半天,把全店 1 万部片粗筛成 200 部候选送到柜台。但 200 部还是太多,顾客只想看 10 部。

于是老板登场:他不再管「在不在候选里」这种粗活,而是给每部候选片打一个精确的分——「这位顾客会喜欢它的概率是多少」,然后从高到低排,挑出 Top-10 摆出来。

召回看「召没召回」(粗、快、广),排序看「排第几」(细、准、窄)。老板的本事,全在那个「概率分」打得准不准。 而 recsys-mini 的老板,大脑是一台 LightGBM——它通过翻看历史账本,学会了怎么打这个分。


Level 1:老板的活儿 — 召回与排序的分工

整个推荐系统是一条漏斗流水线,老板(排序)卡在召回之后、上桌之前。召回和排序,本质是两种完全不同的活儿

召回(挑片员 + 领班)排序(老板)
处理量全店 1 万 → 200200 → 10
关心什么在不在候选集里候选里排第几
速度要求极快(毫秒级扫全库)可以慢一点(只算 200 个)
精度要求粗(宁滥勿缺,别漏真爱)细(每个候选精确打分)
用的特征少而轻(算相似度/热度)多而重(几十个特征精雕)
评估指标Recall@K / CoverageAUC / GAUC

核心洞察:召回宁可错放、不可漏放(漏了排序也救不回来);排序则要在召回给的候选里精挑细选、分出高下。两者是接力赛,不是竞争关系。

训练这位老板的整条流水线分 6 段:

排序训练流水线 6 段:① 构建特征 → ② 负采样(最头疼的一段)→ ③ 拼接特征 → ④ LightGBM 训练 → ⑤ AUC/GAUC 评估 → ⑥ 存档模型+特征

训练排序模型的 6 段流水线。最头疼、最反直觉的是第 ② 段负采样——因为账本里根本没有「不喜欢」的记录。

下面从老板最头疼的一段——②负采样——讲起。


Level 2:最反直觉的难题 — 账本只记了「喜欢」,没记「不喜欢」

老板要学会打分,最自然的办法就是翻历史账本:把每位顾客过去真的租过、评过分的片找出来,告诉大脑「喏,这些都是他喜欢的,照着学」。这些「真喜欢的记录」就叫正样本(标签记成 1)。

训练脚本开头的注释,其实就是整个训练的「样本蓝图」:

样本构造逻辑:
  正样本 = train (训练集) + valid (验证集) 中的真实正反馈
  负样本 = 按 item 流行度采样 (避开 user 历史)
  特征   = user/item 画像 + train 集统计 (防穿越)
  切分   = train -> 训练, valid -> 早停, test 留给全链路评估scripts/04_train_rank.py

一句一句拆开看:

MovieLens 里的交互全是「评过分」的,本项目统统当成正反馈。真实工业场景里正样本定义更讲究——比如「看完 70% 才算正样本」,免得把「手滑点进去又秒退」也当成喜欢。

蓝图看懂了,但回到「负样本」时,老板就撞上了一个绕不过去的坎

账本只记了顾客租过哪些片(喜欢的),压根没记他不喜欢哪些

打个比方:你想教小孩认猫,只翻给他看一沓猫的照片,从来不给他看狗、兔子、桌子——那他根本学不会「什么不是猫」。下次见到一只狗,他多半也会喊「猫!」模型也一样:光看「喜欢的」学不会,必须同时见到「喜欢的」和「不喜欢的」,才能在两者之间画出一条分界线。

这就是排序里最反直觉的一点:正样本是白送的(真实日志里现成就有),「不喜欢的」得我们自己动手「编」出来。这个活儿专业名叫 负采样(Negative Sampling)

那怎么编?最偷懒的想法是「顾客没租过的,就算他不喜欢」。但全店 1 万部片,一个顾客一辈子也就租过几百部,剩下 9000 多部全算「不喜欢」,一下就翻车:

偷懒造法的毛病通俗解释
数量太悬殊1 条「喜欢」配 9000 条「不喜欢」,模型干脆全猜「不喜欢」——照样有 99.99% 准确率,等于啥也没学
题目太简单随便抓的 9000 部,绝大多数是顾客听都没听过的冷门片,跟他喜欢的爆款一眼就能分开,模型轻松满分却学不到真本事

一句话:负样本不能瞎编,得编得「既不太多、又有点难度」


Level 3:负采样的讲究 — 怎么「造」出合格的负样本

recsys-mini 的负采样逻辑核心就三条规矩:

策略:
  1. 用户已交互过的 item 不能当负样本
  2. 按 item 流行度的 0.75 次方采样 (word2vec 同款), 避免全是冷门
  3. 每个正样本配 neg_ratio 个负样本src/data/sampler.py

规矩一:顾客真租过的,绝不能当负样本

这是底线。如果把顾客真心喜欢的片错标成「不喜欢」,等于教大脑学错的东西

seen = user_history.get(u, set())
# 过采一些以应对碰撞
cand = rng.choice(all_items, size=neg_ratio * 3, p=weights, replace=True)
picked = 0
for c in cand:
    if c in seen:
        continue
    rows.append((u, int(c), ts, 0))
    picked += 1
    if picked >= neg_ratio:
        breaksrc/data/sampler.py

seen 是这位顾客的历史集合,采到的候选只要 c in seen跳过size=neg_ratio*3过采样——多抓 3 倍,因为有些会命中历史被跳掉,留点缓冲保证能凑够。

规矩二:编负样本要「挑大家眼熟的片」,别全挑没人听过的

从顾客没看过的片里挑一些当「他大概不喜欢」,怎么挑有门道。先看两种极端:

折中方案:越火的片,越容易被挑来当负样本,但冷门片也得留点机会。recsys-mini 用「热度开 0.75 次方」(word2vec 论文同款):

rng = np.random.default_rng(seed)
weights = np.power(item_pop, 0.75)
weights = weights / weights.sum()src/data/sampler.py

0.75 次方是个「给热门片松松绑」的旋钮:

负采样的 0.75 次方旋钮:0 次方人人均等采样得到全是冷门片题太简单,1 次方完全照热度采样全是爆款太偏科,0.75 次方折中——热门更容易被抽但冷门也有份,难度刚刚好

负采样的核心权衡:一个「偏向热门、但别偏太狠」的旋钮。0.75 是被无数实践验证过的甜点位置。

旋钮设置大白话效果毛病
0 次方(人人均等)谁都一样概率被抽中全是没人听过的冷门片,题太简单
1 次方(完全照热度)越火越容易被抽中全是爆款,冷门没机会,太偏科
0.75 次方(折中)热门更容易被抽,但冷门也有份难度刚刚好

规矩三:每个正样本配 neg_ratio 个负样本

配置里 neg_ratio: 4,即 1 正配 4 负,最终正样本占比约 1/(1+4) = 20%。这个比例平衡了「别太失衡」和「别丢太多负样本信息」。

一个隐藏的精妙点:两套「用户历史」防泄露

脚本里建了两份用户历史,这是个容易忽略但很关键的细节:

user_history_train = build_user_history(train)
# valid 时刻的用户历史 = train 已交互的 + valid 自身
user_history_full = build_user_history(pd.concat([train, valid]))scripts/04_train_rank.py
历史用在排除范围为什么
user_history_train训练负采样只排除 train 看过的训练阶段只知道 train
user_history_full验证负采样排除 train + valid 看过的给 valid 造负样本时,绝不能把顾客在 valid 里真心喜欢的片抓来当负样本

验证负采样用了 seed + 1——和训练换个随机种子,保证两边采的负样本不刻意雷同。

跑完这两步,就得到了带 label 的训练表和验证表:

   user_id  item_id   ts          label
0      1       1193   978300760     1     # 真实租过 → 正样本
1      1       2858   978300760     0     # 采样造的 → 负样本
2      1        480   978300760     0
3      1       1210   978300760     0
4      1        260   978300760     0     # 1 正配 4 负

Level 4:老板打分的依据 — 特征工程

有了正负样本,老板还得知道凭什么打分。你只甩给他一句「3 号顾客 + 1193 号电影」,他两眼一抹黑。得先把这两个编号「翻译」成一堆看得懂的线索——这位顾客是男是女、多大年纪、爱看什么类型;这部电影什么类型、火不火、口碑好不好……这些线索就叫特征(feature)

三类特征一览

类别函数具体特征比喻
① 静态画像build_user_profile性别、年龄、职业顾客的「身份证」
① 静态画像build_item_profile类型 multi-hot(g_Actiong_Comedy…)影片的「标签牌」
② 行为统计build_user_stats历史交互数、平均评分这位顾客「爱不爱看片、打分严不严」
② 行为统计build_item_stats被交互次数(流行度)、平均分、log 流行度这部片「多火、口碑如何」
③ 偏好交叉build_user_genre_pref顾客在各类型上的历史占比顾客的「口味画像」

最有信息量的一招:交叉特征(口味匹配度)

光知道「顾客爱看动作片」或者「这部是动作片」,单独看都用处不大。真正一锤定音的,是两者对不对得上——顾客爱动作,这片正好是动作,那就是强烈的「会喜欢」信号。assemble_samples 就把「顾客的口味」和「影片的类型」对着乘一乘

    # 交叉: user 在该 item 各 genre 上的偏好之和 (近似 user-item 匹配度)
    matches = []
    for g in genre_cols:
        u_col = f"u{g}"
        if u_col in df.columns and g in df.columns:
            df[f"x_{g}"] = df[u_col] * df[g]
            matches.append(f"x_{g}")
    df["x_match_sum"] = df[matches].sum(axis=1) if matches else 0.0src/features/builder.py

举例:顾客历史里 60% 是动作片(ug_Action=0.6),候选片正好是动作片(g_Action=1)→ x_g_Action = 0.6 × 1 = 0.6,把所有类型加总得 x_match_sum——这个「口味匹配度」通常是排序模型里最强的特征之一

最后用 df.fillna(0.0) 兜底——valid/test 里出现但 train 没见过的新顾客/新片,统计特征会是 NaN,统一填 0(冷启动处理)。


Level 5:一条铁律 — 防穿越(数据泄露)

这是排序工程里最容易翻车、后果最严重的一条铁律:

警告: 所有统计特征必须只用 train 数据计算, 否则数据穿越.
对 valid/test 样本, 我们用 train 集统计的特征值.src/features/builder.py

什么是「穿越」?

穿越(Data Leakage)= 老板翻账本时偷看了「未来」的记录。

想象老板在学「2024 年 1 月这位顾客会不会喜欢《阿凡达》」。如果他算「《阿凡达》的平均分」时用了 2024 年全年的数据——那他其实偷看了未来!上线时哪有未来数据?于是线下评估虚高、线上一塌糊涂

recsys-mini 怎么防

所有统计类特征都只喂 train

def build_item_stats(train_df: pd.DataFrame) -> pd.DataFrame:
    """item 统计: 被交互次数 (流行度), 平均评分"""
    g = train_df.groupby("item_id")
    s = pd.DataFrame({
        "i_inter_cnt": g.size(),
        "i_avg_rating": g["rating"].mean(),
    }).reset_index()
    s["i_log_pop"] = np.log1p(s["i_inter_cnt"])
    return ssrc/features/builder.py
特征用谁算给谁用
user_stats / item_stats / user_genre_pref只用 traintrain 和 valid 样本都用这同一份
静态画像(性别/类型)来自 users.dat/movies.dat,不随时间变无穿越风险

关键:给 valid 样本打特征时,绝不能用 valid 自己的数据算统计,只能用 train 算好的值。这样才能模拟「上线时只有过去数据」的真实情况。test 集更是全程不碰。


Level 6:老板的大脑 — LightGBM 二分类

样本和特征都齐了,终于轮到训练老板的「大脑」。recsys-mini 用 LightGBM(一种梯度提升树 GBDT)。

它其实是「二分类」,不是真·排序

类名虽叫 LGBRanker(排序器),但它干的活其实是判断题——看配置里的 objective

lgb:
  objective: binary
  metric: aucconf/config.yaml

objective: binary 的意思是:模型对每部候选片只回答一道判断题——「这位顾客会喜欢它吗?」并给出一个 0~1 的把握分(概率)。 然后把 200 部按这个把握分从高到低一排,就是排序结果了。

换句话说,老板不是直接学「谁排第几」,而是给每部片单独打个「喜欢的概率」,再按概率排队。这种「先打分、再排队」的套路,是工业界精排最主流、最省心的做法(业内叫 CTR 建模)。

训练时怎么防「学过头」:用验证集当刹车

模型有个通病叫过拟合——书读太死,把训练数据里的偶然噪声也当真理背下来,一遇到新数据就傻眼。好比学生把往年真题答案背得滚瓜烂熟,真考试换套题就抓瞎。怎么防?给它配一套模拟考卷(验证集 valid),边学边模考,一旦「模考成绩不再涨」就赶紧叫停。

        self.model = lgb.train(
            params=self.params,
            train_set=dtrain,
            num_boost_round=self.num_boost_round,
            valid_sets=[dtrain, dvalid],
            valid_names=["train", "valid"],
            callbacks=[
                lgb.early_stopping(self.early_stopping_rounds, verbose=True),
                lgb.log_evaluation(period=50),
            ],
        )src/rank/lgb_ranker.py

其余参数也都是防过拟合的常规配置:

参数作用
learning_rate0.05每棵树学一点点,慢工出细活
num_leaves63单棵树复杂度上限
min_data_in_leaf50叶子至少 50 条样本,防止学到噪声
feature_fraction / bagging_fraction0.9每棵树随机用 90% 特征/样本,增加多样性

Level 7:怎么考核老板 — AUC vs GAUC

老板训练完,得考核他「打分准不准」,输出两个核心指标。

AUC:整体上分得清「喜欢」和「不喜欢」吗

AUC 用大白话说:随便拎出一部顾客喜欢的片、和一部不喜欢的片,老板给喜欢的那部打分更高的概率有多大。

但 AUC 有个毛病:它把所有顾客的片一锅烩在一起比。可推荐根本不是这么用的——我们是给每个顾客单独排一份榜,只关心「在 A 自己这份榜里,A 喜欢的有没有排前面」。

GAUC:在每个顾客自己的榜里,排得准吗

GAUC(分组 AUC)的大白话:不再一锅烩,而是给每位顾客单独算一次 AUC,再按各人样本多少加权平均。这样就只看「每个人自己榜内排得好不好」。

为什么排序更该看 GAUC?「配对数数」一个例子秒懂

要看懂这个例子,先记住 AUC 到底在数什么

AUC 是个配对游戏——把所有「喜欢的片」和所有「不喜欢的片」两两配对,数一数有多少对是「喜欢的那部得分更高」,算对的比例就是 AUC。关键:它会拿任意两部片配对——哪怕这两部属于不同的顾客。

现在上例子。假设就两位顾客,各有一部喜欢的、一部不喜欢的,老板打分如下:

顾客A(资深老客,平时分都打得高):
    喜欢的片   → 0.9
    不喜欢的片 → 0.8

顾客B(新客,平时分都打得低):
    喜欢的片   → 0.3
    不喜欢的片 → 0.2

先看每个人自己的榜(这才是推荐真正要用的):顾客 A 的榜「喜欢 0.9 > 不喜欢 0.8」排对了,顾客 B 的榜「喜欢 0.3 > 不喜欢 0.2」也排对了。两个人各自的榜都完美

可 AUC 不管「谁的榜」,它把全部 2 部喜欢的 × 2 部不喜欢的,两两配出 4 对来数:

#喜欢的片不喜欢的片谁分高?算对吗
1A 喜欢 0.9A 不喜欢 0.80.9 > 0.8
2A 喜欢 0.9B 不喜欢 0.20.9 > 0.2
3B 喜欢 0.3A 不喜欢 0.80.3 < 0.8
4B 喜欢 0.3B 不喜欢 0.20.3 > 0.2

4 对里只对了 3 对 → AUC = 0.75,被硬生生拉低了!罪魁就是第 3 对:拿「B 喜欢的片(0.3)」去和「A 不喜欢的片(0.8)」比。可这俩根本不会出现在同一份榜里。这种跨顾客的配对在推荐里毫无意义,AUC 却照样算进去。

而 GAUC 关起门来,只在每个人自己内部配对——只数第 1 对(A 内部)和第 4 对(B 内部),跨人的第 2、3 对根本不算GAUC = 1.0(满分),公正!

结论:推荐永远只在一个人自己的候选里排序,跨人的分数高低压根用不上——所以 GAUC(只看人内部)才是排序的「主考官」,AUC(混着比)只能算个参考分。

📊 关于具体数值:作为参考量级,这类画像+统计特征的 GBDT 精排,离线 AUC 常落在 0.75~0.85、GAUC 略低于 AUC(因为去掉了用户间偏置,是更难的指标)。注意:负采样比例会显著影响 AUC 的绝对值,这个数字只有「同口径前后对比」才有意义,别拿去跟别的项目硬比。

顺带:特征重要性诊断

训练完还会打印 Top-15 gain 特征,判断「老板主要靠什么打分」。通常 x_match_sum(口味匹配度)、i_log_pop(流行度)、u_avg_rating(用户打分习惯)会排在前列。


Level 8:存档与上线一致性

存档:不只存模型,还要存「特征仓库」

排序工程里另一个极易被忽略却致命的点:

    save_pickle(ranker, art / "ranker_lgb.pkl")
    save_pickle({
        "user_profile": user_profile,
        "item_profile": item_profile,
        "user_stats": user_stats,
        "item_stats": item_stats,
        "user_genre_pref": user_genre_pref,
        "genre_cols": genre_cols,
        "feature_cols": feature_cols,
    }, art / "feature_store.pkl")scripts/04_train_rank.py

为什么要把所有特征表连同模型一起存盘

因为上线打分时,必须用和训练时一模一样的特征。

全链路评估(以及未来真上线)时,召回会产生一批新候选,要给它们打分。这时必须用训练时存下的 user_statsitem_stats 来构造特征——如果上线时临时重算特征,口径稍有偏差,就会出现 训练-上线不一致(training-serving skew),模型打分质量暴跌。

存档内容作用
ranker_lgb.pkl老板的大脑(训练好的 LightGBM)
feature_store.pkl老板打分用的全套账本(所有特征表 + 特征列定义)

一条跑不掉的铁律:SSB(召回一改,排序必重训)

⚠️ 每次召回路有任何改动(新增一路、调权重、换算法),送到老板柜台的候选分布就变了。如果老板还用旧候选分布训练出的大脑打分,质量会暴跌——这叫 SSB(Sample Selection Bias,样本选择偏差)

解法:召回一改,排序模型必须重新训练。这是 recall ↔ ranking 的「耦合契约」,跑不掉。详见召回全景里的 SSB 一节。

在整个系统里的位置

角色推荐系统术语干啥
各路挑片员 + 领班多路召回 + 融合粗筛出 200 候选
老板排序 / 精排(本文)给每个候选打概率分,挑 Top-10
老板娘重排(Re-ranking)加多样性、去重、最终拍板

TL;DR — 三句话

  1. 排序 = 录像带店的老板。 领班把 200 部候选送上柜台,老板(LightGBM 二分类)给每部打一个「会喜欢的概率」分,挑出 Top-10。召回看「召没召回」,排序看「排第几」。

  2. 排序最反直觉的坑:账本只记了「喜欢」,负样本得自己造。热度的 0.75 次方 采负样本(避开用户历史),1 正配 4 负。特征 = 画像 + train 统计 + 口味交叉,统计只能用 train 算(防穿越)。模型用 valid 早停防过拟合。

  3. GAUC 才是排序的主考官;存档要连特征一起存。 AUC 看整体、GAUC 按用户分组——推荐只在用户内部排序,GAUC 更贴合。模型和 feature_store 必须一起存盘,保证训练-上线特征一致。切记 SSB 铁律:召回一改,排序必重训


老板打完分排出 Top-N 后,还差最后一道工序——过滤与重排:老板娘把「已看过、不合规、太单调」的问题一次性收拾干净,再把最终结果摆到你眼前。


views
Share this post on:

Previous Post
地区广告政策与合规:GDPR、中国广告法与平台审核差异
Next Post
多路召回融合 RRF:只看排名不看分数,一招破解「各路分数没法比」