☰
Python+朴素贝叶斯实现中文垃圾邮件分类:原理、流程与避坑指南
2026/9/28 2:44:16 网站建设 项目流程

简介:这是一套在个人毕业设计答辩中评审分达98分的原创项目,基于Python与朴素贝叶斯算法实现中文垃圾邮件分类,完整包含源码和数据集,适合计算机、通信、人工智能、自动化等专业学生作为期末课程设计、大作业或毕业设计参考,亦可作为初学者入门与进阶的实战案例。代码经过调试测试,确保可运行,整体结构清晰,函数划分合理,便于读者对照理解分词、特征提取、训练与预测全流程。压缩包共2000个文件,以邮件样本文本数据集为主体,辅以2个Python脚本和1个txt说明文件,可见语料已按训练/测试用途细致梳理;资源包大小约90.62MB,目录层次明确,方便按需调用。目前已有572人学习下载,对于希望快速搭建中文文本分类基线、进一步做算法改进或界面扩展的开发者,具有较好的借鉴与复用价值。

1. 用Python和朴素贝叶斯做中文垃圾邮件分类:毕业设计最值得动手的落地项目

做毕业设计最怕的选题不是难,是做完之后讲不清它在算什么。基于Python和朴素贝叶斯的中文垃圾邮件分类器恰好是这类项目里性价比很高的一条路:代码量不大、数据集好组织、原理能写到黑板上,而且训练过程有很直观的变化——从一封封乱码邮件到“可以拦住九成广告”。它的核心不是神经网络,也不依赖GPU,就是朴素贝叶斯这个经典模型,配合Python生态里的jieba分词和sklearn,把中文邮件文本拆成稀疏矩阵,再按概率判断一封新邮件是垃圾还是正常。适合想做自然语言处理方向、但不想一上来就碰深度学习的学生,也适合需要“源码+数据集”完整组合的课程设计。这篇笔记把数据整理、分词、向量化、训练和评估这条链路完整走一遍,常见翻车点也一并讲掉。

2. 朴素贝叶斯为什么适合中文垃圾邮件:先搞懂它在算什么

2.1 垃圾邮件分类本质是概率比较,不是关键词匹配

第一次做这个项目的同学容易陷入一个误区:以为要写一堆关键词规则,命中两个词就判垃圾。实际上垃圾邮件分类是个标准的二分类问题,建模目标是算“这封邮件属于垃圾类”的概率是否比“属于正常类”更大。用贝叶斯公式写出来就是:

P(垃圾|文本) = P(文本|垃圾) × P(垃圾) / P(文本)

朴素贝叶斯把文本拆成一个个词,然后强行假设这些词在给定类别后互相独立,这样P(文本|垃圾)就可以拆成每个词的条件概率连乘。这个独立性假设在中文垃圾邮件里几乎肯定不成立,“发票”和“代开”往往同时出现,但实验结果是排序常常是对的。这也是朴素贝叶斯能一直在文本分类领域存活下来的原因:粗糙的建模配上足够的样本,照样给出能用的结果。

对毕业设计来说,这个模型有三个很实际的价值。第一,可解释性强,训练完可以把每个词在垃圾类别里的条件概率直接拉出来看,“中奖”“加QQ”“点击链接”这些词的权重一目了然,答辩时不用对着黑匣子念参数。第二,训练开销小,sklearn的MultinomialNB在几千条样本上秒出结果,不挑机器,笔记本CPU完全够用。第三,对小样本友好,几百条正负样本也敢跑,而BERT那一类预训练模型在这个数据量下基本喂不动。

网上这类教学案例,从黑马那套朴素贝叶斯案例到各种二手博客上的复现,主干代码都是一样的:jieba分词 -> TF-IDF向量化 -> MultinomialNB训练。差别只出现在数据和参数上,这也是本篇笔记想讲清楚的部分。

2.2 多项式朴素贝叶斯和伯努利朴素贝叶斯:毕设默认选哪个

朴素贝叶斯家族里有两个模型在文本分类里最常见:多项式朴素贝叶斯(MultinomialNB)和伯努利朴素贝叶斯(BernoulliNB)。它们的区别不在贝叶斯公式本身,而在“特征怎么计数”。用一张表看会比较清楚:

模型特征取值对词频的处理适合场景
MultinomialNB词频计数(0, 1, 2, ...)出现次数直接参与概率计算文本长度差异不大的邮件、短信
BernoulliNB二值(出现/不出现)只记录是否出现,忽略次数短文本、关键词是否出现更重要

