ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LightFM评估指标详解:precision@k、recall@k与AUC

LightFM评估指标详解:precision@k、recall@k与AUC

模型跑完 30 个 epoch,loss 从 0.62 掉到 0.09,看着挺美。可当你把precision_at_k里的数字打出来,屏幕上赫然写着 0.047,心里立刻没底了,这到底是模型不行,还是这个数本来就应该长这样?我在做 LightFM 相关的推荐项目时,这个问题反复出现过很多次:训练侧一切正常,评估侧却全是问号。precision@k、recall@k、AUC 和 reciprocal rank 这四个指标,官方文档只给了函数签名和一句“返回每个用户的分数数组”,至于它们各自算的是什么口径、负样本怎么取、分母是谁、数值多大算合理,全靠自己啃。这篇内容就是把这块补上:从评估协议怎么定,到四个指标的数学原理和手算过程,再到一套能直接跑的完整评估脚本,最后是我在真实项目里踩过的坑和排查思路。无论你是刚把 LightFM 跑通、正在纠结该信哪个数的新手,还是已经上线、想搞明白离线指标和线上效果为什么对不上的老手,下面这些应该都能直接用上。

1. 评估协议先行:指标算得对不对,八成取决于这一步

在写任何一行评估代码之前,有个顺序问题必须先想明白:先定协议,再选指标。很多人在 LightFM 上遇到的“指标看不懂”,根子不在指标本身,而在数据切分方式和评估口径上就没对齐。举个最常见的场景:你拿全量交互矩阵训练,然后拿同一个矩阵当测试集去算 precision@10,结果高得离谱,接近 0.6,于是以为模型神了。实际上模型只是把训练时见过的物品又排到了前面,这叫数据泄漏,不叫效果好。评估协议决定的是“模型在什么条件下被考核”,指标只是把这个考核结果量化出来,协议错了,指标再精确也是错的。

1.1 隐式反馈的评估对象是一个排序列表,不是一个分数

显式评分场景下,我们评估的是“预测分数和真实分数的误差”,RMSE、MAE 那一套。但 LightFM 主要面向隐式反馈,数据只有“交互过”和“没交互过”两种状态,没有 1 到 5 星的强度信息(即使你传了 weight,那也只是置信度而不是评分)。这种情况下,模型输出的连续分数本身没有绝对意义,用 WARP 损失训出来的分数甚至不构成概率,你没法说“0.7 分代表 70% 会点击”。它唯一有意义的用法是排序:把候选物品按分数从高到低排,看真实的交互物品排在第几位。

所以隐式反馈的评估天然是 top-N 评估:给定一个用户,模型输出一个长度为物品总数的排序列表,我们从测试集里拿出这个用户真正交互过的物品,看它们落在这个列表的什么位置。位置越靠前越好,没进前 N 就等于没被推荐。precision@k、recall@k、reciprocal rank 全都是这个思路的不同侧面,AUC 稍微特殊一点,它衡量的是“随机取一个正样本和一个负样本,正样本分数更高的概率”,本质还是排序质量,只是不限制在 top-N 内。

理解这一点很关键,因为它直接决定了后面所有指标的分母:precision@k 的分母是 k(推荐位数量),recall@k 的分母是这个用户在测试集里的正样本数,reciprocal rank 的分母是 1(就是第一个命中的位置取倒数)。分母搞混,数值就永远对不上你的直觉。

1.2 四种切分方式的取舍,直接影响指标能不能信

我在项目里用过四种切分方式,各有各的适用场景,选错了会让指标彻底失真。

随机留出(random holdout)是最常用的:把所有交互随机抽 20% 出来当测试集,剩下 80% 训练。LightFM 自带random_train_test_split,一行代码就能切,而且它会尽量保证每个用户在训练集里至少留一条交互,避免出现完全没有训练信号的用户。这种方式的优点是实现简单、每个用户的测试样本数量相对均衡,缺点是它破坏了时间顺序,模型实际上“看到了未来”。对于物品内容相对稳定、用户兴趣变化不快的场景(比如图书、影视),这个偏差可以接受;对于强时效场景(比如新闻、促销),随机留出会给出过于乐观的估计。

