NLP 命名实体识别:从序列标注到 Span 检测,信息抽取的工程实践 NLP 命名实体识别从序列标注到 Span 检测信息抽取的工程实践一、命名实体识别的工程困境边界模糊与嵌套实体的挑战命名实体识别Named Entity Recognition, NER是 NLP 的基础任务目标是从文本中识别并分类实体人名、地名、组织名等。传统方法将 NER 建模为序列标注问题——每个 Token 分配一个标签B-PER、I-PER、O 等使用 BIO 或 BIOES 标注体系。序列标注方案在扁平实体非嵌套上效果良好但面对嵌套实体时力不从心。嵌套实体在真实文本中普遍存在北京大学信息科学技术学院中北京大学是组织实体信息科学技术学院也是组织实体且嵌套在北京大学内部。BIO 标注体系无法表示这种嵌套关系——一个 Token 只能有一个标签。此外中文 NER 还面临分词边界的困扰预训练模型BERT以字为单位编码但实体边界可能与词边界不对齐导致边界偏移错误。二、NER 技术架构的演进路径flowchart TD A[输入文本] -- B[NER 系统] subgraph 序列标注方法 C[BiLSTM-CRF] D[BERT-CRF] E[BERT-BiLSTM-CRF] end subgraph Span 检测方法 F[Span Boundary Detection] G[Biaffine Classifier] H[Fragment-Level Scoring] end subgraph 生成式方法 I[Seq2Seq 生成实体列表] J[Prompt-based NER] end B -- C B -- D B -- E B -- F B -- G B -- H B -- I B -- J subgraph 核心挑战 K[嵌套实体: 一个Token多个标签] L[边界模糊: 中文分词歧义] M[低资源: 标注数据不足] N[实体类型开放: 新类型涌现] end K -- F K -- G L -- D M -- J N -- J subgraph 工程选型建议 O[扁平实体 → BERT-CRF] P[嵌套实体 → Biaffine] Q[低资源场景 → Prompt-based] end E -- O G -- P J -- QSpan 检测方法的核心思想不再逐 Token 标注而是枚举所有可能的文本片段Span对每个 Span 判断其是否为实体及实体类型。Span 方法天然支持嵌套实体——不同长度的 Span 可以独立分类互不冲突。Biaffine Classifier 是当前 Span 方法的代表使用双仿射注意力机制建模 Span 起止位置的联合概率在嵌套 NER 基准上取得最优结果。三、工程实现BERT-CRF 与 Biaffine Span 检测# ner_models.py — 命名实体识别模型实现 import torch import torch.nn as nn import torch.nn.functional as F from typing import List, Optional, Tuple class CRF(nn.Module): 条件随机场CRF层学习标签间的转移约束 CRF 的核心优势是建模标签转移概率如 I-PER 不能出现在 O 后面 通过全局最优解码Viterbi 算法避免非法标签序列。 def __init__(self, num_tags: int): super().__init__() self.num_tags num_tags # 转移矩阵transitions[i][j] 表示从标签 i 转移到标签 j 的分数 self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) # 起始与终止转移分数 self.start_transitions nn.Parameter(torch.randn(num_tags)) self.end_transitions nn.Parameter(torch.randn(num_tags)) def forward( self, emissions: torch.Tensor, tags: torch.Tensor, mask: Optional[torch.Tensor] None, ) - torch.Tensor: 计算 CRF 负对数似然损失 if mask is None: mask torch.ones_like(tags, dtypetorch.bool) # 计算金标路径的分数 gold_score self._score_sentence(emissions, tags, mask) # 计算所有路径的 log-sum-exp 分数 all_score self._compute_log_partition(emissions, mask) # 负对数似然 log(所有路径分数) - log(金标路径分数) loss (all_score - gold_score).mean() return loss def decode( self, emissions: torch.Tensor, mask: Optional[torch.Tensor] None ) - List[List[int]]: Viterbi 解码寻找最优标签序列 if mask is None: mask emissions.new_ones(emissions.shape[:2]).bool() batch_size, seq_len, _ emissions.shape # 初始化起始转移分数 第一个位置的发射分数 score self.start_transitions emissions[:, 0] history [] for i in range(1, seq_len): # 扩展维度计算转移分数 broadcast_score score.unsqueeze(2) broadcast_emissions emissions[:, i].unsqueeze(1) next_score broadcast_score self.transitions broadcast_emissions # 记录最优前驱标签 next_score, indices next_score.max(dim1) history.append(indices) # 只更新未 mask 的位置 score torch.where( mask[:, i].unsqueeze(1), next_score, score ) # 加上终止转移分数 score self.end_transitions # 回溯解码 best_tags [] _, best_last_tag score.max(dim1) for i in range(seq_len - 2, -1, -1): best_last_tag history[i].gather(1, best_last_tag.unsqueeze(1)).squeeze(1) best_tags.insert(0, best_last_tag) best_tags.insert(seq_len - 1, score.argmax(dim1)) return [t.tolist() for t in best_tags] def _score_sentence( self, emissions: torch.Tensor, tags: torch.Tensor, mask: torch.Tensor ) - torch.Tensor: 计算给定标签序列的分数 batch_size, seq_len, _ emissions.shape score self.start_transitions[tags[:, 0]] emissions[:, 0, tags[:, 0]] for i in range(1, seq_len): score ( self.transitions[tags[:, i - 1], tags[:, i]] emissions[:, i, tags[:, i]] ) * mask[:, i].float() # 终止转移 seq_ends mask.long().sum(dim1) - 1 last_tags tags.gather(1, seq_ends.unsqueeze(1)).squeeze(1) score self.end_transitions[last_tags] return score def _compute_log_partition( self, emissions: torch.Tensor, mask: torch.Tensor ) - torch.Tensor: 前向算法计算 log-sum-exp所有路径分数的对数和 score self.start_transitions emissions[:, 0] for i in range(1, emissions.size(1)): broadcast_score score.unsqueeze(2) broadcast_emissions emissions[:, i].unsqueeze(1) next_score torch.logsumexp( broadcast_score self.transitions broadcast_emissions, dim1 ) score torch.where(mask[:, i].unsqueeze(1), next_score, score) score torch.logsumexp(score self.end_transitions, dim1) return score class BiaffineSpanNER(nn.Module): 双仿射 Span 检测模型支持嵌套实体识别 核心思想对每个 Span 的起止位置 (i, j) 计算双仿射分数 判断该 Span 是否为实体及实体类型。双仿射机制建模起止位置的 二阶交互比独立分类起止位置更精确。 def __init__( self, hidden_dim: int 768, num_labels: int 10, ffnn_hidden: int 150, ffnn_depth: int 2, ): super().__init__() self.num_labels num_labels # 起始位置与结束位置的 MLP 投影 # 将 BERT 输出投影到低维空间减少双仿射计算的参数量 self.start_mlp nn.Sequential( nn.Linear(hidden_dim, ffnn_hidden), nn.ReLU(), ) self.end_mlp nn.Sequential( nn.Linear(hidden_dim, ffnn_hidden), nn.ReLU(), ) # 双仿射分类器(start, end) → 实体类型 # U_k: 每个实体类型一个双仿射矩阵 self.biaffine nn.Parameter( torch.randn(ffnn_hidden, num_labels, ffnn_hidden) ) self.biaffine_bias nn.Parameter(torch.randn(num_labels)) def forward( self, encoder_output: torch.Tensor, spans: Optional[torch.Tensor] None, span_labels: Optional[torch.Tensor] None, ) - Tuple[torch.Tensor, torch.Tensor]: encoder_output: (B, L, D) BERT 编码输出 spans: (B, N, 2) 预提取的 Span 起止位置 span_labels: (B, N) Span 的实体类型标签0 表示非实体 batch_size, seq_len, _ encoder_output.shape # MLP 投影 start_repr self.start_mlp(encoder_output) # (B, L, H) end_repr self.end_mlp(encoder_output) # (B, L, H) # 双仿射得分计算 # score[b, i, j, k] start[b,i]^T U[k] end[b,j] bias[k] # 使用 einsum 高效计算 biaffine_scores torch.einsum( bih,hkj,bj-bik, start_repr, self.biaffine, end_repr ) # (B, L, L, num_labels) biaffine_scores biaffine_scores self.biaffine_bias if span_labels is not None and spans is not None: # 训练模式提取金标 Span 的得分计算损失 loss self._compute_loss(biaffine_scores, spans, span_labels) return biaffine_scores, loss return biaffine_scores, torch.tensor(0.0) def _compute_loss( self, scores: torch.Tensor, spans: torch.Tensor, labels: torch.Tensor, ) - torch.Tensor: 计算 Span 分类的交叉熵损失 batch_indices torch.arange(spans.size(0)).unsqueeze(1).expand_as(spans[:, :, 0]) start_indices spans[:, :, 0] end_indices spans[:, :, 1] # 提取对应 Span 的分类得分 span_scores scores[ batch_indices, start_indices, end_indices ] # (B, N, num_labels) # 交叉熵损失0 类为非实体 loss F.cross_entropy( span_scores.reshape(-1, self.num_labels), labels.reshape(-1), ignore_index-100, ) return loss def decode_spans( self, scores: torch.Tensor, threshold: float 0.5 ) - List[List[Tuple[int, int, int]]]: 从双仿射得分中提取实体 Span batch_results [] probs torch.softmax(scores, dim-1) for b in range(probs.size(0)): entities [] for i in range(probs.size(1)): for j in range(i, probs.size(2)): # 跳过非实体类类别 0 entity_probs probs[b, i, j, 1:] max_prob, label entity_probs.max(dim0) if max_prob threshold: entities.append((i, j, label.item() 1)) batch_results.append(entities) return batch_results四、NER 工程落地的边界与权衡Span 枚举的计算复杂度Span 方法需要枚举所有 O(n²) 个文本片段序列长度 n 增大时计算量急剧增长。实际工程中需限制最大 Span 长度如不超过 30 个 Token将复杂度降至 O(n × max_span_len)。对于长文档建议先做句子切分再逐句识别。中文分词与字级别编码的选择BERT 等预训练模型以字为单位编码避免了分词错误传播问题。但字级别编码丢失了词边界信息——南京市长江大桥中南京市长和长江大桥的边界需要模型自行学习。工程实践中常在字嵌入基础上叠加词边界特征如 SoftLexicon兼顾字级别的灵活性与词级别的边界信息。低资源场景的标注瓶颈垂直领域医疗、法律、金融的 NER 标注数据稀缺从头训练模型效果差。迁移学习通用 NER 模型微调和 Prompt-based NER将 NER 转化为填空任务是两种主流方案。Prompt 方法在 Few-shot 场景下优势明显但模板设计依赖领域知识。实体类型开放性问题预定义实体类型的 NER 系统无法识别训练集中未出现的新类型。生成式 NERSeq2Seq 输出实体列表可处理开放类型但生成式模型的输出格式不稳定需要严格的后处理校验。五、总结命名实体识别从序列标注演进到 Span 检测核心驱动力是嵌套实体的识别需求。BERT-CRF 在扁平实体场景仍是工程首选Biaffine Span 检测是嵌套 NER 的当前最优方案。工程落地的关键在于限制最大 Span 长度控制计算复杂度、字级别编码叠加词边界特征提升中文 NER 精度、低资源场景优先尝试 Prompt-based 方法、生成式 NER 处理开放实体类型但需后处理校验。NER 不是孤立的 NLP 任务——它是信息抽取、知识图谱构建、问答系统的基石NER 的精度直接决定下游系统的可靠性。