简介:面向机器学习初学者与自然语言处理爱好者,这份基于朴素贝叶斯与支持向量机(SVM)的垃圾邮件识别系统,提供了完整可运行的Python工程全源码,可直接用于课程设计、毕业设计或算法对比实验。项目通过对垃圾邮件与正常邮件进行词频统计与模型训练,实现邮件自动分类,整体划分为数据模块、模型构建与附加功能三大环节;附加功能还涉及图像文字识别与图像识别的小程序接入,丰富了应用场景。资源打包共2000个文件,含80张JPG图片样本、5个Python脚本及txt说明、pkl模型文件等,压缩包仅28.64MB,目录结构清晰,便于按模块查阅与二次开发。环境配置方面覆盖Anaconda、pytesseract文字引擎及百度云OCR接口,源码已包含相应调用逻辑,可帮助读者快速复现从数据预处理到分类预测的完整流程。目前已有1357人学习下载,适合希望动手实践经典分类算法、深入理解贝叶斯与SVM原理及工程化实现的开发者。
1. 朴素贝叶斯和SVM做垃圾邮件识别:两个老算法为什么还值得用
你打开邮箱,看到一封标题写满“中奖”“点击领取”的邮件,这大概率是垃圾邮件。而拦截它这件事,很多团队第一时间想到的是深度学习,最后却在生产环境里换回了朴素贝叶斯或SVM——因为在这类强规则、高稀疏、可解释性要求高的文本分类任务里,老算法的性价比反而更高。这个标题里的系统,本质是用朴素贝叶斯和SVM两条路线,对邮件文本做二分类(垃圾/正常),核心是特征工程、训练比对和阈值调优。
它适合谁?适合刚接触机器学习文本分类、想用手边数据跑通一个完整闭环的开发者,也适合需要给上级演示“可解释拦截逻辑”的工程团队。朴素贝叶斯懂得快、参数少;SVM边界更硬、抗噪声更强。两条路都走一遍,你才知道哪条更适合你的邮件场景。下面按数据准备、特征工程、训练调参的顺序,把这个系统从头搭起来。
2. 数据准备与中文分词:先让模型看懂一封邮件
2.1 和英文不同,中文邮件必须先过“分词”这道关
英文邮件天然用空格分词,中文没有分隔符,所以第一步是把原始文本切成词。这个系统的输入是一封封邮件正文,输出是词序列,供后面TF-IDF或词袋模型使用。我用的是jieba分词,在中文场景里它最省事,支持精确模式,也能加载自定义词典。
import jieba import re def clean_and_split(text: str) -> list[str]: # 去掉邮件头中的回复标记和HTML标签残留 text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'[\r\n\t]+', ' ', text) # 去掉URL和邮箱地址,这些特征对分类干扰大 text = re.sub(r'(https?://\S+|www\.\S+|[\w.-]+@[\w.-]+\.\w+)', ' ', text) segs = jieba.lcut(text) # 只保留长度大于1的词,单字在垃圾邮件里通常是噪声 return [w for w in segs if len(w.strip()) > 1 and not re.search(r'^\d+$', w)]jieba.lcut返回的是列表,比jieba.cut转成生成器再列表化更直接。清理步骤里删除HTML标签和URL,是因为垃圾邮件大量使用图片链接跳转,而链接本身不携带中文语义。保留长度大于1的词,可以有效避开“的、了、和”这类高频虚词对特征空间的污染,同时又避免手工维护停用词表。如果数据集里混有英文,可以在re.search里追加一个[A-Za-z]+的过滤条件,但注意不要把小写英文全部丢弃,因为“free”“win”这类英文词在垃圾邮件里也是强信号。
2.2 从原始邮件到训练样本:文件读取与标签对齐
数据通常是TXT文件,一个文件一封邮件,文件名带标签前缀,比如spam_001.txt与ham_001.txt,也可以是CSV里label, content两列。我习惯用后一种,因为标签对齐更直观。下面这个函数读取CSV并把正文切分词,返回词列表集合与对应标签。
import csv def load_and_split(csv_path: str): texts, labels = [], [] with open(csv_path, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: label = 1 if row['label'] == 'spam' else 0 words = clean_and_split(row['content']) if len(words) >= 3: # 过滤正文过短的邮件,防止空样本 texts.append(words) labels.append(label) return texts, labels这里的label用1表示垃圾邮件,0表示正常邮件,和sklearn里binary目标变量默认约定一致。过滤len(words) < 3的样本,是为了避免只有一两个词的邮件产生退化的特征向量。注意CSV文件的字段名要和DictReader里写的完全一致,否则取出来全是None,训练时直接报ValueError: Input contains NaN。这个坑我踩过不止一次,建议在读取后打印len(texts)和len(labels)确认数量一致。
2.3 停用词与自定义词典:两个容易被忽略的细节
大多数中文分词工具都内置了停用词过滤,但默认集对邮件场景并不完全适用。比如“请勿回复”“退订”“点击”这些词,在正常商务邮件里也高频出现,单靠通用停用词表过滤不掉。我通常的做法是:先分词后统计词频,把频次最高和最低的词各看一批,手工决定是否加入停用词。下面这段把词频统计和停用词加载写在一起。
from collections import Counter def build_vocab_stats(tokenized_texts: list[list[str]], top_n: int = 50): counter = Counter() for tokens in tokenized_texts: counter.update(tokens) return counter.most_common(top_n) stopwords = set() with open('stopwords.txt', 'r', encoding='utf-8') as f: for line in f: stopwords.add(line.strip())build_vocab_stats返回的高频词,你可以用它判断“退订”这类词是否该进停用词表。注意,停用词表不是越大越好——如果词频前50里出现“优惠”,而你的正常营销邮件也会说“本月优惠”,这时把“优惠”加入停用词反而会让模型丢失关键信号。我的做法是:先不看词表,训练一版看哪些词对分类贡献度最高,再把中性高频词加入停用词。数据集规模不大时,这一步多做两轮,效果比换模型还明显。
3. 特征工程与TF-IDF向量化:模型见到的不是一个句子,而是一组数字
3.1 词袋模型和TF-IDF的差别:不仅要看词出现没有,还要看词重不重要
把分词结果交给算法前,必须把不定长的词列表变成定长的数值向量。最简单的是词袋模型(CountVectorizer),只统计词频;但“的、地、得”这类词出现次数最多,对分类却没有贡献,因此真正常用的是TF-IDF——它用词频乘以逆文档频率,压低在绝大多数邮件里都出现的词的权重,抬高在少数类别里集中的词的权重。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( tokenizer=lambda x: x, # 我们已经分好词了,这里不再次分词 preprocessor=lambda x: x, min_df=2, max_df=0.85, sublinear_tf=True ) train_vectors = vectorizer.fit_transform([' '.join(tokens) for tokens in train_texts]) test_vectors = vectorizer.transform([' '.join(tokens) for tokens in test_texts])三个参数是调这个系统的关键:min_df=2表示词至少在2篇邮件里出现才保留,过滤只出现一次的罕见词——垃圾邮件里故意拼错的变体词非常多,保留会让特征维度爆炸;max_df=0.85表示在85%以上邮件里都出现的词不保留,它们大概率是中性停用词;sublinear_tf=True把词频用1 + log(tf)替换,弱化高频词的绝对数量优势。fit_transform是在训练集上学习词表并转换,transform直接把同一词表套到测试集上——测试集绝不能单独fit,否则模型会看到词表外的测试分布信息,相当于作弊。
3.2 特征维度太高的处理:卡方检验和互信息选特征
邮件分词后,特征维度轻松到几万甚至十几万。朴素贝叶斯对高维稀疏还扛得住,SVM却会明显变慢。所以我在训练前,先按卡方检验选一遍特征。卡方检验的原理是:如果一个词的出现和“垃圾邮件”这个类别独立,那它的统计量应该在自由度1的卡方分布附近;偏离越大,说明该词和类别越相关。
from sklearn.feature_selection import chi2, SelectKBest selector = SelectKBest(chi2, k=20000) train_selected = selector.fit_transform(train_vectors, train_labels) test_selected = selector.transform(test_vectors) # 查看被挑出来的特征词 feature_names = vectorizer.get_feature_names_out() selected_indices = selector.get_support(indices=True) print([feature_names[i] for i in selected_indices[:30]])SelectKBest配合chi2是最经典的做法,注意它要求输入非负,所以TF-IDF里不要开norm='l2'以外的负值场景。k=20000不是固定值,我一般这样定:先设k=None跑一遍,看总分值和特征数,再把明显拖后腿的尾部砍掉。特征词打印出来看一眼,如果大量是“免费”“中奖”这类强信号,说明选择是有效的;如果出现一堆和邮件无关的通用词,调大min_df再试。卡方检验的代价是可能把低频但强区分的词扔掉,所以对精准率要求极高时,可以换成互信息法。
3.3 向量化和特征选择放进同一个管道,避免数据泄漏
有一个非常隐蔽的错误:先在整个数据集上用卡方检验选特征,再做训练测试集划分。这样测试集的信息混进了特征选择过程,测试指标会虚高。正确做法是把向量化和特征选择都放进Pipeline里,由交叉验证统一完成。
from sklearn.pipeline import Pipeline pipe = Pipeline([ ('vectorizer', TfidfVectorizer(tokenizer=lambda x: x, preprocessor=lambda x: x, min_df=2, max_df=0.85, sublinear_tf=True)), ('selector', SelectKBest(chi2, k=20000)), ('clf', None) # 后面放模型 ]) params = { 'vectorizer__max_df': [0.8, 0.85, 0.9], 'selector__k': [15000, 20000, 25000], 'clf__C': [0.1, 1, 10] }Pipeline中间每一步都有名称前缀,调参时用双下划线连接,比如vectorizer__max_df。这里我先不指定分类器,后面会把朴素贝叶斯和SVM分别填进clf位置对比。用Pipeline的最大好处是,交叉验证的每一折都是在训练子集上重新fit向量化器和选择器,测试子集完全不参与,指标才是可信的。如果你看到测试准确率出奇得高,比如99.8%,先怀疑数据泄漏,别急着高兴。
4. 模型训练与参数调优:朴素贝叶斯和SVM的对比落地
4.1 三个朴素贝叶斯变体怎么选:多项式、伯努利和高斯
sklearn里朴素贝叶斯有三个常用变体:高斯贝叶斯适合连续特征,多项式贝叶斯适合整数词频,伯努利贝叶斯适合“词是否出现”这种0/1特征。在垃圾邮件场景里,绝大多数项目选多项式或伯努利,基本不会选高斯——因为TF-IDF或词袋特征都是稀疏离散分布。下面这个例子直接用Pipeline训练多项式贝叶斯,并用交叉验证看稳定性。
from sklearn.naive_bayes import MultinomialNB from sklearn.model_selection import cross_val_score pipe_nb = Pipeline([ ('vectorizer', TfidfVectorizer(tokenizer=lambda x: x, preprocessor=lambda x: x, min_df=2, max_df=0.85, sublinear_tf=True)), ('selector', SelectKBest(chi2, k=20000)), ('clf', MultinomialNB(alpha=0.1)) ]) scores = cross_val_score(pipe_nb, train_texts, train_labels, cv=5, scoring='f1') print('NB F1: mean=%.4f std=%.4f' % (scores.mean(), scores.std()))多项式贝叶斯唯一的平滑参数是alpha,默认1.0。但在邮件场景,alpha=1往往对词频分布平滑过头,把一些在垃圾邮件里高频出现但在正常邮件里没有的词的概率拉得过平。我见过很多调参者执着于alpha的数值,其实它影响没那么大,alpha=0.1和alpha=1的F1差距通常在0.3%以内,不必花太多心思。真正要注意的是:如果特征矩阵里出现负值,多项式贝叶斯会直接报错,所以前面不要用StandardScaler,也不要在TF-IDF里取差值。
4.2 SVM的参数边界:核函数选择与C值的实际含义
SVM在文本分类里通常指LinearSVC或SVC(kernel='linear'),因为文本特征在高维空间已经比较可分,RBF核反而更容易过拟合。我优先用LinearSVC,它在大数据量下训练更快。核心参数是C:越小对错分样本的容忍度越高,边界越宽松、泛化性越好;越大越追求把所有训练样本分对,容易过拟合。
from sklearn.svm import LinearSVC pipe_svm = Pipeline([ ('vectorizer', TfidfVectorizer(tokenizer=lambda x: x, preprocessor=lambda x: x, min_df=2, max_df=0.85, sublinear_tf=True)), ('selector', SelectKBest(chi2, k=20000)), ('clf', LinearSVC(C=1.0, class_weight='balanced', max_iter=3000)) ]) scores_svm = cross_val_score(pipe_svm, train_texts, train_labels, cv=5, scoring='f1') print('SVM F1: mean=%.4f std=%.4f' % (scores_svm.mean(), scores_svm.std()))class_weight='balanced'是垃圾邮件场景的默认选择,因为垃圾邮件占比往往不到20%,类别天然不平衡,它会按类别频率反向加权。max_iter=3000是线性SVM容易遇到的坑——默认1000次迭代可能不收敛,sklearn会打印警告但不会中断训练,你看到的指标其实是次优解。如果数据量大,把dual=False可以考虑进去,这个参数在样本数大于特征数时用原始问题求解,速度快很多,但同时需要配合loss='squared_hinge'。
4.3 网格搜索的注意点:别一上来就全参数交叉
很多人拿到代码第一件事就是GridSearchCV把所有参数扫一遍,结果训练几小时,只提升0.1%。我的做法是分两步:先用默认参数跑cross_val_score看基线,再对最敏感的参数做小范围网格搜索。SVM里最敏感的就是C,其次是特征选择保留的k。
from sklearn.model_selection import GridSearchCV param_grid = { 'clf__C': [0.1, 0.5, 1.0, 2.0], 'selector__k': [15000, 20000, 30000] } grid = GridSearchCV(pipe_svm, param_grid, cv=3, scoring='f1', n_jobs=-1, verbose=1) grid.fit(train_texts, train_labels) print(grid.best_params_) print(grid.best_score_)这里 `c
本文还有配套的精品资源,点击获取