教你从零搞懂推荐系统 —— 以及 Microsoft Recommenders 究竟怎么玩
> 如果你不能用六年级生听得懂的话解释一样东西,说明你自己没真正理解它。 > —— 加州理工学院毕业典礼,1974
---
目录
1. 开场:Netflix 怎么知道你想看什么 2. 全库的钥匙:四列名的契约 3. 五阶段管线:用 SAR 走一遍 4. 最简单的算法先搞透:SAR = 一次矩阵乘法 5. 从矩阵走向神经网络:NCF 和 Wide&Deep 6. 深度学习机器内部:三大家族 7. 为什么不该用 accuracy:指标不是仪式 8. 真正用起来:装机、换模型、调参 9. 带走的三条启发
---
一、开场:Netflix 怎么知道你想看什么
先忘掉"算法"
我们从一个具体问题开始。你在 Netflix 上看过三部电影:《盗梦空间》《星际穿越》《信条》。然后 Netflix 猜你会喜欢《银翼杀手 2049》。为什么?
不是因为它"聪明"。是因为它有一张大表。
| 盗梦空间 | 星际穿越 | 信条 | 银翼杀手2049 | 小丑 | |
|---|---|---|---|---|---|
| 你 | 5 ★ | 4 ★ | 5 ★ | ? | ? |
| 张三 | 4 ★ | ? | 3 ★ | 5 ★ | 2 ★ |
| 李四 | 5 ★ | 5 ★ | 4 ★ | 5 ★ | ? |
| …… | …… | …… | …… | …… | …… |
怎么填?两种思路:
1. 谁跟你品味像,把他也喜欢的东西推给你 —— 这叫协同过滤(Collaborative Filtering)。你不说你喜欢什么,行动暴露了你。 2. 分析东西本身 —— 科幻片、诺兰导演、有汉斯·季默配乐 —— 这叫内容过滤(Content-Based Filtering)。
Microsoft Recommenders 的 20 种算法就分布在从这棵树上长出来的各个枝丫上。看清楚树,比背 20 个名字重要。
第一个实验:别想,直接跑
装好库之后(怎么装看第八章),你打开 examples/00_quick_start/sar_movielens.ipynb。它做的事情翻译成人话:
把 MovieLens 10 万条评分数据下载下来
↓
按用户切成训练集(75%)和测试集(25%)
↓
拿训练集"学会"哪些电影人们经常一起看
↓
对测试集里的每个用户,猜他还没看过但会喜欢的 10 部电影
↓
拿他实际评分的结果检验猜得对不对
跑完这个 notebook,你就已经完整走过一遍真实推荐系统的全流程了。 换什么算法都只是往这个骨架里换个零件。咱们现在把这具骨架拆开,一个关节一个关节地看。
---
二、全库的钥匙:四列名的契约
只记住一件事
整个 Microsoft Recommenders 库,建立在一条设计决定上。这条决定写在 recommenders/utils/constants.py:5-23:
DEFAULT_USER_COL = "userID"
DEFAULT_ITEM_COL = "itemID"
DEFAULT_RATING_COL = "rating"
DEFAULT_TIMESTAMP_COL = "timestamp"
DEFAULT_PREDICTION_COL= "prediction"
你的数据长这样就行:
| userID | itemID | rating | timestamp |
|---|---|---|---|
| 196 | 242 | 3.0 | 881250949 |
| 186 | 302 | 3.0 | 891717742 |
| 22 | 377 | 1.0 | 878887116 |
- 每个数据集加载器吐出来的 DataFrame 一定有这四列。从 MovieLens(
movielens.py:148)到 Criteo 广告数据集(criteo.py:34)到微软新闻数据集(mind.py:108),进出都用这条契约。 - 每个分割器(
python_splitters.py:161python_stratified_split)吃进这四列、吐出训练/测试两份,还是这四列。 - 每个评估函数(
python_evaluation.py:457precision_at_k,:616ndcg_at_k)在工作:它在userID + itemID上把"模型猜的分数"和"真实评分"做 join,算指标。 - 每个模型即使内部用矩阵、用 tensor、用 graph,吃进来的是这四列的 DataFrame,吐出去的还是
userID + itemID + prediction。
这不像很多科学计算库(每个函数各要一种怪格式)。费曼会喜欢这个设计 —— 简单、统一、不装腔作势。搞清楚这一个事实,你就理解了整个库的一半。
---
三、五阶段管线:用 SAR 走一遍
打开 examples/00_quick_start/sar_movielens.ipynb,用它的真实代码走一遍。这是罗塞塔石碑 —— 其他每一个 00_quick_start/ 下的 notebook 都是同一具骨架换了不同模型。
阶段 1:加载数据
from recommenders.datasets import movielens
data = movielens.load_pandas_df(size="100k")
一行。MovieLens 100k 数据集(943 个用户 × 1682 部电影,10 万条评分)的 zip 下载、解压、列名标准化(movielens.py:148-247)全在这一行里。返回的 DataFrame 有 userID, itemID, rating, timestamp,以及可选的 title, genres, year(从电影标题里用正则抠出来的,movielens.py:351)。
阶段 2:切数据
from recommenders.datasets.python_splitters import python_stratified_split
train, test = python_stratified_split(
data, ratio=0.75,
col_user="userID", col_item="itemID", seed=42
)
分层分割(python_splitters.py:161)是什么意思?不是把 10 万行随机打散了切 —— 那样可能出现"某个用户在训练集里一条数据都没有"的情况,模型根本学不到他,怎么推荐?分层分割保证:每个用户在训练集和测试集里至少各有一条。这是合理评估的前提。底层原理(python_splitters.py:44-113):按 userID 分组,组内按时间排序,组内按比例切前 75% → 训练、后 25% → 测试。
其他分割策略:python_random_split(:19,全局随机)、python_chrono_split(:116,按时间戳)、NCF 数据集内部还做了 leave-one-out(每个用户的最后一条交互留作测试)。
阶段 3:训练模型
from recommenders.models.sar import SAR
from recommenders.utils.timer import Timer
model = SAR(
col_user="userID", col_item="itemID", col_rating="rating",
col_timestamp="timestamp",
similarity_type="jaccard",
time_decay_coefficient=30,
timedecay_formula=True,
normalize=True
)
with Timer() as train_time:
model.fit(train)
SAR 是"简单关联推荐"(Simple Algorithm for Recommendation),由微软贡献(sar_singlenode.py:34)。它不做神经网络、不需要 GPU、不需要几十个 epoch。它在 fit 里做的事极其直观:
1. 建一张用户-物品亲和度矩阵 A
2. 建一张物品-物品相似度矩阵 S
3. 推荐 = A × S(下一章细讲)
Timer()(recommenders/utils/timer.py:7)是每个 notebook 里都会用的小工具,一个计时上下文管理器。
阶段 4:生成推荐
TOP_K = 10
with Timer() as test_time:
top_k = model.recommend_k_items(test, top_k=TOP_K, remove_seen=True)
输入 test DataFrame,输出一个新 DataFrame:
| userID | itemID | prediction |
|---|---|---|
| 1 | 50 | 0.832 |
| 1 | 181 | 0.791 |
| 1 | 100 | 0.756 |
| … | … | … |
| 2 | 121 | 0.900 |
| 2 | 98 | 0.856 |
remove_seen=True 排除了训练集里用户已经交互过的物品 —— 推荐已看过的电影毫无意义。注意 prediction 这一列:它不是"预测评分"(SAR 本质是做排名的,不产生评分),而是亲和度分数 —— 越高越可能喜欢。到评估那步库会自动按这列排 top-k。阶段 5:评估
from recommenders.evaluation.python_evaluation import (
map_at_k, ndcg_at_k, precision_at_k, recall_at_k, rmse
)
eval_map = map_at_k(test, top_k, col_user="userID", col_item="itemID", col_rating="rating", k=TOP_K)
eval_ndcg = ndcg_at_k(test, top_k, col_user="userID", col_item="itemID", col_rating="rating", k=TOP_K)
eval_precision = precision_at_k(test, top_k, col_user="userID", col_item="itemID", col_rating="rating", k=TOP_K)
eval_recall = recall_at_k(test, top_k, col_user="userID", col_item="itemID", col_rating="rating", k=TOP_K)
eval_rmse = rmse(test, top_k, col_user="userID", col_item="itemID", col_rating="rating")
计算方式:评估函数把 test(真实评分)和 top_k(模型推荐结果)在 userID + itemID 上做个 inner join,对上的那些行里计算命中与否,再按用户取平均。这些指标到底什么意思,第七章细讲。
五个阶段总结
load_pandas_df() → python_stratified_split() → model.fit() → model.recommend_k_items() → precision_at_k()
这就是整个库的心跳。换一个模型,把 from recommenders.models.sar import SAR 换成 from recommenders.models.ncf import NCF(注意 NCF 的数据格式略有不同),其余每一行的形状不变。理解了这个骨架,你就理解了怎么用这个库做推荐。
---
四、最简单的算法先搞透:SAR = 一次矩阵乘法
别怕矩阵,它就是一个表
矩阵不是什么神秘东西,它就是一张"行和列都对齐的表"。当你把用户-物品评分表写成矩阵:
item1 item2 item3 ...
user1 5 0 3
user2 0 4 0
user3 1 0 5
...
0 代表"没看过"(不是评 0 分,是没数据)。
SAR 的核心就一行代码
翻到 recommenders/models/sar/sar_singlenode.py:325-377,score 方法:
test_scores = self.user_affinity[user_ids, :].dot(self.item_similarity)
对,没了。后面的代码只是把稀疏矩阵的坐标转回 DataFrame。整个 SAR 的核心就是一次矩阵点乘。
翻译:
user_affinity[user_ids, :]—— 取出这些用户对所有物品的"亲和度"行向量(1×物品数)。item_similarity—— 物品 × 物品的相似度矩阵。.dot()—— 对于用户 u,他给每个候选物品 i 打的分 = 所有他喜欢过的物品 j 和 i 的相似度之和。
- 算《信条》和《银翼杀手 2049》有多相似(可能 0.6)
- 算《星际穿越》和《银翼杀手 2049》有多相似(可能 0.8)
- 加权求和:你的亲和度 × 0.6 + 你的亲和度 × 0.8 → 分数
.dot()。那 S 矩阵(物品相似度)怎么来的?
fit 方法(sar_singlenode.py:226-323)干两件事:
1. 建亲和度矩阵 A(:136-155):把用户评分过 / 点击过的物品标 1。compute_affinity_matrix → 一个 scipy.sparse.coo_matrix。一维是用户,一维是物品。
2. 建共现矩阵 C(:182-205):
C = Uᵀ · U
其中 U 是用 0/1 表示"用户有没有交互过某物品"的矩阵。C[i][j] = 物品 i 和物品 j 被多少个用户同时看过。C 描述了"两个物品总是一起出现的程度"。3. 从共现矩阵到相似度矩阵(:207-225):把 C 的计数变成相似度分数。recommenders/utils/python_utils.py:42-176 提供了 7 种相似度转换:
| 相似度类型 | 直觉 | |---|---| | Cooccurrence(共现)| "一起被看的次数多 → 相似",最直接 | | Jaccard | "一起被看的次数 / (至少被其中一人看过的总次数)",避免热门物品天然占优 | | Cosine(余弦)| "两个物品的用户观看向量夹角多大",标准做法 | | Lift | "A 和 B 同时出现的概率 / A 出现概率 × B 出现概率",检测"意外强关联" | | Mutual Information | 信息论视角:知道用户看过 A 给你多少关于他会不会看 B 的信息量 |
4. 时间衰减(可选):一个在两周前看过的电影,和一个在两年间看过的电影,权重能一样吗?time_decay_coefficient 用半衰期公式(python_utils.py:12 exponential_decay)让离今天越远的交互权重越低。
5. 归一化:normalize=True 时每行除以行和 —— 你每一个交互都是一个"等权票",热门用户不会比冷门用户嗓门大。
费曼的小结
SAR 本质上在说:"你喜欢的那些电影,它们还和哪些电影常常一起被喜欢?把那些推给你。" 这是一种物品-物品协同过滤(item-based collaborative filtering)。没有神经网络、没有梯度下降、没有 embedding。它就是把"一起出现"统计出来,乘一下。你完全可以在一张纸上用手算一个小例子,彻底搞懂。
这就是为什么先用它学。你搞懂了 SAR,你就搞懂了"协同过滤"这个思想的物理直觉 —— 下面那些神经网络变体,只是在用不同方式学那个 S 矩阵里的东西。
---
五、从矩阵走向神经网络:NCF 和 Wide&Deep
SAR 的局限
SAR 的 S 矩阵是硬算出来的:"物品 A 和物品 B 被 42 个用户同时看过,A 和 C 被 3 个用户同时看过 → A 和 B 更相似。"有什么问题?
1. 冷启动:新物品或冷门物品没人看过,跟谁都没有共现,永远被推荐不到。 2. 线性:物品间的真实关系可能不是"线性相似"这么简单。一个用户可能喜欢《黑暗骑士》,但原因既不是"诺兰"也不是"科幻"——他可能只是那天下雨心情好。 3. 不能利用物品本身的属性(类型、导演、价格、关键词等)。
神经网络来了,它做一件事:学 embedding。
Embedding 是什么?一张可以训练的表
想象你给每个用户和每个物品分配一个"品味编码"——一个长度为 64(或 128,你定)的浮点数向量。这个向量是一个"位置"——在一个抽象的 64 维空间里。训练的目标是:调这个向量的值,使得两个"对味"的用户/物品在这个空间里离得很近。
SAR 用计数算 S 矩阵。 Embedding 用梯度下降学那个 S 矩阵的压缩表示。这就是矩阵分解的本质。
NCF:用神经网络做协同过滤
翻到 recommenders/models/ncf/ncf_singlenode.py:132-167,NCF.forward()。
一个神经网络,输入是 (user_id, item_id),输出是一个 0~1 的数(多大概率用户会喜欢),中间有三条路可选:
GMF(广义矩阵分解) ncf_singlenode.py:148:
output_GMF = embedding_GMF_P(user) * embedding_GMF_Q(item)
就是你给 user 和 item 各学了一个 embedding,然后逐元素相乘。这本质就是矩阵分解,换了一套神经网络的皮。MLP(多层感知机) ncf_singlenode.py:155:
concat = torch.cat([embedding_MLP_P(user), embedding_MLP_Q(item)], dim=-1)
output_MLP = mlp_layers(concat)
把 user embedding 和 item embedding 拼接起来,送进几层全连接 + ReLU。这更灵活 —— 它不强制"逐元素相乘",可以学更复杂的非线性组合。NeuMF ncf_singlenode.py:161-163:
concat = torch.cat([output_GMF, output_MLP], dim=-1)
output_NeuMF = output_layer(concat)
把 GMF 和 MLP 的结果拼起来再过一层 —— 同时享受"乘法(GMF)学可解释的结构"和"MLP 学任意非线性"的好处。这是 2017 年何向南团队的经典工作,WWW 2017。数据准备有差异(ncf/dataset.py:301 Dataset):NCF 做了 leave-one-out + 负采样 —— 训练时每个正样本配 4 个"用户没交互过的随机物品"当负样本(:310),测试时配 100 个(:311),训练用 BCELoss + Adam(ncf_singlenode.py:267-324)。这是因为矩阵分解不能只学"什么对"而不学"什么错" —— 你不会想让模型给所有用户推荐同一部《肖申克的救赎》。
Wide&Deep:记忆 + 泛化
翻到 recommenders/models/wide_deep/wide_deep_utils.py:24 WideDeepModel。
谷歌在 2016 年提的架构,解决一个核心矛盾:推荐系统既需要"记忆"(这个用户上次买了卫生纸,下次还推荐卫生纸),也需要"泛化"(买过卫生纸的人通常也需要厨房纸巾,尽管这个用户还没买过)。
Wide 部分(:97-104):线性模型,每条特征直接乘一个权重。擅长记忆特定的交叉模式。比如"用户 A × 物品 B"这个交叉特征有一个独立的权重 —— 历史数据里出现过的模式直接背下来。
Deep 部分(:107-139):embedding + DNN(ReLU→BatchNorm→Dropout 多层)。擅长泛化到从未见过的用户-物品组合 —— 用相似的 embedding 来推断。
合起来(:147-182):wide logit + deep logit → sigmoid → 预测概率。训练时用两个独立的优化器(Wide 用 Adagrad,Deep 用 Adadelta,:188),因为 wide 的稀疏特征和 deep 的密集 embedding 需要不同的学习率策略。
用 Feynman 的话说:Wide&Deep 就像一个人 —— Wide 是"机械记忆"(考试题我见过,答案我背了),Deep 是"理解"(这类题的规律我搞清楚了,换个问法我也能做)。两者合起来,才是最靠谱的学生。
---
六、深度学习机器内部:三大家族
如果你只做经典推荐,学完 SAR 和 NCF 就够了。但如果你要复现论文、做研究,或想理解这个库的"重型武器"怎么组织,这一章把三大家族的内部结构拆开。
deeprec 家族:TF1 的帝国
recommenders/models/deeprec/ 是库内最大的代码块,承载了 xDeepFM、DKN、GRU、Caser、A2SVD、NextItNet、SLi-Rec、SUM、LightGCN。它的代码组织遵循三柱架构:
柱 1:YAML + HParams —— 配置驱动(deeprec/deeprec_utils.py:21-350)
每个模型有一份 YAML 配置文件在 config/ 里(比如 config/gru.yaml、config/caser.yaml)。YAML 被 flat_config 展平 → check_nn_config 验证必填字段(:134,每个模型类型有它自己的必填字段列表:xDeepFM 在 :174,DKN 在 :147,GRU 在 :187)→ 放进 HParams 对象(:305,一个字典包装,按键名暴露为属性)。你换模型,只需换一份 YAML,模型代码的 _build_graph 从 hparams 里读所有超参。这就是 deeprec 的"鞍具"。
柱 2:BaseModel —— 共享骨架(deeprec/models/base_model.py:17)
所有 deeprec 模型的基类。构造函数(:20-73)做:构建 TF1 计算图 → 调用子类的 _build_graph()(:56,抽象方法 :75)→ 计算 loss(:80,data loss + 正则化)→ 构建 train op(:58-61)→ 设置 Saver → 配置 GPU 内存按需增长(:69)→ 打开 tf.compat.v1.Session。
子类只实现一个方法:_build_graph。拿 xDeepFM(deeprec/models/xDeepFM.py:24-71)看,它的 _build_graph 组装了四个并行模块:
1. Linear part(:104):稀疏特征 × 权重,形成线性基底。
2. FM part(:135):经典因子分解机:0.5 × Σ((XW)² - X²W²),学成对特征交互。
3. CIN(Compressed Interaction Network,:161):CIN 是 xDeepFM 的真正创新 —— 用 tf.nn.conv1d 做 explicit 的高阶交互(不止两两特征,还能三层、四层交互),每层做外积→压缩→输出。有一个 fast_CIN 变体(:295)分解卷积核节省内存。
4. DNN(:453):普通多层感知机,加 BatchNorm,学 implicit 交互。
四部分的输出求和(:46-69)成最终 logit。use_Linear_part/use_FM_part/use_CIN_part/use_DNN_part 四个开关全在 YAML 里控制 —— 你可以一键关掉 FM 只留 CIN+DNN。
柱 3:Iterator —— 数据喂入(deeprec/io/iterator.py:9)
BaseIterator ABC 定义了 parser_one_line、load_data_from_file、_convert_data、gen_feed_dict。具体实现:FFMTextIterator(:44,读 FFM 格式文本行),DKNTextIterator(dkn_iterator.py:13,知识图谱特征),SequentialIterator(sequential_iterator.py:15),NextItNetIterator(nextitnet_iterator.py:15)。
所以整个 deeprec 的训练流程是:
YAML → HParams → 传给模型 → _build_graph → 用 BaseModel 的 session 跑 → Iterator 喂数据
学会了这个三柱架构,你就看懂了 deeprec 下面所有模型 —— 换模型只是换 YAML + 换 _build_graph + 换 Iterator,骨架不动。
注:deeprec 里 LightGCN(models/graphrec/lightgcn.py:35)是个另类 —— 它用的是 PyTorch,不是 TF1。在 DataModel/ImplicitCF.py:21 里预计算归一化邻接矩阵 D⁻¹⸍²AD⁻¹⸍²,然后叠 n_layers 层图卷积。评估层用的还是 recommenders.evaluation.python_evaluation 那四个排名指标 —— 你看到了,不管你用的是什么模型,评估永远是同一套东西。
newsrec 家族:双编码器模式
recommenders/models/newsrec/ 实现了 NRMS、NAML、NPA、LSTUR 四种新闻推荐模型。它们共享同一个双编码器模式(newsrec/models/base_model.py:18):
新闻编码器:把一篇新闻(标题词 ID 序列,可选正文/类别/子类别)变成一个稠密向量。词 ID → 共享的 Embedding 层(可以用预训练的 word2vec .npy 初始化,nrms.py:169-174)→ CNN 或 Self-Attention → 注意力池化(layers.py:10 AttLayer2,加性注意力)。
用户编码器:把一个用户的点击历史(一序列的新闻向量)变成一个用户向量。怎么变取决于模型 —— NRMS(nrms.py:14)用多头自注意力(layers.py:110 SelfAttention),NAML(naml.py:15)加上了 body/vert/subvert 多视角,NPA(npa.py:14)让注意力权重依赖用户 ID embedding(个性化注意力),LSTUR(lstur.py:18)用 GRU 建模长短期兴趣。
预测:用户向量 · 候选新闻向量 → sigmoid → 概率。训练时是 softmax 多选一(base_model.py:72-77:同时构建 model 用作训练、scorer 用作推理)。
数据流:datasets/mind.py:108 read_clickhistory 解析微软新闻数据集(MIND)的 behaviors.tsv → io/mind_iterator.py:14 MINDIterator 迭代。
SASRec 家族:用 Transformer 做序列推荐
recommenders/models/sasrec/model.py:390 SASREC。这是 Kang 和 McAuley ICDM 2018 的工作。
做什么:跟 SAR / NCF 不一样的是,它考虑你行为的顺序。不只是"你喜欢过哪些物品",而是"你前天看了 A,昨天看了 B,今天看了 C → 猜你明天会看什么"。
怎么做:
- Item embedding + positional embedding → Transformer encoder(
model.py:506)。 MultiHeadAttention(:14)with causal masking(:79-84)—— 预测 t 时刻的物品时,只能看到 ≤ t 的时刻。和 GPT 是同一个思想:不能偷看未来。- 训练时拿
(pos_seq, neg_seq)几组候选,用 BCE loss(:607)。 - 评估时报告 NDCG@10 和 Hit@10(
:784-881),跟 SAR 的那套评估没有任何区别。
ssept.py:12)继承了 SASREC 但额外加了 user embedding —— 让推荐也考虑"谁在看",不仅是"看了什么顺序"。三个家族一张图小结
| 家族 | 位置 | 框架 | 核心理念 | 代表模型 |
|---|---|---|---|---|
| deeprec | models/deeprec/ | TF1 静态图 | 特征交互(FM/CIN/DNN) + 序列 + 图 | xDeepFM, DKN, GRU, Caser, LightGCN |
| newsrec | models/newsrec/ | Keras/TF1 | 双编码器(新闻→向量,用户→向量,点积) | NRMS, NAML, NPA, LSTUR |
| sasrec | models/sasrec/ | PyTorch | Transformer encoder + causal masking | SASRec, SSEPT |
---
七、为什么不该用 accuracy:指标不是仪式
花半分钟做一个思想实验
你要评估一个约会软件的推荐质量。训练集里用户 A 喜欢了 100 个 profile 中的 3 个。模型给这 100 个 profile 都打了分:它一个都不推荐(输出全是"不喜欢")。这个模型在分类准确率(accuracy)上是多少?
97%。100 个里猜对了 97 个。
这有意义吗? 一点没有。用户打开约会软件看到空列表,准确率 97%,用户走了。
推荐系统面临的就是这个根本问题:正负样本极度不平衡。 一个用户看过的电影相对所有没看过的,比例可能是 1:1000 甚至 1:10000。用 accuracy 衡量推荐质量是自欺 —— 它奖励说"不"的模型。
用对了的指标
实现在 recommenders/evaluation/python_evaluation.py(1751 行),Spark 版在 spark_evaluation.py。我们来搞懂最重要的四个:
Precision@k(:457):
Precision@k = (推荐的 k 个物品里用户真正喜欢的个数) / k
你推荐了 10 部电影,用户标记为喜欢的有 3 部 → Precision@10 = 0.3。不管用户总共喜欢了多少部。这个指标问的是:"我推给你的东西里有用的比例是多少?"Recall@k(:510):
Recall@k = (推荐的 k 个物品里用户真正喜欢的个数) / (用户总共喜欢的个数)
用户总共喜欢了 20 部电影,你推荐的 10 部里覆盖了 3 部 → Recall@10 = 0.15。这个指标问的是:"我喜欢的东西,你找到了多少?"NDCG@k(:616):比 Precision/Recall 更进一步 —— 它不光算"推对了没",还管对的东西排在什么位置。排在第 1 位的"对"比排在第 10 位的"对"更有价值。用对数衰减来给排位加权。NDCG 是最常用的推荐系统排名指标 —— 比赛常用、论文常用、面试也问。
MAP(:751):对所有用户的 Average Precision 取平均。Average Precision 问的是:"这个用户从上往下扫推荐列表时,每扫到一个好东西,到那时为止的 precision 是多少?"把它整个清单扫完,平均一下。
还有"超越准确度"的指标
推荐的任务不止是"推对":
- 多样性(
diversity:1341):你推给用户的 10 部电影是不是全是漫威?如果全是同一类,"对了"也没用 —— 用户会觉得这个 App 很无聊。 - 新颖性(
novelty:1439):你推荐的东西是不是用户自己本来就知道会看排行榜的?热门电影《泰坦尼克号》推对了也不算本事 —— 用户自己也能想到。 - 意外性(
serendipity:1631):推的东西既相关又出乎意料 —— "我没想到你会推这个,但确实很对我胃口。" 这个最难量化但不能不关心。 - 覆盖率(
catalog_coverage:1680):你是不是翻来覆去只推"安全牌"的头部电影,还是敢探索长尾?
怎么跑?
不需要先算 accuracy 再算 precision。库的评估函数直接做:
from recommenders.evaluation.python_evaluation import precision_at_k
eval_precision = precision_at_k(
test, # 真实评分 DataFrame (userID, itemID, rating)
top_k, # 模型预测 DataFrame (userID, itemID, prediction)
col_user="userID", col_item="itemID", col_rating="rating",
k=10
)
里面的逻辑(merge_ranking_true_pred :379 → get_top_k_items :866):把 test 和 top_k 在 (userID, itemID) 上做 inner join → 按 prediction 排每个用户的前 k 个 → 对上的那几行就是命中的 → 按公式算。
一个陷阱:如果你用评分类指标(RMSE/MAE)来评估一个"天生做排名"的模型(比如 SAR),你得先把评分二值化 —— 在 notebook 里(sar_movielens.ipynb cell 12)有这一步,把 ≥3 的评分标为正、<3 的标为负。模型不输出 "预测评分 4.2",它输出 "置信度 0.83"。别搞混。
---
八、真正用起来:装机、换模型、调参
装库(跳过废话,直接上命令)
项目推荐用 uv 管理环境:
# 1. 装 uv(如果你还没有)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. 建虚拟环境
uv venv ~/.venvs/recommenders --python 3.11
source ~/.venvs/recommenders/bin/activate
# 3. 核心包(CPU 能跑所有非 GPU notebook)
uv pip install recommenders
# 4. 创建 Jupyter kernel
uv pip install ipykernel
python -m ipykernel install --user --name recommenders --display-name "Python (recommenders)"
# 5. Clone 仓库,在 VS Code 里打开 notebook
git clone https://github.com/recommenders-team/recommenders.git
extras:什么时候多装什么
定义在 setup.py:50-82:
| Extra | 装了有什么 | 什么时候需要 |
|---|---|---|
[gpu] | TensorFlow 2.8-2.15 + PyTorch 2.0+ | 跑 NCF/SASRec/Wide&Deep 的 GPU 加速,训练速度差 10-50x |
[spark] | PySpark 3.3+ | 跑 Spark ALS、用 Spark 分桶做大数据评估 |
[dev] | black + pytest + 测试工具链 | 你想往库上改代码、跑单元测试 |
[experimental] | xlearn, vowpalwabbit, nni, pymanopt, lightfm, scikit-surprise | 用对应的"实验性"模型(还没完全测试好的) |
SETUP.md。换模型只换一行
你写好了一个五阶段管线脚本,用的 SAR。现在想试 NCF。你要改什么?两处:
1. 模型构建:
# 之前
from recommenders.models.sar import SAR
model = SAR(col_user="userID", col_item="itemID", col_rating="rating", ...)
model.fit(train)
# 之后
from recommenders.models.ncf import NCF
from recommenders.models.ncf.dataset import Dataset as NCFDataset
data = NCFDataset(train=train, test=test, ...) # NCF 需要负采样
model = NCF(n_users=data.n_users, n_items=data.n_items, model_type="NeuMF", ...)
model.fit(data)
2. 评估不变。完全不变。 同样是那四行 precision_at_k / ndcg_at_k / recall_at_k / map_at_k,SAR 的 top_k 输出和 NCF 的 top_k 输出是一样的 DataFrame 结构。
这就是那条四列名契约的力量 —— 换模型不改管线。
调参
傻瓜式(网格搜索):
recommenders/tuning/parameter_sweep.py:9 generate_param_grid 只用了 56 行代码来完成这件事:给你一个参数字典 {"相似度类型": ["jaccard","cosine","lift"], "top_k": [5,10,20]},它用 itertools.product 展开成笛卡尔积(:51),得到 3×3=9 组参数,逐组跑,选指标最高的。
更聪明的(NNI):
recommenders/tuning/nni/ncf_training.py:30 展示了如何把 NCF 接到 Microsoft NNI(nni==1.5,一个超参搜索框架)上:NNI 的搜索算法(TPE 等)自动选一组 n_factors/lr/n_epochs → 调 ncf_training 跑训练 → 回报指标 → NNI 根据结果决定下一组参数。比你自己乱猜快得多。
选哪个模型?一个粗暴的决策表
| 你的场景 | 先试什么 |
|---|---|
| 刚学推荐系统,想快速看到结果 | SAR。不用 GPU,一行 fit,效果合理。 |
| 有用户/物品的属性特征(年龄、类别、价格……) | Wide&Deep 或 xDeepFM。这些天生吃多特征。 |
| 用户行为有明显顺序(先看 A 再看 B 再买 C) | SASRec 或 GRU。序列模型。 |
| 做新闻/文章推荐,文本是主要内容 | NRMS 或 DKN。newsrec 家族就是为这个生的。 |
| 数据大到 pandas 放不下 | Spark 路线:Spark ALS(als_movielens.ipynb)或 LightGBM on Spark(mmlspark_lightgbm_criteo.ipynb)。 |
| 想拿一套成熟的 baseline 跟你的方法比 | 跑一下 examples/06_benchmarks/movielens.ipynb —— 表里列了各算法在 MovieLens 100k 上的全部指标(README.md:140-149)。 |
---
九、带走的三条启发
1. 契约 > 复杂
整个库 20 个算法、5 万行代码,最重要的一行在 constants.py:5-23 —— 那四个列名。一个简单的约定,让分割器、模型、评估器可以任意组合。这不是什么高深学问,这是工程上最值钱的习惯:用最小的接口耦合最复杂的系统。下次你自己设计系统时,用同样的思路。
2. 先搞懂最简单的,再往上堆
SAR 只需要一个 dot()。NCF 需要 embedding + MLP + BCELoss。xDeepFM 需要 TF1 静态图 + CIN + DNN + FM + Linear 四路并行。但它们在本质上做的是同一件事:从 user-item 交互里学出一个从用户到物品的映射。搞懂了 SAR,你就能用"这个算法在 S 矩阵这件事上有什么新想法"来理解每一个后续算法,而不是被它们的名字吓住。
3. 命名不等于理解
你能列出 20 种推荐算法、18 个子目录名,不等于你会用它们。检验标准:你能不能不看文档,拿一个新数据集,从 load 到 evaluate,用 SAR 跑完一遍?如果能,你已经学会了。下一步只需要在 from recommenders.models.sar import SAR 那一行换 import —— 概念是一样的,只是内部机制变了。如果跑不出来,回去看第四章和第三章,直到能为止。
---
就这么回事。Go build something.
---
#RecommenderSystems #MicrosoftRecommenders #SAR #NCF #WideAndDeep #DeepRec #FeynmanLearning #智柴系统实验室🎙️