首页 / 资讯中心 / 文章详情

BiLSTM-CRF序列标注原理与调参实战:工单实体抽取全记录

BiLSTM-CRF序列标注原理与调参实战:工单实体抽取全记录 ★ FEATURED ARTICLE
开头先交代背景。几个月前我接了一个文本抽取类的需求需要从工单数据里把设备名称、故障类型、处理措施这三类实体抽出来。看过一圈方案最后选了BiLSTM-CRF做序列标注。选它的理由很直接效果在评测集上够用部署成本比预训练大模型低一个量级而且推理速度快普通CPU就能扛住线上的并发压力。这篇文章就把我从数据准备、模型实现到调参避坑的完整记录写下来给后面要碰这类任务的同学一个参考。1. 为什么序列标注最终选了BiLSTM-CRF1.1 序列标注任务到底在预测什么序列标注的本质是给输入序列的每一个token分配一个标签。中文场景下最常见的就是BIEO、BIOES这类标注体系B代表实体开始I代表实体内部E代表实体结尾S代表单个字独立成词。拿我做工单实体抽取来说输入空调主机出现异响输出就是空(B-设备)调(I-设备)主(I-设备)机(I-设备)出(O)现(O)异(O)响(B-故障)这样的标签序列。序列标注覆盖的任务非常广分词、词性标注、命名实体识别、槽位填充、语义角色标注本质上都是同一套思路。区别只在于标签集合不同模型结构完全通用。这也是我后来一直复用这套代码的原因——换一个任务改一下标签集和数据格式就行模型部分一行不用动。1.2 纯BiLSTM的致命短板刚接触序列标注的人最容易犯的误区是觉得用BiLSTM每个位置做softmax分类就够了。我最早也这么干过而且当时测试集的F1看起来还不错但线上数据一上来就露馅了。问题出在纯BiLSTM完全不管标签之间的依赖关系。举个例子B-设备后面跟B-故障这种标签序列在实体抽取任务里是不合法的——因为一个实体的开头标签B后面必须跟着实体的内部标签I或者结束标签E不可能直接跳到一个全新的实体开头。但如果只做逐位置的softmax模型在学到一定概率分布后完全有可能在某些边界位置输出这种非法转移。更经典的是BIO体系中O后面跟I的非法组合。如果某个字被标记为O非实体下一个字又标记为I实体内部这意味着实体没有开头直接就有内部这在语义上完全说不通。纯逐位置分类解不了这个问题因为它的目标函数里压根没有标签序列整体是否合理这个约束。1.3 传统CRF的另一面特征工程负担有人会说那直接用CRF不就行了传统CRF确实天生建模标签转移关系但它的短板同样明显极度依赖人工设计的特征模板。写特征模板是个体力活领域一换模板基本要推翻重来。举例说如果是做新闻领域的人名识别你可能要设计当前词是否在姓氏表上一个词是否为人名常用字当前词是单字还是双字等几十上百个特征。但换到工单领域实体是设备类型和故障类型这些特征全部失效又要重新去分析语料、设计特征。特征工程重复劳动量大而且特征模板的上限直接决定了模型的性能天花板。BiLSTM-CRF的聪明之处就在于把特征学习的活交给神经网络。BiLSTM自动学习每个位置的上下文表示CRF负责建模标签间的全局约束。前者的特征学习能力替代了手工特征工程后者的转移约束补上了纯神经网络的短板。这两者互补得非常自然也是这个组合至今仍是序列标注基线模型的原因。2. 模型原理拆解编码层与解码层各自做了什么2.1 BiLSTM编码器双向上下文怎么融合BiLSTM是由前向LSTM和后向LSTM拼接构成的。前向LSTM从左往右读句子每个时刻的隐状态携带的是当前位置左边的信息后向LSTM从右往左读携带的是右边的信息。把两个方向的隐状态拼接起来这个向量就同时包含了左右两个方向的上下文。为什么这种双向信息对序列标注重要因为很多标注决策依赖上下文而非只看当前词。比如在中文里好的这个词在好的空调里好是形容空调质量的形容词但在安装好的设备里好表示完成状态。如果只看当前字或左侧信息很容易分错边界。BiLSTM的每个输出向量都汇聚了整句的信息相当于在编码阶段就已经完成了上下文融合。具体实现上LSTM的三个门——遗忘门、输入门、输出门——决定了信息的保留和更新方式。遗忘门决定上一时刻的哪些记忆要保留输入门决定当前输入有哪些信息要写入记忆输出门决定当前记忆有哪些要输出到隐状态。双向结构则是在时间维度上各跑一遍最后把两个方向的隐状态在特征维度上拼接。2.2 CRF层标签转移矩阵到底学了什么CRF层维护一个标签间的转移矩阵矩阵的每个元素表示从一个标签跳转到另一个标签的得分。比如P(B-设备, I-设备)表示B-设备后面跟I-设备的转移得分训练过程中这个得分会被不断调整到一个较大值而P(B-设备, B-故障)会被训练到很低因为常见的合法语法里不允许这种跳转。除了标签间的转移矩阵CRF还要处理序列的起始和结束。实现上会引入START和STOP两个特殊状态。初始化时把无法从START出发的转移得分设为负无穷把无法到达STOP的转移也设为负无穷这样就从结构上杜绝了非法序列的产生。比如START不能直接跳到I-设备因为实体开头的B或S还没出现B-设备也不能直接跳到STOP因为实体还没闭合。这里有一个需要说明的习惯做法转移矩阵中有一些约束需要人工根据标签体系设计。比如在BIOES体系下B后面不能直接跟另一个BI后面不能跟SE后面不能跟同一实体的I等等。这些约束既可以手工写死也可以让模型自己从数据中学习。作为初始实现手工写死能保证模型输出的序列一定是合法序列防止预测时出现莫名其妙的错误。2.3 训练目标与Viterbi解码训练阶段BiLSTM-CRF不是对每个位置单独算损失而是计算整个标签序列的得分然后做序列级别的归一化。整个序列的得分等于所有位置的发射得分之和加上所有转移得分之和。为了计算归一化概率需要把所有可能的标签序列的得分都加起来。这个数量是指数级的但可以利用动态规划在多项式时间内算出也就是前向算法forward algorithm。训练时最大化的是真实标签序列的概率等价于最小化负对数似然。推理阶段则使用Viterbi算法解码找到得分最高的标签序列。Viterbi的本质也是一个动态规划每一步保留到达每个标签的最优路径得分最后回溯得到整体最优路径。因为转移约束的存在解码出来的标签序列天然满足合法约束不会出现B后面直接接另一个B这种错误。3. 完整实操流程从数据准备到模型落地3.1 数据格式与标签体系设计这一步最花时间也最容易被低估。我用的数据是几千条客服工单原始格式是一整段文本比如用户反映空调主机不制冷师傅上门检查后更换压缩机。要做成序列标注数据必须先切分成字级序列然后逐字打标签。标签体系我设计成BIOES加类别后缀B-设备、I-设备、E-设备、S-设备、B-故障、I-故障、E-故障、S-故障、B-措施、I-措施、E-措施、S-措施外加O。为什么用BIOES而不是BIO因为BIOES能显式标注实体边界训练时给模型更强的边界信号在实体长度较长或实体内部比较模糊的场景下效果更好。需要提醒的是这是全流程中最容易出错也最耗时的一环。我最初用的方式是让标注系统自动标注然后再人工校对但实际发现自动标注的错误率高尤其对实体边界不敏感。后来改成人工标注为主、规则预标注为辅质量才稳定下来。标注完成之后用脚本统计各类标签的分布确认数据没有极端倾斜再进入下一步。3.2 模型实现细节与关键代码模型结构本身不复杂我用PyTorch实现核心代码大致如下import torch import torch.nn as nn class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag_size, embed_dim, hidden_dim): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim // 2, num_layers1, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(0.4) self.fc nn.Linear(hidden_dim, tag_size) # 转移矩阵(tag_size, tag_size)行表示当前标签列表示下一个标签 self.trans nn.Parameter(torch.randn(tag_size, tag_size)) # 手动屏蔽非法转移 self.trans.data[tag2id[START], :] -10000.0 # START不能跳到任何标签 self.trans.data[:, tag2id[STOP]] -10000.0 # 任何标签不能跳到STOP除了结束时 self.trans.data[:, tag2id[START]] -10000.0 # 不能跳到START self.trans.data[tag2id[STOP], :] -10000.0 # STOP不能跳到任何标签 # 更多基于BIOES的约束按需设置发射得分来自BiLSTM的输出经过全连接层后的logits。CRF层的核心是前向算法计算对数似然以及Viterbi解码。关键实现如下def log_sum_exp(vec): max_score, _ torch.max(vec, dim-1) max_score max_score.unsqueeze(-1) return max_score torch.log(torch.sum(torch.exp(vec - max_score), dim-1)) def forward_alg(self, emits, mask): # emits: (batch, seq_len, tag_size) batch_size, seq_len, tag_size emits.size() alpha torch.full((batch_size, tag_size), -10000.0) alpha[:, tag2id[START]] 0.0 for t in range(seq_len): emit_t emits[:, t] # (batch, tag_size) # alpha扩展 转移 发射 alpha_expand alpha.unsqueeze(2) # (batch, tag_size, 1) trans_expand self.trans.unsqueeze(0) # (1, tag_size, tag_size) scores alpha_expand trans_expand emit_t.unsqueeze(1) alpha log_sum_exp(scores) # 最后加上转移到STOP的得分 alpha alpha self.trans[:, tag2id[STOP]].unsqueeze(0) return log_sum_exp(alpha)前向算法中有一个稳定计算的细节log_sum_exp里先减去最大值再做exp避免指数运算导致的数值溢出。这个细节在代码实现中必不可少写代码时写不写直接影响训练稳定性和梯度质量。Viterbi解码的代码结构和前向算法非常相似只是把log_sum_exp换成max同时额外记录每一步的最优前驱标签索引最后反向回溯得到最终序列。3.3 训练配置与效果评估我的训练配置值得直接给出供参考预训练字向量300维基于大规模通用语料训练得到LSTM隐藏层维度256双向拼接后也是256层数1层Dropout0.4Batch大小32学习率0.001Adam优化器训练轮数30轮使用早停机制训练过程中要监测两个指标验证集的实体级F1和标签级准确率。实体级F1按整个实体的边界和类别同时判对才算对标签级准确率逐字计算前者更贴合实际业务需求。我还做了一组对比实验结果值得参考模型实体级F1说明纯BiLSTMsoftmax78.3出现了大量非法标签序列纯CRF手工特征82.1特征工程耗时约3天BiLSTM-CRF89.6无手工特征仅用预训练字向量BiLSTM-CRF随机初始化字向量84.2比预训练向量差5个点左右预训练字向量的影响非常直观F1跳了5个多点。这也解释了为什么在中小规模数据上预训练embedding比模型结构本身的差异更值得优先关注。4. 调参与避坑实录那些文档里不会写的细节4.1 训练不收敛和过拟合的排查我第一版模型跑起来后loss一直降不下去验证F1卡在70左右上不去。排查后发现是学习率设置太大了——CRF的损失量级和纯softmax分类不一样整个序列的对数似然值通常比较大梯度尺度也不同。把学习率从0.01降到0.001之后收敛速度明显改善。另一个容易踩的坑是LSTM层数。看到很多论文用2层LSTM效果好我一开始也跟风用了2层但数据量只有几千条的时候2层LSTM的参数量太大验证集表现反而不如1层。后来在更多数据上测试2层才略微优于1层。所以我的经验是小数据先用单层把baseline跑通再逐步加复杂度不要一开始就上大模型。4.2 非法标签序列与边界错误模型跑通后最早期出现的诡异问题是预测结果中出现O后面接I这种非法序列。理论上CRF的转移矩阵应该学会避免这种情况但训练不充分时转移矩阵的参数还没有完全收敛就会偶尔出现。排查之后发现两个原因一是训练轮数不够CRF的转移参数还没稳定二是训练数据里有标注噪声也就是脏数据里确实存在O后面接I的情况。清洗数据之后非法序列的比例大幅下降。同时我在解码端加了后处理校验代码如果解码结果不合法用规则把非法标签置为O保证线上输出的序列一定是合法的。实体边界错误是另一个高频问题表现为设备实体预测多一个字或漏一个字。最有效的缓解手段是换用BIOES标注体系边界信息更明确。另外字向量的质量直接影响边界判断——在领域数据上增量训练过的字向量比直接用通用向量效果好很多。4.3 数据不平衡和稀疏实体工单数据里措施类实体远少于设备类导致模型对措施类实体的召回率偏低。我试过两种办法一是在loss里对实体类别做权重加权让稀有实体的错误贡献更大的梯度二是对包含稀有实体的样本做过采样。实测下来过采样比加权loss更稳定因为加权loss改变了loss尺度有时需要同步调学习率折腾一圈效果还不一定好。过采样的具体做法也有一点讲究不能简单地把稀有实体样本复制多少份就完事。我采用的方案是对每条包含措施实体的样本做基于相邻词替换的数据增强生成多个变体再并入训练集。这样既增加了稀有实体的样本量又保持了文本多样性验证集F1提升了2个点左右。4.4 Batch内Padding与显存管理序列标注任务的输入是不定长文本Batch内需要padding到相同长度。直观的做法是按最长序列padding但长序列会把整个batch拖得很长短样本的padding部分浪费严重显存利用率低训练速度也慢。我的做法是每个epoch在batch内按序列长度从长到短排序并且限制batch内序列长度差异不超过一个阈值。更常用的策略是先按长度分桶每个batch只包含长度相近的样本。实际测下来同样的训练轮数分桶之后训练速度提升了30%到40%模型最终效果也更稳定。另外要对LSTM的padding部分做mask处理。如果不加maskLSTM会对padding位置也计算隐状态污染真实序列的语义。损失计算时也要mask掉padding位置的所有logits只对真实token计算loss。5. 扩展方向与一点个人体会BiLSTM-CRF这套组合虽然已经有年头了但在实际项目中远没有过时。如果后续数据量变大可以把LSTM替换成预训练编码器CRF层保留在顶部做序列解码效果会再上一个台阶。不过代价是推理速度和部署成本都会变高所以是否升级需要结合业务来权衡。另一个可行方向是做蒸馏用预训练大模型在无标注或弱标注数据上生成soft标签然后用这些soft标签训练BiLSTM-CRF让轻量模型逼近大模型的效果。我在一个内部实验里试过这招F1能追到大模型的96%推理速度却是大模型的十几倍。最后讲一个我反复踩过的通用教训换用不同领域的数据时除了标签体系需要重新设计之外字向量的领域适配往往比模型结构的调整收益更大。我见过不少人拿到新任务先折腾模型结构把LSTM层数加厚、hidden size调大效果没有明显变化回头才发现是embedding没做领域适配。序列标注的瓶颈通常不在模型容量而在数据质量和输入表示这个认知帮我少走了很多弯路。如果你也要做序列标注项目我建议直接从BiLSTM-CRF起步。先把数据标注质量抓起来训练一个不依赖复杂特征工程、推理高效的baseline再根据自己的业务场景逐步优化。这套方法能做到快速落地也能为后面接入更强的模型留出清晰的扩展路径。
阅读完成 · 觉得有帮助?
咨询建站