朴素贝叶斯与TF-IDF实现垃圾邮件过滤:从原理到代码实战
2026/9/24 22:18:53 网站建设 项目流程

简介:基于Python实现的朴素贝叶斯垃圾邮件过滤系统,是一份面向计算机科学、人工智能、大数据等专业学生及开发者的项目源码包,可直接用于毕业设计、课程设计或初期项目演示。系统围绕邮件分类这一自然语言处理任务,实现了训练集选择、模型训练、邮件号测试、过滤精度统计及自定义屏蔽词等功能,并支持配置个人邮箱接入真实邮件进行检测,完整呈现了贝叶斯算法在垃圾邮件识别中的落地流程,便于理解特征提取、概率计算与分类决策的工程化实现。资源包共407个文件,压缩后约995KB,以Python源码(20个py)为主,配合邮件样本数据、txt说明文档和md操作指南,目录结构清晰,方便按模块查阅和二次开发。内置多组训练与测试样本,可快速上手验证,同时保留扩展空间,便于替换数据集或优化算法细节。目前已有481人学习下载,适合作为毕设参考或入门进阶实践。附带操作说明覆盖从训练到测试的完整流程,并针对邮箱配置、目录组织给出具体建议,能有效降低上手门槛。

1. 为什么是朴素贝叶斯:毕设选它,不是因为它简单,而是因为它真的能打

如果你正在为毕业设计找题目,大概率会搜到“Python实现基于朴素贝叶斯的垃圾邮件过滤系统”这类项目。第一反应往往是:这题是不是太简单了?决策树、SVM、深度学习哪个不比它听起来高级?但真把邮件数据丢进去跑一轮,你会发现朴素贝叶斯在文本分类上的表现,一点都不“朴素”。它训练快、对高维稀疏数据天然友好、可解释性强,而且即便在深度学习遍地走的今天,它仍然是工业界垃圾邮件过滤的基线方案,甚至不少商业邮件系统的核心过滤链路里,朴素贝叶斯依然占着一席之地。

这个题目的价值在于:它把自然语言处理、概率统计、文本特征工程和分类模型训练串成了一条完整链路,既不过度复杂,又足够撑起一篇毕业设计的核心工作量。适合基础一般、想稳扎稳打拿到合格分数的同学,也适合想用少量代码见识一下完整机器学习流程的入门者。

接下来,我会从原理讲到代码实现,再到参数调优和踩坑记录,把这条链路完整拆开。你会看到贝叶斯定理是怎么变成代码的,也会看到那些让你的准确率从 0.98 掉到 0.80 的问题到底出在哪。

2. 朴素贝叶斯凭什么筛选垃圾邮件:从贝叶斯定理到两种落地模型

2.1 贝叶斯定理在垃圾邮件场景下是怎么“翻译”的

朴素贝叶斯的理论核心就一行公式:

P(类别|文本) = P(文本|类别) × P(类别) / P(文本)

放到垃圾邮件场景里,这个公式要做的事是:给定一封邮件的文本内容,计算它属于“垃圾邮件”和“正常邮件”这两个类别的概率,哪个大就判给哪边。

问题在于 P(文本|类别) 怎么算。一封邮件的文本是大量词语的组合,理论上要考虑所有词语的联合概率,这几乎无法计算。朴素贝叶斯的“朴素”就体现在这里:它假设各个特征(词)之间相互独立,于是联合概率被拆解为每个词单独概率的乘积。这个假设在真实语言场景下显然不成立——"中奖"和"点击"明明经常一起出现——但大量的工程实践表明,这个简化不仅没让模型崩溃,反而在文本分类任务上表现相当稳定。

拆开来看,实际计算的其实是:

P(垃圾|邮件) ∝ P(垃圾) × Π P(词ᵢ|垃圾)

其中 P(词ᵢ|垃圾) 表示在垃圾邮件中某个词出现的概率。P(垃圾) 是垃圾邮件在整体邮件中的占比,通常叫先验概率。右侧那一串连乘,就是朴素贝叶斯分类器在训练阶段要统计的所有内容。

用一句工程师的话来概括:这个模型做的事情,就是把垃圾邮件里常出现的词的概率学下来,预测的时候数一数邮件里命中了多少高频垃圾词,再综合先验概率做判断。

2.2 选型:多项式朴素贝叶斯为什么比伯努利版更适合文本

