ARTICLE DETAIL

资讯详情

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

基于PyTorch的BERT-BiLSTM-CRF命名实体识别实战与调参指南

基于PyTorch的BERT-BiLSTM-CRF命名实体识别实战与调参指南 简介一套基于PyTorch实现的BERT-BiLSTM-CRF命名实体识别完整工程面向NLP初学者、算法工程师及科研人员解决从零构建序列标注模型时环境配置与代码串联的痛点。项目给出可直接运行的代码与配套数据覆盖数据预处理分词、BERT特殊标记与定长填充、预训练BERT嵌入提取、BiLSTM上下文编码、CRF全局最优标签解码以及训练评估与模型保存全流程适合快速上手实体识别任务或作为改进基线。资源包共23个文件以9个Python脚本为核心源码辅以5个XML工程配置、5个pyc缓存文件、3个txt说明/数据文件及1个iml工程文件压缩包整体仅341KB轻量且结构清晰。目前已有2408人学习下载适合希望理解Transformer与序列标注结合原理的读者可直接对照代码理解BERT-BiLSTM-CRF的协作机制也可在此基础上替换数据集或调整网络结构进行二次实验是兼具教学与实用价值的NLP入门资源。1. 基于 PyTorch 的 BERT-BiLSTM-CRF NER为什么这套组合能直接落地做过 NER 的人大概都有过这种体验规则模板在上线前能撑住场面一遇到没见过的说法就漏纯 BiLSTM-CRF 在标注规范的项目里效果不错但遇到中文字面歧义、一词多义模型的泛化天花板很快就摸到了。把 BERT 接进 BiLSTM-CRF 之后实体识别从「看局部窗口猜」变成了「读全句语义再约束边界」在中文新闻、医疗病历、法律文书这些领域F1 通常能比纯 BiLSTM-CRF 高出 5 到 10 个点。这里要拆的正是基于 PyTorch 的 BERT-BiLSTM-CRF 命名实体识别方案包含完整代码与可直接运行的数据准备方式让你从环境搭建到模型推理一次走通而不是看一堆零散博客拼不出来。这套方案适合两类人一类是刚入门序列标注需要一份能跑通、能改标签体系、能换数据集的基线代码另一类是已经在用 BiLSTM-CRF想用 BERT 替换 Embedding 层但不知道从哪下手。我会把 BERT 怎么用、BiLSTM 为什么必须加、CRF 在解码时做了什么以及训练中最容易翻车的几个参数和现象全部摊开讲。2. 搭建运行环境与准备数据BERT-BiLSTM-CRF 的地基先打牢2.1 版本搭配选择PyTorch、CUDA、Transformers 的兼容组合先说结论我推荐 PyTorch 2.x Transformers 4.x Python 3.8 到 3.10。这个组合在大多数 Linux 服务器和 Windows WSL 上都能稳定跑通。安装 PyTorch 时建议直接去 PyTorch 官网选对应 CUDA 版本的命令比自己手动拼命令靠谱。如果你完全不确定 CUDA 版本先在终端跑一次nvidia-smi看右上角的 CUDA Version那是驱动支持的版本不是 PyTorch 必须匹配的版本。PyTorch 安装时选择 CUDA 11.8 或 12.1 都可以只要驱动版本不低于它就行。# 创建虚拟环境Python 版本根据你本机 conda 情况调整 conda create -n ner python3.9 -y conda activate ner # 安装 PyTorch以 CUDA 12.1 为例官网命令通常长这样 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # 安装 HuggingFace Transformers 和数据集处理工具 pip install transformers datasets tokenizers # 如果需要跑 CRF建议直接安装 pytorch-crf pip install pytorch-crf这里有个容易被忽略的点Transformers 库的版本不要追最新4.36 到 4.40 这个区间就行。太新的版本会要求更高级别的 Tokenizer 行为偶尔会和旧版训练好的 BERT 权重产生兼容告警。安装完成后用一段 Python 验证环境import torch import transformers print(PyTorch:, torch.__version__) print(CUDA available:, torch.cuda.is_available()) print(Transformers:, transformers.__version__)如果torch.cuda.is_available()返回 False而你的机器确实有 N 卡大概率是 PyTorch 装成了 CPU 版或者 CUDA 驱动版本低于 PyTorch 需要的版本。排查时别急着重装先nvidia-smi看驱动再检查当前环境里pip list | grep torch看是不是cpu后缀。2.2 数据格式与标签体系BIO 还是 BIOESNER 的标注体系直接决定模型输出的标签个数也影响 CRF 的约束逻辑。中文 NER 里最常见的两种格式是 BIO 和 BIOES。BIO 把标签拆成 B-实体类型、I-实体类型、O 三类每个实体类型占两到三个标签。BIOES 会把单个字构成的实体单独标成 S-实体类型并在实体结束后补充 E-实体类型因此标签数量比 BIO 多一套但边界在后处理时更明确。从实战效果看BIOES 在 CRF 解码时更能抑制「I 开头的实体」这种非法情况代价是收敛速度略慢。如果你手里只有 BIO 标注的数据强行转成 BIOES 需要写规则不建议在项目初期做直接用 BIO 也能跑出不错的效果。数据文件建议采用一列字、一列标签的 JSONL 或 CoNLL 格式。CoNLL 格式是每行一个「字符/标签」对空行分隔句子JSONL 格式则是每行一个{text: ..., labels: [...]}。我一般更推荐 JSONL因为后面要做 BERT 的 tokenizer可以一条一条地处理不容易踩到 CRF 对序列长度的对齐问题。一个最小化的训练数据示例长这样{id: 1, text: 张三在北京工作, entities: [{start: 0, end: 1, type: PER}, {start: 2, end: 3, type: LOC}]}这里start和end是字符级别的下标end为开区间。先构造这种 Positions 格式再根据你的 BIO/BIOES 标签表转成字级标签序列会比直接标注标签列表更容易检查和校验。开发阶段建议只准备三个文件train.jsonl、dev.jsonl、test.jsonl各 2000/500/500 条已经是能看出模型趋势的量不需要一开始就上全量数据。2.3 预处理脚本把原始标注转成 BERT 的 input_idsBERT 的 Tokenizer 会把中文按字切分吗不一定。BERT-base-Chinese 的中文大部分是单字切分但英文字符、数字片段会被分成子词比如ID123可能被切成ID、##12、##3。如果你的实体中包含中英混合标签对齐就必须细心地做。标准做法是先用 Tokenizer 拿到每个字符对应的 offset然后映射标签。from transformers import BertTokenizerFast tokenizer BertTokenizerFast.from_pretrained(bert-base-chinese) def encode_with_labels(text, char_labels, max_len128): # 拿到每个 token 对应的原始字符区间char_labels 是字级 BIO 标签 encoding tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_offsets_mappingTrue, ) offset_mapping encoding[offset_mapping] labels [O] * len(offset_mapping) # 特殊 token 的 label 设为 -100在计算 loss 时忽略 for i, (start, end) in enumerate(offset_mapping): if start 0 and end 0: labels[i] -100 else: # 取该 token 覆盖的第一个字符的标签子词后续部分可沿用同一标签 labels[i] char_labels[start] if char_labels[start] ! O else O # 标签需要转成数字 id这里留一个映射函数接口 return { input_ids: encoding[input_ids], attention_mask: encoding[attention_mask], token_type_ids: encoding[token_type_ids], labels: label_to_id(labels), }这段代码最好放在data_loader.py里配合 Dataset 类使用。注意offset_mapping里[CLS]和[SEP]的 start 和 end 都是 0所以用start 0 and end 0判断。真实文本中如果第一个字正好从 0 开始那 end 不会是 0这个判断不会误伤。子词的情况例如某个中文词被切分为两个字第二个子词映射到同一个字符 start这里我直接沿用 char_labels[start]等价于把 B 或 I 传播过去。如果你的实体很多跨子词更稳妥的做法是取char_labels[(startend)//2]但中文场景里单字切分占绝大多数不用过度设计。3. 模型结构拆解BERT-BiLSTM-CRF 每一层为什么不能省3.1 BERT 编码层动态上下文是替换词向量的关键传统 Word2Vec 或 GloVe 的问题是无论「苹果」出现在手机新品发布会还是水果市场向量都相同。BERT 不同它根据句子里的全部上下文动态生成每个 token 的表示。把 BERT 作为编码器输入是一串 token输出是每个 token 的 768 维向量以 base 为例这一整层的权重可以通过微调来适配你的领域数据。在实现层面使用 HuggingFace Transformers 时只需要AutoModel.from_pretrained(bert-base-chinese)然后取outputs.last_hidden_state。要注意的是 BERT 输出的是纯上下文表示并没有被专门优化为「命名实体边界」的特征所以后面必须接 BiLSTM 继续抽取局部交互。如果你的句子特别长BERT 有 512 token 的上限截断策略必须和数据准备保持一致否则训练和预测时实体支离破碎。from transformers import BertModel class BertEncoder(torch.nn.Module): def __init__(self, model_namebert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout torch.nn.Dropout(0.1) def forward(self, input_ids, attention_mask): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, return_dictTrue, ) # 最后一层 [batch, seq_len, hidden_size] return self.dropout(outputs.last_hidden_state)这里我加了一个 Dropout参数 0.1 是 BERT 微调的标准配置。如果换用其他预训练模型比如 RoBERTa-wwm-extDropout 也可以保持 0.1。这层输出会直接喂给 BiLSTM所以不需要额外的 Projection 层BiLSTM 的输入维度就是 768。3.2 BiLSTM 层为什么必须双向才能抓边界BERT 已经建模了上下文再套一个 BiLSTM 会不会冗余我的经验是不会尤其在小数据集上提升明显。BERT 的表示虽然语义丰富但它的位置编码是相对位置对实体边界的显式建模不如 LSTM 天然的顺序衰减特性。BiLSTM 能学到的是一种「这个位置前后是不是实体一部分」的序列偏好而且能显著降低 CRF 的训练难度。BiLSTM 把 BERT 输出的每个 token 向量分别送进正向和反向两个 LSTM然后将两个方向的隐状态拼在一起。这个拼接后的向量包含「从左往右」和「从右往左」两种信息比如要判断「中华人民共和国」里的「国」是否结束实体正向 LSTM 知道前面有「中华」和「人民」反向 LSTM 知道后面跟着其他词两头一夹边界就清晰了。import torch import torch.nn as nn class BiLSTM(nn.Module): def __init__(self, input_size768, hidden_size128, num_layers1): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, ) def forward(self, x): # x: [batch, seq_len, 768] outputs, _ self.lstm(x) # outputs: [batch, seq_len, hidden_size * 2] return outputs这里hidden_size128是常见配置双向后每个 token 得到 256 维。如果显存允许hidden_size256能带来一点提升但对小数据集反而容易过拟合可以先从 128 开始。num_layers我用 1加深 LSTM 层会明显减慢训练速度收益不成比例。3.3 CRF 层标签转移约束和维特比解码CRF 做两件事一是给每个 token 的每一类标签算一个发射分数二是学习标签之间的转移分数。比如在 BIO 体系下「I-PER」后面接「B-PER」不合理模型会自动降低这条转移路径的分数再比如 O 后面不能紧跟 I-ORGCRF 也会抑制这种路径。训练时 CRF 计算整条序列路径的分数用前向后向算法得到归一化概率推理时用维特比解码得到全局最优标签路径。具体到 PyTorch 实现我常用pytorch-crf库。这个库只有两个核心方法forward(emissions, tags)算 lossdecode(emissions)做解码。需要强调emissions的形状是[batch, seq_len, tag_num]不能漏掉 batch 维度。CRF 层的输入正是不带softmax的发射分数CRF 内部自带 log-softmax 处理。from torchcrf import CRF class BERT_BILSTM_CRF(nn.Module): def __init__(self, tag_size, bert_namebert-base-chinese): super().__init__() self.bert BertEncoder(bert_name) self.bilstm BiLSTM(input_size768, hidden_size128) self.proj nn.Linear(128 * 2, tag_size) self.crf CRF(tag_size) def forward_loss(self, input_ids, attention_mask, labels): bert_out self.bert(input_ids, attention_mask) lstm_out self.bilstm(bert_out) emissions self.proj(lstm_out) # labels 里 -100 的位置需要掩码掉CRF 不参与计算 mask attention_mask.bool() return -self.crf(emissions, labels, maskmask, reductionmean) def decode(self, input_ids, attention_mask): bert_out self.bert(input_ids, attention_mask) lstm_out self.bilstm(bert_out) emissions self.proj(lstm_out) return self.crf.decode(emissions, maskattention_mask.bool())这里出现了一个容易踩坑的设计CRF 的mask直接用了attention_mask但这个 mask 不仅仅用于 [PAD]还应该屏蔽掉那些标签被设为 -100 的位置。如果你的训练数据里[CLS]和[SEP]的标签是 -100而你在forward_loss里直接调用 CRF它会把[CLS]也当成一个正常 token 参与训练这是最初跑出来 loss 偏高的一种隐藏原因。我给你的建议构造 labels 时把[CLS]的标签也设成 0即 O然后用偏移的方式人工把 CRF mask 处理成只保留文本部分或者干脆在图 2.3 预处理时将特殊 token 的标签设为 0同时把对应的 attention_mask 位置设为 False。这样 CRF 不会计算不合法的路径。4. 训练与推理可复现的完整代码与参数解读4.1 数据加载器与标签映射要保证「可以直接运行」数据加载器就要一步到位。下面这个build_dataset函数接收 JSONL 文件路径把每条记录转成模型所需的 input_ids、attention_mask、labels。标签映射采用 BIO 体系我预设实体类型是PER、LOC、ORG你可以按需替换。import json import torch from torch.utils.data import Dataset, DataLoader TAG_MAP {O: 0, B-PER: 1, I-PER: 2, B-LOC: 3, I-LOC: 4, B-ORG: 5, I-ORG: 6} TAG_LIST list(TAG_MAP.keys()) def chars_to_tags(text, entities): tags [O] * len(text) for ent in entities: start, end, etype ent[start], ent[end], ent[type] if start 0 or end len(text) or start end: continue # 注意 BIO 里单字实体的 B 和 I 都要存在吗单字实体只标 B 不标 I tags[start] fB-{etype} for i in range(start 1, end): tags[i] fI-{etype} return tags class NERDataset(Dataset): def __init__(self, path, tokenizer, max_len128): self.items [] with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: continue item json.loads(line) text item[text] char_tags chars_to_tags(text, item[entities]) enc tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_offsets_mappingTrue, ) offset_mapping enc[offset_mapping] labels [] for start, end in offset_mapping: if start 0 and end 0: labels.append(TAG_MAP[O]) # 特殊 token 映射为 O else: labels.append(TAG_MAP[char_tags[start]]) self.items.append({ input_ids: enc[input_ids], attention_mask: enc[attention_mask], labels: labels, }) def __len__(self): return len(self.items) def __getitem__(self, idx): item self.items[idx] return { input_ids: torch.tensor(item[input_ids]), attention_mask: torch.tensor(item[attention_mask]), labels: torch.tensor(item[labels]), }这里把特殊 token 的标签映射成 0是为了避免 -100 干扰 CRF。对应的 attention_mask 里特殊 token 位置是 1这会让 CRF 把[CLS]也当作可参与状态。由于预设标签 0 是 OCRF 学到的是[CLS]处应该是 O实际上因为[CLS]是全局语义聚集位置把它强制成 O 不会对实体抽取有实质影响而且大大简化 mask 处理。如果你希望 CRF 完全跳过特殊 token可以在forward_loss里构造一个额外的 mask做法是把 attention_mask 中特殊 token 位置改成 False。4.2 训练主循环优化器、学习率、梯度裁剪与验证训练阶段最核心的三个设置是优化器、学习率、是否做梯度裁剪。BERT-BiLSTM-CRF 这类模型我建议使用 AdamW学习率从 2e-5 到 5e-5 之间取。BERT 层的参数比 BiLSTM 和 CRF 更敏感一种常见做法是给 BERT 层设更小的学习率比如 2e-5给 BiLSTM 设 1e-3但这会增加复杂度。先用统一学习率 2e-5 跑两轮观察 loss 是否下降再考虑分层学习率。from torch.optim import AdamW from tqdm import tqdm def train_one_epoch(model, dataloader, optimizer, device, max_grad_norm1.0): model.train() total_loss 0 for batch in tqdm(dataloader): input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) loss model.forward_loss(input_ids, attention_mask, labels) optimizer.zero_grad() loss.backward() # 梯度裁剪防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)训练脚本里我强制加入了clip_grad_norm_这是血泪教训之一。BiLSTM 对梯度尺度很敏感尤其是 CRF 层的转移矩阵在训练前期可能出现某个 batch 的梯度特别大直接把参数冲出合理范围后续 loss 变成 NaN。max_grad_norm1.0是常见值遇到不收敛可以先降到 0.5 看看。4.3 推理与实体抽取函数把标签序列还原回文本实体模型解码得到的是每个 token 对应的标签 id需要先映射回字符串标签再转换成(实体类型, 开始位置, 结束位置, 实体文本)四元组。注意 BERT 子词切分会导致 token 和原始字符不是一一对应所以这里我保存了 offset_mapping用它在结果里还原原始文本的下标。def extract_entities(text, tag_ids, offset_mapping, tag_list): entities [] current_entity None for i, tag_id in enumerate(tag_ids): if tag_id 0 or i len(offset_mapping): # O 标签结束当前实体 if current_entity: entities.append(current_entity) current_entity None continue tag tag_list[tag_id] start, end offset_mapping[i] if start end: continue if tag.startswith(B-): if current_entity: entities.append(current_entity) current_entity { type: tag[2:], start: start, end: end, text: text[start:end], } elif tag.startswith(I-) and current_entity: if current_entity[type] tag[2:]: current_entity[end] end current_entity[text] text[current_entity[start]:end] else: entities.append(current_entity) current_entity None else: if current_entity: entities.append(current_entity) current_entity None if current_entity: entities.append(current_entity) return entities这个函数假设tag_ids已经过滤掉特殊 token 对应的位置。在实际调用模型时offset_mapping来自 tokenizer推理阶段需要重新调用一次 tokenizer 并保证和训练阶段相同。我发现很多人在这里图省事只拿预测结果不管 offset 映射最后输出的实体位置全部偏一位其实是索引没对齐。5. 参数调优与避坑指南BERT-CRF 训练中常见的 5 个问题5.1 O 标签数量压倒性优势导致学不到实体现象训练两轮后 loss 下降但验证集 F1 很低预测结果全是 O一个实体都没识别出来。原因中文数据里 O 标签通常占 80% 以上。CRF 的似然函数对整条路径求 loss模型只要学会输出 O 就能获得很低的 loss实体信息被淹没。解决第一检查你的实体标注长度如果实体平均长度只有 2 个字B 和 I 的比例本来就少可以试着用 Focal Loss 加在 CRF 发射分数上但实现麻烦。更简单的做法是调整损失权重在forward_loss中不直接调用self.crf而是把 CRF 的解码路径分数拆开对实体发射分数做加权。第二数据层面过采样包含实体的句子让每个 epoch 中实体比例提升到 20% 以上。第三如果只是验证集判定标准太严检查一下extract_entities里的实体合并逻辑是不是把连续的 I 标签错误分割了。我遇到过几次自称「效果差」其实是边界判断 bug 的情况。5.2 loss 震荡不收敛学习率小则慢、大则炸现象loss 在前 500 步就冲到个位数然后上下剧烈波动甚至出现 nan。原因BERT 预训练权重和 BiLSTM 随机初始化权重尺度不同。BERT 的梯度普遍偏小BiLSTM 的梯度一开始很大两者混合后 Adam 的 eps 可能扛不住。另一个原因是没有做 warmup学习率从开始的峰值就用容易打乱预训练权重。解决设置 warmup steps主流的 HuggingFace Transformers 提供了get_linear_schedule_with_warmup我把训练代码的优化器部分改成from transformers import get_linear_schedule_with_warmup total_steps len(train_dataloader) * num_epochs warmup_steps int(total_steps * 0.1) optimizer AdamW(model.parameters(), lr2e-5) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps )每一步scheduler.step()。如果你发现 loss 还是不稳确认 CRF 的 mask 是否正确。我遇到过一个奇怪的现象预测阶段 CRF 解码结果全是同一实体类别排查发现是crf.decode的 mask 没有传入导致所有 pad 位置被允许参与转移。5.3 长文本被截断实体正好被切掉一半现象训练时某些实体恰好位于第 128 个 token 往后的位置被truncationTrue切掉。模型永远见不到这部分数据推理时一句 200 字的新闻后半段的实体几乎抽不出来。原因BERT 的 512 token 限制加上我们为了速度统一设 max_len128肯定舍掉部分信息。解决不要盲目提高 max_len显存和训练速度都会立方级增长。更常见的做法是双策略训练时随机截断但保留包含实体较多的片段推理时用滑窗把长文本切成多个 128 token 的重叠窗口然后合并结果。重叠部分取窗口两侧各 16 个 token在合并时如果同一个实体在两个窗口都被识别按置信度或右窗口覆盖原则去重。这种方案我在中长文本上跑到了接近不截断的效果。5.4 显存不足batch size 提不上去现象A100 80G 都能跑但 2080Ti 上 batch size4 就 OOM。原因BERT base 本身显存占用高反向传播时中间激活值非常占显存。很多人忽略 CRF 的前向后向算法也会在 batch 内同步保留发射分数矩阵占用不算大但加上 LSTM 梯度就很容易爆。解决先减小 batch size 到 2使用梯度累积模拟大 batch。代码如下accumulation_steps 4 # 相当于 batch size 2*48 optimizer.zero_grad() for step, batch in enumerate(train_dataloader): loss model.forward_loss(...) loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()另外可以检查是否把 BERT 的输出outputs.last_hidden_state又存储了一份用于别的地方若只是顺传不必显式保存。还可以给模型开启torch.cuda.amp混合精度PyTorch 2.x 用torch.autocast(cuda)包住 forward 和 loss 计算能省约 40% 显存。5.5 CRF 解码输出非法转移但没报错现象测试时 entity 的开始标签是 I 或 E输出中出现I-PER开头且前面不是 B 的情况看起来是 CRF 没学到约束。原因CRF 转移矩阵初值是均匀分布训练不充分时转移矩阵尚未形成强约束更常见的是解码时 mask 没有指定pad 位置参与了维特比路径导致那些非法 tag 拼接在真实 token 之后。还有一个我也踩过的坑标签之间混淆比如B-LOC和I-LOC在 tags 里的 id 不连续CRF 库不做校验照样训练但收敛慢。解决确保crf.decode(emissions, maskattention_mask.bool())。同时检查训练阶段的标签序列中没有不合法跳跃比如把[O, I-PER]这种序列排除掉因为标注规范上它本来就不允许。你可以写一个校验函数在数据加载后预先检查一遍所有标签序列防止脏数据进入训练。6. 进阶从「能跑」到「能上线」推理加速与工程化细节6.1 用 ONNX 导出时CRF 层需要单独处理BERT-BiLSTM-CRF 转 ONNX 的难点不在 BERT而在 CRF 的解码循环。pytorch-crf的维特比解码里包含动态循环ONNX 不太友好。我的做法是把 BERT BiLSTM Linear 层导出为 ONNXemissions矩阵作为输出然后 CRF 解码放在 CPU 上用一个自定义的 NumPy 实现。这样不仅能复用 ONNX Runtime 的高效推理还能省去维护 PyTorch 会话的麻烦。import numpy as np def viterbi_decode(emissions, mask, trans): # emissions: [seq_len, tag_num], mask: [seq_len] seq_len, tag_num emissions.shape dp np.full((seq_len, tag_num), -1e9) dp[0] emissions[0] back np.zeros((seq_len, tag_num), dtypeint) for t in range(1, seq_len): if not mask[t]: continue for j in range(tag_num): scores dp[t-1] trans[:, j] emissions[t, j] idx np.argmax(scores) dp[t, j] scores[idx] back[t, j] idx best_tag np.argmax(dp[seq_len-1]) path [best_tag] for t in range(seq_len-1, 0, -1): best_tag back[t, best_tag] path.append(best_tag) path path[::-1] return path这个掩码循环只适合单条推理。如果你的系统需要批量处理建议直接保留 PyTorch CRF 的decode或者用torch.jit.script把 CRF 模块改成 TorchScript。ONNX 带来的提速主要体现在没有 CRF 分叉时的高吞吐google 的实践中纯 CPU 推理 BERT-base ONNX Runtime 比 PyTorch 快 2 倍左右加上 CRF 后提速会稀释但存留收益仍值得做。6.2 重叠滑窗与实体合并长文本的最终解法对于超出 max_len 的长文本用重叠窗口分别预测再合并。我常设窗口大小 128步长 100重叠 28。合并原则如果在两个窗口中识别出同一位置同一类型的实体保留长度较长或置信度较高的那个如果窗口 A 识别了实体a窗口 B 中同一位置识别了a的一部分合并时要检查重叠区域的标签是否一致。下面给出一个简单的合并逻辑def merge_window_entities(windows): # windows 是每个窗口的 (offset_start, offset_end, entities) 列表 final [] for offset_start, offset_end, entities in windows: for ent in entities: abs_start offset_start ent[start] abs_end offset_start ent[end] # 和已有实体对比 overlap False for existing in final: if abs_start existing[end] and existing[start] abs_end: # 合并同一类型 if existing[type] ent[type]: existing[start] min(existing[start], abs_start) existing[end] max(existing[end], abs_end) existing[text] full_text[existing[start]:existing[end]] overlap True break if not overlap: final.append({type: ent[type], start: abs_start, end: abs_end}) return final这段代码需要定义full_text作用于外部。注意合并时不要跨越窗口边界强行合并除非两个实体的边界正好在重叠区域完全一致。6.3 服务化部署的缓存与批处理在线 NER 服务里最影响吞吐的是 BERT 部分。如果只是单条短句batch size1 的推理速度也能接受但突发流量下瓶颈明显。我的习惯是动态批处理把等待窗口内 50ms 的请求攒成一个 batch用同一个模型推理。这里有个参数要点attention_mask必须按 batch 内最长序列补齐padding 到相同长度但不要每次都加 128按实际最长长度动态 padding 能省下约 20% 的显存。另外对完全相同的实体抽取请求做缓存。NER 服务结果往往高度重复比如同一批新闻稿被多次查询。缓存 key 用文本的 MD5 值value 存实体 JSONTTL 设置 10 分钟。缓存命中时连 CRF 解码都可以绕过直接返回结果。这个小优化在很多业务里能把 QPS 提升到原来的三倍。这套 BERT-BiLSTM-CRF 方案从数据准备到训练调参再到线上加速每一步都有明确的选择依据和避坑逻辑。不要盲目追求新模型比如直接换成中文大模型做生成式实体抽取至少在标注数据少于五万条时序列标注的稳定性仍然好得多。我习惯在每次训练后把预测错误样本打印出来分成「边界错」「类型错」「漏召回」三类对照数据检查。持续三轮之后你会发现大部分问题不在模型结构而在标注规范的边界定义。希望这些踩坑经验能帮你缩短调参周期把精力放到真正影响业务效果的数据和质量上。希望帮到你。本文还有配套的精品资源点击获取
返回列表