前言

本文对基于深度学习的命名实体识别(NER)进行学习,撰写学习报告(代码结构、模型结构等),并设计一个实际场景调用,记录自己的理解与体会。

作者:张硕 中国科学院大学人工智能学院 自动化研究所
课程:自然语言处理
时间:2026.6.17


一、 引言

命名实体识别(Named Entity Recognition, NER)是自然语言处理中的一项基础任务,其目标是从非结构化文本中识别出具有特定语义类型的实体提及,如人名(PER)、地名(LOC)、组织名(ORG)等(Lample et al., 2016)。NER 是信息抽取、知识图谱构建、问答系统、机器翻译等众多应用的关键前置步骤。例如,在搜索引擎中,约 71% 的查询包含至少一个命名实体(Guo et al., 2009),准确识别这些实体能显著提升检索质量。

传统的 NER 系统高度依赖人工设计的特征(如词形、大小写、词典匹配)和领域知识,泛化能力有限且迁移成本高。随着深度学习技术的兴起,基于神经网络的 NER 模型逐渐成为主流,其核心优势在于:(1) 通过端到端学习自动提取层次化特征,无需繁琐的特征工程;(2) 利用分布式表示捕捉词的语义和形态信息;(3) 通过循环或卷积结构建模长距离上下文依赖。本报告系统梳理了深度学习 NER 的经典架构,并基于提供的文献深入剖析了 BiLSTM-CRF、BiLSTM-CNN-CRF 以及 BERT-based 模型的结构与实现,最后设计了一个结合 NER 与关系抽取工具 OpenNRE 的实际应用场景。

二、深度学习 NER 的通用架构

根据 Li et al. (2020) 的综述,现代深度学习 NER 模型通常遵循 “输入表示 → 上下文编码 → 标签解码” 的三层流水线,如下图所示。

NER的三层流水线示意图

  • 输入表示层:将每个词(或字符)映射为稠密向量。包括预训练词嵌入(如 GloVe、Word2Vec)和字符级表示(通过 CNN 或 RNN 提取形态特征)。
  • 上下文编码层:对序列中的每个词,利用其左右上下文生成上下文相关的表示。主流编码器包括双向 LSTM(BiLSTM)、CNN(如 ID-CNN)和 Transformer。
  • 标签解码层:根据上下文表示预测每个词对应的标签序列。常见解码器有 Softmax 分类器、条件随机场(CRF)、RNN 解码器和指针网络,其中 CRF 因能建模标签间的转移约束而被广泛采用。

该架构的优势在于模块化设计,各组件可灵活组合。以下章节将重点介绍两个最具代表性的实现:BiLSTM-CRF 和 BiLSTM-CNN-CRF,以及基于 BERT 的微调范式。

三、经典模型结构详解

3.1 BiLSTM-CRF 模型(Lample et al., 2016)

Lample 等人提出的 BiLSTM-CRF 模型是 NER 领域的里程碑之作,其结构如图所示。

BiLSTM-CRF结构示意图

输入表示:每个词由两部分组成:① 预训练词嵌入(通过 Skip-n-gram 在大规模语料上训练);② 字符级表示。字符级表示采用双向 LSTM(Char-BiLSTM):将每个字符映射为可学习的嵌入向量,分别输入前向和后向 LSTM,将两个方向的最终隐状态拼接作为该词的字符级向量。该设计能有效捕捉前缀、后缀等形态信息,且对未登录词(OOV)具有较好的泛化能力。

上下文编码:将词向量 r w ( w t ) \mathbf{r}^w(w_t) rw(wt)与字符向量 r c ( w t ) \mathbf{r}^c(w_t) rc(wt)拼接后,送入深层双向 LSTM(BiLSTM)。BiLSTM 由前向(从左到右)和后向(从右到左)两个独立 LSTM 组成,输出隐状态: h t = [ h → t ; h ← t ] \mathbf{h}_t = [\overrightarrow{\mathbf{h}}_t; \overleftarrow{\mathbf{h}}_t] ht=[h t;h t]该隐状态 h t \mathbf{h}_t ht 包含了该词完整的左右上下文信息。

