TextCNN实战:基于PyTorch的垃圾邮件分类完整指南 简介基于Python卷积神经网络CNN的垃圾邮件分类系统是一套面向毕业设计或课程设计的完整工程适合需要完成邮件识别类项目的计算机专业学生。资源包共14个文件以Python源码、pyc编译文件、pickle/pkl数据文件、PDF报告与Markdown说明文档为主整体压缩包仅2.67MB轻量但结构完整覆盖数据加载、网络构建、模型训练与分类评估等关键模块。系统采用CNN模型识别垃圾邮件并已提供训练好的最佳模型文件下载后可直接加载运行便于对比实验结果。资源描述显示源码已经过本地编译测试评审分达95分以上内容经助教审定难度适中适合学习参考。目前已有342人学习对于希望借鉴完整项目流程、快速搭建邮件分类实验的读者既可参考源码结构与数据预处理思路也可基于现有模型继续调参优化或做功能扩展。1. 垃圾邮件分类为什么不用朴素贝叶斯而选了CNN邮件分类这个题目网上一堆教程第一步就上朴素贝叶斯因为词频假设成立代码短跑得也快。但毕业论文选基于Python和CNN来做不是把卷积神经网络当摆设——垃圾邮件的判别特征很少落在单个词上而是落在相邻词构成的短窗口里。“免费”和“链接”单独看都正常放到“恭喜您获得优惠券点击链接领取”这个上下文里就有了典型的垃圾信号。CNN文本分类里最经典的TextCNN结构恰好就是用固定宽度的卷积核扫过词序列把局部词组合的特征抓出来再用池化层决定哪些组合对最终判定最有用。这篇博文把这条链路完整拆开邮件数据清洗、中文分词与词表构建、TextCNN网络实现、训练评估、预测闭环一次走通。新手可以照着全部实现有经验的读者可以直接跳到第2章和第4章重点看卷积核尺寸怎么选、类别不平衡怎么处理、模型落盘之后预测管线哪些地方容易断裂。整个方案以Python生态为主线PyTorch作为深度学习框架数据部分不做任何人工特征工程让卷积层自己学特征。2. CNN文本分类原理为什么垃圾邮件任务里TextCNN能打2.1 从“词窗口”理解卷积在邮件上的作用方式卷积神经网络在图像上扫描的是像素块在文本上扫描的是词向量序列。假设一条邮件被处理成[恭喜, 您, 获得, 优惠券, 点击, 链接]每个词映射为一个向量这串向量按顺序排成一个矩阵行数是句子长度列数是词向量维度。CNN的卷积核宽度为2、3或4含义是每次滑动扫描连续2个、3个或4个词的组合。一个宽度为3的卷积核本质上是在问“相邻三个词组合在一起是不是垃圾信号”。垃圾邮件和正常邮件最大的区别就在这种局部组合上正常邮件里的“点击链接”后面通常是“阅读全文”垃圾邮件里的“点击链接”后面跟着“领取奖品”。单个词没有判别力局部词序有。CNN不需要预知哪些组合重要它把所有宽度为2、3、4的组合窗口都扫一遍让网络自己学出哪些组合对分类贡献大。这与朴素贝叶斯把每个词当成独立特征有本质区别n-gram模型虽然也能捕捉组合但CNN把组合的权重和位置信息都做成了可训练参数效果往往更好。2.2 TextCNN的完整结构与关键参数常见的TextCNN对每条邮件做如下变换每一步的输出形状直接影响下一层的参数设置输入序列: [batch_size, seq_len] 嵌入层: [batch_size, seq_len, embed_dim] 卷积层: [batch_size, conv_out_channels, conv_out_length] 最大池化: [batch_size, conv_out_channels] 拼接: [batch_size, total_filters] 全连接: [batch_size, 1]嵌入层把词索引映射成稠密向量。这里有两个选择用Gensim训练Word2Vec后初始化嵌入矩阵或者直接用nn.Embedding随机初始化让网络在训练中自己调整词向量。在数据量只有几万条时随机初始化加足够的训练轮次效果不一定比预训练向量差而且省去训练词向量的步骤。卷积层是核心。PyTorch里用nn.Conv1d实现关键是kernel_size的选择。垃圾邮件特征词的组合长度通常在2到4之间所以常见配置是分别用宽度2、3、4的卷积核每种宽度设置64到128个filter。多尺寸卷积核并行扫描相当于同时关注二元词组、三元词组和四元词组。池化层用nn.MaxPool1d把每个feature map压缩成一个最大值。最大值对应的是“整个句子窗口里最能代表该特征的片段”。垃圾邮件哪怕只有一句像“免费领取”被识别出来这一特征就会被保留这正是垃圾邮件检测需要的特性——不用看完整封信抓住局部信号就能判定。dropout放在哪里很多教程写得含糊。正确位置是池化拼接之后、全连接层之前对拼接后的特征向量做随机失活防止网络记住训练集里的特定组合模式。2.3 和LSTM、Transformer的取舍以及参数边界做过文本分类的人会问为什么不用LSTM或BERTLSTM适合需要捕捉长距离依赖的序列任务比如情感分析中“虽然……但是……”这种跨句子转折。垃圾邮件不一样判断一封邮件是不是垃圾通常只需要看2到5个词的组合就足够不需要跨越20个词去关联上下文。用LSTM在短文本分类上不是不行但训练时间更长且在数据量不大时优势不明显。Transformer近年很火注意力机制理论上比CNN更强但需要更大数据量才撑得起参数量。几万条邮件的规模下BERT类模型容易出现严重过拟合除非做大量数据增强或使用领域预训练模型。CNN在这个量级下参数量适中训练速度快局部特征捕捉能力和Transformer在大数据集上的能力差距不大。这也是毕业设计场景下选CNN最务实的地方。参数边界上要注意卷积核宽度不要超过5过宽的卷积核会引入太多噪声词组合降低泛化能力filter数量在64到256之间太多会让嵌入层和池化层的参数同时膨胀小数据量下反而掉点卷积层原则上不需要堆很深一层多尺寸卷积就够堆叠多层TextCNN在短文本任务上收益很小。3. 垃圾邮件数据预处理清洗、分词、词表与Dataset实现3.1 先搞清楚数据集长什么样再动手垃圾邮件分类数据集常见三种形态纯文本文件按目录区分spam和ham、CSV文件带label列、以及从UCI或Trec等渠道下载的带格式邮件原文。无论哪种第一步都是统一读入到一个两列的DataFrame或字典结构里列名分别为text和label其中label用1表示垃圾邮件、0表示正常邮件。import pandas as pd # 假设CSV的列名为 label,text df pd.read_csv(spam_dataset.csv) # 先检查类别分布这一步决定后续要不要做类别平衡 print(df[label].value_counts()) # 检查空值空文本直接删除或者用占位符替换 df df.dropna(subset[text]) # 统一转成字符串避免数字或NaN干扰清洗 df[text] df[text].astype(str)数据分布检查不是走过场。正常邮件和垃圾邮件的比例如果达到9比1甚至更高直接用原始分布训练模型会倾向于把所有邮件都预测为正常邮件因为这样准确率已经90%。后续训练时要么调整损失函数权重要么对多数类降采样。这一步看清楚了后面第4章设置类别权重时才有依据。3.2 清洗规则按场景分层正则顺序别乱邮件文本和其他文本最不一样的地方是噪声类型多HTML标签、URL、邮箱地址、电话号码、金额数字、多重重叠标点都常见。清洗顺序会影响最终结果我的习惯是先去掉HTML结构再替换URL和邮箱之后处理数字和标点最后才是分词。import re def clean_text(text: str) - str: # 1. 去掉HTML标签防止div、a这类标签被当成词 text re.sub(r[^], , text) # 2. 把所有URL替换成特殊占位符统一处理 text re.sub(rhttps?://\S|www\.\S, URL , text) # 3. 邮箱替换为占位符避免用户名的随机字符污染词表 text re.sub(r\S\S\.\S, EMAIL , text) # 4. 连续数字缩短防止手机号、QQ号进入词表 text re.sub(r\d{4,}, NUM , text) # 5. 多余标点只保留一个避免!!!!这种噪声被拆烂 text re.sub(r[!?。]{2,}, !, text) # 6. 控制空白字符 text re.sub(r\s, , text).strip() return text.lower()注意几个细节URL和邮箱不直接删掉而是替换成占位符是因为垃圾邮件里URL、邮箱本身有强信号变成URL和EMAIL这两个词后CNN的卷积核可以学到“出现URL并配合下载、注册等词时更可能是垃圾邮件”。数字同理手机号具体号码不重要“连续4位以上数字”这个特征本身有意义。lower()对英文Email区分大小写没必要统一为小写可以大大压缩词表。3.3 中文用jieba分词英文直接切分两者在一个预处理流程里中文邮件无法像英文那样按空格切词需要用jieba。这个环节最容易出错的地方是在分词之前先做了停用词过滤结果“不”“没”这类否定词被删掉“不买”变成“买”意思完全反转。正确的做法是先分词再考虑是否过滤停用词。垃圾邮件场景里我连停用词表都建议谨慎使用“免费”“点击”这种词恰恰是垃圾信号的核心不该出现在通用停用词表里。import jieba def tokenize(text: str, is_chinese: bool False) - list[str]: if is_chinese: return list(jieba.cut(text)) # 英文按空白切分简单可靠 return text.split()一个项目如果同时包含中英文混合邮件可以按字符集预判只要文本里出现中文字符就按中文分词处理否则按英文空格切分。混合场景下不用纠结把中英文分别切好拼在一起即可CNN对语言不敏感它只看到词索引序列。分词之后每条邮件变成一串词下一步建词表。3.4 词表构建min_freq和max_len是两个决定模型效果的数字词表构建的规则直接影响嵌入层大小和训练效果。按词频统计去掉只出现一次的词既降低词表规模又防止模型把拼写错误或者某个邮件特有的随机字符串当成有效特征。from collections import Counter def build_vocab(tokenized_texts: list[list[str]], min_freq: int 2, max_vocab: int 20000): counter Counter() for tokens in tokenized_texts: counter.update(tokens) # 按频率降序保留出现次数 min_freq 的词 vocab { pad: 0, unk: 1, } for word, freq in counter.most_common(max_vocab): if freq min_freq: break vocab[word] len(vocab) return vocabmin_freq2代表出现次数小于2的词一律映射为unk。max_vocab20000用来限制嵌入层规模Embedding矩阵大小是[vocab_size, embed_dim]20000乘以128维大约256万参数小数据集下完全扛得住。max_len设为32或64取决于邮件平均长度。统计所有邮件的分词长度分布后取95分位数比拍脑袋设定更科学。截断时截断尾部因为垃圾邮件的关键信号通常在开头部分。数据划分也有讲究。直接用train_test_split按行随机切分即可但必须设置random_state保证每次跑结果一致。垃圾邮件分类不像时间序列需要按时间切分随机划分是合理的。划分比例训练集70%、验证集15%、测试集15%。3.5 Dataset构建让训练和预测共用同一套预处理代码训练阶段和预测阶段最怕各写一套预处理逻辑训练时清洗了数字、预测时忘了效果立刻劣化。正确做法是把清洗和分词封装成独立模块训练和推理都从这个模块导入。from torch.utils.data import Dataset import torch class SpamDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens tokenize(clean_text(self.texts[idx]), is_chineseself._has_chinese(self.texts[idx])) ids [self.vocab.get(w, 1) for w in tokens[: 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把词索引转换、截断、填充全部收口到__getitem__里DataLoader加载时自动完成向量化。vocab.get(w, 1)中1是unk的索引0是pad的索引。这里有个常见的坑拿到预训练好的嵌入矩阵时用unk替换低频词但unk向量最好用零向量初始化或训练一小段否则模型遇到新词时全部输出同一个向量容易把不同长度的新词误判为同一特征。4. 用PyTorch搭建TextCNN并跑通完整训练闭环4.1 TextCNN的PyTorch实现多尺寸卷积核并行模型结构按照第2章的层级依次实现。nn.Conv1d的输入形状是[batch_size, embed_dim, seq_len]所以嵌入层之后需要做一次转置这一点新手经常弄反。多尺寸卷积核分别卷积后各自做最大池化再把结果拼接。import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters, filter_sizes, num_classes, 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_sizesize) for size in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): # x: [batch_size, seq_len] emb self.embedding(x) # [batch, seq_len, embed_dim] emb emb.transpose(1, 2) # [batch, embed_dim, seq_len] conv_outputs [] for conv in self.convs: # 卷积后形状为 [batch, num_filters, seq_len - kernel_size 1] c torch.relu(conv(emb)) # 最大池化把整条特征映射压缩成一个数 pooled torch.max(c, dim2).values conv_outputs.append(pooled) # 拼接所有卷积核提取的特征 cat torch.cat(conv_outputs, dim1) cat self.dropout(cat) out self.fc(cat) return out.squeeze(1)代码里三个细节值得说明。第一padding_idx0让pad位置的嵌入向量始终保持零向量卷积核扫到填充位不会产生额外特征。第二最大池化用torch.max(c, dim2).values取每个feature map在所有时间步上的最大值不保留位置信息防止模型过度依赖“垃圾词出现在第几个位置”。第三dropout放在全连接前而不是卷积前因为卷积层的局部特征提取不该被打断池化后的高维特征向量才是容易过拟合的地方。num_filters在三种尺寸下保持一致拼接后的维度就是num_filters * 3全连接层的输入维度要和这个数严格对齐。4.2 训练超参数配置先按这张表跑第一版第一版模型不需要花哨技巧参数按经验值设置即可。参数名推荐值说明embed_dim128词向量维度太大在数据量小时容易过拟合filter_sizes(2, 3, 4)同时捕捉二元、三元、四元词组特征num_filters64或128每种卷积核的通道数128对训练更充分batch_size64小批量训练显存占用和收敛速度平衡learning_rate1e-3Adam优化器常用初始值适配器下通常够用dropout0.5池化后特征层随机失活比例epochs15到20配early stopping按验证集F1来停criterionBCELoss / BCEWithLogitsLoss二分类任务的标准选择优化器一般选Adambetas保持默认不需要额外调参。学习率1e-3对TextCNN这种浅层模型是安全值如果验证集损失振荡明显降到5e-4。loss可以用BCEWithLogitsLoss把最后一层的logits直接输入即可内部帮我们加Sigmoid数值上更稳定。4.3 训练循环验证集早停和模型保存一次写对def train_model(model, train_loader, val_loader, epochs, lr, device): optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.BCEWithLogitsLoss() model.to(device) best_f1 0.0 for epoch in range(epochs): model.train() train_loss 0.0 for batch_x, batch_y in train_loader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() # Clip梯度防止个别长样本把梯度撑爆 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() val_f1 evaluate(model, val_loader, device) if val_f1 best_f1: best_f1 val_f1 # 只保留验证集F1最高的那一次参数 torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch1}, Loss {train_loss/len(train_loader):.4f}, Val F1 {val_f1:.4f})梯度裁剪这一步常被忽略。垃圾邮件里偶尔有一条长度特别长、特征特别密集的样本卷积输出的数值会异常偏大梯度裁剪把梯度模长限制在1.0以内能有效防止单条超长样本毁掉整个训练过程。早停逻辑不直接写在训练循环里依赖一个evaluate函数返回验证集F1每次取最高值覆盖保存。4.4 评估要看混淆矩阵垃圾邮件最怕漏判准确率在类别不平衡时是骗人的指标。垃圾邮件占20%时模型全部预测为正常就能拿到80%准确率这个模型实际上一点用没有。真正需要盯住的是召回率和F1。垃圾邮件分类里漏判一封信实际垃圾但预测为正常的代价远高于误判一封正常邮件所以召回率代表“垃圾邮件中有多少比例被拦住了”比准确率重要得多。from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, val_loader, device): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x batch_x.to(device) logits model(batch_x) preds (torch.sigmoid(logits) 0.5).float() all_preds.extend(preds.cpu().tolist()) all_labels.extend(batch_y.tolist()) report classification_report(all_labels, all_preds, digits4) cm confusion_matrix(all_labels, all_preds) print(report) return cm[1][1] / (cm[1][1] cm[1][0] 1e-9) # 防止除以0测试时把Sigmoid阈值从默认的0.5调高到0.6或0.7可以提高精确率降低正常邮件被误删的概率调低到0.3到0.4则提高召回率过滤更激进。这个阈值不用改模型结构直接在预测环节做调整即可。训练完成后保存两份东西模型参数best_model.pth和词表文件vocab.json二者缺一不可。词表丢了测试阶段的新文本无法映射成训练时的词索引模型等于白训练。5. 模型落地的关键细节预测管线、目录结构与3个调优技巧5.1 预测阶段的预处理必须和训练阶段完全一致这是最容易断的链路模型训练完进入预测最容易出现的问题不是模型效果差而是预测时的文本处理和训练时的逻辑不一致。训练时把https://xxx替换成URL占位符预测时忘了写这步卷积核就扫到一个纯字符串词表里根本没有这个次映射成unk特征丢失。预测时用了max_len64但训练时用的32序列长度的差异会让填充位置全部偏移。正确做法是把预处理、分词、词表加载、模型加载封装成一个predict.py预测代码只关心输入一条原始文本字符串。def predict_single(text: str, model, vocab, device, max_len64): tokens tokenize(clean_text(text), is_chinesehas_chinese(text)) ids [vocab.get(w, 1) for w in tokens[:max_len]] ids ids [0] * (max_len - len(ids)) input_tensor torch.tensor([ids], dtypetorch.long, devicedevice) with torch.no_grad(): logits model(input_tensor) prob torch.sigmoid(logits).item() return {label: 1 if prob 0.5 else 0, prob: prob}注意这个函数和训练时Dataset里__getitem__的代码结构完全一致只是少了label部分。如果重构时改了清洗正则务必同时更新训练和预测两侧的公共模块或者干脆把预处理函数统一放在一个utils.py里两边共同导入。这一步做到位整个系统才能从实验室搬到真正的邮件流入环境里。5.2 目录结构和交付文档建议毕业设计评审不看单块代码看整体spam-classification/ ├── data/ │ ├── raw/ # 原始邮件数据集 │ ├── processed/ # 清洗后、划分好的训练集与测试集 │ └── vocab.json # 词表文件模型预测时的必需品 ├── src/ │ ├── utils.py # 清洗、分词、词表加载训练和预测共用 │ ├── dataset.py # SpamDataset实现 │ ├── model.py # TextCNN模型定义 │ ├── train.py # 训练入口 │ └── predict.py # 单条文本预测入口 ├── models/ │ └── best_model.pth ├── docs/ │ └── 项目说明文档.md ├── requirements.txt └── README.mdrequirements.txt里写清torch、jieba、pandas、sklearn的版本范围不要写死到具体版本但要注明主版本号例如torch2.0。文档里放一张训练过程的loss和F1曲线图比写十段文字更有说服力。训练完成后把测试集上的混淆矩阵也截图放进文档答辩老师问“效果怎么样”时直接给出数字和可视化结果。5.3 三个立竿见影的调优技巧第一个技巧是阈值动态调整。测试集跑完画出F1随阈值变化的曲线通常0.45到0.55之间有个峰值。如果垃圾邮件漏判代价高把阈值下调到0.35如果正常邮件误删造成用户投诉就上调到0.65。这一招不需要重训模型见效最快。第二个技巧是类别不平衡带来的样本加权问题。训练时给BCEWithLogitsLoss传入pos_weight数值设为负样本数量除以正样本数量。举例来说训练集有4000封垃圾邮件、16000封正常邮件pos_weight4模型会加大对垃圾邮件分错时的惩罚这一步能让F1提升2到5个百分点不用更改网络结构。第三个技巧是检查max_len是否截断掉了关键信息。垃圾邮件有时会把营销文案写在末尾如果训练集里邮件平均长度只有30个词但有几条垃圾信号恰恰出现在第40个词位置就被截断了。统计干净来源的垃圾邮件长度分布取95分位数作为max_len而不是默认取64。《把预处理、特征和接口都收敛在一个模块里模型、词表和数据版本对得上这套代码无论是继续迭代还是交付都不需要返工重构。 提示训练轮次增加不一定提升效果TextCNN在中小数据集上通常在6到10轮到达F1峰值之后开始过拟合早停以验证集F1为准即可。本文还有配套的精品资源点击获取