ARTICLE DETAIL

资讯详情

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

CNN垃圾邮件分类实战:从数据清洗到模型部署与避坑指南

CNN垃圾邮件分类实战:从数据清洗到模型部署与避坑指南 简介基于Python卷积神经网络CNN的垃圾邮件分类系统毕业设计资源包面向计算机相关专业学生及自然语言处理入门者。资源涵盖从邮件文本预处理、词向量构建、CNN模型训练到分类评估的完整流程既可用于理解卷积网络在文本分类中的典型应用也可作为课程设计或毕业设计的可运行基础。压缩包共14个文件大小约2.67MB包含4个Python源码文件及对应编译后的pyc文件另有训练好的CNN权重模型pkl/pickle、1000条邮件正文与标签数据集以及Markdown和PDF格式的说明文档。目录结构清晰源码经过本地编译运行打开即可体验完整实验闭环。该资源已有342人学习下载。随包附带的模型文件可直接调用省去重复训练耗时说明文档对数据格式、模型参数和运行步骤有简要梳理便于快速上手并针对准确率、泛化性做进一步调优。1. 垃圾邮件分类用CNN一个能过答辩、也扛得住面试的选题如果你的毕设题目写着“基于Python卷积神经网络CNN的垃圾邮件分类系统”先别急着觉得它老套。这套题交付的源码、模型、说明文档和全部数据资料看上去只是普通分类任务实际上把数据处理、深度学习、模型评估和工程封装全串了一遍。我见过很多学生拿同一类题目有人三天跑通有人卡在编码乱码上两周。用CNN做文本分类核心思路是把邮件看成一串词的索引序列再用一维卷积提取局部n-gram特征。它没有Transformer那么大的数据胃口也没有朴素贝叶斯那么容易被评委说“没有技术含量”。对毕设来说这是性价比极高的方向不需要独显CPU就能跑解释起来直观调参空间足够写满论文章节。下面我从数据准备开始把每个环节的参数和坑拆开讲清楚。2. 数据准备把邮件文本切碎、编号、补齐到CNN能吃下2.1 数据来源与常见字段结构这类毕业设计拿到的zip压缩包里一般会有一个CSV文件常见字段是label和text。label有两种写法一种是ham/spam字符串另一种是0/1数值text存放一封邮件的原文。拿到数据后不要直接读先用系统命令看一眼文件编码这一步能省掉后面所有乱码问题。我一般会这么做file spam.csv head -n 3 spam.csv cat如果file显示是ISO-8859或GBK那就必须在read_csv里指定encoding或者先转成UTF-8再跑。中文邮件数据集尤其容易出现GBK英文数据集多是UTF-8。我在处理这种毕设数据时会先把一份样本转成统一编码再训练而不是让pandas反复猜。另外如果你手里的数据类别比例悬殊比如正常邮件9000封、垃圾邮件1000封不要慌这在垃圾邮件任务里是常态后面划分训练集和计算损失时都要针对这一点处理不是模型里加一个分类数就完事。2.2 清洗邮件文本去HTML、去URL、去掉噪声字符垃圾邮件往往带着大量HTML标签、跟踪链接、特大号字体文本这些内容对分类有一定信号但更多是噪声会把词表撑大。清洗目标不是把文本变成真空而是保留邮件的正文特征同时控制词表规模。常见做法是import re import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(spam.csv, encodingutf-8, on_bad_linesskip) def clean_text(text): if not isinstance(text, str): text str(text) text re.sub(r[^], , text) # 去掉HTML标签 text re.sub(r(https?://|www\.)\S, , text) # 去掉链接 text re.sub(r[^a-zA-Z\u4e00-\u9fa5], , text) # 保留中英文字符 text re.sub(r\s, , text) return text.lower().strip() df[clean_text] df[text].apply(clean_text) df[label] df[label].map({ham: 0, spam: 1}) print(df[label].value_counts())参数说明on_bad_linesskip是pandas 2.x的写法旧版本写作error_bad_linesFalse作用是跳过格式损坏的行避免因为一个多余逗号让整个程序中断。正则里的[^a-zA-Z\u4e00-\u9fa5]把数字、标点、emoji全部替换成空格。如果你认为数字比如“免费领取200元红包”里的200这类信息有分类价值可以把数字保留成特殊标记但多数英文毕设去掉更省心。做完这一步我建议顺手打印三行结果肉眼检查清洗有没有误伤正常邮件。比如“RD”会被洗成“r d”这在英文邮件里挺常见但不会影响分类大方向真正要注意的是把“免费”这类关键词误删那就得不偿失。2.3 用Tokenizer做序列化词表大小、OOV和最大长度怎么定CNN输入必须是定长数值矩阵所以清洗后的文本要转成序列再padding到同一长度。这里用的是Keras的Tokenizer虽然它属于TensorFlow工具链但可以单独拿来做序列化后面接PyTorch模型也很方便。实际代码长这样from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences MAX_VOCAB_SIZE 20000 MAX_LEN 200 tokenizer Tokenizer(num_wordsMAX_VOCAB_SIZE, filters, lowerTrue, split , oov_tokenOOV) tokenizer.fit_on_texts(df[clean_text].tolist()) train_df, val_df train_test_split( df, test_size0.2, random_state42, stratifydf[label] ) train_sequences tokenizer.texts_to_sequences(train_df[clean_text].tolist()) val_sequences tokenizer.texts_to_sequences(val_df[clean_text].tolist()) X_train pad_sequences(train_sequences, maxlenMAX_LEN, paddingpost, truncatingpost) X_val pad_sequences(val_sequences, maxlenMAX_LEN, paddingpost, truncatingpost) y_train train_df[label].values y_val val_df[label].values这里的几个参数是后面写论文明细要凑的。filters表示不要按默认规则过滤标点因为前面已经自己清洗过再过滤一遍会把OOV以外的词搞丢。oov_tokenOOV让训练里没见过的词统一指向同一个索引否则预测阶段遇到新词会直接丢弃导致序列长度不一致。paddingpost和truncatingpost分别表示在尾部补零和截断这是文本CNN的常规设置比pre版本更能保住邮件开头部分的信号。为什么MAX_LEN选200垃圾邮件文本通常不会太长平均在50到150个词之间。如果设成500训练时会浪费大量计算在padding后的零上而且CNN的感受野根本覆盖不了那么远。我一般会先跑一句df[clean_text].str.split( ).str.len().describe()用90分位对应的长度当MAX_LEN不要拍脑袋。中文邮件用jieba分词后词数会膨胀这时MAX_LEN建议到300。2.4stratify不写等于给模型埋雷上面代码里有一个很不起眼的参数stratifydf[label]。很多教程不会解释它但它的作用是保证训练集和验证集的垃圾邮件比例一致。如果不写随机划分可能让验证集垃圾邮件占10%训练集占30%模型训练时的先验分布和评估时不一致最后准确率虚高或虚低。对这个2万条的数据集随机划分大概率比例不会偏差太多但不严谨。毕设答辩老师很可能问“你的数据怎么划分的”你能回答“用了分层抽样保持正负样本比例相同”这是一个加分点。如果你的数据已经极度不平衡spam只占5%光靠stratify还不够。常见做法有两种一是用class_weight让少数类的损失权重放大二是降采样正常邮件。我建议先试class_weight因为不损失数据PyTorch里就是在损失函数里传weight后面会展开。把这一步做好再进模型路会顺很多。3. 模型构建与训练从Embedding到0.99的F1分3.1 文本CNN原理一维卷积怎么提取n-gram特征CNN在图像里扫描二维像素块在文本里则对应一段连续词向量。比如kernel_size3就相当于每次看连续三个词用卷积核把它们压缩成一个向量再通过ReLU激活。多个不同kernel_size的卷积核分别提取二元、三元、五元特征对应着“免费”“点击领取”“中奖通知”这类局部短语。这是CNN相对TF-IDF加机器学习的最大优势特征不用手工设计卷积核自己学习。这时候一定会被问“CNN不是处理图像的吗”毕业论文里要有这一段解释这里的卷积是Conv1d输入不再是二维像素而是三维张量[batch, seq_len, embed_dim]。文本CNN常用的做法是全局最大池化把每个卷积通道的输出压缩成一个值代表该卷积核在整篇邮件里最强烈的响应位置。这个设计让模型只关心“有没有出现关键短语”不关心短语具体出现在哪。3.2 模型核心结构Embedding Conv1d 全局最大池化有了前面的序列化X_train每一条是长度为200的索引数组。进入模型前先过Embedding层把每个索引映射成一个向量比如100维。之后转置成[batch, embed_dim, seq_len]喂给一维卷积。下面用PyTorch写一个可运行的TextCNNimport torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_filters128, kernel_sizes[3, 4, 5], num_classes2, dropout0.5): super(TextCNN, self).__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Sequential( nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizek, padding0), nn.BatchNorm1d(num_filters), nn.ReLU(), nn.AdaptiveMaxPool1d(1) ) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(len(kernel_sizes) * num_filters, num_classes) def forward(self, x): x self.embedding(x) # [batch, seq_len, embed_dim] x x.permute(0, 2, 1) # [batch, embed_dim, seq_len] pooled [conv(x) for conv in self.convs] # 每个输出 [batch, filters, 1] pooled torch.cat(pooled, dim1).squeeze(2) # [batch, sum(filters)] x self.fc(self.dropout(pooled)) return x这段代码关键在nn.Conv1d后面接AdaptiveMaxPool1d(1)。Adaptive池化保证无论输入序列多长输出都是一个值省去手动计算池化窗口的麻烦。padding_idx0要特别记住前面pad_sequences用0补位Embedding层需要把索引0对应的词向量固定成全零且不参与训练否则模型会把padding位置当成真实词来学。词表大小传入时要小心。Tokenizer的num_words20000只是上限实际数据可能只有5000个不同词这时vocab_size就应该是5001。如果你直接写20001Embedding会多出一大块随机初始化的向量纯属浪费。3.3 训练循环交叉熵、AdamW与每个epoch验证模型结构写完后训练循环看似简单有几个细节值得写进论文。第一是损失函数用CrossEntropyLoss它内部先做softmax不会出现输出层忘了softmax导致loss巨大的问题。第二是优化器推荐AdamW相比L2正则更解耦训练初期loss下降更稳。第三是训练时让dropout生效验证时用torch.no_grad()关闭梯度否则结果会被dropout扰动。import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset train_dataset TensorDataset( torch.tensor(X_train, dtypetorch.long), torch.tensor(y_train, dtypetorch.long) ) val_dataset TensorDataset( torch.tensor(X_val, dtypetorch.long), torch.tensor(y_val, dtypetorch.long) ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse) model TextCNN(vocab_sizemin(MAX_VOCAB_SIZE, len(tokenizer.word_index)) 1, embed_dim100, num_filters128, kernel_sizes[3, 4, 5], num_classes2, dropout0.5) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3) def evaluate(model, loader): model.eval() all_preds, all_labels [], [] total_loss 0.0 with torch.no_grad(): for xb, yb in loader: logits model(xb) loss criterion(logits, yb) total_loss loss.item() all_preds.extend(logits.argmax(dim1).tolist()) all_labels.extend(yb.tolist()) return total_loss / len(loader), all_preds, all_labels for epoch in range(10): model.train() train_loss 0.0 for xb, yb in train_loader: optimizer.zero_grad() logits model(xb) loss criterion(logits, yb) loss.backward() optimizer.step() train_loss loss.item() val_loss, preds, labels evaluate(model, val_loader) print(fepoch {epoch1:02d} | train_loss {train_loss/len(train_loader):.4f} f| val_loss {val_loss:.4f})注意vocab_sizemin(MAX_VOCAB_SIZE, len(tokenizer.word_index)) 1这里加1是因为OOV占用索引1索引0留给padding所以词表大小是实际词数加1。如果数据集很小这个值可能只有几千训练速度会非常快。另外每个epoch都做一遍验证是我坚持的习惯。不要等全部训练完才发现过拟合验证loss在某个epoch突然掉头说明训练该停了。后面避坑章节会讲怎么处理这种情况。3.4 评估要看F1不能只看准确率训练结束后用sklearn.metrics.classification_report打印完整指标from sklearn.metrics import classification_report def show_report(model, loader): _, preds, labels evaluate(model, loader) print(classification_report(labels, preds, target_names[ham, spam])) show_report(model, val_loader)在正常邮件和垃圾邮件比例8:2的情况下准确率95%不一定说明模型好。如果垃圾邮件只占10%模型把所有邮件都预测成正常邮件准确率也有90%。所以论文里至少要写spam的精确率、召回率、F1这三个值。垃圾邮件分类场景中召回率要保证足够高漏过一封垃圾邮件比误杀一封正常邮件更危险。CNN在短文本上的优势能把spam召回率推到0.95以上如果达不到往下看避坑章节。4. 避坑指南垃圾邮件分类项目中我掉过的五个真实陷阱4.1 邮件数据乱码CSV打开全是“锟斤拷”模型训练了个寂寞现象程序里pd.read_csv不报错但打印文本全是黑色方块、问号或“锟斤拷”词表里全是奇怪二字词模型准确率一直在0.8附近上不去。原因这份zip里的数据可能是GB2312、GBK或者ISO-8859-1编码而代码用UTF-8读取中英文字符被错误解码。垃圾邮件数据集通常是爬虫抓的当年存下来的格式五花八门不能用“这年头谁还用GBK”来赌。解决先file 文件名查编码如果系统识别不了就用chardet检测小样本文本import chardet with open(spam.csv, rb) as f: raw f.read(10000) result chardet.detect(raw) print(result)拿到编码名后回填到pd.read_csv(encodinggbk, errorsignore)。注意errorsignore会静默丢弃无法解码的字符适用于少量噪声如果丢弃比例超过1%说明编码压根选错了。我见过最典型的翻车是数据里混入Windows-1252和UTF-8两段内容这时候只能分段清洗没有捷径。4.2 类别不平衡被准确率掩盖现象验证集准确率98%但看confusion matrix垃圾邮件几乎全被分到正常邮件里spam召回率只有0.15。原因spam占比本来就只有10%模型训练时正常类贡献90%的误差梯度更新让正常邮件优先被分对。CNN对这种比例很敏感不需要极端10%就已经能明显翻车。解决两种方案风格完全不同。第一种是改损失函数在PyTorch的CrossEntropyLoss里传weight比如spam权重设为5把少数类错误放大。第二种是过采样用imbalanced-learn的RandomOverSampler在训练集上把spam复制几份。我的经验是先用class_weight如果F1还不够再去做过采样。过采样要在序列化后的X_train、y_train上做因为序列已经是数值矩阵复制样本不会产生新的语义信息。如果你直接在文本层复制也只是改了输入概率分布等于没做。4.3 过拟合到“背答案”训练集准确率99%验证集82%现象train_loss一路降到0.03val_loss却从0.3降不下去后面几个epoch还掉头上涨。原因文本CNN参数量虽然不大但毕设数据集往往只有几千封邮件模型把训练集里的个别拼写和格式当成了通用特征。比如某类垃圾邮件全是大写英文训练集出现10次模型就会把“全大写”当成强特征验证集一次没出现就开始翻车。解决最有效三个旋钮按顺序调。第一把dropout从0.5升到0.7第二把num_filters从128降到64第三把embed_dim从100降到50。先动这三个不要一上来就砍kernel_sizes。另外训练epoch从10减到8配合ReduceLROnPlateau让验证loss不再下降时自动减半学习率。如果做了这些还是过拟合再回头检查数据清洗看看是不是误删了一些关键词导致模型对词频特别敏感。4.4 模型文件和代码版本对不上加载时报 unexpected key现象毕设代码里模型能跑但加载zip里提供的best_model.pth时直接报Missing key(s) in state_dict。原因原作者训练的模型类代码里叫TextCNN你自己重新定义了CNN_Model或者原模型加了Dropout你的实现里没有或者Embedding的维度、词表大小不同。PyTorch的state_dict是靠层名匹配的任何一个层名对不上都会报错。解决如果你只用源码包里的模型先把自己定义的模型类和源码完全对齐不要自作聪明优化。更稳的做法是加载时先打印状态字典的层名列表比对后再加载。model.load_state_dict(torch.load(best_model.pth, map_locationcpu), strictFalse)能忽略多余或缺失的层至少让模型跑起来但这样会把没加载上的层留成随机初始化风险很大只建议用来排查哪个层名不一致。最终还是要改模型类别走捷径。4.5 答辩被问“现在都Transformer了为什么用CNN”现象评委指着论文说“这个方向过时了”你一时间不知道怎么接。原因不是CNN不能做垃圾邮件分类而是你没有把选型理由说透。垃圾邮件分类是短文本任务CNN在局部短语特征上已经足够不需要长距离依赖。解决从三个角度答。一是数据量垃圾邮件数据集通常只有几千到几万条BERT这种大模型在小数据上很容易过拟合微调成本高二是训练资源CNN在CPU上十几分钟收敛BERT在GPU上也要半小时以上三是可解释性CNN的卷积核能直接映射到n-gram短语论文里可以把某个卷积核最激活的短语列出来做成对比表。如果评委继续追问你就承认“如果数据量到百万级我会选Transformer但在毕业设计这个规模下CNN的性价比最高。”这句话说出来其实是加分项因为证明你理解模型的适用边界。5. 最后一公里导出模型、封装预测、去答辩现场演示很多学生训练完就停在一个epoch循环里演示时打开notebook重新跑这在答辩现场风险很大。正确的做法是把训练好的模型和Tokenizer一起落盘写一个单独函数任何一封邮件文本进来一步输出预测结果。torch.save(model.state_dict(), best_model.pth) import pickle with open(tokenizer.pkl, wb) as f: pickle.dump(tokenizer, f)加载时注意两点Tokenizer要直接pickle整个对象因为它包含word_index字典模型则只存权重不要pickle整个模型对象除非你确定部署环境和训练环境完全一致。加载模型前用model TextCNN(...); model.load_state_dict(torch.load(...))词表大小在加载时再算一次不要写死。然后是预测函数def predict_one(text, tokenizer, model, max_len200): model.eval() cleaned clean_text(text) seq tokenizer.texts_to_sequences([cleaned]) padded pad_sequences(seq, maxlenmax_len, paddingpost, truncatingpost) with torch.no_grad(): logits model(torch.tensor(padded, dtypetorch.long)) prob torch.softmax(logits, dim1)[0] return {正常邮件: float(prob[0]), 垃圾邮件: float(prob[1])} if __name__ __main__: print(predict_one(恭喜您获得iPhone大奖点击链接领取。, tokenizer, model)) print(predict_one(亲爱的用户您的快递正在派送中请保持电话畅通。, tokenizer, model))这里有个容易忽略的细节预测时要把训练时同一套clean_text正则拿过来用否则输入原样文本序列化后全是OOV预测结果和随机猜差不多。所以代码维护上建议把clean_text放在utils.py训练和预测都从那里导入不要在两处各写一遍。如果你还想在答辩上加一点亮点可以用训练好的CNN在另一个数据集上做few-shot调优。方法加载已保存模型用小学习率如1e-5冻结Embedding层只更新卷积层训练5个epoch。这样能展示你理解迁移学习顺便把跨数据集的泛化能力写进论文实验。我现在做这类毕设项目习惯是先拆成train.py和predict.py两个文件train只管输出loss曲线和模型权重predict不管训练、直接加载。这个习惯帮我避开了无数次“重跑一遍、参数没保存”的尴尬。希望帮到你。本文还有配套的精品资源点击获取
返回列表