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.6,TensorFlow 1.14,Keras 2.2.4,keras-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 tensorflow==1.14 pip install keras==2.2.4 pip install keras-bert==0.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_len=128, output_layer_num=4 )这里有几个参数要解释清楚。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), activation='softmax')(dropout)这里有一个细节:keras-bert加载出来的model.output是最后一个BERT层的输出,如果你设置了output_layer_num=4,内部会做一个加权求和。不同任务对这几层之和的敏感度不同,可以在小规模数据上做个快速对比实验,选择效果最好的配置。
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微调的大原则是学习率要小,因为预训练模型已经是一个很好的初始点,过大的学习率会把学到的特征破坏掉。如果是跑baseline,1e-5到5e-5之间都有不错的表现,具体值需要根据数据量和训练步数来微调。
5. 训练流程与关键细节
5.1 数据生成器的实现
batch数据组织方式直接影响训练效率和效果。一个标准的序列标注batch包含三个部分:input_ids、input_segment(token type ids)、input_mask(attention mask)。keras-bert的输入层接受的是一个list,可以一次性传入三个向量。
label同样需要padding到相同的seq_len长度。padding位置使用-100,这样在loss计算时自动被mask掉。
def batch_generator(data, labels, tokenizer, batch_size=32, seq_len=128): 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_len=seq_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(显存不够就降到16)
- epochs: 3-5
- learning_rate: 2e-5
- max_seq_len: 128
- dropout: 0.3
- optimizer: 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-3GB(batch_size=32、seq_len=128),如果你的显卡只有4GB显存,很容易OOM。解决办法有几种:
- 减小batch_size,但会影响训练稳定性
- 梯度累积,用小batch多次前向后再更新参数
- 使用mixed precision混合精度训练
- 使用TinyBERT或蒸馏版模型
我当时的做法是batch_size=32,seq_len=128,8GB显存刚好够用。如果你想用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.x),TensorFlow 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取标签之外,你还可以把概率值导出,用阈值过滤的方式处理低置信度的实体预测。在一些对精度要求高的场景,这个操作能帮你拦截掉不少误报。虽然会牺牲一些召回率,但业务上往往更在意预测结果是否可信。
祝大家都能顺利跑出自己的序列标注模型,有坑随时回来翻这篇文章。