电影推荐系统实战:基于MovieLens的协同过滤与内容推荐

📅 2026/8/26 22:01:45
电影推荐系统实战:基于MovieLens的协同过滤与内容推荐
简介推荐系统是解决信息过载的核心技术之一在电商、视频、音乐等场景中无处不在。协同过滤作为最经典的推荐算法通过分析用户行为或物品关联来预测偏好而基于内容的推荐则利用物品特征实现无需历史行为的推荐。两者各有优劣也面临冷启动、数据稀疏等工程挑战。本文以Python为工具基于公开的MovieLens评分数据系统讲解从数据预处理、相似度计算到评分预测与Top-N推荐的完整实现路径并对比用户协同过滤、物品协同过滤与内容推荐三种算法的适用场景。同时结合评估指标和调参经验讨论近邻选择、均值中心化等关键细节帮助读者搭建一个可运行的离线推荐Demo为实际业务中的算法选型与优化提供参考。 把一套能跑通的Python电影推荐系统代码整理出来是我做这个项目最有收获的一件事。这套系统基于公开的MovieLens电影评分数据实现了基于用户的协同过滤、基于物品的协同过滤和基于内容的推荐三种经典算法并且附带完整的评估流程和参数调优记录。它能解决的实际问题很明确当你有一份用户对电影的评分记录时如何给某个用户推荐他还没看过的、大概率他会喜欢的电影。不论你是刚入门推荐系统的学生还是想在公司内部快速搭建一个离线推荐Demo的工程师这篇文章里的代码和踩坑经验都能直接复制使用。项目本身不算复杂但想把“推荐效果”调到一个看得过去的水平还是有不少细节值得琢磨。比如相似度算法选皮尔逊还是余弦、评分预测时要不要做归一化、近邻数量k取多少合适这些参数对结果的影响比想象中大得多。我下面会从整体设计思路开始逐步拆解每一部分代码再分享实际运行中遇到的典型问题和排查方法。1. 项目整体思路与推荐算法选型1.1 推荐系统的核心问题与两类主流解法推荐系统本质上回答一个问题用户u对物品i的偏好程度是多少。这个“偏好”在电影场景里通常体现为评分但在实际业务中也可以是点击、收藏、加购、观看时长等隐式反馈。评分高低直接决定是否把这部电影放进推荐列表所以推荐系统的核心就是预测用户对未交互物品的评分。主流解法分成三派基于内容的推荐、协同过滤、混合推荐。协同过滤又分成基于用户的(User-based CF)和基于物品的(Item-based CF)两种。基于内容的推荐思路最简单——把电影的特征类型、导演、演员、标签变成向量用户喜欢什么特征的电影就推相似特征的电影。协同过滤不谈内容只看行为跟你口味相似的人喜欢什么电影就推给你或者某部电影被和你类似的人喜欢过就推给你。这个项目之所以三种都做不是为了炫技而是因为它们在真实场景里各有短板。基于内容的推荐解决不了“用户口味跨类型”的问题比如一个用户只看过动作片系统永远给他推动作片不会发现他也喜欢温情文艺片因为内容特征上没有任何关联。协同过滤则受冷启动困扰新用户没有任何行为数据新电影没有任何人评分CF直接失效。把三种方法放在一起对照实现你能直观感受到各自的适用边界这也是我建议初学者不要只跑通一个模型就停下来的原因。1.2 为什么选MovieLens数据集与环境准备项目采用MovieLens公开数据集准确说是ml-latest-small版本包含600多名用户对9000多部电影的约10万条评分记录。选它有几个实际考虑数据量适中内存占用低普通笔记本秒级跑完评分范围固定为0.5到5.0的整数或半步做回归预测时方便计算误差每条评分都带时间戳比分重复问题少适合做时间维度上的切分验证。环境方面只需要Python 3.8以上依赖pandas、numpy、scikit-learn三个库。有一个需要提前说明的坑不要一上来就装最新版scikit-learn有些旧代码里调用的接口在新版本中改名了比如sklearn.model_selection.train_test_split在很老的项目里写作sklearn.cross_validation如果你复制了网上的历史代码就会直接报错。推荐在项目目录里用虚拟环境管理依赖python -m venv rec_env source rec_env/bin/activate # Windows下执行 rec_env\Scripts\activate pip install pandas numpy scikit-learn评分数据虽然干净但也不要跳步直接建模。每一步前先用df.head()、df.info()、df.describe()扫一眼数据结构能省掉后面大量排查时间。这部分代码是整个项目的地基地基歪了后面全白干。2. 数据准备与预处理细节2.1 数据加载与格式说明MovieLens数据解压后核心是三个文件ratings.csv用户ID、电影ID、评分、时间戳、movies.csv电影ID、标题、类型、tags.csv用户打标记录。其中用户ID和电影ID都是整数电影类型用竖线分隔的多个标签表示比如“Action|Adventure|Sci-Fi”。加载代码非常简单import pandas as pd import numpy as np ratings pd.read_csv(ratings.csv) movies pd.read_csv(movies.csv) print(ratings.shape) print(ratings.head()) print(ratings[rating].describe())但这里就有一个容易被忽视的细节原始数据里的UserId不是连续的有的用户ID从1开始跳过好几个数MovieId也不是100%连续的。如果直接拿原始ID当数组索引后面构建用户-物品矩阵的时候会留出大量空洞。标准做法是把UserId和MovieId重新映射成从0开始的连续整数索引user_ids ratings[userId].unique() movie_ids ratings[movieId].unique() user2idx {uid: i for i, uid in enumerate(user_ids)} movie2idx {mid: i for i, mid in enumerate(movie_ids)} ratings[user_idx] ratings[userId].map(user2idx) ratings[movie_idx] ratings[movieId].map(movie2idx)这一步做完后面构造稀疏矩阵、做矩阵运算都会简单很多。顺便说一句构造映射字典用enumerate很顺但不要用dict(zip(...))去映射时忘记去重否则重复ID会覆盖映射导致错位。2.2 数据清洗与探索性分析数据清洗这一步看起来可有可无但实际做推荐系统时数据质量直接决定效果上限。我做完加载后一定会检查以下几项是否有空值ratings.isnull().sum()有则直接丢弃对应行。是否有重复记录同一用户对同一电影出现多次评分可能是采集端没合并会影响训练和评估需要去重。评分分布是否合理打印value_counts()看每个分数档位的数量如果大量集中在5分说明用户打分习惯偏好极端做均值归一化时要留意。给一段我当时用的探索性分析代码print(ratings[rating].value_counts().sort_index()) print(用户数:, ratings[user_idx].nunique()) print(电影数:, ratings[movie_idx].nunique()) print(平均每用户评分数量:, ratings.groupby(user_idx).size().mean()) print(平均每电影获得评分数量:, ratings.groupby(movie_idx).size().mean())这些统计数字能帮你判断数据稀疏程度。如果你的评分数据里平均每个电影只有几十条记录那么基于物品的协同过滤会比基于用户的表现更稳定因为物品侧的数据通常更集中。实际跑下来MovieLens这个数据集的稀疏度大约是0.016也就是用户-物品矩阵中只有1.6%的位置有值这在推荐领域已经算相当“稠密”了真实业务里的稀疏度经常是0.1%以下。3. 基于用户的协同过滤实现3.1 相似度算法选择皮尔逊还是余弦基于用户的协同过滤核心是计算用户之间的相似度。最常用的两种指标是皮尔逊相关系数和余弦相似度。两者公式上高度相关但行为有细微差别。余弦相似度衡量的是两个向量的夹角不关心向量长度皮尔逊相关系数在余弦之前先对每个用户的评分做了中心化减去该用户自己的平均分所以它能够消除用户评分尺度差异的影响。举个例子用户A打分区间是3到5用户B打分区间是1到5两人对共同看过的电影偏好顺序完全一致。用余弦相似度算出来的值会被A的“高分习惯”拉低但皮尔逊相关系数能识别出两人口味一致因为A的3分可能就等于B的5分。这个例子非常经典也是我强烈建议在评分场景里优先选皮尔逊的原因。代码实现上可以用pandas的corrwith向量化处理也可以用scipy的pearsonr。但注意直接用corrwith需要先把user-item矩阵的缺失值填成0这种做法会引入大量“无评分”参与计算数学上不正确。正确的做法是只基于两人共同评分的电影计算相似度。这里我直接用numpy实现一个高效版本def pearson_sim(a, b): mask (a ! 0) (b ! 0) if mask.sum() 2: return 0.0 a_common a[mask] b_common b[mask] a_centered a_common - a_common.mean() b_centered b_common - b_common.mean() denom np.sqrt((a_centered ** 2).sum() * (b_centered ** 2).sum()) if denom 0: return 0.0 return (a_centered * b_centered).sum() / denom3.2 用户评分预测与Top-N推荐有了用户相似度矩阵预测用户u对未评分电影i的评分标准的办法是找出与u最相似的k个用户这些用户里对i有评分的人加权投票。权重就是相似度而且实践中通常会做一层归一化让相似度权重落在0到1之间避免负相关用户干扰def predict_rating(user_idx, movie_idx, user_item_matrix, sim_matrix, k20): sim_scores sim_matrix[user_idx].copy() # 排除自己 sim_scores[user_idx] -np.inf top_k_idx np.argsort(sim_scores)[::-1][:k] rated_users user_item_matrix[:, movie_idx] ! 0 neighbors [i for i in top_k_idx if rated_users[i]] if not neighbors: return float(np.mean(user_item_matrix[user_idx, :][user_item_matrix[user_idx, :] ! 0])) numerator 0.0 denominator 0.0 for n in neighbors: w sim_matrix[user_idx, n] if w 0: continue r user_item_matrix[n, movie_idx] # 使用不同用户的平均分做baseline避免用户打分区间的偏差 user_mean user_item_matrix[n, :][user_item_matrix[n, :] ! 0].mean() numerator w * (r - user_mean) denominator w if denominator 0: return float(np.mean(user_item_matrix[user_idx, :][user_item_matrix[user_idx, :] ! 0])) unbiased_pred numerator / denominator u_mean user_item_matrix[user_idx, :][user_item_matrix[user_idx, :] ! 0].mean() return u_mean unbiased_pred这段代码里有两个非常关键的处理第一个是“只选对目标电影有评分的近邻”否则近邻里没看过这部电影的人会把预测值拉向均值第二个是“评分减均值的偏差加权”这其实就是皮尔逊思想在预测阶段的延伸效果比直接加权原始分数好得多。Top-N推荐就是遍历某个用户所有未评分电影用上面的函数预测评分取分数最高的N部电影再和movies.csv联表查出标题和类型。3.3 完整可运行代码示例把上面两段组合起来整个训练和推荐流程可以收敛成一个脚本。为了控制在单机内存可承受的范围这里做一个简化只取评分数量最多的前100个用户和评分数量最多的前500部电影进行实验这样既能快速出结果又不会把机器跑挂user_counts ratings[user_idx].value_counts() movie_counts ratings[movie_idx].value_counts() active_users user_counts[user_counts 20].index[:100] hot_movies movie_counts[movie_counts 5].index[:500] sub_ratings ratings[ratings[user_idx].isin(active_users) ratings[movie_idx].isin(hot_movies)] user_item np.zeros((len(active_users), len(hot_movies))) for uid, mid, score in sub_ratings[[user_idx, movie_idx, rating]].values: u_pos list(active_users).index(uid) m_pos list(hot_movies).index(mid) user_item[u_pos, m_pos] score sim_matrix np.zeros((len(active_users), len(active_users))) for i in range(len(active_users)): for j in range(i 1, len(active_users)): s pearson_sim(user_item[i], user_item[j]) sim_matrix[i, j] s sim_matrix[j, i] s这里用双层循环算相似度时间复杂度O(n^2 * m)对于小数据集没问题但真实数据集上我不会这么写而是改为向量化或分块并行计算。运行上述代码时你会明显发现真正消耗时间的是两层for循环而不是预测本身这是纯Python实现协同过滤的固有瓶颈后面我会提怎么优化。4. 基于物品的协同过滤实现与对比4.1 物品相似度矩阵构建基于物品的协同过滤思路跟基于用户完全对称先算电影之间有多像再推荐“跟你喜欢的电影相似的其他电影”。这里有一个业界共识值得先说明现代工业界里基于物品的协同过滤比基于用户的应用更广泛。原因是用户的规模通常比物品大几个量级用户相似度矩阵是n×n规模的物品矩阵是m×m规模的在hotel或电商场景里m远小于n计算和维护成本低很多。计算物品相似度时要把数据从“用户-物品”矩阵转置成“物品-用户”矩阵。我的代码里直接用user_item.T即可。注意行数代表电影每行是这个电影在各个用户处的评分向量评分人数越多的电影其相似度分数越“可信”。4.2 推荐逻辑与两种CF效果对比基于物品的推荐逻辑是用户A评分5星了电影X系统找出与X最相似的10部电影把用户没看过的那几部推给他。评分预测公式比用户CF更直接因为物品的“质量”通常被认为是稳定的不需要做均值剔除处理def item_based_predict(user_vector, item_sim, movie_idx, k20): # user_vector: 长度m的评分向量 # item_sim: m×m矩阵, item_sim[i][j]是电影i和j的相似度 rated_items np.where(user_vector ! 0)[0] if movie_idx in rated_items: return user_vector[movie_idx] denom 0 num 0 for r_item in rated_items: w item_sim[movie_idx, r_item] if w 0: continue num w * user_vector[r_item] denom w if denom 0: return float(np.mean(user_vector[user_vector ! 0])) return num / denom实现两个模型后我做了个直观的效果对比。同样给某个核心用户做推荐用户CF给出的结果偏向“小众但口味高度匹配”的电影物品CF给出的结果偏向“热门且类型接近”的电影。如果从用户体验出发用户CF更能带来惊喜但冷启动情况下完全没法用物品CF在实时性上新用户只要有一个评分就能产出推荐落地门槛低很多。5. 基于内容的推荐解决冷启动问题5.1 电影特征提取与标签向量化协同过滤虽然强但拿新用户和新电影完全没办法。基于内容的推荐恰好能补上这块短板它不依赖任何评分记录只需要知道电影的“内容特征”和用户的历史偏好。MovieLens的movies.csv里有电影类型字段这是最现成的特征。处理方式是把类型字符串拆开构造one-hot编码每个类型一列电影属于该类型就标1一个电影可以多个类型这就是典型的“多标签向量”。代码实现非常直接genre_list [] for raw_genres in movies[genres].str.split(|): genre_list.extend(raw_genres) genres sorted(set(genre_list)) movie_genre_matrix np.zeros((len(movies), len(genres)), dtypenp.int8) for i, raw_genres in enumerate(movies[genres].str.split(|)): for g in raw_genres: if g (no genres listed): continue movie_genre_matrix[i, genres.index(g)] 1更精细的做法是把导演、演员、标签文本也加进来用TF-IDF做向量化但电影类型已经能提供足够的可解释性和区分度对于demo级项目完全够用。5.2 基于内容的推荐实现用户画像可以简单地表示为他对每个类型的平均评分。假如用户看过10部动作片平均4.5分看过5部爱情片平均2.0分那么他未来的向量应该偏向动作片。计算公式就是所有已评电影特征向量的“评分加权平均”def build_user_profile(user_idx, user_item, movie_genre_matrix): scored np.where(user_item[user_idx] ! 0)[0] if len(scored) 0: return np.zeros(movie_genre_matrix.shape[1]) scores user_item[user_idx, scored] genres_sum np.zeros(movie_genre_matrix.shape[1]) for i, m_idx in enumerate(scored): genres_sum scores[i] * movie_genre_matrix[m_idx] return genres_sum / scores.sum()得到用户画像向量后将所有未评分电影的类型向量与画像向量做余弦相似度分数Top-N就是推荐结果。这种方式不需要任何训练过程可解释性极强“因为你喜欢动作片所以推荐这部动作冒险电影”。但它的局限也很明显推荐结果永远出不了用户已经接触过的类型圈。我给用户看过一个很典型的案例某用户历史评分全是科幻和动作片内容推荐列表里前20名全是这两类没有一部爱情或喜剧。所以工业界通常不会单独用基于内容而是把它作为“冷启动补充通道”或“召回粗排”的一路。6. 推荐质量评估与调参思路6.1 评估指标与时间序列划分推荐系统评估不是看某一次推荐结果爽不爽而是要用指标量化。离线评估最常用的是RMSE和MAE它们衡量预测评分和真实评分的平均误差。计算前要把评分数据划分成训练集和测试集。这里有一个比随机划分更严谨的方案按时间排序对每个用户取最后20%的评分作为测试集其余80%作为训练集。原因在于推荐系统服务的是“未来行为预测”用历史预测未来才符合实际场景。from sklearn.metrics import mean_squared_error, mean_absolute_error ratings[timestamp] pd.to_datetime(ratings[timestamp], units) ratings_sorted ratings.sort_values(timestamp) test_ratio 0.2 test_ratings ratings_sorted.groupby(userId).tail(int(ratings_sorted.groupby(userId).size().mean() * test_ratio)) train_ratings ratings_sorted.drop(test_ratings.index)这里的groupby().tail()有个坑如果某些用户评分数量很少tail取出来的数据可能少于20%极端情况下甚至取不到。稳妥做法是先过滤掉评分数据低于10条的用户再执行划分。6.2 参数调整经验记录我把关键参数的影响记录下来了方便你参考参数取值对效果的影响近邻数量k5 ~ 50k太小受单个垃圾近邻影响大太大引入噪音20左右是MovieLens的甜点区相似度阈值0.1 ~ 0.5低于阈值的近邻直接舍弃能有效过滤负相关/弱相关噪声是否做均值中心化True/False对皮尔逊预测非常关键不做的话预测分数普遍偏高0.3~0.5数据划分方式随机 vs 时间序列随机划分的RMSE通常比时间序列小10%~20%但这是假象线上效果会差一个特别反直觉的体会是并不是相似度越高的近邻贡献越大。我在实验中发现如果某个用户只看过一部电影并且给了5分他跟目标用户的相似度会虚高因为单点重合的样本太少统计意义很弱。所以代码里还要加一个人数门槛比如要求两个用户至少有5部共同评分电影否则相似度直接置0。这个调整对RMSE的改善非常明显从0.98降到了0.91左右。7. 实操中的常见问题与排查技巧7.1 数据稀疏与内存占用问题一旦把用户物品矩阵构建成numpy二维数组内存就会按n*m增长。MovieLens 1M版本有6000用户和4000电影float64数组大约是192MB这还能接受但如果换成真实业务里的千万用户和百万物品二维稠密矩阵直接爆内存。我在这类项目里常用的优化手段有三种改用scipy.sparse.csr_matrix存储只记录有评分的位置。用户CF转物品CFm通常远小于n。相似度矩阵分批计算、落地缓存不要重复构建。这里还要提一个常见的隐蔽错误numpy矩阵默认用float64如果评分数据本身是整数用np.float32就够内存直接砍半。很多人忽略这个细节。7.2 冷启动问题的三种应对方式冷启动分两类新用户没有历史行为新电影没有评分。这个项目里我做的比较全面的应对是新用户冷启动放一个“热门榜”兜底也就是全站评分数量最多、评分均值最高的电影组合。新电影冷启动用基于内容推荐把新电影的类型特征和已有电影算相似度找合适的召回位。混合通道用户评分数量少于5条时直接走热门榜加内容推荐达到阈值后切换协同过滤。很多人问这个阈值怎么定我的经验是小于等于5条时用户观影偏好分布根本不稳强行上协同过滤预测出来的分数可能非常离谱直接从热门榜里选是性价比最高的方案。7.3 结果不合预期的排查思路我踩过最典型的坑是推荐列表里出现大量用户已经看过的电影。这种情况多半是遍历预测时把训练集里的已评分电影也纳入了候选集导致预测分数非常接近真实高分冲进Top-N里。排查时只要在生成候选集时加一句unrated_mask user_item[user_idx] 0 candidates np.where(unrated_mask)[0]另一个坑是时间穿越。如果训练集和测试集划分时没有按时间排序而是随便随机切那么模型会“看到未来”的评分测试集上的RMSE虚低。这个现象在数据量小时尤其明显我的一次实验里随机划分RMSE是0.86按时间划分直接变成了0.96。别把0.86当做好结果那是模型作弊了。还有一个经常被问的问题“为什么推荐出来的电影评分均值才3.5”因为均值回归效应。评分极端分布的数据被中心化处理后预测值自然向平均值收缩这不是bug。如果你希望推荐列表看起来更有“惊喜感”可以在预测分数后加一个很小的权重偏置或者改用排序学习的方式去优化用户交互点击率而不是纯回归分数。8. 项目扩展思路与个人实操心得做完这个项目以后我最大的体会是推荐系统60%的精力要花在数据和评测上真正的模型部分反而是最“标准化”的。很多人一上来就折腾深度学习模型、图神经网络但其实协同过滤、逻辑回归这波经典方法配合好的特征工程和评估体系已经能解决大量业务问题。如果你想在现有代码基础上继续扩展我建议按这个优先级来加入相似度矩阵的持久化缓存用npy格式存盘避免每次重算。把训练好的模型封装成函数再接一个简单的Flask API做成一个能实时响应的推荐服务。加入ALS矩阵分解作为第四个算法对比一下它在稀疏数据上的表现是否优于协同过滤。在评估环节加入PrecisionN和RecallN直接衡量推荐列表中命中用户真实喜欢电影的比例。最后分享一个小技巧调参时不要每次只改一个参数看半天结果直接把参数组合做成笛卡尔积跑网格搜索用RMSE热力图看整体趋势。这样既能发现参数之间的相互作用又能快速定位最优区间。整套代码跑通之后你可以试着把数据集换成自己的业务数据——哪怕是图书、音乐、商品评分算法骨架都不用大改改数据加载逻辑就行。这个迁移能力才是做这些实验真正值钱的地方。本文还有配套的精品资源点击获取