尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于协同过滤的电影推荐系统实战:Python实现与Web服务部署

基于协同过滤的电影推荐系统实战:Python实现与Web服务部署 简介一套基于协同过滤推荐算法的电影推荐系统毕业设计项目面向计算机相关专业学生适用于毕业设计、课程设计或期末大作业。项目已获导师指导并通过答辩评审分97分源码完整、数据库齐全下载后无需修改即可运行。技术实现上采用Python作为后端利用协同过滤算法计算用户与物品相似度并生成个性化推荐前端采用Vue组件化架构包含电影展示、评分交互等典型模块同时配有SQL数据库文件。压缩包内共有687个文件其中包含38个后端源码、33个前端组件、162个页面脚本和162个矢量图标另含HTML页面、CSS样式、数据库脚本、一键启动脚本安装、运行、构建等以及毕业设计论文文档便于快速搭建环境、运行或二次开发。整个资源包大小仅13.3MB目录结构清晰已有310人学习适合需要完整可运行毕设项目、参考推荐算法落地实现或学习前后端整合的同学。1. 我为什么建议你先别急着打开源码协同过滤推荐系统的本质拿到“python基于协同过滤推荐算法的电影推荐系统源码数据库论文毕业设计.zip”这个压缩包第一件事不是解压跑代码而是先想清楚这套系统到底在解决什么。电影推荐是协同过滤推荐算法最经典的应用场景用户很多电影很多但每个用户真正看过的只是其中一小部分系统要靠“和你口味相似的人喜欢什么”来预测你还没看过的电影值不值得看。协同过滤不分析电影内容只依赖用户评分行为所以实现成本低、可解释性强特别适合课程设计和毕业设计。它不负责解决冷启动也不追求实时响应目标就是把离线数据里最可能被当前用户喜欢的 Top-N 部电影挑出来。这套思路不依赖任何深度模型却往往比纯热门榜更让用户满意因为它给出的推荐理由是“相似的人喜欢过”。适合谁正拿推荐系统做毕设课题、想让代码在答辩现场跑出可解释结果的人。2. 两种协同过滤算法怎么选UserCF 或 ItemCF 的选型与相似度计算2.1 相似度计算的数学直觉从余弦相似度到皮尔逊系数先讲人话协同过滤的核心是“相似”。UserCF 认为两个用户看过同样的电影越多、打分越接近这两个用户就越相似ItemCF 认为两部电影被同一批用户评价得越规律这两部电影就越相似。无论选哪种第一步都是计算相似度。最简单的相似度是余弦相似度把每个用户看过的所有电影评分当成一个向量计算两个向量夹角的余弦值。问题在于它没有校准用户的打分习惯有人手松看什么都给 4 分以上有人手紧特别喜欢的电影也只给 3 分。余弦相似度会把这两个人判得很远。所以在做电影推荐时我更常用皮尔逊相关系数它先减去各自的平均分再算向量夹角。平均分就是每个人的“打分基准”减掉之后剩下的才是“超出预期的偏好”。def pearson_sim(user1_ratings: dict, user2_ratings: dict) - float: # 输入是 {movie_id: rating} 的字典 common set(user1_ratings.keys()) set(user2_ratings.keys()) if len(common) 2: return 0.0 mean1 sum(user1_ratings[m] for m in common) / len(common) mean2 sum(user2_ratings[m] for m in common) / len(common) numerator sum((user1_ratings[m] - mean1) * (user2_ratings[m] - mean2) for m in common) denom1 sum((user1_ratings[m] - mean1) ** 2 for m in common) ** 0.5 denom2 sum((user2_ratings[m] - mean2) ** 2 for m in common) ** 0.5 if denom1 0 or denom2 0: return 0.0 return numerator / (denom1 * denom2)参数说明common 是两个用户都评过分的电影集合如果不足 2 部相似度没有统计意义直接返回 0。numerator 是中心化后的协方差denom1 和 denom2 是各自的标准差整个式子就是“共同评分上的相关系数”。这里用字典而不是 DataFrame 计算是因为在冷启动阶段很多用户只有几条评分记录DataFrame 广播计算反而容易把内存撑大。这套相似度计算只适用于评分数据如果你手里的数据只有“看过/没看过”的隐式反馈皮尔逊相关系数就没有意义得换成 Jaccard 相似度或基于共现次数的余弦相似度。做毕设时先看清原始数据有没有评分字段再决定公式。2.2 UserCF 还是 ItemCF三个问题决定你的选型选型不是看哪个算法听起来高级而是看数据形态和产品场景。下表是我在做电影推荐时常用的判断依据判断维度UserCFItemCF物品数量物品远多于用户时用资讯、文章用户远多于物品时用电影、图书更新频率适合实时性强、物品每秒变化的场景适合物品相对静态、用户口味缓慢变化推荐解释难解释只能说“和你相似的人喜欢”容易解释“因为你喜欢 A所以推荐 B”电影推荐场景里通常用户有几万人电影只有几千部我一般直接用 ItemCF。另一个理由是电影是静态物品不像新闻那样每分钟冒出新实体物品相似度矩阵离线算好后可以缓存很久线上接口每次请求只需查表。UserCF 也不是没用系统刚上线、评分很少但用户画像很多时UserCF 更容易先找到一批相似人群让冷启动时期的推荐列表不至于完全空白。选型不必二选一。常见做法是把两种算法的推荐结果做加权融合UserCF 结果占 0.4ItemCF 占 0.6按得分归一化后相加。这个权重不是拍脑袋定的我一般先各跑一遍离线评测看 precision10 谁更高再在 0.3 到 0.7 之间微调。论文里的实验对比部分也可以用这组数字作为论据比只贴一个算法更有说服力。2.3 评分预测公式把相似度变成推荐分数的收尾步骤相似度算完下一步是把相似度变成预测评分。UserCF 的预测公式是预测评分 当前用户平均分 Σ(相似度 × (其他用户评分 - 其他用户平均分)) / Σ相似度。分母用于归一化保证预测分数不会飘出评分区间。ItemCF 的预测公式更直接预测评分 Σ(用户对相似物品的评分 × 相似度) / Σ相似度。它不需要中心化因为电影的打分尺度相对统一用户对相似电影的打分可以直接作为依据。def predict_usercf(target_user_id, target_item_id, user_sim, user_mean, ratings_by_user): if not user_sim.get(target_user_id): return user_mean[target_user_id] total_sim 0.0 total_score 0.0 for other_user, sim in user_sim[target_user_id]: if target_item_id not in ratings_by_user[other_user]: continue total_sim sim total_score sim * (ratings_by_user[other_user][target_item_id] - user_mean[other_user]) if total_sim 0: return user_mean[target_user_id] return user_mean[target_user_id] total_score / total_sim参数说明user_sim 是“每个用户最相似的 K 个用户及其相似度”字典不存全量用户相似度矩阵用户过万之后全量矩阵内存代价太高。user_mean 是每个用户的平均评分。ratings_by_user 是用户到评分字典的映射。最关键的是 total_sim 0 时的兜底返回当前用户平均分避免分母为零。K 的取值我一般从 20 试到 80这类公开电影评分数据上 50 附近通常最稳定超过 100 后相似度太小的用户会把大量噪声带进来。3. 数据库设计与数据清洗从原始评分表到推荐引擎能直接吃的输入3.1 三张表的表结构users、movies、ratings 的分工标题里写了“源码数据库”数据库这块的核心不是建几个表而是想清楚“让算法拿数据时不手忙脚乱”。推荐算法真正只用评分表但做毕设展示时users 和 movies 两张表承担了解释功能推荐结果要显示电影名、类型还要能按用户属性做筛选所以这三张表都要建好。CREATE TABLE users ( user_id INT PRIMARY KEY, gender CHAR(1), age INT, occupation VARCHAR(64), zip_code VARCHAR(16) ); CREATE TABLE movies ( movie_id INT PRIMARY KEY, title VARCHAR(128), genres VARCHAR(128) ); CREATE TABLE ratings ( user_id INT, movie_id INT, rating TINYINT, timestamp INT, PRIMARY KEY (user_id, movie_id), KEY idx_movie (movie_id), KEY idx_user (user_id) );参数说明ratings 没有设置外键是因为导入大批量数据时 MySQL 的外键检查会明显拖慢写入速度而且推荐算法只按 user_id 或 movie_id 过滤不需要 JOIN 外键表靠主键就能去重。timestamp 存 Unix 秒数它的用处有两个一个是按时间划分训练集和测试集另一个是后面避坑章节提到的时间衰减。如果数据量到了百万条以上建议把 ratings 表改为压缩表或按时间分区否则SELECT全表时 IO 会成为瓶颈。3.2 用 SQL 完成第一层数据清洗去重、过滤、处理冷门物品拿到原始数据后第一层清洗直接在 SQL 里完成。核心是过滤掉“评分人数太少”的电影和用户。一部电影只有两个人评过它的相似度没有统计意义一个用户只评了三四部电影他的偏好信息不足以支撑个性化推荐。我在实际项目里还会顺手处理同一用户对同一电影的重复评分保留时间戳最大的一条。-- 保留被至少 50 位用户评分过的电影 CREATE TABLE movies_keep AS SELECT movie_id, COUNT(*) AS cnt FROM ratings GROUP BY movie_id HAVING COUNT(*) 50; -- 保留至少评分过 20 部电影的用户 CREATE TABLE users_keep AS SELECT user_id, COUNT(*) AS cnt FROM ratings GROUP BY user_id HAVING COUNT(*) 20;参数说明阈值 50 和 20 不是固定的。数据量小的时候我会降到 10 和 5保证留下的数据还有几万条数据量很大时反而要提高阈值把长尾噪声挡在外面。一个容易踩的误区是只过滤电影不过滤用户结果少数活跃用户贡献了几千条评分推荐结果全被这几十个用户带偏。另外冷门物品过滤对 ItemCF 尤其重要评分人数少的电影相似度计算时共同用户数不够算出来的相似度要么是 0要么只和自己相似对推荐没有任何信息量。3.3 把清洗结果加载进 PandasID 连续化与训练测试切分SQL 清洗完之后算法部分我用 Python 处理。这里的“ID 连续化”是一步容易被跳过的优化原始 user_id 可能是 1 到 6040 之间的稀疏整数movie_id 可能是 1 到 3952如果不做连续化后面构造稀疏矩阵时 shape 必须按最大值算会白白多出不少内存。import pandas as pd ratings pd.read_sql( SELECT r.user_id, r.movie_id, r.rating, r.timestamp FROM ratings r INNER JOIN movies_keep m ON r.movie_id m.movie_id INNER JOIN users_keep u ON r.user_id u.user_id , engine ) ratings[uid] pd.factorize(ratings[user_id])[0] ratings[mid] pd.factorize(ratings[movie_id])[0] ratings ratings.sort_values(timestamp) train ratings.iloc[:int(len(ratings) * 0.9)] test ratings.iloc[int(len(ratings) * 0.9):]参数说明factorize 会按出现顺序生成 0 到 N-1 的连续整数同时可以保存 user_id 到 uid、movie_id 到 mid 的映射供后面 Web 接口反查。按 timestamp 排序再做 90/10 切分比随机切分更贴近真实线上场景用历史行为预测未来行为不会把时间信息泄漏到训练集。如果你的数据没有 timestamp退而求其次用随机切分但要在论文里注明这个局限。4. 召回与 Top-N 排序协同过滤在离线评测里的完整实现4.1 用 SciPy 构造稀疏评分矩阵避免内存直接爆掉算法部分第一个关键选择是数据结构。user_num × item_num 的稠密矩阵在小数据量时看着没问题一旦用户过万、电影过千float32 的稠密矩阵就要占几百 MB直接拖垮开发机。电影评分数据天然稀疏用 scipy.sparse 就够了。import scipy.sparse as sp import numpy as np user_num train[uid].max() 1 item_num train[mid].max() 1 user_item sp.coo_matrix( (train[rating].values, (train[uid].values, train[mid].values)), shape(user_num, item_num) ).tocsr() item_user user_item.T.tocsr()参数说明coo_matrix 适合从三元组一次性构造tocsr() 转成 CSR 格式后按行切片效率高。user_item 的行是用户、列是电影item_user 是转置行是电影、列是用户ItemCF 阶段主要用 item_user。rating 从数据库读出来是整数不需要主动转 float后面计算时 numpy 会自动提升类型。这里不要用 pandas 的 pivot_table 生成稠密矩阵数据量一大必然内存翻车。4.2 ItemCF 的相似物品表用一次矩阵乘法算完整张表ItemCF 相似度矩阵不用双重循环直接对归一化后的向量做内积先把每个物品向量除以自己的长度再和转置矩阵相乘得到的矩阵第 i 行第 j 列就是物品 i 和 j 的余弦相似度。# 计算每个物品向量的 L2 范数做余弦归一化 item_norm sp.linalg.norm(item_user, axis1) item_user_norm item_user.multiply(1.0 / item_norm[:, None]) # 物品-物品相似度矩阵item_num x item_num item_sim item_user_norm item_user_norm.T逻辑说明矩阵乘法内积结果就是余弦相似度。sp.linalg.norm 对全零行会算出 0除出来是 inf所以归一化之后要手动把非有限值置 0否则后面结果全乱。对角线是每个物品和自己的相似度 1推荐时一定要剔除。接下来按相似度取 TopK我存成字典而不是矩阵因为推荐阶段只访问每个电影对应的少数邻居字典在批量跑测试集时缓存更友好。K 50 item_topK {} item_sim_lil item_sim.tolil() for i in range(item_num): row_idx item_sim_lil.rows[i] row_data item_sim_lil.data[i] pairs [(int(j), float(v)) for j, v in zip(row_idx, row_data) if j ! i and np.isfinite(v)] pairs.sort(keylambda x: -x[1]) item_topK[i] pairs[:K]参数说明K 是“相似邻居数”直接影响线上推荐质量和离线计算量。K 太小时召回范围窄K 太大时相似度噪声变大一般从 30 到 100 之间调。这里的 K 和 UserCF 里的 K 不是一回事ItemCF 我常用 50。存成 list of tuples 而不是 numpy 数组是因为后续给每个用户打分时这段数据被高频读取list 的迭代开销更小。4.3 推荐列表生成与评估precision10、召回率和覆盖率有了 item_topK给用户做推荐就是“把用户评过分的电影作为种子累加每个物品邻居的相似度得分”。这一步不是预测具体评分而是做召回后的排序。def recommend_for_user(uid, train_user_item, item_topK, N10): seen set(train_user_item.rows[uid]) scores {} for movie in train_user_item.rows[uid]: for sim_movie, sim_value in item_topK.get(movie, []): if sim_movie in seen: continue scores[sim_movie] scores.get(sim_movie, 0.0) sim_value ranked sorted(scores.items(), keylambda x: -x[1]) return [mid for mid, _ in ranked[:N]]逻辑说明累加而不是取平均是因为一个用户可能已经看过好几部相似电影累加得分自然带上“证据数量”的权重平均则会给单条证据过高话语权。seen 集合保证推荐里不出现用户已经看过的电影。离线评测我用三组数字precision10、recall10 和覆盖率。def evaluate(train, test, user_item, item_topK, N10): rec_map {uid: recommend_for_user(uid, user_item, item_topK, N) for uid in range(user_item.shape[0]) if len(user_item.rows[uid]) 0} hit 0 total_test 0 for _, row in test.iterrows(): uid, mid int(row[uid]), int(row[mid]) if uid in rec_map and mid in rec_map[uid]: hit 1 total_test 1 precision hit / (len(rec_map) * N) recall hit / total_test recommended_items set() for items in rec_map.values(): recommended_items.update(items) coverage len(recommended_items) / item_num return precision, recall, coverage参数说明precision 是“推荐列表里有多少被测试集命中”recall 是“测试集里有多少被推荐出来”。对电影推荐来说 recall 不会很高因为用户看过的电影不会全出现在 Top-10 里所以看趋势比看绝对值更有意义改了相似度算法之后 precision 升、recall 不降才能说明改动有效。覆盖率反映推荐结果的多样性如果覆盖率太低说明推荐列表被少数热门电影垄断要去查避坑章节里的流行度偏差问题。5. 协同过滤避坑指南5 个能让结果直接翻车的地方5.1 冷启动新用户没有评分预测公式直接失效现象系统上线后新注册用户调用推荐接口返回空列表或者全是热门榜新电影入库后没有任何用户评过它所以它永远不会出现在推荐里。原因协同过滤是纯行为算法没有评分记录就没有相似用户、没有相似物品预测公式的分母为空。电影推荐场景里新电影入场速度虽然不像新闻那么快但冷启动问题依然存在。解决回退策略是“热门榜兜底”按全体用户的评分次数和平均分算一个热度分取前 50 作为冷启动推荐。更友好的做法是在注册页收集用户选定的几部熟悉电影或喜欢的类型用它们的平均分做伪评分让新用户也能进入协同过滤流程。注意伪评分只用于召回不要写入原始 ratings 表否则评测数据被污染。5.2 相似度分母为 0结果全是 NaN现象item_sim 矩阵算完之后推荐列表里出现nan排序时 nan 被当成最大值排到最前面推荐结果全是空或者全是同一部电影。原因部分电影没有任何评分L2 范数为 0归一化时除出来是 inf再算内积就成了 nan。另外皮尔逊相似度里两个用户共同评分不足 2 条时也会因为分母为 0 返回 nan。解决归一化前先找出 norm 为 0 的行单独置 0取 TopK 时用np.isfinite过滤非法值。代码里不要依赖 numpy 自动忽略 nan因为argsort会把 nan 当最大值处理。这条看起来很小却是协同过滤项目里最常见的翻车现场。5.3 内存爆炸全量相似度矩阵塞不进内存现象数据量从几千条涨到几十万条时程序在item_sim item_user_norm item_user_norm.T这行直接被系统 kill或者电脑风扇狂转、内存占用飙到几个 GB。原因物品数一万乘一万的稠密矩阵要 800 MB两万乘两万就要 3.2 GB而真实项目的物品数远不止这个量级。解决把物品相似度矩阵的存储方式换成lil_matrix或者直接只保留 TopK 邻居。上面的代码里用tolil()转了一次正是为了把每一行只留下有用的 K 个。另一个常见方案是分块计算每次只取 1000 个物品向量做内积算完就丢弃峰值内存能压到几百 MB。论文里如果写了“本系统处理十万部电影”一定要把分块策略写进去否则评委一问内存峰值就露馅。5.4 时间偏移推荐结果全是老电影现象离线评测指标看着不错但人工看推荐列表清一色是老电影最近两年的新片一部都上不了榜单。原因老电影进入数据库时间长评分次数天然更多统计上的“相似度证据”更足。每一次新电影都会被老牌热门电影压住因为相似度计算时老电影有更多可比的共同评分。解决给评分加时间衰减权重。我在SELECT评分时会额外带一列权重公式是weight pow(0.9, (now - timestamp) / 31536000)然后把这个 weight 乘到评分上再算相似度。这样相似度矩阵更敏感于最近一年的行为老片不会因为历史评分多而持续霸榜。这个衰减系数是超参0.9 衰减较慢如果数据跨度短可以调整到 0.7 或 0.8。5.5 流行度偏差推荐结果看起来很像热门榜现象用户 A 和用户 B 的推荐列表有七八成重合个性化程度很低换个角度看就是把热门电影重新排了个序。原因热门电影被很多人评过在物品相似度矩阵里和它相似度高的物品也更多热门电影聚集了大量相似度得分排序时把冷门但用户真正喜欢的电影挤下去了。解决两种常见做法。一是中心化把评分改成“评分减去该电影的平均分”弱化热门电影在数量上的优势。二是在推荐得分里除以物品流行度的平方根让冷门但匹配度高的电影有机会浮上来。这个处理要适度惩罚过头会牺牲 precision我一般用 0.5 次幂的惩罚项再根据评测结果微调。6. 把离线推荐变成能答辩的 Web 服务Flask 接口与两个上分技巧6.1 用 Flask 包一个 HTTP 接口把模型文件留在内存里离线算完的 item_topK、uid_map、mid_map 和热门榜单我会用 pickle 存成一个 model.pklWeb 服务启动时一次性加载请求进来只做查表和排序。这样就不会每次请求都重新算一遍相似度。from flask import Flask, jsonify, request import pickle app Flask(__name__) with open(model.pkl, rb) as f: model pickle.load(f) app.route(/recommend) def recommend(): user_id request.args.get(user_id, typestr) if user_id not in model[uid_map]: return jsonify({items: model[hot_list]}) uid model[uid_map][user_id] items recommend_for_user(uid, model[train_user_item], model[item_topK], N10) titles [model[title_map][mid] for mid in items] return jsonify({user_id: user_id, items: titles}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)参数说明user_id 是字符串uid_map 做的是“外部 ID 到内部连续 ID”的转换。hot_list 是冷启动兜底直接从模型文件里读出来接口里不查数据库。debugFalse 是为了答辩演示时不因为调试器状态异常而中断。6.2 给推荐结果加一句“因为你喜欢过 A所以推荐 B”的解释ItemCF 最大的优势就是可解释性不给用户展示出来就浪费了。推荐结果里对每一部被推荐的电影去它的 TopK 邻居里找用户历史评分过的电影取相似度最高的那条作为解释。def explain_item(uid, mid, model): user_movies set(model[train_user_item].rows[uid]) for sim_mid, sim_score in model[item_topK][mid]: if sim_mid in user_movies: return f因为你喜欢过《{model[title_map][sim_mid]}》推荐《{model[title_map][mid]}》 return None参数说明解释结果最多查 K 个邻居就能命中复杂度是 O(K)。这一个字段不改变推荐逻辑却能让答辩评委直观看到“协同过滤为什么这样推荐”比一堆评测指标更有说服力。6.3 验证技巧用多个随机种子跑离线评测评测脚本我习惯直接设计成可重复实验用同一个清洗后的数据集换 10 个随机种子切分训练集和测试集各跑一遍 precision、recall、coverage最后取均值和方差。方差大说明数据稀疏或者切分不稳定需要回到清洗阶段提高过滤阈值只跑一次随机切分得出的结论很可能正好碰上一种很走运的切分。我会把评估脚本写成独立模块每次改完算法先跑离线评测再动接口这个习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取
返回列表