标签解码(CRF 层):这是关键创新。BiLSTM 的输出是独立标签分数 P ∈ R n × k \mathbf{P} \in \mathbb{R}^{n \times k} PRn×k,其中 P i , j P_{i,j} Pi,j 表示第 i i i 个词取第 j j j 个标签的发射分数。CRF 引入全局得分函数: s ( X , y ) = ∑ i = 1 n P i , y i + ∑ i = 1 n − 1 A y i , y i + 1 + A y 0 , y 1 + A y n , y n + 1 s(\mathbf{X}, \mathbf{y}) = \sum_{i=1}^{n} P_{i, y_i} + \sum_{i=1}^{n-1} A_{y_i, y_{i+1}} + A_{y_0, y_1} + A_{y_n, y_{n+1}} s(X,y)=i=1nPi,yi+i=1n1Ayi,yi+1+Ay0,y1+Ayn,yn+1其中 A j , k A_{j,k} Aj,k 是标签 j j j 转移到标签 k k k 的转移分数, y 0 y_0 y0 y n + 1 y_{n+1} yn+1 为特殊起始和终止标签。给定输入序列 X \mathbf{X} X,标签序列 y \mathbf{y} y 的条件概率为: p ( y ∣ X ) = exp ⁡ ( s ( X , y ) ) ∑ y ~ ∈ Y X exp ⁡ ( s ( X , y ~ ) ) p(\mathbf{y}|\mathbf{X}) = \frac{\exp(s(\mathbf{X}, \mathbf{y}))}{\sum_{\tilde{\mathbf{y}} \in \mathbf{Y}_{\mathbf{X}}} \exp(s(\mathbf{X}, \tilde{\mathbf{y}}))} p(yX)=y~YXexp(s(X,y~))exp(s(X,y))训练时最大化正确标签序列的对数似然 log ⁡ p ( y ∣ X ) \log p(\mathbf{y}|\mathbf{X}) logp(yX);推理时使用维特比算法动态规划找出得分最高的标签序列: y ∗ = arg ⁡ max ⁡ y ~ ∈ Y X s ( X , y ~ ) \mathbf{y}^* = \arg\max_{\tilde{\mathbf{y}} \in \mathbf{Y}_{\mathbf{X}}} s(\mathbf{X}, \tilde{\mathbf{y}}) y=argy~YXmaxs(X,y~)

3.2 高效变体:BiLSTM-CNN-CRF (Ma & Hovy, 2016)

该模型将字符级 LSTM 替换为字符级 CNN(卷积神经网络),在保证精度的同时大幅提升计算效率。

字符级 CNN 提取:对于单词 w w w 的字符序列 c 1 , c 2 , . . . , c m c_1, c_2, ..., c_m c1,c2,...,cm,每个字符嵌入为向量 c i ∈ R d c \mathbf{c}_i \in \mathbb{R}^{d_c} ciRdc。使用宽度为 k k k(通常取 3)的一维卷积核在字符序列上滑动,第 i i i 个窗口的卷积输出为: h i = tanh ⁡ ( W c ⋅ [ c i ; c i + 1 ; … ; c i + k − 1 ] + b c ) \mathbf{h}_i = \tanh(\mathbf{W}^c \cdot [\mathbf{c}_i; \mathbf{c}_{i+1}; \ldots; \mathbf{c}_{i+k-1}] + \mathbf{b}^c) hi=tanh(Wc[ci;ci+1;;ci+k1]+bc)其中 W c ∈ R n f × ( k ⋅ d c ) \mathbf{W}^c \in \mathbb{R}^{n_f \times (k \cdot d_c)} WcRnf×(kdc) n f n_f nf 为滤波器数量。对所有位置 i i i 进行最大池化(Max-Pooling)得到最终的字符级向量: r c ( w ) = max ⁡ 1 ≤ i ≤ m − k + 1 h i \mathbf{r}^c(w) = \max_{1 \leq i \leq m-k+1} \mathbf{h}_i rc(w)=1imk+1maxhi该字符表示与预训练词嵌入拼接后作为 BiLSTM 的输入。

