恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
PyTorch全连接神经网络垃圾邮件分类实战:从文本清洗到模型训练
首页
资讯中心
/
PyTorch全连接神经网络垃圾邮件分类实战:从文本清洗到模型训练
PyTorch全连接神经网络垃圾邮件分类实战:从文本清洗到模型训练
发布时间:2026/9/27 23:35:16
简介这份PyTorch全连接神经网络垃圾邮件分类资源面向需要完成毕业设计或课程综合实践的计算机相关专业学生解决利用深度学习进行有监督文本分类的完整落地问题。项目基于Pycharm与Anaconda环境使用Pytorch搭建MLP全连接神经网络和优化器实现垃圾邮件二分类并借助PytorchViz将网络结构可视化、用Canvas绘制损失值与识别精度的动态变化曲线有助于直观理解模型训练与收敛过程。压缩包共20个文件约7.18MB包含可直接运行的main.py源码、spambase数据集及特征说明、网络结构图与流程png、Pytorch项目配置xml以及报告/文档等类型涵盖源代码、数据、可视化图表与实验报告目录组织清晰。目前已由1016人学习下载适合毕业设计、课程实践或机器学习入门参考。读者可获得完整可复现代码、数据预处理与模型评估流程并借助配套文档快速完成环境搭建与结果分析。1. 全连接网络做垃圾邮件分类为什么这个选题值得直接照着做用深度学习 Pytorch 搭一个全连接神经网络做垃圾邮件分类是我认为毕业设计里性价比最高的一条技术路线。任务定义清晰、数据公开好找、代码体量可控又能把文本预处理、词表构建、神经网络、训练评估整条链路完整走一遍答辩时既有代码又有实验数据可以讲。这几年用 Pytorch 做毕设的人很多但很多人卡在第一步文本数据怎么转成张量、全连接层怎么接在文本后面、训完怎么证明模型真的有效。这篇笔记按常见毕设项目的组织方式把从原始邮件文本到可直接运行模型的步骤拆开讲中间附全部关键代码和踩坑记录。适合准备毕业设计、或刚学完 Pytorch 基础想找一个完整文本分类项目练手的人。2. 把邮件文本转成张量清洗、词表与 DataLoader 的三个关键参数拿到一个标着「完整代码数据、可直接运行」的项目先别急着跑 train.py第一步是确认手头数据长什么样。绝大多数可直接运行的垃圾邮件分类项目数据组织只有两种一种是 CSV 文件两列分别是 label 和 text另一种是两个文件夹 spam/ 与 ham/文件按序号命名。前者更常见因为做分层抽样、计算类别比例都方便。跑通之前先把数据路径、列名和标签取值0/1 还是 ham/spam搞清楚这一步能省掉后面大量玄学报错。2.1 文本清洗中英文混合数据怎么统一格式垃圾邮件数据里什么都有HTML 残留、网址、数字、表情符号、大小写混写。模型看到的是词不是字符所以清洗的目标是「让同一个词的不同写法尽量变成同一个词」。英文要小写、去掉标点中文要做分词对「fRee」和「FREE」这种变形统一小写后就对齐了。下面这个清洗函数是我常用的底线版本兼顾中英文import re import jieba def clean_text(text, languagezh): text text.lower() text re.sub(r.*?, , text) # 去掉 HTML 标签 text re.sub(r[^\w\u4e00-\u9fa5], , text) # 只保留中英文、数字、下划线 if language zh: words [w for w in jieba.cut(text) if w.strip()] return .join(words) return .join(text.split())参数说明language控制是否走 jieba 分词英文数据直接按空白切分第二行正则里的\u4e00-\u9fa5是中文的 Unicode 区间不加的话中文全被当标点删掉。这里re.sub(r[^\w\u4e00-\u9fa5], , text)把网址、标点都替换成空格之后split()自然把多余空格压掉。注意我没有做停用词过滤垃圾邮件分类里「免费」「点击」「中奖」恰恰是关键信号停用词表反而会把这些词删掉这一步是很多初版模型效果差的原因之一。2.2 词表构建min_freq 和 max_vocab 怎么定清洗之后是构建词表。词表本质上是一个「词 → 数字索引」的字典模型只能消费数字。构建词表有两个参数决定词表质量min_freq过滤出现次数太少的词max_vocab限制词表总大小。min_freq2通常够用设太高会把「发票」「中奖」这种低频但高信号的词也滤掉max_vocab在 15000 到 30000 之间内存和效果平衡点。from collections import Counter def build_vocab(texts, min_freq2, max_vocab20000): counter Counter() for line in texts: counter.update(line.split()) vocab {pad: 0, unk: 1} for word, freq in counter.most_common(max_vocab - 2): if freq min_freq: vocab[word] len(vocab) return vocab逻辑说明pad占索引 0用于把短序列填充到统一长度unk占索引 1用于预测阶段遇到词表外单词时的兜底。counter.most_common(max_vocab - 2)先按频次排序再截断减去 2 是因为前两个位置被特殊符号占了。这个函数返回的vocab字典会贯穿整个项目训练时用它把文本换成索引序列预测时也要加载同一个词表否则索引对不上。2.3 Dataset 与 collate_fn变长序列怎么对齐PyTorch 的 DataLoader 在取 batch 时默认调用torch.stack它要求每个样本形状完全一致。邮件长度天然不同直接塞进去必报 shape 错误所以必须自定义collate_fn做填充对齐。import torch from torch.utils.data import Dataset, DataLoader from torch.nn.utils.rnn import pad_sequence class SpamDataset(Dataset): def __init__(self, texts, labels, vocab, max_len64): self.data [] for text, label in zip(texts, labels): ids [vocab.get(w, vocab[unk]) for w in text.split()] ids ids[:max_len] # 超过直接截断 self.data.append((torch.tensor(ids, dtypetorch.long), torch.tensor(label, dtypetorch.float32))) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx] def collate_fn(batch): seqs, labels zip(*batch) seqs_padded pad_sequence(seqs, batch_firstTrue, padding_value0) return seqs_padded, torch.stack(labels)参数说明max_len是截断长度后面单讲pad_sequence(batch_firstTrue, padding_value0)把一批长度不等的序列右侧补 0补到 batch 内最长那条的长度。注意标签我转成了float32不是long因为后面要用 BCEWithLogitsLoss它要求标签是浮点型的 0/1。Dataset 里的__getitem__返回的是两个 tensorcollate_fn 再把它们合并成一个 batch。2.4 max_len 怎么选看长度分布别拍脑袋max_len是垃圾邮件分类里最容易拍脑袋定的参数。设太小长邮件的关键信息被截掉设太大大部分样本都是 padding模型学到一堆无意义的填充位置特征。常见做法是先跑一次所有训练样本的分词结果统计每条文本的词数取 90 分位数作为max_len。参数建议区间取值逻辑max_len32128按训练集长度分布的 90 分位数取先画直方图再定min_freq23过滤只出现一次的词太低词表膨胀太高丢信号max_vocab1500030000覆盖全量词频后截断兼顾内存与 OOV 率embed_dim64128文本分类任务 64 够用资源宽裕可以上 128提示padding 方向默认在右侧。用平均池化聚合特征时右侧的 0 会把均值拉低序列越长拉得越狠。这一点在模型部分要用 mask 解决后面避坑章节会再展开。3. 搭建全连接网络主体Embedding、平均池化与三层 MLP 的维度流动模型结构是毕设里老师最会追问的部分。选择全连接神经网络做垃圾邮件分类不是因为它最先进而是它在这类任务上的性价比极高且每一层都能讲出明确作用。这一章把模型拆成 Embedding、池化、全连接三块讲清楚每块在做什么、数据维度在其中怎么变化。3.1 为什么垃圾邮件分类用 MLP 就够了先看为什么不选别的。LSTM 擅长捕捉长距离依赖但垃圾邮件的判别信号集中在「免费」「中奖」「点击链接」「发票」这类词上词与词之间没有复杂的语法依赖用 LSTM 属于杀鸡用牛刀训练还慢。CNN 做文本需要设计卷积核宽度要调 kernel size 才能覆盖不同长度的 n-gram对新手不友好。Transformer 效果好但训练脚本和数据量要求都高CPU 环境跑起来非常痛苦调参翻车概率最大。模型训练速度CPU效果代码量调参难度三层 MLP快良好最少低TextCNN中良好中中要调 kernelLSTM慢中等中中要处理变长序列Transformer很慢好但依赖数据量多高全连接网络对应的是「词嵌入平均 → 多层感知机」这个经典结构本质是把邮件表示成一个定长向量再让 MLP 在这个向量上学习分类边界。邮件平均长度一般只有几十个词词表在 2 万以内这个规模下 MLP 的参数量适中不容易过拟合也方便解释。3.2 模型定义SpamMLP 的核心代码下面这份代码是完整的垃圾邮件分类模型网路部分。结构上就是Embedding 查表 → 平均池化 → 两个全连接层。代码量不大但每一层的职责必须讲清。import torch.nn as nn class SpamMLP(nn.Module): def __init__(self, vocab_size, embed_dim64, hidden_size128, num_classes1, dropout0.3, padding_idx0): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpadding_idx) self.classifier nn.Sequential( nn.Linear(embed_dim, hidden_size), # 第一层线性变换 nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_size, num_classes) # 输出层输出 1 个 logit ) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embed_dim) pooled emb.mean(dim1) # (batch, embed_dim) 平均池化 return self.classifier(pooled).squeeze(-1) # (batch,)逻辑说明输入x是形状(batch, seq_len)的索引矩阵每个元素是词表里的数字编号。nn.Embedding维护一个vocab_size × embed_dim的查找表把编号映射成稠密向量。emb.mean(dim1)对序列长度那一维做平均把所有词的向量压缩成一个句子向量。最后经过两个线性层输出一个 logit注意这里没有手动加 Sigmoid因为损失函数内部会处理避免重复计算导致梯度问题。3.3 前向传播的维度变化每一步都能量出来给新手一个自查手段在forward里临时打印每个变量的 shape是排查维度错误最快的方法。以batch64, max_len64, embed_dim64为例数据流动是这样的x(64, 64)64 封邮件每封最多 64 个词索引embedding(x)(64, 64, 64)每个词变成 64 维向量mean(dim1)(64, 64)按词维度平均得到每封邮件的句子向量classifier(pooled)先变成(64, 128)再变成(64, 1)经过squeeze(-1)变成(64,)为什么不直接flatten展平64 × 64 4096维第一层全连接就是4096 × 128约 52 万个参数训练慢、容易过拟合而且 padding 位置也会被当成有效特征。平均池化把序列压成一个定长向量全连接层只在这个向量上做非线性组合参数量小一个数量级。3.4 四个必调参数embed_dim、hidden_size、dropout 与 padding_idx把SpamMLP里的参数逐个说清楚这也是答辩时最容易被问到的地方。参数默认值调参方向vocab_size词表长度由build_vocab决定不用手动调embed_dim64词向量维度小于 32 信息不够大于 128 收益递减hidden_size128先从 64 起步验证集 F1 不再涨就停dropout0.3过拟合时加到 0.5欠拟合时降到 0.1padding_idx0必须和词表里pad的索引一致padding_idx0的作用很关键它让 Embedding 中索引 0 对应的向量全程保持为 0不参与梯度更新。这样 padding 位置至少不会引入随机噪声。但前面说过平均池化仍会被 padding 拖低均值严格做法是计算 mask 后做 masked mean避坑章节会给出替代方案。4. 训练与评估闭环损失函数、混淆矩阵与模型保存模型定义好之后训练循环是第二个容易翻车的区间。这一章给出一套能直接跑通的最小训练闭环包括损失函数选择、训练验证主循环、评估指标和模型保存加载。评估部分特别强调垃圾邮件分类不能只看准确率因为数据通常不均衡准确率会骗人。4.1 损失函数与优化器BCEWithLogitsLoss 的隐藏细节二分类任务的损失函数有两个等价选择nn.CrossEntropyLoss配合标签做long或者nn.BCEWithLogitsLoss配合标签做float。在这个项目里用后者因为它在内部集成了 Sigmoid输出 logits 直接算损失数值稳定性更好不会出现 Sigmoid 后再取 log 导致梯度消失。import torch import torch.nn as nn criterion nn.BCEWithLogitsLoss() # 内部自带 Sigmoid optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size3, gamma0.5)参数说明优化器选 Adam 而不是 SGD因为 Adam 对学习率不敏感1e-3是大多数文本分类任务的稳定起点。StepLR每 3 个 epoch 把学习率乘 0.5用于后期收敛。这里有个常见误用在模型forward里手动加了torch.sigmoid(logits)再交给BCEWithLogitsLoss等于算了两次激活模型很难收敛。如果坚持要在模型里加 Sigmoid损失函数就要换成nn.BCELoss两者必须配套。4.2 训练与验证主循环完整可运行的代码训练循环的骨架是所有 PyTorch 项目通用的置为训练模式、清梯度、前向、算损失、反向、更新。验证循环要包在torch.no_grad()里不计算梯度省显存也避免误更新参数。def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() # 梯度必须清零否则会累加 logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(loader.dataset) torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, total_correct 0.0, 0 for x, y in loader: x, y x.to(device), y.to(device) logits model(x) loss criterion(logits, y) pred (torch.sigmoid(logits) 0.5).long() total_loss loss.item() * x.size(0) total_correct (pred y.long()).sum().item() n len(loader.dataset) return total_loss / n, total_correct / n逻辑说明model.train()和model.eval()切换的是 Dropout 和 BatchNorm 的行为漏掉eval()会导致评估时 Dropout 仍随机丢弃神经元验证指标忽高忽低。optimizer.zero_grad()必须在每次backward()之前调用否则梯度会跨 batch 累加。返回的total_loss / len(loader.dataset)是平均样本损失total_correct / n是准确率。这段代码里的准确率仅供粗看评估指标后面用 sklearn 算更全。4.3 评估指标准确率之外毕设答辩更看重 F1 和混淆矩阵垃圾邮件数据常见分布是 85% 正常、15% 垃圾。如果模型把所有邮件都判成正常准确率是 85%看起来很高其实一封垃圾邮件都拦不住。所以评估必须看精确率、召回率和 F1。from sklearn.metrics import precision_score, recall_score, f1_score, confusion_matrix torch.no_grad() def evaluate_full(model, loader, device, threshold0.5): model.eval() preds, trues [], [] for x, y in loader: x x.to(device) logits model(x) prob torch.sigmoid(logits) preds.extend((prob threshold).long().cpu().tolist()) trues.extend(y.long().tolist()) cm confusion_matrix(trues, preds) return { precision: precision_score(trues, preds), recall: recall_score(trues, preds), f1: f1_score(trues, preds), confusion_matrix: cm, }参数说明threshold默认 0.5但在垃圾邮件场景里把漏报代价看得更高时可以降到 0.3 或 0.4用召回换精确率。混淆矩阵的四象限含义要能讲出来TN 正常邮件放行、FP 正常邮件被误拦、FN 垃圾邮件漏网、TP 垃圾邮件被拦截。毕业设计答辩时老师最常问的就是「误拦一封正常邮件和漏掉一封垃圾邮件哪个代价更大」答案通常是前者所以实际使用时会提高阈值或调整损失权重。4.4 模型保存与单条推理从训练到落地的最后一步训练结束后模型要保存成文件预测新邮件时再加载。只保存state_dict是推荐做法不保存整个模型对象因为后者会带着网络结构代码路径换环境容易反序列化失败。torch.save(model.state_dict(), spam_mlp.pt) # 预测单条文本 def predict(text, model, vocab, device, max_len64): model.eval() ids [vocab.get(w, vocab[unk]) for w in clean_text(text).split()] ids torch.tensor(ids[:max_len], dtypetorch.long).unsqueeze(0).to(device) with torch.no_grad(): logit model(ids) prob torch.sigmoid(logit).item() return prob, prob 0.5说明map_locationcpu是加载时的常用参数训练在 GPU 上跑、预测在 CPU 上跑时加载必须带它否则会报RuntimeError: Attempting to deserialize object on a CUDA device。预测时把单条文本转成和训练一致的(1, seq_len)形状注意unsqueeze(0)加的是 batch 维度。prob是垃圾邮件的概率阈值 0.5 只是起点根据实际场景调整。5. 避坑指南垃圾邮件分类最容易翻车的五个环节这一章写的是把这个方案在本地完整跑一遍时几乎每个人都会撞上的坑。每一条都按「现象 → 原因 → 解决」写很多问题报错信息不会直接告诉你是哪行代码出的问题要靠经验和排查顺序定位。5.1 坑一DataLoader 报错 stack expects each tensor to be equal size现象训练脚本刚启动DataLoader 取第一批数据时就抛RuntimeError提示 tensor 形状不一致。原因邮件文本长度不同直接torch.stack无法把长度不等的序列堆成矩阵。解决给 DataLoader 传入自定义collate_fn在里面调用pad_sequence做填充就是 2.3 节那段代码。loader DataLoader(dataset, batch_size64, shuffleTrue, collate_fncollate_fn)注意只写collate_fncollate_fn还不够DataLoader还有一个默认参数叫batch_size如果pad_sequence放在 Dataset 里做而 collate_fn 没生效问题依旧。检查 DataLoader 构造时每个参数是否都传进去了。5.2 坑二x 在 GPU 上、y 在 CPU 上loss 计算直接报错现象训练前两个 batch 正常第三个 batch 报Expected all tensors to be on the same device。原因.to(device)只对输入x做了标签y忘了转。解决把x, y x.to(device), y.to(device)写在同一行训练和验证循环都要写。这属于低级的但最常见的翻车点一次循环漏掉后面全是黑匣子报错。5.3 坑三数据不均衡准确率 90% 但垃圾邮件一封没拦住现象训练结束打印准确率 0.87把验证集结果拉出来看预测全是正常邮件。原因数据集里正常邮件占多数模型学到的最省事决策是「全预测成正常」因为这样准确率最高。解决先打印标签分布确认比例再改用 F1 做评估给BCEWithLogitsLoss传pos_weight提高垃圾邮件的误分类代价pos_weight torch.tensor([neg_count / pos_count]) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight)参数说明pos_weight是正样本权重等于负样本数除以正样本数。这个值越大模型把垃圾邮件预测成正常邮件的惩罚越重召回率会上升但精确率可能下降要盯着 F1 调。5.4 坑四训练 loss 下降验证 F1 卡住不涨现象训练集 loss 一直接近 0验证集 F1 死活上不去典型的过拟合。原因模型把训练集里的噪声也背下来了。解决按顺序试这几招——第一dropout从 0.3 提到 0.5第二hidden_size从 256 降回 128 或 64参数少了泛化通常会变好第三早停验证 F1 连续 3 个 epoch 不涨就保存当前模型并停止训练。要注意Dropout 只在训练时生效验证阶段已经由model.eval()自动关闭不需要手动改。5.5 坑五词表里全是低频噪声词模型学不到关键信号现象vocab_size建出来有 5 万但其中大量是只出现一次的乱码、网址片段和数字垃圾邮件的核心关键词反而很少。原因min_freq1或没做 max_vocab 截断低频噪声稀释了词向量空间。解决把min_freq调到 2 或 3max_vocab限制到 20000顺带在清洗环节把纯数字串替换成num这种占位符把网址替换成url让模型学习「邮件里有链接」这个模式而不是学习某条具体链接。def replace_token(text): text re.sub(rhttps?://\S, url , text) text re.sub(r\b\d\b, num , text) return text6. 学有余力的进阶把准确率从 90% 拉到 97% 的调参与验证顺序模型跑通只是及格想让验证集效果明显好看我的习惯是先做「单 batch 过拟合测试」再谈调参。具体做法从训练集里抽 128 条样本做成一个不经过 shuffle 的 DataLoader固定跑 100 步。如果 loss 一直不降说明代码链路有 bug调参全是浪费如果 loss 能降到接近 0说明模型有足够的表达能力这时候再上全量数据。这个习惯能避免在坏模型上调一堆没用的参数。mini_loader DataLoader(dataset, batch_size128, collate_fncollate_fn) for step in range(100): x, y next(iter(mini_loader)) x, y x.to(device), y.to(device) optimizer.zero_grad() loss criterion(model(x), y) loss.backward() optimizer.step() if step % 20 0: print(step, loss.item())单 batch 过拟合通过后调参顺序我一般固定为先调max_len到长度分布的 90 分位数再调hidden_size64 → 128 → 256看验证 F1 有没有继续涨最后才调dropout。顺序反了容易浪费时间dropout0.5放在欠拟合模型上只会让效果更差。验证环节还有一个容易被忽视的地方划分训练集和验证集时不要用纯随机抽样尽量按数据里的时间顺序或来源分组划分。垃圾邮件的特点是变化快按时间前 80% 训练、后 20% 验证更接近真实上线场景效果也会更可信。答辩时能讲清楚「为什么这么划分」比模型效果数字本身更加分。我自己第一次做这个项目时在 max_len 上吃过亏。默认设了 128训练完发现验证集里大量样本实际长度只有二三十个词模型在 padding 噪声上学了一堆没用的模式。后来习惯先画长度分布直方图再定参数再也没在这个坑里浪费时间。这个项目的价值和亮点不在模型有多先进而在于完整闭环可复现、每个环节都能讲明白这套管线改改数据就能迁移到情感分析、文本主题分类上希望帮到你。本文还有配套的精品资源点击获取