ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

命名实体识别实战:BiLSTM-CRF模型原理与PyTorch实现详解

命名实体识别实战:BiLSTM-CRF模型原理与PyTorch实现详解 简介本资源是一套面向NLP初学者与医疗领域AI开发者的命名实体识别NER实战方案聚焦BiLSTM-CRF模型在临床文本中的实体抽取任务解决病症、药物、操作等关键信息自动识别难题。压缩包共24个文件含5个核心Python脚本如train.py、preprocess.py、dataset.py、visual.py及BiLSTM-CRF主模型、8个文本类配置与说明文档含详细使用指南、3张结果可视化PNG图标注分布、CRF优化前后对比、3个Excel格式的CCKS2019与YIDU-S4K医疗NER赛题数据集以及JSON词典、模型权重与缓存文件等整体仅2.23MB轻量易部署。已有261人学习下载。用户可直接复现完整流程从原始医疗文本预处理、BiLSTM编码CRF解码联合训练到标签序列评估与结果可视化配套说明文档清晰指引参数调优与输出解读大幅降低医疗NLP建模门槛。1. 从序列标注到命名实体识别一个经典问题的再审视命名实体识别简称NER是自然语言处理领域里一个既基础又核心的任务。简单来说它的目标就是从一段非结构化的文本中找出并分类那些具有特定意义的实体单元比如人名、地名、组织机构名、时间、货币等等。这听起来像是给文本里的“专有名词”贴标签但实际操作起来远比想象中复杂。一个词在不同的上下文里可能代表完全不同的实体类型甚至可能根本不是实体。比如“苹果”在“我吃了一个苹果”里是水果在“苹果公司发布了新产品”里是公司名。这种歧义性是NER任务最大的挑战之一。从技术角度看NER本质上是一个序列标注问题。我们把输入的句子看作一个由单词或字组成的序列然后为序列中的每一个单元预测一个标签。这个标签不仅表示当前单元是否属于某个实体还表示它在实体中的位置比如是实体的开始、中间还是结束。最常用的标注体系是BIO或BIOES。BIO体系里B-XXX表示某类实体的开始I-XXX表示该实体的内部O表示非实体。BIOES则更精细增加了E-XXX实体结束和S-XXX单字实体。这种将分类问题转化为序列标注问题的思路为后续一系列模型的登场铺平了道路。在深度学习浪潮席卷NLP之前基于统计机器学习的方法如隐马尔可夫模型和条件随机场是解决序列标注问题的主流。尤其是CRF它能够有效地建模标签之间的依赖关系比如“B-PER后面跟I-PER的概率远大于跟O的概率”这种约束对于保证输出标签序列的合理性至关重要。然而这些模型严重依赖精心设计的人工特征比如词性、词形、前后缀、词典匹配等特征工程的好坏直接决定了模型性能的上限。随着词向量和神经网络特别是循环神经网络的出现情况发生了根本性改变。RNN及其变体LSTM、GRU能够自动地从原始文本中学习上下文相关的特征表示极大地解放了特征工程的负担。其中双向LSTM能够同时捕捉一个词的前向和后向上下文信息这对于消歧至关重要——要判断“苹果”的类型看看它前面和后面跟着什么词就行了。因此BiLSTM迅速成为为序列中每个位置生成高质量上下文表征的标配组件。那么一个很自然的想法就产生了为什么不把强大的特征提取器BiLSTM和擅长建模标签依赖的CRF结合起来呢这就是BiLSTM-CRF模型的核心思想。BiLSTM负责“看”文本理解上下文为每个词生成一个富含语义信息的向量CRF则负责“管”标签利用这些向量特征同时考虑整个标签序列的全局最优输出一个符合语法和语义约束的标签序列。这套组合拳在2015年左右被提出后迅速成为NER任务的基准模型和“标配”解决方案在多个公开数据集上达到了当时的state-of-the-art水平其清晰的架构和稳定的性能使其成为学习NER乃至序列标注任务的经典范例。2. BiLSTM-CRF模型架构的逐层拆解理解BiLSTM-CRF最好的方式就是把它拆开一层一层看明白每个组件到底在做什么。我们可以把整个模型想象成一个加工流水线原始文本句子是原材料经过几道工序最终产出标注好的标签序列。2.1 输入层从词语到向量的映射流水线的第一站是输入层。模型接收一个句子比如[“张” “三” “在” “北” “京” “工” “作”]。计算机不认识汉字所以我们需要把每个字或词转换成一个数字向量这个过程就是词嵌入。注意在中文NER中基于字的模型往往比基于词的模型更鲁棒因为它能避免分词错误带来的误差传播。所以这里我们以字为单位进行说明。我们有一个预训练好的嵌入矩阵其大小是[词汇表大小V, 嵌入维度D]。每个字在词汇表中有一个唯一的索引ID。通过查找这个矩阵句子中的每个字w_i都被转换成一个D维的实数向量e_i。这个向量捕获了字的语义信息例如“京”和“都”的向量在空间上应该比较接近。对于未登录词OOV通常使用一个特殊的UNK向量来表示。这一步的输出是一个向量序列[e_1, e_2, ..., e_n]其中n是句子长度。2.2 特征抽取层双向LSTM的上下文编码拿到了每个字的静态表示后下一步是获取它的动态上下文表示。这就是BiLSTM层的工作。LSTM是RNN的一种通过精巧的门控机制输入门、遗忘门、输出门来解决传统RNN的梯度消失/爆炸问题能够更好地捕捉长距离依赖。单向LSTM只能看到当前时刻之前的上下文。但对于“在北京工作”中的“京”字要判断它是否是地名的一部分后面的“工”字也提供了重要信息“工作”通常接在地点后。因此我们使用双向LSTM。具体来说我们会初始化两个LSTM网络一个前向LSTM从左到右读取句子一个后向LSTM从右到左读取句子。对于句子中的第i个字前向LSTM会输出一个向量h_i_f它编码了从句子开头到第i个字的所有信息。后向LSTM会输出一个向量h_i_b它编码了从句子结尾到第i个字的所有信息。最后我们将这两个向量拼接起来得到第i个字的最终上下文表征h_i [h_i_f; h_i_b]。这个h_i向量蕴含了以第i个字为中心的全部上下文信息。假设LSTM的隐藏层维度是H那么h_i的维度就是2H。整个句子的输出就是一个新的序列[h_1, h_2, ..., h_n]每个向量的信息量都比原始的e_i丰富得多。2.3 发射分数与转移分数CRF层的两大核心BiLSTM层为我们提供了每个位置的优质特征h_i。接下来需要一个全连接层有时也叫投影层将h_i映射到标签空间。假设我们有K个不同的标签如B-PER, I-PER, O, B-LOC, I-LOC...。这个全连接层就是一个W*h_i b的线性变换输出一个K维向量。这个向量的第j个分量可以理解为第i个字被预测为第j个标签的“分数”我们称之为发射分数。它反映了BiLSTM根据上下文认为各个标签的可能性。然而仅仅独立地看每个位置的发射分数是不够的。标签之间是有强关联的例如“I-PER”前面几乎不可能是“O”或“B-LOC”而很可能是“B-PER”或“I-PER”。这种标签之间的约束关系就是CRF层要建模的。CRF层引入了一个K x K的矩阵称为转移分数矩阵T。矩阵元素T_{ij}表示从标签i转移到标签j的分数。这个分数是可学习的参数。一个正的T_{B-PER, I-PER}分数会鼓励“B-PER”后面接“I-PER”这种合法序列而一个负的很大的T_{I-PER, B-LOC}分数则会惩罚这种不太可能发生的转移。2.4 从分数到序列维特比解码现在对于一条长度为n的句子我们有了n个发射分数向量每个K维一个K x K的转移分数矩阵那么如何找到全局最优的标签序列y [y_1, y_2, ..., y_n]呢CRF通过定义序列的总分来实现。一条路径y的总分S(X, y)等于所有位置的发射分数之和加上所有相邻标签间的转移分数之和S(X, y) sum_{i1}^{n} (EmissionScore_{i, y_i}) sum_{i1}^{n-1} (T_{y_i, y_{i1}})我们的目标就是找到使得总分S最大的那个标签序列y。这是一个全局搜索问题如果暴力枚举所有K^n种可能计算量无法承受。幸运的是由于分数是逐位置相加的我们可以使用动态规划算法——维特比算法来高效地求解。维特比算法的核心思想是递推。在每一步i我们记录到达每个可能标签的最佳路径的分数和回溯指针。递推公式为score_{i}(t) EmissionScore_{i, t} max_{s} (score_{i-1}(s) T_{s, t})其中score_{i}(t)表示到位置i为止且第i个标签为t的所有路径中的最高分。我们同时记录下是哪个前驱标签s达到了这个最高分。遍历完整个句子后在最后位置选择分数最高的标签然后根据记录的回溯指针向前追溯就能得到全局最优的标签序列。这个过程确保了最终输出的标签序列不仅每个位置的分数高而且序列整体是平滑、合理的。3. 模型训练损失函数与反向传播的细节知道了模型如何做预测接下来就要看它如何通过学习来变强。BiLSTM-CRF模型的训练目标是让模型给正确的标签序列打高分给错误的序列打低分。3.1 损失函数负对数似然我们使用负对数似然作为损失函数。对于一条训练数据句子X 真实标签序列y模型给出的似然度定义为正确路径的分数相对于所有可能路径分数总和的比值。这其实是一个softmax操作在路径级别上的推广。具体公式如下计算所有可能路径的总分Z(X) sum_{y in Y} exp(S(X, y))其中Y是所有可能的标签序列集合。这个值被称为配分函数。计算正确路径y的分数S(X, y)。损失函数L -log(P(y|X)) -log( exp(S(X, y)) / Z(X) ) -S(X, y) log Z(X)。我们的训练目标就是最小化这个损失L。直观理解就是最大化正确路径的分数S(X, y)同时相对地最小化所有路径的总分Z(X)。计算log Z(X)是关键的它同样可以通过类似维特比算法的前向算法一种动态规划高效计算而无需枚举指数级数量的路径。3.2 反向传播误差如何流动在反向传播过程中损失L的梯度会流向模型的每一个可训练参数词嵌入矩阵、BiLSTM的权重和偏置、发射矩阵全连接层的W和b以及CRF的转移矩阵T。对发射分数的梯度梯度会指示每个位置模型应该增加正确标签的发射分数并减少其他标签的发射分数根据模型当前预测的概率分布进行加权减少。对转移分数的梯度梯度会指示转移矩阵T应该增加在训练数据中真实出现的标签转移如B-PER - I-PER的分数并减少其他转移的分数。例如如果训练数据里从未出现“I-PER - B-LOC”这种转移那么T_{I-PER, B-LOC}就会收到负的梯度使其值减小。对BiLSTM和词嵌入的梯度来自发射分数的梯度会继续反向传播到BiLSTM层和词嵌入层调整这些层的参数使得它们能为CRF层产生更好的特征表示。通过大量句子的迭代训练模型逐渐学会1根据上下文生成更准确的发射分数2学习到标签之间合理的转移规律。最终模型参数收敛到一个较好的状态。3.3 一个完整的训练迭代示例假设我们有一个迷你句子“张三”真实标签是[B-PER, I-PER]。假设标签集只有三个B-PER(0), I-PER(1), O(2)。前向传播输入“张”、“三”得到词向量通过BiLSTM得到上下文向量h1, h2。通过全连接层计算发射分数。假设模型输出“张”: [2.1, 0.8, -1.0] (对应B-PER, I-PER, O)“三”: [0.5, 1.8, -0.5]CRF转移矩阵T是3x3的随机初始化矩阵。计算正确路径[B-PER, I-PER]的分数S (2.1 1.8) T[B-PER][I-PER]。用前向算法计算所有路径的总分log Z(X)。计算损失L -S log Z(X)。反向传播计算L对发射分数、转移矩阵T、BiLSTM参数、词向量的梯度。参数更新使用优化器如Adam根据梯度更新所有参数。经过成千上万次这样的迭代模型参数得到优化。在预测“张三”时模型不仅会给“张”的B-PER一个高发射分给“三”的I-PER一个高发射分而且转移矩阵中的T[B-PER][I-PER]也会有一个很高的正值从而使得[B-PER, I-PER]这条路径的总分远超其他可能如[B-PER, O]最终被维特比算法选中。4. 从理论到实践构建你自己的BiLSTM-CRF模型理解了原理接下来我们动手搭建一个。这里我们使用PyTorch框架因为它动态图的特点非常适合教学和实验。我们将分模块构建并解释每个关键步骤。4.1 数据准备与预处理任何NLP项目都始于数据。我们通常使用BIO或BIOES格式的标注数据。每一行是一个“字/词”和它的“标签”句子之间用空行隔开。张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC 工 O 作 O 李 B-PER 四 I-PER 去 O 上 B-LOC 海 I-LOC我们需要构建两个映射字典word2idx: 将字映射到索引ID。需要加入PAD填充符和UNK未知字。tag2idx: 将标签映射到索引ID。同样需要加入PAD标签用于填充。import torch from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, file_path, word2idx, tag2idx): self.sentences [] self.tags [] self.word2idx word2idx self.tag2idx tag2idx sent, tag [], [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: # 空行表示句子结束 if sent: # 将字和标签转换为ID sent_ids [word2idx.get(w, word2idx[UNK]) for w in sent] tag_ids [tag2idx[t] for t in tag] self.sentences.append(sent_ids) self.tags.append(tag_ids) sent, tag [], [] else: parts line.split() if len(parts) 2: w, t parts sent.append(w) tag.append(t) # 处理最后一个句子如果文件末尾没有空行 if sent: sent_ids [word2idx.get(w, word2idx[UNK]) for w in sent] tag_ids [tag2idx[t] for t in tag] self.sentences.append(sent_ids) self.tags.append(tag_ids) def __len__(self): return len(self.sentences) def __getitem__(self, idx): return torch.tensor(self.sentences[idx]), torch.tensor(self.tags[idx])由于句子长度不一我们需要在组成batch时进行填充。DataLoader配合自定义的collate_fn函数可以优雅地处理。def collate_fn(batch): # batch是一个列表每个元素是(sentence_tensor, tags_tensor) sentences, tags zip(*batch) # 获取本batch中句子的实际长度 lengths torch.tensor([len(s) for s in sentences]) # 填充句子和标签 sentences_padded torch.nn.utils.rnn.pad_sequence(sentences, batch_firstTrue, padding_valueword2idx[PAD]) tags_padded torch.nn.utils.rnn.pad_sequence(tags, batch_firstTrue, padding_valuetag2idx[PAD]) return sentences_padded, tags_padded, lengths # 创建DataLoader train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, collate_fncollate_fn)4.2 模型定义搭建BiLSTM-CRF网络现在我们来定义核心模型。我们将它分为几个清晰的子模块。import torch.nn as nn class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim): super(BiLSTM_CRF, self).__init__() self.embedding_dim embedding_dim self.hidden_dim hidden_dim self.vocab_size vocab_size self.tag_to_ix tag_to_ix self.tagset_size len(tag_to_ix) # 1. 词嵌入层 self.word_embeds nn.Embedding(vocab_size, embedding_dim, padding_idxword2idx[PAD]) # 2. BiLSTM层 self.lstm nn.LSTM(embedding_dim, hidden_dim // 2, num_layers1, bidirectionalTrue, batch_firstTrue) # 3. 将BiLSTM输出映射到标签空间发射分数 # BiLSTM是双向的所以输出维度是hidden_dim self.hidden2tag nn.Linear(hidden_dim, self.tagset_size) # 4. CRF层参数转移分数矩阵 # 矩阵的维度是 (tagset_size, tagset_size) # 转移矩阵的 i, j 项表示从标签j转移到标签i的分数注意这里行列的定义不同实现可能相反 self.transitions nn.Parameter(torch.randn(self.tagset_size, self.tagset_size)) # 添加约束不可能从任何标签转移到开始填充符PAD也不可能从结束填充符PAD转移到任何标签。 # 同时强制开始标签我们设为START和结束标签STOP的转移分数非常低负无穷因为我们不在序列中使用它们。 # 这里简化处理我们通常会在tag_to_ix中加入START和STOP标签并约束其转移。 # 为简化我们先不加但需要明白在实际完整实现中需要处理。 self.transitions.data[tag_to_ix[PAD], :] -10000 # 从PAD转移出去分数极低 self.transitions.data[:, tag_to_ix[PAD]] -10000 # 转移到PAD分数极低 def _get_lstm_features(self, sentence_batch, lengths): 获取发射分数 # sentence_batch: (batch_size, max_seq_len) embeds self.word_embeds(sentence_batch) # (batch_size, max_seq_len, embedding_dim) # 打包序列避免LSTM对填充部分进行计算 packed_embeds nn.utils.rnn.pack_padded_sequence(embeds, lengths.cpu(), batch_firstTrue, enforce_sortedFalse) lstm_out, _ self.lstm(packed_embeds) # 解包 lstm_out, _ nn.utils.rnn.pad_packed_sequence(lstm_out, batch_firstTrue) # 将LSTM输出映射到标签空间 lstm_feats self.hidden2tag(lstm_out) # (batch_size, max_seq_len, tagset_size) return lstm_feats def _score_sentence(self, feats, tags, lengths): 计算给定标签序列的分数即S(X, y) batch_size feats.size(0) score torch.zeros(batch_size).to(feats.device) # 为每个样本单独计算 for idx in range(batch_size): feat feats[idx, :lengths[idx]] # (real_seq_len, tagset_size) tag tags[idx, :lengths[idx]] # (real_seq_len) # 累加发射分数 score[idx] torch.sum(feat[range(len(tag)), tag]) # 累加转移分数 # 将标签序列扩展在开头加一个“开始”在结尾加一个“结束”这里用0和1举例实际应用特殊标签 # 简化处理我们假设转移发生在相邻标签间 if len(tag) 1: # 计算从tag[i]转移到tag[i1]的分数 for i in range(len(tag)-1): score[idx] self.transitions[tag[i1], tag[i]] # 注意索引顺序 # 加上从“开始”到第一个标签以及从最后一个标签到“结束”的转移此处简化未实现 # score[idx] self.transitions[tag[0], START_TAG_ID] # score[idx] self.transitions[STOP_TAG_ID, tag[-1]] return score def _forward_alg(self, feats, lengths): 用前向算法计算log(Z(X))支持batch batch_size, max_seq_len, tagset_size feats.size() # 初始化alpha shape: (batch_size, tagset_size) # 对于每个样本在位置0alpha是“开始”标签转移到各个标签的分数 位置0的发射分数 # 简化我们假设“开始”标签的索引是0且其转移到所有标签的初始分数为0 init_alphas torch.full((batch_size, tagset_size), -10000.).to(feats.device) # START_TAG_ID所有位置分数为0 # init_alphas[:, START_TAG_ID] 0. # 简化处理我们假设所有标签都可以作为序列开始且初始分数为feats[:, 0, :] # 更标准的做法是引入一个额外的开始标签。这里我们做一个简化版本仅计算不考虑开始/结束标签的配分函数。 # 这是一个重要的简化会影响效果。完整实现请参考成熟的库如torchcrf。 alpha feats[:, 0, :] # (batch_size, tagset_size) 直接用第一个位置的发射分数作为初始值 # 迭代计算 for t in range(1, max_seq_len): # 对于batch中每个样本我们只计算到其实际长度 # 创建一个mask标记哪些样本在位置t还有效 mask (lengths t).view(-1, 1).expand_as(alpha) # (batch_size, tagset_size) alpha_prev alpha # 计算当前步的alpha: alpha_t(j) log( sum_i exp(alpha_{t-1}(i) T_{j, i}) ) feat_t(j) # 为了数值稳定使用log-sum-exp技巧 # 我们计算 alpha_prev.unsqueeze(2) self.transitions.t().unsqueeze(0) 然后log-sum-exp over dim1 # 这里简化计算展示思路。实际batch计算较复杂。 # 强烈建议使用现成的CRF层如 torchcrf.CRF pass # 由于完整的前向算法实现较为复杂且冗长此处省略细节。 # 在实际项目中强烈推荐使用 pip install pytorch-crf然后 from torchcrf import CRF # 我们的示例将转向使用这个成熟库来简化代码并保证正确性。 return None def neg_log_likelihood(self, sentence_batch, tags_batch, lengths): 计算负对数似然损失 feats self._get_lstm_features(sentence_batch, lengths) # 发射分数 # 使用简化版分数计算未实现完整前向算法 gold_score self._score_sentence(feats, tags_batch, lengths) # 由于前向算法未实现此处无法计算总分数Z(X) # forward_score self._forward_alg(feats, lengths) # return forward_score - gold_score # 作为临时替代我们返回一个需要计算的损失占位符。实际训练必须实现_forward_alg。 print(警告前向算法未完整实现损失计算不准确。) return torch.tensor(0.0, requires_gradTrue) def forward(self, sentence_batch, lengths): 解码预测阶段使用维特比算法找到最优路径 feats self._get_lstm_features(sentence_batch, lengths) # 同样维特比算法在batch上的实现也较复杂。 # 我们将展示使用 torchcrf.CRF 后的简洁版本。 pass上面的代码展示了模型的基本骨架但CRF的核心计算前向算法和维特比算法在batch上的高效实现非常复杂。为了实战的可行性和正确性强烈建议使用成熟的第三方库pytorch-crf。4.3 使用pytorch-crf库简化实现安装pip install pytorch-crf使用后模型定义和训练将变得非常清晰import torch import torch.nn as nn from torchcrf import CRF class BiLSTM_CRF_Easy(nn.Module): def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim): super(BiLSTM_CRF_Easy, self).__init__() self.embedding_dim embedding_dim self.hidden_dim hidden_dim self.vocab_size vocab_size self.tag_to_ix tag_to_ix self.tagset_size len(tag_to_ix) self.word_embeds nn.Embedding(vocab_size, embedding_dim, padding_idxword2idx[PAD]) self.lstm nn.LSTM(embedding_dim, hidden_dim // 2, num_layers1, bidirectionalTrue, batch_firstTrue) self.hidden2tag nn.Linear(hidden_dim, self.tagset_size) # 使用CRF层 self.crf CRF(self.tagset_size, batch_firstTrue) def forward(self, sentence_batch, tags_batch, lengths, is_trainTrue): 训练和预测的统一接口 embeds self.word_embeds(sentence_batch) packed_embeds nn.utils.rnn.pack_padded_sequence(embeds, lengths.cpu(), batch_firstTrue, enforce_sortedFalse) lstm_out, _ self.lstm(packed_embeds) lstm_out, _ nn.utils.rnn.pad_packed_sequence(lstm_out, batch_firstTrue) emissions self.hidden2tag(lstm_out) # (batch_size, seq_len, tagset_size) if is_train: # 训练模式计算负对数似然损失 # CRF层需要mask来忽略填充部分 mask torch.arange(sentence_batch.size(1)).expand(len(lengths), sentence_batch.size(1)).to(lengths.device) lengths.unsqueeze(1) loss -self.crf(emissions, tags_batch, maskmask, reductionmean) return loss else: # 预测模式使用维特比解码 mask torch.arange(sentence_batch.size(1)).expand(len(lengths), sentence_batch.size(1)).to(lengths.device) lengths.unsqueeze(1) best_paths self.crf.decode(emissions, maskmask) return best_paths # 返回一个列表每个元素是该样本的最佳标签序列ID列表4.4 训练循环与模型评估有了模型和数据就可以开始训练了。import torch.optim as optim from seqeval.metrics import classification_report, f1_score # 用于序列标注评估 # 初始化模型、优化器 model BiLSTM_CRF_Easy(vocab_sizelen(word2idx), tag_to_ixtag2idx, embedding_dim100, hidden_dim256).to(device) optimizer optim.Adam(model.parameters(), lr0.001) num_epochs 20 for epoch in range(num_epochs): model.train() total_loss 0 for batch_sentences, batch_tags, batch_lengths in train_loader: batch_sentences, batch_tags batch_sentences.to(device), batch_tags.to(device) # 前向传播计算损失 loss model(batch_sentences, batch_tags, batch_lengths, is_trainTrue) # 反向传播 optimizer.zero_grad() loss.backward() # 梯度裁剪防止梯度爆炸对RNN/CRF很重要 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) print(fEpoch {epoch1}, Loss: {avg_loss:.4f}) # 每隔几轮在验证集上评估 if (epoch 1) % 5 0: model.eval() all_preds [] all_labels [] with torch.no_grad(): for batch_sentences, batch_tags, batch_lengths in val_loader: # 假设有val_loader batch_sentences batch_sentences.to(device) preds model(batch_sentences, None, batch_lengths, is_trainFalse) # 将预测的ID序列和真实标签ID序列转换为标签字符串并收集 # 注意需要根据实际长度mask掉填充部分 for i, length in enumerate(batch_lengths): pred_tag_ids preds[i] true_tag_ids batch_tags[i][:length].cpu().numpy().tolist() # 将ID转回标签字符串 idx2tag {v:k for k,v in tag2idx.items()} pred_tags [idx2tag[idx] for idx in pred_tag_ids] true_tags [idx2tag[idx] for idx in true_tag_ids] all_preds.append(pred_tags) all_labels.append(true_tags) # 使用seqeval评估它支持实体级别的评估 print(classification_report(all_labels, all_preds, digits4))评估时不能使用简单的准确率因为标签O占了大多数。我们使用序列标注领域标准的评估指标精确率Precision、召回率Recall和F1值F1-score并且是在实体级别而非标签级别进行计算。seqeval库正是为此而生。5. 实战中的调优策略与常见陷阱模型跑起来只是第一步要想获得好效果调优和避坑至关重要。以下是一些从实战中总结的经验。5.1 词向量的选择与处理词嵌入是模型的第一层其质量影响巨大。静态预训练词向量如Word2Vec、GloVe。直接加载在训练过程中可以选择冻结不更新或微调。对于小规模数据集冻结可以防止过拟合对于大数据集微调可能更好。动态上下文词向量如BERT、ELMo。它们能根据上下文生成不同的向量表示对歧义消除效果极佳。可以将BERT的输出作为BiLSTM的输入或者直接接一个CRF层BERT-CRF模型。这是目前的主流方法能大幅提升性能但计算成本也更高。字符级特征对于英文或存在未登录词OOV问题严重的场景可以在词向量基础上对每个词内的字符运行一个小的CNN或LSTM得到字符级表征再与词向量拼接。这能有效捕捉前缀、后缀等形态学信息。提示中文NER中基于字的模型配合预训练字向量如中文Word2Vec或BERT是常见且有效的选择可以避免分词错误。5.2 网络结构超参数调优BiLSTM层数与隐藏层维度通常1-2层双向LSTM足够。隐藏层维度是一个重要参数太小则特征学习不充分太大会导致过拟合和计算量增加。可以从128或256开始尝试。双向LSTM的最终输出维度是hidden_dim * 2。Dropout在BiLSTM层前后添加Dropout是防止过拟合的有效手段。可以在词嵌入后、LSTM输入前加Dropout也可以在LSTM层之间如果是多层加Dropout。学习率与优化器Adam优化器是默认的好选择。学习率可以从1e-3或3e-4开始。使用学习率调度器如ReduceLROnPlateau在验证集指标停滞时降低学习率有助于模型收敛到更优点。批次大小Batch Size较小的批次大小如16, 32有时能带来更好的泛化性能但训练更慢。需要根据GPU内存权衡。5.3 CRF层的特殊处理与标签不平衡转移矩阵初始化CRF的转移矩阵不能完全随机初始化。可以给“不可能转移”赋一个很大的负值如-10000例如从I-PER到B-LOC。给“常见转移”赋一个稍高的初始值例如从B-PER到I-PER。这相当于给模型一个先验知识加速收敛。标签不平衡O标签通常占绝大多数。这可能导致模型倾向于将所有词都预测为O。缓解方法在损失函数中为不同标签赋予不同的权重nn.CrossEntropyLoss的weight参数给少数类标签如B-PER,I-PER更高的权重。使用Focal Loss等专注于难例的损失函数。在评估时我们关注的是实体级别的F1而不是标签准确率所以一定程度的不平衡是可以接受的。5.4 解码与后处理中的坑维特比解码的约束标准的CRF解码允许任何转移。但在NER中有些转移是非法的例如O - I-PER非实体内部不能直接是实体内部B-LOC - I-PER实体类型不能突变I-PER - I-LOC同上 我们可以在维特比解码的每一步将非法转移对应的分数设为负无穷强制模型不选择这些路径。pytorch-crf库支持通过constraints参数来添加这些约束。标签序列的合法性即使有CRF有时也可能产生不合法的序列如[B-PER, O, I-PER]。一个简单的后处理规则是遍历预测序列如果遇到I-XXX但前面不是B-XXX或I-XXX则将其强制改为B-XXX如果认为它是一个实体开始或O如果认为它是错误预测。更鲁棒的做法是在CRF层就加入约束。实体边界的微调模型可能将“北京市朝阳区”识别为三个实体[B-LOC, I-LOC, I-LOC]但有时我们希望能合并成一个实体。这需要根据具体任务需求在后续处理阶段进行实体合并。5.5 模型集成与领域适配模型集成训练多个不同随机种子或不同超参数的BiLSTM-CRF模型对它们的预测结果进行投票或平均概率通常能提升1-2个百分点的F1值。领域适配在通用语料如新闻上训练的NER模型在特定领域如医疗、金融上性能会急剧下降。解决方案是进行领域微调使用目标领域的小规模标注数据在预训练好的模型上继续训练。如果数据极少可以冻结BiLSTM层只微调CRF层和顶部的全连接层。我在实际项目中踩过的一个坑是忽略了对齐问题。当使用BERT等预训练模型时其分词器WordPiece会将一个词拆分成多个子词subword例如“playing” -[play, ##ing]。这就产生了序列长度不对齐的问题输入BERT的是子词序列而我们的标签是针对原始词语序列的。常见的解决方案是只取每个词第一个子词对应的BERT输出向量作为该词的表征或者将所有子词向量的平均值/最大值作为词向量。处理不当会导致标签和特征完全错位模型无法学习。使用Hugging Face的transformers库时可以利用tokenizer的return_offsets_mapping功能来精确对齐。BiLSTM-CRF作为一个经典的序列标注模型其设计思想——用神经网络提取特征用概率图模型建模标签依赖——影响深远。尽管如今Transformer如BERT已取代BiLSTM成为特征提取的更强基线但“特征提取器CRF”的架构模式依然流行即BERT-CRF。理解BiLSTM-CRF不仅是为了复现一个模型更是为了掌握序列标注任务的一套完整方法论从问题定义序列标注、特征表示词向量、上下文编码、结构化预测CRF到训练解码的整个流程。当你透彻理解了这一切再去学习更先进的模型就会知其然更知其所以然。本文还有配套的精品资源点击获取
返回列表