ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python朴素贝叶斯垃圾邮件过滤:从原理到系统实现与调优

Python朴素贝叶斯垃圾邮件过滤:从原理到系统实现与调优 简介基于Python实现朴素贝叶斯垃圾邮件过滤系统的完整毕业设计源码包面向计算机、自动化等相关专业学生尤其适合正在准备毕业设计、课程大作业的开发者使用。项目以朴素贝叶斯分类算法为核心完成从邮件文本预处理、特征提取到分类判别的完整流程源码经过严格调试评审得分95分可直接运行。资源共202个文件主要包含10个Python源代码文件、说明文档及大量用于训练与测试的邮件数据压缩包大小约287KB目录结构合理便于学习和二次开发。已有457人学习。除完整代码外还提供操作说明涵盖训练集选择、模型精确度评估、屏蔽词自定义、邮件测试等功能模块可帮助读者深入理解朴素贝叶斯在文本分类中的实际应用并支持在此基础上修改调整实现更多类似分类功能。1. 为什么用 Python 做垃圾邮件过滤朴素贝叶斯依然是毕业设计的最佳起点在邮件过滤这个任务上朴素贝叶斯是少有的“教科书算法直接等效于生产可用方案”的例子。垃圾邮件与正常邮件的差异往往藏在少数高频词、篇幅比例和固定营销句式中朴素贝叶斯用词频和条件概率就能把这些信号一起抓走。它不需要 GPU也不需要预训练模型几十行 Python 就能训练出一个带概率输出的过滤接口短板是忽略词序但在按整体内容判断的场景里这个短板对分类精度的影响比想象中小得多。如果你正在做课程设计或者需要在业务里快速落地一个可解释的文本分类器这套方案都合适。下面从一个最简分类器讲起再到带分词、特征工程、评估调参的完整过滤系统最后给出增量更新和错误样本回看的技巧。2. 朴素贝叶斯先验与似然垃圾邮件过滤公式推导和平滑参数的选择2.1 一封邮件被判垃圾还是正常模型在比较什么在邮件过滤场景里类别只有两个垃圾1和正常0。要判断一封新邮件理论上要计算 P(垃圾|邮件) 和 P(正常|邮件)哪个概率大就归到哪一侧。贝叶斯定理把它拆成三块先验概率 P(垃圾) 表示所有邮件里垃圾占多少似然 P(邮件|垃圾) 表示垃圾邮件里出现这段内容的概率分母 P(邮件) 是证据。实际分类时分母对所有类别相同可以直接约掉所以只需要比较 P(垃圾) × P(邮件|垃圾) 和 P(正常) × P(邮件|正常) 的大小。用对数改写后连乘变成连加log P(垃圾) Σ log P(词汇|垃圾)。改成对数有两个目的一是防止几百个 0.1 连乘后下溢成 0二是把最后比较简化成“先验得分 每个词得分求和”方便观察哪几个词把邮件推向了垃圾那一侧。对一封“免费领取红包”的邮件来说如果训练数据里垃圾邮件频繁出现“免费”和“领取”P(免费|垃圾) 会明显大于 P(免费|正常)这两个词贡献的得分差会把邮件推向垃圾。反过来“会议”“报销”这类只在正常邮件里常见的词会把邮件拉回正常这一侧。2.2 独立性假设看上去不成立为什么还要用朴素贝叶斯的“朴素”指条件独立假设在已知类别的前提下词与词之间不再互相影响。邮件场景里这显然不严格成立“免费”和“领取”几乎总是同时出现同时出现本身没有破坏假设破坏它的是“领取”的出现提高了“免费”出现的概率。但注意模型的输出只是 argmax 排序不是精确概率。对大多数邮件垃圾类与正常类的词分布差异非常大相关词引入的偏差不足以改变排序结果。词与词的相关性在两类里的表现也接近误差会在两侧互相抵消。这就是朴素贝叶斯在文本分类里“理论上不利其实很能打”的主要原因。和逻辑回归比它少几步迭代和 SVM 比它对几万维稀疏输入不需要做特征标准化和深度学习比它不需要大训练集几千条邮件就能稳定工作。对交付周期短、需要把原理讲清楚的毕业设计来说选朴素贝叶斯是性价比最高的路径。2.3 不依赖 sklearn 的最小 Python 实现与拉普拉斯平滑如果不想让外部库遮住核心逻辑可以手写一个最简版本。下面的类用字典保存词频统计不依赖任何训练框架但完整包含朴素贝叶斯的三要素先验、条件概率、对数空间比较。import math from collections import Counter class NaiveBayesSpam: def __init__(self, alpha1.0): self.alpha alpha # 拉普拉斯平滑参数 self.vocab set() # 全局词表 self.word_count {0: Counter(), 1: Counter()} self.total_words {0: 0, 1: 0} self.doc_count {0: 0, 1: 0} def fit(self, docs, labels): for doc, label in zip(docs, labels): words doc.split() self.doc_count[label] 1 self.vocab.update(words) for w in words: self.word_count[label][w] 1 self.total_words[label] 1 def score(self, doc, label): prior math.log(self.doc_count[label] / sum(self.doc_count.values())) likelihood 0.0 for w in doc.split(): cnt self.word_count[label].get(w, 0) # 拉普拉斯平滑的分子分母都要带上 alpha likelihood math.log((cnt self.alpha) / (self.total_words[label] self.alpha * len(self.vocab))) return prior likelihood def predict(self, doc): return 1 if self.score(doc, 1) self.score(doc, 0) else 0alpha默认取 1.0就是经典拉普拉斯平滑分子加 1分母加词表大小。它解决的最大问题是未登录词预测阶段遇到训练集没见过的词时cnt为 0如果不处理这一项算出来是 0整封邮件的对数得分直接变成负无穷。alpha 越大未登录词拿到的“保底概率”越高词频对结果的支配越弱alpha 太小模型对训练集里的每个词更敏感容易过拟合。用几行假数据就能验证这个类能正常跑通train_docs [ 免费 领取 红包 速来, 今晚 项目 上线 记得 提交, 中奖 请联系 客服 领取, 明天 开会 讨论 排期, ] train_labels [1, 0, 1, 0] nb NaiveBayesSpam(alpha0.5) nb.fit(train_docs, train_labels) print(nb.predict(免费 领取 客服)) # 输出 1 print(nb.predict(明天 开会 讨论)) # 输出 0这里用空格直接分词只用于验证计算链路。真实邮件必须先做 HTML 清理、中文分词和停用词过滤否则“免费”“领取”会被标点和大量无关词汇淹没。下一步进入完整系统实现。3. Python 垃圾邮件过滤系统完整源码数据准备、中文分词、特征工程与模型持久化3.1 数据从哪里来CSV 单文件比目录结构更容易验收一个可运行的过滤系统训练数据通常只需要一张 CSV 表一列原文一列标签。用 0 表示正常邮件1 表示垃圾邮件。不管原始邮件是从邮箱导出、从公开数据集转换还是人工整理统一成这个格式之后后续代码都只对这张表负责。源码包里的常见组织方式是分 data、src、models 三个目录数据单独放 data 下训练和预测脚本放 src模型输出到 models。spam_filter/ ├── data/ │ └── spam_mail.csv # label,text 两列 ├── src/ │ ├── preprocess.py │ ├── train.py │ └── predict.py └── models/ └── spam_filter.pkl拿到一个 Python 垃圾邮件过滤源码包无论目录怎么命名训练脚本要做的事情基本是同一套读 CSV、预处理、向量化、训练、保存模型。按这个顺序拆开讲更清楚。实验阶段 2000 条数据能跑通但要接近真实可用效果建议至少准备 5000 条并且两类样本比例不要太悬殊。运行环境按 Python 3.8 准备依赖就四个pandas、jieba、scikit-learn、joblib。3.2 文本清洗与中文分词把邮件转换成词序列邮件文本很少是干净的回复线索里的“转发”、HTML 标签、邮箱和网址如果直接拿去分词词表里会混入大量只出现在少数样本里的噪声。清洗阶段常见做法是去 HTML、去邮箱地址、去数字把连续空白压成一个空格再把结果交给分词器。import re import jieba STOPWORDS {的, 了, 在, 和, 是, 与, 等, 也, 都, 及} def clean_text(text: str) - str: text re.sub(r[^], , text) # 去 HTML 标签 text re.sub(r[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}, , text) # 去邮箱 text re.sub(r\d, , text) # 去数字 text re.sub(r\s, , text) # 合并空白 return text.strip() def tokenize(text: str) - str: cleaned clean_text(text) contains_cjk any(\u4e00 ch \u9fff for ch in cleaned) if contains_cjk: tokens [w for w in jieba.lcut(cleaned) if w.strip() and w not in STOPWORDS and len(w) 1] else: tokens [w.lower() for w in cleaned.split() if w not in STOPWORDS and len(w) 1] return .join(tokens)这里用正则替换而不是删除是为了避免出现“qqcom”这类删掉 和 . 后粘连出来的假词。contains_cjk判断是否包含中文字符中文走 jieba英文按空白切分并统一小写。len(w) 1会过滤单字母和单字词对中文基本没影响对英文能去掉大量 a、b、c 这类无信息项。3.3 特征提取CountVectorizer 还是 TfidfVectorizerMultinomialNB 的输入是文档-特征矩阵每一行是一封邮件每一列是一个词或词组。CountVectorizer 直接统计词频TfidfVectorizer 在词频基础上乘逆文档频率。邮件过滤场景里“的”“了”这类高频词到处出现如果不做逆文档频率压制会稀释真正有区分力的词所以我一般选 TfidfVectorizer并把 sublinear_tf 打开。from sklearn.feature_extraction.text import TfidfVectorizer vec TfidfVectorizer( max_features20000, # 最多保留 20000 个词 ngram_range(1, 2), # 单个词 相邻两个词组成的词对 min_df2, # 词至少在 2 封邮件中出现 sublinear_tfTrue # 用 1log(tf) 代替原始词频 )如果希望模型识别“免费领取”这种固定搭配就把 ngram_range 设为(1, 2)。这会显著增加矩阵列数所以要同时用 max_features 控制规模。min_df 用来去掉只出现在某一封邮件里的生僻词这类词大概率是噪声而不是规律。sublinear_tfTrue防止一封邮件把“免费”重复 50 次就比重复 5 次的邮件得分高出十倍。如果内存紧张也可以换 CountVectorizer 加binaryTrue只记录词是否出现效果在短文本上差距不大。3.4 训练、评估与持久化一段能放进 train.py 的完整流水线下面代码把前面几步串起来再加一层训练测试切分和度量输出。它假设preprocess.py与train.py在同一目录下数据文件在上一级 data 目录里。import pandas as pd import joblib 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 from preprocess import tokenize df pd.read_csv(../data/spam_mail.csv) df[tokens] df[text].apply(tokenize) df df[df[tokens].str.len() 0] X_train, X_test, y_train, y_test train_test_split( df[tokens], df[label], test_size0.2, random_state42, stratifydf[label] ) vec TfidfVectorizer(max_features20000, ngram_range(1, 2), min_df2, sublinear_tfTrue) X_train_vec vec.fit_transform(X_train) X_test_vec vec.transform(X_test) clf MultinomialNB(alpha0.5) clf.fit(X_train_vec, y_train) y_pred clf.predict(X_test_vec) print(classification_report(y_test, y_pred, target_names[正常, 垃圾])) joblib.dump({vectorizer: vec, model: clf}, ../models/spam_filter.pkl)stratifydf[label]保证切分后正常与垃圾的比例和全量数据一致避免某次随机切分恰好把垃圾邮件全放进验证集。fit_transform只调用在训练集上测试集用transform复用同一个词表保证特征维度一致。保存模型时把 vectorizer 和 model 放进同一个字典预测脚本一次 load 就能拿到两样东西。单封邮件的预测脚本这样写import joblib from preprocess import tokenize def predict_one(text: str): data joblib.load(../models/spam_filter.pkl) vec, model data[vectorizer], data[model] X vec.transform([tokenize(text)]) prob model.predict_proba(X)[0][1] # 垃圾类概率 return prob 0.5, round(prob, 4) if __name__ __main__: print(predict_one(恭喜您抽中奖品请点击链接领取))这里的prob 0.5是默认阈值下一章会说明为什么不能永远写死它。4. 朴素贝叶斯过滤系统的调参路线alpha、特征维度、类别不平衡、阈值与网格搜索4.1 用 Pipeline 加 GridSearchCV 一次性找出最优参数上一章的参数是手工定的。手工试参的问题在于参数之间会互相影响max_features 提高后ngram_range 带来的新特征会占用更多列alpha 的最优点也会跟着移动。更可靠的做法是把特征提取和分类器放进一条 Pipeline用 GridSearchCV 同时搜索。from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV pipe Pipeline([ (vec, TfidfVectorizer()), (clf, MultinomialNB()) ]) param_grid { vec__max_features: [5000, 20000, 50000], vec__ngram_range: [(1, 1), (1, 2)], vec__min_df: [1, 2], clf__alpha: [0.1, 0.5, 1.0, 2.0], } gs GridSearchCV(pipe, param_grid, cv5, scoringf1, n_jobs-1) gs.fit(df[tokens], df[label]) print(gs.best_params_) print(gs.best_score_)参数网格一共 3×2×2×4 48 组每组 5 折交叉验证短文本训练通常几分钟内能跑完。用 Pipeline 的意义在于交叉验证每一折都会在训练折上重新 fit vectorizer测试折数据不会被词表提前影响避免数据泄漏。选scoringf1而不是 accuracy是因为垃圾邮件占比可能很低accuracy 会被多数类拉高。4.2 类别不平衡先验概率和样本权重的取舍真实邮件里正常邮件往往远多于垃圾邮件。如果训练集正常占 90%、垃圾占 10%模型会倾向于把所有邮件判为正常因为整体准确率不差。MultinomialNB 没有 class_weight 参数调整先验最直接的方式是传入 class_prior。clf MultinomialNB(class_prior[0.5, 0.5]) # 认为正常与垃圾等可能如果不想改变先验可以在 fit 时给少数类更高权重sample_weight y_train.map({0: 0.6, 1: 1.0}) clf.fit(X_train_vec, y_train, sample_weightsample_weight)sample_weight 会作用到每个样本的似然统计上提高垃圾邮件的召回率但通常伴随正常邮件误杀率上升。调整完之后必须同时看两类各自的 precision 和 recall不能只看一个数字。4.3 分类阈值垃圾邮件过滤里比 alpha 更值得调的参数sklearn 的 predict 方法只是把 predict_proba 的输出以 0.5 为界变成 0 或 1。垃圾邮件场景不是天然对称的误杀一封正常邮件的代价通常比漏放一封垃圾邮件更高尤其在办公场景。所以阈值可以整体上移比如 0.6只有垃圾概率超过 60% 才拦截。from sklearn.metrics import f1_score proba clf.predict_proba(X_test_vec)[:, 1] best_thr, best_f1 0.5, 0 for thr in range(30, 81, 5): pred (proba thr / 100).astype(int) score f1_score(y_test, pred) if score best_f1: best_thr, best_f1 thr / 100, score print(best_thr, best_f1)在测试集上找阈值不是在训练模型它不改变模型参数只是确定拦截线。如果业务里“漏放”更严重比如反钓鱼场景阈值可以下移到 0.3如果是屏蔽营销邮件误杀带来的体验问题更突出阈值就往上调。调完要把阈值和模型一起保存。4.4 五个常见误用和坑第一个坑是给 MultinomialNB 喂连续特征。MultinomialNB 假设特征服从多项分布输入应该是计数或频次把文本长度、标点比例这种连续值硬拼进特征矩阵建议换成 GaussianNB 或对连续值分箱。第二个坑是用全量停用词表过滤否定词。“不”“没”“别”在垃圾邮件判断里非常重要“不要错过”“千万别退订”恰恰是营销邮件的常见措辞。停用词表只删纯语法词不删否定词。第三个坑是对测试集重复调用 fit_transform。只要训练集 fit_transform、测试集 transform词表就是同一个测试集一旦也 fit_transform维度会错乱要么报错要么得到误导性结果。第四个坑是随机切分数据和时间分布不一致。邮件是典型按时间产生的内容昨天的营销话术和今天用词接近随机切分会让验证结果比真实部署乐观。如果数据带时间字段优先按时间划分训练集和测试集。第五个坑是只盯准确率。垃圾邮件比例低时准确率会被多数类拉高。用 classification_report 同时看精度、召回率和 F1并且两类分开看误杀正常邮件和漏判垃圾邮件的代价不同单独看指标才有意义。参数速查表位置参数常用取值调整目的TfidfVectorizermax_features10000~50000控制词表规模和训练速度TfidfVectorizerngram_range(1,1) 或 (1,2)是否要词对特征TfidfVectorizermin_df1~3去除低频噪声词TfidfVectorizersublinear_tfTrue/False压制高频词主导MultinomialNBalpha0.1~2.0平滑强度MultinomialNBclass_prior[0.5,0.5]应对类别不平衡预测阶段threshold0.4~0.7调节误杀与漏放的平衡点5. 让 Python 朴素贝叶斯垃圾邮件过滤模型持续演进增量训练、配置化阈值与错误样本回看模型训练完保存成 pkl 只是交付的开始。真实环境里每天都有新营销话术、新变形词训练时见过的词表很快不够用。两种处理方式一是定期全量重训二是保留已有模型用少数人工确认过的邮件做增量训练。HashingVectorizer 和 MultinomialNB 的 partial_fit 组合适合第二种方式。HashingVectorizer 没有词表状态它把任意文本投影到固定维度新词不会改变特征空间因此可以配合 partial_fit 在已有参数上继续调整。from sklearn.feature_extraction.text import HashingVectorizer from sklearn.naive_bayes import MultinomialNB hv HashingVectorizer(n_features2**20, norml2, alternate_signFalse) clf MultinomialNB(alpha0.3) def learn_from_labeled(text: str, label: int) - None: vec hv.transform([text]) clf.partial_fit(vec, [label], classes[0, 1])第一次调用 partial_fit 时必须传 classes之后每次调用传同一个 classes 不会报错。增量更新不能拿模型自己的预测结果去训练预测结果不等于真实 label错误会顺着参数累积。可以优先挑出概率落在 0.4~0.6 灰区的邮件让人工确认后再回灌模型。如果想复用前面 TfidfVectorizer 训练出的那个 pkl也可以直接对它 partial_fit但词表固定会让新词进不来这时用 HashingVectorizer 重建一套增量流程更彻底。阈值不要直接写死在 predict 函数里。把阈值放进独立配置切换拦截线时不用重新训练。# config.py SPAM_THRESHOLD 0.55predict 阶段先取垃圾类概率再和配置值比较。每次调阈值前先在测试集上跑一遍 4.3 里的搜索逻辑记录不同阈值下的 F1 和误杀数再选业务能接受的那一档。每周固定做一次错误样本回看把最近预测错误的邮件按垃圾概率排序抽前 20 条读一遍。如果漏网邮件里反复出现同一个词或同一个链接不必立刻全量重训先把它临时加进关键词兜底规则等到下一次全量训练时再把这类样本补回训练数据。持续做这一件事比无限调大 max_features、反复试 alpha 更能提升过滤效果。本文还有配套的精品资源点击获取
返回列表