第9章:序列标注(Sequence Labeling)
一、导读
1.1 本章学习目标
序列标注是 NLP 中最基础也最重要的任务之一。它的目标是为序列中的每个元素分配一个标签。命名实体识别(NER)、词性标注(POS Tagging)、语义角色标注(SRL)等都是序列标注任务。
本章的核心学习目标包括:
- 理解序列标注任务的定义和类型:什么是序列标注?有哪些典型的序列标注任务?
- 掌握 BIO/BIOES 标注方案:如何将序列标注问题转化为分类问题
- 理解 HMM(隐马尔可夫模型):生成式序列标注模型
- 掌握 CRF(条件随机场):判别式序列标注模型
- 理解 BiLSTM-CRF 架构:深度学习时代的序列标注标准模型
- 了解基于预训练模型的序列标注:BERT for Token Classification
1.2 为什么本章如此重要
序列标注是 NLP 信息抽取的基础。命名实体识别是问答系统、知识图谱构建、信息检索的前置步骤;词性标注是句法分析的基础。理解序列标注的方法论,对于处理任何涉及序列结构的问题都至关重要。
从方法论角度看,序列标注引入了"标签之间的依赖关系"这一重要概念。与独立分类不同,序列标注需要考虑标签序列的合法性(如 I-PER 不能出现在 B-LOC 之后)。这种结构化的预测问题是 NLP 的核心挑战之一。
1.3 核心问题引导
序列标注与文本分类有什么本质区别?为什么不能简单地逐词独立分类?
BIO 标注方案如何解决多词实体的标注问题?有什么局限性?
HMM 和 CRF 的根本区别是什么?为什么 CRF 通常表现更好?
BiLSTM-CRF 为什么成为序列标注的经典架构?每个组件的作用是什么?
如何使用 BERT 进行序列标注?与 BiLSTM-CRF 相比有什么优势?
二、核心概念详解
9.1 序列标注任务
9.1.1 任务定义
给定输入序列 $\mathbf{x} = (x_1, x_2, \ldots, x_n)$,预测标签序列 $\mathbf{y} = (y_1, y_2, \ldots, y_n)$。
9.1.2 典型任务
词性标注(POS Tagging):为每个词标注词性。
I/PRON love/VERB natural/ADJ language/NOUN processing/NOUN命名实体识别(NER):识别文本中的命名实体。
[Apple]ORG was founded by [Steve Jobs]PERSON in [Cupertino]LOC .分词(Word Segmentation):中文分词可以看作序列标注任务(BMES 标注)。
9.1.3 标注方案
BIO 标注:B(Begin)、I(Inside)、O(Outside)
B-PER I-PER O B-ORG I-ORG I-ORG I-ORG O
Steve Jobs works at Apple Inc. now .BIOES 标注:B(Begin)、I(Inside)、O(Outside)、E(End)、S(Single)
B-PER E-PER O B-ORG I-ORG E-ORG O
Steve Jobs works at Apple Inc. now9.2 隐马尔可夫模型(HMM)
9.2.1 模型定义
HMM 是生成式序列模型,假设标签序列服从马尔可夫链,观测由标签生成:
- 转移概率:$P(y_t | y_{t-1})$,标签之间的转移
- 发射概率:$P(x_t | y_t)$,给定标签生成观测的概率
联合概率:
$$P(\mathbf{x}, \mathbf{y}) = P(y_1) \prod_{t=2}^{n} P(y_t | y_{t-1}) \prod_{t=1}^{n} P(x_t | y_t)$$
9.2.2 维特比解码
使用动态规划找到最优标签序列:
$$\delta_t(j) = \max_{y_1, \ldots, y_{t-1}} P(y_1, \ldots, y_{t-1}, y_t = j, x_1, \ldots, x_t)$$
import numpy as np
class HMM:
def __init__(self, n_states, n_obs):
self.n_states = n_states
self.n_obs = n_obs
self.initial = np.ones(n_states) / n_states
self.transition = np.ones((n_states, n_states)) / n_states
self.emission = np.ones((n_states, n_obs)) / n_obs
def train(self, sequences, label_sequences):
initial_counts = np.zeros(self.n_states)
transition_counts = np.zeros((self.n_states, self.n_states))
emission_counts = np.zeros((self.n_states, self.n_obs))
for obs, labels in zip(sequences, label_sequences):
initial_counts[labels[0]] += 1
for t in range(len(obs)):
emission_counts[labels[t]][obs[t]] += 1
if t > 0:
transition_counts[labels[t-1]][labels[t]] += 1
self.initial = (initial_counts + 1) / (initial_counts.sum() + self.n_states)
self.transition = (transition_counts + 1) / (transition_counts.sum(axis=1, keepdims=True) + self.n_states)
self.emission = (emission_counts + 1) / (emission_counts.sum(axis=1, keepdims=True) + self.n_obs)
def viterbi(self, observations):
T = len(observations)
dp = np.zeros((T, self.n_states))
backptr = np.zeros((T, self.n_states), dtype=int)
# 初始化
for s in range(self.n_states):
dp[0][s] = np.log(self.initial[s]) + np.log(self.emission[s][observations[0]])
# 递推
for t in range(1, T):
for s in range(self.n_states):
scores = [dp[t-1][prev] + np.log(self.transition[prev][s])
for prev in range(self.n_states)]
backptr[t][s] = np.argmax(scores)
dp[t][s] = max(scores) + np.log(self.emission[s][observations[t]])
# 回溯
path = [0] * T
path[T-1] = np.argmax(dp[T-1])
for t in range(T-2, -1, -1):
path[t] = backptr[t+1][path[t+1]]
return path9.3 条件随机场(CRF)
9.3.1 模型定义
CRF 是判别式序列模型,直接建模条件概率 $P(\mathbf{y}|\mathbf{x})$:
$$P(\mathbf{y}|\mathbf{x}) = \frac{1}{Z(\mathbf{x})} \exp\left(\sum_{t=1}^{n} \sum_k \lambda_k f_k(y_{t-1}, y_t, \mathbf{x}, t)\right)$$
其中 $f_k$ 是特征函数,$\lambda_k$ 是权重,$Z(\mathbf{x})$ 是归一化因子。
9.3.2 CRF vs HMM
| 特性 | HMM | CRF |
|---|---|---|
| 模型类型 | 生成式 | 判别式 |
| 特征 | 只能使用观测 | 可以使用任意特征 |
| 标签依赖 | 一阶马尔可夫 | 可以建模任意依赖 |
| 性能 | 通常较差 | 通常更好 |
| 训练速度 | 快 | 较慢 |
9.3.3 线性链 CRF
import torch
import torch.nn as nn
class CRF(nn.Module):
def __init__(self, n_tags):
super().__init__()
self.n_tags = n_tags
self.START_TAG = n_tags
self.STOP_TAG = n_tags + 1
self.transitions = nn.Parameter(torch.randn(n_tags + 2, n_tags + 2))
def forward_alg(self, feats):
"""计算配分函数(前向算法)"""
alpha = torch.full((1, self.n_tags), -10000.0)
alpha[0][self.START_TAG] = 0.0
for feat in feats:
next_alpha = alpha + self.transitions[:self.n_tags, :self.n_tags] + feat.view(1, -1)
alpha = torch.logsumexp(next_alpha, dim=1).view(1, -1)
final = alpha + self.transitions[:self.n_tags, [self.STOP_TAG]]
return torch.logsumexp(final, dim=1)
def score_sentence(self, feats, tags):
"""计算给定标签序列的分数"""
score = torch.tensor(0.0)
tags = torch.cat([torch.tensor([self.START_TAG], dtype=torch.long), tags])
for i, feat in enumerate(feats):
score = score + self.transitions[tags[i], tags[i+1]] + feat[tags[i+1]]
score = score + self.transitions[tags[-1], self.STOP_TAG]
return score
def neg_log_likelihood(self, feats, tags):
"""负对数似然损失"""
forward_score = self.forward_alg(feats)
gold_score = self.score_sentence(feats, tags)
return forward_score - gold_score
def viterbi_decode(self, feats):
"""维特比解码"""
backpointers = []
viterbi = torch.full((1, self.n_tags), -10000.0)
viterbi[0][self.START_TAG] = 0.0
for feat in feats:
next_viterbi = viterbi + self.transitions[:self.n_tags, :self.n_tags]
best_ids = next_viterbi.argmax(dim=1)
backpointers.append(best_ids)
viterbi = next_viterbi.gather(1, best_ids.view(1, -1)) + feat.view(1, -1)
viterbi = viterbi + self.transitions[:self.n_tags, [self.STOP_TAG]]
best_last = viterbi.argmax(dim=1)
best_path = [best_last.item()]
for bp in reversed(backpointers):
best_path.append(bp[0, best_path[-1]].item())
best_path.reverse()
best_path.pop()
return best_path9.4 BiLSTM-CRF
9.4.1 架构
BiLSTM-CRF 是深度学习时代序列标注的经典架构:
Embedding 层:将词映射为向量(可加载预训练词向量)
BiLSTM 层:双向 LSTM 编码上下文信息
线性层:将 LSTM 输出映射为标签分数
CRF 层:建模标签之间的依赖关系
class BiLSTM_CRF(nn.Module):
def __init__(self, vocab_size, tag_to_idx, embedding_dim=128, hidden_dim=128):
super().__init__()
self.embedding_dim = embedding_dim
self.hidden_dim = hidden_dim
self.vocab_size = vocab_size
self.tag_to_idx = tag_to_idx
self.tagset_size = len(tag_to_idx)
self.word_embeds = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2,
num_layers=1, bidirectional=True, batch_first=True)
self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
self.crf = CRF(self.tagset_size)
def get_lstm_features(self, sentence):
embeds = self.word_embeds(sentence)
lstm_out, _ = self.lstm(embeds)
feats = self.hidden2tag(lstm_out)
return feats
def forward(self, sentence, tags):
feats = self.get_lstm_features(sentence)
loss = self.crf.neg_log_likelihood(feats, tags)
return loss
def predict(self, sentence):
feats = self.get_lstm_features(sentence)
return self.crf.viterbi_decode(feats)9.5 基于预训练模型的序列标注
from transformers import AutoModelForTokenClassification, AutoTokenizer
import torch
# 加载预训练 NER 模型
tokenizer = AutoTokenizer.from_pretrained("dslim/bert-base-NER")
model = AutoModelForTokenClassification.from_pretrained("dslim/bert-base-NER")
text = "Steve Jobs founded Apple Inc. in Cupertino, California."
inputs = tokenizer(text, return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)
logits = outputs.logits
predictions = torch.argmax(logits, dim=-1)
# 解码预测
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
labels = [model.config.id2label[p.item()] for p in predictions[0]]
for token, label in zip(tokens, labels):
if label != "O":
print(f"{token}: {label}")三、实战案例
3.1 使用 spaCy 进行 NER
import spacy
nlp = spacy.load("en_core_web_sm")
text = "Apple Inc. was founded by Steve Jobs in Cupertino, California on April 1, 1976."
doc = nlp(text)
for ent in doc.ents:
print(f"{ent.text:20s} → {ent.label_:10s} ({spacy.explain(ent.label_)})")3.2 训练 BiLSTM-CRF 进行 NER
import torch
import torch.optim as optim
# 准备数据(简化示例)
sentences = [
["Steve", "Jobs", "founded", "Apple", "Inc", "."],
["Tim", "Cook", "is", "the", "CEO", "of", "Apple", "."],
]
tag_sequences = [
[0, 1, 2, 3, 4, 4, 4], # B-PER, I-PER, O, B-ORG, I-ORG, O
[0, 1, 2, 2, 2, 2, 3, 4], # B-PER, I-PER, O, O, O, O, B-ORG, O
]
word_to_idx = {"Steve": 0, "Jobs": 1, "founded": 2, "Apple": 3, "Inc": 4, ".": 5,
"Tim": 6, "Cook": 7, "is": 8, "the": 9, "CEO": 10, "of": 11}
tag_to_idx = {"B-PER": 0, "I-PER": 1, "O": 2, "B-ORG": 3, "I-ORG": 4}
# 创建模型
model = BiLSTM_CRF(len(word_to_idx), tag_to_idx, embedding_dim=64, hidden_dim=64)
optimizer = optim.Adam(model.parameters(), lr=0.01)
# 训练
for epoch in range(100):
total_loss = 0
for sent, tags in zip(sentences, tag_sequences):
sent_idx = torch.tensor([[word_to_idx[w] for w in sent]])
tags_tensor = torch.tensor([tags])
loss = model(sent_idx, tags_tensor)
loss.backward()
optimizer.step()
optimizer.zero_grad()
total_loss += loss.item()
if epoch % 20 == 0:
print(f"Epoch {epoch}, Loss: {total_loss:.4f}")3.3 评估序列标注模型
from seqeval.metrics import classification_report, f1_score
# 真实标签和预测标签
y_true = [["B-PER", "I-PER", "O", "B-ORG", "I-ORG", "O"]]
y_pred = [["B-PER", "I-PER", "O", "B-ORG", "O", "O"]]
print(classification_report(y_true, y_pred))
# 使用 seqeval 而非 sklearn,因为 seqeval 支持实体级评估四、本章小结
本章系统讲解了序列标注:
任务定义:为序列中每个元素分配标签,包括 POS 标注、NER、分词等。
标注方案:BIO/BIOES 解决多词实体的标注问题。
HMM:生成式模型,假设标签马尔可夫性和观测条件独立性。维特比算法进行解码。
CRF:判别式模型,直接建模条件概率,可以使用任意特征,通常优于 HMM。
BiLSTM-CRF:BiLSTM 编码上下文,CRF 建模标签依赖。是深度学习时代的标准架构。
预训练模型:BERT for Token Classification 可以直接用于序列标注,性能优异。
序列标注是 NLP 信息抽取的基础。下一章我们将学习机器翻译,这是 NLP 最具挑战性的任务之一。
五、思考与练习
思考题
为什么 CRF 比 HMM 表现更好?从模型假设的角度分析。
BIO 标注方案有什么歧义问题?BIOES 如何解决?
BiLSTM-CRF 中,如果去掉 CRF 层只用 BiLSTM,性能会如何变化?为什么?
在低资源场景下(训练数据很少),你会选择什么方法?为什么?
编程练习
在 CoNLL-2003 NER 数据集上训练 BiLSTM-CRF 模型。比较有无 CRF 层的性能差异。
使用 BERT 进行 NER 任务。比较 BERT 和 BiLSTM-CRF 的 F1 分数。
实现中文分词任务(BMES 标注),比较不同标注方案(BIO vs BMES)的效果。