📑 本页目录(点开跳转)
05 · 矩阵分解
⏱ 25 分钟 | ⭐ 核心 | 🔨 有代码
🎯 一句话
矩阵分解 = 给每个用户和每个物品各配一串数字(向量),让「向量点积」等于「喜欢程度」。这串数字就是 Embedding,是现代推荐系统的地基。
🧠 先建立直觉(别急着看公式)
想象你在描述一个人的电影口味。你不会列出他看过的每一部电影,你会说:
「他科幻控 90 分,文艺片 20 分,喜剧 60 分,恐怖片 5 分。」
同样,描述一部电影:
「《星际穿越》:科幻 95 分,文艺 40 分,喜剧 10 分,恐怖 0 分。」
那这个人有多可能喜欢《星际穿越》? 把两串数字对应相乘再加起来:
0.90×0.95 + 0.20×0.40 + 0.60×0.10 + 0.05×0.00 = 1.00 ← 高分!匹配
这就是矩阵分解的全部思想。 剩下的只是:这些「分数」不用人来定,让模型自己从数据里学出来。
📊 图解:一张大矩阵 → 两张小矩阵
💡 人话翻译:
与其记住「谁喜欢啥」这张巨表,不如给每个人和每个物品各发一张只有 32 个数字的身份卡,用两张卡的匹配度算喜欢程度。 一张 10^12 的表被压缩成了 6400 万个数——压缩就是学习。
🌟 关键收益:泛化能力
原来矩阵里没交互过的格子是「未知」,现在任意两个向量都能算出一个分。 模型可以说出「你没看过 X,但根据你的向量,你会给 4.3 分」——这是 ItemCF 做不到的。
📐 公式(三行,配人话)
预测: $$\hat{r}_{ui} = \mathbf{p}_u^\top \mathbf{q}_i = \sum_{f=1}^{k} p_{uf} \cdot q_{if}$$
目标(要最小化的东西): $$\min_{P,Q} \sum_{(u,i) \in \mathcal{K}} \left( r_{ui} - \mathbf{p}_u^\top \mathbf{q}_i \right)^2 + \lambda \left( \|\mathbf{p}_u\|^2 + \|\mathbf{q}_i\|^2 \right)$$
💡 人话翻译:
第一项:「预测分和真实分的差,越小越好」(只在有数据的格子上算) 第二项 λ(...):「别让向量里的数字太大」——这叫正则化,防止模型死记硬背(过拟合)。 λ 大 → 模型保守;λ 小 → 模型激进。这是你要调的第二个旋钮。
➕ 加上偏置项(Biased MF)——效果提升巨大,成本几乎为零
现实里有两个和「口味匹配」无关的效应:
- 有的用户就是打分高(老好人,什么都给 5 分)
- 有的电影就是评分高(《肖申克救赎》,谁看都说好)
把它们单独拿出来:
$$\hat{r}_{ui} = \underbrace{\mu}_{\text{全局均分}} + \underbrace{b_u}_{\text{用户偏置}} + \underbrace{b_i}_{\text{物品偏置}} + \underbrace{\mathbf{p}_u^\top \mathbf{q}_i}_{\text{个性化匹配}}$$
💡 人话:「基准分 + 这人偏爱打高分多少 + 这片天生好评多少 + 这人和这片的化学反应」
📌 Netflix Prize 的经验:光是加上
bu和bi两项偏置,就能拿到总提升的一大半。 越简单的改进往往性价比越高——这个直觉在整个推荐工程里都适用。
🔨 动手:40 行手写 MF(SGD 版)
import numpy as np
class MF:
def __init__(self, n_users, n_items, k=32, lr=0.01, reg=0.05):
# 小随机数初始化。全 0 会导致梯度全 0,学不动
self.P = np.random.normal(0, 0.1, (n_users, k))
self.Q = np.random.normal(0, 0.1, (n_items, k))
self.bu = np.zeros(n_users)
self.bi = np.zeros(n_items)
self.mu = 0.0
self.lr, self.reg = lr, reg
def fit(self, data, epochs=20):
"""data: [(user, item, rating), ...]"""
self.mu = np.mean([r for _, _, r in data])
for epoch in range(epochs):
np.random.shuffle(data) # 打乱很重要
total_err = 0
for u, i, r in data:
pred = self.mu + self.bu[u] + self.bi[i] + self.P[u] @ self.Q[i]
err = r - pred
total_err += err ** 2
# 梯度下降:朝着减小误差的方向挪一小步
pu, qi = self.P[u].copy(), self.Q[i].copy()
self.P[u] += self.lr * (err * qi - self.reg * pu)
self.Q[i] += self.lr * (err * pu - self.reg * qi)
self.bu[u] += self.lr * (err - self.reg * self.bu[u])
self.bi[i] += self.lr * (err - self.reg * self.bi[i])
rmse = np.sqrt(total_err / len(data))
if epoch % 5 == 0:
print(f"epoch {epoch:2d} RMSE={rmse:.4f}")
def predict(self, u, i):
return self.mu + self.bu[u] + self.bi[i] + self.P[u] @ self.Q[i]
def recommend(self, u, seen, n=10):
scores = self.mu + self.bu[u] + self.bi + self.Q @ self.P[u]
scores[list(seen)] = -np.inf # 屏蔽看过的
return np.argsort(-scores)[:n]
# ---- 造点假数据试试:两个明显的兴趣群体 ----
np.random.seed(42)
data = []
for u in range(100):
if u < 50: # 前 50 人喜欢物品 0-49
items = np.random.choice(50, 20, replace=False)
else: # 后 50 人喜欢物品 50-99
items = np.random.choice(50, 20, replace=False) + 50
for i in items:
data.append((u, int(i), np.random.uniform(4, 5)))
model = MF(100, 100, k=8)
model.fit(data, epochs=21)
print("\n给用户 0(属于前一组)推荐:", model.recommend(0, seen=set(), n=5))
print("给用户 99(属于后一组)推荐:", model.recommend(99, seen=set(), n=5))
你会看到:给用户 0 推的都是 0–49 号物品,给用户 99 推的都是 50–99 号。 模型自己发现了两个兴趣群体,你从没告诉过它。
🎯 隐式反馈怎么办?——两个关键变体
第 3 节说过:隐式反馈只有「1 和未知」,没有「0」。上面的 MF 用不了。两个解法:
变体 1:ALS + 置信度加权(工业界主力)
Hu et al. 2008 的经典做法:
$$\min \sum_{u,i} c_{ui}\left(y_{ui} - \mathbf{p}_u^\top\mathbf{q}_i\right)^2 + \lambda(\cdots)$$
- $y_{ui} = 1$ 如果有交互,否则 0(所有格子都算,包括没交互的)
- $c_{ui} = 1 + \alpha \cdot r_{ui}$ 是置信度(看了 10 次比看了 1 次置信度高)
💡 人话:「没交互的当 0,但我对这个 0 没什么信心(权重低);有交互的当 1,交互越多我越确信(权重高)。」
为什么用 ALS 不用 SGD:固定 P 求 Q 是个最小二乘,有闭式解,能并行、能处理全矩阵。
# 别手写,用现成的(Spotify 出品,工业级)
# pip install implicit
import implicit
from scipy.sparse import csr_matrix
model = implicit.als.AlternatingLeastSquares(factors=64, regularization=0.05,
alpha=40, iterations=20)
model.fit(csr_matrix(user_item_matrix))
ids, scores = model.recommend(userid=0, user_items=csr_matrix(user_item_matrix)[0], N=10)
变体 2:BPR —— 从「预测分数」改成「比较排序」⭐
BPR 的洞察:推荐是排序问题,不是打分问题。用户根本不在乎你预测他给几分,他在乎的是你把哪个放前面。
所以不去拟合分数,而是让模型学会:
「用户点过的物品 i,得分要比他没点的物品 j 高」
$$\max \sum_{(u,i,j)} \ln \sigma\left(\hat{r}_{ui} - \hat{r}_{uj}\right) - \lambda\|\Theta\|^2$$
其中 (u, i, j) 是三元组:用户 u、正样本 i(交互过)、负样本 j(随机采的没交互物品)。
def bpr_step(P, Q, u, i, j, lr=0.05, reg=0.01):
"""一步 BPR 更新"""
x_uij = P[u] @ Q[i] - P[u] @ Q[j] # 正负样本的分差
sig = 1 / (1 + np.exp(x_uij)) # 差越大,梯度越小(已经学会了)
pu = P[u].copy()
P[u] += lr * (sig * (Q[i] - Q[j]) - reg * pu)
Q[i] += lr * (sig * pu - reg * Q[i])
Q[j] += lr * (-sig * pu - reg * Q[j])
🔑 BPR 是推荐算法史上的重要思想转折:从 pointwise(逐个打分) 转向 pairwise(两两比较)。 这个思路一直沿用到今天的深度模型和第 14 节的生成式推荐。
🪜 三种学习范式(贯穿全部教程的概念)
| 范式 | 学什么 | 损失函数 | 优缺点 |
|---|---|---|---|
| Pointwise | 单个样本的分数 | MSE / LogLoss | 简单、好训;但和排序目标不完全一致 |
| Pairwise | 两个样本谁更好 | BPR / RankNet | 直接对齐排序;采样策略影响大 |
| Listwise | 整个列表的排序 | ListNet / LambdaRank | 最贴近目标;计算复杂、难训 |
工业界现状:精排大多用 Pointwise(因为要预估真实概率值给多目标融合用),召回大多用 Pairwise(因为只要排序对)。
📈 从 MF 到深度学习的三条演进路线
三条路线最终都汇进了现代推荐系统。 你已经站在了分叉口。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| ML基础 06 | MF 和 PCA/SVD 都是降维:把高维稀疏压成低维稠密 |
| 数学原理 02 | MF 里那个 λ 的真身是高斯先验,λ = σ²/τ² |
| 数学原理 13 | ALS 的交替优化和 EM 是同一个思想 |
✅ 检查点
- 矩阵分解把一张大矩阵变成了什么?为什么这算「学习」?
- Biased MF 里的
bu和bi分别代表什么? - 隐式反馈用 MF 的两个变体是什么?各自的核心思想?
- Pointwise / Pairwise 的区别?BPR 属于哪个?
- 什么是 Embedding?(用自己的话)
👀 答案
1. 变成用户矩阵 P(每人一个 k 维向量)和物品矩阵 Q。原来的「未知格子」现在可以通过向量点积算出来 → 有了泛化能力,这就是学习。 2. `bu` = 这个用户比平均分高/低多少(打分习惯);`bi` = 这个物品比平均分高/低多少(内在质量)。 3. ① ALS + 置信度加权:把未交互当 0 但权重低;② BPR:不预测分数,只保证「交互过的 > 没交互的」。 4. Pointwise 预测单个样本的绝对分数,Pairwise 只学两个样本的相对顺序。BPR 是 Pairwise。 5. 一串固定长度的数字,用来代表一个用户/物品/特征,且「意思相近的东西向量也相近」。🛑 可以停在这里
⚡ 走神救援
矩阵分解:给每个用户和物品配一个 k 维向量(Embedding),点积 = 喜欢程度。加偏置项 μ+bu+bi 性价比极高。隐式反馈用 ALS(置信度加权)或 BPR(pairwise,学「正样本分>负样本分」)。核心跃迁:相似度从「数出来」变成「学出来」,从此有了泛化能力。⭐ ALS 和 BPR 解决的是两种不同的问题:ALS 把隐式反馈当成带置信度的评分(点了 10 次比点 1 次更可信);BPR 干脆不预测绝对分,只学「正样本分 > 负样本分」这个序关系 —— 而排序场景本来就只关心序。⭐ 偏置项 μ+bu+bi 是性价比最高的一步:它单独就能解释很大一部分方差(有人给分普遍高、有些电影普遍受欢迎),把这部分剥掉之后,向量才去学真正的「口味匹配」。
下一节 👉 06-工业架构-召回粗排精排重排.md ⭐ 这是全教程最重要的一节