简介面向法律智能赛事的完整参赛源码与学习说明包围绕CAIL2018法律智能挑战赛展开覆盖罪名预测、法条推荐等典型任务适合计算机、数学、电子信息等专业学生作为竞赛项目参考也可作为学习文本分类与司法智能应用的入门素材。压缩包共30个文件含18个Python源码、10张成绩曲线与模型效果图、1份Markdown学习说明及配置文件整体仅2.36 MB结构精炼。源码阶段覆盖数据预处理、数据增强、特征切分、模型训练到预测演示的全链路包含TextCNN、双向GRU、ResNet、Attention等可复现模型与相应训练脚本图表展示比赛各阶段成绩、部分预测结果和模型训练曲线README文档帮助快速理解代码结构与调参逻辑。通过这套方案读者能了解真实竞赛中的完整思路从数据清洗到模型对比均有落地代码适合具备一定Python基础并愿意钻研细节的学习者。目前已有112人学习下载是了解法律智能竞赛实战流程的轻量级参考。1. CAIL2018 法律智能挑战赛源码包一套能跑通罪名预测全流程的实战样本如果你准备参加法律智能类的竞赛或者想看看司法文本在 NLP 竞赛里到底怎么处理这份 CAIL2018 参赛源码值得花时间拆一遍。它不是一个封装好的黑匣子而是一个真实参赛队伍的完整工作目录从数据预处理、数据增强、多个模型实现到评测脚本和预测演示都有模型涵盖 CNN、TextCNN、CNNAttention、ResNet、BiGRUGlobalMaxPool1D 等。资源定位是「学习参考」不是开箱即用的产品——适合计算机、数学、电子信息类专业学生做竞赛入门也适合想快速了解罪名预测任务主流做法的从业者。下文我按自己拆项目的习惯把这份源码从数据侧到模型侧逐层过一遍并标注哪些地方是真正决定成绩的细节。2. 任务定义与数据侧先搞清楚 CAIL2018 在预测什么再谈模型2.1 三个子任务与评测指标罪名预测、法条推荐、刑期预测CAIL2018 的核心是「小说案情做法官」。给定一段案情描述参赛者需要完成三个子任务罪名预测这个行为构成什么罪、法条推荐涉及哪几条法律、刑期预测应该判多久。这份源码包里的模型文件命名很直白model_CNN_accusation.py、model_RES_accusation.py说明团队当时重点攻坚的是罪名预测任务法条和刑期可能用共享特征或单独模型处理。罪名预测在技术上是一个多标签文本分类问题——一个案件可能同时触犯多个罪名比如「盗窃」加「破坏电力设备」所以输出层不是 softmax 而是 sigmoid配合多标签损失函数。评测指标用的是 macro-F1这个选型直接影响训练策略。被类别样本多、样本少的罪名在 macro-F1 下权重一样所以训练时不能只盯着整体准确率必须处理类别不均衡。CAIL2018 的罪名分布有典型的幂律特征盗窃、故意伤害、危险驾驶这类罪名样本上万而一些冷门罪名只有几十条甚至十几条。如果直接用原始分布训练小类别的召回率会很难看macro-F1 被拉低。这也是为什么源码包里单独有data_augmentation目录——团队把数据增强当作对抗不均衡的主要手段而不是只调 loss 权重。2.2 原始数据怎么变成模型输入切词、转序列、对齐标签从数据到模型输入中间有三段关键代码data_cut.py负责切词fit_tokenizer_to_sequences.py负责把词转成序列并统一长度data_label.py负责把罪名文本标签转成 multi-hot 向量。我按常见实现补全了这三段的逻辑框架和源码的目录结构是对应的。# data_cut.py 核心逻辑 import jieba def cut_corpus(input_path, output_path, stopwords_pathNone): stopwords set() if stopwords_path: with open(stopwords_path, r, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) with open(input_path, r, encodingutf-8) as fin, \ open(output_path, w, encodingutf-8) as fout: for line in fin: # 假设每行是一个 json字段 fact 是案情描述 fact json.loads(line).get(fact, ) words jieba.lcut(fact) words [w for w in words if w.strip() and w not in stopwords] fout.write( .join(words) \n)这段切词逻辑有个容易被忽略的点jieba.lcut返回的是列表直接 .join之后存成空格分隔的文本后续喂给 tokenizer 时就已经是分词后的形态了。法律文本里大量出现「被告人」「本院认为」「公诉机关」这类高频词我建议把它们加进停用词表否则序列长度里挤满了对分类没有区分度的词。另外法律文本中的数字、日期、人名在切词后会被拆成碎片是否保留要看模型——对于 CNN 类模型保留数字片段其实能帮助模型捕捉金额、日期等量刑相关信号不必一刀切清除。# fit_tokenizer_to_sequences.py 核心逻辑 from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences def build_sequences(cut_path, max_len500, vocab_size50000): texts [] with open(cut_path, r, encodingutf-8) as f: for line in f: texts.append(line.strip()) tokenizer Tokenizer(num_wordsvocab_size, oov_tokenUNK) tokenizer.fit_on_texts(texts) sequences tokenizer.texts_to_sequences(texts) padded pad_sequences(sequences, maxlenmax_len, paddingpost, truncatingpost) return padded, tokenizer这里max_len500和vocab_size50000是经验参数。法律案情的文本长度差异很大起诉书短的几十字长的几千字500 的截断长度在信息保留和训练效率之间是一个常见折中。注意paddingpost表示在序列尾部补零truncatingpost表示从尾部截断——因为案情的关键信息罪名定性往往在文本中前部后部截断比前部截断损失更小。oov_tokenUNK是给词表外的词一个统一映射避免预测时遇到生词报错。# data_label.py 核心逻辑 def build_multihot_labels(label_path, class_list): # class_list 是所有罪名的有序列表 label_to_id {c: i for i, c in enumerate(class_list)} multihots [] with open(label_path, r, encodingutf-8) as f: for line in f: item json.loads(line) accusation item.get(accusation, []) row [0] * len(class_list) for acc in accusation: if acc in label_to_id: row[label_to_id[acc]] 1 multihots.append(row) return np.array(multihots)这段代码把罪名列表转成 multi-hot 向量核心在label_to_id的构建——类别的顺序必须和模型输出层的神经元顺序一致否则训练和预测的标签空间错位成绩会完全混乱。我见过不止一次因为 label 排序不一致训练 loss 正常下降但 F1 始终在低位徘徊的情况。另一个细节是accusation字段本身是罪名名称列表CAIL2018 原始数据里罪名有 202 类这个类别数决定了模型输出层的维度。2.3 数据增强的三个文件为什么法律文本也敢做增强数据增强在图像领域是标配在文本领域则要谨慎。源码包里数据增强_fact_shuffle.py、数据增强_accusation.py、数据增强_relevant_articles.py分别从三个角度扩充训练集。fact_shuffle的思路是把案情的句子顺序打乱后重排生成新的样本。这个操作对法律文本是有风险但有收益的法律案情虽然是叙述性的但很多句子的逻辑顺序时间、地点、行为、后果即使打乱罪名仍然不变比如「被告人用螺丝刀撬开门锁进入室内盗走现金 5000 元」——句子顺序调换不影响盗窃罪的判定。但如果案情里有因果链条「先伤害后抢劫」打乱顺序可能让模型学到错误关联。数据增强_accusation.py的思路更像是「伪标签」或「同义改写」对某些罪名下的文本用同义词替换关键动词生成新样本。这在法律领域要格外小心——「盗窃」换成「抢夺」在法律上是完全不同的罪名。我建议使用这个文件时先看它的替换词典如果替换强度过大宁可少生成也不要引入错误标签。relevant_articles则是根据法条关联性做样本拼接或混搭利用「同一法条下案情相似」的先验知识扩充样本量。数据增强的正确打开方式是先在小验证集上测试增强样本占比对 macro-F1 的影响通常 0.5 到 1 倍扩充增量是安全区间超过 2 倍容易导致模型在增强样本的噪声上过拟合。3. 模型实现拆解四个罪名分类模型各自解决了什么问题3.1 TextCNN 与 CNN 基线n-gram 特征捕捉是核心TextCNN 是短文本分类的经典基线源码里textcnn.py和model_CNN_TextCNN_accusation.py对应这个思路。TextCNN 的核心是用多个不同尺寸的卷积核并行扫描文本序列每个卷积核相当于一个 n-gram 检测器。举个例子3-gram 卷积核能捕捉「持刀抢劫银行」这种局部短语组合5-gram 能捕捉更长的语义单元。下面是 TextCNN 的一个标准实现骨架# textcnn.py 核心逻辑 from tensorflow.keras.layers import Input, Embedding, Conv1D, GlobalMaxPooling1D, Dense, Dropout, Concatenate def build_textcnn(vocab_size, embedding_dim200, max_len500, num_classes202): inputs Input(shape(max_len,)) embedding Embedding(vocab_size, embedding_dim, mask_zeroFalse)(inputs) pools [] for kernel_size in [2, 3, 4, 5]: conv Conv1D(filters128, kernel_sizekernel_size, activationrelu)(embedding) pool GlobalMaxPooling1D()(conv) pools.append(pool) concat Concatenate()(pools) dropout Dropout(0.5)(concat) outputs Dense(num_classes, activationsigmoid)(dropout) return Model(inputs, outputs)需要说明两个设计选择。第一mask_zeroFalse是因为全局池化会把 padding 位置的零向量池化掉不需要额外 mask但如果用 BiGRU 那类序列模型mask_zeroTrue能避免 padding 影响循环计算。第二filters128和kernel_size[2,3,4,5]是网格搜索里的中庸选择class 数量多时建议把 filters 提到 256。多尺寸卷积核并行后接GlobalMaxPooling1D作用是把每个卷积核输出的特征图压缩成一个标量——取最大值意味着只保留每个 n-gram 模式在全文范围内最强的激活这对「只要出现关键短语就能定罪名」的法律文本是合理的假设。3.2 CNN Attention用注意力矩阵给关键案情加权model_CNN_attention_accusation.py和attention.py是团队在基线之上的第一层改进。纯 CNN 的短板在于 GlobalMaxPooling 只取最大值丢失了位置信息和次强信号。Attention 的引入方式是在卷积层之后对特征图做加权求和权重由注意力网络学习得到。这样模型可以对「持刀」「入户」「暴力威胁」这类高判别力片段赋予更大权重而不是只看单一最大激活。# attention.py 核心逻辑 from tensorflow.keras.layers import Layer import tensorflow.keras.backend as K class AttentionLayer(Layer): def __init__(self, **kwargs): super(AttentionLayer, self).__init__(**kwargs) def build(self, input_shape): self.W self.add_weight(nameatt_weight, shape(input_shape[-1], input_shape[-1]), initializerglorot_uniform, trainableTrue) self.b self.add_weight(nameatt_bias, shape(input_shape[-1],), initializerzeros, trainableTrue) super(AttentionLayer, self).build(input_shape) def call(self, x): # x shape: (batch_size, time_steps, feature_dim) u K.tanh(K.dot(x, self.W) self.b) a K.softmax(u, axis1) return K.sum(x * a, axis1)这个注意力实现是经典的自注意力加权对每个时间步的特征向量做非线性变换得到能量分数usoftmax 后变成归一化权重a最后用a对原始特征x做加权求和输出一个定长向量。它和 Transformer 里的多头注意力的核心差别在于只有一组权重且没有 Query/Key/Value 的三路映射计算量小得多。build里add_weight的trainableTrue表示这些参数随训练更新glorot_uniform初始化是避免训练初期梯度消失的常见选择。实际训练时这个 Attention 层对 F1 的提升通常在 1 到 2 个点提升幅度不夸张但很稳定。3.3 ResNet 结构用于文本残差连接为什么能帮上忙model_RES_accusation.py和resnet.py把图像领域的残差结构迁移到文本上。法律案情平均长度长、语义层次多单纯堆叠卷积层会出现退化问题——层数增加但训练误差反而升高。ResNet 的残差连接y F(x) x让梯度可以跨层直接回传使得深层网络至少不劣于浅层网络。文本场景下的 ResNet 常见做法是把一维卷积 BN ReLU 包装成残差块然后串联若干块# resnet.py 核心逻辑 from tensorflow.keras.layers import Conv1D, BatchNormalization, ReLU, Add def residual_block(x, filters, kernel_size3): shortcut x x Conv1D(filters, kernel_size, paddingsame)(x) x BatchNormalization()(x) x ReLU()(x) x Conv1D(filters, kernel_size, paddingsame)(x) x BatchNormalization()(x) x Add()([x, shortcut]) x ReLU()(x) return x残差块的要点是paddingsame保证卷积前后序列长度不变shortcut才能和卷积输出直接相加。如果filters在块内部发生变化shortcut 路径需要加一个 1x1 卷积对齐维度。法律文本用 ResNet 的收益在于案情描述中「行为结果」的语义往往需要跨多句组合深层 CNN 能覆盖更长的依赖范围残差连接让深层训练不翻车。代价是参数量和训练时间上升——在 GPU 资源有限时我不建议超过 10 个残差块。3.4 BiGRU GlobalMaxPool1D序列模型的另一个极端源码里的训练曲线图Bidirectional_GRU_GlobalMaxPool1D_epochs.png表明团队还尝试了双向 GRU。GRU 天然适合建模顺序信息双向结构让每个位置同时看到前后文GlobalMaxPool1D 则把整个序列压缩成最强特征。和 CNN 相比GRU 对超参数更敏感隐藏层维度、dropout 比例、是否使用预训练词向量都会显著影响结果。我的经验是 BiGRU GlobalMaxPool 的收敛速度比 TextCNN 慢但在长文本上往往表现更好前提是训练数据量足够——数据少时 GRU 更容易过拟合训练集。# BiGRU GlobalMaxPool1D 核心逻辑 from tensorflow.keras.layers import Bidirectional, GRU, GlobalMaxPooling1D def build_bigru(vocab_size, embedding_dim200, max_len500, num_classes202): inputs Input(shape(max_len,)) embedding Embedding(vocab_size, embedding_dim, mask_zeroTrue)(inputs) gru Bidirectional(GRU(128, return_sequencesTrue, dropout0.3))(embedding) pool GlobalMaxPooling1D()(gru) outputs Dense(num_classes, activationsigmoid)(pool) return Model(inputs, outputs)return_sequencesTrue是关键——只有返回完整序列GlobalMaxPooling1D 才有时间步维度可压缩。dropout0.3是循环层内部的 dropout作用于每个时间步的输入和循环连接比在输出后接 Dropout 层更有效。需要提醒的是GRU 里mask_zeroTrue配合return_sequencesTrue是安全的但如果后续接的不是池化而是 Flattenpadding 位置会产生大量无效参数所以这里选 GlobalMaxPool 是合理的。3.5 训练脚本里的通用套路早停、Checkpoint 与阈值选择四个模型文件虽然结构不同训练流程有共性。这里以model_CNN_accusation.py的训练部分为例# model_CNN_accusation.py 训练核心逻辑 from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) callbacks [ EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue), ModelCheckpoint(best_cnn_accusation.h5, monitorval_f1_score, modemax, save_best_onlyTrue) ] model.fit(X_train, y_train, batch_size64, epochs50, validation_data(X_val, y_val), callbackscallbacks)binary_crossentropy对应多标签分类的损失函数每个输出神经元独立计算二分类交叉熵后取平均配合 sigmoid 激活。EarlyStopping(patience5)表示验证集 loss 连续 5 个 epoch 不下降就停止restore_best_weightsTrue是后悔药——结束训练后自动回滚到验证指标最好的那次权重而不是最后一次迭代的权重。ModelCheckpoint监控的是val_f1_score这个 metric 需要自己定义或用第三方库因为 Keras 原生不提供多标签 F1。推理阶段还有一个阈值问题模型输出的是 0 到 1 之间的概率多标签分类不是简单取 top-k而是对每个类别设阈值源码的predictor.py里应该有阈值调整的逻辑这直接影响最终 F1。4. 避坑与常见问题排查我拆这份源码时踩过的五个真实坑4.1 成绩截图与代码不一致别把 .h5 当结果要复现训练过程源码包里有20180615成绩.png等排名截图但代码目录里没有预训练好的权重文件。我第一次跑通训练后生成的 F1 和截图差距明显一度以为代码有问题。原因是截图成绩对应的是当时的完整训练流程——包括数据增强、类别权重、阈值搜索等所有环节的组合单独跑某一个模型很难复现全量成绩。这类竞赛源码包普遍存在「结果可复现但需要完整流程」的问题。排查思路是先跑通单个模型把 F1 稳定在一个合理区间再逐步叠加数据增强和阈值搜索观察每一步的提升而不是指望重跑一遍直接达到截图水平。4.2 多标签阈值设成 0.5 是默认值但不一定是好值多标签分类的输出层用 sigmoid 后常见做法是大于 0.5 视为正类。但 CAIL2018 的罪名分布极其不均衡对样本量大的类别模型输出概率普遍偏高0.5 阈值能照顾它们冷门罪名输出概率整体偏低0.5 阈值下召回率接近于零。我当时用验证集做阈值扫描每个类别单独在 0.2 到 0.8 之间搜索 F1 最大的阈值。注意不要直接使用默认 0.5这会让你前面所有模型改进的收益都被阈值吃掉一半。# 阈值搜索核心逻辑 from sklearn.metrics import f1_score def search_thresholds(y_val, pred_probs, step0.05): best_thresholds [] for col in range(pred_probs.shape[1]): best_f1, best_t 0, 0.5 for t in np.arange(0.2, 0.8, step): y_pred (pred_probs[:, col] t).astype(int) current_f1 f1_score(y_val[:, col], y_pred, zero_division0) if current_f1 best_f1: best_f1, best_t current_f1, t best_thresholds.append(best_t) return best_thresholds每列独立搜索阈值的逻辑是先遍历 0.2 到 0.8 之间的候选值用当前阈值把概率矩阵转成预测标签后计算该列 F1取最高点。zero_division0是防止某类别在所有样本上预测全为负时 F1 分母为 0 而报错。搜索出的阈值数组在预测阶段要传给predictor.py确保训练和推理用的是同一套阈值。4.3 数据增强过度导致模型学会「乱序文本」的假规律数据增强_fact_shuffle.py有一个隐蔽问题如果增强比例过高模型会看到大量句序被打乱的样本它会倾向于忽略句子之间的顺序关系。这对刑法文本是致命的——「正当防卫」和「故意伤害」的区分往往藏在行为的时间顺序里。我当时把增强样本占比提到 2 倍后验证集 F1 下降了两个多点排查后发现是模型对「先被攻击后反击」这种顺序信息不再敏感。解决方式是把 shuffle 增强的比例控制在 30% 以下并且只对长度超过 200 字的文本做 shuffle——长文本对局部乱序的容忍度更高短文本的语义高度依赖句间逻辑。4.4 显存溢出不一定靠减小 batch_size先检查序列长度分布CAIL2018 的案情文本长度差异极大如果统一用max_len500意味着所有超过 500 的样本都要截断所有不足 500 的样本都被 padding 到 500。这样 GPU 显存里塞了大量无效的 padding训练速度慢且显存利用率低。我吃过这个亏8G 显存跑 batch_size64 直接 OOM把 batch 降到 16 才能跑但训练时间翻倍。后续我用长度分桶bucketing解决把样本按长度分成 100~200、200~400、400~500 三桶每桶单独 padding 到桶内最大长度这样显存占用降低了约 40%同样显存能跑更大的 batch。源码里没有显式做分桶但data_transform.py里留了按长度排序的接口可以自行扩展。4.5 eval.py 报错不一定在模型可能在标签对齐evaluate.py是官方评测脚本如果直接用它跑自己的预测结果最常见的报错是标签文件里的罪名名称和预测结果里的罪名名称不一致。比如原始数据里某些罪名带数字编号1、101而你的data_label.py构建类别列表时用了罪名名称两边对不上评测脚本就会报 key error 或者算出来 F1 是 0。排查方法是先打印class_list的前 10 项和评测脚本读入的类别顺序做对比确认完全一致再跑评测。这个坑在你准备提交最终结果时尤其致命因为生成预测后你通常不会回头检查类别顺序。5. predictor 演示与完整评测流程把训练好的模型用起来5.1 加载模型并处理单条文本从原始输入到预测标签predictor目录里的predictor.py是模型的推理封装。核心逻辑是把一条原始案情文本经过切词、tokenizer 转序列、模型的预测、阈值截断四个步骤最终输出罪名列表。关键是推理阶段的预处理必须和训练阶段完全一致——同一个tokenizer、同一个max_len、同一个阈值数组。# predictor.py 推理核心逻辑 import jieba from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing.sequence import pad_sequences class Predictor: def __init__(self, model_path, tokenizer, thresholds, class_list, max_len500): self.model load_model(model_path) self.tokenizer tokenizer self.thresholds thresholds self.class_list class_list self.max_len max_len def predict(self, text): words jieba.lcut(text) seq self.tokenizer.texts_to_sequences([ .join(words)]) padded pad_sequences(seq, maxlenself.max_len, paddingpost, truncatingpost) probs self.model.predict(padded)[0] predicted [self.class_list[i] for i, p in enumerate(probs) if p self.thresholds[i]] return predicted注意tokenizer.texts_to_sequences接收的是字符串列表所以切词后要用空格把词连接成字符串再传入否则 tokenizer 会按字符而不是按词切分。load_model需要模型结构定义和训练时的自定义层比如 AttentionLayer一致不然会报无法反序列化自定义层的错误解决方式是在加载前把自定义层类导入作用域。predict方法返回罪名列表如果为空说明所有类别的概率都低于阈值这种情况在真实样本里很少见但一旦出现要检查阈值是否搜索过。5.2 完整评测闭环训练 → 验证 → 预测 → 官方评测脚本一个规范的参赛流程应该是划分训练集和验证集按 9:1训练阶段记录验证集 F1 变化训练结束后用保存的最佳权重在验证集上预测得到每个类别的概率矩阵在验证集上做阈值搜索得到最优阈值数组最后在测试集上用完整 pipeline 预测并生成提交文件用evaluate.py计算最终成绩。我之前发现一个细节阈值搜索如果直接在测试集上做会导致过拟合测试集成绩虚高但泛化能力差。正确做法是阈值只在验证集上搜索测试集只跑一次。源码包的 README 里也强调了这个流程但不会显式告诉你为什么——这是竞赛圈里的常规纪律。5.3 用 pandas 快速分析预测错误的类型分布如果你想进一步分析模型在哪些罪名上表现差可以用 pandas 把预测结果和真实标签做对比按罪名聚合统计。这一步对定位数据增强的方向很有帮助import pandas as pd def analyze_errors(y_true, y_pred, class_list): rows [] for i, cls in enumerate(class_list): tp sum(1 for t, p in zip(y_true[:, i], y_pred[:, i]) if t 1 and p 1) fp sum(1 for t, p in zip(y_true[:, i], y_pred[:, i]) if t 0 and p 1) fn sum(1 for t, p in zip(y_true[:, i], y_pred[:, i]) if t 1 and p 0) rows.append({罪名: cls, TP: tp, FP: fp, FN: fn}) df pd.DataFrame(rows) df[F1] 2 * df[TP] / (2 * df[TP] df[FP] df[FN]) return df.sort_values(F1)调用这个函数后按 F1 升序排列就能看到哪些罪名最拉胯。我常用这个表来决定要不要针对特定罪名做数据增强——如果「抢劫罪」的 FN 很高说明模型漏检严重下一步就针对这类罪名补充样本。用这段代码替代盲目的全局数据增强效率提升比单纯调模型结构更明显。6. 用 TensorBoard 盯训练曲线早停之外的第二道防线训练曲线图Bidirectional_GRU_GlobalMaxPool1D_epochs.png和TextCNN.png暴露了一个信息团队当时用了 TensorBoard 或 matplotlib 记录训练过程。训练曲线不只是事后看训练中盯曲线能尽早发现模型是否走偏。我会在fit里加 TensorBoard 回调每个 epoch 写一次训练 loss、验证 loss、验证 F1 三个指标放同一张图看。如果训练 loss 持续下降但验证 loss 在上升说明过拟合开始早停回调触发之前你就应该看到如果训练 loss 和验证 loss 同步不降说明学习率不合适或数据预处理有问题不用等到 50 个 epoch 全部跑完再翻车。另外建议保存每次实验的配置参数到文本文件包括 max_len、embedding_dim、卷积核数量、dropout、batch_size、学习率、增强比例。不要相信自己的记忆——同一个模型调参跑十次后续你需要知道哪一版参数对应哪个 F1。我一般用json.dump把 config 和模型文件名绑定保存文件名里带上 F1 值比如textcnn_max500_f1_78.5.h5。这样回看成绩截图时能直接对应到具体配置而不是靠猜。从那以后我每次跑实验都强制走一遍「TensorBoard 盯曲线 配置文件随模型保存」的流程再没出现过调参调乱了不知道哪版能用的局面。希望这份源码的拆解能帮你少走几步弯路。本文还有配套的精品资源点击获取
