打开 Netflix,首页推荐的剧集刚好是你想看的;刷 TikTok,下一条视频总能戳中你的兴趣点;周一早上打开 Spotify,“Discover Weekly” 推荐的 30 首陌生歌曲,有一半你都想收藏。
这些体验背后并没有魔法,而是一种默默运行在几乎所有消费产品中的机器学习系统——推荐系统。
各大平台为何愿意在此投入大量资源?几个被反复引用的数据可以说明问题:麦肯锡报告显示,Amazon 35% 的销售额来自推荐引擎;YouTube 工程师在 RecSys 2016 论文中提到,约 70% 的观看时长由推荐驱动;Netflix 产品负责人公开表示,平台上约 75% 的播放量来自推荐,而非用户主动搜索。
这是 16 篇系列文章的第一篇,旨在帮你建立一个完整且真实的推荐系统认知模型。读完后,再看任何一篇推荐论文或工业架构,你都不会感到陌生。
本文你会学到
- 三大基础范式:协同过滤、内容推荐、混合推荐,分别适合什么场景
- 矩阵分解——拿下 Netflix Prize 的核心方法,附完整数学推导和代码
- 生产环境中真正重要的评估指标:Precision、Recall、MAP、NDCG,以及多样性与覆盖率
- 工业界标配的 多阶段漏斗架构:如何在 100 毫秒内从千万级物料中选出 Top-10
- 推荐工程师绕不开的五个难题:冷启动、稀疏性、长尾分布、时序漂移、规模扩展
- 可直接运行的 User-CF、Item-CF 和矩阵分解 Python 实现
预备知识: 熟悉 Python,了解基础线性代数(点积、矩阵),并能耐心阅读一些公式。不需要机器学习背景。

一、为什么推荐系统重要#
聊算法之前,先搞清楚我们要解决什么问题。
信息过载的真实规模#
现代平台的内容量早已超出人类的浏览能力:Netflix 全球片库约 17,000 部,YouTube 每分钟新增 500 小时视频,Spotify 收录超过 1 亿首歌曲,Amazon 商品数量以亿计。
没有过滤的情况下,用户花在搜索上的时间会远超消费内容的时间。选择越多,决策越差,满意度反而下降——这就是“选择悖论”。推荐系统的任务是为每个用户打造一副个性化滤镜,筛选出真正重要的那一小部分内容。
商业价值#
推荐系统的收益大到足以支撑独立的组织架构。

几个有据可查的数字:
| 平台 | 影响 | 来源 |
|---|
| Amazon | 约 35% 营收来自推荐 | MGI / McKinsey 2013 |
| Netflix | 约 75% 的播放由推荐驱动 | Gomez-Uribe & Hunt, ACM TMIS 2015 |
| YouTube | 约 70% 的观看时长来自推荐 | Covington 等, RecSys 2016 |
| Spotify | Discover Weekly 一年内累计播放破 23 亿 | Spotify Newsroom |
这不是小打小闹的提升。对于头部平台来说,CTR 提升 1%,往往意味着每年八九位数的收益。这也是为什么一支推荐团队动辄需要上百名工程师。
你每天都在哪里遇到它#
不同场景下的推荐策略也不同:
- 电商——“买了又买”、个性化首页、搭配套装
- 流媒体视频——按口味聚类的首页行、“因为你看过……”、自动续播下一集
- 音乐——Discover Weekly、Daily Mix、从一首歌生成的电台
- 社交信息流——优化参与度的排序、“你可能认识的人”、For You 页面
底层其实只有一小套核心思想在反复使用,下面我一个个拆解来看。
二、三大范式#
任何一个推荐系统,无论是 50 行代码的小脚本,还是 YouTube 的复杂技术栈,都基于以下三种核心思想之一:
- 协同过滤——从 谁喜欢什么 中学习
- 内容推荐——从 物品的属性 中学习
- 混合推荐——结合两者,因为每种方法都有盲区
我们的数据通常长这样:

大部分位置是未知的。推荐算法的本质任务就是填补这些空白。
协同过滤——“和你口味相似的人喜欢这个”#
协同过滤(Collaborative Filtering,CF)背后的思想非常简单:如果两个用户过去的选择一致,未来很可能也会一致。算法不需要知道物品是什么,它只依赖用户与物品的交互模式。
口味双胞胎的比喻
假设你和朋友对 50 部电影的评分几乎完全一致,她可以算是你的 口味双胞胎。如果她看了一部新电影并打了高分,而你还没看过,合理的推测是:你也会喜欢这部影片,即使你们都说不清具体原因。
两种实现方式
协同过滤有两种常见形式:
- User-CF——找到和你相似的用户,推荐他们喜欢的物品
- Item-CF——找到和你已喜欢物品相似的其他物品
在工业界,Item-CF 更受欢迎,原因有两点:
- 物品的变化较慢(比如一部电影的近邻基本固定),相似度可以离线预计算并缓存。
- 大多数平台的物品数量远少于用户数量,Item-Item 的计算规模更可控。
数学表达
定义如下符号:
- $U = \{u_1, \dots, u_m\}$
是 $m$
个用户的集合
- $I = \{i_1, \dots, i_n\}$
是 $n$
个物品的集合
- $R \in \mathbb{R}^{m \times n}$
是用户-物品评分矩阵,其中 $r_{ui}$
表示用户 $u$
对物品 $i$
的评分
$$\hat{r}_{ui} = \bar{r}_u + \frac{\sum_{v \in N(u)} \text{sim}(u, v) \cdot (r_{vi} - \bar{r}_v)}{\sum_{v \in N(u)} |\text{sim}(u, v)|}$$通俗解释:以用户 $u$
的平均评分 $\bar r_u$
为基础,根据相似用户的评分偏差加权调整,权重是用户间的相似度。
相似度计算
常用的相似度度量有两种:
- 余弦相似度
$\text{sim}_{\cos}(u, v) = \frac{\sum_{i \in I_{uv}} r_{ui} r_{vi}}{\sqrt{\sum r_{ui}^2}\sqrt{\sum r_{vi}^2}}$ - 皮尔逊相关系数
$\text{sim}_{\text{pearson}}(u, v) = \frac{\sum_{i \in I_{uv}} (r_{ui} - \bar r_u)(r_{vi} - \bar r_v)}{\sqrt{\sum (r_{ui} - \bar r_u)^2}\sqrt{\sum (r_{vi} - \bar r_v)^2}}$
关键区别在于:如果 Alice 总是打 4–5 分,Bob 总是打 2–3 分,余弦相似度会认为他们完全不同;而皮尔逊相关系数会先减去均值,识别出他们的 相对偏好 可能一致,从而判定他们是口味双胞胎。
优劣势对比
| 优势 | 劣势 |
|---|
| 不依赖领域知识,无需物品特征 | 冷启动问题严重:新用户、新物品无法处理 |
| 容易发现意外惊喜 | 数据稀疏时效果差 |
| 数据越多效果越好 | 热度偏差明显,热门物品更容易被推荐 |
内容推荐——“它和你喜欢的很像”#
协同过滤问的是 “还有谁喜欢这个?”,而内容推荐问的是 “这东西本身是什么?你之前喜欢过类似的吗?”
内容推荐通过分析用户交互过的物品 属性,构建用户的偏好画像,然后用画像对未见过的物品打分。它不关心其他用户的行为。
如何表示物品
物品的特征表示取决于其类型:
- 文本(新闻、文章): TF-IDF 向量、句向量、主题模型
- 图像(商品、艺术品): CNN 嵌入、颜色直方图、视觉属性
- 音频(音乐): MFCC、音频嵌入、元数据(节奏、调式、流派)
- 结构化数据(电影、商品): one-hot 类目、价格、知识图谱特征
$$\hat{r}_{ui} = \mathbf{w}_u^\top \mathbf{x}_i + b_u$$
$$\min_{\mathbf{w}_u, b_u} \sum_{i \in I_u} (r_{ui} - \mathbf{w}_u^\top \mathbf{x}_i - b_u)^2 + \lambda \|\mathbf{w}_u\|^2$$末尾的 $\lambda \|\mathbf{w}_u\|^2$
是 L2 正则化,用于防止模型过度拟合少量评分数据。
优劣势对比
| 优势 | 劣势 |
|---|
| 新物品只要有特征即可推荐 | 依赖高质量特征,需要大量特征工程 |
| 推荐理由天然可解释 | 容易陷入局部最优,反复推荐同一类物品 |
| 单个用户的历史数据就够用 | 新用户依然面临冷启动问题(没有画像) |
混合推荐——取长补短#
纯协同过滤和纯内容推荐各有明显的短板。混合推荐将两者结合起来,当一种方法失效时,另一种方法可以补位。

常见的五种混合方式如下:
$$\hat{r}_{ui} = \alpha \cdot \hat{r}_{ui}^{\text{CF}} + (1 - \alpha) \cdot \hat{r}_{ui}^{\text{CB}}$$$\alpha$
可以固定、在验证集上学习,或者动态调整(资深用户多用 CF,新用户多用内容推荐)。
2. 切换混合
根据场景选择方法:
1
2
3
4
5
6
| def predict(user, item):
if item.rating_count < MIN_RATINGS:
return content_based_predict(user, item)
if user.rating_count < MIN_USER_RATINGS:
return content_based_predict(user, item)
return collaborative_filtering_predict(user, item)
|
3. 特征融合
把内容特征作为额外输入,融入协同过滤模型中。
4. 级联混合
先用低成本方法生成候选集,再用高精度方法重排。这是工业界的主流模式,也是后续漏斗架构的基础。
5. 元层次混合
用一个模型的输出作为另一个模型的输入。例如,现代深度推荐系统中,“先用内容学物品嵌入,再在嵌入上做协同过滤” 就属于这一类。
真实案例
Netflix 的生产环境推荐器是一个复杂的混合体:
- 基于观看历史的矩阵分解
- 融合文本、图像、音频的深度网络
- 上下文信号(时间、设备)
- 业务约束(新鲜度、多样性)
- 几十个模型的最终集成
如今几乎没有头部平台还在使用单一算法。
三、矩阵分解:推荐系统的主力#

