首页 / 资讯中心 / 文章详情

从零构建基于Python的新闻推荐系统:召回、排序与冷启动实践

从零构建基于Python的新闻推荐系统:召回、排序与冷启动实践 ★ FEATURED ARTICLE
简介基于Python的新闻推荐系统资源包面向推荐系统初学者和希望了解新闻场景落地实现的开发者资源内含完整工程覆盖新闻文本清洗、特征表示、候选集生成、排序融合等核心环节借助脚本即可跑通基础推荐链。压缩包共181个文件包含102个txt说明文档、35个Python脚本、32个pyc编译结果与7个概率模型文件配套README和项目配置整体10.32MBtxt文档记录算法原理与运行配置py脚本按数据加载、特征处理、推荐计算拆分模块p文件为预训练概率模型可免去重复训练直接评估效果。目前已有4071人学习下载。无论是梳理推荐系统全流程还是基于现有代码做课程设计、毕业设计或改造为新闻资讯类原型验证这套内容都能提供清晰落地的参考。1. 为什么“基于 Python 的新闻推荐系统”值得自己动手搭一遍很多团队一提到新闻推荐第一反应就是直接上深度学习模型。但真正在一线做过内容推荐的人都知道先把传统推荐逻辑用 Python 跑通把数据、特征、召回、排序串成一条完整链路往往比盲目套模型更能解决问题。所谓“基于 Python 的新闻推荐系统”核心不是搞出一个黑匣子而是解决从新闻入库到用户点击反馈的每一环新闻怎么写进库、用户行为怎么记录、候选集怎么生成、分数怎么排序、新内容怎么避免石沉大海。我见过不少项目数据量不大、并发不高却因为一上来就用复杂架构结果连基线效果都说不清楚。反而是先用 Python 写一套基于内容召回加协同过滤加热门兜底的最小系统业务指标立刻有了参照物。这篇笔记适合三类人刚接触推荐系统的后端工程师想自己复现一个可运行项目数据分析师想理解召回和排序的区别还有带团队做内容产品、想快速验证推荐价值的人。读完你能得到一条可复现的落地路径以及那些文档里不会写的坑。2. 先把推荐逻辑想清楚基于内容、协同过滤还是两者都要2.1 基于内容的推荐为什么是新闻场景的默认起点新闻和电商有个本质区别物品的生命周期极短。一条新闻发布后有效推荐窗口往往只有几小时到几天。如果完全依赖用户行为来学相似性新新闻根本等不到足够点击就会被时间冲走。基于内容的推荐不依赖用户行为只要新闻有正文文本就能和用户历史上看过的内容算相似度。这是它成为新闻推荐默认起点的最直接原因。具体流程并不复杂先把每篇新闻正文做分词用 TF-IDF 转成向量再把用户最近读过的新闻向量取出来和全库新闻算余弦相似度最后按相似度排序排除用户已经看过的内容。这套逻辑在没有任何点击数据时也能启动对“新用户冷启动”天然友好。但它有个明显毛病越推越窄。用户连续看了几条体育新闻推荐流里就全是体育甚至全是同一支球队的报道。这时候就需要协同过滤来补充因为协同过滤能通过“和你相似的人在看什么”把用户兴趣边界往外扩一点。2.2 协同过滤的适用边界当用户行为数据积累到多少条再上协同过滤的输入是用户和物品的交互矩阵行是用户列是新闻值是点击、收藏或停留时长。理论上它能捕获内容之外的隐性关联但在新闻场景里它有一个非常现实的适用边界行为数据不够时矩阵稀疏度高得离谱算出来的相似度几乎全是噪声。我一般会看两个数单条新闻的平均交互次数以及单个用户的平均行为条数。如果一个新闻只有两三次点击它和其他新闻的相似度根本没有统计意义。实操经验是单个物品至少有 5 到 10 个交互记录、单个用户至少积累 5 条有效行为后协同过滤的结果才开始值得参考。在这之前强行上协同过滤只会让推荐结果被少数活跃用户带偏这种翻车我见过太多次。所以在项目初期我的建议是先用基于内容撑住推荐主链路同步记录用户行为日志。等到行为数据攒够一定量级再在候选集合并阶段加入协同过滤分数而不是一开始就替换掉内容推荐。2.3 混合策略的简单骨架加权分数合并与回退机制实际线上推荐系统基本不会只用单一算法常见做法是“多路召回 分数融合”。召回阶段把基于内容算出来的相似新闻、协同过滤算出的相似新闻、热门新闻都放到候选池里排序阶段给不同来源的候选算一个加权总分最后统一排序。混合策略里最容易被忽略的是一个回退机制当某个用户的实时行为太少协同过滤路根本召不回有效候选时系统要能自动降级到“热门 内容”组合。回退逻辑写起来很简单但它决定了新用户进来第一眼看到的是不是一堆莫名其妙的内容。def hybrid_recall(user_id, clicked_news, content_sim_dict, cf_sim_dict, hot_list): candidate_score {} # 用户行为少于 5 条时协同过滤参考价值很低 if len(clicked_news) 5: primary content_sim_dict fallback hot_list else: primary cf_sim_dict fallback content_sim_dict for news_id, score in primary.get(user_id, {}).items(): if news_id not in clicked_news: candidate_score[news_id] candidate_score.get(news_id, 0) score # 回退路永远保留热门兜底保证有新内容可以推 for news_id in fallback[:50]: if news_id not in clicked_news: candidate_score[news_id] candidate_score.get(news_id, 0) 0.1 return sorted(candidate_score.items(), keylambda x: x[1], reverseTrue)这段代码里primary和fallback分别表示主召回路和备用召回路0.1是给热门兜底一个很低的保底权重让它在正常情况下不会霸屏但又能确保用户行为很少时不至于推荐池空掉。注意点有两个一是候选新闻必须过滤掉用户已读内容否则推荐流会出现大量重复二是回退分数要给得足够低热门太重会让推荐结果失去个性化这和没做推荐系统没有区别。3. 用 Python 从零搭出可运行的推荐核心数据、特征与召回3.1 造一份可用的新闻数据集字段设计与模拟数据的生成逻辑一个推荐系统项目拿到手第一件事不是看算法代码而是看数据长什么样。新闻推荐依赖的核心字段其实就五个新闻唯一 ID、标题、正文、分类、发布时间。分类用于冷启动阶段的兴趣抽样发布时间用于时间衰减计算标题和正文则是内容相似度计算的主要原料。真实场景里数据往往在某个后台数据库里但本地复现时我们通常先造一份模拟数据。造数据不是随意随机生成而是要让类别分布接近现实娱乐类最多科技类其次体育类保持一定比例这样推荐结果才有区分度。下面是一段生成模拟数据的代码方便你先跑通全流程。import pandas as pd import random from datetime import datetime, timedelta random.seed(42) categories [科技, 娱乐, 体育, 财经, 健康] title_words { 科技: [芯片, 算法, 开源, 手机, 人工智能], 娱乐: [电影, 歌手, 综艺, 票房, 明星], 体育: [男篮, 足球, 联赛, 冠军, 选手], 财经: [股市, 基金, 银行, 利率, 投资], 健康: [睡眠, 饮食, 运动, 体检, 疫苗], } rows [] now datetime.now() for i in range(3000): c random.choice(categories) words random.sample(title_words[c], k3) title f{words[0]}观察{words[1]}与{words[2]}的最新进展 content .join( [f{random.choice(title_words[c])}相关讨论] * random.randint(30, 60) ) publish_time now - timedelta(hoursrandom.randint(1, 72)) rows.append([fnews_{i}, title, content, c, publish_time]) df pd.DataFrame(rows, columns[news_id, title, content, category, publish_time]) df.to_csv(news_data.csv, indexFalse, encodingutf-8)注意这段代码刻意让正文由重复短语拼成因为在验证推荐链路时我们只需要看相似度计算和召回排序是否按预期工作不需要一开始就追求文本质量。真实项目中正文需要做清洗去掉 HTML 标签、去掉版权声明、过滤过短的碎片文本。publish_time字段的格式建议统一成 ISO 标准时间字符串否则后续算时间衰减时解析格式会非常痛苦。3.2 中文分词与 TF-IDF 向量化jieba 配置和 sklearn 的配合中文和英文不同句子没有天然空格所以第一步是分词。Python 生态里最常见的选择是 jieba 库。分词结果的质量直接影响 TF-IDF 向量表达的内容语义这里有两个容易踩坑的地方一是 jieba 默认会切出大量单字无意义词二是新闻里常见的人名、产品名比如某款手机型号会被切得七零八落。我一般的做法是先加载自定义词典补充行业词再准备一个停用词表把“我们”“但是”“关于”这类高频无意义词直接过滤掉。然后配合 sklearn 的 TfidfVectorizer 完成向量化。TfidfVectorizer 的tokenizer参数可以传入自定义分词函数这样分词和向量化就在同一步完成。import jieba from sklearn.feature_extraction.text import TfidfVectorizer # 建议加载自定义词典例如某个新闻产品里特有的 App 名称、人名 # jieba.load_userdict(news_dict.txt) stopwords set() # 实际使用时常从 stopwords.txt 读取 for line in open(stopwords.txt, encodingutf-8): stopwords.add(line.strip()) def tokenize(text): return [w for w in jieba.lcut(text) if w.strip() and w not in stopwords] vectorizer TfidfVectorizer( tokenizertokenize, min_df2, max_df0.8, ngram_range(1, 2), sublinear_tfTrue, ) tfidf_matrix vectorizer.fit_transform(df[content]) print(向量维度:, tfidf_matrix.shape)这里min_df2表示只在至少两篇新闻中出现的词才会进入词表用来过滤只在某一篇里出现的低频噪声max_df0.8表示在超过 80% 新闻里都出现的词会被去掉因为这类词几乎不携带区分信息ngram_range(1, 2)让向量同时包含单个词和相邻词组对新闻短语有一定的语义捕捉能力sublinear_tfTrue用1 log(tf)代替原始词频避免长正文在某些词上词频过高而主导相似度。3.3 召回层的两种候选生成热门兜底与相似度 TopN特征向量算好后接下来进入召回层。召回的目的是从全量新闻里挑出几百个候选而不是直接排序输出。这一步讲究“快”和“全”宁可多召回一些不相关内容也不能漏掉真正相关的。基于内容的召回最常见做法是离线预先算好新闻两两之间的相似度矩阵线上根据用户最近点击过的新闻从矩阵里取出 top 相似新闻作为候选。相似度计算直接用 sklearn 的cosine_similarity算完之后用argpartition取 top K 效率更高这里为了可读性先用简单排序。from sklearn.metrics.pairwise import cosine_similarity import numpy as np sim_matrix cosine_similarity(tfidf_matrix) def get_content_candidates(clicked_news_ids, sim_matrix, top_n30): candidates {} for news_id in clicked_news_ids: idx df[df[news_id] news_id].index[0] sim_scores sim_matrix[idx] # 当前新闻自身的相似度是 1.0要跳过 top_indices np.argsort(sim_scores)[::-1][1:top_n1] for tid in top_indices: candidates[df.iloc[tid][news_id]] sim_scores[tid] return sorted(candidates.items(), keylambda x: x[1], reverseTrue)热门召回则简单得多把最近 24 小时内点击量最高的新闻取出来作为推荐池的保底。这里要强调一点热门不能只看累计点击要除以新闻存活时长否则发布早的新闻永远占据榜单新新闻没有出头机会。def get_hot_news(df, click_count, hours24): df df[df[publish_time] datetime.now() - timedelta(hourshours)] df[hot_score] df[news_id].map(click_count) / ( (datetime.now() - df[publish_time]).dt.total_seconds() / 3600 1 ) return df.sort_values(hot_score, ascendingFalse)[news_id].tolist()分母加1是为了防止发布时长接近 0 时分数变成无穷大。召回层两个来源各自独立后面进入排序层再合并去重。这一步最关键的参数是top_n它决定了内容候选池的规模和多样性。top_n太小会导致推荐内容单一太小则会引入大量不相关文章我常用 30 到 50 之间。4. 排序与参数调优相似度阈值、K 值与时间衰减4.1 相似度计算的三个关键参数metric、阈值与归一化排序阶段直接决定用户最终看到的内容顺序而这里的参数设置比算法选择更容易被忽略。第一个问题是相似度用什么 metric。新闻正文向量化后是稀疏高维向量欧氏距离在高维空间里区分度很差所以优先选择余弦相似度。它只看方向不看模长对新闻短文本相对稳定。第二个问题是内容相似度阈值。计算候选集时如果不对相似度设下限就会把一堆只有轻微语义关联的新闻拉进来。我一般把余弦相似度阈值控制在 0.3 到 0.5 之间。低于 0.3 的结果基本是噪声高于 0.5 则召回数量骤减容易出现“候选池空窗”。第三个问题是分数归一化。基于内容的相似度和协同过滤的预测分数往往不在同一量纲上比如内容相似度普遍在 0.1 到 0.6 之间协同过滤分数可能是 2.5 到 5.0。直接加权相加协同过滤会完全压制内容分。常见做法是分别对上界做归一化把所有分数压到 0 到 1 区间。def normalize_scores(scores_dict): if len(scores_dict) 0: return scores_dict max_v max(scores_dict.values()) min_v min(scores_dict.values()) if max_v min_v: return {k: 1.0 for k in scores_dict} return {k: (v - min_v) / (max_v - min_v) for k, v in scores_dict.items()}这段归一化代码用到了 min-max 线性变换它保留了候选之间的相对顺序同时把分数统一成可比较的量纲。注意如果所有候选分数相同max_v min_v这时直接全部赋值为 1.0避免除零报错。这个边界情况在候选池只有一条新闻或者所有新闻都相似时经常触发。4.2 用户实时行为序列的 K 值选择与窗口长度用户兴趣不是一成不变的所以计算候选集时不能把用户历史所有点击都等权看待。常见做法是取“最近 K 条行为”加“时间窗口长度限制”。K 值设得太大用户的早期兴趣会干扰近期意图设得太小模型只看到用户几分钟前的误触点击就被带偏。我的经验是 K 取 20 到 50 之间。新闻阅读是高频低时长行为用户一天可能产生几十次点击只取最近 20 条能基本覆盖当天兴趣又不会被一周前的旧兴趣干扰。窗口长度一般限制在 7 天以内超过 7 天的行为直接不参与候选集计算因为新闻本身的时效性让一周前的阅读行为价值极低。行为也要分权重。同样一次阅读搜索结果页点进来的和推荐流里点进来的意图强度完全不同停留时间 10 秒的和停留 2 分钟的也完全不同。通常做法是给 different 行为类型设置权重点击为 1收藏为 2完整阅读停留超过 30 秒为 3。构建用户行为序列时按权重累加相似度分数。def build_user_profile(click_log, weight_map{click: 1, collect: 2, read: 3}): user_profiles {} for user_id, news_id, action, ts in click_log: if user_id not in user_profiles: user_profiles[user_id] {} user_profiles[user_id][news_id] ( user_profiles[user_id].get(news_id, 0) weight_map.get(action, 1) ) return user_profiles这个函数把每个用户的点击记录聚合成了“新闻 ID 到加权得分”的字典。后续计算用户向量时以这个加权值为权重对新闻向量做加权平均得到用户兴趣向量。注意不要把原始行为记录直接拿去做平均否则一条 2 分钟的深度阅读和一次 1 秒的误触被同等对待推荐结果偏离得非常厉害。4.3 让推荐结果“新一点”时间衰减因子怎么加进排序分数新闻推荐的排序和电商一个最大的不同是“新”本身就是一种强价值。用户打开新闻 App希望看到今天发生的、刚发布的而不是三天前的旧闻。如果只按相似度和热度排序被验证过的高热度旧闻会一直霸占前排。所以排序阶段必须显式加入时间衰减。时间衰减的常见实现是对每条新闻算一个生存时间age再按指数衰减调整分数。衰减系数 lambda 控制“新闻变旧”的速度。lambda 设得越小旧新闻衰减越慢设得越大推荐流越偏向新内容。新闻场景里我一般取 lambda 在 0.05 到 0.2 之间也就是说一条新闻发布 24 小时后分数降到原始分数的 88% 到 95% 的水平三天后明显弱化。import math def add_time_decay(score, publish_time, now, lambda_val0.1): age_hours (now - publish_time).total_seconds() / 3600.0 return score * math.exp(-lambda_val * age_hours)lambda_val的选择要结合产品形态来定。资讯类产品希望每天都有新内容lambda 可以设到 0.15 以上知识类产品用户会回头查看几天前的深度文章lambda 应该设到 0.05 以下否则好内容生命周期过短内容生产成本完全覆盖不住。搜索的时候经常发现推荐列表里同一篇新闻出现两次这是因为内容正文相似但标题不同或者同一个稿子做了二次编辑。排序阶段要加一个“相似内容折叠”策略如果候选池里有两条新闻内容相似度超过 0.85只保留发布时间更新或者分数更高的那一条。这个去重步骤建议放在时间衰减之后、最终排序之前。5. 新闻推荐系统落地避坑从冷启动到评估陷阱5.1 冷启动新用户和新新闻都推不动的 3 个表现冷启动是新闻推荐系统里被讨论最多、也是最容易“看起来解决了实际没解决”的问题。先说新用户现象是新用户第一次打开 App推荐流要么全是默认热门要么五花八门没有重点用户点了几下就流失。原因是系统完全不知道用户兴趣又不敢把优质长尾内容给新用户。我当时常用的方案是在用户注册时让用户选 3 到 5 个感兴趣的新闻分类用分类下的热门新闻填充推荐流。这个方案简单有效但要注意分类兴趣和实际点击兴趣往往偏差很大不能一直用分类兴趣要尽快用实时行为替换。再说新新闻现象是一条刚发布的新闻进入内容库后几个小时内没有任何推荐流量直到编辑手动推荐才被分发。原因是线上推荐系统出于性能考虑通常会离线预计算内容相似度矩阵新入库的新闻要等下一次离线任务跑完才会出现在候选池里。解决方法是把向量化嵌入到新闻入库流程里新闻一写入内容库就即时算出 TF-IDF 向量并插入相似度检索索引。这样新新闻从入库到进入推荐流的时间能压缩到秒级。最后一种表现是“推了但没人点”。这说明冷启动系统确实把新内容送出去了但召回层没有匹配到对应的兴趣用户。要检查是不是相似度计算时只用了正文而忽略了标题。新闻标题通常是核心卖点的浓缩正文里大量重复的背景描述反而会稀释标题语义。我的做法是把标题和正文按 2:1 的权重拼接后再做 TF-IDF或者在分词时给标题词额外加权。5.2 数据稀疏行为矩阵 95% 为空时协同过滤失效的排查思路数据稀疏是协同过滤最常见的翻车现场。现象是协同过滤召回的候选新闻和用户当前兴趣毫不相关甚至出现用户刚看了一篇科技新闻推荐里却出现一条被相似用户点过的娱乐八卦。原因是用户行为矩阵里 95% 以上的格子是空的少量非空数据的相似度计算受少数高热新闻主导所有用户都被拉向同一个方向。排查时先不要怀疑算法先看两个数据指标。第一矩阵的非空率是多少低于 5% 基本可以先放弃协同过滤回到基于内容的方案。第二单条新闻的平均交互次数是多少低于 5 次说明物品端没有足够的信号来构建相似关系。解决方向有两个一是延长行为统计窗口用 30 天累计行为代替 7 天窗口但新闻场景里 7 天以上的行为已经明显过时这个方法要谨慎二是引入行为权重衰减让稀疏行为仍可用但不被放大。更本质的思路是把协同过滤降级为“辅助召回”。也就是说不要让协同过滤独自决定推荐结果而是让它作为多路召回里的其中一路最终排序由内容相关性和时间衰减决定。这样即使协同过滤路召回的候选质量不高内容相关性和时间衰减也会把它们压到后排。5.3 评估陷阱离线指标好看、线上点击率上不去的常见原因现象是离线评估时 precision 和 recall 都挺高模型一上线线上点击率反而比旧版本还低。这个问题我反复踩过很多次最核心的原因是数据穿越。划分训练集和测试集时如果没按时间切分而是随机切分就会出现模型用“未来数据”去预测“过去行为”的荒谬场景。离线评估因此虚高一旦上线模型面对的都是尚未发生的行为效果自然回归真实水平。解决方法是严格按时间序列切分用前 7 天行为做训练用第 8 天行为做测试。评估时同时要保证测试集里的候选新闻在训练集时间节点之前都是已经存在的否则等于让模型预测一篇还没发布的新闻。另一个常见原因是不理解离线指标和线上业务指标的关系。离线 precision10 高只能说明模型推荐的新闻跟用户历史行为相似度高但它衡量不了内容的新鲜感、多样性、甚至标题吸引力。线上点击率是标题、封面、位置和推荐相关性的综合结果。我见过一个项目离线指标提升了 5%线上点击率反而掉了最后排查发现是推荐结果多样性下降用户刷了几次觉得全是同类内容就离开了。所以评估推荐系统离线指标只是门槛线上 AB 测试才是最终标准。如果线上条件不允许做完整的 AB至少要在小流量上对比新老版本的点击率、人均阅读时长和次日留存三个指标点击率单指标很容易被骗。6. 验证与进阶离线指标怎么算服务化接口怎么留6.1 算 PrecisionK 与 RecallK一份针对推荐场景的验证脚本离线验证是每个推荐项目唯一能快速迭代的闭环。推荐系统的离线指标通常用 PrecisionK 和 RecallKK 一般取 5、10 或 20。Precision 关心推荐结果里有几条用户真的看了Recall 关心用户看过的内容里模型捞回来了几条。两个指标天然此消彼长实际使用要看业务目标如果是信息流Precision 更优先如果想提高长尾内容分发Recall 更值得关注。评估脚本按时间切分训练集和测试集已经说过这里给一个可直接运行的评估函数输入是推荐列表和用户实际点击列表。def evaluate_at_k(recommended_list, actual_list, k10): rec recommended_list[:k] rec_set set(rec) actual_set set(actual_list) hits len(rec_set actual_set) precision hits / k recall hits / len(actual_set) if len(actual_set) 0 else 0.0 return precision, recall这个函数有个隐藏问题如果actual_set为空直接算 recall 会除零。我上面的写法用条件表达式返回 0.0这样在聚合多个用户的评估结果时某个用户无行为不会拖垮整体平均。真正跑评估时要遍历测试集所有用户把所有用户的 precision 和 recall 分别做平均才得到系统整体指标。6.2 给推荐服务留接口后续接 Redis 缓存和增量更新的建议推荐系统做完离线验证接下来要面对线上服务化。常见做法是把推荐逻辑封成一个独立的推荐服务对外只暴露一个接口传入用户 ID 和请求上下文返回推荐新闻 ID 列表。内部多路召回、排序、去重都被封装在接口后面这样算法迭代和线上部署可以解耦。线上服务最大的挑战是延迟和召回结果陈旧。离线预计算的相似度可以放到 Redis 里Key 可以是用户 IDValue 是推荐结果列表设置一个合理的过期时间比如 5 分钟。这能挡住大部分重复请求同时保证用户行为变化后推荐结果不会长期不更新。增量更新方面新闻内容的向量更新要比用户行为更新频繁得多。一条新新闻入库后推荐服务应该立刻能把它加入候选池。常见做法是给新闻向量单独建一个向量索引比如用 numpy 矩阵放在内存里或者用专门的向量数据库。每次新新闻入库追加一行向量同时更新 Redis 里的内容索引版本号排序服务拿到新版本号后重新计算候选。避免在线上服务里直接调 jieba 重新做分词和 TF-IDF 向量化那个代价太高新闻向量应该入库时只算一次之后就只做检索。回到我的经验如果当初做新闻推荐系统时能先跑一个纯基于内容的 baseline把数据清洗和评估流程做扎实后面的演进会省掉大量返工。先把链路跑通拿到用户反馈再谈模型升级这个顺序不能反。希望这篇笔记能把你的推荐系统从一个零散想法带到一个可以上线验证的 MVP真正落到业务里产生价值帮到你少走一段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站