ARTICLE DETAIL

资讯详情

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

Python实现垃圾邮件分类:朴素贝叶斯实战指南

Python实现垃圾邮件分类:朴素贝叶斯实战指南 简介基于Python实现的朴素贝叶斯垃圾邮件分类完整项目面向计科、人工智能、通信工程等专业学生可作为课程设计、毕业设计或入门机器学习实战参考。压缩包共包含2000个文件除3个Python源文件和5个pyc编译文件外还有README说明文档及大量分类器状态、中间数据文件完整覆盖邮件分词、特征提取、模型训练到分类预测的流程。包体约17.17MB目录结构清晰便于按阶段复现。目前已有90人学习下载适合想通过实际代码掌握朴素贝叶斯原理、文本分类处理及Python工程组织方式的初学者进阶使用也可在此基础上修改扩展自定义功能。项目经测试运行无误可直接复刻使用。1. 用Python实现垃圾邮件分类贝叶斯是绕不过去的第一步运维邮箱里躺着两封标题几乎一样的“域名续费通知”一封来自注册商一封来自钓鱼团伙。靠发件人地址判断已经不可靠靠关键词黑名单又总在漏和误杀之间摇摆。这时候回头再看贝叶斯公式会发现它天生就是做这件事的把“某个词出现在垃圾邮件里”的证据换算成“这封邮件是垃圾邮件”的概率。标题里的这个Python项目核心就是用朴素贝叶斯对邮件文本做二分类分类对象从英文语料到中文邮件均可依赖只有Python、jieba和一点概率论常识。我见过不少入门者把垃圾邮件分类当成“调库比赛”拿现成分类器跑一遍准确率就收工。但真实场景里语料不干净、类别不平衡、中文编码混乱任何一个环节都能让模型从90%掉到60%。这篇文章从贝叶斯原理讲起落到可运行的Python代码再到中文邮件场景下的预处理和调参要点看完你可以在自己的语料上复现一套完整流程。2. 朴素贝叶斯分类垃圾邮件的原理与特征选择2.1 从贝叶斯公式到分类决策先验概率与后验概率贝叶斯公式本身非常短P(A|B) P(B|A) × P(A) / P(B)。套到垃圾邮件分类里A表示“这封邮件是垃圾邮件”B表示“邮件中出现了某些词”。我们要算的是P(垃圾|词序列)也就是看到这封邮件的内容之后它属于垃圾邮件的概率有多大。关键在于P(B|A)怎么求。假设邮件由词序列w1、w2、w3……wn组成理论上需要知道这些词在垃圾邮件中共同出现的联合概率这需要海量数据才能估计。朴素贝叶斯的“朴素”就在于它做了一个很强的假设所有特征词之间相互独立。于是P(B|A) P(w1|A) × P(w2|A) × …… × P(wn|A)每个词单独在垃圾邮件中出现的概率相乘就行。这个假设在语言学上明显不成立——“中奖”和“恭喜”经常一起出现但在工程上它极其有效尤其适合文本分类这种高维稀疏场景。算完P(垃圾|邮件)和P(正常|邮件)比较两者大小哪个大就分到哪类。分母P(B)是常数比较时可以直接约掉实际计算只比较分子部分。零概率问题必须处理如果某个词只在正常邮件里出现过在垃圾邮件里从未出现那么P(wi|垃圾) 0连乘结果直接变成0。解决办法是拉普拉斯平滑在分子分母各加一个常数。常见的做法是分子加1分母加词汇表大小这样既能保持概率性质又不会让罕见词直接抹掉其他所有词的贡献。实际写代码时概率值会连乘几千个小于1的数浮点数精度很快下溢所以要改成对概率取对数连乘变成连加数值稳定性好得多。2.2 文本特征与中文分词jieba切分与停用词过滤邮件文本和结构化数据不同不能直接塞进公式。必须先把字符串切成词。英文按空格和标点切分基本够用中文则必须分词比如“优惠活动”不能拆成“优”和“惠”。我在本地跑这类项目时默认直接用jieba.lcut()它返回一个词列表速度可以接受准确率在通用场景下也够。分词之后是特征筛选。先做停用词过滤的、了、是、在、你、我这类词在垃圾邮件和正常邮件里都会出现对分类没有判别力反而引入噪声。再从业务角度添加自定义停用词如果语料里大量出现“退订”字样而且退订同时存在于垃圾邮件和正常邮件的结尾建议把“退订”加进停用词表否则模型会把退订当成垃圾信号。关于特征表示这里直接使用词频count即每个词在邮件中出现的次数而不是TF-IDF。朴素贝叶斯配合词频在垃圾邮件分类里足够用原因是垃圾邮件的高频词免费、点击、中奖和正常邮件的高频词会议、报告、附件区分度很高。TF-IDF适合长文本主题建模用到朴素贝叶斯上反而会弱化高频词的证据强度一旦某个词在垃圾邮件中出现5次单纯评概率已经说明很多问题。import jieba from collections import Counter def tokenize(text: str, stopwords: set) - list: tokens jieba.lcut(text.lower()) return [t for t in tokens if t not in stopwords and len(t.strip()) 1] stopwords set() with open(stopwords.txt, encodingutf-8) as f: stopwords {line.strip() for line in f if line.strip()} test_email 恭喜您获得笔记本电脑一部请点击链接领取奖品 tokens tokenize(test_email, stopwords) print(Counter(tokens))这段代码先把文本转小写再用jieba切成词表过滤停用词和单字。只保留长度大于1的词是因为中文单字在停用词表之外还有很多虚词直接滤掉可以减少特征维度。注意len(t.strip()) 1对英文单词会误伤比如a本身有意义但中文垃圾邮件场景下这个约束问题不大英文语料建议改成正则只保留字母。3. 邮件数据准备目录结构、CSV标注与划分策略3.1 目录结构组织与数据读取训练朴素贝叶斯分类器需要标注好的邮件语料。公开数据集方面Enron是学术界常用的英文邮件语料国内经常见到的trec06p也有中文邮件子集。使用公开数据时注意下载到的zip压缩包解压后通常是每个邮件一个独立文件正样本和负样本分目录存放必须先统一读取并标注。即使不借助任何公开数据集自己攒几百封邮件按下面的目录结构整理一样可以跑通data/ ├── ham/ # 正常邮件 │ ├── 001.eml │ ├── 002.eml ├── spam/ # 垃圾邮件 │ ├── 001.eml │ ├── 002.eml读取这种结构的代码很直接import os from pathlib import Path def load_corpus(base_dir: str): texts, labels [], [] for label, folder in [(spam, spam), (ham, ham)]: folder_path Path(base_dir) / folder for file in os.listdir(folder_path): file_path folder_path / file try: with open(file_path, r, encodingutf-8, errorsignore) as f: content f.read() texts.append(content) labels.append(label) except IsADirectoryError: continue return texts, labels参数说明errorsignore是必须的邮件语料里经常混入非法编码字节不加上这个参数读取过程会被个别坏文件打断。label先用字符串后面转成0/1数值。文件夹名称约定为spam和ham在实际项目中可以按需调整。这段代码不依赖pandas因为邮件文件读取本身只需要标准库减少一个依赖在环境受限的机器上更稳妥。如果你拿到的是csv格式把open和read换成pandas的read_csv即可注意csv里需要有content和label两列。3.2 训练测试集划分与交叉验证数据准备好之后划分训练测试集这一步经常被忽视但它直接影响结果可信度。最常见的错误是直接随机抽样没有打乱数据导致训练集全是某种类型的邮件测试集又是另一种。另一个更隐蔽的坑是同一封邮件的多个副本比如转发链里出现的相同正文既出现在训练集又出现在测试集造成信息泄漏评测出的准确率虚高。sklearn.model_selection.train_test_split里stratify参数会按标签比例分层采样保证训练集和测试集里垃圾邮件的比例与整体一致。类别不平衡的语料尤其需要这个参数否则切出来的测试集可能只有几个垃圾邮件样本评估结果波动极大。from sklearn.model_selection import train_test_split texts, labels_str load_corpus(data) y [1 if label spam else 0 for label in labels_str] X_train, X_test, y_train, y_test train_test_split( texts, y, test_size0.2, random_state42, stratifyy ) print(f训练集: {len(X_train)} 封, 测试集: {len(X_test)} 封) print(f训练集中垃圾邮件占比: {sum(y_train) / len(y_train):.2f})关于交叉验证初学阶段建议先用一次train_test_split跑通全流程再上交叉验证。五折交叉验证平均后得到的结果更接近真实水平。cross_val_score配合KFold可以直接复用同一个分类器对象同时注意传入的必须是已经完成分词的数值特征矩阵。分词做在交叉验证内部还是外部也有讲究应该在内部做否则测试集的信息会在预处理阶段泄露给训练过程。简单场景下影响不大但严谨的评估流程需要知道这一点。4. 训练与预测Python实现贝叶斯垃圾邮件分类器的核心代码4.1 分词与特征向量化模型训练前要把每封邮件变成特征向量。我用CountVectorizer而不是手工构造Counter字典因为它自带tokenizer接口可以塞进jieba分词函数。先把训练语料整体拟合一次得到词汇表再transform成矩阵。from sklearn.feature_extraction.text import CountVectorizer vectorizer CountVectorizer(tokenizerlambda doc: tokenize(doc, stopwords), max_features5000) X_train_vec vectorizer.fit_transform(X_train) X_test_vec vectorizer.transform(X_test) print(f特征维度: {X_train_vec.shape[1]})这里的关键参数是max_features5000。限制特征数量等于只保留语料中出现频次最高的5000个词低频词只出现在一两封邮件里的词会被丢弃。这样做的原因是低频词没有统计意义某个词只在训练集里出现2次算出的条件概率波动极大对测试集的泛化几乎没有帮助。5000这个值在中小语料上效果不错语料增大到几万封时可以调到8000到10000。tokenizer传lambda而不是直接传tokenize函数是因为CountVectorizer会要求tokenizer返回字符串列表而tokenize的第二个参数stopwords需要预先绑定。4.2 训练计算先验概率与条件概率特征向量化完成后核心训练逻辑就是统计和计算from sklearn.naive_bayes import MultinomialNB model MultinomialNB(alpha1.0) model.fit(X_train_vec, y_train) print(先验概率 (样本分布):, model.class_log_prior_) print(特征数量:, model.feature_count_.shape)MultinomialNB的三个关键属性在训练后可以直接检查class_log_prior_是每个类别的对数先验概率feature_log_prob_是每个词在每个类别下的对数条件概率feature_count_是每个词在各类别中的出现次数统计。alpha1.0正是拉普拉斯平滑参数它在每个词的计数上加1防止零概率。调大alpha会增强平滑强度让所有词的概率趋向均匀调小则让数据本身的信号更强更容易过拟合。遇到“训练集准确率95%测试集准确率60%”时先别急着换模型把alpha往1.5、2.0方向调很多时候测试集分数能拉回来几个点。MultinomialNB适用于离散计数特征这正是CountVectorizer的输出格式。对比GaussianNB适用于连续值和BernoulliNB适用于0/1布尔特征词频矩阵用Multinomial最合理。如果你想验证这一点可以把特征矩阵改成X_train_vec.astype(bool).astype(int)喂给BernoulliNB准确率通常会明显下降原因是邮件正文里词频信息确实带来了判别力。4.3 预测用对数概率做分类决策预测阶段比训练更值得抠细节。直接用model.predict()拿到0/1标签没问题但你需要理解内部发生了什么。MultinomialNB在预测时对每个类别计算对数概率的和加上先验选择得分高的类别作为预测结果。import numpy as np pred_proba model.predict_proba(X_test_vec) pred_label np.argmax(pred_proba, axis1) for i in range(5): prob_spam pred_proba[i][1] print(f邮件{i}: 垃圾概率{prob_spam:.3f}, 预测{spam if pred_label[i] 1 else ham})predict_proba返回的是一个二维数组每一行对应一封邮件两列分别是属于正常邮件和垃圾邮件的概率。只看predict()的结果会丢失置信度信息垃圾概率0.51和0.99完全是两个量级前者说明特征信号很弱后者说明模型非常笃定。实际业务里应该暴露概率给调用方让上层逻辑根据阈值做更细的决策比如概率在0.4到0.6之间标记为“待人工审核”而不是强行给出二分类结果。垃圾概率还可以作为怀疑指数按从大到小排序优先人工审核概率最高的那些比随机抽查效率高得多。4.4 评估指标与常见误用垃圾邮件分类里准确率有欺骗性如果语料中90%是正常邮件模型把所有邮件都判成正常准确率也有90%。所以必须看混淆矩阵和F1分数。from sklearn.metrics import confusion_matrix, f1_score cm confusion_matrix(y_test, pred_label) print(混淆矩阵 [TP, FP / FN, TN]:) print(cm) print(F1分数:, f1_score(y_test, pred_label))混淆矩阵的四项对应关系要记清楚True Positive表示垃圾邮件被正确拦截这是垃圾邮件分类里最关心的值False Negative表示垃圾邮件漏网漏一封钓鱼邮件进收件箱代价可能很高False Positive表示正常邮件被误判为垃圾严重时会错过客户邮件。调参时看F1比看准确率更能反映模型质量。如果追求极端拦截率可以把决策阈值往下调比如垃圾概率大于0.3就拦截但要承担更多正常邮件被误杀的风险阈值往上调则相反宁放过勿错杀。5. 中文邮件场景落地编码、正文提取与模型持久化的三个细节5.1 邮件编码与正文提取直接用Python读.eml文件时会发现内容并不是干净的纯文本。邮件头部、HTML标签、base64编码的附件全部混在一起。如果把这些原始内容送进分词器得到的词表里会充满“font”、“div”、“img”这类与分类无关的标签词直接污染特征。我处理中文邮件时先做两步第一步检测编码中文邮件常见的编码有gb2312、gbk、gb18030和utf-8.eml文件头部的Content-Type会声明charset但很多时候声明和实际内容不一致不能盲信。import re from email import policy from email.parser import BytesParser def extract_text_from_email(raw_bytes: bytes) - str: msg BytesParser(policypolicy.default).parsebytes(raw_bytes) if msg.is_multipart(): parts [] for part in msg.walk(): content_type part.get_content_type() if content_type text/plain: charset part.get_content_charset() or utf-8 try: parts.append(part.get_content().decode(charset, errorsignore)) except (UnicodeDecodeError, AttributeError): continue return \n.join(parts) else: return msg.get_content() def clean_html(text: str) - str: text re.sub(r[^], , text) text re.sub(r\s, , text) return text.strip()这段代码解决了两个问题解析multipart邮件时只保留text/plain部分丢弃HTML和附件用charset字段去解码正文。真实邮件里经常遇到get_content()返回的已经是str但解码失败的情况所以try-except必须有。clean_html里的正则只做粗粒度标签移除邮件正文中的HTML转义实体比如amp;可以用html.unescape()补充处理。做完这两步再进jieba分词特征质量会有明显提升。5.2 模型持久化与可解释性验证本地调通的分类器需要保存下来供后续预测使用。用joblib是最省事的方式训练好的模型和vectorizer要一起保存因为预测阶段必须用同一个词汇表做transform否则特征对不上。加载后可以对单封邮件输出详细的解释信息到底是哪些词把邮件推向了垃圾邮件一侧。import joblib joblib.dump(model, spam_model.joblib) joblib.dump(vectorizer, vectorizer.joblib) loaded_model joblib.load(spam_model.joblib) loaded_vec joblib.load(vectorizer.joblib) def debug_raw_email(text: str, prob_threshold: float 0.5): vec loaded_vec.transform([text]) proba loaded_model.predict_proba(vec)[0][1] pred spam if proba prob_threshold else ham print(f垃圾概率: {proba:.3f}, 判定: {pred}) feature_names loaded_vec.get_feature_names_out() token_seq tokenize(text, stopwords) word_prob [ (w, loaded_model.feature_log_prob_[1][list(feature_names).index(w)]) for w in set(token_seq) if w in feature_names ] word_prob.sort(keylambda x: x[1], reverseTrue) print(最像垃圾邮件的词:, word_prob[:5]) debug_raw_email(恭喜您获得大奖请点击链接填写收货地址)这段代码最后把垃圾邮件条件下对数概率最高的前5个词打印出来能直观看到模型关注什么。注意这段代码有个性能隐患list(feature_names).index(w)在每次循环里都做了一次列表扫描调试用没问题线上预测时要改成字典映射。get_feature_names_out()只有在CountVectorizer的版本较新时才可用如果你的sklearn版本旧换成get_feature_names()。模型保存时连带保存预处理中间结果比如tokenized之后的文本也是值得考虑的做法因为分词本身消耗的时间在网络请求的延迟预算里不可忽略。整套流程跑完后用十几封真实邮件过一遍debug函数凡是垃圾概率在0.4到0.6之间徘徊的样本回头去看它命中了哪些词、缺少哪些词往往能发现语料标注错误或者分词边界问题这比盲目调参有用得多。本文还有配套的精品资源点击获取
返回列表