
简介这份资源面向推荐系统入门与进阶的 Java 开发者聚焦协同过滤算法的工程化落地帮助读者理解并实现「相似物品推荐」这一核心思路给定用户 u先找出其历史喜欢的物品集合 R(u)再把与 R(u) 相似的物品推荐给该用户从而完成个性化推荐。压缩包共 3 个文件以 2 个 Java 源码文件为主要内容分别对应基于物品与基于用户的协同过滤实现另含 1 个数据分片文件整体约 12.8MB结构精简便于直接阅读与二次改造。资源附带测试集可配合源码验证相似度计算与推荐结果适合作为课程设计、毕业设计或推荐算法练手的参考实现。目前已有 2290 人学习下载读者可从中掌握协同过滤的完整编码流程、相似度度量与推荐列表生成思路并借助测试数据快速调试与排错为后续扩展矩阵分解、混合推荐等方案打下基础。1. 从零手搓协同过滤为什么 Java 实现依然值得拆一遍电商详情页底下那排“猜你喜欢”十有八九背后跑的是协同过滤。这套东西理论不复杂难的是把它从公式落成能跑、能测、能调参的代码。这份资源给的是一个纯 Java 实现的协同过滤算法包附带测试集不依赖 Python 生态也不需要 Spark 集群javac加java两条命令就能把整条链路跑通。它适合两类人一类是想搞懂推荐系统底层逻辑、但被各种框架封装挡住视线的后端工程师另一类是要在 Java 老系统里塞一个轻量推荐模块、又不想引入重型依赖的从业者。下面我按自己拆包复现的顺序把数据格式、相似度计算、评分预测、测试集验证和踩坑点一层层摊开讲。2. 拆开资源看结构数据格式、算法骨架与依赖边界拿到一个算法包我习惯先看它怎么组织数据、怎么切分模块而不是急着跑main。协同过滤的坑一大半埋在数据格式和相似度计算的边界条件里先把这两块摸清楚后面调参才不会抓瞎。2.1 测试集长什么样用户-物品-评分三元组协同过滤的输入本质是一张稀疏矩阵行是用户列是物品格子里是评分。资源里的测试集通常以文本形式给出每行一条评分记录字段用逗号或制表符分隔。常见格式是userId,itemId,rating评分范围一般是 1 到 5 的整数也可能出现 0.5 步长的小数。我拿到手第一件事是统计三个数用户数、物品数、评分数再算一下稀疏度。# 统计测试集规模假设文件是 ratings.txt wc -l ratings.txt # 总评分数 cut -d, -f1 ratings.txt | sort -u | wc -l # 去重后的用户数 cut -d, -f2 ratings.txt | sort -u | wc -l # 去重后的物品数这三条命令分别给出评分总条数、独立用户数、独立物品数。稀疏度等于1 - 评分数 / (用户数 × 物品数)真实场景里这个值往往在 0.95 以上也就是说矩阵里 95% 以上是空的。这个数字很关键稀疏度越高用户之间共同评过分的物品越少相似度计算的样本就越薄推荐结果越容易抖。如果测试集稀疏度超过 0.99那基本只能靠基于物品的协同过滤或者加正则纯 UserCF 会很难看。注意如果测试集里出现同一个userId,itemId重复评分先做去重或取平均否则相似度会被重复记录带偏。2.2 算法骨架UserCF 与 ItemCF 的分叉点这份 Java 实现一般会抽象出几个核心类数据加载器、相似度计算器、推荐引擎、评测器。UserCF 和 ItemCF 共用同一套数据结构和相似度接口分叉点在“算谁和谁的相似度”。UserCF 算用户之间的相似度推荐时找和目标用户最像的 K 个用户把他们评过、目标用户没评过的物品加权汇总。ItemCF 算物品之间的相似度推荐时找目标用户评过分的物品看这些物品和哪些物品最像再汇总。选哪个不是拍脑袋。UserCF 适合用户数远小于物品数、且用户兴趣相对稳定的场景比如新闻推荐ItemCF 适合物品数可控、物品相似度更新不频繁的场景比如电商。测试集如果用户数明显少于物品数先跑 UserCF反过来先跑 ItemCF。资源里两套都给了的话建议都跑一遍对比评测指标别默认哪个一定好。2.3 依赖边界纯 JDK 能跑到什么程度纯 Java 实现的好处是零外部依赖javac编译、java运行不需要 Maven 拉一堆包。代价是矩阵运算、稀疏存储、并发加速都得自己写。常见做法是用MapInteger, MapInteger, Double存用户到物品的评分外层 key 是用户 ID内层 key 是物品 ID。这种嵌套 Map 读起来直观但内存开销比稀疏向量大十万级用户、百万级评分还能扛再往上就得换int[]加double[]的压缩存储。// 典型的嵌套 Map 数据结构加载评分数据 MapInteger, MapInteger, Double userItemRatings new HashMap(); // 读取每行 userId,itemId,rating userItemRatings .computeIfAbsent(userId, k - new HashMap()) .put(itemId, rating);computeIfAbsent保证用户第一次出现时自动建内层 Map避免手动判空。put直接覆盖同键旧值如果测试集有重复评分这里会保留最后一条所以去重要在加载前做。这个结构后续算相似度时遍历某个用户的所有评分就是userItemRatings.get(userId).entrySet()很方便。缺点是每个Integer、Double都是对象自动装箱拆箱在百万级数据上会有明显开销追求性能的话换成fastutil的Int2DoubleOpenHashMap但那就不算纯 JDK 了看你的边界要求。3. 相似度计算与评分预测公式落地成 Java 代码相似度是协同过滤的心脏选错公式或者边界没处理推荐结果会离谱到让你怀疑数据。这一章把余弦相似度、皮尔逊相关系数两种常见方案落到代码再讲评分预测怎么加权汇总。3.1 余弦相似度把评分当向量算夹角余弦相似度把每个用户的评分看成高维向量两个向量的夹角越小越相似。公式是点积除以模长乘积。落到 Java 里关键是只遍历两个用户共同评过分的物品否则缺失值当 0 处理会稀释相似度。// 计算两个用户的余弦相似度 double cosineSimilarity(MapInteger, Double a, MapInteger, Double b) { double dot 0.0, normA 0.0, normB 0.0; for (Map.EntryInteger, Double e : a.entrySet()) { int item e.getKey(); double ra e.getValue(); normA ra * ra; Double rb b.get(item); if (rb ! null) { // 只累加共同评分物品的点积 dot ra * rb; } } for (double rb : b.values()) { normB rb * rb; } if (normA 0 || normB 0) return 0.0; // 防止除零 return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }dot只在两用户共同评过分的物品上累加这是余弦相似度在推荐场景的标准做法。normA和normB分别是两个用户全部评分的平方和开根。最后的除零判断不能省测试集里如果有用户评分全为 0 或者空评分norm会是 0直接除会抛NaN后续排序全乱。参数上余弦相似度对评分绝对值不敏感适合评分尺度不统一的场景缺点是没考虑用户打分习惯有人习惯打高分、有人习惯打低分余弦会把这种偏差当成相似。3.2 皮尔逊相关系数减去均值再算皮尔逊相关系数在余弦基础上减去了每个用户的平均评分能抵消打分习惯差异。公式是协方差除以标准差乘积。代码上比余弦多一步算均值共同评分物品上做中心化。// 计算两个用户的皮尔逊相关系数 double pearsonSimilarity(MapInteger, Double a, MapInteger, Double b) { double meanA a.values().stream().mapToDouble(Double::doubleValue).average().orElse(0); double meanB b.values().stream().mapToDouble(Double::doubleValue).average().orElse(0); double cov 0.0, varA 0.0, varB 0.0; for (Map.EntryInteger, Double e : a.entrySet()) { Double rb b.get(e.getKey()); if (rb ! null) { double da e.getValue() - meanA; double db rb - meanB; cov da * db; varA da * da; varB db * db; } } if (varA 0 || varB 0) return 0.0; return cov / (Math.sqrt(varA) * Math.sqrt(varB)); }meanA、meanB是各自全部评分的均值cov是共同物品上的中心化点积。varA、varB是中心化平方和。共同评分物品少于 2 个时方差可能为 0返回 0 相似度是稳妥做法。皮尔逊的代价是计算量比余弦大且对共同评分数量敏感共同物品太少时相关系数不稳定。我一般先用余弦跑基线如果发现推荐结果明显偏向打分高的用户再换皮尔逊。3.3 评分预测加权汇总与 Top-N 截断算出相似度后预测目标用户对某物品的评分公式是相似度加权平均。UserCF 里找和目标用户最相似的 K 个用户对这 K 个用户评过、目标用户没评过的物品用相似度做权重汇总评分。// 基于 K 个最近邻预测目标用户对物品 item 的评分 double predict(int targetUser, int item, MapInteger, MapInteger, Double data, MapInteger, Double sims, int k) { ListMap.EntryInteger, Double neighbors sims.entrySet().stream() .filter(e - e.getKey() ! targetUser data.get(e.getKey()).containsKey(item)) .sorted((x, y) - Double.compare(y.getValue(), x.getValue())) .limit(k) .collect(Collectors.toList()); double num 0.0, den 0.0; for (Map.EntryInteger, Double n : neighbors) { double sim n.getValue(); double rating data.get(n.getKey()).get(item); num sim * rating; den Math.abs(sim); } return den 0 ? 0.0 : num / den; }filter里两个条件缺一不可排除目标用户自己且近邻必须评过这个物品。sorted按相似度降序limit(k)取前 K 个。den用相似度绝对值求和防止负相似度把分母抵消成 0。K 的取值是玄学也是经验太小推荐不稳定太大失去个性化常见从 10 到 50 之间调测试集小的话 K 别超过用户总数的十分之一。预测出评分后对目标用户所有未评物品排序取 Top-N就是最终推荐列表。4. 跑通测试集评测指标与参数调优代码能编译不代表推荐有效得用测试集量化。这一章讲怎么切分训练测试、算准确率和召回率、以及 K 值和相似度阈值怎么调。4.1 训练测试切分留一法与随机切分测试集不能全拿来算相似度否则等于开卷考试。常见做法是留一法每个用户留一条评分做测试其余做训练。或者按比例随机切分比如 80% 训练、20% 测试。留一法适合评分稀疏的数据随机切分适合评分较多的数据。// 留一法切分每个用户最后一条评分进测试集 MapInteger, MapInteger, Double train new HashMap(); MapInteger, MapInteger, Double test new HashMap(); for (Map.EntryInteger, MapInteger, Double u : all.entrySet()) { ListMap.EntryInteger, Double list new ArrayList(u.getValue().entrySet()); Map.EntryInteger, Double held list.remove(list.size() - 1); // 留最后一条 train.put(u.getKey(), new HashMap()); for (Map.EntryInteger, Double e : list) { train.get(u.getKey()).put(e.getKey(), e.getValue()); } test.put(u.getKey(), new HashMap()); test.get(u.getKey()).put(held.getKey(), held.getValue()); }list.remove(list.size() - 1)取最后一条做测试其余进训练。注意训练集里可能出现某个用户一条评分都不剩的情况后续算相似度要跳过。切分后训练集算相似度和预测测试集算指标两边用户物品集合要对齐。4.2 准确率与召回率推荐对了几个评测推荐系统常用准确率和召回率。对每个用户推荐 N 个物品其中命中测试集真实评分的算对。准确率等于命中数除以 N召回率等于命中数除以测试集该用户真实评分数。两个指标要一起看只追准确率会推荐热门物品只追召回率会推一堆无关的。// 计算单个用户的准确率和召回率 double precision (double) hitCount / N; double recall (double) hitCount / testItems.size();hitCount是推荐列表和测试集物品的交集大小N是推荐列表长度testItems.size()是该用户测试集里的物品数。所有用户取平均就是全局指标。测试集小的时候指标波动大建议多切几组取平均别拿一次结果下结论。4.3 K 值与相似度阈值调参的边界K 是近邻数量相似度阈值是过滤太弱相似度的下限。K 太小推荐不稳K 太大个性化消失。相似度阈值太低会引入噪声近邻太高近邻不够预测失败。我一般先固定阈值 0.1 到 0.2K 从 10 开始每次翻倍跑评测看准确率和召回率的拐点。参数常见范围调大影响调小影响K 近邻数10 ~ 50推荐更稳但个性化下降个性化强但结果抖动相似度阈值0.1 ~ 0.3近邻质量高但数量少近邻多但噪声大推荐列表 N5 ~ 20召回升准确降准确升召回降这张表是经验值具体还得看测试集稀疏度和评分分布。调参别一次动多个固定其他变量单独调一个否则出了问题不知道是谁的锅。5. 避坑与排查协同过滤落地时的五个血泪经验协同过滤的坑大多不在公式而在数据边界和工程细节。下面五条是我拆这类资源时反复遇到的每条按现象、原因、解决写。现象推荐结果全是热门物品冷门物品永远不出现。原因相似度计算时热门物品被大量用户共同评分相似度天然偏高冷门物品共同评分少被淹没。解决对相似度做热门惩罚比如除以物品流行度的对数或者在推荐列表里做多样性重排强制混入一定比例冷门物品。现象预测评分出现 NaN 或 Infinity排序结果错乱。原因除零没防住用户评分全为空、相似度分母为 0、共同评分物品数为 0 都会触发。解决每个除法前加分母判零返回默认相似度 0 或默认评分全局均值别让 NaN 流进排序。现象测试集指标高得离谱上线后效果崩盘。原因训练测试切分时数据泄漏测试集评分参与了相似度计算。解决严格隔离相似度只用训练集算测试集评分在预测阶段不可见切分后打印两边物品集合确认无重叠。现象用户数一多内存爆掉GC 频繁。原因嵌套 HashMap 存对象百万级评分时装箱开销和哈希表扩容吃内存。解决换原始类型压缩存储或者分批加载、边算边释放别一次性把全量评分读进内存。现象同一个用户每次跑推荐结果不一样。原因相似度相同时排序不稳定HashMap 遍历顺序不保证。解决排序时加次级排序键比如相似度相同按用户 ID 升序保证结果可复现。提示排查时先打印中间结果相似度矩阵、近邻列表、预测评分各抽样几条比盯着最终推荐列表猜问题快得多。6. 进阶技巧把离线评测接进日常验证习惯跑通一次不算完协同过滤这种对数据分布敏感的东西得有一套能重复跑的验证流程。我现在的习惯是写一个Evaluator类把切分、训练、预测、算指标串成一条命令每次改完相似度公式或调完 K 值直接跑一遍看指标变化而不是手动点来点去。// 一键评测切分 - 训练 - 预测 - 输出指标 public class Evaluator { public static void main(String[] args) { MapInteger, MapInteger, Double all DataLoader.load(ratings.txt); SplitResult split Splitter.leaveOneOut(all); Recommender rec new UserCFRecommender(split.train, 20, 0.1); // K20, 阈值0.1 double[] metrics Metrics.evaluate(rec, split.test, 10); // Top-10 System.out.printf(Precision%.4f Recall%.4f%n, metrics[0], metrics[1]); } }UserCFRecommender构造参数依次是训练数据、K 值、相似度阈值Metrics.evaluate第三个参数是推荐列表长度。这样每次调参只改构造参数评测流程不动。更进一步可以把多组 K 值和阈值写成循环批量跑出指标矩阵挑拐点。// 网格搜索 K 和阈值 for (int k : new int[]{10, 20, 30, 50}) { for (double th : new double[]{0.05, 0.1, 0.2}) { Recommender rec new UserCFRecommender(split.train, k, th); double[] m Metrics.evaluate(rec, split.test, 10); System.out.printf(K%d th%.2f P%.4f R%.4f%n, k, th, m[0], m[1]); } }这个双层循环能把参数组合一次跑完输出一张表拐点一目了然。注意测试集小的时候别过度调参容易过拟合到这一份切分上多切几组交叉验证更稳。从那以后我每次改相似度公式或者换数据集都强制先跑一遍这个评测指标没提升就不往下走省得后面返工。希望帮到你。本文还有配套的精品资源点击获取