☰
基于Mahout的协同过滤电影推荐系统:UserCF与ItemCF实现详解
2026/10/10 1:56:04 网站建设 项目流程

简介:面向计算机相关专业毕设与课程设计场景的Mahout协同过滤电影推荐系统完整源码包。项目基于Mahout实现协同过滤推荐算法,从用户行为数据出发,通过用户相似度或物品相似度计算,最终生成个性化推荐列表;包含Java后端源码、JSP动态页面、JavaScript前端交互、Web配置文件及设计说明文档,适合推荐算法入门、毕设二次开发或初期立项演示。压缩包共62个文件,Java源码与编译class各16个,辅以JSP页面、XML配置、JS脚本及MovieLens数据集等,整体仅18.43MB,目录结构清晰,便于直接导入IDE运行调试。目前已有136人学习下载。资源内代码经过完整测试,运行成功后才上传,答辩评审平均分达96分,附带的项目说明文档与数据文件可帮助快速理解协同过滤推荐流程,也可在现有基础上扩展功能;该实现可作为毕业设计、课程设计或推荐算法实验的完整参考,对相似度计算与推荐结果排序等关键环节有直观演示;若运行遇到问题,可联系作者获取指导。

1. 基于Mahout的电影推荐系统:一套能直接交差的协同过滤毕设路线

拿到“Java毕业设计-基于Mahout实现协同过滤推荐算法的电影推荐系统 +源代码+设计说明”这个题目,很多人第一反应是先去写用户管理、电影管理的增删改查页面,直到答辩前一周才发现推荐算法那块还是一片空白。这个题目的关键结论是:难点不在Java Web,而在把Mahout的协同过滤跑通并接到自己的数据上——评分表结构设计错了,相似度算出来全是NaN,推荐列表永远为空,这才是最常见的翻车点。这篇文章按我自己做推荐系统的顺序拆解:协同过滤原理与选型、MovieLens数据落库、UserCF和ItemCF的Mahout实现、调参与踩坑,最后把推荐引擎嵌进Spring Boot对外提供接口。适合Java方向需要完整落地一个算法的毕业生,也适合想在公司内部快速搭一个“猜你喜欢”原型但不想从零写相似度计算的工程师。

2. 协同过滤的两种算法路线:UserCF和ItemCF,毕业设计为什么优先选ItemCF

2.1 协同过滤的数学直觉:先算相似度,再做TopN打分

协同过滤的核心假设是“相似的人会有相似的口味,相似的商品会被相似的人喜欢”。把100个用户对1000部电影的评分画成矩阵,行是用户,列是电影,格子是1到5分的评分,大多数格子是空的。UserCF的思路是先拿目标用户的行向量和其他用户的行向量做相似度计算,找到最像的K个邻居,再用邻居对某部电影的评分加权平均,预测目标用户对这部电影的打分。ItemCF则反过来计算电影列向量之间的相似度,找到“和你看过的电影最像的没看过的那批电影”聚合打分。

两种算法在Mahout里都映射成一条组装链:DataModel负责提供评分矩阵,Similarity负责计算相似度,最后交给专门推荐器输出List 。注意这里没有复杂的机器学习训练过程,协同过滤本质上是基于统计的最近邻方法,所以它不需要训练和推理两阶段,数据加载完就能出结果。这既是优点也是坑:评分矩阵的稀疏程度直接决定相似度还有没有意义。

2.2 UserCF还是ItemCF:电影场景下两个角度的取舍

电影推荐和音乐推荐有一个共性:物品数量相对用户数量少得多,而且用户的兴趣在一段时间内比较稳定。ItemCF先用用户自己评过分的电影做线索,去找这批电影的相似电影,推荐结果解释起来很自然——“因为你喜欢《盗梦空间》,所以推荐《星际穿越》”。UserCF则是先找和你口味一致的一群人,把这些人看过而你没看过的电影拎出来推荐,社交属性更强,但电影场景下用户之间的共同评分往往很少,邻居质量不好保证。