我的建议是中文垃圾邮件默认选多项式朴素贝叶斯,理由是邮件里“免费免费免费”这种重复是有信号价值的,伯努利模型会把这种重复信息直接丢掉。但要注意一个前提:邮件长度差异很大时,短邮件天然词频低,模型容易被长邮件带偏。毕设数据集如果来源混杂,后面我会改用TF-IDF而不是纯词频,用逆文档频率把长文本里反复出现的通用词压下去,这是多项式模型配合TF-IDF的常见组合。

还有一个细节:sklearn的MultinomialNB没有class_weight参数,不像SVM和逻辑回归那样可以直接传类别权重。样本不均衡的时候,要么在训练前对数据集做采样,要么用sample_weight给少数类样本加权。这个坑放到第4章展开。

2.3 中文分词和英文最大差异:词表构建的三个必踩坑

英文邮件按空格切词就行,中文不行,这是两个语言做文本分类的最大差异。第一道工序是分词,常用jieba,jieba.lcut返回一个list,代码层面就是一行的事,但后面三个坑会让你“代码跑通但效果稀烂”。

第一个坑是词表爆炸。中文语料不做任何清洗直接进TfidfVectorizer,特征维度能冲到几十万。这里面有大量噪声:URL、邮箱、数字、HTML标签、全角标点都会被切成莫名其妙的“词”。解决办法是在分词之前用正则清洗,把非中文、非字母、非数字的字符统一替换成空格。

第二个坑是单字词和停用词。分词结果里会有大量“的”“了”“我”“是”,这些停用词不进词表反而更好。我一般会拿一份现成的中文停用词表(CSDN或GitHub上很多txt资源),在分词后过滤一遍。单字词用长度过滤,len(word) > 1直接把单个汉字扔掉,虽然“房”“车”这类单字也有语义,但毕设场景下词表控制比保留单字更划算。

第三个坑是稀有词。整个语料里只出现过一两次的词,对分类没有泛化意义,反而把矩阵拉宽。常见做法是用TfidfVectorizer的min_df参数过滤掉文档频率过低的词,具体值后面讲。

这三个坑的处理顺序,我一般按“清洗 -> 分词 -> 过滤停用词和单字 -> 向量化参数截断”来排。顺序反过来会出问题:先分词再清洗,URL被拆成一堆乱词混进词表,后面再想捞出来就麻烦了。

3. 从源码到数据集:把毕设工程的骨架搭起来

3.1 目录结构和数据文件的组织方式

拿到一份毕设代码,先看目录。我习惯把数据、代码、结果三个目录分开,避免训练到一半发现原始数据被动过。

spam_classifier/ ├── data/ │ ├── raw/ # 原始邮件文本 │ │ ├── ham/ # 正常邮件 │ │ └── spam/ # 垃圾邮件 │ ├── processed/ # 清洗分词后的中间文件 │ └── mail.csv # 两列: label, text ├── code/ │ ├── clean.py # 清洗 + 分词 │ ├── train.py # 训练与评估 │ └── predict.py # 单封邮件预测 └── output/ ├── model.pkl # 训练好的模型 └── tfidf.pkl # 拟合好的向量化器

这个结构对应的数据流向是:raw目录里放按文件夹分类的原始邮件文本,先用脚本合并成mail.csv,再在train.py里读入处理。常见做法是邮件正文和标签用文件夹区分,以文件名作为唯一ID,避免写标签的时候弄混。如果你的数据集已经是CSV格式,第一步只有几行代码:

import pandas as pd df = pd.read_csv('data/mail.csv', encoding='utf-8', names=['label', 'text']) print(df['label'].value_counts()) # 先看一眼类别分布,确认有没有严重不均衡

read_csv的encoding是第一个隐患点,早期中文邮件语料有大量GBK编码,直接用utf-8会报错,后面单独讲。names参数是防止数据没有表头时列名错位。

3.2 文本清洗与jieba分词:最小可运行代码

写一个clean.py,核心就两个函数。用正则依次去掉HTML标签、URL、邮箱地址和数字,保留中英文和空格,然后交给jieba。

import re import jieba def clean_text(text: str) -> str: # 1. 去掉HTML标签 text = re.sub(r'<[^>]+>', '', text) # 2. 去掉URL text = re.sub(r'https?://\S+', '', text) # 3. 去掉邮箱地址 text = re.sub(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', '', text) # 4. 去掉数字 text = re.sub(r'\d+', '', text) # 5. 非中英文全部转成空格 text = re.sub(r'[^\u4e00-\u9fa5A-Za-z]', ' ', text) return text def tokenize(text: str, stopwords: set) -> str: text = clean_text(text) words = jieba.lcut(text) words = [w for w in words if w.strip() and w not in stopwords and len(w) > 1] return ' '.join(words)

