简介:基于朴素贝叶斯算法的新闻分类项目源码,面向计算机相关专业的期末大作业与课程设计场景,适合需要完成新闻分类任务或进行机器学习实战练习的学生参考使用。项目经导师指导、助教审定并获评98分,源码均已完成本地编译与调试,可直接运行。压缩包共2000个文件,约13.33MB,包含3个Python核心脚本(负责贝叶斯模型训练、分类器构建与URL数据解析)、1996个txt频率统计与预处理结果文件,以及1个md说明文档,便于核对词频分布并快速上手。目前已有152人学习下载,读者可结合频率文件理解朴素贝叶斯训练流程,并在此基础上调整参数、扩展语料,用于作业提交、课程答辩或算法对比实验。
1. 新闻分类第一道坎:为什么朴素贝叶斯比想象中能打
搜索“新闻分类项目源码”的人,多半不是来复习概率论公式的,而是手里攒了几千条已标注的新闻文本,想尽快跑出一个能用的多分类模型。放到五年前,这个需求难免要讨论“要不要直接上 BERT”;但很多内容平台的第一版新闻分类,恰恰就是朴素贝叶斯撑起来的——训练以秒计,推理不需要 GPU,短文本上的 F1 往往能到 0.85 上下。这个标题对应的项目源码,核心是三件事:把新闻文本清洗成可训练的表格数据、用朴素贝叶斯拟合一个多分类器、留出清晰可复用的预测接口。它适合刚接触机器学习、想从“跑通代码”走向“能调参数”的人,也适合标注数据有限、又不想等深度学习训练时间的团队。
2. 项目骨架怎么搭:一个能直接跑的中文新闻分类最小结构
2.1 目录结构与四个核心文件:train、predict、evaluate、utils 谁管什么
拿到项目源码先别急着跑,先把文件职责分清。我一般会排成下面这样一棵树:
news_classifier/ ├── data/ │ ├── news_train.csv # 训练集:content 列 + category 列 │ ├── news_test.csv # 测试集:结构与训练集一致 │ └── stopwords.txt # 每行一个停用词 ├── utils/ │ └── text_processor.py # 分词、清洗、停用词过滤都收在这里 ├── train.py # 加载数据 -> 向量化 -> 训练 -> 持久化 ├── evaluate.py # 加载测试集 -> 打印分类报告与混淆矩阵 ├── predict.py # 加载模型 -> 对单条文本或批量文本预测 ├── models/ │ ├── nb_pipeline.pkl # 训练产物:向量器 + 分类器打成一个包 │ └── label_map.json # 类别名与模型内部标签的映射 └── requirements.txt # jieba / scikit-learn / pandas / joblib这个结构好在职责分离:数据、代码、模型产物分开放,训练脚本只产出 models/ 下的文件,预测脚本只消费 models/ 下的文件。很多人把训练和预测写在一个脚本里,测试时还好,一上线每个请求都要走一遍训练逻辑,接口延迟好几秒,就是这么来的。
utils/text_processor.py 是整个项目最不能乱动的公共模块。train.py 和 predict.py 都从这里面 import tokenize 函数,保证训练和预测走的是同一套分词逻辑。分词不一致是文本分类里最隐蔽的坑:训练时“新能源汽车”是一个词,预测时被切成了“新能源”“汽车”,特征对不上,模型效果立刻掉几个点。
第一次跑通这个项目的顺序也很固定,三条命令就够了:
pip install -r requirements.txt python train.py python evaluate.pypredict.py 放到最后跑,因为它依赖 models/ 下的训练产物。如果 evaluate.py 打印出来的分类报告已经不差,再拿 predict.py 做单条文本的冒烟测试,这才是合理的推进顺序。
2.2 数据接入:把任意新闻语料归一化成两列 CSV
模型不关心原始数据存在 MySQL、ES 还是 Excel 里,它只认两列:content 和 category。所以项目源码里第一个要跑通的逻辑一定是数据归一化。不管原始数据是从公开的中文新闻语料导出的,还是从业务库里导出的,常见做法都是先归一化成两列:
import pandas as pd from sklearn.model_selection import train_test_split # 原始数据字段名可能五花八门,先只挑这两列 df = pd.read_csv("raw_news.csv") df = df[["content", "category"]].dropna() # 类型强制转字符串,去掉首尾空格和多余空白符 df["category"] = df["category"].astype(str).str.strip() df["content"] = df["content"].astype(str).str.replace(r"\s+", " ", regex=True) # 分层切分:让训练集和测试集的类别比例保持一致 train_df, test_df = train_test_split( df, test_size=0.2, random_state=42, stratify=df["category"] ) train_df.to_csv("data/news_train.csv", index=False) test_df.to_csv("data/news_test.csv", index=False) # 顺手打印类别分布,类别不平衡在这里就能看出来 print(train_df["category"].value_counts())stratify=df["category"] 是一个容易忽略但很重要的参数。朴素贝叶斯对先验概率敏感,训练集里财经类占 60%、体育类只占 5%,学出来的先验本身就是歪的,不做分层切分,测试集上的结论会进一步失真。random_state=42 让切分结果可复现,否则同一份数据跑两次报告对不上,排查问题时会非常痛苦。
dropna() 这里也有个现实问题:新闻正文有时整行为空,直接 drop 是最省事的。但如果 drop 之后发现数据少了一半,问题就不在预处理,而在源头的采集逻辑——很多采集源只抓到了标题没抓到正文,这时应该回去修爬虫,而不是在项目里硬扛。
另外,读 CSV 时建议统一指定 encoding="utf-8"。新闻语料里偶尔混着其他编码的文本,不指定编码,pandas 会默认用系统编码,Windows 上经常在 train.py 第一行就报 UnicodeDecodeError。
2.3 训练脚本:一条 pipeline 把向量化和分类器绑在一起
数据准备好了,训练脚本可以非常短。不要把“分词、向量化、训练”拆成三步手动串,直接用 sklearn 的 Pipeline 一步到位:
import joblib import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import make_pipeline from utils.text_processor import tokenize df = pd.read_csv("data/news_train.csv") X, y = df["content"], df["category"] pipe = make_pipeline( TfidfVectorizer( tokenizer=tokenize, ngram_range=(1, 2), max_features=30000, min_df=2, max_df=0.8, sublinear_tf=True, ), MultinomialNB(alpha=1.0), ) pipe.fit(X, y) joblib.dump(pipe, "models/nb_pipeline.pkl")这段代码的核心逻辑是:tokenize 是自定义分词函数,返回一个词列表;TfidfVectorizer 拿到词列表后统计 IDF;MultinomialNB 直接吃 TF-IDF 稀疏矩阵。pipeline 把两步焊在一起,predict.py 加载模型后,对新文本只需要调 pipe.predict([text]),内部会自动完成分词、向量化、预测三个动作。
参数方面,max_features=30000 限制词典规模,防止出现几十万维的超稀疏矩阵拖慢训练;min_df=2 去掉全语料里只出现一次的词,这些词大概率是错别字或长尾噪声;ngram_range=(1, 2) 同时保留单词和相邻词对,让“新能源汽车”这种组合词即使被切碎也能以二元词对的形式留下痕迹;sublinear_tf=True 对词频做对数缩放,抑制长新闻里重复词被过度放大。这几个参数是新闻分类的稳妥起点,不是最优值,后面要用交叉验证再搜一轮。
3. 朴素贝叶斯原理:条件独立假设与拉普拉斯平滑的工程意义
3.1 从贝叶斯公式到文本分类:先验、似然、后验的三步换算
读懂项目源码里的公式,才能回答“为什么这么调参”。贝叶斯公式放在新闻分类场景里是这样的:给定一篇文本 x,它属于类别 y 的概率是 P(y|x) = P(y) · P(x|y) / P(x)。其中 P(y) 是类别先验——训练集里财经类比体育类多,先验就大;P(x|y) 是似然——在财经类语料里,“股市”这个词出现的概率;分母 P(x) 对所有类别都一样,实际计算时可以直接忽略,因为我们要比较的是“哪个类别概率更大”,不是概率本身。
难点在 P(x|y)。一篇新闻有几十个词,直接统计“在财经类下出现整句话 x”的概率,需要海量数据且根本统计不完。朴素贝叶斯做了一个极强假设:词与词之间条件独立。于是 P(x|y) 拆成每个词概率的连乘:P(x|y) ≈ P(w1|y) · P(w2|y) · … · P(wn|y)。这个假设在语言学上明显是错的——“美联储”和“加息”根本不是独立出现的——但工程上它让计算变得可行,结果还出奇地稳。
这就是朴素贝叶斯能成为新闻分类基线的根本原因:它不试图理解语言,只做词的分布统计。对财经、体育、娱乐这种类别区分度明确的新闻,词分布差异足够大,即便独立性假设被违反,类别的相对大小依然正确。工程上这叫“用有偏估计换可行性”,在标注数据不足时,这种取舍比追求模型完美更务实。
3.2 多项式模型 vs 伯努利模型:新闻正文该选哪个
项目源码里用的是 MultinomialNB(多项式朴素贝叶斯),但细心的读者会发现 sklearn 里还有个 BernoulliNB(伯努利朴素贝叶斯),两者都常用于文本分类,区别在特征的取值方式:
| 模型 | 特征取值 | 核心假设 | 新闻场景适配度 |
|---|---|---|---|
| MultinomialNB | 词频或 TF-IDF 值 | 特征服从多项式分布 | 高:正文长度差异大,词频信息有价值 |
| BernoulliNB | 0 或 1(词是否出现) | 特征服从伯努利分布 | 低:只关心词是否出现,丢弃了词频 |
新闻正文少则几十字、多则几千字,同一个词“经济”在一篇长文里出现 20 次,在另一篇短文里出现 1 次,两者的信息量显然不同。MultinomialNB 能利用这个差异,BernoulliNB 则把所有非零特征一律视为 1,等于把词频信息扔了。所以这个项目选 MultinomialNB 是合理的。
但有一个边缘场景值得知道:如果只拿新闻标题做分类,标题通常不超过 30 字,词频差异本来就小,BernoulliNB 有时反而更稳,因为它能抑制长标题里重复词带来的过度放大。如果业务本身就是标题分类,可以在项目里保留一个开关,两个模型都跑一遍对比,用报告说话。
3.3 手写一个 40 行的朴素贝叶斯分类器,看懂 sklearn 背后在干什么
只看 API 调用很难真正理解算法。我习惯在项目里保留一个简化版实现,作用是教学和排查。这里给出核心训练与预测逻辑:
import math from collections import Counter, defaultdict class ManualMultinomialNB: def __init__(self, alpha=1.0): self.alpha = alpha self.class_counts = Counter() # 每个类别的文档数 self.word_counts = defaultdict(Counter) # 类别 -> 词 -> 词频 self.vocab = set() def fit(self, X_tokens, y): for tokens, label in zip(X_tokens, y): self.class_counts[label] += 1 for w in set(tokens): # 实际工程应按出现次数累加,这里简化 self.word_counts[label][w] += 1 self.vocab.add(w) def predict(self, tokens): best_label, best_score = None, -float("inf") total_docs = sum(self.class_counts.values()) for label in self.class_counts: # 先验 P(y),取对数防止下溢 log_prior = math.log(self.class_counts[label] / total_docs) # 拉普拉斯平滑的分母:该类别总词数 + alpha * 词典大小 denom = sum(self.word_counts[label].values()) + self.alpha * len(self.vocab) log_likelihood = 0.0 for w in tokens: count = self.word_counts[label].get(w, 0) + self.alpha log_likelihood += math.log(count / denom) score = log_prior + log_likelihood if score > best_score: best_label, best_score = label, score return best_label这段代码演示的正是 sklearn MultinomialNB 的核心计算:先验是对数概率,似然是每个词的条件概率取对数后累加。用对数是为了防止几十个概率连乘后下溢成 0,这是朴素贝叶斯工程实现里最关键的一个细节。
alpha=1.0 就是拉普拉斯平滑。注意分子 count + alpha:某个词在训练集的某类别里一次都没出现过,count 为 0,如果不加 alpha,整个连乘直接变成 0,这个词所在的文档就永远不可能被分到这个类别。alpha 相当于给每个词在各类别里预置了 1 次虚拟出现次数,保证概率永远不会是 0。它在新闻分类里几乎是必开的,sklearn 的默认值就是 1.0,项目源码里沿用这个默认值,是在“不调参也能用”和“给后续调参留空间”之间取的平衡。
4. 特征工程决定上限:分词、TF-IDF 与停用词的调参点
4.1 jieba 分词:精确模式加自定义词典,别用搜索引擎模式
朴素贝叶斯不管中文分词,它只认空格分隔的 token,所以分词质量直接决定特征质量。项目里一般用 jieba,稳定、更新频繁,分词速度也够。分词函数建议这样写:
import jieba # 项目启动时加载自定义词典,路径放在配置文件里 jieba.load_userdict("data/userdict.txt") STOPWORDS = set() with open("data/stopwords.txt", encoding="utf-8") as f: for line in f: w = line.strip() if w: STOPWORDS.add(w) def tokenize(text: str) -> list[str]: words = jieba.cut(text, cut_all=False) return [ w for w in words if len(w.strip()) > 1 and w not in STOPWORDS ]两个参数值得讲。第一,cut_all=False 表示精确模式,这是新闻分类的默认选择;cut_all=True 是搜索引擎模式,会输出大量冗余词组合,让特征空间膨胀好几倍,对朴素贝叶斯这种对特征规模敏感的模型很不友好。第二,过滤逻辑里 len(w.strip()) > 1 把单字词去掉,中文单字词多数是“的、了、在”这类虚词以及大量噪声,留着只会稀释有效特征。
停用词过滤放在分词函数里,而不是 TfidfVectorizer 的 stop_words 参数里,是因为分词阶段就把停用词丢掉能减小词表规模,后面向量化时的 min_df、max_df 统计也更贴合真实分布。userdict.txt 的格式是每行“词语 词频 词性”,比如“新能源汽车 1000 n”,不写词频和词性也可以,只写词就能生效。
4.2 TF-IDF 参数:ngram_range、max_features、min_df、max_df 的推荐起点
TfidfVectorizer 的参数往往比朴素贝叶斯本身的参数更影响最终效果。下面是新闻分类项目里最值得调的四个参数和我的经验起点:
| 参数 | 推荐起点 | 调整方向 |
|---|---|---|
| ngram_range | (1, 2) | 调到 (1, 1) 损失词组信息;(1, 3) 在数据量大时值得试 |
| max_features | 30000 | 数据量小降到 10000;维度太高训练慢且易过拟合 |
| min_df | 2 | 数据量大提到 5,去掉低频噪声 |
| max_df | 0.8 | 降到 0.6 可进一步去掉“新闻”“记者”这类高频噪词 |
| sublinear_tf | True | 用 1+log(tf) 缩放词频,抑制长文档里重复词的过度权重 |
ngram_range=(1, 2) 是新闻分类里最划算的选择。它保留单词,同时生成相邻词对,比如“新能源汽车”即使被 jieba 切成“新能源”“汽车”两个词,二元词组也能捕捉到它们的相邻关系。代价是特征数量会翻几倍,所以需要 max_features 做截断。
min_df=2 的含义是一个词至少在 2 篇文档中出现过才进入词表。全语料只出现一次的词,要么是错别字,要么是长尾专名,对分类决策几乎没有贡献,却会占一个特征维度。max_df=0.8 表示在超过 80% 的文档里都出现的词会被忽略,“新闻、记者、报道、今天”这类词在各类新闻里都高频出现,区分度接近零,留着只会稀释有效特征的权重。
sublinear_tf=True 是容易被忽略但收益明显的参数,它把原始词频 tf 替换为 1+log(tf),让词频从线性增长变成对数增长。一个词在长文里出现 100 次和出现 50 次,缩放后的差异远小于原始数值的差异,避免长文档里高频词主导整个向量。新闻正文长度差异极大,这个参数几乎必开。
4.3 停用词表:通用表打底,领域噪词必须自己加
网上能下到各种版本的通用中文停用词表,覆盖“的、了、是、在”这类功能词。但新闻分类光靠通用表远远不够,真正的噪声是领域相关的。做财经新闻分类的人会发现“记者、来源、责任编辑”这些词在各类财经新闻里反复出现,需要手动加进去。
一个务实的做法是把停用词分成两层。第一层是通用表,下载一个几百行的中文停用词表放 data/stopwords.txt;第二层是领域词表,每次看分类报告时,找到那些在各类别里 tf-idf 排名都很高、但和类别语义无关的词,追加到同一个文件里。追加之后要重新训练,因为停用词影响的是词表构建阶段,不只是预测阶段。
需要提醒的是停用词不要加得太狠。像“经济、市场”这种词在财经类里出现频率显著高于其他类,它们是有效的分类信号,不能因为“看起来像背景词”就加进停用词表。判断标准只有一个:这个词在各类别里的出现频率是否接近,接近才删,有明显差异就保留。
5. 新闻分类避坑清单:5 个最常见的翻车点与排查方法
这几条是新闻分类项目里最容易翻车的点,每条都是真实项目里攒下来的血泪经验,按“现象、原因、解决”写清楚。
5.1 翻车点一:类别不平衡,小类目被大类目吃掉
现象:训练集里体育类占 40%、文化类只占 4%,训练完跑测试集,体育类 F1 有 0.9,文化类只有 0.3,分类报告里文化类的 recall 尤其惨。
原因:sklearn 的 MultinomialNB 没有 class_weight 参数,它只会老老实实按训练集里的类别比例学习先验。少数类先验太低,后验被多数类压制,预测时几乎不会输出少数类。
解决:在 fit 时通过 sample_weight 补偿少数类,常见做法是按类别反比加权:
import numpy as np from sklearn.utils.class_weight import compute_class_weight classes = np.unique(y) weights = compute_class_weight(class_weight="balanced", classes=classes, y=y) sample_weight = np.array([weights[list(classes).index(label)] for label in y]) pipe.fit(X, y, multinomialnb__sample_weight=sample_weight)注意这里传的是 multinomialnb__sample_weight,因为 pipe 是 Pipeline,参数要带上前缀指定到 MultinomialNB 这一步。排查时先看 classification_report 里每个类别的 support 值,support 小于 100 的类别都要警惕。
5.2 翻车点二:直接照搬英文新闻组案例的预处理流程
现象:网上很多“朴素贝叶斯文本分类”教程基于英文 20 Newsgroups 数据集,照搬预处理:text.lower().split(),结果中文新闻分类准确率只有 60% 出头,甚至更低。
原因:英文单词天然以空格分词,中文没有空格边界,必须单独分词。而且英文可以放心做小写化和词形还原,中文根本没有大小写,却要做英文里不存在的自定义词典加载和领域停用词过滤。
解决:项目里所有文本处理入口必须经过 utils/text_processor.py 的 tokenize 函数,不能直接调 sklearn 默认的 tokenizer。排查方法就是打印一条样本的分词结果,如果中文被空格切开之后是一大堆单字,说明分词这步压根没接上。
5.3 翻车点三:jieba 把领域词切碎,特征语义丢失
现象:训练时“新能源汽车”被 jieba 切成“新能源”“汽车”,这两个词单独出现在科技类和财经类新闻里都不少见,模型很难分辨一条新闻到底在讲环保还是讲汽车市场。
原因:jieba 的默认词库是通用语料训练的,对垂直领域专名覆盖不足。新闻、金融、医疗都有一堆复合词,通用词库不会自动识别。
解决:维护一个 data/userdict.txt,把业务领域的核心专名加进去,一行一个词:
新能源汽车 碳达峰 认房不认贷 低空经济加载一次即可,jieba.load_userdict 在进程启动时调用。加完词典必须重新训练模型,因为分词结果变了,整个词表都会跟着变。排查时在 tokenize 里临时打印几个高频专名的切分结果,一眼就能看出切没切对。
5.4 翻车点四:预测阶段对单条文本做 fit_transform,特征空间整个错位
现象:训练时用 fit_transform 得到 30000 维词表,预测时为了“省事”对单条文本也调用 fit_transform,结果预测概率乱套,或者类别永远落在某一个类上。
原因:fit_transform 会重新拟合词表。单条文本只有几百个词,重新拟合出来的词表只有几十个特征,和训练时的 30000 维特征空间完全对不上。这是文本分类项目里最经典的翻车现场,几乎每个从零写预测脚本的人都会踩一次。
解决:训练阶段 fit_transform,预测阶段只准用 transform。pipeline 打包的意义就在这——模型持久化时把 TfidfVectorizer 和 MultinomialNB 一起存成 nb_pipeline.pkl,预测时加载整个 pipeline 调 predict,从源头杜绝了单独调用向量器引发错位。这也是我在 2.3 节强调 make_pipeline 的原因。
5.5 翻车点五:标题和正文等权拼接,短标题信号被长正文淹没
现象:标题“沪指收复 3000 点”被拼进一篇 3000 字的财经正文后,标题里那三四个关键词在 TF-IDF 向量中的权重被稀释到几乎不影响判断,分类结果和只用正文没区别。
原因:TF-IDF 里词频是文档级统计,标题只有几十个字,拼接进正文后词频占比极小,标题的强区分信号被长文稀释。
解决:常见做法是给标题加权。不要直接拼接,而是把标题复制三到五份再和正文拼接,代码上就是一行字符串乘法:train_df["content"] = 标题 * 3 + 正文。这样标题里的关键词词频被放大,TF-IDF 会给予更高权重。如果数据源本身没有标题只有正文,跳过这个处理;如果标题质量高,这个加权几乎不花成本就能带来一到两个点的提升。
提示:标题加权倍数不要超过 5 倍,否则模型会变成只认标题词,正文里真正重要的长尾信息,比如导语里的具体数据词,会被完全无视。
6. 把基线模型做扎实:交叉验证、分类报告与模型持久化
项目跑到这步,说明手里的朴素贝叶斯新闻分类已经能工作了。最后这三个技巧决定它能不能从“能跑”变成“敢上线”。
第一个技巧:用交叉验证替代单次切分。把 2.3 节的 pipeline 丢给 GridSearchCV,搜 alpha 和 ngram_range 的搭配:
from sklearn.model_selection import GridSearchCV param_grid = { "tfidfvectorizer__ngram_range": [(1, 1), (1, 2)], "multinomialnb__alpha": [0.1, 0.5, 1.0, 2.0], } grid = GridSearchCV(pipe, param_grid, cv=5, scoring="f1_macro", n_jobs=-1) grid.fit(X, y) print(grid.best_params_, grid.best_score_)scoring 选 f1_macro 而不是 accuracy,因为新闻类别往往不平衡,accuracy 会被多数类掩盖。交叉验证的意义是看方差:如果五折分数波动超过两个点,说明数据量不够或者类别分布不稳,这时候调再细的参数都没用,应该回去补数据。
第二个技巧:每次训练后固定看三个数。precision 看模型输出这个类别时有多可靠,recall 看这个类别的新闻有多少被找回来了,F1 是两者的调和。重点看 macro avg,而不是 overall accuracy。如果某一类的 precision 高但 recall 低,说明模型很保守,只在该类特征极其明显时才肯输出该类,这时再用 5.1 节的 sample_weight 方法给该类加权。
第三个技巧:joblib 存的是整个 pipeline,加载后先用一条真实业务文本做冒烟测试。我个人的习惯是训练完随手跑一下 predict.py,输入一条“今日 A 股三大指数集体收涨,沪指涨 0.78%”,确认输出是财经类再收工。模型持久化文件就是后悔药,用 pipeline 统一打包之后,这条流程多跑几遍,踩坑的概率就低很多。希望帮到你。
本文还有配套的精品资源,点击获取