简介:这是一份基于Pytorch的垃圾邮件分类完整实现,面向深度学习初学者以及需要完成课程设计、毕业设计的计算机专业学生。项目采用MLP全连接神经网络与优化器进行有监督分类,完整覆盖spambase数据读取、特征处理、模型搭建、迭代训练与效果评估流程,并使用PytorchViz将网络结构可视化,用Canvas绘制损失函数与识别精度的动态变化曲线,便于直观理解训练过程。压缩包共20个文件,包含可直接运行的Python源码、csv数据文件、网络与曲线可视化图(png/gv格式)、PDF/Word/文本文档等,整体仅7.18MB,目录清晰、依赖简单,可快速迁移到类似文本分类任务。目前已有1016人学习下载,实用性已得到验证。资源内的报告文档和可视化图表能够帮助使用者梳理MLP分类原理,并为毕业设计说明书或课程报告提供现成材料。
1. 拿 PyTorch 全连接神经网络做垃圾邮件分类:这份代码为什么能直接跑
如果你在找毕业设计的落脚点,大概率见过两类资源:一类把原理写到天花乱坠,代码却缺胳膊少腿;另一类代码齐全,但 README 写得像天书,跑起来全是坑。「深度学习 PyTorch 全连接神经网络 垃圾邮件的分类」这套资源,难得的是把完整代码和可直接用的数据集一起给你,模型能训练、能评估、能对单条短信做预测,属于打开即用的完整闭环。它主线是经典的全连接网络(MLP)做文本二分类,预处理用 TF-IDF 向量化,框架是 PyTorch。尤其适合基础一般、没多少时间折腾环境、只求把流程跑通再谈改进的学生。我拆完这套资源的整体思路和落坑记录都在下文,照着做能省下你至少两周的弯路。
2. 数据处理和 TF-IDF 向量化:模型效果七成由这一步决定
2.1 数据长什么样:确认标签、样本量和编码
这套资源默认用的是公开的 SMS Spam Collection 类数据集,特点非常典型:英文短信为主,字段只有两列,一列是 label(spam / ham),一列是短信原文。这类数据集的干净程度比中文评论数据好很多,因为没有分词负担,但仍有格式问题需要处理。
拿到代码后,第一步先读数据并做基础统计,我一般在项目里会先加一段最基础的数据探查代码:
import pandas as pd from sklearn.model_selection import train_test_split df = pd.read_csv('./data/spam.csv', encoding='latin-1') # 原数据集常有多余的无名列,先清掉 df = df[['v1', 'v2']].copy() df.columns = ['label', 'text'] print(df['label'].value_counts()) print('空值数量:', df.isnull().sum().sum()) print('重复样本数:', df.duplicated().sum())这里有几个参数值得注意。encoding='latin-1'是这个数据集的老坑,很多渠道下载的版本带特殊字符,用utf-8读会直接报错。v1、v2是原始列名,v1是标签、v2是短信内容,不同版本列名可能略有差异,建议先打印df.columns确认一次。数据集里 ham 远多于 spam,这种类别不平衡会直接影响后面训练,需要记一下比例。重复样本删除与否,取决于你是否担心模型把重复文本的标签背下来,我一般会保留,因为真实场景里重复内容出现很正常。
2.2 把文本变成张量输入:TF-IDF 的参数取值逻辑
全连接神经网络不能直接吃字符串,需要把文本转成数值向量。这套资源选用 TF-IDF 而不是 Word2Vec 或 BERT,核心原因是:数据量小,TF-IDF 足够表达词的重要性,且训练快、内存占用低。我拆过的类似项目里,TF-IDF 加全连接在垃圾邮件这个任务上,效果不比词向量差太多,但调试成本低一个量级。
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( lowercase=True, analyzer='word', stop_words='english', max_features=5000, ngram_range=(1, 2), sublinear_tf=True ) X = vectorizer.fit_transform(df['text']) y = (df['label'] == 'spam').astype(int).values # 全连接网络输入是稠密向量,转成数组 X = X.toarray() print('向量维度:', X.shape) 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是影响最大的一个。它限制词汇表大小,防止稀疏矩阵维度爆炸,同时也算一种轻量特征筛选。ngram_range=(1, 2)同时保留单词和相邻双词组合,对垃圾邮件里 “free prize” 这类复合表达很有用。sublinear_tf=True用 1 + log(tf) 替代原始词频,削弱高频词的主导地位。stop_words='english'直接过滤掉 the、and 这类无意义词。stratify=y保证切分后训练集和测试集里正负样本比例一致,这一步对类别不平衡数据非常重要,忘了会导致测试集里 spam 占比失真。toarray()把稀疏矩阵转成普通二维数组,因为后面要喂给全连接层。
提示:如果你的数据是中文短信,
analyzer='word'就不适用了,需要先用 jieba 分词再传入,stop_words也要换成中文停用词表。这套资源是英文数据,中文场景可以直接沿用 TF-IDF 模块,只换预处理。
2.3 DataLoader 封装与批次设计
PyTorch 训练需要数据迭代器,这里把已经向量化好的 NumPy 数组封装成 Dataset 与 DataLoader。
import torch from torch.utils.data import TensorDataset, DataLoader from sklearn.preprocessing import StandardScaler # 对输入向量做标准化,加速全连接网络收敛(不是必须,但建议加) scaler = StandardScaler(with_mean=False) X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32).view(-1, 1) X_test_t = torch.tensor(X_test, dtype=torch.float32) y_test_t = torch.tensor(y_test, dtype=torch.float32).view(-1, 1) train_dataset = TensorDataset(X_train_t, y_train_t) test_dataset = TensorDataset(X_test_t, y_test_t) batch_size = 64 train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False) print('训练批次数量:', len(train_loader))StandardScaler在这里用的with_mean=False,是因为 TF-IDF 矩阵是稀疏的,按列做减去均值的操作会让矩阵变稠密,直接爆内存。如果不做标准化,全连接网络也能收敛,但收敛速度明显慢。view(-1, 1)把标签形状从[N]变成[N, 1],这样和模型输出的形状能直接做 BCELoss 计算。shuffle=True只用于训练集,每次迭代打乱样本顺序,避免模型学到批次内的固定排列。
3. 搭建全连接网络与训练管线:PyTorch 的核心代码拆解
3.1 网络结构设计:隐藏层维度怎么定
垃圾邮件分类是二分类问题,输出层一个节点即可。这套资源的网络结构是典型的全连接堆叠:输入层维度等于 TF-IDF 特征数,接着若干隐藏层,每层后面接 ReLU 激活和 Dropout,最后接 Sigmoid。
import torch.nn as nn class SpamClassifier(nn.Module): def __init__(self, input_dim, hidden_dims=[256, 128], dropout_prob=0.5): super().__init__() layers = [] prev_dim = input_dim for hidden_dim in hidden_dims: layers.append(nn.Linear(prev_dim, hidden_dim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(dropout_prob)) prev_dim = hidden_dim layers.append(nn.Linear(prev_dim, 1)) self.net = nn.Sequential(*layers) def forward(self, x): return self.net(x) model = SpamClassifier(input_dim=X_train.shape[1], hidden_dims=[256, 128]) print(model)hidden_dims=[256, 128]是这份资源在测试集上表现比较稳定的组合。输入 5000 维压到 256,再到 128,最后输出 1,逐层降维。隐藏层不是越宽越好,5000 维直接映射到 1 维是线性模型的做法,非线性拟合能力不够;直接上 512、512 这种大结构,在小数据集上几乎必然过拟合。dropout_prob=0.5每次训练随机丢弃一半的神经元连接,相当于隐性地做模型集成,对文本高维稀疏输入特别有效。ReLU 不用多解释,线性层之后没有非线性激活,多层就等价于一层,这是必须的。
3.2 损失函数、优化器和训练循环:为什么用 BCELoss 而不是 CrossEntropyLoss
输出层是 1 个节点过 Sigmoid 得到概率值,对应二分类,正反两类互斥,所以用BCELoss。如果你把输出层改成 2 个节点再过 Softmax,那就该用CrossEntropyLoss。两者数学上等价,但后者还包含 LogSoftmax,数值稳定性更好。这里的代码选择前一种,代码更少。
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.BCELoss() optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-5) def train_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0, 0, 0 for X_batch, y_batch in loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) optimizer.zero_grad() outputs = torch.sigmoid(model(X_batch)) loss = criterion(outputs, y_batch) loss.backward() optimizer.step() preds = (outputs > 0.5).float() total_loss += loss.item() * X_batch.size(0) correct += (preds == y_batch).sum().item() total += y_batch.size(0) return total_loss / total, correct / total train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer, device) print(f'训练集 loss: {train_loss:.4f}, acc: {train_acc:.4f}')weight_decay=1e-5是 L2 正则化的 PyTorch 实现,和 Dropout 双管齐下压制过拟合。lr=0.001是 Adam 的默认学习率,对这个任务通常够用。model.train()打开 Dropout 和 BatchNorm 的训练模式,这个细节一旦漏了,Dropout 失效,训练和预测时模型行为不一致。optimizer.zero_grad()必须在每个 batch 之前清空梯度,否则梯度会在 batch 之间累积。输出层前面手动加了torch.sigmoid,而nn.BCELoss要求输入已经是概率值,记住这个对应关系,排错时会省很多事。
3.3 完整训练流程:多少个 epoch 合适
epochs = 20 best_acc = 0 for epoch in range(epochs): train_loss, train_acc = train_epoch( model, train_loader, criterion, optimizer, device ) print(f'Epoch {epoch+1:02d} | ' f'Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f}')20 个 epoch 不是拍脑袋来的。这个数据量级别的垃圾邮件分类任务,用 TF-IDF 加全连接网络,通常在 10 到 25 轮之间收敛,超过 30 轮后训练 loss 降得很慢,测试指标反而会逐步下滑。best_acc变量用来记录当前最优模型,后面讲模型保存时会用它做条件判断。训练过程中可以顺手打印每个 epoch 的训练 loss,观察 loss 是否平稳下降,如果出现震荡或直接原地不动,先去查学习率,而不是加 epoch 数量。
4. 评估与模型保存:别只盯着准确率,召回率才是垃圾邮件的关键
4.1 混淆矩阵、精确率、召回率和 F1
垃圾邮件分类和一般图像分类不一样,图像分类看准确率基本够用,垃圾邮件不行。试想:30 条重要邮件里有 1 条被误判成垃圾邮件,这个模型体验是灾难性的。实际操作中,宁可让 5 条垃圾邮件漏网,也别误杀一条正常邮件。所以评估阶段必须看混淆矩阵和召回率。
from sklearn.metrics import ( confusion_matrix, classification_report, precision_recall_fscore_support ) model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for X_batch, y_batch in test_loader: X_batch = X_batch.to(device) outputs = torch.sigmoid(model(X_batch)) preds = (outputs > 0.5).float().cpu() all_preds.extend(preds.numpy().flatten()) all_labels.extend(y_batch.numpy().flatten()) print(confusion_matrix(all_labels, all_preds)) print(classification_report(all_labels, all_preds, target_names=['ham', 'spam']))model.eval()关闭 Dropout,这一点和前面的model.train()对应。如果没有这行代码,同样的输入在两次预测中结果可能不同,因为 Dropout 在推理时还在随机丢神经元。torch.no_grad()告诉 PyTorch 不需要计算梯度,推理时显存占用更低。target_names的顺序,spam 是正类(1),ham 是负类(0),如果查classification_report出现对应错位,先查标签映射关系。
提示:阈值 0.5 不是不可变的。如果你的应用更在意「不能漏掉垃圾邮件」,可以把阈值降到 0.3;更在意「不能误杀正常邮件」,就调到 0.7。这个操作不需要重新训练,只调整判定边界即可。
4.2 保存和加载模型:要存 state_dict 而不是整个模型
模型训练耗时不算长,但保存仍然是必须的,因为你不可能每次预测都重新训练一遍。PyTorch 官方推荐的方式是只保存state_dict,不保存整个模型对象,这样和 PyTorch 版本兼容性更好。
# 保存模型参数 torch.save(model.state_dict(), './checkpoints/spam_model.pt') # 预测阶段加载模型 model = SpamClassifier(input_dim=X_train.shape[1], hidden_dims=[256, 128]) model.load_state_dict(torch.load('./checkpoints/spam_model.pt', map_location='cpu')) model.eval()map_location='cpu'解决 GPU 训练的模型在纯 CPU 机器上加载的问题。如果训练时用了device='cuda',加载时不加这个参数,在无 GPU 环境会直接报错。加载后必须再执行一次model.eval(),因为 load 之后模型默认还是在train()模式。顺带说一句,整套资源里最好把词表(vectorizer)也保存下来,没有它等于只拿到了枪没拿到子弹——预测新文本时需要走同一个 TF-IDF 变换流程,维度才能对齐。
5. 避坑与常见问题:这份资源最容易翻车的五个地方
5.1 报错:RuntimeError: size mismatch
现象:训练第一个 epoch 时报size mismatch for net.0.weight: copying a param with shape torch.Size([256, 5000]) ...。
原因:加载预训练模型时input_dim和当前数据特征数不一致,通常是你换了自己的数据集,词表大小变了,但还想着用原模型的权重。
解决:模型第一个 Linear 层的输入维度必须严格等于 TF-IDF 特征数。换数据就重新训练,或者修改max_features保持维度一致后重新训练,不要硬加载旧权重。我一般会写一个断言,启动时先打印model里的第一层维度,再和X_train.shape[1]比对。
5.2 现象:测试准确率 99%,但实测新短信几乎全判成 spam
原因:这是信息泄漏的经典案例。如果在fitTF-IDF 之前先做了train_test_split,然后在全量数据上拟合 vectorizer,测试集的信息就已经参与了特征构建。更常见的变体是:在向量化之后做标准化,但scaler.fit用的是全量 X。
解决:正确顺序是——先切分数据,再在训练集上调fit_transform,用同一个已拟合的 vectorizer 对测试集只做transform。资源里有时为了简洁没强调这个顺序,如果你拿到的代码是「先 fit 再 split」,请务必改过来。这条不修,模型指标好看但完全没实用价值。
5.3 现象:模型预测结果全是 0(全是 ham)
原因:类别不平衡加上随机初始化,模型陷入局部最优,把所有样本都预测为多数类。BCELoss 在这种场景下很容易出现「学不到少数类」的问题。
解决:先确认y的标签映射是对的。如果正负比超过 1:5,考虑在损失函数中加权重,torch.nn.BCELoss(weight=torch.tensor([5.0])),其中 5 是负样本数除以正样本数的比值。或者用WeightedRandomSampler做有放回采样。这套资源的数据里 spam 占比约 13%,不算极端,但如果你把数据换成自己收集的,比例可能完全不同。
5.4 现象:Anaconda 里 PyTorch 装完能 import,但torch.cuda.is_available()永远返回 False
原因:安装的是 CPU 版 PyTorch。PyTorch 的安装包区分 CUDA 版和 CPU 版,在 PyPI 官方源里默认拉到的就是 CPU 版,pip install torch不会自动给你带 CUDA 支持的版本。
解决:去 PyTorch 官网选你的 CUDA 版本对应的安装命令,比如pip install torch --index-url https://download.pytorch.org/whl/cu118(CUDA 11.8)。装完验证两件事:torch.__version__里是否带+cu后缀,以及torch.cuda.is_available()是否返回 True。整套资源不依赖 GPU,纯 CPU 也能跑完 20 个 epoch,只是每个 epoch 会慢 3 到 5 倍。
5.5 现象:训练 loss 在 0.69 附近震荡,怎么调参都不降
原因:这是模型没有学到任何有效信息的典型信号。0.69 约等于-ln(0.5),说明模型输出概率接近 0.5,等价于随机猜测。
解决:按顺序排查——输入特征是否全零(TF-IDF 参数设太大,所有值被截断);标签是否已经映射正确(全是 0 或全是 1);学习率是否过大导致发散(改成 0.0001 试一下)。三个都查完,最后看X_train[0]的前 20 个值是否非零。最常见的原因是max_features设置过小,整个向量化后每个样本的非零特征极少,模型没有有效输入。
6. 把模型用起来:从 Jupyter 到命令行预测脚本
资源里的代码大概率以 Jupyter Notebook 组织,但毕业设计答辩时,只拿出一个带输出的 notebook 是不够分量的。把训练好的模型封装成命令行工具会是一个亮点。我用argparse写了一个可接收单条文本、输出预测结果的脚本,这里分享核心段落。
import argparse import torch from sklearn.feature_extraction.text import TfidfVectorizer import joblib def parse_args(): parser = argparse.ArgumentParser(description='垃圾邮件分类预测') parser.add_argument('--text', type=str, required=True, help='输入短信文本') parser.add_argument('--model_path', type=str, default='./checkpoints/spam_model.pt') parser.add_argument('--vectorizer_path', type=str, default='./checkpoints/vectorizer.pkl') return parser.parse_args() args = parse_args() vectorizer = joblib.load(args.vectorizer_path) model = SpamClassifier(input_dim=len(vectorizer.vocabulary_), hidden_dims=[256, 128]) model.load_state_dict(torch.load(args.model_path, map_location='cpu')) model.eval() X = vectorizer.transform([args.text]).toarray() X = scaler.transform(X) # 别忘了保存并加载训练时的 scaler with torch.no_grad(): prob = torch.sigmoid(model(torch.tensor(X, dtype=torch.float32))).item() print(f'Spam 概率: {prob:.4f}, 判定: {"spam" if prob > 0.5 else "ham"}')注意这里input_dim用的是len(vectorizer.vocabulary_),对应训练时的词表大小。第 5 章提到的 scaler 在这里也要保存和加载,没有它的标准化步骤,模型输入分布和训练时不一致,预测概率会偏移得离谱。保存方式用joblib.dump(scaler, './checkpoints/scaler.pkl'),加载时保持同名。
如果你想把项目再往上提一个档次,可以加一个简单的混淆矩阵可视化图,以及用matplotlib画训练和验证的 loss 曲线图。这些图放到毕业论文里,比大段文字说明直观得多。我还习惯在训练脚本里加一个--eval_only模式,跳过训练直接跑评估,省去重新训练的时间消耗。
从那次拆包之后,我每拿到一份开源资源,都强制先跑通再改结构,不跳过预处理直接开训练。这个小习惯让我避开了至少三次「数据泄漏导致的虚假高准确率」。希望这套拆解帮你在毕业设计里少走几步弯路,把时间花在真正有增量的事情上。
本文还有配套的精品资源,点击获取