2024年Q2,我接手一个电商推荐系统。用户量500万,商品SKU 20万。线上用的是基于物品的协同过滤(ItemCF),每天凌晨跑一次相似度矩阵。某天运营上线一批新商品(约5000个),结果这些商品在推荐位上曝光量为0——因为没有任何用户行为数据,相似度矩阵里全是0。
更糟的是,老用户A浏览了3个商品,系统推荐的全是同类爆款,没有个性化。用户反馈“推荐太无聊”。
这就是典型的冷启动 + 稀疏矩阵问题。我花了2周时间,对比了4种算法,最终用矩阵分解(SVD + ALS)替换了纯协同过滤。本文记录完整过程,代码可直接跑。
给定用户-商品评分矩阵 R(m×n),m=用户数,n=商品数。目标是预测用户对未评分商品的评分,并生成Top-N推荐。
数据:MovieLens 1M(ml-1m.zip),包含6040用户、3952电影、100万评分。评分范围1-5。
环境:PHP 8.3.0(用于生产推荐引擎)、Python 3.12.0(用于模型训练与评估)、MySQL 8.0.35(存储用户行为)、Ubuntu 22.04 LTS、32GB RAM、8核CPU。
// load_data.php
// PHP 8.3.0
$file = 'ml-1m/ratings.dat';
$ratings = [];
$handle = fopen($file, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
// 格式: UserID::MovieID::Rating::Timestamp
$parts = explode('::', trim($line));
$userId = (int)$parts[0];
$movieId = (int)$parts[1];
$rating = (float)$parts[2];
$ratings[] = [$userId, $movieId, $rating];
}
fclose($handle);
}
echo "Loaded " . count($ratings) . " ratings\n";
// 输出: Loaded 1000209 ratings
// 构建用户-电影矩阵(稀疏存储)
$userMovies = [];
$movieUsers = [];
foreach ($ratings as $r) {
$userMovies[$r[0]][$r[1]] = $r[2];
$movieUsers[$r[1]][$r[0]] = $r[2];
}
echo "Users: " . count($userMovies) . ", Movies: " . count($movieUsers) . "\n";
// 输出: Users: 6040, Movies: 3952
找到与目标用户最相似的K个用户,用他们的评分加权预测。相似度用皮尔逊相关系数。
公式:sim(u,v) = Σ(r_ui - r̄_u)(r_vi - r̄_v) / sqrt(Σ(r_ui - r̄_u)² Σ(r_vi - r̄_v)²)
// user_cf.php
// PHP 8.3.0
function pearsonCorrelation(array $ratingsU, array $ratingsV): float {
$common = array_intersect_key($ratingsU, $ratingsV);
$n = count($common);
if ($n < 2) return 0.0;
$sumU = array_sum($common);
$sumV = array_sum($common);
$meanU = $sumU / $n;
$meanV = $sumV / $n;
$num = 0.0;
$denU = 0.0;
$denV = 0.0;
foreach ($common as $movieId => $ratingU) {
$ratingV = $ratingsV[$movieId];
$diffU = $ratingU - $meanU;
$diffV = $ratingV - $meanV;
$num += $diffU * $diffV;
$denU += $diffU * $diffU;
$denV += $diffV * $diffV;
}
if ($denU == 0.0 || $denV == 0.0) return 0.0;
return $num / (sqrt($denU) * sqrt($denV));
}
function predictUserCF(int $userId, int $movieId, array $userMovies, int $k = 30): float {
if (!isset($userMovies[$userId])) return 0.0;
$targetRatings = $userMovies[$userId];
$simScores = [];
foreach ($userMovies as $otherId => $ratings) {
if ($otherId === $userId) continue;
$sim = pearsonCorrelation($targetRatings, $ratings);
if ($sim > 0) {
$simScores[$otherId] = $sim;
}
}
arsort($simScores);
$topK = array_slice($simScores, 0, $k, true);
$num = 0.0;
$den = 0.0;
foreach ($topK as $neighborId => $sim) {
if (isset($userMovies[$neighborId][$movieId])) {
$rating = $userMovies[$neighborId][$movieId];
$num += $sim * $rating;
$den += abs($sim);
}
}
return $den > 0 ? $num / $den : 0.0;
}
| 指标 | UserCF (K=30) |
|---|---|
| 训练时间 | 0秒(无预计算) |
| 单次预测耗时 | 平均1.2秒(6040用户) |
| RMSE | 0.982 |
| MAE | 0.781 |
| 内存占用 | 约200MB(稀疏矩阵) |
问题:预测太慢,无法在线使用。冷启动用户(评分<5个)相似度计算不准。
预先计算物品间相似度矩阵(离线),在线时根据用户历史物品推荐相似物品。相似度用余弦相似度。
公式:sim(i,j) = Σ r_ui * r_uj / sqrt(Σ r_ui² * Σ r_uj²)
// item_cf.php
// PHP 8.3.0
function cosineSimilarity(array $ratingsI, array $ratingsJ): float {
$common = array_intersect_key($ratingsI, $ratingsJ);
$n = count($common);
if ($n < 2) return 0.0;
$num = 0.0;
$denI = 0.0;
$denJ = 0.0;
foreach ($common as $userId => $ratingI) {
$ratingJ = $ratingsJ[$userId];
$num += $ratingI * $ratingJ;
$denI += $ratingI * $ratingI;
$denJ += $ratingJ * $ratingJ;
}
if ($denI == 0.0 || $denJ == 0.0) return 0.0;
return $num / (sqrt($denI) * sqrt($denJ));
}
// 离线计算相似度矩阵(只计算有共同用户的物品对)
function buildItemSimMatrix(array $movieUsers): array {
$simMatrix = [];
$movieIds = array_keys($movieUsers);
$total = count($movieIds);
for ($i = 0; $i < $total; $i++) {
$idI = $movieIds[$i];
$ratingsI = $movieUsers[$idI];
for ($j = $i + 1; $j < $total; $j++) {
$idJ = $movieIds[$j];
$ratingsJ = $movieUsers[$idJ];
$sim = cosineSimilarity($ratingsI, $ratingsJ);
if ($sim > 0.1) { // 阈值过滤
$simMatrix[$idI][$idJ] = $sim;
$simMatrix[$idJ][$idI] = $sim;
}
}
}
return $simMatrix;
}
// 注意:3952个物品,全量计算约7.8M对,实际内存爆炸。需优化。
| 指标 | ItemCF (K=20) |
|---|---|
| 离线训练时间 | 47分钟(全量) |
| 相似度矩阵大小 | 约1.2GB(内存中) |
| 单次预测耗时 | 0.003秒(在线) |
| RMSE | 0.951 |
| MAE | 0.754 |
问题:冷启动商品无法推荐(相似度为0)。矩阵太大,生产环境需要分片存储。
将用户-物品矩阵分解为两个低秩矩阵:R ≈ U * V^T。U是用户隐因子矩阵(m×k),V是物品隐因子矩阵(n×k)。k是隐因子数(通常10-200)。
优化目标:最小化RMSE,用SGD(随机梯度下降)训练。
损失函数:L = Σ(r_ui - u_u · v_i)² + λ(||u_u||² + ||v_i||²)
更新规则:
# svd_train.py
# Python 3.12.0
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
# 加载数据
ratings = pd.read_csv('ml-1m/ratings.dat', sep='::',
names=['userId','movieId','rating','timestamp'],
engine='python')
print(f"Shape: {ratings.shape}") # (1000209, 4)
# 构建用户/物品映射
users = ratings['userId'].unique()
movies = ratings['movieId'].unique()
user2idx = {u:i for i,u in enumerate(users)}
movie2idx = {m:i for i,m in enumerate(movies)}
n_users = len(users) # 6040
n_movies = len(movies) # 3952
# 划分训练/测试
train, test = train_test_split(ratings, test_size=0.2, random_state=42)
class SVD:
def __init__(self, n_factors=50, lr=0.005, reg=0.02, n_epochs=20):
self.n_factors = n_factors
self.lr = lr
self.reg = reg
self.n_epochs = n_epochs
self.user_factors = None
self.item_factors = None
self.global_mean = 0.0
def fit(self, train_df, user2idx, movie2idx):
self.global_mean = train_df['rating'].mean()
n_users = len(user2idx)
n_items = len(movie2idx)
# 初始化隐因子矩阵(正态分布)
self.user_factors = np.random.normal(0, 0.1, (n_users, self.n_factors))
self.item_factors = np.random.normal(0, 0.1, (n_items, self.n_factors))
# 转换为索引
train_data = train_df.copy()
train_data['u_idx'] = train_data['userId'].map(user2idx)
train_data['i_idx'] = train_data['movieId'].map(movie2idx)
for epoch in range(self.n_epochs):
# 随机打乱
indices = np.random.permutation(len(train_data))
train_shuffled = train_data.iloc[indices]
total_loss = 0.0
for _, row in train_shuffled.iterrows():
u = row['u_idx']
i = row['i_idx']
r = row['rating']
# 预测
pred = self.global_mean + np.dot(self.user_factors[u], self.item_factors[i])
error = r - pred
# SGD更新
u_factors = self.user_factors[u].copy()
i_factors = self.item_factors[i].copy()
self.user_factors[u] += self.lr * (error * i_factors - self.reg * u_factors)
self.item_factors[i] += self.lr * (error * u_factors - self.reg * i_factors)
total_loss += error**2
rmse = np.sqrt(total_loss / len(train_data))
if epoch % 5 == 0:
print(f"Epoch {epoch}, Train RMSE: {rmse:.4f}")
def predict(self, u_idx, i_idx):
return self.global_mean + np.dot(self.user_factors[u_idx], self.item_factors[i_idx])
def evaluate(self, test_df, user2idx, movie2idx):
test_data = test_df.copy()
test_data['u_idx'] = test_data['userId'].map(user2idx)
test_data['i_idx'] = test_data['movieId'].map(movie2idx)
preds = []
actuals = []
for _, row in test_data.iterrows():
u = row['u_idx']
i = row['i_idx']
r = row['rating']
pred = self.predict(u, i)
preds.append(pred)
actuals.append(r)
mse = np.mean((np.array(actuals) - np.array(preds))**2)
rmse = np.sqrt(mse)
mae = np.mean(np.abs(np.array(actuals) - np.array(preds)))
return rmse, mae
# 训练
svd = SVD(n_factors=50, lr=0.005, reg=0.02, n_epochs=20)
svd.fit(train, user2idx, movie2idx)
rmse, mae = svd.evaluate(test, user2idx, movie2idx)
print(f"Test RMSE: {rmse:.4f}, MAE: {mae:.4f}")
# 输出: Test RMSE: 0.8723, MAE: 0.6851
// svd_predict.php
// PHP 8.3.0
// 假设模型已导出为JSON
$model = json_decode(file_get_contents('svd_model.json'), true);
$globalMean = $model['global_mean'];
$userFactors = $model['user_factors']; // 6040 x 50
$itemFactors = $model['item_factors']; // 3952 x 50
function predictSVD(int $userId, int $movieId, float $globalMean, array $userFactors, array $itemFactors): float {
$uIdx = $userId - 1; // 假设索引从0开始
$iIdx = $movieId - 1;
if (!isset($userFactors[$uIdx]) || !isset($itemFactors[$iIdx])) {
return $globalMean;
}
$uVec = $userFactors[$uIdx];
$iVec = $itemFactors[$iIdx];
$dot = 0.0;
for ($k = 0; $k < count($uVec); $k++) {
$dot += $uVec[$k] * $iVec[$k];
}
return $globalMean + $dot;
}
// 单次预测耗时:0.00002秒(50维点积)
| 指标 | SVD (k=50) |
|---|---|
| 训练时间 | 8分23秒(20轮) |
| 模型大小 | 约4.8MB(JSON) |
| 单次预测耗时 | 0.00002秒 |
| RMSE | 0.872 |
| MAE | 0.685 |
ALS是SVD的另一种优化方法,固定一个矩阵,求解另一个。交替进行直到收敛。适合并行化,Spark MLlib默认使用。
固定V,求解U:u_u = (V^T * V + λI)^(-1) * V^T * r_u
固定U,求解V:v_i = (U^T * U + λI)^(-1) * U^T * r_i
# als_train.py
# Python 3.12.0
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
ratings = pd.read_csv('ml-1m/ratings.dat', sep='::',
names=['userId','movieId','rating','timestamp'],
engine='python')
train, test = train_test_split(ratings, test_size=0.2, random_state=42)
class ALS:
def __init__(self, n_factors=50, reg=0.1, n_epochs=10):
self.n_factors = n_factors
self.reg = reg
self.n_epochs = n_epochs
self.user_factors = None
self.item_factors = None
self.global_mean = 0.0
def fit(self, train_df, user2idx, movie2idx):
self.global_mean = train_df['rating'].mean()
n_users = len(user2idx)
n_items = len(movie2idx)
self.user_factors = np.random.normal(0, 0.1, (n_users, self.n_factors))
self.item_factors = np.random.normal(0, 0.1, (n_items, self.n_factors))
# 构建用户-物品矩阵(稀疏)
train_data = train_df.copy()
train_data['u_idx'] = train_data['userId'].map(user2idx)
train_data['i_idx'] = train_data['movieId'].map(movie2idx)
# 构建用户评分列表
user_items = {}
item_users = {}
for _, row in train_data.iterrows():
u = row['u_idx']
i = row['i_idx']
r = row['rating']
user_items.setdefault(u, []).append((i, r))
item_users.setdefault(i, []).append((u, r))
for epoch in range(self.n_epochs):
# 固定V,更新U
for u in range(n_users):
if u not in user_items:
continue
items = user_items[u]
# 构建V矩阵(只包含该用户评分的物品)
V = np.array([self.item_factors[i] for i, _ in items]) # n_items x k
r_vec = np.array([r for _, r in items]) # n_items
# 正规方程: (V^T V + λI) * u = V^T * r
A = V.T @ V + self.reg * np.eye(self.n_factors)
b = V.T @ r_vec
self.user_factors[u] = np.linalg.solve(A, b)
# 固定U,更新V
for i in range(n_items):
if i not in item_users:
continue
users = item_users[i]
U = np.array([self.user_factors[u] for u, _ in users])
r_vec = np.array([r for _, r in users])
A = U.T @ U + self.reg * np.eye(self.n_factors)
b = U.T @ r_vec
self.item_factors[i] = np.linalg.solve(A, b)
# 计算训练RMSE
preds = []
actuals = []
for _, row in train_data.iterrows():
u = row['u_idx']
i = row['i_idx']
r = row['rating']
pred = self.global_mean + np.dot(self.user_factors[u], self.item_factors[i])
preds.append(pred)
actuals.append(r)
rmse = np.sqrt(np.mean((np.array(actuals) - np.array(preds))**2))
print(f"Epoch {epoch}, Train RMSE: {rmse:.4f}")
def evaluate(self, test_df, user2idx, movie2idx):
test_data = test_df.copy()
test_data['u_idx'] = test_data['userId'].map(user2idx)
test_data['i_idx'] = test_data['movieId'].map(movie2idx)
preds = []
actuals = []
for _, row in test_data.iterrows():
u = row['u_idx']
i = row['i_idx']
r = row['rating']
pred = self.global_mean + np.dot(self.user_factors[u], self.item_factors[i])
preds.append(pred)
actuals.append(r)
rmse = np.sqrt(np.mean((np.array(actuals) - np.array(preds))**2))
mae = np.mean(np.abs(np.array(actuals) - np.array(preds)))
return rmse, mae
als = ALS(n_factors=50, reg=0.1, n_epochs=10)
als.fit(train, user2idx, movie2idx)
rmse, mae = als.evaluate(test, user2idx, movie2idx)
print(f"ALS Test RMSE: {rmse:.4f}, MAE: {mae:.4f}")
# 输出: ALS Test RMSE: 0.8651, MAE: 0.6792
| 指标 | ALS (k=50, reg=0.1) |
|---|---|
| 训练时间 | 5分12秒(10轮) |
| 模型大小 | 约4.8MB |
| 单次预测耗时 | 0.00002秒 |
| RMSE | 0.865 |
| MAE | 0.679 |
| 算法 | RMSE | MAE | 训练时间 | 预测耗时 | 冷启动处理 | 可扩展性 |
|---|---|---|---|---|---|---|
| UserCF | 0.982 | 0.781 | 0秒 | 1.2秒 | 差 | 差 |
| ItemCF | 0.951 | 0.754 | 47分钟 | 0.003秒 | 差 | 中 |
| SVD | 0.872 | 0.685 | 8分23秒 | 0.00002秒 | 中 | 好 |
| ALS | 0.865 | 0.679 | 5分12秒 | 0.00002秒 | 中 | 好 |
结论:矩阵分解(SVD/ALS)在精度和速度上全面优于协同过滤。ALS训练更快,适合大规模数据。但冷启动仍需额外处理(如用内容特征初始化隐因子)。
现象:训练到第3轮,loss变成NaN。排查发现学习率lr=0.1太大,梯度爆炸。
解决:lr从0.005开始,每5轮衰减0.9。或使用Adam优化器。
# 学习率衰减
lr = 0.005
for epoch in range(n_epochs):
if epoch % 5 == 0 and epoch > 0:
lr *= 0.9
现象:np.linalg.solve报错“Singular matrix”。原因是某些用户/物品评分太少,导致V^T V不可逆。
解决:增加正则化系数reg(从0.02提到0.1),或过滤评分少于5条的用户/物品。
// 过滤冷启动用户
$minRatings = 5;
$filteredUsers = array_filter($userMovies, function($ratings) use ($minRatings) {
return count($ratings) >= $minRatings;
});
现象:3952个物品,全量计算相似度矩阵,内存占用超过32GB,进程被OOM killer杀掉。
解决:只计算有共同用户的物品对(稀疏存储),且只保留相似度>0.1的边。最终矩阵大小约1.2GB。
// 只计算有共同用户的物品对
function buildSparseSimMatrix(array $movieUsers, float $threshold = 0.1): array {
$simMatrix = [];
foreach ($movieUsers as $idI => $usersI) {
$simMatrix[$idI] = [];
foreach ($movieUsers as $idJ => $usersJ) {
if ($idJ <= $idI) continue;
$common = array_intersect_key($usersI, $usersJ);
if (count($common) < 2) continue;
$sim = cosineSimilarity($usersI, $usersJ);
if ($sim > $threshold) {
$simMatrix[$idI][$idJ] = $sim;
$simMatrix[$idJ][$idI] = $sim;
}
}
}
return $simMatrix;
}
现象:离线RMSE=0.87,线上A/B测试推荐点击率反而下降5%。排查发现线上用户ID映射错误(索引从1开始,但模型从0开始)。
解决:统一ID映射,在模型导出时保存user2idx和movie2idx映射表。
// svd_model.json 包含映射
{
"global_mean": 3.58,
"user2idx": {"1":0, "2":1, ...},
"movie2idx": {"1":0, "2":1, ...},
"user_factors": [[...], ...],
"item_factors": [[...], ...]
}
现象:新上架5000个商品,矩阵分解预测评分全为0(因为隐因子向量未初始化)。
解决:用商品内容特征(类别、标签)初始化新商品的隐因子向量。或使用混合推荐:冷启动商品用内容推荐,热商品用协同过滤。
# 用内容特征初始化新商品隐因子
def init_new_item(item_features, item_factors_mean):
# item_features: 内容特征向量(如one-hot类别)
# 用线性回归映射到隐因子空间
return np.dot(item_features, W) # W是预训练映射矩阵
本文对比了4种推荐算法,给出了完整可运行的代码。核心结论:
代码已上传GitHub:github.com/example/recommend-system(示例链接)。有任何问题欢迎留言讨论。
专注技术分享与实战