Transformer序列标注实战:从标签对齐到模型落地的完整指南
序列标注这个任务我在刚接触Transformer那会儿踩过的坑比后面做过的项目还多。标题里这个“上手8”的编号说明它属于一个系列教程的第八篇前面大概率已经铺完了注意力机制、位置编码、编码器解码器结构这些基础内容到这一篇才真正把模型摁到一个具体任务上去跑。序列标注听起来朴素——不就是给每个token打个标签嘛——但真动手做起来从数据对齐、标签体系设计、子词切分后的标签映射到损失函数里那个ignore_index该填几每一步都有能让人卡半天的地方。这篇内容我打算把序列标注任务从任务定义到模型落地整条链路拆开讲重点放在Transformer做序列标注时那些和RNN时代完全不同的处理细节上适合已经能跑通Transformer基础结构、想把它用到命名实体识别、词性标注、事件抽取这类逐token分类任务上的朋友参考。不管你是刚学完理论想找个任务练手还是已经在做序列标注但模型效果总差一口气下面这些内容应该都能对上你的需求。1. 序列标注任务的整体设计与方案选型1.1 序列标注到底在标注什么先把任务本身说清楚。序列标注是给输入序列里的每一个位置分配一个标签输入是长度为n的token序列输出是同样长度为n的标签序列。命名实体识别是最典型的场景比如“某公司昨日发布了新款产品”这句话要标出“某公司”是组织机构、“昨日”是时间、“新款产品”是产品名。词性标注、中文分词、语义角色标注、事件论元抽取本质上都是这个框架下的变体。这个任务有个容易被忽略的特点输入输出长度严格对齐。分类任务输出一个标签生成任务输出不定长序列而序列标注要求每个输入位置都有且只有一个输出标签。这个约束直接决定了模型结构的选择——编码器输出的每个位置向量都要单独接一个分类头而不是像生成任务那样用解码器自回归地吐token。我在早期尝试时曾经想过用编码器-解码器结构做序列标注结果发现解码器那边根本没必要纯编码器加逐位置分类头就够了还省了一半参数量。另一个关键点是标签之间的依赖关系。相邻位置的标签不是独立的比如B-ORG后面大概率跟I-ORG而不是I-PER这种转移约束在解码阶段能显著提升效果。Transformer本身没有显式的序列转移建模能力所以要么在输出层加CRF要么在解码时做约束搜索这个取舍后面会详细讲。1.2 为什么用Transformer而不是RNN做序列标注这个问题值得掰开说因为选型逻辑直接决定了后面所有实现细节。RNN做序列标注是天然契合的——它本身就是逐时间步处理每个位置的隐状态天然携带上下文信息接个分类头就完事。Transformer的编码器输出虽然也是逐位置的但它的上下文聚合靠的是全局注意力每个位置都能看到全序列这在理论上比RNN的单向或双向传递更充分。实测下来的差异主要体现在三个地方。第一是长距离依赖RNN在长序列上梯度传播路径长靠前的信息传到靠后位置时衰减明显而Transformer的注意力是直接连接距离为1的路径长实体跨十几个token也能稳定捕捉。第二是并行效率RNN必须串行处理时间步训练时batch内序列还得padding对齐Transformer一次前向就能处理整个序列训练速度在长序列上优势明显。第三是预训练模型的迁移现在做序列标注基本都会加载预训练语言模型的权重做微调而这些预训练模型清一色是Transformer架构用RNN反而接不上这个生态。但Transformer做序列标注也有它的麻烦。最直接的就是位置信息的处理RNN的时序信息是结构自带的Transformer得靠位置编码显式注入位置编码的设计和长度外推能力会直接影响标注效果。另外输出层的设计比RNN复杂因为要额外考虑标签转移约束怎么加。这些麻烦点恰恰是这篇要重点拆解的内容。1.3 标签体系设计BIO还是BIOES动手写代码之前标签体系必须先定下来这个决定会影响数据预处理、模型输出维度、解码逻辑三处。最常见的两套方案是BIO和BIOES。BIO把每个token标成B-X实体开始、I-X实体内部、O非实体三类标签总数是2×实体类别数1。BIOES在此基础上拆出E-X实体结束和S-X单字实体标签总数是4×实体类别数1。多出来的标签让实体边界更明确解码时不用靠“下一个标签不是I”来推断结束位置。方案标签数以3类实体为例边界清晰度适用场景BIO7一般需靠后续标签推断结束实体较短、类别少的任务BIOES13高起止都有显式标记实体较长、边界易混淆的任务BMES中文分词4高中文分词专用我个人的经验是实体平均长度超过3个token、或者实体类别之间容易混淆时BIOES带来的边界信息值得多那几个输出维度。但如果实体普遍很短比如人名大多两三个字BIO和BIOES的差距在1个点以内用BIO省事。中文分词场景则直接用BMES这是分词任务的惯例不用纠结。注意标签体系一旦定下来训练数据、验证数据、测试数据、推理输出必须全程一致。我见过有人训练用BIO、推理时按BIOES解析结果实体边界全错排查了半天才发现是标签映射表对不上。1.4 子词切分带来的标签对齐问题这是Transformer做序列标注最容易被低估的坑。预训练模型用的是子词分词器一个词可能被切成多个子词而标注数据是按词或按字标的。比如“Transformer”可能被切成“Trans”“former”两个子词但标注只有一个标签那这两个子词该各拿什么标签常见处理策略有三种。第一种是首子词拿原标签其余子词标为忽略训练时这些位置不计损失推理时取首子词的预测作为该词的标签。第二种是所有子词都拿原标签适合BIO体系下I标签的延续但会让模型学到重复模式。第三种是首子词拿B标签、后续子词拿I标签这个最符合BIO语义但要求原始标注能拆出B和I。我一般用第一种实现简单且不会引入噪声。具体做法是在tokenize之后用一个-100填充那些非首子词位置PyTorch的CrossEntropyLoss默认忽略-100。这里有个细节特殊token[CLS]、[SEP]、[PAD]也要标成-100否则模型会去学预测这些位置的标签纯属浪费容量。def align_labels_with_tokens(labels, word_ids): new_labels [] current_word None for word_id in word_ids: if word_id ! current_word: current_word word_id label -100 if word_id is None else labels[word_id] new_labels.append(label) elif word_id is None: new_labels.append(-100) else: new_labels.append(-100) # 非首子词忽略 return new_labels这段逻辑看着简单但word_ids()返回的None对应特殊token第一个子词的word_id和上一个不同所以要取标签后续子词word_id相同就填-100。写的时候一定要拿几条样本打印出来核对我当初就是没核对导致所有实体的第二个子词都被当成独立位置训练模型学出来的边界全是错的。2. 模型结构拆解与核心实现要点2.1 编码器加分类头的最小结构序列标注的模型结构可以极简到“预训练编码器 线性分类层”。编码器输出形状是[batch, seq_len, hidden_size]分类层把hidden_size映射到标签数输出[batch, seq_len, num_labels]然后对seq_len维度做softmax训练时其实不需要CrossEntropyLoss内部会做。import torch.nn as nn from transformers import AutoModel class SequenceLabeler(nn.Module): def __init__(self, model_name, num_labels, dropout0.1): super().__init__() self.encoder AutoModel.from_pretrained(model_name) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(self.encoder.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask): outputs self.encoder(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state sequence_output self.dropout(sequence_output) logits self.classifier(sequence_output) return logits结构就这么点但有几个参数选择值得说。dropout位置放在编码器输出和分类层之间而不是分类层之后因为分类层输出直接进损失函数加dropout反而干扰梯度。分类层初始化用默认的均匀分布就行不用特殊初始化因为预训练编码器输出已经是有意义的表示分类层只需要学一个线性映射。是否冻结编码器取决于数据量标注数据少于5000条时建议先冻结底层只训顶层和分类头数据多了再全量微调。2.2 学习率分层设置的必要性预训练编码器和随机初始化的分类头对学习率的敏感度完全不同。编码器已经在大规模语料上收敛到不错的表示空间学习率太大会把预训练学到的知识冲掉分类头是随机初始化的需要较大的学习率才能快速收敛。如果统一用一个学习率要么编码器被破坏要么分类头学得慢。标准做法是分层设置编码器用较小的学习率通常2e-5到5e-5分类头用较大的学习率1e-3到1e-2。实现上可以用参数组encoder_params list(model.encoder.named_parameters()) head_params list(model.classifier.named_parameters()) optimizer torch.optim.AdamW([ {params: [p for n, p in encoder_params], lr: 3e-5}, {params: [p for n, p in head_params], lr: 1e-3}, ], weight_decay0.01)这里weight_decay设0.01是BERT微调的常见值对编码器参数做正则化防止过拟合。分类头其实不需要weight decay但统一设了影响也不大。学习率调度用线性warmup加线性衰减warmup比例设0.1也就是前10%的步数学习率从0线性升到设定值避免训练初期梯度波动太大。2.3 加不加CRF一个需要实测的取舍CRF层在序列标注里是老牌配置它显式建模相邻标签的转移概率解码时用维特比算法找全局最优路径。在Transformer上加CRF就是在分类层输出的logits之上再叠一层转移矩阵训练时算负对数似然推理时做维特比解码。加CRF的好处是能约束非法转移比如B-PER后面直接跟I-ORG这种明显不合理的组合会被转移矩阵压低概率。但代价是训练和推理都变慢维特比解码是O(n×L²)的复杂度L是标签数标签多的时候开销不小。我的实测结论是标签数少于10、实体边界清晰的任务CRF带来的提升在0.5个点以内可以不加标签数超过15、或者实体类别之间容易混淆的任务CRF能带来1到2个点的提升值得加。另外如果用了BIOES体系边界信息已经通过标签本身编码了CRF的边际收益会下降。不加CRF时解码就是每个位置取argmax简单直接。加CRF时需要额外实现转移矩阵和维特比解码代码量增加不少。如果决定加建议直接用现成的CRF实现自己写容易在mask处理上出错——padding位置的转移概率必须屏蔽掉否则维特比路径会跑到padding上去。2.4 注意力掩码与padding的正确处理序列标注的batch内序列长度不一必须padding对齐而padding位置不能参与损失计算也不能影响注意力。这里有两个mask要区分清楚。attention_mask是给编码器用的标记哪些位置是真实token、哪些是padding形状[batch, seq_len]真实位置为1padding为0。编码器内部会把padding位置的注意力权重屏蔽掉保证真实token不会attend到padding上。labels的ignore_index是给损失函数用的padding位置和特殊token位置、非首子词位置都标成-100CrossEntropyLoss计算时跳过这些位置。这两个mask必须配合使用只设一个都会出问题。loss_fct nn.CrossEntropyLoss(ignore_index-100) loss loss_fct(logits.view(-1, num_labels), labels.view(-1))view(-1, num_labels)把[batch, seq_len, num_labels]压成[batch×seq_len, num_labels]labels压成[batch×seq_len]然后逐位置算交叉熵ignore_index的位置自动跳过。这个写法是标准操作但要注意logits和labels的维度顺序必须对齐我见过有人把logits转置了导致loss算出来是乱的。提示调试时可以打印一下(labels ! -100).sum()确认有效标签数和预期一致。如果明显偏少多半是子词对齐或特殊token处理出了问题。3. 完整实操流程与关键环节实现3.1 数据准备与标签映射表构建数据格式我习惯用列表套字典每条样本包含tokens和labels两个字段。标签映射表要固定下来存成json训练、验证、推理共用同一份避免顺序错乱。import json label_list [O, B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC] label2id {l: i for i, l in enumerate(label_list)} id2label {i: l for l, i in label2id.items()} with open(label_map.json, w, encodingutf-8) as f: json.dump({label2id: label2id, id2label: id2label}, f, ensure_asciiFalse)标签顺序建议把O放第一位因为O的数量通常最多放第一位在某些实现里能让padding的默认id和O对齐减少出错概率。实体类别按业务重要性排序没有硬性要求。数据清洗阶段要重点检查三件事标签序列长度和token序列长度是否一致、有没有非法标签不在label_list里的、BIO体系下有没有孤立的I标签I前面不是B或I。孤立的I标签在训练时会误导模型建议统一改成B或者直接标O具体看业务语义。3.2 分词与标签对齐的完整实现这一步是整个流程里最容易出bug的地方我把完整实现贴出来并逐行解释。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(model_name) def tokenize_and_align(example): tokenized tokenizer( example[tokens], is_split_into_wordsTrue, truncationTrue, max_length128, paddingmax_length, ) word_ids tokenized.word_ids() aligned_labels [] previous_word_id None for word_id in word_ids: if word_id is None: aligned_labels.append(-100) elif word_id ! previous_word_id: aligned_labels.append(label2id[example[labels][word_id]]) else: aligned_labels.append(-100) previous_word_id word_id tokenized[labels] aligned_labels return tokenizedis_split_into_wordsTrue告诉分词器输入已经是切好的词列表不要再去切。word_ids()返回每个子词对应的原词索引特殊token对应None。遍历时遇到None标-100遇到新词取原标签遇到同词的后续子词标-100。max_length128对大多数序列标注任务够用长文档场景可以调到256或512但要注意显存。这里有个隐蔽的坑paddingmax_length会把所有样本pad到128包括很短的样本浪费计算。更好的做法是训练时用paddingTrue动态pad到batch内最长验证和推理时再固定长度。不过动态pad需要配合DataCollator稍微复杂一点初期用固定长度跑通再说。3.3 训练循环与评估指标计算训练循环本身是标准套路重点在评估指标。序列标注不能用准确率因为O标签占比通常超过80%全预测O也能有高准确率。要用实体级别的precision、recall、F1也就是把预测的实体区间和真实实体区间做匹配完全重合才算对。from seqeval.metrics import precision_score, recall_score, f1_score def compute_metrics(eval_pred): logits, labels eval_pred predictions logits.argmax(axis-1) true_labels [] pred_labels [] for pred, label in zip(predictions, labels): cur_true [] cur_pred [] for p, l in zip(pred, label): if l -100: continue cur_true.append(id2label[l]) cur_pred.append(id2label[p]) true_labels.append(cur_true) pred_labels.append(cur_pred) return { precision: precision_score(true_labels, pred_labels), recall: recall_score(true_labels, pred_labels), f1: f1_score(true_labels, pred_labels), }seqeval这个库专门做序列标注的实体级评估输入是标签字符串的列表套列表它会自动按BIO/BIOES规则解析实体区间。注意要把-100的位置过滤掉否则会报非法标签。评估时用model.eval()和torch.no_grad()别忘了。训练超参我常用的配置是batch_size 16到32看显存、epoch 3到5、编码器学习率3e-5、分类头学习率1e-3、warmup比例0.1、最大序列长度128。这个配置在几千条标注数据上基本能跑出可用的效果数据量再大可以适当增加epoch。3.4 推理与后处理从logits到实体列表推理阶段拿到每个位置的预测标签后还要做后处理把标签序列转成实体列表。BIO体系下遇到B-X就开始一个实体遇到I-X且前一个是B-X或I-X就延续遇到O或不同类别的标签就结束当前实体。def decode_entities(tokens, pred_labels): entities [] current None for token, label in zip(tokens, pred_labels): if label.startswith(B-): if current: entities.append(current) current {type: label[2:], start: token, tokens: [token]} elif label.startswith(I-) and current and label[2:] current[type]: current[tokens].append(token) else: if current: entities.append(current) current None if current: entities.append(current) return entities这段逻辑要处理孤立I标签的情况——如果I-X前面没有对应的B-X按规则应该丢弃或者当成B-X处理。我倾向于丢弃因为孤立I本身就是标注错误强行解析会引入噪声。BIOES体系下解码更简单遇到S-X直接成实体遇到E-X结束当前实体不用靠“下一个不是I”来推断。后处理完还要做一件事把子词拼回原词。因为分词时一个词被切成多个子词解码出来的实体token列表可能是子词片段需要按原词边界合并。这个在中文场景下不明显中文基本按字切英文场景下必须处理否则实体文本会是“Trans”而不是“Transformer”。4. 常见问题排查与实战避坑指南4.1 损失不下降或F1卡在低位这是最高频的问题原因通常集中在三处。第一是标签对齐错了模型在学一个错误的映射loss可能降但F1上不去。排查方法是拿一条训练样本把input_ids解码回文本、把labels解码回标签人工核对是否一一对应。第二是学习率设置不当编码器学习率太大导致预训练知识被破坏表现为loss震荡或先降后升。把编码器学习率降到1e-5试试。第三是数据里O标签占比过高模型倾向于全预测Orecall极低。这种情况要么加类别权重要么用focal loss要么补充正样本。4.2 实体边界预测偏移预测出来的实体比真实实体多一个或少一个token这种边界偏移很常见。原因之一是子词对齐策略如果非首子词没标-100而是标了原标签模型会学到“实体延续”的假模式边界往外扩。检查labels里-100的分布是否符合预期。原因之二是标签体系不适合BIO在长实体上边界模糊换成BIOES通常能改善。原因之三是位置编码外推序列长度超过预训练时的最大长度位置信息失真把max_length控制在预训练范围内。4.3 推理速度慢的优化方向序列标注推理要逐位置分类序列长的时候慢。优化方向有几个用动态padding避免pad到固定长度浪费计算batch推理把多条样本拼一个batch模型量化用int8推理精度损失通常在0.5个点以内导出ONNX用onnxruntime跑CPU上能快2到3倍。如果用了CRF维特比解码是瓶颈标签数多的时候考虑换成逐位置argmax加约束后处理。4.4 常见问题速查表现象可能原因排查动作解决方向loss不降标签对齐错误解码样本核对修正对齐逻辑F1低但loss正常全预测O看混淆矩阵加类别权重边界偏移子词标签策略查-100分布改对齐策略或换BIOES训练震荡编码器学习率过大看loss曲线降到1e-5推理慢固定长度padding看batch利用率动态padding加量化显存溢出batch或序列过长看显存峰值梯度累积替代大batch提示每次改完数据预处理逻辑务必先在小样本上过拟合——取20条数据训练到loss接近0如果过拟合都做不到说明数据处理有bug别急着上全量数据。4.5 几个容易被忽略的实操心得第一个心得是验证集要按实体分布分层采样不能随机切。如果验证集里某类实体特别少F1波动会很大调参时容易误判。第二个心得是训练日志要记录每个类别的F1整体F1掩盖类别间差异某类实体F1掉到0.3时整体可能还有0.8不看分类别指标发现不了。第三个心得是推理时的max_length要和训练一致训练用128推理用512位置编码分布变了效果会掉。第四个心得是标签映射表要版本化改标签体系时新建一份而不是覆盖旧的否则旧模型加载新映射表会全错。这些坑我基本都踩过一遍最惨的一次是子词对齐逻辑写错训练了三个epoch发现F1一直0.4上不去回头查数据才发现所有多子词实体的第二个子词都被当成了独立位置模型学出来的实体边界全是碎的。从那以后我养成了习惯任何数据预处理代码写完先打印五条样本的原始文本、分词结果、对齐后标签人工核对无误再开训。这个习惯省下的时间远比核对花的时间多。