资讯详情

朴素贝叶斯垃圾邮件过滤:Python实现与毕设避坑指南

📅 2026/10/12 22:12:00 | 华诺云谱 👁 阅读
朴素贝叶斯垃圾邮件过滤:Python实现与毕设避坑指南
简介一套基于Python与朴素贝叶斯算法的垃圾邮件过滤系统完整项目内置可交互界面与训练/测试流程。资源面向计算机科学、人工智能、数据科学、信息安全等专业学生适用于毕设、课程设计、大作业及入门进阶通过选择训练集完成模型训练支持精确度评估、自定义屏蔽词、指定邮件号判别并可联网读取邮箱测试。压缩包共407个文件以20个Python源码文件、zip附件、txt/md说明文档为主体另有大量按数字编号的邮件样本数据文件包体仅995KB轻量易部署。目前已有481人学习下载搭配操作说明可快速复现实验并二次开发是理解朴素贝叶斯分类在文本过滤中应用的直观范本。1. 垃圾邮件误判背后的朴素贝叶斯毕设项目最常见的“轮子”为什么值得再写一遍你投了一个多月简历最后面试邀请被邮箱系统塞进垃圾箱三天后才看到。这种“宁可错杀”的过滤策略背后站着一个原理简单但效果可靠的算法朴素贝叶斯。标题里这个 Python 项目给的是从数据准备到训练预测的完整源码和操作说明面向的是想在毕设里快速落地一条机器学习主线的读者。它不需要 GPU不需要深度学习框架一台普通笔记本加 jieba 分词就能跑完整个流程对从零开始的毕设来说它既能讲清楚公式推导又方便做可视化对比是投入产出比很高的选题方向。这份源码的常见结构我也大概能猜到一个训练脚本、一个预测脚本、几份样本邮件和一份操作说明。本文会顺着这条主线把背后每个决策讲透——为什么用词袋模型、拉普拉斯平滑参数怎么设、阈值选多少、哪些环节最容易让代码跑得通但准确率翻车。你读完可以直接对着源码逐行核验也能把这个项目当成自己的“原创工作量”去答辩。2. 从贝叶斯定理到“垃圾词”投票先搞懂P(垃圾|邮件)是怎么算出来的2.1 贝叶斯公式的物理含义与朴素假设的边界贝叶斯公式写作 P(A|B) P(B|A)P(A)/P(B)。放到垃圾邮件过滤里A 表示“这封邮件是垃圾”B 表示“邮件里出现了某些词”。于是左边 P(垃圾|中奖、点击) 就是我们想要的答案在已知邮件出现这些词的前提下它是垃圾的概率。右边拆成四个部分P(中奖、点击|垃圾) 是垃圾邮件里这些词的分布P(垃圾) 是邮件整体里垃圾的占比P(中奖、点击) 是做归一化的证据项。举个具体数字帮助理解。假设邮箱中垃圾邮件占 30%也就是 P(垃圾) 0.3。再看历史数据“中奖”在垃圾邮件里出现的比例是 80%在正常邮件里只有 5%。那么收到一封含“中奖”的邮件P(垃圾|中奖) 0.8 × 0.3 / (0.8 × 0.3 0.05 × 0.7) ≈ 0.87。这里分母把“它是垃圾”和“它不是垃圾”两条路都算了一遍这就是归一化的含义。毕设答辩时能把这个算式徒手推出来比背概念有用得多。“朴素”二字的来源是独立性假设把一封邮件拆成一堆词假定任意两个词的出现互不影响所以 P(中奖、点击|垃圾) 可以拆成 P(中奖|垃圾) × P(点击|垃圾)。真实语言里这些词之间是有关系的“中奖”和“点击”同时出现时其实互相强化但假设它们独立只会低估一点概率方向不会偏。工程上这个假设换来极低的计算复杂度训练阶段只需要一遍扫描统计词频不需要梯度迭代普通笔记本处理几千封邮件也就是秒级完成。在垃圾邮件这种“词面意图明显”的场景里独立假设带来的误差远小于它带来的收益。毕设答辩还经常被追问“先验概率从哪来”。我在下面的代码里用训练集的类别占比当 P(垃圾) 的估计这是最常见做法。但注意如果你为了均衡训练把垃圾邮件和正常邮件各取 1000 封先验就被人为改成了 0.5会和你真实邮箱里的垃圾比例脱节。稳妥的做法有两种一是训练集按真实分布采样二是把先验单独存起来预测时替换成线上统计值条件概率保持固定、定期重训。这两种方案在答辩时都可以展开讲它们也是后面第五章“增量学习”的业务雏形。2.2 词袋模型与特征清洗哪些词该进特征表特征层面毕设最常用的表示是词袋模型把一封邮件变成一个“词到次数”的向量。词袋模型分两种多项式模型统计每个词出现多少次伯努利模型只记录出现没出现。垃圾邮件常用重复词堆强度比如“免费免费免费”伯努利模型里重复不会放大信号。我的经验是用多项式模型再加一层“同封邮件内重复词只计一次”的去重兼容两者的优点。这个取舍会在第三章的完整代码里体现出来。分词之前特征清洗的优先级是这样排的先去掉 HTML 标签、URL、邮箱地址这些不传递意图的噪音再统一小写然后对中文用 jieba 切词、对英文按字母串切分。特别提醒停用词表千万不要从网上抄一份就完事。“不”“没有”“别”这类否定词一旦删掉“不收费”会变成“收费”语义直接反转。我在过滤系统里只删“的”“了”“吗”“你”“我”这类纯语气词否定词和程度副词全部保留。特征表建好之后我习惯先把词频分布打出来看一眼再做筛选。用下面这段代码统计全量词频卡一个最低出现次数from collections import Counter def build_vocab(samples: list[tuple[list[str], int]], min_df: int 3): 统计全量词频过滤出现次数过低的噪音词。 freq Counter() for words, _ in samples: freq.update(set(words)) # 文档级去重后再累加 return {w: c for w, c in freq.items() if c min_df}这段代码最关键的是set(words)同一封邮件里出现 10 次“免费”和出现 1 次“免费”对判别的贡献应该等价按文档计数能压住堆词效应。min_df是词频下界默认 3 表示只在 3 封以上邮件里出现过的词才能进词表纯一次性的怪词比如乱码残留会被直接过滤掉。上界同样可以卡如果某个词在 80% 以上的邮件里都出现典型如“的”“了”“the”它对区分垃圾与否几乎没有信息量可以排除以减少运算量。这两个边界参数我一般放在验证集上调而不是凭感觉固定。3. 用Python从零实现朴素贝叶斯垃圾邮件过滤数据、训练与预测的三块核心代码3.1 数据组织目录扫描、标签命名与编码兜底实际毕设里我推荐用两个目录存邮件spam/放垃圾邮件ham/放正常邮件文件名就是编号。目录名就是标签新增数据只需要往文件夹里丢文件不需要改代码。加载函数很简单核心代码如下from pathlib import Path def load_mails(spam_dir: str, ham_dir: str) - list[tuple[list[str], int]]: 读取邮件目录返回 [(分词列表, 标签)]垃圾1正常0。 samples [] for f in Path(spam_dir).glob(*.txt): text f.read_text(encodingutf-8, errorsignore) samples.append((tokenize(text), 1)) # tokenize 在3.2代码中定义 for f in Path(ham_dir).glob(*.txt): text f.read_text(encodingutf-8, errorsignore) samples.append((tokenize(text), 0)) return sampleserrorsignore是预防编码异常真实邮件数据里偶尔混着 GBK 或其他历史编码不兜底直接抛 UnicodeDecodeError。但它只能保证不崩真正要处理 MIME 编码得用 email 标准库解 base64那个坑放在第四章避坑里细说。这里更重要的一点是分词函数 tokenize 必须单独抽出来让加载和预测共用同一个实现。我见过最惨的毕设事故就是训练时没去 URL、预测时把 URL 正则去掉了训练和预测两边的特征空间对不上模型效果直接崩盘。环境依赖也不要略过。这份代码基于 Python 3.8 以上版本安装 jieba 和 scikit-learn 只需要一条命令pip install jieba scikit-learn。sklearn 在本文里只用来做模型评估不做训练主体所以训练部分仍然可以心安理得地写“自己实现的朴素贝叶斯”。3.2 训练核心统计计数、平滑与完整类实现训练阶段只做一件事把每一类下每个词的计数挨个累加。我决定不调 sklearn 的多项式贝叶斯接口因为毕设答辩一定问“你讲讲原理”能自己写出来比调包有说服力得多。完整实现包含 tokenize、训练、打分、预测四个部分import math import re import jieba from collections import defaultdict def tokenize(text: str) - list[str]: 清洗邮件正文并分词保证训练和预测全流程一致。 text re.sub(r[^], , text) # 去HTML标签 text re.sub(rhttps?://\S, , text) # 去URL text re.sub(r[\w.-][\w.-], , text) # 去邮箱地址 words jieba.lcut(text.lower()) return [w.strip() for w in words if w.strip() and not w.isdigit()] class NaiveBayesSpamFilter: def __init__(self, alpha: float 1.0): self.alpha alpha self.doc_count defaultdict(int) # 每类邮件数 self.word_count defaultdict(lambda: defaultdict(int)) # 每类词频 self.word_total defaultdict(int) # 每类词总量 self.vocab set() # 全量词表 def fit(self, samples: list[tuple[list[str], int]]) - None: 训练统计先验概率与条件概率分母预加平滑量。 for words, label in samples: self.doc_count[label] 1 for w in set(words): # 同封邮件重复词只计一次 self.word_count[label][w] 1 self.word_total[label] 1 self.vocab.add(w) for label in self.doc_count: self.word_total[label] self.alpha * len(self.vocab) def score(self, words: list[str], label: int) - float: 返回对数后验分数全程在log空间累加防浮点下溢。 total_docs sum(self.doc_count.values()) log_prob math.log(self.doc_count[label] / total_docs) for w in words: if w not in self.vocab: continue # 未登录词直接跳过 cnt self.word_count[label][w] self.alpha log_prob math.log(cnt / self.word_total[label]) return log_prob def predict(self, words: list[str]) - int: spam_score self.score(words, 1) ham_score self.score(words, 0) return 1 if spam_score ham_score else 0 def predict_proba(self, words: list[str]) - float: 返回P(垃圾|邮件)供外部阈值调整用。 s, h self.score(words, 1), self.score(words, 0) return 1.0 / (1.0 math.exp(h - s))几个参数按重要性排。alpha1.0是拉普拉斯平滑强度某个词在垃圾邮件里从没见过直接给它零概率会让整个连乘归零平滑相当于给每个词一个最低保底次数alpha 越大弱特征的权重被压得越低。set(words)实现“文档内去重”一封邮件里 20 个“免费”和一个“免费”投票权一样防止重复堆词主导预测。sum(self.doc_count.values())就是先验 P(垃圾) 的来源训练集分布直接体现在这里。score用math.log累加而不是直接连乘因为几十个词的概率连乘后数值会小到浮点下溢成 0log 空间把乘法变成加法数值上安全得多。参数 / 写法作用建议值alpha拉普拉斯平滑强度1.0调大则弱特征权重降低set(words)同封邮件内重复词去重必须开启errorsignore编码异常兜底读取历史邮件数据时必加预测阈值垃圾/正常判定边界先用 0.5 基准再用验证集调整3.3 用 predict_proba 调阈值0.5 不是唯一选择predict的比较逻辑 spam_score ham_score 等价于默认阈值 0.5。但实际业务里垃圾邮件的代价不对称把正常邮件误杀损失远大于放进来一封促销邮件。毕设数据量又小概率分布往往不均匀0.5 不一定是好选择。调阈值前先要拿到概率值predict_proba干的就是这件事。常见做法是在验证集上遍历阈值按 F1 选最优值。下面这个函数可以直接抄进项目def select_threshold(model, valid_samples: list[tuple[list[str], int]]) - float: 在验证集扫阈值按F1选出最优判定边界。 best_th, best_f1 0.5, 0.0 for th in [i / 100 for i in range(20, 70, 5)]: # 0.20 ~ 0.65 tp fp fn 0 for words, label in valid_samples: p model.predict_proba(words) pred 1 if p th else 0 if pred 1 and label 1: tp 1 elif pred 1 and label 0: fp 1 elif pred 0 and label 1: fn 1 precision tp / (tp fp) if tp fp else 0 recall tp / (tp fn) if tp fn else 0 f1 2 * precision * recall / (precision recall) if precision recall else 0 if f1 best_f1: best_f1, best_th f1, th return best_th参数说明遍历范围 0.20 到 0.65、步长 0.05是我在千封级别数据上常用的范围。如果你验证下来的最佳值落在边界上说明范围给窄了要扩大再扫一次。F1 是精确率和召回率的调和平均比准确率更能反映“垃圾邮件有没有漏、正常邮件有没有误杀”。如果你的毕设场景更看重“别漏掉垃圾邮件”可以把选阈值的目标函数改成“召回率不低于某个底线时精确率最高”这种调整在答辩时也更容易展开。4. 毕设必踩的5个坑编码乱码、阈值失衡与数据泄漏的排查手册4.1 坑一邮件正文读出来全是乱码准确率直接崩到50%现象训练能正常跑但检测数据时发现中文显示成乱码或者程序直接抛 UnicodeDecodeError准确率比随机猜高不了多少。原因公共邮件数据集很多是历史的 base64 或 quoted-printable 编码open().read()读出来的是编码文本而不是真正的中文自己收集的邮件又可能混着 GBK 和 UTF-8 两种编码Windows 上最常见的坑就是gbk编码读utf-8文件。解决用 Python 标准库email模块做两步解码。先解析 MIME 结构再根据 Content-Transfer-Encoding 自动解码正文import email from email import policy def decode_mail_body(raw: bytes) - str: 将原始邮件字节流解码为普通文本正文。 msg email.message_from_bytes(raw, policypolicy.default) if msg.is_multipart(): part next((p for p in msg.walk() if p.get_content_type() text/plain), None) return part.get_content() if part else return msg.get_content()说明policy.default让 get_content 直接返回可读字符串而不是字节数组多部分邮件只取text/plain避免 HTML 模板里的标签和样式全堆进特征表。这个函数提前放在数据加载阶段能省掉后面一整类玄学问题。4.2 坑二停用词删过头“不收费”被删成了“收费”现象训练结束后模型把所有带“不”的邮件都判成垃圾正常邮件召回率低得可怜。原因很多人直接从网上抄一份通用中文停用词表里面把“不”“没有”“别”这类否定词当成噪音删掉。垃圾邮件里高频出现“不收费”“不是骗局”正常邮件里也有“不参加”“不满意”删除之后句子语义完全反转模型学到的特征全是错的。解决自建停用词表只删纯语气词、人称代词和功能性虚词比如“的”“了”“吗”“你”“我”“他”。否定词和程度副词“很”“非常”“绝对”必须留着。有一个简单判断标准删掉一个词之后如果你自己读这个邮件句子发现语义变了这个词就不能删。4.3 坑三训练集和测试集划分不干净准确率虚高到95%现象测试集准确率 95%答辩时拿一封真实新邮件一测立刻掉到 70%。原因最常见两个来源。一是同一封邮件的不同版本比如 .txt 和 .eml 解析后的正文同时混进了训练集和测试集相当于开卷考试二是随机划分时没固定 random_state跑多次结果波动很大最后挑了一次最好看的数字去汇报。解决按邮件编号或时间戳排序取前 80% 做训练、后 20% 做测试这样模拟的是“历史数据预测未来”的真实场景。如果非要用随机划分把random_state固定写死在代码里并在论文里注明。还有一条纪律调参只用验证集测试集只允许最后跑一次。4.4 坑四阈值拍脑袋定0.5营销邮件全被放进来现象垃圾邮件能挡住但“点击领取优惠券”“限时秒杀”这类营销邮件全被当成正常邮件放行演示效果很难看。原因正常邮件和垃圾邮件不是天然 50/50 的分布营销邮件在概率上介于两者之间硬阈值 0.5 会把它们划到正常侧。解决把阈值下调到 0.35 甚至 0.3宁可误杀少量正常邮件也别让营销邮件进收件箱。严谨一点就用 3.3 节那个select_threshold函数去验证集上扫一遍把 F1 曲线打印出来在召回率骤降点之前选值。业务上“少打扰用户”的需求决定了召回率优先级高于精确率。4.5 坑五中文分词没加载自定义词典“中奖”被切开现象数据里大量出现“中奖”“发票”“刷单”但模型对这些词无感做特征重要性分析也看不出强特征。原因jieba 默认词典面向新闻和生活语料对“代开发票”“刷单兼职”这类场景词默认切分可能切成“代”“开发”“票”或者直接单字单字区分度太低。解决准备一个自定义词典文件比如spam_words.txt每行一个词格式为“词 权重 词性”然后加载jieba.load_userdict(spam_words.txt) # 词典内容示例 # 代开发票 10 n # 刷单兼职 10 n # 限时秒杀 10 n加载后必须重新分词才生效。词典内容挑垃圾意图强烈的领域词就行权重统一填 10不需要刻意调。这个操作同时保证训练和预测的分词一致性——只要词典固定线上线下的切分结果就不会漂移。5. 让毕设从合格到优秀混淆矩阵验收、阈值曲线与增量学习5.1 用混淆矩阵和分类报告验收而不是只看准确率准确率在正负样本不平衡时会骗人。如果数据里 90% 是正常邮件模型全部判正常也能拿到 90% 准确率但一封垃圾邮件都拦不住。我用 sklearn 的 metrics 模块输出混淆矩阵和分类报告比单独打一个 accuracy 有说服力得多from sklearn.metrics import confusion_matrix, classification_report y_true [label for _, label in test_samples] y_pred [model.predict(words) for words, _ in test_samples] print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names[正常, 垃圾]))混淆矩阵四个格子最有价值的是右上角的假阳性数量——正常邮件被误判成垃圾的封数。答辩时能指着矩阵解释“这边是误杀那边是漏网”比报一个 95% 准确率有价值得多。同时把 3.3 节的predict_proba结果整理成“召回率-阈值”折线你会看到某个阈值之后召回率骤降转折点附近就是最优参数。5.2 增量学习让用户反馈变成模型的后悔药朴素贝叶斯的训练是一次性的但真实邮件系统最值钱的能力是用户勾选“这不是垃圾”之后立刻学习。因为fit方法本身就是计数累加增量更新非常自然拿到用户标记为正常的邮件直接调用fit([(words, 0)])。代价是 O(词数) 量级不需要重扫全量数据。毕设里把这个逻辑包装成一个retrain_on_feedback(mail, label)函数配合 2.1 节提到的“先验单独替换”就能讲一个完整的在线学习闭环。最后讲个我自己的教训当年我用 200 封邮件训练阈值设 0.5报告写得漂亮结果拿导师的真实邮箱测了三封就翻车一封发票推销混进去了。后来把训练集扩到 2000 封、阈值调到 0.33演示效果才真正稳定下来。这个项目越到后面越会发现缺的往往不是算法而是数据规模和诚实的评估方式。希望这些参数和踩坑经验能帮到你答辩顺利。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