BERT序列标注实战:keras-bert实现NER与标签对齐全指南 1. 项目背景与方案选型做NLP的同学应该都有同感序列标注属于那种看起来简单做起来全是坑的任务。命名实体识别、中文分词、词性标注、槽位填充本质上都是同一个问题给一段文本中的每个token打上一个类别标签。这类任务的难点不在于模型结构有多复杂而在于数据怎么对齐、标签怎么设计、边界怎么处理。尤其是引入BERT之后分词器把词切成子词label对齐的细节直接决定模型能不能收敛。这个项目用的是keras-bert而不是现在更流行的transformers库。选择keras-bert的理由很实际依赖少、上手快、代码量小。transformers库功能全但封装层次多对新手来说很多参数根本用不上出了问题反而不好排查。keras-bert在设计上很轻量模型加载、tokenize、训练预测的API都很直观适合快速验证想法、跑通基线。如果你只是需要把一个序列标注任务落地不搞那些花哨的实验对比keras-bert是性价比很高的选择。当然keras-bert也有它的局限性后面会具体说。比如它使用的是老版本的TensorFlow 1.x或兼容模式跟tf.keras的写法有一些差异在数据管道的组织上不如transformers灵活。但换个角度看正因为API简单反而逼着你把数据预处理的细节吃透这对理解BERT的原理非常有帮助。这个项目适合谁参考一类是想快速实现NER等序列标注任务、但没有太多工程经验的初学者另一类是已经在用其他框架、想对比一下不同实现方式的从业者。文章会从环境搭建到模型训练再到预测推理完整走一遍流程同时把我在实操中踩过的坑、调参心得一并写出来。序列标注任务本身不难理解但很多细节做好了是锦上添花做不好就是灾难。比如BERT的WordPiece分词会把一个词切成多个subword token如果你直接拿词的标签去对token的标签一定对不齐。再比如padding部分参与loss计算会导致模型学出一堆无意义的输出。这些坑在文档和教程里很少被强调但恰恰是实战中最容易卡住的地方。2. 环境准备与依赖安装2.1 版本选型的重要性先说一下环境。keras-bert这个库对版本比较敏感如果没装对版本光是import就会报一堆莫名其妙的错误。我当时的安装环境是Python 3.6TensorFlow 1.14Keras 2.2.4keras-bert用的0.8.0版本。这个组合实测下来比较稳定不会有严重的兼容性问题。为什么强调版本因为keras-bert底层依赖的是Keras的API而Keras在TensorFlow 2.x时代经历了一次大改。TensorFlow 2.0开始把keras作为官方内置库很多api的调用方式都变了。keras-bert在0.8.0之后基本不再活跃维护如果用太新的TensorFlow版本容易出现op无法注册、module找不到之类的问题。pip install tensorflow1.14 pip install keras2.2.4 pip install keras-bert0.8.02.2 预训练模型的选择除了框架还需要下载BERT预训练权重。中文场景一般用的是Google发布的BERT-Base-Chinese包含约1.1亿参数。这个权重文件解压后包含三个文件bert_config.json模型配置文件、bert_model.ckpt权重文件、vocab.txt词表文件。如果你在某个平台下载慢可以找镜像源或者用一些开源社区提供的转换格式。需要注意一点keras-bert加载的是ckpt格式的权重不是TF-Hub的pb格式也不是PyTorch的bin格式下载的时候要确认文件类型正确。我当时使用的是bert-base-chinese词表大小是21128包含中文字符、英文字符、标点符号以及一些特殊token。在做序列标注时词表基本不用改动直接用原版就行。3. 数据准备与label对齐3.1 标注体系的选择序列标注的标签体系最常用的是BIO或BIOES。BIO用B表示实体开始、I表示实体内部、O表示非实体。BIOES在BIO基础上增加了E实体结束和S单字实体。我选择的是BIOES原因很简单它比BIO多提供了边界信息相当于给模型一个更强的约束。比如人名张三用BIO标注是张/B-PER 三/I-PER模型只知道这两个字都是人名的一部分但不知道哪里结束。用BIOES标注则是张/B-PER 三/E-PER模型能明确学到三是实体末尾边界判断更准。代价是标签类别更多训练数据需要更大的覆盖量但对于中小规模数据集来说BIOES的效果普遍更好。标签映射表的设计也有讲究。把所有标签整合成一个字典按字母顺序或者按自定义顺序编号。我习惯把O放在第一个因为O是背景类样本数量最多编号为0之后方便后面做mask操作。label_list [O, B-PER, I-PER, E-PER, S-PER, B-ORG, I-ORG, E-ORG, S-ORG] label2id {label: idx for idx, label in enumerate(label_list)} id2label {idx: label for idx, label in enumerate(label_list)}3.2 BERT分词与标签对齐这是整个序列标注任务中最容易出错的地方必须重点讲。BERT使用WordPiece分词器会把一个词拆成多个子词。比如英文单词unbelievable可能被拆成un、believ、able三个token。中文场景下因为字是天然的最小单位大部分情况下就是一个字一个token但遇到英文单词、数字、特殊符号时同样可能被拆分。keras-bert的tokenizer提供了tokenize方法对输入句子分词后返回token列表。但这个列表里包含了[CLS]和[SEP]两个特殊token你需要手动把它们从标签对齐的过程中排除掉。对齐的核心策略是first piece规则如果一个原始词被拆成多个子词只给第一个子词打上该词的真实标签其余子词全部标记为X表示不参与loss计算。这样做的原因是BERT的子词本身没有完整的语义边界如果强行给每个子词都打上标签等于要求模型预测出人为划分的子词边界这超出了任务本身的范围。比如一个词阿帕奇如果被分词器拆成阿、帕、奇三个token且这个词本身是ORG实体的一部分那么这三个token的标签应该分别是B-ORG、I-ORG、E-ORG。但如果某个英文单词被拆成五个子词标签分配就变成了第一个子词打实体标签比如B-PER后面的子词全部打X。在实现对齐时需要记录原始分词到BERT token的映射关系。keras-bert的tokenizer没有直接提供这个映射需要自己实现。一个简单的做法是用tokenizer的tokenize方法得到token列表后逐个检查每个token是否包含##前缀。BERT的WordPiece规则里一个词的第一个子词不带前缀后续子词带##前缀英文场景。中文场景由于每个字就是一个token基本不会出现这种情况但英文、数字混排时处理逻辑必须考虑。def align_label(text, labels, tokenizer): tokens tokenizer.tokenize(text) label_indices [] idx 0 for token in tokens: if token [CLS] or token [SEP]: continue if token.startswith(##): label_indices.append(-100) else: label_indices.append(labels[idx]) idx 1 return tokens, label_indices这里用-100作为X标签的占位值后面在loss计算时可以直接过滤掉。3.3 数据增强与样本平衡序列标注数据集的类别分布很不均衡O类非实体占据了绝大部分样本。如果你的数据量比较小实体的B/I/E边界样本天然稀少模型很容易偏向预测O导致实体召回率极低。缓解方法有几个层面。第一个是在loss上做文章给实体类别的loss乘以一个权重。第二个是数据增强对于标注数据比较充足的情况可以通过同义词替换、实体替换等方式扩充样本。第三个是调整batch size和训练轮数让小类别有更多机会被模型看到。我在项目中还做了一个小技巧把文本随机截断成不同长度的片段。这样一方面增加了样本多样性另一方面避免了模型对固定长度输入的依赖。但要控制截断操作不做坏实体边界如果截断了实体中间的字标签就废了。4. 模型构建与参数配置4.1 加载预训练模型keras-bert提供了load_bert_model函数一行代码就能加载BERT模型和预训练权重。from keras_bert import load_bert_model model load_bert_model( bert_config_path, bert_checkpoint_path, seq_len128, output_layer_num4 )这里有几个参数要解释清楚。seq_len是最大序列长度超长部分截断不足部分padding。output_layer_num指的是从BERT输出的后几层取出来做加和或拼接。对于序列标注这种token-level的任务一般取最后4层输出进行加权求和效果比较好因为底层特征偏通用、高层特征偏语义融合起来对实体边界的判断更有帮助。4.2 输出层设计BERT输出维度是768序列标注需要在每个token上输出标签类别所以要加一个全连接层Dense做分类。最朴素的写法是直接Dense softmax。如果想让模型学得更稳可以在Dense之前加一个Dropout防止过拟合。from keras.layers import Dense, Dropout from keras.models import Model bert_output model.output # shape: (batch_size, seq_len, 768) dropout Dropout(0.3)(bert_output) output Dense(len(label2id), activationsoftmax)(dropout)这里有一个细节keras-bert加载出来的model.output是最后一个BERT层的输出如果你设置了output_layer_num4内部会做一个加权求和。不同任务对这几层之和的敏感度不同可以在小规模数据上做个快速对比实验选择效果最好的配置。4.3 条件随机场CRF的取舍很多人问序列标注到底要不要加CRF我的看法是如果你做的是严格边界要求的任务如医疗实体抽取、地址解析加CRF能显著提升边界准确率如果你只是做粗粒度的实体识别、槽位填充不加CRF用softmax也能达到不错的效果。CRF的本质是在token级别的输出上增加了标签转移约束。比如B-PER后面不能直接接E-ORG模型通过CRF layer学习这些转移规则输出的标签序列在整体上更合理。keras-bert本身不提供CRF层需要引入keras_contrib或者tf.contrib的CRF。这里有个经验keras-contrib的CRF实现跟TensorFlow版本耦合很紧版本不对就报错。如果你不想折腾依赖可以先跑通softmax版本再考虑是否值得加CRF。4.4 Loss函数与优化器loss函数推荐用sparse_categorical_crossentropy配合label的mask使用。因为标签对齐后有些token是-100占位符这些位置不应该参与loss计算。处理方法是在计算loss时只对标签大于等于0的位置计算交叉熵其他位置直接忽略。keras中不方便直接对loss做mask操作一个可行方案是自定义一个loss函数或者在数据生成器里把-100的位置替换成一个特殊值并在softmax层之前把所有位置的logits都保留最后在loss计算时通过sample_weight的方式屏蔽掉无效位置。我实际用的方法是sample_weight方案def masked_ce_loss(y_true, y_pred): # y_true shape: (batch_size, seq_len) mask tf.cast(tf.greater(y_true, -1), tf.float32) y_true_masked tf.maximum(y_true, 0) loss tf.keras.losses.sparse_categorical_crossentropy(y_true_masked, y_pred) loss * mask return tf.reduce_sum(loss) / tf.reduce_sum(mask)优化器我用的Adam学习率设为2e-5。BERT微调的大原则是学习率要小因为预训练模型已经是一个很好的初始点过大的学习率会把学到的特征破坏掉。如果是跑baseline1e-5到5e-5之间都有不错的表现具体值需要根据数据量和训练步数来微调。5. 训练流程与关键细节5.1 数据生成器的实现batch数据组织方式直接影响训练效率和效果。一个标准的序列标注batch包含三个部分input_ids、input_segmenttoken type ids、input_maskattention mask。keras-bert的输入层接受的是一个list可以一次性传入三个向量。label同样需要padding到相同的seq_len长度。padding位置使用-100这样在loss计算时自动被mask掉。def batch_generator(data, labels, tokenizer, batch_size32, seq_len128): num_samples len(data) while True: indices np.random.permutation(num_samples) for start in range(0, num_samples, batch_size): batch_idx indices[start:start batch_size] input_ids [] input_segments [] input_masks [] label_ids [] for idx in batch_idx: text data[idx] label labels[idx] tokens, label_indices align_label(text, label, tokenizer) ids, segment tokenizer.encode(text, max_lenseq_len) mask [1] * len(ids) ids [0] * (seq_len - len(ids)) segment [0] * (seq_len - len(segment)) mask [0] * (seq_len - len(mask)) label_indices label_indices[:seq_len - 2] label_indices [-100] label_indices [-100] label_indices [-100] * (seq_len - len(label_indices)) input_ids.append(ids) input_segments.append(segment) input_masks.append(mask) label_ids.append(label_indices) yield [np.array(input_ids), np.array(input_segments), np.array(input_masks)], np.array(label_ids)5.2 训练过程的调参记录训练参数建议如下batch_size: 32显存不够就降到16epochs: 3-5learning_rate: 2e-5max_seq_len: 128dropout: 0.3optimizer: Adam我实际上跑了4个epoch每个epoch大概2000步总共8000步左右。loss在前两个epoch下降很快从2.3降到0.4左右后面两个epoch趋于平缓最终loss在0.2附近。验证集上的F1值在第三轮达到最高第四轮略有波动这种情况建议做early stopping保存验证集上最好的模型权重。需要特别注意的是bert微调不需要跑太多epoch因为BERT本身的特征抽取能力很强过长的训练反而容易过拟合。如果你的数据量很小几百条甚至2个epoch就够了。我见过一些新人一上来跑10个epoch结果训练集F1接近100%验证集惨不忍睹。5.3 GPU显存占用与OOM问题BERT-Base模型参数量约1.1亿运行时显存占用大概在2-3GBbatch_size32、seq_len128如果你的显卡只有4GB显存很容易OOM。解决办法有几种减小batch_size但会影响训练稳定性梯度累积用小batch多次前向后再更新参数使用mixed precision混合精度训练使用TinyBERT或蒸馏版模型我当时的做法是batch_size32seq_len1288GB显存刚好够用。如果你想用BERT-Large那基本需要12GB以上显存了。6. 预测与结果后处理6.1 预测流程模型训练完成后预测阶段需要注意的问题跟训练阶段不同。预测时不需要计算loss所以不需要label只需要输入文本然后拿到模型输出的每个token的标签概率分布再通过argmax得到预测类别。但预测时同样需要处理BERT的分词对齐问题。因为模型输出是对每个token的预测你需要把这些token级别的预测映射回原始文本级别的标签。对于##开头的子词直接取它的预测结果虽然这个token的训练标签是-100但预测时模型还是会给出一个输出我们后续再做合并。6.2 子词合并策略假设BERT把阿帕奇拆成了三个token模型对每个token都输出了标签比如阿预测为B-ORG帕预测为I-ORG奇预测为E-ORG。原始文本是一个词最终实体标签应该合并成一个整体。但如果分词器把unbelievable拆成了三个token第一个token预测B-PER第二个预测I-PER第三个预测E-PER那么最终还是要合并得到完整实体。代码处理上可以用一个循环遍历token列表然后把相同的标签连续段合并起来。判断边界时要注意B/I/E的连贯性如果出现B-PER后面直接跟B-ORG这种跳变说明模型预测出了问题要不就是数据对齐有问题要不就是CRF缺失导致转移约束不足。6.3 结果评估评估序列标注模型用标准NER的评估方式precision、recall、F1而且是在entity级别计算不是在token级别计算。计算entity级别的F1时必须要求实体的起始位置和类别都完全正确才算对这样能更严格地反映模型效果。我当时用了一个小工具脚本把预测出的实体跟真实实体逐一比对先按类别过滤再按边界匹配。评估结果在验证集上F1约为0.90在测试集上F1约为0.86效果中规中矩。如果你要达到更高的F1通常需要更大的训练数据、更精细的标注规范以及可能的领域自适应预训练。7. 常见问题与排查技巧实录7.1 标签对齐错误导致loss无法下降有一次我在中文数据集上训练发现loss一直卡在2.1左右不下降预测结果几乎全是O。排查了很久最后发现是对齐函数在处理英文名称时出了问题某个英文单词被拆成多个子词后原始标签数跟token数对不上导致后面的所有标签全部错位。这个问题的排查方法很直观随机取几个训练样本把对齐后的token和label打印出来看。如果出现某token的label本来是PER但打印出来是O的情况说明对齐逻辑有bug。用我前面提供的align_label函数时务必检查token.startswith(##)的条件这个在英文场景是生效的但中文文本混合英文时也要测试。7.2 BERT输入长度限制BERT的最大输入长度是512超过的部分会被截断。如果你的语料里存在超长文本实体恰好出现在尾部那么这个实体的标签就彻底丢了。针对这种情况简单方案是设置更大的max_len但代价是显存占用和训练时间增加而且超过512就没意义了。另一个方案是做滑窗拆分把长文本切成多个片段每个片段之间保留一定的重叠区域这样实体不会因为切分边界而被破坏。我做长文档NER时用过一个三段滑窗法设置窗口大小256、步长128这样相邻窗口有128个token的重叠。实体如果跨窗口保证至少有一个窗口能完整覆盖它。7.3 模型预测结果全是O这种情况在上手第一个序列标注项目时特别常见。排查点依次是第一检查数据对齐看是不是大部分真实标签都被-100覆盖了。第二检查loss是否正确下降如果loss在下降但预测全是O大概率是类别不均衡太严重O类占了95%以上模型收敛到局部最优。第三检查预测阶段的argmax轴是否写错如果axis写成了0取出来的是batch维度的结果自然全是O。7.4 keras-bert与CUDA的兼容性问题如果你的机器装了新版本CUDA比如11.xTensorFlow 1.14是跑不起来的因为它编译时用的是CUDA 10.0。解决方法是建一个独立的虚拟环境专门装TensorFlow 1.14。如果不想用老版本也可以换用支持TF2的bert-for-tf2库API设计类似但底层兼容性更好。不过我自己用下来还是觉得keras-bert的代码更顺手所以一直在虚拟环境里维护。7.5 训练速度慢的问题BERT微调训练速度主要受限于序列长度和batch size。seq_len从128提升到256训练时间翻倍不止。如果你的任务对长文本不太敏感可以先用128快速跑通baseline最后再用256做一次精调。另外数据生成器如果用纯Python循环每次生成batch都在做分词操作速度会非常慢。建议把所有数据提前分词并转换成id序列训练过程中只做查表操作。8. 一点实用建议整个项目做下来我的感受是序列标注任务的技术门槛不算高真正的难点在数据层面的细致处理。BERT这层编码器相当强大只要你给它喂的数据是对的它就能学到很不错的特征表达。反而是数据对齐、padding掩码、预测合并这些看起来不起眼的环节出了问题最难排查。如果你打算在自己的数据集上做类似任务我建议先拿几百条样本跑通全流程再逐步增加数据量。小样本阶段可以快速验证代码逻辑是否正确避免在大数据集上浪费时间。模型结构、超参数的调整建议用小数据集做对比实验结论是可靠的。最后分享一个小技巧keras-bert的model.predict返回的是各个token在每个类别上的概率分布除了argmax取标签之外你还可以把概率值导出用阈值过滤的方式处理低置信度的实体预测。在一些对精度要求高的场景这个操作能帮你拦截掉不少误报。虽然会牺牲一些召回率但业务上往往更在意预测结果是否可信。祝大家都能顺利跑出自己的序列标注模型有坑随时回来翻这篇文章。