每行替换规则的作用:前三个sub处理邮件里最常见的三类噪声,HTML标签在爬来的语料里几乎必然出现,URL在垃圾邮件里是强特征,但会拆成“http”“www”这类无意义碎片,邮箱同理。数字在广告邮件里大量出现,但对判断是不是垃圾没有通用价值,直接清掉。最后一条替换规则把剩下的标点、全角符号统一变成空格,防止分词时把“你好!!”粘成一个词。

jieba.lcut返回分词后的列表,列表推导式里做了四件事:w.strip()去掉空白,w not in stopwords过滤停用词,len(w) > 1丢弃单字词。注意stopwords从一个单独的txt里读取,每行一个词,用set存,判断速度远快于list。

提示:清洗和分词只做一次,把结果存成processed目录下的中间CSV,后面每次调参跑训练就不用重新分词了,能省不少时间。

3.3 TfidfVectorizer参数:min_df、max_df、ngram_range怎么设

分词完成后,每封邮件变成一串空格分隔的词。向量化这一步把文本转成数值矩阵,我选用TF-IDF而不是纯词频,理由是它同时考虑了词在当前文档里的重要性(TF)和在整个语料里的普适性(IDF)。一个词在100封邮件里反复出现,IDF会被压得很低,“的”“了”“免费”这种词不会因为出现多就喧宾夺主。

from sklearn.feature_extraction.text import TfidfVectorizer tfidf = TfidfVectorizer( ngram_range=(1, 2), # 保留单个词和相邻两个词 min_df=2, # 至少在2篇文档里出现 max_df=0.6, # 出现在超过60%文档里的词不要 sublinear_tf=True, # TF用1+log(tf),压缩高频词 max_features=10000 # 词表上限,防止维度过高 ) X = tfidf.fit_transform(tokenized_docs)

四个关键参数的理解方式:min_df=2过滤只出现一次的稀有词,这类词没有泛化意义还把矩阵拉宽;max_df=0.6过滤掉过于常见的词,这类词区分度太低,中文语料里可能是“邮件”“回复”这类通用词;ngram_range=(1,2)在保留单词的同时把相邻两词也收进特征,“中奖”和“免费”连在一起比单独出现更能判断垃圾邮件;sublinear_tf=True把原始词频做对数压缩,因为一封长邮件里同一个词出现20次和出现5次的信息量差异没有数字上那么大。max_features=10000是保险栓,min_df和max_df没拦住的噪声还能靠词表上限兜底。

拟合完看一眼X的shape,维度在一万以内是正常的。如果只有几千维说明语料太单一,如果直接顶到上限10000,回头查清洗和过滤环节是不是漏了什么。

提示:fit_transform只允许用在训练集上。测试集和之后任何一封新邮件都必须用transform,让词表保持训练时的样子,否则会把测试集信息混进训练过程,这就是第4章要讲的泄漏问题。

3.4 训练与评估:MultinomialNB落地

训练代码不长,但切分方式直接决定评估结果可信不可信。

from sklearn.model_selection import train_test_split from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import accuracy_score, classification_report X_train, X_test, y_train, y_test = train_test_split( X, df['label'], test_size=0.2, random_state=42, stratify=df['label'] ) model = MultinomialNB(alpha=1.0, fit_prior=True) model.fit(X_train, y_train) y_pred = model.predict(X_test) print(accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred))

stratify=df['label']是必选项,它保证切分后垃圾邮件和正常邮件的比例和全量数据一致。如果不加,随机切分可能把某一类邮件大部分分到测试集,训练集类别比例失真,模型效果看起来忽高忽低。

评估指标不能只看准确率。举个极端场景:100封邮件里有80封垃圾,模型全部判垃圾,准确率已经有80%,但这个模型没有任何区分能力。classification_report里每一类的precision、recall、f1-score才是答辩时要盯住的三列。垃圾邮件这一类的recall代表“真正的垃圾邮件被拦下来多少”,如果这个值低于0.8,说明大量垃圾会漏进收件箱。

如果想在答辩里多讲一层,把训练好的模型和TF-IDF向量化器都存成pkl文件,写一个predict.py接收一封新邮件文本,走“清洗 -> 分词 -> transform -> predict_proba”的流程输出概率值,这比只跑一次训练脚本完整得多。

import joblib joblib.dump(model, 'output/model.pkl') joblib.dump(tfidf, 'output/tfidf.pkl')

predict阶段的核心代码是model.predict_proba(new_vec)[:, 1],拿到的是“这封邮件属于垃圾类”的概率,后面调阈值会用到这个值。