时间切分(temporal split)是最贴近线上真实的:取某个时间点之前的所有交互做训练,之后的一段时间做测试。这种方式能真实反映“用历史预测未来”的效果,指标通常比随机留出低一截,但更值得信。代价是需要数据里有可靠的时间戳,而且要处理好“测试期新出现的物品”和“测试期新出现的用户”这两类冷启动样本,它们会显著拉低平均值。

leave-one-out是每个用户留最后一条(或随机一条)交互做测试,剩下的训练。这是很多学术论文的做法,特别适合搭配 reciprocal rank / MRR 这类指标,因为每个用户只有一个正样本,MRR 就直接等于“这个正样本排在第几位的倒数”。它的优点是评估快、口径清晰,缺点是对只有一个测试样本的用户来说,指标方差极大,而且它默认“用户只有一个正确答案”,跟真实推荐场景(用户可能同时想看很多个东西)有差距。

按用户分组切分是在做 A/B 对比或者交叉验证时用的:把用户分成 K 折,每折轮流做测试集。这样能评估指标的稳定性,也能避免“某一批用户刚好特别难”带来的偶然性,代价是训练 K 次,时间成本翻倍。

我的建议是:第一轮调参用随机留出快速迭代,确定候选方案后用时间切分做最终验收,论文复现或与学术结果对比时用 leave-one-out。三种方式算出来的绝对数值不可比,别拿随机留出的 0.15 去和论文里的 0.05 比,那不是模型差距,是协议差距。

1.3 训练集、测试集、验证集,三个角色别串

还有一层容易被忽略的:测试集不能参与任何决策。用测试集挑 epoch、挑num_components、挑正则系数,本质上就是拿考试卷当练习册,最后报出来的分数一定偏乐观。正确的做法是切三份:训练集训模型,验证集选超参和早停,测试集只在最后报告一次。

LightFM 官方示例里经常直接在测试集上扫参数,这在教程里没问题,是为了让代码短一点,但搬到项目里就是隐患。我一般会先切 70/15/15:70% 训练,15% 验证,15% 测试。验证集的作用是回答“训多少轮合适”“embedding 维度取多少”,测试集只在方案定稿后跑一遍。如果数据量小到切三份会让训练集太瘦,那就用 K 折交叉验证,把每折的验证集结果平均,再单独留一份固定测试集做最终报告。

另外提醒一个细节:切分之后,Dataset对象一定要在全量数据上fit(构建用户和物品的 ID 映射),然后在切分后的矩阵上build_interactions。如果只在训练集上fit,测试集里的新物品会直接丢失,评估结果的召回上限就被悄悄砍掉一块,而你完全看不出来。

2. 四个指标的原理拆解:每个数到底在算什么

这一节把四个指标逐个拆开。我不打算只贴公式,那样你抄完还是不知道数值为什么长这样。每个指标我都会说清楚三件事:它衡量什么、分母是谁、在 LightFM 里具体怎么算出来的。理解了这三点,你看到任何一个数字都能立刻判断它是否合理。

2.1 AUC:LightFM 算的是逐用户 macro 口径,和 sklearn 不是一回事

先说 AUC,因为它是最容易被误用的一个。LightFM 的auc_score返回的是每个用户各自算一个 ROC AUC,然后取算术平均,也就是 macro 口径。而sklearn.metrics.roc_auc_score默认是把所有 (用户, 物品) 对拼成一个大池子算,是 micro 口径。这两个数经常差得很远,尤其是用户之间正样本数量悬殊的时候,micro 会被交互量大的活跃用户主导,macro 则给每个用户同等权重。

单个用户的 AUC 怎么算?把所有物品分成两类:这个用户交互过的(正样本)、没交互过的(负样本)。然后取任意一对“一正一负”,比较模型给它们的分数,正样本分数更高就算赢,相等算平局计 0.5,最后统计胜率。用公式写就是:

AUC_u = (Σ_{i∈正} Σ_{j∈负} 1[s(i) > s(j)] + 0.5·1[s(i) = s(j)]) / (|正| × |负|)

写成这样你就明白为什么它在隐式反馈里会虚高了:负样本池是“该用户没有交互过的全部物品”,在一个几万物品的库里,这个池子里绝大多数的物品是用户根本没机会看到的,模型给它们打低分毫不费力。所以 LightFM 在真实数据上 AUC 跑到 0.9 甚至 0.95 都很常见,这不代表模型好,只代表“随机挑一个用户看过的和一个他大概率没听过的,模型能分对”,这个任务太简单了。

