
简介面向自然语言处理初学者与文本分类项目实践者这套项目方案演示了如何基于Keras与Keras-bert实现文本多标签分类任务核心思路是在BERT预训练模型基础上进行微调。项目以2020语言与智能技术竞赛事件抽取任务数据作为样例覆盖从模型训练、评估到预测的完整流程适合需要参考可运行代码来上手BERT微调与多标签分类的读者。压缩包共10个文件包含4个Python脚本训练、评估、预测及对抗训练FGM、2个CSV数据文件、2个TXT配置与依赖文件以及README说明文档整体体积仅1.01MB轻量易用。目前已有1634人学习下载。资源提供了可直接运行的项目框架、数据样例与模型调用实现能够帮助读者快速理解多标签分类的数据组织方式、BERT微调流程和预测输出处理节省从零搭建环境与调试代码的时间。1. 用 Keras 微调中文 BERT 做多标签文本分类从事件抽取任务说起多标签文本分类和普通文本分类最大的区别在于一条样本可以同时命中多个类别。2020 语言与智能技术竞赛的事件抽取任务就是典型场景一句话里可能同时出现“股价上涨”和“公司上市”两个事件模型输出不是一个 argmax而是一组独立的概率。这套基于 Keras 和 Keras-bert 的项目核心流程是加载 chinese_L-12_H-768_A-12 这个中文 BERT 预训练模型在输出端接一层 sigmoid然后对整个 BERT 做微调。它适合已经会用 Keras、想快速把多标签任务落到 BERT 上的从业者。我拆这套代码时最在意的不是 BERT 本身而是标签怎么编码、阈值怎么定、预训练参数怎么对应上。2. 数据与标签工程把事件抽取文本转成 BERT 能吃的 multi-hot 样本2.1 先确认你拿到的是哪种数据train.csv 字段与多标签来源这个项目的数据来自 2020 语言与智能技术竞赛的事件抽取任务压缩包里 data 目录下只有 train.csv 和 test.csv 两个文件。事件抽取本身要模型输出触发词、事件类型、论元角色而该项目把它简化成了多标签分类预测一条文本命中了哪些事件类型或者涉及哪些论元角色。这两种目标都能做成多标签关键看 CSV 里那一列标签如何组织。我建议你拿到数据后第一件事不是写代码而是把 train.csv 打开看几行。事件抽取竞赛的数据一般会包含文本内容、事件类型列表、论元角色列表有的版本还会有触发词。常见字段大概长这样字段示例在本项目里的作用text / content百度宣布收购云丁科技送入 BERT 的输入文本event_type[收购, 上市]多标签的标签来源argument_role[买方, 被收购方]备选的多标签来源trigger_word[宣布, 收购]不参与分类训练要注意的是不同渠道拿到的 CSV 列名不一定一致我见过有的版本把标签列叫 labels有的把标签列直接存成字符串形式的列表。原项目代码里对列名的假设未必和你手上的数据对得上所以先做一步字段适配别急着跑训练脚本。test.csv 在竞赛官方版本里通常是没有标签的但本项目里它被当作文本来源使用如果你发现 test.csv 里没有标签列那说明它只承担推理或验证角色。2.2 多标签编码MultiLabelBinarizer 与文本清洗多标签分类的第一步是把“一行文本挂多个标签”这种结构变成固定维度的 0/1 向量。常见做法是用 sklearn 的 MultiLabelBinarizer我一般不会自己手写编码器因为类别数量一多容易出边界问题。下面的逻辑几乎可以直接复用import ast import pandas as pd from sklearn.preprocessing import MultiLabelBinarizer df pd.read_csv(data/train.csv, encodingutf-8-sig) print(df.head()) # CSV 里如果是 [a, b] 这种字符串需要先转成真正的 list if isinstance(df[event_type].iloc[0], str): df[event_type] df[event_type].apply(ast.literal_eval) mlb MultiLabelBinarizer() y mlb.fit_transform(df[event_type]) print(样本数:, y.shape[0], 标签数:, y.shape[1]) print(标签列表:, mlb.classes_)这段代码里最关键的是ast.literal_eval它把 CSV 里存成字符串的列表还原成 Python list。很多人在这一步翻车直接拿字符串去做 MultiLabelBinarizer结果每个字符变成一个标签类别数瞬间膨胀到几千。MultiLabelBinarizer的classes_属性会按字母序排列所有标签后续预测脚本里要拿它把模型输出映射回标签名所以建议把mlb对象一起存下来后面评估和推理都要用。还有一点值得注意千万别用 LabelEncoder 或 to_categorical 来处理这种标签那是单标签分类的做法会把一条样本强制归到唯一类别多标签的语义就丢了。2.3 长度分布与 max_len 设置别拿全量数据直接跑 BERTBERT 的输入长度上限是 512但注意力机制的计算量是 O(n²)把 max_len 设成 512 会明显拖慢训练而且事件抽取的句子通常比较短。我一般会先统计一下文本长度分布再决定 max_len 取多少这个习惯能省不少显存。lengths df[text].astype(str).str.len() print(lengths.describe(percentiles[0.5, 0.75, 0.9, 0.95, 0.99])) max_len min(int(lengths.quantile(0.95)), 256) print(max_len:, max_len)这里取 0.95 分位数而不是最大值是为了避免极端长文本把序列长度撑得很大。比如一个 3000 字的异常样本会把 max_len 顶到 512而 95% 的样本其实只有 80 个字这就是浪费。实际项目里我会先用 128 起步如果验证集 F1 不理想再往上调因为事件抽取的语言比较紧凑128 通常够用。改这个值会影响后面所有脚本包括训练、评估和预测三处必须保持一致否则会导致维度对不上。2.4 分词keras-bert 的 Tokenizer 和 vocab.txt 的对应关系keras-bert 自带 Tokenizer它直接从 vocab.txt 读取词表不需要额外装 jieba 之类的分词器。这一步衔接得好不好直接影响后面所有训练和推理from keras_bert import Tokenizer tokenizer Tokenizer(chinese_L-12_H-768_A-12/vocab.txt) # encode 返回 (token_ids, segment_ids) ids, seg tokenizer.encode(百度宣布收购云丁科技, max_len128) print(token_ids:, ids) print(segment_ids:, seg)这里输出的token_ids是整型数组长度就是 max_len不足部分用 [PAD] 填充segment_ids用来区分句子 A 和句子 B单句输入时全为 0。需要特别注意的是这个项目的准则是“训练、验证、预测用同一个 tokenizer 实例”否则词表索引对不上预测结果会很奇怪。项目中只有 vocab.txt 是随包附带的config 文件和一个庞大的模型参数文件需要你自己准备目录名 chinese_L-12_H-768_A-12 已经暗示了它对应的就是 BERT-base 中文版12 层、768 维、12 个注意力头参数量上亿别指望它很小。2.5 训练集 / 验证集划分数据准备最后一步是划出验证集。多标签场景下不适合直接按train_test_split的默认参数硬切因为某些事件类型出现次数很少随机切分可能把它们的样本全切到训练集或全切到验证集。我一般会先看标签分布再决定切分策略from sklearn.model_selection import train_test_split # 至少保证标签数量大于 1否则这类标签无法评估 label_counts y.sum(axis0) print(低频标签数:, (label_counts 5).sum()) X df[text].astype(str).tolist() X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.1, random_state42, stratifyNone )stratify在 multi-label 下不能直接用因为 sklearn 的这个参数只支持一维数组。如果你发现低频标签问题很严重常见做法是按“标签组合”做分层切分也就是把每条样本的标签集合变成一个字符串指纹再对指纹做 stratify这样能尽量保证切分后的分布一致。但对本项目来说先把 random_state 固定住比什么策略都重要否则每次调试结果都不同很难判断改动到底有没有效果。3. 训练脚本拆解model_train.py 的模型结构、学习率与关键参数3.1 为什么输出层一定要用 sigmoid 而不是 softmax很多第一次转做多标签的人会习惯性地在最后一层接 softmax这属于单标签思维。softmax 的数学性质决定了所有类别的概率之和等于 1也就是“必须二选一”。而多标签任务里每个标签都是独立事件“股价上涨”和“公司上市”可以同时存在互不排斥。正确的做法是对每个标签单独做二分类sigmoid 输出 0 到 1 的概率损失函数用 binary_crossentropy。这个区别看起来基础但在实际训练中用错激活函数和损失函数会导致模型永远学不出多标签的结构验证集 F1 卡在很低的值上不去。3.2 加载 checkpointconfig.json 与 model.ckpt 的对应关系现在做 大模型微调 的主流路线是 Transformers Trainer但这套项目用的是 Keras 生态的老写法底层是通过 keras-bert 直接加载谷歌格式的 checkpoint。加载代码长这样from keras_bert import load_trained_model_from_checkpoint config_file chinese_L-12_H-768_A-12/bert_config.json checkpoint_file chinese_L-12_H-768_A-12/bert_model.ckpt bert load_trained_model_from_checkpoint( config_file, checkpoint_file, seq_lenmax_len, trainingTrue, # 训练阶段打开保留 Dropout )trainingTrue这个参数很容易被忽略。BERT 结构里有 Dropout 层如果加载时用 False模型会进入推理模式Dropout 全部关闭训练阶段必须打开否则相当于少了正则化微调几个 epoch 就可能过拟合。还有一层对应关系要搞清楚bert_config.json定义的是模型结构它告诉加载器层数、隐层维度、注意力头数bert_model.ckpt才是真正的预训练参数一两个 GB 很正常。项目中只有一个 vocab.txt如果 config 和 ckpt 没下载对加载时会报 shape mismatch报错信息会明确告诉你是第几层对不上。3.3 用 [CLS] 输出还是 mean poolingbert 模型跑完得到的是一个三维张量形状是(batch_size, seq_len, hidden_size)接分类层之前必须压成二维。BERT 原始论文对分类任务用的是 [CLS] 位置的输出也就是每个序列第一个 token 对应的向量取法如下from keras.layers import Input, Lambda, Dense, Dropout from keras.models import Model from keras import backend as K token_in Input(shape(None,), nameTokenSeq) seg_in Input(shape(None,), nameSegmentSeq) x bert([token_in, seg_in]) # 取 [CLS] 位置的向量形状变为 (batch_size, hidden_size) x Lambda(lambda v: v[:, 0])(x) # 备用写法对整条序列做 mean pooling # x Lambda(lambda v: K.mean(v, axis1))(x) x Dropout(0.1)(x) output Dense(y.shape[1], activationsigmoid)(x) model Model([token_in, seg_in], output)[CLS] 的向量在预训练阶段被训练成“聚合全句语义”的表示所以分类任务用它是最稳的选择。mean pooling 在某些长文本场景下略好因为 [CLS] 在高 seq_len 时容易稀释信息但对事件抽取这种偏短文本的任务我实测差距不大。Dropout 接在池化层之后很关键建议保持在 0.1 左右加得太大反而掉点。3.4 优化器AdamWarmup 与学习率设置BERT 微调的学习率比一般 CNN 模型小一两个数量级。从头训练一个模型用 1e-3 没问题但 BERT 的预训练参数已经收敛得很好学习率太大一步就把权重冲坏了。keras-bert 库自带了 AdamWarmup 优化器集成了 weight decay 和 warmup是微调 BERT 的常见配置from keras_bert import AdamWarmup model.compile( lossbinary_crossentropy, optimizerAdamWarmup(decay0.01, warmup_ratio0.1, lr2e-5), metrics[accuracy], ) history model.fit( [train_ids, train_segs], y_train, batch_size16, epochs6, validation_split0.1, shuffleTrue, )warmup_ratio0.1意味着前 10% 的迭代步学习率从 0 线性升到 2e-5之后再用 decay 缓慢下降这一步是 BERT 微调里公认的稳定技巧。学习率我一般用 2e-5 起步数据集小就降到 1e-5数据集大且任务难才考虑 3e-5 到 5e-5。batch_size 在 16 和 32 之间选太大容易 OOM太小训练不稳定。validation_split0.1 是在训练集内部又切了 10% 做动态验证方便边训练边看曲线但它和你手动切出的验证集是两回事不要混着用。3.5 全量微调还是冻结底层项目标题写得很明确这是对 BERT 做微调也就是所有层都参与训练。但当我面对样本量很小的业务数据时会倾向于冻结 BERT 的前一半层只训练后几层和分类层这样做能明显降低过拟合风险代价是上限可能略低# 微调前可选的冻结策略 # for layer in bert.layers[:10]: # layer.trainable FalseBERT 底层学到的是通用的词法、句法特征顶层才更接近任务语义。数据量大就全量微调数据量小就只微调顶层这是我这几年踩坑踩出来的选择依据。如果你的任务是事件抽取这种领域性较强的场景底层通用特征反而值得保留冻结底层往往效果更好。4. 评估与预测阈值、F1 与 model_evaluate.py 的落地用法4.1 多标签预测没有 argmax阈值才是真正的输出层模型训练完成后model.predict输出的形状是(样本数, 标签数)每个值都是 0 到 1 之间的概率。单标签分类可以直接np.argmax挑最大的索引但多标签里每个概率都是独立判断需要手动设阈值。默认阈值是 0.5但实际项目中我很少直接用默认值因为事件类型的标签分布极不均衡有些标签的训练样本极少模型对它们的输出概率普遍偏低用 0.5 会把它们全部判成负类。import numpy as np y_pred_prob model.predict([val_ids, val_segs]) y_pred (y_pred_prob 0.5).astype(int) print(y_pred_prob.shape)4.2 阈值搜索遍历阈值找验证集 F1 最高的点把阈值当成超参数来调是项目里最值得学习的一个细节。我会在验证集上遍历 0.1 到 0.9 之间的步长对每个阈值算一次 F1取最优值对应的阈值作为最终预测标准from sklearn.metrics import f1_score, hamming_loss best_th, best_f1 0.5, 0.0 for th in np.arange(0.1, 0.9, 0.05): p (y_pred_prob th).astype(int) f1 f1_score(y_val, p, averagemicro) if f1 best_f1: best_f1 f1 best_th th print(best threshold:, best_th, best micro F1:, best_f1) p_final (y_pred_prob best_th).astype(int) print(hamming loss:, hamming_loss(y_val, p_final))这个搜索过程看起来简单但它能回答一个实际问题模型输出概率普遍偏低时阈值自动降到 0.25 附近模型过于自信时阈值会往 0.6 以上走。多标签评估指标里micro F1 把所有标签的预测结果汇总后再算 F1受稀有标签影响较小适合事件抽取这种类别很多的场景macro F1 先逐类算 F1 再平均对低频标签非常敏感通常数值会低很多报告时要写清楚用的是哪一种。4.3 model_evaluate.py 里到底该看哪些指标项目里自带 model_evaluate.py但我建议在跑它之前先明白每个指标对你的业务意味着什么否则只是看一串数字。指标计算方式适用场景子集准确率一条样本所有标签完全预测正确才算对严格场景偏保守micro F1汇总所有标签的 TP/FP/FN 后统一计算标签不均衡时的主指标macro F1每个标签单独算 F1 再取平均关注稀有标签时hamming loss所有标签位置上预测错误的平均比例衡量整体误差率事件抽取这种场景我一般以 micro F1 为主因为它不会被出现 3 次的低频事件类型主导。但你也应该留意哪些事件类型 F1 为 0那些通常是训练样本本来就极少或者阈值被压低后预测过头的类型。把每一类的 F1 单独打印出来往往能发现数据标注质量问题的线索。4.4 model_predict.py 单条推理把概率变成可读的标签结果预测脚本的核心任务是把 tokenizer、模型、阈值、标签名四样东西串起来def predict_one(text, thresholdbest_th): ids, seg tokenizer.encode(text, max_lenmax_len) proba model.predict([[ids], [seg]])[0] results [] for idx in np.argsort(proba)[::-1]: if proba[idx] threshold: results.append((mlb.classes_[idx], round(float(proba[idx]), 4))) return results print(predict_one(百度宣布收购云丁科技))这里np.argsort(proba)[::-1]的作用是把概率从高到低排序只保留大于等于阈值的标签。我见过有人直接model.predict(preprocessed)却忘记把结果和mlb.classes_对齐输出一串 0 和 1 根本看不出对应哪个事件类型这种问题其实就出在没保存 mlb。另一个常见错误是训练时 text 做了清洗而预测时没做比如训练阶段去掉了空格而预测时保留空格tokenizer 结果不同预测性能骤降。5. 避坑记录BERT 微调里那些翻车翻得最疼的地方5.1 坑一keras-bert 装上就跑不起来ImportError 或版本冲突现象from keras_bert import Tokenizer直接报错或者 import 成功但加载模型时提示找不到某个函数。原因keras-bert 这个包是 2019 年前后发布的适配的是 Keras 2.2.x 和 TensorFlow 1.x 的老接口。现在新环境默认装的是 Keras 3.x 或 TensorFlow 2.16API 变动很大keras-bert 依赖的keras.engine.topology这类内部路径在新版本里已经不存在了。解决我一般建议单独建一个虚拟环境跑这套代码Python 用 3.7 或 3.8TensorFlow 用 1.14 或 2.2 之间带tf.keras兼容层的版本Keras 锁在 2.2.4 前后配套的 numpy 版本也别装太新否则会有奇怪的二进制不兼容。这套组合虽然老但和 keras-bert 的依赖刚好咬合。别在这个环境里混装 Transformers 库很容易把兼容层搞乱。5.2 坑二GPU 显存 OOM训练直接崩掉现象model.fit跑第一个 epoch 没几步进程被 kill报资源耗尽。原因BERT-base 有 1.02 亿参数反向传播过程中还要保存中间激活值。max_len128、batch_size32 在 8G 显存上很容易爆。解决我会按“batch_size 降到 16 → max_len 降到 64 → 使用混合精度 → 换更大显存”的顺序排查。对事件抽取这种短文本任务max_len64 并不会掉多少分但显存占用会下降一大截。如果你只有 8G 显存max_len128、batch_size8 是能跑起来的组合就是训练会慢一些。5.3 坑三训练 loss 完全不降现象loss 在前几百步纹丝不动甚至偶尔升高验证 F1 一直是 0 左右。原因学习率设置不对。BERT 微调用 1e-3 这种常规学习率会让权重更新幅度远大于预训练参数的细腻程度等于把参数打乱重来另一个可能是 label 编码错误比如 MultiLabelBinarizer 之前没把字符串转成 list导致每个字符独立成一类。解决先把学习率压到 2e-5加上 warmup_ratio0.1再检查 y_train 的形状正常情况下它应该只有几十列不可能有几千列。我调试时会抽 500 条样本跑两三个 epoch如果 loss 能稳定下降再上全量数据这样排查速度快很多。5.4 坑四预测结果全是负类一条正例都出不来现象模型在验证集上 F1 还行但拿业务文本去预测输出全是空列表或全 0。原因最可疑的是阈值问题。模型对长尾标签的输出概率可能普遍在 0.2 到 0.4 之间硬碰 0.5 阈值当然全被滤掉。另一个可能是训练和预测的预处理不一致比如训练时文本做了繁简转换而预测时没做tokenizer 结果差异很大。解决先打印一条样本前 10 个标签的概率值肉眼看它们集中在什么区间再决定是调阈值还是修预处理。要是概率整体很低直接用第 4 章的阈值搜索方法在验证集上找最优阈值往往一次见效。5.5 坑五微调后模型“失忆”通用能力下降现象模型在目标任务上 F1 很高但在另一批相似业务语料上泛化很差表现为对训练集里出现过的措辞很敏感换一种说法就识别不出来。原因这是微调的典型副作用预训练阶段学到的通用语义被任务数据覆盖了样本量越少越严重。解决我通常冻结 BERT 前 10 层左右只微调后几层和分类层能在很大程度上保留通用语义如果还是过拟合就把冻结层数再加直到验证集表现稳定。对事件抽取这种强语义任务底层通用特征保留得越好遇到没见过的句式时越不容易翻车。6. 进阶玩法FGM 对抗训练与阈值的自适应调法6.1 FGM.py 在做什么给 embedding 加一层梯度扰动项目里附带了一个 FGM.py这就是很多人听说过的对抗训练。它的核心思想不是让模型强行拟合样本而是在 embedding 方向上找到一个能让 loss 增大最多的扰动把扰动加到 embedding 上让模型见过更难一点的样本。这样训练出来的模型决策边界更平滑对同义改写、标点抖动这类噪声更鲁棒。import tensorflow as tf from keras import backend as K class FGM: def __init__(self, model, emb_nameEmbedding-Token): self.model model self.emb_name emb_name self.backup {} def attack(self, epsilon1.0): for layer in self.model.layers: if layer.name self.emb_name: emb layer.get_weights()[0] grad K.get_session().run( tf.gradients(self.model.total_loss, layer.trainable_weights)[0] ) delta epsilon * grad / (K.sqrt(K.sum(grad**2)) 1e-8) self.backup[layer.name] emb layer.set_weights([emb delta]) def restore(self): for layer in self.model.layers: if layer.name in self.backup: layer.set_weights([self.backup[layer.name]])这段代码的生效方式是每个 batch 正常前向计算后先求当前梯度把归一化后的梯度当作扰动加进 embedding再用扰动后的权重跑一次反向传播更新参数最后把 embedding 恢复原值。epsilon 的取值决定扰动强度我一般用 0.5 到 1.0太大会让 loss 震荡。需要提醒的是model.fit这种高层接口没法在内部插入 attack 和 restore所以要么像 FGM.py 这样把训练循环拆出来自己控制要么在自定义 Callback 里做直接套用会静默不生效这是最常见的误操作。6.2 阈值不该在最终验证集上反复调用同一份验证集找最优阈值再在它上面报告 F1得到的分数是乐观偏差。把数据切成三份成本很高所以我通常会把那 10% 的验证集再切一半一半用于阈值搜索一半用于最终确认。这样做的好处是阈值不会对特定验证集样本过拟合项目上线后换一批真实文本效果不会突然掉一截。6.3 快速验证流程上线前必走一遍我拿到这套代码后会把训练流程固定成三步第一步从全量数据里抽 5% 样本跑 3 个 epoch验证数据管线、标签编码和模型装配都没问题第二步用全量数据跑满 6 个 epoch同时在验证集上做阈值搜索记下最优阈值第三步用 FGM 从头训练一遍比较两次的验证集 F1。FGM 通常会涨 1 到 2 个百分点的 micro F1如果没涨我会检查是不是 FGM 没有真正生效。而且我现在每次微调前都会强制自己走一遍固定流程先看长度分布再确认标签编码维度然后固定随机种子跑一次小样本验证最后才全量训练。这套流程其实不复杂但能挡住 90% 的翻车问题。希望帮到你。本文还有配套的精品资源点击获取