1. 为什么是BERT+CRF:这套组合到底强在哪儿
先聊一个很多人刚开始都会纠结的问题。我见过不少新手直接用BERT接一层softmax去做序列标注,训完发现F1值总差那么三四个点,结果在bad case里一看——B-PER后面连着跟了三个I-ORG,标签顺序完全乱套。这就是典型的"没有约束条件"导致的输出乱象。
命名实体识别本质上是一个结构化预测问题,不是单纯的多分类问题。你在预测第i个token是B-PER还是I-PER的时候,字符串是一个完整句子的上下文,前后标签之间的依赖关系本身就携带了强约束信息。举个最简单的例子:BIO标注规则下,I-PER前面必须是B-PER或者I-PER,单独一个O后面直接跟I-PER是绝对不该出现的,句子开头也不该出现I-标签。这些规则,softmax一个都不知道。
BERT+CRF这套方案解决的就是这个问题——BERT负责学习上下文的语义表示,CRF层负责为标签序列套上合法的转移约束。语义信息告诉我"这里大概率是个人名",转移矩阵告诉我"这个人名的标签必须符合B-PER开头、I-PER跟进的规范"。两者各管一摊,配合起来顺理成章。
从实际效果来看,在中文实体识别场景里,加上CRF层通常能把F1值稳定提升两到四个百分点,尤其是遇到长实体、嵌套实体的时候优势更明显。如果你做的是英文数据集,效果差距可能稍微小一点,但CRF带来的约束收益依然是实打实的。这也是为什么时至今日,BERT+CRF依然是很多NLP线上系统的标配基线。
2. 训练前必须想清楚的三件事:标注体系、标签映射、长度问题
这一节不是废话,很多效果翻车的项目,仔细排查下来都不是模型的问题,而是某个预处理细节没处理好。
2.1 BIO标注还是BIOES标注?
BIO是三个标签:B-(Begin,实体开始)、I-(Inside,实体内部)、O(Outside,其他)。BIOES是在这个基础上再加E-(End,实体结束)和S-(Single,单字实体)两个标签。
新手喜欢纠结选哪个,我的建议是:如果你的实体库里有很多单字实体,或者实体平均长度很短,用BIOES会舒服一点。BIOES把实体的边界信息表达得更明确,模型学起来容易一些。但BIO也不是不能用,CRF转移矩阵本身就具备约束能力,BIO配合CRF效果也不会差。
我用得最多的是BIO,原因很简单:标注工具普遍默认BIO格式,自己写转换脚本也快,少两个标签意味着num_labels小一截,训练速度会快那么一点点。你如果是从零开始做,直接用BIO就好。
2.2 标签映射表的坑
这是一个特别基础又特别容易出错的地方。你的标签映射关系一旦和模型输出的id顺序搞混,整个训练就不可能有正确结果。我习惯这样建映射:
label_list = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"] label2id = {label: idx for idx, label in enumerate(label_list)} id2label = {idx: label for label, idx in label2id.items()} num_labels = len(label_list)这个映射表在训练脚本和推理脚本里必须完全保持一致。我最开始犯过的错就是训练脚本和推理脚本各写了一份映射,后来给实体类型加了新类,只改了训练脚本,推理脚本没改,好了,线上预测全乱。从此我把映射表单独抽成配置文件,所有脚本共用一份,这算是血泪教训。
2.3 长度截断:实体被拦腰切断怎么办
BERT-base中文模型的max length通常是512,但实际上大多数数据集里的句子没这么长。训练前通常会设定一个max_len,比如128或256,把长句子截断。
问题在于盲目截断会把实体从中间切开。比如一句话里人名出现在第200到203个token的位置,如果你把前128个token直接截断,这个实体就完全丢掉了。我自己处理这种问题都是优先做"切句"而不是"截断"——按标点符号把长文本切成短的子句,然后再做padding和mask。如果实在切不了,那再截断,并保证最大长度不低于256,这样损失已经很小了。
另外要注意[CLS]和[SEP]这两个特殊token是占位置的。你设定的max_len=128,实际能放文本token的位置只有126个。
2.4 特殊token对应的标签
[CLS]、[SEP]以及padding的[PAD]位置怎么办?这里有个很关键的细节。
如果用纯BERT+softmax,你可以把特殊token的label设为-100,让loss计算时自动忽略。但加了CRF之后这条路走不通,因为CRF是对整条序列做联合解码,每个位置都必须有一个合法标签。我的做法是:所有特殊token统一标记为O,推理decode之后再把对应位置的结果扔掉。
# 构造训练样本时的标签处理 labels = ["O"] + token_labels + ["O"] # [CLS]填O, 真实token填对应标签, [SEP]填O提示:如果用transformers的tokenizer做word-level tokenize,要先把token级别的标注对齐到subword级别。中文场景按字切分时,通常一个token对一个人工标注的字符,问题不大;英文场景需要使用
labels对齐逻辑,稍麻烦一些。
3. 模型结构与DataLoader构造:把BERT和CRF拼起来
这一部分我直接给出一个完整可用的模型定义,然后逐块解释为什么这么写。
3.1 模型定义
import torch import torch.nn as nn from transformers import BertModel, BertPreTrainedModel from torchcrf import CRF class BertCrfForTokenClassification(BertPreTrainedModel): def __init__(self, config, num_labels): super().__init__(config) self.bert = BertModel(config, add_pooling_layer=False) # 只需要token级别的输出 self.dropout = nn.Dropout(config.hidden_dropout_prob) self.classifier = nn.Linear(config.hidden_size, num_labels) self.crf = CRF(num_labels, batch_first=True) self.num_labels = num_labels self.init_weights() def forward(self, input_ids, attention_mask=None, token_type_ids=None, labels=None): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ) sequence_output = outputs.last_hidden_state sequence_output = self.dropout(sequence_output) logits = self.classifier(sequence_output) # (batch_size, seq_len, num_labels) if labels is not None: loss = -self.crf(logits, labels, mask=attention_mask.bool(), reduction="mean") return loss, logits else: decoded = self.crf.decode(logits, mask=attention_mask.bool()) return decoded, logits注意我用的CRF是torchcrf这个第三方库的实现,接口简洁、性能也不错。它内部实现了转移矩阵的定义、训练时前向算法的配分函数计算和推理时的Viterbi解码,不需要自己重复造轮子。正常情况下pip install torchcrf就能装。
3.2 为什么add_pooling_layer=False
BERT原始输出除了每个token的隐层状态之外,还会在[CLS]位置接一个池化层,这是为句子分类任务设计的。NER任务只需要token级别的隐层表示,这个池化层纯属多余。显式关掉它,省一点内存和计算。
3.3 CRF的输入和输出
self.crf(logits, labels, mask=attention_mask.bool(), reduction="mean")返回的是负的对数似然损失,前面加负号才是真正的loss。
这里有个很容易忽略的点:mask必须是布尔类型,并且padding位置为False。CRF内部计算时会跳过这些位置,不把它们纳入转移路径。如果你忘了传mask,或者传了全True的mask,padding位置就会参与计算——不仅loss被padding稀释,转移矩阵也会被无意义的padding标签污染掉。
decode阶段返回的是一个list,list里每个元素是对应样本的标签序列(已经去掉了padding部分,但[CLS]和[SEP]还在),格式是[6, 1, 2, 2, 0, 6, ...]这样的数字id列表。后面要转成字符串标签再做评估。
3.4 DataLoader里别丢attention_mask
这是另一个实际训练中非常常见的低级错误。很多人在构造Dataset时把input_ids和labels放进来了,attention_mask忘记录。训练过程中报错还算好的,最怕的是mask传了全True,模型可能还能跑,但效果崩得毫无头绪。
class NERDataset(torch.utils.data.Dataset): def __init__(self, encodings, labels): self.encodings = encodings self.labels = labels def __getitem__(self, idx): return { "input_ids": torch.tensor(self.encodings["input_ids"][idx]), "attention_mask": torch.tensor(self.encodings["attention_mask"][idx]), "labels": torch.tensor(self.labels[idx]), } def __len__(self): return len(self.labels)tokenizer返回的token_type_ids在单句文本场景下可以省略,多句拼接或者带指代消解任务的场景才需要用到。
4. 训练策略:分层学习率与早停
BERT+CRF训练和纯分类任务的微调有共同点,也有一些专门的讲究。这一节我把我的训练配置和踩坑经过都写出来。
4.1 为什么CRF层要用更大的学习率
BERT预训练模型里的参数已经收敛得差不多了,微调时学习率必须低一点,一般2e-5到5e-5之间。而CRF层的转移矩阵是随机初始化的,从零开始学,如果学习率和BERT主干保持一致,CRF学得非常慢,前几个epoch基本学不到合理的转移约束。
我的经验是给CRF层单独开一个学习率,通常是BERT主干的5到10倍。实现方式有两种:
一种是简单粗暴,直接在optimizer参数里给两组参数:
optimizer = torch.optim.AdamW([ {"params": model.bert.parameters(), "lr": 2e-5}, {"params": model.classifier.parameters(), "lr": 2e-5}, {"params": model.crf.parameters(), "lr": 2e-4}, ], weight_decay=0.01)另一种是用transformers的get_linear_schedule_with_warmup给BERT部分做warmup,CRF层不参与warmup。我试过两种,效果差别不大,看个人习惯。
4.2 损失函数的真实来源:负对数似然,不是交叉熵
很多人会好奇:这个模型到底是怎么算loss的?这里把CRF的数学原理拆开说。
CRF给每个标签序列算一个score,这个score由两部分构成:发射分数(来自BERT输出的logits)和转移分数(CRF转移矩阵)。给定一个标签序列y = (y_1, y_2, ..., y_n):
score(y) = sum(emit[i, y_i]) + sum(transition[y_{i-1}, y_i])
训练的目标是最大化正确答案y_true对应的条件概率P(y_true | x)。分母是一个对所有可能标签序列求和的配分函数Z:
P(y_true | x) = exp(score(y_true)) / Z
Z = sum over all y of exp(score(y))
求P的负对数就是loss。在实现层面,分母的Z不能用暴力枚举的方式算,CRF用前向算法(动态规划)高效计算log-sum-exp,这也是CRF层训练速度的关键。
这也解释了为什么padding位置必须mask——那些无意义的[PAD]token没有转移语义,强行算进来会对配分函数产生错误的贡献。
4.3 一个epoch看两次loss,该停就停
NER训练epoch数通常不需要太多,BERT-base微调3到5个epoch就够了。我一般epoch设5,但配合早停策略,不在验证集上继续提升的时候自动终止。
判断NER模型好坏的标准不是只看loss,要结合实体级别的精确率、召回率、F1值综合判断。只用loss做早停可能掉进一个陷阱:loss降得很欢,F1值却在某个点开始滑落,这是过拟合的典型信号——CRF转移矩阵把训练集里的特殊模式记死了,泛化能力反而下降。
我在训练脚本中每500步打印一次训练loss,每个epoch结束用验证集算一次F1值。当发现F1连续两个epoch没有提升,就保存当前模型作为最优权重并终止训练。
5. 推理与解码:Viterbi算法与实体还原
训练完成后,推理阶段的逻辑和训练不完全一样,有几个细节值得单独拎出来说。
5.1 decode拿到的不是概率,是路径
有些新手会这么做:把模型输出logits,在每个位置取argmax,然后直接把标签序列还原成实体。这在纯BERT+softmax方案里是常规做法,但加了CRF之后不能用argmax。
CRF的解码是全局最优路径搜索——Viterbi算法会综合考虑每个位置的发射分数和前后标签之间的转移分数,找到一条整体得分最高的完整路径。局部argmax选的路径可能在某个位置单独看是对的,但全局转移约束可能不成立,比如出现O后面直接跟I-PER这种非法转移。
model.eval() with torch.no_grad(): decoded_ids, logits = model(input_ids, attention_mask, token_type_ids) # decoded_ids是list of list,每个元素是label id序列 pred_labels = [[id2label[idx] for idx in seq] for seq in decoded_ids]注意这个输出里[CLS]和[SEP]的位置还在,并且被decode成了O。你需要在拿到预测结果之后把它们和原始文本对齐时一并删掉。
5.2 从标签序列还原实体并计算F1
还原实体的过程不复杂但容易出错,尤其是实体横跨多个连续token时要正确合并成一句完整的实体文本。我习惯这样写:
def extract_entities(token_list, label_list): entities = [] current_entity = "" current_type = "" for token, label in zip(token_list, label_list): if label.startswith("B-"): if current_entity: entities.append((current_type, current_entity)) current_entity = token current_type = label[2:] elif label.startswith("I-"): if current_entity and current_type == label[2:]: current_entity += token else: # I开头但没有对应的B,说明预测序列本身有问题,直接跳过这个token continue else: # O if current_entity: entities.append((current_type, current_entity)) current_entity = "" current_type = "" if current_entity: entities.append((current_type, current_entity)) return entities这个判断逻辑有一个重点:遇到I-的时候要检查当前有没有对应的B-开头实体,且实体类型要一致。比如当前是B-PER在身,后面跟了一个I-ORG,这明显是非法序列,直接把I-ORG扔掉比让它硬拼进B-PER更合理。
评估阶段我直接用seqeval这个库,它接受的输入是字符串标签序列的列表而非数字id:
from seqeval.metrics import classification_report true_sequences = [...] # 每个样本的gold标签,元素为"B-PER"这种字符串 pred_sequences = [...] # 预测标签,同样为字符串列表 report = classification_report(true_sequences, pred_sequences)注意:
seqeval要求把每个token的标签还原成原始字符串格式再传进去,数字id它是识别不了的。
5.3 推理速度优化
BERT+CRF比纯BERT推理慢一些,CRF解码的Viterbi部分是串行的动态规划,也难做batch内的并行加速。实际部署时如果对吞吐量有要求,可以在小batch下用torch.no_grad()+torch.cuda.amp混合精度推理减半显存占用,或者考虑把模型的CRF层单独导出后再做加速。
6. 训练实测:三个最该注意的坑
这一节是真实训练中踩出来的经验,每一个都直接影响最终F1值。
6.1 CRF转移矩阵的学习率太高会训崩
第一次训BERT+CRF的时候,我把所有参数都统一用了5e-5,跑了两个epoch,训练loss下降很正常,第三轮开始F1值反而下跌。查看logits发现数值不断膨胀。问题出在CRF转移矩阵的一个特殊性质上:它要被指数函数作用后去做log-sum-exp归一化,数值一膨胀,梯度就容易爆炸。
后来我把CRF层学习率降到2e-4,BERT主干学习率保持2e-5,问题就消失了。如果你发现训练过程中loss出现NaN,第一反应不应该是调整BERT的学习率,而是检查CRF层的梯度是否异常。
6.2 label2id和id2label反了,一切全乱
这真的是低级错误,真发生了更隐蔽。现象是训练loss很正常,但decode出来的标签几乎全部乱套——实体类型彻底错位。排查了一天,最后发现推理脚本里id2label的字典键值写反了。我的建议是把映射表单独存成一个JSON文件,训练和推理共用,不要各自维护。
{ "label2id": {"O": 0, "B-PER": 1, "I-PER": 2, "B-ORG": 3, "I-ORG": 4, "B-LOC": 5, "I-LOC": 6}, "id2label": {"0": "O", "1": "B-PER", "2": "I-PER", "3": "B-ORG", "4": "I-ORG", "5": "B-LOC", "6": "I-LOC"} }6.3 实体被截断不处理,训练数据白花钱
这个问题在前面提到过,但这里再给一个具体的处理方案。假设你的原始文本特别长,且模型max_len设了128。直接把所有超长句子硬截断会丢失尾部实体的训练信号。我当时的数据集来自多个信息源,一个句子平均长度超过300个字符,直接截断后尾部实体几乎全部消失,模型对末尾位置的实体识别F1值惨不忍睹。
我的方案是预处理阶段先按句号、问号、分号做切句,把句子切到max_len以内,个别子句还是超长的再用滑动窗口截断并保留30个字符的重叠区。这样处理后,实体保留率提升了很多。
6.4 用reduce=True还是mean?batch不一致时天差地别
torchcrf的reduction参数可以设置为mean或sum。如果batch内每个样本的真实长度差异很大,用sum的话,长样本的loss占比会显著高于短样本,相当于每步更新的方向被长样本主导。用mean能缓解这个问题。
不过我实测下来,如果你的数据在DataLoader里已经做了统一长度padding,且每个batch内长度差异不大,sum和mean的差别并不明显。数据来源混杂、长度非常不均匀的时候,优先选mean。
7. 从单卡到多卡:DistributedDataParallel训练注意点
如果你只是做实验,单卡训练完全够用。但如果你想在大规模数据集上用多卡训练,有一个细节必须提前处理——CRF层的同步问题。
DistributedDataParallel会自动同步所有可学习参数,CRF的转移矩阵也是可学习参数,理论上没有问题。实际操作中要注意的是每个进程的数据采样器必须保证整个batch里的序列都是完整的,不能把一个样本切片分到两个进程——这个要求本身是合理的,因为DDP的梯度同步逻辑不会允许这样干。唯一需要小心的点是,不同卡上batch的真实序列长度差异过大时,GPU利用率会明显不均,建议先用排序做bucket sampler把相同长度的样本凑一个batch。
如果你用的是DataParallel而非DDP,情况更简单,但CRF层在DataParallel里不会自动展开,需要手动把self.crf的调用放到GPU主设备上处理,稍微烦一点。总体建议:新项目直接用DDP,别用DataParallel。
最后再分享一个小技巧。训练完成后,保存模型时除了存model.state_dict(),一定把label2id、id2label、max_len、label_list这些配置一起存下来,做成一个ner_config.json。这样后续部署推理服务或者模型二次迭代时,不会因为忘了映射表配置而折腾半天。我自己在项目交付后被新来的同事问过的第一个问题就是"这个id2label在哪儿找",有了这个配置文件,整个交接会顺畅很多。