简介:面向人工智能与推荐系统初学者,这份压缩包提供基于Mahout的新闻推荐系统完整工程,涵盖基于用户协同过滤、基于内容与基于热点三类推荐算法,适合课程设计、毕业设计或快速入门实践。包内共42个文件,以26个Java源码为主,配合3个SQL初始化脚本、3个Properties配置、3个Markdown说明及Maven封装命令,便于导入IDE直接构建运行与二次开发。已有520人学习下载,热度可观。实现层面,协同过滤复用Mahout接口,可选择谷本系数、对数似然或曼哈顿距离作为相似度度量;内容推荐利用Deeplearning4j的doc2vec构建文档向量,并用Jieba、HanLP完成分词与关键词提取;热点推荐统计最高浏览量并过滤过期新闻,提升推荐时效性。Spring Boot负责API与ORM,整体结构清晰,从数据表设计到算法集成均有完整代码,能帮助读者系统掌握推荐系统的主流方案与落地细节。
1. 基于Mahout的新闻推荐:三个推荐策略是怎么在工程里分工的
新闻是短生命周期的物品,一条热点从发布到被取代往往不超过24小时。做过推荐系统的人都知道,把电商里成熟的协同过滤直接搬到新闻场景,第一个问题就是冷启动:新新闻没有任何用户行为,协同过滤永远不可能把它们推给任何人。这套基于Mahout的新闻推荐系统(news-recommender-master)用了一个比较务实的做法——不迷信单一模型,而是把Mahout协同过滤、基于内容的doc2vec推荐和基于热点的统计推荐三条线并行,再用Spring Boot把它们包成一个可对外服务的API。用户行为落在useroperation.sql,新闻内容在news.sql,用户信息在user.sql,整个流程对人工智能毕业设计或者推荐系统入门者来说是一个完整的学习样本;线上维护时也能看出真实推荐系统至少需要三层召回,而不是一个模型走天下。
2. Mahout协同过滤实战:谷本系数、对数似然与曼哈顿距离的选型对比
2.1 基于用户的协同过滤为什么先被选中
Mahout中基于物品的协同过滤同样可用,但这里优先实现基于用户的版本,原因是新闻场景里“用户”的持久性比“新闻”好得多。新闻每天更替,新闻与新闻的相似度矩阵需要频繁重算;用户兴趣短期是平稳的,用户与用户的相似度矩阵重算成本更低。Mahout的taste模块把这套流程拆成了三块:DataModel负责装载“谁在什么时候对什么新闻产生了什么偏好”,UserSimilarity负责度量用户之间的距离,UserNeighborhood负责圈定当前用户的近邻集合,最后交给GenericUserBasedRecommender做TopN预测。这里DataModel我们直接用MySQLJDBCDataModel读useroperation表。
2.2 用Mahout实现一个基于用户的新闻推荐
假设useroperation.sql里的核心表结构是user_id、news_id、operate_type、operate_time,operate_type有view、favorite、share三种取值。Mahout需要的是三元组userID, itemID, preference,所以第一步是把用户行为转成偏好值,相同user_id对同一news_id只保留一条聚合结果。下面这段代码是直接跑通流程的最小实现:
// UserBasedNewsRecommender.java import org.apache.mahout.cf.taste.common.TasteException; import org.apache.mahout.cf.taste.impl.model.file.FileDataModel; import org.apache.mahout.cf.taste.impl.neighborhood.NearestNUserNeighborhood; import org.apache.mahout.cf.taste.impl.recommender.GenericUserBasedRecommender; import org.apache.mahout.cf.taste.impl.similarity.TanimotoCoefficientSimilarity; import org.apache.mahout.cf.taste.model.DataModel; import org.apache.mahout.cf.taste.neighborhood.UserNeighborhood; import org.apache.mahout.cf.taste.recommender.RecommendedItem; import org.apache.mahout.cf.taste.recommender.Recommender; import org.apache.mahout.cf.taste.similarity.UserSimilarity; import java.io.File; import java.util.List; public class UserBasedNewsRecommender { public static void main(String[] args) throws TasteException { // 格式:userID,newsID,preference,preference 由行为聚合而来 DataModel model = new FileDataModel(new File("data/user_rating.csv")); UserSimilarity similarity = new TanimotoCoefficientSimilarity(model); // 也可以换成 new LogLikelihoodSimilarity(model) 或 new CityBlockSimilarity(model) UserNeighborhood neighborhood = new NearestNUserNeighborhood(10, 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() + " score=" + item.getValue()); } } }代码的逻辑链路很明确:读入文件后,算法先为所有用户两两计算相似度,再为userID=1的用户找出相似度最高的10个邻居,最后在这10个邻居看过而该用户没看过的新闻里挑选预估偏好值最高的10条返回。最容易忽略的是DataModel的输入文件必须是CSV且不能有表头,行与行之间用英文逗号分隔。把useroperation.sql导出的数据做一次GROUP BY再拼接CSV,是我在这个项目里最先做的一步。
2.3 三种相似度算法在新闻场景下的取舍
TanimotoCoefficientSimilarity、LogLikelihoodSimilarity、CityBlockSimilarity是Mahout已经实现好的三个相似度实现,但它们的数学含义完全不同。选错度量的代价是推荐结果看起来总是推重复内容,或者对所有人都推同一批大流量新闻。
| 相似度 | Mahout类 | 计算方式 | 适合的新闻场景 | 需要警惕的问题 |
|---|---|---|---|---|
| 谷本系数 | TanimotoCoefficientSimilarity | 两用户共现新闻数除以并集新闻数 | 行为只有浏览/未浏览两种状态的布尔日志 | 只看共现,不看偏好强弱,点击行为不均匀时会失真 |
| 对数似然 | LogLikelihoodSimilarity | 比较两用户同时喜欢某新闻的概率与随机概率的偏差 | 新闻流量差异极大、热门新闻污染相似度的场景 | 对每个用户的观测次数敏感,行为太少的用户分数波动大 |
| 曼哈顿距离 | CityBlockSimilarity | 对两用户在共同观看新闻上的偏好值绝对差求和取反 | 有阅读时长、点赞数等连续偏好值时 | 不同新闻的总流量差异会让量纲失衡,需要先做偏好值归一化 |
实际项目里,如果useroperation里只有布尔类型的浏览记录,我一般先用谷本系数跑一版基线,因为它对稀疏向量最宽容;如果发现推荐结果里新闻被几个大流量账号反复带偏,就切到对数似然,它的统计推断会惩罚“大家都看过所以我不觉得你们相似”的共现;只有把用户在单条新闻上的停留时长也当成偏好值写进表里后,才会把曼哈顿距离作为最终选项。
2.4 评估推荐质量时容易掉进去的坑
不建议直接看推荐结果列表“凭感觉挑几条新闻试一下”,因为协同过滤的离线评估需要区分训练集和测试集。Mahout提供了RecommenderEvaluator,常见做法是把用户行为数据按8:2划分,用训练集计算近邻,用测试集里真实存在的用户—新闻对来校验预测偏差。这里要注意:新闻推荐里的正样本是用户真正点开的新闻,但用户没有点开不代表不感兴趣,所以基于用户行为的离线评估天然低估推荐效果,评估分数的绝对值没有意义,只有用来比较不同相似度参数时才有相对参考价值。
2.5 冷启动用户和邻居阈值的边界
NearestNUserNeighborhood是“无论如何找最像的10个人”,即便那些邻居的相似度只有0.02,结果也不可靠。在真实任务里我会换成ThresholdUserNeighborhood,只保留相似度高于0.15的用户作为邻居;如果邻居数少于3,直接认为该用户属于冷启动,走后续基于内容和热点的召回,而不是强行给结果。这个边界判断是协同过滤在新闻系统里能不能用的关键:用户活跃度低的场景下,宁可不推也不要推错。
3. 基于内容的新闻推荐:Jieba分词到Deeplearning4j的doc2vec向量化
3.1 内容推荐要解决的问题和用户画像建立
基于内容的推荐不再依赖用户间行为关联,它的逻辑是先把新闻文本转化成机器可以计算的向量,再对“用户偏好向量”和“候选新闻向量”做相似度运算。这里包含两个建模对象:新闻向量表示新闻内容在语义空间中的位置,用户偏好向量则由用户历史上读过的多条新闻向量平均而来。相比协同过滤,内容推荐对新闻冷启动有天然优势,只要新闻文本入库,即使没有任何人读过它,它也能被推给与文本语义相近的用户。
3.2 用Jieba分词和HanLP关键词提取搭建文本预处理管道
新闻标题往往只有十几个字,正文几百到几千字,不做预处理直接喂给向量模型会引入大量噪声。项目里同时用了Jieba和HanLP,Jieba负责粗粒度分词,HanLP负责抽取关键词,这样doc2vec的训练语料就有了两种形式的输入:全文词序列和关键词集合。
// NewsTextPreprocessor.java import com.huaban.analysis.jieba.JiebaSegmenter; import com.hankcs.hanlp.HanLP; import java.util.List; public class NewsTextPreprocessor { private final JiebaSegmenter segmenter = new JiebaSegmenter(); // 返回用于 doc2vec 训练的词序列 public List<String> toTokens(String title, String content) { return segmenter.sentenceProcess(title + "," + content); } // 返回用于构建用户兴趣画像的关键词 public List<String> toKeywords(String content) { return HanLP.extractKeyword(content, 5); } }Jieba的sentenceProcess是整句切分接口,分词结果不会自动剔除停用词,所以我在训练doc2vec之前会把标点、数字、单字词和“我们”“可以”这类停用词过滤掉。HanLP的extractKeyword内部实现了TF-IDF关键词抽取,每个新闻保留5个关键词,后续把这些关键词累加到用户兴趣序列里,相当于为“用户读了很多条新闻”做了一个粗粒度的兴趣抽象。
3.3 Deeplearning4j训练ParagraphVectors的关键参数
ParagraphVectors是Deeplearning4j对doc2vec的实现。doc2vec在每个新闻段落上附加一个段落向量,与滑动窗口里的词向量一起训练,最终段落向量就是VSM中的新闻向量。
// NewsDoc2VecTrainer.java import org.deeplearning4j.models.embeddings.loader.WordVectorSerializer; import org.deeplearning4j.models.paragraphvectors.ParagraphVectors; import org.deeplearning4j.text.documentiterator.FileDocumentIterator; import org.deeplearning4j.text.tokenization.tokenizerfactory.DefaultTokenizerFactory; ParagraphVectors vectors = new ParagraphVectors.Builder() .layerSize(100) // 段落向量维度 .windowSize(5) // 上下文滑动窗口大小 .minWordFrequency(2) // 词频低于 2 的单词被丢弃 .iterations(5) // 每批数据迭代次数 .epochs(1) // 全量语料遍历轮数 .learningRate(0.025) // 学习率 .trainWordVectors(true) // 同时训练词向量,便于扩展相似词 .tokenizerFactory(new DefaultTokenizerFactory()) .build(); vectors.fit(new FileDocumentIterator(new java.io.File("data/news_corpus/"))); WordVectorSerializer.writeParagraphVectors(vectors, "news_paragraph_vectors.bin");这组参数是新闻文本量级在几万条规模时的一个稳妥起点。layerSize是输出向量的维度,100维对新闻分类和找相似来说足够,维度太高在后续排序融合阶段会拉慢计算;windowSize是预测当前词时上下文要考虑的词数,新闻标题短,窗口取5比较合适;minWordFrequency=2剔除了只出现一次的词,既能降噪又不会丢失长尾新闻的关键词信息。Deeplearning4j里iterations和epochs是两回事,iterations控制单个batch内的梯度更新次数,epochs控制整个语料被遍历的轮数,后者通常取10到20效果更好,但训练耗时也是线性增长。
3.4 用训练好的向量计算新闻相似度
训练完成后,用户画像向量就是该用户历史读过的新闻段落向量的均值,然后对候选新闻集合做余弦相似度排序:
// ContentBasedRecommender.java import org.nd4j.linalg.api.ndarray.INDArray; public double cosine(INDArray userVec, INDArray newsVec) { double dot = userVec.mul(newsVec).sumNumber().doubleValue(); double userNorm = userVec.norm2Number().doubleValue(); double newsNorm = newsVec.norm2Number().doubleValue(); if (userNorm == 0.0 || newsNorm == 0.0) return 0.0; return dot / (userNorm * newsNorm); }余弦相似度只关心向量方向不关心模长,对应到新闻内容推荐上,就等价于“你的兴趣分布和这篇新闻的内容分布是否一致”,而不会因为某一篇新闻的文本特别长就天然占据优势。项目里没有用模型自带的nearestNeighbor而是自己写余弦,是因为最终还要把内容推荐分数与协同过滤、热点分数做加权融合,保留原始相似度值更方便统一处理。
3.5 为什么说“用Gensim会更方便”
Deeplearning4j的优点是全Java链路,不需要单独为推荐服务部署一套Python环境;缺点是训练参数暴露得偏底层,迭代调参要反复重跑。实际交付这个新闻推荐系统时,我更推荐把文本向量化这一步拆成独立Python微服务,用Gensim的Doc2Vec完成同等功能。Gensim的代码量小很多,而且对内存和本地语料的处理更省心。
# gensim_doc2vec.py from gensim.models.doc2vec import Doc2Vec, TaggedDocument # 每条数据的 tokens 是 Jieba 已经切好的词列表 documents = [TaggedDocument(words=tokens, tags=[news_id]) for news_id, tokens in news_token_pairs] model = Doc2Vec(documents, vector_size=100, window=5, min_count=2, epochs=20, workers=4) model.save("news_doc2vec.model")这段代码与Deeplearning4j的参数一一对应,但Gensim把迭代轮数统一成epochs,不再区分iterations和epochs,对维护者更友好。线上推荐服务按天级全量训练doc2vec,训练完导出向量文件,Java侧只做向量读取和余弦计算。这样做的直接好处是文本处理相关的异常不会拖垮推荐主链路,分词和向量训练迭代的速度也更快。
4. 基于热点的新闻推荐:浏览量聚合、时间衰减与Spring Boot API接入
4.1 热点推荐的业务定义为什么必须加时间窗
新闻热点和其他商品热销的本质区别是时效性。一条新闻发布10个小时后浏览量冲到最高,通常第二天就会被新话题取代。如果把所有时间的浏览量放在一起排序,结果会变成陈年老闻的天下,用户感知到的就是“这个推荐系统没有新鲜感”。所以热点推荐要做的第一件事,是给浏览量统计加上时间过滤条件:只统计最近N天内有浏览行为的新闻,并且用发布时间和浏览时间同时做约束。
4.2 用SQL把useroperation聚合出热点新闻列表
useroperation.sql里的操作记录没有独立的新闻浏览量字段,所以需要从行为明细里聚合。下面这条SQL是热点榜单的核心实现:
SELECT n.news_id, n.title, COUNT(uo.operation_id) AS view_count FROM news n LEFT JOIN user_operation uo ON n.news_id = uo.news_id AND uo.operate_type = 'view' AND uo.operate_time >= DATE_SUB(NOW(), INTERVAL 3 DAY) WHERE n.publish_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY n.news_id, n.title ORDER BY view_count DESC LIMIT 20;这里把浏览行为限定在最近3天而不是全部时间,是为了避免早期新闻凭借更长的累积窗口抢走新新闻的曝光机会。发布于7天内的约束代表热点新闻的取值范围,两个时间条件必须同时加,缺了哪一个都会让热点列表偏向旧新闻。LIMIT 20是为了给后面的融合排序留出候选集空间,热点只是召回层的一部分,需要拿到比最终推荐条数更多的候选。
如果想进一步体现时效性,可以给发布时间加上半衰期衰减。假设一条新闻的热度半衰期是24小时,那么排序指标不是view_count而应该是按小时衰减之后的期望浏览量:
ORDER BY view_count * EXP(-TIMESTAMPDIFF(HOUR, n.publish_time, NOW()) * 0.05) DESC0.05是衰减系数,把小时差换算成分数后呈指数衰减。这个系数不需要精确标定,一般通过线上A/B试验对比点击率来调,常见取值范围在0.03到0.08之间。系数越小新闻越“长寿”,系数越大热点越要争分夺秒。
4.3 在Spring Boot里把热点SQL包成可对外服务的API
Spring Boot在这个系统里承担两个职责:ORM访问MySQL,对外提供JSON格式的推荐接口。热点推荐是最容易独立先实现的一个API,因为它不依赖训练模型。用JdbcTemplate执行上面的SQL,再把结果映射成NewsItem对象:
// HotNewsController.java import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @RestController @RequestMapping("/api/recommendation") public class HotNewsController { private final JdbcTemplate jdbcTemplate; public HotNewsController(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @GetMapping("/hot") public List<NewsItem> hotNews( @RequestParam(defaultValue = "3") int behaviorDays, @RequestParam(defaultValue = "7") int publishDays, @RequestParam(defaultValue = "20") int limit) { String sql = """ SELECT n.news_id, n.title, COUNT(uo.operation_id) AS view_count FROM news n LEFT JOIN user_operation uo ON n.news_id = uo.news_id AND uo.operate_type = 'view' AND uo.operate_time >= DATE_SUB(NOW(), INTERVAL ? DAY) WHERE n.publish_time >= DATE_SUB(NOW(), INTERVAL ? DAY) GROUP BY n.news_id, n.title ORDER BY view_count DESC LIMIT ? """; return jdbcTemplate.query(sql, new Object[]{behaviorDays, publishDays, limit}, (rs, rowNum) -> new NewsItem( rs.getLong("news_id"), rs.getString("title"), rs.getInt("view_count"))); } }SQL里的三个问号对应behaviorDays、publishDays、limit三个入参,接口设计上的考虑是让前端在调试热点推荐时不用改代码就能来回切时间窗口。NewsItem是持有newsId、title、viewCount三个字段的记录类,也可以直接复用项目里已有的新闻实体,但要记得在JSON序列化时忽略掉不必要的大字段,比如正文内容,避免响应体过大。
4.4 热点召回层的一个雷:浏览行为被重复统计
useroperation表如果记录了用户的刷新行为,同一个用户在同一个新闻详情页刷新10次就会产生10条view操作,直接COUNT会把浏览量虚高好几倍。常见做法是在SQL里用COUNT(DISTINCT user_id)代替COUNT(operation_id),这样热点榜展示的是“有多少个不同的人看了”,而不是“这一页被刷了多少次”。具体用哪种口径取决于业务目标,如果目标是衡量新闻的讨论度,去重用户数更可靠;如果是衡量服务负载,保留原始请求数。这个选择直接决定热点排序结果,项目里我一般两个都统计,一个用于业务展示,一个用于监控系统压力。
5. 融合三个召回结果:归一化、权重调参与线上AB验证技巧
5.1 三种分数先归一化再相加
三条召回链输出的分数量纲完全不同:Mahout协同过滤输出的是预估偏好值,doc2vec输出的是余弦相似度,热点输出的是浏览量。直接相加会把量纲最大的一方变成主导项,热点新闻会霸榜。我在项目里做的第一步是min-max归一化:
public double normalize(double score, double min, double max) { if (max - min < 1e-6) return 0.5; return (score - min) / (max - min); }把每条候选新闻在三路召回里的原始分数都压缩到0到1之间,再去掉已经在其他召回结果里出现过的新闻ID,最后按加权公式排序取前N条返回。三个权重的和固定为1,默认从0.5、0.3、0.2起步。
在项目里需要一个人工标注的小样本相关度集合,然后对三个权重做网格搜索。网格搜索的粒度不需要太细,一步一档就够了:每档权重组合跑一遍离线评测,看TopN结果里人工标注的相关新闻占比。线上环境再以一周为周期轮流切换几个候选权重组合,用点击率和平均阅读时长来判断哪组更符合实际业务。
5.2 两个最容易被忽略的工程细节
第一是必须给协同过滤和内容推荐结果设置最小可信阈值。归一化之后碰巧有个候选新闻的余弦相似度只有0.02,那它依然有机会排到前面,提前把低于阈值的候选清掉,再开始融合。第二是用户短期兴趣保护和相似内容过滤。同一个账号在24小时内不应该反复看到同一条新闻的相似副本,用新闻ID去重是最底线的手段,进阶做法是把同一新闻来源或者同一专题下的文章也做了聚合去重。
5.3 上线前用两张日志表验证三路召回的贡献
每次推荐请求带上recommendation_plan参数记录当前使用的权重组合,响应里输出每一路召回各自贡献的新闻数。这样在日志系统里按recommendation_plan分组、按天聚合,就能看到不同参数组合下点击分布的区别。推荐结果落库成exposure_log表,字段包含用户ID、新闻列表JSON、权重参数、耗时,点没点再由独立的click_log表承接。两张表按request_id关联之后,才有资格谈线上A/B验证。
本文还有配套的精品资源,点击获取