ARTICLE DETAIL

资讯详情

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

BERT+BiLSTM+CRF中文命名实体识别实战:从数据到调参全解析

BERT+BiLSTM+CRF中文命名实体识别实战:从数据到调参全解析 简介在自然语言处理中命名实体识别NER是一项经典的序列标注任务其目标是从非结构化文本中抽取人名、地名、机构名等关键实体。这一技术在信息抽取、问答系统和知识图谱构建中扮演着核心角色。面对中文文本的复杂性和实体边界模糊问题单纯依赖预训练语言模型往往难以保证标签序列的合法性。BERT通过强大的上下文语义表征能力理解文本BiLSTM捕捉序列的双向依赖关系而CRF条件随机场则利用标签转移矩阵和维特比解码确保输出标签路径全局最优。三种模型结构各司其职形成了一套稳健的NER解决方案。本文基于真实项目经验系统梳理了中文NER中的数据标注、BERT tokenizer对齐、模型搭建、训练调参及推理部署等关键环节并提供可复现代码帮助开发者快速构建高精度的中文实体识别系统。 很多人一上来就抱着bert-base-chinese去微调跑几个epoch看到准确率还行就交差了。但真到了中文命名实体识别NER这种序列标注任务上如果你对实体边界要求比较高或者数据里有大量嵌套、歧义的情况单靠BERT直接输出标签效果往往会让你失望。我去年在做司法文书里的人名、地名、机构名抽取时试了一圈组合最后还是回到了BERT BiLSTM CRF这个经典结构上。这套方案真正的优势不是某一层有多强而是三层结构各自解决了不同的问题最后拼在一起效果稳得让人放心。这篇东西不打算复述文档就把我当时从零搭这套系统的完整思路、代码细节、数据格式、调参过程和踩坑记录都整理出来。项目代码和一份可直接运行的示例数据我会一并打包说明配置方式你拿过去改改数据路径就能跑适合想快速上手又不想只停留在跑通demo层面的朋友。1. 项目整体设计与技术选型思路1.1 为什么偏偏是 BERT BiLSTM CRF 这个组合要理解这个组合为什么经典得先把每个组件各自干了什么拆开看。BERT这里用的是中文预训练模型负责的是上下文语义表征。它通过Transformer的双向编码结构把每个token的向量表示做得非常丰富能够捕捉到“苹果”在“苹果公司发布新品”和“削了一个苹果”这两种语境下的语义差异。在NER任务里这一个能力就已经解决了一大半问题——因为很多实体识别错误本质上就是语义理解错误比如“华为”到底是公司名还是别的什么词必须靠上下文判断。但BERT有一个天然的问题它没有序列标注的结构性约束。它预测每个token的标签时基本是独立的Softmax输出虽然效果好但无法保证标签序列的合法性。举个例子“B-PER”人名开始后面直接跟一个“B-LOC”地名开始这种输出从逻辑上讲是错乱的但纯BERT模型完全有可能产生这种结果。BiLSTM负责的是序列依赖建模。双向LSTM分别从正向和反向捕捉序列信息能够较好地学习到“标签之间的局部依赖关系”比如一个实体的内部标签流转趋势B-PER - I-PER - I-PER这种是合理的但B-PER - I-LOC就很不自然。不过BiLSTM的局限在于它能“感觉到”这种约束却没有办法把它变成硬性的规则。换句话说BiLSTM知道哪个路径更合理但它无法保证一定能走对。CRF条件随机场就是用来补上这最后一块拼图的。CRF的核心价值在于维护一个标签转移矩阵。比如从“B-PER”转移到“I-PER”的得分很高从“B-PER”转移到“B-LOC”的得分很低。这个转移矩阵是模型自己从数据里学出来的。在解码阶段CRF会用维特比算法全局搜索一条最优标签路径而不是对每个token独立取最大值。这个差别在长文本上尤其明显——它等于把整个序列的标签看作一个整体来解码而不是一个个孤立地分类。所以三个组件的关系可以理解成BERT负责“看懂文本”BiLSTM负责“梳理序列”CRF负责“确保输出合法”。这三位各管一段互相配合最终效果就是语义理解的准确率高序列建模的鲁棒性强标签输出的合法性有硬保障。1.2 数据标注体系选型BIO 还是 BIOES在NER任务里标签体系看起来是个小选择实际影响很大。入门项目里最常见的是BIO体系即每个token标注为B-实体类型实体开头、I-实体类型实体内部或O非实体。BIOES体系则在BIO基础上增加了E-实体类型实体结尾和S-实体类型单个字成实体。我自己的经验是如果实体长度普遍较短比如人名大多数是2-4个字BIOES会比BIO有明显优势。因为S标签能明确告诉模型“这是一个单独的实体”不需要模型去猜到底是B还是I。但BIOES也有缺点——标签数量变多了每个标签的样本分布会更稀疏尤其在数据量不大时训练难度会增加。如果你做的是中文NER还有一个中文特有的问题需要提前想清楚字符级还是词级标注。中文不像英文天然有空格分词所以大部分NER项目都采用字符级标注。一个汉字对应一个标签这样不需要额外的分词工具也避免了分词错误传播到NER阶段。但字符级标注的问题是模型失去了词边界的信息。BERT的tokenizer对中文是按字切分的所以字符级标注恰好能和BERT的输入对齐这也是BERT在中文NER上效果好的原因之一。我在这套代码里默认用了BIOES体系因为示例数据集里的实体大多比较短人名、地名、机构名BIOES的收敛速度和最终F1值通常更好。如果你自己的数据里有大段实体或者实体内部结构复杂可以切换到BIO只需要在构建标签映射表的时候改一下标签列表就行。1.3 技术栈和版本选择别在环境上浪费时间这套代码的运行环境我强烈建议按下面的配置来尤其是PyTorch和Transformers的版本不要随意升降级。版本不匹配导致的报错比模型本身的问题还难排查。我当前的验证环境是Python 3.10PyTorch 2.1.0Transformers 4.38.2HuggingFace的Tokenizers库会自动跟随Transformers安装。如果你的是PyTorch 2.x系列注意Transformers的BertModel.from_pretrained接口是稳定的不会受PyTorch版本太大影响。但PyTorch 2.0以后的torch.compile特性不要在这个项目里用torch.compile对CRF这种自定义层的支持不太稳定运行时容易报一些奇怪的问题。CUDA和GPU不是必须的。数据集规模不大时几万条句子CPU跑10个epoch也就半小时左右。但如果你的数据量达到十万级别还是建议用GPU。另外提一个很多人踩过的坑不要直接装最新的CUDA工具包而是要根据PyTorch版本来选择。PyTorch官方网站在安装命令中会对应好CUDA版本照抄那个命令就行。我的实测组合是CUDA 11.8 PyTorch 2.1.0 cuDNN 8.9运行非常稳定。2. 数据准备与预处理细节2.1 数据集的目录结构和标注格式代码里我采用了一个比较通用的数据组织方式把数据集分成train.txt、dev.txt和test.txt三个文件放在data目录下。每个文件里的格式都是每行一个“字符 标签”对字符和标签之间用空格或Tab分隔句子之间用一个空行隔开。汉 B-PER 族 I-PER O 著 O 名 O 作 O 家 O 鲁 B-PER 迅 I-PER 是 O 浙 B-LOC 江 I-LOC 省 I-LOC 人 O这种格式的好处是它跟HuggingFace的datasets库、很多开源NER项目的加载脚本都兼容后续如果要换框架或者做交叉验证不需要改数据格式。另外为了验证代码在实体类别上的泛化能力示例数据里我模拟了PER人名、LOC地名、ORG机构名三类实体。2.2 标签映射表与实体类别统计在训练之前需要把所有可能的标签收集起来构建一个label2id映射。这个映射的作用是把“B-PER”这种字符串标签转成数字ID方便模型计算损失。代码中用遍历数据集的方式来实现确保所有标签都被覆盖到不会写着写着遇到一个训练集没有的标签。def build_label2id(dataset_paths): labels set() for path in dataset_paths: with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if line: _, label line.split() labels.add(label) sorted_labels sorted(labels) label2id {label: idx for idx, label in enumerate(sorted_labels)} id2label {idx: label for label, idx in label2id.items()} return label2id, id2label这里有一个细节值得注意O标签一定要保留不要删掉。有些初学朋友觉得O标签数据太多想减少一下结果模型完全分不清什么是实体边界。O标签是CRF层理解“实体开始”的重要参照没有O的训练信号CRF的转移矩阵会学歪。输出的label2id可以打印出来看一下确保标签数量合理。比如BIOES体系下有PER/LOC/ORG三类实体标签总数是4×3 1 13个。如果你看到标签数明显不对不用急着跑训练先检查数据标注是否有错误。2.3 BERT Tokenizer 带来的对齐问题与解决方案这是整个项目里最容易出大坑、也是新手最常忽略的地方。BERT的Tokenizer在进行切分时不是一个字对应一个token那样简单。对于中文来说基本是字级切分[CLS]和[SEP]会额外占据两个token位置但真正的麻烦在于你对原句的字符序列做的标签在tokenizer处理之后长度可能不一致。更确切地说中文BERT的tokenizer大多是字级切分所以中文里字符和token基本是1对1的关系对齐问题不严重。但有两个必须处理的特殊情况[CLS]符号位在句首它的标签是什么答案是-100这个特殊的忽略标签。[SEP]符号位在句尾它的标签同样设为-100。-100是PyTorch的CrossEntropyLoss中默认的ignore_index值会在计算损失时自动跳过这些位置。因为模型不会为[CLS]和[SEP]预测任何语义实体标签它们只是辅助BERT理解序列结构的特殊token所以必须忽略它们的损失。def encode_with_labels(text, labels, tokenizer, label2id, max_len128): tokens [] token_labels [] for char, label in zip(text, labels): tokenized tokenizer.tokenize(char) tokens.extend(tokenized) if len(tokenized) 1: token_labels.append(label2id[label]) else: # 针对某些词表里被拆开的token让同一个token下的标签保持一致 token_labels.extend([label2id[label]] * len(tokenized)) # 截断到 max_len - 2预留 [CLS] 和 [SEP] 的位置 tokens tokens[: max_len - 2] token_labels token_labels[: max_len - 2] tokens [[CLS]] tokens [[SEP]] token_labels [-100] token_labels [-100] input_ids tokenizer.convert_tokens_to_ids(tokens) attention_mask [1] * len(input_ids) # padding 到 max_len padding_length max_len - len(input_ids) input_ids [tokenizer.pad_token_id] * padding_length attention_mask [0] * padding_length token_labels [-100] * padding_length return { input_ids: input_ids, attention_mask: attention_mask, labels: token_labels, }在这个函数里labels就是给模型训练的标签序列。训练时BERT的预测输出会和这个labels计算损失。由于[CLS]、[SEP]和[PAD]位置都是-100损失计算时会自动跳过模型实际上只会在真实的字符位置上学习。这个设计看似细节但没有它模型的输出会非常混乱——它会在[CLS]位置也要输出一个实体标签训练信号完全没有意义。2.4 数据加载器与动态Batch处理数据加载环节最推荐的做法是用DatasetDataLoader。由于每个样本已经统一到max_len的长度在DataLoader中不需要再做动态paddingbatch之间互相是等长的代码上会更省心。from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, data_path, tokenizer, label2id, max_len): self.samples [] with open(data_path, r, encodingutf-8) as f: text_chars [] labels [] for line in f: line line.strip() if not line: if text_chars: self.samples.append(encode_with_labels( text_chars, labels, tokenizer, label2id, max_len )) text_chars [] labels [] else: parts line.split() if len(parts) 2: char, label parts text_chars.append(char) labels.append(label) if text_chars: self.samples.append(encode_with_labels( text_chars, labels, tokenizer, label2id, max_len )) def __len__(self): return len(self.samples) def __getitem__(self, idx): sample self.samples[idx] return { input_ids: torch.tensor(sample[input_ids], dtypetorch.long), attention_mask: torch.tensor(sample[attention_mask], dtypetorch.long), labels: torch.tensor(sample[labels], dtypetorch.long), }关于max_len的选择我实测一般取128足够了。中文的一句话平均长度在20-60字之间128已经能覆盖绝大多数场景。如果你处理的文本很长比如法律条文、病历记录可以把这个值调到256或512但代价是显存占用成倍增加。我的建议是先统计一下你数据集的句子长度分布选择能覆盖95%样本的max_len不要盲目给最大值。3. 模型核心实现与原理拆解3.1 BERT 特征提取层初始化与输出处理模型定义的核心是把三个组件串起来。这块说白了就是搭积木但每块积木怎么搭配是有讲究的。import torch import torch.nn as nn from transformers import BertModel, BertConfig class BertBiLSTMC RF(nn.Module): def __init__(self, bert_pretrainedbert-base-chinese, num_labels13, lstm_hidden256): super().__init__() self.bert BertModel.from_pretrained(bert_pretrained) self.dropout nn.Dropout(0.1) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers2, batch_firstTrue, bidirectionalTrue, ) self.fc nn.Linear(lstm_hidden * 2, num_labels) self.crf CRF(num_labelsnum_labels)在初始化阶段有一个小细节LSTM的input_size是768这是bert-base-chinese的隐藏层维度。LSTM的hidden_size我设置了256bidirectionalTrue意味着正向和反向各有一个256维的输出拼接后是512维这个512维再通过全连接层映射到num_labels个类别上。很多人会问LSTM层数到底选几层我的经验是2层够用了。层数太多会带来严重的过拟合尤其在数据量不充裕的情况下。如果你发现训练损失下降很慢不要急着加层数先检查学习率是否太大、数据是否充分、标签是否对齐。3.2 BiLSTM 序列建模层为什么要双向拼接LSTM按时间步处理序列单向LSTM只有一个方向的信息流动——从开头向结尾。但文本里的实体识别往往需要后文信息来确认前文的实体类型。举个例子“北京到上海的高铁”这句话里“北京”和“上海”都是地名。但如果只看“北京到”这三个字模型可能还是能判断出来。真正需要后文的场景是“北京大学坐落于”——“北京”到底是单独的地名还是“北京大学”这个机构名的一部分必须结合后面的“大学”和“坐落于”才能确定。双向LSTM的设计正是为了解决这个问题。正向LSTM每个位置都聚合了前文信息反向LSTM每个位置都聚合了后文信息两路输出拼接后每个位置的向量同时包含左上下文和右上下文。这个信息结构比单独用BERT输出更丰富了一层序列角度的理解也正是这层结构在实体边界预测上比纯BERT更精准的原因。3.3 CRF 条件随机场损失计算与维特比解码CRF层是整个模型里最“数学”的部分也是很多朋友觉得难啃的地方。我先把它拆成两个核心问题来讲训练时要计算什么推理时要做什么。训练时计算负对数似然损失CRF的损失函数是真实标签路径的得分 / 所有可能路径的得分之和的负对数。所谓“路径”就是整个序列每个位置上的标签组合例如在13个标签、128个位置下理论上有13的128次方条路径。CRF不可能枚举所有路径但可以用动态规划前向算法在多项式时间内高效计算所有路径的得分之和。class CRF(nn.Module): def __init__(self, num_labels): super().__init__() self.num_labels num_labels self.transitions nn.Parameter(torch.randn(num_labels, num_labels)) # 禁止从某些标签转移到无效标签可以在这里初始化 def forward(self, emissions, tags, mask): # emissions: (batch, seq_len, num_labels) # tags: (batch, seq_len) # mask: (batch, seq_len) 1为有效0为无效 batch_size, seq_len, _ emissions.size() score torch.zeros(batch_size, dtypeemissions.dtype, deviceemissions.device) for i in range(batch_size): length mask[i].sum() true_score self._score_sentence(emissions[i, :length], tags[i, :length]) total_score self._forward_algorithm(emissions[i, :length]) score[i] total_score - true_score return score.mean()这里的true_score可以直接通过累加发射分数和转移分数算出来。total_score用前向算法得到。两者的差值就是负对数似然损失。需要再强调一遍这个差值越小说明真实路径在所有可能路径中的占比越大模型对标签序列的预测就越有把握。推理时维特比解码推理阶段不再需要计算所有路径而是直接用维特比算法找到得分最高的一条路径。维特比的核心思想是每一步只保留到达当前状态的最优路径而不是所有路径。def viterbi_decode(self, emissions, mask): batch_size, seq_len, num_labels emissions.size() best_paths [] for i in range(batch_size): length mask[i].sum() emit emissions[i, :length] init self.transitions[0, :] # 这里假定id0是起始状态 prev_scores init emit[0] backpointers [] for t in range(1, length): next_scores prev_scores.unsqueeze(1) self.transitions emit[t].unsqueeze(0) best_scores, best_tags next_scores.max(dim0) backpointers.append(best_tags) prev_scores best_scores # 回溯得到完整路径 best_path [] last_tag prev_scores.argmax() best_path.append(last_tag.item()) for bp in reversed(backpointers): last_tag bp[last_tag] best_path.append(last_tag.item()) best_path.reverse() best_paths.append(best_path) return best_paths维特比解码比逐位置取argmax要慢一些但换来的标签序列合法性是值得的。实测中同样的模型用CRF解码比逐位置argmax在F1值上能高出1-2个百分点。这个差距在数据量大的任务中会被放大。3.4 组合后的前向传播逻辑前向传播时模型按照BERT - Dropout - BiLSTM - 全连接 - CRF的顺序进行。这里的Dropout层作用非常关键——它在训练时随机丢弃一部分神经元防止过拟合。我固定设置为0.1如果数据量很小可以提高到0.3。def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state sequence_output self.dropout(sequence_output) lstm_output, _ self.bilstm(sequence_output) logits self.fc(lstm_output) if labels is not None: loss -self.crf(logits, labels, attention_mask) return loss else: mask attention_mask preds self.crf.viterbi_decode(logits, mask) return preds如果你不理解CRF的输入为什么要用logits而不是softmax后的概率值我给你一个简单的理解方式CRF前向算法内部要累加发射分数和转移分数在logits对数空间里做加法等价于在概率空间里做乘法。直接用未归一化的logits参与计算数值上更稳定也不需要额外套一层softmax。这是个直接的工程细节。4. 完整训练流程与参数配置4.1 训练循环从损失计算到梯度更新训练循环本身并不复杂但有几个关键地方必须写对优化器的选择、学习率调度、梯度裁剪、以及不要把[PAD]位置也算进损失。from transformers import AdamW, get_linear_schedule_with_warmup def train_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss 0.0 for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) optimizer.zero_grad() loss model(input_idsinput_ids, attention_maskattention_mask, labelslabels) loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step() total_loss loss.item() return total_loss / len(dataloader)梯度裁剪是一个值得养成习惯的操作。LSTM层在长序列上容易产生梯度爆炸如果不裁剪训练损失会突然变成NaN。max_norm5.0是一个比较保守的设定如果LSTM层数多或者序列长可以适当调小到1.0。4.2 优化器与学习率调度BERT和下游层分开设置一个很重要的实践是BERT预训练层的学习率要比下游的BiLSTM和CRF层小很多。BERT已经经过大规模预训练权重已经非常成熟不需要大幅改动而BiLSTM和全连接层是从随机初始化开始学习的需要更快的更新速度。no_decay [bias, LayerNorm.weight] optimizer_grouped_parameters [ {params: [p for n, p in model.bert.named_parameters() if not any(nd in n for nd in no_decay)], weight_decay: 0.01, lr: 2e-5}, {params: [p for n, p in model.bert.named_parameters() if any(nd in n for nd in no_decay)], weight_decay: 0.0, lr: 2e-5}, {params: [p for n, p in model.named_parameters() if not n.startswith(bert.)], weight_decay: 0.01, lr: 1e-3}, ] optimizer AdamW(optimizer_grouped_parameters)weight_decay这里只有偏置和LayerNorm参数设置为0这是AdamW优化器的通用实践——偏置项和LayerNorm的参数没有做L2正则化的必要保持原样反而更稳定。学习率调度方面我使用预热warmup加线性衰减。预热的含义是训练刚开始时学习率从一个很小的值逐渐升到目标值避免模型在初始阶段剧烈震荡。我一般设置总步数的10%作为warmup比例。total_steps len(dataloader) * num_epochs warmup_steps int(total_steps * 0.1) scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps)4.3 训练参数清单与显存设置参考我把一套经过验证的参数整理在下面你可以直接参考。数据量中等1万条句子时这套参数在单张RTX 3060上跑得非常流畅。参数名推荐值说明BERT模型bert-base-chinese中文预训练模型max_len128超过128的句子会被截断batch_size16显存不够时降到8epoch10建议同时监控验证集F1BERT学习率2e-5过高会导致灾难性遗忘下游层学习率1e-3BiLSTMCRF从零开始学warmup比例10%前10%步数线性升温梯度裁剪5.0防止梯度爆炸dropout0.1防止过拟合优化器AdamWBERT官方推荐4.4 验证集评估用F1分数而不是准确率NER任务里存在严重的类别不平衡问题。绝大多数token属于O非实体类别占比可能高达80%以上。如果你用整体准确率来评估模型哪怕模型什么都不预测全部输出O准确率也能达到80%以上。这显然无法反映模型识别实体的真实能力。所以评估时必须用精确率Precision、召回率Recall和F1值而且需要逐实体类型计算再取宏平均。seqeval是HuggingFace生态里最常用的NER评估库里面集成了classification_report接口可以直接输出每个实体类型各自的精确率、召回率、F1值。from seqeval.metrics import classification_report def evaluate(model, dataloader, device, id2label): model.eval() true_labels [] pred_labels [] with torch.no_grad(): for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].cpu().numpy() preds model(input_idsinput_ids, attention_maskattention_mask) for i in range(len(labels)): true_seq [] pred_seq [] for j in range(len(labels[i])): if labels[i][j] ! -100 and attention_mask[i][j].item() 1: true_seq.append(id2label[labels[i][j]]) pred_idx preds[i][j] pred_seq.append(id2label[pred_idx] if pred_idx ! -100 else O) true_labels.append(true_seq) pred_labels.append(pred_seq) report classification_report(true_labels, pred_labels, digits4) print(report) return report这套评估代码的核心逻辑是只计算真实标签不为-100的位置也就是过滤掉[CLS]、[SEP]和[PAD]位置的预测。这是NER评估里必须做的处理否则会把特殊token的错误预测也算进去污染指标。5. 项目运行指南与踩坑实录5.1 一图流跑通从环境配置到训练推理下面是启动项目的命令行步骤从创建虚拟环境到跑完训练再推理全流程在这里。# 1. 创建虚拟环境建议Python 3.10 conda create -n ner python3.10 -y conda activate ner # 2. 安装依赖 pip install torch2.1.0 pip install transformers4.38.2 pip install seqeval1.2.5 pip install pandas numpy tqdm # 3. 训练 python train.py # 4. 推理加载保存的模型权重 python predict.py --text 鲁迅是浙江绍兴人曾任北京大学教授。训练完成后模型会保存为best_model.bin——我这里的保存逻辑是每个epoch结束后在验证集上算一次F1如果F1比历史最好值高就保存当前权重。这样保证最终得到的模型是验证集上表现最优的版本而不是最后一个epoch的版本。5.2 推理阶段的中文文本处理细节推理时输入的是一句原始中文文本不是已经标注好标签的格式。所以需要做一次与训练时完全一致的预处理字符化、tokenizer编码、生成attention_mask、通过模型推理、最后把预测的标签ID映射回原始字符串。def predict(model, tokenizer, text, id2label, max_len128, devicecpu): model.eval() chars list(text) tokens [[CLS]] tokenizer.tokenize(text)[:max_len - 2] [[SEP]] input_ids tokenizer.convert_tokens_to_ids(tokens) attention_mask [1] * len(input_ids) padding max_len - len(input_ids) input_ids [tokenizer.pad_token_id] * padding attention_mask [0] * padding input_ids torch.tensor([input_ids], dtypetorch.long).to(device) attention_mask torch.tensor([attention_mask], dtypetorch.long).to(device) with torch.no_grad(): preds model(input_idsinput_ids, attention_maskattention_mask) pred_labels [id2label[p] for p in preds[0] if p ! -100] entities [] current_entity None for char, label in zip(chars, pred_labels): if label.startswith(B-): current_entity {text: char, type: label[2:]} elif label.startswith(I-) and current_entity is not None: current_entity[text] char elif label.startswith(E-) and current_entity is not None: current_entity[text] char entities.append(current_entity) current_entity None elif label.startswith(S-): entities.append({text: char, type: label[2:]}) else: current_entity None return entities这段解析函数做的事本质上就是通过标签序列来拼接实体文本。看到B-PER开始就打开一个实体收集器看到I-PER就继续拼字看到E-PER就把收集器关闭并加入结果看到S-PER说明单个字成实体直接加入结果。这个逻辑非常直观但很容易在I-与E-的拼接关系上写错导致实体文本残缺。建议在写完解析逻辑后手动构造几个简单的测试样本验证一下。5.3 完整项目目录结构参考把项目文件组织好后续维护、换数据、部署都会省不少事。我建议的目录结构如下ner-project/ ├── data/ │ ├── train.txt │ ├── dev.txt │ └── test.txt ├── models/ │ └── best_model.bin ├── src/ │ ├── __init__.py │ ├── dataset.py │ ├── model.py │ ├── train.py │ ├── predict.py │ └── evaluate.py ├── requirements.txt └── README.md5.4 常见问题速查表这部分我整理一下跑这个项目时最容易遇到的一系列问题基本涵盖了我在实际过程中被卡住过的场景。问题现象根本原因解决方案损失变成NaNLSTM梯度爆炸调小学习率增加梯度裁剪验证集F1一直为0标签映射表未包含所有标签确认label2id覆盖所有实体类型训练时bert权重被改动过大BERT学习率太高将BERT层学习率降到1e-5或2e-5预测结果实体是乱码token对齐错误核对encode_with_labels函数模型输出全是O标签标签不平衡检查数据里是否有B-/I-标注考虑数据增强显存不足batch_size太大降到4或8或减少max_lenseqeval报错标签不匹配pred里出现了-100过滤-100标签后再评估5.5 关于数据质量的一句实在话很多朋友跑通代码后第一反应是去找更大的数据集。但根据我的实际经验数据质量比数据量重要得多。我做司法文书NER时最开始用了一份自动标注的数据集不用量很大但标注正确率也就90%左右。模型跑完F1卡在78%上不去。后来我花了两个晚上人工修正了一批标注错误F1直接飙升到89%。标注噪声对序列标注任务的伤害远大于对分类任务——因为“B-PER后面接I-LOC”这种错误标注会把CRF学到的转移矩阵搞乱带来的负面影响会顺着序列传播。如果时间有限建议优先做好两件事一是保证每个实体至少出现10次以上避免CRF学不到对应的转移规律二是对标注格式做一次程序化校验确保每个B-后面一定跟着I-或E-每个I-前面一定有B-或I-。这两步做完模型效果不会差。6. 从“能跑”到“用好”的进阶建议6.1 模型保存与加载的正确姿势训练完的模型一定要保存完整的状态字典而不是只保存模型参数。完整起见我建议把label2id、id2label、模型配置都打包存成一个.pt文件这样后续加载推理时不需要再手动指定标签映射大幅减少踩坑概率。torch.save({ model_state_dict: model.state_dict(), label2id: label2id, id2label: id2label, config: { bert_pretrained: bert-base-chinese, num_labels: len(label2id), lstm_hidden: 256, } }, models/best_model.pt)加载时用torch.load读出字典再根据里面的config重建模型结构再load_state_dict恢复权重。这个过程看起来繁琐但可以避免很多“模型文件单独存等要用的时候却不知道标签映射是谁”的尴尬。6.2 领域适配用小数据微调出好效果如果你需要在自己的领域里跑一套NER比如合同文本里的甲方乙方、医疗病历里的症状药物不需要从零训练。拿我提供的这套代码只需要把你的领域数据按照同样的格式标注好然后把预训练BERT换成bert-base-chinese直接跑微调就行。即使是几千条句子的小数据在BERT强大的预训练语义基础上也能取得不错的效果。核心的套路是数据格式一致、代码逻辑不变、只换数据和标注体系。我的理解是先让小数据把BERT里面已经学到的语言常识“唤醒”到领域语义上之后再让BiLSTM和CRF学习领域中的序列标注规则。如果领域数据更少几百条可以尝试冻结BERT层的前几层只训练后半部分和BiLSTMCRF这样能降低过拟合风险。6.3 几个值得后续尝试的优化方向如果基础版本跑通了想进一步提升效果我建议按下面的优先级依次尝试。第一加入词典特征。给BERT输入旁边拼接一个词典匹配的向量比如该字符是否匹配到实体词典这种特征对于“中国人名识别”这种词典覆盖率较高的场景很有效。第二使用对抗训练。在embedding上添加可学习的扰动例如FGM方法能提升模型的鲁棒性对抗样本在中文语义上的扰动就是同义替换、顺序颠倒。在NER任务上这个技巧能稳定提升0.5-1个F1点。第三用更大模型替换BERT。比如RoBERTa-wwm-ext-large在中文NER上通常比bert-base-chinese强不少但显存占用也成倍增长。如果你有充足的GPU资源值得一试。第四引入span级别的分类器。如果实体大多是名词短语可以尝试使用基于span的模型结构而不是逐token标注。Span-based NER在处理长实体、嵌套实体时的表现更好。但是这个改动会比较大不建议在现有代码上直接改更适合当成一个全新项目来做。7. 写在最后的一些实操体会这套BERT-BiLSTM-CRF模型我前后用了小半年从最初的纯BERT方案一路调到这里。我的体会是与其去追各种花哨的新模型比如生成式大模型做NER不如先把这套经典结构理解透掌握好数据处理、损失计算、解码逻辑这些基本功。很多看起来“高大上”的新方法底层逻辑依然是“做语义表征”和“做结构化约束”只是换了种方式组合而已。如果你照着这篇文章把代码跑通了建议做三件小事一是换一个自己熟悉领域的数据集试一下感受一下标注质量对模型效果的影响二是把CRF层去掉试试纯BERTBiLSTM的效果亲眼看看CRF到底带来了多大提升三是写一个属于自己的推理接口把模型接到一个小工具或者Web服务里让模型真正用起来。最后再分享一个小技巧在保存模型的时候把当时的验证集F1值和实体类别数一起记录到文件名里比如model_epoch10_f1_88.5.pt。现在看起来有点“土”但这习惯让我在后来同时调十几个模型版本时省下了大量对比时间——你一定会感谢过去的自己。本文还有配套的精品资源点击获取
返回列表