ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Mahout新闻推荐实战:协同过滤、Doc2vec与热点融合的三路召回策略

Mahout新闻推荐实战:协同过滤、Doc2vec与热点融合的三路召回策略 简介面向人工智能与推荐系统初学者这份压缩包提供基于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_timeoperate_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,preferencepreference 由行为聚合而来 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); ListRecommendedItem items recommender.recommend(1L, 10); for (RecommendedItem item : items) { System.out.println(item.getItemID() score item.getValue()); } } }代码的逻辑链路很明确读入文件后算法先为所有用户两两计算相似度再为userID1的用户找出相似度最高的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和HanLPJieba负责粗粒度分词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 ListString toTokens(String title, String content) { return segmenter.sentenceProcess(title content); } // 返回用于构建用户兴趣画像的关键词 public ListString 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比较合适minWordFrequency2剔除了只出现一次的词既能降噪又不会丢失长尾新闻的关键词信息。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(wordstokens, tags[news_id]) for news_id, tokens in news_token_pairs] model Doc2Vec(documents, vector_size100, window5, min_count2, epochs20, workers4) 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包成可对外服务的APISpring 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 ListNewsItem 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验证。本文还有配套的精品资源点击获取
返回列表