ARTICLE DETAIL

资讯详情

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

Python+CNN垃圾邮件分类实战:从预处理到模型部署

Python+CNN垃圾邮件分类实战:从预处理到模型部署 简介基于Python卷积神经网络CNN的垃圾邮件分类系统毕业设计项目面向计算机相关专业毕业生及课程设计学生。源码均已在本地编译运行评审得分98分项目难度适中可作为毕业设计、期末大作业的完整参考。压缩包共14个文件仅2.67MB包含4个Python源码、5个pyc编译文件、2个pickle数据文件、1个pkl模型文件以及PDF报告与README说明。cnn.py定义网络结构data.py完成邮件预处理train.py负责训练main.py串联整个流程best_cnn.pkl为训练好的模型参数。数据文件mailContent_list与mailLabel_list已整理完毕可快速验证分类效果。项目从数据加载、模型搭建到训练推理覆盖完整流程模块划分清晰便于二次开发与替换数据集。配套报告有助于理解课题设计与实现细节。目前已有190人学习下载适合需要高效完成CNN文本分类方向毕业设计或课程项目的同学参考使用。1. 为什么垃圾邮件分类会用到CNN先看看我们处理的是什么信号垃圾邮件分类系统是课程设计和毕业设计里最容易被低估的落地项目。它没有图像识别那种酷炫demo但每一封误判都要付出真实代价——垃圾邮件漏判让用户被轰炸正常邮件误杀则直接断掉业务往来。用Python和基于卷积神经网络CNN做垃圾邮件分类核心思路是把一封邮件的正文当成一维信号让卷积核去抓“中奖”“验证码”“点击链接”这类局部判别特征再经过池化和全连接层输出一个0到1之间的垃圾概率。下面把从数据清洗、模型搭建、训练导出到常见翻车现场的完整路径按可复现的方式讲一遍。适合正在做毕业设计、或者想给自己邮箱脚本加一道自动过滤的同学也适合想弄清楚CNN在文本分类上到底怎么用的人。2. 把邮件正文变成CNN能吃的张量清洗、分词、padding与不平衡数据2.1 数据集与标签定义用什么语料决定模型上限垃圾邮件分类的公开语料比很多人想象的多但质量参差。英文方向最常用的是SpamAssassin的公开邮件语料分2001、2002、2003三个版本里面已经标注好spam和ham非垃圾文件是eml格式直接读body即可。稍微新一点的是Enron-Spam它把Enron员工邮件和人工收集的垃圾邮件混在一起规模和场景更接近真实邮箱。还有一个trec07p大约75000封是TREC评测用的基准数据标签文件和邮件分开存储。中文方向公开数据少得多常见的是CSDMC2010一个中文垃圾邮件评测集但年份早、体量不大很多毕设其实是自建语料或从网上收集标签质量完全看个人功夫。我的建议是英文起步直接拿SpamAssassin先把流程跑通中文场景优先找CSDMC2010这种带标准切分的找不到再把自建语料的标签标准写进论文。这里有一个关键动作要先做——把“垃圾邮件”定义清楚。一般我会把垃圾邮件定义成三类未订阅的商业推广、诈骗类、带恶意链接或附件的。营销邮件要不要算垃圾必须在标数据前定下来。后面踩“营销邮件被误杀”的坑根源往往就是标注时把订阅类促销全打成垃圾模型学到的是“促销词”而不是“未订阅骚扰”。2.2 从原始邮件到序列清洗顺序和分词方法邮件和普通文本不一样它自带结构。一封邮件的正文前面有From、To、Subject、Date这些头字段内容里可能有HTML标签、URL、Base64编码的附件文本。如果直接把这些喂给CNN模型会把大量算力花在学“www”“http”这些噪声上而不是语义特征。我一般按下面的顺序处理import re import jieba def clean_mail(text, langen): # 1. 去邮件头body通常从第一个空行后开始 if text.startswith((From , Received:)): parts re.split(r\r?\n\r?\n, text, maxsplit1) text parts[1] if len(parts) 1 else text # 2. 去HTML标签 text re.sub(r[^], , text) # 3. 去URL text re.sub(rhttps?://\S|www\.\S, , text) # 4. 按语言清洗字符 if lang en: text text.lower() text re.sub(r[^a-z0-9\s], , text) else: text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9\s], , text) text .join(jieba.cut(text)) # 5. 合并连续空格 return .join(text.split())这段代码里几个顺序不能乱先剥邮件头再处理正文因为空行分隔符在无头邮件里同样适用HTML标签要在去URL之前剥否则标签属性里的链接会残留最后合并空格是为了让后续Tokenizer更稳定。中文加了一步jieba分词英文不需要因为英文天然按空格分词。做中文邮件分类的话分词这步别跳过直接按字切会有太多无意义单字CNN的n-gram窗口就抓不到“中奖信息”这种连续词。分词之后要做的是构建词汇表和序列化。TensorFlow的Tokenizer在fit_on_texts时会自动建立word_indextexts_to_sequences把句子映射成整数id序列。这里有个常见误区有人对全体语料做Tokenizer的fit然后才切训练测试集本质上是让“未来的测试文本”提前进入词汇表属于数据泄漏的一种。正确顺序是先全局清洗再shuffle整个数据集然后只对训练集fit Tokenizer验证集和测试集只用transformfrom tensorflow.keras.preprocessing.text import Tokenizer tokenizer Tokenizer(num_words20000, oov_tokenOOV) tokenizer.fit_on_texts(train_texts) # train_texts来自切分后的训练集 train_sequences tokenizer.texts_to_sequences(train_texts) val_sequences tokenizer.texts_to_sequences(val_texts)oov_token会把词汇表外的新词统一映射成一个固定id避免未知词在预测阶段被静默丢弃。这一步做完就有了train_sequences接下来处理长度和类别不平衡。2.3 padding长度和类别不平衡两个必须提前定好的参数序列化之后每条邮件长度差异很大。英文垃圾邮件一篇几十到几百词中文邮件短的只有几十字长的上千。CNN的输入必须是定长所以要做padding。max_len设多少不是拍脑袋我一般这样定import numpy as np from tensorflow.keras.preprocessing.sequence import pad_sequences lens [len(seq) for seq in train_sequences] p95 int(np.percentile(lens, 95)) print(95分位长度:, p95, 最长:, max(lens)) max_len min(p95, 256) # 256是经验上限超长邮件大多信息冗余 x_train_pad pad_sequences(train_sequences, maxlenmax_len, paddingpost, truncatingpost) x_val_pad pad_sequences(val_sequences, maxlenmax_len, paddingpost, truncatingpost)paddingpost表示在序列后面补0truncatingpost表示超长时截掉尾部。垃圾邮件的重要特征通常集中在头部包括标题和前几行正文所以用post截断比pre截断更稳。反过来如果你处理的是法律文书那种重点在结尾的文本才考虑pre。词汇表大小也在这一步顺手定下来Keras的Tokenizer(num_words20000)表示只保留出现频率最高的20000个词其余词统一映射为OOV。词汇表太小冷门但判别性强的词如“insurance”“免费”会被抹掉太大模型参数量暴涨稀疏词又学不到稳定表示。经验值是先跑统计看覆盖训练集95%出现的词数落在15000到30000之间都正常。类别不平衡是另一个躲不开的问题。真实邮箱里垃圾邮件占比可能在10%到50%浮动但很多公开语料刻意做成1:1和真实分布不同。如果你的语料本身不平衡常见做法有两种一是用sklearn的compute_class_weight把少数类的loss权重调高二是在数据层面做过采样。我用得更多的是前者垃圾邮件样本本来就少单纯重复采样容易让模型在少数类上过拟合from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( class_weightbalanced, classesnp.array([0, 1]), yy_train ) class_weight {0: class_weights[0], 1: class_weights[1]} print(class_weight)把class_weight传给model.fit之后模型在spam类上的loss会被放大抑制“全预测为正常邮件”的偷懒行为。这一步做完预处理侧的关键参数就齐了。提示无论英文还是中文清洗完HTML和URL后一定要重新统计一次长度分布真实长度通常会缩水很多max_len跟着变别用清洗前的统计结果。3. 用TensorFlow/Keras搭CNN邮件分类模型一维卷积的最小实现3.1 为什么卷积核能抓“中奖”和“验证码”这类判别词文本CNN的理论基础不复杂。Embedding层把每个词映射成一个稠密向量于是一封长度max_len的邮件变成形状为(max_len, embed_dim)的矩阵。Conv1D在序列维度上滑动一个窗口窗口大小等于kernel_size。当kernel_size3时每次看到的是连续3个词向量的拼接相当于在看一个三词短语配合128个filter一个filter学“中奖验证码领取”另一个filter学“点击链接注册”互不干扰。图像CNN在空间上滑动捕捉局部图案文本CNN在长度方向上滑动捕捉n-gram级别的局部搭配。这是CNN能用于文本分类的原因也是它和RNN的分工点。RNN捕捉长距离依赖比如“尽管前文在说A但最后一句才是真正意图”但垃圾邮件分类里判别信号几乎都集中在短语级很少需要跨过50个词去关联因果。加上CNN可以并行计算训练速度比LSTM快一个量级在小数据集上也不容易过拟合。标题里选CNN做邮件分类不是赶时髦是这个任务本身更适合短文本特征抽取。3.2 一个能直接跑的最小CNN模型直接看可复现的模型代码。用TensorFlow 2.x的Keras接口保持结构简单import tensorflow as tf from tensorflow.keras import layers, models def build_text_cnn( vocab_size20000, embed_dim64, max_len200, filters128, kernel_size3, dropout0.5 ): model models.Sequential([ layers.Embedding( vocab_size, embed_dim, input_lengthmax_len, mask_zeroFalse # 后续没有Masking层时开True会出警告默认False ), layers.Conv1D( filtersfilters, kernel_sizekernel_size, activationrelu, paddingsame ), layers.GlobalMaxPooling1D(), layers.Dropout(dropout), layers.Dense(1, activationsigmoid) ]) return model model build_text_cnn() model.summary()几个关键选择。Embedding层的输入是整数id序列词id范围必须在vocab_size内所以Tokenizer的num_words要和这里严格一致。Conv1D的padding用了“same”卷积后序列长度不变GlobalMaxPooling能拿到完整的局部特征如果换成“valid”最后几个窗口会丢边界信息对判别短语出现在末端的邮件不友好。GlobalMaxPooling1D对每个filter只保留最强的一个激活值把(max_len, 128)压成(128,)这一步让模型对特征位置不敏感——这是垃圾邮件分类想要的“点击链接”出现在开头还是结尾都该触发报警。Dropout放在池化之后目的是让全连接层不要完全依赖某几个激活值大的filter。我见过有人把Dropout放在Embedding之后效果一般也不差但训练初期loss更容易震荡。如果数据集只有几千封邮件建议把dropout提到0.5以上否则验证loss会在第5个epoch之后开始回升。3.3 filter数、kernel_size、dropout这几个值怎么设下面是我跑过比较多的默认参数组合新手可以先抄再根据数据调整参数常见取值说明vocab_size20000覆盖训练集约95%词频邮件语料不需要30000以上embed_dim64小语料50到64几十万级语料才建议128filters128过小时特征不足过大在几千封语料上必过拟合kernel_size3等价于三元词窗口抓“点击链接”这种搭配dropout0.5池化后加控制全连接层过拟合max_len95分位长度按2.3节统计别拍脑袋设100如果想更接近经典TextCNN结构可以把多个kernel_size并行一个分支取3另一个分支取5池化前拼接。实现上要用函数式API而不是Sequentialtext_input layers.Input(shape(max_len,), dtypeint32) embed layers.Embedding(vocab_size, embed_dim, input_lengthmax_len)(text_input) branch3 layers.GlobalMaxPooling1D()( layers.Conv1D(filters64, kernel_size3, activationrelu, paddingsame)(embed) ) branch5 layers.GlobalMaxPooling1D()( layers.Conv1D(filters64, kernel_size5, activationrelu, paddingsame)(embed) ) concat layers.Concatenate()([branch3, branch5]) output layers.Dense(1, activationsigmoid)( layers.Dropout(0.5)(concat) ) model tf.keras.Model(text_input, output)多尺度卷积比单kernel_size更稳在中文语料上尤其管用因为中文搭配长度不固定“免费领取”是两词“点击链接注册”是四词。缺点是训练时间多约30%但毕业设计这种规模完全不是问题。词向量这里用Embedding随机初始化就够不要硬套几百MB的预训练向量。公开预训练向量大多基于英文维基或通用中文语料对电商垃圾邮件几乎没有提升反而会把大量OOV词随机化拖慢收敛。这个模型的可训练参数规模也很好算Embedding层20000乘64约128万卷积和全连接层加起来只有几千总共约130万参数。对比BERT那种上亿参数这点量级在普通显卡上十几秒过一轮CPU也能跑。这也是CNN邮件分类适合作为毕设的原因跑得动、效果可解释、踩坑可控。纯CPU环境把batch_size调到128、embed_dim降到32两三小时内也能出结果。4. 模型训练到导出loss、混淆矩阵与可部署的模型文件4.1 训练脚本batch_size、epoch、学习率与早停训练别一上来就20个epoch干跑。垃圾邮件语料一般几万封到十几万封模型又浅20个epoch之后的趋势基本都是训练loss继续降、验证loss抬头。我习惯先把epoch设大一点比如30同时加EarlyStopping看验证loss连续两三个epoch不降就停回来再看到底跑了几轮from tensorflow.keras.callbacks import EarlyStopping model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), lossbinary_crossentropy, metrics[accuracy] ) early_stop EarlyStopping( monitorval_loss, patience2, restore_best_weightsTrue ) history model.fit( x_train_pad, y_train, batch_size64, epochs30, validation_split0.15, shuffleTrue, class_weightclass_weight, callbacks[early_stop] )batch_size64是折中梯度噪声足够在Adam下逃离局部平缓区又比32快一倍。如果你的GPU显存很小32也行但学习率要同步降到5e-4小batch天然有更大噪声学习率再高很容易震荡。loss用binary_crossentropy而不是categorical_crossentropy因为这里是二分类单输出最后一层是sigmoid。有人把输出改成2个神经元加softmax也能跑但二分类场景多此一举。这里注意validation_split0.15是在训练集内部切15%做验证和前面2.2节全局切分不冲突。全局先用80%做训练训练内部再切15%用于早停。class_weight只作用于训练分支不会作用到验证分支所以验证集上的loss是未加权的能公平反映模型真实表现。如果你发现加权后训练loss偏高但验证loss还行不用慌这是class_weight把少数类放大后的正常现象关键还是看验证集上的混淆矩阵。4.2 评估指标垃圾邮件场景下准确率会骗人准确率在垃圾邮件分类里是最容易骗人的指标。假设邮箱正常邮件占比90%模型无脑预测“正常”准确率也有90%看起来很好看但垃圾邮件一封没拦住。所以必须看混淆矩阵和precision、recall。垃圾邮件场景里优先看两个数垃圾邮件召回率表示有多少垃圾邮件被拦下正常邮件精确率间接反映误杀率。误杀一封正常邮件比漏放十封垃圾邮件更严重实际运营里用户投诉几次就会要求下线。from sklearn.metrics import classification_report, confusion_matrix y_pred_prob model.predict(x_test_pad) y_pred (y_pred_prob 0.5).astype(int32) print(confusion_matrix(y_test, y_pred)) print(classification_report( y_test, y_pred, target_names[正常邮件 (ham), 垃圾邮件 (spam)] ))看classification_report时如果spam的recall低于0.9说明有大量垃圾邮件漏过优先调class_weight而不是盲目加层。如果ham的precision低于0.95说明正常邮件被误杀得多可以先试把分类阈值从0.5提高到0.6看误杀率会不会降。阈值本质是业务参数不是模型参数调优方法放到第六章细讲。4.3 导出h5模型和tokenizer部署时缺一不可的两个文件训练完成别只保存权重。模型文件、tokenizer、max_len、vocab_size这四样必须打包否则换个环境加载模型后根本无法做预测。我见过至少三个同学只导出h5到演示时才发现新邮件根本转不了id。保存代码如下model.save(spam_cnn.h5) # 含结构、权重、优化器状态 import pickle with open(tokenizer.pkl, wb) as f: pickle.dump(tokenizer, f) import json config {max_len: max_len, vocab_size: vocab_size, threshold: 0.5} with open(config.json, w, encodingutf-8) as f: json.dump(config, f, ensure_asciiFalse, indent2)model.save保存的是Keras格式加载时用tf.keras.models.load_model即可。tokenizer.pkl里存了word_index和word_counts这是新邮件转id的唯一依据。如果不保存tokenizer而是换个Tokenizer重新fit新邮件里的词序完全不同embedding层下标直接错乱。加载模型时如果环境TensorFlow版本和训练时跨了大版本偶尔会报自定义对象错误这是正常的用tf.keras.models.load_model(spam_cnn.h5, compileFalse)绕过compileFalse表示只加载结构和权重不恢复优化器状态部署时完全够用。提示训练脚本和预测脚本请共用同一个预处理模块把clean_mail、tokenizer加载、pad_sequences封装成独立函数不要在两处各写一份。5. 邮件分类系统落地避坑5个典型翻车现场5.1 现象训练集准确率98%测试集只有75%这是毕业设计里最尴尬的假战绩多半不是模型不行而是数据泄漏。常见泄漏源有三个一是先对全量数据fit Tokenizer再切训练测试词汇表泄漏二是邮件语料按时间组织早期邮件在训练集、晚期在测试集垃圾邮件活动有季节性分布不同三是去重不彻底同一封营销邮件在训练集和测试集各出现一次模型等于背答案。解决方法是把顺序反过来先全局清洗再全局shuffle再按8:1:1切分。shuffle时固定随机种子比如直接train_test_split(X, y, test_size0.2, random_state42)论文里的指标才可复现。凡是fit类的操作包括Tokenizer、标准化、过采样都只能发生在切分之后且只覆盖训练集。5.2 现象中文邮件分词后长短悬殊max_len怎么设都不对中文邮件比英文更两极分化通知类邮件正文可能只有“您的包裹已签收”十个字垃圾邮件却动辄上千字堆砌关键词。设max_len100长邮件被截掉太多设max_len512短邮件padding后九成是0训练时间翻倍。我一般先画分词后长度直方图取95分位点同时统计被截断样本里垃圾邮件的占比。如果被截断的长邮件里垃圾邮件比例明显高于整体比例说明长正文是垃圾邮件的强特征这时不要简单截断可以改成“只保留前512词”或“取前200词加后200词拼接”的做法。后者对长垃圾邮件更有效因为垃圾邮件特征往往集中在开头和结尾中间是复制粘贴的杂文填充truncated [i for i, s in enumerate(train_sequences) if len(s) max_len] spam_rate y_train[truncated].mean() full_rate y_train.mean() print(f截断样本spam占比: {spam_rate:.2f}, 整体spam占比: {full_rate:.2f})如果截断样本中spam占比远高于整体就改用拼接策略如果差异不大直接截断不影响大局。5.3 现象正常营销邮件被大量误杀用户投诉激增前面2.1节说过标签定义没定清楚就会在这里爆雷。营销邮件和垃圾邮件的措辞高度重合“优惠”“免费”“限时”“领取”满天飞。标注时如果把订阅型营销邮件全打为spam模型学到的判别特征就是“促销词”而非“未订阅骚扰”于是正常订阅用户每天收到游戏规则介绍却进垃圾箱。解决思路有两条。一是把营销邮件单独标为一类做三分类正常、营销、垃圾后续把营销归入低优先级收件箱而不是垃圾箱。二是坚持二分类的话就只把“未订阅的、主动发送的商业推广、诈骗、带恶意链接”标为spam订阅型营销标为ham。上线前统计ham的precision低于0.95就调高阈值宁可漏拦几封垃圾邮件不要误杀正常邮件。5.4 现象predict时报shape错或者embedding下标越界训练时好好的部署时加载模型一predict就报错十有八九是预测管线没有复用训练管线的预处理对象。很多人写predict脚本时重新建Tokenizer并调用fit_on_texts再texts_to_sequences结果词序完全不同embedding层报index out of range还有人padding时没指定max_len输入长度和模型不一致。解决方法是把训练阶段所有对象全部保存预测阶段严格按相同顺序调用clean_mail、tokenizer.texts_to_sequences、pad_sequences(maxlenmax_len)、model.predict。一个最小预测函数如下def predict_email(text, model, tokenizer, max_len): cleaned clean_mail(text, langzh) # 必须和训练阶段同一函数 seq tokenizer.texts_to_sequences([cleaned]) pad pad_sequences(seq, maxlenmax_len, paddingpost, truncatingpost) prob model.predict(pad, verbose0)[0][0] return prob注意清洗函数也要一并复制到部署环境。如果用了jieba部署环境必须安装并加载同样的词典否则分词结果不一致预测效果直接衰减。5.5 现象模型文件快100MB加载慢web接口扛不住embedding层是体积大头。vocab_size200000、embed_dim128时光词向量权重就是200000乘128乘4字节约102MB加上其他层轻松超过120MB。解决方向有三个第一vocab_size砍到20000覆盖常见词后冷门词交给OOV模型体积降到约10MB第二embed_dim从128降到64体积再减半短文本任务精度损失通常不到0.5%第三用model.save_weights配合结构代码部署省掉优化器状态。对体积有硬要求的话可以在训练后单独做embedding剪枝但以毕业设计场景来说把vocab_size和embed_dim控制好就够了。经验数值vocab_size20000、embed_dim64、max_len200的模型h5文件约6到8MBCPU加载不到1秒部署到本地Flask或FastAPI毫无压力。6. 把模型做成一个预测命令阈值调优与一套常用的验证流程模型训好只是第一步真正有价值的是把“垃圾概率”接进业务决策。这里最推荐的做法不要把0.5当成铁律写一小段脚本扫描阈值找到让业务损失最小的点。业务损失可以简单定义成垃圾邮件漏拦一封记1分正常邮件误删一封记10分然后遍历0.3到0.9找最低分import numpy as np y_prob model.predict(x_test_pad).ravel() for threshold in np.arange(0.3, 0.91, 0.05): y_pred (y_prob threshold).astype(int) leak_spam ((y_test 1) (y_pred 0)).sum() # 垃圾邮件漏拦 block_ham ((y_test 0) (y_pred 1)).sum() # 正常邮件误杀 cost leak_spam * 1 block_ham * 10 print(f阈值{threshold:.2f}: 漏拦{leak_spam}, 误杀{block_ham}, 总代价{cost})阈值确定后写进config.json业务侧拿到模型概率先和阈值比较大于阈值才进垃圾箱。这个做法额外的好处是方便调参不需要重新训练模型就能适应不同严厉程度的邮件环境。另一个我一直保持的习惯跑CNN之前一定先跑一个简单的逻辑回归或朴素贝叶斯baseline。原因很直接——CNN在短文本上的提升有限如果baseline的F1已有95%CNN做到96%说明模型结构没问题如果baseline只有80%CNN做到96%那提升不一定是CNN带来的也可能是预处理和标签清洗的功劳。这个对照实验写进论文很加分能直接回答“CNN比传统方法好在哪”。最后说一个与模型无关的部署习惯上线时让模型输出“疑似垃圾”而不是直接删除邮件先进垃圾箱并保留15天后悔药路径。垃圾分类系统做得再好误杀概率也不会归零给用户一条找回路径比追求更高精度更实在。这一点在毕设答辩和真实项目里都很加分希望帮到你。本文还有配套的精品资源点击获取
返回列表