性能:在 CoNLL-2003 上达到 91.21% 的 F1 值,复现实验(Ganesh & Reddy, 2020)在 PyTorch 实现中稳定达到 91.18%。

3.3 预训练范式:BERT 微调 (Devlin et al., 2019)

BERT(Bidirectional Encoder Representations from Transformers)采用 “大规模无监督预训练 + 下游任务微调” 范式。

预训练机制任务1:掩码语言模型(MLM):随机遮罩输入中 15% 的 Token,模型基于双向上下文预测被遮罩的原始 Token。对于被选中的位置 i,80% 概率替换为 [MASK],10% 概率替换为随机 Token,10% 概率保持不变。最终使用交叉熵损失优化。

预训练机制任务2:下一句预测(NSP):50% 概率选择真实连续句子对(标签 IsNext),50% 概率选择随机句子对(标签 NotNext),用于学习句子间关系。

应用于 NER(微调):将 NER 视为 Token 级分类任务。输入文本送入 BERT,每个 Token i 获取其最后一层隐状态向量 T i ∈ R H \mathbf{T}_i \in \mathbb{R}^H TiRH。接一个线性分类层: P i = Softmax ( W s T i + b s ) \mathbf{P}_i = \text{Softmax}(\mathbf{W}^s \mathbf{T}_i + \mathbf{b}^s) Pi=Softmax(WsTi+bs)其中 P i ∈ R k \mathbf{P}_i \in \mathbb{R}^{k} PiRk 为标签概率分布, k k k 为标签类别数。训练时极小化交叉熵损失: L = − ∑ i = 1 n log ⁡ P i [ y i ] \mathcal{L} = -\sum_{i=1}^{n} \log P_i[y_i] L=i=1nlogPi[yi]在 CoNLL-2003 上,BERT-Large 微调后 F1 达到 92.8%,显著超越传统深度模型。

四、讨论

本章以 Ganesh & Reddy (2020) 的 PyTorch 复现方案和 PyTorch 官方 BiLSTM-CRF 教程为基础,对 NER 项目的代码结构进行逐模块深入剖析。

4.1 项目整体目录结构

NER_Project/
├── model/
│ ├── init.py
│ ├── char_cnn.py # 字符级 CNN 模块
│ ├── bilstm.py # BiLSTM 编码器模块
│ ├── crf.py # CRF 层完整实现
│ └── ner_model.py # 整体模型组装
├── utils/
│ ├── init.py
│ ├── data_loader.py # 数据集加载与预处理
│ ├── vocab.py # 词表/字符表/标签表构建
│ └── metrics.py # 评估指标计算
├── train.py # 训练主脚本
├── eval.py # 评估脚本
├── config.py # 超参数配置
└── main.py # 入口脚本

