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

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

刚看完《肖申克的救赎》,想再找一部"那种感觉"的电影。按类型筛选会把你淹死在一堆烂片监狱题材里。协同过滤换了个思路:它根本不看电影本身,只问一个问题——和你看过相似东西的人,还都喜欢什么?

这一个想法,撑起了 Amazon、YouTube、Spotify 和你今天刷到的每一条 Feed。本文要讲清楚背后的算法谱系:从 1990 年代的邻域方法,一路到拿下 Netflix Prize 的矩阵分解。

你将学到:

  • 邻域协同过滤的两条路线(User-CF 与 Item-CF)以及各自的适用场景
  • 余弦、皮尔逊、调整余弦——三种相似度度量的差别究竟在哪
  • 为什么稀疏数据下矩阵分解比邻域方法更稳
  • SGD 与 ALS:同一个模型,截然不同的训练故事
  • BPR 解决排序、FM 处理边信息、加权 MF 处理隐式反馈
  • 每个算法都有可跑的 Python 实现,文末附实战常见坑

1 · 协同过滤的核心思想

协同过滤(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% 以上都是缺失。
  • 流行度偏差:热门挤压长尾。

后面要讲的矩阵分解,正面解决了第二个痛点。


2 · 基于用户的协同过滤(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 是逆向的那一个。

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

评分预测

拿到目标用户 $u$ 的 $k$ 个最近邻 $N_k(u)$ 之后:

$$ \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
74
75
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)$——用户数平方级;稀疏数据下两个用户共评物品太少,相似度算不准;用户冷启动是最致命的一刀。下一节把问题翻过来。


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

为什么工业界默认 Item-CF

Amazon 2003 年那篇论文奠定了 Item-CF 的工业地位,原因朴素但决定性:

  • 物品数往往少于用户数,相似度矩阵更小、更便宜。
  • 物品相似度稳定。一部电影的受众画像不会一夜变化;用户的心情会。可以提前算好物品相似度,长期缓存。
  • 可解释性更强。“因为你看了 A,所以推 B”,这句话用户真的看得懂。

调整余弦:首选度量

对被同一批用户 $U_{ij}$ 共同评分的物品 $i$ 和 $j$:

$$ \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
53
import numpy as np
from collections import defaultdict


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

    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 是骨干,但在百万级物品库下,预算的相似度矩阵也会变成内存怪物。这就引出了一种根本不同的思路:不再两两比较,而是把矩阵压缩


4 · 矩阵分解

从邻域到潜因子

评分矩阵 $R \in \mathbb{R}^{m \times n}$ 又大又空。但其中的信号是低秩的:少数几个潜在的"品味维度"就能解释绝大部分方差。矩阵分解把这个直觉显式写出来:

$$ R \approx P \cdot Q^{\!T} $$

其中 $P \in \mathbb{R}^{m \times k}$ 一行一个用户,$Q \in \mathbb{R}^{n \times k}$ 一行一个物品。维度 $k$(通常 20–200)就是潜因子的数量。预测评分就是简单的内积:

$$ \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)$。 100 万用户 × 100 万物品 × 8 字节 = 8 TB 稠密矩阵;分解到 $k=50$ 后大约只剩 0.8 GB。
  2. 缺失值变成一等公民。 邻域方法必须依赖共同评分才能算相似度;分解模型可以从学到的因子里直接预测任意一格。

几何解读

把 $k$ 个轴想成被模型自动发现的"品味维度"——硬核剧情 vs. 轻喜剧现代爆米花 vs. 文艺经典……没有人手工标注,模型从数据中自己学出来。

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

用户向量 $\mathbf{p}_u$ 记录"你在每个轴上有多在意",物品向量 $\mathbf{q}_i$ 记录"它在每个轴上有多少含量",二者内积就是匹配分。

目标函数

我们让 $P$ 和 $Q$ 在已观测评分上最小化平方误差,再加一项 L2 正则压住因子的范数:

$$ \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)

对每条已观测的评分 $(u, i)$,定义误差 $e_{ui} = r_{ui} - \hat{r}_{ui}$,然后沿负梯度方向小步走:

$$ \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
40
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$ 中的任一个,损失就退化成带正则的线性回归,有解析解。 那就交替来。

固定 $Q$,最优用户向量是

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

其中 $Q_u$ 把用户 $u$ 评过的所有物品向量堆成矩阵。对称地,固定 $P$:

$$ \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 在线增量更新。

5 · 偏置项 —— 性价比最高的一招

朴素分解假设评分完全来自用户–物品的潜在交互。但现实噪音很多:

  • 有人逢片必给 4–5 分(慷慨),有人天生只打 1–2(严苛)。
  • 有些物品被普世追捧,另一些被普世吐槽。
  • 整个平台还有一个全局水位(1–5 分制下通常约 3.5)。

加偏置项几乎不花什么力气,却经常把 RMSE 直接砍掉 10–20%:

$$ \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"其实就是它——不是教科书里的奇异值分解,而是带偏置的矩阵分解借了一个熟脸名字。


6 · SVD++ —— 把隐式反馈也用起来