sklearn 里有两个朴素贝叶斯变体,初学者很容易搞混:MultinomialNB(多项式朴素贝叶斯)和 BernoulliNB(伯努利朴素贝叶斯)。这两个模型的区分点,在于特征的取值形式。

MultinomialNB 的输入是词频矩阵或 TF-IDF 权重,特征值可以取 0、1、2、17 这样的任意非负整数或实数。它建模的是“某个词出现了多少次”,因此对文本的刻画更细。

BernoulliNB 则把输入二值化:词出现了就是 1,没出现就是 0。它只关心“有没有”,不关心“有多少次”。某些场景下——比如检测短文本里是否出现敏感词——伯努利模型反而更抗噪,因为它不会因为一封垃圾邮件里反复写了 10 次“点击”就给出过度极端的概率。

在垃圾邮件过滤这个任务上,我一般直接建议用 MultinomialNB。理由是垃圾邮件里有很多重复强调的营销词,词频信息本身就是很强的判别信号。"免费"出现 1 次和出现 8 次的邮件,显然威胁程度不一样。相比之下,"中奖"这种词出现一次就足够了,但这类高频词的数量级差别,靠词频矩阵能更好地被表达。

如果你用的是词袋模型并且做了 TF-IDF 转换,MultinomialNB 也是唯一合理的建模选择,因为 TF-IDF 值本身就是连续实数,BernoulliNB 强行二值化等于把 TF-IDF 的信息丢了。

2.3 拉普拉斯平滑:alpha 参数救回零概率,也救回准确率

朴素贝叶斯里有个非常经典又非常隐蔽的问题:如果某个词在训练集的“正常邮件”里一个都没出现过,但出现在“垃圾邮件”里,那么计算 P(该词|正常) 的时候就会得到一个 0。连乘之后,整封邮件被判为正常邮件的概率直接变成 0。这个现象被称为零概率问题,在多分类和样本不均衡的场景下尤其致命。

解决方法是拉普拉斯平滑,在公式里加一个平滑系数 alpha:

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

当 alpha=1 时,就是标准拉普拉斯平滑;alpha<1 时平滑力度变低,更贴近原始频率;alpha>1 时平滑力度更大,每个词的概率分布趋向均匀。

sklearn 的 MultinomialNB 里,alpha 这个参数默认就是 1.0,多数文本分类任务直接用默认值表现就不错。但在某些数据集上——比如垃圾邮件占比特别低、词表又特别大的时候——微调 alpha 能带来明显的效果波动。这个参数是垃圾邮件过滤里少有的几个有明显“手感”的超参数,后面我会专门演示它怎么调。

3. 数据准备与文本预处理:垃圾邮件判得准,七成靠这步

3.1 数据集从哪来:自带语料、公开数据集、自己攒,三条路怎么选

做毕设的第一步不是写模型,而是找数据。垃圾邮件过滤的数据集常见来源有三个:

自带语料:如果你用的是教学型项目包,里层往往会附带几百到几千封标注好的邮件文本。这类数据已经切好了训练集/测试集,预处理路径短,适合快速验证全流程。缺点是数据量小,模型指标容易虚高。

公开数据集:Enron 邮件数据集是邮件分类领域使用最广泛的真实数据之一,包含约 3.3 万封真实邮件,是难得的“干净”真实数据来源。SpamAssassin 公共语料库也常被用来做基准测试,邮件量级在几千封左右,标注清晰。用公开数据集的优势是论文里可以标注来源、对比他人结果,方便多了。

自己爬取/收集:把个人邮箱里接收的邮件导出为文件,手动标注。这个方案的坑非常多——个人邮箱垃圾邮件比例通常偏低、类别分布不均、隐私问题也绕不开。一般不推荐作为毕设数据的唯一来源,可以用作补充验证。

无论选哪条路,毕设项目里数据的组织方式通常都约定俗成:data/ 目录下分 ham/(正常邮件)和 spam/(垃圾邮件)两个文件夹,每封邮件存为一个 txt 文件。这套做法的好处是,加载数据时可以直接用 os.listdir 遍历文件夹,路径即标签,不需要额外维护一份标注表格。

3.2 预处理流程:清洗、分词、去停用词,英文中文一套代码搞定

加载完原始邮件文本后,首先要做的是清洗。邮件里有大量 HTML 标签、URL、邮件地址、数字和标点,这些东西对分类没有正向贡献,还会撑大特征空间。常见做法是用正则把它们替换成占位符或直接删除。

