☰
垃圾邮件分类实战:文本预处理与朴素贝叶斯/SVM模型对比
2026/9/26 15:00:59 网站建设 项目流程

简介:这份机器学习编程作业压缩包聚焦垃圾邮件分类任务,面向正在学习机器学习、需要完成编程实验或想掌握文本分类完整流程的初学者。资源围绕斯坦福机器学习课程练习六展开,涵盖邮件数据清洗、分词与去停用词、数值特征构建、分类器训练与交叉验证等环节,可用于理解朴素贝叶斯、支持向量机等算法在实际垃圾邮件检测中的应用。压缩包大小约874KB,内部为课程练习的代码框架、数据与指导说明,适合作为对照实现的参考。目前已有542人浏览学习。对于希望系统梳理垃圾邮件分类建模步骤的读者,这份资料提供了从原始邮件到特征向量、再到训练与调优的完整思路,能辅助巩固NLP特征工程与分类器评估方法,并在此基础上尝试调整模型参数或引入集成方法,进一步拓展自己的实验方案。

1. 垃圾邮件分类作业:先搞清楚这个.7z里装的是什么

拿到《机器学习编程作业垃圾邮件分类.7z》这种压缩包,多半是在补机器学习入门课的编程作业:里面是一批真实邮件的txt文本,目标只有一个——训练一个二分类模型,把spam和ham分开。这个作业比表面看起来更磨人,它把机器学习应用流程里的关键环节全串了一遍:文本预处理、特征维度控制、模型选择、评估指标权衡,哪一步偷懒都会在分数上现形。适合正在写作业的人,也适合想用Python把邮件过滤跑通的新手。别急着调模型,先解压看数据到底长什么样。

2. 解包与数据摸底:从.7z到能训练的邮件矩阵

2.1 用py7zr解压并遍历邮件目录

拿到.7z先别急着双击,直接在代码里解一次,后面所有步骤都能从头重跑。.7z不是zip,标准库zipfile读不了,常见做法是装py7zr,几行代码解干净。

import py7zr import os archive_path = "机器学习编程作业垃圾邮件分类.7z" extract_dir = "spam_data" with py7zr.SevenZipFile(archive_path, mode='r') as archive: archive.extractall(path=extract_dir) for root, dirs, files in os.walk(extract_dir): print(root, len(files)) for name in files[:3]: print(" ", name)

这段逻辑很简单:SevenZipFile打开压缩包,extractall解到本地目录,os.walk递归打印每个子目录的文件数和前几个文件名,用来确认包内结构。py7zr需要先安装,命令行执行pip install py7zr。解压后经常出现压缩包内部还套一层目录的情况,比如“spam_data/作业/train/”这种结构,不要慌,后续遍历时把根目录换到实际数据所在层即可。

接下来读邮件。课程作业里的邮件文件常见有两种形态:一种是标准RFC822格式,带From、Subject、Content-Type等头部;另一种是纯文本只有正文。为了兼容两种,我习惯用email模块按二进制读,而不是open默认的文本模式。

import email from email import policy def load_email(path): with open(path, 'rb') as f: msg = email.message_from_binary_file(f, policy=policy.default) subject = msg['subject'] or '' body = '' if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == 'text/plain': body += part.get_content() or '' # 只取纯文本,避开HTML和附件 else: body = msg.get_content() or '' return subject + '\n' + body

这里三个细节值得记。第一,用rb模式打开,把解码完全交给email模块,避免Windows下open默认编码和邮件实际编码打架。第二,policy=policy.default会让email按邮件头里的charset自动解码,遇到GBK、ISO-8859-1这类老编码也能扛住。第三,is_multipart做分支,walk遍历所有part只收text/plain,这一步顺手把HTML标签和附件二进制内容挡在特征之外。Subject单独取出来拼在正文前,因为很多垃圾邮件的标题本身就是强特征。

文件读完,把内容收集成samples和labels。如果压缩包目录结构是“ham/”和“spam/”两个文件夹,直接从目录名映射标签;如果文件名本身带标签,就用正则从文件名提取。我一般写一个循环统一处理:

samples = [] labels = [] for label_dir in ["ham", "spam"]: label = 1 if label_dir == "spam" else 0 folder = os.path.join(extract_dir, label_dir) for name in os.listdir(folder): path = os.path.join(folder, name) samples.append(load_email(path)) labels.append(label)

这步的坑在于目录名不统一。有的包叫spam/ham,有的叫positive/negative,还有的只靠文件名前缀区分。别写死,先跑一次os.listdir看输出再决定映射规则。标签顺序和样本顺序必须一一对应,后面任何shuffle都要同时作用于两边索引,否则模型学到的是错位标签。

2.2 划分训练集和验证集:随机种子不是玄学

数据读出来后第一件事不是建模,是从训练数据里分一块验证集出来。作业压缩包通常自带测试集,但调参阶段你不可能反复打开测试集看结果,那等于拿测试集做训练,提交时分数虚高。

from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( samples, labels, test_size=0.2, random_state=42, stratify=labels )

train_test_split的参数里,stratify=labels最容易被忽略。垃圾邮件数据集经常不均衡,spam可能只占一两成。不加stratify时,随机划分可能把spam样本全部划进验证集,训练集只剩ham,模型直接废掉。stratify会保证训练集和验证集内部的正负样本比例与原始数据一致。random_state=42也值得养成习惯:它让每次运行得到同样的划分,调试时才能判断效果变化来自模型改动还是数据波动。没有固定随机种子,你会同一份代码跑出两个结果,原地怀疑人生。

2.3 先数标签再定指标:不均衡数据从第一眼就要防

from collections import Counter print("train:", Counter(y_train)) print("val:", Counter(y_val))

如果spam和ham接近1:1,后面可以放心用accuracy。如果spam只占15%,你会发现一个什么都不学、把所有邮件都判成ham的模型准确率也有85%,但它在真实场景里毫无用处。看到不均衡数据后,评估指标要从accuracy换成f1或recall,这个决定必须在这一步就做,不然训练完对着漂亮的准确率报告兴奋半天,实盘时漏放了一大半垃圾邮件。

3. 特征工程与模型选型:朴素贝叶斯和SVM谁更适合这份作业

3.1 邮件文本预处理:去HTML、小写、词干化

sklearn和nltk是python机器学习常用包里处理这类文本任务最稳的两个搭档。特征工程的第一道工序是把原始文本变成干净的词序列。垃圾邮件里充斥着HTML残留、URL、各种花式拼写的促销词,这些噪音直接影响词袋质量。我一般用下面这个函数处理:

import re from nltk.stem import PorterStemmer from nltk.corpus import stopwords stemmer = PorterStemmer() STOP = set(stopwords.words('english')) def clean_text(text: str) -> str: text = text.lower() text = re.sub(r'<[^>]+>', ' ', text) # 去HTML标签 text = re.sub(r'http\S+', ' url ', text) # URL归一化成固定token text = re.sub(r'[^a-z0-9\s]', ' ', text) # 去标点和特殊字符 words = text.split() words = [stemmer.stem(w) for w in words if w not in STOP] return ' '.join(words)

逐个说为什么这么做。lower()把大写归一化,避免“FREE”和“free”变成两个特征;去HTML标签是因为富文本残留会把模型带偏;URL归一化成固定token 'url',是因为同一链接在每封邮件里的具体地址都不同,不归一化会生成大量只出现一次的无意义特征;去掉标点能消除“click!!!”和“click!!”这种变体。四个正则做完,文本基本只剩小写单词。

词干化是最后一个取舍点。PorterStemmer把“clicking”“clicked”变成“click”,把“payment”变成“pay”,显著压缩特征空间。但它也可能过度合并,比如把一些业务词干化成意外形态。对小数据集,我建议先做词干化再看特征词表,如果发现语义被破坏,就退回去只保留小写和去标点。这个决定要基于数据看,不要迷信。

停用词表用nltk的英文停用词,去掉the、a、and这类词。但要注意:通用停用词表是给通用英语场景用的,垃圾邮件里一些“通用词”反而有判别力,比如“free”出现在促销邮件里就是强特征。所以更稳的做法是预处理阶段不删停用词,交给向量化阶段的min_df去控制低频噪音。我现在倾向后者,省得来回调停用词表。

