简介:这份毕业设计资源面向高校计算机相关专业学生与需要完成课程项目的开发者,围绕基于Python卷积神经网络CNN的垃圾邮件分类系统展开,帮助解决文本分类建模、模型训练与工程落地等实际问题。压缩包共14个文件,约2.67MB,包含4个py源码文件、5个pyc编译文件、2个pickle数据文件、1个pkl模型文件,以及1份md说明和1份pdf报告,覆盖数据读取、CNN模型定义、训练脚本与已训练模型等核心模块。资源中的源码均经过本地编译验证可运行,评审分达到95分以上,内容经助教老师审定,难度适中。已有342人学习下载,读者可据此快速理解邮件文本向量化、卷积特征提取与分类评估的完整流程,并借助说明文档与报告梳理项目结构、复现实验步骤,适合作为毕业设计参考或深度学习入门实践素材。
1. 垃圾邮件分类为什么值得用 CNN 做一遍:从词袋到卷积核的认知切换
邮箱里那堆“发票代开”“低息贷款”不是随机骚扰,它们有稳定的词序模式和局部搭配。传统做法是把邮件切成词袋,丢给朴素贝叶斯或 SVM,准确率能到 90% 上下,但一遇到“免费 领取 优惠 券”这种拆开看都正常、连起来才可疑的短语,词袋就抓瞎了。卷积神经网络 CNN 的价值恰好在这里:它用滑动窗口在词向量序列上扫,能捕捉“连续几个词一起出现”的局部特征,再通过池化把最强信号留下来。这套毕业设计标题里包含源码、模型、说明文档和全部数据资料,本质是让你用 Python 把“邮件文本 → 词向量矩阵 → 卷积提特征 → 分类”这条链路完整跑通。适合正在做 NLP 方向毕设、想找一个数据好找、模型不复杂、结果可解释的本科生,也适合想补一次端到端深度学习落地经验的开发者。下面按“数据怎么进、模型怎么搭、参数怎么调、坑在哪”的顺序拆开讲。
2. 数据准备与词向量化:把一封邮件变成 CNN 能吃的矩阵
2.1 垃圾邮件数据集长什么样,标签怎么对齐
常见做法是使用公开的 SMS Spam Collection 或 Enron-Spam 数据集,前者约 5574 条、后者约 3 万条,字段通常是label和text两列,label 取值ham或spam。如果你拿到的压缩包里数据是 CSV 或 TSV,先确认编码是 UTF-8,否则中文邮件会乱码。标签对齐这一步不能省:把ham映射为 0、spam映射为 1,后面模型输出层用 sigmoid 做二分类,损失函数用 binary crossentropy,这是最省心的组合。
import pandas as pd # 读取数据,sep 按实际文件调整,常见是逗号或制表符 df = pd.read_csv("spam.csv", encoding="latin-1", usecols=[0, 1]) df.columns = ["label", "text"] # 标签映射:ham->0, spam->1 df["label"] = df["label"].map({"ham": 0, "spam": 1}) # 去掉空文本和重复样本,重复样本会让验证集虚高 df = df.dropna(subset=["text"]).drop_duplicates(subset=["text"]) print(df["label"].value_counts())逻辑说明:usecols只取有用的两列,避免多余列干扰;drop_duplicates很关键,垃圾邮件数据集里重复文本不少,不去重会导致训练集和验证集泄漏,准确率虚高到 99% 但实际泛化很差。参数上,encoding要根据文件实际编码改,英文数据集常用latin-1,中文用utf-8。
2.2 分词、去停用词与序列截断长度怎么定
英文邮件按空格和标点切分即可,中文邮件需要 jieba 之类的分词器。去停用词要谨慎:free、win、cash这类词恰恰是垃圾邮件的强信号,不能当停用词删掉。真正该删的是the、is、and这种高频无信息词。序列长度建议取所有邮件词数的 95 分位数,常见在 50 到 100 之间。太短会截掉关键信息,太长会引入大量 padding 拖慢训练。
import numpy as np from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences MAX_WORDS = 10000 # 词表上限,覆盖高频词即可 MAX_LEN = 80 # 序列长度,按数据 95 分位调整 tokenizer = Tokenizer(num_words=MAX_WORDS, oov_token="<OOV>") tokenizer.fit_on_texts(df["text"]) sequences = tokenizer.texts_to_sequences(df["text"]) # 统计长度分布,确认 MAX_LEN 是否合理 lengths = [len(s) for s in sequences] print("95分位长度:", np.percentile(lengths, 95)) X = pad_sequences(sequences, maxlen=MAX_LEN, padding="post", truncating="post") y = df["label"].values逻辑说明:oov_token处理词表外词,避免预测时遇到新词直接报错;padding="post"在句尾补零,配合后面的卷积层更自然。参数MAX_WORDS设 10000 是经验值,词表再大低频词也学不到有效权重,反而增加嵌入层参数量。
2.3 嵌入层:用预训练词向量还是从头训
数据量小于 1 万条时,从头训嵌入层容易过拟合,建议加载 GloVe 或 word2vec 预训练向量,把embedding_matrix传给Embedding层的weights参数,并设trainable=False先冻结。数据量超过 5 万条时可以从头训,让模型自己学领域相关的词表示。这个选择直接决定模型收敛速度和最终 F1,是选型阶段最该想清楚的一步。
# 假设已加载 GloVe 词向量到 embeddings_index 字典 EMBED_DIM = 100 embedding_matrix = np.zeros((MAX_WORDS, EMBED_DIM)) for word, i in tokenizer.word_index.items(): if i >= MAX_WORDS: continue vec = embeddings_index.get(word) if vec is not None: embedding_matrix[i] = vec逻辑说明:词表索引超过MAX_WORDS的直接跳过,保证矩阵行数和嵌入层输出维度一致。没命中预训练向量的词保持全零,训练中会慢慢学到值。这一步做完就可以进入模型搭建。
3. 用 Keras 搭一个能跑通的 TextCNN:结构、参数与训练循环
3.1 TextCNN 的三层核心结构为什么这样设计
TextCNN 的结构不复杂:嵌入层输出(batch, seq_len, embed_dim)的矩阵,然后并行走三个卷积核,尺寸分别是 2、3、4,每个尺寸 128 个滤波器。为什么用多个尺寸?因为 2 元组捕捉“免费 领取”这种双词搭配,3 元组捕捉“点击 链接 领取”,4 元组捕捉更长的固定话术。每个卷积后面接全局最大池化,把每个滤波器的最强响应取出来,最后拼接成一个 384 维向量,过 dropout 再进全连接层输出一个概率值。
from tensorflow.keras import layers, models def build_textcnn(vocab_size, embed_dim, embed_matrix, max_len): inputs = layers.Input(shape=(max_len,)) x = layers.Embedding(vocab_size, embed_dim, weights=[embed_matrix], trainable=False)(inputs) # 三种卷积核并行提取不同长度的 n-gram 特征 convs = [] for kernel_size in [2, 3, 4]: c = layers.Conv1D(128, kernel_size, activation="relu")(x) c = layers.GlobalMaxPooling1D()(c) convs.append(c) x = layers.Concatenate()(convs) x = layers.Dropout(0.5)(x) x = layers.Dense(64, activation="relu")(x) outputs = layers.Dense(1, activation="sigmoid")(x) model = models.Model(inputs, outputs) return model model = build_textcnn(MAX_WORDS, EMBED_DIM, embedding_matrix, MAX_LEN) model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"]) model.summary()逻辑说明:GlobalMaxPooling1D而不是普通池化,是因为文本分类只关心“有没有出现强特征”,不关心出现在哪个位置。Dropout(0.5)放在拼接后,抑制全连接层过拟合。参数上,滤波器数量 128 是常见起点,数据量小可以降到 64,数据量大可以升到 256。
3.2 训练循环、早停与类别不平衡处理
垃圾邮件数据通常 ham 多 spam 少,比例可能 5:1 甚至 10:1。直接训练模型会偏向预测 ham,召回率低。两种处理方式:一是class_weight给 spam 更高权重,二是过采样 spam 到和 ham 相当。推荐第一种,改动小且不引入重复样本。
from sklearn.utils import class_weight from tensorflow.keras.callbacks import EarlyStopping # 计算类别权重 weights = class_weight.compute_class_weight( class_weight="balanced", classes=np.unique(y), y=y ) class_weight_dict = {0: weights[0], 1: weights[1]} early_stop = EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True) history = model.fit( X, y, validation_split=0.2, epochs=20, batch_size=64, class_weight=class_weight_dict, callbacks=[early_stop] )逻辑说明:class_weight="balanced"自动按类别频率反比给权重,spam 少就权重大,模型会更关注 spam 的错分。EarlyStopping的patience=3表示验证损失连续 3 轮不降就停,restore_best_weights回滚到最优轮次,避免最后一轮过拟合。参数batch_size=64在几千条数据上比较稳,太大收敛慢,太小梯度噪声大。
3.3 评估指标不能只看准确率
垃圾邮件分类里,把正常邮件误判为垃圾(假阳性)比漏掉垃圾邮件(假阴性)更严重,因为用户可能错过重要邮件。所以评估要看 precision、recall 和 F1,尤其是 spam 类的 recall。混淆矩阵能直观看出两类错分情况。
from sklearn.metrics import classification_report, confusion_matrix y_pred = (model.predict(X) > 0.5).astype(int) print(confusion_matrix(y, y_pred)) print(classification_report(y, y_pred, target_names=["ham", "spam"]))逻辑说明:阈值 0.5 是默认值,如果更在意 spam 召回,可以降到 0.3 到 0.4,代价是假阳性上升。这个阈值要在验证集上调,不能拍脑袋定。classification_report会给出每类的 precision、recall、F1,重点看 spam 那一行。
4. 调参与排错:TextCNN 在垃圾邮件任务上的避坑清单
4.1 避坑一:验证集准确率 99% 但线上预测全错
现象:训练时验证准确率冲到 99%,拿几条新邮件测试却几乎全判成 ham。原因:数据去重没做,训练集和验证集有重复样本,模型只是记住了样本而不是学到模式。解决:在划分训练验证集之前先drop_duplicates,并且用train_test_split时设stratify=y保证两类比例一致。更严格的做法是按邮件来源或时间划分,避免同源邮件同时出现在训练和验证集。
4.2 避坑二:嵌入层可训练导致过拟合
现象:训练损失持续下降,验证损失从第 3 轮开始上升,F1 卡在 0.85 上不去。原因:数据量小的时候嵌入层参数量远大于样本数,模型把词向量调得只适合训练集。解决:把Embedding层的trainable设为False,冻结预训练向量;如果效果不够再解冻最后几层做微调。同时把 dropout 从 0.3 提到 0.5,加 L2 正则。
4.3 避坑三:序列截断把关键信息切掉了
现象:模型对长邮件判断特别差,短邮件正常。原因:MAX_LEN设得太小,比如设成 30,而垃圾邮件往往话术很长,关键短语在句尾被截掉。解决:先统计序列长度分布,取 95 分位作为MAX_LEN。如果长邮件确实重要,可以取 99 分位,但要注意显存和训练时间。另一个技巧是truncating="pre"保留句尾,因为很多垃圾邮件的行动号召在最后。
4.4 避坑四:中文邮件没做分词直接按字输入
现象:中文数据集上准确率只有 70% 左右,明显低于英文。原因:中文没有空格,按字符切分虽然能跑,但卷积核学不到词级别的搭配。解决:用 jieba 分词后再进 Tokenizer,或者直接用字符级加更大的卷积核。分词后记得把MAX_WORDS调大,中文词表比英文大不少。
4.5 避坑五:预测时 tokenizer 和训练时不一致
现象:模型保存后重新加载,预测结果和训练时对不上。原因:只保存了模型权重,没保存 tokenizer 的词表,重新加载时 tokenizer 重新 fit 导致索引错位。解决:用pickle把 tokenizer 一起存下来,预测时加载同一个 tokenizer 做texts_to_sequences。或者用tf.keras.models.save_model保存完整模型,但 tokenizer 仍需单独处理。
import pickle # 保存 with open("tokenizer.pkl", "wb") as f: pickle.dump(tokenizer, f) model.save("textcnn_spam.h5") # 加载预测 with open("tokenizer.pkl", "rb") as f: tokenizer = pickle.load(f) model = tf.keras.models.load_model("textcnn_spam.h5") seq = tokenizer.texts_to_sequences(["free money click now"]) pad = pad_sequences(seq, maxlen=MAX_LEN, padding="post") print(model.predict(pad))逻辑说明:tokenizer 保存的是词到索引的映射,必须和模型权重配套使用。pad_sequences的参数要和训练时完全一致,包括maxlen、padding、truncating,任何一项不同都会导致输入分布偏移。
5. 从能跑到好用:模型固化、阈值调优与一个可复现的验证习惯
模型训练完只是起点,真正决定这套系统能不能用的,是推理阶段的稳定性和阈值选择。我一般会把验证集上的预测概率画出来,看 ham 和 spam 的概率分布有没有重叠区。如果重叠少,0.5 阈值就够;如果重叠多,就要在 precision 和 recall 之间做取舍。具体做法是遍历 0.1 到 0.9 的阈值,算每个阈值下的 F1,取最高的那个。
from sklearn.metrics import f1_score probs = model.predict(X_val).ravel() best_thr, best_f1 = 0.5, 0 for thr in np.arange(0.1, 0.9, 0.05): preds = (probs > thr).astype(int) f1 = f1_score(y_val, preds) if f1 > best_f1: best_f1, best_thr = f1, thr print(f"最佳阈值 {best_thr:.2f}, F1 {best_f1:.4f}")这段代码的逻辑很直白:在验证集上找让 F1 最大的阈值,而不是默认 0.5。参数np.arange(0.1, 0.9, 0.05)的步长 0.05 够用,再细意义不大。注意这个阈值要在独立的验证集上调,不能拿训练集或测试集调,否则又是另一种泄漏。
另一个容易被忽略的点是模型固化后的输入校验。线上来的邮件可能包含大量 HTML 标签、URL、特殊符号,训练时如果没做清洗,推理时就会遇到大量 OOV。我习惯在预处理里加一步正则清洗:去掉 HTML 标签、把 URL 替换成统一占位符、把连续数字替换成<NUM>。这一步在训练和推理时必须用同一个函数,不能训练时清、推理时不清。
import re def clean_text(text): text = re.sub(r"<[^>]+>", " ", text) # 去 HTML 标签 text = re.sub(r"http\S+|www\.\S+", " <URL> ", text) # URL 占位 text = re.sub(r"\d+", " <NUM> ", text) # 数字占位 text = re.sub(r"\s+", " ", text).strip() return text.lower()逻辑说明:URL 和数字占位是为了减少词表膨胀,同时保留“这里有链接”“这里有数字”的信号。lower()统一大小写,避免Free和free被当成两个词。这个函数要同时用在训练数据预处理和推理输入上,建议单独放一个模块里,两边 import 同一个函数。
最后说一个我踩过的坑:有次把模型和 tokenizer 都保存了,但忘了保存MAX_LEN和MAX_WORDS这两个常量,换台机器推理时用了默认值,结果输入序列长度对不上,模型直接报维度错误。后来我养成的习惯是把所有预处理参数写进一个config.json,和模型文件放一起,加载时先读配置再建 tokenizer 和 padding。这个习惯看起来笨,但省了无数次“明明训练时好好的”的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取