4. 中文垃圾邮件分类器避坑实录:编码、脏样本与过拟合排查

这一章写的都是我自己踩过、以及帮别人排查毕设代码时见过的坑,每一条都让训练“看起来成功但结果不对”。

4.1 现象一:读CSV直接报UnicodeDecodeError

症状:pd.read_csv('mail.csv', encoding='utf-8')刚执行就抛UnicodeDecodeError,或者读进来后文本里全是“鍒嗘瀽”这类乱码。

原因:公开的中文邮件语料年份较老,很多数据在GBK或GB2312里存储,部分语料还是GBK和UTF-8混合编码。同一个CSV里前一百行是UTF-8,后一百行是GBK的情况并不少见。

解决:先用errors='ignore'把文件读进来再说,让pandas忽略非法字节:

df = pd.read_csv('data/mail.csv', encoding='utf-8', errors='ignore')

读进来后抽样看text列,如果整列乱码,改用encoding='gbk'重新读。如果是混合编码,先把文件用工具统一转码再合并,我一般会先跑一个探测脚本统计编码类型,再用iconv批量转换。不要在pandas里反复试编码,那是碰运气。

4.2 现象二:全角符号和HTML转义让词表爆炸

症状:训练出来的模型在测试集上准确率不错,但打印tfidf.get_feature_names_out()一看,词表里有大量“free”“&amp”“%off”这类怪词。

原因:邮件里有全角英文字母、全角数字,还有“&nbsp;”“&amp;”这类HTML转义实体。正则里如果只匹配半角字符,全角字符会残留下来,被jieba切成碎片进词表。词表里多出几千个这样的噪声词,模型就被带偏了。

解决:在clean_text的第一步加一个Unicode规范化,把所有全角字符转成半角:

import unicodedata import html text = unicodedata.normalize('NFKC', text) # 全角转半角,兼容性组合也拆开 text = html.unescape(text) # "&amp;" 转成 "&","&nbsp;" 转成空格

注意顺序:先html.unescape再normalize,否则“&amp;”被转成半角后unescape识别不了。转完再走原来的正则清洗,词表能缩掉一小半。

4.3 现象三:样本不均衡导致准确率虚高

症状:classification_report打出来,accuracy有0.96,但垃圾邮件那一类的recall只有0.55,正常邮件precision却很高。模型把大量垃圾邮件放进了收件箱。

原因:数据集中垃圾邮件远多于正常邮件,比如3比1甚至9比1。朴素贝叶斯在计算先验概率P(垃圾)时本来就会偏向多数类,数据越不均衡,模型越倾向于把不确定的样本判给多数类。

解决:MultinomialNB没有class_weight参数,两个替代方案。一是对多数类降采样,把垃圾邮件随机抽到和正常邮件相近的数量;二是用sample_weight给少数类样本加权,让模型在训练时把少数类的误判看得更重:

import numpy as np sample_weight = np.where(y_train == 'spam', 1.2, 1.0) model.fit(X_train, y_train, sample_weight=sample_weight)

权重不要拉太高,1.1到1.5之间先试,太高会把模型推到另一个极端,变成什么都不判垃圾。调完看垃圾邮件的recall是否上来,同时确认正常邮件的recall没有掉太多。评估的重心也建议放在F1值而不是准确率上。

4.4 现象四:切分泄漏,测试集信息混进训练

症状:训练集上准确率0.99,测试集上0.98,模型看起来完美,但拿几封真实新邮件去测,效果差一截。

原因:有人把向量化写成了整个语料先fit_transform,再切分训练集和测试集。这样测试集文件的特征分布已经参与了词表和IDF的计算,相当于考试前偷看了答案。最常见的错误写法是先对全量文本做TfidfVectorizer.fit_transform,再train_test_split。

解决:严格按“先切分,再分别处理”的顺序:

X_train_texts, X_test_texts, y_train, y_test = train_test_split( df['text'], df['label'], test_size=0.2, random_state=42, stratify=df['label'] ) X_train = tfidf.fit_transform(X_train_texts) X_test = tfidf.transform(X_test_texts)

fit_transform只出现在训练集上,测试集和将来上线后的新邮件全部只用transform。这个坑排查起来最隐蔽,因为指标一切正常,只有上线后翻车。

5. 答辩必问的两个理论点:特征独立假设与拉普拉斯平滑

5.1 拉普拉斯平滑alpha:设0会让整个模型挂掉

MultinomialNB的alpha参数默认是1.0,对应拉普拉斯平滑。如果不明白它的作用,答辩时被问“alpha为什么设1”就答不上来。公式层面是这样的:

P(词|类别) = (该词在类别中出现的次数 + alpha) / (类别中所有词出现总次数 + alpha × 词表大小)

