简介:一套基于Keras和Keras-bert的文本多标签分类项目,面向NLP学习与项目实践者,演示如何对BERT进行微调来完成多标签分类任务。项目选用2020语言与智能技术竞赛事件抽取数据作为样例,将事件抽取问题转化为多标签分类,适合希望借助预训练模型解决实际标签场景的读者。压缩包共10个文件,大小约1.01MB,其中4个Python脚本分别负责模型训练、评估、预测与FGM对抗训练,2个CSV文件为训练集和测试集,另有中文BERT词表、依赖说明及README文档,目录结构清晰。目前已有1634人学习。通过此项目可掌握Keras-bert调用流程、多标签分类的标签处理与评估细节,同时了解FGM对抗训练如何增强模型鲁棒性,是一份可复用性强的文本分类参考实现。
1. 多标签文本分类的实战入口:用 Keras-bert 对 BERT 做微调,先把一个难点说清楚
多标签文本分类是 NLP 项目里踩坑率最高的任务形态之一。拿 2020 语言与智能技术竞赛:事件抽取任务来说,一条新闻报道经常同时包含“产品发布”和“高管变动”两种事件,如果拿单标签分类模型硬套,模型会在标签共现样本上直接翻车。这个项目用 Keras 和 Keras-bert 实现文本多标签分类任务,核心是加载中文 BERT 预训练权重做微调,在[CLS]向量上接一个全连接层,输出端用 sigmoid 激活做多标签预测。适合你的场景是:需要快速跑通 BERT 微调、样本本身有标签共现、又不想切换到 transformers 全家桶的情况。下面我按文件结构、训练脚本、踩坑记录到阈值调优的顺序,把整个跑通过程拆开。
2. 把 BERT 微调搬到 Keras 生态:Keras-bert 的运行机制与工程选型
2.1 为什么选 Keras-bert 而不是 transformers
如果你习惯管理层维护的 transformers 库,第一次看到 Keras-bert 可能会觉得它够“裸”:没有Trainer,没有一键pipeline,甚至文档都停留在几年前的风格。但恰恰是这种裸,让它在多标签微调任务里更好控制。
Keras-bert 本质上就是一组 Keras 层往外拼成一个完整 BERT 模型,load_trained_model_from_checkpoint加载后直接返回 Keras 模型对象。这意味着你可以把它嵌进keras.Model、Sequential,也可以随时借用 Keras 回调、自定义 loss、对抗训练这些成熟机制。transformers 在 TF 2.x 里也能做到这些,但老项目里如果 TensorFlow 和 Keras 版本被锁住,升降级往往会拖出更多兼容问题。
我一般只在三种情况下优先选 Keras-bert:一是手头代码本来就基于 Keras 写的,迁移成本最低;二是要频繁访问中间层权重,做 FGM 这类对抗训练,Keras-bert 的权重命名很规整,好过滤;三是离线环境里只给了 Keras 生态的依赖清单,不想再引入 transformers 那套依赖树。
踩坑方面要提一句:这个库已经不活跃更新,你在网上搜到的多数教程都是 TF 1.x + Keras 2.3 时代的产物。拿最新版 TF 直接跑,大概率会撞上兼容性问题,这我在第 4 章里专门展开讲。
2.2 中文 BERT 怎么被 Keras-bert 吃进去:tokenizer 与 checkpoint 的依赖链
项目里默认的中文预训练模型是chinese_L-12_H-768_A-12,也就是 BERT-base 中文版:12 个 Transformer 编码层、隐藏层 768 维、12 个注意力头,参数量在 1.1 亿上下。这个模型文件名里的L-12_H-768_A-12就是它的三个关键结构参数,训练脚本里配置的seq_len决定了输入序列长度,不影响预训练权重结构。
这个目录里,vocab.txt只是词表,负责把文本映射成 token id。Keras-bert 加载权重时实际需要三件套:
| 文件 | 作用 | 缺失后果 |
|---|---|---|
bert_config.json | 模型结构配置,包含层数、隐藏层维度、注意力头数 | 模型无法初始化 |
bert_model.ckpt | 预训练权重,训练时从这里做微调起点 | 模型加载直接报错 |
vocab.txt | 中文词表,Tokenizer 依赖它做切分 | tokenizer 无法初始化 |
一段文本要喂给 BERT,不能直接把字符串丢进去。Keras-bert 的Tokenizer会先按 WordPiece 机制切词,中文基本是逐字切,再把切好的 token 转成两个数组:一个存 token id 序列input_ids,一个存段落 id 序列segment_ids。第一个 token 固定是[CLS],句尾补[SEP],不足长度补[PAD]。多标签分类中,我们最后只取[CLS]位置的输出向量作为整句话的语义表示。
这里有个关键点:训练和预测时必须使用同一套 tokenizer 和同一个seq_len,否则 token 对齐就乱掉。我见过不少人训练时seq_len=256,预测时图省内存改成 128,导致维度直接冲突或者静默截断,后面第 4 章会讲这个坑。
2.3 项目文件清单与职责边界
解开压缩包后,先不要急着跑model_train.py。我建议先对照文件清单过一遍,明确每个脚本的输入和输出,不然训练到一半发现数据文件路径不对,心态容易崩。
| 文件/目录 | 职责 |
|---|---|
model_train.py | 主训练脚本,加载 BERT 权重、数据、训练并保存 h5 模型 |
model_predict.py | 推理脚本,加载训练好的 h5 模型做多标签预测 |
model_evaluate.py | 评估脚本,计算准确率、召回率、F1 等指标 |
FGM.py | Fast Gradient Method 对抗训练实现,提升泛化能力 |
data/train.csv | 训练语料,格式一般是一列文本、一列多标签 |
data/test.csv | 测试语料,用于最终评估 |
chinese_L-12_H-768_A-12/vocab.txt | 词表文件 |
requirements.txt | 依赖版本锁定文件 |
README.md | 使用说明 |
单看文件布局,这是个很标准的 Keras 微调项目:数据、训练、评估、预测四个环节分离。model_train.py是核心入口,model_predict.py和model_evaluate.py都依赖它产出的 h5 文件。你拿到压缩包后要特别确认一件事:chinese_L-12_H-768_A-12目录下面是否只有vocab.txt。如果只有词表,那bert_config.json和bert_model.ckpt需要自己去下载补全,这是这个项目最容易卡住的第一步。
3. 训练脚本实操:从 train.csv 到 model_train.py 的参数体系
3.1 数据格式与多标签编码
项目用到的数据是 2020 语言与智能技术竞赛:事件抽取任务里的那批语料。官方任务本身是个结构抽取任务,需要抽事件类型、触发词、论元;但如果你只想判断“这句话涉及哪些事件类型”,就可以把它简化为一个多标签分类任务来建模。
train.csv的常见格式是两列,一列存文本,一列存标签,多个标签之间用符号隔开。先写一段数据预处理代码,把标签集合收集起来,再把每行样本转成 multi-hot 编码:
import pandas as pd import numpy as np df = pd.read_csv('data/train.csv', encoding='utf-8') df = df.dropna(subset=['text', 'label']) # 收集全部标签,构造 id 映射 label_set = set() for labels in df['label'].str.split(','): label_set.update(labels) label2id = {label: i for i, label in enumerate(label_set)} id2label = {i: label for label, i in label2id.items()} num_labels = len(label_set) print(f'标签数量: {num_labels}') # 把每行的标签列表转成 multi-hot 向量 def encode_labels(label_str, label2id=label2id, num_labels=num_labels): vec = np.zeros(num_labels, dtype=np.float32) for label in label_str.split(','): label = label.strip() if label in label2id: vec[label2id[label]] = 1.0 return vec df['label_vector'] = df['label'].map(encode_labels) print(df.head(3))这段代码做的事很简单:先遍历全部数据收集标签集合,再为每个标签分配一个整数 id,最后把每行标签串转成固定长度的 0/1 向量。注意label2id的构建一定要在全局数据上做,不能只在一部分样本上做,否则训练集和验证集会因为标签空间不一致直接崩掉。
num_labels就是模型输出层的神经元个数。这个数字直接在model_train.py里被使用,所以如果你换了自己的数据集,标签数量变了,这一处必须跟着改。
3.2 model_train.py 的主干流程
整个训练脚本的核心其实不长。加载 BERT 预训练模型、接上多标签输出层、用二分类交叉熵训练。下面是主干代码,实际项目里文件结构可能稍有出入,但骨架基本一致:
import numpy as np import keras from keras import layers, optimizers from keras_bert import load_trained_model_from_checkpoint, Tokenizer seq_len = 128 config_path = './chinese_L-12_H-768_A-12/bert_config.json' checkpoint_path = './chinese_L-12_H-768_A-12/bert_model.ckpt' vocab_path = './chinese_L-12_H-768_A-12/vocab.txt' tokenizer = Tokenizer(vocab_path) # 加载预训练 BERT,training=True 是关键 bert_model = load_trained_model_from_checkpoint( config_path, checkpoint_path, seq_len=seq_len, training=True ) # 两个输入:token ids 和 segment ids x1_in = layers.Input(shape=(seq_len,), name='input_indices') x2_in = layers.Input(shape=(seq_len,), name='input_segments') x = bert_model([x1_in, x2_in]) # 只取 [CLS] 向量作为整句表示 cls_out = layers.Lambda(lambda t: t[:, 0, :], name='cls_extract')(x) cls_out = layers.Dropout(0.1)(cls_out) # 多标签输出层,sigmoid 每个维度独立判断 pred = layers.Dense(num_labels, activation='sigmoid')(cls_out) model = keras.models.Model([x1_in, x2_in], pred) model.compile( optimizer=optimizers.Adam(learning_rate=2e-5), loss='binary_crossentropy' ) model.summary()这里有两个必须理解的细节。第一,load_trained_model_from_checkpoint里的training=True表示加载后以训练模式运行模型,批归一化和 Dropout 行为是训练模式;如果这里写成False,训练时前面的 BERT 层就不会正常更新,甚至会出现验证集正常但训练集 loss 不降的诡异现象。
第二,Lambda(lambda t: t[:, 0, :])取的是第一个 token,也就是[CLS]位置的向量。BERT 输入的每个 token 都对应一个 768 维输出,整句输入会产生seq_len × 768的一个矩阵。分类任务里我们不需要每个 token 的输出,只要这个代表全句语义的向量。
输出层用 sigmoid 而不是 softmax,这是多标签分类和单标签分类的本质区别。softmax 强制所有类别的概率之和为 1,多标签场景下一句话可以同时属于多个事件类型,几个概率同时接近 1 才是对的,所以每个输出节点必须独立判断。
3.3 参数怎么调:batch_size、seq_len、学习率、warmup
BERT 微调不像训练普通神经网络那样随意。预训练权重已经很接近任务需要的语义空间,学习率开大一点就可能把学到的信息冲毁。项目里几个关键参数的合理范围如下:
| 参数 | 常见范围 | 调参说明 |
|---|---|---|
seq_len | 128 / 256 | 事件抽取数据通常句子不长,128 足够;如果文本是长段落,可以上调到 256,但显存占用会明显增加 |
batch_size | 16 / 32 | 显存够用就 32,小于 16 时梯度噪声偏大,收敛不稳定 |
learning_rate | 1e-5 ~ 3e-5 | BERT 微调推荐范围,超过 5e-5 容易掉点 |
dropout | 0.1 | 只加到分类头,BERT 内部 keep 默认 |
epochs | 3 ~ 5 | 小数据集 3 轮基本够,多了容易过拟合 |
warmup | 前 10% 步数 | warmup 让学习率从小步长升到大步长,前期不冲垮权重 |
我一般把学习率固定在 2e-5,batch_size 优先取 32,如果显存不够先降 batch 而不是降seq_len。seq_len下调会直接截断文本,丢掉事件触发的关键上下文,对多标签分类影响很直接。
每轮训练结束一定要保存模型权重文件。建议用 Keras 的ModelCheckpoint回调,按验证集 F1 或 loss 保存最优权重,别只保存最后一轮。多标签任务里最后一轮经常不是最优,尤其是训练后期已经过拟合,这点我在第 5 章还会提到。
3.4 把 FGM 对抗训练接到微调流程里
这个项目单独放了一个FGM.py,说明作者是在认真做泛化能力,不是简单跑通就完事。FGM(Fast Gradient Method)的思路很直观:在 embedding 层加上一个很小的扰动方向,让模型在扰动后的样本上也算一次 loss,相当于引入对抗性干扰让模型学得更稳。
常见实现是把扰动加到 embedding 权重上,前向算 loss,反向更新一次梯度,然后恢复原权重:
class FGM: def __init__(self, model): self.model = model self.backup = {} def attack(self, epsilon=0.5, emb_name='embedding'): for name, weight in self.model.trainable_weights: if emb_name in name: self.backup[name] = weight.value() grad = weight.gradient if grad is not None: weight.assign(weight - epsilon * grad / (np.linalg.norm(grad) + 1e-12)) def restore(self): for name, weight in self.model.trainable_weights: if name in self.backup: weight.assign(self.backup[name]) self.backup.clear()训练循环里配合使用:
fgm = FGM(model) for batch in data_generator: x_indices, x_segments, y_true = batch # 正常训练一步 loss = model.train_on_batch([x_indices, x_segments], y_true) # 加扰动再训练一步 fgm.attack(epsilon=0.5) loss_adv = model.train_on_batch([x_indices, x_segments], y_true) fgm.restore()epsilon=0.5是扰动幅度,值太大容易把权重带歪,太小起不到对抗作用。实际操作中可以先从 0.3 试起,观察验证集 F1 有没有提升,没有明显提升就调小。需要注意,emb_name的过滤规则要匹配 Keras-bert 里 embedding 层的真实命名,我一般会在训练前打印model.trainable_weights看一下实际层名,再去设置过滤条件,而不是盲写'embedding'。
4. BERT 微调避坑排查:五个我在现场踩过的坑
4.1 坑一:加载预训练权重后训练,loss 不降还掉点
现象是:model_train.py跑起来了,第一轮训练 loss 从初始值开始不降反升,验证集指标比随机预测还差。
原因:load_trained_model_from_checkpoint里的training参数没设成True。这个参数控制 BERT 内部的 Dropout 和 LayerNormalization 是训练模式还是推理模式,如果训练时误用了training=False,整个模型像在推理模式下做训练,梯度能反向传播,但归一化统计量不会正确更新,训练就废了。
解决:加载时显式传training=True;保存模型后推理时再传training=False。我在model_predict.py里也会重新加载一遍模型结构,保证推理模式正确。
4.2 坑二:验证集所有标签全预测成 0
现象是:训练几轮后 loss 已经降到 0.2 以下,但把所有验证样本喂给模型,输出概率都小于 0.5,最终预测全是 0。
原因:多标签数据里正样本比例通常很低。事件抽取数据中某一种事件类型可能在 100 条里只有 5 条,负样本太多了,模型学到最后倾向于所有输出都向着 0 收敛,0.5 这个默认阈值在很多类别上根本不可用。
解决:不要拘泥于 0.5 阈值。模型输出是连续概率值,先保存概率矩阵,再在验证集上做阈值网格搜索,找一个能让 F1 最优的阈值。这个内容我在第 5 章会单独给一段代码。应急办法是给 loss 加class_weight,把正样本权重调高,但这只能缓解,解决不了阈值适配问题。
4.3 坑三:keras_bert 和 Keras/TensorFlow 版本冲突,compile 直接报错
现象是:执行到model.compile或者model = keras.models.Model(...)时报错,常见有AttributeError: module 'keras' has no attribute 'layers',或者TypeError: call() missing 1 required positional argument。
原因:keras_bert 最后一次活跃更新停留在 TF 1.x/2.0 时代,后续 TF 版本调整内部 API,keras.layers的导入路径和Model初始化方式都变了,新版兼容直接断掉。
解决:严格按requirements.txt锁定的版本安装依赖。我这边最稳的组合是tensorflow==1.15+keras==2.3.1+keras_bert==0.8.3。如果必须在 TF 2.x 下跑,建议装tensorflow-gpu==2.2左右的老版本,并保证tf.keras和独立keras不要混用,混用会出现模型参数放不到同一设备上的诡异问题。
4.4 坑四:预测结果整体错位,类别排序完全不对
现象是:model_predict.py跑完,输出概率和标签顺序对不上,比如id2label明明是从train.csv构建的,预测脚本里却用了另外一套索引。
原因:训练时构建了一次label2id,预测时又重建了一套,两套映射没有做序列化保存。一旦类别的枚举顺序改变,同一个位置的输出对应的就不是同一个标签了。
解决:训练完把label2id和id2label存成 JSON 文件,model_predict.py启动时直接读这个文件,禁止二次重建。这个文件是所有下游推理的依赖,丢了就只能重训。
4.5 坑五:chinese_L-12_H-768_A-12 目录缺少权重文件,训练直接跑死
现象是:从压缩包解压后直接跑model_train.py,报出FileNotFoundError: bert_model.ckpt或bert_config.json不存在的错误。
原因:项目压缩包里chinese_L-12_H-768_A-12目录下通常只放了vocab.txt,因为模型权重文件太大不适合直接打包分享。补全权重文件这一步,被很多人默认成了“项目自带”,结果卡在第一步。
解决:先检查这个目录下有没有bert_model.ckpt、bert_config.json、vocab.txt三个文件。缺前两个就去下载完整的 BERT 中文预训练模型包,解压后放回对应目录。下载完再看一眼bert_config.json里的hidden_size是不是 768,跟model_train.py里seq_len无关,但和 BERT 内部维度直接相关,错了会在 load 时直接报 shape mismatch。
5. 阈值调优与验证技巧:把 model_evaluate.py 变成多标签决策器
训完模型不代表事情结束了,多标签分类的最后一步本来是调阈值。很多文本分类项目评估只看 accuracy,但对多标签任务来说这指标没有意义:如果一句话同时有 3 个真实标签,模型预测出其中 2 个,accuracy 会判它是错的,但实际业务里能召回 2 个已经很有用。所以我习惯在model_evaluate.py里先算全概率矩阵,再做阈值扫描。
import numpy as np from sklearn.metrics import f1_score # y_true: one-hot 编码的真实标签,shape 为 (n_samples, n_labels) # y_pred_prob: 模型输出的概率矩阵,shape 与 y_true 相同 best_f1 = 0.0 best_threshold = 0.5 for threshold in np.arange(0.25, 0.85, 0.05): y_pred_bin = (y_pred_prob > threshold).astype(int) micro_f1 = f1_score(y_true, y_pred_bin, average='micro') if micro_f1 > best_f1: best_f1 = micro_f1 best_threshold = threshold print(f'最优阈值: {best_threshold}, 最优 Micro-F1: {best_f1:.4f}')average='micro'把所有类别样本汇总后计算 F1,适合事件抽取这种各类别样本数差距悬殊的场景;如果你的业务更关注每个事件类型都要独立可用,可以同时看一眼average='macro',两者差异大的时候说明某几个类别被模型严重忽略了。
还有一种容易被忽略的验证技巧:分类阈值扫描结果不能只保存一次,换训练轮次后阈值大概率会变。我通常把每轮的验证概率矩阵都存下来,训练结束后统一做阈值搜索,最后把最优阈值写进model_predict.py。基于概率阈值算出的结果再做一次事件类型维度的汇总分析,比如哪些类型总是被同时预测出来,哪些类型被漏掉,这个分析能反过来指导你是否需要增加训练样本。
顺便说一句,model_evaluate.py输出的指标里如果 Macro-F1 和 Micro-F1 差距大于 0.2,基本可以断定少数类被模型放弃了,这时候优先考虑的核对该类别的训练样本量,其次再考虑换阈值。数据层面没有修好,阈值再扫都没有用。
多标签分类里,阈值和标签映射是我每次交付前必查的两项。从那以后,我每次跑多标签项目都会强制走一遍完整流程:先做全局阈值扫描,再确认阈值持久化到推理脚本,最后核对标签映射文件版本。规则听起来简单,但它确实是这类项目最容易出错的地方。希望帮到你。
本文还有配套的精品资源,点击获取