ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BERT+BiLSTM+CRF中文命名实体识别实战:从数据对齐到评估优化

BERT+BiLSTM+CRF中文命名实体识别实战:从数据对齐到评估优化 简介基于BERTBiLSTMCRF的中文命名实体识别完整项目资源面向人工智能、通信工程、自动化、电子信息等计算机相关专业的毕设、课程设计与实践学习。项目源码已获导师认可答辩评分达95分代码经测试运行无误可直接用于演示也可在此基座上二次开发。资源包共19个文件以Python源码为主9个py涵盖模型定义、循环单元、数据预处理、训练验证脚本及官方评测工具另含Markdown文档用于使用说明3张结构或演示图片辅助理解以及配置文件和授权文本压缩包仅132KB轻量易部署。已有111人学习浏览适合NLP入门到进阶的读者。压缩包内目录结构清晰从数据读取、模型构建到训练评估均有完整实现可直观理解BERT编码、BiLSTM上下文捕获与CRF序列标注的协作机制也可作为高起点改写的基座支持毕业设计、课程作业或初期项目演示等场景。1. 基于BERTBiLSTMCRF的中文命名实体识别项目链路比单模型重要中文命名实体识别NER项目里最常见的翻车点不是损失函数写错而是BERT、BiLSTM、CRF三者之间没有对齐。BERT负责输出字级别的上下文表征BiLSTM在序列方向再做一次双向抽取CRF把相邻标签的合法关系叠加进解码过程。任何一层衔接出错验证集F1就会贴着70%反复横跳。这类项目的资料包通常覆盖数据预处理、模型代码、训练日志和评测脚本但真正决定能否拿到高分的是看token对齐、CRF的条件概率实现和解码细节是否经得起换数据集的考验。这个标题里的任务适合已经会写PyTorch、正准备把中文NER从“脚本能跑”推向“指标能用”的工程师。下文按做这个题目最通用的一套工程方案展开先处理标注数据和BERT输入再手写CRF层然后过一遍训练参数和典型错误最后落到实体级F1评估和推理加速。2. 中文命名实体识别的数据准备BIOES标签词典与BERT输入对齐2.1 字符级标注为什么比词级标注更适合中文NER做中文命名实体识别时我一般不会先做分词。原因有两个一是分词器本身的错误会直接扩散到实体边界而且分词词典与BERT字表不一致时会引入对齐逻辑的复杂度二是NER本质是字符级序列标注在字符一级就能完整表达实体边界分词反而成了多余步骤。标注体系上常见有BIO和BIOES两种。BIO只区分Begin、Inside、Outside实体长度为1时只有B没有I边界信息弱一些。BIOES增加了E和SS-XXX表示单个字符实体E-XXX表示实体结尾。以“我在北京大学上班”为例字符我在北京大学上班。BIO标签OOB-ORGI-ORGI-ORGI-ORGOOOBIOES标签OOB-ORGI-ORGI-ORGE-ORGOOOBIOES相对BIO的核心收益在于CRF的转移约束更强。CRF可以从数据里学到“I-ORG后面要接I-ORG或E-ORG”“E-ORG后面不能再接I-ORG”这类规则BIO标签体系把实体结束边界模糊掉了这种约束就学不出来。工程上我默认选BIOES除非下游任务明确要求BIO格式。中文BERT预训练模型按字切分绝大多数汉字各成一个token所以字符级标签能直接对应到token级。但遇到英文混写、数字连续串或特殊符号时分词器可能会把它们合并成一个token这时就必须用分词器返回的word_ids做对齐而不是简单按字符下标硬切。2.2 BERT分词器与标签对齐构造input_ids、attention_mask、tag_ids数据预处理的核心函数是把原始文本和字符级标签转成BERT可用的三个张量。使用Hugging Face Transformers库时常见做法是把文本拆成字符列表传入tokenizer再通过word_ids做标签映射def build_bert_input(text, label_list, tokenizer, label2id, max_len128): 把文本和字符级标签对齐到BERT token级。 text: 字符串例如 我在北京大学上班 label_list: 与text逐字符对齐的标签例如 [O,O,B-ORG,...] enc tokenizer( list(text), # 按字符传入便于逐字对齐 is_split_into_wordsTrue, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt, ) tag_ids [] for wid in enc.word_ids(): if wid is None: # [CLS]、[SEP]以及padding位置标签用0占位 tag_ids.append(0) else: # wid是原文本中的字符下标直接取该字符的标签 tag_ids.append(label2id[label_list[wid]]) return ( enc[input_ids], # [1, max_len] enc[attention_mask], # [1, max_len] torch.tensor(tag_ids).unsqueeze(0), # [1, max_len] )这里的关键是把is_split_into_wordsTrue打开tokenizer才不会把中文句子当成一整个字符串做额外合并。word_ids()返回每个token在原始字符序列中的下标None表示特殊token。padding位置也被标记为None所以会自动落到0号标签。常见的错误有三个一是用BIO标注却把E丢掉模型学不会实体收尾二是对齐时直接label_list[i]取标签遇到合并token就错位三是在padding位置补了标签却没同步更新attention_maskCRF计算时会把这些无效位置也算进序列得分。在进入模型之前建议打印一批样本人工检查对齐结果原始字符: 我 在 北 京 大 学 上 班 字符标签: O O B-ORG I-ORG I-ORG E-ORG O O bert tokens: [CLS] 我 在 北 京 大 学 上 班 [SEP] token标签: 0 O O B-ORG I-ORG I-ORG E-ORG O O 0这个检查步骤看起来不起眼却能省下后面排错的大半时间。2.3 CRF的约束到底约束了什么如果序列每个位置都用softmax独立分类模型很容易输出“B-ORG后面直接跟E-PER”“E-LOC后面跟I-ORG”这类自相矛盾的标签串。CRF处理的是整条标签序列的条件概率在模型中保存一个num_labels × num_labels的转移矩阵训练目标会把矛盾转移压下去把合法结构抬上来。中文NER语料里LOC和PER相邻出现的频率不低比如“北京张三”独立分类器会把“京”预测成实体头部而CRF会利用整个序列的全局得分把它纠正回来。CRF和HMM的区别在于HMM的转移概率是语言学统计出来的固定值CRF的转移矩阵是随整个模型梯度更新学出来的不需要手工指定。这意味着引入BERT和BiLSTM之后CRF学到的是“在当前上下文特征下的标签兼容性”比静态规则灵活得多。实务中我从不手工向转移矩阵写死规则让模型自己从BIOES数据里学效果更稳。3. 用PyTorch搭建BERTBiLSTMCRF模型组件、CRF损失与维特比解码3.1 组装模型BERT编码、BiLSTM双向抽取、线性发射层模型结构可以按照“BERT分词器输出字符向量BiLSTM在序列维度二次抽取上下文线性层产生发射分数CRF做结构化解码”这条链路实现。代码里把CRF作为模型内部模块挂在后面前向时直接算损失或返回解码路径import torch import torch.nn as nn from transformers import BertModel class BertBiLstmCrf(nn.Module): def __init__(self, bert_name, num_labels, lstm_hidden256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_name) self.dropout nn.Dropout(dropout) # 输入维度是BERT最后一层的hidden size中文BERT是768 self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue, ) # 双向LSTM输出维度是 hidden_size * 2 self.fc nn.Linear(lstm_hidden * 2, num_labels) self.crf CRF(num_labels) def forward(self, input_ids, attention_mask, tag_idsNone): # BERT输出 [batch, seq_len, 768] seq_out self.bert( input_idsinput_ids, attention_maskattention_mask, ).last_hidden_state seq_out self.dropout(seq_out) # BiLSTM输出 [batch, seq_len, hidden_size * 2] lstm_out, _ self.bilstm(seq_out) emit self.fc(self.dropout(lstm_out)) # 发射分数 if tag_ids is not None: # 返回负对数似然作为loss return -self.crf.log_likelihood(emit, tag_ids, attention_mask) # 推理时走维特比解码 return self.crf.viterbi_decode(emit, attention_mask)这里BiLSTM的输入是BERT的768维输出768到256本身也是降维过程可以滤掉一部分冗余特征。batch_firstTrue让LSTM输入输出都是[batch, seq_len, hidden]和BERT输出形状一致省去转置。num_layers1是中文NER的常见选择两层LSTM在数据量不足时容易过拟合而且训练时间接近翻倍收益通常不明显。线性层的输出是发射分数形状为[batch, seq_len, num_labels]它只表示每个token独立属于某个标签的得分还没考虑标签之间的转移关系下一步交给CRF。3.2 CRF手写发射分数、转移矩阵和前向算法CRF的核心是两类分数发射分数来自BiLSTM输出转移分数是这个层自己要学的参数。对一条真实标签序列它的得分是逐位置的发射分数、相邻标签转移分数、句首句尾边界分数之和。训练目标是最大化真实路径得分、最小化所有路径得分的对数归一化所以在损失上取负对数似然。手写CRF时前向算法按时间步做动态规划代码里循环序列长度因此理解起来比torchcrf这类库的封装版本直观很多class CRF(nn.Module): def __init__(self, num_labels): super().__init__() self.num_labels num_labels self.start nn.Parameter(torch.zeros(num_labels)) self.end nn.Parameter(torch.zeros(num_labels)) self.trans nn.Parameter(torch.randn(num_labels, num_labels) * 0.1) def score(self, emit, tags, mask): 计算真实标签路径的得分。 batch_size, seq_len, _ emit.shape idx torch.arange(batch_size) # 句首得分 第一个token的发射得分 scores self.start[tags[:, 0]] emit[idx, 0, tags[:, 0]] for t in range(1, seq_len): prev tags[:, t - 1] cur tags[:, t] step self.trans[prev, cur] emit[idx, t, cur] valid mask[:, t].bool() zero torch.zeros_like(step) scores scores torch.where(valid, step, zero) # 句尾得分取每句最后一个有效位置 last mask.sum(dim1) - 1 scores scores self.end[tags[idx, last]] return scores def forward_score(self, emit, mask): 前向算法计算所有标签路径的logsumexp得分。 batch_size, seq_len, num_tags emit.shape # 初始alpha 句首得分 第一个位置发射得分 alphas self.start emit[:, 0] # [B, T] for t in range(1, seq_len): emit_t emit[:, t].unsqueeze(1) # [B, 1, T] scores ( alphas.unsqueeze(2) # 上一个位置的alpha self.trans # 标签间转移 emit_t # 当前发射 ) # [B, T_prev, T_cur] new_alphas torch.logsumexp(scores, dim1) # 沿prev标签归约 valid mask[:, t].bool().unsqueeze(1) alphas torch.where(valid, new_alphas, alphas) finals alphas self.end.unsqueeze(0) return torch.logsumexp(finals, dim-1) def log_likelihood(self, emit, tags, mask): 真实路径得分 - 所有路径logsumexp得分。 return self.score(emit, tags, mask) - self.forward_score(emit, mask) def viterbi_decode(self, emit, mask): 维特比解码返回每个样本的最优标签序列。 batch_size, seq_len, _ emit.shape dp self.start emit[:, 0] # [B, T] backpointers [] for t in range(1, seq_len): scores ( dp.unsqueeze(2) self.trans emit[:, t].unsqueeze(1) ) # [B, T_prev, T_cur] best_scores, best_tags scores.max(dim1) valid mask[:, t].bool().unsqueeze(1) dp torch.where(valid, best_scores, dp) backpointers.append(best_tags) dp dp self.end.unsqueeze(0) end_tags dp.argmax(dim-1) paths [] for i in range(batch_size): last int(end_tags[i].item()) path [last] for t in range(seq_len - 1, 0, -1): # 回溯只在有效时间步进行 if int(mask[i, t]) and int(mask[i, t - 1]): last int(backpointers[t - 1][i][last].item()) path.append(last) else: path.append(0) path.reverse() valid_len int(mask[i].sum().item()) # 去掉 [CLS] 和 [SEP] 对应的输出 paths.append(path[1: valid_len - 1]) return paths代码里的score和forward_score最需要理解score把真实标签逐位置加起来forward_score用动态规划把所有可能标签路径的得分做logsumexp这个值就是分母。两者相减得到单条序列的log-likelihood。维特比解码时保存每个时间步的最佳前驱标签解码结束后从句子末尾回溯得到全局最优路径。转移矩阵初始化用0.1系数缩小随机值避免初始阶段转移分数过大淹没发射分数。mask参数控制有效长度padding位置不参与计算这一点在训练和推理中始终一致。3.3 训练循环从损失回传、梯度裁剪到warmup调度训练循环里除了常规的forward、backward、step之外还需要两个BERT微调特有的操作线性warmup调度和梯度裁剪。训练循环可以写成下面这样from transformers import get_linear_schedule_with_warmup device torch.device(cuda if torch.cuda.is_available() else cpu) model BertBiLstmCrf(bert-base-chinese, num_labelslen(label2id)).to(device) optimizer torch.optim.AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps, ) for epoch in range(epochs): model.train() total_loss 0.0 for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) tag_ids batch[tag_ids].to(device) loss model(input_ids, attention_mask, tag_ids) loss.backward() # 梯度裁剪防止LSTM部分梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() print(fepoch {epoch 1} | avg loss {total_loss / len(train_loader):.4f})clip_grad_norm_的max_norm5.0是中文NER里比较稳的经验值。BiLSTM在长序列上反向传播会出现梯度模值突刺不裁剪的话训练前期loss容易跳高。warmup比例0.1意味着前10%的step学习率从0线性升到设定值防止BERT预训练权重在早期被大梯度扰动。4. 调参、冻结与诊断让BERTBiLSTMCRF不再预测整行O4.1 BERT微调常用参数学习率、batch size与warmup怎么配中文NER的数据量通常在几千到几万条句子BERT全量微调的学习率区间和图像分类完全不同。我常用的起点如下参数建议值说明BERT部分学习率2e-5数据量不足一万条时降到1e-5BiLSTM/CRF学习率1e-4高于BERT部分让新层更快收敛batch size16句子平均长度短显存压力小warmup ratio0.1训练step少时可提高到0.15BiLSTM hidden256数据量少用128简单数据集256足够dropout0.3放在BERT输出和LSTM输出之后梯度裁剪阈值5.0训练不稳定时降到1.0如果BERT和BiLSTM用同一个学习率新初始化的LSTM层梯度模值通常比预训练层大会把BERT部分带偏。常见做法是把参数按模块名分组给两组不同的学习率bert_params [] other_params [] for name, param in model.named_parameters(): if not param.requires_grad: continue if name.startswith(bert.): bert_params.append(param) else: other_params.append(param) optimizer torch.optim.AdamW([ {params: bert_params, lr: 2e-5}, {params: other_params, lr: 1e-4}, ])这种分组方式比统一学习率好调。若出现loss不降先单独调BiLSTM/CRF组的学习率通常问题出在新层而不是BERT。4.2 梯度裁剪、BERT层冻结与BiLSTM层数的经验值训练初期可以让BERT先冻结只训BiLSTM和CRF这个阶段用来确认数据管道没有问题。几个epoch后再解冻BERT最后两层最后再全量微调。冻结的好处是显存占用更低、训练时间短同时避免预训练表征被小规模数据冲掉。逐步解冻的节奏可以按epoch划分epoch 0-1 冻结BERT全部参数只训练BiLSTMCRF epoch 2-3 解冻BERT最后两层按2e-5微调 epoch 4 全量解冻学习率降到1e-5收尾BiLSTM层数方面我基本只用一层。两层双向LSTM在中文NER上的收益有限训练耗时却接近翻倍而且更容易在验证集上抖动。如果实体类型很多、句子结构复杂可以先试一层256维效果不够再加到第二层128维而不是一开始就堆层数。4.3 预测全O或全是B-ORG定位三步法模型训练完预测结果几乎全是O是所有NER项目都会撞见的坑。按下面三步定位。第一步先排除对齐错误。打印一条验证集样本的input_ids、tag_ids和attention_mask确认特殊token和padding位置是否被算进损失。如果对齐时把padding位置的标签写成O而不是0CRF会在padding上累计无意义的转移分数。第二步检查CRF转移矩阵的打印值。训练结束后把model.crf.trans.data打印出来观察对角线和非对角线数值分布。如果某个标签对应的行值整体偏高模型就会倾向于输出该标签。常见情况是O标签占了语料90%以上转移矩阵学到“从O到O”成本极低导致所有预测都塌缩到O。第三步看损失值是否还在下降。如果loss已经很低但预测全是O说明模型找到的“安全解”是全部预测为O因为O占比太高即使全猜O也能拿到很低的负对数似然。这时候可以按实体类型做欠采样或者把O标签在损失里的权重适当降低让实体标签的梯度贡献更大。5. 实体级F1评估与推理加速中文NER项目收尾的两个硬指标5.1 按实体而不是按字符计算P、R、F1字符级准确率在NER任务里没有意义因为“北京大学”四个字只要预测错一个边界整个实体就算错。项目验收要按实体级别评估即预测实体的起点、终点、类型三者和标注完全一致才算正确。先写一个从标签序列里抽取实体的函数def extract_entities(tag_ids, id2label, o_id0): 从BIOES标签序列中抽取实体列表每个实体是 [start, end, type]。 entities [] cur None for i, tid in enumerate(tag_ids): label id2label[tid] if label.startswith(B-): if cur: entities.append(cur) cur [i, i, label[2:]] elif label.startswith(I-) and cur and cur[2] label[2:]: cur[1] i elif label.startswith(E-) and cur and cur[2] label[2:]: cur[1] i entities.append(cur) cur None elif label.startswith(S-): if cur: entities.append(cur) entities.append([i, i, label[2:]]) cur None elif label O: if cur: entities.append(cur) cur None if cur: entities.append(cur) return entities得到gold实体列表和pred实体列表后用集合求交算正确数再分别除以预测数和真实数得到P和RF1是两者的调和平均。这个计算方式要和论文里的常见做法一致完全匹配才算对部分匹配不记分。seqeval这类库也能做同样的事但手写抽取逻辑能让你在调试时清楚看到边界错在哪个字符上。5.2 推理阶段能做的提速和模型瘦身取舍中文NER推理时CRF的转移矩阵是数值量很小的稠密矩阵前向算法循环在序列长度上展开这部分计算量在GPU上并不大真正的耗时瓶颈是BERT的token编码。常用的提速手段有三个动态batch按长度排序减少padding浪费推理时关闭dropout和梯度计算以及观察是否真的需要BiLSTM这一层。如果项目场景是短文本识别实体类型不超过10种可以尝试把BiLSTM拿掉让BERT输出直接过一个线性层接CRF。这个配置在短句上速度提升明显指标下降常在0.5到1个百分点以内。它和textcnn加BERT做意图识别是同一类思路意图识别是句子级分类最后接池化和分类头就够了而NER这类token级序列标注即使有BERT也需要一个能约束标签依赖关系的解码层这就是CRF始终保留的原因。动手验证时可以固定同一份验证集分别跑“BERTBiLSTMCRF”和“BERTCRF”两组对比F1和单条样本推理时间。如果F1差距小于1个点而速度提升明显生产环境就选瘦身后的结构。若追求极限性能再把lstm_hidden从256降到128跑一轮对比这个参数的影响会直接反映在显存占用和推理延迟上。本文还有配套的精品资源点击获取
返回列表