3.2 用TfidfVectorizer构建特征矩阵:五个参数怎么设

文本变成向量,课程作业里最标准的选择是TfidfVectorizer。它比CountVectorizer多做两件事:除以文档频率抑制“the”“and”这类到处出现的词,再归一化到单位长度。

from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( max_features=5000, min_df=2, ngram_range=(1, 2), sublinear_tf=True, norm='l2' ) X_train = vectorizer.fit_transform(train_clean) X_val = vectorizer.transform(val_clean) # 复用训练集词表,禁止重新fit

这里每个参数都对应一种常见的坑。max_features=5000把词表卡在5000维,防止几千封邮件产生几十万维稀疏矩阵,训练慢且容易过拟合;min_df=2删掉只在1封邮件里出现过的词,这些词大概率是拼写错误或随机噪音,留着只会让模型记住个别样本;ngram_range=(1,2)同时保留单词和相邻词对,垃圾邮件里的“click here”“free money”这类组合特征对判断很有帮助,代价是维度翻几倍,所以max_features要相应收紧;sublinear_tf=True把词频换成1+log(tf),压制某封邮件里重复十几遍的词对特征空间的垄断;norm='l2'保证每行向量长度相同,这直接影响后面线性SVM的收敛。

注意第二行:X_val用的是transform而不是fit_transform。vectorizer已经在训练集上fit过了,验证集只能做同样的变换,不能重新学词表。否则验证集里的生僻词会扩展出训练时没有的特征维度,模型要么报维度错误,要么特征顺序全变。

3.3 朴素贝叶斯和线性SVM的选型对比

特征矩阵就绪后,课程作业里最常见的两个模型是朴素贝叶斯和线性SVM。它们在文本分类上表现都不错,但原理和调参方向完全不同。MultinomialNB的独立性假设是机器学习假设里最典型的一条——明知特征之间并不独立,但经验上好用;LinearSVC则在高维空间找最大间隔超平面,更适合对特征做精细控制。

from sklearn.naive_bayes import MultinomialNB from sklearn.svm import LinearSVC nb = MultinomialNB(alpha=0.1) svm = LinearSVC(C=1.0, class_weight='balanced')

MultinomialNB只有一个主要参数alpha,做拉普拉斯平滑,防止某个词在某一类邮件中没出现导致概率直接变成零。alpha设多少?我一般从0.1开始调,因为alpha=1.0(sklearn默认)在特征多、样本少时平滑力度过大,会把真实差异抹平;alpha=0.1保留更多原始信息。

LinearSVC适合文本特征有两个原因:一是特征高维但稀疏,线性核就够了,不需要RBF;二是TfidfVectorizer的l2归一化让每个样本在同尺度下,SVC不会因为某封邮件特别长而偏向它。需要调的主要是C和class_weight。C控制正则强度,C越小泛化越好,默认1.0可以接受;class_weight='balanced'会在不均衡数据里自动给少数类更高权重,比手工调阈值省事。

选型建议分场景。如果作业只要求跑通并给出准确率,直接MultinomialNB,参数少、可解释、训练快;如果要求分析模型表现和调参过程,LinearSVC配合PR曲线调阈值更有内容写。这份作业和吴恩达机器学习作业里那个spam分类的mat数据不同,那边已经向量化好了直接喂SVM,而这里的txt邮件要求你自己走完文本处理到特征构建,工作量和坑都大得多。两个模型必须用同一套向量化特征比较,否则分差来自特征而不是模型。

4. 训练与评估:准确率之外,垃圾邮件还该看什么

4.1 交叉验证与超参数搜索:为什么评分用f1

训练和评估要放在一个闭环里。cross_val_score可以快速估算模型稳定性:

from sklearn.model_selection import cross_val_score scores = cross_val_score(nb, X_train, y_train, cv=5, scoring='f1') print("f1 = %.4f ± %.4f" % (scores.mean(), scores.std()))

