☰
Transformer序列标注实战:从标签对齐到模型落地的完整指南
2026/10/26 20:01:26 网站建设 项目流程

序列标注这个任务,我在刚接触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, dropout=0.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_ids=input_ids, attention_mask=attention_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_decay=0.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],真实位置为1,padding为0。编码器内部会把padding位置的注意力权重屏蔽掉,保证真实token不会attend到padding上。

labels的ignore_index是给损失函数用的,padding位置和特殊token位置、非首子词位置都标成-100,CrossEntropyLoss计算时跳过这些位置。这两个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", encoding="utf-8") as f: json.dump({"label2id": label2id, "id2label": id2label}, f, ensure_ascii=False)

标签顺序建议把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_words=True, truncation=True, max_length=128, padding="max_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 tokenized

is_split_into_words=True告诉分词器输入已经是切好的词列表,不要再去切。word_ids()返回每个子词对应的原词索引,特殊token对应None。遍历时遇到None标-100,遇到新词取原标签,遇到同词的后续子词标-100。max_length=128对大多数序列标注任务够用,长文档场景可以调到256或512,但要注意显存。

这里有个隐蔽的坑:padding="max_length"会把所有样本pad到128,包括很短的样本,浪费计算。更好的做法是训练时用padding=True动态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标签占比过高,模型倾向于全预测O,recall极低。这种情况要么加类别权重,要么用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上不去,回头查数据才发现所有多子词实体的第二个子词都被当成了独立位置,模型学出来的实体边界全是碎的。从那以后我养成了习惯:任何数据预处理代码写完,先打印五条样本的原始文本、分词结果、对齐后标签,人工核对无误再开训。这个习惯省下的时间远比核对花的时间多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询