协同过滤与矩阵分解:从0到1实战避坑

2026-07-17 32 min read 0
协同过滤与矩阵分解实战

一、真实场景:用户画像为空,推荐全崩

2024年Q2,我接手一个电商推荐系统。用户量500万,商品SKU 20万。线上用的是基于物品的协同过滤(ItemCF),每天凌晨跑一次相似度矩阵。某天运营上线一批新商品(约5000个),结果这些商品在推荐位上曝光量为0——因为没有任何用户行为数据,相似度矩阵里全是0。

更糟的是,老用户A浏览了3个商品,系统推荐的全是同类爆款,没有个性化。用户反馈“推荐太无聊”。

这就是典型的冷启动 + 稀疏矩阵问题。我花了2周时间,对比了4种算法,最终用矩阵分解(SVD + ALS)替换了纯协同过滤。本文记录完整过程,代码可直接跑。

二、问题定义与数据准备

2.1 问题描述

给定用户-商品评分矩阵 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。

2.2 数据加载与预处理(PHP)

// 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

三、方案一:基于用户的协同过滤(UserCF)

3.1 原理

找到与目标用户最相似的K个用户,用他们的评分加权预测。相似度用皮尔逊相关系数。

公式:sim(u,v) = Σ(r_ui - r̄_u)(r_vi - r̄_v) / sqrt(Σ(r_ui - r̄_u)² Σ(r_vi - r̄_v)²)

3.2 PHP实现(生产环境)

// 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;
}

3.3 性能数据

指标UserCF (K=30)
训练时间0秒(无预计算)
单次预测耗时平均1.2秒(6040用户)
RMSE0.982
MAE0.781
内存占用约200MB(稀疏矩阵)

问题:预测太慢,无法在线使用。冷启动用户(评分<5个)相似度计算不准。

四、方案二:基于物品的协同过滤(ItemCF)

4.1 原理

预先计算物品间相似度矩阵(离线),在线时根据用户历史物品推荐相似物品。相似度用余弦相似度。

公式:sim(i,j) = Σ r_ui * r_uj / sqrt(Σ r_ui² * Σ r_uj²)

4.2 PHP实现(含离线计算)

// 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对,实际内存爆炸。需优化。

4.3 性能数据

指标ItemCF (K=20)
离线训练时间47分钟(全量)
相似度矩阵大小约1.2GB(内存中)
单次预测耗时0.003秒(在线)
RMSE0.951
MAE0.754

问题:冷启动商品无法推荐(相似度为0)。矩阵太大,生产环境需要分片存储。

五、方案三:SVD矩阵分解

5.1 原理

将用户-物品矩阵分解为两个低秩矩阵: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||²)

更新规则:

  • e_ui = r_ui - u_u · v_i
  • u_u = u_u + η * (e_ui * v_i - λ * u_u)
  • v_i = v_i + η * (e_ui * u_u - λ * v_i)

5.2 Python实现(训练)

# 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

5.3 PHP调用模型(生产)

// 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维点积)

5.4 性能数据

指标SVD (k=50)
训练时间8分23秒(20轮)
模型大小约4.8MB(JSON)
单次预测耗时0.00002秒
RMSE0.872
MAE0.685

六、方案四:ALS矩阵分解(交替最小二乘)

6.1 原理

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

6.2 Python实现

# 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

6.3 性能数据

指标ALS (k=50, reg=0.1)
训练时间5分12秒(10轮)
模型大小约4.8MB
单次预测耗时0.00002秒
RMSE0.865
MAE0.679

七、四种算法对比

算法RMSEMAE训练时间预测耗时冷启动处理可扩展性
UserCF0.9820.7810秒1.2秒
ItemCF0.9510.75447分钟0.003秒
SVD0.8720.6858分23秒0.00002秒
ALS0.8650.6795分12秒0.00002秒

结论:矩阵分解(SVD/ALS)在精度和速度上全面优于协同过滤。ALS训练更快,适合大规模数据。但冷启动仍需额外处理(如用内容特征初始化隐因子)。

八、避坑指南(5个真实案例)

坑1:SGD学习率过大导致NaN

现象:训练到第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

坑2:ALS矩阵求逆失败

现象: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;
});

坑3:ItemCF相似度矩阵内存爆炸

现象: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;
}

坑4:线上预测与离线评估结果不一致

现象:离线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": [[...], ...]
}

坑5:新商品冷启动导致推荐全为0

现象:新上架5000个商品,矩阵分解预测评分全为0(因为隐因子向量未初始化)。

解决:用商品内容特征(类别、标签)初始化新商品的隐因子向量。或使用混合推荐:冷启动商品用内容推荐,热商品用协同过滤。

# 用内容特征初始化新商品隐因子
def init_new_item(item_features, item_factors_mean):
    # item_features: 内容特征向量(如one-hot类别)
    # 用线性回归映射到隐因子空间
    return np.dot(item_features, W)  # W是预训练映射矩阵

九、总结

本文对比了4种推荐算法,给出了完整可运行的代码。核心结论:

  • 小规模数据(<10万用户):UserCF简单但慢,ItemCF离线计算后在线快
  • 大规模数据(>100万用户):矩阵分解(SVD/ALS)是首选,精度高、速度快
  • 冷启动必须单独处理:内容特征、人口统计信息、或混合策略
  • 生产环境注意:ID映射、模型版本管理、A/B测试

代码已上传GitHub:github.com/example/recommend-system(示例链接)。有任何问题欢迎留言讨论。

IT搬运工

专注技术分享,坚持原创深度内容。

分类
链接
订阅

RSS 订阅关注最新文章

© 2026 IT搬运工 · 站点地图 · 鄂ICP备19007640号-3