☰
垃圾邮件识别实战:朴素贝叶斯与SVM文本分类全流程
2026/10/8 10:30:24 网站建设 项目流程

简介:这是一份基于朴素贝叶斯与支持向量机(SVM)的垃圾邮件识别系统Python工程,面向机器学习初学者、NLP应用开发者及需要课程设计参考的高校学生。项目通过对垃圾邮件与正常邮件数据集进行训练,完成词汇词频统计与分类建模,涵盖数据模块、模型构建与附加功能三大模块,可用于理解文本分类、特征提取及两类经典算法的实际落地。压缩包共含约2000个文件,主要类型包括:80个jpg图像样本、5个Python源码文件、pkl模型文件、txt说明文档以及大量数字命名的数据文件,整体约28.64MB,目录结构便于按模块查阅。已有1357人学习下载。除完整源码与数据集外,资源附带环境配置说明:支持Windows下Anaconda配置及Linux虚拟机运行,并整合pytesseract文字识别与百度云图像识别接口,可作为扩展功能参考。读者可对照工程代码与数据文件,系统掌握邮件文本预处理、词频统计、朴素贝叶斯与SVM模型训练及评估流程,为课程设计或实际项目提供可复用的基础方案。

1. 垃圾邮件识别:一个能同时跑通朴素贝叶斯和SVM的机器学习分类工程

垃圾邮件识别是机器学习分类算法里最适合亲手跑通一遍的场景:数据不用标注、正负样本明显,朴素贝叶斯和SVM都能跑出像样的结果。这个Python工程把两条路线做在同一套流程里——jieba分词、停用词过滤、TF-IDF特征、MultinomialNB和LinearSVC训练、混淆矩阵评估、joblib持久化,全链路源码完整。朴素贝叶斯胜在轻快、几乎不吃参数,SVM胜在边界干净、抗噪强,对比着调很快能建立"什么场景选什么分类器"的判断直觉。适合课程设计、算法入门、以及想把邮件过滤需求落成可调用脚本的从业者。这篇笔记按拆完工程的实际顺序来写:预处理怎么做、两个算法的参数怎么设、评估为什么不能只看准确率,以及踩过的几个坑。

2. 邮件文本预处理与特征工程:从原始正文到TF-IDF矩阵

2.1 中文分词与停用词表:为什么不直接把文本喂给分类器

朴素贝叶斯和SVM都属于"不吃字符串"的分类算法。sklearn的TfidfVectorizer虽然能直接接收原始文本,但它在内部是按空格切词的。英文天然有空格分词,中文一整句没有空格,直接传进去会把"恭喜您获得iPhone一部请点击链接"当成一个"词",特征完全失效。所以这个工程的第一步是接入jieba分词,把每封邮件切成词序列,再用空格拼回一句话交给TfidfVectorizer处理。

分词完成之后,还有一个直接影响分类效果的动作:停用词过滤。像"的""了""在""是"这类高频虚词,在垃圾邮件和正常邮件里出现的比例几乎一样,它们只会给TF-IDF特征矩阵增加大量权重接近的维度,顺带拉高SVM的训练耗时。停用词表的选择,常见做法是下载哈工大停用词表,拿到后再针对邮件业务补几个词。我一般会在跑完第一版模型后,把特征权重最高的词拉出来人工看一眼,凡是"垃圾和正常邮件里都大量出现"的词,就手动加进停用词集合里。这比重换一个更大的通用词表更有效,因为你清理的是这个数据集自己的噪声,而不是别人数据集的噪声。

注意:分词要在特征化之前做,而且训练集和验证集必须用同一套分词逻辑。这个工程把分词函数封装成独立模块,后续换停用词表或加自定义词典都不用动模型代码。

2.2 从原始邮件到TF-IDF矩阵:特征构造的代码与参数

工程里的数据通常是CSV格式,两列:text为邮件正文,label为spam或ham。如果手头是spam/ ham目录结构,遍历目录把文件名当标签、文件内容当正文拼成DataFrame即可,这里按CSV版本展开。

