前五篇讲完了漏斗的第一段——召回。挑片员们(ItemCF、Popular、双塔)和领班(多路融合)忙活半天,把全店粗筛成 200 部候选送到柜台。这一篇进入漏斗第二段——排序(Ranking / 精排)。延续「录像带出租店」的比喻,这次的主角是店里的「老板」:他要亲自给每部候选片打一个「你会喜欢的概率」分,再挑出最可能命中的 10 部摆到你眼前。
Table of contents
Open Table of contents
一句话理解
排序 = 录像带店的「老板」。
挑片员们(ItemCF、热门榜…)和领班(多路融合)忙活半天,把全店 1 万部片粗筛成 200 部候选送到柜台。但 200 部还是太多,顾客只想看 10 部。
于是老板登场:他不再管「在不在候选里」这种粗活,而是给每部候选片打一个精确的分——「这位顾客会喜欢它的概率是多少」,然后从高到低排,挑出 Top-10 摆出来。
召回看「召没召回」(粗、快、广),排序看「排第几」(细、准、窄)。老板的本事,全在那个「概率分」打得准不准。 而 recsys-mini 的老板,大脑是一台 LightGBM——它通过翻看历史账本,学会了怎么打这个分。
Level 1:老板的活儿 — 召回与排序的分工
整个推荐系统是一条漏斗流水线,老板(排序)卡在召回之后、上桌之前。召回和排序,本质是两种完全不同的活儿:
| 召回(挑片员 + 领班) | 排序(老板) | |
|---|---|---|
| 处理量 | 全店 1 万 → 200 | 200 → 10 |
| 关心什么 | 在不在候选集里 | 候选里排第几 |
| 速度要求 | 极快(毫秒级扫全库) | 可以慢一点(只算 200 个) |
| 精度要求 | 粗(宁滥勿缺,别漏真爱) | 细(每个候选精确打分) |
| 用的特征 | 少而轻(算相似度/热度) | 多而重(几十个特征精雕) |
| 评估指标 | Recall@K / Coverage | AUC / GAUC |
核心洞察:召回宁可错放、不可漏放(漏了排序也救不回来);排序则要在召回给的候选里精挑细选、分出高下。两者是接力赛,不是竞争关系。
训练这位老板的整条流水线分 6 段:
训练排序模型的 6 段流水线。最头疼、最反直觉的是第 ② 段负采样——因为账本里根本没有「不喜欢」的记录。
下面从老板最头疼的一段——②负采样——讲起。
Level 2:最反直觉的难题 — 账本只记了「喜欢」,没记「不喜欢」
老板要学会打分,最自然的办法就是翻历史账本:把每位顾客过去真的租过、评过分的片找出来,告诉大脑「喏,这些都是他喜欢的,照着学」。这些「真喜欢的记录」就叫正样本(标签记成 1)。
训练脚本开头的注释,其实就是整个训练的「样本蓝图」:
样本构造逻辑:
正样本 = train (训练集) + valid (验证集) 中的真实正反馈
负样本 = 按 item 流行度采样 (避开 user 历史)
特征 = user/item 画像 + train 集统计 (防穿越)
切分 = train -> 训练, valid -> 早停, test 留给全链路评估scripts/04_train_rank.py
一句一句拆开看:
- 正样本 = 真实的「喜欢」:顾客真金白银租过、还打了分的片,这种「我确实喜欢」的记录骗不了人,直接拿来当学习目标。
train和valid两段历史里的真实反馈都收进来当正样本。 - 负样本 = 自己「编」出来的「不喜欢」:账本里没有「不喜欢」的记录,得自己造。怎么造是重头戏(Level 3)。
- 特征 = 打分用的「线索」,且只能用过去:光有
(顾客, 影片)编号没法打分,得配上性别、年龄、类型、热度等线索。「防穿越」是铁律——统计线索只能用train算。 - 切分 = 三份数据各司其职:
train学习、valid早停、test全程雪藏留给全链路评估。
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 次方(折中) | 热门更容易被抽,但冷门也有份 | 难度刚刚好 |
规矩三:每个正样本配 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_Action、g_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 | 只用 train | train 和 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
- 大脑是一棵棵决策树攒出来的(最多
num_boost_round=500棵),每多长一棵,就专门纠正前面那些树看走眼的地方,越攒越准。 - 早停(early stopping):每长一棵树,就拿模拟考卷(valid)考一次。如果连续
early_stopping_rounds=30棵树成绩都不再提升,就自动喊停——这就是模拟考卷当「刹车」的作用。
其余参数也都是防过拟合的常规配置:
| 参数 | 值 | 作用 |
|---|---|---|
learning_rate | 0.05 | 每棵树学一点点,慢工出细活 |
num_leaves | 63 | 单棵树复杂度上限 |
min_data_in_leaf | 50 | 叶子至少 50 条样本,防止学到噪声 |
feature_fraction / bagging_fraction | 0.9 | 每棵树随机用 90% 特征/样本,增加多样性 |
Level 7:怎么考核老板 — AUC vs GAUC
老板训练完,得考核他「打分准不准」,输出两个核心指标。
AUC:整体上分得清「喜欢」和「不喜欢」吗
AUC 用大白话说:随便拎出一部顾客喜欢的片、和一部不喜欢的片,老板给喜欢的那部打分更高的概率有多大。
- AUC = 1.0 → 每次都对,神级老板;
- AUC = 0.5 → 跟抛硬币一样,纯瞎猜。
但 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 对来数:
| # | 喜欢的片 | 不喜欢的片 | 谁分高? | 算对吗 |
|---|---|---|---|---|
| 1 | A 喜欢 0.9 | A 不喜欢 0.8 | 0.9 > 0.8 | ✓ |
| 2 | A 喜欢 0.9 | B 不喜欢 0.2 | 0.9 > 0.2 | ✓ |
| 3 | B 喜欢 0.3 | A 不喜欢 0.8 | 0.3 < 0.8 | ✗ |
| 4 | B 喜欢 0.3 | B 不喜欢 0.2 | 0.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_stats、item_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 — 三句话
-
排序 = 录像带店的老板。 领班把 200 部候选送上柜台,老板(LightGBM 二分类)给每部打一个「会喜欢的概率」分,挑出 Top-10。召回看「召没召回」,排序看「排第几」。
-
排序最反直觉的坑:账本只记了「喜欢」,负样本得自己造。 按 热度的 0.75 次方 采负样本(避开用户历史),1 正配 4 负。特征 = 画像 + train 统计 + 口味交叉,统计只能用 train 算(防穿越)。模型用 valid 早停防过拟合。
-
GAUC 才是排序的主考官;存档要连特征一起存。 AUC 看整体、GAUC 按用户分组——推荐只在用户内部排序,GAUC 更贴合。模型和
feature_store必须一起存盘,保证训练-上线特征一致。切记 SSB 铁律:召回一改,排序必重训。
老板打完分排出 Top-N 后,还差最后一道工序——过滤与重排:老板娘把「已看过、不合规、太单调」的问题一次性收拾干净,再把最终结果摆到你眼前。