系列 · 推荐系统 · 第 2 篇

推荐系统(二)—— 协同过滤与矩阵分解

深入讲解协同过滤与矩阵分解:User-CF、Item-CF、相似度度量、潜因子模型、SVD++、ALS、BPR、因子分解机,附直觉解释、推导和可运行 Python。

刚看完《肖申克的救赎》,想找一部类似感觉的电影。用类型筛选,结果会塞满一堆糟糕的监狱题材烂片;而协同过滤则换了个思路:它不关心电影本身,而是问了一个简单的问题——和你看过相同内容的人,还喜欢什么?

这个简单的想法——让群体行为说话——撑起了 Amazon、YouTube、Spotify 以及你每天刷到的推荐流。本文将详细讲解背后的算法演变:从 1990 年代的邻域方法,到拿下 Netflix Prize 的矩阵分解模型。

你将学到:

  • 邻域协同过滤的两条路线(User-CF 和 Item-CF)及其适用场景
  • 用余弦、皮尔逊、调整余弦衡量“相似口味”的方法
  • 稀疏数据上矩阵分解比邻域方法更有效的理由
  • SGD 和 ALS:同一模型的不同训练方式
  • BPR 用于排序,FM 处理边信息,加权 MF 应对隐式反馈
  • 每个算法都有可运行的 Python 实现,文末解答常见问题和坑点

推荐系统(二)—— 协同过滤与矩阵分解 — 章节概览图

· 协同过滤的核心思想#

协同过滤(Collaborative Filtering,CF)基于一个简单的假设:

历史行为相似的用户,需求也相似;被同一群人喜欢的物品,彼此也相似。

这就够了。CF 不需要类型标签、演员列表或用户画像,只需记录谁对什么有过交互。基于这一假设,衍生出两种方法:

  • User-CF:找到与你历史行为相似的用户,推荐他们喜欢的东西。
  • Item-CF:找到与你已喜欢的物品相似的其他物品,推荐给你。

两种视角下的协同过滤:User-CF 连接品味相似的用户,Item-CF 连接受众重合的物品

一个玩具评分矩阵#

5 个用户 × 5 部电影,评分范围 1–5,缺失值用 ? 表示:

用户肖申克阿甘当幸福泰坦尼克教父
Alice55?34
Bob5542?
Carol44355
David21?43
Eve??234

User-CF 的逻辑:Alice 和 Bob 在《肖申克》和《阿甘》上的评分完全一致,说明他们的品味接近。Bob 给《当幸福》打了 4 分,因此把《当幸福》推荐给 Alice。

Item-CF 的逻辑:《肖申克》和《阿甘》在所有用户中的评分分布几乎相同,说明这两部电影相似。喜欢其中一部的人大概率也会喜欢另一部。

优势与局限#

CF 的优势在于简单(不需要特征工程)和惊喜感(能挖掘出非显而易见的关联,如经典的“啤酒与尿布”案例),还能跨品类推荐,例如把播客推荐给爱看小说的用户。

但它的局限也很明显:

  • 冷启动:新用户或新物品没有历史数据;
  • 稀疏性:真实场景中的评分矩阵 99% 以上是空的;
  • 热度偏差:热门物品会挤压长尾物品的曝光机会。

后面要讲的矩阵分解方法,正是为了解决稀疏性问题而设计的。


· 基于用户的协同过滤(User-CF)#

核心步骤#

  1. 选择一种相似度度量,计算每对用户之间的相似度。
  2. 对每个预测目标,找到与目标用户最相似的 $k$ 个邻居,称为邻域
  3. 用邻域用户的评分加权平均,预测缺失评分。
  4. 排序、截取 Top-N,生成推荐。

如何衡量“品味相似”#

设用户 $u$$v$ 都评过分的物品集合为 $I_{uv}$ 。文献中最常用的三种相似度度量如下:

$$ \text{sim}(u, v) = \frac{\sum_{i \in I_{uv}} r_{ui}\, r_{vi}}{\sqrt{\sum_{i \in I_{uv}} r_{ui}^2}\;\sqrt{\sum_{i \in I_{uv}} r_{vi}^2}} $$

它只关注方向。比如用户 A 打分范围是 4–5,用户 B 是 1–2,但打分形状一致,余弦相似度仍会认为他们高度相似。