alpha加在分子和分母上,作用是防止某个词在训练集中没出现过,导致概率直接算成0。朴素贝叶斯分类时要做连乘,只要有一个词的条件概率是0,整封邮件的概率就是0,这叫零概率问题。alpha=1是经典取值,它把每个词的计数都加了1,相当于给没见过的词留了一点先验空间。

如果把alpha设成0,训练集中没出现过的词在预测时就会让概率归零,测试集里只要有一封邮件带了新词,模型就直接判成概率为0的那一类。我做毕设时试过把alpha调到0.01,词表大的时候效果还行,词表小的时候很容易翻车。常见做法是先保持alpha=1.0,后面画一条alpha从0.01到10的曲线,看F1值怎么变,这个实验放答辩PPT里很有说服力。

alpha取值平滑强度常见效果
0.01几乎不平滑新词敏感,稀疏语料下容易概率归零
1.0默认拉普拉斯平滑中文邮件场景下的稳选
10强平滑概率趋向均匀,过度抹平词与词的差异

调alpha本质是在“相信数据”和“容忍未知”之间找平衡。alpha越小越依赖训练集里的词频统计,alpha越大越倾向于认为每个词出现概率差不多。垃圾邮件语料一般就是几千到几万封,这个规模下alpha=1.0基本不会出错。

5.2 特征独立假设怎么回答才不掉坑

答辩老师最可能追问的一个问题是:贝叶斯假设特征独立,但中文里词和词明显不独立,为什么模型还能用?

这个问题答得好能加分,答不好会显得只是调包。我习惯用两层来解释。第一层,朴素贝叶斯虽然概率估计有偏,但它的目标是选“概率更大的类别”,而不是精确估计概率本身。只要计算出来的排序是对的,模型就能给出正确分类。数学研究里有结论,偏差并不一定导致分类错误,这在很多实际任务里都成立。第二层,特征独立假设让训练需要的样本量大幅下降。如果考虑词与词之间的依赖关系,需要估计的参数数量是指数级的,几千条样本根本不够用。朴素贝叶斯用“粗暴拆分”换来了可行性,这是工程上的取舍。

另一个加分回答是承认假设不成立但后果可控,然后指向拉普拉斯平滑:alpha在分母上给所有词补了先验,新词、罕见词不会被一票否决,模型的鲁棒性实际上是靠平滑兜底。

6. 把分类器从“能跑”做到“能讲”:三个进阶验证技巧

训练完模型只是开始,毕业设计里真正拉开差距的是验证方法。三个技巧按投入产出比排,每个都不需要太多代码,但答辩时每个都能展开讲几分钟。

第一个技巧是自定义分类阈值。sklearn默认以0.5作为垃圾/正常的判定边界,但垃圾邮件拦截更怕漏而不是误杀。我一般会打印所有预测概率的分布,然后看预测概率在0.3到0.5之间的样本长什么样。如果这些样本里垃圾邮件居多,就把判定阈值从0.5下调到0.4甚至0.35,用predict_proba拿概率值,再自己写比较逻辑,而不是用默认的predict。这个调整直接体现在recall上,垃圾邮件的拦截率能提好几个百分点。

第二个技巧是打印“最像垃圾但判成正常”的样本。把测试集里预测类别和真实类别不一致的邮件按概率排序,打印概率最接近阈值的那十几封原文,逐条看模型漏判的原因。大部分漏判出在两类样本上:广告邮件伪装成正常聊天口吻,或者正常邮件里夹带了一个强垃圾词。前者说明该做二元词特征(ngram_range加长到(1,3)),后者说明清洗时该把这些强词单独处理。带着这些样本讲调参过程,比扔一张准确率截图可信得多。

第三个技巧是画混淆矩阵加学习曲线。混淆矩阵一张图能说清四个数:正确拦截、错误拦截、正确放行、错误放行,比报一串指标直观得多。学习曲线则展示训练集大小和F1值的关系,画出来能看到模型在多少样本后趋于平稳,这直接回应“为什么用朴素贝叶斯而不是深度学习”——在这个数据量下深度学习还没吃饱,朴素贝叶斯已经收敛了。两张图都是matplotlib十几行代码的事,放在毕设论文的实验分析章节里很加分。

我当年第一次跑这个项目,就是栽在4.4那个切分泄漏上,测出来98%的准确率高兴了一整天,拿新邮件一测才发现全是幻觉。后来老老实实把“先切分再向量化”写进自己的代码模板里,再也没犯过。做这个方向的毕设,最值得投入的时间不是把模型换得多高级,而是把数据管好、把评估做扎实。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询