视频加载失败

课程

1515 字
约 5 分钟

实验五:基于 BiLSTM-CRF 的命名实体识别 (NER)

自然语言处理labs/lab/lab05/docs·更新于 2026-09-15

实验五:基于 BiLSTM-CRF 的命名实体识别 (NER)

实验地点:计算机大楼606

实验目的: 理解并实现基于 BiLSTM+CRF 的命名实体识别流程,掌握 CRF 层在实体边界约束中的作用。

实验环境(硬件和软件) Windows 11,Python 3.12,PyTorch

实验内容:

本实验基于 CoNLL2003 英文数据集,实现了完整的 BiLSTM+CRF 命名实体识别流水线。

整体流程包括:读取 CoNLL 格式数据、构建词表和向量化器、使用 Embedding + BiLSTM + CRF 完成序列标注,最后通过 Viterbi 解码输出最优标签序列。

CRF 层的核心作用是通过转移矩阵约束标签之间的合法转移关系,例如 B-PER 后只能接 I-PER 或 O,而不能接 B-LOC,从而避免产生不合法的标签序列。

模型训练采用 early stopping 和学习率衰减策略,评估使用 CoNLL 标准工具 conlleval.pl 统计 precision、recall 和 F1。

实验步骤:

1. 数据读取:使用 Conll03Reader 读取 CoNLL2003 格式的 train/valid/test 数据。

class Conll03Reader:
    def read(self, data_path):
        data_parts = ['train', 'valid', 'test']
        extension = '.txt'
        dataset = {}
        for data_part in tqdm(data_parts):
            file_path = os.path.join(data_path, data_part + extension)
            dataset[data_part] = self.read_file(str(file_path))
        return dataset

    def read_file(self, file_path):
        samples = []
        tokens = ['<start>']
        tag = ['<start>']
        with open(file_path, 'r', encoding='utf-8') as fb:
            for line in fb:
                line = line.strip('\n')

                if line == '-DOCSTART- -X- -X- O':
                    pass
                elif line == '':
                    if len(tokens) > 1:
                        samples.append((tokens + ['<end>'], tag + ['<end>']))
                        tokens = ['<start>']
                        tag = ['<start>']
                else:
                    contents = line.split(' ')
                    tokens.append(contents[0])
                    tag.append(contents[-1])
        return samples

def predata(input_path="./data/conll2003"):
    ds_rd = Conll03Reader()
    condata = ds_rd.read(input_path)
    return condata

2. 模型定义:BiLSTM+CRF 模型,包含 Embedding 层、BiLSTM 编码层、线性层(emission scores)和 CRF 转移矩阵。

