9语音与语言处理(第3版)

序列标注

序列任务

NERPOS标注CRF

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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. now

9.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)$$

python
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 path

9.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

特性HMMCRF
模型类型生成式判别式
特征只能使用观测可以使用任意特征
标签依赖一阶马尔可夫可以建模任意依赖
性能通常较差通常更好
训练速度快较慢

9.3.3 线性链 CRF

python
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_path

9.4 BiLSTM-CRF

9.4.1 架构

BiLSTM-CRF 是深度学习时代序列标注的经典架构:

Embedding 层:将词映射为向量(可加载预训练词向量)

BiLSTM 层:双向 LSTM 编码上下文信息

线性层:将 LSTM 输出映射为标签分数

CRF 层:建模标签之间的依赖关系

python
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 基于预训练模型的序列标注

python
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

python
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

python
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 评估序列标注模型

python
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)的效果。


9/12序列标注
0%