4.2 数据加载与预处理模块(utils/data_loader.py

4.2.1 数据集格式与读取

CoNLL-2003 数据集每行为 word label 格式,空行分隔句子。数据读取函数核心逻辑:

def read_conll_file(file_path):
    """读取 CoNLL 格式文件,返回句子列表和标签列表"""
    sentences = []
    labels = []
    with open(file_path, 'r', encoding='utf-8') as f:
        words = []
        tags = []
        for line in f:
            line = line.strip()
            if line == '':
                if words:
                    sentences.append(words)
                    labels.append(tags)
                    words = []
                    tags = []
            else:
                parts = line.split()
                words.append(parts[0])
                tags.append(parts[-1])  # 标签在最后一列
    return sentences, labels

4.2.2 标签编码与 BIOES 转换

模型使用 BIOES 标签体系(B-begin, I-inside, O-outside, E-end, S-single)。BIOES 相比 BIO 的改进在于显式标记实体结尾(E)和单字实体(S),能提供更精确的边界信息。转换函数:

def bio_to_bioes(tags):
    """将 BIO 标签序列转换为 BIOES 标签序列"""
    new_tags = []
    i = 0
    while i < len(tags):
        if tags[i] == 'O':
            new_tags.append('O')
            i += 1
        elif tags[i].startswith('B-'):
            entity_type = tags[i][2:]
            # 查找同类型连续 I- 标签
            j = i + 1
            while j < len(tags) and tags[j] == f'I-{entity_type}':
                j += 1
            length = j - i
            if length == 1:
                new_tags.append(f'S-{entity_type}')
            else:
                new_tags.append(f'B-{entity_type}')
                for _ in range(length - 2):
                    new_tags.append(f'I-{entity_type}')
                new_tags.append(f'E-{entity_type}')
            i = j
        else:
            # 处理异常情况
            new_tags.append(tags[i])
            i += 1
    return new_tags

4.2.3 动态填充与批次处理

由于句子长度不一,需实现动态填充(Dynamic Batching):

def collate_fn(batch):
    """自定义批次整理函数,处理变长序列"""
    sentences, labels, char_indices = zip(*batch)
    
    # 获取批次内最大句子长度和最大词长
    max_seq_len = max(len(s) for s in sentences)
    max_word_len = max(max(len(w) for w in s) for s in sentences)
    
    # 填充词索引
    padded_words = torch.zeros(len(sentences), max_seq_len, dtype=torch.long)
    padded_labels = torch.full((len(sentences), max_seq_len), -1, dtype=torch.long)
    # 填充字符索引 (batch, seq_len, max_word_len)
    padded_chars = torch.zeros(len(sentences), max_seq_len, max_word_len, dtype=torch.long)
    lengths = torch.tensor([len(s) for s in sentences], dtype=torch.long)
    
    for i, (words, tags, chars) in enumerate(zip(sentences, labels, char_indices)):
        padded_words[i, :len(words)] = torch.tensor(words)
        padded_labels[i, :len(tags)] = torch.tensor(tags)
        for j, char_seq in enumerate(chars):
            padded_chars[i, j, :len(char_seq)] = torch.tensor(char_seq)
    
    return padded_words, padded_labels, padded_chars, lengths

关键点:必须使用 pack_padded_sequence 压缩填充位后再输入 LSTM,防止模型对 PAD 位置计算无效特征。

4.3 字符级 CNN 模块(model/char_cnn.py

字符级 CNN 用于从单词的字符序列中提取形态特征(如前缀、后缀)。为什么需要字符级表示?因为英文中存在大量的未登录词(OOV)(如罕见姓氏、新造词)。如果只依赖词嵌入,遇到未登录词就只能映射为 UNK,丢失了大量形态信息(比如 -ing 表示进行时,-tion 表示名词)。字符级 CNN 的作用就是提取单词的拼写形态特征。使用 CNN 而非 LSTM 是因为 CNN 可以并行计算窗口内的 n-gram 特征(如 3 个字母组合),速度更快。

数据流动(张量变换):

4.3.1 模块初始化

class CharCNN(nn.Module):
    def __init__(self, char_vocab_size, char_emb_dim, char_hidden_dim, 
                 kernel_size=3, dropout=0.5):
        super(CharCNN, self).__init__()
        self.char_emb_dim = char_emb_dim
        self.char_hidden_dim = char_hidden_dim
        
        # 字符嵌入层
        self.char_embeds = nn.Embedding(char_vocab_size, char_emb_dim, padding_idx=0)
        
        # 二维卷积层:输入通道1,输出通道 char_hidden_dim
        # 卷积核尺寸 (kernel_size, char_emb_dim)
        self.char_cnn = nn.Conv2d(
            in_channels=1,
            out_channels=char_hidden_dim,
            kernel_size=(kernel_size, char_emb_dim),
            padding=(kernel_size // 2, 0)  # 保持序列长度
        )
        self.dropout = nn.Dropout(dropout)

4.3.2 前向传播

def forward(self, chars):
    """
    Args:
        chars: (batch_size, max_word_len) 字符索引序列
    Returns:
        char_repr: (batch_size, char_hidden_dim) 每个词的字符级向量
    """
    # 字符嵌入: (batch_size, max_word_len, char_emb_dim)
    char_embeds = self.char_embeds(chars)
    # 添加通道维度: (batch_size, 1, max_word_len, char_emb_dim)
    char_embeds = char_embeds.unsqueeze(1)
    
    # 卷积: (batch_size, char_hidden_dim, new_len, 1)
    conv_out = self.char_cnn(char_embeds)
    
    # 最大池化: 沿序列长度维度取最大值
    # (batch_size, char_hidden_dim, 1, 1)
    pooled = F.max_pool2d(conv_out, kernel_size=(conv_out.size(2), 1))
    
    # 压缩维度: (batch_size, char_hidden_dim)
    char_repr = pooled.squeeze(-1).squeeze(-1)
    
    return self.dropout(char_repr)

关键设计:使用 Conv2d 而非 Conv1d 是为了在字符嵌入维度上进行完整覆盖,每个卷积核覆盖 (kernel_size, char_emb_dim) 区域,相当于在字符序列上滑动提取 n-gram 特征。

4.4 BiLSTM 编码器模块(model/bilstm.py

仅仅知道单词本身的形态还不够,NER 极度依赖上下文(例如“Apple”在“Apple Inc.”中是组织,在“eat an apple”中是普通名词)。BiLSTM 能够捕获双向的长距离依赖。前向 LSTM 看上文,后向 LSTM 看下文。这里引入 pack_padded_sequence 是本章最核心的技巧:它会把填充前的序列压缩成“锯齿状”的 PackedSequence,让 LSTM 只处理真实数据,跳过 PAD 位,既加速计算,又防止 PAD 位对隐状态产生不良影响。

4.4.1 模块初始化

class BiLSTM(nn.Module):
    def __init__(self, input_dim, hidden_dim, num_layers=1, dropout=0.5):
        super(BiLSTM, self).__init__()
        self.hidden_dim = hidden_dim
        self.num_layers = num_layers
        
        self.lstm = nn.LSTM(
            input_size=input_dim,
            hidden_size=hidden_dim // 2,  # 双向时每方向维度为 hidden_dim/2
            num_layers=num_layers,
            bidirectional=True,
            batch_first=True,
            dropout=dropout if num_layers > 1 else 0
        )
        self.dropout = nn.Dropout(dropout)

4.4.2 前向传播(含动态填充处理)

def forward(self, embeddings, lengths):
    """
    Args:
        embeddings: (batch_size, seq_len, input_dim) 词嵌入 + 字符表示拼接
        lengths: (batch_size,) 每个句子的实际长度
    Returns:
        output: (batch_size, seq_len, hidden_dim) 每个位置的上下文表示
    """
    # 1. 按长度降序排列(pack_padded_sequence 要求)
    sorted_lengths, sorted_idx = lengths.sort(descending=True)
    sorted_embeddings = embeddings[sorted_idx]
    
    # 2. 压缩填充位
    packed = nn.utils.rnn.pack_padded_sequence(
        sorted_embeddings, 
        sorted_lengths.cpu(), 
        batch_first=True,
        enforce_sorted=True
    )
    
    # 3. BiLSTM 前向传播
    packed_output, (hidden, cell) = self.lstm(packed)
    
    # 4. 恢复填充
    output, _ = nn.utils.rnn.pad_packed_sequence(
        packed_output, 
        batch_first=True
    )
    
    # 5. 恢复原始顺序
    _, unsorted_idx = sorted_idx.sort()
    output = output[unsorted_idx]
    
    return self.dropout(output)

关键点:pack_padded_sequence 和 pad_packed_sequence 是处理变长序列的标准方法,能有效避免对填充位置的无意义计算。

4.5 CRF 层模块(model/crf.py

如果只用 Softmax,模型会独立地给每个词打标签,这会导致荒谬的错误(例如:B-PER 后面直接接 I-LOC)。CRF 通过维护一个 转移矩阵(Transition Matrix) 来惩罚这种非法转移。前向算法(Forward Algorithm)用于计算配分函数(即所有可能标签序列的得分之和),这是训练时的归一化项;维特比算法(Viterbi Algorithm)用于在推理时从指数级路径中快速找到得分最高的那条路径。

4.5.1 模块初始化与转移矩阵

class CRF(nn.Module):
    def __init__(self, num_tags, batch_first=True):
        super(CRF, self).__init__()
        self.num_tags = num_tags
        self.batch_first = batch_first
        
        # 转移矩阵: transitions[i][j] 表示从标签 i 转移到标签 j 的分数
        self.transitions = nn.Parameter(torch.randn(num_tags, num_tags))
        # 起始转移: start_transitions[i] 表示从 START 转移到标签 i 的分数
        self.start_transitions = nn.Parameter(torch.randn(num_tags))
        # 终止转移: end_transitions[i] 表示从标签 i 转移到 END 的分数
        self.end_transitions = nn.Parameter(torch.randn(num_tags))
        
        self.reset_parameters()
    
    def reset_parameters(self):
        """参数初始化"""
        nn.init.uniform_(self.transitions, -0.1, 0.1)
        nn.init.uniform_(self.start_transitions, -0.1, 0.1)
        nn.init.uniform_(self.end_transitions, -0.1, 0.1)

4.5.2 序列得分计算

对于输入序列 x 和标签序列 y,定义全局得分: s ( x , y ) = ∑ i = 1 n P i , y i + ∑ i = 1 n − 1 A y i , y i + 1 + A s t a r t , y 1 + A y n , e n d s(x,y) = \sum_{i=1}^{n} P_{i,y_i} + \sum_{i=1}^{n-1} A_{y_i,y_{i+1}} + A_{start,y_1} + A_{y_n,end} s(x,y)=i=1nPi,yi+i=1n1Ayi,yi+1+Astart,y1+Ayn,end
代码实现:

def _compute_score(self, emissions, tags, mask):
    """
    计算给定标签序列的得分
    Args:
        emissions: (seq_len, batch_size, num_tags) 发射分数
        tags: (seq_len, batch_size) 标签序列
        mask: (seq_len, batch_size) 有效位置掩码
    Returns:
        score: (batch_size,) 每个样本的序列得分
    """
    seq_len, batch_size = emissions.shape[:2]
    score = self.start_transitions[tags[0]]  # 起始转移
    
    # 累加发射分数和转移分数
    for i in range(seq_len):
        score += emissions[i, torch.arange(batch_size), tags[i]] * mask[i]
        if i < seq_len - 1:
            score += self.transitions[tags[i], tags[i+1]] * mask[i] * mask[i+1]
    
    # 终止转移(仅对有效序列的最后一个标签)
    last_tags = tags[seq_len - 1]
    score += self.end_transitions[last_tags] * mask[seq_len - 1]
    
    return score

4.5.3 配分函数计算(前向算法)

配分函数 Z(x) = sum_{y’} exp(s(x, y’)) 的计算是 CRF 训练的关键:

def _compute_normalizer(self, emissions, mask):
    """
    前向算法计算配分函数
    Args:
        emissions: (seq_len, batch_size, num_tags)
        mask: (seq_len, batch_size)
    Returns:
        log_norm: (batch_size,) 对数配分函数
    """
    seq_len, batch_size = emissions.shape[:2]
    
    # forward_var: (batch_size, num_tags) 当前步的累积分数
    forward_var = self.start_transitions + emissions[0]
    
    for i in range(1, seq_len):
        # 扩展维度用于广播: (batch_size, num_tags, 1) + (num_tags, num_tags)
        # 计算从所有前驱标签转移到当前标签的分数
        expanded_var = forward_var.unsqueeze(2)  # (B, T, 1)
        trans_expanded = self.transitions.unsqueeze(0)  # (1, T, T)
        
        # (B, T, T): 前驱分数 + 转移分数
        next_tag_var = expanded_var + trans_expanded
        # 在标签维度上取 logsumexp: (B, T)
        next_forward_var = torch.logsumexp(next_tag_var, dim=1)
        
        # 加上当前步的发射分数
        next_forward_var = next_forward_var + emissions[i]
        
        # 应用掩码: 有效位置更新,无效位置保持
        forward_var = torch.where(
            mask[i].unsqueeze(1),
            next_forward_var,
            forward_var
        )
    
    # 加上终止转移,取 logsumexp
    final_var = forward_var + self.end_transitions
    log_norm = torch.logsumexp(final_var, dim=1)
    
    return log_norm

4.5.4 负对数似然损失

def forward(self, emissions, tags, mask=None):
    """
    计算 CRF 的负对数似然损失
    """
    if mask is None:
        mask = torch.ones_like(tags, dtype=torch.bool)
    
    if self.batch_first:
        emissions = emissions.transpose(0, 1)  # (seq_len, batch, num_tags)
        tags = tags.transpose(0, 1)            # (seq_len, batch)
        mask = mask.transpose(0, 1)            # (seq_len, batch)
    
    # 正确路径得分
    numerator = self._compute_score(emissions, tags, mask)
    # 配分函数(所有路径得分之和)
    denominator = self._compute_normalizer(emissions, mask)
    
    # 负对数似然: -log( exp(score) / Z ) = -score + log(Z)
    nll = -numerator + denominator
    return nll.mean()

五、 实际场景调用:基于 OpenNRE 的公司收购关系抽取

5.1 场景描述与业务价值

在金融情报分析、企业竞争态势监测和知识图谱构建等实际业务中,从海量新闻文本中自动抽取公司之间的收购关系是一项极具价值的工作。传统的做法依赖人工阅读和整理,效率低下且难以规模化。本节设计了一个基于 OpenNRE 的自动化关系抽取流水线,能够从科技新闻中识别公司间的收购关系,并输出结构化的三元组结果。

业务场景:假设某投资研究机构需要从每日科技新闻中自动收集“哪些公司收购了哪些公司”的信息,用于构建行业并购数据库。输入是新闻句子,输出是 <收购方, 收购关系, 被收购方> 的三元组。

5.2 技术方案

本方案采用 OpenNRE 提供的预训练关系抽取模型 wiki80_bert_softmax。该模型基于 BERT 编码器,在 Wiki80 数据集(80 种关系类型)上进行了微调,具备良好的通用关系识别能力。

模型特点:

  • 编码器:BERT-base-uncased,能够生成深度的双向上下文表示
  • 架构:SoftmaxNN(BERT 编码器 + 线性分类层)
  • 训练数据:Wiki80 数据集,涵盖 80 种常见关系类型

为什么选择 OpenNRE:

  • 开箱即用,提供多种预训练模型
  • 支持自定义文本和实体位置输入
  • 与本次报告中学习的 BERT 微调范式一脉相承

5.3 详细实现步骤

测试数据准备:准备了 10 条科技新闻句子,涵盖 Microsoft、Google、Amazon 等知名科技公司的收购事件。每条数据包含三个字段:

{
    "text": "Microsoft acquired GitHub in 2018 for $7.5 billion.",
    "h": {"name": "Microsoft", "pos": [0, 9]},
    "t": {"name": "GitHub", "pos": [18, 24]}
}

其中 pos 字段表示实体在文本中的字符级起止位置(左闭右开区间),这是 OpenNRE 推理函数的输入格式要求。
核心推理脚本:功能分别为加载预训练模型 wiki80_bert_softmax;读取 JSON 格式的测试数据;逐条调用 model.infer() 进行关系预测;收集结果并生成统计摘要和可视化图表。

import opennre
import json
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import os

# 加载模型
model = opennre.get_model('wiki80_bert_softmax')

# 加载数据
with open("data/annotations.json", "r", encoding="utf-8") as f:
    data = json.load(f)

# 批量推理
results = []
for item in data:
    rel, conf = model.infer({
        'text': item['text'],
        'h': {'pos': item['h']['pos']},
        't': {'pos': item['t']['pos']}
    })
    results.append({
        'head': item['h']['name'],
        'tail': item['t']['name'],
        'relation': rel,
        'confidence': conf
    })

df = pd.DataFrame(results)
df.to_csv("outputs/results.csv", index=False)

可视化脚本:为了直观呈现模型表现,生成了三类可视化图表。

# 图1:置信度分布
plt.figure(figsize=(10, 5))
sns.histplot(df['confidence'], bins=15, kde=True, color='steelblue')
plt.axvline(0.5, color='red', linestyle='--', label='阈值 0.5')
plt.savefig("outputs/confidence_distribution.png", dpi=300)

# 图2:关系类型频次
plt.figure(figsize=(10, 5))
df['relation'].value_counts().plot(kind='bar')
plt.savefig("outputs/relation_distribution.png", dpi=300)

# 图3:置信度箱线图
plt.figure(figsize=(10, 5))
sns.boxplot(x='relation', y='confidence', data=df)
plt.savefig("outputs/confidence_by_relation.png", dpi=300)

5.4 实验结果

运行批量抽取脚本后,得到以下 10 条预测结果:
预测结果
统计摘要如下:

==================================================
📊 统计摘要
==================================================
总样本数:        10
平均置信度:      0.8854
最高置信度:      0.9933
最低置信度:      0.7149

关系频次:
  subsidiary: 6 次
  owned by: 4==================================================

5.5 实验分析

模型语义理解能力分析:对“收购”这一语义概念给出了两种表达:subsidiary(子公司)和 owned by(被拥有)。从知识图谱的角度看,subsidiary 和 owned by 实际上都隐含了控制权和所有权关系,与“收购”存在近义关联。这说明 BERT 编码器有效捕获了句子中的因果语义,模型具备较强的语义迁移能力。

置信度分布分析:

  • 平均置信度 0.8854:整体预测可信度较高
  • 最高置信度 0.9933(IBM → Red Hat):语义最明确的样本
  • 最低置信度 0.7149(Salesforce → Slack Technologies):模型略有不确定
    实验结果relation_distribution
    实验结果confidence_distribution
    实验结果confidence_by_relation

从置信度箱线图可以看出,owned by 类别的置信度分布更为集中且偏高(0.91-0.99),subsidiary 类别则分布较广(0.71-0.99),表明模型对 owned by 这一表达模式的学习更为充分。

模型局限性讨论:OpenNRE 的训练数据(Wiki80)使用的关系标签体系与本次测试的“收购”语义存在标签体系差异。Wiki80 中可能将“收购”类型的样本统一归入 subsidiary 或 ownership 等粗粒度类别。这说明在使用通用关系抽取模型时,需要根据目标关系类型对模型进行领域适配或微调,以使其输出与业务需求对齐。

6. 总结与展望

本报告系统梳理了深度学习在 NER 领域的技术演进:

  • 从特征工程到表示学习:BiLSTM-CRF 模型证明了无需人工特征即可达到 SOTA。
  • 从 LSTM 到 CNN 再到 Transformer:字符级 CNN 提升计算效率,BERT 等预训练模型利用深度双向注意力机制彻底改变范式。
  • 从独立模型到联合流水线:NER 与关系抽取(如 OpenNRE)可无缝串联,服务于知识图谱构建等实际应用。

未来挑战:

  • 社交媒体非正式文本(如 W-NUT 2017,F1 仅约 40%)的噪声处理。
  • 细粒度实体识别(数百种类型)和嵌套实体(NER 嵌套率达到 17%-30%)的建模。
  • 低资源语言和领域的少样本学习(Few-shot NER)。
Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