class BiLSTM_CRF(nn.Module):

    def __init__(self, token_vocab, tag_vocab, batch_size,
                 dropout=0.5, embedding_dim=256,
                 hidden_dim=256, pretrained_embedding=None,
                 padding_idx=0, num_layers=1):
        super(BiLSTM_CRF, self).__init__()
        self.dropout = nn.Dropout(dropout)
        self.embedding_dim = embedding_dim
        self.hidden_dim = hidden_dim
        self.token_vocab = token_vocab
        self.tag_vocab = tag_vocab
        self.pad = self.token_vocab.pad_token

        self.tagset_size = len(tag_vocab)
        self.begin_tag_idx = tag_vocab.lookup_token('<start>')
        self.end_tag_idx = tag_vocab.lookup_token('<end>')

        if pretrained_embedding is None:
            self.word_embeds = nn.Embedding(len(self.token_vocab), embedding_dim)
        else:
            self.word_embeds = nn.Embedding(len(self.token_vocab), embedding_dim,
                                            _weight=pretrained_embedding)

        self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2,
                            num_layers=num_layers, bidirectional=True)

        self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)

        self.transition = nn.Parameter(
            torch.randn(self.tagset_size, self.tagset_size))
        self.transition.data[self.begin_tag_idx, :] = -10000
        self.transition.data[:, self.end_tag_idx] = -10000

        self.hidden = self.init_hidden(num_layers, batch_size)

    def init_hidden(self, num_layers, batch_size):
        return (torch.randn(2 * num_layers, batch_size, self.hidden_dim // 2, device=self.device),
                torch.randn(2 * num_layers, batch_size, self.hidden_dim // 2, device=self.device))

    def _forward_alg(self, feats, mask):
        """Forward algorithm for CRF partition function

        Args:
            feats: [b_s, seq_len, tag_size]
            mask: [b_s, seq_len]
        Returns:
            [b_s] partition function scores
        """
        init_alphas = torch.full((feats.size(0), self.tagset_size), -10000., device=self.device)
        init_alphas[:, self.begin_tag_idx] = 0.

        forward_var_list = []
        forward_var_list.append(init_alphas)
        d = torch.unsqueeze(feats[:, 0], dim=1)
        for feat_index in range(1, feats.size(1)):
            n_unfinish = mask[:, feat_index].sum()
            d_uf = d[:n_unfinish]
            emit_and_transition = feats[:n_unfinish, feat_index].unsqueeze(dim=1) + self.transition
            log_sum = d_uf.transpose(1, 2) + emit_and_transition
            max_v = log_sum.max(dim=1)[0].unsqueeze(dim=1)
            log_sum = log_sum - max_v
            d_uf = max_v + torch.logsumexp(log_sum, dim=1).unsqueeze(dim=1)
            d = torch.cat((d_uf, d[n_unfinish:]), dim=0)
        d = d.squeeze(dim=1)
        max_d = d.max(dim=-1)[0]
        d = max_d + torch.logsumexp(d - max_d.unsqueeze(dim=1), dim=1)
        return d

    def _get_lstm_features(self, embedded_vec, seq_len):
        """Get emission scores from BiLSTM

        Args:
            embedded_vec: [max_seq_len, b_s, e_d]
            seq_len: [b_s]
        Returns:
            [b_s, seq_len, tag_size]
        """
        pack_seq = pack_padded_sequence(embedded_vec, seq_len)
        lstm_out, self.hidden = self.lstm(pack_seq)
        lstm_out, _ = pad_packed_sequence(lstm_out, batch_first=True)
        lstm_feats = self.hidden2tag(lstm_out)
        lstm_feats = self.dropout(lstm_feats)
        return lstm_feats

    def _score_sentence(self, feats, tags, mask):
        """Score the gold tag sequence

        Args:
            feats: [b_s, seq_len, tag_size]
            tags: [b_s, seq_len]
            mask: [b_s, seq_len]
        Returns:
            [b_s] gold path scores
        """
        score = torch.gather(feats, dim=2, index=tags.unsqueeze(dim=2)).squeeze(dim=2)
        score[:, 1:] += self.transition[tags[:, :-1], tags[:, 1:]]
        total_score = (score * mask.type(torch.float)).sum(dim=1)
        return total_score

    def _viterbi_decode(self, feats, mask, seq_len):
        """Viterbi decoding for finding best tag sequence

        Args:
            feats: [b_s, seq_len, tag_size]
            mask: [b_s, seq_len]
            seq_len: [b_s]
        Returns:
            scores, tag_sequences
        """
        batch_size = feats.size(0)
        tags = [[[i] for i in range(len(self.tag_vocab))]] * batch_size
        d = torch.unsqueeze(feats[:, 0], dim=1)
        for i in range(1, seq_len[0]):
            n_unfinished = mask[:, i].sum()
            d_uf = d[:n_unfinished]
            emit_and_transition = self.transition + feats[:n_unfinished, i].unsqueeze(dim=1)
            new_d_uf = d_uf.transpose(1, 2) + emit_and_transition
            d_uf, max_idx = torch.max(new_d_uf, dim=1)
            max_idx = max_idx.tolist()
            tags[:n_unfinished] = [[tags[b][k] + [j] for j, k in enumerate(max_idx[b])] for b in range(n_unfinished)]
            d = torch.cat((torch.unsqueeze(d_uf, dim=1), d[n_unfinished:]), dim=0)
        d = d.squeeze(dim=1)
        score, max_idx = torch.max(d, dim=1)
        max_idx = max_idx.tolist()
        tags = [tags[b][k] for b, k in enumerate(max_idx)]
        return score, tags

    def neg_log_likelihood(self, token_vec, tag_vec, seq_len):
        """Compute negative log likelihood loss"""
        mask = (token_vec != self.token_vocab.lookup_token(self.pad)).to(self.device)
        token_vec = token_vec.transpose(0, 1)
        embedded_vec = self.word_embeds(token_vec)
        feats = self._get_lstm_features(embedded_vec, seq_len)

        forward_score = self._forward_alg(feats, mask)
        gold_score = self._score_sentence(feats, tag_vec, mask)
        return forward_score - gold_score

    def forward(self, token_vec, tag_vec, seq_len):
        """Forward pass: Viterbi decoding to find best path

        Args:
            token_vec: [b_s, max_seq_len]
            tag_vec: [b_s, max_seq_len]
            seq_len: [b_s]
        Returns:
            scores, tag_sequences
        """
        mask = (token_vec != self.token_vocab.lookup_token(self.pad)).to(self.device)
        token_vec = token_vec.transpose(0, 1)
        embedded_vec = self.word_embeds(token_vec)
        lstm_feats = self._get_lstm_features(embedded_vec, seq_len)

        mask = mask[:, :lstm_feats.size(1)]
        score, tag_seq = self._viterbi_decode(lstm_feats, mask, seq_len)
        return score, tag_seq

    @property
    def device(self):
        return self.word_embeds.weight.device

3. CRF 前向算法:计算配分函数 Z(x),用于归一化所有可能路径的得分之和。

def _forward_alg(self, feats, mask):
        """Forward algorithm for CRF partition function

        Args:
            feats: [b_s, seq_len, tag_size]
            mask: [b_s, seq_len]
        Returns:
            [b_s] partition function scores
        """
        init_alphas = torch.full((feats.size(0), self.tagset_size), -10000., device=self.device)
        init_alphas[:, self.begin_tag_idx] = 0.

        forward_var_list = []
        forward_var_list.append(init_alphas)
        d = torch.unsqueeze(feats[:, 0], dim=1)
        for feat_index in range(1, feats.size(1)):
            n_unfinish = mask[:, feat_index].sum()
            d_uf = d[:n_unfinish]
            emit_and_transition = feats[:n_unfinish, feat_index].unsqueeze(dim=1) + self.transition
            log_sum = d_uf.transpose(1, 2) + emit_and_transition
            max_v = log_sum.max(dim=1)[0].unsqueeze(dim=1)
            log_sum = log_sum - max_v
            d_uf = max_v + torch.logsumexp(log_sum, dim=1).unsqueeze(dim=1)
            d = torch.cat((d_uf, d[n_unfinish:]), dim=0)
        d = d.squeeze(dim=1)
        max_d = d.max(dim=-1)[0]
        d = max_d + torch.logsumexp(d - max_d.unsqueeze(dim=1), dim=1)
        return d
  • (1)CRF 层在本实验中的作用是什么?

CRF 层通过可学习的转移矩阵,对相邻标签之间的转移概率进行建模。例如,B-PER 到 I-PER 的转移得分会较高,而 B-PER 到 I-LOC 的转移得分会被压低。

这使得模型在解码时能够利用全局信息找到最优标签序列,而非逐 token 独立决策,从而有效提升实体边界的准确性。

  • (2)Viterbi 解码与贪心解码的区别是什么?

贪心解码对每个位置独立选择概率最高的标签,不考虑标签之间的依赖关系,容易产生不合法的标签序列。

Viterbi 解码则通过动态规划在所有可能的标签序列中找到全局最优解,保证输出序列在转移矩阵约束下的得分最大。

实验数据记录:

1. 数据集为 CoNLL2003,包含 train/valid/test 三个划分。

2. 模型参数:embedding_dim=100,hidden_dim=50,batch_size=32,num_epochs=50。

3. 使用 Adam 优化器,初始学习率 0.001,ReduceLROnPlateau 衰减策略。

4. Early stopping 在验证损失连续 5 个 epoch 不改善时触发。

5. 测试结果通过 conlleval.pl 评估 precision、recall 和 F1。

问题讨论:

问题:BiLSTM+CRF 模型相比纯 BiLSTM 的改进。

现象描述:CRF 层引入转移矩阵后,模型能够学习标签间的合法转移模式,避免产生 B-PER → I-LOC 等非法序列。

原因分析:纯 BiLSTM 对每个 token 独立预测标签,无法利用标签间的依赖关系;CRF 通过全局归一化建模了这种依赖。

解决方法:本实验已实现 BiLSTM+CRF 架构,后续可尝试引入 GloVe 预训练词向量进一步提升性能。

Profile Image of the Author
Sonder
好想要技术
这是公告标题
这只是一个公告
分类
标签
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录