ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PyTorch实战:Bert-BiLSTM-CRF中文命名实体识别全流程指南

2026/10/7 5:19:51 拓冰建站 浏览量
PyTorch实战:Bert-BiLSTM-CRF中文命名实体识别全流程指南 简介基于PyTorch实现的Bert-BiLSTM-CRF命名实体识别项目面向自然语言处理研究者与Python开发者适合学习预训练模型和序列标注任务的完整落地方式。项目从数据预处理、模型定义、训练预测到评估形成闭环代码结构清晰便于掌握BERT微调、BiLSTM上下文编码以及CRF约束标签关系的核心原理。资源包共19个文件、约3.95MB以6个Python脚本、5个TXT文档和2个Jupyter Notebook为主另含训练集、开发集、测试集、LICENSE与READMEPython代码覆盖模型、CRF层、训练和预测入口TXT文档提供原始语料与BIO格式标注数据Notebook展示数据处理和预测演示。已有936人学习适合用于NLP课程设计、论文复现或工程起步。读者可通过完整代码理解三个模型如何串联并借助内置语料与配置复用训练流程为信息抽取、问答系统等任务打下基础。1. Bert-BiLSTM-CRF 到底是做什么的NER 任务里它为什么是常青树做中文命名实体识别NER时你大概率见过这个组合Bert 负责把每个字变成带有上下文语义的向量BiLSTM 负责在序列方向上再抽一层时序特征CRF 负责在标签之间做全局最优解码。这个“Bert-BiLSTM-CRF”在 PyTorch 生态里一直是被复现最多的基线模型之一Github 上随便一搜就有大量以“Bert-BiLSTM-CRF-pytorch-master”命名的项目。它解决的核心问题很具体给定一句中文文本把里面的人名、地名、机构名、时间、数字等实体边界和类别标出来并且保证标签序列合法——比如“B-PER”后面不能直接跟“I-ORG”。适合读这篇的人不是想研究论文的算法工程师而是手里有一批标注数据、想把 NER 跑起来用在业务里的从业者。你可能是第一次用 PyTorch 跑这类的模型也可能已经被环境、显存、解码结果不对这些问题卡了一整天。我会按自己实际调通这个方向的经验把环境、数据、模型、训练、排错和部署一条线讲清楚里面所有命令和代码都基于 Python 3.8 以上的 PyTorch 环境你可以直接照着改。2. 环境与数据准备PyTorch 版本、CUDA 匹配和标注数据长什么样2.1 环境搭建Python、PyTorch、CUDA 版本怎么选很多项目跑不起来不是模型代码有问题而是 PyTorch 和 CUDA 版本没对上。我一般建议先用conda建独立环境不要动系统 Python。常见做法是创建 Python 3.8 或 3.9 的环境然后根据显卡驱动决定安装哪个 PyTorch 版本。如果你是 N 卡先在终端执行nvidia-smi看右上角的 CUDA 版本那个是驱动支持的版本不是已经装好的 runtime 版本安装 PyTorch 时只需要保证要求的 CUDA 版本不高于驱动版本就行。conda create -n bertner python3.9 conda activate bertner # 查看当前机器可用 GPU 和驱动 nvidia-smi # 安装 PyTorch这里以 CUDA 11.8 为例 pip install torch2.0.1cu118 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118逻辑说明torch2.0.1cu118表示 PyTorch 2.0.1 对应 CUDA 11.8 的预编译包。如果你的机器是 CUDA 12.1就改成cu121。安装完成后在 Python 里执行import torch; print(torch.cuda.is_available())返回True说明环境可用。很多人在这里踩坑用pip install torch装了 CPU 版本结果训练时只有 CPU 在跑慢得离谱。检查方法是看torch.version.cuda和torch.backends.cudnn.version()是否正常。参数说明Python 3.9 不是必须的但很多中文预训练模型对 3.10 的 warning 比较多3.8/3.9 最省心。PyTorch 2.0 和 1.x 在 API 上几乎兼容但 2.0 的torch.compile可选项不影响本模型结构。如果你用的是老项目里面能见到from torchcrf import CRF这类依赖注意那是独立的pytorch-crf库和项目里自己实现的 CRF 层不要混淆后面会细说。2.2 数据格式BIO/BIOES 标注与数据集划分跑通这个标题下的代码你首先要有一份标注语料。最常见的是 BIO 和 BIOES 两种标注体系。BIO 里B-XXX表示实体开始I-XXX表示实体内部O表示非实体。BIOES 则多出E结尾和S单字实体序列约束更强。实战里BIOES 的 F1 通常会高 1-2 个点但数据标注成本也高一些。如果你手里的数据已经是 BIO那代码里改 CRF 约束层的合法转移规则时要小心后面避坑会讲。# 数据文件格式示例每行一个 字 空格 标签 # 句子之间用空行分隔 # # 中 B-LOC # 国 I-LOC # 首 O # 都 O # 北 B-LOC # 京 I-LOC# 读取并划分数据集的常见写法 def load_data(file_path): sentences, labels [], [] words, tags [], [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if line : if words: sentences.append(words) labels.append(tags) words, tags [], [] else: char, tag line.split() words.append(char) tags.append(tag) if words: sentences.append(words) labels.append(tags) return sentences, labels逻辑说明这个函数把所有字和标签按句读进来。空行作为句子边界这是最标准的 CoNLL 格式。注意如果你的语料是 JSON 或者别的格式先统一转成这种三行/两列结构再喂给模型不要指望代码自动处理。参数说明file_path是 UTF-8 编码的文本。加载完后我一般按 8:1:1 划分训练/验证/测试但需要先random.seed(42)固定随机种子否则每次跑结果都不一样。实体类别集合要从训练集里统计出来比如{PER, LOC, ORG}再加上O共有标签数len(tags)*21——因为 BIO 体系中每个实体类别有 B 和 I 两种标签加上 O。2.3 预训练模型下载与本地缓存Bert 参数从哪里来大多数项目直接from transformers import BertModel, BertTokenizer然后指定一个中文预训练模型名字比如bert-base-chinese。第一次运行时会自动下载但国内网络环境下经常失败所以我建议手动下载到一个固定目录然后用from_pretrained指定本地路径。from transformers import BertModel, BertTokenizer # 第一次运行前手动下载然后放到本地目录 # model_dir ./pretrained/bert-base-chinese model BertModel.from_pretrained(bert-base-chinese, cache_dir./cache) tokenizer BertTokenizer.from_pretrained(bert-base-chinese, cache_dir./cache)# 也可以先用命令行下载到 cache 目录 python -c from transformers import BertTokenizer; BertTokenizer.from_pretrained(bert-base-chinese)逻辑说明cache_dir参数指定缓存位置避免每次重复下载。如果你的机器没有外网就把整个模型文件夹包含config.json、pytorch_model.bin、vocab.txt拷到内网然后从本地路径加载。注意pytorch_model.bin大约 400MB 多别用文本方式传输。参数说明bert-base-chinese的词典是 21128 个字词采用字粒度适合中文 NER。如果你的业务是英文换成bert-base-uncased。如果领域很特殊比如法律、医疗建议用领域预训练模型但加载方式完全一样。这里有个细节tokenizer 分出来的 token 是字级别的但 Bert 的 WordPiece 会把一个汉字看成是一个 token所以长度上跟原始字符数一致后续对齐标签会省很多事。3. 拆解模型结构从 Bert 到 BiLSTM 再到 CRF三个组件各管什么3.1 Bert Encoder为什么不用最后一层 hidden state 直接分类Bert 输出的last_hidden_state是一个三维张量形状是[batch_size, seq_len, hidden_size]中文 Bert 的hidden_size是 768。直觉上可以直接接一个线性层做分类但实际效果往往不如加 BiLSTM。原因在于Bert 的每个字向量虽然已经包含了上下文信息但它是双向 Transformer 堆叠出来的直接分类时每个位置独立决策标签之间没有任何约束。举个例子“小明去北京”这句话里“北”和“京”都是 LOC如果线性层把“北”预测成 B-LOC、把“京”预测成 O单独看都说得过去但序列上就出现实体断在了中间。常见做法是把 Bert 的输出喂给 BiLSTM让模型在时序上再学一层“字符到标签”的过渡特征。我一般会取last_hidden_state也就是model(...).last_hidden_state或老版本里的sequence_output然后加一个维度投影把 768 维降到 BiLSTM 的hidden_size比如 256。class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.proj nn.Linear(768, lstm_hidden) self.bilstm nn.LSTM( input_sizelstm_hidden, hidden_sizelstm_hidden // 2, num_layers2, batch_firstTrue, bidirectionalTrue ) self.dropout nn.Dropout(dropout) self.hidden2tag nn.Linear(lstm_hidden, num_tags) self.crf CRF(num_tags, batch_firstTrue)参数说明lstm_hidden256表示 BiLSTM 两个方向的隐藏单元数各自为 128拼起来是 256。num_layers2在数据量不大的情况下效果比单层好但参数量翻倍如果训练集只有几千句改成 1 层更不容易过拟合。dropout0.5是常用值但如果你发现训练 loss 降不下去可以降到 0.3。注意这里CRF类是自定义的不是 transformers 里的库。3.2 BiLSTM 层序列特征压缩与 dropout 位置BiLSTM 的核心价值是捕捉句子内部的局部依赖比如“一个词是 I-ORG 之前必须要有 B-ORG 开头”。Transformer 虽然也能做到但需要额外的位置编码和更深的层。BiLSTM 在 NER 这种包含连续多个标签的任务上给了模型一个显式的“双向时间步”归纳偏置。常见错误是把 dropout 加在 LSTM 输入和输出上都加导致特征被过度随机化。我的做法是LSTM 的输出先经过 dropout再做线性层LSTM 内部的层间 dropout 用默认 0 就好。def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state # [B, L, 768] seq_out self.dropout(seq_out) seq_out self.proj(seq_out) # [B, L, 256] seq_out, _ self.bilstm(seq_out) # [B, L, 256] seq_out self.dropout(seq_out) emissions self.hidden2tag(seq_out) # [B, L, num_tags] if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool()) return loss else: return self.crf.decode(emissions, maskattention_mask.bool())逻辑说明attention_mask既用于 Bert也用于 CRF 的 mask保证 padding 位置不参与损失计算和解码。这里没有对attention_mask做任何变形因为batch_first模式下形状本来就是[B, L]。很多人忽略mask参数导致 padding 部分也被当作真实标签最终 F1 偏低。参数说明proj层的存在是因为 Bert 的输出 768 维太大直接接 BiLSTM 会让 LSTM 参数量爆炸先降到 256 再送进去。如果你显存紧张可以把lstm_hidden改成 128但 F1 可能掉 1 个点左右。还有一个细节BiLSTM 的初始隐藏状态不指定PyTorch 默认全零实测中如果不做特殊初始化效果区别不大。3.3 CRF 层转移矩阵和维特比解码CRF 做的事情是给整个标签序列打分而不是给每个位置单独打分。它维护一个可学习的转移矩阵形状是[num_tags, num_tags]其中trans[i][j]表示从标签 i 转移到标签 j 的分数。这个矩阵能学到很多硬规则比如 O 可以转移到 B-XXX但不能转移到 I-XXXB-PER 之后可以跟 I-PER 或 B-XXX但不要跟 I-ORG。这些约束在数据里天然存在CRF 学得比手写规则好。class CRF(nn.Module): def __init__(self, num_tags, batch_firstTrue): super().__init__() self.num_tags num_tags self.batch_first batch_first self.start_trans nn.Parameter(torch.randn(num_tags)) self.end_trans nn.Parameter(torch.randn(num_tags)) self.trans nn.Parameter(torch.randn(num_tags, num_tags)) def forward(self, emissions, tags, mask): return self._log_likelihood(emissions, tags, mask)这里我简写了自定义 CRF 的设计骨架完整实现需要写维特比算法篇幅很长。推荐直接使用开源库pytorch-crf也就是pip install pytorch-crf然后from torchcrf import CRF。这个库在业内被广泛使用接口简单支持 mask唯一的坑是它要求输入emissions的形状是[seq_len, batch_size, num_tags]所以你需要在传给 CRF 前把batch_first转成seq_first。用上面代码里的batch_firstTrue可以直接支持但要确认库版本是否支持老版本不支持。参数说明num_tags中包含O和所有B-XXX/I-XXX。不要漏掉start_trans和end_trans这是序列开始和结束的独特转移。有些简化实现会把这些忽略导致解码时第一个标签的概率偏低。如果你发现模型预测的句子第一个字经常被标成 I八成是这里出了问题。4. 从零跑通训练代码核心模块、损失函数与优化器配置4.1 数据加载器与 padding 掩码数据准备好后需要把它变成 PyTorch 的Dataset和Dataloader。关键是把句子里的每个字对应到 Bert 的 token id然后统一 padding 到 batch 内最长长度而不是全数据集最大长度这样能省显存。标签也要做同样的 padding且 padding 位置的标签值必须是不参与 loss 的数比如 -100 或一个特殊值配合 mask 屏蔽。from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, sentences, labels, tokenizer, tag2idx): self.sentences sentences self.labels labels self.tokenizer tokenizer self.tag2idx tag2idx def __len__(self): return len(self.sentences) def __getitem__(self, idx): words self.sentences[idx] tags self.labels[idx] # 用 Bert tokenizer 把每个字转成 id tokens [self.tokenizer.cls_token_id] self.tokenizer.convert_tokens_to_ids(words) [self.tokenizer.sep_token_id] label_ids [self.tag2idx[O]] [self.tag2idx[t] for t in tags] [self.tag2idx[O]] return tokens, label_idsdef collate_fn(batch): max_len max(len(t[0]) for t in batch) input_ids, labels [], [] for tokens, label_ids in batch: pad_len max_len - len(tokens) input_ids.append(tokens [0] * pad_len) labels.append(label_ids [-100] * pad_len) return torch.tensor(input_ids), torch.tensor(labels)逻辑说明cls和sep位置被标记为O注意 padding 位置我们用 -100 而不是 0 或 O因为 -100 在计算损失时会被忽略。collate_fn里的[0] * pad_len是 Bert 的 padding token id一般不用单独设置因为后续传给 Bert 时attention_mask会自动处理。参数说明tag2idx是标签到索引的映射例如{O:0, B-PER:1, I-PER:2, ...}。如果语料里有 BIOES 标签映射关系要同步。还有一个点如果句子长度超过 512Bert 会报错需要在collate_fn里截断常见做法是保留前 510 个字加上 cls 和 sep。4.2 损失函数CRF 的负对数似然怎么算NER 任务本质是一个序列标注任务损失不是普通的交叉熵而是 CRF 的负对数似然。CRF 计算所有可能的标签序列的分数总和损失就是真实序列的分数除以所有序列分数的总和再取负对数。PyTorch 的CrossEntropyLoss是逐位置独立计算不考虑转移约束所以不能用它。def train_one_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss 0 for input_ids, labels in dataloader: input_ids input_ids.to(device) labels labels.to(device) attention_mask (input_ids ! 0).to(device) optimizer.zero_grad() loss model(input_ids, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() return total_loss / len(dataloader)逻辑说明attention_mask直接用input_ids ! 0生成因为 padding token id 是 0而 cls/sep 都是非 0。clip_grad_norm_是 BiLSTM 训练里最关键的技巧LSTM 梯度容易爆炸把梯度范数限制在 1.0 能稳定训练。这里scheduler.step()每个 batch 都调用不是每个 epoch。参数说明max_norm1.0是经验值如果 loss 出现 NaN可以降到 0.5。如果训练集比较小把max_norm调到 5.0 也能收敛但需要你多做几次实验。注意loss.backward()之后必须 clip然后再optimizer.step()顺序反了等于白做。4.3 训练循环学习率、warmup 与梯度裁剪Bert 类模型的训练参数跟普通 CNN 不同。一般我用 AdamW学习率设在2e-5到5e-5之间BiLSTM 部分可以用相对大一点的 1e-3因为它是随机初始化的Bert 是预训练的。常见做法是给 Bert 参数和分类层参数分别设置不同的学习率组。from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.proj.parameters(), lr: 1e-3}, {params: model.bilstm.parameters(), lr: 1e-3}, {params: model.hidden2tag.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3}, ]) total_steps len(dataloader) * epochs scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps)逻辑说明get_linear_schedule_with_warmup是 transformers 提供的调度器前 10% 的 step 学习率线性增加后面线性衰减到 0。这能避免 Bert 在第一步就冲得太猛把预训练特征破坏掉。如果你不想引入 transformers 的调度器也可以手动调学习率但 warmup 几乎成为标配。参数说明epochs根据数据量定我一般先跑 10 个 epoch 观察验证集 F1如果还在涨就继续如果验证集掉、训练集不掉说明过拟合把 dropout 调大或把 BiLSTM 层数减少。你还会看到有人用FocalLoss处理实体类别不均衡但配 CRF 时不是必须因为 CRF 已经处理了标签依赖简单加权本地损失并不好用。5. 避坑指南Bert-BiLSTM-CRF 最常见的 5 个翻车点5.1 现象loss 为负数验证集 F1 却是 0有的项目里 CRF 的 loss 是负数很多人以为计算错了。其实 CRF 的负对数似然理论上可以大于 0但如果你实现的是直接用crf(emissions, tags)的负值而该函数内部本身就返回负对数似然再取负就变成正数。常见现象是 loss 从正数变成负数且波动剧烈原因是 mask 没有传给 CRFpadding 部分填充的标签 -100 被当成真实标签参与计算导致转移矩阵学歪了。解决办法检查 CRF 的前向函数是否接收mask并确保labels中 padding 位置的值是 -100 或其他不会出现在标签映射中的值同时mask对应位置是 False。如果你用pytorch-crf库它不支持 -100 自动忽略必须把 padding 标签改成 0即 O然后用 mask 屏蔽。我一般直接重写一个带 -100 忽略的 CRF 实现或者把 padding 的标签设成 0 并用 mask 处理。5.2 现象显存 OOMbatch4 也报 CUDA out of memoryBERT 是显存大户模型本身约 400MB加上激活值最大长度 512 的时候batch 大小只能开到个位数。常见错误是直接把全数据集的最大长度作为 batch 内 padding 长度导致短句子也被填充到 512。解决办法在collate_fn里按当前 batch 的最大长度动态 padding然后关闭梯度计算时只计算推理部分。还可以打开 PyTorch 的torch.utils.checkpoint对 Bert 做梯度检查点从而省下激活内存代价是训练慢 30% 左右。实践中最直接的是把max_length限制在 128 或 256对大多数中文 NER 句子已经够用因为实体很少跨长句。5.3 现象预测结果全是 O没有任何实体模型训练完decode 出来的标签全是 O。很多人的第一反应是标签映射写错了但其实更常见的原因是 CRF 的转移矩阵偏向 O因为 O 在训练集中占比 90% 以上。如果学习率太大或者 epoch 太少模型学到的只是“多数类”的偏向。解决办法查看训练 loss 是否还在下降如果 loss 下降但验证 F1 一直是 0说明标签对齐有问题。很多时候是从 Bert 的 WordPiece 到原始字符层的对齐错了中文一个字就是一个 token但如果句子里有英文或数字tokenizer 可能把它们拆成多个 subword导致标签序列长度和词表长度不匹配。我在处理中文语料时会要求把所有英文数字转成 [UNK] 或按单字符处理避免对齐坑。5.4 现象加载模型后 predict 结果和训练时不一致训练完保存model.state_dict()重新加载后预测结果错得离谱。常见原因是你只保存了模型参数却忘记保存标签映射和 tokenizer。另一个坑是model.eval()没调用PyTorch 里 dropout 和 layer norm 在训练和推理行为不同不切换模式会让结果随机。解决办法保存时同时保存tag2idx和idx2tag还有 tokenizer 的vocab.txt。加载时用同一个 tokenizer 和同一个标签映射。我一般会写一个save_model函数把字典打包成model_config.json放在模型目录下推理时统一从目录读取避免散落各处。import torch from transformers import BertTokenizer model_dir ./saved_model torch.save(model.state_dict(), f{model_dir}/pytorch_model.bin) with open(f{model_dir}/tag2idx.json, w) as f: json.dump(tag2idx, f) tokenizer.save_pretrained(model_dir)推理时model.load_state_dict(torch.load(f{model_dir}/pytorch_model.bin, map_locationcpu)) model.eval()逻辑说明map_location在没有 GPU 的机器上加载时很关键否则会报 “GPU not available” 的错误。model.eval()放在load_state_dict之后、推理之前。如果还不对检查输入文本在预处理时是否做了strip()空格和换行都会影响 tokenizer 的切分结果。5.5 现象CPU 推理慢到无法接受Bert 在 CPU 上跑一句话需要几百毫秒到几秒如果业务要求实时直接上 GPU 是最简单的方案。但如果你只有 CPU可以考虑把 Bert 换成轻量模型比如distilbert-base-chinese或者把 BiLSTM 换成 CNN 的时序卷积但这已经偏离了标题。另一个常见优化是转成 ONNX用torch.onnx.export导出再配合onnxruntime的 CPU 推理速度能提升 2-5 倍。pip install onnx onnxruntimetorch.onnx.export( model, (dummy_input_ids, dummy_mask), bert_bilstm_crf.onnx, opset_version13, input_names[input_ids, attention_mask], output_names[decoded_tags], dynamic_axes{input_ids: {0: batch, 1: seq}, attention_mask: {0: batch, 1: seq}} )逻辑说明CRF 的维特比解码包含循环分支导 ONNX 时可能不支持全部动态特性。我常做的方案是先导出 BertBiLSTM 的 emissionsCRF 的解码留在 Python 里算这样既快又可控。但实际项目中如果你只是做批量离线推理直接用 PyTorch 加torch.no_grad()也能接受不要把优化放在第一步。参数说明opset_version13是 ONNX 的标准版本过新或过旧都可能触发兼容问题。导出时需要固定模型为 eval 模式否则 dropout 会被埋进图里。另外dynamic_axes必须配置否则输入长度只能固定实际使用价值大打折扣。6. 进阶用 Bert-BiLSTM-CRF 部署自己的中文 NER 服务6.1 用 PyTorch 导出模型状态与保存 tokenizer当你训练收敛后需要把模型做成可复用的交付物。不要只保存state_dict还得保存模型结构信息Bert 路径、标签数、hidden 维度以及标签映射。我把所有这些放进一个配置 JSON 里加载时按配置还原模型类。import json config { bert_path: ./pretrained/bert-base-chinese, num_tags: len(tag2idx), lstm_hidden: 256, dropout: 0.5, tag2idx: tag2idx, idx2tag: {str(i): tag for tag, i in tag2idx.items()} } with open(f{model_dir}/config.json, w, encodingutf-8) as f: json.dump(config, f, ensure_asciiFalse)注意PyTorch 官方推荐保存state_dict而不是torch.save(model)因为后者会把模型类的位置耦合进去换代码目录后容易加载失败。保存state_dict之后加载时用相同的代码定义模型结构再load_state_dict。6.2 一个简单的 Flask 预测接口实际部署时不需要把 PyTorch 整个服务暴露出去通常用一个轻量接口包装模型推理。下面是一个用 Flask 写的 POST 接口请求体为{text: 今天小明去北京}返回 JSON 包含实体列表。from flask import Flask, request, jsonify import torch app Flask(__name__) # 全局加载一次模型避免每次请求 reload model None tokenizer None idx2tag None def init(): global model, tokenizer, idx2tag cfg json.load(open(f{model_dir}/config.json)) tokenizer BertTokenizer.from_pretrained(model_dir) model BertBiLSTMCRF(cfg[bert_path], cfg[num_tags]) model.load_state_dict(torch.load(f{model_dir}/pytorch_model.bin, map_locationcpu)) model.eval() idx2tag {int(k): v for k, v in cfg[idx2tag].items()} def predict(text): tokens tokenizer(text, return_tensorspt) input_ids tokens[input_ids] attention_mask tokens[attention_mask] with torch.no_grad(): tags model(input_ids, attention_mask) tags tags[0] # 去掉 batch 维度 words list(text) entities [] cur_name cur_type start_idx 0 for i, tag_id in enumerate(tags): tag idx2tag[tag_id] if tag.startswith(B-): if cur_name: entities.append({start: start_idx, end: i-1, type: cur_type, word: cur_name}) cur_name words[i] cur_type tag[2:] start_idx i elif tag.startswith(I-): if cur_name: cur_name words[i] else: if cur_name: entities.append({start: start_idx, end: i-1, type: cur_type, word: cur_name}) cur_name if cur_name: entities.append({start: start_idx, end: len(words)-1, type: cur_type, word: cur_name}) return entities app.route(/ner, methods[POST]) def ner(): data request.get_json() text data[text] return jsonify({entities: predict(text)}) if __name__ __main__: init() app.run(host0.0.0.0, port8000)逻辑说明接口里直接用tokenizer(text)会得到[CLS]... [SEP]的序列。我解码预测结果后通过字符位置找回实体文本。这里没有处理 Batch 请求实际并发每秒可能只有几个请求因为 CPU 推理较慢。如果并发高考虑用多进程加载多个模型实例但每份模型约 400MB内存压力很大。参数说明map_locationcpu保证没有 GPU 的机器也能加载。with torch.no_grad()是推理必需的否则每步都保存梯度显存和内存都会翻倍。如果你有多张 GPU可以给model.parallelize()但界面接口一般不用这么复杂。6.3 验证与回归在测试集上计算 F1 的注意点部署前一定要在测试集上跑一遍 F1而且要用严格的实体级评估不是标签级准确率。实体级要求预测实体的边界和类型完全匹配才算对。常见错误是直接比较两个序列的标签忽略了连续实体的合并。def evaluate_f1(model, dataloader, idx2tag, device): model.eval() true_entities [] pred_entities [] with torch.no_grad(): for input_ids, labels in dataloader: input_ids input_ids.to(device) attention_mask (input_ids ! 0).to(device) pred_tags model.decode(input_ids, attention_mask) # 把真实标签转成实体集合对比计算 precision/recall/F1具体实体抽取函数我这里不展开了核心逻辑是遍历每个位置当标签为 B-XXX 时开始累积到 O 或下一个 B 结束。这样算出来的 F1 才是业界标准。我在实际项目中经常看到有人用标签级准确率 99% 来衡量但实体级 F1 只有 70%那部署出去业务根本没法用。6.4 最后一手用早停和模型快照减少调参焦虑训练 NER 模型最怕的是晚上睡觉前把 epoch 设成 50第二天一看验证集 F1 在 epoch 15 之后一路往下掉。我习惯写一个EarlyStopping监控验证集 F1连续 3 个 epoch 不再提升就停并自动保存最优模型参数作为“后悔药”。这个技巧不需要改模型结构只需要在训练循环里加一个计数器。best_f1 0 patience 3 bad_epochs 0 for epoch in range(epochs): train_loss train_one_epoch(...) val_f1 evaluate_f1(...) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), f{model_dir}/best.pth) bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: print(fEarly stop at epoch {epoch}) break这里有个细节保存最佳模型时我用的不是模型最后一步的参数而是保存最佳状态后训练循环结束再加载一次best.pth作为最终模型。这个习惯帮我避免了无数次“明明之前跑出过 90 分重跑却只有 85”的玄学问题。也希望这个做法对你有用希望这些参数和避坑记录能帮你把 Bert-BiLSTM-CRF 真正落地到自己的数据上。本文还有配套的精品资源点击获取