做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_layers=1): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size) self.lstm = nn.LSTM( embed_size, hidden_size // 2, num_layers=num_layers, batch_first=True, bidirectional=True ) def forward(self, token_ids): emb = self.embedding(token_ids) outputs, _ = self.lstm(emb) return outputs这里有个细节值得专门提醒:我把单个方向的hidden_size设成了hidden_size // 2。因为PyTorch在设置bidirectional=True时,最终拼接的隐状态维度是两个方向之和。如果我们希望整个编码器输出的维度是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。从i=1开始递推到i=n,复杂度是O(n乘L的平方)。这个复杂度在标签数量只有十几个的序列标注任务里完全可接受。
在代码层面,如果你不想深究内部细节,直接用pytorch-crf库就是一行事:
from torchcrf import CRF crf = CRF(num_tags) loss = -crf(emissions, tags, mask=mask)这里的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_first=True, bidirectional=True ) 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, mask=mask)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(), lr=1e-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_norm=5.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够用,词级别想表达更丰富语义用300 |
| BiLSTM隐藏层 | 200-256 | 再大提升有限,训练更慢 |
| 层数 | 1层 | 2层对BiLSTM-CRF提升不明显,容易过拟合 |
| Dropout | 0.5 | 放在embedding后和LSTM输出前 |
| 优化器 | Adam | 默认beta参数就够,调起来省心 |
| 学习率 | 1e-3 | 实体抽取任务上很好的起点,不行再降到1e-4 |
| Batch size | 32-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, dtype=torch.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(dim=0) dp[i, j] = score + emissions[i, j] back[i, j] = best_idx best_last = dp[seq_len - 1].argmax(dim=0) 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, mask=mask)就行,库内部封装好了维特比。我的习惯是调试阶段手写一个维特比验证库函数的输出,确认两边结果一致后,线上代码再用库函数,以免被第三方库的版本升级坑到。
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-4 |
| loss持续震荡 | 梯度爆炸或batch size过小 | 加梯度裁剪,适当增大batch |
| loss正常但标签全预测成O | 正负样本极不平衡 | 统计O和实体标签比例,考虑增加实体样本权重 |
| loss出现NaN | mask没传递或设备不一致 | 检查mask形状,检查所有tensor是否移动到同设备 |
| 验证集好但测试集差 | 过拟合或数据分布不一致 | 加早停,检查训练集验证集划分是否随机 |
我遇到最多的是mask和设备问题。先说mask:如果忘记把mask传给CRF,padding位置也会参与损失计算,模型会去预测那些无意义的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整数而不是bool;mask和标签的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头,分别预测不同层级的实体类别,效果比单头好不少。这个思路在“粒度粗细不同”的序列标注任务里值得一试。
最后分享一个实操习惯:任何序列标注模型上线后,都要定时在真实语料上抽样本做人工复核,看看新词、新句式会不会让模型质量缓慢下滑。模型训好只是开始,后续的持续观测和数据回流才是让它长期好用的关键。