
简介一份基于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 接进 KerasKeras-bert 的加载原理与最小可用模型2.1 Keras-bert 到底加载了什么以及为什么选它而不是 transformersKeras-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_len128 ) # 取出 BERT 的 [CLS] 输出也就是每个样本的整句表示 cls_out bert.get_output_layer() # 多标签分类头Dense 输出维度等于标签总数激活函数必须用 sigmoid labels_in Input(shape(num_labels,)) logits Dense(num_labels, activationsigmoid, nameclassifier)(cls_out) model Model(bert.inputs, logits) model.compile( optimizeradam, lossbinary_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_len128)就行。给出一段标准的编码与数据生成代码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_len128): input_ids [] segment_ids [] for text in texts: # encode 返回 (词ID序列, segment 序列) 的元组 ids, segs tokenizer.encode( text, max_lenmax_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( optimizeradam, lossbinary_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_KERAS1让 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( monitorval_loss, patience5, restore_best_weightsTrue ), ModelCheckpoint( best_bert.hdf5, monitorval_loss, save_best_onlyTrue, save_weights_onlyTrue ), ReduceLROnPlateau( monitorval_loss, factor0.1, patience2, verbose1 ) ] model.fit( [train_ids, train_segs], train_labels, validation_data([val_ids, val_segs], val_labels), epochs10, batch_size16, callbackscallbacks )EarlyStopping的restore_best_weights参数值得专门说一句。如果不设置它训练结束后模型停留在最后一轮的状态而最后一轮经常不是最优状态尤其是在学习率被ReduceLROnPlateau调低之后模型可能在最后几轮里出现了轻微过拟合。设置了这个参数训练结束时权重会恢复到验证集指标最好的那一轮相当于给自己留了后悔药。阈值扫描这个操作放在训练结束后做。拿验证集每个样本的预测概率把阈值从 0.1 到 0.7 逐步抬高每到一个阈值就计算一次精确率、召回率和 F1然后挑 F1 最高的阈值作为线上判定标准。我在实际项目里发现多标签场景下直接使用 0.5 的人很多而扫描后的最优阈值往往落在 0.3 到 0.5 之间。这个操作本身就是一次不需要重新训练的性能提升成本极低收益却很稳定。文本多标签分类这个方向数据质量对模型效果的上限影响远超模型结构本身。我踩过最深的坑是在标签标注不干净的数据上花了大量时间调模型后来把标注不一致的样本清理掉效果直接跳过调参带来的提升。从那以后我的习惯是拿到数据先花半天时间做标签分布和标注质量分析再动手搭模型。这个顺序建议你也试试希望帮到你。本文还有配套的精品资源点击获取