系列 · 推荐系统 · 第 1 篇

推荐系统(一)—— 入门与基础概念

推荐系统入门指南:三大范式(协同过滤、内容推荐、混合推荐)、评估指标(Precision、Recall、NDCG)、生产级多阶段漏斗架构,以及冷启动、稀疏性、长尾分布等核心挑战,附可直接运行的 Python 实现。

打开 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、Netflix、YouTube,推荐已经驱动了大部分活动

几个有据可查的数字:

平台影响来源
Amazon约 35% 营收来自推荐MGI / McKinsey 2013
Netflix约 75% 的播放由推荐驱动Gomez-Uribe & Hunt, ACM TMIS 2015
YouTube约 70% 的观看时长来自推荐Covington 等, RecSys 2016
SpotifyDiscover Weekly 一年内累计播放破 23 亿Spotify Newsroom

这不是小打小闹的提升。对于头部平台来说,CTR 提升 1%,往往意味着每年八九位数的收益。这也是为什么一支推荐团队动辄需要上百名工程师。

你每天都在哪里遇到它#

不同场景下的推荐策略也不同:

  • 电商——“买了又买”、个性化首页、搭配套装
  • 流媒体视频——按口味聚类的首页行、“因为你看过……”、自动续播下一集
  • 音乐——Discover WeeklyDaily Mix、从一首歌生成的电台
  • 社交信息流——优化参与度的排序、“你可能认识的人”、For You 页面

底层其实只有一小套核心思想在反复使用,下面我一个个拆解来看。


二、三大范式#

任何一个推荐系统,无论是 50 行代码的小脚本,还是 YouTube 的复杂技术栈,都基于以下三种核心思想之一:

  1. 协同过滤——从 谁喜欢什么 中学习
  2. 内容推荐——从 物品的属性 中学习
  3. 混合推荐——结合两者,因为每种方法都有盲区

我们的数据通常长这样:

一个小型用户-物品评分矩阵,绝大多数格子都是空的——这是协同过滤的核心数据结构

大部分位置是未知的。推荐算法的本质任务就是填补这些空白。


协同过滤——“和你口味相似的人喜欢这个”#

协同过滤(Collaborative Filtering,CF)背后的思想非常简单:如果两个用户过去的选择一致,未来很可能也会一致。算法不需要知道物品是什么,它只依赖用户与物品的交互模式。

口味双胞胎的比喻
假设你和朋友对 50 部电影的评分几乎完全一致,她可以算是你的 口味双胞胎。如果她看了一部新电影并打了高分,而你还没看过,合理的推测是:你也会喜欢这部影片,即使你们都说不清具体原因。

两种实现方式
协同过滤有两种常见形式:

  • User-CF——找到和你相似的用户,推荐他们喜欢的物品
  • Item-CF——找到和你已喜欢物品相似的其他物品

在工业界,Item-CF 更受欢迎,原因有两点:

  1. 物品的变化较慢(比如一部电影的近邻基本固定),相似度可以离线预计算并缓存。
  2. 大多数平台的物品数量远少于用户数量,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$ 为基础,根据相似用户的评分偏差加权调整,权重是用户间的相似度。

相似度计算
常用的相似度度量有两种:

  1. 余弦相似度
    $\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}}$
  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]$ 区间。

同一份排序结果,从这三个指标看可能会得出截然不同的结论:

一个 10 项排序列表(其中 4 项相关)上的 Precision@k、Recall@k、NDCG@k 对比

参考实现:

 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 规则选出的探索项。

九、总结#

记住这五点:

  1. 没有万能算法: 选型要看数据、规模和产品目标。实际生产中,混合方法才是主流。
  2. 架构比算法更重要,尤其是在大规模场景下。 召回 → 粗排 → 精排 → 重排的漏斗设计,是深度模型能在 100 毫秒内完成服务的关键。
  3. 评估是多目标的: 准确率、排序质量、多样性、覆盖率以及线上业务指标都很重要,但它们之间往往存在冲突。
  4. 难点问题永远存在: 冷启动、稀疏性、长尾分布、时序漂移、规模——每个团队都在长期解决这些问题。
  5. 多读代码: 直觉不仅藏在公式里,也藏在实现细节中。

本系列后续内容安排如下:

  • 第 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)

值得动手尝试的开源库

本系列

推荐系统 16 篇

  1. 01 推荐系统(一)—— 入门与基础概念 当前
  2. 02 推荐系统(二)—— 协同过滤与矩阵分解
  3. 03 推荐系统(三)—— 深度学习基础模型
  4. 04 推荐系统(四)—— CTR 预估与点击率建模
  5. 05 推荐系统(五)—— Embedding 表示学习
  6. 06 推荐系统(六)—— 序列推荐与会话建模
  7. 07 推荐系统(七)—— 图神经网络与社交推荐
  8. 08 推荐系统(八)—— 知识图谱增强推荐系统
  9. 09 推荐系统(九)—— 多任务学习与多目标优化
  10. 10 推荐系统(十)—— 深度兴趣网络与注意力机制
  11. 11 推荐系统(十一)—— 对比学习与自监督学习
  12. 12 推荐系统(十二)—— 大语言模型与推荐系统
  13. 13 推荐系统(十三)—— 公平性、去偏与可解释性
  14. 14 推荐系统(十四)—— 跨域推荐与冷启动解决方案
  15. 15 推荐系统(十五)—— 实时推荐与在线学习
  16. 16 推荐系统(十六)—— 工业级架构与最佳实践

读有所得?

GitHub 关注我 → 新文周更

GitHub