LightFM 内部实现是用排序加二分查找做的,等价于上面这个 Mann-Whitney U 统计量,不是随机采样近似,所以数值是精确的。但要注意train_interactions参数在 AUC 里的作用和在其他指标里不一样:它会把这些训练集里的正样本从负样本池里剔除。这件事必须做,否则你训练时见过的高分物品会被当成负样本参与比较,白白拉低 AUC;而如果不传train_interactions,测试集和训练集还有重叠的话,同一批物品既当正样本又当负样本,结果直接失真。

我在实际使用中的体会是:AUC 只适合做粗筛和横向对比,不适合做最终决策。它告诉你“模型有没有学到东西”,学没学到看 AUC 是不是明显高于 0.5 就够了(0.5 等于瞎猜)。至于“模型学到了多少、top 位置准不准”,AUC 回答不了,得看下面三个。

2.2 precision@k 与 recall@k:一对跷跷板,分母差一个数量级

precision@k 的定义很直观:给用户推荐 k 个物品,其中有多少个是他真正交互过的,除以 k。recall@k 是:推荐 k 个物品,命中了用户测试集里所有正样本中的多少个,除以正样本总数。

precision@k = |推荐列表 Top-k ∩ 测试集正样本| / k recall@k = |推荐列表 Top-k ∩ 测试集正样本| / |测试集正样本|

两个公式只差一个分母,但含义天差地别。precision@k 关心“推荐位有多准”,是产品视角:用户刷一屏看到 10 个东西,几个是他感兴趣的。recall@k 关心“用户想要的东西被覆盖了多少”,是算法视角:他真正想要的 20 个东西里,我们捞出来几个。k 小的时候 precision 高、recall 低,k 变大两者往反方向走,这是必然的,别指望同时优化两个。

这里有个极其重要的坑:LightFM 的precision_at_k分母固定是 k,不会取min(k, 候选物品数)。也就是说,如果某个用户在测试集里只有 2 个正样本,k 取 10,那么他的 precision@10 上限就是 0.2,哪怕推荐列表完美命中也就 0.2。当测试集里大量用户的正样本数少于 k 时,整体 precision@k 会被系统性压低,你会误以为模型很差,其实是分母设大了。

我的一般做法是:k 不要超过测试集用户平均正样本数的一半。比如时间切分后每个用户平均有 8 个测试交互,那 k 取 5 或者 10 都还行;如果平均只有 3 个,k 取 20 就纯属自欺欺人。真要看 k=20 的效果,可以先看 recall@20,它的分母是用户真实正样本数,不会因为样本少而被压上限。

还有一点:LightFM 的这两个函数返回的是长度为“有效用户数”的 numpy 数组,你需要自己.mean()。默认preserve_rows=False,会把测试集里没有任何正样本的用户整行丢掉,所以返回数组的长度可能小于dataset.interactions_shape()[0]。算平均值的时候分母已经变了,如果你在外部又除了一次总用户数,那就错了。想保持行数一致、不丢行,传preserve_rows=True,那些用户会被记 0 分,这样平均值包含了“完全没推荐对”的用户,更接近真实体验。

2.3 reciprocal rank 与 MRR:只看第一个命中的位置

reciprocal rank 是给单个用户算的:找到排序列表里第一个命中的正样本,取它的排名的倒数。排第 1 就是 1.0,排第 2 是 0.5,排第 10 是 0.1,一个都没命中就是 0。把所有用户的 RR 平均起来,就是 MRR(Mean Reciprocal Rank)。

RR_u = 1 / rank_of_first_hit (未命中记 0) MRR = mean(RR_u)

这个指标的特点是极度偏向头部。排第 1 和第 2 之间,RR 从 1.0 掉到 0.5,差了一倍;排第 9 和第 10 之间,只差 0.011。所以它适合回答“用户想要的第一个东西,我们有没有放在最前面”。在那些用户目标非常明确、一次只需要一个正确结果的场景里(比如“猜你想搜的那个词”、单一意图的推荐位),MRR 是最贴合的指标。

但用在通用商品推荐上,MRR 有个明显的盲区:它只看第一个命中,后面命中的全都不管。一个把用户 20 个正样本里的 19 个都排进前 20 的模型,和一个只排对 1 个但恰好排在第 1 位的模型,只要第一个命中的位置一样,MRR 就完全一样。所以 MRR 我通常只作辅助参考,跟 precision@k、recall@k 一起看。LightFM 的reciprocal_rank函数返回的就是每个用户的 RR 数组,同样需要自己取均值。

