
简介这份机器学习编程作业资源聚焦垃圾邮件分类任务面向正在学习机器学习、需要动手实践分类模型的学生与初学者。内容围绕邮件文本的完整建模流程展开涵盖数据预处理中的分词、去停用词与特征编码特征工程中的词频统计与邮件长度等特征构造以及朴素贝叶斯、支持向量机、逻辑回归等常见分类算法的对比实践并涉及交叉验证、精确率与召回率等模型评估指标和超参数调优思路。压缩包为7z格式整体约874KB包内以练习代码、数据集与指导文档为主便于按模块加载数据并逐步复现实验。目前已有543人学习下载适合希望巩固分类算法理论、提升特征工程与模型调优编程能力的读者参考也可作为课程作业与自学的实践素材。1. 垃圾邮件分类作业从压缩包到可复现管线的拆解思路拿到「机器学习编程作业垃圾邮件分类.7z」这个标题多数人的第一反应是找数据集、跑个朴素贝叶斯、交个报告。但真正做过这类作业的人知道坑不在模型本身而在从压缩包解压到最终评估这条链路上编码格式不统一、标签泄漏、词表在训练集和测试集之间不一致、阈值选错导致精确率和召回率严重失衡。这篇笔记面向正在做或准备做垃圾邮件分类作业的读者把整个流程拆成可复现的步骤每一步都给出参数含义和排错方向。无论你用的是朴素贝叶斯、线性 SVM 还是轻量级 Transformer管线搭建的逻辑是相通的。下面从数据解压后的第一行代码开始讲。2. 数据解压后的第一件事把原始邮件变成模型能吃的结构2.1 压缩包里通常有什么先做一次结构盘点一个典型的垃圾邮件分类作业压缩包解压后大概率包含以下几类内容按目录分好的spam/和ham/文件夹、一个或多个 CSV 文件列名可能是label、text或v1、v2、一份 README 或作业说明 PDF、有时还会附带一个预训练词向量文件或停用词表。不要急着写模型代码先花五分钟做结构盘点。# 查看解压后的目录树限制深度避免输出过长 find ./spam_classify -maxdepth 3 -type f | head -50 # 统计两个类别的文件数量确认是否均衡 echo spam count: ls ./spam_classify/spam | wc -l echo ham count: ls ./spam_classify/ham | wc -l # 如果是 CSV先看前几行和列名 head -3 ./spam_classify/data.csv这三条命令分别解决三个问题目录结构是否和预期一致、类别是否严重不均衡、CSV 的列名和分隔符是什么。常见翻车场景是 CSV 用分号分隔而默认按逗号读导致整列变成一个字段。另一个高频问题是文件编码为latin-1而非utf-8直接读会抛UnicodeDecodeError。2.2 用 pandas 统一加载并做标签映射确认结构后用 pandas 把数据统一加载成text和label两列。标签映射这一步看起来简单但它是后续所有评估指标的基础映射错了精确率和召回率会完全颠倒。import pandas as pd import os def load_dataset(root_dir): 从目录结构加载邮件数据返回 DataFrame records [] for label_name, label_id in [(ham, 0), (spam, 1)]: folder os.path.join(root_dir, label_name) for fname in os.listdir(folder): fpath os.path.join(folder, fname) # 尝试多种编码避免单封邮件导致整体失败 for enc in (utf-8, latin-1, cp1252): try: with open(fpath, r, encodingenc) as f: text f.read() break except UnicodeDecodeError: continue else: continue # 三种编码都失败则跳过该文件 records.append({text: text, label: label_id}) return pd.DataFrame(records) df load_dataset(./spam_classify) print(df[label].value_counts()) print(df.head(3))这段代码的关键设计有三点。第一编码回退策略真实邮件数据里混入latin-1或cp1252编码的文件是常态逐个尝试比统一指定编码更稳。第二标签映射约定ham0, spam1后续所有predict_proba的输出列索引都依赖这个约定。第三跳过无法解码的文件而不是让整个流程崩溃但要记录跳过数量如果跳过比例超过 2% 就需要回头检查数据源。参数方面os.listdir不保证顺序如果后续需要可复现的划分必须在划分时设random_state。value_counts()的输出直接告诉你类别比例如果 spam 占比低于 15%后续评估不能只看准确率。2.3 划分训练集和测试集时最容易犯的错划分这一步的坑比想象中多。最常见的错误是在划分之前就做了全局的文本清洗或词表构建导致测试集信息泄漏到训练过程。正确顺序是先划分再分别对训练集拟合预处理器用拟合好的预处理器变换测试集。from sklearn.model_selection import train_test_split # 分层划分保证训练集和测试集的类别比例一致 X_train, X_test, y_train, y_test train_test_split( df[text].values, df[label].values, test_size0.2, # 测试集占比作业场景 0.2 足够 random_state42, # 固定随机种子保证可复现 stratifydf[label] # 按标签分层避免小类在测试集中过少 ) print(ftrain: {len(X_train)}, test: {len(X_test)}) print(ftrain spam ratio: {y_train.mean():.3f}) print(ftest spam ratio: {y_test.mean():.3f})stratify参数在类别不均衡时是必须的。如果不加一次随机划分可能让测试集中 spam 样本只占 5%导致召回率评估极不稳定。random_state固定后任何人用同样的代码都能得到同样的划分这对作业复现和调试至关重要。3. 文本预处理与特征工程词表、TF-IDF 和那些容易忽略的参数3.1 从原始邮件到词表清洗的边界在哪里邮件文本的噪声来源和普通文本不同HTML 标签、邮件头信息、URL、邮箱地址、连续空白和特殊符号。清洗的力度需要控制洗得太狠会丢掉区分 spam 的关键信号比如大量 URL 和特定促销词洗得太轻则词表膨胀、模型泛化差。import re def clean_email(text): 邮件文本清洗保留对分类有用的信号 # 去除 HTML 标签但保留标签内文本 text re.sub(r[^], , text) # 将 URL 替换为统一占位符保留存在URL这个信号 text re.sub(rhttp\S|www\.\S, __URL__ , text) # 将邮箱地址替换为占位符 text re.sub(r\S\S, __EMAIL__ , text) # 将连续数字替换为占位符避免年份、金额等具体数字进入词表 text re.sub(r\d, __NUM__ , text) # 转小写并合并连续空白 text text.lower() text re.sub(r\s, , text).strip() return text # 对训练集和测试集分别应用不涉及拟合可以统一处理 X_train_clean [clean_email(t) for t in X_train] X_test_clean [clean_email(t) for t in X_test]这里用占位符替代 URL、邮箱和数字而不是直接删除是因为「邮件中包含 URL」本身就是 spam 的强特征。如果直接删掉模型就失去了这个信号。清洗函数不涉及任何从数据中学习的参数所以对训练集和测试集统一应用不会造成泄漏。3.2 TF-IDF 向量化四个必须关注的参数文本清洗完成后用 TF-IDF 把文本转成数值向量。sklearn 的TfidfVectorizer参数很多但真正影响分类效果的主要是四个max_features、ngram_range、min_df和max_df。from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( max_features50000, # 词表上限控制特征维度 ngram_range(1, 2), # 同时使用 unigram 和 bigram min_df2, # 忽略出现次数少于2的词 max_df0.95, # 忽略出现在95%以上文档中的词 sublinear_tfTrue, # 使用 1log(tf) 替代原始 tf strip_accentsunicode ) # 关键在训练集上拟合然后变换训练集和测试集 X_train_vec vectorizer.fit_transform(X_train_clean) X_test_vec vectorizer.transform(X_test_clean) print(fvocab size: {len(vectorizer.vocabulary_)}) print(ftrain shape: {X_train_vec.shape})max_features50000是作业场景的常用值数据量在几千到几万封邮件时这个上限既能保留足够信息又不会让模型过慢。ngram_range(1, 2)引入 bigram 能捕捉「click here」「limited offer」这类短语对 spam 识别帮助明显代价是特征维度增加。min_df2过滤掉只出现一次的词这些词大概率是噪声或拼写错误。max_df0.95过滤掉几乎每封邮件都有的词类似停用词的作用但更自适应。sublinear_tfTrue是一个容易被忽略但有效的设置。邮件中某个词出现 50 次和出现 5 次其重要性差异并不是线性的取对数后能压缩这种差异让模型更关注词的存在性而非绝对频次。3.3 用管道把预处理和模型串起来手动管理fit_transform和transform的调用顺序容易出错用Pipeline可以把整个流程封装成一个对象既减少出错概率也让交叉验证的代码更简洁。from sklearn.pipeline import Pipeline from sklearn.naive_bayes import MultinomialNB pipeline Pipeline([ (tfidf, TfidfVectorizer( max_features50000, ngram_range(1, 2), min_df2, max_df0.95, sublinear_tfTrue )), (clf, MultinomialNB(alpha0.1)) ]) # 直接用原始清洗后的文本训练 pipeline.fit(X_train_clean, y_train) # 预测和评估 y_pred pipeline.predict(X_test_clean)管道的好处在于交叉验证时每个折内都会重新拟合 TF-IDF不会出现用全量数据拟合词表再划分的泄漏问题。MultinomialNB的alpha0.1是平滑参数默认值 1.0 在短文本上可能过度平滑0.1 到 0.5 之间通常效果更好可以通过交叉验证选。4. 模型训练与评估准确率之外的指标才是作业得分点4.1 为什么准确率在垃圾邮件分类里会骗人如果数据集中 ham 占 85%、spam 占 15%一个把所有邮件都判为 ham 的模型准确率是 85%但它一封垃圾邮件都识别不出来。作业评分如果只看准确率这个模型能拿不低的分数但实际毫无用处。所以评估必须看精确率、召回率和 F1。from sklearn.metrics import classification_report, confusion_matrix print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_names[ham, spam]))classification_report输出的每一列含义需要明确precision 是「判为 spam 的邮件里有多少真的是 spam」recall 是「真正的 spam 里有多少被找出来了」f1 是两者的调和平均。垃圾邮件分类场景下通常更关注 spam 类的 recall因为漏掉一封垃圾邮件比误判一封正常邮件代价更大当然具体取决于业务设定。4.2 交叉验证选超参数不要用测试集调参测试集只能用来做最终评估调参必须用交叉验证。常见做法是在训练集上做 5 折交叉验证用 F1 作为选择标准。from sklearn.model_selection import cross_val_score # 在训练集上做5折交叉验证评估F1 scores cross_val_score( pipeline, X_train_clean, y_train, cv5, scoringf1 ) print(fCV F1: {scores.mean():.4f} /- {scores.std():.4f})scoringf1默认计算的是正类spam的 F1。如果更关注 recall可以改成scoringrecall。scores.std()反映模型在不同折上的稳定性如果标准差超过 0.05说明模型对数据划分敏感可能需要增加数据量或简化模型。4.3 阈值调整默认 0.5 不一定是最优选择predict()默认以 0.5 为阈值把概率转成类别但在类别不均衡或误判代价不对称时调整阈值能显著改善目标指标。import numpy as np from sklearn.metrics import precision_recall_curve # 获取 spam 类的预测概率 y_prob pipeline.predict_proba(X_test_clean)[:, 1] # 计算不同阈值下的精确率和召回率 precisions, recalls, thresholds precision_recall_curve(y_test, y_prob) # 找到 recall 不低于 0.95 时 precision 最高的阈值 target_recall 0.95 mask recalls[:-1] target_recall best_idx np.argmax(precisions[:-1][mask]) best_threshold thresholds[mask][best_idx] print(fbest threshold: {best_threshold:.3f}) print(fprecision: {precisions[:-1][mask][best_idx]:.3f}) print(frecall: {recalls[:-1][mask][best_idx]:.3f}) # 用新阈值生成预测 y_pred_adjusted (y_prob best_threshold).astype(int)这段代码的逻辑是先设定一个业务可接受的 recall 下限比如 0.95即 95% 的垃圾邮件必须被拦截然后在这个约束下找精确率最高的阈值。precision_recall_curve返回的thresholds长度比precisions和recalls少一个所以索引时要加[:-1]这是一个常见的索引越界坑。5. 避坑与排查那些让作业从满分掉到及格线的问题5.1 词表泄漏测试集词被模型提前看到现象交叉验证 F1 很高0.98但换一个测试集或重新划分后 F1 骤降到 0.7 左右。原因在划分数据之前就用全量数据拟合了TfidfVectorizer测试集中的词已经进入了词表模型在训练时间接「看到」了测试集的信息。解决始终把向量化放进Pipeline或者严格遵循「先划分、再在训练集上fit_transform、在测试集上只transform」的顺序。5.2 标签映射颠倒spam 和 ham 反了现象模型准确率看起来正常但classification_report里 spam 类的 precision 和 recall 都极低ham 类却接近 1.0。原因加载数据时把spam映射成了 0、ham映射成了 1而后续评估代码默认正类是 1。解决在数据加载函数里显式打印标签映射关系并在评估前用y_test[:10]和原始标签对照确认。5.3 编码问题导致部分邮件内容为空现象训练出的模型在测试集上表现正常但预测新邮件时总是判为 ham。原因加载数据时部分邮件因编码问题读取失败被跳过或者读进来的是乱码导致模型没有学到有效的 spam 特征。解决在加载函数中记录跳过的文件数量和文件名如果跳过比例超过 1% 就逐一检查这些文件的编码。用chardet库可以自动检测编码但会增加依赖作业场景下手动尝试几种常见编码通常够用。5.4 测试集调参导致过拟合现象在测试集上反复调整max_features、alpha等参数最终测试集 F1 达到 0.99但助教用另一个隐藏测试集评估时只有 0.8。原因测试集被当成了验证集使用模型间接拟合了测试集。解决从训练集中再切出一部分作为验证集或者用交叉验证选参数测试集只在最后评估时用一次。5.5 停用词表引入的负面效果现象加了英文停用词过滤后F1 反而下降了 2 到 3 个百分点。原因停用词表里可能包含对 spam 分类有用的词比如「free」「now」「click」在某些停用词表中被列为停用词但这些恰恰是垃圾邮件的强信号。解决垃圾邮件分类场景下不建议使用通用停用词表。如果要用先检查停用词表中是否包含领域关键词或者用max_df来自适应地过滤高频词。6. 从作业到可用把分类器封装成可调用的预测函数6.1 保存和加载模型joblib 比 pickle 更适合 sklearn训练完的Pipeline需要保存下来下次直接加载使用不用重新训练。sklearn 模型用joblib保存比pickle更高效尤其是包含大型稀疏矩阵时。import joblib # 保存整个管道包含向量化器和分类器 joblib.dump(pipeline, spam_classifier_pipeline.joblib) # 加载 loaded_pipeline joblib.load(spam_classifier_pipeline.joblib) # 验证加载后的模型输出一致 sample [Congratulations! You won a free iPhone. Click here now!] print(loaded_pipeline.predict(sample)) print(loaded_pipeline.predict_proba(sample))保存整个管道而不是只保存分类器是因为向量化器的词表和参数必须和分类器匹配。如果只保存分类器加载后还需要重新拟合向量化器容易出错。6.2 封装预测函数输入原始文本输出标签和置信度实际使用时输入的是原始邮件文本需要先清洗再预测。封装一个函数把清洗、向量化、预测串起来并返回置信度供人工复核。def predict_email(text, pipeline, threshold0.5): 对单封邮件进行预测 返回: (label_str, spam_probability) cleaned clean_email(text) prob pipeline.predict_proba([cleaned])[0, 1] label spam if prob threshold else ham return label, round(prob, 4) # 测试几个边界样本 test_emails [ Hi, can we reschedule tomorrows meeting to 3pm?, URGENT: Your account has been compromised. Click http://example.com to verify., Free entry in a weekly competition to win FA Cup tickets!, ] for email in test_emails: label, prob predict_email(email, loaded_pipeline) print(f[{label}] (spam_prob{prob}) {email[:60]}...)这个函数的设计要点threshold作为参数暴露出来方便根据业务需求调整返回概率值而不是只返回标签让调用方可以设置自己的决策逻辑清洗函数和训练时保持一致避免训练和推理阶段的预处理不一致。6.3 用错误分析找到模型的盲区模型上线前把测试集中预测错误的样本拿出来看一遍比盯着 F1 数字更有价值。# 找出测试集中预测错误的样本 errors [] for text, true_label, pred_label, prob in zip( X_test_clean, y_test, y_pred, y_prob ): if true_label ! pred_label: errors.append({ text: text[:200], true: spam if true_label 1 else ham, pred: spam if pred_label 1 else ham, spam_prob: round(prob, 3) }) # 按置信度排序优先看模型自信地犯错的样本 errors_sorted sorted(errors, keylambda x: abs(x[spam_prob] - 0.5), reverseTrue) for e in errors_sorted[:5]: print(ftrue{e[true]}, pred{e[pred]}, prob{e[spam_prob]}) print(f {e[text][:100]}) print()错误分析通常会发现几类问题模型把包含「free」的正常邮件误判为 spam精确率问题或者把没有明显关键词的垃圾邮件漏判召回率问题。针对第一类可以在后处理中加规则过滤针对第二类需要检查特征工程是否遗漏了某些信号比如邮件头信息或发送时间。6.4 一个容易被忽略的技巧用特征重要性反查模型逻辑朴素贝叶斯的特征对数概率可以直接查看找出对 spam 和 ham 判别贡献最大的词。# 获取向量化器和分类器 tfidf loaded_pipeline.named_steps[tfidf] clf loaded_pipeline.named_steps[clf] # 获取特征名和对应的对数概率差 feature_names tfidf.get_feature_names_out() log_prob_diff clf.feature_log_prob_[1] - clf.feature_log_prob_[0] # 找出最偏向 spam 的20个词 top_spam_idx np.argsort(log_prob_diff)[-20:][::-1] print(Top spam indicators:) for idx in top_spam_idx: print(f {feature_names[idx]}: {log_prob_diff[idx]:.3f}) # 找出最偏向 ham 的20个词 top_ham_idx np.argsort(log_prob_diff)[:20] print(\nTop ham indicators:) for idx in top_ham_idx: print(f {feature_names[idx]}: {log_prob_diff[idx]:.3f})这个分析能帮你验证模型是否学到了合理的模式。如果 top spam 词里出现的是__URL__、free、click、offer这类词说明模型逻辑正常。如果出现的是某个特定发件人的名字或某个无关的词说明数据集中存在泄漏或偏差需要回头检查数据来源。我自己的习惯是每次训练完模型后都跑一遍这个分析花两分钟看一眼 top 特征比看十遍 F1 数字更能发现问题。有一次就是通过这个分析发现测试集里混入了训练集的重复样本导致 F1 虚高。希望帮到你。本文还有配套的精品资源点击获取