垃圾邮件分类作业避坑指南:从文本清洗到模型调优的完整流程

发布时间:2026/10/22 18:14:33
垃圾邮件分类作业避坑指南:从文本清洗到模型调优的完整流程
简介这份机器学习编程作业资源聚焦垃圾邮件分类任务面向正在学习机器学习、需要动手实践分类模型的学生与初学者。压缩包内以代码、数据集与指导文档为主围绕邮件文本的预处理、特征工程、算法选型、模型评估与优化等环节展开帮助读者完整走通从数据清洗到模型部署的实践链路。资源包大小约874KB文件总数与类型明细上游暂未提供但可预期包含练习代码、样本数据及说明文档等核心内容。目前已有543人学习下载说明该作业在入门群体中具有一定参考价值。读者可借助其中的数据集与代码框架练习分词、去停用词、词频特征构建并对比朴素贝叶斯、SVM、逻辑回归等算法的分类效果同时通过准确率、精确率、召回率与F1分数等指标评估模型进而理解超参数调整与特征重要性分析的实际意义是巩固机器学习理论并提升编程能力的合适练习素材。1. 垃圾邮件分类作业为什么总在朴素贝叶斯上翻车很多人第一次拿到「机器学习编程作业垃圾邮件分类.7z」这类压缩包第一反应是解压、找.py、跑一遍、看准确率然后交差。但真正动手之后会发现同样的数据、同样的算法有人跑出 98% 的准确率有人只有 70% 出头差别往往不在模型本身而在文本预处理和特征构造这两步。垃圾邮件分类是机器学习里最经典的文本二分类任务它的核心不是把模型调多深而是把一封邮件变成一组能区分「垃圾」和「正常」的数值特征。这个作业适合刚学完朴素贝叶斯、逻辑回归、TF-IDF 的初学者也适合想补一遍完整文本分类流程的从业者。它解决的是一个非常具体的问题给你一堆带标签的邮件文本怎么训练出一个能对新邮件判断「是垃圾还是正常」的分类器并且让这个判断过程可解释、可复现。2. 从压缩包到可训练数据邮件文本的读取与清洗2.1 先搞清楚压缩包里通常有什么这类作业压缩包的结构一般比较固定常见做法是包含一个data目录、一个main.py或train.py入口脚本、一个README以及可能存在的requirements.txt。数据部分通常是两种形态一种是已经分好类的spam和ham两个文件夹每个文件夹里是一堆.txt邮件另一种是一个csv或tsv文件两列分别是label和text。在动手写代码之前先花两分钟把目录结构看清楚比盲目 import 要省事得多。# 查看压缩包内容不解压先看结构 unzip -l spam_classifier_assignment.7z # 如果系统没有 unzip 对 7z 的支持用 7z 命令 7z l spam_classifier_assignment.7z # 解压到指定目录 7z x spam_classifier_assignment.7z -o./workspace上面三条命令分别对应「查看压缩包列表」「确认 7z 格式支持」「解压到工作目录」。参数-o后面跟的是输出路径注意-o和路径之间没有空格这是 7z 命令的固定写法。解压之后不要急着改代码先用find或tree看一眼实际目录层级很多作业的data目录下面还有一层train和test如果路径写错后面读文件会直接报FileNotFoundError。2.2 邮件文本清洗的四个必做步骤邮件文本和普通文章不一样它里面混着大量噪声HTML 标签、邮件头、URL、邮箱地址、特殊符号、大小写混乱。如果直接把原始文本丢给CountVectorizer特征维度会爆炸而且很多维度是纯噪声。我一般会按下面这个顺序做清洗每一步都有明确目的。import re import html def clean_email_text(raw_text): # 1. 反转义 HTML 实体比如 nbsp; amp; 这类 text html.unescape(raw_text) # 2. 去掉 HTML 标签保留标签之间的内容 text re.sub(r[^], , text) # 3. 把 URL 和邮箱地址替换成统一占位符 # 不直接删除是因为「是否包含链接」本身是强特征 text re.sub(rhttp\S|www\.\S, URLTOKEN , text) text re.sub(r\S\S, EMAILTOKEN , text) # 4. 只保留字母和空格去掉数字和标点再统一转小写 text re.sub(r[^a-zA-Z\s], , text) text text.lower() # 5. 合并连续空白字符 text re.sub(r\s, , text).strip() return text这段代码的逻辑说明如下。第一步html.unescape处理的是邮件正文里常见的 HTML 实体很多垃圾邮件是用 HTML 模板批量生成的不处理的话nbsp;会变成独立 token。第二步去掉标签但保留内容因为标签本身没有分类价值但标签里的文字有。第三步把 URL 和邮箱替换成占位符而不是删除这是一个容易被忽略的技巧垃圾邮件里出现链接的概率远高于正常邮件保留URLTOKEN这个 token 相当于给模型留了一个强信号。第四步只保留字母是因为数字和标点在邮件分类里贡献很小反而会增加特征维度。第五步合并空白是收尾避免出现空字符串 token。参数方面正则表达式[^a-zA-Z\s]里的^表示取反意思是「除了字母和空白字符之外的所有字符」。如果你处理的邮件包含中文需要把a-zA-Z改成\u4e00-\u9fa5a-zA-Z否则中文会被全部清掉。这个细节在纯英文作业里无所谓但一旦数据里混了中文邮件不改正则会导致大量样本变成空文本准确率直接崩掉。2.3 把清洗后的文本转成特征矩阵清洗完只是得到干净文本真正喂给模型的是数值矩阵。文本分类里最常用的两种特征是词袋计数和 TF-IDF。词袋计数只看词频TF-IDF 会压低高频常见词的权重。对于垃圾邮件分类我一般先用 TF-IDF 跑基线因为邮件里「free」「win」「click」这类词虽然出现频率高但区分度也高TF-IDF 能在保留区分度的同时抑制「the」「a」这种无意义高频词。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split # 假设 texts 是清洗后的文本列表labels 是对应标签 # labels 里 1 表示垃圾邮件0 表示正常邮件 vectorizer TfidfVectorizer( max_features5000, # 只保留权重最高的 5000 个词 ngram_range(1, 2), # 同时考虑单个词和相邻两个词的组合 min_df2, # 至少在 2 封邮件里出现过的词才保留 max_df0.95, # 出现在超过 95% 邮件里的词直接丢弃 sublinear_tfTrue # 对词频做对数缩放抑制极端高频 ) X vectorizer.fit_transform(texts) y labels X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy )这段代码里每个参数都值得说清楚。max_features5000是控制特征维度的硬上限邮件分类任务里 3000 到 8000 是比较稳的区间太小会丢信息太大容易过拟合。ngram_range(1, 2)表示同时使用 unigram 和 bigrambigram 能捕捉「click here」「free money」这类固定搭配对垃圾邮件识别帮助明显但会让特征数翻倍所以要和max_features配合使用。min_df2过滤掉只出现一次的词这些词大概率是噪声或者拼写错误。max_df0.95过滤掉几乎每封邮件都有的词相当于自动停用词过滤。sublinear_tfTrue是对词频取对数避免某个词出现 100 次就主导整个向量。train_test_split里的stratifyy是必须加的因为垃圾邮件和正常邮件的比例通常不平衡不加分层可能导致测试集里某一类样本极少评估结果不可信。random_state42是为了复现作业里如果不固定随机种子每次跑出来的分数都不一样没法对比改进效果。3. 朴素贝叶斯和逻辑回归两个基线怎么选、怎么调3.1 多项式朴素贝叶斯为什么是文本分类的默认起点朴素贝叶斯在文本分类里几乎是「默认答案」原因是它假设特征之间相互独立这个假设在文本里明显不成立但实际效果却出奇地好。对于垃圾邮件分类多项式朴素贝叶斯直接建模「某个词在垃圾邮件里出现的概率」计算量小训练速度快而且对小数据集友好。它的核心参数只有两个alpha和fit_prior。from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report, confusion_matrix nb_clf MultinomialNB( alpha0.1, # 拉普拉斯平滑系数 fit_priorTrue # 从数据中学习类别先验概率 ) nb_clf.fit(X_train, y_train) y_pred_nb nb_clf.predict(X_test) print(confusion_matrix(y_test, y_pred_nb)) print(classification_report(y_test, y_pred_nb))alpha0.1是平滑系数默认值是 1.0。平滑的作用是防止某个词在训练集里没出现过导致概率为 0从而让整个预测失效。alpha越小模型越相信训练数据alpha越大平滑越强模型越保守。在邮件分类里我一般会试 0.01、0.1、1.0 三个值用交叉验证选最好的。fit_priorTrue表示从数据里学习类别先验如果垃圾邮件和正常邮件比例严重失衡可以改成False并使用class_prior手动指定但大多数作业数据不需要走到这一步。confusion_matrix输出的是四个数字真正例、假正例、假负例、真负例。垃圾邮件分类里假正例把正常邮件判成垃圾的代价通常比假负例高因为用户更讨厌重要邮件被误拦。如果作业要求你讨论业务代价这一点必须写进去。classification_report会给出精确率、召回率和 F1不要只看准确率准确率在不平衡数据上会骗人。3.2 逻辑回归的调参空间和特征系数解读逻辑回归比朴素贝叶斯多了一个正则化参数C而且它的系数可以直接解读为「某个词对垃圾邮件判定的贡献方向」。如果你想让作业有分析深度逻辑回归比朴素贝叶斯更适合做特征重要性展示。from sklearn.linear_model import LogisticRegression from sklearn.model_selection import GridSearchCV lr_clf LogisticRegression( C1.0, # 正则化强度的倒数越小正则越强 penaltyl2, # L2 正则防止某些特征权重过大 solverliblinear, # 小数据集上用 liblinear 更稳 max_iter1000, # 迭代上限防止不收敛警告 class_weightbalanced # 自动按类别频率调整权重 ) param_grid {C: [0.01, 0.1, 1.0, 10.0]} grid GridSearchCV(lr_clf, param_grid, cv5, scoringf1) grid.fit(X_train, y_train) best_lr grid.best_estimator_ y_pred_lr best_lr.predict(X_test)C是逻辑回归最重要的参数它控制正则化强度。C越小正则化越强模型越简单越不容易过拟合C越大模型越拟合训练数据。邮件分类里C的合理范围通常在 0.1 到 10 之间。solverliblinear是专门为小数据集设计的求解器比lbfgs更稳定但如果你数据量很大应该换成saga。class_weightbalanced会自动把类别权重设成与频率成反比这对垃圾邮件分类很重要因为正常邮件通常远多于垃圾邮件。GridSearchCV里的scoringf1表示用 F1 分数选最优参数而不是准确率。这是一个关键选择在不平衡数据上F1 比准确率更能反映模型对少数类的识别能力。cv5是五折交叉验证如果数据量很小可以改成cv3减少计算时间。3.3 两个基线的对比与选择依据把两个模型放在一起对比时不要只看一个数字。下面这张表是我在多个邮件数据集上总结出来的经验区间具体数值会随数据变化但趋势基本一致。对比维度多项式朴素贝叶斯逻辑回归训练速度极快秒级较快秒到分钟级小数据集表现稳定不易过拟合需要调 C否则容易过拟合特征可解释性有概率值但方向性弱系数正负明确可解释性强不平衡数据需要手动处理先验class_weight直接支持典型 F1 区间0.92 到 0.970.94 到 0.98选择依据很简单如果作业只要求跑通并报告结果朴素贝叶斯足够如果要求分析哪些词最能区分垃圾邮件逻辑回归的系数更直观。我一般会两个都跑用朴素贝叶斯做基线用逻辑回归做特征分析最后在报告里对比两者的混淆矩阵差异。4. 避坑与排查垃圾邮件分类作业里最容易翻车的五个点4.1 数据泄漏在划分训练集之前做了 TF-IDF现象测试集准确率异常高达到 99% 以上但换一批新邮件预测效果很差。原因先对整个数据集做fit_transform再划分训练集和测试集导致测试集的词表信息泄漏到了训练阶段。解决必须先划分训练集和测试集只在训练集上fit向量化器然后用同一个向量化器transform测试集。# 错误做法 X vectorizer.fit_transform(all_texts) X_train, X_test train_test_split(X) # 正确做法 texts_train, texts_test, y_train, y_test train_test_split(texts, labels) X_train vectorizer.fit_transform(texts_train) X_test vectorizer.transform(texts_test)4.2 标签编码不一致spam/ham 和 0/1 混用现象模型训练不报错但预测结果全部偏向某一类。原因读取数据时一部分文件用文件夹名当标签另一部分用 csv 里的字符串标签导致spam、ham、1、0混在一起LabelEncoder可能把它们编码成四个类别。解决在读数据阶段统一标签映射写一个显式的字典。label_map {spam: 1, ham: 0, 1: 1, 0: 0} labels [label_map[str(x).lower()] for x in raw_labels]4.3 空文本样本清洗太狠导致整封邮件变空字符串现象TfidfVectorizer报错或某些样本全为 0 向量。原因正则清洗把非字母字符全部去掉后如果原邮件主要是 HTML 标签和数字清洗完就只剩空字符串。解决在清洗函数最后加一个判断如果结果为空返回一个占位 token。if not text: return EMPTYTOKEN return text4.4 停用词表用错把否定词也去掉了现象模型对「not free」「no win」这类邮件的判断完全相反。原因使用了通用英文停用词表把not、no、nor去掉了导致否定含义丢失。解决邮件分类里不要用默认停用词表或者手动排除否定词。from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS custom_stop ENGLISH_STOP_WORDS - {not, no, nor, never}4.5 评估指标只看准确率不平衡数据上的假象现象准确率 95%但垃圾邮件召回率只有 60%。原因正常邮件占 90%模型只要全部预测为正常就能拿到 90% 准确率。解决同时看精确率、召回率和 F1并且优先关注垃圾邮件那一类的召回率。from sklearn.metrics import f1_score print(f1_score(y_test, y_pred, pos_label1))5. 把作业做成可复现的小工具交叉验证与错误样本分析5.1 用分层交叉验证替代单次划分单次train_test_split的结果波动很大尤其是数据量小的时候。把交叉验证加进去报告里的数字会稳得多也更有说服力。from sklearn.model_selection import cross_val_score from sklearn.pipeline import Pipeline pipeline Pipeline([ (tfidf, TfidfVectorizer(max_features5000, ngram_range(1, 2))), (clf, MultinomialNB(alpha0.1)) ]) scores cross_val_score( pipeline, texts, labels, cv5, scoringf1, n_jobs-1 ) print(fF1 均值: {scores.mean():.4f}, 标准差: {scores.std():.4f})Pipeline的作用是把向量化和分类器绑在一起交叉验证时每一折都会重新fit向量化器避免数据泄漏。n_jobs-1表示用满所有 CPU 核心加速。scoringf1和前面一样关注少数类。输出里标准差很重要如果标准差超过 0.03说明模型在不同折上表现不稳定需要检查数据分布或增加正则化。5.2 把预测错误的邮件挑出来看模型跑完不是终点把错误样本导出来读一遍往往能发现清洗规则或特征构造的问题。这一步是区分「交作业」和「真学会」的分水岭。import pandas as pd # 假设 test_texts 是测试集原始文本 error_df pd.DataFrame({ text: test_texts, true_label: y_test, pred_label: y_pred_lr }) # 挑出假正例正常邮件被误判为垃圾 false_positives error_df[ (error_df[true_label] 0) (error_df[pred_label] 1) ] # 挑出假负例垃圾邮件被漏判 false_negatives error_df[ (error_df[true_label] 1) (error_df[pred_label] 0) ] print(f假正例数量: {len(false_positives)}) print(f假负例数量: {len(false_negatives)}) # 看前几条假正例的原文 for text in false_positives[text].head(3): print(text[:200]) print(---)这段代码的逻辑很直接把测试集的原始文本、真实标签、预测标签拼成一张表然后按条件筛选。假正例是正常邮件被误拦假负例是垃圾邮件被放过。读几条假正例的原文你可能会发现它们包含「free」但其实是正常促销邮件或者包含「click」但其实是正常通知。这些发现会直接指导你调整特征或清洗规则比如把「free」单独作为一个特征而不是和「free money」绑定。5.3 保存模型和向量化器方便下次直接预测作业做完之后把训练好的向量化器和分类器保存下来下次拿到新邮件可以直接预测不用重新训练。这也是把作业变成小工具的关键一步。import joblib # 保存 joblib.dump(vectorizer, tfidf_vectorizer.pkl) joblib.dump(best_lr, spam_classifier.pkl) # 加载并预测新邮件 loaded_vec joblib.load(tfidf_vectorizer.pkl) loaded_clf joblib.load(spam_classifier.pkl) new_email Congratulations! You won a free prize, click here to claim cleaned clean_email_text(new_email) features loaded_vec.transform([cleaned]) prediction loaded_clf.predict(features) print(垃圾邮件 if prediction[0] 1 else 正常邮件)joblib比pickle更适合保存 sklearn 模型因为它在处理 numpy 数组时效率更高。保存时要同时保存向量化器和分类器只保存分类器是没用的因为新文本必须用同一个向量化器转换。预测新邮件时清洗函数必须和训练时完全一致否则特征分布会对不上预测结果不可信。5.4 一个容易被忽略的技巧把邮件长度作为额外特征文本分类里有一个简单但有效的技巧把邮件长度、大写字母比例、感叹号数量作为额外数值特征拼到 TF-IDF 矩阵后面。垃圾邮件往往有更多大写字母和感叹号这个信号 TF-IDF 捕捉不到。import numpy as np from scipy.sparse import hstack def extract_meta_features(texts): features [] for text in texts: length len(text) upper_ratio sum(1 for c in text if c.isupper()) / (length 1) exclaim_count text.count(!) features.append([length, upper_ratio, exclaim_count]) return np.array(features) meta_train extract_meta_features(texts_train) meta_test extract_meta_features(texts_test) X_train_combined hstack([X_train, meta_train]) X_test_combined hstack([X_test, meta_test])hstack是 scipy 里的稀疏矩阵横向拼接函数能把 TF-IDF 矩阵和数值特征拼在一起。注意数值特征的范围要和 TF-IDF 的 0 到 1 区间大致匹配如果长度是几百最好做归一化否则会主导模型。这个技巧在作业里属于加分项能让你的方案和别人的「纯 TF-IDF 朴素贝叶斯」拉开差距。我做了几轮邮件分类之后养成了一个习惯每次跑完模型先不看准确率先把假正例和假负例各读十条。读完之后再回头看清洗规则和特征参数往往比盲目调参有效得多。这个习惯帮我省下了大量试错时间也让我对「模型到底学到了什么」有了更具体的感知。希望帮到你。本文还有配套的精品资源点击获取