
简介基于协同过滤的电影推荐系统设计是一份面向Python毕业设计或课程设计的完整项目资源适合计算机相关专业学生及推荐系统入门开发者参考。项目围绕用户相似度与物品相似度两条主线实现评分数据清洗、相似度计算、推荐生成、前端展示与MySQL存储等完整流程能帮助读者理解推荐系统从数据处理到算法应用的落地过程。压缩包共227个文件大小约6.77MB以Python源码、前端页面文件、CSV电影评分数据集、SQL数据库脚本和Markdown说明文档为主图片素材占比较大便于直接查看界面效果与架构设计。资源包含电影类型、评分等表结构及示例数据支持快速搭建实验环境并二次拓展配套README提供使用说明与设计思路可作为毕业设计说明书或课程报告的重要参考。目前已有41人学习下载适合需要以实战项目完成课程作业或毕业设计的同学。1. 协同过滤推荐系统从评分矩阵到个性化推荐的完整链路当用户只留下几条稀疏的评分系统却要预测他会不会喜欢一部从未看过的电影——靠的不是内容分析而是“审美相近的人都在看什么”。这份基于协同过滤的电影推荐系统设计是一份典型的 Python 毕业设计/课程设计源码包内部包含ratings.csv评分数据、MovieGenre3.csv电影元数据、rrtotaltable.csv综合表以及 Bootstrap 前端静态资源和完整的推荐逻辑。它解决两个核心问题一是基于用户行为数据构建评分预测模型二是把预测结果通过 Web 页面可视化呈现。适合正在做推荐系统课设、或者想理解 UserCF/ItemCF 工程落地的开发者阅读既能抄作业也能学到数据清洗和相似度计算的细节。2. 数据层设计ratings.csv 与 MySQL 表结构怎么配合2.1 原始数据长什么样拿到源码包先别急着跑代码我一般先把三个 CSV 文件结构摸清楚。MovieGenre3.csv是电影与类型的映射表ratings.csv是用户对电影的评分记录rrtotaltable.csv可以理解为中间表通常已经做了部分聚合比如每个用户对每部电影的评分汇总。字段设计如下表文件常见字段类型业务含义MovieGenre3.csvmovie_id, title, genresint, string, string电影ID、标题、类型列表ratings.csvuser_id, movie_id, rating, timestampint, int, float, int评分主体、评分对象、分值、时间戳rrtotaltable.csvuser_id, movie_id, ratingint, int, float去重后的评分明细用于建模三个文件之间的关系是ratings.csv通过movie_id关联MovieGenre3.csv拿到电影类型rrtotaltable.csv则是对评分记录做user_id movie_id去重后的结果避免同一条记录被重复计算。这里有个容易被忽略的点ratings.csv里的timestamp虽然建模时不一定用但它对评估推荐时序很有价值后面做冷启动和离线切分时要用到。2.2 MySQL 建表语句与数据导入虽然 Pandas 可以直接读 CSV但项目里要求用 MySQL 存储用户、电影、评分三类核心数据。这样做的好处是推荐系统上线后能支撑增量更新而且 SQL 查询比全量扫描内存文件更利于维护。我一般会建三张表users、movies、ratings其中ratings是核心事实表。建表语句如下CREATE DATABASE IF NOT EXISTS movie_rec DEFAULT CHARSET utf8mb4; USE movie_rec; CREATE TABLE users ( user_id INT PRIMARY KEY, user_name VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE movies ( movie_id INT PRIMARY KEY, title VARCHAR(200) NOT NULL, genres VARCHAR(100) ) ENGINEInnoDB; CREATE TABLE ratings ( user_id INT NOT NULL, movie_id INT NOT NULL, rating DECIMAL(2,1) NOT NULL, timestamp INT NOT NULL, PRIMARY KEY (user_id, movie_id), FOREIGN KEY (user_id) REFERENCES users(user_id), FOREIGN KEY (movie_id) REFERENCES movies(movie_id) ) ENGINEInnoDB;ratings表用user_id movie_id作为联合主键能防止同一用户对同一部电影产生多条评分。DECIMAL(2,1)可以存储 0.0 到 9.9 的评分如果你的评分范围是 1-5也足够用。导入数据我用LOAD DATA LOCAL INFILE因为 CSV 是文本格式比逐条 INSERT 快得多LOAD DATA LOCAL INFILE /path/to/ratings.csv INTO TABLE ratings FIELDS TERMINATED BY , LINES TERMINATED BY \n IGNORE 1 LINES (user_id, movie_id, rating, ts) SET timestamp IF(ts , UNIX_TIMESTAMP(), CAST(ts AS UNSIGNED));注意IGNORE 1 LINES跳过表头如果 CSV 里没有表头就去掉这一句。ts是 MySQL 用户变量用来处理时间戳字段可能为空的情况。导入完成后务必执行SELECT COUNT(*) FROM ratings比对行数因为 CSV 里可能混有空行或重复行这一步是最快的检验方式。2.3 数据清洗缺失值、异常值、评分归一化协同过滤对数据质量很敏感脏数据会直接拉低相似度计算的准确度。我处理评分数据的顺序是去重、过滤缺失值、处理异常评分、归一化。Pandas 代码如下import pandas as pd import numpy as np ratings pd.read_csv(ratings.csv) print(原始行数:, len(ratings)) # 1. 去重同一用户对同一电影只保留一条评分 ratings ratings.drop_duplicates(subset[user_id, movie_id], keeplast) # 2. 缺失值评分必须存在用户和电影ID必须有效 ratings ratings.dropna(subset[rating]) ratings ratings[ratings[user_id] 0] ratings ratings[ratings[movie_id] 0] # 3. 异常值评分超出1-5分的直接剔除按实际评分范围调整 ratings ratings[(ratings[rating] 1) (ratings[rating] 5)] # 4. 归一化每个用户减去自己的平均分消除打分尺度差异 user_mean ratings.groupby(user_id)[rating].transform(mean) ratings[rating_centered] ratings[rating] - user_mean print(清洗后行数:, len(ratings)) print(用户数:, ratings[user_id].nunique(), 电影数:, ratings[movie_id].nunique())keeplast是有讲究的如果同一条记录出现了多次保留最后写入的版本因为后导入的数据通常更新。评分归一化是协同过滤常用的预处理方式它能消除用户打分严苛或宽松带来的偏差——有人习惯打 2 分有人习惯打 4 分不归一化的话相似度会被“个人尺度”主导。清洗完的数据可以直接喂给后面的算法也可以写回 MySQL 的rrtotaltable表。3. 相似度计算与协同过滤核心算法实现3.1 基于用户的协同过滤找到口味相近的邻居UserCF 的核心是“物以类聚人以群分”先找和目标用户评分行为最相似的 K 个用户再用这些邻居的评分加权预测目标用户对未看影片的评分。相似度度量我用皮尔逊相关系数因为它对评分尺度不敏感适合用户评分风格不一致的场景。实现如下import numpy as np from sklearn.metrics.pairwise import cosine_similarity def pearson_sim(user1_ratings, user2_ratings): # 找出两个用户都评过分的电影 common user1_ratings.index.intersection(user2_ratings.index) if len(common) 2: return 0.0 u1 user1_ratings[common] u2 user2_ratings[common] # 皮尔逊相关系数 return np.corrcoef(u1, u2)[0, 1] def predict_usercf(target_user, item_id, rating_matrix, user_sim, k10): # 获取目标用户对所有物品的平均分 target_mean rating_matrix.loc[target_user].mean() # 列出对目标物品有过评分的用户 rated_users rating_matrix[item_id].dropna().index if len(rated_users) 0: return target_mean scores [] weights [] for user in rated_users: if user target_user: continue sim user_sim[target_user][user] if sim 0: continue user_mean rating_matrix.loc[user].mean() # 邻居对该物品的评分减去其平均分再按相似度加权 scores.append(sim * (rating_matrix.loc[user, item_id] - user_mean)) weights.append(sim) if np.sum(weights) 0: return target_mean pred target_mean np.sum(scores) / np.sum(weights) return min(5.0, max(1.0, pred))代码里最关键的逻辑是预测值 目标用户平均分 加权偏差。为什么要用每个用户自己的平均分做基准因为有些用户习惯打低分有些习惯打高分直接用原始评分加权会产生系统性偏差。sim 0的邻居直接丢弃只保留正向相似的用户避免负相关的用户干扰预测。k10表示只取最相似的 10 个用户这个值一般取 5-50需要根据数据集规模调。3.2 基于物品的协同过滤从已评分电影找相似ItemCF 更适合用户多、物品少的场景比如电影网站里电影数量通常远小于用户数量。它的逻辑是“喜欢 A 的人往往也喜欢 B”通过物品间的相似度来推荐。计算物品相似度常用余弦相似度因为物品评分向量通常都是非负的余弦值能直接反映方向一致性def compute_item_sim_matrix(rating_matrix): # rating_matrix 是 用户x电影 的DataFrameNaN表示无评分 item_sim {} items rating_matrix.columns for i, item_a in enumerate(items): for item_b in items[i1:]: common_users rating_matrix[item_a].dropna().index.intersection( rating_matrix[item_b].dropna().index ) if len(common_users) 5: continue vec_a rating_matrix[item_a][common_users].values vec_b rating_matrix[item_b][common_users].values # 余弦相似度 sim np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b)) item_sim[(item_a, item_b)] sim item_sim[(item_b, item_a)] sim return item_sim def predict_itemcf(user_id, item_id, rating_matrix, item_sim, k10): # 找出用户已经评分过的电影 rated rating_matrix.loc[user_id].dropna() if len(rated) 0: return rating_matrix.mean().mean() # 按相似度排序取前k个 candidates [(item_sim[(item_id, other)], other, rating) for other, rating in rated.items() if (item_id, other) in item_sim] candidates.sort(keylambda x: x[0], reverseTrue) candidates candidates[:k] weight_sum 0.0 score_sum 0.0 for sim, other, rating in candidates: if sim 0: continue score_sum sim * rating weight_sum sim if weight_sum 0: return rating_matrix.mean().mean() return score_sum / weight_sumItemCF 的经典实现里相似度矩阵可以提前算好存到内存线上预测时只查表不需要实时扫描全部评分。这里有个实际优化点只统计common_users 5的电影对因为共同评分人数太少算出来的相似度统计上不可靠很可能把冷门电影的“偶然共现”当成强关联。predict_itemcf里按相似度排序后取前 K 个注意要先过滤sim 0否则会累加负贡献。3.3 用 Surprise 库实现 SVD 矩阵分解自己手写 UserCF/ItemCF 适合理解原理但做毕业设计如果只交个邻近算法评审容易问“有没有试过更进阶的方法”。矩阵分解是协同过滤的经典扩展Surprise 库把 SVD 封装得很干净代码量很少from surprise import SVD, Dataset, Reader, accuracy from surprise.model_selection import train_test_split # 读取评分数据格式为 (user_id, item_id, rating) reader Reader(rating_scale(1, 5)) data Dataset.load_from_df(ratings[[user_id, movie_id, rating]], reader) # 划分训练集和测试集 trainset, testset train_test_split(data, test_size0.2, random_state42) # SVD 参数因子数20学习率0.005正则化0.02 algo SVD(n_factors20, n_epochs20, lr_all0.005, reg_all0.02, random_state42) algo.fit(trainset) # 预测并计算RMSE predictions algo.test(testset) rmse accuracy.rmse(predictions) print(fSVD RMSE: {rmse:.4f})n_factors是把用户和物品映射到隐特征空间的维度一般取 10-100。维度太小拟合不足太大容易过拟合。reg_all是正则化系数用来惩罚过大的参数抑制泛化误差。train_test_split默认是随机切分但如果是按时间戳预测新评分应该用PredefinedKFold或者手动按时间排序后切分否则评估结果会偏乐观。SVD 的预测公式是把评分近似为全局均值加用户偏置加物品偏置再加隐向量点积理解这个背景就知道为什么lr_all需要设置得比较小——学习率过大容易在优化后期震荡。三种相似度/矩阵方法对比如下方法相似度/分解方式适合场景主要缺点UserCF皮尔逊相关系数用户少、物品多的社区推荐用户数量大时在线计算慢ItemCF余弦相似度电影、电商等物品较固定的场景冷门物品相似度不可靠SVD隐因子矩阵分解评分密集度中等以上冷启动用户/物品无效4. 前端展示与推荐流程打通Bootstrap 页面怎么和 Python 后端交互4.1 项目静态文件与页面职责推荐算法算得再好最终得让用户看得到。项目里bootstrap.css负责整体栅格布局和组件基础样式bootstrap.min.css是压缩版线上环境用它减少加载体积firstPage.css和main.css是自定义样式前者控制首页的视觉结构比如电影海报网格和推荐横幅后者控制列表页表格、导航栏等组件细节。demo.css一般用于演示页的临时调色star.css负责评分星星的渲染。划分很清晰框架样式、业务样式、演示样式分开改版时不容易互相污染。文件职责典型页面bootstrap.css / min.css栅格、按钮、导航、卡片等基础组件全局firstPage.css首页独特布局、推荐位排版index.htmlmain.css列表、详情、表格通用样式电影列表页star.css评分星星的显示与控制评分区域demo.css演示环境临时样式覆盖局部组件演示页面我看到的项目结构里前端静态资源一般放在static/目录模板放在templates/目录Python 后端路由负责把推荐结果传给模板。这种前后端不分离的写法在课程设计里最常见实现简单也不容易出跨域问题。4.2 Flask 路由设计推荐接口怎么暴露给前端后端我习惯用 Flask 写因为它轻量、灵活和 Pandas/Surprise 的模型逻辑无缝配合。推荐接口的核心路由如下from flask import Flask, render_template, request, jsonify import pandas as pd from surprise import SVD app Flask(__name__) # 伪代码加载训练好的模型和电影信息 model SVD() model.load(svd_model.pkl) movies pd.read_csv(MovieGenre3.csv) app.route(/) def index(): return render_template(index.html) app.route(/recommend, methods[GET]) def recommend(): # 参数用户ID、推荐数量 user_id int(request.args.get(user_id, 1)) top_n int(request.args.get(top_n, 10)) # 找到用户没看过的电影 watched ratings[ratings[user_id] user_id][movie_id].tolist() candidates movies[~movies[movie_id].isin(watched)] # 用模型预测候选电影评分 scores [] for movie_id in candidates[movie_id]: pred model.predict(user_id, movie_id).est scores.append((movie_id, pred)) scores.sort(keylambda x: x[1], reverseTrue) top scores[:top_n] # 拼装返回数据 result [] for movie_id, pred in top: title movies.loc[movies[movie_id] movie_id, title].values[0] result.append({movie_id: movie_id, title: title, score: round(pred, 2)}) return jsonify(result) if __name__ __main__: app.run(debugTrue)逻辑说明request.args.get(user_id, 1)读取 URL 上的查询参数默认给 1方便前端直接测试。候选集是“用户没看过的电影”这个必须在查询阶段就用isin过滤掉否则预测结果全是已看过的产品上没意义。model.predict(user_id, movie_id).est返回预测评分注意 Surprise 的预测对象里.est是最终估计值uid和iid分别是内部转换后的 ID。如果用户 ID 是字符串Surprise 会自动映射但记得传入前转成统一类型否则会映射错位。4.3 模板渲染把推荐结果循环输出到页面/recommend返回 JSON 后前端可以先用fetch拉数据再渲染也可以直接在后端模板里循环。更简单的是用 Jinja2 在服务端渲染!-- templates/index.html -- link relstylesheet href{{ url_for(static, filenamebootstrap.min.css) }} link relstylesheet href{{ url_for(static, filenamemain.css) }} div classcontainer h2 classmt-4为你推荐/h2 div classrow idrecommend-list {% for item in recommendations %} div classcol-md-3 col-sm-6 mb-3 div classcard div classcard-body h5 classcard-title{{ item.title }}/h5 p classcard-text预测评分{{ item.score }}/p button classbtn btn-sm btn-primary star-btn>app.route(/page, methods[GET]) def recommend_page(): # ... 计算 top 推荐 ... return render_template(index.html, recommendationsresult)url_for(static, filenamebootstrap.min.css)会解析到static/目录下的资源注意路径首字符不要加斜杠。使用col-md-3让每行显示 4 个卡片col-sm-6在小屏上显示 2 个这是 Bootstrap 栅格系统的响应式核心。如果推荐列表为空模板里要加{% if not recommendations %}的兜底提示否则页面会空白让人误以为接口挂了。5. 进阶技巧评估推荐效果与冷启动处理5.1 离线评估指标与代码推荐效果不能只靠肉眼判断我在课程设计里至少会算 RMSE 和 MAE这两个指标直接反映评分预测的误差。再补一个精确率看推荐列表里有多少是用户真正感兴趣的。评估代码写在模型训练之后from surprise import accuracy from collections import defaultdict # 计算精确率/召回率 def precision_recall_at_k(predictions, k10, threshold4): # 对每个用户按预测评分降序取前k个 user_est_true defaultdict(list) for uid, iid, true_r, est, _ in predictions: user_est_true[uid].append((est, true_r)) precisions [] recalls [] for uid, user_ratings in user_est_true.items(): user_ratings.sort(keylambda x: x[0], reverseTrue) # 推荐列表中真正评高分(4)的数量 hit sum(1 for est, true in user_ratings[:k] if true threshold) # 用户实际喜欢(高评分)的总数 actual sum(1 for _, true in user_ratings if true threshold) if actual 0: recalls.append(hit / actual) precisions.append(hit / k) return sum(precisions) / len(precisions), sum(recalls) / len(recalls) prec, rec precision_recall_at_k(predictions) print(fPrecision{10}: {prec:.4f}, Recall{10}: {rec:.4f})threshold4表示把真实评分 4 分及以上的电影视为“用户喜欢”。Precision10的含义是推荐列表前 10 个里有多少比例是用户确实喜欢的Recall则看用户喜欢的电影有多少被推荐出来了。注意这里要先按预测评分排序再截断不能直接按原始顺序取否则精确率没有意义。5.2 冷启动场景与策略协同过滤的天然短板是冷启动新用户没有任何评分新电影也没有被评分过。实际项目中我见到最多的策略是“混合推荐”冷启动用户直接给他热门榜冷启动电影则靠类型标签做内容相似度兜底。实现时可以在推荐路由里加判断如果用户评分记录数小于 5直接返回MovieGenre3.csv里出现频率最高的类型对应的电影不进入协同过滤模型。代码上只需要在app.route(/recommend)开头加一个分支逻辑很简单但能显著提升演示效果避免新用户页面空白。5.3 参数调优K值、相似度阈值、矩阵分解因子数手写 UserCF 的 K 值和 Surprise 的n_factors都需要调。我用GridSearchCV对 SVD 的参数组合搜索核心参数范围如下表参数推荐范围影响K近邻数5 ~ 50太小欠平滑太大引入噪声min_common最小共同评分数3 ~ 10低于此值的相似度置零n_factors10 ~ 100隐因子维度过大容易过拟合reg_all0.01 ~ 0.1正则化强度缓解过拟合用 Surprise 自带工具做网格搜索from surprise.model_selection import GridSearchCV param_grid { n_factors: [20, 50, 100], lr_all: [0.002, 0.005, 0.01], reg_all: [0.02, 0.05, 0.1] } gs GridSearchCV(SVD, param_grid, measures[rmse, mae], cv5) gs.fit(data) print(Best RMSE:, gs.best_score[rmse]) print(Best params:, gs.best_params[rmse])cv5表示 5 折交叉验证最终返回的best_score是平均 RMSE。一个容易踩的坑如果数据集里某些用户只有 1-2 条评分交叉验证时这些用户可能全部落进训练集或测试集导致结果异常高。我一般会在调参前先过滤掉评分次数少于 3 的用户保证每个用户都有足够的样本参与训练。网格搜索结束后把最优参数固定下来重训一遍全量数据再生成推荐列表。如果你在调参时同时改动邻居数和相似度阈值建议先固定相似度算法只动一个变量否则 RMSE 的波动很难归因。本文还有配套的精品资源点击获取