☰
CNN文本分类实战:Python构建垃圾邮件识别系统
2026/10/7 5:45:52 网站建设 项目流程

简介:这份基于Python卷积神经网络CNN的垃圾邮件分类系统毕业设计资料包,面向计算机相关专业毕业生和机器学习初学者,完整涵盖源码、预训练模型、说明文档与全部数据资料,可支撑课程设计、毕业设计或项目复现。共14个文件,以Python源文件(4个py)、编译缓存(5个pyc)、pickle与pkl格式的数据集/模型、Markdown说明文档及PDF报告为主,体积仅2.67MB,轻量且结构清晰,便于按需查阅和快速上手。已有342人学习下载。包内包含CNN模型定义、数据处理脚本、训练与主程序入口,以及已训练好的CNN模型和1000条邮件样本数据,并附有详细说明文档和PDF报告,能够帮助用户理解垃圾邮件识别流程、掌握模型训练与评估方法,并掌握从数据加载、特征提取到模型预测的完整链路。整体难度适中,代码经本地编译运行通过,评审分达95分以上,适合需要快速获得可运行毕业设计项目的读者,也便于答辩展示和二次开发。

1. 垃圾邮件分类不缺模板,缺的是能跑通的CNN落地盘

拿到「基于Python卷积神经网络CNN的垃圾邮件分类系统」这套资料,很多人的第一反应是“又是课设模板”。但实际跑一遍就会意识到,垃圾邮件二分类恰恰是最适合验证CNN文本能力的场景:数据量大、类别噪音可控、效果可量化,而且从切词到部署的链路短。这套系统解决的不只是“判断一封邮件是不是垃圾”,而是让你在有限算力下,完整走通一条从原始语料到可推理模型的CNN工程链路。它适合正在做毕业设计、需要交付源码和说明文档的学生,也适合想用深度学习文本分类练手、但不想一上来就卷Transformer的从业者。

2. CNN怎么读懂邮件文本:TextCNN架构与选型理由

2.1 为什么二分类还要卷积:n-gram特征提取与局部相关性

文本不是图像,但CNN处理文本的方式同样是“滑窗+聚合”。一封邮件经过切词后变成一个词索引序列,CNN在序列上做一维卷积,每个卷积核的本质是一个n-gram模式识别器。以经典TextCNN为例,卷积核宽度为3时,它只看当前词前后各一个词的组合;宽度为5时,它能看到更长局部搭配。垃圾邮件里的“免费”“中奖”“点击链接”这类强信号词,往往以固定搭配出现,卷积核正好捕捉这种局部相关性,不用像传统词袋模型那样事先枚举特征。

相比LSTM和Transformer,CNN在短文本分类上的优势是训练速度快、参数量小、对噪音容忍度高。邮件正文长度通常几百词,全局依赖不强,CNN完全够用。这个选型也符合毕业设计对可解释性的要求——每个卷积核对应一类局部模式,池化后向量的某个维度代表“这封邮件里有没有出现这类模式”,调试时能直观看出模型在关注什么。这就是为什么我不建议在这个规模的任务上一上来就换BERT,收益有限,但环境依赖和显存开销会大一个量级。

2.2 环境搭建与数据准备:版本、依赖和一个干净的跑通基准

这套系统基于Python实现,核心深度学习框架建议选TensorFlow 2.x或PyTorch,两者都能完成一维卷积建模。我一般倾向于TensorFlow 2.x,因为Keras高层API对卷积层和嵌入层的封装直观,写训练循环时心智负担小。做毕业设计时,环境锁版本是第一步,避免出现“今天能跑,明天库一升级就报错”的情况。

# 创建独立虚拟环境,避免和系统Python互相污染 python -m venv venv_spam source venv_spam/bin/activate # 核心依赖:TensorFlow 2.x、中文分词器、数据处理库 pip install tensorflow-cpu==2.13.* pip install jieba pip install pandas scikit-learn

版本锁定在2.13这个分支是因为它的API稳定性好,对CPU训练友好,且tf.keras内部接口没有大的破坏性变更。jieba负责中文分词,垃圾邮件数据集里中英文混杂时,先切词再做索引映射是标准做法。如果数据集是纯英文,jieba可以换成直接按空格切分,逻辑更简单。

