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

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

打开 Netflix,首页那一行剧集刚好就是你想看的。刷抖音,下一条视频总能戳中你;周一早上打开 Spotify,“Discover Weekly” 里三十首陌生歌一半都让你想加入收藏。

这些体验背后没有魔法,只有一类悄悄运行在几乎所有消费产品里的机器学习系统——推荐系统(Recommendation System)

平台为什么愿意在这里堆人堆钱?看几个被反复引用的数字就明白了:麦肯锡报告称 Amazon 35% 的销售额来自推荐引擎;YouTube 工程师在 RecSys 2016 论文里给出约 70% 的观看时长由推荐驱动;Netflix 产品负责人公开表示,平台上约 75% 的播放来自推荐而非主动搜索。

这是这个 16 篇系列的第一篇,目标是给你一份完整、诚实的推荐系统心智模型——读完之后,再看任何一篇推荐论文或工业架构都不应该再陌生。

本文你会学到

  • 三大基础范式:协同过滤、内容推荐、混合推荐,分别在什么场景下胜出
  • 矩阵分解——拿下 Netflix Prize 的主力,含完整数学推导与代码
  • 真正在生产里有用的评估指标:Precision、Recall、MAP、NDCG,以及多样性、覆盖率
  • 工业界标配的 多阶段漏斗 架构:在 100 ms 内从千万级物料挑出 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. 混合推荐——把两者揉在一起,因为它们各有盲区

我们手上的数据长这样:

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

绝大多数位置都是未知。所有推荐算法本质上做的同一件事:填补空白

2.1 协同过滤——“和你像的人喜欢这个”

