十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Python CNN垃圾邮件分类实战:从数据清洗到阈值调优

Python CNN垃圾邮件分类实战:从数据清洗到阈值调优 简介这是一套面向计算机、人工智能及相关专业学生与开发者的Python CNN垃圾邮件分类系统完整项目源码可直接用于毕业设计、课程设计、作业提交或项目初期立项演示也适合希望入门深度学习文本分类的小白进阶学习。压缩包共14个文件约2.67MB包含4个py源码文件与5个pyc编译文件分别对应模型定义、数据处理、训练与主程序等核心模块另有2个pickle与1个pkl数据及模型文件、1份md说明文档和1份pdf报告结构清晰、便于按模块阅读。项目围绕CNN文本分类展开涵盖邮件内容与标签数据集、卷积网络建模、训练流程与模型保存等环节代码完整且经过测试可正常运行读者可据此理解垃圾邮件识别的整体实现思路并在此基础上修改扩展功能。目前已有58人学习关注遇到配置或运行问题还可远程交流答疑。1. 从一封钓鱼邮件说起Python CNN 垃圾邮件分类系统到底在做什么你邮箱里那封您的账户存在异常登录请点击链接验证的邮件正文里夹杂着乱码字符、奇怪的标点、还有几个刻意拼错的单词——这不是发件人粗心而是对抗规则引擎的常规操作。传统基于关键词黑名单的过滤器遇到这种变形文本基本就瞎了。Python CNN 垃圾邮件分类系统要解决的正是这类语义还在、表面特征被搅乱的判别问题把邮件正文当成一个字符或词序列用卷积神经网络去抓局部 n-gram 特征再堆叠池化层压缩成全局表示最后接全连接层做二分类。这套方案适合谁如果你正在做毕设、需要一个深度学习 文本分类的完整闭环或者你手头有一批标注好的邮件数据、想验证 CNN 在短文本上的实际表现那这个方向值得投入。它不依赖复杂的预训练模型一台带普通显卡的机器就能跑通全流程从数据清洗、词向量初始化、卷积核设计到推理部署每一环都能拆开讲清楚。下面我按自己实际搭过一遍的顺序把能复现的细节摊开。2. 数据管道与词向量初始化CNN 吃进去的到底是什么2.1 邮件文本清洗的四个必做动作原始邮件数据通常是一堆.eml文件或 CSV 里的text,label两列。直接丢给模型之前我一般会走四步去 HTML 标签、统一小写、剥离多余空白、保留标点但做归一化。注意垃圾邮件里标点密度本身就是强特征全删掉反而丢信息所以只把连续重复标点压成一个。import re import html def clean_email(raw: str) - str: # 1. 反转义 HTML 实体比如 amp; - text html.unescape(raw) # 2. 去掉 script/style 块和所有标签 text re.sub(r(script|style)[^]*.*?/\1, , text, flagsre.S | re.I) text re.sub(r[^], , text) # 3. 统一小写 text text.lower() # 4. 连续标点压缩保留单个 text re.sub(r([!?.,;:])\1{1,}, r\1, text) # 5. 空白归一 text re.sub(r\s, , text).strip() return text逻辑说明html.unescape处理邮件里常见的实体编码正则先删脚本块再删标签顺序不能反否则 script 内容会被当普通文本留下。参数上re.S让.匹配换行re.I忽略大小写。标点压缩那条正则里\1{1,}表示同一个标点重复至少两次才替换单个标点不动。2.2 词表构建与序列截断长度怎么定清洗完的文本要转成整数序列。我一般用torchtext或手写Vocab类按词频保留前 30000 个词其余映射到unk。序列长度取 200 是短邮件场景的常用值——覆盖 95% 以上样本的正文长度又不至于让卷积核感受野浪费在 padding 上。from collections import Counter def build_vocab(texts, max_size30000, min_freq2): counter Counter() for t in texts: counter.update(t.split()) # 按频率排序保留达到最小频次的词 words [w for w, c in counter.most_common(max_size) if c min_freq] vocab {w: i 2 for i, w in enumerate(words)} # 0 给 padding1 给 unk vocab[pad] 0 vocab[unk] 1 return vocab def encode(text, vocab, max_len200): ids [vocab.get(w, vocab[unk]) for w in text.split()][:max_len] # 不足补 0 ids [vocab[pad]] * (max_len - len(ids)) return ids参数说明max_size控制词表上限太大导致嵌入矩阵稀疏太小则unk比例飙升。min_freq2过滤只出现一次的词这些词在训练集里几乎学不到有效向量。max_len200是经验值你可以统计一下自己数据的分位数取 90% 或 95% 分位对应的长度。2.3 嵌入层随机初始化还是加载预训练如果数据量在几万条以上随机初始化嵌入层、让模型自己学通常够用。数据少于一万条时我建议加载glove.6B.100d或fasttext的英文词向量做初始化把词表里能对上的词向量填进去对不上的保持随机。这一步能明显加快收敛尤其是垃圾邮件里那些固定搭配click here、verify account本身就有较强的语义聚类。import numpy as np import torch.nn as nn def create_embedding(vocab, embed_dim100, pretrained_pathNone): weight np.random.normal(0, 0.1, (len(vocab), embed_dim)) weight[vocab[pad]] 0 # padding 向量固定为零 if pretrained_path: # 假设预训练文件每行是 word v1 v2 ... with open(pretrained_path, encodingutf-8) as f: for line in f: parts line.rstrip().split( ) if len(parts) ! embed_dim 1: continue w parts[0] if w in vocab: weight[vocab[w]] np.array(parts[1:], dtypefloat32) emb nn.Embedding.from_pretrained(torch.tensor(weight), freezeFalse) return embfreezeFalse表示嵌入层参与微调如果数据极少可以设True冻结住只训练后面的卷积层。pad向量置零是常规操作避免 padding 位置贡献梯度。3. 卷积核怎么摆把 CNN 文本分类的玄学拆成可调参数3.1 多尺寸卷积核并行的结构设计文本 CNN 的经典结构是 Kim 在 2014 年提出的用 3、4、5 三种窗口大小的卷积核各若干组分别捕捉三元、四元、五元词组特征然后各自做最大池化拼接后接全连接。这个设计的好处是不同尺寸核关注不同粒度的局部模式——free money 是二元click link now 是三元垃圾邮件里这些模式反复出现。import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_filters128, kernel_sizes(3,4,5), num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 每个尺寸一组卷积输出通道数 num_filters self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (k, embed_dim)) for k in kernel_sizes ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): # x: (batch, seq_len) emb self.embedding(x) # (B, L, D) emb emb.unsqueeze(1) # (B, 1, L, D) # 每个卷积后接 ReLU 和全局最大池化 pooled [] for conv in self.convs: c F.relu(conv(emb)) # (B, F, L-k1, 1) c c.squeeze(3) # (B, F, L-k1) c F.max_pool1d(c, c.size(2)) # (B, F, 1) pooled.append(c.squeeze(2)) out torch.cat(pooled, dim1) # (B, F*3) out self.dropout(out) return self.fc(out)逻辑说明nn.Conv2d的输入是(B, 1, L, D)卷积核高度为k、宽度等于嵌入维度D这样每个卷积核扫过的是连续k个词的完整向量。max_pool1d在时间步维度取最大值把变长序列压成定长向量。参数上num_filters128是常用起点kernel_sizes覆盖 3 到 5 能抓住大部分短语模式。3.2 三个必调参数学习率、dropout、卷积核数量学习率我一般从1e-3开始用 Adam 优化器如果 loss 震荡就降到5e-4。dropout 放在池化后、全连接前0.5是文本分类的常规值数据量小可以提到0.6。卷积核数量从64到256都有人用我的经验是数据量小于五万条时128足够再大收益递减数据量十万以上可以试256但要注意过拟合。from torch.optim import Adam model TextCNN(vocab_sizelen(vocab), embed_dim100, num_filters128) optimizer Adam(model.parameters(), lr1e-3, weight_decay1e-5) criterion nn.CrossEntropyLoss()weight_decay1e-5是轻量 L2 正则配合 dropout 一起压过拟合。如果验证集准确率比训练集低超过 5 个百分点先把 dropout 调大、再考虑减卷积核数量。3.3 训练循环里必须盯住的三个信号训练时不要只看 loss。我一般同时打印训练准确率、验证准确率和验证 loss。如果训练 loss 持续下降但验证 loss 在第三轮后开始上升就是过拟合的明确信号该早停或加正则了。另外垃圾邮件分类常遇到类别不平衡正常邮件远多于垃圾邮件这时候准确率会骗人要看垃圾邮件那一类的召回率。def train_epoch(model, loader, optimizer, criterion): model.train() total_loss, correct, total 0, 0, 0 for x, y in loader: optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() pred logits.argmax(dim1) correct (pred y).sum().item() total y.size(0) return total_loss / len(loader), correct / total每轮结束后在验证集上跑model.eval()加torch.no_grad()记录验证指标。如果垃圾邮件召回率低于 0.9优先调分类阈值或给垃圾邮件类加权而不是继续加网络深度。4. 避坑与排查我踩过的五个真实翻车点4.1 现象训练准确率 99%上线后垃圾邮件全漏原因数据泄露。清洗时先对整个数据集做了词表构建验证集和测试集的词也进了词表模型在训练时见过验证集的词汇分布。解决词表只能用训练集构建验证和测试集编码时遇到未登录词一律映射unk。这个坑我踩过一次改完后验证准确率从 99% 掉到 93%但线上表现反而稳了。4.2 现象loss 变成 nan训练直接崩原因学习率太大或者嵌入层加载预训练向量时出现了全零行导致梯度爆炸。解决先把学习率降到1e-4试一轮如果还崩检查预训练文件里是否有空行或维度不匹配的行加载时加try/except跳过异常行。另外nn.Embedding的padding_idx0要设对否则 padding 位置也会参与梯度更新。4.3 现象模型把带unsubscribe的邮件全判成垃圾原因训练集里unsubscribe几乎只出现在垃圾邮件中模型学到了这个虚假相关。解决检查数据标注是否把营销邮件和垃圾邮件混为一谈如果业务上营销邮件不算垃圾需要在标注阶段区分开或者在损失函数里对这类样本降权。更根本的办法是增加正常邮件中unsubscribe的样本量。4.4 现象推理速度慢单条邮件要 200ms原因每次推理都重新构建词表、加载模型。解决把词表和模型权重在服务启动时加载一次推理时只做编码和前向。另外max_len从 200 降到 100 能砍掉近一半计算量前提是确认 100 长度覆盖了绝大多数样本。4.5 现象换了一批新邮件数据准确率断崖下跌原因数据分布漂移。新数据的词汇、格式和训练集差异大比如从英文邮件换成中文邮件或者从纯文本换成带大量表格的 HTML。解决定期用新数据做增量训练或者至少监控推理时的unk比例超过 10% 就说明词表覆盖不足需要重新构建。5. 从能跑到好用阈值调优与增量更新的具体手法模型训练完只是起点。垃圾邮件分类有个特点误判正常邮件为垃圾的代价远高于漏掉一封垃圾邮件。所以推理时不要直接用argmax而是调分类阈值。具体做法是在验证集上画精确率-召回率曲线找到垃圾邮件召回率 0.95 以上时精确率还能接受的那个阈值。import numpy as np def find_threshold(model, val_loader, target_recall0.95): model.eval() all_probs, all_labels [], [] with torch.no_grad(): for x, y in val_loader: probs torch.softmax(model(x), dim1)[:, 1] # 垃圾邮件类概率 all_probs.extend(probs.tolist()) all_labels.extend(y.tolist()) all_probs np.array(all_probs) all_labels np.array(all_labels) best_thr, best_prec 0.5, 0 for thr in np.arange(0.1, 0.9, 0.01): pred (all_probs thr).astype(int) tp ((pred 1) (all_labels 1)).sum() fp ((pred 1) (all_labels 0)).sum() fn ((pred 0) (all_labels 1)).sum() recall tp / (tp fn 1e-8) prec tp / (tp fp 1e-8) if recall target_recall and prec best_prec: best_prec, best_thr prec, thr return best_thr, best_prec这段代码遍历 0.1 到 0.9 的阈值在满足目标召回率的前提下选精确率最高的那个。参数target_recall根据业务定一般 0.95 起步。找到阈值后写进配置文件推理时用prob threshold代替argmax。增量更新方面我习惯每积累 5000 条新标注数据就做一次微调加载已有权重用新数据加少量旧数据比如 1:1 混合跑 2 到 3 个 epoch学习率设为初始值的十分之一。这样既能适应新出现的垃圾邮件模式又不会把旧知识忘光。微调后重新跑一遍验证集确认指标没退化再上线。最后说个习惯我每次改完模型结构或参数都会把当次的配置、验证集指标、unk比例记在一个表格里哪怕只是调了 dropout。这个习惯帮我省过好几次改了哪里导致效果变差的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表