$$ \text{sim}(u, v) = \frac{\sum_{i \in I_{uv}} (r_{ui} - \bar{r}_u)(r_{vi} - \bar{r}_v)}{\sqrt{\sum_{i \in I_{uv}} (r_{ui} - \bar{r}_u)^2}\;\sqrt{\sum_{i \in I_{uv}} (r_{vi} - \bar{r}_v)^2}} $$

调整余弦也用了类似的去均值方法;当物品集对齐时,它和皮尔逊在数学上等价。

下图直观展示了两者的差异。用户 A 打分慷慨,用户 B 打分严格,但他们的偏好曲线形状完全一致——只是评分水位不同。

同形不同水位的两位用户:余弦给 0.99,皮尔逊给 1.00;余弦把"打分尺度"误认成了"品味"

余弦相似度给出约 0.99,因为两个向量几乎指向同一方向;皮尔逊相关系数给出 1.00——去均值后两条曲线完全重合。在真实系统中,用户打分习惯差异较大,皮尔逊通常是更稳妥的选择。

用皮尔逊相关系数计算玩具矩阵,得到以下热力图。Alice 和 Bob 紧密正相关,David 则是个反例。

玩具评分矩阵中两两用户之间的皮尔逊相关系数

评分预测#

$$ \hat{r}_{ui} = \bar{r}_u + \frac{\sum_{v \in N_k(u)} \text{sim}(u, v) \cdot (r_{vi} - \bar{r}_v)}{\sum_{v \in N_k(u)} |\text{sim}(u, v)|} $$

可以理解为:“从我的平均分出发,根据邻居们对这个物品的评分调整,越像我的邻居权重越大。” 其中 $r_{vi} - \bar{r}_v$ 表示邻居相对于自己基准的评分偏差——这和皮尔逊的去均值思路一脉相承。

可运行的实现#

 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
import numpy as np
from scipy.stats import pearsonr

class UserBasedCF:
    """支持皮尔逊 / 余弦 / 欧氏距离的邻域协同过滤。"""

    def __init__(self, k: int = 20, similarity: str = "pearson"):
        self.k = k
        self.similarity = similarity

    def fit(self, ratings: dict[str, dict[str, float]]) -> "UserBasedCF":
        self.ratings = ratings
        self.user_mean = {u: np.mean(list(r.values())) for u, r in ratings.items()}
        self.users = list(ratings)
        self.user_idx = {u: i for i, u in enumerate(self.users)}
        n = len(self.users)
        self.sim = np.zeros((n, n))
        for i in range(n):
            for j in range(i + 1, n):
                s = self._pair_sim(self.users[i], self.users[j])
                self.sim[i, j] = self.sim[j, i] = s
        return self

    def _pair_sim(self, u: str, v: str) -> float:
        common = set(self.ratings[u]) & set(self.ratings[v])
        if len(common) < 2:
            return 0.0
        ru = np.array([self.ratings[u][i] for i in common])
        rv = np.array([self.ratings[v][i] for i in common])
        if self.similarity == "pearson":
            r, _ = pearsonr(ru, rv)
            return 0.0 if np.isnan(r) else float(r)
        if self.similarity == "cosine":
            return float(ru @ rv / (np.linalg.norm(ru) * np.linalg.norm(rv)))
        # 欧氏距离 → 落在 (0, 1] 的相似度
        return 1.0 / (1.0 + np.linalg.norm(ru - rv))

    def predict(self, user: str, item: str) -> float:
        if user not in self.ratings:
            return 3.0
        idx = self.user_idx[user]
        neighbours = [
            (v, self.sim[idx, self.user_idx[v]], self.ratings[v][item])
            for v in self.ratings
            if v != user and item in self.ratings[v] and self.sim[idx, self.user_idx[v]] > 0
        ]
        if not neighbours:
            return self.user_mean[user]
        neighbours.sort(key=lambda t: t[1], reverse=True)
        neighbours = neighbours[: self.k]
        num = sum(s * (r - self.user_mean[v]) for v, s, r in neighbours)
        den = sum(abs(s) for _, s, _ in neighbours)
        return self.user_mean[user] + num / den if den else self.user_mean[user]

    def recommend(self, user: str, n: int = 10) -> list[tuple[str, float]]:
        seen = set(self.ratings.get(user, {}))
        candidates = {i for r in self.ratings.values() for i in r} - seen
        scored = [(i, self.predict(user, i)) for i in candidates]
        scored.sort(key=lambda t: t[1], reverse=True)
        return scored[:n]