import pandas as pd import jieba from sklearn.feature_extraction.text import TfidfVectorizer # 假设数据是CSV,两列:text 为邮件正文,label 为 spam/ham df = pd.read_csv("emails.csv", encoding="utf-8-sig", dtype=str) df["label"] = df["label"].map({"spam": 1, "ham": 0}) df["label"] = df["label"].fillna(0).astype(int) # 加载停用词表,一行为一个词 stop_words = set() with open("stopwords.txt", "r", encoding="utf-8") as f: for line in f: word = line.strip() if word: stop_words.add(word) # 分词并过滤停用词,返回空格拼接的分词串 def cut_mail(text): words = jieba.lcut(str(text)) return " ".join(w for w in words if w.strip() and w not in stop_words) df["cutted"] = df["text"].apply(cut_mail) # max_features 控制特征数量,ngram_range 决定是否组合相邻词 vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2), min_df=2) X = vectorizer.fit_transform(df["cutted"]) y = df["label"] print("特征矩阵形状:", X.shape)

这段代码有几个参数值得单独说。encoding="utf-8-sig"是为了兼容带BOM的CSV文件,用记事本或Excel保存过CSV的同学都遇到过首行第一个字段莫名多个\ufeff字符的情况,这个字符会混进词表变成一个"特征词"。min_df=2过滤掉只在1封邮件里出现过的词,这些词提供不了统计区分度,还会给矩阵增加噪声列。max_features=5000对几千封邮件的规模够用,如果数据集到几万封,可以提到10000以上,但不是越大越好——特征维度上去后SVM训练时间近似线性上涨,而分类效果很快进入平台期。ngram_range=(1, 2)把"中"和"奖"、"发票"这类双字组合也纳入特征,对中文垃圾邮件尤其重要,因为很多垃圾词是双字词,单字特征容易被停用词淹没。

2.3 划分训练集与验证集:stratify是分层抽样的便宜售后

特征矩阵构造好后,下一步是切训练集和验证集。这里有一个新手最容易忽略的细节:train_test_split默认是纯随机切分,垃圾邮件在样本里占比只有30%的话,验证集里垃圾比例可能变成20%或40%,每次跑评估结果都不同,你根本没法判断模型改动到底有没有效果。

from sklearn.model_selection import train_test_split # stratify=y 保证正负样本比例在切分前后一致 X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) print("训练集正样本占比:", round(y_train.mean(), 4)) print("验证集正样本占比:", round(y_val.mean(), 4))

stratify=y参数按y的类别比例做分层抽样,切分后训练集和验证集的正样本占比基本保持一致。random_state=42固定随机数种子,让每次切分结果可复现——这是保证实验可重复的前提,不然你调了一个参数,重新跑一遍代码,数据分布变了,结论就不可信了。有一点要注意:stratify必须配合分类标签使用,如果y是连续数值,sklearn会直接报错。这一步做完,数据就可以进模型了。

3. 朴素贝叶斯与SVM建模:用sklearn把分类器跑起来并调出可复现参数

3.1 朴素贝叶斯家族:MultinomialNB与BernoulliNB的适用边界

朴素贝叶斯的基本原理是用贝叶斯定理计算P(类别|特征),它假设特征之间条件独立,虽然这个假设在文本上严格说不成立,但实际效果出奇的好。sklearn里朴素贝叶斯有三个常用变体:GaussianNB用于连续数值特征,文本场景基本不用;MultinomialNB假设特征服从多项式分布,直接接收词频计数或TF-IDF值,是文本分类默认选项;BernoulliNB假设特征只有0和1,只关心"词出现或没出现"。

垃圾邮件场景里"中奖"出现1次和出现10次,对判定结果的影响完全不同——同一封邮件里反复出现的词往往就是这封邮件的主体意图,所以MultinomialNB比BernoulliNB更贴合。工程里如果想用BernoulliNB做对照,需要把TF-IDF矩阵做一个二值化,即所有大于0的值都变成1,这一步可以用sklearn.preprocessing.Binarizer完成。我的经验是BernoulliNB在邮件场景通常比MultinomialNB低1到2个百分点的F1,但训练速度几乎没差别,作为基准模型跑一份对照数据还是值得的。

另一个容易被忽略的问题是,TF-IDF矩阵是scipy.sparse稀疏矩阵,MultinomialNB和LinearSVC都原生支持,直接传入就行。有人习惯性调用.toarray()转成稠密矩阵,几千封邮件的5000维特征矩阵转出来就是上亿个浮点数,内存直接爆掉。这个坑我在早期项目里踩过一次,从那以后凡是给sklearn传稀疏矩阵,我都会先看一眼矩阵类型。

3.2 SVM核选择:为什么垃圾邮件场景默认线性核

SVM在文本分类里用的是LinearSVC,也就是线性核SVM。原理上SVM找一个超平面把两类样本分开,超平面到两侧最近样本的间隔最大化,间隔越大泛化能力越强。文本特征经过TF-IDF变换后维度高且稀疏,绝大多数特征维度的值是0,样本在这个高维空间里往往是线性可分的——线性核已经足够,不需要RBF核再往更高维映射。

