ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Pytorch全连接神经网络实战:垃圾邮件分类从零到一

Pytorch全连接神经网络实战:垃圾邮件分类从零到一 简介这份资源面向深度学习入门者、课程实践学生及毕业设计选题人群围绕垃圾邮件分类任务提供基于Pytorch全连接神经网络MLP的完整监督学习方案。内容涵盖使用Pytorch搭建MLP模型与optimizer优化器进行有标签训练并借助PytorchViz库可视化网络结构通过Canvas库呈现损失函数值与识别精度随训练轮次的变化过程帮助读者理解分类模型的构建、训练与评估全流程。压缩包共20个文件约7.18MB包含py主程序、csv与data数据样本、names与DOCUMENTATION说明、gv与png可视化图、docx报告及pdf作业要求等覆盖代码、数据与文档三类材料目录结构清晰便于直接运行与二次修改。目前已有1016人学习下载适合作为课程作业、综合实践或毕业设计的参考模板读者可据此掌握数据读取、模型定义、训练调参及结果可视化的完整思路。1. 从一封钓鱼邮件说起Pytorch 全连接神经网络怎么做垃圾邮件分类你邮箱里那封您的账户存在异常登录请点击链接验证的邮件大概率已经被服务商拦下了。但如果让你自己从零搭一个垃圾邮件分类器用 Pytorch 全连接神经网络跑通完整流程很多人会卡在几个地方文本怎么变成向量、词表怎么建、模型层数和维度怎么定、训练完怎么预测一封新邮件。这个标题对应的就是一条最小可用的工程路径——用 Pytorch 搭一个全连接网络输入邮件文本特征输出垃圾/正常二分类结果配套完整代码和数据能直接跑起来。它适合三类人正在做毕业设计需要一套能跑通、能讲清楚原理的代码刚学完 Pytorch 基础想找一个完整项目练手以及需要快速验证文本分类方案是否可行的工程师。核心链路不复杂邮件文本 → 分词 → 构建词表 → 转成数值向量 → 全连接网络 → 二分类输出。全连接网络在这里不是最先进的方案但它是理解深度学习文本分类最直接的入口调参空间小、训练快、结果可解释作为毕业设计或入门实战项目刚好够用。2. 数据准备与词表构建把邮件文本变成网络能吃的数字2.1 垃圾邮件分类的数据长什么样常见的公开垃圾邮件数据集一般有两种格式一种是每封邮件一个 txt 文件放在 spam 和 ham 两个文件夹里另一种是一个 CSV 或 TSV 文件两列——标签和文本。标签通常用 0/1 或 spam/ham 表示。不管哪种格式你最终要得到的是一个列表每个元素是文本, 标签的元组。我一般会先做一次数据探查看三件事类别是否均衡、文本长度分布、有没有明显的编码问题。垃圾邮件数据集经常出现正负样本比例 3:7 甚至更悬殊的情况这会影响后面的阈值选择。文本长度差异也很大有的邮件只有一句话有的带完整 HTML 模板长度可能差几十倍。import os import pandas as pd def load_data(data_dir): 从目录加载邮件数据假设结构为: data_dir/spam/*.txt 和 data_dir/ham/*.txt 返回 DataFrame列名为 text 和 label texts, labels [], [] for label_name, label_id in [(spam, 1), (ham, 0)]: folder os.path.join(data_dir, label_name) for fname in os.listdir(folder): if not fname.endswith(.txt): continue with open(os.path.join(folder, fname), r, encodingutf-8, errorsignore) as f: texts.append(f.read()) labels.append(label_id) df pd.DataFrame({text: texts, label: labels}) # 打乱顺序避免同类别连续出现影响训练 df df.sample(frac1, random_state42).reset_index(dropTrue) return df df load_data(data) print(df[label].value_counts()) print(df[text].str.len().describe())这段代码做了三件事遍历两个类别文件夹读取文本、统一编码为 utf-8 并忽略无法解码的字符、打乱顺序。errorsignore这个参数很关键邮件文本里经常混入非 utf-8 字符不加这个参数会直接报错中断。random_state42保证每次运行打乱结果一致方便复现。2.2 分词、去停用词与词表裁剪文本变成数字的第一步是分词。英文邮件按空格和标点切分基本够用中文邮件需要额外接分词工具。毕业设计场景下我建议先用最简单的方式跑通英文用正则切分中文用 jieba。不要一上来就上 BERT 分词器那会把问题复杂化。分词之后要建词表。核心问题是词表多大合适太小会丢失信息太大会导致参数爆炸。对于全连接网络词表控制在 5000 到 10000 之间比较合理。低频词直接映射为UNK这样既能控制参数量又不会因为几个生僻词影响训练。import re from collections import Counter def tokenize(text): 简单分词转小写按非字母数字字符切分 text text.lower() tokens re.findall(r[a-z0-9], text) return tokens def build_vocab(texts, max_size8000, min_freq2): 构建词表 max_size: 最大词表大小 min_freq: 最低词频低于此频率的词归入 UNK 返回: word2idx 字典, idx2word 列表 counter Counter() for text in texts: counter.update(tokenize(text)) # 按频率排序取前 max_size-2 个预留 PAD 和 UNK most_common counter.most_common(max_size - 2) # 过滤低频词 most_common [(w, c) for w, c in most_common if c min_freq] idx2word [PAD, UNK] [w for w, _ in most_common] word2idx {w: i for i, w in enumerate(idx2word)} return word2idx, idx2word word2idx, idx2word build_vocab(df[text].tolist()) print(f词表大小: {len(idx2word)})max_size8000和min_freq2是两个需要根据数据量调整的参数。数据量在 5000 条以下时词表可以缩到 5000数据量超过 5 万条时可以放宽到 15000。PAD和UNK分别用于填充和未知词索引固定为 0 和 1后面做 padding 时会用到。2.3 把变长文本转成固定长度向量全连接网络要求输入维度固定但邮件长度不一。常见做法是设定一个最大长度max_len超过的截断不足的用PAD补齐。max_len怎么选看文本长度分布——覆盖 90% 到 95% 样本的长度即可。如果 95% 的邮件在 200 个词以内就设max_len200没必要为了少数长邮件把维度拉到 1000。import torch from torch.utils.data import Dataset, DataLoader class EmailDataset(Dataset): def __init__(self, texts, labels, word2idx, max_len200): self.texts texts self.labels labels self.word2idx word2idx self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens tokenize(self.texts[idx]) # 转索引未知词用 UNK 的索引 1 ids [self.word2idx.get(t, 1) for t in tokens] # 截断 if len(ids) self.max_len: ids ids[:self.max_len] # 填充 ids ids [0] * (self.max_len - len(ids)) return torch.tensor(ids, dtypetorch.long), \ torch.tensor(self.labels[idx], dtypetorch.float) dataset EmailDataset(df[text].tolist(), df[label].tolist(), word2idx) loader DataLoader(dataset, batch_size64, shuffleTrue)这里有一个容易翻车的点word2idx.get(t, 1)里的默认值 1 对应UNK不要写成 0否则未知词和填充词混在一起模型会学到错误的模式。batch_size64是起点显存不够就降到 32 或 16训练不稳定也可以适当减小。3. 全连接网络结构设计与 Pytorch 实现3.1 为什么用全连接网络而不是 CNN 或 Transformer垃圾邮件分类本质上是文本二分类全连接网络在这个任务上的表现取决于输入表示的质量。如果用词袋或 TF-IDF 特征全连接网络能拿到不错的结果如果用词嵌入加平均池化效果会更好。相比 CNN全连接网络没有卷积核的局部感知能力但它参数少、训练快、不容易过拟合——在数据量几千到几万条的毕业设计场景下这反而是优势。我一般会这样选数据量小于 1 万条直接用全连接网络加词嵌入平均池化数据量在 1 万到 10 万条之间可以加一层一维卷积超过 10 万条再考虑 Transformer。不要因为 Transformer 火就硬上小数据集上它大概率不如一个调好的全连接网络。3.2 网络结构嵌入层 全连接层的具体维度结构设计上我推荐这个配置嵌入层把每个词映射到 128 维向量然后对所有词的嵌入做平均池化得到一个 128 维的句向量接两层全连接——第一层 128 到 64第二层 64 到 1最后用 Sigmoid 输出概率。中间加 Dropout 和 ReLU。import torch.nn as nn class SpamClassifier(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim64, dropout0.3): super().__init__() # 嵌入层vocab_size 个词每个词 embed_dim 维 # padding_idx0 表示 PAD 不参与梯度更新 self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.fc1 nn.Linear(embed_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, 1) self.relu nn.ReLU() self.dropout nn.Dropout(dropout) def forward(self, x): # x: (batch, max_len) emb self.embedding(x) # (batch, max_len, embed_dim) # 平均池化忽略 PAD 的影响 mask (x ! 0).float().unsqueeze(-1) # (batch, max_len, 1) emb emb * mask pooled emb.sum(dim1) / mask.sum(dim1).clamp(min1e-9) out self.relu(self.fc1(pooled)) out self.dropout(out) out self.fc2(out) return out.squeeze(-1) # (batch,) model SpamClassifier(vocab_sizelen(idx2word)) print(model)padding_idx0让PAD对应的嵌入向量不参与梯度更新这是标准做法。平均池化时用 mask 把填充位置排除掉否则短文本会被大量零向量拉低均值影响分类效果。dropout0.3是文本分类的常用起点过拟合严重就调到 0.5欠拟合就降到 0.1 或去掉。3.3 训练循环与关键超参数训练部分需要关注四个东西损失函数、优化器、学习率、早停。二分类用BCEWithLogitsLoss它把 Sigmoid 和交叉熵合在一起数值更稳定。优化器用 Adam学习率从 1e-3 开始。早停看验证集损失连续 3 个 epoch 不下降就停。from torch.optim import Adam from sklearn.model_selection import train_test_split # 划分训练集和验证集 train_texts, val_texts, train_labels, val_labels train_test_split( df[text].tolist(), df[label].tolist(), test_size0.2, random_state42, stratifydf[label] ) train_dataset EmailDataset(train_texts, train_labels, word2idx) val_dataset EmailDataset(val_texts, val_labels, word2idx) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64) device torch.device(cuda if torch.cuda.is_available() else cpu) model SpamClassifier(vocab_sizelen(idx2word)).to(device) criterion nn.BCEWithLogitsLoss() optimizer Adam(model.parameters(), lr1e-3) best_val_loss float(inf) patience, wait 3, 0 for epoch in range(20): model.train() train_loss 0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() train_loss loss.item() # 验证 model.eval() val_loss 0 with torch.no_grad(): for x, y in val_loader: x, y x.to(device), y.to(device) logits model(x) val_loss criterion(logits, y).item() avg_val val_loss / len(val_loader) print(fEpoch {epoch1}, train_loss{train_loss/len(train_loader):.4f}, val_loss{avg_val:.4f}) if avg_val best_val_loss: best_val_loss avg_val torch.save(model.state_dict(), best_model.pt) wait 0 else: wait 1 if wait patience: print(早停触发) breakstratifydf[label]保证训练集和验证集的类别比例一致这在样本不均衡时很重要。torch.save保存验证集损失最低的模型而不是最后一个 epoch 的模型。学习率 1e-3 是 Adam 的经典起点如果 loss 震荡厉害就降到 5e-4 或 1e-4。4. 垃圾邮件分类的避坑与排查6 个血泪教训4.1 准确率 99% 但预测全是正常邮件现象训练完一看准确率 99%但拿几封明显的垃圾邮件去预测全部输出正常。原因数据严重不均衡正常邮件占 95% 以上模型学会了全部预测为正常就能拿高准确率。解决看混淆矩阵和 F1 分数不要只看准确率。训练时给正样本加权BCEWithLogitsLoss(pos_weighttorch.tensor([负样本数/正样本数]))或者用 WeightedRandomSampler 过采样少数类。4.2 验证集 loss 不降反升现象训练集 loss 一路下降验证集 loss 先降后升中间出现明显拐点。原因过拟合。全连接网络参数虽少但在小数据集上仍然会记住训练样本。解决加大 Dropout 到 0.5加 L2 正则化优化器里设weight_decay1e-4或者减少隐藏层维度。如果数据量允许增加训练数据是最有效的。4.3 预测时出现 IndexError: index out of range现象训练时正常预测新邮件时报索引越界。原因预测时用的词表大小和训练时不一致或者新邮件里有词表外的词但没走UNK映射。解决把word2idx和模型一起保存预测时加载同一个词表。所有未知词统一用word2idx.get(token, 1)映射到UNK。4.4 中文邮件分词后效果很差现象英文邮件分类 F1 能到 0.95换成中文邮件掉到 0.7。原因中文没有空格用正则切分等于按字切分丢失了词级别的语义。解决接 jieba 分词jieba.lcut(text)替代tokenize函数。如果效果仍不理想考虑用字符级 n-gram 特征补充。4.5 GPU 上训练比 CPU 还慢现象换了 GPU 发现每个 epoch 时间没缩短甚至更长。原因数据量小、模型小数据传输到 GPU 的开销超过了计算加速的收益。解决这种规模的数据和模型直接用 CPU 训练就行。如果坚持用 GPU把batch_size调大、num_workers设为 2 或 4减少数据加载瓶颈。4.6 保存的模型加载后预测结果不一致现象训练完保存模型重新加载后同一封邮件预测结果变了。原因只保存了state_dict但重新实例化模型时用了不同的参数比如embed_dim或hidden_dim不一致或者忘了调用model.eval()。解决保存时同时存模型配置加载时先实例化相同结构的模型再load_state_dict预测前务必model.eval()关闭 Dropout。5. 从跑通到能用阈值调优与错误分析的具体手法模型训练完输出的是概率默认以 0.5 为阈值判定类别。但垃圾邮件场景下漏判一封垃圾邮件的代价和误判一封正常邮件的代价不一样。如果业务上更不能容忍漏判就把阈值调低到 0.3 甚至 0.2如果误判正常邮件后果更严重就调到 0.6 或 0.7。这个阈值没有标准答案取决于你的使用场景。from sklearn.metrics import precision_recall_curve, f1_score import numpy as np model.eval() all_probs, all_labels [], [] with torch.no_grad(): for x, y in val_loader: x x.to(device) logits model(x) probs torch.sigmoid(logits).cpu().numpy() all_probs.extend(probs) all_labels.extend(y.numpy()) all_probs np.array(all_probs) all_labels np.array(all_labels) # 遍历阈值找最佳 F1 best_thresh, best_f1 0.5, 0 for thresh in np.arange(0.1, 0.9, 0.05): preds (all_probs thresh).astype(int) f1 f1_score(all_labels, preds) if f1 best_f1: best_f1 f1 best_thresh thresh print(f最佳阈值: {best_thresh:.2f}, F1: {best_f1:.4f})这段代码在验证集上遍历 0.1 到 0.9 的阈值找 F1 最高的那个。注意不要在测试集上调阈值那等于用测试集信息做决策结果不可信。调好阈值后固定下来再用测试集做最终评估。错误分析是另一个容易被忽略的环节。把验证集中预测错误的样本单独拿出来看你会发现一些规律比如包含大量 HTML 标签的邮件容易被误判或者短文本少于 10 个词的分类效果明显差。针对这些规律做定向优化——清洗 HTML 标签、对短文本单独处理——比盲目调参有效得多。我自己的习惯是每次训练完先看混淆矩阵再看错误样本的前 20 条最后才决定要不要调参。大部分时候数据清洗带来的提升比换模型结构大得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表