数据准备这一步,重点是理清标签和正文两个字段。常见的公开邮件语料(如SpamAssassin公开集)格式是纯文本邮件原文,需要自己提取主题和正文;课设自带的“全部数据资料”里一般已经整理成CSV或TXT两列——label, text。拿到任何格式的数据,第一件事不是建模,而是写一个数据探查脚本,统计类别分布、文本长度分布和缺失值。

import pandas as pd df = pd.read_csv('spam_data.csv', encoding='utf-8') print(df['label'].value_counts()) print('正文长度分布:') print(df['text'].str.len().describe()) # 过滤空正文,标签统一为0/1 df = df.dropna(subset=['text']) df['label'] = df['label'].map({'ham': 0, 'spam': 1})

这段代码有两个作用:一是确认类别是否平衡,二是看正文长度分布来决定后面序列截断长度。如果发现垃圾邮件占比只有5%,后续训练时必须关注召回率,不能只看准确率。

2.3 词表与嵌入层:随机初始化与预训练向量怎么选

词表构建是把切词后的文本映射成整数ID。常见做法是只保留出现频次高于某个阈值的词,其余统一归为UNK。词表大小直接影响模型参数量和训练速度。垃圾邮件分类场景下,词表控制在1万到3万之间比较合适。低于1万会丢掉低频但强信号的词(比如某些变体拼写),高于3万则引入大量噪音词,且训练时梯度更新分散。

嵌入层有两种初始化方案:随机初始化,或加载预训练词向量。随机初始化意味着模型从零学习每个词的向量表示,在语料规模足够(几万封以上)时完全可行;预训练向量(如GloVe或中文词向量)能带来更好的冷启动效果,但需要额外处理词表对齐问题——语料里的词在预训练词表里找不到时,要么随机初始化,要么标为UNK。对这个任务,我一般先用随机初始化跑通基线,再对比预训练向量收益。多数情况下,垃圾邮件词表集中、语义直接,随机初始化加足够的训练轮次就能达到实用水平,不必在向量文件加载上浪费时间。

3. 从切词到训练:最小可运行的CNN邮件分类器

3.1 数据预处理:切词、编码与训练/验证集划分

预处理的完整链路是:清洗文本 → 切词 → 索引化 → 填充截断 → 划分数据集。清洗这一步容易被低估。邮件文本里的HTML标签、URL、邮箱地址、回复引文(>前缀的行)都是噪音。HTML标签会以“<a href=...”这种形式混进词表,URL会切出大量无意义片段,回复引文则会让同一封邮件在正负样本里同时出现——这是典型的数据泄露源。

import re import jieba from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences def clean_text(text): text = re.sub(r'<[^>]+>', ' ', text) # 去HTML text = re.sub(r'https?://\S+|www\.\S+', ' URL ', text) # URL替换为占位符 text = re.sub(r'\S+@\S+', ' EMAIL ', text) # 邮箱替换为占位符 text = re.sub(r'\s+', ' ', text) return text df['text_clean'] = df['text'].apply(clean_text) df['text_cut'] = df['text_clean'].apply(lambda x: ' '.join(jieba.cut(x))) tokenizer = Tokenizer(num_words=20000, oov_token='<UNK>', filters='') tokenizer.fit_on_texts(df['text_cut']) sequences = tokenizer.texts_to_sequences(df['text_cut']) MAX_LEN = 200 X = pad_sequences(sequences, maxlen=MAX_LEN, padding='post', truncating='post') y = df['label'].values

代码逻辑说明:clean_text里的正则替换不是简单删除,而是把URL统一替换成占位符“URL”,这样“点击URL”和“访问URL”会被模型识别为同一类模式,比直接删掉保留信息量。Tokenizer里的filters=''是关键——默认过滤器会去掉标点符号,但切词后文本只有空格和词,不需要再做过滤;oov_token='<UNK>'确保词表外的词有归属。MAX_LEN=200对邮件正文来说能覆盖约95%的样本——如果第2章统计显示中位长度在150词以内,200就是合理截断点。超过200词的部分直接截掉,因为CNN只看局部模式,长尾内容对分类贡献不大。

划分数据时要用分层采样,保证训练集和验证集中正负样本比例一致:

from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 )

