简介:这份机器学习编程作业资源聚焦垃圾邮件分类任务,面向正在学习机器学习、需要动手实践分类模型的学生与初学者。压缩包内以代码、数据集与指导文档为主,围绕邮件文本的预处理、特征工程、算法选型、模型评估与优化等环节展开,帮助读者完整走通从数据清洗到模型部署的实践链路。资源包大小约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 实体,比如 & 这类 text = html.unescape(raw_text) # 2. 去掉 HTML 标签,保留标签之间的内容 text = re.sub(r'<[^>]+>', ' ', text) # 3. 把 URL 和邮箱地址替换成统一占位符 # 不直接删除,是因为「是否包含链接」本身是强特征 text = re.sub(r'http\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 模板批量生成的,不处理的话 会变成独立 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_features=5000, # 只保留权重最高的 5000 个词 ngram_range=(1, 2), # 同时考虑单个词和相邻两个词的组合 min_df=2, # 至少在 2 封邮件里出现过的词才保留 max_df=0.95, # 出现在超过 95% 邮件里的词直接丢弃 sublinear_tf=True # 对词频做对数缩放,抑制极端高频 ) X = vectorizer.fit_transform(texts) y = labels X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y )这段代码里每个参数都值得说清楚。max_features=5000是控制特征维度的硬上限,邮件分类任务里 3000 到 8000 是比较稳的区间,太小会丢信息,太大容易过拟合。ngram_range=(1, 2)表示同时使用 unigram 和 bigram,bigram 能捕捉「click here」「free money」这类固定搭配,对垃圾邮件识别帮助明显,但会让特征数翻倍,所以要和max_features配合使用。min_df=2过滤掉只出现一次的词,这些词大概率是噪声或者拼写错误。max_df=0.95过滤掉几乎每封邮件都有的词,相当于自动停用词过滤。sublinear_tf=True是对词频取对数,避免某个词出现 100 次就主导整个向量。
train_test_split里的stratify=y是必须加的,因为垃圾邮件和正常邮件的比例通常不平衡,不加分层可能导致测试集里某一类样本极少,评估结果不可信。random_state=42是为了复现,作业里如果不固定随机种子,每次跑出来的分数都不一样,没法对比改进效果。
3. 朴素贝叶斯和逻辑回归:两个基线怎么选、怎么调
3.1 多项式朴素贝叶斯为什么是文本分类的默认起点
朴素贝叶斯在文本分类里几乎是「默认答案」,原因是它假设特征之间相互独立,这个假设在文本里明显不成立,但实际效果却出奇地好。对于垃圾邮件分类,多项式朴素贝叶斯直接建模「某个词在垃圾邮件里出现的概率」,计算量小,训练速度快,而且对小数据集友好。它的核心参数只有两个:alpha和fit_prior。
from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report, confusion_matrix nb_clf = MultinomialNB( alpha=0.1, # 拉普拉斯平滑系数 fit_prior=True # 从数据中学习类别先验概率 ) 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))alpha=0.1是平滑系数,默认值是 1.0。平滑的作用是防止某个词在训练集里没出现过导致概率为 0,从而让整个预测失效。alpha越小,模型越相信训练数据;alpha越大,平滑越强,模型越保守。在邮件分类里,我一般会试 0.01、0.1、1.0 三个值,用交叉验证选最好的。fit_prior=True表示从数据里学习类别先验,如果垃圾邮件和正常邮件比例严重失衡,可以改成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( C=1.0, # 正则化强度的倒数,越小正则越强 penalty='l2', # L2 正则,防止某些特征权重过大 solver='liblinear', # 小数据集上用 liblinear 更稳 max_iter=1000, # 迭代上限,防止不收敛警告 class_weight='balanced' # 自动按类别频率调整权重 ) param_grid = {'C': [0.01, 0.1, 1.0, 10.0]} grid = GridSearchCV(lr_clf, param_grid, cv=5, scoring='f1') 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 之间。solver='liblinear'是专门为小数据集设计的求解器,比lbfgs更稳定,但如果你数据量很大,应该换成saga。class_weight='balanced'会自动把类别权重设成与频率成反比,这对垃圾邮件分类很重要,因为正常邮件通常远多于垃圾邮件。
GridSearchCV里的scoring='f1'表示用 F1 分数选最优参数,而不是准确率。这是一个关键选择:在不平衡数据上,F1 比准确率更能反映模型对少数类的识别能力。cv=5是五折交叉验证,如果数据量很小,可以改成cv=3减少计算时间。
3.3 两个基线的对比与选择依据
把两个模型放在一起对比时,不要只看一个数字。下面这张表是我在多个邮件数据集上总结出来的经验区间,具体数值会随数据变化,但趋势基本一致。
| 对比维度 | 多项式朴素贝叶斯 | 逻辑回归 |
|---|---|---|
| 训练速度 | 极快,秒级 | 较快,秒到分钟级 |
| 小数据集表现 | 稳定,不易过拟合 | 需要调 C,否则容易过拟合 |
| 特征可解释性 | 有概率值,但方向性弱 | 系数正负明确,可解释性强 |
| 不平衡数据 | 需要手动处理先验 | class_weight直接支持 |
| 典型 F1 区间 | 0.92 到 0.97 | 0.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_label=1))5. 把作业做成可复现的小工具:交叉验证与错误样本分析
5.1 用分层交叉验证替代单次划分
单次train_test_split的结果波动很大,尤其是数据量小的时候。把交叉验证加进去,报告里的数字会稳得多,也更有说服力。
from sklearn.model_selection import cross_val_score from sklearn.pipeline import Pipeline pipeline = Pipeline([ ('tfidf', TfidfVectorizer(max_features=5000, ngram_range=(1, 2))), ('clf', MultinomialNB(alpha=0.1)) ]) scores = cross_val_score( pipeline, texts, labels, cv=5, scoring='f1', n_jobs=-1 ) print(f"F1 均值: {scores.mean():.4f}, 标准差: {scores.std():.4f}")Pipeline的作用是把向量化和分类器绑在一起,交叉验证时每一折都会重新fit向量化器,避免数据泄漏。n_jobs=-1表示用满所有 CPU 核心加速。scoring='f1'和前面一样,关注少数类。输出里标准差很重要,如果标准差超过 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 + 朴素贝叶斯」拉开差距。
我做了几轮邮件分类之后养成了一个习惯:每次跑完模型,先不看准确率,先把假正例和假负例各读十条。读完之后再回头看清洗规则和特征参数,往往比盲目调参有效得多。这个习惯帮我省下了大量试错时间,也让我对「模型到底学到了什么」有了更具体的感知。希望帮到你。
本文还有配套的精品资源,点击获取