简介:一份基于Keras与Keras-bert的文本多标签分类实战项目,面向NLP入门者与竞赛实践人群,利用BERT微调解决“一条文本可能对应多个标签”的问题,并以2020语言与智能技术竞赛“事件抽取”数据为样例建模,适用于新闻分类、评论打标、工单自动归类等场景。资源共10个文件,压缩包约1.01MB,以Python脚本、CSV数据与TXT辅助文件为主;训练、评估与预测脚本构成完整建模闭环,CSV提供训练与测试样例,TXT包含BERT词表及环境依赖清单,说明文档帮助快速上手;此外加入对抗训练策略以增强模型鲁棒性。已有1634人学习/下载。通过该项目可掌握BERT微调、多标签分类的标签组织与代码实践,脚本具备直接复用性,适合在此基础上扩展到其他领域文本分类或进一步调优,尤其适合希望在已有模型上继续调优的入门者。
1. 用 Keras 和 Keras-bert 做文本多标签分类:为什么微调 BERT 是这条路上最顺的走法
接到一个文本分类需求,第一反应不是"用什么模型",而是先问一句:标签是互斥的还是并存的?如果是并存,比如一篇文章同时命中"技术"和"管理"两个标签,这就不是多分类,而是多标签分类。很多人在这一步就走错了,拿着 softmax 交叉熵去硬训,模型收敛得再漂亮,预测时也只能从标签里挑一个,业务上根本没法用。Keras-bert 这套组合解决的问题,正是把 BERT 接到 Keras 的训练流程里,用 sigmoid + binary_crossentropy 做多标签输出,并对 BERT 本体做微调,让预训练模型的语言理解能力贴合自己的领域数据。这个方案的好处是够直白:数据准备、模型搭建、训练调度都在 Keras 生态里完成,不需要额外引入一套训练框架,而且微调后的模型在标注数据只有几千条的场景下,效果往往就能超过从零训练的 TextCNN 或 BiLSTM 一大截。适合谁?手上有已标注的文本数据、熟悉 Keras 基本流程、想把 BERT 带来的提升尽快落到业务里的工程师。
2. 把 BERT 接进 Keras:Keras-bert 的加载原理与最小可用模型
2.1 Keras-bert 到底加载了什么,以及为什么选它而不是 transformers
Keras-bert 这个库的核心作用是把 BERT 的预训练权重和配置加载成 Keras 的 Layer 对象,让 BERT 的前向计算以 Keras 层的方式参与模型组装。它的加载入口是load_builtin_bert或load_model,前者直接读取库自带的预训练权重,后者接收checkpoint_path和config_path指向你自己下载的 BERT 权重目录。很多人在这一步产生了疑问:官方推荐的是 transformers 库的TFAutoModel,为什么还要用 Keras-bert?常见做法是,如果你的整个项目已经跑在 Keras 2.x 上,数据管道和训练回调都已经用 Keras 的写法写好了,那用 Keras-bert 做接入层,模型结构保持 Keras 风格,后续调试和部署的心智负担最小。另外 Keras-bert 的输出格式对做微调的人相当友好,它的model.get_output_layer()可以直接拿到[CLS]位置的稠密向量,不用像 transformers 那样去处理last_hidden_state和pooler_output的选择问题。当然,这个库的更新节奏比较慢,对 TensorFlow 2.0 以上的兼容性也不是完美无缺,后面避坑章节会具体展开。
2.2 构建多标签分类头:从 BERT 向量到 sigmoid 输出
跑通最小模型只需要三步:加载 BERT 主体、接一个分类头、确定损失函数。先把 Keras-bert 的安装和环境对齐说清楚。常见做法是用 conda 创建一个干净的环境,Python 版本不要选太新,3.7 或 3.8 都是安全区间。TensorFlow 用 1.15 版本的话,Keras-bert 的兼容性最稳,如果项目要求 TensorFlow 2.x,建议把keras_bert替换为社区维护的兼容分支,或者开启 TF 2 的兼容模式。下面给出一段用 TensorFlow 1.15 + Keras 2.x 组合的完整代码骨架:
import numpy as np from keras.layers import Input, Dense, Lambda from keras.models import Model from keras.losses import binary_crossentropy from keras_bert import load_model, load_vocabulary # 加载中文 BERT 权重,config 和 checkpoint 需事先下载到本地目录 bert_model_path = './chinese_bert/' bert = load_model( bert_model_path + 'bert_config.json', bert_model_path + 'bert_model.ckpt', seq_len=128 ) # 取出 BERT 的 [CLS] 输出,也就是每个样本的整句表示 cls_out = bert.get_output_layer() # 多标签分类头:Dense 输出维度等于标签总数,激活函数必须用 sigmoid labels_in = Input(shape=(num_labels,)) logits = Dense(num_labels, activation='sigmoid', name='classifier')(cls_out) model = Model(bert.inputs, logits) model.compile( optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'] ) print(model.summary())这段代码里最值得留意的有两点。第一,bert.inputs是一个列表,包含两个张量,分别是词 ID 序列和 segment 序列,喂数据时必须同时传入,少了任何一个都会报输入不匹配。第二,分类头没有接 Dropout。很多人习惯性地在这里加一个 Dropout(0.1),但在微调场景里 BERT 本身已经带了不错的正则效果,分类头再接 Dropout 反而容易让模型欠拟合,尤其是标签数量多、每个标签的样本量又不大的时候。我的建议是先不加,如果验证集出现明显的过拟合迹象,再加一个 0.1 到 0.2 之间的 Dropout。
2.3 损失函数的选择逻辑:sigmoid 与 binary_crossentropy 的搭配
多标签分类的损失函数和输出层是配套的。输出层用 softmax,损失函数就必须是 categorical_crossentropy,因为 softmax 强制每个样本在所有类别上的概率之和等于 1,这对应的是"每篇文档只有唯一标签"的假设。多标签场景里,一个样本可以同时属于多个标签,每个标签的预测是独立的概率值,所以输出层用 sigmoid,每个维度独立地输出 0 到 1 的概率,损失函数则用 binary_crossentropy,它把每个标签当作一个独立的二分类问题来算交叉熵。这段代码里我把 Keras 的字符串'binary_crossentropy'直接传给了compile,它等价于tf.keras.losses.BinaryCrossentropy。这个选择的好处是,训练过程中每个标签的梯度贡献是解耦的,模型不会因为某个标签的样本特别多就把所有输出都往那个方向带。
3. 文本多标签分类的数据处理:从原始语料到模型输入的完整链路
3.1 标签矩阵和多标签阈值的设定
数据准备这一步,我见过太多人把一个简单问题弄复杂。多标签分类的标签编码不需要任何花哨的向量化技巧,直接用 One-hot 的扩展版本 Multi-hot:每一条样本对应一个长度为标签总数的 0/1 向量,1 表示这条样本属于该标签。比如标签集合是["技术", "管理", "运营"],一条同时命中"技术"和"管理"的样本,其标签向量就是[1, 1, 0]。这里有个容易踩坑的地方,就是标签集合必须提前锁定,训练集里出现过的所有标签都要纳入集合,不能等数据处理到一半再追加,否则后面的标签向量长度会不一致,模型训练直接报错。另外,如果一个标签在训练集中只出现过十几次,这类样本基本学不出可用的特征,常见做法是设定一个最低出现次数,比如 20 次,低于这个阈值的标签直接丢弃,并把这些样本上的对应标签位清零。这个操作需要在数据预处理阶段完成,等模型训起来再回头筛标签,费时费力。
构造好标签向量后,还需要决定预测阶段的判定阈值。sigmoid 输出的不是严格的 0 或 1,而是概率。默认阈值是 0.5,但实际项目中很少有人直接用 0.5,原因很简单:如果业务上更看重召回,阈值就往下调,比如 0.3;如果更看重精确率,阈值往上调,比如 0.7。调整阈值不需要重新训练,只需要在预测阶段对输出概率做一个阈值过滤。这块的调参技巧在最后一章单独展开。
3.2 Tokenizer 和序列长度对齐
把实际文本转成 BERT 能吃的输入,需要分词器。Keras-bert 提供了load_vocabulary方法来加载 BERT 自带的词表,然后用tokenizer.encode把文本转成词 ID 序列。这里有一个新手经常翻车的点:BERT 自带的词表是字符级还是词级?中文 BERT 的词表是字级别的,即每个汉字对应一个 ID,所以不需要像英文那样走 WordPiece 之后再来一层 subword 切分。用 Keras-bert 的TokenTokenizer或直接调用tokenizer.encode(text, max_len=128)就行。给出一段标准的编码与数据生成代码:
from keras_bert import load_vocabulary from keras.preprocessing.sequence import pad_sequences # 加载 BERT 词表并构建 tokenizer token_dict = load_vocabulary('./chinese_bert/vocab.txt') from keras_bert import Tokenizer tokenizer = Tokenizer(token_dict) def encode_texts(texts, max_len=128): input_ids = [] segment_ids = [] for text in texts: # encode 返回 (词ID序列, segment 序列) 的元组 ids, segs = tokenizer.encode( text, max_len=max_len ) input_ids.append(ids) segment_ids.append(segs) return np.array(input_ids), np.array(segment_ids) train_ids, train_segs = encode_texts(train_texts)这段代码的关键在于tokenizer.encode里的max_len参数。它会在序列内部完成截断,超出的部分直接丢弃。max_len的选择没有固定标准,常见做法是先统计训练集文本长度的分布,把 95 分位数的长度作为初始值,再用验证集做一轮实验。如果文本长度跨度很大,比如有的短文本只有十几个字,有的长文本有两千字,直接用 128 或 256 做统一截断会损失不少信息。我一般会先跑到 128,观察验证集指标,再提高到 256 对比一次,取效果好的那个。注意一点,max_len增加后,训练时间和显存占用会同步增长,BERT 的注意力计算复杂度是序列长度的平方,128 到 256 的提升不只是两倍的耗时,更接近三到四倍。
3.3 数据划分与训练集 / 验证集采样策略
多标签分类的数据划分比单标签敏感得多。如果直接用train_test_split按行切分,得到的训练集和验证集中,某些罕见标签可能只在训练集出现,验证集里完全看不到,这会导致验证指标失真。常见做法是使用分层采样,按标签向量的组合进行分层。但如果标签组合数量极大,每个组合下样本又很少,分层会退化成近乎随机。一个务实的变通方案是:优先保证验证集中每个标签至少出现一定次数,比如 50 次,如果某个标签在验证集里样本太少,就把它从验证集的评估指标里剔除。代码层面不需要特别复杂的库,直接用 pandas 的groupby对标签组合做分组,然后在每个组内按比例随机抽样即可。验证集比例建议在 0.1 到 0.2 之间,数据集总量少于一万条时,验证集用 0.15 比较稳妥,总量很大时 0.1 就够。
这个环节还有一档排序要做,就是验证集和训练集不能有文本重复。文本分类任务里,重复样本会导致模型在训练阶段就把验证集的内容背下来,微调 BERT 时尤其明显,因为预训练模型的容量很大,完全有能力记住训练样本。如果数据是从线上日志里捞的,去重步骤不能省。去重键可以直接用文本本身,也可以用文本的 MD5,处理大文件时后者更快。去重之后再划分、再编码。
4. 微调 BERT 的训练参数设置:batch size、学习率与冻结策略
4.1 学习率为什么不能沿用默认值,以及分层学习率的常见做法
BERT 微调最忌讳的就是直接用默认学习率。Keras 的 Adam 默认学习率是 0.001,直接用在 BERT 上,预训练权重会在前几步就被冲乱,训练 loss 可能不降反升,验证集指标直接归零。BERT 的原始论文给出的微调学习率区间是 2e-5 到 5e-5,这个数值范围在后续的大量实践中被反复验证有效。我在实际项目中默认从 3e-5 起步,如果训练不稳定,就降到 2e-5。还有一种更精细的做法叫分层学习率,即 BERT 主体靠后的层用较大学习率,靠前的层用较小学习率,分类头因为是随机初始化的,用 10 倍于 BERT 主体的学习率。原因是靠近输入的前几层学到的是通用语言特征,微调幅度太大会破坏这些特征;靠近输出的后几层更接近任务相关的语义,适合多学一些;分类头是全新的,需要更大的步长快速收敛。在 Keras 里实现分层学习率,常规做法是把模型的不同层分组,分别设置lr,然后用自定义优化器包装。这块不展开全部代码,核心思路是在fit阶段传入多个optimizer分组,Keras 的MultiOptimizer可以做到。
4.2 冻结 BERT 前几层的方法与适用场景
如果只有几百条训练数据,全量微调几乎必然过拟合。这时候的常见做法是冻结 BERT 的前几层,只微调靠近输出的后半部分和分类头。具体操作在 Keras 里非常简单:
from keras import backend as K # 获取 BERT 模型的层列表,按名称定位 for i, layer in enumerate(bert.layers): # 假设 BERT 主体有 12 层 Transformer,冻结前 8 层 if i < 8: layer.trainable = False else: layer.trainable = True # 冻结后必须重新编译,trainable 的修改才会生效 model.compile( optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'] )这段代码里有三个细节需要讲清楚。第一,layer.trainable必须在compile之前修改,否则 Keras 不会重新构建优化器的权重列表,冻结不会生效,这是最常见的一个坑。第二,bert.layers里不仅包含 Transformer 层,还有 Embedding 层和其他辅助层,用索引直接冻结可能会误伤 Embedding,更好的做法是根据层的名字做匹配,Keras 的层名字可以在model.summary()里看到。第三,冻结前几层只适合数据量很小的场景,如果训练数据有十万条以上,冻结反而会限制模型拟合能力,不冻结直接全量微调效果更好。冻结策略的本质是引入正则化,它和 Dropout 一样,是对模型表达能力的一种约束。
4.3 batch size 与梯度累积的取舍
batch size 的选择在 BERT 微调里是个硬约束。显存不够,batch size 就只能调小,但 batch size 太小会导致训练不稳定。BERT 论文里的典型配置是 batch size 16 或 32,在单卡 12GB 显存上,序列长度 128 的情况下,batch size 16 通常能跑起来。如果显存只够跑 batch size 4 或 8,有两个方向可以走。第一,降低序列长度,把max_len从 128 降到 64,显存占用会明显下降,代价是长文本信息截断。第二,使用梯度累积,即每跑几个小 batch 再更新一次梯度,模拟大 batch 的效果。在 Keras 里实现梯度累积,常见做法是用自定义回调,在on_batch_end里判断是否达到累积步数,然后手动调用optimizer.apply_gradients。这个方案会牺牲一些训练速度,因为每个 batch 的前向计算还是要做,但至少能把模型训起来。
我个人在处理超长文本时更倾向先截断再加大 batch size。原因是 BERT 对长度超过训练长度的位置没有很好的处理能力,硬把max_len提到 512,显存消耗成倍增加,而收益往往只是若干百分点的准确率提升。如果业务场景里长文本占比不高,直接截断到 128 反而更稳定。截断位置也有讲究,中文文本的信息分布不均匀,开头往往包含核心主题,结尾常常是套话,所以常见的做法是保留开头一段、结尾一段,中间截断,比从头到尾截断更合理。
5. 文本多标签分类避坑:Keras-bert 常见问题与排查清单
5.1 安装阶段:TensorFlow 与 Keras-bert 的版本冲突
现象:按照文档执行pip install keras-bert后,导入keras_bert直接报错,提示找不到keras.engine.topology。原因:Keras-bert 是较早发布的库,它依赖 Keras 2.x 的内部结构,TensorFlow 2.x 里 Keras 被集成到tf.keras,原有的keras包如果没有安装,或者装了新版 Keras 3.x,模块路径完全不兼容。解决:创建一个独立环境,指定 TensorFlow 1.15 加 Keras 2.2 的组合,这是 Keras-bert 最稳的搭配;如果必须用 TensorFlow 2.x,可以尝试安装keras2.3.1 并设置环境变量TF_KERAS=1,让 Keras-bert 走 TensorFlow 的 Keras 实现。更进一步的方案是换用keras_bert的替代库,或者直接用transformers的TFAutoModel加载 BERT,但那样就要把整个训练流程切换到 TF 2 风格。
5.2 数据处理阶段:encode 返回的 segment 全为 0,模型学不到句子关系
现象:训练出来了,loss 降得很慢,验证集准确率在随机猜测水平附近。把tokenizer.encode的返回打印出来,发现 segment 序列全为 0。原因:encode默认接收单条文本,此时 segment 就是全 0;但如果文本本身没有按[CLS] text [SEP]的格式拼接,模型就从输入里看不到完整的语义结构。BERT 的输入需要包含开头标记[CLS]和结尾标记[SEP],Keras-bert 的encode方法在max_len内会自动完成这些拼接,但如果你的文本里手动加了特殊字符,或者文本本身包含换行符等异常字符,分词结果可能会有偏差。解决:在调用encode前对文本做一次清洗,把换行符、制表符替换成空格,去掉过长的连续空白。序列长度不足max_len的部分,encode会自己补齐 padding,不需要手动调用pad_sequences,手动补齐反而可能重复填充。
5.3 训练阶段:验证集 loss 和准确率一直在抖,像没收敛
现象:训练过程中 loss 不下降,或者说每轮之间的波动幅度极大,模型效果和随机差不多。原因:除了学习率设置过大以外,最常见的原因是数据顺序没有打乱。Keras-bert 的fit默认会在每个 epoch 开始时打乱数据,但如果数据管道里自己提前做了排序,比如所有同类标签的样本扎堆排列,模型学到的标签分布就是偏的。另一个容易被忽略的原因,是sample_weight没有设置。多标签数据里标签分布极度不均衡时,模型会倾向于把所有输出往 0 推,因为多数标签是 0,往 0 推可以降低整体 loss。解决:设置class_weight或sample_weight,为每个标签分配权重,常用做法是给低频标签更高的权重。不过要注意,权重设置过大也会把模型带偏,出现把所有样本都预测为该标签的现象,需要反复试探。
5.4 预测阶段:预测结果全是 0,一个标签都出不来
现象:训练时验证集指标看起来不错,但推理时所有样本的输出概率都低于 0.5,没有标签被激活。原因:阈值 0.5 太高,模型虽然在训练时被压向了 0/1 两端,但实际输出的概率分布集中在 0.2 到 0.5 之间,尤其是标签数量多、语义相近的场景。解决:不要在训练结束后直接拿 0.5 当判定标准,而是在验证集上扫描概率阈值,选一个让 F1 最高的值作为最终阈值。扫描区间通常是 0.1 到 0.7,步长 0.05。如果扫描出来的最佳阈值低于 0.2,说明模型本身的判别力不够,优先去调数据或模型,而不是继续压阈值,否则会带来大量误报。
5.5 部署阶段:训练和预测时输出不一致,概率分布整体偏移
现象:训练环境里验证效果好,离线跑测试也好,但上线后发现预测结果和离线测试差距很大。原因:训练时输入文本的预处理和预测时不一致,比如训练时做了文本清洗,上线时忘了给推理链路做同样的清洗;或者训练时序列长度用 128,上线时改了接口参数导致编码长度变化。这类问题不会报错,但直接影响线上效果,而且排错很难。解决:把数据预处理封装成一个独立的函数,训练和推理共用同一份代码;预测接口上线前,用训练集里随机抽出的 100 条样本做一致性回归测试,对比本地预测结果和线上预测结果是否一致。
6. 微调收尾技巧:用回调控制训练节奏,用阈值扫描把验证集指标变成上线指标
训练 BERT 微调模型,最少要配三个回调。第一个是EarlyStopping,监控验证集 loss,如果连续三轮不下降就停。第二个是ModelCheckpoint,每轮结束把当前最优权重存下来,防止训练到最后过拟合后权重被覆盖。第三个是ReduceLROnPlateau,当验证集 loss 进入平台期时,把学习率自动乘 0.1,这比手动盯训练曲线要省心得多。有一段很常见的配置写法:
from keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau callbacks = [ EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ), ModelCheckpoint( 'best_bert.hdf5', monitor='val_loss', save_best_only=True, save_weights_only=True ), ReduceLROnPlateau( monitor='val_loss', factor=0.1, patience=2, verbose=1 ) ] model.fit( [train_ids, train_segs], train_labels, validation_data=([val_ids, val_segs], val_labels), epochs=10, batch_size=16, callbacks=callbacks )EarlyStopping的restore_best_weights参数值得专门说一句。如果不设置它,训练结束后模型停留在最后一轮的状态,而最后一轮经常不是最优状态,尤其是在学习率被ReduceLROnPlateau调低之后,模型可能在最后几轮里出现了轻微过拟合。设置了这个参数,训练结束时权重会恢复到验证集指标最好的那一轮,相当于给自己留了后悔药。
阈值扫描这个操作放在训练结束后做。拿验证集每个样本的预测概率,把阈值从 0.1 到 0.7 逐步抬高,每到一个阈值就计算一次精确率、召回率和 F1,然后挑 F1 最高的阈值作为线上判定标准。我在实际项目里发现,多标签场景下直接使用 0.5 的人很多,而扫描后的最优阈值往往落在 0.3 到 0.5 之间。这个操作本身就是一次不需要重新训练的性能提升,成本极低,收益却很稳定。
文本多标签分类这个方向,数据质量对模型效果的上限影响远超模型结构本身。我踩过最深的坑,是在标签标注不干净的数据上花了大量时间调模型,后来把标注不一致的样本清理掉,效果直接跳过调参带来的提升。从那以后,我的习惯是拿到数据先花半天时间做标签分布和标注质量分析,再动手搭模型。这个顺序建议你也试试,希望帮到你。
本文还有配套的精品资源,点击获取