实际做毕业设计时,我一般会优先把ItemCF跑通,因为它不需要调邻居数这个额外参数,代码链条更短,离线评估的误差通常也比UserCF低。但论文里两种算法都要写,用同一份数据做对比,这样才能体现工作量。下面的对比表可以直接放进设计说明文档:

维度UserCFItemCF
计算对象用户与用户的相似度电影与电影的相似度
适合场景新闻、社交内容,用户兴趣变化快电影、电商、音乐,物品相对稳定
冷启动表现新用户无评分时完全失效对冷门电影失效,但热门电影仍有推荐
Mahout实现复杂度需要配置邻居类不需要邻居类,链条更短
评估误差(MovieLens上常见表现)略高略低

2.3 Mahout在算法选型里的定位:相比从零手写和Spark优势在哪

Mahout 0.9的org.apache.mahout.cf.taste包是一套纯Java的内存推荐引擎,它把DataModel、UserSimilarity、ItemSimilarity、Recommender、Evaluator全都抽象好了。选它而不是自己手写相似度计算,最大的好处是省掉一堆边界问题:两个用户没有共同评分时皮尔逊相关系数怎么处理、向量模长为0时余弦相似度怎么避免除零,这些Mahout都有默认策略。相比Spark ALS,Mahout单机就能跑,不需要搭集群,对毕业设计来说部署成本和答辩复杂度都低得多。

但选Mahout也要接受它的历史包袱:0.9之后taste模块基本停止维护,它假设数据能装进单机内存,百万级评分没问题,上千万条就会吃力。这是“够用但别硬撑”的边界。数据量超过这个量级,应该换成Spark MLlib的ALS或自研ItemCF离线任务。对这个毕设题目来说,MovieLens 100K数据集只有10万评分,Mahout跑起来非常轻松。

3. 把MovieLens跑进本地数据库:建表、导数和数据体检

3.1 数据源和表结构设计:users、movies、ratings三张表

公开数据集MovieLens 100K是这类题目最稳妥的选择,包含943个用户对1682部电影的10万条评分,评分取值为1到5的整数。文件格式是纯文本,每行用双冒号分隔:userId::movieId::rating::timestamp。不建议自己爬网站数据或手工编造评分,因为答辩时导师一定会问数据来源和规模,用公开数据集反而能讲清楚。

数据库表结构按经典的三表设计。用户表只保留必要字段,电影表把题材类型直接存成VARCHAR,评分表用(user_id, movie_id)做复合主键,防止重复导入把数据搞脏。timestamp列在Mahout里会被转成long,所以直接建BIGINT类型,不要用MySQL的DATETIME,否则后续JDBCDataModel映射会多一层类型转换,完全没必要。

