基于Python卷积神经网络的垃圾邮件分类系统设计
发布时间:2026/10/2 14:12:56来源:尧图网络
简介基于Python与卷积神经网络CNN的垃圾邮件分类系统源码及模型面向计算机相关专业毕业设计、课程设计与期末大作业场景适合需要完整可运行项目参考的学生或开发者。项目按入口、CNN模型定义、数据加载与训练流程划分模块覆盖从邮件文本读取、预处理、模型训练到结果预测的完整链路并附带已训练好的最优模型和1000条邮件文本及标签数据可以直接运行验证也可基于现有代码做二次改造。压缩包共14个文件以Python源码及编译缓存py/pyc、pickle格式数据文件、pkl模型文件以及Markdown和PDF说明文档为主整体大小约2.67MB各类型文件职责清晰便于按需查阅与复现实验。整套源码均经过本地编译验证可运行评审分达98分难度适中内容经助教老师审定能够满足毕业设计、期末大作业和课程设计的交付要求。目前已有190人学习下载是兼顾完整性与实用性的CNN文本分类参考项目适合深入理解垃圾邮件分类的建模思路与工程实现。1. 毕业设计选垃圾邮件分类为什么 CNN 是这条跑得通的路毕业设计最怕的不是模型不够深而是讲不清、跑不动、答辩一问就卡壳。基于 Python 卷积神经网络 CNN 的垃圾邮件分类系统恰好是那种训练时间短、效果能可视化、追问也能接住的方向。CNN 处理文本并不是把邮件当图片而是用一维卷积在词序列上滑动抓的是连续几个词组成的局部特征对几百词长度的邮件常见的公开语料上准确率能做到 95% 上下训练一份模型通常只需要几分钟。这个方案适合手里有一批邮件样本、想快速跑通“数据清洗→模型训练→评估→演示”全流程的本科毕设也适合当新手的第一个深度学习落地项目。下面我按自己做这类系统的顺序从数据处理、模型搭建一直写到训练踩坑和答辩演示。2. 把邮件文本变成张量数据清洗、词表构建与训练集划分自然语言不能直接喂进 CNN第一步永远是先把邮件转成整数序列。这一步看起来枯燥却决定了后面模型能不能收敛。很多毕设翻车不是模型写得不对而是数据里全是 HTML 标签、URL 和发件人签名模型学到的根本不是邮件内容。2.1 先把邮件读进来目录约定与基础清洗我习惯把语料按照data/train/spam、data/train/ham这样的目录放好一个邮件一个 txt 文件目录名就是标签。这样读数据不需要解析复杂的格式后面做交叉验证也方便。import re import pandas as pd from pathlib import Path BASE Path(data) def load_emails(split: str train) - pd.DataFrame: rows [] for label, folder in [(spam, 1), (ham, 0)]: for f in (BASE / split / folder).iterdir(): if not f.is_file(): continue rows.append((f.read_text(encodingutf-8, errorsignore), label)) return pd.DataFrame(rows, columns[text, label])这段代码的核心是目录名到标签的映射spam是垃圾邮件标签为 1ham是正常邮件标签为 0。读取时用errorsignore是怕某些邮件文件里带着非法编码字符直接抛异常会打断整个遍历。数据量不大时这样读没问题如果邮件上万封可以考虑用pathlib的rglob替代iterdir并且按批次读入。读进来之后做清洗。垃圾邮件文本里最常见的干扰是 HTML 标签、URL、特殊符号和大小写混写清洗的目标是留下真正有判别力的词。def clean_text(raw: str) - str: raw raw.lower() # 全部转小写避免 Free/free 变成两个词 raw re.sub(r[^], , raw) # 去掉 HTML 标签 raw re.sub(rhttp\S, URL , raw) # 链接统一占一位 raw re.sub(r[^a-z\s], , raw) # 非字母字符全部变成空格 raw re.sub(r\s, , raw).strip() return raw这里的注意点有两个。第一URL 不要直接删掉而是替换成占位词URL因为垃圾邮件里带链接是极强的信号删掉等于把最重要的特征抹掉了。第二[^a-z\s]表示只保留小写字母和空白标点、数字、货币符号都会被清掉如果语料里包含中文或其他语言需要先确认 CNN 的输入是字符级还是词级再决定是否保留非字母字符。2.2 构建词表与序列填充让每条邮件长度可比较清洗之后的文本长度不一而 CNN 需要定长输入。常见做法是先统计全量词频按出现次数过滤低频词再给每个词编号最后把所有句子 padding 到同样的长度。from collections import Counter from torch.nn.utils.rnn import pad_sequence import torch def build_vocab(texts, min_freq2): counter Counter() for t in texts: counter.update(t.split()) vocab {pad: 0, unk: 1} # 0 留给填充位1 留给未登录词 for word, freq in counter.items(): if freq min_freq: vocab[word] len(vocab) return vocab def encode(texts, vocab, max_len200): ids [] for t in texts: tokens t.split()[:max_len] # 超过 max_len 的直接截断 ids.append([vocab.get(w, vocab[unk]) for w in tokens]) tensors [torch.tensor(seq, dtypetorch.long) for seq in ids] return pad_sequence(tensors, batch_firstTrue, padding_value0)min_freq2的意思是只出现一次的词不要。这类词大多数是拼写错误、乱码或者私人昵称放进词表只会增加维度和过拟合风险。unk用编号 1pad用编号 0是文本分类的常规做法。pad_sequence会把一个 batch 里的短句自动补到和最长句一样长padding_value0指定用 0 号词来填。提示padding_value必须和词表里的pad编号一致否则模型会在填充位上学到无意义的特征。2.3 数据划分的讲究分层抽样确保验证集两类都有垃圾邮件数据集天然不平衡垃圾邮件占比往往超过一半有的公开语料里 spam 能到 60% 以上。如果不做分层切分随机划分出来的验证集可能恰好没有垃圾邮件模型输出全 0 还显示 99% 准确率这是典型的假象。from sklearn.model_selection import train_test_split df[clean] df[text].map(clean_text) X df[clean] y df[label] X_tr, X_va, y_tr, y_va train_test_split( X, y, test_size0.2, stratifyy, random_state42 )stratifyy会按照原始类别比例分别从垃圾邮件和正常邮件里取样保证训练集和验证集的比例一致。这里还有一个小坑train_test_split返回的索引是洗过的如果用 pandas 的iloc去取值一定要保证 X 和 y 是同一个 DataFrame 里的两列否则切完之后标签和文本就对不齐现象就是验证集 loss 反复震荡、准确率上不去。切完之后打印一下各类别数量再继续。邮件头部的Subject:和From:我一般保留不去掉邮件头的主题行因为垃圾邮件的主题本身是非常强的特征去掉反而会损失判别信息。数据准备到这里模型代码其实已经写好了一半。3. 用 Conv1D 搭出分类器Embedding、卷积核与训练循环数据变成整数序列之后下一步是让 CNN 在这个序列上工作。这里用的不是图像里的二维卷积而是一维卷积 Conv1D。很多新手第一次看 CNN 文本分类会很困惑文本又不是图片卷积怎么卷理解这一点是整个模型部分的钥匙。3.1 为什么文本分类用一维卷积而不是二维卷积文本经过词表映射后是一个整数序列长度是词数。进入模型之前先经过 Embedding 层每个词变成一个向量整条文本就成了形状为(batch, seq_len, embed_dim)的三维矩阵。Conv1D 的卷积核沿着seq_len这个方向滑动每一步覆盖连续 k 个词本质上是抓 n-gram 特征k3 相当于在提取 trigram 模式k5 就相当于 5-gram。垃圾邮件识别靠的就是“点击链接”“中奖”“免费领取”这些局部短语不需要跨段落的长距离依赖所以小卷积核就够用。CNN 和 RNN 相比还有一个决定性优势一维卷积可以并行处理所有位置训练速度快RNN 必须按时间步逐个读入慢而且序列一长容易遗忘。对邮件这种短文本CNN 是比 RNN 更合适的选择。3.2 定义一个能跑的最小 CNN 模型我用 PyTorch 写了一个精简版 SpamCNN一个 Embedding 层、两组不同大小的卷积核、全局最大池化再接全连接输出二分类 logits。import torch import torch.nn as nn class SpamCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_filters128, kernel_sizes(3, 5), num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizek, paddingk // 2) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): emb self.embedding(x) # (B, L, D) emb emb.permute(0, 2, 1) # (B, D, L) pooled [] for conv in self.convs: feat torch.relu(conv(emb)) # (B, F, L) feat feat.max(dim2).values # 全局最大池化 pooled.append(feat) out torch.cat(pooled, dim1) # (B, F * len(kernel_sizes)) return self.fc(self.dropout(out))重点看维度变化。输入x是(batch, seq_len)的整数张量Embedding 之后变成(batch, seq_len, embed_dim)但 PyTorch 的 Conv1d 要求输入是(batch, channels, length)所以必须用permute(0, 2, 1)把embed_dim换到第二维。paddingk // 2是为了让卷积输出长度和输入保持一致如果不做 padding边缘词只被卷积核覆盖一次信息权重会被压低。max(dim2).values做的是全局最大池化。每个卷积核在序列上滑动会产生一组响应值我们只取最大的那一个代表“这条文本里有没有出现这个 n-gram 模式”。这样不管邮件长度是 50 词还是 500 词最后落到全连接层的特征数量都是固定的。padding_idx0这个参数容易被忽略但非常关键。Embedding 层会把 0 号词映射成全零向量填充位就不会对卷积产生任何激活值模型真正学到的只是有效词的特征。3.3 训练循环从交叉熵损失到学习率衰减模型结构定下来之后训练逻辑是标准的分类流程。数据部分我把第二节存好的序列包成 PyTorch 的TensorDataset再用DataLoader按批取出。from torch.utils.data import TensorDataset, DataLoader tr_ids encode(X_tr.tolist(), vocab, max_len200) va_ids encode(X_va.tolist(), vocab, max_len200) tr_ds TensorDataset(tr_ids, torch.tensor(y_tr.tolist())) va_ds TensorDataset(va_ids, torch.tensor(y_va.tolist()))训练循环里最值得说清楚的是三个东西优化器、学习率调度和模型保存。from torch.optim import Adam from torch.optim.lr_scheduler import StepLR device torch.device(cuda if torch.cuda.is_available() else cpu) model SpamCNN(len(vocab)).to(device) opt Adam(model.parameters(), lr1e-3) scheduler StepLR(opt, step_size3, gamma0.5) def train_one_epoch(model, loader, opt, device): model.train() total_loss 0 for x, y in loader: x, y x.to(device), y.to(device) opt.zero_grad() logits model(x) loss nn.functional.cross_entropy(logits, y) loss.backward() opt.step() total_loss loss.item() return total_loss / len(loader) for epoch in range(15): train_loss train_one_epoch(model, tr_ds_loader, opt, device) scheduler.step() print(fepoch {epoch:02d} loss {train_loss:.4f})Adam配lr1e-3是文本分类里最省心的默认组合大多数情况下不需要手动调学习率。StepLR每 3 个 epoch 把学习率减半训练前期大步走后期小步收敛比全程固定学习率更容易收敛到平稳区域。15 个 epoch 只是参考真正该做的是每个 epoch 结束之后跑一次验证集validation loss 连续两个 epoch 不降就停止。3.4 保存模型不只是存权重词表和配置一起存毕设源码包交付时最常见的问题是模型权重在但没人知道它对应的词表和模型参数是什么。我保存模型时会把 state_dict、vocab、config 全部打进同一个文件。torch.save({ state_dict: model.state_dict(), vocab: vocab, config: { embed_dim: 100, num_filters: 128, kernel_sizes: (3, 5), dropout: 0.5, } }, checkpoints/spam_cnn.pt)加载时先读 config 重建模型再灌权重ckpt torch.load(checkpoints/spam_cnn.pt, map_locationcpu) model SpamCNN(len(ckpt[vocab]), **ckpt[config]) model.load_state_dict(ckpt[state_dict]) model.eval()map_locationcpu存在的意义是让模型在没有显卡的机器上也能加载。model.eval()必须切否则 Dropout 在推理时依旧随机丢神经元预测结果每次都不一样答辩现场会非常尴尬。4. 不只看准确率混淆矩阵、过拟合信号与阈值调整我见过很多毕设把模型训练完打印一个 accuracy 就收工了。但对垃圾邮件分类这种二分类场景准确率是最容易骗人的指标因为类别本身不平衡模型可能把所有邮件都判成正常邮件准确率依然很高。评估这一步做得扎实答辩时才能回答“你的模型到底好在哪”。4.1 混淆矩阵盯住“正常邮件被当成垃圾”那一格验证集跑完之后第一件事是打印混淆矩阵和分类报告。from sklearn.metrics import confusion_matrix, classification_report y_pred [] model.eval() with torch.no_grad(): for x, _ in va_ld: x x.to(device) logits model(x) y_pred.extend(logits.argmax(dim1).cpu().tolist()) cm confusion_matrix(y_va, y_pred) print(cm) print(classification_report(y_va, y_pred, target_names[ham, spam]))混淆矩阵的四格分别对应正常邮件判正常、正常邮件误判成垃圾、垃圾邮件漏判成正常、垃圾邮件正确拦截。垃圾邮件场景真正要盯的是左上角那一格——正常邮件被误判成垃圾邮件。这个误判的代价远高于漏判因为一封重要邮件被扔进垃圾箱可能造成实质损失。做调优之前先把这一格的数字记下来后面每一步改动都以它是否下降为准。4.2 过拟合的三个信号训练 loss 降、验证 loss 反弹、验证集波动训练到第 5、6 个 epoch 时经常出现一种现象训练 loss 很漂亮地一路下降验证集准确率却开始上下摇摆甚至明显变差。这就是过拟合。模型不是在学“垃圾邮件长什么样”而是在背训练集里的个别词尤其是随机数字串和私人化的邮件签名。常见的处理按优先级排先把 Dropout 从 0.3 提到 0.5让模型不能依赖单条路径再加早停验证 loss 连续两个 epoch 不降就停止保留上一次的最优权重最后可以对训练文本做噪声增强随机把一部分词替换成unk。import random def noisy_tokens(tokens, vocab, p0.1): unk vocab[unk] return [unk if random.random() p else t for t in tokens]替换成unk而不是删除是为了保持序列长度不变不破坏 padding 的结构。这个增强方式比同义词替换更省事效果也够用因为它强迫模型不要依赖单个词出现与否而是去关注词的组合关系。4.3 阈值调整把 logits 变成信心分数而不是直接 argmax模型输出的 logits 经过 softmax 之后会得到一个概率。默认的决策规则是取概率更大的一类即argmax但很多时候我们希望在演示时“宁可不拦不能误拦”——只有模型非常有把握时才把它判定为垃圾邮件。这就要把阈值往右移。probs torch.softmax(logits, dim1)[:, 1] # 取“是垃圾邮件”的概率 pred_spam (probs 0.8).long() # 阈值 0.8只有足够确定才拦0.8只是个起点具体取多少要在验证集上试。把阈值从 0.5 到 0.95 每隔 0.05 扫一遍记录每个阈值下的精确率和召回率你会发现二者此消彼长。把这条曲线放进论文的评估章节答辩时被问到“阈值怎么定的”就有据可答。4.4 看模型到底在学什么提取卷积核关注的关键词可解释性是深度学习毕设被问到最多的地方。一个简单有效的做法是挑一条测试邮件找出激活最强的卷积核再看这个卷积核在哪些词位置响应最大把这些词打印出来。def top_act_words(model, text, vocab, conv_idx0, k5): tokens clean_text(text).split()[:200] ids torch.tensor([[vocab.get(w, 1) for w in tokens]]).to(device) model.eval() with torch.no_grad(): emb model.embedding(ids) # (1, L, D) act torch.relu(model.convs[conv_idx](emb.permute(0, 2, 1))) # (1, F, L) kernel_score act.sum(dim2) # 每个卷积核的总响应 best_kernel kernel_score.argmax(dim1).item() # 最活跃的核 act_line act[0, best_kernel] # 该核在每个词位置的响应 pos act_line.argsort(descendingTrue)[:k] return [tokens[int(i)] for i in pos if int(i) len(tokens)]这个函数先找出整条文本里响应最强的那个卷积核再把这个核在序列每个位置的激活值排序取最高的几个词。如果拿垃圾邮件跑取出来的词集中在click、free、offer、URL附近说明模型学的是合理的局部模式而不是某个训练样本里的乱码。这一步输出的关键词可以直接放进论文截图。5. 训练避坑维度对不上、内存被杀与全 0 预测的现场排查带毕设的学生跑这套系统时遇到的报错来来回回就那么几类。把几个高频问题记下来能省下大量反复对比的时间。我按“现象→原因→解决”的格式写清楚遇到同样问题直接对号入座。5.1 现象RuntimeError: size mismatch模型第一轮 forward 就抛 shape 相关的报错。这是新手最容易卡住的地方而且报错信息往往很长最后一行才写到真正的维度矛盾。原因大概率是 Conv1d 的输入维度不对。Embedding 输出是(batch, seq_len, embed_dim)Conv1d 要的是(batch, embed_dim, seq_len)。少了permute(0, 2, 1)这一步卷积核会把词向量维度当成序列长度去滑基本必报错。另一种常见原因是全连接层的输入维度num_filters * len(kernel_sizes)算错了尤其是kernel_sizes里有 3 和 5 时很容易写成num_filters * 2实际应该是128 * 2 256。解决方法是训之前先打印每一层的输入输出形状。用一行临时调试代码x torch.randint(0, 1000, (2, 200)) print(model.embedding(x).shape) # torch.Size([2, 200, 100])看到这一步的形状再确认 permute 之后的形状基本上报错位置就清楚了。5.2 现象训练跑到一半进程直接被杀没有抛 ValueError而是训练进度条突然消失或者控制台直接回到命令行有时候是 OOM Killer 把进程结束了。这类问题在 CPU 环境表现为内存耗尽在 GPU 环境表现为显存溢出。最常见原因是max_len取得太大。一份毕设数据里可能会有上千词的邮件如果把所有邮件都 padding 到最长卷积层的激活值会成倍增加。max_len200对垃圾邮件分类通常已经够用因为垃圾邮件的关键特征集中在开头几十个词里长邮件尾部大多数是免责声明和签名。另一个原因是 batch size 太大文本数据虽然单条小但批量大了之后中间变量仍然可观。解决思路分三步max_len降到 200batch_size从 64 降到 32推理时用torch.inference_mode()替代torch.no_grad()这一步能省下一些为反向传播预留的资源如果还在溢出检查 DataLoader 里的num_workersWindows 下设置过大反而会新开多个进程复制数据集内存翻倍。5.3 现象验证集准确率很高但预测全是 0训练集准确率 99%验证集准确率 98%看起来一切正常。把验证集随机抽几条出来人工看发现模型把所有邮件都判成了“正常邮件”。这类问题最常见的原因是数据切分时没有做分层。数据集里垃圾邮件占 60%、正常邮件占 40%如果随机切分验证集里可能恰好出现类别失衡模型只需要输出数量多的那一类就能拿到高准确率。第二个常见原因是 X 和 y 在切分后索引错位验证循环里取到的标签和文本不是同一封邮件的。解决方法是回看第 2.3 节的stratifyy并且切分之后打印pd.Series(y_va).value_counts()确认两个类别都在。另外验证集预测完不要只打印 accuracy直接打印classification_report里面的召回率会立刻暴露问题如果 spam 的召回率是 0.00说明模型根本没有识别垃圾邮件的能力。5.4 现象一个 epoch 要跑半小时明显不正常数据量只有几千封邮件但训练速度慢到离谱。问题一般不在模型而在数据处理链路。最典型的两个原因一是在encode阶段用纯 Python 循环逐条处理文本每次调用vocab.get耗时不高但累积起来很慢二是 DataLoader 没有开多进程数据读取和模型训练串行执行。解决方法是把encode里的循环用列表推导重写能快一个数量级然后给 DataLoader 加num_workers2。注意 Windows 下num_workers设成 0 最保险大于 0 可能在多进程启动时报错需要把训练代码放进if __name__ __main__:块里。再检查一下max_len如果设成 1024大部分算力都花在 padding 出来的零向量上这就是最直接的浪费。5.5 现象模型文件拷到别的机器上加载时报 KeyError毕设答辩经常要在老师的电脑上或者另一台机器上做演示把 checkpoint 文件拷过去之后load_state_dict报 KeyError或者加载成功但预测结果完全不对。原因通常是只保存了state_dict没有保存词表。模型权重是针对某一套 vocab 学出来的如果换了个词表同一个词编号对应的是完全不同的词预测结果自然全部错乱。还有一种是加载时重新定义了一个不同 config 的模型参数名对不上直接抛 KeyError。第 3.4 节的保存格式就是为这个场景设计的state_dict、vocab、config 三个一起存。换机器加载时先读 config再重建模型再灌权重顺序不能乱。另外别忘装依赖requirements.txt里固定torch大版本比如torch1.13,2.1避免换机器后版本差异导致算子不兼容。6. 让模型在答辩现场站得住留出集验证与离线演示脚本拿到一套“源码模型”的毕设工程我第一件事不是改模型而是检查它有没有能直接跑的入口。好的工程结构至少要有train.py、predict.py、utils.py和checkpoints/目录。如果是自己的项目换台机器能跑才叫完成如果是在别人的源码上改先复现最小命令再动结构。答辩演示最忌讳现场写代码哪怕只是敲两行也可能翻车。我习惯做一个单文件demo.py里面包含完整的预测链路读邮件→清洗→编码→加载模型→输出结论。这样老师无论给一句话还是一封邮件文本都能直接出结果。def predict(path_or_text): text Path(path_or_text).read_text(encodingutf-8, errorsignore) \ if Path(path_or_text).exists() else path_or_text tokens clean_text(text).split()[:200] ids torch.tensor([[vocab.get(w, 1) for w in tokens]]) logits model(ids) prob torch.softmax(logits, dim1)[0, 1].item() return (垃圾邮件 if prob 0.8 else 正常邮件, round(prob, 4))这个脚本的关键在于入口函数同时接受文件路径和纯文本演示时灵活性高很多。判定阈值直接写在调用侧不写死在模型里现场可以根据需要调整。我一般会准备三条测试样本一条明显是垃圾邮件的、一条正常邮件、一条模棱两可的把三条的输出都放到 PPT 里覆盖模型能做什么、不能做什么两个维度。评估方面我习惯在第一次切分时就额外留出 10% 的数据整个过程从头到尾不碰等模型和阈值都定稿之后再跑最后一次测试。这 10% 是真正的“面生”数据测试结果可信度高。留出集上的混淆矩阵和几条误判样例可以放进论文附录作为真实评估证据。最后一招是便宜好用的多尺度融合。用第 3 章的代码分别训练kernel_sizes(3, 5)和kernel_sizes(3, 5, 7)两个模型预测时对概率取平均再和阈值比较。这个做法对随机初始化带来的结果抖动有明显的改善答辩时还能多讲一个“为什么用不同卷积核”的理由。我自己当年最惨的一次翻车就是拿验证集反复调参最后把验证集当成了测试集来汇报效果被老师当场问出了漏洞。从那之后我习惯先把数据分成三份训练集调参、验证集选阈值、留出集定结论再也不在同一份数据上自欺。希望这篇笔记能帮你在答辩前少走一遍弯路。本文还有配套的精品资源点击获取
网站建设高端定制企业官网