RBF核在文本场景的问题在于:两个样本的TF-IDF向量里,共同出现的词通常很少,核函数计算出来的相似度趋近于0,整个核矩阵的信息量非常低,模型花大量时间在算一堆接近常数的相似度上,调gamma参数更像是在翻车边缘试探。如果一定要上RBF核,第一步应该对TF-IDF矩阵做L2归一化,再做交叉验证调整C和gamma,但文本分类场景里这么折腾的收益,通常不如直接换LinearSVC来得实在。这个工程的SVM分支就是线性核路线,也符合主流做法。

3.3 训练与对比代码:Pipeline封装与关键参数

训练阶段我把向量化和分类器装进同一个Pipeline,这样做最大的好处是预测新邮件时不用手动记住"先transform再predict"的调用顺序,一个pipeline.predict()全搞定。

import joblib from sklearn.pipeline import Pipeline from sklearn.naive_bayes import MultinomialNB from sklearn.svm import LinearSVC from sklearn.model_selection import cross_val_score # 复用上一章的vectorizer,封装成两个Pipeline nb_pipeline = Pipeline([ ("tfidf", vectorizer), ("clf", MultinomialNB(alpha=1.0, fit_prior=True)), ]) svm_pipeline = Pipeline([ ("tfidf", vectorizer), ("clf", LinearSVC(C=1.0, loss="squared_hinge", class_weight="balanced")), ]) # 五折交叉验证看的是稳定性和泛化能力,不是单次切分的运气 for name, pipe in [("MultinomialNB", nb_pipeline), ("LinearSVC", svm_pipeline)]: scores = cross_val_score(pipe, X, y, cv=5, scoring="f1") print(name, "F1均值:", round(scores.mean(), 4), "标准差:", round(scores.std(), 4))

这里几个参数是文本分类场景的常用起点。alpha=1.0是拉普拉斯平滑系数,防止某个词在训练集的某个类别里没出现过、导致条件概率算成0,一般从1.0起步,验证集结果波动大再在0.1到5之间搜索。fit_prior=True让模型从训练数据学习先验概率,如果样本是重新人工采样过的、正负比例已经改了,可以设成False用均匀先验。LinearSVC的C=1.0是正则化强度的逆参数,C越大越容易过拟合训练数据;垃圾邮件场景下C从1调高到10,验证集指标通常变化不大,训练耗时却明显上涨。class_weight="balanced"让模型按类别频率自动加权,专门应付正负样本比例不均衡,这条在垃圾邮件数据集上几乎必开。

调参不用一上来就上大网格搜索,先用小范围确定参数量级:

from sklearn.model_selection import GridSearchCV param_grid = { "clf__alpha": [0.1, 0.5, 1.0, 2.0], } grid = GridSearchCV(nb_pipeline, param_grid, cv=5, scoring="f1") grid.fit(X_train, y_train) print("最优alpha:", grid.best_params_)

Pipeline参数名里双下划线的含义是"子步骤的参数入口",clf__alpha表示Pipeline中名为clf的步骤的alpha参数。alpha超过2.0后平滑过度,特征之间的差异会被抹平,模型反而变笨。

4. 模型评估与对比:只看准确率会得出完全相反的结论

4.1 垃圾邮件识别里精确率与召回率为什么那么重要

如果样本中垃圾邮件占20%,写一个每次都返回"正常邮件"的模型,准确率也有80%——这就是准确率在类别不均衡场景下失效的原因。垃圾邮件识别要看的指标是精确率和召回率:精确率Precision是"模型判为垃圾的邮件里,真的是垃圾的比例",召回率Recall是"所有真正的垃圾邮件里,被模型拦下来的比例"。

两个指标在业务上是有冲突的。放到邮件场景里,误杀一封正常询盘邮件(精确率下降)比漏掉一封广告邮件(召回率下降)代价大得多。所以工程实践里我会更偏重精确率:宁肯放走几封广告,也不要让客户的重要邮件进了垃圾箱。这个偏好不需要改模型,后面用阈值就能控制。工程里如果报告只给了一个准确率,那这份评估基本等于白做。

4.2 从混淆矩阵到ROC曲线:读结果的具体代码

评估代码要一次把三个东西都打印出来:混淆矩阵、分类报告、ROC曲线。混淆矩阵看错误类型,分类报告看精确率和召回率,ROC-AUC看模型整体区分能力。

