ARTICLE DETAIL

资讯详情

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

CNN文本分类实战:垃圾邮件识别模型的完整Python实现路线

CNN文本分类实战:垃圾邮件识别模型的完整Python实现路线 简介基于CNN的中文垃圾邮件分类系统Python源码与项目说明面向自然语言处理初学者、深度学习实践者及需要课程设计或毕业设计的学生。系统原始数据包含64620封中文邮件其中垃圾邮件42854封考虑GPU显存限制随机抽取1000封用于训练与测试垃圾邮件659封数据均存为pickle格式方便直接加载和二次实验。资源共14个文件包体2.67MB含4个Python源文件主程序、模型构建、数据处理、训练、5个pyc缓存、2个pickle数据、1个训练好的模型文件、1份PDF说明及1份README文档模块清晰便于按环节阅读与复现。目前已有136人学习下载可依托完整代码、模型、数据与说明文档快速掌握基于CNN的文本分类完整流程适合作为高分项目参考。1. 垃圾邮件每天都在涨CNN 分类是性价比最高的自动化方案邮件服务器每天要拦下几十万封垃圾邮件规则库跟不上变体速度贝叶斯又对长文本和大词表敏感。我平时最常用的落地方案是把垃圾邮件分类做成一个基于 CNN 卷积神经网络的短文本分类任务邮件正文先转成词索引序列再由 Embedding 层映射成词向量矩阵多个不同宽度的卷积核在序列上滑动抓取 n-gram 局部特征最后经过全局池化和全连接层输出 spam/ham 二分类。这套基于 CNN 的垃圾邮件分类 Python 源码加 PDF 项目说明路线核心不是让你背模型而是把「清洗→分词→词表→序列→CNN→评估」整条链路跑通。对课程设计、毕业设计和刚入门的 Python 开发者来说它能在普通笔记本上完成训练、评估与预测每一步都可调试、可复现不需要 GPU 也能出结果。2. 为什么文本分类选 CNN卷积核宽度、词嵌入与同 RNN 的取舍CNN 处理文本的方式是把文本当成一维信号输入是词索引序列经 Embedding 得到的词向量矩阵卷积核沿句长方向滑动提取连续词窗口的局部模式。这和图像 CNN 在通道维度上做二维卷积思路一致只是把图像的高度换成了句子长度。真正决定分类效果的不是卷积本身的数学形式而是你怎样设计「词窗口大小」和「特征聚合方式」。下面把这几个设计点拆开讲顺便说清为什么在垃圾邮件这种中等长度文本上CNN 往往比 RNN 和 Transformer 更省心。2.1 卷积核宽度就是在选 n-gram对垃圾邮件来说判别信号经常是局部短语比如“免费领取”“点击链接”“账户异常”“中奖通知”。CNN 的每一个卷积核每次只看固定数量的相邻词这个窗口大小就是 kernel_size。设置 3 时模型抓到三元组级别的局部搭配设置 5 时抓到更长一点的短语模式设置 7 以上则倾向于捕捉整句级别的语义块。一个常见做法是让多个不同宽度的卷积核并行而不是只用一个固定宽度。这样做可以让模型同时看到短搭配和长搭配对“免费 领取 优惠券”和“您的账户 存在 异常 请立即 点击链接”这类不同长度的特征都会响应。代码里通常写成这样import tensorflow as tf from tensorflow.keras.layers import Conv1D, GlobalMaxPooling1D, Input, Concatenate # 假设 embed_out 是 Embedding 层的输出shape 为 (batch, max_len, embedding_dim) embed_out Input(shape(max_len, embedding_dim)) conv3 Conv1D(filters64, kernel_size3, paddingsame, activationrelu)(embed_out) conv5 Conv1D(filters64, kernel_size5, paddingsame, activationrelu)(embed_out) pool3 GlobalMaxPooling1D()(conv3) # shape: (batch, 64) pool5 GlobalMaxPooling1D()(conv5) # shape: (batch, 64) merged Concatenate()([pool3, pool5])这里的filters64表示每种宽度用 64 个卷积核相当于生成 64 种不同的局部特征paddingsame让卷积输出长度和输入一致避免首尾词因为窗口滑不到而丢失GlobalMaxPooling1D对每个特征图取最大值相当于把“序列里最强烈的信号”抽出来只保留该特征是否出现以及出现强度不再关心它出现在邮件开头还是结尾。对垃圾邮件识别来说这种性质很合适因为“免费字样出现在第 10 行还是第 30 行”对判断结果影响不大。2.2 词嵌入层随机初始化还是加载预训练向量词嵌入是把离散词索引映射成稠密向量的层也是 CNN 文本分类里参数量最大的一块。常见做法是先随机初始化 Embedding 权重训练时随模型一起更新如果训练数据足够多模型自己就能学出“免费”“优惠”“点击”这些词在向量空间里的相近位置。如果你的语料只有几千封邮件随机初始化容易欠拟合这时可以加载预训练词向量作为初始值比如 GloVe 或者中文维基语料训练好的词向量把trainableTrue留出来再做微调。在实际课程项目里我一般先用随机初始化跑通再看验证集表现决定要不要换预训练向量。这样做的原因是加载预训练向量需要额外的词表对齐步骤如果训练集里很多词在预训练词表里不存在初始化后反而会引入大量随机向量对结果未必有帮助。设置 Embedding 维度时不用盲目求大。短文本二分类任务里embedding_dim50或100已经够用300 维只有在数据量很大、语义区分很细时才有明显收益而且会拖慢训练并增加过拟合风险。2.3 池化为什么选全局最大池化而不选平均池化CNN 卷积层输出的是一组特征序列每个位置代表“该卷积核在当前窗口上的响应强度”。接下来必须做一个降维操作。全局最大池化取的是每个特征图的最大值它回答的问题是“这个模式在整封邮件里是否出现过”全局平均池化则回答“这个模式平均响应有多强”。对垃圾邮件来说一封邮件里只要出现一次“紧急转账”就足够判定为可疑这个信号被淹没在平均池化里反而是损失。所以文本分类里全局最大池化是默认选择平均池化一般只在需要保留全局分布特征时才用。加上池化后模型不会因为邮件长短不一而导致全连接层输入维度变化这也是 CNN 比纯全连接网络更适合变长文本的原因。2.4 CNN 与 RNN、Transformer 在邮件场景的取舍很多入门者会纠结为什么不用 LSTM 或 Transformer。我做过同数据对比结论是在垃圾邮件这类中短文本上CNN 训练速度快约一个数量级参数量更小对局部关键词的响应更直接而且不容易因为长距离依赖建模不足而崩。RNN 的优势是捕捉顺序依赖但对垃圾邮件来说“把词按顺序读一遍”带来的信息增量有限反而容易出现梯度消失和训练不稳定。Transformer 在长文本上表现更好但需要更大数据、更精细的调参对小语料来说容易过拟合。下面这组对比是我做选型时常用的判断依据模型训练速度长距离依赖小数据表现适合邮件场景的原因CNN快弱稳局部 n-gram 特征直接、参数量小RNN/LSTM慢中一般适合句子级顺序建模但训练久Transformer中强差需要大数据和精细学习率策略如果你做的是一门课程项目或毕业设计CNN 的另一个优势是容易解释把卷积核输入权重可视化后能直接看到哪些词窗口对“垃圾邮件”判断贡献最大写到项目文档里也更有说服力。2.5 一个最小可跑的 CNN 文本分类骨架把以上几点收敛成一个骨架模型也就是多宽度卷积加全局池化加全连接。下面这个结构是垃圾邮件分类最常用的模板后面章节的训练代码会围绕它展开from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Embedding, Conv1D, GlobalMaxPooling1D, Dense, Dropout, Concatenate model_input Input(shape(max_len,)) # 每条样本都是定长整数序列 x Embedding(input_dimvocab_size, output_dimembedding_dim, input_lengthmax_len, trainableTrue)(model_input) branches [] for k in [3, 4, 5]: conv Conv1D(filters64, kernel_sizek, paddingsame, activationrelu)(x) pool GlobalMaxPooling1D()(conv) branches.append(pool) merged Concatenate()(branches) if len(branches) 1 else branches[0] merged Dense(64, activationrelu)(merged) merged Dropout(0.5)(merged) output Dense(1, activationsigmoid)(merged) model Model(model_input, output) model.summary()这段代码里比较容易被忽略的是Embedding的input_length。如果不写模型也能接受变长序列但后面Conv1D输出的时间步长度会随输入变化接Dense层时反而不方便。固定成max_len后训练和推理时的输入形状完全一致不容易在保存模型后踩到形状不匹配的坑。3. 从原始邮件到训练样本数据清洗、分词与序列填充模型结构只是整个系统的一小半垃圾邮件分类项目里真正决定分数的是数据准备。很多源码包跑不通问题不在模型而在原始邮件文本没有清洗干净或者分词和序列化环节的边界处理不规范。这一章按我常用的处理顺序写一遍可以直接对照着改。3.1 用哪些开源邮件数据集训练数据常用 SpamAssassin 公开邮件语料和 Enron 邮件数据集英文场景下这两个最稳。中文场景没有特别标准的公开垃圾邮件集常见做法是从 UCI 的 SMS Spam Collection 或自建标注邮件中整理。使用这些数据时要注意两点一是邮件正文里可能包含发件人、IP、时间等敏感信息课程项目里最好先去重再脱敏不要在文档里贴原始邮件原文二是公开数据集的正负样本比例不同下载后先做一次类别统计避免 Spam 和 Ham 数量差距过大影响训练。我一般会把数据统一整理成两列的 CSV一列是label取值为spam或ham另一列是text存放邮件正文。这样后面用 pandas 读取即可不需要处理复杂邮件格式。import pandas as pd df pd.read_csv(mail_data.csv, encodingutf-8) print(df[label].value_counts()) df[label] df[label].map({spam: 1, ham: 0}) print(df.head())这里把spam映射成 1、ham映射成 0是为了配合二分类交叉熵损失。打印类别分布是为了确认正负样本比例如果出现 90% 都是ham的情况后面训练时必须用分层采样和class_weight否则模型会学成“全预测 ham”也能拿到虚高的准确率。3.2 正则清洗邮件正文邮件文本和普通网页文本不一样里面常混着 HTML 标签、URL、邮件头、引号、多余空白和转义字符。以下清洗流程是我每次都会做的固定动作import re import html def clean_mail(text): text html.unescape(text) text re.sub(r[^], , text) # 去 HTML 标签 text re.sub(rhttp\S|https\S, , text) # 去 URL text re.sub(r[^a-zA-Z\u4e00-\u9fa5], , text) # 只保留英文、中文 text re.sub(r\s, , text) return text.strip().lower() df[text] df[text].apply(clean_mail)这里的html.unescape会把amp;这类实体转回正常字符否则“ATT”会被清洗成“AT T”造成无效分词。正则去 URL 是因为垃圾邮件里的链接域名经常伪装保留具体链接会让模型记住某个特定域名而不是学会“出现可疑链接”这个通用信号。保留英文字母、中文和空格是为了给分词器一个干净输入如果你用英文数据集中文范围\u4e00-\u9fa5可以直接去掉。清洗的粒度需要根据数据集调整。如果你做的是中文邮件分类上面这段正则已经够用如果做英文还可以额外做词干提取但要注意“claim”和“claimed”合并之后可能把不同时态的语义也合并掉对垃圾邮件识别来说收益不稳定。3.3 分词与停用词中文数据必须分词英文数据可以按空白切分也可以使用 nltk 的分词器。中文分词最常用的是 jieba因为它安装简单、词典覆盖度高并且在邮件这种短文本上切分速度很快。注意不要把所有标点直接吞掉后再分词而是先按上一节的清洗规则处理再用分词器切分。停用词表的选择要克制。像“的”“了”“是”这类高频词去掉确实能降噪但“不”“没有”“拒绝”这类否定词和“免费”一样重要不能进停用词表。我见过有同学把 nltk 官方停用词表直接套在中文数据上结果“不要”被拆成“不”和“要”后“要”被当成停用词删掉最后模型完全看不出“不要免费领取”是否定句。建议只过滤长度小于 1 的词和空格先不引入完整停用词表跑出基线后再谨慎添加。3.4 序列化与填充Tokenizer pad_sequences模型不能直接吃文本需要先把词映射成整数索引再统一成等长序列。TensorFlow 自带的 Tokenizer 在这个环节最省事from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences max_words 20000 max_len 200 tokenizer Tokenizer(num_wordsmax_words, oov_tokenOOV) tokenizer.fit_on_texts(df[text]) sequences tokenizer.texts_to_sequences(df[text]) X pad_sequences(sequences, maxlenmax_len, paddingpost, truncatingpost) y df[label].values print(样本形状:, X.shape)这里num_words20000表示词表只保留频率最高的 20000 个词低频词一律映射到OOV。邮件分类数据量通常不大20000 已经足够设置过大会让 Embedding 层参数膨胀还会增加过拟合风险。oov_tokenOOV是必须加的否则训练时没见过的词在预测阶段会被直接丢弃导致索引错位。max_len200是经过对比后的折中值SPAMAssassin 邮件平均长度约 150 到 300 词取 200 能在保信息和控制计算量之间平衡如果数据偏长可以先统计sequences长度的 95 分位再定。paddingpost和truncatingpost的组合也很重要。垃圾邮件往往在结尾放“立即点击”或“取消订阅”使用truncatingpost保留下开头部分如果关键信息和长度分布符合同一个套路这个截断策略效果最好。如果你的邮件数据集里确认经常在末尾出现关键词就把truncating改成pre做一轮对照测试再定。4. 构建与训练 CNN 垃圾邮件分类器损失函数、网络结构与模型保存数据准备好之后进入训练环节。这一章把模型定义、编译、训练回调和预测代码串成一条完整流程。很多课程项目到这里会踩的两个坑一是过拟合但不知道在哪看二是模型保存后预测时找不到 tokenizer。下面按完整流程过一遍。4.1 完整模型搭建代码沿用第 2 章的多宽度卷积结构加入 BatchNormalization 和 Dropout 来稳定训练和缓解过拟合from tensorflow.keras.layers import BatchNormalization, Dense, Dropout, Embedding from tensorflow.keras.models import Model def build_cnn_model(vocab_size, embedding_dim, max_len): model_input Input(shape(max_len,)) x Embedding(input_dimvocab_size, output_dimembedding_dim, input_lengthmax_len, trainableTrue)(model_input) branches [] for k in [3, 4, 5]: conv Conv1D(filters64, kernel_sizek, paddingsame, activationrelu)(x) pool GlobalMaxPooling1D()(conv) branches.append(pool) if len(branches) 1: merged branches[0] else: merged Concatenate()(branches) merged Dense(64, activationrelu)(merged) merged BatchNormalization()(merged) merged Dropout(0.5)(merged) output Dense(1, activationsigmoid)(merged) model Model(model_input, output) return model model build_cnn_model(vocab_sizemax_words, embedding_dim100, max_lenmax_len)BatchNormalization放在 Dense 激活之后、Dropout 之前是比较常见的写法它会把上一层输出重新归一化避免训练后期梯度更新幅度过大。Dropout(0.5)的作用是随机丢弃一半神经元连接强制模型不要过度依赖某几个强特征词。对几千条样本的邮件数据集这个强度的 Dropout 是必要的如果样本超过几万条可以降到 0.3。4.2 编译参数与训练流程损失函数用binary_crossentropy是因为这是二分类任务输出层只有一个神经元配合 sigmoid 激活。优化器选 Adam因为它对学习率的敏感度低默认learning_rate0.001在大多数文本分类任务上都能直接跑出不错的结果。训练环节最值得加的是两个回调EarlyStopping 和 ModelCheckpoint。前者在验证集指标不再提升时提前停止后者只保存验证集上最好的模型权重避免训练到最后反而把最优参数覆盖掉。from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint model.compile(optimizerAdam(learning_rate1e-3), lossbinary_crossentropy, metrics[accuracy]) callbacks [ EarlyStopping(monitorval_loss, patience3, restore_best_weightsTrue), ModelCheckpoint(best_mail_cnn.keras, monitorval_accuracy, save_best_onlyTrue, verbose1) ] history model.fit( X_train, y_train, validation_data(X_val, y_val), batch_size64, epochs20, callbackscallbacks, verbose1 )patience3表示验证集 loss 连续 3 个 epoch 不下降就停止训练。这个数字不要设太大课程项目里数据量小训练到第 5 到 8 个 epoch 基本就到平台期耐心值设大只会浪费时间。batch_size64在 8000 条左右的数据上是稳的如果你的显存或内存紧张可以降到 32反过来如果数据上十万条可以提到 128。训练完成后用history.history画 loss 曲线和 accuracy 曲线这是 PDF 项目说明里最关键的图。正常过程是训练 loss 持续下降验证 loss 先降后平稳如果训练 loss 降但验证 loss 回升说明过拟合开始EarlyStopping 会在这个点把模型拉回最佳状态。4.3 训练集、验证集、测试集怎么划分划分方式直接决定项目分数。最常见但不推荐的做法是随手train_test_split(test_size0.2)不设置stratify参数。如果原始数据里垃圾邮件比例只有 15%随机划分后验证集里可能只有 5% 的垃圾邮件模型判断“全是正常邮件”也能得到很高的准确率但实际召回率完全不可用。正确的划分方式是这样from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) X_train, X_val, y_train, y_val train_test_split( X_train, y_train, test_size0.2, stratifyy_train, random_state42 )这里用两次划分保证测试集和验证集里的正负样本比例和全体数据一致。random_state42固定随机种子是为了让你调参时能复现一模一样的划分结果写进项目文档后别人拿到源码也能复现你的指标。4.4 模型保存与加载预测模型权重只保存了网络参数tokenizer 里存的词表和词索引也必须一起保存。这是项目里最容易漏的一步很多人训练完只model.save部署时新邮件句子无法转成和训练一致的整数序列。import json model.save(mail_cnn_full.keras) with open(tokenizer.json, w, encodingutf-8) as f: json.dump(tokenizer.to_json(), f, ensure_asciiFalse)预测时的加载流程必须包含 tokenizer 重建和序列填充from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing.text import tokenizer_from_json with open(tokenizer.json, r, encodingutf-8) as f: loaded_tokenizer tokenizer_from_json(json.load(f)) loaded_model load_model(mail_cnn_full.keras) def predict_mail(raw_text): text clean_mail(raw_text) seq loaded_tokenizer.texts_to_sequences([text]) X pad_sequences(seq, maxlenmax_len, paddingpost, truncatingpost) prob loaded_model.predict(X)[0][0] return prob, 1 if prob 0.5 else 0loaded_tokenizer.predict之前加一次clean_mail很重要否则原始邮件里的 HTML 标签和 URL 会按空白切出大量OOV导致序列里的有效信息密度大幅下降。5. CNN 垃圾邮件分类避坑五条最常翻车的现场记录以下五条是从课程作业和实际项目里反复出现的共性问题每一条都按现象、原因、解决三个部分写清楚。5.1 准确率 97%实际垃圾邮件一封都拦不住现象训练准确率很高抽样检查预测结果时发现模型几乎把所有邮件都判成正常邮件。原因是验证集和训练集里垃圾邮件占比过低模型发现全预测为 0 就能拿到超高准确率完全没有学到判别特征。解决方法是先看value_counts统计类别比例划分数据时使用stratifyy分层采样如果正负样本实在悬殊给少数类加大权重。from sklearn.utils.class_weight import compute_class_weight class_weight compute_class_weight(balanced, classesnp.array([0, 1]), yy_train) model.fit(X_train, y_train, class_weight{0: class_weight[0], 1: class_weight[1]})判定模型好不好不能只看 accuracy优先看验证集 F1。垃圾邮件识别里“漏拦”和“误拦”的代价不同F1 能同时体现精准率与召回率。5.2 预测阶段大量索引越界或词全部变成 OOV现象训练时模型正常加载模型推理时输入一批新邮件概率齐刷刷停在 0.5 附近。原因通常是训练和预测用了两个不同的 tokenizer或者num_words设置过小导致邮件里大量关键词在词表里根本不存在。解决方法是把 tokenizer 和模型一起保存、一起加载并且对新邮件和训练邮件执行完全相同的清洗函数。另外要给 Tokenizer 设置oov_tokenOOV这样即使出现未登录词也不会报错而是落到统一的未知词表示上。5.3 训练 loss 降到 0.02验证 F1 却在 0.6 附近徘徊现象模型把训练集背下来了泛化能力很差。原因是模型参数量过大、训练轮次过多或者 Dropout 比例太小。解决方法是优先降低 Dense 层的神经元数量从 64 降到 32再提高 Dropout 到 0.5同时打开 EarlyStopping让训练在验证集开始变差前停下来。也可以给 Embedding 层设置较小的embedding_dim比如 100 降到 50减少可学习参数。过拟合在邮件分类项目里非常常见因为课程项目常用数据只有几千条模型学完垃圾邮件的“固定话术”后容易把邮件里的发布时间、发件人昵称这种无关特征也当成判别依据。5.4 长邮件截断到 200 词后关键信息恰好被切掉现象某封垃圾邮件在开头铺垫很长结尾才出现“点击领取”字样截断后模型把它判成正常邮件。原因是pad_sequences默认truncatingpost只保留开头部分。解决方法是统计训练集分词后序列长度的分布用 95 分位数作为max_len而不是拍脑袋定一个数。lengths [len(seq) for seq in sequences] print(95分位长度:, int(np.percentile(lengths, 95)))如果确实发现关键信息在尾部把truncating改为pre或直接采用首尾各保留一段的拼接策略取开头 100 词和结尾 100 词拼成一条样本。这个技巧在垃圾邮件分类实测里经常能把 F1 拉高 2 到 3 个点。5.5 训练时 GPU 显存不够但 CPU 训练慢到无法接受现象在本地用 CPU 训练几千条数据要等十几分钟换 GPU 后又显存溢出。原因是max_len太大且卷积核 filters 设得过高Embedding 层加上三个 128 宽度的卷积分支占用大量显存。解决方法是先降max_len到 128再降 filters 到 32 或 48课程项目数据量下效果差距很小。如果仍然溢出把batch_size从 64 降到 16这是最快见效的手段。6. 别只看准确率用混淆矩阵、F1 和阈值迁移让项目真正能交差训练结束后验证集准确率 95% 不代表项目完成。最后一个环节是评估细节也是把这份源码写进项目说明时最能体现专业度的部分。先计算混淆矩阵和 F1而不是只打印 accuracy。垃圾邮件分类里漏拦一封垃圾邮件可能只是用户体验问题误拦一封正常邮件可能是业务事故所以要把两个维度的错误分开看。from sklearn.metrics import confusion_matrix, f1_score y_pred (model.predict(X_test) 0.5).astype(int) tn, fp, fn, tp confusion_matrix(y_test, y_pred).ravel() precision tp / (tp fp 1e-9) recall tp / (tp fn 1e-9) f1 2 * precision * recall / (precision recall 1e-9) print(f精确率: {precision:.4f} 召回率: {recall:.4f} F1: {f1:.4f})如果召回率低于预期可以扫一遍阈值而不是死等 0.5。把阈值从 0.3 到 0.7 按 0.05 步进逐一遍历选一个让 F1 最高的值用于预测函数。另一个容易被忽略的是时间维度上的评估。邮件数据有时间先后垃圾邮件话术会快速演变。把测试集按照时间切分比如前 80% 训练、后 20% 做测试比随机划分更接近线上实际表现。我在第一次做这个项目时随机划分测试 F1 是 0.94改成时间划分后掉到 0.87原因是测试集里出现了训练阶段完全没见过的“虚拟货币”话术。这个发现后来直接写进了项目文档的“局限与改进”一节反而成了拿分亮点。关于增量更新常见做法是每周把预测置信度低且被人工复核过的邮件加入训练集用model.fit在原有权重上继续训练 1 到 2 个 epoch学习率降到原来的十分之一。如果不想维护训练流程也可以把模型导出成 TF Serving 格式只做推理接口。输出层返回的不只是类别而是概率值这样下游系统可以根据概率高低决定直接拦截、进垃圾箱还是进入人工复核队列。最后说一个我自己的教训最影响项目评价的不是模型结构而是你有没有把每一步的中间结果落到文档里。词表大小、max_len、验证集 F1、混淆矩阵、坏案例截图这五样东西比任何花哨的网络结构都更能说明你做了什么。我早期只是把训练代码跑完就交差结果答辩时被问到“为什么 max_len 选 200”我答不出来。后来所有实验都固定随机种子、记录每组超参数再有人问就能直接翻记录回答。希望你少走这个弯路希望这篇笔记帮到你。本文还有配套的精品资源点击获取
返回列表