简介:基于Python的新闻推荐系统完整源码包,面向推荐系统入门者与自然语言处理应用开发者,帮助理解新闻数据清洗、中文分词、特征提取、相似度计算到最终推荐结果生成的完整链路。压缩包共一百八十一个文件,整体大小约十点三二兆字节,其中三十五个Python脚本构成系统核心逻辑,一百零二个文本文件提供语料、停用词表与配置说明,三十二个编译缓存文件便于快速加载,七个概率模型文件为结巴分词所需的发射概率与状态转移参数,另含测试数据文件及项目说明文档,目录结构按功能模块划分。资源目前已有四千零七十一人学习下载,适合用作课程设计、毕业设计或推荐系统实践入门参考。通过该包可获得一套可直接运行的新闻推荐实现,包括分词与特征选择脚本、相似度匹配策略、测试样本以及完整的工程配置,无需额外训练语言模型即可复现基础流程,也可在此基础上进行算法调优与二次开发。
1. 拿到「基于python的新闻推荐系统.zip」之后:先别急着调算法
「基于python的新闻推荐系统」这类代码包,解压后看起来功能齐全,但真正跑通它并让推荐结果可解释,90%的时间会耗在数据清洗和效果验证上,而不是算法调参。很多人第一步就搞反了:急着去看协同过滤和神经网络的代码,结果数据没洗干净,推荐出来的全是乱码和旧闻。这个压缩包代表了一类典型的工程形态——离线导入新闻文本,做分词与向量化,用相似度或行为日志召回候选,再通过Web层把结果暴露出去。它适合有Python基础、想快速拥有一套可演示可扩展的原型,也想搞明白推荐系统落地链路的人。下面按我自己的拆解顺序来讲。
2. 拆解系统骨架:数据流与模块边界决定你要改哪里
拿到代码包先别读算法文件,先把数据流认清楚。推荐系统最怕的不是模型写不出来,而是你根本不知道一条新闻从进来到展示给你,中间经过了几道手。新闻推荐和电商推荐的差别也在数据流上:新闻时效性强,用户只看一次,重复推荐同一篇等于推荐失败,所以数据流里必须有时效控制,这是第一个要建立的认知。
2.1 先认准四层数据流:采集、清洗、召回、服务
我一般会把这类代码包从逻辑上拆成四层。第一层是数据接入,常见做法是RSS拉取、爬虫入库,或者直接读本地CSV/JSON;第二层是文本清洗,去HTML标签、去停用词、分词,有时候还要做去重和正文抽取;第三层是召回与排序,把用户特征和新闻特征算成向量,做相似度或行为预测,产出一个有序列表;第四层是服务层,用Flask或FastAPI把列表包装成接口,前端拿到JSON渲染。
这四层之间的边界决定了你要改哪里。如果推荐结果不准,问题可能出在第二层的分词,也可能出在第三层的相似度计算,还可能出在数据接入层的时效字段根本没解析。我见过不少人在服务层调了半天参数,最后发现是采集层把发布时间全存成了字符串,时间衰减函数拿到的是脏数据。所以第一步永远是画一条数据流,标清楚每个字段从哪来到哪去。
一个典型的目录结构长这样,不一定每个包都完全一致,但大方向差不多:
news_recommend/ ├── data/ │ ├── news.csv # 新闻正文与元数据 │ └── user_behavior.csv # 用户浏览、点击、停留日志 ├── src/ │ ├── preprocess.py # 清洗、去重、分词 │ ├── recall.py # 候选集召回:相似度/协同过滤 │ ├── rank.py # 排序:时间衰减、热度加权 │ └── server.py # Flask/FastAPI 接口 ├── models/ # 训练好的向量和索引文件 └── requirements.txt动手改代码之前先把依赖装齐。没有requirements.txt就按下面这个清单装,基本覆盖这类项目的最小依赖:
pip install jieba scikit-learn pandas numpy flask四个依赖对应四件事:jieba负责中文分词,scikit-learn提供TF-IDF向量化和相似度计算,pandas做数据加载和透视,flask把推荐结果暴露成HTTP接口。装完之后第一件事不是跑全流程,而是先跑preprocess.py看它输出的清洗结果是否符合预期。我自己的习惯是先把数据读出来打印几条,确认标题、正文、发布时间、分类这几个核心字段没缺没乱,再往下走。
2.2 最小启动配置:把代码包从「能看」变成「能跑」
这类代码包最常见的启动方式是把数据路径和参数集中在一个配置里,通常是config.py或者直接写在文件开头的常量区。你需要确认三件事:数据路径指向真实文件、停用词表路径存在、服务端口没被占用。我一般会先写好config,再单独验证数据加载这一段。
# config.py DATA_PATH = "./data/news.csv" BEHAVIOR_PATH = "./data/user_behavior.csv" STOP_WORDS_PATH = "./data/stopwords.txt" # 向量化参数 VECTORIZER_MAX_FEATURES = 5000 # 特征维度上限,过高会慢且噪声大 VECTORIZER_MIN_DF = 2 # 至少在2篇文档中出现才保留 VECTORIZER_MAX_DF = 0.8 # 超过80%文档都出现的词视为停用词 # 排序参数 TIME_DECAY_DAYS = 3 # 时间衰减半衰期,新闻超过3天权重减半 HOT_WEIGHT = 0.3 # 热度分占最终排序的权重参数不是越大越好。max_features设到10000并不是更准,反而会把长尾噪声带进来;min_df设太大会把低频但有价值的专有名词全部丢掉。min_df和max_df是配合使用的,min_df控制词频下限,max_df控制词频上限,两者都是用来挡噪声的。数据量只有几千条时,max_features保持3000到5000就够用了。
配置完成后,跑通的最小命令就是两步:先预处理,再启动服务。预处理脚本会输出清洗后的DataFrame,服务脚本会加载这个DataFrame并启动推荐接口。如果预处理这一步就报错,别急着查算法,先看数据文件的编码和字段分隔符,这类代码包最常见的问题是数据文件用了GBK编码但代码按UTF-8读取,或者日期字段格式不统一。
3. 基于内容的推荐三步:分词向量化、相似度计算、排序加权
标题上写着新闻推荐系统,大部分代码包的主干线其实是基于内容的推荐,也就是把每一篇新闻变成向量,用户读了什么就把那篇新闻的向量当成用户兴趣向量,然后找和它最像的新闻。这条路实现简单、冷启动友好,是这类代码包的首选算法。
3.1 TF-IDF 向量化与余弦相似度:推荐最核心的一行
基于内容推荐的第一步是让机器能「读」新闻。中文不像英文天然按空格分词,所以先用jieba把标题和正文切碎,再用TF-IDF把词序列变成固定维度的向量。TF-IDF干的事很直接:一个词在一篇新闻里出现得多,同时在所有新闻里出现得少,那它就对这篇新闻有代表性。比如「央行」在财经新闻里高频出现,但在全库出现频率不高,它的TF-IDF权重就高;而「新闻」「记者」这种到处都有的词权重会被压下去。
import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def load_and_tokenize(path): df = pd.read_csv(path, encoding="utf-8-sig") df["title"] = df["title"].fillna("") df["content"] = df["content"].fillna("") df["text"] = df["title"] + " " + df["content"] df["tokenized"] = df["text"].apply( lambda x: " ".join(jieba.lcut(x)) ) return df df = load_and_tokenize("./data/news.csv") vectorizer = TfidfVectorizer( max_features=5000, min_df=2, max_df=0.8, ngram_range=(1, 2), # 保留词和相邻词组合,缓解分词误差 sublinear_tf=True # 词频取对数,抑制高频词 ) tfidf_matrix = vectorizer.fit_transform(df["tokenized"]) similarity_matrix = cosine_similarity(tfidf_matrix, tfidf_matrix)上面这段代码做了三件事:读数据并分词、把分词结果向量化、计算新闻两两之间的余弦相似度矩阵。注意fit_transform的对象是分词后用空格拼起来的字符串,不是原始文本,这是新手最容易漏掉的一步。直接喂原始中文文本,TfidfVectorizer会把每个连续的汉字串当成一个特征,效果很差。
几个参数值得单独说。ngram_range=(1, 2) 的作用是保留「降准」「央行降准」这样的相邻词组合,因为单字分词容易把「央行降准」切成「央行 / 降准」,丢掉了组合语义。sublinear_tf=True 是对词频做对数变换,防止一篇长文里重复出现的词直接压过其他词。cosine_similarity 计算出来的是稠密矩阵,新闻量超过一万条时内存会明显吃力,后面避坑章节再说怎么处理。
3.2 排序加权:加时间衰减和热度,不做新闻「复读机」
纯相似度排序很快暴露一个问题:最相似的往往是老新闻或者长文。原因很简单,相似度只看内容,不看时间。热门政治新闻的相似度可能几个月前就被算好了,如果直接按相似度返回,用户打开系统看到的是上周甚至上月的旧闻。这就是新闻推荐和商品推荐最大的区别,商品可以反复推荐同款,新闻推荐同一条就是翻车。
处理办法是排序阶段做加权,把时间衰减和热度分数揉进最终得分。我常用的排序公式是:最终分 = 相似度 * 时间衰减因子 + 热度分 * 热度权重。时间衰减因子用指数函数,越老的新闻权重越低;热度分可以用点击数、评论数或者转发数归一化得到。
import numpy as np from datetime import datetime, timedelta def time_decay_factor(publish_date, half_life_days=3): """新闻时效衰减:超过半衰期天数,权重减半""" if pd.isna(publish_date): return 0.5 try: publish_dt = datetime.strptime(str(publish_date)[:10], "%Y-%m-%d") except ValueError: return 0.5 age_days = (datetime.now() - publish_dt).days return float(np.power(0.5, max(age_days, 0) / half_life_days)) def rank_for_user(similarity_row, news_df, clicked_idx, top_n=10): content_score = similarity_row.copy() # 排除用户已经读过的新闻,避免复读 for idx in clicked_idx: content_score[idx] = 0.0 # 排除自己和自己比的那一项 content_score[clicked_idx[-1]] = 0.0 decay = news_df["publish_date"].apply( lambda d: time_decay_factor(d, TIME_DECAY_DAYS) ) hot = news_df["hot_score"].fillna(0) / (news_df["hot_score"].max() + 1e-9) final_score = content_score * np.array(decay) + HOT_WEIGHT * np.array(hot) top_indices = final_score.argsort()[::-1][:top_n] return news_df.iloc[top_indices][["title", "publish_date", "hot_score"]]这段代码的关键是把向量化得到的相似度当成一个基础分,再用时间衰减和热度去修正。排除已读新闻是必须的,不然用户每次刷新看到的都是同一批文章。half_life_days是半衰期参数,新闻类场景我一般设2到3天,设太短会导致当天新闻霸榜,设太长又会让一周前的旧闻持续出现。hot_weight设0.3左右比较稳妥,设太高会让推荐变成纯热榜,失去了个性化。hot_score字段如果上游没给,可以用click_cnt或comment_cnt替代,完全没有行为数据时就先设0,别硬算。
这一步做完,一个最小可用的基于内容的新闻推荐其实已经成立了。先把这三步跑通,再考虑协同过滤,不要一上来就上模型。
4. 协同过滤与混合策略:用户行为数据够厚时再加
基于内容的方法有天花板:它只懂新闻内容,不懂人。两个用户可能兴趣完全不同,但因为看了同一篇新闻,理论上就应该有某种关联。协同过滤补的正是这一层,它不看新闻长什么样,只看行为记录。用户和新闻的交互数据越厚,协同过滤的价值越大。
4.1 基于用户的协同过滤:构造用户-新闻矩阵并计算相似用户
基于用户的协同过滤思路很朴素:找到和你读过同样新闻的人,把那些人读过但你没读过的新闻推荐给你。实现上要构造一个用户-新闻矩阵,行是用户,列是新闻,值是行为强度。行为强度可以简单用点击数,更完整的做法是点击为1、点赞为3、收藏为5,用不同权重表达用户偏好程度。
import pandas as pd import numpy as np behavior = pd.read_csv("./data/user_behavior.csv", encoding="utf-8-sig") # 行为强度映射:点击1分,点赞3分,收藏5分 behavior["value"] = behavior["action"].map( {"click": 1, "like": 3, "collect": 5} ) user_item = behavior.pivot_table( index="user_id", columns="news_id", values="value", fill_value=0 ) def pearson_sim(a, b): """皮尔逊相关系数,处理不同用户打分尺度差异""" mask = (a > 0) & (b > 0) if mask.sum() < 2: return 0.0 a_common, b_common = a[mask], b[mask] return np.corrcoef(a_common, b_common)[0, 1] def user_cf_recommend(user_id, user_item, top_k=5, top_n=10): target = user_item.loc[user_id].values.astype(float) scores = [] for other_id, row in user_item.iterrows(): if other_id == user_id: continue sim = pearson_sim(target, row.values.astype(float)) if sim <= 0: continue scores.append((other_id, sim)) scores.sort(key=lambda x: x[1], reverse=True) top_users = scores[:top_k] rec_scores = np.zeros(user_item.shape[1]) for other_id, sim in top_users: other_vec = user_item.loc[other_id].values.astype(float) # 只累加目标用户没看过的新闻 unseen = target == 0 rec_scores[unseen] += sim * other_vec[unseen] news_cols = user_item.columns top_idx = rec_scores.argsort()[::-1][:top_n] return news_cols[top_idx[rec_scores[top_idx] > 0]]核心逻辑是三重循环:先算目标用户和其他人的相似度,取最像的top_k个用户,再把这些用户看过的新闻按相似度加权累加,最后只保留目标用户没看过的、且分数大于0的新闻。皮尔逊相关系数比余弦相似度更适合这里,因为它做了均值中心化,两个用户一个喜欢给1分、一个喜欢给5分,皮尔逊也能识别出趋势一致。
user_item矩阵的稀疏度是这个算法的命门。1000个用户、5000篇新闻,矩阵里95%以上是0是常态。如果top_k取太大,把相似度很低的用户也拉进来,推荐结果会被噪声淹没。我一般控制在5到15之间。这个算法的时间复杂度是O(用户数×新闻数),用户量过万后纯Python循环会慢得明显,这时候就该考虑用矩阵分解降维,也就是SVD。
4.2 混合推荐与冷启动兜底:什么时候值得上 SVD
SVD类方法的核心假设是用户和物品可以映射到一个低维隐空间,用户向量和新闻向量做内积就能得到偏好分数。这类代码包里如果用了surprise库,最常见的就是SVD或NMF。它比基于用户的协同过滤更省内存,泛化能力也更强,但需要调的参数多了好几个。
三种方案的选型边界,我自己的判断标准大概是这样:
| 方案 | 需要的数据 | 冷启动表现 | 实现成本 | 适合阶段 |
|---|---|---|---|---|
| 基于内容 | 只要新闻文本 | 好,新新闻直接可用 | 低 | 冷启动、行为数据不足时 |
| 基于用户协同过滤 | 至少千级用户行为 | 差,新用户和新新闻都拿不到推荐 | 中 | 行为数据积累到一定量级 |
| SVD矩阵分解 | 行为数据越厚越好 | 差,需要交互才能生成向量 | 高 | 数据量大、追求精度时 |
冷启动是这个领域最现实的问题。新用户一条行为都没有,协同过滤和SVD都无法给出任何候选;新新闻没有任何交互,矩阵里那一列全是0,算出来的分数也是0。所以务实的做法是混合,用基于内容做兜底,保证新用户也有推荐可看,行为数据积累后再逐步加大协同过滤的权重。
def hybrid_score(content_score, cf_score, alpha=0.5): """alpha越大越偏向协同过滤,冷启动阶段建议0.2""" return alpha * cf_score + (1 - alpha) * content_scorealpha是混合比例,没有唯一正确答案。产品上线初期用户行为稀疏,我建议alpha设0.2,让内容推荐主导;跑两周之后行为数据厚了,再慢慢调到0.5。调这个参数要看数据,用后面的离线评估来看效果变化,而不是拍脑袋。SVD不是银弹,它的隐向量解释性差,出了问题很难定位是数据问题还是参数问题,这也是很多代码包把SVD写成可选分支而不是主线的真实原因。
5. 避坑指南:把新闻推荐系统跑起来最常踩的5个坑
这类代码包看着完整,实际跑起来每一步都有翻车的可能。下面这几条是我自己踩过、也看别人踩过的典型问题,按现象到解决的方式写清楚,照着排查能省不少时间。
5.1 分词结果全是单字或乱码:停用词表和词典先兜底
现象:preprocess之后打印分词结果,发现大量单字,例如「的」「了」「在」占了大半,或者出现一堆类似乱码的字符,「央行」被切成「央」和「行」。
原因:停用词表没加载成功,或者加载了但路径指错了文件;词典缺失导致专有名词和领域词被切碎。
解决:先确认停用词表路径真实存在,再看加载函数用的是不是utf-8编码。词典缺失时的补救办法是给jieba加自定义词典,把新闻领域的专有词加进去。注意自定义词典的每行格式必须是「词 词频 词性」,词频会直接影响切分优先级。
# user_dict.txt 内容示例,词频数字越大越容易被识别为完整词 央行 1000 n 降准 800 n 美联储 900 n加载方式就是jieba.load_userdict("./data/user_dict.txt"),放在分词逻辑之前执行一遍。加完词典之后重新跑一次分词,如果单字比例明显下降,说明是词典缺失的问题;如果还是乱,回到编码上查。
5.2 相似度计算内存爆掉:几千条新闻直接卡死
现象:新闻量只有几千条,跑cosine_similarity时内存占用飙升到几十GB,进程被杀或者卡到没法操作。
原因:默认算出来的是稠密矩阵,5000篇新闻就是5000×5000的浮点数矩阵,大约200MB;数据量到20000篇,矩阵就膨胀到3GB以上。如果同时把tfidf_matrix和similarity_matrix都保留在变量里,内存翻倍。
解决:改用稀疏相似度计算,或者按需要分批计算。cosine_similarity其实可以只计算当前用户需要的那些行,不要一次性算全量矩阵。更彻底的做法是保存稀疏的TF-IDF向量,再用linear_kernel对单个向量做计算。
from sklearn.metrics.pairwise import linear_kernel # 不生成全量矩阵,只算当前新闻和全库的相似度 query_vec = tfidf_matrix[target_idx] sim_row = linear_kernel(query_vec, tfidf_matrix).flatten()这里的思路是把「先算全量再取一行」改成「只算一行」。用linear_kernel替代cosine_similarity,是因为两者对归一化后的TF-IDF向量结果等价,但linear_kernel在稀疏矩阵上更快。五千条新闻的场景下,这个改动能把秒级延迟控制在可接受范围内,内存占用也从GB级降到MB级。
5.3 推荐结果变成「自己的影子」:相似度最高的永远是本文
现象:用户读完某篇新闻后,推荐列表第一名就是这篇新闻本身,或者排在前面的全是标题几乎一样的转载稿。
原因:计算相似度时没有排除目标新闻自身。每篇新闻和自身的余弦相似度都是1.0,必然排在第一位。另外,转载稿如果内容重复度高,相似度自然也很高,但用户已经读过了。
解决:排序前把已读新闻的相似度分数置0,具体代码在第3.2节已经写过了。同时要在数据层面做去重,按标题或正文的MD5哈希去掉转载副本,否则去重不彻底,推荐列表会反复出现同一条新闻的不同转载版本。
df["dup_key"] = df["content"].str.strip().apply( lambda x: hashlib.md5(x.encode("utf-8")).hexdigest() ) df = df.drop_duplicates(subset="dup_key", keep="first")去重保留最先入库的那条,转载的副本直接丢掉。这一步放在分词之前做,能减少不少无效计算量。
5.4 服务接口时好时坏:路径写死和进程残留是两大元凶
现象:本地跑Flask接口,第一次启动正常,改了几个参数重启后报端口占用;或者换了一台机器,数据路径直接失效,接口能启动但返回空列表。
原因:代码包里常见两种坏习惯——端口号写死不做冲突检测,文件路径用绝对路径写死,换环境必挂。
解决:服务启动前先检测端口占用,数据路径全部改成相对路径,基于项目根目录做拼接。换机器时只需要保证数据文件放在约定目录下,不用改代码。
# 快速排查端口占用 lsof -i:5000 # 如果被占用,要么kill旧进程,要么换端口启动接口启动后我习惯先手动请求一次推荐接口,确认返回的JSON里新闻标题不是空的,再继续往前端接。这一步能避免前端跑来问「为什么页面空白」时才发现是服务层挂了。
5.5 推荐结果和热榜没区别:个性化失效的两个隐蔽原因
现象:用户A和用户B看到的推荐列表几乎一样,全是全站热门新闻,个性化形同虚设。
原因:两个可能。第一,热榜权重hot_weight设太高,把个性化分数压没了;第二,用户行为数据太稀疏,协同过滤部分拿不到有效信号,混合结果被内容分主导,而内容分本身趋同。
解决:先看hot_weight,如果超过0.5,降下来再测。再看行为数据量,如果单用户的历史行为少于5条,协同过滤本来就发挥不出来,这时候应该承认系统处于冷启动阶段,靠内容推荐兜底是正常现象。个性化不是算法决定的,是数据量决定的,行为数据不够厚之前,推荐结果趋同是常态而不是bug。
6. 让推荐效果可验证:离线评估与最小日志埋点
推荐系统做完了不能只靠「感觉还行」,得让效果变成能对比的数字。我最常用的验证方法是离线评估,拿历史点击日志当测试集,看推荐列表能不能命中用户真正点击过的新闻。
def precision_at_k(recommended, actual, k=10): ret = recommended[:k] hit = len(set(ret) & set(actual)) return hit / k按天划分训练集和测试集,用前7天数据训练或生成推荐,第8天用户的真实点击当标准答案,算Precision@K。这个数字能回答「推荐列表里有多少比例是用户真会点的」,是调参时最有用的一个指标。
线上要做的另一件事是日志埋点,把每次推荐的用户ID、推荐结果、排序位置和曝光时间写下来。我习惯在推荐接口里追加一行JSON日志,这样后续可以复现每一次推荐,排查问题不用猜,也能支撑将来做用户行为和兴趣漂移分析。这套最小验证下来,推荐系统就不再是黑匣子了。
最后说个我自己的教训:第一次做这类系统时,我把时间精力全花在调SVD参数上,结果发现评估指标纹丝不动,因为行为数据只有几百条,矩阵稀疏到根本没有有效信号。后来老老实实把内容推荐做扎实、把离线评估建起来,才真正开始有可迭代的方向。推荐系统里数据决定了上限,算法只是逼近这个上限的方式,调参是最后的步骤而不是第一步。希望帮到你。
本文还有配套的精品资源,点击获取