from sklearn.metrics import confusion_matrix, classification_report, roc_curve, auc y_pred = svm_pipeline.predict(X_val) cm = confusion_matrix(y_val, y_pred) print("混淆矩阵 [TN FP; FN TP]:", cm.tolist()) print(classification_report(y_val, y_pred, target_names=["ham", "spam"])) # LinearSVC有decision_function,可以直接拿置信分数画ROC scores = svm_pipeline.decision_function(X_val) fpr, tpr, thresholds = roc_curve(y_val, scores) print("ROC-AUC:", round(auc(fpr, tpr), 4))

混淆矩阵的排列顺序是[TN, FP; FN, TP]。如果FN很大,说明垃圾邮件大量漏网,业务上"垃圾邮件泛滥";如果FP很大,说明正常邮件被误杀,业务上"客户投诉爆表"。ROC-AUC和阈值无关,它衡量的是模型把所有垃圾邮件的分数排在正常邮件前面的能力,适合用来比较朴素贝叶斯和SVM谁的整体区分力强。注意LinearSVC有decision_function方法,返回的是每个样本到超平面的带符号距离,画ROC直接用这个分数,不需要调predict。MultinomialNB没有decision_function,要改用predict_proba取正类概率列。

4.3 最终选型与对比表

两个模型都训练完成后,用表格综合对比:

模型训练速度预测速度稀疏矩阵支持典型场景关键参数
MultinomialNB极快极快原生支持词频统计类文本、快速基线alpha平滑系数
LinearSVC较快快原生支持高维稀疏特征、需要边界清晰C、class_weight
BernoulliNB快快原生支持只关心词是否出现的场景binarize阈值

选型结论不是"哪个算法赢了",而是场景决定算法优先级。如果业务偏向"不能漏掉垃圾邮件",优先调SVM的C值并观察召回率;如果偏向"不能误杀正常邮件",用朴素贝叶斯配合调高阈值更省心。SVM在这个工程里的定位是给朴素贝叶斯做交叉验证对照,两个模型的F1差异通常在一个百分点以内,关键区别在训练速度和推理速度——LinerSVC在样本量上万后优势明显,朴素贝叶斯在数据集小时更稳。

5. 常见问题与避坑:预处理、样本不均衡与过拟合排查

5.1 训练准确率99%,新来的垃圾邮件一条没拦住

现象:验证集上F1高得吓人,模型上线后新到的垃圾邮件几乎全部漏判。

原因:最常见的两个原因——停用词表过"重",把垃圾邮件的高频特征词全部滤掉了;或者训练集和验证集里有完全重复的邮件,验证时等于在背答案。工程里如果爬取邮件时没做去重,同一封被转发多次的邮件会同时出现在训练集和验证集,准确率虚高超标。

解决:先对所有邮件正文做哈希去重,完全相同的文本只保留一条;然后检查停用词表里是不是误加了"点击""免费""活动"这类在垃圾邮件里具有强区分度的词;最后如果样本带时间戳,按时间排序做前80%后20%的切分,这样验证集更贴近"用过去预测未来"的真实场景。

5.2 验证集分数虚高,一上真实邮件就露馅

现象:交叉验证F1有0.95,部署后对实时邮件预测效果很差。

原因:向量化环节在验证集上又做了一次fit_transform。fit_transform会重新学习词表和IDF权重,等于模型变相偷看了验证集的文本分布,这在机器学习里叫数据泄露,是最容易犯的错误之一。工程里特征构造那章的代码如果写成X = vectorizer.fit_transform(df["cutted"]),后面预测新邮件时又写X_new = vectorizer.fit_transform(X_new),就中招了。

解决:坚持Pipeline流程——训练阶段只fit_transform一次,之后所有预测统一用transform方法。Pipeline封装的另一个好处就是把这两步绑定在一起,减少手动误操作的空间。检查方式很简单:在验证集上分别跑一次fit_transform和transform,两份特征矩阵如果差异很大,就说明泄露已经发生了。

5.3 中文邮件大量乱码,特征矩阵里全是问号

现象:词表里出现一堆??,或者jieba分词切出来的全是乱码字符,模型准确率跌到50%附近,接近瞎猜。

原因:邮件来源不同,编码也不同。旧系统导出的邮件可能是GBK或GB18030编码,新版爬虫抓的是UTF-8,pd.read_csv默认按UTF-8读,遇到GBK内容的行就解成乱码或直接报解码错误。编码不统一在邮件数据集里特别常见,因为垃圾邮件发送方经常用不同编码逃避过滤。