stratify=y强制按标签比例划分,避免随机划分时验证集恰好没有垃圾邮件,导致评估指标失真。random_state=42固定随机种子,保证每次运行划分结果一致——这对后续调参复现很重要。

3.2 模型构建:嵌入、多尺度卷积、全局池化与分类头

TextCNN的标准结构是:嵌入层 → 三种尺寸的卷积核并行卷积 → 全局最大池化 → 拼接 → 全连接分类。三种卷积核宽度通常取2、3、4或3、4、5,分别对应二元、三元、四元词组模式。每个尺寸的卷积核数量(filter数)取128是经验和性能的折中,取256能提升一点效果但训练时间翻倍。

from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Embedding, Conv1D, GlobalMaxPooling1D, Concatenate, Dense, Dropout def build_text_cnn(vocab_size=20000, embed_dim=100, max_len=200, num_filters=128): inputs = Input(shape=(max_len,), dtype='int32') embedding = Embedding(vocab_size, embed_dim, mask_zero=False)(inputs) conv_blocks = [] for kernel_size in [2, 3, 4]: conv = Conv1D(filters=num_filters, kernel_size=kernel_size, activation='relu', padding='same')(embedding) pool = GlobalMaxPooling1D()(conv) conv_blocks.append(pool) concat = Concatenate()(conv_blocks) dropout = Dropout(0.5)(concat) outputs = Dense(1, activation='sigmoid')(dropout) model = Model(inputs, outputs) return model model = build_text_cnn() model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy']) model.summary()

各层的作用:嵌入层把词ID映射到100维稠密向量,Embedding的权重在训练中会更新,让垃圾邮件高频词和正常邮件高频词在向量空间自动分开。卷积层padding='same'保证卷积输出长度不变,避免因序列长度边界效应丢失首尾词信息。GlobalMaxPooling1D在时间维度上取最大值——对每个卷积核,它只保留“该模式在整个文本中响应最强的位置”作为特征值,这让模型对“垃圾特征出现在第10个词还是第190个词”不敏感,正是文本分类需要的平移不变性。Dropout(0.5)是防止过拟合的关键,TextCNN在训练集上很容易过拟合,不加Dropout的话验证集F1会明显低于训练集。

参数调整的边界在这里要说明:embed_dim=100对中小语料足够,升到300并不带来对等的效果提升,因为随机初始化的词向量在高维空间里更容易稀疏;num_filters=128是安全默认值,数据量小于1万条时降到64能降低过拟合风险。mask_zero=False是因为我们用pad_sequences填充的0值不需要被掩码跳过,让卷积核在填充位上算出0激活值,不会影响池化结果。

3.3 训练循环:超参数、早停与模型保存

训练环节最关键的三个设置:batch size、早停和模型保存。batch size取32是文本分类的标准值,小于16时梯度更新太频繁、训练不稳定,大于128时收敛变慢且显存占用增大。早停则直接决定最终交付的模型质量——不看验证集F1就手动stop epoch,是课设项目里最常见的翻车方式。

from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint early_stop = EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ) checkpoint = ModelCheckpoint( 'best_model.h5', monitor='val_f1', mode='max', save_best_only=True, verbose=1 ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=30, batch_size=32, callbacks=[early_stop, checkpoint] )

EarlyStopping的patience=5表示验证集loss连续5轮不下降就终止训练,restore_best_weights=True保证回到验证集最优的那一轮权重,而不是训练终止时可能已过拟合的最后一轮。ModelCheckpoint监控val_f1,这是因为accuracy在类别不平衡时毫无意义——如果验证集里80%是正常邮件,模型全预测“正常”也有80%准确率。mode='max'明确告诉回调F1越大越好。

训练完成后,必须用独立的测试集(或交叉验证)做最终评估,不能用验证集分数当交付结果。验证集参与过早停决策,分数天然偏高。常见做法是把初始数据按6:2:2分成训练、验证、测试,最后报告测试集上的精确率、召回率和F1。模型保存为h5文件后,推理时重新加载即可:

from tensorflow.keras.models import load_model loaded_model = load_model('best_model.h5') def predict_spam(text): cleaned = clean_text(text) cut = ' '.join(jieba.cut(cleaned)) seq = tokenizer.texts_to_sequences([cut]) padded = pad_sequences(seq, maxlen=MAX_LEN, padding='post', truncating='post') prob = loaded_model.predict(padded, verbose=0)[0][0] return prob sample = "恭喜您获得万元大奖,点击链接立即领取!" print(predict_spam(sample))

这个推理函数把预处理链路完整封装,输入原始文本直接输出风险概率。实际交付时,阈值不是固定0.5——如果垃圾邮件的代价很低(顶多进垃圾箱),可以设0.3提高召回;如果正常邮件被误杀代价高,就设0.7。这个阈值应该在说明文档里明确写出,并给出不同阈值下的混淆矩阵。

4. 避坑:TextCNN训练与部署中最常踩的5个坑

4.1 验证集F1虚高:切词前做了全局统计

现象:训练时验证集F1高达0.98,但保存的模型在实际邮件上表现一塌糊涂。

原因:在划分训练/验证集之前,就先用全量数据做了fit_on_texts构建词表。验证集里的垃圾邮件特有词已经出现在词表里,模型相当于早就“见过”验证集的词分布。这是文本分类里最常见的数据泄露。解决:先train_test_split切分原始文本,再在训练集上单独做fit_on_texts,验证集的词表外词自然落到<UNK>。

4.2 推理时模型“失灵”:训练和预测的预处理不一致

现象:模型评估分数正常,但上线后对明显垃圾的邮件输出概率接近0。

原因:训练时空格切分,预测时用jieba切词,词表对不上;或者训练时清洗了HTML,预测时没清洗,<a href>直接落到UNK。解决:把clean_text、切词、tokenizer.texts_to_sequences、pad_sequences封装成一个preprocess_pipeline()函数,训练和推理只用这一个入口,从根上消除不一致。

4.3 训练loss震荡不降:学习率与梯度爆炸的排查顺序

现象:loss在0.6到0.7之间反复横跳,Epoch 5到Epoch 20毫无下降趋势。

原因:embedding层参数多,Adam默认学习率0.001对这个任务偏高,导致嵌入向量更新步长过大,模型在最优解附近来回震荡。解决:把学习率降到0.0001到0.0003区间,或对embedding层设置较小的learning rate乘数(如embedding.trainable = True配合lr=0.0001)。如果loss直接跳到NaN,检查嵌入层权重是否有NaN值,必要时加梯度裁剪clipvalue=1.0。

4.4 类别不平衡:准确率99%但垃圾邮件一条拦不住

现象:训练集里97%是正常邮件,模型全预测正常,准确率报表很好看但没有任何实用价值。

原因:二分类交叉熵在极端类别不平衡下会偏向多数类,F1远低于准确率。解决:首选class_weight给少数类加权重。class_weight={0: 1.0, 1: 5.0}表示垃圾邮件的loss权重是正常邮件的5倍,让模型为“分错垃圾邮件”付出更大代价。更激进的做法是下采样多数类或上采样少数类,但邮件数据量通常足够,class_weight是最轻量方案。

4.5 中文邮件被切成“乱码词”:编码与分词器双重坑

现象:jieba.cut的结果混入大量单字和标点,邮件特征词被切碎,模型学不到有效模式。

原因:原始数据可能是GBK编码,pandas.read_csv用utf-8读取后出现乱码;或者分词前没去掉特殊符号,jieba把“【】”和内容拆开了。解决:读文件时明确指定编码,pd.read_csv(file, encoding='gbk', errors='ignore');分词前先做一轮re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', text),只保留中英文和数字,再交给jieba。

5. 参数怎么调:用一组对照实验理解CNN的边界

5.1 必调参数:卷积核数量、核宽、Dropout、序列长度

TextCNN对超参数不是极度敏感,但以下四个参数值得做对照实验,而不是拍脑袋定值。卷积核宽度组合,[2,3,4]和[3,4,5]的差异在短文本上可观察——邮件正文偏短则用前者,偏长则用后者。卷积核数量,128和256的差异通常在0.5% F1以内,但256的训练时间接近翻倍。Dropout从0.3调到0.5,对过拟合的抑制有明显差异,0.5是通用安全值。序列长度受语料长度分布约束,不是自由参数——选定一个覆盖95%样本的长度即可,过长只是浪费计算。