分词环节要区分语言。英文按空格和标点切分,也有用 nltk 的 word_tokenize 的;中文则需要 jieba 这类分词库。一个值得注意的细节是:中文场景下“免费”和“免 费”会被切成不同的词,所以分词器质量和词表大小直接影响后续效果。

停用词表需要针对邮件场景做定制。通用停用词表里有 "the"、"is"、"的"、"了" 这些词,但在邮件里还需要额外滤掉 "hello"、"dear"、"sir"、"best" 这类寒暄词。它们不携带分类信息,但对模型来说是噪声。

下面是一个同时支持中英文场景的预处理函数,可以抄下来直接用:

import re import jieba def preprocess_text(text, use_jieba=False): # 统一转小写,英文场景建议保留 text = text.lower() # 去除HTML标签 text = re.sub(r'<[^>]+>', '', text) # 去除URL text = re.sub(r'http[s]?://\S+|www\.\S+', ' ', text) # 去除邮箱地址 text = re.sub(r'\S+@\S+', ' ', text) # 去除数字和标点,只保留中英文和空格 text = re.sub(r'[^a-z\u4e00-\u9fa5\s]', ' ', text) if use_jieba: # 中文场景:结巴分词,默认精确模式 words = [w for w in jieba.cut(text) if w.strip()] else: # 英文场景:按空白切分 words = text.split() return ' '.join(words)

预处理的核心逻辑:先把所有内容统一成小写,再做标签清理,最后用空格的 join 把词序列拼回字符串。这里不直接返回 list 而是返回空格分隔的字符串,是因为后续接 CountVectorizer 的时候,它期望输入的语料是一个个文档字符串。

参数说明:use_jieba 在中文语料下置为 True,英文语料下保持 False。这个函数是统一入口,训练和预测阶段必须调用同一个预处理逻辑,否则会出现训练集是“清理过的词”,测试集还是“带标点的原文”,导致特征空间对不上,模型直接翻车。

3.3 特征工程:词袋与 TF-IDF 的取舍,以及归一化的隐藏作用

预处理完的文本不能直接丢给模型,需要先完成文本到数值向量的转换。这一步在 sklearn 里有现成工具:CountVectorizer 和 TfidfVectorizer。

CountVectorizer 输出的是词频矩阵,每一行代表一封邮件,每一列代表一个词,矩阵中的值是该词在邮件中出现的次数。词频矩阵保留了频率信息,配合 MultinomialNB 使用时,模型看到的是“这个词出现了几次”。

TfidfVectorizer 则更进一步:它不只是统计词频,还考虑了逆文档频率——如果一个词在 1000 封邮件里都出现,它的 IDF 值会被压低;如果只在少数邮件里出现,IDF 值会抬高。这等于把“常见但没区分度”的词的权重拉下来。

垃圾邮件过滤里 TF-IDF 通常比纯词频效果好一点,但差距不一定很大。原因是垃圾邮件和正常邮件的高频词集合本身就有明显差异,词频矩阵已经包含了足够信息。不过 TF-IDF 有一个隐藏好处:它对文本长度做了归一化,一封 10 行的垃圾邮件和一封 100 行的正常邮件放在一起比较时,TF-IDF 不会让长文本天然获得更大的权重。

需要特别注意的一点是:TF-IDF 的拟合只能在训练集上做,然后用同一个已拟合的向量机去转换测试集。如果对全部数据一起 fit,测试集信息会泄漏进训练过程,测试准确率会虚高到让你误以为模型很好,答辩时被老师一问就露馅。

4. 训练与评估:跑通朴素贝叶斯分类器的完整代码与参数调优

4.1 最小可运行版本:从加载数据到输出分类报告