如果测试集是按 leave-one-out 切的(每个用户只有一个正样本),MRR 的含义就非常干净,就是“这个唯一正样本平均排在第几位”,可以直接换算成“平均倒数排名”。这也是为什么学术论文偏爱 leave-one-out + MRR 的组合:口径简单,不容易产生歧义。而在随机留出或时间切分下,一个用户有多个正样本,MRR 会遇到“取第一个命中”这个定义上的模糊地带,此时更推荐用 MAP@k 或 NDCG@k。

2.4 补充两个内置函数没有的指标:MAP@k 和 NDCG@k

LightFM 只内置了前面四个,但实际项目里我经常还会加两个,因为它们在“多正样本”场景下更稳。

MAP@k(Mean Average Precision)的思路是:对每个用户,从头到尾扫推荐列表,每遇到一个命中就计算一次当时的 precision,然后对这些 precision 取平均。它同时考虑了“命中了多少”和“命中得有多靠前”,比单纯 precision@k 更能区分“命中集中在头部”和“命中散落在尾部”的模型。

NDCG@k(归一化折损累计增益)是工业界最常用的排序指标,公式是 DCG 除以 IDCG:

DCG@k = Σ_{i=1..k} rel_i / log2(i + 1) IDCG@k = Σ_{i=1..min(k, |正样本|)} 1 / log2(i + 1) NDCG@k = DCG@k / IDCG@k

隐式反馈里 rel_i 只有 0 和 1,所以 DCG 就是把命中位置的折损权重加起来。位置越靠后权重越低,第 1 位权重 1,第 2 位 0.63,第 10 位 0.29。IDCG 是理想情况(所有正样本都排在最前面)的 DCG,除一下就把结果归一到 0 到 1 之间,方便跨用户比较。NDCG 的好处是它对 k 的敏感度比 precision 温和,而且不会因为用户正样本少而被硬性压低上限。

我一般会同时看 precision@10、recall@10、NDCG@10 三个,如果三个方向一致,结论就比较可靠;如果 precision 高但 NDCG 低,说明命中都堆在列表末尾,头部质量其实不行。

3. 从零跑通:一份可直接复用的评估脚本

原理讲完,落地。这一节的代码我尽量写成能直接复制运行的形式,中间会插入几处“为什么这么写”的说明,因为这些地方恰恰是最容易写错的地方。数据我用自己构造的一份模拟交互表,字段结构跟常见的用户行为日志一致,你替换成自己的数据源即可。

3.1 数据准备与特征矩阵构建

先看数据构建部分。这份模拟数据包含用户 ID、物品 ID、交互强度和两组侧信息(用户的城市分层、物品的类目),用来演示 LightFM 的混合推荐能力。

import numpy as np import pandas as pd from lightfm import LightFM from lightfm.data import Dataset from lightfm.cross_validation import random_train_test_split from lightfm.evaluation import ( precision_at_k, recall_at_k, auc_score, reciprocal_rank ) rng = np.random.default_rng(20240501) n_users, n_items = 1200, 600 rows = [] for u in range(n_users): # 每个用户 5~40 条交互,模拟长尾分布 cnt = int(rng.integers(5, 40)) items = rng.choice(n_items, size=cnt, replace=False) for i in items: rows.append((f"u{u}", f"i{i}", float(rng.integers(1, 5)))) interactions_df = pd.DataFrame(rows, columns=["user_id", "item_id", "weight"]) # 侧信息 user_meta = pd.DataFrame({ "user_id": [f"u{u}" for u in range(n_users)], "city_tier": [f"tier{rng.integers(1, 5)}" for _ in range(n_users)], }) item_meta = pd.DataFrame({ "item_id": [f"i{i}" for i in range(n_items)], "category": [f"cat{rng.integers(1, 12)}" for _ in range(n_items)], })

构建Dataset的时候,关键点是先 fit 用户和物品全集,再 fit_partial 特征,顺序反了会报特征未知的错。另外fit要在全量数据上做,不是只在训练集上做,理由前面说过。

