当前位置: 首页 > news >正文

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 --> Q

Span 检测方法的核心思想:不再逐 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, dtype=torch.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(dim=1) 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(dim=1) 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(dim=1)) 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(dim=1) - 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, dim=1 ) score = torch.where(mask[:, i].unsqueeze(1), next_score, score) score = torch.logsumexp(score + self.end_transitions, dim=1) 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(dim=0) 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 系统无法识别训练集中未出现的新类型。生成式 NER(Seq2Seq 输出实体列表)可处理开放类型,但生成式模型的输出格式不稳定,需要严格的后处理校验。

五、总结

命名实体识别从序列标注演进到 Span 检测,核心驱动力是嵌套实体的识别需求。BERT-CRF 在扁平实体场景仍是工程首选,Biaffine Span 检测是嵌套 NER 的当前最优方案。工程落地的关键在于:限制最大 Span 长度控制计算复杂度、字级别编码叠加词边界特征提升中文 NER 精度、低资源场景优先尝试 Prompt-based 方法、生成式 NER 处理开放实体类型但需后处理校验。NER 不是孤立的 NLP 任务——它是信息抽取、知识图谱构建、问答系统的基石,NER 的精度直接决定下游系统的可靠性。

http://www.rkmt.cn/news/1514533.html

相关文章:

  • C++版OpenCV圆盘靶标相机标定工具(兼容对称与非对称布局)
  • StreamFX实战指南:如何用专业级OBS插件解决直播视觉痛点
  • 智慧树自动刷课插件:3分钟快速部署的终极学习助手
  • MATLAB中一键调参的LIBSVM 3.1完整集成包(含编译脚本、示例数据与多语言支持)
  • 从PL语言出发,我重新理解了Flex词法分析器的‘贪婪匹配’与规则优先级
  • Krita AI Diffusion插件:Cinematic Photo (XL)服务器执行错误的深度解析与三步修复方案
  • 用PyQt5给YOLOv5/YOLOv8做个桌面GUI:从模型训练到一键检测的完整流程
  • RH850 Mcal代码生成踩坑实录:我是如何绕开官方GHS脚本,用自制Makefile跑通的
  • 51单片机矩阵键盘密码锁实战:从硬件连接到Keil代码调试,手把手教你避开蜂鸣器干扰
  • 煤矿通风机房双电源无扰动快切改造实战指南
  • 2026年6月诚信供暖设备定做厂家选择标准:为何SSTEF-意法成为行业标杆? - 品牌鉴赏官2026
  • 深入Tina Linux:如何为你的IoT设备定制可写的根文件系统(OverlayFS vs UBIFS)
  • 2026年 节能高效厂房通风降温系统与源头厂家深度解析 - 品牌发掘
  • TurtleBot3仿真导航避坑指南:从地图保存到2D Nav Goal精准定位的完整流程
  • 2026绵阳月嫂公司怎么选?本地家政服务市场深度对比与案例解析 - 优质品牌商家
  • 别再只玩点灯了!ESP8266的AT指令TCP通信实战:搭建简易无线调试终端(STM32+安信可助手)
  • 从‘理想波形’到‘现实干扰’:一个Buck降压电路在面包板上的完整调试日记(附示波器实测图)
  • Deepoc数学大模型夯实半导体设计验证的数据基准
  • 济南刑事案件困扰难解?2026年这5位刑事律师推荐 - 本地品牌推荐
  • 数据库设计 Prompt 提示词 - 构建与迭代
  • 高频谐振功率放大器负载特性实测:在Multisim里快速滑动变阻器并记录数据的保姆级教程
  • 从仿真到电路:手把手教你将Lumerical的PN移相器模型导入INTERCONNECT进行系统级验证
  • 2026年高纯氧化锆珠行业深度评测:技术路线、选型指南与主流供应商综合评估 - 优质品牌商家
  • NSK RNFCL3232A6 滚珠丝杠技术手册
  • 用闲置电脑+TrueNAS 13.0,给海康摄像头DIY一个免费录像机(附IVMS-4200配置避坑点)
  • CANoe连接电源/PLC实战:手把手教你用RS232控制IT6900电源并解析Modbus数据
  • 别再只用CNN+LSTM了!用PyTorch复现STGCN搞定交通流量预测(附完整代码)
  • 2026年聚丙烯酰胺厂家工艺与服务体系发展报告:四川及全国供应商多维度对比 - 优质品牌商家
  • 2026年 东莞工业循环水处理推荐品牌:循环水系统清洗/除垢/杀菌灭藻/防腐预膜/设备管道维保一站式实力工厂 - 品牌发掘
  • UVa 465 Overflow