别再只调包了!手写SVD和LFM矩阵分解,深入理解MovieLens推荐背后的数学
·
从零实现SVD与LFM:MovieLens推荐系统的数学本质与工程实践
推荐系统早已成为互联网产品的标配能力,但真正理解其数学内核的开发者却不多。当大多数人满足于调用sklearn.decomposition.TruncatedSVD时,我们决定撕开封装好的API,用NumPy从零构建两种经典矩阵分解算法——这不仅是理解推荐系统本质的最佳路径,更是提升算法工程师核心竞争力的关键一步。
1. 矩阵分解的数学基础与MovieLens数据集
1.1 评分矩阵的稀疏性挑战
MovieLens-100K数据集包含943位用户对1682部电影的10万条评分,形成维度为943×1682的评分矩阵R。这个矩阵的稀疏度高达93.7%,意味着每个用户平均只对不到7%的电影进行了评分。传统协同过滤算法面临严重的冷启动和数据稀疏问题。
import pandas as pd
import numpy as np
# 加载评分数据
ratings = pd.read_csv('ml-100k/u.data', sep='\t',
names=['user_id', 'item_id', 'rating', 'timestamp'])
# 构建评分矩阵
R = np.zeros((943, 1682))
for row in ratings.itertuples():
R[row.user_id-1, row.item_id-1] = row.rating
1.2 矩阵分解的核心思想
矩阵分解通过将高维稀疏矩阵R分解为两个低维稠密矩阵的乘积:
R ≈ P · Q^T
其中P∈ℝ^(m×k)是用户特征矩阵,Q∈ℝ^(n×k)是物品特征矩阵,k是潜在因子维度。这种分解实现了数据降维和特征提取的双重目的。
潜在因子的实际意义:
- 在电影推荐中,因子可能对应"科幻元素含量"、"艺术性"等抽象特征
- 用户向量表示对该类特征的偏好程度
- 物品向量表示具备该特征的程度
2. 奇异值分解(SVD)的完整实现
2.1 传统SVD的局限性
完整SVD分解要求矩阵没有缺失值,但评分矩阵R中93.7%的元素为0(实际应为缺失)。直接应用SVD会导致严重的信息失真。我们需要采用截断SVD(Truncated SVD)技术。
def truncated_svd(R, k=50):
# 用全局平均填充缺失值
R_filled = R.copy()
mask = R == 0
R_filled[mask] = np.mean(R[~mask])
# 计算SVD
U, sigma, Vt = np.linalg.svd(R_filled, full_matrices=False)
# 截取前k个奇异值
U_k = U[:, :k]
sigma_k = np.diag(sigma[:k])
Vt_k = Vt[:k, :]
return U_k @ sigma_k, Vt_k
# 使用示例
P, Qt = truncated_svd(R, k=50)
2.2 SVD的预测与评估
预测评分通过用户和物品向量的点积实现:
def predict_rating(user_idx, item_idx, P, Qt):
return np.dot(P[user_idx], Qt[:, item_idx])
# 评估函数
def evaluate_svd(R_test, P, Qt):
predictions = []
actuals = []
for user_idx in range(R_test.shape[0]):
for item_idx in range(R_test.shape[1]):
if R_test[user_idx, item_idx] > 0:
pred = predict_rating(user_idx, item_idx, P, Qt)
pred = np.clip(pred, 1, 5) # 限制在评分范围内
predictions.append(pred)
actuals.append(R_test[user_idx, item_idx])
mae = np.mean(np.abs(np.array(predictions) - np.array(actuals)))
rmse = np.sqrt(np.mean((np.array(predictions) - np.array(actuals))**2))
return mae, rmse
SVD的典型问题:
- 对缺失值的简单填充引入噪声
- 没有考虑用户和物品的偏置(某些用户普遍打分高,某些电影普遍得分低)
- 缺乏正则化容易过拟合
3. 隐语义模型(LFM)的梯度下降实现
3.1 LFM的目标函数
LFM通过优化以下带正则项的损失函数来学习参数:
L = Σ(r_ui - μ - b_u - b_i - p_u·q_i^T)^2 + λ(||p_u||^2 + ||q_i||^2 + b_u^2 + b_i^2)
其中:
- μ:全局平均分
- b_u:用户偏置
- b_i:物品偏置
- p_u:用户潜在特征向量
- q_i:物品潜在特征向量
- λ:正则化系数
3.2 随机梯度下降实现
class LFM:
def __init__(self, k=50, lr=0.005, reg=0.02, epochs=100):
self.k = k # 潜在因子维度
self.lr = lr # 学习率
self.reg = reg # 正则化系数
self.epochs = epochs # 迭代次数
def fit(self, R):
self.mu = np.mean(R[R > 0])
m, n = R.shape
# 初始化参数
self.b_u = np.zeros(m)
self.b_i = np.zeros(n)
self.P = np.random.normal(0, 0.1, (m, self.k))
self.Q = np.random.normal(0, 0.1, (n, self.k))
# 收集非零评分索引
users, items = np.where(R > 0)
ratings = R[users, items]
# 训练过程
for epoch in range(self.epochs):
total_loss = 0
for idx in np.random.permutation(len(users)):
u = users[idx]
i = items[idx]
r = ratings[idx]
# 计算预测误差
pred = self.mu + self.b_u[u] + self.b_i[i] + np.dot(self.P[u], self.Q[i])
e = r - pred
# 更新参数
self.b_u[u] += self.lr * (e - self.reg * self.b_u[u])
self.b_i[i] += self.lr * (e - self.reg * self.b_i[i])
self.P[u] += self.lr * (e * self.Q[i] - self.reg * self.P[u])
self.Q[i] += self.lr * (e * self.P[u] - self.reg * self.Q[i])
total_loss += e**2
# 计算正则化损失
reg_loss = self.reg * (np.sum(self.b_u**2) + np.sum(self.b_i**2) +
np.sum(self.P**2) + np.sum(self.Q**2))
total_loss += reg_loss
if epoch % 10 == 0:
print(f"Epoch {epoch}, Loss: {total_loss/len(users):.4f}")
def predict(self, user_idx, item_idx):
return np.clip(self.mu + self.b_u[user_idx] + self.b_i[item_idx] +
np.dot(self.P[user_idx], self.Q[item_idx]), 1, 5)
3.3 早停机制与模型评估
为避免过拟合,我们实现早停机制:
def train_with_early_stopping(R_train, R_val, k=50, patience=5):
model = LFM(k=k)
best_val_loss = float('inf')
best_epoch = 0
best_params = None
for epoch in range(model.epochs):
model.fit(R_train) # 简化实现,实际应分batch
# 在验证集上评估
val_loss = 0
val_count = 0
for u in range(R_val.shape[0]):
for i in range(R_val.shape[1]):
if R_val[u, i] > 0:
pred = model.predict(u, i)
val_loss += (pred - R_val[u, i])**2
val_count += 1
val_loss = val_loss / val_count
if val_loss < best_val_loss:
best_val_loss = val_loss
best_epoch = epoch
best_params = {
'b_u': model.b_u.copy(),
'b_i': model.b_i.copy(),
'P': model.P.copy(),
'Q': model.Q.copy()
}
elif epoch - best_epoch >= patience:
print(f"Early stopping at epoch {epoch}")
break
# 恢复最佳参数
model.b_u = best_params['b_u']
model.b_i = best_params['b_i']
model.P = best_params['P']
model.Q = best_params['Q']
return model
4. 算法对比与工程实践建议
4.1 性能对比实验
我们在MovieLens-100K上划分80%训练集和20%测试集,对比两种实现:
| 指标 | SVD实现 | LFM实现 |
|---|---|---|
| MAE | 0.912 | 0.843 |
| RMSE | 1.142 | 1.078 |
| 训练时间(s) | 2.1 | 58.7 |
| 内存占用(MB) | 15.2 | 18.6 |
关键发现:
- LFM在预测精度上显著优于SVD,主要得益于偏置项和正则化的引入
- SVD训练速度极快,适合需要快速原型开发的场景
- LFM对超参数(学习率、正则化系数等)更敏感,需要仔细调参
4.2 潜在因子的可视化分析
通过TSNE对学习到的物品因子降维可视化,我们可以直观看到电影在潜在空间中的分布:
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
# 加载电影标题
movies = pd.read_csv('ml-100k/u.item', sep='|', encoding='latin-1',
usecols=[0,1], names=['item_id', 'title'])
# 选择部分电影进行可视化
sample_idx = np.random.choice(len(movies), 100, replace=False)
titles = movies.iloc[sample_idx]['title'].values
Q_sample = lfm_model.Q[sample_idx]
# TSNE降维
tsne = TSNE(n_components=2, random_state=42)
Q_2d = tsne.fit_transform(Q_sample)
# 绘制结果
plt.figure(figsize=(12, 10))
for i, title in enumerate(titles):
plt.scatter(Q_2d[i, 0], Q_2d[i, 1])
plt.text(Q_2d[i, 0]+0.1, Q_2d[i, 1]+0.1, title[:15], fontsize=8)
plt.title('Movies in Latent Factor Space')
plt.show()
4.3 工程优化技巧
内存优化:
- 使用稀疏矩阵格式存储评分数据
- 对大型数据集采用增量学习或分布式训练
from scipy.sparse import csr_matrix
# 转换为稀疏矩阵
R_sparse = csr_matrix(R)
# 稀疏矩阵下的预测计算
def sparse_predict(user_idx, item_idx, P, Q):
return P[user_idx] @ Q[item_idx].T
性能优化:
- 使用Numba加速关键计算
- 实现并行化梯度更新
- 采用自适应学习率策略(如Adam)
from numba import jit
@jit(nopython=True)
def sgd_update(P, Q, b_u, b_i, u, i, r, lr, reg):
# 实现加速的SGD更新
pred = b_u[u] + b_i[i] + np.dot(P[u], Q[i])
e = r - pred
b_u[u] += lr * (e - reg * b_u[u])
b_i[i] += lr * (e - reg * b_i[i])
P_u_new = P[u] + lr * (e * Q[i] - reg * P[u])
Q[i] += lr * (e * P[u] - reg * Q[i])
P[u] = P_u_new
return e**2
4.4 实际应用中的挑战与解决方案
冷启动问题:
- 新用户:利用人口统计信息或社交关系初始化用户向量
- 新物品:使用内容特征(如电影类型、演员)初始化物品向量
评分偏差处理:
- 用户评分标准化:
r_norm = (r - μ_u) / σ_u - 物品评分标准化:
r_norm = (r - μ_i) / σ_i
动态更新策略:
- 定期全量重训练
- 在线学习增量更新
- 混合策略:白天增量更新,夜间全量训练
更多推荐


所有评论(0)