简介:基于Python的协同过滤电影推荐系统源码包,完整覆盖用户相似度计算、基于用户与基于物品的两种协同过滤实现、推荐排序及结果评估等核心环节,适合推荐系统初学者、算法研究者以及需要完成毕业设计的开发者参考借鉴。压缩包内含228个文件,整体约6.92MB,以17个py源码文件为主体,承载数据清洗、用户相似度计算与推荐生成逻辑;另有14个pyc编译文件、7个csv数据集、1个sql数据库脚本,以及css、js、html等前端页面文件和148张界面展示图片,构成一套可运行、可扩展的完整项目工程。当前已有60人学习下载,尤其适用于需要从零搭建推荐系统原型的实验场景。借助这份源码,读者能直观理解协同过滤如何处理用户评分记录,掌握余弦相似度、皮尔逊相关系数等经典相似度计算方法,并参考其前端交互与可视化设计快速迭代自己的实验项目;同时,源码中包括的评估与优化思路,也为后续引入矩阵分解或深度学习模型、进一步解决冷启动等问题提供了扎实基础。
1. 打开这个zip的正确顺序:先把三个CSV当说明书看完
打开这个压缩包,最先看到的不是 .py 文件,而是一堆 bootstrap.min.css、Test.css、firstPage.css、star.css 这类样式文件——我第一反应是怀疑自己下错了资源。再往下翻,ratings.csv、MoviesGenre3.csv、rrtotaltable.csv 三张表摆在面前,Python 源码夹在中间,这才确认它是一个能直接跑起来的基于协同过滤算法的电影推荐系统,不是只有公式的讲义。它解决的核心问题很具体:根据用户历史评分,从几千部电影里推断他下一步最可能看什么。适合正在准备推荐系统相关课题的学生,也适合想快速在 Flask 或 Django 后端里嫁接一个个性化推荐接口的从业者。我拆完这个包之后最大的感受是:这个项目最好的入口不是先读源码,而是先把三个 CSV 当说明书读完。
2. 从三个CSV到评分矩阵:数据预处理与稀疏度控制
2.1 三张表是协同过滤的输入输出契约
ratings.csv、MovieGenre3.csv、rrtotaltable.csv,三张表几乎把协同过滤的输入、辅助信息和输出都定义完了。拿到手第一件事就是把它们并列放在一起看字段,因为它决定了后面的矩阵怎么建、预测结果怎么复核。
| 文件 | 典型字段 | 在推荐链路里的角色 |
|---|---|---|
| ratings.csv | userId, movieId, rating, timestamp | 协同过滤的主输入:谁在什么时候给哪部电影打了多少分 |
| MovieGenre3.csv | movieId, title, genres | 电影元数据:用于展示标题、按类型做冷启动兜底 |
| rrtotaltable.csv | userId, movieId, rating, predict, title, rank | 作者跑完算法导出的推荐结果,相当于一份“标准答案”样例 |
很多初学者拿到压缩包,第一眼会往 Python 源码里冲,想赶紧看协同过滤怎么写的。但真正会让我反复翻看的,是 rrtotaltable.csv。它把算法的输出结构固定成了“用户 ID + 电影 ID + 预测分 + 排名”,你后面写的任何推荐函数,最终都要落到这个格式上。这不是玄学,是工程约定:前端要展示推荐列表、后端要做分页和日志,靠的就是这个统一输出格式。
2.2 用Pandas把长表转成评分矩阵
rating.csv 是典型的“长表”:每个用户对每部电影的一次评分就是一行。协同过滤算法需要的却是“宽表”:行是用户,列是电影,单元格是分数。这个转换不做好,后面所有相似度计算都是空谈。我一般会先用 Pandas 做一次合并,把电影标题带过来,然后直接用 pivot_table 转矩阵。
import pandas as pd import numpy as np # 读取评分数据,dtype 显式指定可以避免大表时 float 被读成 object ratings = pd.read_csv( 'ratings.csv', dtype={'userId': 'int32', 'movieId': 'int32', 'rating': 'float32'} ) # 读取电影信息,只保留构建矩阵和兜底推荐用到的字段 movie_info = pd.read_csv( 'MovieGenre3.csv', dtype={'movieId': 'int32'} )[['movieId', 'title', 'genres']] # 把电影标题联回评分表 merged = ratings.merge(movie_info, on='movieId', how='left') # 长表转宽表:index=userId, columns=movieId, values=rating rating_matrix = merged.pivot_table( index='userId', columns='movieId', values='rating' ) print('用户数 x 电影数:', rating_matrix.shape) print('稀疏度: %.2f%%' % (100 * rating_matrix.notna().sum().sum() / rating_matrix.size))这段代码里最容易被忽略的是 dtype 参数。rating 表在实际项目中往往几百 MB,如果不指定 dtype,Pandas 会按 int64 和 float64 去猜,内存占用直接翻倍。pivot_table 之后得到的 rating_matrix,默认会把缺失评分填成 NaN,这一点非常关键——它代表“用户没看过这部电影”,而不是“用户打了 0 分”。所以稀疏度算出来通常会超过 95%,也就是说矩阵里绝大多数格子都是空值。
2.3 均值中心化:消除用户打分尺度差异
拿到评分矩阵后,直接算相似度是一个很常见的低级错误。因为每个人的打分习惯不一样,有人习惯打 3 到 4 分,有人要么 1 分要么 5 分,原始的 4 分对不同用户含义完全不同。解决方式是做均值中心化:把每个用户自己的平均分减掉,剩下的值代表“这部电影比这个用户平时口味高/低多少”。
# 每个用户的平均评分,axis=1 表示按行计算 user_mean = rating_matrix.mean(axis=1) # 归一化矩阵:减去用户均分,缺失评分先填 0 # 注意:这个 0 只在相似度计算时用,不代表真实评分 norm_matrix = rating_matrix.sub(user_mean, axis=0).fillna(0.0) # 同样可以算每部电影的平均分,Item-based 预测时会用到 item_mean = rating_matrix.mean(axis=0) print('示例用户均值:', user_mean.head(3).round(2).to_dict())参数 axis=0 的含义是沿着行方向做减法,也就是每个用户减自己的均值。fillna(0.0) 把缺失位置补成 0,因为中心化后的 0 表示“和用户平均口味持平”,这在数学上比直接补原始评分靠谱得多。但这里有个隐藏坑:如果用户只打过一部电影,中心化后该行只剩一个 0,其他全是 0,这个用户和谁算相似度都得不到有效邻居。后面避坑章节会专门说这个场景。
3. 两种协同过滤的实现:余弦相似度、User-based与Item-based
3.1 相似度计算的三个候选:余弦、皮尔逊、Jaccard
协同过滤的本质是找“关系近的人或物”。关系怎么量化?源码包里最常用的就是余弦相似度、皮尔逊相关系数和 Jaccard 相似度。它们不是互相替代的关系,而是适用场景不同。
| 方法 | 数学直觉 | 适合场景 | 缺点 |
|---|---|---|---|
| 余弦相似度 | 向量夹角越小越相似 | 评分向量完整时效果好 | 对用户评分尺度敏感 |
| 皮尔逊相关系数 | 减去均值后的余弦 | 评分尺度不一时更稳 | 冷启动时容易分母为 0 |
| Jaccard 相似度 | 交集 / 并集 | 只看是否看过,不管打几分 | 丢失评分强度信息 |
在 Python 里计算三种相似度都不难。我会优先用 scikit-learn 的 cosine_similarity,因为它内部对稀疏数据做了优化,比自己写循环快一个量级。
from sklearn.metrics.pairwise import cosine_similarity # 对中心化后的矩阵做用户相似度:行是用户 user_sim = cosine_similarity(norm_matrix) user_sim_df = pd.DataFrame( user_sim, index=rating_matrix.index, columns=rating_matrix.index ) # 对中心化矩阵转置,可以算物品相似度 item_sim = cosine_similarity(norm_matrix.T) item_sim_df = pd.DataFrame( item_sim, index=rating_matrix.columns, columns=rating_matrix.columns )注意 cosine_similarity 默认会对向量做 L2 归一化,所以直接传入原始评分矩阵也可以跑,但效果远不如先做均值中心化。如果追求更严格的皮尔逊系数,可以直接调用 DataFrame.corr(method='pearson'),不过当矩阵里有大量 NaN 时,corr 只能利用两个用户共同评过分的电影来计算,共同评分太少时相关系数会非常不稳定。这也是为什么实践中很多人宁可用“中心化 + 余弦”来代替皮尔逊,形式上等价,但数值稳定性更好。
3.2 User-based CF:找口味相近的人,加权算预测分
基于用户的协同过滤逻辑很简单:找到和目标用户最像的 K 个用户,看这 K 个人怎么评价一部目标用户没看过的电影,然后按相似度加权汇总。源码包里如果只实现了一个预测函数,大概率就是这段逻辑。
def user_based_predict(user_id, movie_id, K=20, threshold=0.1): # 电影不存在于评分矩阵时直接返回 NaN if movie_id not in rating_matrix.columns: return np.nan # 取出用户之间相似度行 sims = user_sim_df.loc[user_id] # 只保留给这部电影打过分的其他用户 other_users = rating_matrix[movie_id].dropna().index candidates = sims[other_users].drop(user_id, errors='ignore') # 过滤相似度过低的,再取前 K 个 candidates = candidates[candidates >= threshold].nlargest(K) if len(candidates) == 0: return np.nan # 加权求和:归一化评分 * 相似度 / 相似度总和,最后加回用户均值 weights = candidates.values norm_vals = norm_matrix.loc[candidates.index, movie_id].values pred = (weights * norm_vals).sum() / weights.sum() + user_mean.loc[user_id] return round(float(pred), 4)这里 K 控制邻居数量,threshold 用来过滤相似度太低的用户。K 取太小,预测方差大;K 取太大,会把不相关的用户也拉进来。常见做法是 K=20 到 K=50 之间,再配合交叉验证去找最优值。threshold=0.1 的意思是相似度低于 0.1 的邻居直接不要,作用是防止一堆低相似度用户把预测结果拖向全局均值。
外层调用时需要处理两个失败分支:电影不存在返回 NaN,邻居不足返回 NaN。NaN 会向后传导,最后前端会看到一个空推荐位,这就是冷启动问题的一种表现。
3.3 Item-based CF:转置评分矩阵,把电影当用户算
基于用户的 CF 最大的问题是:用户数量增长很快,每次都要算两两用户之间的相似度,在线场景基本扛不住。而基于物品的 CF 是提前算好电影之间的相似度,在线推荐时只需要查表。电影数量相对稳定,所以这个方案被工业界用得更广泛。
def item_based_predict(user_id, movie_id, K=20, threshold=0.0): if movie_id not in item_sim_df.columns: return np.nan # 取目标电影的相似度行 sims = item_sim_df[movie_id] # 找出目标用户看过的电影 rated_items = rating_matrix.loc[user_id].dropna() # 从看过的电影里,找出与目标电影最像的 K 部 candidates = sims[rated_items.index].drop(movie_id, errors='ignore') candidates = candidates[candidates >= threshold].nlargest(K) if len(candidates) == 0: return np.nan # 用候选电影的评分加权平均预测目标电影得分 weights = candidates.values user_ratings = rated_items.loc[candidates.index].values pred = (weights * user_ratings).sum() / weights.sum() return round(float(pred), 4)Item-based 的预测公式比 User-based 少一步均值回填,因为这里衡量的是电影之间的绝对相似度,用户的评分尺度影响不大。不过更好的做法是把用户均值和电影均值都纳入公式,比如:pred = item_mean[movie_id] + (加权评分的偏离量)。这个偏离量就是候选电影评分减去各自均值的加权和。
源码包里的 rrtotaltable.csv 如果对比过两套算法,你会发现 Item-based 的结果通常“更稳”:它不会因为某个新用户只有三五条评分就完全崩掉,只要用户看过的电影里有一部能和目标电影算相似度,就能给出预测。缺点是冷门电影因为很少被共同评分,相似度全是零,永远没有露脸机会。所以很多系统会在相似度矩阵里加一个“平滑项”,给冷门电影一个最低权重。
4. 协同过滤排查与调参:四个必查点
4.1 新用户一进来,推荐列表全空白
现象:用户 ID 在 ratings.csv 里没有任何评分记录,调用 user_based_predict 或 item_based_predict 返回值全是 NaN,前端榜单直接空白。
原因:协同过滤完全依赖历史评分建立联系。新用户是“无历史、无邻居、无评分”的三无用户,相似度矩阵里对应行全为零,候选邻居被 threshold 全部过滤掉。
解决:在推荐入口加一个回退策略。最直接的办法是取全局热度榜兜底:按所有评分的平均分排序,选前 N 部电影给新用户。更精细一点的做法是使用 MovieGenre3.csv 的 genres 字段做类型覆盖率,先让用户选三个喜欢的类型,然后从这些类型里按热度排序。
def safe_recommend(user_id, top_n=10): preds = batch_predict(user_id) if preds.dropna().empty: # 回退到全局热度榜:评分数量 * 平均分加权 hot = rating_matrix.count().mul(rating_matrix.mean()).sort_values(ascending=False) return hot.head(top_n).index.tolist() return preds.sort_values(ascending=False).head(top_n).index.tolist()这个逻辑看起来简单,但能挡住线上环境最尴尬的“白屏推荐”。后来我养成的习惯是:任何推荐接口上线前,先用一个从没出现过的 userId 去压一遍测试,确认兜底逻辑有效再放行。
4.2 评分全是一个均值,预测结果毫无区分度
现象:算法跑通了,但每个用户拿到的 Top-10 推荐列表高度相似,预测分都集中在 3.5 到 3.8 之间,几乎没有波动。
原因:训练矩阵过于稀疏,大部分候选电影只有零星几个人看过,相似度计算时这些冷门电影的邻居数量太少,加权平均后自然滑向全局均值。另一个原因是用户评分数据本身就集中在中段,比如大部分人都打 3 到 4 分,中心化之后的偏差值很小。
解决:先看评分分布。如果 ratings.csv 里评分方差过小,第一优先级不是调算法,而是检查数据采集口径:是不是用了“五星制但只允许三星起评”的控件?如果是算法本身的问题,可以给相似度加一个“共同评分数量”下限。
# 约束:两个用户至少共同评过 5 部电影,相似度才有效 common_count = rating_matrix.notna().astype(int).dot(rating_matrix.notna().astype(int).T) valid_mask = common_count >= 5 user_sim_df = user_sim_df.where(valid_mask, 0.0)4.3 相似度被热门电影带偏,推荐结果全是“大众爆款”
现象:推荐列表里频繁出现钢铁侠、复联这类高热度电影,用户明明偏好小众文艺片,结果榜上全是商业大片。
原因:余弦相似度在计算电影相似度时,热门电影因为被大量用户评过分,在很多用户的 norm 向量里都残留非零值,导致它与大部分电影都有不低的相似度。相似度被“流行度”主导,而不是被“共同偏好”主导。
解决:对 norm_matrix 做流行度惩罚。常见做法是在计算物品相似度时,把电影的被评次数作为分母权重,类似 IDF 的思路。
# 电影流行度:被评次数 item_popularity = rating_matrix.notna().sum(axis=0) # 惩罚系数:被评次数越多的电影,权重越低 penalty = 1.0 / (1.0 + np.log(item_popularity)).values # 加权后再算相似度,需要逐项相乘 weighted_norm = norm_matrix.mul(penalty, axis=1) item_sim_penalized = cosine_similarity(weighted_norm.T)这个处理会让冷门电影在相似度矩阵里获得更多话语权,但也别惩罚过度,否则相似度会变得很碎,推荐结果跳跃性太强。penalty 公式里的 +1 是防止极端数据下取对数出错,工程上是个常见小细节。
4.4 训练测试划分不当,评估分数虚高
现象:把评分数据随机按 80/20 切分,训练集和测试集完全随机混合,然后拿测试集数据回测,准确率和召回率都高得离谱,上线后推荐效果却明显缩水。
原因:随机划分导致同一个用户的多条评分被同时分到训练集和测试集,算法在训练时已经见过用户对某部电影的口味,测试时只不过是在“回忆答案”。这属于数据泄漏,不是真实泛化能力。
解决:按用户维度做划分。比如每个用户取其最早 80% 的评分做训练,最晚 20% 的评分做测试。ratings.csv 里本身有 timestamp 字段,正好用上时间序划分。
from sklearn.metrics import precision_score, recall_score, f1_score def temporal_split(ratings, test_ratio=0.2): ratings = ratings.sort_values(['userId', 'timestamp']) train_list, test_list = [], [] for _, group in ratings.groupby('userId'): cut = int(len(group) * (1 - test_ratio)) train_list.append(group.iloc[:cut]) test_list.append(group.iloc[cut:]) return pd.concat(train_list), pd.concat(test_list) train_df, test_df = temporal_split(merged)这种划分方式更接近真实场景:用户只拥有过去的数据,要预测的是未来的行为。评估时再用 precision@K 和 recall@K 看推荐命中率,而不是拿预测分数和真实分数对比。预测分数是一个序数概念,和真实分差 0.5 不代表推荐失败,关键看排序是否正确。
5. 进阶:用 rrtotaltable.csv 反推模型质量,做好冷启动验证
rrtotaltable.csv 表面上是推荐结果导出,其实是一个很好的“对照样本”。我拿到这个文件后做的第一件事,是把它和原始 ratings.csv 做一次差分比较:看看哪些电影被推荐给了用户,但这些用户从来没给它们评过分。这才是协同过滤真正产生价值的地方。
进阶玩法是把这个表当作一个回归目标,用代码去反推算法参数。比如你怀疑邻居数量 K 没调好,可以固定其他参数不变,分别跑 K=10、20、50,然后用 rrtotaltable.csv 里的预测分数和当前模型的预测分数做 RMSE 对比,选误差最小的 K。这一步直接把你从“看代码”推进到“调代码”。
# 顺手用命令行先看 rrtotaltable.csv 的列结构和行数 head -5 rrtotaltable.csv wc -l rrtotaltable.csv另一个我特别建议做的最小验证,是冷启动测试。
# 构造一个只有 1 条评分的“半冷启动”用户 fake_user_id = 99999 rating_matrix.loc[fake_user_id] = 0 rating_matrix.loc[fake_user_id, 1] = 5.0 norm_matrix.loc[fake_user_id] = rating_matrix.loc[fake_user_id] - rating_matrix.loc[fake_user_id].mean() cold_pred = user_based_predict(fake_user_id, 1) print('半冷启动用户预测分:', cold_pred)如果这条预测返回 NaN,说明算法的邻居过滤条件太苛刻,threshold 或 K 需要调低。如果返回一个接近用户均值的分数,说明预测结果还有参考价值,但要注意置信度很低。然后你可以同样拿这个假用户走一遍 item_based_predict,比较两种算法在冷启动下的表现差异。这个测试过程我每次拆新的推荐项目都会强制走一遍,因为它能在五分钟内暴露相似度矩阵是否可行、兜底策略是否生效、评分尺度是否统一,比直接跑完整评估更快。希望这个验证习惯也能帮到你少走一些弯路。
本文还有配套的精品资源,点击获取