资讯动态

朴素贝叶斯代码实现:从手写原理到sklearn垃圾邮件分类实战

发布时间:2026/9/9 6:52:31 来源:尧图企业网站定制
搞懂朴素贝叶斯的代码实现其实只需要把三件事弄清楚先验概率怎么算条件概率怎么存预测的时候怎么拼。很多教程把公式推来推去换到代码里就不知道怎么落地我在给团队做机器学习内训的时候也发现这个算法属于“看着简单一写就出错”的典型。这篇就把我自己常用的多项式朴素贝叶斯实现思路、以及用sklearn做垃圾邮件分类的完整流程一起梳理出来从手写版本到工程版都能直接拿去改。1. 朴素贝叶斯原理复习代码不会写通常是公式没吃透1.1 贝叶斯定理到底在算什么朴素贝叶斯的理论基础是贝叶斯定理公式写出来长这样P(y|x) P(x|y) * P(y) / P(x)其中P(y)是先验概率P(x|y)是似然概率P(y|x)是后验概率。分类的任务就是给定一个样本x找出让P(y|x)最大的那个y。在文本分类的场景里x是一封邮件的全部内容y是“垃圾邮件”或“正常邮件”这两个类别。直接算P(x|y)是几乎不可能的事因为x包含的词组合太多了根本没有足够的数据去准确估计每种组合的概率。朴素贝叶斯的关键假设就是特征之间条件独立。说得直白一点就是一封邮件里出现“中奖”和出现“免费”这两个词在处理时当作互不影响各自独立对“这是垃圾邮件”这个判断做贡献。这个假设在现实中往往不成立但它把问题复杂度从指数级降到了线性级而且实际效果出奇地好。1.2 三种朴素贝叶斯变体的适用场景sklearn里最常用的朴素贝叶斯有三个很多新手一上来就MultinomialNB其实选错变体是导致模型效果差的常见原因。高斯朴素贝叶斯GaussianNB特征值服从正态分布的连续型数据比如身高、体重、温度这类数值。多项式朴素贝叶斯MultinomialNB特征是离散型的计数数据最典型的就是词频、TF-IDF值。文本分类首选它。伯努利朴素贝叶斯BernoulliNB特征是布尔值就是“出现/不出现”这种二值特征适合短文本或关键词是否存在的场景。选模型之前先看特征形态这个习惯比背任何API都有用。垃圾邮件分类的特征是词频或TF-IDF向量所以MultinomialNB是主力选手。1.3 合适场景和不合适场景朴素贝叶斯真正有优势的场景有三个特征特征维度高、数据量中等偏上、特征之间独立性相对好。文本分类、垃圾邮件过滤、情感极性判断、新闻自动分类都符合这些特征。不适合的场景也很明显特征之间强相关的数据比如图像像素点、股票时序数据、用户行为序列这些问题用树模型或神经网络会靠谱得多。还有一类情况是训练数据非常少先验和条件概率估计不准这时朴素贝叶斯也容易翻车。2. 环境和数据准备没有靠谱的数据集代码写得再漂亮也没用2.1 环境依赖建议我用的是Python 3.10sklearn版本2.1.2pandas 2.0以上。装环境时建议直接一把安装pip install numpy pandas scikit-learn jiebajieba是中文分词用的处理英文文本可以不用装。sklearn版本超过1.0之后API变化不大但建议至少装1.1以上否则Pipeline和交叉验证的某些新特性体验不到。需要注意一点numpy和sklearn版本要兼容不要一个装得太新一个装得太旧否则经常出现导入报错白白浪费时间。2.2 垃圾邮件数据集怎么准备做朴素贝叶斯文本分类最经典的就是垃圾邮件识别。数据可以直接用UCI的SMSSpamCollection数据集一共5574条短信标注为spam和ham两类。这是个开源的公开数据集很多教程都在用。如果你网络环境不方便下载也可以自己快速构造一小批数据来验证流程。但真实项目里数据质量永远比模型复杂度重要。我在实操中会用完整数据集跑通整个流程再考虑在真实业务数据上做增量训练。数据读进来之后通常要做三步预处理去重、去空、打标签垃圾1正常0。还有一个容易被忽略的动作——检查类别分布是否极端不平衡。垃圾邮件和正常邮件的比例如果差太多准确率就会虚高后面要用PR曲线或混淆矩阵来观察真实效果。2.3 手写版和sklearn版的分工这篇会写两个版本第一个是自己的“裸”实现只依赖numpy和python字典用来理解算法内部机制第二个是sklearn版用Pipeline封装适合实际跑任务。两个版本应该得到几乎相同的预测结果这本身就是一种验证手段。3. 手写朴素贝叶斯把公式一句一句翻译成代码3.1 训练阶段要统计什么朴素贝叶斯的训练过程不是“学习权重”而是“数数字”。我们需要统计以下三类信息每个类别出现的文档数量用来算先验概率P(y)每个类别中每个单词出现的总次数总词表大小用于拉普拉斯平滑理解了这三件事代码逻辑就是一个统计循环的事。当预测时拿到一篇新文本我们把它切词之后对于每个类别累加每个词在对应类别下的对数条件概率再加上该类别的对数先验概率最后选出得分最高的类别。这里用对数概率很关键因为多个词概率相乘的结果会非常小小到浮点数直接下溢变成0用对数可以把乘法变成加法既避免了精度问题数值更稳定计算也更快。3.2 拉普拉斯平滑为什么不能省如果一个词在训练集中某个类别下没出现过那么这个词在该类别下的条件概率就是0。任何一篇包含这个词的文本无论其他证据多强最终该类别得分都会被乘成0。这在文本分类里是致命问题因为词表之外的新词随时会出现。拉普拉斯平滑的公式很简单P(word|class) (count(word, class) alpha) / (total_words_in_class alpha * vocab_size)alpha通常取1.0也叫做加一平滑。它保证每个词在任意类别下都有一个不为零的基础概率。这个细节手写实现时特别容易漏漏掉之后模型准确率会急剧下降因为测试集里总有新词。3.3 手写完整代码与关键点讲解import numpy as np from collections import defaultdict class NaiveBayesText: def __init__(self, alpha1.0): self.alpha alpha self.class_log_prior {} self.word_log_prob {} self.vocab set() self.classes [] def fit(self, X, y): # X是已经切好词的列表每个元素是一篇文档的词列表 # y是标签列表 docs_count len(y) self.classes list(set(y)) # 1. 统计先验概率 class_counts defaultdict(int) for label in y: class_counts[label] 1 for c in self.classes: self.class_log_prior[c] np.log(class_counts[c] / docs_count) # 2. 统计每个类别下的词频 class_word_count defaultdict(lambda: defaultdict(int)) class_total_words defaultdict(int) for tokens, label in zip(X, y): for word in tokens: self.vocab.add(word) class_word_count[label][word] 1 class_total_words[label] 1 # 3. 计算带拉普拉斯平滑的条件概率对数 vocab_size len(self.vocab) for c in self.classes: total class_total_words[c] self.word_log_prob[c] {} for word in self.vocab: count class_word_count[c].get(word, 0) # 拉普拉斯平滑公式 self.word_log_prob[c][word] np.log( (count self.alpha) / (total self.alpha * vocab_size) ) def predict(self, X): results [] for tokens in X: scores {} for c in self.classes: # 先验概率作为初始得分 score self.class_log_prior[c] for word in tokens: if word in self.vocab: score self.word_log_prob[c][word] scores[c] score results.append(max(scores, keyscores.get)) return results这个实现训练的核心就是三个统计步骤统计类别数量得到先验概率对数统计每个类别下每个词的频次然后把条件概率对数算好存进字典。预测时对每个测试样本遍历所有类别把命中词的条件概率对数累加。这里用的是累加而不是累乘因为取了对数。一个小细节如果某个词不在词表里直接跳过而不是报错。因为拉普拉斯平滑已经覆盖了未登录词的情况但预测时词表中没有的词本身就是低频词跳过它对结果影响不大。3.4 用手写版跑一个小实验先用一个小数据集验证逻辑是否正确X_train [ [免费, 领取, 红包], [你好, 明天, 开会], [点击, 链接, 中奖], [项目, 进度, 确认], ] y_train [spam, ham, spam, ham] model NaiveBayesText(alpha1.0) model.fit(X_train, y_train) X_test [ [免费, 中奖], [明天, 开会], ] print(model.predict(X_test))理论预期是第一条预测为spam第二条预测为ham。这个简单的测试能在几十秒内验证手写实现的正确性比直接拿去跑大数据集再回来调bug效率高得多。4. sklearn实战垃圾邮件分类任务完整流程4.1 CountVectorizer还是TfidfVectorizersklearn做文本分类第一步一定是把文本转成数值向量。两个最常用的向量化器是CountVectorizer和TfidfVectorizer。CountVectorizer统计的是词频每个文档表示为一个向量第i维就是第i个词在文档中出现的次数。TfidfVectorizer在词频的基础上乘上了逆文档频率也就是说在所有文档中频繁出现的词会被降权而在少数文档中出现的词会被提权。文本分类场景里TfidfVectorizer通常略优于CountVectorizer因为它天然削弱了“的”、“是”、“了”这类停用词的干扰。但到底谁更好最好在两个上分别跑一遍用测试集对比。用TfidfVectorizer时需要注意一个参数ngram_range。只用单个词1,1会丢失词组信息比如“不”和“不好”语义完全不同。我在实践里常用(1,2)即单个词加两个词的组合效果比较均衡计算开销也不算大。4.2 Pipeline的好处是省心Pipeline可以把向量化和分类模型串成一条流水线训练时自动把文本转成向量再喂给分类器预测时对测试文本做同样的转换避免你手动记着要对测试集做和训练集完全一致的处理。一个经常踩的坑就是手动处理时在测试集上重新fit了TfidfVectorizer导致训练集和测试集的特征空间不一致模型效果崩盘。用Pipeline从机制上杜绝这个问题。4.3 垃圾邮件分类完整代码import re import numpy as np import pandas as pd import jieba from sklearn.model_selection import train_test_split from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import Pipeline from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix, classification_report # 1. 读取数据 # 数据集大概长这样第一列是标签(spam/ham)第二列是短信文本 df pd.read_csv(SMSSpamCollection, sep\t, headerNone, names[label, message]) df[label] df[label].map({spam: 1, ham: 0}) print(df[label].value_counts()) # 2. 中文分词处理 def tokenize(text): # 去除非中英文字符再分词 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9\s], , text) words jieba.lcut(text) # 过滤掉单字和空白 return .join([w.strip() for w in words if w.strip() and len(w.strip()) 1]) df[message_clean] df[message].apply(tokenize) # 3. 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( df[message_clean], df[label], test_size0.2, random_state42, stratifydf[label] ) # 4. 构建Pipeline model Pipeline([ (tfidf, TfidfVectorizer(ngram_range(1, 2), max_features5000)), (nb, MultinomialNB(alpha1.0)) ]) # 5. 训练和预测 model.fit(X_train, y_train) y_pred model.predict(X_test) # 6. 评估 print(准确率:, accuracy_score(y_test, y_pred)) print(精确率:, precision_score(y_test, y_pred)) print(召回率:, recall_score(y_test, y_pred)) print(F1:, f1_score(y_test, y_pred)) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred))这里有几个细节值得展开说一下。第一步导入了pandas用来读数据可以直接把tab分隔的文本文件读成DataFrame。mapper将spam和ham映射成1和0分类器只能处理数值标签。这个数据集默认没有中文如果直接拿英文短信文本做demo分词那步可以省略并直接用空格切分英文单词效果也差不多。我保留了jieba分词是为了说明通用流程。TfidfVectorizer里的max_features设成5000是有讲究的。特征太多会增加训练时间更重要的是很多低频词对分类没什么帮助还容易造成过拟合。5000到10000是一个对短文本分类比较稳的区间。如果你做长文本分类可以适当放大这个数。4.4 评估指标不要只看准确率垃圾邮件识别场景里最能反映模型好坏的是召回率Recall和精确率Precision它们比准确率敏感得多。召回率低意味着大量垃圾邮件漏掉了垃圾邮件混进收件箱用户会被烦死精确率低意味着很多正常邮件被误判成垃圾邮件用户的重要邮件被吞这是更不能接受的。所以这个场景通常更看重精确率。如果精确率太低宁可牺牲部分召回率也要保证正常邮件不被误杀。classification_report输出的三行指标精确率、召回率、F1值要对照着看。如果precision和recall差距大说明模型偏向某一边可以调整MultinomialNB的alpha参数或者换模型。5. 实战中常见的坑与排查记录5.1 中文文本的分词问题拿到中文短信数据分词是绕不开的一步。用jieba就能做基本分词但要注意jieba.lcut()默认会把一句话切成一堆词切分后的噪声词比如单个标点、单字最好过滤掉否则它们会进入TF-IDF特征稀释真正的关键词。我在跑这个项目的过程中发现把re.sub清洗和jieba分词串起来做成一个函数是最靠谱的方式。清洗表达式里的\u4e00-\u9fa5代表中文字符范围保留中文、英文字母和数字去掉所有标点符号和emoji。如果数据是英文为主就保留字母和数字去掉标点。5.2 类别不平衡时先验概率失真很多真实垃圾邮件数据集里正常邮件远多于垃圾邮件比例可能是9比1甚至更高。这时MultinomialNB学出来的先验概率会严重偏向多数类预测时几乎都判为正常邮件。遇到这种情况第一选择不是加权重而是检查数据集本身是否已经过采样或欠采样。我习惯先尝试随机欠采样让两类比例平衡到大约1比1跑出来的结果通常比默认数据好很多。如果不想损失数据可以给模型加class_weightbalanced参数让少量类别获得更高权重。5.3 概率为0和浮点数溢出的处理手写实现已经用对数概率解决了下溢问题。sklearn的MultinomialNB内部也是对数变换所以一般不会出现数值问题。但如果你自己写实现时忘记拉普拉斯平滑概率相乘时会出现0一旦词表里有罕见词就是灾难。可以把拉普拉斯平滑的alpha当成超参数来调。alpha越小模型越依赖已有统计alpha越大模型对未登录词越宽容。我跑实验时alpha从0.01到10都试过大部分时候1.0就是靠谱的默认值但在特征特别稀疏的数据集上调到0.5效果反而略好。5.4 向量化参数导致维度不一致除了Pipeline之外还有一个低级的坑单独用TfidfVectorizer时如果在测试集上重新fit_transform测试集和训练集的特征维度就对不上预测时会直接报错或结果为空。正确做法是训练集用fit_transform测试集只用transform。用Pipeline之后就不用操心这个问题它自动绑定了同一个向量化器。5.5 如何调整MultinomialNB的参数MultinomialNB主要的可调参数就两个alpha平滑系数和fit_prior是否学习先验概率。fit_priorFalse意味着假设所有类别先验概率相等适用于类别严重不平衡且你不想让模型偏向多数类的情况。调这两个参数可以用GridSearchCV但这个模型训练速度很快直接手动跑几组对比也不费劲。我快速调参时一般写个for循环遍历alpha列表打印出每组测试集F1选最优的那组。6. 从手写到工程朴素贝叶斯在实际项目中还能怎么扩展6.1 在线学习和增量更新的价值朴素贝叶斯被低估的一个特性是它支持增量学习。sklearn的MultinomialNB有partial_fit方法可以分批喂数据实现真正的在线学习。这对于邮箱、客服系统这类持续产生新数据的场景特别有用——垃圾邮件的形式会不断变化旧模型不做增量更新很快就会过时。我第一次用partial_fit时踩了个大坑调用partial_fit必须传入classes参数而且这个参数一旦确定就不能改。如果你的业务里后续可能出现新类别要提前把所有可能的类别放进classes列表里。6.2 和其他模型的对比角度很多人觉得朴素贝叶斯太“朴素”了但我自己在多个文本分类比赛里实测朴素贝叶斯的F1值往往落在0.75到0.85区间而简单逻辑回归也差不多在这个区间两者差距很小。相比之下SVM和XGBoost确实能跑更高但调参成本和训练时间也高出一个量级。当数据量不大、基线还没建立时朴素贝叶斯永远是我第一个尝试的模型。它能快速给出一个可信的baseline后续用更复杂的模型替换时也方便对比提升幅度到底值不值得那些工程成本。6.3 把封装推理服务时的注意事项如果你要把训练好的模型部署成接口建议用pipeline把向量器和分类器打包成单个对象然后用joblib.dump序列化保存import joblib # 训练完成后保存 joblib.dump(model, nb_spam_classifier.joblib) # 预测阶段加载不用重新训练 loaded_model joblib.load(nb_spam_classifier.joblib) new_sms [免费领取红包点击链接查看详情] print(loaded_model.predict(new_sms))一个小提醒保存模型时最好连向量器一起保存否则单独预测时需要自己手工把文本转成向量很容易漏掉训练时的正则化或去停用词步骤。Pipeline天然解决了这个问题保存的就是完整处理流程。6.4 关于数据集迭代和效果优化的心得我在实际项目中还试过每次新增一批垃圾邮件样本后不重新训练整个模型而是用partial_fit增量更新。测试下来在线学习版本和全量重训版本的准确率差距在1%以内但训练时间从小时级下降到了秒级。这个方案特别适合每天都有大量新样本流入、QPS要求又比较高的场景。我个人的习惯是模型每周全量重训一次每天增量更新两次。这样既保证了新样本能快速生效又避免了模型太久不重训导致旧知识漂移的问题。如果有人问我要不要用深度学习模型替代朴素贝叶斯做垃圾邮件分类我的回答通常是先看看朴素贝叶斯能不能达到95%准确率如果已经达到了就先跑着。深度学习带来的提升在多数文本分类任务里并没有想象中那么大却会让上线和维护的复杂度直线上升。做工程永远先考虑投入产出比。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价