User-CF 和 Item-CF 这些邻域方法虽然直观,但真正拿下 Netflix Prize 并奠定现代推荐系统基础的,是 矩阵分解(Matrix Factorization,MF)。它不仅是双塔深度模型的前身,更是推荐领域的经典工具。
几何直觉#
矩阵分解假设用户偏好和物品特性可以用少量 隐因子 来概括。这些隐因子是从数据中自动学习出来的,而不是人工标注的。

看图最清楚:每个用户和每个物品都位于一个 $k$
维空间中(实际应用中 $k$
通常在 20 到 200 之间)。比如电影推荐,隐因子可能对应 “大片 ↔ 文艺” 或 “轻松 ↔ 深沉” 的维度,但我不需要告诉模型这些——它自己会发现。
$$\hat{r}_{ui} = \mathbf{p}_u^\top \mathbf{q}_i$$如果用户向量和物品向量方向接近,点积值就大,预测评分也就高。
优化目标#
$$\min_{P, Q} \sum_{(u, i) \in \Omega} (r_{ui} - \mathbf{p}_u^\top \mathbf{q}_i)^2 + \lambda (\|\mathbf{p}_u\|^2 + \|\mathbf{q}_i\|^2)$$
$$\hat{r}_{ui} = \mu + b_u + b_i + \mathbf{p}_u^\top \mathbf{q}_i$$$\mu$
是全局均值,$b_u$
表示 “这个用户打分普遍偏高或偏低”,$b_i$
表示 “这个物品普遍受欢迎或不受欢迎”。把这些一阶效应剥离后,隐因子只需要建模真正的 交互,这才是我想要模型学习的核心。
两种优化方法#
$$\mathbf{p}_u \leftarrow \mathbf{p}_u + \eta (e_{ui} \mathbf{q}_i - \lambda \mathbf{p}_u), \quad \mathbf{q}_i \leftarrow \mathbf{q}_i + \eta (e_{ui} \mathbf{p}_u - \lambda \mathbf{q}_i)$$交替最小二乘(ALS): 固定 $Q$
,对每个 $\mathbf{p}_u$
解闭式最小二乘问题;再交换角色。ALS 在用户和物品两侧都能轻松并行,这也是 Spark MLlib 推荐器选择 ALS 的原因。
下面是一个完整且最小化的矩阵分解实现,带偏置项,并用 SGD 训练:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
| import numpy as np
from typing import List, Tuple
class MatrixFactorization:
"""带用户/物品偏置的矩阵分解,使用 SGD 训练。"""
def __init__(
self,
n_users: int,
n_items: int,
n_factors: int = 20,
learning_rate: float = 0.01,
regularization: float = 0.02,
n_epochs: int = 20,
):
self.n_users = n_users
self.n_items = n_items
self.n_factors = n_factors
self.lr = learning_rate
self.reg = regularization
self.n_epochs = n_epochs
# 隐因子用小随机数初始化
rng = np.random.default_rng(0)
self.P = rng.normal(0, 0.1, (n_users, n_factors))
self.Q = rng.normal(0, 0.1, (n_items, n_factors))
self.user_bias = np.zeros(n_users)
self.item_bias = np.zeros(n_items)
self.global_mean = 0.0
def fit(self, ratings: List[Tuple[int, int, float]]) -> "MatrixFactorization":
self.global_mean = float(np.mean([r for _, _, r in ratings]))
for epoch in range(self.n_epochs):
np.random.shuffle(ratings)
sse = 0.0
for u, i, r in ratings:
pred = self._predict(u, i)
err = r - pred
sse += err ** 2
p_u, q_i = self.P[u].copy(), self.Q[i].copy()
self.P[u] += self.lr * (err * q_i - self.reg * p_u)
self.Q[i] += self.lr * (err * p_u - self.reg * q_i)
self.user_bias[u] += self.lr * (err - self.reg * self.user_bias[u])
self.item_bias[i] += self.lr * (err - self.reg * self.item_bias[i])
if (epoch + 1) % 5 == 0:
rmse = np.sqrt(sse / len(ratings))
print(f"epoch {epoch + 1:3d} RMSE = {rmse:.4f}")
return self
def _predict(self, u: int, i: int) -> float:
return (
self.global_mean
+ self.user_bias[u]
+ self.item_bias[i]
+ self.P[u] @ self.Q[i]
)
def recommend(
self, u: int, top_n: int = 10, exclude: set | None = None
) -> List[Tuple[int, float]]:
exclude = exclude or set()
scores = [(i, self._predict(u, i)) for i in range(self.n_items) if i not in exclude]
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_n]
if __name__ == "__main__":
ratings = [
(0, 0, 5.0), (0, 1, 3.0), (0, 2, 2.5),
(1, 0, 4.0), (1, 2, 4.5), (1, 3, 3.0),
(2, 1, 3.5), (2, 2, 4.0), (2, 4, 2.0),
(3, 0, 3.0), (3, 3, 4.0), (3, 4, 4.5),
(4, 1, 4.0), (4, 2, 3.5), (4, 3, 5.0),
]
model = MatrixFactorization(n_users=5, n_items=5, n_factors=10, n_epochs=50)
model.fit(ratings)
print("\n用户 0 的 Top-3:", model.recommend(0, top_n=3, exclude={0, 1, 2}))
|
动手试试: 把 n_factors 从 2 调到 50,观察 RMSE 的变化:太少会欠拟合,太多在这种小数据上会过拟合。真实场景中,合理范围几乎总在 20–200 之间。
隐式反馈#
线上系统很少有显式的 1–5 评分,更多时候只有 隐式反馈:点击、观看时长、停留时间、购买行为等。问题是,没有交互 不等于 不喜欢——用户可能根本没看到过这个物品。
$$\min_{P, Q} \sum_{u, i} c_{ui} (p_{ui} - \mathbf{p}_u^\top \mathbf{q}_i)^2 + \lambda(\|\mathbf{p}_u\|^2 + \|\mathbf{q}_i\|^2)$$其中 $p_{ui} \in \{0, 1\}$
表示是否交互,$c_{ui} = 1 + \alpha f_{ui}$
是随交互频次 $f_{ui}$
增长的置信度。我们对所有用户-物品对优化,但已交互对置信度高(“基本可以确定是正样本”),未交互对置信度低(“大概率是负样本,但不一定”)。
四、评估:度量真正重要的东西#
搭建推荐系统只是工作的一半,判断它是否真的有效是另一半——而且这比大多数人想象的要难得多。
单一准确率数字会骗人#
如果模型预测评分的准确率达到 95%,是不是就说明它很好?不一定。考虑以下几点:
- 用户通常只对自己喜欢的内容打分。如果对所有物品都预测高分,模型看似表现不错,但推荐结果毫无价值。
- 如果测试集主要由热门物品组成,那测出来的只是简单场景的表现。
- 如果推荐结果缺乏多样性,用户可能短期内满意,但长期来看容易流失。
因此,诚实的评估需要 多个指标,每个指标从不同角度衡量系统的性能。
分类指标#
$$\text{Precision@}K = \frac{|R_u \cap T_u|}{|R_u|}, \quad \text{Recall@}K = \frac{|R_u \cap T_u|}{|T_u|}$$Precision@K 衡量的是:推荐的物品中有多少是用户真正感兴趣的?
Recall@K 衡量的是:用户感兴趣的所有物品中,有多少被推荐出来了?
这两个指标直观易懂,但它们忽略了 Top-$K$
列表中的 顺序——排在第 1 位的相关物品和排在第 10 位的相关物品得分相同。
排序指标——位置很重要#
在真实的推荐界面中,第 1 位的价值远远高于第 10 位。以下是两个主流排序指标。
$$\text{AP@}K = \frac{1}{|T_u|} \sum_{k=1}^{K} \text{Precision@}k \cdot \text{rel}(k)$$其中 $\text{rel}(k) = 1$
表示排名 $k$
的物品是相关的。MAP 是所有用户的 AP 均值,奖励那些将相关物品排在前面的推荐。
$$\text{DCG@}K = \sum_{k=1}^{K} \frac{2^{\text{rel}_k} - 1}{\log_2(k + 1)}, \quad \text{NDCG@}K = \frac{\text{DCG@}K}{\text{IDCG@}K}$$$\log_2(k+1)$
起到 折扣 作用——第 1 位得满分,第 10 位得分仅为约三分之一。IDCG 是按真实相关性完美排序后的 DCG,因此 NDCG 始终落在 $[0, 1]$
区间。
同一份排序结果,从这三个指标看可能会得出截然不同的结论:

参考实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| import numpy as np
from typing import List
def precision_at_k(ranked: List[int], relevant: set, k: int) -> float:
if k == 0:
return 0.0
hits = sum(1 for i in ranked[:k] if i in relevant)
return hits / k
def recall_at_k(ranked: List[int], relevant: set, k: int) -> float:
if not relevant:
return 0.0
hits = sum(1 for i in ranked[:k] if i in relevant)
return hits / len(relevant)
def average_precision(ranked: List[int], relevant: set, k: int) -> float:
"""AP@K:相关物品越靠前得分越高。"""
if not relevant:
return 0.0
score, hits = 0.0, 0
for rank, item in enumerate(ranked[:k], start=1):
if item in relevant:
hits += 1
score += hits / rank
return score / min(len(relevant), k)
def ndcg_at_k(true_relevances: List[float], k: int) -> float:
"""NDCG@K:支持分级相关性(评分)。"""
rel = np.asarray(true_relevances[:k], dtype=float)
if rel.size == 0:
return 0.0
discounts = np.log2(np.arange(2, rel.size + 2))
dcg = np.sum((2 ** rel - 1) / discounts)
ideal = np.sort(np.asarray(true_relevances, dtype=float))[::-1][:k]
idcg = np.sum((2 ** ideal - 1) / discounts[: ideal.size])
return float(dcg / idcg) if idcg else 0.0
if __name__ == "__main__":
ranked = [5, 2, 8, 1, 9, 3, 7, 4, 6, 10]
relevant = {1, 2, 5, 7}
print(f"Precision@5 = {precision_at_k(ranked, relevant, 5):.3f}")
print(f"Recall@5 = {recall_at_k(ranked, relevant, 5):.3f}")
print(f"AP@10 = {average_precision(ranked, relevant, 10):.3f}")
print(f"NDCG@10 = {ndcg_at_k([5,5,2,5,1,3,4,2,1,1], 10):.3f}")
|
准确率之外:覆盖率、多样性、意外性#
一个永远只推全站 Top-10 的系统,虽然 Precision 很高,但会毁掉产品。以下是三个与准确率正交的指标:
- 覆盖率——系统推荐过的物品占全库的比例。如果只覆盖 1%,剩下的 99% 就形同虚设。
- 列表内多样性——单条推荐列表中物品两两之间的平均差异。十部高度雷同的续集不是好列表。
- 意外性(Serendipity)——既相关又出乎意料的推荐。比如让用户感叹“它怎么知道我会喜欢这个?”的瞬间。
这些目标常常与准确率 冲突。如何取舍是产品经理的决策,而不是算法工程师的任务。
线上指标——决胜的关键#
离线指标指导研发,线上指标决定成败。生产环境中真正重要的指标包括:
- CTR——点击 / 曝光
- 转化率——购买 / 点击
- 参与度——观看时长、会话长度、回访率
- 业务指标——人均收入、LTV、留存率
核心工具是 A/B 测试:随机分桶,新旧系统并行运行,只有在统计意义上确认提升后才上线新版本。
五、系统架构:从千万到 Top-10#
真实推荐系统的任务非常艰巨:每秒处理数十万次请求,每次在 $10^7$
规模的物料池中选出 10 个,且延迟必须控制在 100 毫秒以内。没有任何单一模型能完成这个任务。最终大家达成共识,采用 多阶段漏斗 的架构——逐步缩小候选集,同时逐步使用更复杂的模型。

