多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PyTorch全连接网络实战:垃圾邮件分类完整代码与避坑指南

PyTorch全连接网络实战:垃圾邮件分类完整代码与避坑指南 简介这份资源面向深度学习入门者与毕业设计选题学生提供基于Pytorch全连接神经网络完成垃圾邮件分类的完整可运行方案。项目使用MLP多层感知机模型配合优化器进行有标签监督学习并借助PytorchViz库可视化网络结构通过Canvas库绘制损失值与识别精度的对数变化曲线帮助读者直观理解训练过程与模型收敛情况。压缩包共20个文件约7.18MB包含py主程序、csv与data数据文件、names与DOCUMENTATION说明、gv与png可视化图、docx报告及pdf作业要求等覆盖代码、数据、文档与图示四类内容结构完整便于复现。目前已有1016人学习下载适合需要快速搭建分类实验、撰写毕业设计或课程作业的读者参考可直接在Pycharm与anaconda环境中运行调试。1. 从一封垃圾邮件说起这套 PyTorch 全连接网络代码到底能跑出什么你邮箱里那封“恭喜您中奖 100 万”的邮件是怎么被自动丢进垃圾箱的答案往往不是规则引擎而是一个几层全连接网络在毫秒级做完的二分类判断。这份资源就是把这个判断过程完整摊开PyTorch 实现的全连接神经网络做垃圾邮件分类带完整代码和数据集毕业设计级别下载后可直接运行。它适合三类人——刚学完pytorch基础框架想找一个能跑通全流程的练手项目、被毕业设计卡住需要一份结构清晰的参考实现、以及想搞明白“文本怎么变成张量喂进网络”的从业者。整套流程覆盖数据读取、分词与词表构建、文本向量化、模型定义、训练循环、评估与预测不依赖预训练模型纯全连接结构CPU 就能跑完对pytorch环境搭建的要求很低。下面按“资源是什么 → 怎么用 → 坑在哪”的顺序拆开讲每一步都落到能抄的代码和能改的参数上。2. 数据到张量垃圾邮件分类的预处理管线怎么搭全连接网络吃的是固定长度的数值向量而邮件是变长文本中间这条预处理管线决定了模型能不能收敛。很多人pytorch实战翻车不是模型写错是这一步埋了雷。2.1 读取数据与标签映射资源里的数据一般是 CSV 或两个文本目录一列是邮件内容一列是标签spam/ham 或 1/0。先统一读进来把标签转成 0/1 整数因为nn.BCELoss和CrossEntropyLoss对标签格式要求不同这一步定死后面才不会乱。import pandas as pd import torch from torch.utils.data import Dataset, DataLoader # 读取数据假设是两列label, text df pd.read_csv(spam.csv, encodinglatin-1) df df[[v1, v2]].rename(columns{v1: label, v2: text}) # 标签映射ham-0, spam-1 df[label] df[label].map({ham: 0, spam: 1}) # 去掉缺失和空文本否则后面分词会报错 df df.dropna(subset[text]).reset_index(dropTrue) print(df[label].value_counts()) # 先看类别是否均衡逻辑说明map把字符串标签转成整数dropna清掉空行。参数上encoding要按实际文件改很多垃圾邮件数据集是latin-1而不是utf-8用错会直接抛UnicodeDecodeError。value_counts()是必看的一步如果 spam 只占 5%后面训练会偏向多数类需要调 loss 的weight或做重采样。2.2 分词、建词表与固定长度截断全连接层要求输入维度固定所以要把每封邮件变成等长向量。常见做法是按空格和标点分词 → 统计词频 → 保留最高频的 N 个词建词表 → 每封邮件映射成索引序列 → 截断或补齐到固定长度。import re from collections import Counter def tokenize(text): text text.lower() # 只保留字母数字和空格去掉标点噪声 text re.sub(r[^a-z0-9\s], , text) return text.split() # 统计词频 counter Counter() for t in df[text]: counter.update(tokenize(t)) # 保留最高频的 10000 个词0 留给 padding1 留给未知词 VOCAB_SIZE 10000 vocab {word: i 2 for i, (word, _) in enumerate(counter.most_common(VOCAB_SIZE))} vocab[pad] 0 vocab[unk] 1 MAX_LEN 100 # 每封邮件固定 100 个 token def encode(text): ids [vocab.get(w, 1) for w in tokenize(text)][:MAX_LEN] # 不足补 0 ids [0] * (MAX_LEN - len(ids)) return ids df[ids] df[text].apply(encode)逻辑说明most_common取高频词2是给 pad 和 unk 让位。MAX_LEN100是权衡——太短丢信息太长全连接层参数暴涨。encode里先截断再补齐顺序不能反否则长邮件会把补齐的 0 挤掉。这一步产出的ids就是后面 Dataset 的输入。2.3 封装 Dataset 与 DataLoaderPyTorch 的标准做法是继承Dataset把 numpy 或 list 转成 tensor再交给DataLoader做批处理和打乱。class SpamDataset(Dataset): def __init__(self, ids, labels): self.ids torch.tensor(ids, dtypetorch.long) self.labels torch.tensor(labels, dtypetorch.float32) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.ids[idx], self.labels[idx] # 简单切分训练/验证 split int(len(df) * 0.8) train_ds SpamDataset(df[ids][:split].tolist(), df[label][:split].tolist()) val_ds SpamDataset(df[ids][split:].tolist(), df[label][split:].tolist()) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size64, shuffleFalse)逻辑说明标签用float32是为了配合BCELoss如果用CrossEntropyLoss则要改成long。batch_size64是 CPU 上比较稳的值显存或内存吃紧就降到 32。shuffleTrue只给训练集验证集打乱没有意义还会让指标波动看起来更玄学。3. 全连接网络结构几层、多少隐藏单元、怎么防过拟合模型本身不复杂但层数、激活函数、Dropout 的搭配直接决定它是欠拟合还是过拟合。这一章把结构选择和训练循环一起讲透。3.1 网络定义与维度推导输入是MAX_LEN长度的索引序列但全连接层不能直接吃索引需要先过一个Embedding层把每个 token 变成向量再展平。这是文本分类里全连接网络的标配做法。import torch.nn as nn class SpamNet(nn.Module): def __init__(self, vocab_size, embed_dim, max_len, hidden_dim): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.fc1 nn.Linear(embed_dim * max_len, hidden_dim) self.relu nn.ReLU() self.dropout nn.Dropout(0.5) self.fc2 nn.Linear(hidden_dim, 1) self.sigmoid nn.Sigmoid() def forward(self, x): # x: [batch, max_len] emb self.embedding(x) # [batch, max_len, embed_dim] emb emb.view(emb.size(0), -1) # 展平成 [batch, embed_dim*max_len] out self.fc1(emb) out self.relu(out) out self.dropout(out) out self.fc2(out) return self.sigmoid(out).squeeze(1)逻辑说明padding_idx0让 pad 对应的 embedding 不参与梯度更新这是处理补齐的标准操作。view把三维展成二维维度是embed_dim * max_len改MAX_LEN或embed_dim时这里必须同步。Dropout(0.5)放在隐藏层后是防过拟合最直接的手段。输出过sigmoid得到 0~1 概率squeeze(1)去掉多余维度以匹配标签形状。3.2 训练循环与损失函数选择二分类用BCELoss优化器用Adam学习率从 1e-3 起步。训练循环里要同时记录训练损失和验证指标否则你不知道模型是在学还是在背。from torch.optim import Adam from sklearn.metrics import accuracy_score, f1_score device torch.device(cuda if torch.cuda.is_available() else cpu) model SpamNet(VOCAB_SIZE, embed_dim64, max_lenMAX_LEN, hidden_dim128).to(device) criterion nn.BCELoss() optimizer Adam(model.parameters(), lr1e-3) def evaluate(loader): model.eval() preds, trues [], [] with torch.no_grad(): for x, y in loader: x, y x.to(device), y.to(device) p model(x) preds (p 0.5).long().cpu().tolist() trues y.long().cpu().tolist() return accuracy_score(trues, preds), f1_score(trues, preds) for epoch in range(10): model.train() total_loss 0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() p model(x) loss criterion(p, y) loss.backward() optimizer.step() total_loss loss.item() acc, f1 evaluate(val_loader) print(fepoch {epoch1} loss {total_loss/len(train_loader):.4f} acc {acc:.4f} f1 {f1:.4f})逻辑说明model.train()和model.eval()切换影响 Dropout 行为漏写会让验证结果不可信。zero_grad必须在backward前否则梯度累加。评估时用torch.no_grad()省内存。指标上垃圾邮件分类不能只看准确率f1_score更能反映少数类表现这也是为什么前面强调先看类别分布。3.3 关键参数怎么调参数常用值调大后果调小后果embed_dim64 / 128表达强但参数多、易过拟合表达不足、欠拟合hidden_dim128 / 256拟合能力强、训练慢容量不够、学不动MAX_LEN100 / 200信息全但维度爆炸丢关键信息dropout0.3~0.5抑制过拟合、可能欠拟合过拟合风险高lr1e-3震荡不收敛收敛慢这张表是调参时的第一参照。常见做法是先把embed_dim64、hidden_dim128、dropout0.5跑通再根据验证集 f1 微调。如果训练 loss 一直降但验证 f1 不涨优先加 dropout 或减 hidden_dim。4. 避坑与排查跑不起来、不收敛、指标虚高都在这这一章是血泪经验集中区每条都按“现象 → 原因 → 解决”写照着排查能省大量时间。4.1 报 UnicodeDecodeError 或标签全是 NaN现象读 CSV 直接抛编码错误或者label列映射后全是 NaN。原因数据集编码不是 utf-8或者标签字段名和代码里写的不一致。解决先pd.read_csv(path, encodinglatin-1)试再df.columns打印列名核对映射前用df[label].unique()看实际取值别照抄 ham/spam。4.2 训练 loss 不降或变 NaN现象loss 一直停在 0.69 附近或者几个 batch 后变 NaN。原因学习率太大、输入索引越界、或者BCELoss收到了 logits 之外的异常值。解决先把 lr 降到 1e-4 试检查vocab大小和Embedding的vocab_size是否一致索引超过词表会直接崩确认sigmoid输出在 0~1 之间若手动改了结构漏了 sigmoidBCELoss会算出 NaN。4.3 验证准确率虚高但实际预测全是一类现象acc 有 0.9但 f1 很低预测结果几乎全是 ham。原因类别不均衡模型学会了全猜多数类。解决看value_counts()若比例悬殊给BCELoss加pos_weight或在 Dataset 里对少数类过采样。别只看 accf1 才是这类任务的照妖镜。4.4 换了 MAX_LEN 后维度不匹配现象改MAX_LEN后报mat1 and mat2 shapes cannot be multiplied。原因fc1的输入维度是embed_dim * max_len改了一处没改另一处。解决把max_len作为参数传进模型构造函数别在Linear里写死数字这样改一处全局生效。4.5 CPU 上训练太慢现象一个 epoch 要跑十几分钟。原因MAX_LEN或hidden_dim太大或者batch_size太小。解决先把MAX_LEN降到 50、hidden_dim降到 64 跑通流程确认逻辑无误再往上加。num_workers在 Windows 上设 0 更稳设大了反而报错。5. 进阶技巧把这份代码改成能复用的文本分类模板跑通只是起点真正省事的是把它抽象成可复用模板。我一般会做三件事把词表构建和编码抽成独立函数、把模型参数集中到一个 config 字典、把训练循环封装成train_model(model, train_loader, val_loader, epochs)。这样换数据集时只改读取部分模型和训练逻辑不动。验证模型是否真的学到东西有个简单办法拿几封手写邮件喂进去看概率。比如“free money click now”应该接近 1“meeting at 3pm tomorrow”应该接近 0。如果两者概率都在 0.5 附近说明模型没学到区分性特征回去检查分词是否把关键信号词切没了。def predict(text, model, vocab, max_len100): model.eval() ids [vocab.get(w, 1) for w in tokenize(text)][:max_len] ids [0] * (max_len - len(ids)) x torch.tensor(ids, dtypetorch.long).unsqueeze(0).to(device) with torch.no_grad(): prob model(x).item() return prob print(predict(free money click now, model, vocab)) print(predict(meeting at 3pm tomorrow, model, vocab))逻辑说明unsqueeze(0)造出 batch 维度因为模型 forward 按 batch 处理。tokenize必须和训练时完全一致否则词表对不上预测结果会莫名其妙。这个函数也是毕业设计答辩时演示的好素材能直观说明模型不是摆设。还有一个容易忽略的点保存和加载模型时state_dict只存参数不存结构。加载前必须先用相同参数实例化模型再load_state_dict。我见过有人直接torch.load整个模型换台机器就因为类路径对不上而失败。从那以后我每次保存都只存state_dict加载时先建结构再灌参数这个习惯帮我省了不止一次返工。希望这份代码和这些踩坑记录能帮你把垃圾邮件分类这个项目真正跑起来、讲清楚。本文还有配套的精品资源点击获取
返回列表