cv=5表示五折交叉验证,把训练集分成5份,轮流用4份训练、1份验证,最后取平均。这比单次train_test_split更稳,方差小说明模型对数据划分不敏感。scoring='f1'而不是accuracy,是因为前面已经确认数据可能不均衡,accuracy会被多数类带偏,f1同时惩罚漏报和误报。

超参数搜索建议只对alpha做一次小网格,别贪多:

from sklearn.model_selection import GridSearchCV param_grid = { 'alpha': [0.01, 0.1, 0.5, 1.0], 'fit_prior': [True, False] } gs = GridSearchCV(MultinomialNB(), param_grid, cv=5, scoring='f1') gs.fit(X_train, y_train) print(gs.best_params_, gs.best_score_)

fit_prior控制是否从训练数据学习先验概率,False时假设两类各占一半。不均衡数据下一般保持True,让模型用真实比例做先验。GridSearchCV内部会做完整的交叉验证,所以不要再在外面套一层train_test_split做验证,否则评估会失真。

4.2 评估指标:垃圾邮件场景优先看召回率还是精确率

训练完打印完整的classification_report:

from sklearn.metrics import classification_report, confusion_matrix y_pred = svm.predict(X_val) print(classification_report(y_val, y_pred, target_names=['ham', 'spam'])) print(confusion_matrix(y_val, y_pred))

输出里有precision、recall、f1。垃圾邮件场景下,spam类的recall意味着有多少垃圾邮件被拦下来了;spam类的precision意味着预测为spam的样本里有多少是真的垃圾邮件。业务上怎么选:个人邮箱场景,误杀一封正常邮件比漏放一封垃圾邮件更让用户恼火,优先保precision;公司反垃圾网关场景,漏放垃圾邮件会持续骚扰所有人,recall更重要。课程作业通常不讲业务背景,所以两个数字都要看,并在报告里解释清楚取舍依据,这比只贴模型名和准确率值钱。

4.3 用PR曲线和混淆矩阵回看模型行为

混淆矩阵四格对应四种情况:spam判对、ham判成spam、ham判对、spam判成ham。每次训练完我都会把假正例和假负例的原始文本打出来看,比任何指标都直观。PR曲线用来选阈值:

from sklearn.metrics import precision_recall_curve proba = nb.predict_proba(X_val)[:, 1] precision, recall, thresholds = precision_recall_curve(y_val, proba)

画出曲线后在作业报告里说明:precision和recall的平衡点在哪,阈值设在多少能让业务损失最小,这比写一句“模型表现良好”有说服力得多。

注意:LinearSVC没有predict_proba,它只有decision_function输出的距离分数。要画PR曲线时,要么用decision_function的结果替代,要么换MultinomialNB。

5. 垃圾邮件分类作业避坑指南:5个翻车现场

这个作业翻车率远高于表面难度,下面5个现场是我见过最多、自己也踩过的。

5.1 中文邮件乱码:读出来全是锟斤拷

现象:用open(path).read()直接读邮件,打印出来不是正常文本,而是一堆占位替换符。

原因:邮件文件是老编码,常见的有GBK、GB2312,或者邮件头声明的charset与实际编码不一致。open默认按系统编码解码,Windows下往往是UTF-8,遇到GBK字节流就解出乱码。

解决:读取改成email模块的二进制路径,用policy=policy.default让email按邮件头解码;如果邮件没有头,用chardet检测bytes再解码。出现乱码时不要手工替换字符,先看原始bytes前几十个字节,判断是编码问题还是文件本身损坏。

5.2 HTML标签和附件污染特征

现象:训练完看特征权重,top词全是html、body、img、table。

原因:load_email把所有part都拼进了正文,HTML源码和附件文本进入特征空间,模型学到的是页面结构词,不是语言语义。

解决:在load_email里只收text/plain的part。如果整封邮件只有HTML没有纯文本part,可以先用正则剔除标签再收进正文,但这是下策,因为HTML源码里还可能有脚本和样式内容。作业数据里通常都能找到纯文本版本,优先走干净路径。

5.3 测试集重新fit_transform导致维度不一致

现象:模型预测时报错,提示特征数量不一致或dimension mismatch。

原因:拿到测试集后又做了一遍fit_transform,测试集的词汇表和训练集词汇表不一样,特征矩阵列数自然也变了。

解决:vectorizer只fit一次,所有后续数据都调用transform。把vectorizer和classifier包进Pipeline能从根上杜绝这个问题,fit时学到的所有状态会在predict时自动复用于新数据。

# 错误:测试集重新fit X_test = vectorizer.fit_transform(test_clean) # 正确:复用训练集词表 X_test = vectorizer.transform(test_clean)

提示:把vectorizer和classifier包进Pipeline,fit和predict自动复用同一套转换状态,这比手动保存两个对象省心得多。

5.4 朴素贝叶斯出现零概率

现象:训练不报错,但预测时某条样本的log_probability出现-Inf,或者predict_proba输出极端0/1。

原因:某个词在spam类中出现、在ham类中完全没出现,未平滑的概率计算会直接给0。MultinomialNB默认alpha=1.0时一般不出现,但如果你手写朴素贝叶斯,或者把alpha设为接近0,就会踩。

解决:保持alpha=0.1或更高。手写实现时,分子加alpha、分母加alpha乘以词汇表大小,就是拉普拉斯平滑的标准形式。这个坑在手写作业里最坑,公式抄错一位符号,结果全乱。

5.5 .7z解压后文件名编码异常导致路径报错

现象:Windows下py7zr解压成功,os.listdir也能列出文件名,但拼接路径后open报FileNotFoundError,文件名看起来是乱码。

原因:压缩包内部文件名可能是GBK编码,常见于Windows下打包的课程资源,py7zr默认按UTF-8解压,解出来一串乱码字符,与实际文件系统对不上。

解决:解压时尝试指定encoding参数;或者解压后批量重命名。最省事的做法是先用7-Zip手工解一遍,观察文件名是否正常,再用Python处理目录;如果手工解也乱,就先转码文件名。这个坑不是垃圾邮件任务独有的,拿到任何中文资源包都可能遇到。

6. 收尾技巧:把模型导出成能复用的spam_filter

6.1 用joblib保存完整pipeline而不是裸模型

作业要求可能只是交一个脚本,但既然已经跑通了,多花两分钟把它变成可复用的小工具更值。常见做法是把vectorizer和classifier串成Pipeline,再用joblib整体导出:

from sklearn.pipeline import Pipeline from joblib import dump, load pipe = Pipeline([ ('vec', TfidfVectorizer( max_features=5000, min_df=2, ngram_range=(1, 2), sublinear_tf=True )), ('clf', MultinomialNB(alpha=0.1)) ]) pipe.fit(train_clean, y_train) dump(pipe, 'spam_filter.joblib')

只保存分类器的joblib文件,换个环境就报错,因为预测时还依赖同一个词表和特征顺序。Pipeline把特征工程和模型打包后,加载一个对象就恢复全部状态,这是我在所有作业里的固定习惯。机器学习的“模型”在这里不只是分类器,vectorizer才是先决状态。

6.2 新邮件预测调用链

导出之后,新邮件走完整调用链只需要一个函数:

def predict_spam(raw_text, pipeline_path='spam_filter.joblib'): clf = load(pipeline_path) cleaned = clean_text(raw_text) prob = clf.predict_proba([cleaned])[0][1] return prob

predict_proba返回二维数组,第0行对应第一封输入邮件,第1列是spam类概率。拿到概率后自己定业务阈值——0.5是默认值,但如果你在PR曲线上看到甜点在0.3,就直接用0.3判断。这个函数就是一个小型邮件过滤系统的雏形,往后接邮件接收流程、告警、日志都从它开始。

6.3 一个验证习惯:每次训练完先打印错分样本

最后一件事,也是我想重点说的习惯:每次训练完,把验证集里预测错的前20条样本打印出来,一行一行读。多数时候你会发现,问题不是模型不行,而是某个处理环节把关键信息丢了——比如附件文件名进了特征、URL归一化把域名提示全抹掉、邮件头里的回复地址被当成正文。这个回看习惯比任何调参都有效。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询