下面是一段可以直接跑通的完整代码。假设数据集目录结构是data/ham/*.txtdata/spam/*.txt,每封邮件一个文件。

import os from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, confusion_matrix def load_mails(data_dir="data"): texts, labels = [], [] for label_name, label in [("ham", 0), ("spam", 1)]: label_dir = os.path.join(data_dir, label_name) for fname in os.listdir(label_dir): if not fname.endswith(".txt"): continue with open(os.path.join(label_dir, fname), encoding="utf-8", errors="ignore") as f: texts.append(preprocess_text(f.read())) labels.append(label) return texts, labels texts, labels = load_mails("data") # 划分训练集和测试集,保持类别分布一致 X_train, X_test, y_train, y_test = train_test_split( texts, labels, test_size=0.2, random_state=42, stratify=labels ) # 特征工程:先fit训练集,再transform测试集 vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2)) X_train_vec = vectorizer.fit_transform(X_train) X_test_vec = vectorizer.transform(X_test) # 训练朴素贝叶斯分类器 model = MultinomialNB(alpha=1.0) model.fit(X_train_vec, y_train) # 预测并输出评估指标 y_pred = model.predict(X_test_vec) print(classification_report(y_test, y_pred, target_names=["ham", "spam"]))

运行这段代码,你会看到 precision、recall、f1-score 三列数据。代码里用stratify=labels保证训练集和测试集中垃圾邮件的占比一致,避免因切分随机性导致测试集恰好全是正常邮件。

代码逻辑拆开讲:load_mails函数按文件夹遍历所有 txt 文件,文件名后缀过滤是为了防止读入隐藏文件或缓存文件;预处理函数和训练流程分离,保证测试数据走了和训练数据完全相同的清洗管线;TfidfVectorizer设置了max_features=5000ngram_range=(1, 2),前者限制特征维度防止过拟合,后者引入相邻两个词的组合特征来捕捉"免费领取""点击链接"这类短语。

4.2 读懂指标:准确率、召回率、F1,垃圾邮件场景重点盯哪个

classification_report 输出的三个指标,含义完全不同,在毕设答辩时你大概率会被问到它们的区别。

准确率(Precision)表示模型判为垃圾邮件的里面,真正是垃圾邮件的比例。这个指标低,意味着误杀严重——正常邮件被丢进了垃圾箱,这在真实业务里是不可接受的。

召回率(Recall)表示所有真正的垃圾邮件里面,模型成功抓出来多少。这个指标低,意味着漏网之鱼多——垃圾邮件混进了收件箱。

F1 是两者的调和平均,用来综合衡量。

在垃圾邮件过滤场景里,正常邮件的误杀成本远高于垃圾邮件的漏判成本。一封被误杀的工作邮件可能让用户错过重要通知,而漏掉一封垃圾邮件,用户手动删除就够了。所以调模型时,我会先保证 ham 类的 precision 足够高(比如 0.98 以上),再追求 spam 类的 recall。这个取舍在毕设论文和答辩里可以作为模型的优化目标来写,是一个天然的加分点。

4.3 参数调优方向:alpha 取值、特征维度、阈值调整

MultinomialNB 的 alpha 是第一个要调的参数。我通常会在 [0.01, 0.1, 0.5, 1.0, 2.0] 这个范围内做网格搜索。alpha 过小时,零概率问题复现,个别稀有词的频率完全主导概率计算;alpha 过大时,所有词的概率趋向均一,模型失去判别能力。从工程经验看,0.1 到 1.0 之间往往有最优值。

特征维度max_features也值得调。特征数太少(比如 1000),部分低频但有强判别力的词会被裁掉;特征数太多(比如 50000),训练时间变长,且出现大量只在个别邮件中出现一次的噪声词。5000 到 10000 是邮件场景下比较稳妥的区间。

还有一个容易被忽略的参数:类别的判定阈值。MultinomialNB 的predict_proba输出的是两个类别的概率值,默认predict在垃圾邮件概率大于 0.5 时判定为垃圾。如果你想降低误杀率,可以把阈值抬到 0.65 甚至 0.8——只有模型很有把握时才判为垃圾。实际调模型时,我会在验证集上画出不同阈值下的误杀率和漏判率曲线,这个动作在毕设里属于“锦上添花”级别的分析。

5. 避坑:朴素贝叶斯垃圾邮件过滤最常见的五个翻车点

5.1 现象:测试准确率 0.97,但拿了一封新垃圾邮件去测,预测结果是正常邮件

原因:训练集和测试集来自同一批数据,做了全局向量化。如果对全部数据一起 fit 向量机,再划分训练测试集,特征空间和 IDF 权重都“看过”了测试集内容,测试准确率是虚高的。

解决:严格要求拆分顺序——先train_test_split切出训练集和测试集,再在训练集上fit_transform向量机,测试集只用transform。这是一条铁律,任何文本分类项目都适用。

5.2 现象:中文邮件预测准确率远低于英文邮件,甚至跑出 0.5 的随机水平

原因:英文按空格切分天然合理,中文如果不分词或分词方式不当,“免费领取”会被拆成“免”和“费领取”,语义完全被毁掉。另一个常见原因是没有区分中英文编码,GBK 编码的邮件用 UTF-8 读取后被errors="ignore"吞掉了大量内容。

解决:中文语料必须用 jieba 分词,并在预处理函数里将use_jieba置为 True。读取文件时先探测编码,或统一在数据组织阶段把全部邮件转成 UTF-8 编码,避免在代码里做编码猜测这种玄学操作。

5.3 现象:alpha 从默认 1.0 改成 0.01 后,在训练集上效果爆炸,测试集效果骤降

原因:alpha 越小,模型越依赖词汇的原始频率,在训练集上拟合得越好,但泛化能力越弱。这是典型的过拟合,alpha 在这里扮演了正则化系数的角色。

解决:调参时用交叉验证,不要只看训练集指标。sklearn 的GridSearchCV对 alpha 做网格搜索,以 5 折交叉验证的 F1 作为评分标准,选出的 alpha 会比单次切分的结果稳健得多。

5.4 现象:预测阶段发现特征维度对不上,代码直接报错 ValueError

原因:预测用的向量机没有重新调用fit,或者直接新建了一个空的TfidfVectorizer。新向量机的词汇表是空的,转换出来的矩阵列数为 0,模型无法 predict。

解决:把向量机和分类器一起做持久化,预测时整体加载,不要在预测脚本里重新定义向量机。正确的做法是:

import joblib # 训练阶段:一起保存 joblib.dump(vectorizer, "models/vectorizer.pkl") joblib.dump(model, "models/nb_model.pkl") # 预测阶段:一起加载 vectorizer = joblib.load("models/vectorizer.pkl") model = joblib.load("models/nb_model.pkl")

5.5 现象:垃圾邮件占比极低(比如 5%),模型把所有邮件都判为正常邮件,准确率却高达 95%

原因:类别不均衡。如果测试集里 95% 都是正常邮件,无脑全判正常也能拿到 0.95 的准确率,但模型没有任何实际用处。

解决:看混淆矩阵,而不是只看准确率。如果混淆矩阵显示模型一个垃圾邮件都没抓到,说明模型已经退化成了“常数预测器”。对策有两个方向:一是用class_weight给少数类(垃圾邮件)更高的惩罚权重;二是调整判定阈值,让模型在垃圾邮件概率达到 0.3 时就触发警报。毕设论文里建议单独讨论这个问题,它非常能体现你对模型的理解。

6. 收尾交付:模型持久化与增量更新,一个能演示给老师看的完整闭环

如果你想把毕设演示做得更完整,可以加一个“实时过滤”模块:让用户输入一段邮件文本,系统直接输出判定结果和概率。这里有一个技术上容易被忽略的细节:新输入的文本必须走完全相同的预处理和向量化管线。

def predict_new_text(raw_text, vectorizer, model, use_jieba=True): cleaned = preprocess_text(raw_text, use_jieba=use_jieba) vec = vectorizer.transform([cleaned]) prob = model.predict_proba(vec)[0] spam_prob = prob[1] if spam_prob >= 0.5: return "spam", spam_prob else: return "ham", 1 - spam_prob # 使用示例 result, prob = predict_new_text("恭喜您中奖了,点击链接领取奖品!", vectorizer, model)

增量更新是我碰到过的最有价值的进阶特性。朴素贝叶斯支持在线学习,sklearn 的partial_fit方法可以让你在模型跑一段时间后,用新标注的邮件继续训练,而不用全部重来:

# 已有模型,给定新的一批数据 new_texts = ["最新促销活动,全场五折", "项目周报已发送"] new_labels = [1, 0] new_vecs = vectorizer.transform(new_texts) model.partial_fit(new_vecs, new_labels, classes=[0, 1])

需要注意的是:partial_fit必须在第一轮调用时显式传入classes参数,否则会报错。另外,增量更新的向量机不会更新词汇表和 IDF 权重——新词不会被加入词表。如果要让增量更新真正生效,得定期重训,或者在项目设计上老实标注这一点,把它作为一个“已知限制”写在论文里,反而显得思考深入。

做毕设这么多年带下来的经验是:谨慎地承认限制,比装作完美更能让答辩老师信服。模型不是万能的,能说清楚边界在哪,本身就是工程能力的一部分。这个项目做完之后,建议你把整个本地运行流程录一个屏幕录像,演示时直接放录像,省去现场跑环境翻车的时间。希望帮到你。

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

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

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

立即咨询