
简介一套基于朴素贝叶斯与支持向量机的垃圾邮件识别系统完整Python工程面向机器学习初学者、自然语言处理爱好者及需要快速搭建分类应用的开发者。压缩包共包含2000个文件以80张JPG图片、5个Python脚本、1个PKL模型文件及大量数据样本文件为主整体约28.64MB内部按数据模块、模型构建、附加功能三大模块组织图片为邮件图像样本脚本包含完整源码模型文件为训练好的参数文档则说明配置方法。已有1357人学习下载。项目覆盖从邮件数据预处理、词频统计分析到模型训练评估的完整流程并整合pytesseract与百度云图像识别接口可对图像形式邮件内容进行文字识别与分类通过对比朴素贝叶斯与SVM在相同数据集上的表现可直观理解概率推断与最大间隔两种建模思想。支持Python 3.6及以上可在Windows或Linux环境运行附全源码便于二次开发与算法对比实验。1. 垃圾邮件识别这个机器学习项目为什么值得动手做一遍垃圾邮件识别可能是机器学习分类算法里最容易做出成就感的一类项目数据量可控、标签天然清晰、效果一眼可见。朴素贝叶斯和SVM两条路线正好覆盖“生成式”和“判别式”两种建模思路同一份特征在不同模型上的表现差异比很多教科书例子直观得多。这份Python工程全源码把整条链路串好了——原始邮件导入、文本清洗、分词、TF-IDF向量化、朴素贝叶斯与SVM训练评估、模型持久化拿来就能跑也能拆成模板改到新闻分类、评论审核等场景。适合刚接触机器学习分类的初学者也适合想快速搭一套文本分类骨架的从业者。源码里没有复杂的分布式依赖一台普通电脑就能完成全部实验。2. 文本数据处理从一封原始邮件到TF-IDF特征向量2.1 邮件格式与数据集差异先搞清楚你面对的数据做垃圾邮件分类第一步不是建模而是看数据长什么样。常见的开源邮件数据集有Enron、CSDMC2010 SPAM Corpus这类大规模语料也有这份工程里使用的简化版目录结构ham/和spam/两个文件夹分别装正常邮件和垃圾邮件每封邮件是一个独立文本文件。这种目录式组织是所有文本分类项目最容易照搬的格式不需要解析数据库直接用路径就能打标签。实际业务里的邮件通常带完整邮件头From、To、Subject、Date这部分对垃圾邮件识别有信号价值但也会引入大量噪声。工程中的做法是取\n\n之后的正文字段来做分类邮件头只按Subject单独提取作为特征之一。我一般会建议先只处理正文跑通基线之后再决定要不要把主题、发件人等元数据拼进特征里。先减法后加法调参路径会清晰很多。2.2 清洗与分词英文按空格中文按jieba文本清洗直接决定特征质量。垃圾邮件里最常见的干扰项是HTML标签、URL、货币符号、多余空白和大小写混乱。一个保守的清洗策略是小写化、去HTML标签、去标点符号、压缩连续空白。过度清洗的风险是丢掉区分度比如“FREE”和“free”在垃圾邮件里都非常高频统一小写后反而更利于模型学习。import re from pathlib import Path def load_mail(path: str) - str: 读取邮件文件跳过邮件头只保留正文 raw Path(path).read_text(encodingutf-8, errorsignore) body raw.split(\n\n, 1)[-1] return body def clean_text(text: str) - str: 基础清洗小写、去HTML、去标点、压缩空白 text text.lower() text re.sub(r[^], , text) text re.sub(rhttps?://\S|www\.\S, , text) text re.sub(r[^\w\s], , text) text re.sub(r\s, , text).strip() return textload_mail里的split(\n\n, 1)[-1]是拿正文的常用技巧因为邮件头和正文之间几乎一定有空白行。clean_text里的每一步都是可解释的URL替换成空格而不是直接删除是为了避免把相邻单词拼在一起。英文分词直接用text.split()即可如果是中文场景换成jieba.cut注意去掉单字词和停用词后再进入向量化。2.3 特征向量化为什么垃圾邮件分类首选TF-IDF词袋模型把文本变成稀疏矩阵但纯词频CountVectorizer有个问题正常邮件里大量出现的“the”“and”会拿到极高权重而这些词对分类毫无帮助。TF-IDF用“词频乘以逆文档频率”把这个偏差压下去一个词出现越普遍它的IDF权重越低反之越稀缺的拼写比如“Viagra”的变形写法越容易被放大。垃圾邮件识别里变形词和随机拼写是常态这恰恰是TF-IDF的主场。from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( max_features8000, min_df2, max_df0.8, ngram_range(1, 2), sublinear_tfTrue, ) X_train vectorizer.fit_transform(train_texts) X_test vectorizer.transform(test_texts)max_features8000限制特征维度防止矩阵过大拖慢SVM训练min_df2去掉只出现一次的稀有词max_df0.8过滤掉超过80%文档都出现的通用词ngram_range(1, 2)让特征同时包含单词和相邻双词“free offer”这种词组就能被捕捉到sublinear_tfTrue对词频做对数压缩降低长邮件带来的数值偏差。这些参数在垃圾邮件场景下是比较通用的起点值直接套用即可不必一上来就网格搜索。2.4 用Python把预处理流程串起来实际跑数据时预处理不要散落在各个Notebook单元格里最好集中成函数。工程里的做法是把步骤拆成“读取→清洗→分词→向量化”四段任何一步失败都能定位到具体模块。向量化这一步要特别注意fit_transform只允许在训练集上调用一次测试集必须用同一个已拟合的vectorizer.transform否则特征空间不一致后面的模型评估全部失真这个坑后面还会细讲。def build_dataset(ham_dir: str, spam_dir: str): texts, labels [], [] for label, folder in [(0, ham_dir), (1, spam_dir)]: for f in Path(folder).glob(*.txt): texts.append(clean_text(load_mail(str(f)))) labels.append(label) return texts, labels train_texts, train_labels build_dataset(data/ham_train, data/spam_train) test_texts, test_labels build_dataset(data/ham_test, data/spam_test)代码里把标签预设成0和1这样后续classification_report输出里的0/1二分类指标不用额外映射直接对应正常邮件和垃圾邮件。如果数据量很小我一般会先跑一次完整流程确认向量维度再决定是否需要调max_features。3. 朴素贝叶斯分类器原理、手写核心代码与sklearn落地3.1 贝叶斯公式与条件独立性假设朴素贝叶斯的出发点非常朴素给定一封邮件的特征词计算它属于垃圾邮件的后验概率公式是P(垃圾|特征) P(特征|垃圾) * P(垃圾) / P(特征)。真正让计算可行的是条件独立性假设——假设特征词之间互不影响。这个假设在现实中绝对不成立比如“credit”和“card”在邮件里几乎总是共现但朴素贝叶斯的工程价值就在于即使假设不成立它在线性可分的文本分类上依然表现出色而且训练速度极快。为什么垃圾邮件识别场景特别合适因为垃圾邮件往往有强烈的词汇信号如“免费”“中奖”“点击链接”这些词单独出现时已经足够判别词与词之间的关联反而没那么重要。条件独立性假设在这里损失的精度有限却换来了极低的计算开销。做机器学习入门时朴素贝叶斯是理解“先验概率”“条件概率”“拉普拉斯平滑”这三个概念的性价比最高的载体。3.2 手写朴素贝叶斯先验概率与条件概率怎么算工程源码里的sklearn实现可以直接用但手动写一遍核心逻辑会让你对后面的参数调整有真正的掌控感。手写版的关键点有两个第一每个类别都要计算先验概率P(类别)第二每个词在每个类别下的条件概率P(词|类别)要加平滑避免某个词在训练集中没出现过导致概率为零。import numpy as np from collections import defaultdict class NaiveBayesSpam: def __init__(self, alpha1.0): self.alpha alpha self.class_stats {} def fit(self, X, y): # X: 每封邮件是一个token列表例如 [free, offer, click] self.classes sorted(set(y)) for c in self.classes: docs [X[i] for i in range(len(y)) if y[i] c] word_counter defaultdict(int) for doc in docs: for w in set(doc): # 同一封邮件内重复词只计一次 word_counter[w] 1 total sum(word_counter.values()) prior len(docs) / len(y) self.class_stats[c] (prior, word_counter, total) self.vocab set() for _, wc, _ in self.class_stats.values(): self.vocab | set(wc.keys()) def predict_one(self, tokens): scores {} for c, (prior, wc, total) in self.class_stats.items(): log_prob np.log(prior) for w in set(tokens): p_w (wc.get(w, 0) self.alpha) / (total self.alpha * len(self.vocab)) log_prob np.log(p_w) scores[c] log_prob return max(scores, keyscores.get)set(doc)是防止邮件里重复出现同一词把条件概率连带放大这是文本朴素贝叶斯最常见的实现细节。分母里的self.alpha * len(self.vocab)对应拉普拉斯平滑alpha1.0是最常用取值越大表示对未出现词的容忍度越高。用np.log累积概率是为了防止连乘结果下溢为0这在数百万词特征下是必须的。这个手写版在中小数据量下跟sklearn结果几乎一致理解了这几十行后面调MultinomialNB的alpha时就不会觉得是玄学。3.3 sklearn的MultinomialNBalpha参数与拉普拉斯平滑实际工程里直接用MultinomialNB即可。它假设特征服从多项式分布正好匹配TF-IDF输出的非负稀疏矩阵。注意不要用GaussianNB那是为连续正态分布特征设计的直接用在稀疏TF-IDF上会内存爆炸且效果偏差。from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report, confusion_matrix nb MultinomialNB(alpha1.0) nb.fit(X_train, train_labels) y_pred nb.predict(X_test) print(classification_report(test_labels, y_pred, target_names[ham, spam])) print(confusion_matrix(test_labels, y_pred))alpha1.0是sklearn默认值对应第3.2节手写代码里的拉普拉斯平滑。调alpha可以观察一个反差现象alpha过小如0.01模型对训练集里出现过的词过于自信容易过拟合alpha过大如10所有词的条件概率被强行拉平区分度下降。我从经验上看垃圾邮件语料里alpha在0.5到2之间表现最稳不必过分纠结。如果训练集和测试集分布差异大优先检查数据切分是否随机而不是动alpha。4. SVM分类器核函数选择与C、gamma调参4.1 间隔最大化与核函数的作用SVM和朴素贝叶斯的本质区别在于优化目标朴素贝叶斯是在拟合生成过程SVM则直接寻找一个把两类样本分开的超平面而且要求这个超平面离两侧最近样本点的距离间隔最大化。间隔越大泛化能力通常越强。这个“最大间隔”思想让SVM在高维稀疏文本特征下表现非常好因为文本向量化后的特征维度动辄几千甚至几万样本量却只有几千封邮件这种“高维小样本”场景正好是SVM的舒适区。核函数的作用是把原始特征映射到更高维空间在线性不可分时寻找非线性边界。但在垃圾邮件识别这个具体任务上要谨慎使用复杂核函数。文本经过TF-IDF后本身已经具有较好的线性可分性多数情况下线性核已经够用。RBF核带来的非线性提升通常非常有限却会让训练时间和过拟合风险显著增加。4.2 线性核与RBF核的适用边界线性核适合特征维度高、样本量中等偏小、且类别重叠不严重的场景垃圾邮件识别正好符合这些条件。RBF核适合特征维度较低、样本量较大、需要捕捉非线性关系的数据。选核函数不是越复杂越好先跑线性核如果测试集F1低于0.95再考虑RBF这是文本分类任务里比较稳的路线。RBF核有两个核心参数。C是误分类惩罚系数C越大模型越不允许训练集出错但容易把噪声也学进去导致过拟合C越小边界越平滑但可能欠拟合。gamma控制RBF核的影响半径gamma越大每个训练样本的影响范围越窄决策边界越弯曲gamma越小边界越接近直线。我用下面这个表格帮助理解两者配合参数小取值效果大取值效果垃圾邮件场景建议C边界平滑可能欠拟合严格拟合训练集可能过拟合从1开始网格搜索1~50gamma决策边界接近线性每个点都影响很大容易过拟合用scale自适应或0.01~0.1gammascale是sklearn的默认设置表示根据特征数量自动计算通常比手动指定更稳。特征维度在8000左右时scale计算出的gamma值已经很小配合C10往往就能跑出一个不错的baseline。4.3 用SVC和LinearSVC训练垃圾邮件分类器工程里同时提供了SVC和LinearSVC两个版本。SVC支持RBF核但训练复杂度随样本量上升很快数据超过几万条就会明显变慢LinearSVC是线性核的优化实现用liblinear求解训练速度快一个量级适合做大规模文本分类的基准。from sklearn.svm import SVC, LinearSVC # 线性核文本特征优先尝试这条 svm_linear LinearSVC(C1.0, class_weightbalanced, max_iter2000) svm_linear.fit(X_train, train_labels) linear_acc svm_linear.score(X_test, test_labels) # RBF核当线性核不够时再上 svm_rbf SVC( kernelrbf, C10.0, gammascale, class_weightbalanced, probabilityFalse, ) svm_rbf.fit(X_train, train_labels) rbf_acc svm_rbf.score(X_test, test_labels)class_weightbalanced是一个容易忽略但很重要的参数它根据类别样本量自动调整权重解决垃圾邮件集里正常邮件和垃圾邮件数量不均衡的问题。max_iter2000是LinearSVC容易触发的坑C调大后对偶问题求解迭代次数可能不够训练日志里会显示“ConvergenceWarning”手动调大max_iter即可。probabilityTrue会显著增加SVM训练耗时如果不需要输出概率保持False。TF-IDF输出的矩阵本身已经做了L2归一化所以这里不需要再做StandardScaler这一点和数值型特征的处理不同。如果换成词频矩阵CountVectorizer那就必须先归一化或标准化否则SVM的优化过程会因为数值范围差异而难以收敛。这个区别是我在项目里踩过坑的地方放到下一个章节细说。5. 工程落地与避坑目录结构、模型保存、五个翻车点5.1 工程目录结构训练、预测、工具函数分开很多机器学习项目能跑通Notebook却很难落地问题出在代码组织。这份工程采用了一个标准的三段式目录结构训练流程和预测流程通过模型文件解耦不会出现训练时用一套代码、上线时逻辑对不上的尴尬。spam_filter/ ├── data/ │ ├── ham_train/ spam_train/ │ └── ham_test/ spam_test/ ├── src/ │ ├── preprocess.py # 清洗、分词、向量化 │ ├── train_nb.py # 朴素贝叶斯训练入口 │ ├── train_svm.py # SVM训练入口 │ └── predict.py # 加载模型对单封邮件预测 ├── models/ │ ├── nb_model.joblib │ └── vectorizer.joblib └── requirements.txtmodels/目录里同时保存训练好的分类器和向量化器。这是一个必须养成的习惯因为预测新邮件时要保证特征空间跟训练时完全一致加载同一个vectorizer是唯一可靠的方式。requirements.txt里主要锁定scikit-learn、joblib、jieba这三个包版本不需太严scikit-learn1.0即可。5.2 训练到保存joblib持久化与重构pickle可以保存模型但对大对象不友好joblib针对numpy数组结构做了优化是保存sklearn模型更常用的方案。训练完成后把模型和向量化器一起落盘预测时只需要三行代码。import joblib from src.preprocess import clean_text, load_mail # 训练完成后保存 joblib.dump(nb, models/nb_model.joblib) joblib.dump(vectorizer, models/vectorizer.joblib) # 预测新邮件 model joblib.load(models/nb_model.joblib) vec joblib.load(models/vectorizer.joblib) raw load_mail(data/unseen.txt) X vec.transform([clean_text(raw)]) print(spam if model.predict(X)[0] 1 else ham)注意predict.py里的transform不是fit_transform。每次预测都重新拟合向量化器是初学机器学习时最容易犯的错误后果是特征空间每预测一封邮件就变一次模型输出完全不可复现。预测流程里不要做任何改变特征空间的操作这是工程底线。5.3 常见问题数据泄漏、乱码、类别不平衡、SVM归一化、模型体积坑一向量化器fit_transform用在了全量数据上。现象训练集准确率极高测试集却频繁翻车。原因对整批数据做fit_transform会把测试集词表信息泄漏进训练过程评估结果虚高。解决严格遵守训练集fit_transform、测试集transform且每轮实验都重新创建向量化器不要复用上一次的拟合结果。坑二中文邮件读取乱码。现象load_mail输出一堆éÂ之类字符特征全部异常。原因部分中文邮件是GB2312或GBK编码errorsignore没有解决编码识别问题。解决用chardet先检测编码再读取或者用errorsreplace把无法解码字节替换为占位符宁可用替代符也不要静默丢弃。坑三正常邮件和垃圾邮件数量严重失衡。现象准确率0.9但垃圾邮件召回率只有0.4垃圾邮件大量漏判。原因模型学会了把所有样本预测成多数类。解决SVM加class_weightbalanced朴素贝叶斯侧做欠采样或过采样更稳的做法是改用F1-score作为评估指标而不是看准确率。坑四词频矩阵直接喂SVM导致训练极慢。现象LinearSVC训练时间比预期多几十倍甚至出现不收敛警告。原因词频矩阵数值范围从0到大几百特征尺度差异大对偶问题求解困难。解决换用TF-IDF的L2归一化输出或手动加StandardScaler(with_meanFalse)对稀疏矩阵做标准化但不能直接用默认的标准化器它会尝试把稀疏矩阵转稠密。坑五模型文件体积超出预期。现象SVM保存的.joblib文件动辄几百MB。原因RBF核SVM需要保存大量支持向量在8000维特征下尤其膨胀。解决优先用LinearSVC它保存的是系数向量而非支持向量体积小一个数量级如果必须用RBF核考虑降max_features或减少训练样本。6. 把模型上限顶上去网格搜索与交叉验证的一点点经验6.1 GridSearchCV怎么跑不浪费机器朴素贝叶斯和SVM各自跑通之后下一步是让参数组合更接近最优。网格搜索是直观做法但如果把C和gamma都放入大范围网格组合数量会爆炸。我的经验是先粗后细第一轮把C设为[1, 10, 100]、gamma设为[scale, 0.01, 0.1]跑出排名后再在最优值附近加密。from sklearn.model_selection import GridSearchCV from sklearn.svm import SVC param_grid { C: [0.5, 1, 5, 10], gamma: [scale, 0.01, 0.1], kernel: [rbf], } grid GridSearchCV( SVC(class_weightbalanced), param_grid, cv5, scoringf1_macro, n_jobs-1, verbose1, ) grid.fit(X_train, train_labels) print(grid.best_params_, grid.best_score_)cv5表示五折交叉验证每折都用不同的子集做验证得到的分数比单次切分可靠得多。scoringf1_macro比默认准确率更稳尤其在类别不平衡时能避免模型偏向多数类。n_jobs-1让并行计算用满所有CPU核心RBF核网格搜索在8000维特征下非常吃计算量如果机器内存不足减少max_features或把cv降到4再跑。6.2 模型持久化的后悔药网格搜索结束后用grid.best_estimator_重新在完整训练集上拟合一次然后替换掉旧模型文件。这一步容易遗忘导致搜索了一堆参数但线上模型还是旧的。从那以后我每次做文本分类项目都会把“重新拟合最佳模型→保存→用一封真实邮件验证”这三步强制走一遍确认输出概率和置信区间都正常才收工。这个习惯帮我避免过好几次“模型看似调优实则未生效”的尴尬。希望帮到你。本文还有配套的精品资源点击获取