CREATE TABLE users ( user_id BIGINT PRIMARY KEY, user_name VARCHAR(50) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE movies ( movie_id BIGINT PRIMARY KEY, title VARCHAR(200) NOT NULL, genres VARCHAR(255) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE ratings ( user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, rating DOUBLE NOT NULL, rating_time BIGINT NOT NULL, PRIMARY KEY (user_id, movie_id), KEY idx_movie_id (movie_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这套表的逻辑是:评分表是绝对核心,Mahout只认这张表;用户表和电影表是给Web页面展示用的,不参与算法计算。给movie_id建普通索引是因为ItemCF的评估过程要频繁按电影聚合评分,没有索引全表扫描会很慢。字符集用utf8mb4而不是utf8,是为了避免电影名里出现生僻字或Emoji时入库报错。

3.2 Maven依赖与Java 8环境:Mahout 0.9能用的最小组合

用Maven管理依赖比手动丢jar包干净得多,但Mahout 0.9的传递依赖非常古老,会带进来一堆Hadoop相关的库。我一般会在pom.xml里显式引入mahout-core和mahout-math,并把不需要的传递依赖排除掉,避免本地Java环境启动时报警告甚至冲突。JDK版本固定用Java 8,Java 9以上跑Mahout 0.9经常出现模块化访问限制问题。

<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.apache.mahout</groupId> <artifactId>mahout-core</artifactId> <version>0.9</version> <exclusions> <exclusion> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.apache.mahout</groupId> <artifactId>mahout-math</artifactId> <version>0.9</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> </dependencies>

逻辑说明:mahout-core里有taste推荐引擎的完整实现,mahout-math提供向量和矩阵运算支持,mysql-connector-java负责数据库连接。排除hadoop-client是经验之举——Mahout是从Hadoop生态走出来的库,0.9默认带了一堆Hadoop依赖,而本地跑推荐用不上分布式文件系统,保留只会增加启动耗时和版本冲突概率。注意驱动版本不要用8.0.x,它和5.1.x的JDBC URL参数写法有差异,Mahout里的JDBCDataModel对驱动兼容性停留在老版本风格上。

3.3 用JDBC批量导入评分数据,并验证行数

数据导入是最容易出错的一步,常见翻车姿势是逐行INSERT导致导入10万条评分要跑几分钟,或者用文本工具打开csv再粘到Navicat里导到一半报错。正确做法是写一个一次性导入程序,用Files.readAllLines把整个ratings.dat读进内存,按::拆分后由PreparedStatement批量执行,每500条提交一次事务。

public class ImportRatings { public static void main(String[] args) throws Exception { String filePath = "D:/ml-100k/ratings.dat"; List<String> lines = Files.readAllLines(Paths.get(filePath), StandardCharsets.UTF_8); String jdbcUrl = "jdbc:mysql://localhost:3306/movie_rec?useUnicode=true&characterEncoding=utf8"; try (Connection conn = DriverManager.getConnection(jdbcUrl, "root", "123456")) { String sql = "INSERT INTO ratings (user_id, movie_id, rating, rating_time) VALUES (?, ?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(sql); int count = 0; conn.setAutoCommit(false); for (String line : lines) { String[] parts = line.split("::"); ps.setLong(1, Long.parseLong(parts[0])); ps.setLong(2, Long.parseLong(parts[1])); ps.setDouble(3, Double.parseDouble(parts[2])); ps.setLong(4, Long.parseLong(parts[3])); ps.addBatch(); if (++count % 500 == 0) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); System.out.println("imported count = " + count); } } }

代码里把整批数据先攒在Batch里再执行,能显著减少网络往返和MySQL事务日志写入压力。每500条commit一次是稳妥的折中:一次性提交十万条万一中间失败,整个事务回滚代价太大;每一条都commit又太慢。最后打印的count要和源文件行数核对,MovieLens 100K的ratings.dat应该是100000行,如果少了,基本是文件编码或分隔符被Excel转义过的问题,重新从官方下载原文件即可。

3.4 数据体检:评分稀疏度直接决定你后面算法能不能用

导入完成后别急着写推荐引擎,先跑几条SQL看看数据长什么样。推荐系统的血泪经验是:算法代码没跑通通常不是算法问题,而是数据本身有问题。比如评分表里混进了NULL值、某个用户只有一条评分、某部电影没有任何人看过,这些都会让相似度计算返回NaN。

SELECT COUNT(*) AS rating_count, COUNT(DISTINCT user_id) AS user_count, COUNT(DISTINCT movie_id) AS movie_count, AVG(rating) AS avg_rating FROM ratings;

这四条统计信息足够判断数据能不能用。MovieLens 100K的结果应该接近943个用户、1682部电影、100000条评分、平均分3.5左右。用评分总数除以用户数和电影数的乘积,得到评分矩阵稀疏度约6.3%,意味着绝大多数格子是空的。这告诉我们两点:第一,皮尔逊相关系数在这种稀疏矩阵上会大量退化,因为两个用户很可能只有一两部共同评分的电影,这时LogLikelihoodSimilarity比Pearson更稳;第二,必须在论文里写清楚评分的分布,否则答辩被问“为什么用这个相似度”会答不上来。

4. 实现UserCF和ItemCF推荐引擎:核心代码与参数说明

4.1 UserCF:相似度、邻居、推荐器三件套怎么组合

UserCF在Mahout里必须凑齐三个对象:UserSimilarity、UserNeighborhood、Recommender,缺一个都不行。下面的代码用MySQLJDBCDataModel直接从数据库读评分,不需要自己写数据加载逻辑。这里选PearsonCorrelationSimilarity做示范,邻居取最近的50个用户,最后用GenericUserBasedRecommender生成推荐。

DataSource ds = DataSourceUtil.getDataSource(); // 复用Druid或C3P0连接池 DataModel model = new MySQLJDBCDataModel( ds, "ratings", "user_id", "movie_id", "rating", "rating_time"); UserSimilarity similarity = new PearsonCorrelationSimilarity(model); UserNeighborhood neighborhood = new NearestNUserNeighborhood(50, similarity, model); Recommender recommender = new GenericUserBasedRecommender(model, neighborhood, similarity); List<RecommendedItem> items = recommender.recommend(1L, 10); for (RecommendedItem item : items) { System.out.println(item.getItemID() + " -> " + item.getValue()); }

逻辑说明:MySQLJDBCDataModel的构造参数顺序是数据源、评分表名、用户ID列名、物品ID列名、评分列名、时间戳列名,注意最后一个参数如果表里没有对应列,可以直接传null,Mahout内部会处理,但推荐结果里会丢失时间权重相关的信息。NearestNUserNeighborhood的第一个参数50表示只取和目标用户相似度最高的50个用户作为邻居,这个值的敏感性很高。

参数设置的经验是:邻居数太小(比如5个),推荐结果受少数几个用户影响,方差大;邻居数太大(比如200个),会把相似度很低的用户也拉进来,推荐列表变得平庸。MovieLens这种规模,40到100之间比较合理。PearsonCorrelationSimilarity适合评分尺度统一的显式数据,但如果数据稀疏,我建议换成LogLikelihoodSimilarity,后面避坑章节会细说。

4.2 ItemCF:把相似度从用户换成电影

ItemCF的组装比UserCF更简洁,不需要UserNeighborhood,因为它的计算单元是“目标用户评分过的电影”和“候选电影”之间的相似度,而不是用户群体。Mahout里用GenericItemBasedRecommender一行就能跑起来。

DataModel model = new MySQLJDBCDataModel( ds, "ratings", "user_id", "movie_id", "rating", "rating_time"); ItemSimilarity similarity = new LogLikelihoodSimilarity(model); Recommender recommender = new GenericItemBasedRecommender(model, similarity); List<RecommendedItem> items = recommender.recommend(1L, 10);

逻辑说明:GenericItemBasedRecommender的recommend方法内部会先找出目标用户已经评分过的所有电影,然后计算这些电影与库中其他电影的相似度,按加权评分聚合出候选电影得分,最后默认排除用户已经看过的电影。这里用LogLikelihoodSimilarity而不是Pearson,是因为ItemCF在计算电影列向量相似度时,很多电影只在极少数用户的评分里共同出现过,皮尔逊相关在这种二元共现数据上极不稳定。

LogLikelihoodSimilarity的数学直觉是:如果两部电影被同一批用户共同评分的次数,显著高于随机情况下可能出现的共现次数,那它们就有真实关联。它不需要处理评分均值和方差,对稀疏数据更友好。如果换了相似度后推荐结果明显变多、不再是空列表,就说明之前用Pearson确实选错了。

4.3 推荐结果的排序与去重:别让重复电影刷屏

Mahout返回的List 自带按预测分降序排序,但有两个细节要注意。第一,它只保证预测分高到低的顺序,不保证你拿到的是“最近想看”的电影,如果希望推荐更贴近用户的近期口味,可以在SQL里给评分时间加权。第二,当数据里有同一部电影的不同条目时,比如某些数据集把同一部电影的不同版本(导演剪辑版、加长版)当作不同movieId,推荐结果就可能出现高度相似的内容连续刷屏,看起来像没去重。

我自己处理这类问题时,会在拿到List 之后再做一层业务过滤:按电影的genres字段做分类,保证推荐的10部电影里不超过3部属于同一题材;或者按导演、主演去重。Mahout不负责这种业务语义,它只管“计算出来的分”。另一个更务实的手段是直接在推荐SQL层面排除用户已经看过的电影,虽然Mahout的推荐器默认排除了,但从Web接口传参那层再加一次过滤,能防止你自己在recommendAndScore方法里误用被过滤的候选集。

4.4 用Mahout评估器给推荐效果打分

毕业设计的推荐系统如果没有量化评估,答辩时会非常被动。Mahout内置了多种RecommenderEvaluator实现,最常见的AverageAbsoluteDifferenceRecommenderEvaluator计算的是预测评分和真实评分之间的平均绝对误差。建议用80%的评分数据做训练集,20%做测试集。

RecommenderEvaluator evaluator = new AverageAbsoluteDifferenceRecommenderEvaluator(); RecommenderBuilder builder = new RecommenderBuilder() { @Override public Recommender buildRecommender(DataModel model) throws TasteException { ItemSimilarity similarity = new LogLikelihoodSimilarity(model); return new GenericItemBasedRecommender(model, similarity); } }; double score = evaluator.evaluate(builder, null, model, 0.8, 1.0); System.out.println("MAE = " + score);

逻辑说明:evaluate方法的参数含义分别是推荐器构造器、训练集占整体比例、评估训练迭代次数。0.8表示80%数据训练,1.0表示用全部数据做一次随机划分评估。这个分数在1到5的评分尺度下,一般落在0.7到0.9之间,越小越好。如果分数大于1.5,说明预测基本等于随机猜,先回头检查数据导入和相似度选择。

评估器只能证明“算法在这个数据集上有没有统计意义上的效果”,它不能证明你的Web系统好用。所以论文里最好同时写两类结果:一类是MAE这样的离线指标,另一类是抽样几个用户的推荐列表做人工展示,比如挑一个看过大量科幻片的用户,验证推荐结果里科幻片占比高不高。

5. 常见问题与避坑记录:从编译冲突到推荐结果全为空

5.1 Java版本与Guava依赖冲突,启动就NoSuchMethodError

现象:Maven编译正常,但main方法一启动就抛NoSuchMethodError,堆栈指向Guava或Hadoop类。 原因:Mahout 0.9依赖的Guava版本停留在老版本,而Spring Boot或其他组件引入了新版Guava,类库冲突导致运行时找不到老方法。 解决:固定JDK 8,并在pom里排除Mahout自带的传递依赖。如果项目里同时有Guava,显式声明一个版本,我一般用16.0.1和Mahout 0.9搭配。排查时在IDE里打开Dependency Analyzer,搜索Guava看有没有多个版本。

5.2 中文乱码与时间戳字段类型不匹配

现象:从数据库读出来的电影名是问号,或者时间戳列插入后变成负数。 原因:JDBC URL没有带characterEncoding参数,MySQL连接用了默认的latin1;时间戳列被建成DATETIME,Mahout按long读取时解析失败。 解决:JDBC URL统一加useUnicode=true&characterEncoding=utf8,建表用utf8mb4。rating_time列用BIGINT存Unix秒,导入时不要做任何日期格式化,直接存原始long值。乱码问题只影响页面展示,不影响推荐计算,但答辩演示时全是问号会很掉档次。

5.3 全量计算内存溢出,OOM只发生在半夜跑定时任务时

现象:本地测试只推荐几个用户没问题,部署到服务器后跑全量用户推荐,JVM直接OutOfMemoryError。 原因:MySQLJDBCDataModel默认把整张评分表加载进内存缓存,10万条评分没问题,但如果评分表膨胀到千万条级别,且定时任务每跑一次就new一个DataModel,内存自然爆掉。 解决:先限定推荐范围——不是所有用户都要每天刷新推荐,只算最近7天有登录行为的用户。再给JVM设-Xmx2g或根据机器内存调整。Mahout是内存模型这件事在论文里必须写明局限性,然后补一段“将来数据量增长时可迁移到Spark ALS”的展望。

5.4 新用户没有评分,推荐结果恒为空

现象:管理系统里新注册一个用户,前端调用推荐接口返回空数组,日志也不报错。 原因:协同过滤是纯历史行为驱动,新用户没有任何评分记录,DataModel里根本没有这一行,无法计算相似用户或相似电影。 解决:降级策略。在Service里捕获空结果,改用热门电影兜底:统计评分表中每个movieId的评分人数和平均分,按“平均分不低于3.5且评分人数最多”排序取前10。这也是业界冷启动问题的标准处理方式,写进设计说明是一个加分项。

5.5 相似度全是0.0或者NaN,评估分数虚高

现象:打印用户1的相似用户列表,输出全是0.0或NaN,但MAE却异常低。 原因:数据导入时把rating列和timestamp列搞反了,或者大量评分值被导入成了同一个常数,导致方差为0,皮尔逊相关系数失去意义。评估器数据划分时训练集和测试集用户重叠度低,也会让误差看起来很小。 解决:先跑数据体检SQL,确认rating字段的MIN、MAX、AVG在合理范围。再写一个临时main方法,只加载DataModel,打印userId=1最相似的5个用户及其相似度值,肉眼看数值是否合理。0.0不可怕,全是NaN才需要回头查数据。

6. 把推荐引擎嵌进Web系统:Spring Boot离线计算与缓存优化

6.1 用Service封装Mahout引擎,避免每次请求重建DataModel

很多毕设代码把推荐逻辑写在Controller里,每来一个请求就new一个DataModel和Recommender,数据量不大时看不出问题,但每次都要重新查库、重新计算相似度矩阵,响应时间会膨胀到几百毫秒甚至秒级。正确做法是把引擎生命周期交给Spring容器,启动时初始化一次,请求只做查表。

@Service public class RecommendService { private Recommender recommender; @PostConstruct public void init() throws TasteException { DataSource ds = DataSourceUtil.getDataSource(); DataModel model = new MySQLJDBCDataModel( ds, "ratings", "user_id", "movie_id", "rating", "rating_time"); ItemSimilarity similarity = new LogLikelihoodSimilarity(model); this.recommender = new GenericItemBasedRecommender(model, similarity); } public synchronized List<RecommendedItem> recommend(Long userId, int topN) throws TasteException { return recommender.recommend(userId, topN); } }

init方法标注了@PostConstruct,Spring容器创建Bean之后自动执行数据加载,整个应用生命周期只初始化一次。recommend方法加synchronized是为了防止高并发下Mahout内部的非线程安全状态被同时访问,单机系统完全够用。Controller只管接收userId和topN参数,把业务结果包装成JSON返回,这层隔离让推荐算法和Web框架彻底解耦。

6.2 定时离线重算:推荐结果写表,接口只查表

在线实时计算对单机Mahout不友好,更稳妥的架构是定时离线算好结果落到数据库,前端接口从推荐结果表里取数据。用Spring的@Scheduled注解写定时任务,每天凌晨3点30分跑一次全量重算,这样用户访问时段没有任何计算压力。

@Scheduled(cron = "0 30 3 * * ?") public void refreshAllRecommendations() { List<User> users = userMapper.selectRecentActiveUsers(7); for (User user : users) { List<RecommendedItem> items = recommend(user.getId(), 10); recommendResultMapper.replaceInto(user.getId(), items); } }

定时任务里只算最近7天活跃的用户,比全量计算省一半以上时间。结果表用REPLACE INTO覆盖写入,老旧推荐记录不会残留。页面端推荐接口变成一条简单查询:SELECT movie_id, score FROM recommend_result WHERE user_id = ? ORDER BY score DESC。这套架构的好处是答辩时可以清楚讲出“计算层、存储层、展示层分离”,也符合真实工业界推荐系统的离线计算模式。

做这个题时我自己吃过一次亏:一开始把大部分时间花在写页面上,最后才去调Mahout,结果发现评分数据里混着Null值,相似度怎么算都是空,连续熬夜了两天才排查完。现在回头看,正确顺序应该是先把数据灌进去、把评估器跑通,确认推荐结果能出来,再回头补页面。算法引擎是整个系统的发动机,发动机不转,外壳再漂亮也过不了答辩,这个顺序反过来能省下大量改稿时间。希望这篇笔记能帮你把协同过滤这条线一次跑通,别在数据格式和依赖冲突这些本该半小时解决的问题上反复折腾。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询