资讯详情

手写朴素贝叶斯实现中文文本分类:从原理到Python实战全解析

📅 2026/10/15 12:54:07 | 华诺云谱 👁 阅读
手写朴素贝叶斯实现中文文本分类:从原理到Python实战全解析
朴素贝叶斯实现中文文本分类这个项目我从头到尾手写了一遍。本来只是机械照搬公式中途才发现真正能把误差、平滑、对数概率这些东西讲明白的还是得自己动手实现而不是直接调现成库。这篇笔记不是照着官方文档翻译而是把我踩过的坑、验证过的参数、对代码每一行背后的理由都记录下来。无论你是刚接触自然语言处理还是课程作业正好卡在文本分类上这份内容应该能让你少走很多弯路——尤其是中文处理部分编程题的麻烦往往不在模型而在分词、编码、语料清洗这些不显眼的环节。我会先讲清楚朴素贝叶斯为什么能用于文本分类、为什么对中文也适用然后完整复现一遍从语料准备到模型评估的实战流程。途中会穿插大量代码、参数讨论和真实错误案例所有代码都基于Python 3环境。1. 思路起点中文文本分类与朴素贝叶斯的适配逻辑1.1 文本分类到底在解决什么问题从新闻分类说起文本分类属于监督学习中的典型任务。输入是一段不定长的自然语言文本输出是一个离散类别标签。新闻网站每天产生几十万篇内容要把体育、财经、科技、健康等类别自动分门别类手动标注显然不现实这就是文本分类最直接的应用场景。除此之外垃圾邮件识别、情感分析、工单自动打标本质上都是同一类问题。文本分类的核心难点是模型没有办法直接“读懂”汉字必须先把文本变成数字。英文天然用空格分词中文没有这个边界所以中文文本分类比英文多一道分词工序。分词质量直接影响后面所有环节——词分错了统计出来的特征就是错的分类器再聪明也白搭。我在做这个项目的时候首要目标不是追求最高的分类准确率而是彻底搞懂一条完整链路原始语料如何变成向量、概率如何计算、预测时如何比较不同类别的得分。选朴素贝叶斯不是因为它最强而是因为它足够简单、可解释性强适合作为整个NLP学习路径的第一块基石。1.2 贝叶斯定理与“朴素”假设为什么条件独立这么离谱还能用贝叶斯定理的公式很简洁P(类别 | 文档) P(文档 | 类别) × P(类别) / P(文档)翻译成人话已知一篇文章长这样它是某个类别的概率等于“这个类别本来就罕见程度”乘以“在这个类别下长出这篇文章的可能性”。P(类别)是训练集中各类别文档所占比例这个好算。P(文档 | 类别)就麻烦了一篇文章包含几十上百个词要估计这个条件概率数据量根本不够。于是朴素贝叶斯引入一个非常强的假设给定类别时文档中各个词的出现是相互独立的。用公式表达就是P(文档 | 类别) P(词1 | 类别) × P(词2 | 类别) × … × P(词n | 类别)这个假设在现实中几乎不可能成立。“算法”和“数据结构”明显经常一起出现但在条件独立的计算式里它们被当成互不影响。可神奇的地方在于即使这个假设是错的朴素贝叶斯在文本分类任务上的表现依然相当稳定。原因在于分类时我们并不需要精准的概率值只需要比较哪个类别的得分更高这类“错误假设”带来的偏差在各个类别上是相似的最后反倒把它们抵消了一部分。打个生活类比你想判断某人是不是程序员你看到他发朋友圈经常出现“需求”“bug”“加班”这些词。就算“bug”和“需求”并不完全独立你也能凭这些高频词大概估计他大概率是个程序员。朴素贝叶斯做的就是这件事——它不去精算联合分布而是拿着每个词的“证据”做加权投票。1.3 为什么不先上深度学习朴素贝叶斯作为基线模型的价值做任何机器学习项目第一条建议都是先跑一个简单的基线模型再考虑复杂方案。我在这个项目中对比过三个方向朴素贝叶斯训练速度快到几乎瞬时完成参数少可解释性强。一通代码写下来你能看到每个词对最终结果贡献了多少。支持向量机分类效果通常比朴素贝叶斯好一点但需要做大量的特征工程处理高维稀疏文本向量时调参成本不低。深度学习模型如RNN、Transformer效果确实最好但需要大规模语料训练时间以小时计对初学者来说黑盒感太强调参出问题根本无从排查。对于模拟项目X这种量级的中文新闻语料朴素贝叶斯已经能给出可用的结果。更重要的是后续换更复杂的模型时可以拿朴素贝叶斯的结果作为参照线——如果复杂模型连这条基线都跑不赢说明问题不在模型复杂度而在数据处理环节。2. 动手前的准备语料、环境与整体流程设计2.1 语料集选择与预处理数据质量决定效果上限我做这个项目时使用的语料来自某公开中文新闻数据集包含四个类别体育、财经、科技、健康每类大约500篇文档。总数据量2000篇左右对这个项目来说足够用了——太少看不出统计规律太多又没必要因为朴素贝叶斯本来就不需要海量数据。语料格式是CSV两个字段label和text。label是类别名称的字符串text是完整的文章内容。需要注意的一个常见坑是CSV文件在Windows下常常保存为GBK编码直接用pandas读取会报编码错误。正确做法是指定编码import pandas as pd df pd.read_csv(news_data.csv, encodingutf-8-sig)这里用utf-8-sig而不是utf-8因为在Windows下生成的CSV文件可能会带有BOM头utf-8-sig能自动识别并去除。如果文件本身是GBK编码可以改成encodinggbk需要先确认实际编码。拿到原始语料后先做一次快速清洗去HTML标签、去异常符号、去重复文档。我最初没去重结果发现同一篇体育新闻出现多次导致模型对少数高频词的权重过度放大分类结果偏向“背题”而不是“理解”。去重后训练效果好了一截。2.2 环境配置与工具选型哪些库必须装哪些可以手写项目核心是手写朴素贝叶斯分类器所以没有直接调用现成的MultinomialNB。但有几个工具库还是免不了jieba中文分词库。分词是中文NLP的必要前置步骤。pandas读取和操作语料。scikit-learn主要用于数据划分和模型评估以及后续对比实验。训练部分手写。re正则表达式清洗文本。collections.Counter与defaultdict做词频统计方便且高效。安装命令就一行pip install jieba pandas scikit-learn我建议目录结构一开始就分好naive_bayes_cn/ ├── data/ │ └── news_data.csv ├── preprocess.py ├── model.py └── evaluate.py模块分离的最大好处是每次实验不用从头跑数据清洗调试效率成倍提升。很多同学喜欢把所有代码堆在一个Jupyter Notebook里前期是方便后期改一个参数就要全部重跑非常浪费时间。2.3 文本处理流水线从原始字符串到概率模型的完整链路整个项目的数据流可以概括成五步原始文本 → 清洗去HTML、去数字、去英文清洗后文本 → 分词jieba切词分词结果 → 去停用词、低频词词序列 → 统计词频构建词典和特征表达特征表达 → 训练朴素贝叶斯模型这套流水线里第3步往往是最容易被忽略的。中文文本里有大量“的”“了”“也”“我们”这类词语它们在所有类别的文章里都高频出现对分类几乎没有区分度但却会稀释真正有区分度的词语的频率占比。比如“体育”类文章中出现“球队”10次“的”出现50次如果不做过滤模型会误以为“的”比“球队”更能代表体育类。我用的是哈工大停用词表同时手动加了一批新闻语料里常见的虚词。分词时只用jieba默认模式不加载自定义词典——项目初期保持简单后续需要再扩展。3. 核心实现朴素贝叶斯分类器从零手写3.1 模型代码结构设计先想清楚接口再动手许多初学者一上来就写一堆函数写到一半发现逻辑混乱。我这次先设计了分类器的接口再填充实现class NaiveBayesClassifier: def __init__(self, alpha1.0): self.alpha alpha self.classes [] self.class_prior {} self.vocab [] self.word_prob {} def fit(self, X, y): pass def predict(self, X): pass def predict_one(self, x): passfit方法接收两个参数X是分词后的文档列表每个文档是词列表y是对应的类别标签列表。predict接收待预测的文档列表返回预测类别列表。predict_one处理单条文档。内部数据结构的设计逻辑class_prior字典键是类别值是该类别的先验概率。word_prob嵌套字典第一层键是类别第二层键是词值是平滑后的条件概率。vocab所有训练集中出现过的词列表用于条件概率计算时的分母。这个设计的好处是接口清晰后续想换成伯努利模型或者加入TF-IDF权重都不需要大改外部调用方式。3.2 分词与特征构建从文章到词袋向量分词这一步是整个中文NLP流程里最影响最终效果的因素之一。我用的jieba.cut默认精确模式返回一个生成器需要转成列表import jieba import re def tokenize(text): text re.sub(r[^\u4e00-\u9fa5], , text) words [w.strip() for w in jieba.cut(text) if w.strip()] return words正则[^\u4e00-\u9fa5]会匹配所有非中文字符包括数字、英文、标点、空格全部替换成空格。之所以要把数字和英文直接去掉是因为新闻语料里数字如“2024”“100万”在不同类别里都可能出现对分类没有区分价值英文缩写如“AI”“NBA”虽然有一定区分度但会降低特征的纯净度而且中文分词器对混合中英文的文本支持一般。训练阶段需要构建词典。我采用的做法是统计全语料的词频过滤掉出现次数小于2的词然后按词频降序截断前5000个词作为特征词典from collections import Counter word_counter Counter() for words in train_docs: word_counter.update(words) min_df 2 max_features 5000 filtered_words {w: c for w, c in word_counter.items() if c min_df} top_words [w for w, _ in filtered_words.most_common(max_features)]过滤低频词的原因是某个词只在训练集里出现一次很可能只是偶然拼写或人名模型记住它反而会过拟合。截断特征数则为了控制计算规模5000维已经能覆盖绝大多数新闻类文本的常用词。这里我特别说明一个细节特征表示用词袋模型而不是TF-IDF。朴素贝叶斯的条件概率本身就是基于词频统计估计的TF-IDF会改变词频分布反而破坏了原模型假设。TF-IDF一般配合支持向量机等非概率模型使用。3.3 训练阶段先验概率与条件概率的计算细节训练阶段要做两件事估计每个类别的先验概率估计每个词在每个类别下的条件概率。先验概率的计算逻辑没有任何技巧就是统计各类别文档数占总文档数的比例import numpy as np def fit(self, X, y): self.classes np.unique(y) total_docs len(y) for c in self.classes: docs_in_class [X[i] for i in range(total_docs) if y[i] c] self.class_prior[c] len(docs_in_class) / total_docs条件概率的计算是重点也是容易出错的地方。对每个类别遍历该类所有文档统计每个词出现的总次数然后计算每个词的条件概率from collections import defaultdict def fit(self, X, y): self.classes np.unique(y) total_docs len(y) vocab_size len(self.vocab) for c in self.classes: docs_in_class [X[i] for i in range(total_docs) if y[i] c] self.class_prior[c] len(docs_in_class) / total_docs term_freq defaultdict(int) total_terms 0 for doc in docs_in_class: for word in doc: if word in self.vocab: term_freq[word] 1 total_terms 1 self.word_prob[c] {} for word in self.vocab: self.word_prob[c][word] (term_freq[word] self.alpha) / (total_terms self.alpha * vocab_size)分母total_terms self.alpha * vocab_size里的vocab_size是关键它保证所有词的概率之和等于1这是概率分布的基本约束。很多实现写错分母少乘一个词典大小最后得到的概率分布不合法预测结果自然不对。total_terms是所有词出现次数的总和不是文档数量。我第一次写的时候不小心用了文档数结果所有条件概率都比真实值小了好几个数量级分类结果一塌糊涂。出现这个问题的排查方法是把某个类别的所有word_prob加起来看是否等于1不等就是代码错了。3.4 预测阶段对数概率解决下溢问题预测一条新文档时要计算它在每个类别下的得分选出得分最高的类别作为预测结果。根据贝叶斯定理score(c) log(P(c)) Σ log(P(w|c))这里使用对数概率而不是直接乘概率是必须的而不是什么花哨技巧。原因很简单一篇文档有几百个词每个词的条件概率通常在0.001到0.01之间几百个这样的数连乘结果会变成1e-100级别的小数浮点数精度直接被击穿出现下溢。取对数把连乘变成连加就能完美避免这个问题。我实现的predict_one方法import math def predict_one(self, doc): scores {} for c in self.classes: score math.log(self.class_prior[c]) for word in doc: if word in self.word_prob[c]: score math.log(self.word_prob[c][word]) scores[c] score return max(scores, keyscores.get)预测时如果遇到词典外的词直接忽略。因为这个词在训练阶段没有进入词典也没有对应的概率忽略处理加一个小平滑能让代码更稳健但我实测下来直接跳过影响不大。对数公式里还有一个隐含问题如果某个词在某个类别下从未出现过平滑前的概率就是0对数为负无穷整个类别的得分变成负无穷。所以平滑处理在预测环节同样重要。3.5 拉普拉斯平滑系数alpha选1.0还是选0.1拉普拉斯平滑的公式如下P(w|c) (词频 alpha) / (该类总词数 alpha × 词典大小)alpha的默认值通常取1.0也就是经典拉普拉斯平滑。它保证了词典里每个词在任何类别下都有非零概率避免因为个别词没在训练语料中出现过导致整个类别被判死。我在实验中对比了几组alpha取值alpha准确率观察结论0.010.841几乎没有平滑遇到未登录词容易给零概率0.10.853略有提升1.00.862默认值的表现已相当稳定100.847平滑过度各类别概率趋同区分度下降从数据来看1.0确实是个好默认值。alpha太大所有类别的概率分布都被拉向均匀等于抹掉了词与类别之间的对应关系alpha太小又起不到防止零概率的作用。如果你发现模型对训练集里的罕见词过度敏感可以适当调大alpha试试。3.6 模型评估准确率、精确率、召回率与F1分类模型的评估不能只看准确率尤其在类别不平衡时。我用的评估方法来自scikit-learnfrom sklearn.metrics import classification_report, confusion_matrix y_pred model.predict(test_docs) print(classification_report(y_test, y_pred, target_namesmodel.classes))classification_report会列出每个类别的精确率、召回率、F1值以及宏平均和加权平均。输出类似precision recall f1-score support 体育 0.92 0.88 0.90 96 财经 0.85 0.91 0.88 102 科技 0.80 0.82 0.81 98 健康 0.83 0.77 0.80 99精确率是“分到这个类别的样本里有多少是真属于这个类别”衡量的是误报情况召回率是“真实属于这个类别的样本里有多少被正确找回来”衡量的是漏报情况。F1是两者的调和平均综合反映性能。只看准确率会掩盖一个问题假设健康类文档特别少模型把所有文档都预测成体育准确率可能也有70%但健康类的召回率就是0。所以在看指标时我一般先看三个数字宏平均F1、加权平均F1、混淆矩阵里的对角元素比例。4. 实验记录与结果分析参数调优的全过程4.1 实验设计与复现设置为了保证实验可复现训练集和测试集划分时必须固定随机种子from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( texts, labels, test_size0.2, random_state42, stratifylabels )test_size0.2表示测试集占20%stratifylabels确保划分后各类别比例与原数据集一致防止某些类别在被测试集中意外缺失。random_state42让多次运行得到相同结果这一点在对比不同参数效果时非常重要——如果随机种子不固定两次实验的语料划分都不同结果差异就无法归因于参数改动。4.2 基线结果与逐类分析第一次跑通模型时分类准确率在0.84附近。这个数值在四分类任务中不算高但作为无调优版本的基线是合格的。逐类别看体育类效果最好F1接近0.90。原因是体育类文本有大量强特征词汇——“比分”“进球”“联赛”“冠军”——这些词在其他类别中几乎不出现模型很好学。财经类次之但误报较多有相当一部分科技类文章被预测成了财经类。再看错误案例有一篇科技类新闻内容讲某公司发布新品重点描述了产品价格、销售额和市场份额。标题看着像科技内容里却反复出现“公司”“发布”“售价”“市场”这些财经高频词。朴素贝叶斯只看词频统计没有语义理解能力自然容易分错。这种错误在简单模型里非常典型。4.3 参数调优实验记录过滤词、截断词数和平滑系数我做了三组对照实验。第一组停用词表是否启用的对比。不停用词的版本准确率0.82启用后0.86。提升接近4个百分点原因是高频虚词严重干扰了类别判断尤其是出现“在体育频道”“在财经报道”这类短语时“在”字都会给财经和体育同时加分。第二组词典截断词数的影响。分别测试2000、5000、10000个词截断词数准确率训练时间20000.845不到1秒50000.862约1秒100000.858约1秒从2000到5000有明显提升5000到10000反而下降。原因是过多低频词进入词典带来了噪声模型学到了一些只在单条文档中出现过的词造成过拟合。这验证了截断特征数量的必要性。第三组拉普拉斯平滑系数。上一节表格已经展示过alpha1.0是最优。平滑系数不影响计算性能只影响概率分布。这三组实验做完后我最终确定的参数组合是启用停用词表、最小词频2、截断词数5000、alpha1.0。最终准确率0.878宏平均F1为0.87。4.4 混淆矩阵分析找具体错误类别confusion_matrix输出的4x4矩阵能直观展示类别间的混淆情况。我的实验中最大的错误流量来自“科技→财经”和“健康→科技”。“科技→财经”错误的根源是财经语料里包含大量“公司”“投资”“市场”科技类新品发布新闻也爱用这些词。改进方向的其中一个思路是增加类别专属词典——“算法”“人工智能”“代码”“芯片”这些词要出现在科技类文档里模型才能更坚定地把它们分对。“健康→科技”的错误更有意思健康类文档经常出现“研究”“发现”“数据”这些词而科技类也经常用这些词描述科研成果。技术上可以尝试使用bigram双词组合特征比如“为了提高分类效果也可以直接判断“研究发现”“临床数据”这样的组合而非单个“研究”“发现”。这是朴素贝叶斯很容易扩展的增强方向。5. 踩坑记录与优化方向平均每个坑都值得写三行注释5.1 中文编码问题GBK、UTF-8与BOM的高级纠缠中文文本处理最基础也最容易翻车的就是编码问题。我第一次读取CSV数据时直接用pd.read_csv(news_data.csv)pandas默认用系统编码解析在中文Windows下大概率默认GBK读取正常但在Linux服务器上默认UTF-8读同一个文件直接报UnicodeDecodeError。解决方式很粗暴统一把CSV文件另存为UTF-8 with BOM格式读取时指定encodingutf-8-sig。如果是他人提供的GBK文件可以先用文本编辑器转一次编码或者用encodinggbk读取后输出为新文件。编码问题看起来简单但一旦在训练中途才报错排查成本很高我推荐在进入模型训练前先加一段编码检测逻辑with open(news_data.csv, rb) as f: raw f.read() if raw.startswith(b\xef\xbb\xbf): print(UTF-8 with BOM) else: # 尝试多种编码 for enc in [utf-8, gbk, gb18030]: try: raw.decode(enc) print(fencoding: {enc}) break except UnicodeDecodeError: continue5.2 维度爆炸与内存占用从几万维到五千维如果不做词频过滤整个训练集的全部独立词数量可能超过3万个。对每个类别保存一个长度3万的概率向量四个类别就是12万个浮点数听起来不多但每次预测文档时都要遍历所有词的概率速度明显下降。更严重的问题是特征稀疏。一篇几百字的文章转成几万维的词袋向量其中只有几十个维度是非零的这不仅是内存浪费还会导致很多词在单个类别下只出现过一两次概率估计不可靠。我采用“最小词频截断词典”的组合策略效果立竿见影。词频小于2的去掉然后按词典顺序截断到5000维内存占用减少到原来的六分之一分类准确率反而提升了。这在机器学习里是个常见规律减少特征数量往往能提升泛化能力。5.3 类别不平衡先验概率的公平与偏见语料中四个类别文档数如果差别大朴素贝叶斯的先验概率会显著偏向样本量大的类别。我测试过一个小实验把体育类从500篇缩减到100篇其他类保持500篇测试时体育类的召回率从0.88掉到0.61。原因就是体育类先验概率大幅下降预测得分被其他类压制。对类别不平衡的处理有几种思路数据层面对少数类做上采样重复采样或对多数类做下采样让各类别样本数接近。算法层面在计算先验概率时手动调整权重比如给少数类乘一个加权系数。评估层面使用宏平均F1而不是准确率作为主要指标。我在实验中采用了下采样宏平均F1的策略把每类文档数统一到300篇分类效果比原数据更加均衡。代价是整体数据集变小模型对少数类特征的学习更充分换来了更平衡的预测表现。5.4 标点符号与英文混排正则清洗的边界jieba分词器对中文标点、英文标点混合的文本处理效果一般。我在清洗阶段直接用正则过滤掉非中文字符包括英文AI、NBA等缩写也一并清除。这样做的代价是丢掉了一些可能很有区分度的特征比如“NBA”明显是体育类的强特征词。权衡之后我的处理方案是中文为主保留少量常见英文缩写。具体做法是在正则中保留部分英文字母组合text re.sub(r[^\u4e00-\u9fa5A-Za-z\u4e00-\u9fa5], , text)但随后我发现这样做又引入了新的问题英文单词会被jieba切分成单个字母或乱码片段导致特征很脏。最终我还是回到最简单暴力的方案只保留中文。对于这个四类新闻分类任务去掉英文后效果几乎没受影响反而让特征更干净。如果你做的是科技类文本英文术语含量高可以考虑在分词后手动合并英文缩写或者用分词器的默认英文处理能力。5.5 手写模型与sklearn实现的结果对比作为验证我用sklearn.naive_bayes.MultinomialNB跑了一遍同样的数据和参数得到的结果与我手写模型几乎一致准确率差异在0.5个百分点以内。这说明手写实现的核心逻辑是正确的。这个对比实验非常有价值。很多同学担心自己手写的模型和标准库有差异实际上标准库无非也是这些数学步骤的工程化封装。跑通对比之后我对朴素贝叶斯的理解明显上了一个层次——不是会用库而是明白库里面到底做了什么计算。6. 扩展方向这个项目还能怎么玩项目做到这里作为学习笔记已经完整了。但朴素贝叶斯本身还有很多可以深挖的方向。一个方向是从多项分布模型切到伯努利模型。多项分布模型统计词频伯努利模型只统计词是否出现两者适用的场景略有不同。对短文本分类伯努利模型有时效果更好对长文本新闻分类多项分布模型更合适。可以把两种实现都写上对比一下它们在当前语料上的表现。另一个方向是引入二元词袋bigram特征。记得之前提到的“健康→科技”混淆问题吗如果特征从单个词变成“研究”“发现”的组合模型就能学习“临床”“数据”这类词组捕捉到更多上下文信息。当然bigram会大幅增加词典大小需要更激进的截断策略。还可以把模型换成TF-IDF加权配合支持向量机对比朴素贝叶斯的效果差距。我当时试过支持向量机在同样的5000维特征上准确率比朴素贝叶斯高约2个百分点但训练时间也多了不少。对于新闻分类这类任务2个百分点的提升是否值得那么多训练成本需要根据实际场景权衡。我个人在实际操作中的体会是做文本分类项目的关键不是模型有多复杂而是整个数据链路是否通顺。朴素贝叶斯十几行核心代码就能写完真正消耗时间的反而是分词、清洗、调停用词表、排查编码错误这些环节。这个项目做完之后再看任何跟NLP相关的任务我都不会再对数学公式产生恐惧——毕竟连贝叶斯定理都能亲手写成代码了还有什么模型是不能理解的
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