dataset = Dataset() dataset.fit( users=interactions_df["user_id"].unique(), items=interactions_df["item_id"].unique(), ) dataset.fit_partial( users=user_meta["user_id"].unique(), user_features=[f"city::{v}" for v in user_meta["city_tier"].unique()], ) dataset.fit_partial( items=item_meta["item_id"].unique(), item_features=[f"cat::{v}" for v in item_meta["category"].unique()], ) interactions_matrix, weights_matrix = dataset.build_interactions( (r.user_id, r.item_id, r.weight) for r in interactions_df.itertuples() ) user_features_matrix = dataset.build_user_features( ((r.user_id, [f"city::{r.city_tier}"]) for r in user_meta.itertuples()), normalize=False, ) item_features_matrix = dataset.build_item_features( ((r.item_id, [f"cat::{r.category}"]) for r in item_meta.itertuples()), normalize=False, )

normalize=False这个参数值得单独说一句。LightFM 默认会做特征归一化,对用户特征做 L2 归一化,对物品特征做的归一化方式也有一套默认逻辑。在类别型特征(one-hot 形式)上,归一化基本无害;但如果你用的是数值型特征(比如用户活跃度分位、价格区间),归一化会改变特征的相对尺度,进而影响 embedding 的初始化量级,训练出来的模型跟你的预期可能不一样。我习惯在类别特征场景下直接关掉,让每个特征权重都是 1,行为更可预测。

3.2 模型训练:参数怎么定,为什么这么定

LightFM 的损失函数有四种:logistic、bpr、warp、warp-kos。隐式反馈场景我默认用warp,因为它针对“每个用户只关心少量正样本”的场景做了优化,训练时会主动挖掘那些排在正样本前面、但实际是负样本的物品作为惩罚项,收敛快且 top-N 效果通常更好。logistic适合你确实有负反馈信号(比如明确的不感兴趣、取消关注)的场景;bpr是 pairwise 的经典做法,比 WARP 稳但慢。

num_components是 embedding 维度,我一般从 32 或 64 开始。这个值不是越大越好:维度高到一定程度后,每个用户和物品都能找到足够多的自由参数去拟合训练集,测试集指标反而会掉。在交互数据只有几万条量级时,64 维已经足够,128 维以上一定要配强正则。

