简介:基于协同过滤算法的电影推荐系统,是面向计算机相关专业毕业生和Java学习者的完整毕业设计项目,可用于课程设计、期末大作业或直接作为毕设方案。系统围绕电影推荐核心功能,整合用户行为与影片数据,通过协同过滤算法实现个性化推荐,前后端代码完整。资源包共2020个文件,大小约43.42MB,涵盖Java后端源码、Vue前端页面、SQL数据库脚本、XML配置及项目说明文档,其中png、svg、gif等素材用于界面展示,java、vue、sql文件对应系统核心逻辑与数据初始化,js、css、html等则是前端交互与页面样式的重要组成。已有264人学习下载,项目经过严格调试,附带数据库脚本和软件工具,可直接运行部署;压缩包内同时提供完整目录结构和项目说明,便于读者理解推荐系统实现思路,并在此基础上进行功能扩展与二次开发。
1. 为什么毕业设计选电影推荐系统:协同过滤的选型逻辑
每年毕业季,计算机专业的选题清单里,推荐系统都占着稳定的一席。原因很直接:它既有可见的算法含量,又有完整的工程链条——数据表设计、后端接口、前端页面、算法落地,一条线走完,几乎覆盖了企业招聘 JD 里最常见的技能点。而电影推荐系统又是推荐方向里最“友好”的载体:数据容易获取、结果直观可解释、演示效果好,不需要像电商推荐那样处理复杂的库存和价格体系。
这套基于协同过滤算法的电影推荐系统源码+数据库,属于典型的毕设级完整工程。它前端由 Vue 构建管理界面,后端走 Java 技术栈,核心算法落的是协同过滤——一个不需要物品内容属性、只依靠用户行为数据就能产生推荐结果的经典思路。拿到手里之后,它能跑、能改、能讲清楚原理,这也是它被当作毕业设计高频使用的根本原因。下面从工程结构说起,把它拆开看一遍。
2. 前后端分离架构与 Vue 备份文件:如何还原一个可运行的前端工程
拿到压缩包后,第一眼看到的往往是前端目录里一串以.vue.bak结尾的文件:update-password.vue.bak、IndexMain.vue.bak、IndexAsideStatic.vue.bak、BreadCrumbs.vue.bak。这些并不是系统运行需要的文件,而是开发者在改代码之前留下的备份——比如IndexMain.vue.bak就是IndexMain.vue在重构前的一份快照。
2.1 处理 .bak 备份文件
正常情况下,npm run serve只会编译.vue结尾的文件,.vue.bak后缀不会被 Webpack 识别为组件,所以这些备份文件不影响运行。但如果你在 IDE 里做全局搜索,会发现报错信息大量指向这些备份文件——因为 IDE 默认把它们误判成 Vue 组件去解析了。
我一般会先做一次批量清理,把备份文件从工程里移除,避免 IDE 索引和后续打包出幺蛾子:
find . -name "*.vue.bak" -type f -delete如果不想删,想留底,用批量重命名把备份还原为正式组件也可以:
for f in *.vue.bak; do mv "$f" "${f%.bak}"; done${f%.bak}是 bash 的参数展开语法,作用是去掉变量f末尾的.bak后缀,实现把update-password.vue.bak还原成update-password.vue。
2.2 前端工程的主干结构
清理完备份文件,前端目录结构就清晰了。典型 Vue 全家桶工程中,src/views存放页面级组件、src/router维护路由表、src/api封装 axios 请求。从IndexMain.vue的文件名可以推断,它对应管理后台的主布局,即登录后看到的整体框架;IndexAsideStatic.vue是左侧静态导航菜单,BreadCrumbs.vue是面包屑导航组件,这三个配合起来,就是一个标准后台管理界面的骨架。
后端启动后,前端联调的入口一般在vue.config.js里配置代理:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } };这里把/api开头的请求统一转发到后端 8081 端口。changeOrigin: true表示改写请求头中的Origin字段,避免后端接口做跨域校验时被拦截。实际联调时如果出现 404 或 CORS 报错,优先检查这里。
2.3 后端分层与代码包规划
后端是标准的 Spring Boot 工程,包结构通常按控制层、业务层、数据访问层拆开。大致划分如下:
| 包名 | 职责 | 典型类 |
|---|---|---|
controller | 接收 HTTP 请求,参数校验,返回 JSON | MovieController.java、UserController.java |
service | 业务逻辑,协同过滤推荐算法在此实现 | RecommendService.java、RatingService.java |
mapper | MyBatis 数据访问接口,对应 XML 或注解 SQL | RatingMapper.java |
entity | 数据库实体映射 | User.java、Movie.java、Rating.java |
common | 统一返回结果、异常处理、工具类 | Result.java、GlobalExceptionHandler.java |
从前端的页面推断,模块集中在用户管理、电影信息管理、评分管理和推荐结果展示四块。用户在页面上给电影打 1 到 5 星,评分经接口写入rating表,推荐模块实时读取这些评分来计算“和你口味相近的人在看什么”。
前端页面文件之所以有大量.bak备份,说明这个项目在二次开发过程中被改过多轮。接手时可以大胆删除备份文件,但改代码之前自己留一份.bak是好习惯——尤其当你正准备动RecommendService里的相似度计算逻辑时。
3. 数据库设计与用户-电影评分表:从 ER 关系到建表 SQL
推荐系统的数据模型不算复杂,但表结构设计会直接影响协同过滤算法的实现难度。这套系统的数据库脚本里,最核心的关联关系是“用户—评分—电影”三元组。
3.1 核心表职责划分
| 表名 | 字段要点 | 作用 |
|---|---|---|
user | id、username、password | 登录、个人推荐身份 |
movie | id、title、genre、release_date | 电影信息维护,列表展示 |
rating | user_id、movie_id、score、create_time | 协同过滤的数据来源 |
rating是最关键的一张表,它代表了一个用户对一部电影的明确反馈。协同过滤的一切计算,本质上都是在处理这张表里的行。
3.2 建表 SQL 与字段设计
CREATE TABLE `movie` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '电影ID', `title` varchar(255) NOT NULL COMMENT '电影标题', `genre` varchar(100) DEFAULT NULL COMMENT '电影类型,逗号分隔', `release_date` date DEFAULT NULL COMMENT '上映日期', `poster_url` varchar(500) DEFAULT NULL COMMENT '海报地址', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影信息表'; CREATE TABLE `rating` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '用户ID', `movie_id` bigint(20) NOT NULL COMMENT '电影ID', `score` tinyint(4) NOT NULL COMMENT '评分,1-5分', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '评分时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_movie` (`user_id`,`movie_id`), KEY `idx_movie_id` (`movie_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户评分表';uk_user_movie唯一约束保证了同一个用户不能对同一部电影重复打分,后写入的评分需要走ON DUPLICATE KEY UPDATE做幂等更新。idx_movie_id索引支撑按电影查评分列表的场景——计算物品相似度时按电影聚合评分数据很频繁。
字段类型上,score用tinyint而不是int,因为评分范围就是 1 到 5,tinyint只占 1 字节,索引和内存开销更小。genre用了逗号分隔文本存多值,这不算严格意义上的第三范式,但在毕设项目里够用,查询时用LIKE '%动作%'即可。生产环境应该拆多对多关联表,但这套系统以推荐算法为核心,电影元数据只是辅助维度,不必过度设计。
3.3 导入数据库的实操要点
数据库脚本文件名一般是movie_recommend.sql之类。导入时注意字符集问题:
mysql -u root -p --default-character-set=utf8mb4 movie_db < movie_recommend.sql指定--default-character-set=utf8mb4是为了避免中文电影名在导入时变成乱码。如果你用 Navicat 或 DataGrip 可视化导入,同样要把连接字符集调整为utf8mb4,否则genre字段里的“动作 / 喜剧”等中文值很可能修复不回来。
导入完成后,建议立刻检查评分表的数据分布:
SELECT COUNT(*) AS rating_count, COUNT(DISTINCT user_id) AS user_count, COUNT(DISTINCT movie_id) AS movie_count FROM rating;这个查询结果直接决定协同过滤的推荐效果。如果movie_count太少,比如就几十部,那么用户之间找到共同评分物品的概率会很低,推荐列表会出现大量评分稀疏导致的计算无效。毕设演示时,如果这个数字低于 50,需要往movie表里补数据。
4. 基于用户的协同过滤实现:相似度计算与 Top-N 推荐
这套系统的推荐核心用的是基于用户的协同过滤(User-Based Collaborative Filtering),思想一句话就能说清:找到与你历史评分最相似的一群用户,把他们喜欢而你没看过的电影推荐给你。相比基于物品的协同过滤,它在用户量小、评分数据稀疏的毕设场景里反而更好使——因为两个用户有几部共同看过的电影,相似度就能算出来,而两部电影要等大量用户给它俩同时打分才能建立关联。
4.1 算法主流程
整个推荐逻辑在RecommendService.java里,大致分为四步:
- 读取当前用户的所有评分记录;
- 找出与当前用户共同评分电影数量大于等于 2 的其他用户;
- 用皮尔逊相关系数计算用户间相似度,取 Top K 最近邻;
- 汇总最近邻的评分,按加权得分排序输出前 N 部电影。
4.2 皮尔逊相似度计算
public double pearsonSimilarity(Map<Long, Double> userRatings, Map<Long, Double> otherRatings) { Set<Long> commonMovies = new HashSet<>(userRatings.keySet()); commonMovies.retainAll(otherRatings.keySet()); if (commonMovies.size() < 2) { return 0.0; } double sum1 = 0.0, sum2 = 0.0, sum1Sq = 0.0, sum2Sq = 0.0, sumProd = 0.0; long n = commonMovies.size(); for (Long movieId : commonMovies) { double r1 = userRatings.get(movieId); double r2 = otherRatings.get(movieId); sum1 += r1; sum2 += r2; sum1Sq += r1 * r1; sum2Sq += r2 * r2; sumProd += r1 * r2; } double numerator = sumProd - (sum1 * sum2 / n); double denominator = Math.sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n)); if (denominator == 0.0) { return 0.0; } return numerator / denominator; }这段代码要注意两个点:一是commonMovies.size() < 2时直接返回 0,因为只有一个共同评分算出的相关系数要么是 1 要么是 -1,没有统计意义;二是分母里乘积为 0 的情况——如果某个用户对所有共同看过的电影都打了同样的分,标准差就是 0,相似度直接归零。
4.3 预测评分与推荐生成
拿到相似度之后,预测当前用户对未看过电影m的评分,用加权平均:
double weightedSum = 0.0; double simSum = 0.0; for (SimilarUser neighbor : topNeighbors) { Double neighborScore = neighbor.getRatings().get(targetMovieId); if (neighborScore != null) { weightedSum += neighbor.getSimilarity() * neighborScore; simSum += Math.abs(neighbor.getSimilarity()); } } double predictedScore = simSum == 0 ? 0 : weightedSum / simSum;weightedSum是相似度与评分的乘积累加,simSum是相似度绝对值求和,用来归一化。这里用绝对值做分母是防止负相似度导致预测分被拉成负值。相似度可能为负数——皮尔逊相关系数的取值范围是[-1, 1],口味完全相反的两个用户算出来是负值。实际项目中,我会直接过滤掉负相似度的用户,只保留正相关的邻居参与计算。
4.4 冷启动兜底策略
协同过滤天生怕冷启动。新用户没有任何评分,相似度无从计算;新电影没有用户打分,永远进不了推荐列表。这套系统里应该有对应的兜底逻辑——如果当前用户的评分记录少于 3 条,直接返回全站评分最高的电影列表作为“热门推荐”;如果某部电影从未有评分,则在入库时打上“新片”标签,靠编辑推荐位展示。
SELECT id, title, release_date FROM movie ORDER BY (SELECT AVG(score) FROM rating WHERE rating.movie_id = movie.id) DESC LIMIT 10;这个子查询按avg(score)倒排,是全网热门榜的简版实现。注意它扫描了整张rating表,演示数据量级下没问题,数据量大了应该把平均分冗余到movie表里做物化列。
5. 部署验证与评分预测改进:从能跑到跑出效果
项目能不能在答辩现场立住,部署顺序和验证方式是关键。按下面的步骤走,能避免大部分“环境问题”。
5.1 环境要求速查
| 组件 | 版本建议 | 说明 |
|---|---|---|
| JDK | 1.8 | Spring Boot 2.x 的默认要求 |
| Maven | 3.6+ | 后端依赖管理 |
| MySQL | 5.7 或 8.0 | 导入数据库脚本 |
| Node.js | 14.x + | 前端构建 |
| IDE | IDEA 或 VSCode | 后端推荐 IDEA |
版本不一定要完全一致,但 JDK 版本和后端 pom.xml 里的<java.version>必须对齐,否则编译直接报错。
5.2 启动三步走
第一步,导入数据库。用命令行连接 MySQL 后执行source movie_recommend.sql,或直接用 Navicat 运行 SQL 文件。完成后确认当前库名与后端application.yml里的url一致:
spring: datasource: url: jdbc:mysql://localhost:3306/movie_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 你自己的密码characterEncoding=utf8mb4必须保留,否则中文电影标题在接口返回时会出现乱码。第二步,在 IDEA 里直接运行Application.java启动后端,默认端口看application.yml,常见是 8081 或 8080。第三步,前端目录下执行npm install && npm run serve,等编译完成就能用浏览器打开管理端页面。
5.3 离线验证推荐效果:留一法与 MAE
演示时不能只凭肉眼说“推荐挺准”,最好给一个量化指标。常见的做法是留一法评估:把每个用户最近的一条评分从数据里摘掉,用剩下的数据训练推荐算法,再用算法预测被摘掉的那条,最后计算平均绝对误差(MAE):
public double evaluateMAE(List<Rating> testRatings, Map<Long, Double> predictions) { double totalError = 0.0; for (Rating rating : testRatings) { Double predicted = predictions.get(rating.getMovieId()); if (predicted != null) { totalError += Math.abs(rating.getScore() - predicted); } } return totalError / testRatings.size(); }totalError是预测分与真实分的绝对差之和,除以测试集条数得到 MAE。MAE 在 0.7 以下,说明推荐算法的预测精度在可接受范围。苗头不对时,优先检查相似度计算里是否把user_id和movie_id搞混,这个错误在这个项目里出现的频率相当高。
5.4 给算法打补丁:均值中心化预测
皮尔逊相似度虽然好用,但它对用户的评分习惯很敏感。有人习惯全打高分,有人严格到几乎不给 4 分以上,原始分直接加权平均会让“严格用户”的推荐结果普遍偏低。修正做法是均值中心化:
double userMean = userRatings.values().stream().mapToDouble(Double::doubleValue).average().orElse(0.0); double predictedScore = userMean + weightedSum / simSum;先算出当前用户的个人评分均值,再加回去。这样处理的是“偏离个人习惯”的偏差——相当于把每个用户的评分拉平到同一基准线上再比较。改完这个点,你可以在答辩时说清楚:这是协同过滤里标准的 baseline 修正,目的就是抵消用户评分尺度差异。同理,在pearsonSimilarity里也可以先减均值再算相关系数,效果更稳定,我通常会两个一起改。
最后还有一个实践技巧:调整最近邻数量 K。K 太小,推荐结果受个别用户影响太大;K 太大,低相似度的噪声用户混进来拉低准确率。在这个项目里,K 取 10 到 20 之间比较合适,配合 MAE 指标跑一遍,找一个让误差最小的 K 值写在答辩 PPT 里——这个细节往往比完整复述算法推导更能拿分。
本文还有配套的精品资源,点击获取