第一阶段——召回(候选生成)#
目标:将 $10^7$
的物料快速缩减到几千个,耗时必须远低于 10 毫秒。
多个低成本的 召回通道 并行运行——Item-CF、向量近邻检索(FAISS、ScaNN、HNSW)、热门内容、社交图谱、内容匹配等——然后合并结果。这一阶段的核心是 高召回率:允许一些假阳性通过,后续阶段会过滤掉;但漏掉一个真正相关的物品就是致命的,因为后续阶段无法挽回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| class CandidateGenerator:
def __init__(self):
self.user_cf = UserCFRecall()
self.item_cf = ItemCFRecall()
self.mf = MatrixFactorizationRecall()
self.popular = PopularItemsRecall()
self.content = ContentBasedRecall()
def generate(self, user_id: int, n: int = 2000) -> list[int]:
candidates: set[int] = set()
candidates.update(self.user_cf.recall(user_id, n=500))
candidates.update(self.item_cf.recall(user_id, n=500))
candidates.update(self.mf.recall(user_id, n=500))
candidates.update(self.popular.recall(user_id, n=200))
candidates.update(self.content.recall(user_id, n=300))
candidates -= self.history(user_id)
return list(candidates)[:n]
|
第二阶段——粗排#
目标:用一个 简单但不简陋 的模型,将几千个候选进一步缩减到几百个。
常见选择包括逻辑回归、小型 MLP 或蒸馏后的深度模型。特征也比较简单——用户画像、物品热度、召回分数、召回通道本身——因为在这个规模下,复杂特征的成本太高。
第三阶段——精排#
目标:用整个系统中最 强大、最昂贵 的模型,对剩余的几百个候选进行精确排序。
这是深度学习模型的主战场:Wide & Deep、DCN、DIN、基于 Transformer 的序列模型、双塔架构等。这些模型利用丰富的特征(长历史行为、实时会话、物品嵌入、交叉特征),通常还会同时优化多个目标——点击率、转化率、观看时长等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| import torch
import torch.nn as nn
class WideAndDeep(nn.Module):
"""Wide & Deep 排序模型——线性记忆 + MLP 泛化。"""
def __init__(
self,
n_wide_features: int,
embedding_sizes: list[int],
embedding_dim: int = 16,
deep_layers: tuple[int, ...] = (128, 64, 32),
):
super().__init__()
self.wide = nn.Linear(n_wide_features, 1)
self.embeddings = nn.ModuleList(
[nn.Embedding(n, embedding_dim) for n in embedding_sizes]
)
layers, in_dim = [], len(embedding_sizes) * embedding_dim
for hidden in deep_layers:
layers += [nn.Linear(in_dim, hidden), nn.ReLU(), nn.Dropout(0.3)]
in_dim = hidden
self.deep = nn.Sequential(*layers)
self.out = nn.Linear(deep_layers[-1] + 1, 1)
def forward(self, wide_x, emb_idx):
wide = self.wide(wide_x)
deep_in = torch.cat(
[emb(emb_idx[:, i]) for i, emb in enumerate(self.embeddings)], dim=1
)
deep = self.deep(deep_in)
return torch.sigmoid(self.out(torch.cat([wide, deep], dim=1))).squeeze()
|
第四阶段——重排与策略#
目标:将按相关性排序的列表转化为一个 用户体验友好 的最终列表。
业务逻辑都在这一层实现:通过 MMR(Maximal Marginal Relevance)提升多样性、为新内容注入新鲜度、降权用户刚看过的物品、应用内容审核规则、安排广告位、满足公平性约束。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import numpy as np
def maximal_marginal_relevance(
candidates: list,
scores: np.ndarray,
similarity: np.ndarray,
top_n: int,
lam: float = 0.5,
) -> list:
"""MMR:平衡相关性与多样性。lam=1 表示纯相关性。"""
selected = [int(np.argmax(scores))]
remaining = [i for i in range(len(candidates)) if i != selected[0]]
while len(selected) < top_n and remaining:
mmr = [
lam * scores[i] - (1 - lam) * max(similarity[i, j] for j in selected)
for i in remaining
]
pick = remaining[int(np.argmax(mmr))]
selected.append(pick)
remaining.remove(pick)
return [candidates[i] for i in selected]
|
漏斗架构的严格分层设计,正是 “深度学习的高质量” 和 “100 毫秒的低延迟” 能够共存的关键。便宜的模型负责广撒网,昂贵的模型则在小范围内精雕细琢。
六、五个长期未解的挑战#
研究了这么多年,每个推荐团队依然要面对同样的五个问题。这些问题至今没有完美的解决方案。
冷启动#
新用户没有历史记录,新物品没有交互数据。在 CF 看来,两者都是透明的。
对于 新用户——通过快速选择几个兴趣点完成引导,回退到人口统计特征,先依赖热门和趋势内容,等积累了一些信号再说。
$$\text{score}(i) = \hat{r}_i + \beta \sqrt{\frac{\log N}{n_i}}$$让曝光次数少的物品有机会证明自己的价值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| import numpy as np
def epsilon_greedy_recommend(
candidates, scores, item_counts, *, epsilon=0.1, top_n=10, threshold=10
):
"""ε-贪心:Top-N 大部分用于利用,小部分用于探索。"""
candidates = np.asarray(candidates)
scores = np.asarray(scores)
cold = np.array([item_counts.get(c, 0) < threshold for c in candidates])
n_explore = int(round(top_n * epsilon))
n_exploit = top_n - n_explore
exploit = candidates[~cold][np.argsort(-scores[~cold])][:n_exploit]
explore_pool = candidates[cold]
explore = np.random.choice(
explore_pool, size=min(n_explore, len(explore_pool)), replace=False
) if len(explore_pool) else np.array([], dtype=candidates.dtype)
out = np.concatenate([exploit, explore])
np.random.shuffle(out)
return out[:top_n].tolist()
|
稀疏性#
Netflix 有大约 2 亿用户和 1.7 万部影片,理论上可能的交互数是 $3.4 \times 10^9$
。但平均每个用户只评分几十部电影,密度远低于 0.001%。
能帮上忙的工具包括:矩阵分解(通过隐空间共享统计强度)、隐式反馈(点击和停留时间显著增加矩阵密度)、跨域迁移(用音乐偏好为电影偏好打基础)、以及强力正则化。
长尾问题#
物品热度总是服从幂律分布,形状永远不变:

最热门的几个百分点物品占据了大多数交互,剩下的半数物品处于 冷启动区——CF 模型根本无法处理这些信号不足的物品。
这带来了三个实际问题:流行度偏差(强者愈强的反馈循环)、创作者不公平(小众创作者难以被发现)、用户不满意(小众需求得不到满足)。缓解方法包括:逆倾向加权(训练时降低热门物品权重)、重排阶段加入显式的多样性约束、以及上下文老虎机算法 学习 哪些尾部物品能吸引哪些用户群体。
时序漂移#
用户兴趣会变,物品会过时,趋势会爆发也会冷却。一个用上季度数据训练的模型,很可能在今天自信地给出错误结果。
常见的应对方法:时间衰减权重 $w(t) = e^{-\lambda(t_{\text{now}} - t)}$
、在线学习(持续小步更新模型)、加入时段和新鲜度特征、以及基于最近几分钟行为建模的会话级序列模型,而不是依赖几个月前的数据。
Pinterest、字节跳动、Meta 这样的系统需要在 100 毫秒 p99 的延迟内处理每秒数百万次 QPS。这是极其苛刻的工程约束,对算法选择的影响不亚于精度本身。
标准工具箱包括:激进的缓存策略、向量检索库(FAISS、ScaNN、HNSW)、模型量化与蒸馏、按用户分片,以及严格的离线/在线分工——前一天晚上预计算所有能算的内容,请求时只做与请求直接相关的部分。
七、从零实现两种 CF#
读代码比读公式更能学到东西。下面给出两份完整可运行的参考实现:User-CF 和 Item-CF。
用户-协同过滤#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| import numpy as np
from collections import defaultdict
from typing import Dict, List, Tuple
class UserBasedCF:
"""基于皮尔逊相似度和均值中心化预测的 User-CF。"""
def __init__(self, k_neighbors: int = 50, min_common_items: int = 3,
similarity: str = "pearson"):
self.k = k_neighbors
self.min_common = min_common_items
self.similarity = similarity
self.user_ratings: Dict[int, Dict[int, float]] = defaultdict(dict)
self.item_users: Dict[int, set] = defaultdict(set)
self.user_means: Dict[int, float] = {}
self.global_mean = 0.0
def fit(self, ratings: List[Tuple[int, int, float]]) -> "UserBasedCF":
all_r = []
for u, i, r in ratings:
self.user_ratings[u][i] = r
self.item_users[i].add(u)
all_r.append(r)
self.global_mean = float(np.mean(all_r))
for u, items in self.user_ratings.items():
self.user_means[u] = float(np.mean(list(items.values())))
return self
def _sim(self, u: int, v: int) -> float:
common = set(self.user_ratings[u]) & set(self.user_ratings[v])
if len(common) < self.min_common:
return 0.0
r1 = np.array([self.user_ratings[u][i] for i in common])
r2 = np.array([self.user_ratings[v][i] for i in common])
if self.similarity == "pearson":
r1 = r1 - self.user_means[u]
r2 = r2 - self.user_means[v]
n1, n2 = np.linalg.norm(r1), np.linalg.norm(r2)
return float(r1 @ r2 / (n1 * n2)) if n1 and n2 else 0.0
def predict(self, u: int, i: int) -> float:
if u not in self.user_ratings:
return self.global_mean
if i not in self.item_users:
return self.user_means.get(u, self.global_mean)
if i in self.user_ratings[u]:
return self.user_ratings[u][i]
sims = [(v, self._sim(u, v)) for v in self.item_users[i] if v != u]
sims = sorted((s for s in sims if s[1] > 0), key=lambda x: -x[1])[: self.k]
if not sims:
return self.user_means[u]
weighted = sum(s * (self.user_ratings[v][i] - self.user_means[v]) for v, s in sims)
denom = sum(abs(s) for _, s in sims)
return self.user_means[u] + weighted / denom if denom else self.user_means[u]
def recommend(self, u: int, n: int = 10, exclude_rated: bool = True
) -> List[Tuple[int, float]]:
if u not in self.user_ratings:
popular = sorted(self.item_users.items(), key=lambda x: -len(x[1]))
return [(i, self.global_mean) for i, _ in popular[:n]]
rated = set(self.user_ratings[u]) if exclude_rated else set()
scores = [(i, self.predict(u, i)) for i in self.item_users if i not in rated]
return sorted(scores, key=lambda x: -x[1])[:n]
|
物品-协同过滤#
Item-CF 的结构与 User-CF 类似,但问题的角度不同:从“哪些用户和我相似”变成了“哪些物品和我喜欢的物品相似”。标准相似度是 调整余弦——先减去每个用户的评分均值再计算余弦相似度,从而消除用户评分基线的差异。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| class ItemBasedCF:
"""带相似度缓存的、使用调整余弦相似度的 Item-CF。"""
def __init__(self, k_neighbors: int = 50, min_common_users: int = 3):
self.k = k_neighbors
self.min_common = min_common_users
self.item_ratings: Dict[int, Dict[int, float]] = defaultdict(dict)
self.user_items: Dict[int, set] = defaultdict(set)
self.user_means: Dict[int, float] = {}
self.sim_cache: Dict[Tuple[int, int], float] = {}
self.global_mean = 0.0
def fit(self, ratings: List[Tuple[int, int, float]]) -> "ItemBasedCF":
all_r = []
for u, i, r in ratings:
self.item_ratings[i][u] = r
self.user_items[u].add(i)
all_r.append(r)
self.global_mean = float(np.mean(all_r))
for u, items in self.user_items.items():
self.user_means[u] = float(np.mean([self.item_ratings[i][u] for i in items]))
return self
def _sim(self, a: int, b: int) -> float:
key = (min(a, b), max(a, b))
if key in self.sim_cache:
return self.sim_cache[key]
common = set(self.item_ratings[a]) & set(self.item_ratings[b])
if len(common) < self.min_common:
self.sim_cache[key] = 0.0
return 0.0
r1 = np.array([self.item_ratings[a][u] - self.user_means[u] for u in common])
r2 = np.array([self.item_ratings[b][u] - self.user_means[u] for u in common])
n1, n2 = np.linalg.norm(r1), np.linalg.norm(r2)
s = float(r1 @ r2 / (n1 * n2)) if n1 and n2 else 0.0
self.sim_cache[key] = s
return s
def predict(self, u: int, i: int) -> float:
if u not in self.user_items:
return self.global_mean
if i not in self.item_ratings:
return self.user_means.get(u, self.global_mean)
sims = [(j, self._sim(i, j)) for j in self.user_items[u] if j != i]
sims = sorted((s for s in sims if s[1] > 0), key=lambda x: -x[1])[: self.k]
if not sims:
return self.user_means.get(u, self.global_mean)
num = sum(s * self.item_ratings[j][u] for j, s in sims)
den = sum(abs(s) for _, s in sims)
return num / den if den else self.user_means.get(u, self.global_mean)
def similar_items(self, i: int, n: int = 10) -> List[Tuple[int, float]]:
others = [j for j in self.item_ratings if j != i]
sims = [(j, self._sim(i, j)) for j in others]
return sorted(sims, key=lambda x: -x[1])[:n]
|
八、常见问题#
CF 和内容推荐怎么选?
交互数据多、物品特征难定义(比如一首歌为什么“好听”)、希望带来惊喜感——用 CF。物品元数据丰富但交互稀疏、新物品频繁出现、需要可解释性推荐、或者隐私限制行为数据使用——用 内容推荐。实际生产中,混合方法更靠谱。
隐式反馈(点击、观看)和显式评分怎么处理?
隐式数据量大但噪声也大——没交互不代表不喜欢。三种常用方法:加权矩阵分解(把已观测数据当作高置信正样本,未观测数据当作低置信负样本)、负采样(每个正样本采几个未观测的当负样本)、成对排序损失比如 BPR。
矩阵分解的隐因子维度怎么定?
一般范围是 20–200。低于 10 容易欠拟合;高于 500 收益递减,训练时间变长,还容易过拟合。建议从 50–100 开始,在验证集上调参。
多久重新训练一次模型?
看数据更新速度。像 TikTok 这种系统,亚秒级在线学习是标配;Netflix 和 Spotify 通常按天或周重训;Amazon 商品这种生命周期长的场景,可以按周或月重训核心部分,再用在线更新维护用户状态。
探索与利用怎么平衡?
纯利用会造信息茧房,纯探索会让用户反感。实际操作中,可以用 ε-贪心($\epsilon \in [0.05, 0.15]$
)、自适应的汤普森采样,或者直接在 Top-10 里留 1–2 个位置给 UCB 规则选出的探索项。
九、总结#
记住这五点:
- 没有万能算法: 选型要看数据、规模和产品目标。实际生产中,混合方法才是主流。
- 架构比算法更重要,尤其是在大规模场景下。 召回 → 粗排 → 精排 → 重排的漏斗设计,是深度模型能在 100 毫秒内完成服务的关键。
- 评估是多目标的: 准确率、排序质量、多样性、覆盖率以及线上业务指标都很重要,但它们之间往往存在冲突。
- 难点问题永远存在: 冷启动、稀疏性、长尾分布、时序漂移、规模——每个团队都在长期解决这些问题。
- 多读代码: 直觉不仅藏在公式里,也藏在实现细节中。
本系列后续内容安排如下:
- 第 2 篇——协同过滤与矩阵分解的全面解析
- 第 3 篇——推荐系统的深度学习基础组件
- 第 4 篇——CTR 预估(Wide & Deep、DCN、DeepFM)
- 第 5 篇——Embedding 技术(Word2Vec、Item2Vec、图嵌入)
- 第 6–10 篇——序列推荐、GNN、知识图谱、多任务学习、DIN
- 第 11–16 篇——对比学习、基于 LLM 的推荐、公平性、跨域推荐、实时推荐、工业实践
推荐阅读
- Aggarwal, Recommender Systems: The Textbook——最全面的单册参考书
- Koren, Bell, Volinsky, Matrix Factorization Techniques for Recommender Systems(IEEE Computer 2009)——Netflix Prize 经典论文
- Hu, Koren, Volinsky, Collaborative Filtering for Implicit Feedback Datasets(ICDM 2008)
- Cheng 等,Wide & Deep Learning for Recommender Systems(DLRS 2016)
- Covington, Adams, Sargin, Deep Neural Networks for YouTube Recommendations(RecSys 2016)
- He 等,Neural Collaborative Filtering(WWW 2017)
值得动手尝试的开源库