简介:一套基于Python与朴素贝叶斯的中文垃圾邮件分类器毕业设计项目,面向计算机、通信、人工智能、自动化等专业学生、老师及从业者,旨在解决中文垃圾邮件自动识别问题,适合期末课程设计、课程大作业或毕设参考。项目采用朴素贝叶斯算法完成中文文本分类,代码经调试可运行,并附带完整数据集与源码,覆盖从中文分词、特征选择到模型训练与预测的完整流程。包体共2000个文件,主要由Python脚本、TXT说明及大量编号语料数据组成,压缩包约90.62MB,目录结构清晰,便于按数据、模型、训练等模块查阅。已有572人学习下载,项目答辩评审分达98分,学习借鉴价值较高。基础较强的读者可在原代码基础上修改调整,扩展邮件分类功能,是入门自然语言处理与文本分类的实用素材。
1. 基于Python和朴素贝叶斯的中文垃圾邮件分类器:为什么这个毕设项目值得拆一遍
做中文垃圾邮件分类,很多新手第一反应是上深度学习,其实用朴素贝叶斯就够了。这个基于Python和朴素贝叶斯的中文垃圾邮件分类器项目,把数据预处理、分词、特征提取、训练评估和预测封装成了一条完整链路,还自带数据集,跑通之后你对文本分类的整体认知会比啃一个月理论更扎实。它适合三类人:正在做课程设计或毕设的学生,想快速上手NLP分类流程的开发者,以及需要一套可改写的邮件过滤基线代码的从业者。下面我按自己拆项目的习惯,把原理、代码结构、参数调优和踩坑记录展开讲,每一段都能直接对着源码操作。
2. 朴素贝叶斯怎么处理中文邮件:分词、特征向量与概率计算的落地选型
2.1 先搞清楚朴素贝叶斯在垃圾邮件场景下算什么
朴素贝叶斯不是一种模型,而是一族基于贝叶斯定理的分类方法。在垃圾邮件场景里,它计算的是:给定一封邮件的特征向量,它属于垃圾邮件类别的后验概率有多大。核心公式是:
P(类别|特征) = P(特征|类别) * P(类别) / P(特征)实现时通常忽略分母P(特征),因为它对所有类别都一样。实际计算的是分子部分,然后比较垃圾邮件和正常邮件两个类别的分子大小。这里的“朴素”指假设特征之间相互独立,也就是邮件里出现“发票”这个词和出现“点击”这个词互不影响。这个假设在真实文本里显然不成立,但实验证明它在分类任务上依然稳,而且计算量极小,训练速度比任何神经网络都快。
在这个项目里,特征就是邮件文本分词后得到的词项。每个词在垃圾邮件中的条件概率,用训练集里该词在该类别邮件中出现的次数除以该类别总词数来估计。为了避免某个词在某一类中从未出现导致概率为0,需要做平滑处理,这就是后面要讲的alpha参数。
2.2 中文文本的预处理管线:去噪、分词、停用词
中文邮件和英文邮件最大的不同在于没有天然的空格分词,所以预处理管线第一步是去噪和标准化,第二步才是分词。我拆这套源码时看到的典型流程是:先去掉HTML标签、URL、纯数字串、特殊符号,再把邮件正文统一转为小写——中文没有大小写概念,但邮件里往往夹杂英文,统一小写能减少特征维度。
下面是一段常见的预处理代码,结构可以直接套用:
import re import jieba def clean_text(text): # 去掉HTML标签 text = re.sub(r'<[^>]+>', '', text) # 去掉URL text = re.sub(r'https?://\S+', '', text) # 去掉邮箱地址 text = re.sub(r'\b[\w\.-]+@[\w\.-]+\.\w+\b', '', text) # 去除非中英文和数字的符号,保留中文、英文、数字和常用标点 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s]', '', text) # 合并多余空格 text = re.sub(r'\s+', ' ', text).strip() return text def tokenize(text): # 精确模式分词,适合分类任务 words = jieba.lcut(text) # 过滤单字和无意义词,这里可以加载停用词表 stopwords = {'的', '了', '和', '是', '在', '我', '有', '也', '就'} return [w for w in words if w.strip() and w not in stopwords]clean_text里的正则顺序有讲究:先剥HTML,再剥URL和邮箱,最后清理特殊符号,否则嵌套标签清理不干净。tokenize里用jieba.lcut精确模式,它返回的是list,适合后续直接统计词频。停用词表建议至少包含几百个常见虚词,否则“的”“了”“是”这类高频词会把真正有判别力的词淹没。如果你手头没有完整停用词表,先维护一个20个词的小表,观察分类结果再逐步扩充。
2.3 特征表示:词频向量与TF-IDF的取舍
分词之后,每封邮件从一段文本变成了一个词列表。朴素贝叶斯不能直接吃字符串,必须转成数值向量。两种常见方案:词频向量(CountVectorizer)和TF-IDF向量。这个毕设项目里通常用的是词频向量,因为MultinomialNB配合词频是学界验证过的黄金组合。TF-IDF会弱化高频词的作用,对朴素贝叶斯来说反而不一定更好,因为词在类别中的分布本身就是判别信号。
这里用scikit-learn实现向量化,代码不长:
from sklearn.feature_extraction.text import CountVectorizer from sklearn.naive_bayes import MultinomialNB # 设置最少出现2次,最多考虑5000个特征 vectorizer = CountVectorizer( tokenizer=jieba.lcut, min_df=2, max_features=5000, binary=False ) X_train = vectorizer.fit_transform(train_texts) X_test = vectorizer.transform(test_texts) model = MultinomialNB(alpha=1.0) model.fit(X_train, train_labels)CountVectorizer的tokenizer参数直接传入jieba.lcut,省去手动分词的中间步骤,但注意它要求分词函数返回字符串列表,jieba.lcut刚好满足。min_df=2表示只保留至少在2封邮件里出现过的词,滤掉低频噪声。max_features=5000则是硬性特征维度上限,防止中文词表过大导致内存爆炸。binary=False表示记录词频而不是只记是否出现,MultinomialNB天然适配频数输入。这里不建议用TfidfVectorizer,原因在后面调优章节展开。
3. 源码结构与数据集说明:从目录到训练脚本一次跑通
3.1 项目目录与核心模块职责
拆一个毕设项目,我最先看目录结构,因为目录能直接反映作者有没有工程意识。常规的垃圾邮件分类器源码一般包含这几个模块:数据读取、文本预处理、特征提取、模型训练、模型评估、预测演示。少数质量高的还会配一个可视化界面,但核心还是这几个Python模块。
下面是一个典型的清理后目录,你可以对照自己下载的资源看:
spam_classifier/ ├── data/ │ ├── train/ │ │ ├── ham/ # 正常邮件 │ │ └── spam/ # 垃圾邮件 │ └── test/ │ ├── ham/ │ └── spam/ ├── src/ │ ├── preprocess.py # 清洗、分词、停用词过滤 │ ├── features.py # 向量化与特征选择 │ ├── train.py # 训练主脚本 │ ├── evaluate.py # 评估与混淆矩阵 │ └── predict.py # 单封邮件预测 ├── models/ # 保存训练好的模型和向量器 └── requirements.txt这个结构的好处是每个模块职责单一:preprocess.py只管把原始邮件变成干净词列表,features.py只管把词列表变成特征矩阵,train.py只负责fit和保存模型。如果你拿到的资源是单文件jupyter notebook也没关系,按这个思路把代码块拆分成模块,后续改参数、换数据集都会方便得多。
3.2 训练与预测流程:参数设置与关键代码
训练脚本的核心动作就三件:读数据、向量化、训练。但真正的工程细节在于如何读数据。中文邮件数据集常见两种组织方式:一种是按类别分文件夹,邮件以纯文本存为文件;另一种是csv或Excel,每行一封邮件。这个项目的数据集通常是按文件夹组织的,读取代码可以这样写:
import os def load_emails(base_dir): texts = [] labels = [] for label, category in enumerate(['ham', 'spam']): folder = os.path.join(base_dir, category) for filename in os.listdir(folder): if filename.endswith('.txt'): with open(os.path.join(folder, filename), 'r', encoding='utf-8') as f: texts.append(f.read()) labels.append(label) return texts, labels train_texts, train_labels = load_emails('data/train') test_texts, test_labels = load_emails('data/test')读取时用enumerate把ham映射成0,spam映射成1,这是sklearn常规做法。encoding='utf-8'一定要显式写,否则Windows下默认可能是gbk,直接抛UnicodeDecodeError。训练后保存模型,用joblib最省事:
from sklearn.externals import joblib # 新版sklearn已经移除externals,直接import joblib import joblib joblib.dump(model, 'models/spam_model.pkl') joblib.dump(vectorizer, 'models/vectorizer.pkl')保存vectorizer非常关键,很多新手只存模型不存向量器,预测时重新fit_transform,导致特征空间对不上。模型和向量器必须成对保存,预测时先transform再predict:
def predict_one(text): clean = clean_text(text) vec = vectorizer.transform([clean]) pred = model.predict(vec)[0] proba = model.predict_proba(vec)[0] return 'spam' if pred == 1 else 'ham', probapredict_proba返回的是[正常邮件概率, 垃圾邮件概率],这两个概率是直接的分子计算结果,可以拿来设置阈值。比如当垃圾邮件概率超过0.9才判为垃圾,否则归为正常,能显著降低误杀率。
3.3 数据集格式与划分策略
数据集的格式直接决定预处理代码怎么写。常见的中文垃圾邮件公开数据是trec06p格式,但它原始是带标签的邮件文件,一行一个路径和标签,正文和头部混在一起,还带base64编码。这个毕设项目里的数据集通常已经整理成纯文本,每封邮件一个txt文件,按ham/spam分好文件夹,省掉了最脏的解析工作。
如果你的数据集是csv,格式一般是两列:label和content。这时读取代码要换成pandas:
import pandas as pd df = pd.read_csv('data/emails.csv', encoding='utf-8') texts = df['content'].tolist() labels = df['label'].apply(lambda x: 1 if x == 'spam' else 0).tolist()不管哪种格式,训练集和测试集必须按类别分层切分。直接用train_test_split时加上stratify参数:
from sklearn.model_selection import train_test_split train_texts, test_texts, train_labels, test_labels = train_test_split( texts, labels, test_size=0.2, random_state=42, stratify=labels )stratify保证切分后两个类别比例和原始数据一致。垃圾邮件数据通常正常邮件占比高,如果不分层,很可能测试集里垃圾邮件比例失衡,导致评估结果虚高或虚低。
4. 参数调优与评价指标:准确率、召回率与误杀邮件的权衡
4.1 平滑系数alpha的作用与选值
MultinomialNB里有个alpha参数,它对应拉普拉斯平滑的平滑系数。公式中的分子在计算P(词|类别)时,给每个词的计数加一个alpha,避免某个词在某一类里从未出现过就概率归零。alpha=1是默认值,实际调优时可以在0.01到10之间网格搜索。
alpha太小,模型对未见过的词过于敏感,遇到训练集没出现过的词会给出极端概率;alpha太大,所有词的概率被拉平,分类器失去判别力。我一般先按数量级试:0.1、0.5、1.0、2.0,观察准确率和召回率的变化。有一段代码可以快速网格搜索:
from sklearn.model_selection import GridSearchCV param_grid = {'alpha': [0.01, 0.1, 0.5, 1.0, 2.0, 5.0]} gs = GridSearchCV(MultinomialNB(), param_grid, cv=5, scoring='f1') gs.fit(X_train, train_labels) print(gs.best_params_)GridSearchCV内部自动做5折交叉验证,scoring='f1'比准确率更适合不平衡类别。注意网格搜索前先把X_train准备成稀疏矩阵,不然内存会爆。跑出来的最优alpha如果是0.01,说明你的特征词质量很高,不需要太多平滑;如果是2.0,说明语料噪声大,平滑能压住过拟合。
4.2 特征维度与最小词频约束
max_features和min_df是控制特征空间的两个旋钮。min_df=1会把只出现一次的词都纳入特征,这些词基本都是噪音,还会让矩阵变得巨大。min_df太小导致过拟合,太大则丢掉稀有但判别力强的词,比如某些缩写或特殊业务词。
我建议先用max_features=5000、min_df=2跑一遍,看分类报告。如果垃圾邮件召回率低,说明垃圾邮件的特有词被过滤掉了,把min_df调到1试试。如果训练时间长、模型文件超过100MB,把max_features降到3000。这个项目的特征维度一般控制在3000到8000之间,中文常用词也就1万左右,低于这个范围信息不足,高于这个范围稀疏性太强。
另一个容易被忽略的是max_df,它控制高频词上限。如果某个词在90%的邮件里都出现,比如“邮件”“内容”,它基本没有判别力,反而会稀释其他词的权重。设置max_df=0.8可以让向量器忽略在80%以上文档都出现的词:
vectorizer = CountVectorizer( tokenizer=jieba.lcut, min_df=2, max_df=0.8, max_features=5000 )注意max_df填小数表示比例,填整数表示文档数。混合使用时务必想清楚,我见过有人同时填min_df=0.8和max_features=5000,结果min_df成了最大词频,整个特征列表被滤空。
4.3 混淆矩阵看分类器的真实表现
准确率在垃圾邮件分类里是骗人的。假设数据里90%是正常邮件,模型把所有邮件都判为正常,准确率也有90%,但一封垃圾邮件都拦不住。所以评价这个项目必须看混淆矩阵和precision、recall、f1。
sklearn分类报告一行代码就能看到全部指标:
from sklearn.metrics import classification_report, confusion_matrix preds = model.predict(X_test) print(classification_report(test_labels, preds, target_names=['ham', 'spam'])) print(confusion_matrix(test_labels, preds))输出里重点关注spam这一行的recall。recall是垃圾邮件查全率,等于被正确识别的垃圾邮件数除以实际垃圾邮件总数。recall太低意味着大量垃圾邮件漏网。precision是查准率,等于被识别为垃圾的邮件里真正是垃圾的比例。precision太低意味着正常邮件被误杀。对垃圾邮件过滤来说,误杀正常邮件比漏过一封垃圾邮件更严重,所以我会优先保证precision在95%以上,再尽量提高recall。
下面是一个手绘混淆矩阵的参考,通常打印出来长这样:
precision recall f1-score support ham 0.98 0.99 0.99 2000 spam 0.97 0.95 0.96 800如果spam的recall比precision低,说明分类器偏向保守,宁可不报也不误杀。这时可以调低predict_proba的判别阈值,从默认0.5降到0.3,让更多邮件进入垃圾类别,代价是precision下降。
5. 避坑排查:中文编码、样本不平衡与过拟合的常见翻车记录
5.1 现象:读取邮件时报UnicodeDecodeError或乱码
原因:邮件原始编码不是utf-8,Windows下常见gbk、gb2312,部分中文数据集用base64编码了整个邮件原文,直接用文本模式读取必然报错。
解决:先尝试用utf-8读,失败则回退到gbk,再不行就尝试latin1。写一个健壮的读取函数:
def read_email(path): for enc in ['utf-8', 'gbk', 'gb2312', 'latin1']: try: with open(path, 'r', encoding=enc) as f: return f.read() except UnicodeDecodeError: continue raise ValueError('无法解码文件: ' + path)latin1是最后的兜底,它不会解码失败,但出来的字符串可能是乱码,需要后续清洗。如果是base64编码的邮件正文,需要先做base64解码,再走上面的编码尝试。这类情况在下载的老数据集里很常见,毕设项目给你整理的txt往往已经处理过,但你自己扩展数据集时一定会遇到。
5.2 现象:所有邮件都被预测为垃圾邮件
原因:训练数据比例严重失衡,垃圾邮件样本远多于正常邮件,或者正常邮件文件夹里混入了垃圾邮件,导致先验概率P(垃圾)接近1。MultinomialNB对先验概率很敏感,当某一类样本占比90%以上时,后验概率会被先验压住。
解决:先检查train_labels里的类别分布,用collections.Counter统计。如果spam数量是ham的两倍以上,做两类操作。一是对样本量大的类别降采样,随机抽到和少数类一样多;二是保持数据不变,手工调整模型先验概率。MultinomialNB的class_prior参数可以手动设定:
model = MultinomialNB(class_prior=[0.5, 0.5])class_prior里两个值分别对应正常和垃圾的先验概率。设成[0.5, 0.5]强制分类器忽略样本不均衡,让决策完全依赖词的条件概率。这个参数在真实场景中很好用,但毕设答辩时一定要解释清楚你调它的理由。
5.3 现象:训练集准确率99%,测试集只有80%
原因:典型的过拟合。特征空间太大、min_df设得太低、停用词表缺失,导致模型记住了训练集里的个别词组合,而不是泛化的垃圾邮件特征。
解决:先检查样本量是否过少,正常邮件和垃圾邮件加起来如果不足2000封,再好的模型也会过拟合。其次看min_df和max_features是否合理,把min_df从1提高到2,max_features从8000降到3000,过拟合会明显缓解。最后检查是否把整个邮件文本(包括发件人、主题、底部签名)都扔进了特征,这些区域包含大量无关的个人信息,例如公司名、问候语,应该从正文中剥离。预处理时只保留邮件正文部分,这个项目如果已经处理好,你自己爬新数据时就要注意。
5.4 现象:分词后“发票”被拆成“发”和“票”,专有名词被切开
原因:jieba默认词典没有收录这些业务词,精确模式会按最大概率路径切割,长词概率不够高就被拆开。
解决:把业务词加入jieba自定义词典。在项目里维护一个词典文件,比如dict.txt,每行一个自定义词加词频:
发票 10 中奖 10 信用卡 5训练前加载:
jieba.load_userdict('dict.txt')这比在分词函数里硬编码替换靠谱得多。垃圾邮件的核心词就那几十个,加完词典后spam类别的特征质量会显著提升。顺便说一句,不要在load_userdict之后再用jieba.regen,修改词典后要重新初始化分词器,否则旧分词结果会缓存失效。
6. 从毕设到可用:加载模型、增量训练与真实邮件测试的落地技巧
6.1 保存与加载模型:避免每次重新训练
毕设答辩演示时,每次都现场训练5分钟很尴尬。把训练好的模型和向量器持久化到磁盘,预测时直接加载。加载代码要放在一个独立函数里,确保模型和向量器版本配对:
import joblib def load_spam_model(model_path='models/spam_model.pkl', vec_path='models/vectorizer.pkl'): model = joblib.load(model_path) vectorizer = joblib.load(vec_path) return model, vectorizer这里有个坑:如果训练用的sklearn版本和加载用的版本不一致,joblib可能报错或静默加载出错误对象。建议requirements.txt里固定版本,比如scikit-learn==0.24.2,换环境时用pip install -r requirements.txt装齐。我自己遇到过升级sklearn后旧模型加载失败,最后只能重新训练的情况,从那以后每次保存模型我都会在模型文件里写入训练用的sklearn版本号。
6.2 批量测试与单封邮件预测的封装
真实使用场景不是调一次predict,而是对一批邮件做过滤。封装一个批量预测函数,输入邮件文本列表,输出垃圾邮件索引:
model, vectorizer = load_spam_model() def filter_spam(email_list, threshold=0.5): clean_list = [clean_text(t) for t in email_list] X = vectorizer.transform(clean_list) proba = model.predict_proba(X)[:, 1] # 垃圾邮件概率 spam_flags = proba > threshold return spam_flags, probathreshold参数可以外部调节。默认0.5,想减少误杀就调高到0.7,想增加拦截就降到0.3。我在本地用企业真实邮件测试时,0.6的阈值能把误杀率控制在2%以下。要提醒的是,transform和训练时的格式必须一致,传入的文本必须是字符串列表,哪怕只有一封邮件也要包成list。
6.3 增量学习的实现思路
朴素贝叶斯常被说成“一次性训练”的模型,但MultinomialNB其实支持partial_fit,可以在不重新训练全部数据的情况下更新模型。思路是:先用已有数据训练,之后每天拿到新标注邮件,调用partial_fit增量更新:
# 初始训练后,新邮件过来时 new_texts = [...] # 新标注的邮件 new_labels = [...] # 新标注的标签 X_new = vectorizer.transform(new_texts) model.partial_fit(X_new, new_labels)注意partial_fit第一次调用时必须传入classes参数,指定所有类别列表:
model.partial_fit(X_new, new_labels, classes=[0, 1])增量更新的前提是vectorizer不能变。如果新邮件里出现从未见过的生词,CountVectorizer.transform会把它们忽略,因为它们不在训练时的词汇表里。所以要真正做好增量,还得定期用全部语料重新fit vectorizer,或者用HashingVectorizer规避词汇表固定问题。HashingVectorizer不存储词汇表,而是用哈希函数把词映射到固定维度,但它牺牲了可解释性,毕设项目里用CountVectorizer更便于展示特征重要性。
做完这些改造,这个项目就不再只是一个能跑通的数据分类演示,而是一个可以对接真实邮件流的轻量级过滤组件。从拆代码到改代码,我最大的教训是:不要一上来就改模型,先把数据读对、编码搞对、特征向量器理解透,再动参数。每次跑完一组实验,记录下数据量、alpha、特征维度和混淆矩阵,三个月后再看这些记录,比任何理论都管用。希望这些踩坑记录能帮你少走一段弯路。
本文还有配套的精品资源,点击获取