
做NLP的时间一长就会发现序列标注几乎是绕不开的一道坎。无论是命名实体识别、中文分词还是词性标注最后都会落到同一个问题上给一个句子里的每个词或者每个字打一个标签。我在处理某采购系统的报销单自动抽取时需要从自由文本里抽出“出差日期”“出发城市”“到达城市”“报销金额”这些实体前前后后试过规则匹配、HMM、纯BiLSTM最后长期稳定跑下来的还是BiLSTM-CRF这套组合。这篇就把这套方案的原理和实操细节讲清楚适合正在做NER、分词、槽位填充这类任务想搞明白“BiLSTM为什么能和CRF配合”“这套模型落地时有哪些坑”的开发者。1. 先搞清楚序列标注到底在解决什么问题1.1 序列标注的本质和你最常见的三个应用序列标注说直白点就是给输入序列中的每个单元分配一个离散标签。这里的单元可以是词、可以是字在有些任务里甚至可以是语音帧。标签的含义由任务决定但数学上看都是同一个框架模型输入一个长度不确定的token序列输出一个同样长度的标签序列。最常见的三个应用场景是这几个。命名实体识别NER从文本中识别人名、地名、机构名等实体标签一般用B-PER、I-PER、B-LOC、I-LOC、O这种形式其中PER代表人物、LOC代表地点、O代表非实体。中文分词对每个字打B/M/E/S标签B表示词首、M表示词中、E表示词尾、S表示单字词。词边界识别本质也是一种序列标注。词性标注为每个词标注名词、动词、形容词等词性标签集取决于你使用的词性标注规范。还有一个不太起眼但实际很常见的场景是槽位填充也就是对话系统里把用户表达映射成意图里的具体槽位。比如“帮我订明天去某地的高铁票”系统要把“明天”标为时间槽、把“某地”标为目的地槽。这个任务的数据标注方式和NER几乎一样很多NER的方案可以直接平移。这里稍微展开讲一下标注体系。最常用的是BIO和BIOES两种。BIO用B-X表示一个实体的开头I-X表示实体内部O表示实体外部。BIOES在BIO基础上增加了E-X表示实体结尾、S-X表示单个字符构成的实体。我习惯用BIOES因为模型需要明确学到实体的结束位置尤其当实体长度差异比较大的时候BIOES预测出来的边界比BIO更稳。代价是标签数量多了一些标签类别多了训练时需要更多样本才能把每个类别的特征学扎实。1.2 为什么最终选择了BiLSTM-CRF而不是别的方案方案选型阶段我认真对比过几类常见做法不是拍脑袋定的。第一种是纯规则匹配。在报销单这种格式相对固定的文本上规则能解决一部分问题比如“金额数字单位”这类模式很稳定。但一旦遇到“从某地出发到某地办事后再返回”这种自由句式规则组合爆炸维护成本直线上升。第二种是传统机器学习方法典型代表是线性链CRF。它的优势是特征可解释、小样本上通过人工特征也能达到不错的效果问题是要手动设计特征比如词性、前后缀、词典匹配等。每换一个业务领域特征就要重新设计一轮很费力气。第三种是纯BiLSTM加softmax分类。这种方法让模型自动学习特征不需要人工特征工程在大量标注数据上效果很好。但纯BiLSTM对标签之间的结构关系完全没有感知它在每个token上独立做决策输出的标签序列可能是非法的。BiLSTM-CRF就是把上面的第三种的表示学习能力和第二种的序列约束能力结合起来BiLSTM负责从原始文本中学习上下文相关的特征表示CRF层负责学习标签之间的转移规则。举个例子可以说明这个差异。假设一句话是“小王去某市出差”如果模型对“某市”两个字分别预测双向循环网络可能在“某”上给出B-LOC高分在“市”上却给出O的高分因为从单个token角度看一个表示地点的名词出现在句末后面没有更多证据模型很难确定它是否属于实体内部。但CRF层的转移矩阵能明确学到“B-LOC后面接I-LOC是合法的接O是不合法的”因此在解码时会把“市”纠正成I-LOC。这个例子让我很直观地感受到CRF层不是在“锦上添花”而是在很多边界含糊的位置直接承担了纠错功能。2. 拆解BiLSTM-CRF的两个核心组件2.1 BiLSTM到底在算什么先说说LSTM本身。LSTM是循环神经网络的一个变体通过门控机制控制信息的保留和遗忘避免传统RNN在长序列上梯度消失、学不到远距离依赖的问题。它内部有遗忘门、输入门、输出门这三个门控但我们在工程上不一定要把每个门的细节背得多熟更重要的是理解它的行为给定一个token序列LSTM会依次读取每个位置输出一个隐状态这个隐状态把当前位置和之前位置的信息浓缩在一起。单向LSTM的问题是只从前往后看。在这个句子里某个词后面是什么内容模型在读到这个词的时候还看不到。比如“某地方言”里的“地方”和“去某地的机票”里的“某地”虽然两个“某地”看起来一样但后续出现的是“方言”还是“的”对判断它是不是地名有决定性作用。单向模型读到“某地”时后面信息还不可见就只能靠前面的信息猜测效果当然打折。BiLSTM的做法很直接一个正向LSTM从左到右读一遍句子一个反向LSTM从右到左再读一遍句子然后把每个位置的两个隐状态拼接起来。这样每个token的最终表示里既包含了左侧信息也包含了右侧信息相当于每个位置都有一份“前文摘要”和一份“后文摘要”。这个思路和我之前做文本分类时只用单向模型完全不同对序列标注这类强依赖上下文边界的任务尤其重要。用PyTorch实现BiLSTM非常简洁。比如一个基础的编码器import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_layers1): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM( embed_size, hidden_size // 2, num_layersnum_layers, batch_firstTrue, bidirectionalTrue ) def forward(self, token_ids): emb self.embedding(token_ids) outputs, _ self.lstm(emb) return outputs这里有个细节值得专门提醒我把单个方向的hidden_size设成了hidden_size // 2。因为PyTorch在设置bidirectionalTrue时最终拼接的隐状态维度是两个方向之和。如果我们希望整个编码器输出的维度是hidden_size正向反向各用一半。有些人直接写hidden_size不除2结果输出维度变成hidden_size * 2后面接线性层时维度对不上这种报错排查起来不算难但如果一开始想清楚就会省下不少时间。2.2 CRF层的转移矩阵到底做了什么现在说CRF层。BiLSTM输出后通常会过一个线性层把每个位置的隐状态映射成一个维度等于标签数量的分数向量。这个分数向量叫发射分数表示模型认为当前位置属于某个标签的可能性分数越高越可能。如果没有CRF常规操作是对每个位置的发射分数单独做softmax取得分最高的标签作为输出。问题在于标签序列本身存在明显的结构约束简单argmax会无视这些约束。举几个真实场景下的约束B-PER后面不可能直接接I-LOC因为一个人名实体后面不应该直接进入一个地点实体内部O后面不能直接接I-PER因为实体必须由B-PER开启即使同为实体标签B-PER后面接I-LOC也是不合理的它代表实体类别跳变。这些约束如果只靠数据硬灌给模型模型也能学个大概但学得既不彻底也不保证100%遵守因为softmax的每个位置决策是独立的没有任何机制保证全局标签序列合法。CRF层做的事情就是在发射分数之上额外引入一个可训练的转移矩阵。矩阵第i行第j列表示从标签i转移到标签j的得分。序列级别的总得分由两部分相加每个位置的发射分数加上相邻位置的转移分数。这样说可能有点抽象可以类比成你在一排队伍里给每个人发一张候选标签票但票能不能被接受还要看前后相邻的人拿的是什么标签。比如你手里有一张“I-LOC”的票但前面一个人的票是“O”那这票基本作废因为“O到I-LOC”的转移是非法路径转移矩阵会给一个很低的分数。实际实现时还要加入序列起始标记和结束标记它们各对应一个特殊状态。起始标记用来约束序列第一个标签不能是I类标签结束标记用来约束最后一个标签不能是B类标签。这些规则都体现在转移矩阵的对应行列上。2.3 训练时CRF的损失函数与前向算法CRF层的训练目标不是让正确序列的分数无脑变高而是让正确序列在所有可能序列中的相对概率尽量大。用数学一点的话说就是把一组发射分数和转移分数转成每个标签序列的概率然后最大化正确标签序列的概率也就是最小化负对数似然。这里有一个容易踩的坑所有可能标签序列的数量是指数级的标签数量是L、序列长度是n时大概有L的n次方条。不能真的枚举所有序列然后求和必须用动态规划来处理这就是所谓的前向算法。前向算法的思路是维护一个矩阵alpha[i][j]表示从第1个位置到第i个位置并且在第i个位置标签为j的所有前缀路径的得分之和在log域下通常做log-sum-exp。从i1开始递推到in复杂度是O(n乘L的平方)。这个复杂度在标签数量只有十几个的序列标注任务里完全可接受。在代码层面如果你不想深究内部细节直接用pytorch-crf库就是一行事from torchcrf import CRF crf CRF(num_tags) loss -crf(emissions, tags, maskmask)这里的emissions是BiLSTM输出后过线性层得到的发射分数tags是正确标签序列mask标记有效位置。库内部会自动完成前向算法求归一化项、计算目标序列得分、求负数取平均。我个人的建议是第一版先用这个库把pipeline跑通确保数据处理没问题之后再自己把前向算法和维特比解码写一遍。只有自己写过一遍才能真正理解CRF对序列约束的作用。我第一次手写CRF时在log-sum-exp的处理上出了好几个bug但也正是那次踩坑让我对CRF的计算图结构有了很清楚的把握。3. 动手实现从数据准备到模型搭建3.1 数据准备与标注规范从零开始做一个序列标注任务数据整理往往占掉一半以上的工作量。我这里以命名实体识别为例采用BIOES标注体系。首先把原始文本转成“每行一个token加标签、空行分隔句子”的格式这是最通用的序列标注数据格式之一。小 B-PER 王 I-PER 去 O 某 B-LOC 市 E-LOC 出 O 差 O预处理脚本要做的事包括读文件、按空行切句子、把每行的token和标签分别收集成两个列表。然后构建两个映射表一个是token到id的词典一个是label到id的标签表。构建词典时有一个重要决定必须设置一个UNK的id把所有出现在训练集里次数很少或者完全没见过的词映射到UNK。序列标注任务里OOV问题是注定存在的不设置UNK的话模型遇到生词会直接报错或者随机映射训练和推理会不稳定。标签表也要预留一个PAD标签。因为一个batch里的句子长度不同我们会用pad的方式把句子补齐到相同长度。pad的位置不参与损失计算也不参与CRF的转移计算PAD标签在转移矩阵中要设置约束不允许从PAD转移到其他标签也不允许从其他标签转移到PAD。还有一个经常被忽略的数据清洗细节标注一致性问题。同一个实体在语料里有时候被标成“B-LOC I-LOC”有时候标成“B-LOC E-LOC”这会让模型学到互相矛盾的边界规则。我会在预处理阶段写一个简单的校验脚本把标注序列里“O后面跟I类标签”这类明显违反BIOES规则的情况直接标红逐条修复。3.2 完整模型搭建完整模型结构有五层嵌入层、BiLSTM编码器、线性投影层、CRF层。嵌入层把token id变成稠密向量BiLSTM编码器双向读取输出每个位置的上下文表示线性投影层把上下文表示映射成发射分数CRF层在序列级别建模标签转移。如果使用pytorch-crf库模型代码可以写成这样import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_tags): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.dropout nn.Dropout(0.5) self.bilstm nn.LSTM( embed_size, hidden_size // 2, batch_firstTrue, bidirectionalTrue ) self.classifier nn.Linear(hidden_size, num_tags) self.crf CRF(num_tags) def get_emissions(self, token_ids): emb self.dropout(self.embedding(token_ids)) outputs, _ self.bilstm(emb) emissions self.classifier(outputs) return emissions def forward(self, token_ids, tags, mask): emissions self.get_emissions(token_ids) return -self.crf(emissions, tags, maskmask)forward里返回的是负对数似然可以直接作为loss。这里有一个值得注意的细节CRF层期望的emissions形状是[batch_size, seq_len, num_tags]标签形状是[batch_size, seq_len]mask形状也是[batch_size, seq_len]数据类型是bool。如果你在数据管道里用了别的形状或者mask类型不对库会在运行时抛出蛮奇怪的错误建议起步阶段就把这三个量的形状写在一个assert里节省排查时间。由于喂给Embedding的是词级别token如果做的是字级别的中文NER那token就是单个汉字。中文NER里字级别输入在OOV上更稳因为汉字集合相对封闭生僻字很少词级别输入虽然在某些词边界明显的任务里有优势但分词错误会直接传递到下游。3.3 训练循环与基本参数设置训练主干代码是标准流程但有几个要点梯度裁剪、设备一致性、batch里的pad处理。model BiLSTMCRF(vocab_size, embed_size, hidden_size, num_tags) model.to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(max_epochs): for batch in data_loader: token_ids, labels, mask [x.to(device) for x in batch] loss model(token_ids, labels, mask) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step()梯度裁剪是BiLSTM-CRF训练里仅次于学习率的重要参数。我在某个项目里第一次不裁剪loss到第5轮开始周期性震荡最小值降不下去加上裁剪之后曲线变得平滑最终F1也上来了。原因在于LSTM的反向传播路径很长梯度很容易出现指数级增长裁剪直接把梯度向量的模长限制在5.0防止参数被极端的大梯度带偏。设备一致性也是新手容易踩的坑。embedding矩阵在CPU上初始化模型搬到GPU后参数也搬过去但数据如果忘了搬到GPU计算时就会出现CPU tensor和GPU tensor混用轻则类型报错重则在CRF内部冒出奇怪的NaN。我一般的做法是把batch里的所有tensor在进模型前统一搬到device不做任何留到模型内部的搬运。4. 训练与推理的实用细节4.1 训练参数怎么调我不太喜欢给一套“万能参数”因为数据不同最优值会漂。但下面这套起点配置在大多数序列标注任务上都能很快跑起来参数推荐值我的理由嵌入维度100-300字级别100够用词级别想表达更丰富语义用300BiLSTM隐藏层200-256再大提升有限训练更慢层数1层2层对BiLSTM-CRF提升不明显容易过拟合Dropout0.5放在embedding后和LSTM输出前优化器Adam默认beta参数就够调起来省心学习率1e-3实体抽取任务上很好的起点不行再降到1e-4Batch size32-64取决于显存太大反而收敛慢梯度裁剪5.0防梯度爆炸必备再说一个很多人不知道的小技巧CRF转移矩阵的初始化。如果数据里O标签占绝大多数模型一开始很容易输出大量实体标签或者干脆把实体全部吞掉。我们可以人工把O到O的转移初始值设置成高一点比如0.0其他转移设为-1.0让模型一开始倾向于输出O然后随着训练慢慢调整转移矩阵。这个做法在实体稀疏的数据集上特别好用。早停也是推荐加的。我通常预留一个验证集每个epoch结束后在验证集上算实体级F1连续3到5个epoch没有提升就保存最优模型并停止训练。序列标注任务里过拟合比文本分类来得更隐蔽因为整体准确率看着不低但实体的precision和recall可能已经开始分开下滑所以只看验证F1更靠谱。4.2 推理时用维特比解码而不是逐点argmax训练完成后进入推理最容易犯的错就是用torch.argmax(emissions, dim-1)直接找每个位置最优标签。这个做法忽略了标签之间的转移约束会和训练目标不一致训练时CRF在做全局最优序列推理推理时你却做局部最优两个阶段口径不一样效果会打折扣。正确做法是维特比解码。维特比算法同样是动态规划它维护到当前位置、以每个标签结尾的所有路径中得分最高的一条路径的分数同时记录这条路径上当前位置的前驱标签。从位置1推到位置n后取最后一个位置得分最高的标签再沿着记录的前驱指针一路回溯就得到全局最优标签序列。伪代码可能更直观import torch def viterbi_decode(emissions, transitions, start_idx, end_idx, mask): seq_len, num_tags emissions.shape dp torch.full((seq_len, num_tags), -1e9) back torch.zeros(seq_len, num_tags, dtypetorch.long) dp[0] emissions[0] transitions[start_idx].clone() for i in range(1, seq_len): for j in range(num_tags): score, best_idx (dp[i-1] transitions[:, j]).max(dim0) dp[i, j] score emissions[i, j] back[i, j] best_idx best_last dp[seq_len - 1].argmax(dim0) path [best_last.item()] for i in range(seq_len - 1, 0, -1): path.append(back[i, path[-1]].item()) return list(reversed(path))上面忽略了mask和start/end的精细处理实际工程代码里要把padding位置排除在解码范围之外。如果用pytorch-crf库直接用crf.decode(emissions, maskmask)就行库内部封装好了维特比。我的习惯是调试阶段手写一个维特比验证库函数的输出确认两边结果一致后线上代码再用库函数以免被第三方库的版本升级坑到。4.3 我跑出来的效果观测说一组我实际项目里的对比数据不一定有普适性但能说明CRF层的价值。同样的训练数据大约几万条标注句子同样的BiLSTM结构纯BiLSTM softmax实体F1大概在88。BiLSTM CRF实体F1提升到91左右。BiLSTM CRF 额外词典特征拼接实体F1能到92附近。CRF贡献的3个点F1在业务上是有意义的。更重要的是我检查预测结果时发现纯softmax版本输出的标签序列经常出现“B-LOC后面直接跟O再把下一个词标成I-LOC”这种局部奇怪但全局自洽的错误而CRF版本基本不会出现这种违反标签转移规则的输出。这个差异在把预测结果结构化展示给业务方看的时候非常明显CRF版本的输出给人的“秩序感”强很多。5. 常见问题与排查实录5.1 训练不收敛或者loss震荡如果loss一直不降或者上下乱跳先别急着调模型结构按顺序排查这几个点。现象可能原因排查建议loss几乎没有下降学习率太极端先确定学习率是1e-3左右不行降到1e-4loss持续震荡梯度爆炸或batch size过小加梯度裁剪适当增大batchloss正常但标签全预测成O正负样本极不平衡统计O和实体标签比例考虑增加实体样本权重loss出现NaNmask没传递或设备不一致检查mask形状检查所有tensor是否移动到同设备验证集好但测试集差过拟合或数据分布不一致加早停检查训练集验证集划分是否随机我遇到最多的是mask和设备问题。先说mask如果忘记把mask传给CRFpadding位置也会参与损失计算模型会去预测那些无意义的pad token学习了垃圾特征loss往往不降反升。再说设备CPU初始化、GPU运行或者反过来在CRF内部会有一些隐式操作类型不一致时不是每个框架都能给出清晰报错经常表现成莫名其妙的NaN。5.2 实体边界总是预测错实体边界错是NER任务里最常见的典型错误。表现是实体整体被找到了但头或者尾多出一个字或者漏掉一个字。比如“某市电子商务产业园”被识别成“某市电子商务产业”或者“某银行”被识别成“某银”。排查时我强烈建议做一次错误聚类分析把验证集里所有预测错样本按错误类型归堆看看最多的是“实体完全漏召”“实体张冠李戴”“实体边界偏移”中的哪一类。如果是边界偏移大概率是标注数据本身的问题。同一个实体在不同句子里的边界标注不统一模型就会学到摇摆的边界规则。另外一个容易被忽略的点是BIOES和BIO混用。如果你的上游同学给了BIO标注数据你自己又手动加了一些BIOES标注的样本模型会同时学到两套互相冲突的转移约束导致边界预测混乱。遇到这种情况我的做法是写一个脚本统一转成BIOES或者统一转成BIO然后在训练前跑一遍校验代码保证标签序列里不会出现“O后面直接跟E-LOC”“B-LOC后面直接跟E-LOC”这类结构。5.3 Batch内padding的坑Batch训练必然要用padding把不同长度的句子补齐而mask是CRF训练里最关键的一个辅助信息。CRF在计算所有可能路径的得分时路径会穿过padding位置。如果mask没传对或者mask标记的有效位置不对CRF会把pad标签当成正常标签参与转移打分导致损失错乱。我第一次写序列标注代码时在mask上折腾了很久。常见错误包括mask用0/1整数而不是boolmask和标签的shape不一致mask没有和设备上模型参数同步。我的经验是在数据管道末尾统一检查这三个tensor的shape让它们分别是[batch, seq, num_tags]、[batch, seq]、[batch, seq]并且都是bool类型且在同一个设备上。这样能把问题在进入模型前就拦截住。5.4 OOV词怎么处理序列标注任务里的未登录词处理比一般分类任务更要重视。原因很直接如果句子里的地点名、产品名是训练集里没见过的模型在embedding矩阵里根本查不到对应向量只能通过UNK兜底信息完全丢失。最简单的做法是词级dropout训练时随机把一部分词替换成UNK让模型学会在OOV情况下仍然尽量输出合理的标签。这个技巧成本很低效果还算可以。更进阶的做法是引入字符级编码对每个词把组成它的字符序列再用一个双向LSTM或CNN编码成字符级向量然后和词向量拼接。这样即使整词没见过字符组合仍然能提供一定的语义。我在地址解析任务里试过字符级CNN对生僻地址词的识别效果提升很明显。如果项目里允许用预训练语言模型也可以把BiLSTM的输入换成预训练模型的token embedding甚至直接用预训练模型的输出作为发射分数。这个方案对OOV的处理更彻底但计算开销大推理速度慢要根据业务场景取舍。6. 几点实战体会6.1 数据质量和标注一致性永远是第一位说了这么多技术细节如果让我只挑一条经验我会选数据质量决定上限。在报销单实体抽取项目里我前两版模型效果不理想一度怀疑结构有问题结果仔细检查后发现是标注数据里大约8%的样本存在边界错标和标签体系混用。清洗掉这些问题后同一个模型结构F1直接涨了两个多点。所以每次开工我都会先写一个校验脚本检查训练数据里的标签序列合法性、实体边界一致性、句子空行格式等。这种脚本不复杂但能省掉后续排查的无数时间。数据上的多花一小时胜过调参的三天。6.2 模型选型的取舍和后续扩展BiLSTM-CRF是一个经典的组合不是因为它最时髦而是因为它在小模型、低算力、中等数据量的条件下能给出稳定且可解释的结果。如果要进一步提升效果我会优先考虑两条路线一是给BiLSTM换成预训练模型整体效果能涨但推理耗时也明显增加二是保持BiLSTM不变在结构上做多任务学习比如在一个模型里同时做实体抽取和实体类别判断共享上下文表示可以减少重复计算。另外想提一个针对实体层级关系的经验。我在地址解析任务里实体类别存在“地址-城市-街道”这种包含关系用一个CRF头难处理这种嵌套结构。后来我改成共享同一个BiLSTM编码器后面挂多个CRF头分别预测不同层级的实体类别效果比单头好不少。这个思路在“粒度粗细不同”的序列标注任务里值得一试。最后分享一个实操习惯任何序列标注模型上线后都要定时在真实语料上抽样本做人工复核看看新词、新句式会不会让模型质量缓慢下滑。模型训好只是开始后续的持续观测和数据回流才是让它长期好用的关键。