if __name__ == "__main__":
    ratings = {
        "Alice": {"肖申克": 5, "阿甘": 5, "泰坦尼克": 3, "教父": 4},
        "Bob":   {"肖申克": 5, "阿甘": 5, "当幸福": 4, "泰坦尼克": 2},
        "Carol": {"肖申克": 4, "阿甘": 4, "当幸福": 3, "泰坦尼克": 5, "教父": 5},
        "David": {"肖申克": 2, "阿甘": 1, "泰坦尼克": 4, "教父": 3},
        "Eve":   {"当幸福": 2, "泰坦尼克": 3, "教父": 4},
    }
    model = UserBasedCF(k=2).fit(ratings)
    print(f"Alice 对《当幸福》的预测分:{model.predict('Alice', '当幸福'):.2f}")
    for item, score in model.recommend("Alice", n=3):
        print(f"  {item}: {score:.2f}")

User-CF 的局限#

User-CF 直观且易于解释,但离线计算相似度的时间复杂度是 $O(m^2 \cdot n)$ ——用户数平方级增长。稀疏数据下,用户间共评物品太少,相似度难以准确计算;用户冷启动问题更是致命。下一节将问题翻转,探索基于物品的协同过滤。


· 基于物品的协同过滤(Item-CF)#

为什么工业界偏爱 Item-CF#

Amazon 在 2003 年发表的论文让 Item-CF 成为推荐系统的默认选择。原因很简单,但非常关键:

  • 物品数量通常少于用户数量,所以相似度矩阵更小、计算成本更低。
  • 物品相似度稳定。一部电影的受众特征不会一夜之间改变,但用户的心情会变。我可以提前计算好物品相似度并缓存起来。
  • 推荐结果可解释性强。“因为你喜欢 A,所以推荐类似的 B”,这种话用户一听就懂。

调整余弦:首选的相似度度量#

$$ \text{sim}(i, j) = \frac{\sum_{u \in U_{ij}} (r_{ui} - \bar{r}_u)(r_{uj} - \bar{r}_u)}{\sqrt{\sum_{u \in U_{ij}} (r_{ui} - \bar{r}_u)^2}\;\sqrt{\sum_{u \in U_{ij}} (r_{uj} - \bar{r}_u)^2}} $$

一个关键点是减去的是用户均值,而不是物品均值。这一步去掉了“谁倾向于打高分”的噪声,剩下的就是两件物品是否在同一个人身上引发一致反应。

预测公式#

$$ \hat{r}_{ui} = \bar{r}_u + \frac{\sum_{j \in N_k(i)} \text{sim}(i, j) \cdot (r_{uj} - \bar{r}_u)}{\sum_{j \in N_k(i)} |\text{sim}(i, j)|} $$

这里用的是物品 $i$$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
50
51
52
import numpy as np
from collections import defaultdict

class ItemBasedCF:
    """基于物品的协同过滤,使用调整余弦相似度。"""

    def __init__(self, k: int = 20):
        self.k = k

    def fit(self, ratings: dict[str, dict[str, float]]) -> "ItemBasedCF":
        self.ratings = ratings
        self.user_mean = {u: np.mean(list(r.values())) for u, r in ratings.items()}
        self.item_users: dict[str, dict[str, float]] = defaultdict(dict)
        for u, items in ratings.items():
            for i, r in items.items():
                self.item_users[i][u] = r
        self.sim: dict[str, dict[str, float]] = defaultdict(dict)
        items = list(self.item_users)
        for a in range(len(items)):
            for b in range(a + 1, len(items)):
                s = self._adjusted_cosine(items[a], items[b])
                if s > 0:
                    self.sim[items[a]][items[b]] = s
                    self.sim[items[b]][items[a]] = s
        return self

    def _adjusted_cosine(self, i: str, j: str) -> float:
        common = set(self.item_users[i]) & set(self.item_users[j])
        if not common:
            return 0.0
        num = den_i = den_j = 0.0
        for u in common:
            di = self.item_users[i][u] - self.user_mean[u]
            dj = self.item_users[j][u] - self.user_mean[u]
            num += di * dj
            den_i += di * di
            den_j += dj * dj
        return num / np.sqrt(den_i * den_j) if den_i and den_j else 0.0

    def predict(self, user: str, item: str) -> float:
        if user not in self.ratings or item not in self.sim:
            return self.user_mean.get(user, 3.0)
        seen = self.ratings[user]
        rated_neighbours = [(j, s) for j, s in self.sim[item].items() if j in seen]
        if not rated_neighbours:
            return self.user_mean[user]
        rated_neighbours.sort(key=lambda t: t[1], reverse=True)
        rated_neighbours = rated_neighbours[: self.k]
        mean = self.user_mean[user]
        num = sum(s * (seen[j] - mean) for j, s in rated_neighbours)
        den = sum(abs(s) for _, s in rated_neighbours)
        return mean + num / den if den else mean