协同过滤(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$ 的评分

$R$ 是 极稀疏的——真实系统里被观测到的格子通常不到 0.1%。我们的目标就是把缺失的预测出来。User-CF 的预测公式:

$$ \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 分。余弦会判定他们不像;皮尔逊先把每人的均值减掉,识别出他们的 相对偏好 其实可能完全一致,会把他们认成口味双胞胎。

优劣势。

优势劣势
与领域无关,无需物品特征冷启动:新用户、新物品都束手无策
容易出现 “意料之外” 的好推荐数据稀疏时表现急剧下降
数据越多自动越好流行度偏差,热门物品被进一步放大

2.2 内容推荐——“它和你之前喜欢的很像”

如果说 CF 问的是 “还有谁喜欢这个?",内容推荐问的就是 “这东西本身长什么样,你之前是不是喜欢过类似的?”

它从你交互过的物品的 属性 出发,构建你的偏好画像,再用画像对未见过的物品打分。它不在乎别的用户怎么看。

怎么表示物品。 取决于物料类型:

  • 文本(新闻、文章):TF-IDF、句向量、主题模型
  • 图像(商品、艺术品):CNN 嵌入、颜色直方图、视觉属性
  • 音频(音乐):MFCC、音频嵌入、元数据(节奏、调式、流派)
  • 结构化(电影、商品):one-hot 类目、价格、知识图谱特征

形式化。 设 $\mathbf{x}_i \in \mathbb{R}^d$ 是物品 $i$ 的特征向量,$\mathbf{w}_u \in \mathbb{R}^d$ 是用户 $u$ 学到的偏好权重。则:

$$ \hat{r}_{ui} = \mathbf{w}_u^\top \mathbf{x}_i + b_u $$

物品是一串特征分数,用户是每个特征的权重,相乘相加即可。$\mathbf{w}_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 正则化,对大权重收税,防止仅凭少量评分就学到过于自信的画像。

优劣势。

优势劣势
新物品上架即可用,只要有特征依赖好的特征,需要工程投入
推荐理由天然可解释容易过度专业化,反复推同一类物品
单个用户的数据就够新用户依然冷启动(还没有画像)

2.3 混合推荐——取长补短

纯 CF 和纯内容推荐各有可预见的失败模式。混合方法把两者拼起来,让其中一种掉链子时另一种顶上。

三种范式在冷启动处理、意外发现、可解释性、稀疏数据容忍度、对特征工程的依赖等维度上的对比——所有轴都是"越高越好”

常见的五种拼法:

1. 加权混合。 两个分数线性组合:

$$ \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. 特征融合。 把内容特征当成 CF 模型的额外输入,统一一个模型搞定。

4. 级联混合。 先用便宜的方法筛候选,再用精细的方法重排。这是工业界的主导模式,也是后面第五节漏斗架构的雏形。

5. 元层次混合。 一个模型的输出作为另一个模型的输入。现代深度推荐里,“先用内容学物品向量,再在向量上做 CF” 都属于这一类。

真实例子。 Netflix 生产环境的推荐器是个高度复杂的混合体:观看历史上的矩阵分解 + 融合文本/图像/音频的深度网络 + 上下文信号(时间、设备)+ 业务约束(新鲜度、多样性)+ 几十个模型的最终集成。今天几乎没有头部平台还在跑单一算法。


三、矩阵分解:协同过滤的主力

User-CF 和 Item-CF 这类邻域方法直观,但真正拿下 Netflix Prize、并奠定后续半部推荐史(包括今天的双塔深度模型)的,是 矩阵分解(Matrix Factorization,MF)

3.1 几何直觉

MF 假设用户偏好和物品特性都可以被一组数量不多的 隐因子 概括——这些维度由数据涌现,不是手工标注。

用户和物品被嵌入到同一个二维隐空间;点积大表示物品对该用户匹配度高

图说得最清楚:每个用户和每个物品都活在一个 $k$ 维空间里(实践中 $k = 20$–$200$)。对于电影,那些轴可能最终长得像 “大片 ↔ 文艺”、“轻松 ↔ 沉重”,但你并不告诉模型这件事——它自己学出来。

预测分数就是 点积

$$ \hat{r}_{ui} = \mathbf{p}_u^\top \mathbf{q}_i $$

用户向量和物品向量方向越接近,点积越大,预测分越高。

3.2 优化目标

我们要找 $R \approx P Q^\top$,其中 $P \in \mathbb{R}^{m \times k}$ 装着用户向量,$Q \in \mathbb{R}^{n \times k}$ 装着物品向量。在已观测评分上最小化平方误差,加 L2 正则:

$$ \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$ 表示 “这个物品被普遍喜欢(或讨厌)"。把这些一阶效应剥离出去之后,隐因子只需建模真正的 交互,这才是我们想要它学的东西。

3.3 两种优化算法

随机梯度下降(SGD)。 对每条观测评分计算误差 $e_{ui} = r_{ui} - \hat{r}_{ui}$,然后:

$$ \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 的原因。

3.4 实现

下面是一个最小但完整的、带偏置、用 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
80
81
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 之间。

3.5 隐式反馈

线上系统极少有 1–5 的显式评分,多数时候只有 隐式反馈:点击、观看时长、停留、购买。麻烦在于,没有交互 不等于 不喜欢——可能根本没看到过。

经典解法是 加权矩阵分解(Hu, Koren & Volinsky, 2008):

$$ \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}$ 增长的置信度。我们对 所有 用户-物品对优化,但已交互对置信度高(“基本可以确定是正样本”),未交互对置信度低(“大概率是负样本,但不一定”)。


四、评估:度量真正重要的东西

把模型搭出来只是一半,知道它好不好是另一半——而且后者比一般人想象的难。

4.1 一个准确率数字会骗人

模型预测评分的准确率有 95%,是好吗?也许,但要小心:

  • 用户只给自己喜欢的东西打分。对所有物品都预测高分也能 “准”,但毫无价值。
  • 测试集若被热门物品主导,你只测了简单的一半。
  • 推荐缺乏多样性,用户当下满意,下个月流失。

诚实的评估必须 多个指标 同时看,每个指标度量不同的东西。

4.2 分类类指标