每组对照实验固定随机种子,只改一个变量。记录训练集F1和验证集F1的差距,如果差距超过5个百分点,说明过拟合严重,优先加Dropout或减卷积核数量,而不是盲目加数据。

5.2 用早停和F1分数判断“是不是该停了”

判断模型是否收敛,不看训练集表现,只看验证集F1是否还在上升。实际操作时画两条曲线——验证集loss和验证集F1——同时观察。F1平稳但loss仍在下降,说明模型在“变得更自信”但不是“变得更准”,此时早停触发是合理的。训练曲线抖动剧烈时,先从学习率入手,而不是调整模型结构。

另一个实用技巧:保存每个epoch结束时的模型,而不是只保存最后一次。我习惯在ModelCheckpoint里加save_freq='epoch'并保留最近三轮的权重文件,这样如果某一轮突然出现验证集F1暴涨(通常是数据顺序导致的偶然现象),还能回退到上一轮稳定的权重,不用重新训练。这就是“后悔药”机制,成本只是几个磁盘空间。

5.3 从模型到交付:说明文档、数据字典与复现路径

课设交付时,源代码之外最容易被扣分的是说明文档。一套实用的说明文档至少包含四个部分:环境版本清单(精确到tensorflow==2.13.0这种级别)、数据字典(label字段的取值含义、text字段的清洗规则)、复现步骤(从原始CSV到模型权重的完整命令序列)、以及模型评估报告(测试集上的混淆矩阵、精确率、召回率、F1)。这份文档本质上是让另一个人在不咨询你的情况下,能在30分钟内从零复现你的结果。

数据资料的整理同样有讲究。“全部数据资料”不应只有原始邮件和模型权重,还应该包含:词表文件(vocab.txt)、切分后的训练/验证/测试集(三个CSV)、以及每次实验的日志。这些文件让答辩老师能验证你的工作不是“只跑通了一个现成脚本”,而是完整经历了数据清洗、特征构建、模型调优的闭环。

6. 最后一个技巧:把“主题+正文”做成双通道输入

邮件数据天然包含两个字段:主题和正文。主题通常只有十几个词,正文可能几百词,混在一起输入时,主题的强信号会被正文的长序列稀释。更合理的做法是双通道输入:主题和正文各自走一条CNN通道,分别提取特征,最后拼接送入分类层。实现方式是在模型里定义两个输入:

from tensorflow.keras.layers import Input, Embedding, Conv1D, GlobalMaxPooling1D, Concatenate, Dense, Dropout subject_input = Input(shape=(20,), name='subject') body_input = Input(shape=(200,), name='body') # 共享同一份Embedding权重,减少参数量 shared_embed = Embedding(20000, 100) sub_emb = shared_embed(subject_input) body_emb = shared_embed(body_input) def conv_pool(emb): blocks = [] for k in [2, 3, 4]: conv = Conv1D(64, k, activation='relu', padding='same')(emb) pool = GlobalMaxPooling1D()(conv) blocks.append(pool) return Concatenate()(blocks) sub_feat = conv_pool(sub_emb) body_feat = conv_pool(body_emb) merged = Concatenate()([sub_feat, body_feat]) merged = Dropout(0.5)(merged) outputs = Dense(1, activation='sigmoid')(merged) model = Model(inputs=[subject_input, body_input], outputs=outputs)

这种结构在标记数据里效果提升明显,因为“主题含‘免费’且正文含‘点击链接’”这类组合模式,在单通道模型里需要卷积核自己去长序列里找共现,在双通道模型里则被结构性拆解了。主题通道的序列长度设为20,正文通道保持200,两个通道的卷积核数量降到64,总参数比单通道200维大输入还少一些。

做完双通道改进后,我养成了一个习惯:每次改完结构或数据,先跑一个10轮的小实验确认loss方向正确,再挂上完整训练。这个小实验花不到十分钟,但能避免大量白等一小时的无效训练。做这个课设最大的收获不是那个0.98的F1,而是意识到一套系统的价值在于别人能否复现你每一步的选择——参数为什么这样设、数据为什么这样切、阈值为什么定这个数。希望这篇笔记能帮你在答辩时讲清楚每一个问题,而不是只能说“它跑起来了”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询