用户的大部分行为是隐式的:点击、滚动、停留时长、买了但不评分。SVD++ 把这些"沉默的信号"也吸进用户向量里。

设 $I_u$ 是用户 $u$ 以任何方式接触过的物品集合(评分或仅交互)。给每个这样的物品再学一组向量 $\mathbf{y}_j$。用户表示变为:

$$ \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++ 大约再砍 ~2% 的 RMSE。绝对值不大,在排行榜上就是决定性的差距。


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

如果你真正在意的指标是"用户点了置顶那条吗",那么平方误差是个错误的损失函数。RMSE 把"4.6 预测真值 5"罚得和"把《当幸福》排到《肖申克》前面"一样狠——后者才是真的伤。

贝叶斯个性化排序(BPR)换了一个目标:构造三元组 $(u, i, j)$,其中用户 $u$ 与物品 $i$ 有交互、与 $j$ 没有,然后最大化"模型给 $i$ 的分高于 $j$“的概率:

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

$\sigma$ 是 sigmoid。BPR 只看 pair 的先后顺序,因此对"没有显式负样本"这件事天然免疫——这恰好就是隐式反馈数据的常态。

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%。手里只有点击不是评分时,它就是默认选择。


8 · 因子分解机(FM)—— 把边特征也吃进去

纯 MF 只认识用户 ID 和物品 ID。但你手里通常远不止这些:用户年龄、物品类目、当前时段……Steffen Rendle 在 2010 年提出的因子分解机把 MF 推广到任意稀疏特征向量 $\mathbf{x}$:

$$ \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 $$

每个特征都有一组潜向量 $\mathbf{v}_i$,两两交互通过内积打分。最妙的一处是:表面上 $O(n^2)$ 的交互项可以化简为 $O(kn)$:

$$ \sum_{i特征数和因子数都是线性级,FM 因此能扛起百万维 one-hot 的工业级 CTR 数据。

如果把 $\mathbf{x}$ 设成 [用户 one-hot ; 物品 one-hot],FM 正好退化为矩阵分解——MF 是 FM “只用 ID 特征” 的特例。


9 · 隐式反馈的几种处理思路

真实平台很少有评分,更多的是行为:浏览、点击、收听、购买。三种主流套路:

  • 加权矩阵分解:把每条已观测交互看成"目标值 1,置信度 $c_{ui} = 1 + \alpha \log(1 + \text{count})$",把缺失看成"目标值 0,置信度低”。损失变成 $\sum c_{ui} (1 - \hat{r}_{ui})^2 + \lambda \dots$
  • 负采样:每条正样本配若干个未交互物品作为负样本,按 0 训练。
  • BPR:如上节所述,用排序对绕开"什么是负样本"这件事。
1
2
3
def confidence(count: float, alpha: float = 10.0) -> float:
    """Hu/Koren/Volinsky 的隐式反馈置信度公式。"""
    return 1.0 + alpha * np.log1p(count)

10 · 实战 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 收敛快、可分布式。许多团队晚上 ALS 离线初始化,白天 SGD 增量微调。

为什么 BPR 在隐式数据上比 RMSE 训练的 MF 好? 因为 RMSE 优化的根本是错误目标。下游指标(CTR、NDCG)是排名的函数,不是评分的函数;BPR 直接优化排名。

对付稀疏性的招数。 矩阵分解本身、加正则、引入隐式反馈、用 FM 引入边特征——没有银弹,是一套组合拳。

偏置项凭什么这么有效? 它把"结构性效应"(这位用户慷慨、这部片子普世受捧)和真正的交互信号剥离开来,让潜因子专心建模品味。一接入,RMSE 通常就下降 10–20%。

初始化。 因子用 $\mathcal{N}(0, 0.1)$,偏置用 0(数据会在第一轮把它们推到合理位置)。千万别用全零因子——梯度会卡住。

预测越界怎么办? 直接 clip 到 $[r_{\min}, r_{\max}]$。Sigmoid 缩放在数学上更优雅,但在离线评估里很少能赢过 clip。

怎么做实时推荐? 把每个用户的向量缓存住;对头部用户预算 Top-N;只在长尾用户上实时计算。需要持续学习时,用 SGD 在流上更新,并对嵌入表做版本管理。

怎么防过拟合? L2 正则取 $\lambda \in [0.01, 0.1]$,验证集早停,再不行就先把 $k$ 砍小,最后才考虑更花哨的招。


总结

协同过滤是现代推荐系统的地基,整条线索其实很短:

  • 邻域方法把"和你像的人也喜欢……“翻译成相似度加权平均。Item-CF 是工业默认。
  • 矩阵分解把稀疏评分矩阵压成低秩用户/物品嵌入,用内积预测评分。
  • 偏置项剥离结构性噪音;SVD++ 把隐式反馈也吸进来。
  • ALSSGD 是两条优化路线——闭式解 vs. 流式更新,并行 vs. 在线。
  • BPR 把损失从 RMSE 换成排序;FM 把 MF 推广到任意稀疏特征。

这些思想会一直跟着你走。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 篇。

Liked this piece?

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

GitHub