Item-CF 是推荐系统的核心方法,但在百万级物品库中,预计算的相似度矩阵会占用大量内存。这就促使我寻找一种全新的思路:不再直接比较行和列,而是对矩阵进行压缩


推荐系统(二)—— 协同过滤与矩阵分解 — 章节小结图

· 矩阵分解#

从邻域到隐向量#

评分矩阵 $R \in \mathbb{R}^{m \times n}$ 又大又稀疏,但信号是低秩的:少数几个潜在的“品味维度”就能解释大部分方差。矩阵分解直接捕捉了这个直觉。

$$ R \approx P \cdot Q^{\!T} $$ $$ \hat{r}_{ui} = \mathbf{p}_u \cdot \mathbf{q}_i = \sum_{f=1}^{k} p_{uf}\, q_{if} $$

稀疏评分矩阵 R 被两个瘦矩阵 P 和 Qᵀ 的乘积近似;问号是我们要填充的缺失值

为什么比邻域法强得多#

两个原因:

  1. 存储从 $O(mn)$ 降到 $O((m+n)k)$ 百万用户 × 百万物品 × 8 字节 = 8 TB 密集矩阵;分解到 $k=50$ 后大约只剩 0.8 GB。
  2. 缺失值成为一等公民: 邻域方法需要共同评分才能比较两行;矩阵分解直接从学到的隐向量预测每个单元格。

几何视角#

$k$ 个轴看作模型自动发现的“品味维度”——比如 硬核剧情 vs. 轻喜剧现代爆米花 vs. 文艺经典。没人手工标注这些维度,模型从数据中自己学出来。

用户(●)和物品(▲)嵌入二维隐空间;Alice 和《肖申克》落在同一象限,内积大,推荐成立

用户向量 $\mathbf{p}_u$ 表示“你对每个维度有多在意”,物品向量 $\mathbf{q}_i$ 表示“它在每个维度上有多强”。二者内积就是匹配分。

目标函数#

$$ \mathcal{L} = \sum_{(u,i) \in \mathcal{R}} (r_{ui} - \mathbf{p}_u \cdot \mathbf{q}_i)^2 + \lambda \big(\|\mathbf{p}_u\|^2 + \|\mathbf{q}_i\|^2\big) $$

优化方法主要有两种:SGD 和 ALS。

优化方法一:随机梯度下降(SGD)#

$$ \mathbf{p}_u \leftarrow \mathbf{p}_u + \alpha\, (e_{ui}\, \mathbf{q}_i - \lambda\, \mathbf{p}_u) $$ $$ \mathbf{q}_i \leftarrow \mathbf{q}_i + \alpha\, (e_{ui}\, \mathbf{p}_u - \lambda\, \mathbf{q}_i) $$

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
import numpy as np
import random

class MatrixFactorization:
    """基础矩阵分解,用 SGD 训练。"""

    def __init__(self, k=50, lr=0.01, reg=0.01, epochs=20, seed=0):
        self.k, self.lr, self.reg, self.epochs = k, lr, reg, epochs
        self.rng = np.random.default_rng(seed)

    def fit(self, ratings: dict[str, dict[str, float]]):
        users = list(ratings)
        items = list({i for r in ratings.values() for i in r})
        self.u_idx = {u: i for i, u in enumerate(users)}
        self.i_idx = {i: j for j, i in enumerate(items)}
        self.items = items
        self.P = self.rng.normal(0, 0.1, (len(users), self.k))
        self.Q = self.rng.normal(0, 0.1, (len(items), self.k))

        samples = [(self.u_idx[u], self.i_idx[i], r)
                   for u, row in ratings.items() for i, r in row.items()]

        for epoch in range(1, self.epochs + 1):
            random.shuffle(samples)
            sse = 0.0
            for u, i, r in samples:
                err = r - self.P[u] @ self.Q[i]
                p, q = self.P[u].copy(), self.Q[i].copy()
                self.P[u] += self.lr * (err * q - self.reg * p)
                self.Q[i] += self.lr * (err * p - self.reg * q)
                sse += err * err
            if epoch % 5 == 0:
                print(f"epoch {epoch}: RMSE={np.sqrt(sse / len(samples)):.4f}")
        return self

    def predict(self, user: str, item: str) -> float:
        if user not in self.u_idx or item not in self.i_idx:
            return 0.0
        return float(self.P[self.u_idx[user]] @ self.Q[self.i_idx[item]])

优化方法二:交替最小二乘(ALS)#

ALS 利用了一个漂亮的特点:固定 $P$$Q$ 中的一个,损失退化成带正则的线性回归,有解析解。 我们交替更新。

$$ \mathbf{p}_u = (Q_u^{\!T} Q_u + \lambda I)^{-1} Q_u^{\!T} \mathbf{r}_u $$ $$ \mathbf{q}_i = (P_i^{\!T} P_i + \lambda I)^{-1} P_i^{\!T} \mathbf{r}_i $$

每个半步是一次小型线性求解,更重要的是不同用户之间彼此独立——天生适合 Spark MLlib 这种分布式引擎。

SGD 与 ALS 实战对比#

下图是同一个 80×60 合成评分矩阵上两种优化器的训练曲线。ALS 头几步就插到地板,因为每步是闭式解;SGD 要慢慢蹭到差不多的位置。

40 个 epoch 的训练 RMSE:ALS 几个迭代就收敛,SGD 慢一些但最终位置接近

但“ALS 更快”这句话需要加注脚。ALS 内存开销更大——每次更新都要装一个 $k \times k$ 的 Gram 矩阵;SGD 一条样本一条样本地走,内存友好。粗略经验:

  • 小数据、需要在线更新:选 SGD。
  • 大批量、有分布式集群:选 ALS。
  • 两者通吃:先用 ALS 离线初始化,再用 SGD 在线增量更新。

· 偏置项 —— 提升准确率最划算的方法#

简单的矩阵分解假设评分完全由用户和物品的潜在交互决定。但实际情况要复杂得多:

  • 有些用户打分特别宽松,动不动就给 4–5 分;另一些用户则很严格,基本只打 1–2 分。
  • 有些物品天生受欢迎,几乎人人都喜欢;另一些物品则普遍不讨喜。
  • 整个平台还有一个全局平均分(比如在 1–5 分制下,通常在 3.5 左右)。
$$ \hat{r}_{ui} = \mu + b_u + b_i + \mathbf{p}_u \cdot \mathbf{q}_i $$

其中,$\mu$ 是全局均值,$b_u$ 是用户偏置,$b_i$ 是物品偏置。因子向量只需要专注于拟合真正反映用户品味的交互部分

$$ b_u^{(0)} = \frac{1}{|I_u|}\sum_{i \in I_u}(r_{ui} - \mu), \qquad b_i^{(0)} = \frac{1}{|U_i|}\sum_{u \in U_i}(r_{ui} - \mu - b_u^{(0)}) $$

SGD 更新时只需多加两行代码:

1
2
self.b_u[u] += self.lr * (err - self.reg_b * self.b_u[u])
self.b_i[i] += self.lr * (err - self.reg_b * self.b_i[i])

这就是 Netflix Prize 时代所谓的 “SVD”——它并不是教科书上的奇异值分解,而是借了个熟悉的名字,实际指的是带偏置的矩阵分解


· SVD++ —— 融入隐式反馈#

用户的行为大多是隐式的:点击、滑动、停留时长、购买但不评分。SVD++ 利用这些“无声信号”构建更丰富的用户向量。

$$ \hat{r}_{ui} = \mu + b_u + b_i + \mathbf{q}_i^{\!T} \left(\mathbf{p}_u + |I_u|^{-1/2}\sum_{j \in I_u} \mathbf{y}_j\right) $$

括号里的意思是:“从显式因子出发,加上用户交互过的所有物品隐式嵌入的平均值。” $|I_u|^{-1/2}$ 防止活跃用户主导模型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class SVDpp:
    """带隐式反馈的 SVD++ 模型。"""

    def __init__(self, k=50, lr=0.005, reg=0.02, epochs=20):
        self.k, self.lr, self.reg, self.epochs = k, lr, reg, epochs

    def fit(self, ratings, implicit=None):
        # 索引用户和物品,初始化 P, Q, Y, 偏置, mu
        # 每一步:
        #   N = implicit_items[u]
        #   y_sum = Y[N].sum(0) / sqrt(len(N))
        #   pred  = mu + b_u + b_i + (P[u] + y_sum) @ Q[i]
        #   梯度更新 P[u], Q[i], Y[N], b_u, b_i
        ...

在 Netflix Prize 中,从带偏置的 MF 升级到 SVD++,RMSE 再降约 2%。绝对值虽小,但在排行榜上却是决定性的优势。


· BPR —— 当排序比评分更重要#

如果我的核心指标是“用户是否点击了置顶的那条内容”,那么平方误差就不是一个合适的损失函数。RMSE 会把“预测值 4.6 对应真实值 5”的误差,和“把《当幸福》排在《肖申克》前面”的错误等同对待——但后者才是真正的痛点。

$$ \mathcal{L} = \sum_{(u, i, j) \in D_S} \ln \sigma(\hat{r}_{ui} - \hat{r}_{uj}) - \lambda \|\Theta\|^2 $$

这里 $\sigma$ 是 sigmoid 函数。BPR 只关注物品对的相对顺序,因此天然规避了“没有显式负样本”的问题——而这正是隐式反馈数据的典型特征。

SGD 更新直接由梯度推导得出。对于一个三元组 $(u, i, j)$ ,令 $x_{uij} = \sigma(\hat{r}_{uj} - \hat{r}_{ui})$ ,更新规则如下:

1
2
3
P[u] += lr * (x_uij * (Q[i] - Q[j]) - reg * P[u])
Q[i] += lr * ( x_uij *  P[u]        - reg * Q[i])
Q[j] += lr * (-x_uij *  P[u]        - reg * Q[j])

实际效果上,BPR 在隐式数据上的 AUC 比朴素 MF 高出 5–10%。当手头只有点击数据而没有评分数据时,BPR 是最自然的选择。


· 因子分解机(FM)—— 支持边特征的场景#

$$ \hat{y}(\mathbf{x}) = w_0 + \sum_{i=1}^{n} w_i x_i + \sum_{i=1}^{n}\sum_{j=i+1}^{n} \langle \mathbf{v}_i, \mathbf{v}_j \rangle\, x_i\, x_j $$ $$ \sum_{i<j} \langle \mathbf{v}_i, \mathbf{v}_j \rangle x_i x_j = \tfrac{1}{2}\sum_{f=1}^{k}\!\left[\Big(\sum_i v_{i,f} x_i\Big)^2 - \sum_i v_{i,f}^2 x_i^2\right] $$

FM 的计算复杂度对特征数和因子数都是线性的,因此能够轻松应对百万维 one-hot 编码的工业级 CTR 数据集。

如果把 $\mathbf{x}$ 设置为 [用户 one-hot ; 物品 one-hot],FM 就会退化为矩阵分解(MF)。换句话说,MF 是 FM 的一个特例,只用 ID 特征。


· 隐式反馈的三种处理方法#

真实推荐系统很少有评分数据,更多的是用户行为数据:浏览、点击、收听、购买。针对隐式反馈,主要有三种处理方式:

  • 加权矩阵分解(Weighted MF):将每次观测到的交互视为目标值为 1 的正样本,并赋予置信度 $c_{ui} = 1 + \alpha \log(1 + \text{count})$ ,未观测到的交互则视为目标值为 0 的低置信度样本。损失函数变为 $\sum c_{ui} (1 - \hat{r}_{ui})^2 + \lambda \dots$
  • 负采样(Negative Sampling):为每个正样本随机选取若干未交互的物品作为负样本,按目标值为 0 进行训练。
  • BPR:直接基于排序对进行优化,完全绕开“如何定义负样本”的问题。
1
2
3
def confidence(count: float, alpha: float = 10.0) -> float:
    """Hu/Koren/Volinsky 提出的隐式反馈置信度计算公式。"""
    return 1.0 + alpha * np.log1p(count)

· 实战 Q&A#

User-CF 还是 Item-CF? 优先选 Item-CF。物品数量少且更稳定,相似度矩阵小、生命周期长。只有在用户稀少但活跃的场景下才考虑 User-CF。

潜因子维度 $k$ 怎么选? 用户量 10 万以下,从 20–50 开始;百万级选 50–100;千万级以上用 100–200。然后用交叉验证调参,找到验证集 RMSE 不再下降的最小 $k$ ,这就是最佳值。

冷启动怎么处理? 新用户:用注册特征(年龄、地域)初始化隐向量,或者直接推荐热门物品兜底。新物品:用内容特征(类别、标签)初始化隐向量,或者基于物品侧特征做 Item-CF。混合策略一般是“先靠内容顶上,积累到 10–20 次交互后再切换到 CF”。

SGD 还是 ALS? SGD 内存占用低、支持在线更新;ALS 收敛快、适合分布式计算(如 Spark 集群)。很多团队晚上用 ALS 离线训练初始化,白天用 SGD 增量更新。

为什么 BPR 在隐式数据上比 RMSE 训练的 MF 效果好? 因为 RMSE 优化的是错误目标。下游指标(CTR、NDCG)关注的是排序,而不是绝对评分;BPR 直接优化排序。

如何应对稀疏性? 矩阵分解本身、加正则化、引入隐式反馈信号、通过 FM 引入边特征。没有一招制胜的办法,必须多管齐下。

偏置项为什么这么重要? 它剥离了“结构性效应”(比如某个用户打分普遍高、某物品普遍受欢迎),让隐向量专注于建模真实的用户偏好。加入偏置项后,RMSE 通常能下降 10–20%。

初始化怎么做? 因子用 $\mathcal{N}(0, 0.1)$ 初始化,偏置项设为 0(第一轮训练会自动调整)。避免全零初始化——梯度会卡住。

预测值越界怎么办? 直接截断到 $[r_{\min}, r_{\max}]$ 。Sigmoid 缩放数学上更优雅,但在离线评估中很少优于简单截断。

如何实现实时推荐? 缓存每个用户的隐向量;对头部用户预计算 Top-N;仅对长尾用户实时计算。需要持续学习时,用 SGD 在流式数据上更新,并对嵌入表进行版本管理。

如何防止过拟合? 使用 L2 正则,$\lambda \in [0.01, 0.1]$ ;验证集早停;如果还不行,先降低 $k$ ,最后再考虑其他复杂方法。


总结#

协同过滤是现代推荐系统的基础,整个发展脉络其实很清晰:

  • 邻域方法把“和你相似的人也喜欢……”转化为相似度加权平均。Item-CF 是工业界的默认选择。
  • 矩阵分解将稀疏的评分矩阵压缩为低维的用户和物品嵌入,通过内积预测评分。
  • 偏置项去除结构性噪声;SVD++ 引入隐式反馈进一步提升效果。
  • ALSSGD 是两种优化思路——闭式解与流式更新、并行与在线的对比。
  • BPR 用排序损失替代 RMSE;FM 将矩阵分解推广到任意稀疏特征。

这些思想换了个形式,依然贯穿在深度推荐模型中。DeepFM 的嵌入层本质就是矩阵分解;双塔召回是带神经网络编码器的矩阵分解;序列模型依旧依赖类似 SVD++ 方法学到的物品嵌入。掌握这一章内容,后续的系列你会觉得不过是同一主题的不同变奏。

参考文献#

  • Koren, Y., Bell, R., & Volinsky, C. (2009). Matrix factorization techniques for recommender systems. Computer, 42(8), 30–37. IEEE
  • Rendle, S., Freudenthaler, C., Gantner, Z., & Schmidt-Thieme, L. (2009). BPR: Bayesian personalized ranking from implicit feedback. UAI. arXiv:1205.2618
  • Rendle, S. (2010). Factorization machines. ICDM. DOI
  • Koren, Y. (2008). Factorization meets the neighborhood: a multifaceted collaborative filtering model. KDD. DOI
  • Hu, Y., Koren, Y., & Volinsky, C. (2008). Collaborative filtering for implicit feedback datasets. ICDM. DOI

本文是推荐系统系列的第 2 篇,共 16 篇。

本系列

推荐系统 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