简介:基于协同过滤算法的Java电影推荐系统源码,面向Java开发者、电影类流媒体平台及相关专业学习者。系统通过分析用户历史行为数据,提取偏好特征并智能推荐符合口味的电影,适合课程设计、毕业设计参考或二次开发起点。压缩包共76个文件,包括38个Java源文件、15个JSP页面、10个XML配置、2个SQL脚本及JS、CSS等,业务逻辑、页面展示与数据库脚本分层存放,整体结构清晰;压缩包大小仅1.1MB,下载部署轻量。目前已有823人学习/下载,受到开发者关注。源码可直接导入常见IDE运行,配合SQL脚本快速初始化数据库,帮助理解协同过滤算法从评分矩阵构建、相似度计算到TopN推荐的完整落地过程;同时可借鉴JSP+Servlet风格的Web分层设计、数据访问与接口组织方式,是JavaWeb与推荐系统入门及项目实战的高性价比参考。
1. 基于协同过滤算法的Java电影推荐系统源码:它解决什么问题,值不值得照着做
你手头有一个电影站的后台,每天进来几千用户,留下几万条点击和打分,但首页只能按时间或热度排,运营问你“能不能给每个人推不一样的电影”。这个标题给的就是一条能直接落地的路线:基于协同过滤算法的Java电影推荐系统源码,从数据表设计、相似度计算到Top-N推荐接口,全部用Java技术栈实现,不依赖Python服务、不引入Spark,一台普通服务器加MySQL就能跑起来。它适合三类人:做Java课程设计和毕业设计的学生,接私活需要快速交付推荐模块的工程师,以及正在刷java基础面经、随时可能被问到“协同过滤怎么算、缺点是什么”的求职者。后面我会拆成算法选型、数据建模、核心代码、工程接口和踩坑记录五块,每一步都能照着抄,也能照着排错。
2. 协同过滤算法选型:UserCF与ItemCF的取舍,以及Java实现前的数据准备
协同过滤不是一种算法,而是一族算法,落地前如果选错方向,后面写多少代码都救不回来。这一章先解决两个问题:你的场景到底选UserCF还是ItemCF,以及算法跑起来之前,评分数据应该以什么形态装进Java内存。
2.1 UserCF还是ItemCF?先看你的用户量和电影量
协同过滤分两派:基于用户的UserCF和基于物品的ItemCF。UserCF先找出和你口味最接近的一批用户,再把这批用户看过而你没看过的电影推荐给你;ItemCF反过来,先算出电影和电影的相似度,然后从你自己打过高分的电影出发,找没看过的相似片单。两者数学上都在算相似度矩阵,但适用场景差的非常多。
我的经验是:用户量远大于物品量的场景,UserCF会先崩。比如一个电影站有50万注册用户、1万部电影,UserCF要算50万乘以50万的用户相似度矩阵,那就不叫推荐系统,叫内存杀手。反过来,物品量远大于用户量的场景,ItemCF的物品相似度表同样会爆炸。电影推荐的典型特征是物品稳定、用户量大,业内普遍首选ItemCF做主力,UserCF用来做“和你口味相似的人也在看”这种社交化榜单,两个一起上也没问题。
在这套源码里我会把两个都实现,通过配置文件切换,默认走ItemCF。这样既方便交作业时讲清选型理由,也方便你拿同一份数据做对比实验。还有两个必调的参数:相似度阈值和邻居数K。我一般把“相似度低于0.1的邻居直接扔掉”做成常量配置,而不是散落在代码里,后面调参时只需要改配置文件,不用重编译。
2.2 MovieLens数据集与Java实体建模:从CSV到User-Rating矩阵
推荐系统没有数据就是空中楼阁,最常见的做法是拿MovieLens开源数据集,里面包含users、movies、ratings三个文件。ratings文件每行是“userId::movieId::rating::timestamp”,评分是1到5的整数,这个字段格式直接决定了Java代码里怎么切分字符串。把这份数据映射成Java对象,最少只需要两个东西:一个Rating实体类,和一个能装载整个评分矩阵的DataLoader。
下面这段代码先定义了Rating实体,再用一个方法把CSV解析成嵌套Map。为什么用Map而不是二维数组?假设1万个用户、1万部电影,稠密二维数组要开1亿个格子,double[][]占用约800MB内存,而真实评分记录可能只有10万条。稀疏Map只存放有的位置,内存占用能降一个数量级。
public class Rating { private int userId; private int movieId; private double score; private long timestamp; public Rating(int userId, int movieId, double score, long timestamp) { this.userId = userId; this.movieId = movieId; this.score = score; this.timestamp = timestamp; } public int getUserId() { return userId; } public int getMovieId() { return movieId; } public double getScore() { return score; } public long getTimestamp() { return timestamp; } }public class DataLoader { public static Map<Integer, Map<Integer, Double>> loadRatings(String path) throws IOException { Map<Integer, Map<Integer, Double>> userItemMap = new HashMap<>(); try (BufferedReader br = new BufferedReader(new FileReader(path))) { String line; while ((line = br.readLine()) != null) { String[] fields; if (line.contains("::")) { fields = line.split("::"); } else { fields = line.split(","); } if (fields.length < 3) { continue; } try { int userId = Integer.parseInt(fields[0]); int movieId = Integer.parseInt(fields[1]); double score = Double.parseDouble(fields[2]); userItemMap.computeIfAbsent(userId, k -> new HashMap<>()).put(movieId, score); } catch (NumberFormatException e) { // 脏数据直接跳过,真实环境不能因为一行坏数据挂掉整个加载过程 } } } return userItemMap; } }逻辑说明:userItemMap的结构是“用户ID -> (电影ID -> 评分)”,这是UserCF的输入形态。关键方法是computeIfAbsent,它是java基础里很常用的Map操作,等价于“key不存在就new一个HashMap放进去,存在就直接用”,比先判断再put的三行样板代码干净得多。注意我在解析里同时兼容了双冒号和逗号两种分隔符,因为MovieLens旧版用“::”,新版改成了逗号,写死任何一种都会在换数据集时翻车。NumberFormatException的catch也是必须的,爬虫拉来的数据永远比你想象的脏。
2.3 评分矩阵的存储与加载:为什么要转置成ItemCF能用的结构
实际写ItemCF时,光有userItemMap还不够,因为它要回答“某部电影被哪些人评过分”,这是userItemMap的反向关系。常见做法是启动时做一次转置生成itemUserMap,结构是“电影ID -> (用户ID -> 评分)”,内存开销比再读一遍文件要小。
public class DataLoader { public static Map<Integer, Map<Integer, Double>> transpose( Map<Integer, Map<Integer, Double>> userItemMap) { Map<Integer, Map<Integer, Double>> itemUserMap = new HashMap<>(); for (Map.Entry<Integer, Map<Integer, Double>> userEntry : userItemMap.entrySet()) { int userId = userEntry.getKey(); Map<Integer, Double> items = userEntry.getValue(); for (Map.Entry<Integer, Double> itemEntry : items.entrySet()) { int movieId = itemEntry.getKey(); double score = itemEntry.getValue(); itemUserMap.computeIfAbsent(movieId, k -> new HashMap<>()).put(userId, score); } } return itemUserMap; } }参数说明:这段是纯循环遍历,不依赖任何框架,用Java 8的forEach也能写,但传统的for在这种双重遍历下可读性更好。转置完以后,ItemCF的相似度计算只需要把itemUserMap里的两个value取出来求余弦,连Map结构都几乎不用改。还有一个值得做的预处理:过滤掉评分记录少于5条的用户,这些人对相似度矩阵贡献的是噪声,删掉以后推荐质量会明显提升——这个阈值在真实项目里要按数据量调,数据稀疏就降到2到3,数据稠密就提到10。
3. 用Java实现协同过滤核心:相似度计算、UserCF与ItemCF的完整代码
这一章是全篇的重头戏。我按“相似度函数 -> UserCF流程 -> ItemCF流程 -> TopN排序”的顺序给代码,每个函数都能直接复制进你的工程跑起来。拿到源码后先改数据路径,再调阈值,最后看推荐列表,比对着概念空想快得多。
3.1 余弦相似度与皮尔逊相关系数:两段可直接抄的Java方法
相似度是协同过滤的心脏。最常用的是余弦相似度和皮尔逊相关系数。余弦相似度把每个用户的评分看成一个向量,计算两个向量夹角的余弦值:完全同向为1,垂直为0,反向为-1。皮尔逊相关系数相当于对两个用户各自的评分做中心化,也就是减掉各自均值之后再算余弦,它能抵消“有人手松全打5分、有人手紧平均给3分”这种偏差。
public class SimilarityUtil { public static double cosine(Map<Integer, Double> a, Map<Integer, Double> b) { Set<Integer> common = new HashSet<>(a.keySet()); common.retainAll(b.keySet()); if (common.isEmpty()) { return 0.0; } double dot = 0.0; for (int id : common) { dot += a.get(id) * b.get(id); } double normA = 0.0; for (double v : a.values()) { normA += v * v; } double normB = 0.0; for (double v : b.values()) { normB += v * v; } if (normA == 0.0 || normB == 0.0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } public static double pearson(Map<Integer, Double> a, Map<Integer, Double> b) { Set<Integer> common = new HashSet<>(a.keySet()); common.retainAll(b.keySet()); if (common.size() < 2) { return 0.0; } double sumA = 0.0; double sumB = 0.0; for (int id : common) { sumA += a.get(id); sumB += b.get(id); } double meanA = sumA / common.size(); double meanB = sumB / common.size(); double numerator = 0.0; double denomA = 0.0; double denomB = 0.0; for (int id : common) { double va = a.get(id) - meanA; double vb = b.get(id) - meanB; numerator += va * vb; denomA += va * va; denomB += vb * vb; } if (denomA == 0.0 || denomB == 0.0) { return 0.0; } return numerator / (Math.sqrt(denomA) * Math.sqrt(denomB)); } }参数说明:皮尔逊至少要求2个共同评分,否则分子分母恒为0算出来是NaN,在推荐链路里NaN会一路传染到排序结果。还要注意一个细节:余弦相似度的normA、normB是对用户全部评分计算的,而不是只对共同评分的电影计算。如果你只算共同交集里的模长,结果会把“看过很多电影的用户”和“只看过几部电影的用户”拉到同一水平线上,这是我踩过的坑,忘一次错一次。
3.2 用Java实现UserCF:找TopK相似用户,加权汇总候选电影
UserCF的流程拆成四步:取出目标用户的评分,遍历全部用户算相似度,按相似度排序取前K个邻居,最后把邻居们评过分而目标用户没看过的电影按相似度加权汇总。预测分数用“加权平均”而不是简单求和,这是为了避免邻居多的用户天然占优。
public class UserCF { private final Map<Integer, Map<Integer, Double>> userItemMap; private final int topK; private final int topN; private final double minSimilarity; public UserCF(Map<Integer, Map<Integer, Double>> userItemMap, int topK, int topN, double minSimilarity) { this.userItemMap = userItemMap; this.topK = topK; this.topN = topN; this.minSimilarity = minSimilarity; } public List<Map.Entry<Integer, Double>> recommend(int targetUserId) { Map<Integer, Double> targetRatings = userItemMap.get(targetUserId); if (targetRatings == null || targetRatings.isEmpty()) { return Collections.emptyList(); } Map<Integer, Double> simMap = new HashMap<>(); for (Map.Entry<Integer, Map<Integer, Double>> entry : userItemMap.entrySet()) { int otherUserId = entry.getKey(); if (otherUserId == targetUserId) { continue; } double sim = SimilarityUtil.pearson(targetRatings, entry.getValue()); if (sim > minSimilarity) { simMap.put(otherUserId, sim); } } List<Map.Entry<Integer, Double>> neighbors = topN(simMap, topK); Map<Integer, Double> scoreMap = new HashMap<>(); Map<Integer, Double> weightMap = new HashMap<>(); for (Map.Entry<Integer, Double> neighbor : neighbors) { int neighborId = neighbor.getKey(); double sim = neighbor.getValue(); for (Map.Entry<Integer, Double> rating : userItemMap.get(neighborId).entrySet()) { int movieId = rating.getKey(); if (targetRatings.containsKey(movieId)) { continue; } scoreMap.merge(movieId, sim * rating.getValue(), Double::sum); weightMap.merge(movieId, sim, Double::sum); } } Map<Integer, Double> predictMap = new HashMap<>(); for (Integer movieId : scoreMap.keySet()) { predictMap.put(movieId, scoreMap.get(movieId) / weightMap.get(movieId)); } return topN(predictMap, topN); } private List<Map.Entry<Integer, Double>> topN(Map<Integer, Double> map, int n) { return map.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(n) .collect(Collectors.toList()); } }逻辑说明:minSimilarity默认给0.1,低于这条线的用户不做邻居;topK默认给20。在稀疏的数据集上,把minSimilarity降到0.05能多召回不少候选,但同时引入噪声。scoreMap里累加的是“相似度乘以邻居评分”,weightMap里累加的是相似度本身,两者相除得到加权平均分,天然落在1到5区间,不用再归一化。过滤“已看过的电影”必须做,否则推荐列表里出现用户看过的片子,产品验收这关就过不了。
3.3 用Java实现ItemCF:先离线建电影相似度表,再在线召回
ItemCF和UserCF最大的差别是把“用户与用户相似”换成“电影与电影相似”,而电影相似度可以预先离线算好。第一步把userItemMap转置成itemUserMap,第二步对两两电影算余弦相似度写入movieSimMap,第三步在线推荐时从用户评分过的电影出发去相似表里捞候选。下面这段先给出建表和召回两个方法。
public class ItemCF { private final Map<Integer, Map<Integer, Double>> itemUserMap; private final Map<Integer, Map<Integer, Double>> movieSimMap = new HashMap<>(); private final int numSimilar; private final int topN; private final double minSimilarity; public ItemCF(Map<Integer, Map<Integer, Double>> userItemMap, int numSimilar, int topN, double minSimilarity) { this.itemUserMap = DataLoader.transpose(userItemMap); this.numSimilar = numSimilar; this.topN = topN; this.minSimilarity = minSimilarity; buildSimilarityTable(); } private void buildSimilarityTable() { List<Integer> movieIds = new ArrayList<>(itemUserMap.keySet()); for (int i = 0; i < movieIds.size(); i++) { int movieA = movieIds.get(i); Map<Integer, Double> usersOfA = itemUserMap.get(movieA); for (int j = i + 1; j < movieIds.size(); j++) { int movieB = movieIds.get(j); Map<Integer, Double> usersOfB = itemUserMap.get(movieB); double sim = SimilarityUtil.cosine(usersOfA, usersOfB); if (sim > minSimilarity) { movieSimMap.computeIfAbsent(movieA, k -> new HashMap<>()).put(movieB, sim); movieSimMap.computeIfAbsent(movieB, k -> new HashMap<>()).put(movieA, sim); } } } } public List<Map.Entry<Integer, Double>> recommend(Map<Integer, Double> userRatings) { Map<Integer, Double> scoreMap = new HashMap<>(); Map<Integer, Double> weightMap = new HashMap<>(); for (Map.Entry<Integer, Double> entry : userRatings.entrySet()) { int ratedMovie = entry.getKey(); double rating = entry.getValue(); Map<Integer, Double> simMovies = movieSimMap.get(ratedMovie); if (simMovies == null) { continue; } List<Map.Entry<Integer, Double>> similarList = topN(simMovies, numSimilar); for (Map.Entry<Integer, Double> similar : similarList) { int candMovie = similar.getKey(); double sim = similar.getValue(); if (userRatings.containsKey(candMovie)) { continue; } scoreMap.merge(candMovie, sim * rating, Double::sum); weightMap.merge(candMovie, sim, Double::sum); } } Map<Integer, Double> predictMap = new HashMap<>(); for (Integer movieId : scoreMap.keySet()) { predictMap.put(movieId, scoreMap.get(movieId) / weightMap.get(movieId)); } return topN(predictMap, topN); } }逻辑说明:buildSimilarityTable是双重循环,只算上三角(j从i+1开始)并在两边同时写入,能把计算量减半。movieSimMap的规模是“存量电影数乘以平均邻居数”,1万部电影最终可能存几十万个相似对,完全在内存可接受的范围内。recommend方法里的numSimilar我通常取10,意思是一部电影最多找10个相似电影参与累加,避免相似度表过大时每个候选都要遍历全表。这段代码的潜在翻车点:如果你在recommend里传入了空的userRatings,scoreMap和weightMap为空,最后返回空列表——调用方要提前做冷启动判断,不能救到这一步。
3.4 TopN排序与推荐列表去重:用一个工具方法收尾
无论是UserCF还是ItemCF,最后都要把候选按得分降序截断成指定长度。Java里最省事的写法是stream加sorted加limit,但要注意一个细节:当分数相同且条目数超过limit时,stream limit的结果顺序不稳定。工程上更稳妥的做法是先按“分数降序、电影ID升序”做二次排序,保证结果是确定性的。
排序这块我不建议自己手写冒泡排序java教科书代码,数据量上去之后性能不够看。用JDK自带的排序就行:
private List<Map.Entry<Integer, Double>> topN(Map<Integer, Double> map, int n) { return map.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed() .thenComparing(Map.Entry.comparingByKey())) .limit(n) .collect(Collectors.toList()); }参数说明:thenComparing(Map.Entry.comparingByKey())是加分项,它解决了同分时排序结果不稳定导致的前端展示跳变问题。如果你还在JDK 8以下,把stream换成Collections.sort然后手动截断,效果一样。去重逻辑在核心流程里已经做了两次:一是在累加候选时跳过用户已评分的电影,二是在最终推荐表里不重复插入同一部电影,复合主键(user_id, movie_id)兜底防止并发写重复。
4. 把算法源码变成可交付的工程:Spring Boot + MyBatis的分层与接口
算法能跑通只是第一步,推荐系统源码真正值钱的部分在工程化:目录分层是否清晰、数据从哪儿来、接口怎么暴露。这一章我按一个常见做法来拆:Maven工程打底,Spring Boot接HTTP,MyBatis管SQL,算法层保持纯净。
4.1 推荐系统源码的目录结构:算法包和业务包为什么必须分开
我见过很多推荐系统源码,最头疼的是把协同过滤算法写在Controller里,一个类几百行,既没法单测也没法复用。正确的做法是把算法层和Spring框架彻底隔离,目录结构大致是这样:
movie-recommend/ ├── pom.xml └── src/main/java/com/example/recommend/ ├── algorithm/ │ ├── SimilarityUtil.java │ ├── UserCF.java │ ├── ItemCF.java │ └── DataLoader.java ├── entity/ │ ├── Movie.java │ └── UserRating.java ├── mapper/ │ ├── MovieMapper.java │ └── UserRatingMapper.java ├── service/ │ └── RecommendService.java ├── controller/ │ └── RecommendController.java └── RecommendApplication.javaalgorithm包里的类不依赖Spring的@Component、@Autowired这些注解,放到任何Java 8环境都能直接运行。这样做的直接收益是:你可以写一个main方法单独加载MovieLens数据调UserCF,不用启动一个Web容器就能验证算法效果,debug一个推荐逻辑从改代码到看到结果不超过10秒。这也是面向对象编程java里“职责单一”思想落地:算法只负责算,Service只负责编排,Controller只负责传输。
pom.xml里需要引入的东西很简单:Spring Boot Web、MyBatis Starter和MySQL驱动。版本以你自己本地的Spring Boot为准,我习惯用2.7.x搭配MyBatis 2.x,如果是新学的同学直接用3.x也一样,接口上没有大差别。
4.2 MySQL表设计:评分表、电影表与推荐结果表,字段和索引这样定
推荐系统要落库,至少要有三张表:电影表存元数据,评分表存用户行为,推荐结果表存离线算好的TopN。表结构可以直接照抄下面这个,字段名我压到了最少:
CREATE TABLE `movie` ( `id` int NOT NULL, `title` varchar(255) NOT NULL, `genres` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ); CREATE TABLE `user_rating` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `movie_id` int NOT NULL, `score` double NOT NULL, `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_movie_id` (`movie_id`) ); CREATE TABLE `recommend_result` ( `user_id` int NOT NULL, `movie_id` int NOT NULL, `score` double NOT NULL, `rank` int NOT NULL, `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`, `rank`) );参数说明:user_rating表的核心是user_id和movie_id两个索引,user_id索引服务于“给某用户取全部评分”,movie_id索引服务于“给某电影取全部评分用户”;第二个用途是ItemCF离线计算时的SQL支撑。recommend_result表用(user_id, rank)做复合主键,rank直接存1到N,意味着同一个用户新一轮结果只要按rank覆盖插入,不用先删后插,省一次事务。score字段存的是协同过滤算出来的预测分,前端排序直接按它排就行。
4.3 Spring Boot接口层:Controller保持轻薄,Service兜底冷启动
接口层最常见的错误是把算法塞进Controller再包一层循环。下面这个例子是标准的三层写法,先看Controller:
@RestController @RequestMapping("/api/recommend") public class RecommendController { private final RecommendService recommendService; public RecommendController(RecommendService recommendService) { this.recommendService = recommendService; } @GetMapping("/{userId}") public List<MovieVO> recommend(@PathVariable int userId) { return recommendService.recommendForUser(userId); } }逻辑说明:Controller只干一件事,把userId参数取出来交给Service,再把Service返回的结果以JSON形式吐给前端。构造函数注入比字段上加@Autowired更利于测试,这也是很多公司代码规范里的要求。MovieVO是一个轻量DTO,只有movieId、title、score、rank四个字段,不要直接把MyBatis的实体类返回给前端,否则表结构一改,接口字段就跟着变,坑所有调用方。
紧接着是Service层:
@Service public class RecommendService { private final UserRatingMapper userRatingMapper; private final RecommendResultMapper recommendResultMapper; private final MovieMapper movieMapper; public RecommendService(UserRatingMapper userRatingMapper, RecommendResultMapper recommendResultMapper, MovieMapper movieMapper) { this.userRatingMapper = userRatingMapper; this.recommendResultMapper = recommendResultMapper; this.movieMapper = movieMapper; } public List<MovieVO> recommendForUser(int userId) { List<RecommendResult> results = recommendResultMapper.selectByUserId(userId); if (results == null || results.isEmpty()) { List<Movie> hotMovies = movieMapper.selectHotMovies(20); return MovieVO.fromMovies(hotMovies); } return MovieVO.fromResults(results); } }参数说明:Service层做了冷启动兜底——如果recommend_result表里查不到这个用户,直接查movie表里的热门电影榜返回,前端的推荐位永远不会空。这比在算法层强行判断更干净,因为接口的语义变成“永远有推荐结果”。selectHotMovies的SQL用“评分人数和平均分”组合排序:ORDER BY score_count DESC, avg_score DESC LIMIT 20,不需要join,性能就够。
如果要把在线实时推荐也接进来,做法是在Service里注入ItemCF的Bean,把mapper查到的评分记录转成Map结构喂给算法,但这要求评分数据量小到能在几十毫秒内完成遍历。数据量超过10万条以后,我更建议离线计算落库再加每日定时任务,别让算法接口扛实时压力,这就是“离线为主、实时兜底”的常见架构。
5. 协同过滤必踩的五个坑:冷启动、稀疏矩阵、评分偏差与性能翻车排查笔记
这一章的每条记录都是真实调参时会撞见的现象,我按“现象、原因、解决”三段写,你照着日志对比就知道问题在哪一环。
5.1 冷启动:新用户没有评分,推荐结果永远是空的
现象:刚注册的用户调用推荐接口,返回空列表;前端页面在“猜你喜欢”区域一片空白,转化率直接归零。
原因:UserCF和ItemCF都依赖历史评分。用户没有任何行为时,相似度计算的分母是0,任何公式都产出0,算法层面无能为力。
解决:冷启动阶段不做协同过滤,用热门榜兜底。我前面Service里的实现就是把selectHotMovies结果返回出去。热门榜不能简单用评分人数排序,我一般用“评分人数×平均分”再乘时间衰减权重,避免老片霸榜。等用户产生了五六条评分记录之后,再切换回协同过滤逻辑,这是最简单的渐进式冷启动。
5.2 稀疏矩阵:两个用户只共同看过一部电影,相似度却高达0.9
现象:调日志时发现一对用户相似度0.87,但他们共同评分只有1部电影。相似表里大量这样虚高的相似对,推荐结果跑偏。
原因:余弦相似度在交集样本极少时统计上不显著,一个共同评分就能把夹角带得极小,相似度虚高。电影站动辄上万部电影,用户平均评分又少,交集为0是常态,交集为1根本不稀奇。
解决:加置信度门槛,当共同评分数小于阈值时直接返回0。我在SimilarityUtil.pearson里已经写了common.size() < 2就返回0.0,实际项目里建议把阈值提到5。同时优先使用皮尔逊相关系数替代原始余弦,因为皮尔逊先做了均值中心化,对“共同评分样本少但绝对分数高”的抗性更好。这条在数据稀疏的新站上尤其重要。
5.3 评分偏差:有人手松全打5分,有人手紧只打3分,余弦相似度直接失真
现象:用户A给所有电影打5分,用户B同样几部电影打4分,余弦相似度依然算得很高,可两人的真实口味可能完全相反。
原因:余弦相似度把绝对分数当成向量方向来算,没有减去每个用户的平均分。手松和手紧在绝对分数上差了整整一个常数偏移,这个偏移在余弦公式里没有被消除。
解决:改用皮尔逊相关系数,等价于把每个用户的评分先中心化(原分数减均值)再算余弦。我的SimilarityUtil里两个方法都写了,工程里默认走pearson就对了。如果你坚持用余弦,那就先做归一化,我可以把“score - userAvgScore”存入Map后再交给余弦方法。
5.4 热门电影屠榜:推荐结果全是全局爆款,多样性和个性化同时归零
现象:离线计算结果里,Top10推荐总是那几部评分人数最多的电影,每个用户的列表大同小异,运营抱怨“推荐没有差异性”。
原因:热门电影被大量用户评分,它们和所有电影都容易产生相似关系;用户在评分过的电影里去捞相似候选,热门电影出现的频率极高,累加积分碾压小众片。
解决:累加权重时加入热门惩罚。常见做法是对候选电影的相似度乘上一个衰减系数:sim / Math.pow(1 + Math.log(1 + itemUserMap.get(movieId).size()), 2),这个系数随评分人数上升而下降。还可以在后处理里做类型约束:按genres分组,每组最多选3部,保证TopN里有喜剧也有悬疑,而不是清一色的动作大片。这一步是推荐结果从“能用”到“像样”的分水岭。
5.5 性能翻车:循环里查库导致N+1查询,一次推荐接口把数据库打挂
现象:一次推荐请求发出后,数据库慢查询日志里出现两三百条SQL,接口耗时8秒,QPS一高MySQL连接直接打满。
原因:典型的N+1问题。常见写法是在算法里遍历用户列表,每次循环调一次mapper查询评分,比如for (userId : allUsers) { userItemMap = userMapper.selectRatingsByUserId(userId); }。用户量一上来,这个循环就是灾难。
解决:把行为数据一次性加载进内存。在Spring Boot启动时用@PostConstruct或ApplicationRunner执行一次全量load,把评分记录组装成userItemMap和itemUserMap保存在单例Bean里,接口只从内存取数。数据量超过百万条时,改为离线Spark或定时任务预计算后再落recommend_result表,在线接口绝不碰评分明细表,只查推荐结果表。这条不只是性能问题,还是一个架构取舍:推荐结果表里只有每个用户前20条结果,查询走主键,P95响应时间能压到10毫秒以内。
6. 验证推荐结果是否靠谱:离线切分、覆盖率与K值调参的进阶收尾
推荐系统源码写完,最容易被忽略的一步是验证。没有验证手段,你就分不清“参数调好了”和“参数调顺手了”。我最后分享一个每次都会做的离线验证流程和两个指标。
先把评分数据按时间切分:按timestamp排序,前80%作为训练集,后20%作为测试集。协同过滤只用训练集计算相似度和推荐,然后用测试集里用户真实看过的电影和推荐结果做比对。如果推荐结果里出现了用户真实看过的测试集电影,说明这次推荐是命中的。命中数除以测试集电影数就是召回率,再算一下推荐结果覆盖了电影全量的多少比例,得到覆盖率。这两个数字一个衡量准确度,一个衡量多样性。
K值和相似度阈值的调参法:固定minSimilarity为0.1,把K从5、10、15、20依次跑一遍,记录每组的召回率和覆盖率。你会发现K小的时候精度高但覆盖面窄,K大了以后结果趋于热门化。我在做电影推荐这类的经验值是K=20到30,minSimilarity=0.1到0.2,如果你的数据更稀疏,K往下调、minSimilarity往下调;数据更稠密就反过来。调参顺序是先调K,再调minSimilarity,最后才调热门惩罚系数,因为K对结果的影响最直接。
最后一个进阶技巧是存一份movieSimMap的序列化缓存。ItemCF建电影相似度表在几千部电影时需要几十秒,可能每次重启都要等。常见做法是用Java自带的ObjectOutputStream把movieSimMap写到本地临时文件,启动时先检查文件存在就反序列化加载,不存在才重建。我会在代码里注明缓存的失效条件:只要评分数据更新了,就要删掉缓存文件重新生成,否则推荐永远不会反映新行为。
这套源码我自己交过课设,也改过生产环境的需求,最大的教训是:协同过滤不是装上去就完事的黑匣子,它需要你把每一个阈值当成可配置的参数,再把验证指标当成调参的准绳。推荐结果能打60分还是90分,差别往往不在算法本身,而在于你有没有把冷启动兜底和热门惩罚做到位。希望帮到你。
本文还有配套的精品资源,点击获取