对每个用户 $u$,记 $R_u$ 是我们推荐的物品集(Top-$K$),$T_u$ 是该用户真正喜欢的物品集。

$$ \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 位的命中被同等看待。

4.3 排序指标——位置很重要

真实界面里,第 1 位的价值远高于第 10 位。两种主流指标。

Mean Average Precision(MAP)。 对单个用户:

$$ \text{AP@}K = \frac{1}{|T_u|} \sum_{k=1}^{K} \text{Precision@}k \cdot \text{rel}(k) $$

其中 $\text{rel}(k) = 1$ 当 rank $k$ 的物品相关。MAP 是所有用户上的均值。它奖励那些把命中往前排的系统。

Normalized Discounted Cumulative Gain(NDCG)。 当相关性是分级的(不仅 0/1),它是黄金指标:

$$ \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
45
46
47
48
49
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}")

4.4 准确率之外:覆盖率、多样性、意外性

一个永远只推全站 Top-10 的系统可以拿到很好的 Precision,同时毁掉你的产品。三个与准确率正交的指标用来约束它:

  • 覆盖率——系统 会不会 推荐到的物品占全库的比例。如果只覆盖 1%,剩下 99% 等同于不存在。
  • 列表内多样性——单条推荐列表内物品两两之间的平均差异。十部高度雷同的续集不是好列表。
  • 意外性(Serendipity)——既相关又出乎意料。“它怎么知道我会喜欢这个?” 的那种瞬间。

这些目标常常与准确率 冲突。如何取舍是产品决定,不是算法决定。

4.5 线上指标——决胜在这里

离线指标指导研发,线上指标决定能不能上线。生产里真正重要的:

  • CTR——点击 / 曝光
  • 转化率——购买 / 点击
  • 参与度——观看时长、会话长度、回访率
  • 业务——人均收入、LTV、留存

工具是 A/B 测试:用户随机分桶,新旧系统并行跑,仅在统计意义上确认提升后才发版。


五、系统架构:从千万到 Top-10

真实推荐系统的工作量令人生畏:在 100 ms 内、每秒数十万次地,从 $10^7$ 量级的物料里挑出 10 件。没有任何单一模型能做到。大家最终殊途同归到 多阶段漏斗:候选不断收窄,模型不断变贵。

推荐漏斗:物料 → 召回 → 粗排 → 精排 → 重排,标注了每一阶段的物品数量与延迟预算

第一阶段——召回(候选生成)

目标:把 $10^7$ 物料压到几千个,远小于 10 ms。

多个便宜的 召回通道 并行运行——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
34
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
22
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 ms 的延迟” 能并存的原因——便宜的模型撒大网,贵的模型在小集合上精雕细琢。


六、五个长期开放的问题

研究做了几十年,每一支推荐团队仍然要和同样的五个问题搏斗,没有谁有干净的解法。

冷启动

新用户没有历史,新物品没有交互。两者在 CF 眼里都是隐形的。

新用户——通过简单的口味选择来 onboarding,回退到人口属性,依靠热门 / 趋势先撑过去,等到积累出一点信号。

新物品——使用内容特征(用文本/图像/音频把物品嵌入到与相似物品邻近的位置),并使用 探索:用置信上界给新物品额外加分

$$ \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
23
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 ms p99 内承担每秒数百万 QPS。这是个残酷的工程约束,对算法选型的影响不亚于精度。

标准武器库:激进缓存、向量检索库(FAISS、ScaNN、HNSW)、模型量化与蒸馏、按用户分片,以及严格的离线/在线分工——前一晚能算的全部算掉,请求时只做与请求强相关的部分。


七、从零实现两种 CF

读代码的收益常常超过读公式。下面给出两份完整可跑的参考实现:User-CF 和 Item-CF。

7.1 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
56
57
58
59
60
61
62
63
64
65
66
67
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]

7.2 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 ms 内服务的根本原因。
  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)

值得动手玩的开源库

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub