简介:这份资源面向深度学习入门者、课程实践学生及毕业设计选题人群,提供基于Pytorch的全连接神经网络垃圾邮件分类完整方案。项目使用MLP多层感知机模型与optimizer优化器完成有标签监督学习,并借助PytorchViz库可视化网络结构,通过Canvas库呈现损失函数值与识别精度随训练轮次的变化曲线,帮助读者直观理解模型训练过程。压缩包共20个文件,约7.18MB,包含py主程序、csv与data数据文件、names与DOCUMENTATION说明、gv与png可视化图、docx报告、pdf作业要求及md说明等,覆盖代码、数据、文档与图表多种类型。已有1016人学习下载,适合希望快速跑通分类流程、参考网络可视化与训练曲线绘制、完成课程作业或毕业设计的读者直接使用。
1. 垃圾邮件分类:为什么全连接网络依然是性价比最高的起点
邮箱里每天躺着几十封“恭喜您中奖”“发票代开”“低息贷款”,手动删到手软。垃圾邮件分类这个任务,说白了就是给一封邮件打上“垃圾”或“正常”的标签,本质是一个二分类问题。很多同学一上来就想用 CNN、BERT,觉得全连接网络太“浅”拿不出手。但我在实际项目里反复验证过:在文本特征工程做到位的前提下,一个三层全连接网络在垃圾邮件分类上的准确率能稳定跑到 97% 以上,训练时间却只要几十秒。对于毕业设计来说,这个方案既能体现深度学习完整流程,又不会因为模型太复杂导致调参调到崩溃。这篇文章会从数据预处理、词袋特征、PyTorch 建模、训练调参到避坑排查,把整条链路拆开讲清楚,代码可以直接复制运行。适合正在做深度学习入门项目、需要一份能跑通的全连接网络实战案例的读者。
2. 数据准备与文本向量化:把邮件变成网络能吃的数字
2.1 垃圾邮件数据集长什么样
常见的公开垃圾邮件数据集一般是一个 CSV 或 TSV 文件,两列:一列是标签(ham/spam 或 0/1),一列是邮件原始文本。文本里包含邮件头、正文、HTML 标签、特殊符号,长度从几十个字符到上万字符不等。我拿到的数据集通常有 5000 到 6000 条样本,正负样本比例大概在 1:4 到 1:6 之间,也就是说正常邮件远多于垃圾邮件。这个比例很重要,后面讲评估指标时会用到。
拿到数据后第一件事不是急着喂给模型,而是做一遍探索性检查:看有没有缺失值、标签分布是否极度不均衡、文本里有没有大量乱码。我一般会用 pandas 读进来,打印前五行和标签计数,确认数据格式没问题再往下走。
import pandas as pd # 读取数据集,假设是 tab 分隔,列名为 label 和 text df = pd.read_csv('spam.csv', sep='\t', header=None, names=['label', 'text']) # 查看基本信息 print(df.head()) print(df['label'].value_counts()) print(df.isnull().sum())这段代码做了三件事:加载数据、看标签分布、检查缺失值。sep='\t'要根据实际文件调整,有的数据集是逗号分隔。value_counts()的输出直接告诉你正负样本比例,如果垃圾邮件只占 5% 以下,后面训练时就要考虑加权损失函数。
2.2 文本清洗的四个关键步骤
原始邮件文本不能直接扔给模型,里面全是噪声。我一般按四步走:去 HTML 标签、转小写、去标点和数字、去停用词。去 HTML 用正则表达式<[^>]+>一把梭;转小写是为了让 “Free” 和 “free” 被当成同一个词;去标点用string.punctuation配合str.translate;去停用词可以用 NLTK 的英文停用词表,也可以自己维护一个精简列表。
import re import string from nltk.corpus import stopwords stop_words = set(stopwords.words('english')) def clean_text(text): # 去 HTML 标签 text = re.sub(r'<[^>]+>', ' ', text) # 转小写 text = text.lower() # 去标点 text = text.translate(str.maketrans('', '', string.punctuation)) # 去数字 text = re.sub(r'\d+', '', text) # 分词并去停用词 words = text.split() words = [w for w in words if w not in stop_words and len(w) > 2] return ' '.join(words) df['clean_text'] = df['text'].apply(clean_text)len(w) > 2这个过滤条件是我踩坑后加的,因为邮件里大量出现 “a”“an”“to” 这类短词,去掉后特征维度能降不少。停用词表用 NLTK 的需要提前nltk.download('stopwords'),如果环境不允许下载,可以手写一个几十个词的列表替代。
2.3 用词袋模型和 TF-IDF 把文本转成向量
全连接网络的输入必须是固定长度的数值向量,所以要把变长文本转成定长表示。最常用的两种方法是词袋模型(Bag of Words)和 TF-IDF。词袋模型只统计词频,TF-IDF 则会给常见词降权、给稀有词升权。在垃圾邮件分类里,TF-IDF 通常比纯词频效果好一到两个百分点,因为像 “free”“win”“click” 这类词在垃圾邮件里高频出现,但在正常邮件里也有,TF-IDF 能更好地区分。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2)) X = vectorizer.fit_transform(df['clean_text']).toarray() y = (df['label'] == 'spam').astype(int).valuesmax_features=5000表示只保留词频最高的 5000 个词或词组,这个值可以根据数据量调整,数据大就设 10000,数据小就设 3000。ngram_range=(1, 2)表示同时考虑单个词和相邻两个词的组合,比如 “click here” 作为一个特征,这能捕捉到一些固定搭配。toarray()把稀疏矩阵转成稠密数组,因为后面要转成 PyTorch 张量。注意如果max_features设得很大,toarray()会消耗大量内存,5000 维、6000 条样本大概是 240MB 左右,一般机器扛得住。
提示:TF-IDF 的
fit_transform只能用在训练集上,验证集和测试集必须用同一个 vectorizer 的transform方法,否则会造成数据泄露。
3. PyTorch 全连接网络搭建:三层结构就够了
3.1 网络结构设计与维度推导
输入维度是 5000(TF-IDF 特征数),输出维度是 1(二分类用 Sigmoid 输出概率)。中间隐藏层我一般用两层:第一层 256 个神经元,第二层 64 个神经元,激活函数用 ReLU,最后接一个 Sigmoid。为什么选这个结构?因为垃圾邮件分类的特征空间虽然大,但有效特征其实集中在几百个词上,隐藏层太宽容易过拟合,太窄又学不到足够模式。256-64 这个配置在我试过的五六个数据集上都表现稳定。
import torch import torch.nn as nn class SpamClassifier(nn.Module): def __init__(self, input_dim): super(SpamClassifier, self).__init__() self.fc1 = nn.Linear(input_dim, 256) self.fc2 = nn.Linear(256, 64) self.fc3 = nn.Linear(64, 1) self.relu = nn.ReLU() self.sigmoid = nn.Sigmoid() self.dropout = nn.Dropout(0.3) def forward(self, x): x = self.relu(self.fc1(x)) x = self.dropout(x) x = self.relu(self.fc2(x)) x = self.dropout(x) x = self.sigmoid(self.fc3(x)) return xDropout(0.3)是防止过拟合的关键,训练时随机丢弃 30% 的神经元,测试时自动关闭。fc3输出一个标量,经过 Sigmoid 后变成 0 到 1 之间的概率值,大于 0.5 判为垃圾邮件。输入维度input_dim要跟 TF-IDF 的max_features保持一致,否则会报维度不匹配错误。
3.2 数据加载与批训练配置
PyTorch 用DataLoader做批训练,需要先把 NumPy 数组转成 Tensor。我一般按 8:2 划分训练集和验证集,用random_split或者手动切分。批大小设 64 或 128,太小训练慢,太大显存吃紧。优化器选 Adam,学习率 0.001,损失函数用BCELoss,因为输出是二分类概率。
from torch.utils.data import DataLoader, TensorDataset from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42) train_dataset = TensorDataset(torch.FloatTensor(X_train), torch.FloatTensor(y_train).unsqueeze(1)) val_dataset = TensorDataset(torch.FloatTensor(X_val), torch.FloatTensor(y_val).unsqueeze(1)) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False) model = SpamClassifier(input_dim=5000) criterion = nn.BCELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001)unsqueeze(1)把标签从形状(N,)变成(N, 1),跟模型输出维度对齐。shuffle=True只在训练集上用,验证集不需要打乱。random_state=42保证每次划分结果一致,方便复现。
3.3 训练循环与验证指标
训练循环里每跑完一个 epoch,就在验证集上算一次准确率和损失。我一般跑 20 个 epoch,如果验证损失连续 3 个 epoch 不下降就提前停。准确率在垃圾邮件分类里其实不够用,因为正负样本不均衡,模型全预测成正常邮件也能有 80% 准确率。所以我还会看精确率、召回率和 F1 分数。
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score for epoch in range(20): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() output = model(batch_x) loss = criterion(output, batch_y) loss.backward() optimizer.step() model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: output = model(batch_x) preds = (output > 0.5).float() all_preds.extend(preds.numpy()) all_labels.extend(batch_y.numpy()) acc = accuracy_score(all_labels, all_preds) precision = precision_score(all_labels, all_preds) recall = recall_score(all_labels, all_preds) f1 = f1_score(all_labels, all_preds) print(f'Epoch {epoch+1}: Acc={acc:.4f}, Precision={precision:.4f}, Recall={recall:.4f}, F1={f1:.4f}')model.eval()和torch.no_grad()是验证阶段必须加的,前者关闭 Dropout,后者节省显存。(output > 0.5)把概率转成 0 或 1 的硬标签。如果召回率明显低于精确率,说明模型漏掉了很多垃圾邮件,可以调低分类阈值到 0.4 试试。
4. 训练调参与效果验证:让模型真正可用的几个操作
4.1 学习率和批大小的组合实验
学习率和批大小是影响训练效果最直接的两个超参数。我做过一组对比实验:学习率 0.001 配批大小 64,验证集 F1 在 0.96 左右;学习率 0.01 配批大小 128,F1 掉到 0.93,而且训练损失震荡明显;学习率 0.0001 配批大小 32,F1 能到 0.97,但训练时间翻倍。对于毕业设计来说,0.001 配 64 是性价比最高的组合,训练快且效果稳定。
| 学习率 | 批大小 | 验证 F1 | 训练时间(20 epoch) |
|---|---|---|---|
| 0.001 | 64 | 0.96 | 约 40 秒 |
| 0.01 | 128 | 0.93 | 约 25 秒 |
| 0.0001 | 32 | 0.97 | 约 90 秒 |
如果训练损失一直不下降,先检查学习率是不是太大;如果训练损失下降但验证损失上升,说明过拟合了,加 Dropout 或者减小隐藏层维度。
4.2 用混淆矩阵看模型到底错在哪
准确率、F1 这些标量指标只能告诉你“好不好”,不能告诉你“错在哪”。混淆矩阵能直观看到有多少垃圾邮件被误判成正常邮件,有多少正常邮件被误判成垃圾邮件。后者更严重,因为把正常邮件扔进垃圾箱可能导致用户漏看重要信息。
from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('Predicted') plt.ylabel('Actual') plt.show()如果混淆矩阵显示假阴性(垃圾邮件判成正常)很多,可以调低阈值;如果假阳性(正常邮件判成垃圾)很多,可以调高阈值。我一般会把阈值从 0.5 调到 0.6,牺牲一点召回率换取更高的精确率,因为误杀正常邮件的代价更大。
4.3 保存模型和推理单条新邮件
训练完之后要把模型参数和 TF-IDF 向量器一起保存,否则下次推理时没法复现同样的特征转换。PyTorch 用state_dict()保存权重,用torch.save存成.pth文件。TF-IDF 向量器用 joblib 保存。
import joblib # 保存模型权重 torch.save(model.state_dict(), 'spam_classifier.pth') # 保存 TF-IDF 向量器 joblib.dump(vectorizer, 'tfidf_vectorizer.pkl') # 推理新邮件 def predict_email(text, model, vectorizer): model.eval() cleaned = clean_text(text) features = vectorizer.transform([cleaned]).toarray() tensor = torch.FloatTensor(features) with torch.no_grad(): prob = model(tensor).item() return 'spam' if prob > 0.5 else 'ham', prob result, prob = predict_email("Congratulations! You won a free iPhone!", model, vectorizer) print(f'Prediction: {result}, Probability: {prob:.4f}')推理时要保证clean_text函数和训练时完全一致,否则特征分布对不上,预测结果会莫名其妙地差。vectorizer.transform而不是fit_transform,这个坑我见过太多人踩。
5. 避坑与排查:垃圾邮件分类项目里最容易翻车的五个地方
5.1 数据泄露:验证集准确率虚高到 99%
现象:训练时验证集准确率一路飙到 99%,但拿新邮件测试时效果很差。
原因:在划分训练集和验证集之前就做了 TF-IDF 的fit_transform,导致验证集的词汇信息泄露到了训练阶段。或者用fit_transform处理了全量数据再切分。
解决:先切分数据,再在训练集上fit_transform,验证集和测试集只用transform。这个顺序不能乱。
5.2 维度不匹配:RuntimeError: mat1 and mat2 shapes cannot be multiplied
现象:模型初始化时报维度错误,提示输入特征数和全连接层权重形状对不上。
原因:TfidfVectorizer的max_features设了 5000,但实际数据去重后只有 4800 个词,toarray()出来的列数是 4800,而nn.Linear(5000, 256)期望输入 5000 维。
解决:用vectorizer.get_feature_names_out()的长度作为input_dim,或者直接X.shape[1]传给模型构造函数。不要硬编码 5000。
5.3 过拟合:训练集 F1 0.99,验证集 F1 0.85
现象:训练集上表现完美,验证集差一大截,损失曲线一个降一个升。
原因:模型参数太多、训练数据太少、Dropout 没加或者太小。
解决:把隐藏层从 256-64 降到 128-32,Dropout 从 0.3 提到 0.5,或者加 L2 正则化(在优化器里设weight_decay=1e-4)。数据量实在少的话,可以做数据增强,比如同义词替换。
5.4 停用词表下载失败导致程序卡住
现象:运行nltk.download('stopwords')时网络超时,程序一直卡在那里。
原因:NLTK 的数据服务器在境外,网络不稳定时下载会失败。
解决:提前手动下载好停用词文件放到 NLTK 数据目录,或者直接手写一个停用词列表替代。我一般手写 50 个最常见的英文停用词,效果差别不大。
5.5 阈值 0.5 不是万能:精确率和召回率需要权衡
现象:模型输出的概率集中在 0.4 到 0.6 之间,用 0.5 做阈值时精确率和召回率都不理想。
原因:Sigmoid 输出的概率分布受训练数据正负比例影响,正样本少的时候概率会偏低。
解决:画一条精确率-召回率曲线,找到 F1 最大的阈值点。或者直接用sklearn.metrics.precision_recall_curve自动选阈值。我一般会把阈值调到 0.6 左右,优先保证正常邮件不被误杀。
6. 从全连接网络再往前走一步:特征工程和模型融合的实战技巧
全连接网络跑通之后,如果想进一步提升效果,最有效的方向不是换更深的网络,而是回头做特征工程。我在实际项目里试过几个技巧,投入产出比很高。第一个是加字符级 TF-IDF,把analyzer='char_wb'和ngram_range=(2, 4)跟词级特征拼在一起,能捕捉到 “Fr33”“C1ick” 这类垃圾邮件常用的变形词。第二个是加邮件元特征,比如大写字母比例、感叹号数量、URL 数量、邮件长度,这些手工特征跟 TF-IDF 拼起来后,F1 能再涨 1 到 2 个百分点。
# 字符级 TF-IDF 与词级特征拼接 char_vectorizer = TfidfVectorizer(analyzer='char_wb', ngram_range=(2, 4), max_features=2000) X_char = char_vectorizer.fit_transform(df['clean_text']).toarray() X_combined = np.hstack([X, X_char])np.hstack把两个特征矩阵按列拼接,输入维度变成 7000。注意字符级特征也要在训练集上fit,验证集用transform。这个操作会让训练时间增加 30% 左右,但效果提升明显。
另一个技巧是模型融合。训练三个不同随机种子的全连接网络,推理时取平均概率。这个方法简单粗暴,但能把 F1 再拉高 0.5 到 1 个百分点。我一般用三个种子:42、123、2024,分别训练后保存权重,推理时加载三个模型取平均。
def ensemble_predict(text, models, vectorizer): cleaned = clean_text(text) features = vectorizer.transform([cleaned]).toarray() tensor = torch.FloatTensor(features) probs = [] for model in models: model.eval() with torch.no_grad(): probs.append(model(tensor).item()) avg_prob = sum(probs) / len(probs) return 'spam' if avg_prob > 0.5 else 'ham', avg_prob最后说一个我自己的习惯:每次跑完实验,不管效果好坏,都把超参数、验证指标和混淆矩阵记到一个表格里。看起来麻烦,但当你试到第十组参数时,回头翻记录能省下大量重复实验的时间。垃圾邮件分类这个任务不难,但要把每个环节做扎实,从数据清洗到阈值选择,每一步都有讲究。希望帮到你。
本文还有配套的精品资源,点击获取