Python实现朴素贝叶斯垃圾短信分类:从原理到实战 简介本资源是一个面向高校计算机专业学生与自然语言处理初学者的期末大作业级项目基于Python实现朴素贝叶斯算法完成垃圾短信二分类任务覆盖数据预处理、特征工程TF-IDF向量化、模型训练含MultinomialNB与BernoulliNB对比、评估与可视化全流程。压缩包共135个文件含37个核心Python源码含预处理、训练、评估模块、36个编译缓存文件、14个模型参数文件.p、8个mac系统隐藏文件.DS_Store以及XML配置、JPEG/JPG示例图、JSON/MD文档等整体大小为72.15MB结构清晰便于按模块理解与复现。已有153人学习下载资源内含完整可运行代码、多模型对比脚本KNN、DT、RF、GBDT、SVM、LR等、vec_tfidf向量器及mtx稀疏矩阵存储特别适合课程设计、算法实践与NLP入门项目拓展。 期末大作业选中“Python实现基于朴素贝叶斯的垃圾短信分类”这个题目从一个过来人的角度看这个选题完全可以做得既稳又出彩。它不像深度学习项目那样需要昂贵的算力也不像爬虫项目那样容易被反爬折磨数据公开、效果明确、原理清晰是典型的“付出就有回报”的机器学习入门项目。这篇文章会把我做这个项目时的完整思路、核心代码、踩坑记录都摊出来从数据集怎么找、代码怎么写到报告里怎么把原理讲明白一条龙给你捋清楚。如果你也是被老师布置了类似题目、或者自己打算用这个题目练手又或者是想给GitHub补一个拿得出手的入门项目这篇文章的实操细节应该能帮你省掉不少试错时间。我会先讲清楚为什么选这个方案再给出能直接跑通的代码结构最后把常见报错和排查方法整理成清单读完你不仅能交差还能在答辩时把老师问住的每一个点都接住。1. 项目概述与选题思路1.1 垃圾短信分类解决了什么问题先说清楚这个项目到底在做什么。输入是一条短信文本输出是一个二分类标签spam垃圾短信或者 ham正常短信。看起来简单但它是文本分类里最有代表性的入门场景之一因为短信的特点是短、噪声大、口语化严重、类别分布不均真实世界中垃圾短信可能只占10%左右这意味着不能光靠“猜多数类”来混分数必须让模型真正抓住“免费、中奖、点击链接”这类信号。把这个任务放在期末大作业的语境里还有一个隐含要求老师希望看到你理解机器学习的完整流程而不是背一个API。所以纯调包调用sklearn虽然能出结果但报告里能写的深度有限。我当时的策略是核心算法手写实现工具库只用来读数据和做对比实验这样代码有亮点报告也有话可说。还有一点容易被忽略这个任务的错误成本是不对称的。把正常短信误判成垃圾短信比漏掉一条垃圾短信更让人恼火因为你可能错过一条重要通知。这个细节在评估阶段的指标选择里非常关键后面我会专门展开。1.2 为什么选朴素贝叶斯而不是深度学习很多同学第一反应是“现在不都流行深度学习吗能不能用BERT”我的建议是别在期末大作业里给自己挖坑。垃圾短信数据集通常只有几千条样本这点数据量根本喂不饱神经网络而且深度学习模型的可解释性差答辩时老师问一句“这条短信为什么被判为垃圾”你很难给出清晰的回答。朴素贝叶斯在这个任务上有三个天然优势。第一训练极快数据量小的时候几秒钟就能训完第二可解释性强贝叶斯定理本身就是一套清晰的概率推导每条短信的判定结果可以拆解成每个词的贡献第三在短文本分类上效果确实不错虽然它做了很强的特征独立假设但垃圾短信里的关键词往往非常集中“免费”“领取”“点击”这些词一出现概率就会被拉得很高独立假设的负面影响被削弱了。有些人可能会说“朴素贝叶斯太老了”但“老”不等于“没用”。它是生成式模型的代表和逻辑回归这种判别式模型刚好形成对照。报告里如果能写一段“为什么在数据量小、特征稀疏的场景下朴素贝叶斯仍然能和逻辑回归打得有来有回”这个项目的理论深度立刻就不一样了。1.3 期末大作业怎么从需求拆分出代码结构拿到题目不要急着写代码先把流程拆出来。一个标准的文本分类任务逃不开六个环节数据读取、数据清洗、特征提取、模型训练、预测、评估。我当时把项目目录结构设计成下面这样后来答辩时老师夸过一句“结构很清晰”。sms_spam_classifier/ ├── data/ │ └── SMSSpamCollection.txt ├── src/ │ ├── __init__.py │ ├── preprocess.py │ ├── naive_bayes.py │ ├── evaluate.py │ └── main.py ├── output/ │ └── confusion_matrix.png ├── requirements.txt └── README.md这样的好处是每一层职责单一preprocess负责把文本变成token序列naive_bayes负责训练和预测evaluate负责算指标main把整个流程串起来。老师看代码的时候不需要从头到尾读一遍只需要看main.py就能知道程序怎么运行。这个习惯在你以后写任何项目都用得上代码首先是给人看的其次才是给机器跑的。2. 数据准备与环境搭建2.1 公开数据集的下载与格式说明垃圾短信分类有一个非常经典的公开数据集叫做SMS Spam Collection来自UCI机器学习仓库很多论文和教学项目都用它。总共5574条短信其中垃圾短信约747条正常短信约4827条类别比例大约是1:6.5和真实场景比较接近适合用来做不平衡分类的讨论。下载下来是一个txt文件每行是一条样本列之间用Tab分隔第一列是标签spam或者ham第二列是短信原文。比如ham Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat... spam Free entry in 2 a wkly comp to win FA Cup final tkts 21st May 2005. Text FA to 87121 to receive entry question(std txt rate)TCs apply 08452810075over18s用pandas读取的时候有个特别容易踩的坑这个文件的编码不是常规的utf-8而是latin-1。如果直接pd.read_csv默认编码去读大概率会报UnicodeDecodeError。正确姿势是显式指定编码。import pandas as pd df pd.read_csv( data/SMSSpamCollection.txt, sep\t, headerNone, names[label, message], encodinglatin-1, ) df[label] df[label].str.strip() print(df.shape) print(df[label].value_counts())如果你拿到的是中文数据集格式可能类似CSV文件里有一列叫“短信内容”一列叫“类别”或者反过来。不同来源的编码五花八门utf-8、gbk、gb2312都有读之前先用文本编辑器打开看一眼或者用Python的chardet库自动检测编码。这一步花不了几分钟却能避免后面一大堆莫名其妙的乱码问题。2.2 Python环境与依赖安装这个项目对Python版本的要求不严格3.8以上都行我自己在3.10上跑过没有任何问题。安装依赖前先确认一下当前环境python --version pip --version如果电脑上有多个Python版本强烈建议用虚拟环境避免项目之间互相污染。我当时用的是conda直接一条命令搞定conda create -n sms python3.10 conda activate sms pip install pandas jieba scikit-learn matplotlib这里每个库都有明确用途pandas负责读数据jieba负责中文分词scikit-learn只用来做对比实验和数据划分matplotlib用来画混淆矩阵。核心的朴素贝叶斯分类器全程不依赖sklearn这样无论老师怎么问你都能说清楚每一步在干什么。有一个经验值得分享如果最终交作业时要写requirements.txt别手写版本号直接pip freeze requirements.txt虽然会把一些用不到的间接依赖也打进去但至少保证别人拿到文件后能复现环境。比版本号随便写、结果装不上要强得多。2.3 源码目录设计让老师一眼看懂的技巧目录结构上一节已经给了这里聊聊为什么这样设计以及每个文件里应该放什么。data目录放原始数据集不要把它跟代码混在一起因为数据集属于输入资源后续如果换数据集只需要替换这个目录下的文件代码不用动。src目录下划分四个模块是我反复调整后觉得最均衡的方案。preprocess.py里放文本清洗和分词函数naive_bayes.py里放朴素贝叶斯分类器的类定义evaluate.py里放准确率、精确率、召回率、F1的计算函数main.py是入口文件负责把整个流程串起来。output目录用来存放运行结果和图表比如混淆矩阵的png图片这样报告里的配图可以直接从输出目录拿。README.md的内容也很重要很多同学不重视这个文件其实它就是项目的说明书。写清楚三块内容怎么安装依赖、怎么运行代码、运行结果大概是什么样。老师如果要在你的机器上跑一遍第一个打开的就是README。我记得当时写README时还加了一句“如果解压报错file is not a zip file请重新下载压缩包并检查文件完整性”后来发现同班同学真的遇到了这个问题直接打印出来当成排查手册用。3. 核心原理与代码实现3.1 朴素贝叶斯公式只要记住三条朴素贝叶斯的核心是贝叶斯定理公式长这样P(类别|文本) P(文本|类别) * P(类别) / P(文本)用大白话解释就是已知一条短信内容求它属于某个类别的概率。右边三项分别叫后验概率、似然概率、先验概率。先验概率表示训练集里垃圾短信占多少比例似然概率表示如果是垃圾短信这段文本出现的可能性有多大。两者相乘再除以常数P(文本)就得到后验概率。这里“朴素”两个字体现在特征独立假设上假设短信里每个词的出现是相互独立的不考虑词与词之间的搭配关系。于是P(文本|类别)可以拆成每个词的概率连乘P(文本|类别) P(词1|类别) * P(词2|类别) * ... * P(词n|类别)打个比方就像医生判断感冒时把“流鼻涕”和“咳嗽”各自对感冒的贡献单独拿出来相乘不去管这两个症状之间的关联。这种假设明显不符合现实语言使用规律但实验反复证明在文本分类这种特征稀疏的场景下它仍然能取得很好的效果这就是这个算法的魅力所在。最后一条需要记住的是决策规则比较P(spam|文本)和P(ham|文本)的大小哪个大就判给哪个类别。因为两个式子分母P(文本)相同所以计算时可以直接省略这就少算一步。3.2 文本预处理清洗、分词、特殊token文本数据不能直接喂给模型得先变成词序列。这个步骤看似机械其实对最终效果影响巨大。我当时总结出四步预处理流程转小写、替换特殊内容、分词、过滤空词。英文短信的处理比较简单转小写之后用正则表达式提取字母序列就可以了。但有一个细节非常关键要把URL替换成固定占位符URL把连续数字替换成NUM。这样做的目的是减小词表规模避免“666666”和“888888”这种没有泛化能力的数字串各占一个词的位置。经过替换后模型看到的是“点击 URL 领取 NUM”这种更抽象的模式反而更容易学到垃圾短信的套路。中文短信的处理则要依赖分词工具。我把jieba集成在预处理函数里检测到文本包含中文字符时自动走中文分支否则走英文分支这样代码可以同时支持中英文数据集。import re def tokenize(text: str) - list: text text.lower().strip() if re.search(r[\u4e00-\u9fa5], text): import jieba tokens [w for w in jieba.lcut(text) if w.strip()] else: text re.sub(rhttps?://\S, URL , text) text re.sub(r\d, NUM , text) tokens re.findall(r[a-z], text) return tokens这里注意一个细节英文分支里我用re.findall提取纯字母标点符号会被自然过滤掉不需要专门做去标点操作。中文分支里标点符号怎么处理呢jieba分词后标点符号会单独成为token如果不过滤它们会进入词表既增加词表体积又没有分类价值。所以我在return之前做了一次长度和空白过滤简单粗暴地去掉所有单字标点。3.3 训练阶段一个核心类搞定先验概率与条件概率训练朴素贝叶斯的过程本质上就是数数的过程。先数每个类别有多少条短信算出先验概率再数每个词在每个类别里出现了多少次算出条件概率。代码我写成了一个类这样主程序调用起来非常干净。import math import re from collections import defaultdict class NaiveBayesClassifier: def __init__(self, alpha1.0): self.alpha alpha self.vocab set() self.prior_log {} self.cond_log {} self.oov_log {} self.classes [] def _tokenize(self, text: str) - list: if re.search(r[\u4e00-\u9fa5], text): import jieba tokens [w for w in jieba.lcut(text) if w.strip()] else: text re.sub(rhttps?://\S, URL , text.lower()) text re.sub(r\d, NUM , text) tokens re.findall(r[a-z], text) return tokens def fit(self, X, y): class_docs defaultdict(list) for text, label in zip(X, y): class_docs[label].append(text) self.classes list(class_docs.keys()) total len(X) term_freq {} class_total_terms defaultdict(int) for cls, docs in class_docs.items(): term_freq[cls] defaultdict(int) for text in docs: for token in self._tokenize(text): term_freq[cls][token] 1 class_total_terms[cls] 1 self.vocab.add(token) vocab_size len(self.vocab) for cls in self.classes: self.prior_log[cls] math.log(len(class_docs[cls]) / total) self.cond_log[cls] {} denominator class_total_terms[cls] self.alpha * vocab_size for word in self.vocab: numerator term_freq[cls][word] self.alpha self.cond_log[cls][word] math.log(numerator / denominator) self.oov_log[cls] math.log(self.alpha / denominator)训练过程里有一个必须解决的问题某个词在某个类别中从未出现时它的概率是0。如果在预测时出现这个词连乘结果直接变成0整个判断就崩了。解决办法是拉普拉斯平滑给每个词的计数加上一个固定数值alphaalpha通常取1.0。分子加alpha分母加alpha乘词表大小这样所有词的概率都大于零而且总概率归一化不变。代码里我直接把概率取了对数这个操作在下一点详细说先记住结论训练阶段存储的是log概率预测阶段只用加法不用乘法。这里的循环开销是不是很大词表大小可能几千类别只有两个所以就是两次几千词的循环运行时间几乎可以忽略不计。这也是朴素贝叶斯适合期末项目的原因笔记本上跑起来毫无压力。3.4 预测阶段对数概率避免下溢前面提到概率连乘会出问题这里展开讲。假设一条短信有50个词每个词的概率大约在0.01量级连乘之后的结果是10的负100次方已经超出了Python浮点数的可靠表示范围会直接下溢成0.0。这时候模型的分辨能力就没了所有短信的概率都是0。解决办法是取对数。由于log是单调递增函数取对数不会改变概率大小关系的比较结果却能把连乘转成连加。0.01的连乘变成对数的连加数值范围从10的负100次方变成负几百计算稳定得多。预测函数代码如下def predict_proba(self, text): tokens self._tokenize(text) scores {} for cls in self.classes: score self.prior_log[cls] for token in tokens: if token in self.cond_log[cls]: score self.cond_log[cls][token] else: score self.oov_log[cls] scores[cls] score return scores def predict(self, text): scores self.predict_proba(text) return max(scores, keyscores.get)还有一个细节预测时可能出现训练集里从未见过的词OOVOut of Vocabulary。处理方法是用oov_log这个提前算好的概率替代它的分母同样包含了alpha平滑不会出现负无穷。这个看似不起眼的处理也是答辩时老师常问的点“如果模型没见过的词怎么办”如果你能答出“用平滑后的未知词概率兜底”这一问就过了。到这里核心算法已经完整了。只需要把fit和predict组合起来配合数据读取就能跑出一个可用的分类器。4. 评估指标与调优方向4.1 准确率不等于一切命中率与误杀率的权衡模型跑完最直接的问题是效果怎么样很多人第一反应是看准确率Accuracy。但在这个任务里准确率有很强的欺骗性。还记得前面说的类别分布吗垃圾短信只占13%左右。如果模型把每条短信都判定为正常准确率也能有87%但这个模型毫无价值。所以必须引入更细的指标。我定义spam为正类那么精确率Precision TP / (TP FP)表示模型判定为垃圾的短信里真的是垃圾的比例。这个指标关注的是“误杀”问题。召回率Recall TP / (TP FN)表示真实垃圾短信里被模型找出来的比例。这个指标关注的是“漏网”问题。F1值是精确率和召回率的调和平均适合需要综合对比的场景。当时我在main.py里写了一个朴素的评估函数不用sklearn也能算def evaluate(y_true, y_pred, positivespam): tp fp fn tn 0 for true, pred in zip(y_true, y_pred): if pred positive: if true positive: tp 1 else: fp 1 else: if true positive: fn 1 else: tn 1 accuracy (tp tn) / (tp fp fn tn) precision tp / (tp fp) if (tp fp) else 0 recall tp / (tp fn) if (tp fn) else 0 f1 2 * precision * recall / (precision recall) if (precision recall) else 0 return { accuracy: accuracy, precision: precision, recall: recall, f1: f1, }在报告里不要把四个指标全列完就完事要讲清楚业务含义。我的建议是强调精确率和召回率的矛盾关系想让模型多抓垃圾短信就得把判定阈值调低这会导致一些正常短信被误杀相反想减少误杀就会漏掉一些垃圾短信。你需要在报告中说明自己更在意哪一点然后解释为什么这么选。我当时给出的理由是“短信误杀成本高于漏网成本”所以把精确率放在比召回率更优先的位置这一句话就把评估维度拉高了。4.2 混淆矩阵把错误样本打印出来看指标是数字数字背后的故事藏在混淆矩阵里。用sklearn的confusion_matrix即可生成画图用matplotlib和seaborn。如果有同学不想引seaborn直接用matplotlib的imshow也能画。from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt cm confusion_matrix(y_test, y_pred, labels[spam, ham]) print(cm)矩阵的行是真实类别列是预测类别。左上角是真正例spam正确识别为spam右下角是真负例ham正确识别为ham右上角是假负例垃圾短信被漏掉左下角是假正例正常短信被误杀。每次跑完模型我建议把预测错误的样本单独打印出来看这一步是最有价值的分析环节。我当时的经验是误杀的正常短信里有很多包含“回复TD退订”这类营销词模型把它们当成垃圾短信特征而漏掉的垃圾短信往往是那种伪装成正常对话的推广信息词表里没有明显的中奖、领奖关键词。把这些case整理进报告比单纯贴一个准确率有说服力得多。4.3 三个实用的调优技巧调优部分我推荐三个方向每一个都是可以在报告里单独开一小节写的。第一个是平滑参数alpha的扫描实验。alpha太小平滑作用弱未登录词概率极低模型容易过拟合alpha太大所有词的概率都被拉向均匀模型区分度下降。我当时从0.01、0.1、0.5、1.0、2.0、5.0、10.0里面选了几个点做实验发现alpha在0.5到1.0之间F1值最稳。这个实验本身就能说明你理解平滑机制而不是把alpha当成一个神秘数字。第二个是特征层面的改进。英文短信里垃圾短信经常用全大写单词来吸引注意力中文短信里则经常以“点击链接”“加微信”为核心动词。可以增加一些规则特征比如短信里是否包含URL、是否包含连续数字、是否包含“免费”同义词把这些作为额外的二值特征加入训练。这个思路在报告里可以写成“从纯词袋特征到规则特征的融合”能体现你对特征工程的理解。第三个是决策阈值的调整。默认以0.5作为分类阈值但朴素贝叶斯输出的是概率分数你可以把阈值从0.5改到0.6甚至0.7。阈值提高后需要更强证据才判为垃圾短信精确率通常上升召回率下降。用一段简单的循环就可以画出精确率和召回率随阈值变化的曲线这个图放在报告里非常加分它直观展示了模型的可调性。5. 常见问题与排查技巧实录5.1 zip解压报错file is not a zip file 怎么处理这个项目是以zip压缩包形式交付的所以在真正开始写代码前很多同学就卡在了解压环节。最常见的一个报错是“file is not a zip file”或者更详细的“invalid zip archive: could not find eocd”。这个eocd是zip格式的结束标记如果文件末尾找不到它说明整个压缩包不完整。什么情况下会触发这个报错我实测下来有三个主要原因。第一文件下载不完整网络中断导致压缩包只下载了一部分第二文件被改名时扩展名弄错了比如把html文件直接重命名成.zip第三某些浏览器下载时会把压缩包另存为乱序文件虽然扩展名看起来是zip但内容已经损坏。解决办法其实很简单先看文件大小如果下载下来的zip文件只有几百KB甚至几KB而原始项目应该有几十MB那基本就是下载出了问题删掉重新下载即可。如果文件大小正常但依然解压失败用Bandizip或7-Zip打开选择“修复压缩文件”功能有时候能救回来。还有一个小技巧用命令行手动解压错误信息会更详细。unzip sms_spam_classifier.zip它能明确告诉你第一个损坏点在哪。我在交付项目时也特意在README里写了这句排查建议后来真的有同学点赞说帮了大忙这种小事往往最体现工程的细致程度。5.2 数据集读取乱码与标签格式问题读完上一节你可能会以为只有zip会出问题实际上数据读取环节的坑更多。第一个坑是编码。前面说过SMSSpamCollection需要latin-1不需要去猜测直接指定就行。如果你用的是中文数据集编码通常是utf-8或gbk建议先尝试utf-8报错就换成gbk再不行用chardet检测。第二个坑是标签列带隐藏字符。有的数据集会用UTF-8 BOM格式保存第一列的标签读出来会带一个不可见字符\u feff导致字符串比较时“spam”不等于“spam”。解决方式很简单读取后统一做一次strip有时候还要把label列的编码统一。如果发现代码逻辑没问题但准确率极低先打印一下df[label].unique()看看有没有隐藏异常值。第三个坑是分隔符。SMSSpamCollection用tab分隔但有些数据集用逗号有些用分号还有短信正文里本身包含分隔符的情况。这时候不能盲目用split最好直接用pandas的read_csv传入sep参数或者用Python的csv模块明确指定分隔方式。我见过同学写一行代码用split(,)结果短信正文里有逗号直接把一条样本拆成了两行训练数据瞬间报废。5.3 分词库缺失、依赖版本与运行报错排查清单最后一个部分我把项目运行时可能遇到的报错整理成一个速查表这是我做了几次完整项目后沉淀下来的排查顺序。当你拿到源码后不知道该从哪里排错时就按这个表格一行行对照基本能覆盖90%的启动问题。报错现象可能原因解决方法ModuleNotFoundError: No module named jieba未安装中文分词库pip install jiebaUnicodeDecodeError: utf-8 codec cant decode数据集编码不匹配在read_csv中指定encodinglatin-1或gbkValueError: Expected 2 fields, saw 3分隔符使用不当检查数据是否包含Tab或逗号混用准确率只有87%左右且全是正常类评估时没有看PRF指标打印混淆矩阵查看误判分布预测时报KeyError类别标签不一致检查标签是否包含空格或隐藏字符概率为0或负数且非常大未使用对数概率改用log概率累加方式代码运行但输出乱码编码或终端字体问题设置PYTHONIOENCODINGutf-8有一个排查技巧尤其重要报错信息里的最后一行也就是Traceback的末尾往往才是真正的问题所在不要再往上翻好几屏找原因。我刚写这个项目时也有这个坏习惯报错一长就慌后来习惯先看最后一行再结合前面的上下文判断问题定位速度至少快了一倍。还有一种运行时Warning值得注意比如sklearn导入时出现的版本兼容提示。这类Warning通常不影响运行但如果强迫症犯了可以在main.py开头加一行过滤警告的代码保证输出日志干净import warnings warnings.filterwarnings(ignore)不过我建议你在交作业时保留这些Warning反而能体现你环境配置的过程和真实的运行状态答辩时老师如果问起来你还能顺势讲一句“这是seaborn和numpy的版本兼容提示不影响模型结果”显得项目是你一步步亲手跑出来的而不是临时拼凑的。本文还有配套的精品资源点击获取