解决:读取时统一指定编码,工程里我建议读取CSV时用encoding="utf-8-sig",碰到解码失败的行用errors="ignore"跳过并在日志里记录数量;更稳的做法是先抽几行文本用chardet检测编码,确认数据集的主编码后再全量读取。乱码样本占比超过5%时不要硬跑模型,先修数据。

5.4 正常邮件误杀率高,阈值却不知道往哪调

现象:模型把大量正常邮件丢进垃圾箱,业务方直接找过来。

原因:默认分类边界是0,即决策函数值大于0判为垃圾,小于0判为正常。这个0是数学上的最优分界,但不一定是业务上的最优分界。如果实际业务更讨厌误杀正常邮件,就应该把阈值往上提。

解决:用ROC曲线选阈值,而不是拍脑袋。用第4章的roc_curve返回的thresholds数组,逐个看每个阈值下的精确率和召回率,找到精确率大于0.95的那个阈值点,存成配置文件。写代码时不要把阈值硬编码在模型里,而是作为predict函数的一个参数传进去。

5.5 SVM训练超过十分钟,换LinearSVC三秒结束

现象:SVM训练时间长得能去泡杯咖啡,模型文件还特别大。

原因:用了sklearn.svm.SVC而不是LinearSVC,且默认核是RBF。SVC的复杂度在样本数平方到立方之间,几千封邮件勉强能跑,上万封就开始让人怀疑人生;RBF核在高维稀疏特征上算出来的核函数值趋向常数,训练效率低。

解决:文本分类的第一选择永远是LinearSVC,它底层是liblinear实现,专门优化线性SVM在大规模稀疏数据上的求解速度。两个API虽然都叫SVM,但SVC和LinearSVC的默认行为完全不同。工程里如果遇到训练慢,优先检查是不是把这两个类搞混了。

6. 部署成命令行检测工具:模型持久化与单封邮件打分

6.1 持久化Pipeline而不是单独保存向量化器

模型训练完后,我会把整个Pipeline用joblib.dump持久化成一个文件,而不是分别保存向量化器和分类器。分开保存意味着预测时要手动保证词表顺序和模型权重对应,一旦词表改动过,新邮件向量化的特征列和模型期望的顺序错位,预测结果就是一个静默错误,比重训练更难排查。Pipeline打包保存后,这些都是内部状态,预测时不需要关心。

import joblib import jieba # 训练完成后一次保存,预测时一次性加载 joblib.dump(svm_pipeline, "models/spam_pipeline.joblib")

6.2 命令行检测脚本与阈值自检

下面这个脚本是部署的核心,读取一封邮件正文,输出判定结果和置信分数。

import sys import joblib import jieba pipe = joblib.load("models/spam_pipeline.joblib") with open("stopwords.txt", "r", encoding="utf-8") as f: stop_words = set(line.strip() for line in f if line.strip()) def predict(text, threshold=0.0): words = " ".join(w for w in jieba.lcut(text) if w not in stop_words) score = pipe.decision_function([words])[0] label = "spam" if score > threshold else "ham" return label, score if __name__ == "__main__": mail = "恭喜您获得iPhone一部,请点击链接填写收货信息,活动长期有效" label, score = predict(mail) print("判定:", label, "置信分:", round(score, 4))

score来自decision_function,正值偏向垃圾邮件,负值偏向正常邮件,绝对值越大置信度越高。上面这个例子,"恭喜""点击""活动"这些特征词会直接把分数推到正区间。如果用的是MultinomialNB,Pipeline里没有decision_function,要改成predict_proba(text)[0][1]取垃圾邮件的概率。脚本里的threshold参数可以直接从外部传入,部署到邮件服务器时按实际投诉率调整,比如把阈值调到0.5,只拦截那些高置信度的垃圾邮件,误杀率会明显下降,代价是部分边缘垃圾邮件漏网。这个权衡没有标准答案,取决于你的业务更怕哪种代价。

我现在的习惯是每次改了特征或调了参数,都拿一批真实的邮件文本跑一遍这个脚本,把输出存成日志,人工扫一遍判定结果再上线,从不直接拿交叉验证的分数当最终结论。自己拆这个工程时踩过最深的坑就是验证集上好看、真实环境翻车,从那以后每换一个数据集,我都强制走一遍数据清洗、分层抽样、阈值自检这三个步骤,虽然多花半小时,但上线后的安全感完全不一样。希望这套流程和代码对你有用。

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

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

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

立即咨询