(train, test) = random_train_test_split( interactions_matrix, test_percentage=0.2, random_state=42 ) model = LightFM( no_components=64, loss="warp", learning_schedule="adadelta", learning_rate=0.05, item_alpha=1e-6, # 物品 embedding 的 L2 正则 user_alpha=1e-6, # 用户 embedding 的 L2 正则 random_state=42, ) model.fit( train, user_features=user_features_matrix, item_features=item_features_matrix, epochs=30, num_threads=4, verbose=True, )

learning_schedule用adadelta是 LightFM 文档推荐的搭配,它对学习率不敏感,收敛稳。item_alpha和user_alpha这两个正则系数我一开始经常忘记设,结果 item 侧过拟合特别明显,训练集 precision 很高、测试集一塌糊涂。经验值是:物品数远大于用户数时,item_alpha要给得比user_alpha大一点,因为物品 embedding 的自由度更高。但别一上来就调到 1e-2,那会把 embedding 压得几乎为 0,模型退化成只按特征打分。

还有一个特别容易忽略的:random_state一定要设。LightFM 的 embedding 初始化是随机的,不设种子的话,同样的代码两次运行 precision@10 能差出 5% 到 10%,尤其是在数据量小的时候。你在调参时看到的“提升”,很可能只是随机波动。

3.3 内置评估函数调用与结果解读

评估调用的部分不复杂,但参数有讲究,尤其是train_interactions这个必须传。

def report(model, train, test, uf, itf, k=10): p = precision_at_k(model, test, train_interactions=train, k=k, user_features=uf, item_features=itf, num_threads=4) r = recall_at_k(model, test, train_interactions=train, k=k, user_features=uf, item_features=itf, num_threads=4) a = auc_score(model, test, train_interactions=train, user_features=uf, item_features=itf, num_threads=4) rr = reciprocal_rank(model, test, train_interactions=train, user_features=uf, item_features=itf, num_threads=4) print(f"users evaluated: {len(p)}") print(f"precision@{k}: {p.mean():.4f}") print(f"recall@{k}: {r.mean():.4f}") print(f"AUC: {a.mean():.4f}") print(f"MRR: {rr.mean():.4f}") return p, r, a, rr p10, r10, auc, mrr = report(model, train, test, user_features_matrix, item_features_matrix, k=10)

train_interactions=train这个参数如果漏掉,后果非常严重:模型会给训练时见过的物品打高分,而这些物品会占满 top-k 的位置,测试集的正样本根本挤不进去。表现出来就是 precision@10 低得莫名其妙,你调了半天参数也不动。加上它之后,那些训练集里的物品会在排序时被排除,才是真正的“推荐新东西”。

想看多个 k 的效果,循环一下就行,别偷懒只报一个 k:

for k in (5, 10, 20): p = precision_at_k(model, test, train_interactions=train, k=k, user_features=user_features_matrix, item_features=item_features_matrix, num_threads=4) r = recall_at_k(model, test, train_interactions=train, k=k, user_features=user_features_matrix, item_features=item_features_matrix, num_threads=4) print(f"k={k:>2} P@k={p.mean():.4f} R@k={r.mean():.4f}")

最后打印出来的users evaluated这个数也值得盯一眼。如果它明显小于你数据里的用户总数,说明有一批用户在测试集里一条正样本都没有,被preserve_rows=False默认丢掉了。这批用户大概率是交互极少的长尾用户,把他们排除在外,等于在“只评估好用户”,指标会偏乐观。想把他们算进去,传preserve_rows=True,接受他们把平均值拉低,这才是真实分布。

3.4 自实现排序指标:支持候选集过滤和多 k 一次算完

内置函数够用,但有两个需求它满足不了:一是我想一次性算出所有 k 的所有指标,避免重复扫全库排序;二是线上推荐往往有召回阶段,候选集不是全库,我想在候选集内评估。这两个需求都指向同一个做法:先把完整的分数矩阵拿出来,自己在 NumPy 里操作。

def build_score_matrix(model, n_users, uf=None, itf=None, batch=2000, n_threads=4): n_items = model.item_embeddings.shape[0] scores = np.zeros((n_users, n_items), dtype=np.float32) for start in range(0, n_users, batch): end = min(start + batch, n_users) scores[start:end] = model.predict( np.arange(start, end), user_features=uf, item_features=itf, num_threads=n_threads, ) return scores def rank_metrics(scores, test_csr, train_csr, ks=(5, 10, 20)): n_users = scores.shape[0] max_k = max(ks) out = {f"{m}@{k}": [] for m in ("precision", "recall", "ndcg") for k in ks} out["rr"] = [] out["map"] = [] for u in range(n_users): pos = test_csr[u].indices if len(pos) == 0: continue s = scores[u].copy() seen = train_csr[u].indices s[seen] = -np.inf # 排除训练集已交互物品 top = np.argpartition(-s, max_k)[:max_k] top = top[np.argsort(-s[top])] # 按分数真正排序 rel = np.isin(top, pos).astype(np.float32) hit_flags = rel.copy() for k in ks: hits = hit_flags[:k].sum() out[f"precision@{k}"].append(hits / k) out[f"recall@{k}"].append(hits / len(pos)) disc = 1.0 / np.log2(np.arange(2, k + 2)) dcg = (hit_flags[:k] * disc[:k]).sum() ideal = np.ones(min(k, len(pos)), dtype=np.float32) idcg = (ideal * disc[:min(k, len(pos))]).sum() out[f"ndcg@{k}"].append(dcg / idcg if idcg > 0 else 0.0) hit_idx = np.flatnonzero(hit_flags) out["rr"].append(1.0 / (hit_idx[0] + 1) if hit_idx.size else 0.0) prec_at_hit = [(hit_flags[:i + 1].sum()) / (i + 1) for i in hit_idx] out["map"].append(np.mean(prec_at_hit) if prec_at_hit else 0.0) return {k: float(np.mean(v)) if v else 0.0 for k, v in out.items()}

这段代码里有几个地方值得停下来看。

np.argpartition(-s, max_k)[:max_k]是为了避免对整库做全排序,它只保证前 max_k 个元素是最大的,内部顺序不保证,所以后面还要用np.argsort(-s[top])对这小段重排一次。在物品数上万时,这个技巧能省掉大量时间,尤其是在循环里对每个用户都做一次的情况下。

s[seen] = -np.inf就是train_interactions的等价操作,但放在这里更直观,也方便你加更多过滤条件,比如下架商品、屏蔽类目、已经买过的品类去重之类。

候选集过滤也很好接:把不在候选集里的物品下标也设为-inf即可。这个能力在内置函数里是没有的,而它正是让离线指标更贴近线上的关键一步,后面第 5 节会展开说。

ndcg那段的ideal长度取min(k, len(pos)),对应的是“理想情况下最多能命中几个”,这个细节错了会让 NDCG 超过 1,成为一个不可能出现的奇异值。

4. 指标异常排查实录:几个我真实踩过的坑

纸上谈兵没意思,这一节全是我在项目里真实遇到过的情况,按“现象—原因—怎么查”的结构整理。你可以把它当成速查表,指标不对劲的时候翻出来对照。

4.1 AUC 0.95,precision@10 只有 0.03

这是最常被问到的组合。看到 AUC 高就高兴,看到一个位数就慌,其实是两回事。

先说原因。AUC 高说明模型确实学到了区分度,能把“用户交互过的”和“绝大多数没交互的”分开,这在隐式反馈里很容易做到,因为大部分物品跟用户毫无关联,模型随便给个低分就对。precision@10 低说明模型在头部没能排对:它知道什么明显不对,但排不出“最对的 10 个”,因为全库里跟当前用户相关度最高的那些物品之间差距很小,模型分不出高下。

再检查两件事。第一,k 是不是设得太大,前面说过分母是 k,测试集用户正样本不够时上限被卡死。第二,是不是没传train_interactions。这两个原因占了我排查案例的大半。

如果两个都排除了,那就是模型能力的真实问题,可以从降num_components、加正则、加特征三个方向调。我的经验是,加特征收益最明显,光靠协同信号在小数据上很难把头部排准,而一组好的物品侧特征(类目、标签、价格带)往往能立竿见影。

4.2 同样的代码跑两次,结果差 8%

第一次遇到时我还以为是环境问题,查了半天才发现是没设random_state。

LightFM 有两个随机源:embedding 初始化,以及 WARP 损失里的负样本采样。这两个都不设种子的话,每次训练出来的模型都不一样。数据量大时差异会被平均掉,数据量小的时候,precision@10 波动 5% 到 10% 完全正常。

处理方法有三条:训练时设random_state;切分时设random_state;报告指标时,用 3 到 5 个不同种子各跑一遍,报均值加减标准差,而不是报单次结果。我第一次做交叉验证报告时就吃了亏,只跑了单个种子,指标看着涨了 0.01,换种子一跑,发现那 0.01 在噪声范围内,白高兴一场。

4.3 冷启动用户把平均值拖垮

时间切分之后,测试集里必然有一批训练时没见过的新用户。虽然Dataset在构建时通过用户特征给了他们对应的特征向量,模型也能打分,但由于user_embeddings里这些用户的位置从未被更新过,他们的分数实际上是噪声。

更麻烦的是,这批人的数量在时间切分下往往不小,而且他们的真实交互通常也少,预测本来就难,指标自然低。他们会以几倍于老用户的幅度拉低平均值,让你误以为模型整体退化了。

我的做法是分层报告:把用户按训练集里的交互数量分成活跃、中等、冷启动三档,分别算 precision@10 和 recall@10。分档看之后结论常常会变:老用户可能稳中有升,冷启动用户在小幅下降,整体平均下降完全由冷启动用户占比变化导致。如果不分档,你会做出错误的判断,比如为了救冷启动指标去砍模型复杂度,结果把老用户的收益全砍没了。

4.4 常见问题速查表

现象最可能的原因快速排查
precision@k 明显偏低且不随训练变化没传train_interactions检查参数,补上训练矩阵
precision@k 被系统性压低k 大于测试集平均正样本数打印测试集每用户正样本数分布
结果在不同次运行间波动大未设random_state固定种子,多次取均值
评估函数返回数组长度远小于用户数preserve_rows=False丢弃了无正样本用户传preserve_rows=True对比
评估时抛出交集错误训练集与测试集有重叠且开启了交集检查设check_intersections=False,或改用互斥切分
AUC 接近 0.99负样本池过大,指标天然虚高换看 precision@k、NDCG@k
训练集指标远高于测试集正则太弱或 embedding 维度太高先加item_alpha,再降维度
评估很慢逐个用户全库排序,且未开多线程用num_threads,或按批构建分数矩阵

这张表我贴在文档里很久了,新同事上手时先看一遍,能省掉至少半天摸索。里面“评估很慢”那一条尤其值得注意,LightFM 的评估函数在多线程下会并行处理用户,但如果你同时在外部开了大量并行任务,线程会互相抢核反而更慢。我一般把num_threads设成物理核数的一半,留出余量。

5. 让离线指标更贴近线上:我的几条实践经验

指标算出来了,也解读了,最后一步是让它对线上真的有指导意义。这一节是我在这个环节踩坑后攒下的经验,都是“知道之后能少走不少弯路”的那种。

5.1 在候选集内评估,而不是在全库内评估

前面提过,LightFM 的评估是对全库排序。这会导致 recall@10 特别低,因为分母是用户所有正样本,而模型是从几万个物品里挑 10 个。但线上真实的推荐流程通常有两级:召回阶段先从几万降到几百,精排再从那几百里挑 10 个。你在全库上评估,考核的其实是召回加精排的整体效果,而你的 LightFM 只负责其中一段。

这时候用第 3.4 节的自实现脚本,把不属于候选集的物品全部 mask 掉,算出来的 precision@10、recall@10 才是你这个模块的真实水平。我做过对比:同一个模型,全库口径下 recall@10 是 0.06,在 500 条候选集内是 0.31,差了五倍。拿全库口径去和线上点击率对齐,怎么调都对不上,因为口径根本不是一个东西。

5.2 指标要和展示位、k 值对齐

离线指标的 k 值不是随便挑的,应该等于线上一次曝光能展示的坑位数量。列表页一屏 10 个,就看 k=10;横滑卡片 5 个,就看 k=5;如果用户会往下滑好几屏,那还需要看 k=20 甚至 k=50 的衰减情况,判断“第二屏还剩多少价值”。

我见过的一个典型错误是:线上只有 5 个坑位,离线却一直优化 precision@50,因为那个数字看起来涨得最明显。结果上线后,模型把大量把用户真正想看的东西排在了第 8、第 12 位,恰好都在曝光位之外,线上指标纹丝不动。这个坑的根源就是把离线指标当成了独立目标,忘了它只是线上流程的一个代理。

5.3 一定要跑一个流行度 baseline

这一点我觉得比前面所有调参技巧都重要。在报出任何一个 LightFM 指标之前,先算一遍“按物品全局热门程度排序”的 baseline,把这个数字放在旁边。

原因很实在:小数据集加隐式反馈的组合里,流行度 baseline 的 precision@10 经常能打赢没调好的协同过滤模型。因为大多数用户的行为确实集中在头部热门物品上,你不需要任何个性化就能命中不少。如果 LightFM 的效果没有明显超过这个 baseline,那你上线的东西可能还不如“都推爆款”来得省事,而且更稳定。

我遇到的第一个项目就吃过这个亏:模型比 baseline 只高了 1.2 个百分点,团队很高兴地上了线,结果线上的个性化收益基本为零,因为那 1.2 个点全在噪声里。后来我们把验收标准改成“必须稳定超过 baseline 一个明确的阈值,并且在多个随机种子下都成立”,才避免了类似的事。

5.4 把指标当诊断工具,而不是 KPI

最后一个体会。precision@k、recall@k、AUC、reciprocal rank 这四兄弟,各自看的是一个侧面,单独拿任何一个当优化目标,都会把模型带偏。只盯 precision@k,模型会变得极度保守,只敢推头部最热的那几个;只盯 recall@k,模型会变得极度发散,把什么冷门东西都往列表里塞,头部质量崩掉;只盯 AUC,模型会满足于“能区分”而不去争头部排序。

我现在的习惯是四个一起看,再加 NDCG,形成一个组合视图:AUC 告诉我模型有没有学到东西,precision@k 告诉我头部准不准,recall@k 告诉我覆盖面够不够,MRR 告诉我最想要的那个东西有没有放对位置,NDCG 综合衡量排序质量。五个数趋势一致再做决策,有分歧就先去查分歧的原因,通常能挖出数据或口径上的问题,而这些问题往往比调参重要得多。

实际项目里我还会顺手加一个随机采样 baseline:给每个用户从全库随机抽 k 个物品当推荐。它的 precision@k 约等于 k 除以物品总数,数字极小,作用是提供一个“下限”参照。有了上下两条基线,中间那个真实的模型分数才有意义,你也才能理直气壮地跟别人说这个 0.08 到底算好还是算差。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进