Keras-bert实战:BERT微调实现多标签文本分类全流程解析

发布时间:2026/9/12 14:15:07
Keras-bert实战:BERT微调实现多标签文本分类全流程解析 简介面向需要完成文本多标签分类任务的高校学生与入门开发者这份基于Keras与Keras-bert的源码包提供了对BERT进行微调的完整可运行方案覆盖从数据预处理、模型训练到结果评估与预测的整套流程能直接服务于期末大作业、课程设计或实际项目开发。压缩包共10个文件包括4个Python脚本分别承担训练、预测、评估和FGM对抗训练、2个CSV数据文件、1份依赖说明以及1份操作文档整体大小仅1.01MB部署快捷、使用门槛低。代码中包含大量注释关键步骤如数据划分、BERT层参数设置、训练轮次和评估指标均有说明新手可参考文档逐步跑通内置中文BERT词汇表和对应的模型配置文件附带可直接运行的样例数据同时支持替换成自定义数据集便于围绕自身场景展开实验。FGM对抗训练脚本可作为增强模块帮助提高模型在文本分类中的鲁棒性适合不同水平的读者按需选用。目前已有137人学习下载是完成文本多标签分类课程设计或期末大作业时一份轻量而实用的参考实现。1. 多标签文本分类为什么绕不过BERT微调一条客服工单可能同时命中“退款”“支付”“体验”三个标签这类任务没法用softmax压缩成一选一。文本多标签分类的标准工业做法是拿预训练好的BERT当底座在上面接一个独立的sigmoid输出层再带着少量标注数据做一次“二次训练”——这就是BERT微调。标题里把Keras、Keras-bert和多标签分类放一起本质上要解决三件事Keras怎么加载BERT权重、多标签目标怎么翻译成输出层和损失函数、微调超参怎么设才不白训。这套做法适合标签体系有重叠、单条文本可能归属多个类别的场景也适合团队里已经以Keras为主、不想为BERT单独上一套框架的情况。真正容易翻车的点不在模型结构而在输入组装和微调阶段的参数控制。2. Keras-bert 接 BERT安装、Tokenizer 与三个输入的组装Keras-bert是一个把BERT官方checkpoint封装成Keras Layer的第三方库核心价值是让习惯了Keras API的人用几行代码把BERT搬进自己的模型而不是去读TensorFlow的Estimator或者PyTorch的transformers源码。整个使用链路分三段加载词汇表、组装输入、加载预训练权重。2.1 多标签分类和单标签分类的分歧点损失函数跟着目标走先把问题本质说清楚。单标签分类里一个样本只属于一个类别输出层的softmax把所有类别的概率归一化成和为1各类别之间存在竞争关系。多标签分类里一个样本可以同时属于多个类别比如“退款”和“支付”可以同时为真类别之间不互斥此时softmax等于强制模型在互斥前提下做选择会直接压制多标签同时出现的概率表达。多标签的分类头在结构上和单标签只差一个激活函数把softmax换成sigmoid每个输出节点独立判断“这个标签是否成立”。损失函数也要从categorical_crossentropy换成binary_crossentropy对一个样本而言它对每个标签独立计算二分类损失再求和。Keras里这是两个字符的差别但背后是模型对目标分布的假设换了。2.2 安装 keras-bert 前先确认 Keras 2.x 环境Keras-bert的接口和Keras 3.x不兼容它在Keras 2.x的API体系下封装最常见的问题是一上来就pip install结果发现load_trained_model_from_checkpoint导入失败或Layer命名不兼容。安装前先确认环境pip install keras-bert python -c import keras; print(keras.__version__) python -c from keras_bert import load_trained_model_from_checkpoint; print(ok)如果Keras版本是3.x建议建一个Python 3.8~3.10的虚拟环境装TensorFlow 2.4到2.10之间某个版本让Keras跟随TensorFlow内置的2.x版本而不是单独装新Keras。一个可复现的依赖组合是pip install tensorflow2.6.0 keras-bert0.89.0参数说明keras-bert0.89.0是发布较早但稳定的版本后续没有大的功能更新锁定TensorFlow版本是因为Keras 2.x的backend在2.6附近变更过一次TensorFlow 2.10之后再装容易出现符号冲突。装完后一定要跑上面第二行验证导入Keras-bert的报错经常发生在导入阶段而不是运行阶段。2.3 用 Tokenizer 组装 token_ids、segment_ids喂给 Keras 模型BERT的输入不是一段原始字符串而是三个对齐的数值序列。Keras-bert里封装好的Tokenizer会把文本转成token_ids和segment_ids两个list其中token_ids里CLS占101、SEP占102segment_ids用来区分句子A和句子B文本分类场景只用一句话segment_ids全部为0。from keras_bert import Tokenizer # vocab.txt 是 BERT checkpoint 自带的词典文件 token_dict Tokenizer.load_vocabulary(vocab_pathchinese_L-12_H-768_A-12/vocab.txt) tokenizer Tokenizer(token_dict, casedFalse) # 对单条文本做编码 ids, segments tokenizer.encode(支付密码错误退款迟迟不到账, max_len128) print(ids[:10]) print(segments[:10])输出里ids的前两个位置分别是101和“支”对应的词表编号最后一个有效位置之后补0到128长度。这里有一个容易踩的坑max_len决定了整个模型能看到的文本长度BERT的位置编码上限是512但不代表每条样本都塞到512。文本分类任务里超过128个字的信息增量通常很小长度拉长带来的显存开销是平方级增长一般从128起步跑通后如果发现长文本被截断影响明显再上调到256。接下来加载BERT权重Keras-bert的加载入口接收两个文件路径和一个序列长度from keras_bert import load_trained_model_from_checkpoint bert_model load_trained_model_from_checkpoint( config_filechinese_L-12_H-768_A-12/bert_config.json, checkpoint_filechinese_L-12_H-768_A-12/bert_model.ckpt, seq_len128 ) bert_model.summary()返回的bert_model本身就是一个Keras Model输入是[token_ids, segment_ids]输出是最后一层Transformer编码器的张量shape为(batch_size, 128, 768)。load_trained_model_from_checkpoint的参数含义config_file是BERT的json配置文件记录层数、隐藏维度、注意力头数checkpoint_file是预训练权重文件路径注意不需要带.index和.data后缀seq_len必须和tokenizer.encode里的max_len保持一致否则拼接模型时维度对不上。验证是否加载成功直接看summary里第一个encoder层的参数数量如果是0或者None说明checkpoint文件路径不对。3. 多标签输出层sigmoid、二分类交叉熵和阈值的取舍输入组装好、BERT权重能加载之后下一步是决定“BERT输出变成标签的最后一公里怎么走”。这里的选择直接决定模型收敛速度和预测效果。3.1 为什么输出层必须用 sigmoid 而不是 softmaxsoftmax会把所有输出节点的值归一化到总和为1某个标签概率升高必然压低其他标签。多标签场景里“退款”和“支付”同时为1是数据分布本身就允许的softmax的归一化约束和这个事实互相矛盾。sigmoid则对每个输出节点独立做映射输出值在0到1之间互不影响模型可以同时给出两个接近1的概率。损失函数也要跟着换。Keras里binary_crossentropy会对每个输出节点单独计算交叉熵再对整批样本做平均这和softmax交叉熵的梯度传播路径不同前者的梯度只由单个标签的预测误差驱动后者会被其他类别的概率牵制。换个直白的说法多标签分类本质上是同时训练若干个共享底层的二分类器底层的语义表示由所有标签共同塑造。3.2 在 BERT 输出上接多标签头的最小代码BERT模型的输出是整个token序列的编码结果要拿来做分类常见做法是取第一个位置即CLS位置的向量它在预训练阶段被设计成聚合了整个句子的语义。把BERT接到Keras的Sequential模型里比较别扭因为BERT输入有两个正确做法是用函数式APIfrom tensorflow.keras import layers, models from tensorflow.keras.optimizers import Adam # bert_model 来自上一节的 load_trained_model_from_checkpoint bert_output bert_model.output # shape: (batch, 128, 768) # 取 CLS 位置向量 cls_vector layers.Lambda(lambda x: x[:, 0], namecls_token)(bert_output) cls_vector layers.Dropout(0.3, namecls_dropout)(cls_vector) # 多标签输出层num_labels 是标签总数 num_labels 10 outputs layers.Dense(num_labels, activationsigmoid, namemulti_label_head)(cls_vector) # 组装完整模型 model models.Model(inputsbert_model.inputs, outputsoutputs) # 多标签任务必须用 binary_crossentropy model.compile( optimizerAdam(learning_rate2e-5), lossbinary_crossentropy, metrics[accuracy] )代码逻辑说明bert_model.inputs返回的是一个列表包含[token_ids, segment_ids]两个输入张量和之前tokenizer.encode的输出一一对应。Lambda层取出x[:, 0]即每个样本的CLS向量shape从(batch, 128, 768)变成(batch, 768)。Dropout在微调阶段必不可少预训练权重已经很强不加正则很容易在少量标注数据上过拟合。Dense层的输出节点数等于标签总数激活函数必须是sigmoid。有个替代方案是把最后的Dense层去掉sigmoid改在loss里设置from_logitsTrueoutputs layers.Dense(num_labels, namemulti_label_head)(cls_vector) model.compile(losstf.keras.losses.BinaryCrossentropy(from_logitsTrue), ...)这个做法的数值稳定性更好Dense层输出的logits和sigmoid组合在一起由损失函数统一算梯度避免sigmoid在两端饱和导致的梯度消失。两种写法效果等价二选一即可。3.3 阈值 0.5 只是起点不是终点模型输出的不是一个标签集合而是每个标签的概率。常规做法是概率大于0.5就认为命中了但0.5默认了“正负样本代价相等、先验概率均衡”在标签分布倾斜时这个假设不成立。比如“退款”标签在训练集里出现频率是30%“发票”只出现2%模型对后者的预测概率天然偏向低位阈值设在0.5会丢掉大量本该命中的样本。阈值的确定应该留给验证集而不是拍脑袋定。我一般先把验证集预测概率存下来再在0.3到0.7区间内按步长扫描选一个整体F1最高的值这个做法在后面的章节展开。4. 微调实战BERT 微调的参数设计与训练回调模型结构搭完并不意味着可以直接开始训练。BERT微调和从零训练一个网络是两套完全不同的超参逻辑预训练权重已经包含大量语言知识训练的目的是让这些知识向你的标签体系偏移步子迈大了反而把原有特征冲掉。4.1 学习率2e-5 起步warmup 和线性衰减怎么搭BERT原论文对微调有明确建议学习率取2e-5、3e-5、5e-5这几个档位epochs取2到4batch size取16或32。这个范围比普通CNN训练小一个数量级因为预训练权重处于一个已经收敛的局部最优附近学习率过大直接把权重推出这个区域效果甚至不如不微调。实际操作里我习惯给优化器配一个warmup前10%的训练步数让学习率从0线性升到目标值等模型从预训练分布过渡到分类任务分布之后再用线性衰减把学习率降到接近0。Keras里可以用tf.keras.optimizers.schedules实现from tensorflow.keras.optimizers.schedules import PolynomialDecay import numpy as np total_steps int(np.ceil(len(train_texts) / batch_size)) * epochs warmup_steps int(total_steps * 0.1) lr_schedule PolynomialDecay( initial_learning_rate2e-5, decay_stepstotal_steps, end_learning_rate0.0, power1.0 )如果想省事不自己写调度器还有个近似做法前1到2个epoch用2e-5之后把learning rate降到5e-6再训练1个epoch效果非常接近。注意warmup里不要用学习率扫描BERT微调对learning rate极其敏感一个网格搜索的成本足以跑完整轮训练。4.2 冻结策略全量微调还是只微调顶层另一个常见问题是BERT的12层要不要全部参与训练。两种路线各有适用场景数据量小几千条级别时全量微调容易让底层通用语义特征被少量标注样本带偏这时候冻结前几层、只训练靠后的编码层和分类头更稳数据量大且标签体系和预训练语料差异明显时全量微调效果上限更高。Keras里冻结某个Layer就是置trainableFalse参数是否更新要看模型编译之后是否重新compile# 冻结 BERT 的前 8 层微调后 4 层 分类头 for layer in bert_model.layers: if hasattr(layer, name) and layer.name.startswith(Encoder-) and int(layer.name.split(-)[1]) 8: layer.trainable False model.compile(...) # 修改 trainable 后必须重新 compile需要说明的是bert_model.layers里的名称是Encoder-0到Encoder-11这样的格式Encoder-8到Encoder-11加上分类头是训练的主力。冻结分层和LoRA这类参数高效微调是两条路线冻结选择的是“部分层完全不更新”LoRA是在权重旁边加低秩旁路如果目标是跑通多标签分类冻结分层足够用不用为了追新而引入额外依赖。4.3 EarlyStopping、模型保存和 batch size 的配合训练阶段的第三个关键点是回调配置。EarlyStopping监控val_loss一旦连续多个epoch不下降就提前停止并恢复最佳权重同时用ModelCheckpoint保存验证集上表现最好的模型防止训练结束只留下最后一个epoch的权重而最后一个epoch往往已经过拟合。from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint # 训练数据需要把 tokenizer.encode 的输出整理成两个 numpy 数组 # train_ids, train_segments, train_labels # val_ids, val_segments, val_labels callbacks [ EarlyStopping( monitorval_loss, patience2, restore_best_weightsTrue ), ModelCheckpoint( bert_multilabel_best.h5, monitorval_loss, save_best_onlyTrue, save_weights_onlyTrue # 只保存权重不保存完整结构 ) ] model.fit( x[train_ids, train_segments], ytrain_labels, validation_data([val_ids, val_segments], val_labels), batch_size16, epochs5, callbackscallbacks, verbose1 )batch_size16在BERT微调里是常见选择显存占用低梯度噪声也能起到一定正则作用。如果GPU显存允许32通常收敛更快但对小数据容易过拟合8以下会导致每个batch的梯度估计方差大训练震荡明显。patience2表示验证集损失连续两轮不降低就停已经能应对大部分情况。这里有个隐含要求训练数据必须一次性做tokenize不要在fit里传原始字符串否则每次epoch都要重复做转换速度慢一个数量级。train_ids和train_segments的shape都是(样本数, 128)train_labels是(样本数, num_labels)用numpy的float32数组存储。5. 验证与排错多标签预测最容易翻车的三个细节5.1 标签分布失衡时只看 accuracy 会骗自己model.compile里的metrics[accuracy]在Keras中对于多标签输出会以“所有标签全部预测正确”才算一个样本预测对在标签稀疏的场景下这个值大概率在90%以上因为模型只要把多数标签预测为0准确率就已经很高。真正需要看的是每个标签的精确率和召回率from sklearn.metrics import classification_report # 验证集预测 val_probs model.predict([val_ids, val_segments]) val_pred (val_probs 0.5).astype(int) # target_names 是标签名列表 print(classification_report(val_labels, val_pred, target_nameslabel_names))输出里重点看macro avg一行的F1值。如果某个标签的召回率明显低于精确率说明模型输出偏向保守阈值需要往下调。多标签任务建议在验证集上记录micro-F1和macro-F1两个指标micro-F1受高频标签主导反映整体效果macro-F1给每个标签相同权重适合标签不均衡时衡量模型对低频标签的识别能力。5.2 预测结果全零或全一检查阈值和正样本占比模型训练正常但预测时所有样本都没有命中任何标签是阈值设置问题概率大于0.5才记为1而某些标签的正样本占比本来就低模型学到的输出均值远低于0.5。先跑一个统计脚本看验证集预测概率的分布import numpy as np val_probs model.predict([val_ids, val_segments]) print(概率均值:, np.round(val_probs.mean(axis0), 3)) print(概率最大值:, np.round(val_probs.max(axis0), 3))如果大部分标签的概率均值在0.1以下、最大值在0.6左右说明模型已经学到了知识只是阈值定得太高。把阈值降到0.3甚至0.2再配合5.3的校准步骤可以解决。全零的另一成因是训练标签做了one-hot编码但存在错位比如标签列索引和词汇表顺序没对齐排查方法是打印几条训练样本的train_labels肉眼核对。5.3 在验证集上做阈值再校准而不是拍脑袋定 0.5最实用的技巧把阈值本身当成超参去搜索。验证集预测概率已经拿到后对每个标签可以共用同一阈值也可以逐标签找最优阈值后者在标签分布差异大时提升明显from sklearn.metrics import f1_score best_thresholds [] for label_idx in range(num_labels): best_th 0.5 best_f1 0.0 for t in np.arange(0.3, 0.7, 0.02): pred (val_probs[:, label_idx] t).astype(int) score f1_score(val_labels[:, label_idx], pred, zero_division0) if score best_f1: best_f1 score best_th t best_thresholds.append(best_th) print(逐标签最优阈值:, best_thresholds)代码逻辑说明对第i个标签遍历0.30到0.70共20个候选阈值取F1最高点作为该标签的预测阈值。zero_division0防止某个标签在验证集中完全没有正样本时除零报错。跑完后预测阶段就用val_probs np.array(best_thresholds)替换全局0.5。这里要提醒一点阈值校准要在验证集上做测试集只负责最终评估在测试集上调阈值会放大指标虚高模型上线后一换数据就露馅。保存模型时把阈值数组存到一个npy文件里随模型一起发布避免推理脚本里写死0.5。本文还有配套的精品资源点击获取