恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
机器学习作业实战:垃圾邮件分类完整流程与避坑指南
首页
资讯中心
/
机器学习作业实战:垃圾邮件分类完整流程与避坑指南
机器学习作业实战:垃圾邮件分类完整流程与避坑指南
发布时间:2026/10/13 21:52:28
简介这是以垃圾邮件分类为主题的机器学习编程作业压缩包面向机器学习初学者与正在学习文本分类、支持向量机等算法的学生可用于对照课程练习要求完成实验。压缩包体积约874KB围绕machine-learning-ex6提供练习代码、邮件数据集与指导文档整体轻量适合快速下载后直接搭建实验环境。该资源已有543人浏览学习对于需要参考经典垃圾分类作业实现思路的读者而言节省了从零整理数据与代码的时间。任务覆盖邮件文本预处理分词、去停用词、词干提取、特征工程词频统计、特殊字符计数、模型构建朴素贝叶斯、SVM、逻辑回归与评估准确率、精确率、召回率、F1及ROC曲线。通过完成该作业读者能系统掌握文本分类的完整链路学会用交叉验证和超参数调优提升泛化能力并借助特征重要性分析理解模型决策依据为后续真实邮件过滤项目打下基础。1. 机器学习编程作业垃圾邮件分类从数据集到评分报告一篇够用的落地路径如果你正在做一门机器学习课的编程作业题目是垃圾邮件分类大概率你已经发现网上教程一堆但真正能让你把作业交上去、拿个不低的分数的完整路径反而没人讲。这个作业本质是文本二分类——把邮件正文和标题转成数值特征训练一个分类器判断它是正常邮件还是垃圾邮件。它覆盖了机器学习最标准的流程数据清洗、特征提取、模型选型、评估与调参所以几乎所有入门课程包括吴恩达那套和很多学校的期末大作业都会拿它当课程设计选题。这篇笔记就按我实际提交作业的顺序把整个流程拆开讲清楚每一步用什么代码、参数怎么设、哪些地方会翻车以及为什么这样选。2. 原始邮件转成干净文本三种加载格式、清洗正则与分词边界2.1 邮件数据集长什么样三种常见格式与加载方式这个作业的第一步不是建模而是把 .7z 解压出来的数据变成 Pandas DataFrame。不同老师给的数据格式不一样常见做法有三种我建议你拿到数据后先花五分钟确认格式再决定加载代码省得后面全乱。第一种是 CSV 文件两列label和text。label 是spam或hamtext 是邮件正文。这是最省事的格式加载代码一行搞定import pandas as pd df pd.read_csv(spam_data.csv, encodingutf-8) print(df.shape) print(df[label].value_counts())逻辑说明先看数据量和标签分布确认有没有明显的类别不均衡——如果 spam 只占 10%后面评估指标就得换思路不能只看准确率。encoding参数建议先试utf-8报错就换gbk或latin1邮件数据集里这两种编码很常见。第二种是文件夹结构spam/和ham/两个目录下各有一堆文本文件。这种格式需要用os.listdir遍历读取import os def load_from_folders(spam_dir, ham_dir): data [] for label, folder in [(spam, spam_dir), (ham, ham_dir)]: for fname in os.listdir(folder): path os.path.join(folder, fname) with open(path, r, encodingutf-8, errorsignore) as f: data.append({label: label, text: f.read()}) return pd.DataFrame(data) df load_from_folders(spam, ham)逻辑说明errorsignore是个保命参数——有些邮件文件里有非法编码字符直接open会抛异常忽略掉这些坏字符虽然会损失一点内容但至少不会让程序中断。如果你发现加载后很多文本是空的再回头检查是不是errors把内容吞多了。第三种是 .eml 原始邮件文件里面有完整的邮件头From、To、Subject和 multipart 正文。这种格式最接近真实场景但处理也最麻烦。我一般只用email标准库做轻量解析import email from email import policy with open(sample.eml, rb) as f: msg email.message_from_binary_file(f, policypolicy.default) text if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() text/plain: text part.get_content() else: text msg.get_content() print(Subject:, msg[Subject]) print(Body:, text[:500])逻辑说明这里只提取text/plain部分忽略text/html。原因很简单HTML 里有大量标签噪声而且很多垃圾邮件故意把正文嵌在 HTML 里绕过滤规则对初学者来说先只处理纯文本部分结果更稳定。等你想提分了再回来把 HTML 里的可见文本也提取出来。2.2 文本清洗正则表达式去掉 HTML 标签、URL、邮箱与数字原始邮件文本非常脏。垃圾邮件里最常见的就是链接、促销数字、特殊符号这些对分类器来说不但没用还会把特征维度撑大。清洗的目标是保留有语义的词去掉纯格式噪声。import re def clean_text(text: str) - str: # 去掉 HTML 标签 text re.sub(r[^], , text) # 去掉 URL text re.sub(rhttp[s]?://\S|www\.\S, , text) # 去掉邮箱地址 text re.sub(r\S\S, , text) # 去掉数字保留纯字母文本 text re.sub(r\d, , text) # 去掉多余标点只保留字母、空白、基本符号 text re.sub(r[^\w\s], , text) # 合并多个空格 text re.sub(r\s, , text).strip() return text.lower() df[clean_text] df[text].apply(clean_text)逻辑说明每个re.sub都是独立一步注释里写清楚了删什么。最后.lower()统一成小写是为了让 Win 和 win 被当成同一个特征否则特征维度翻倍却没有任何信息增益。参数说明[^\w\s]这个 pattern 是「保留单词字符和空白其余全删」。对英文邮件够用但如果你拿到的是中文数据集\w在 Python 的 re 模块里默认不匹配中文需要改成[^0-9a-zA-Z\u4e00-\u9fff\s]否则中文全被删光。这个坑我踩过一次检查清洗结果时发现中文邮件全成了空格串差点提交了个空模型。2.3 分词与停用词中英文作业的处理差异清洗完就是分词。英文简单空格按空白切分就行中文必须用分词库常见做法是 jieba。这个环节的错误典型是把split( )用在中文数据上结果每个汉字都是独立的 token模型结果一塌糊涂。# 英文数据集直接按空白切分 def tokenize_en(text: str) - list: return text.split() # 中文数据集用 jieba 分词 import jieba def tokenize_zh(text: str) - list: return [w for w in jieba.lcut(text) if w.strip()]分词之后的下一步是去停用词。停用词是「the」「a」「的」「了」「在」这类高频但无语义的词留着它们只会稀释真正有判别力的特征。停用词表不用自己造主流方案是加载公开的停用词表文件常见做法是放一个stopwords.txt在项目目录里按行读取def load_stopwords(pathstopwords.txt): with open(path, r, encodingutf-8) as f: return set(line.strip() for line in f) stopwords load_stopwords() def filter_stopwords(tokens: list) - list: return [w for w in tokens if w not in stopwords]参数说明停用词过滤在分类任务里不是必须的——逻辑回归和朴素贝叶斯对停用词并不敏感因为它们的权重学得出来。但用了之后有两个实际好处特征维度下降、训练速度变快而且如果你后面要拿 TF-IDF 特征做可视化留下停用词会让高频词全部占据主导词云和图谱完全没法看。所以我的建议是作业阶段就老老实实过滤掉省得后面返工。这一步做完整理一下加载数据 → 清洗 → 分词 → 去停用词得到的 tokens 列表才是模型的输入。很多初学者把这四步混在一个函数里调参时想单独看哪一步出了问题都没法定位。我习惯把它们拆成独立函数每个函数只干一件事这样后面跑网格搜索或者换数据集时只需要改对应的函数。3. 特征工程是胜负手词袋与 TF-IDF 的参数怎么设才不翻车3.1 词袋模型与 CountVectorizer 的四个关键参数文本本身不是数值模型读不懂。词袋模型是最直观的转换方式统计每个词在每封邮件里出现的次数生成一个「文档-词频」矩阵。scikit-learn 里的CountVectorizer可以一把梭完成分词和向量化但直接无脑调用会出问题。from sklearn.feature_extraction.text import CountVectorizer vectorizer CountVectorizer( tokenizertokenize_en, max_features5000, min_df2, max_df0.8, ngram_range(1, 2), stop_wordsNone # 我们已经自己过滤过这里不重复处理 ) X vectorizer.fit_transform(df[clean_text])逻辑说明fit_transform做了两件事——fit阶段遍历所有文档建立词表transform阶段把每封邮件映射成一个稀疏向量。这个矩阵的行数是邮件数量列数是词表大小。参数说明这 4 个参数是词袋模型的核心调节项直接决定特征维度。参数作用建议初始值备注max_features限制特征总数只保留词频最高的前 N 个词5000训练集小就设 2000~3000防止维度爆炸min_df词至少要在多少篇文档中出现低于则丢弃2过滤只在 1 篇邮件里出现的生僻词max_df词在超过多少比例的文档中出现则丢弃0.8 80%0.8去掉所有邮件都有的通用词ngram_range保留单词还是词组(1, 2)加 bi-gram 能抓住 free money 这类短语3.2 TF-IDF 为什么更适合垃圾邮件分类词袋模型有个硬伤长邮件天然词频高但这不代表它更像垃圾邮件。TF-IDF 解决了这个问题——它用词频乘以逆文档频率一个词在某封邮件里出现得多、但在整个数据集里很少出现这个词才有高权重。对垃圾邮件来说免费「中奖」「点击」这类词在正常邮件里极少出现TF-IDF 会给它们比词袋更高的分数学习效率明显更好。from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( tokenizertokenize_en, max_features5000, min_df2, max_df0.8, ngram_range(1, 2), sublinear_tfTrue, norml2 ) X vectorizer.fit_transform(df[clean_text])逻辑说明sublinear_tfTrue表示对词频做1 log(tf)变换目的是削弱某个词在某封邮件里疯狂重复的影响——垃圾邮件里经常把一个词刷几十遍比如 FREE FREE FREE。不做这个变换这种重复直接线性放大权重模型会误以为这词极其重要。参数说明norml2是默认的向量归一化方式做完之后每行向量的模为 1长度差异被抹平。不要改成None因为后续模型SVM、逻辑回归对特征尺度敏感归一化能显著加快收敛。从最终作业分数来看同一个模型用 TF-IDF 特征几乎总比词袋高 1~3 个百分点。这 1~3 个点在中考评分标准里可能就是 A- 和 A 的差距。我第一次提交直接用词袋F1 分数卡在 0.91换到 TF-IDF 没动任何模型参数直接涨到 0.93。3.3 标签不均衡用什么指标评估才不算作弊垃圾邮件数据集里 ham 和 spam 的比例往往不是 1:1常见的是 8:2 甚至 9:1。这时候如果你用 accuracy准确率当评估标准会出现一种假象模型把所有邮件都预测成 ham准确率也有 80%~90%看起来成绩不错实际模型是个废柴——所有垃圾邮件都漏掉了。from sklearn.metrics import classification_report, confusion_matrix print(classification_report(y_test, y_pred, target_names[ham, spam])) print(confusion_matrix(y_test, y_pred))逻辑说明classification_report会输出每个类别的 precision、recall、F1-score以及整体 accuracy。你要重点看的是spam这一行的 recall——它表示「真实垃圾邮件里有多少比例被正确识别出来了」。recall 低意味着垃圾邮件大量漏掉这在作业评分标准里通常扣分最狠因为垃圾邮件分类的核心诉求就是「不漏」。参数说明如果分类报告显示 spam 的 recall 明显低于 precision典型调节方向有两个一是靠后文会讲的阈值调整二是换用class_weightbalanced让模型在训练时给少数类更高惩罚权重。第二种做法的代码逻辑是在损失函数里放大垃圾邮件的错分代价强行把模型注意力从多数类拉回来。4. 模型训练与调参朴素贝叶斯、逻辑回归、SVM 谁的作业分更高4.1 三个候选模型训练代码与选型理由垃圾邮件分类是二分类问题常见做法是拿三个模型做对比朴素贝叶斯、逻辑回归、线性 SVM。这三个各有特点朴素贝叶斯训练最快对小数据集和高维稀疏文本特征非常友好是这题最稳的基线逻辑回归可解释性好输出的概率可以直接拿来调阈值线性 SVM 在文本分类上通常精度最高但对特征尺度敏感务必用标准化的 TF-IDF 特征。from sklearn.model_selection import train_test_split from sklearn.naive_bayes import MultinomialNB from sklearn.linear_model import LogisticRegression from sklearn.svm import LinearSVC # 先把数据切分再单独对训练集做特征适配 X_train, X_test, y_train, y_test train_test_split( df[clean_text], df[label], test_size0.2, random_state42, stratifydf[label] ) # 注意Vectorizer 只用训练集 fit防止测试集信息泄露 X_train_vec vectorizer.fit_transform(X_train) X_test_vec vectorizer.transform(X_test) models { MultinomialNB: MultinomialNB(alpha0.5), LogisticRegression: LogisticRegression(max_iter1000, C1.0), LinearSVC: LinearSVC(C1.0, max_iter2000), } for name, model in models.items(): model.fit(X_train_vec, y_train) print(name, model.score(X_test_vec, y_test))逻辑说明代码里最关键的是vectorizer.fit_transform(X_train)和vectorizer.transform(X_test)的区分——特征提取器只能接触训练集测试集在fit阶段必须完全隐身。这是数据泄露的高发点几乎每届作业都有人栽在这把整个数据集 fit 完再划分测试集的统计信息混进了词表模型评估成绩虚高一上真实数据就现原形。参数说明MultinomialNB的alpha是拉普拉斯平滑系数默认 1.0但如果特征词表很大调小到 0.1~0.5 往往能提升精度LogisticRegression的C是正则化强度的倒数C越小正则化越强默认 1.0后面会讲怎么调LinearSVC的C类似但它的默认损失函数是 hinge loss对离群点更敏感max_iter太小会不收敛报 ConvergenceWarning。4.2 网格搜索与交叉验证评分高的模型是搜出来的手工调参有上限而且很玄学。课程作业想稳定拿高分做法是把关键参数丢进GridSearchCV用交叉验证自动搜索。这样你交上去的作业里能写清楚「最优参数由 5 折交叉验证得出」本身就是加分项。from sklearn.model_selection import GridSearchCV from sklearn.pipeline import Pipeline pipeline Pipeline([ (tfidf, TfidfVectorizer(tokenizertokenize_en)), (clf, LogisticRegression(max_iter1000)), ]) param_grid { tfidf__max_features: [3000, 5000, 8000], tfidf__ngram_range: [(1, 1), (1, 2)], tfidf__sublinear_tf: [True, False], clf__C: [0.1, 1.0, 10.0], } grid GridSearchCV( pipeline, param_grid, cv5, scoringf1, n_jobs-1, verbose1 ) grid.fit(X_train, y_train) print(Best params:, grid.best_params_) print(Best CV score:, grid.best_score_)逻辑说明用Pipeline把特征提取和分类器包在一起GridSearchCV在每折交叉验证里都会重新做一次fit_transform既避免了数据泄露又能搜到从特征到模型的最优组合。scoringf1是关键——前面说了类别不均衡时 accuracy 是假指标这里直接用 F1 打分模型搜索会偏向对垃圾邮件识别更均衡的方案。参数说明cv5表示 5 折交叉验证训练集被切成 5 份轮流拿 1 份验证、4 份训练。n_jobs-1让所有 CPU 核心并行跑能省大量时间——如果数据集大建议从[3000, 5000]这类小范围开始搜先确认哪些参数方向有效再加密网格。血泪经验网格搜索组合数是各参数取值个数的乘积上面的参数组合是 3×2×2×3 36 组 × 5 折 180 次训练。如果跑起来太慢优先缩小max_features的范围它对训练速度影响最大其次是去掉ngram_range的二元组选项。4.3 阈值调整让垃圾邮件分类更符合实际代价分类模型默认以 0.5 作为判定边界——预测概率大于 0.5 判为正类spam否则判为负类ham。但垃圾邮件分类的代价是不对称的把正常邮件误判成垃圾误报的代价通常远低于把垃圾邮件放进来漏报的代价。所以调低阈值到 0.3~0.4 往往更实用。from sklearn.metrics import f1_score # 用逻辑回归或朴素贝叶斯拿到概率而不是硬分类 model LogisticRegression(max_iter1000, C1.0).fit(X_train_vec, y_train) proba model.predict_proba(X_test_vec)[:, 1] # 取 spam 类的概率 for threshold in [0.3, 0.4, 0.5, 0.6, 0.7]: y_pred (proba threshold).astype(int) print(fthreshold{threshold:.1f}, F1{f1_score(y_test, y_pred):.4f})逻辑说明predict_proba返回两个列分别是属于 ham 和 spam 的概率这里取第二列spam 的概率。循环里逐个试阈值观察 F1 随阈值变化的曲线找到峰值的那个阈值再用(proba threshold)生成最终的硬标签。参数说明注意LinearSVC没有predict_proba它只能输出硬标签所以这个调阈值方案只适用于朴素贝叶斯和逻辑回归。还有一种方式是自定义决策函数用model.decision_function(X)拿原始得分再在得分上找最优偏移量但这超出了绝大多数课程作业的需求不展开讲。5. 垃圾邮件分类必踩的五个坑现象、原因与解法5.1 训练集和测试集特征泄露准确率虚高到 99% 却被打回重写现象模型在测试集上的准确率高达 99%你很高兴地交上去结果助教说成绩无效。原因你把CountVectorizer或TfidfVectorizer在fit_transform时用在了全量数据上之后再切分训练集和测试集。测试邮件里的词表和词频统计已经参与了特征构建模型相当于提前看到了测试答案。解决严格遵循「先切分、再特征提取」的顺序——vectorizer.fit_transform(X_train)只碰训练集然后vectorizer.transform(X_test)。写作业时在代码注释里显式标注这两行的区别评分老师看到这个细节会确认你理解了数据泄露问题。5.2 清洗后文本全是空字符串现象df[clean_text]某些行的长度为零喂给fit_transform报 empty vocabulary 或 value of min_df 0 的错误。原因两种典型情况——中文数据用了英文正则清洗规则[^\w\s]把汉字全删了或者所有停用词过滤后一条短邮件只剩停用词被清成了空列表。解决清洗之前先打印df[text].head()确认语言和格式清洗之后立即检查空字符串数量。如果是因为停用词过滤太狠把filter_stopwords改为「只过滤长度大于 1 的停用词」或者保留词表中出现次数最高的停用词给模型一点兜底特征。5.3 编码问题导致乱码gbk vs utf-8 的经典翻车现象加载 CSV 时乱码或者训练时UnicodeDecodeError: utf-8 codec cant decode byte 0xc4 in position ...。原因邮件数据集来自不同渠道有的用utf-8有的用gbk或gb2312国内爬取的数据尤其常见。一个encoding参数写死就会翻车。解决读取时用一个「编码回退」方案——先试utf-8失败再试gbk和latin1def read_text(path, encodings(utf-8, gbk, latin1)): for enc in encodings: try: with open(path, r, encodingenc) as f: return f.read() except UnicodeDecodeError: continue raise RuntimeError(fCannot decode file: {path})逻辑说明按优先级逐个尝试编码直到成功读入。latin1是最后的兜底——它能把任意字节映射成字符不报错但中文会变成乱码。所以latin1只能用于英文数据集中文数据如果utf-8和gbk都失败说明文件本身损坏直接跳过更明智。5.4 正则化参数调过头模型欠拟合分数反而更低现象改了LogisticRegression(C0.001)或LinearSVC(C0.001)之后训练集准确率从 0.95 掉到 0.86测试也同步变差。原因C太小意味着正则化惩罚太重模型权重被压得接近零学不动训练数据里的真实模式。这是初学者常犯的「调参方向反了」错误——以为参数越小越稳妥实际是把模型摁死在欠拟合状态。解决用网格搜索而不是手调。如果你搜出来的最优C落在网格边界比如 10.0 是边界值说明方向没搜到头重新把范围改到[10, 50, 100]再搜。另一个快速自查方式是打印训练集得分训练集得分低必然欠拟合训练集得分高但测试集得分低才是过拟合两个问题的调参方向完全相反。5.5 预测阶段用了和训练阶段不同的预处理流程现象交叉验证分数尚可但把模型拿去预测新邮件时结果极差甚至报特征维度不匹配错误。原因训练时你在clean_text函数里加了某种清洗规则预测时写了个简化版只split()不清理特殊符号——预测入口的 token 和训练词表对不上模型看到全是「陌生词」。解决把「清洗 → 分词 → 向量化」这三步强制打包进一个Pipeline对象里训练和预测都用同一个 pipeline 调用不要让预处理函数散落在文件各处。如果作业要求你自己写函数那就单独建一个preprocess.py模块统一导出preprocess(text) - tokens训练脚本和预测脚本都从模块导入避免复制粘贴导致的版本漂移。6. 从跑通到高分错误分析、模型融合与一条验证习惯6.1 错误分析把预测错的样本打印出来才知道模型差在哪光看指标数字提升不起来的时候最有效的动作是打印错例。把被误判的邮件原文和预测概率拉出来看十行问题往往立刻暴露——可能是某种特殊文本格式没清洗干净可能是某个高频词把模型带偏了。import numpy as np proba model.predict_proba(X_test_vec)[:, 1] threshold 0.4 y_pred (proba threshold).astype(int) misclassified X_test[(y_pred ! y_test).to_numpy()] true_labels y_test[(y_pred ! y_test).to_numpy()] pred_labels y_pred[(y_pred ! y_test).to_numpy()] for i in range(min(10, len(misclassified))): print(fTRUE{true_labels.iloc[i]}, PRED{pred_labels[i]}) print(misclassified.iloc[i][:300]) print(---)逻辑说明这段代码把预测和真实标签不一致的样本过滤出来打印正文片段。我做过一次作业错例里大量是「带大量 HTML 但 text/plain 部分为空」的邮件清洗阶段把它们洗成了空文本模型只能猜。看完错例我回去改进清洗逻辑F1 一下子涨了 0.5 个点。6.2 简单有效的融合三个模型投票分数稳定上升如果时间允许做一个低成本融合让朴素贝叶斯、逻辑回归、线性 SVM 各自预测取多数投票作为最终结果。实践里往往比单模型高 0.5~1 个点而且能显著降低模型之间的方差。from sklearn.svm import LinearSVC from collections import Counter nb MultinomialNB(alpha0.5).fit(X_train_vec, y_train) lr LogisticRegression(max_iter1000, C1.0).fit(X_train_vec, y_train) svm LinearSVC(C1.0, max_iter2000).fit(X_train_vec, y_train) preds_nb nb.predict(X_test_vec) preds_lr lr.predict(X_test_vec) preds_svm svm.predict(X_test_vec) final_pred [] for i in range(len(y_test)): votes Counter([preds_nb[i], preds_lr[i], preds_svm[i]]) final_pred.append(votes.most_common(1)[0][0]) print(F1 of ensemble:, f1_score(y_test, final_pred))逻辑说明逐条样本取三个模型预测结果的众数。三个模型学到的决策边界不同投票能抵消单模型的偏差尤其在类别边界附近的样本上融合的稳定性明显好于单模型。6.3 一条验证习惯任何改动先看交叉验证分数再看测试分数我交作业前最后一遍检查永远重复同一个流程把数据集重新跑一次完整的train_test_split特征提取、网格搜索、预测评估一步不落地回放。任何清洗逻辑或参数的改动先看交叉验证分数如果它没提升测试分数再高也不提交——因为那很可能是随机性带来的假象。垃圾邮件分类这个题目不难真正拉开分差的往往就是数据泄露、编码、预处理流程不一致这些细节而不是模型本身有多花哨。希望这篇笔记能帮你少踩几个坑一次交出自己满意的版本。本文还有配套的精品资源点击获取