简介:一份基于PyTorch的BERT-BiLSTM-CRF命名实体识别(NER)实战项目,面向NLP学习者与开发人员,展示了如何将预训练语言模型与序列标注模型结合,完成从文本预处理到实体识别的完整流程。压缩包共19个文件,包含6个Python脚本(模型定义、训练、预测、CRF实现、工具函数)、2个Jupyter Notebook(数据拆分与预测演示)、5个txt文件(示例数据与BIO处理结果),另有配置文件、README、LICENSE、.gitignore等,整体仅3.95MB。项目结构清晰,代码与数据分离,可直接运行调试。已有936人学习浏览。资源内含完整可复现的训练、验证、预测代码,以及示例训练/验证/测试数据和多种BIO标注中间结果,能直观看到原始语料到BIO标注的转换过程。配合Notebook可逐步理解数据加载、模型搭建和评估细节,同时为基于BERT的序列标注任务提供了可扩展的基础框架,适合课程设计、毕业设计或入门NLP实践参考。
1. Bert-BiLSTM-CRF是什么:一次把实体识别拉回可落地的组合选择
如果你做中文命名实体识别(NER),一定绕不开这个组合:Bert负责把每个字编码成上下文向量,BiLSTM在字级别再过一道序列特征,最后用CRF约束标签转移的合法性,比如“B-Person后面不能跟I-Location”。这个PyTorch项目模板解决的就是从原始文本里抽人名、地名、机构名、时间等实体的任务。你可以直接拿它做客服日志里的产品名抽取、医疗文本中的症状与药品识别、合同里甲方乙方抽取。它的价值在于:把深度学习的泛化能力和结构化约束揉在一个端到端模型里,新手也能在阅读代码后跑通,但想要调出好效果,得理解三个子模块各自在做什么。下面我就把它讲透,顺手给你一份能复现的最小代码和踩坑清单。
2. 拆开Bert-BiLSTM-CRF:三个组件为什么能共生而非鸡肋
2.1 BERT输出的字向量,到底在表达什么
BERT是一个双向Transformer编码器。输入是token ids、attention_mask和segment_ids,输出每个token的上下文向量。在NER任务里,这个向量是整个模型的“前菜”,决定了模型能不能理解一词多义。比如“苹果”在“苹果公司发布新手机”里是组织名,在“我吃了一个苹果”里是食物。BERT通过上亿参数的大规模语料预训练,把这种上下文感知能力压缩进了模型权重。落地时我们通常选择bert-base-chinese这类中文预训练权重,hidden_size是768,12层。它输出的是序列级特征,不是标签概率,所以后面必须接解码头。
有一个常见的认知误区是:既然BERT这么强,那直接把最后一层输出过一个线性层加softmax不就完成分类了吗?确实可以,但这样会把序列标注当成逐token独立分类,完全忽略标签之间的依赖。比如“B-Person后接I-Location”这种转移,在整个中文语料里几乎不存在,独立分类不会对此有任何约束。你会发现模型单独看每个token预测得挺准,但整条路径拼起来漏洞百出。BERT解决的是“这个字是什么”,CRF解决的是“这一串标签成不成句”。
BERT自己也有位置编码,但这种位置编码表达的是词序的相对位置,不是BIO这种标注语法规则。你说不清BERT哪里能惩罚“O后面接I-Person”的非法路径,它只负责给出高质量的语义特征。因此我可以把BERT定性为最强大的特征提取器,而不是解码器。它的输出形状是[batch_size, seq_len, hidden_size],这个维度为后续BiLSTM提供输入。
2.2 BiLSTM:在BERT面前它是不是只能拖后腿
BiLSTM即双向长短期记忆网络,分别从头到尾和从尾到头读一遍序列,再把两个方向的隐藏状态拼起来。很多人说Transformer时代LSTM已经过时,但在短序列NER上,它依然是个实用的中间层。原因有三个:第一,BiLSTM的递归结构天然建模“从左向右的标注流”,是一种有方向的上下文,这和CRF的路径解码逻辑更一致;第二,BiLSTM参数量小,hidden_size=256时,双向LSTM参数量约20万,和BERT的上亿参数比几乎可以忽略,训练开销增量主要来自它的门控计算;第三,BiLSTM能对BERT输出的768维向量做一次非线性重组,把隐层维度降下来,减小CRF转移矩阵的输入规模,这能缓解小数据上的过度参数化。
我在工程中一般用hidden_size=256,双向拼接后是512维,再接线性层映射到标签类别数。如果实体类型比较多,比如细粒度医学实体有20多类,我习惯把hidden_size降到128,防止CRF转移矩阵学过头。BiLSTM的dropout设置在0.1~0.3。对于几百条的小数据,dropout设成0.5也合理,但要配合早停,否则欠拟合。有些实验直接从Bert到CRF,不要BiLSTM,在超大规模数据下F1差距不大,但在中等规模数据上,BiLSTM通常能带来1到2个百分点的提升。所以这个中间环节不是凑数,而是一个低成本高收益的结构设计。
很多开源代码里把LSTM层数写成2层,其实没有必要。层数增加不仅带来显存和时间成本,还让CRF的梯度在回传时衰减更慢。我在一个中文法律文本数据集上做过对比,1层的F1比2层高0.4个百分点,还省了至少30%内存。所以如果任务不是长垂领域,坚持1层更香。BILSTM还有一个隐藏的好处:它的输出和输入是时间步对齐的,这天然匹配序列标注,不需要额外做position-wise操作。你把BERT输出的768维向量按时间步依次送进LSTM,得到的每个时间步隐藏状态都是对当前字和前序字、后序字的再一次融合,这种融合后的特征送到CRF,会比直接拿BERT最后一层好调得多。
2.3 CRF的转移矩阵,是怎么变成一条合法路径的
CRF是条件随机场的缩写,在NER里它就是解码层。除了维护一个发射得分,还维护一个标签转移矩阵[T, T],T是标签类别数。这个矩阵中元素M[i,j]表示标签i在某个位置之后,下一个位置变成标签j的得分。训练时,模型对每个token给出发射得分,加上转移得分,得到一整条路径的得分。真正的损失函数是真实路径的负对数似然,也就是让真实路径得分在所有可能路径得分之和中所占比例最大。推理时用维特比算法做动态规划,从所有可能路径中找出总得分最高的一条。
CRF是这项组件的灵魂,它让标签预测从“独立分类”变成“结构化预测”。比如你的标签体系是B-Person, I-Person, B-Location, I-Location, O,训练数据里从未出现B-Person后接I-Location,CRF就会在训练中把这个转移得分压得非常低,最后几乎不可能被解码出来。这种硬约束在规则里写起来很麻烦,但CRF一个矩阵就学完了。另外,CRF还能自动学会“O后面必须是B而不是I”,因为I标签必须跟在同类型B后面,这是BIO标注的天然约束,不写代码也能被CRF编码。
实现上,我习惯用torchcrf库。它封装了前向得分、负对数似然和维特比解码,接口稳定。但必须记住:CRF的前向计算需要mask来指明哪些位置是有效token,padding位置需要被排除。如果mask被忽略,CRF就会把padding也当成合法状态去转移,导致训练时学出一堆“O到O”的噪声路径,验证时输出无意义的空实体甚至跨实体片段。这类错误不容易在loss数值上直接察觉,需要仔细观察decode输出。
标签集的设计也会直接影响CRF效果。BIO标注比IO标注多一个类别维度,转移矩阵变得更细,但也更容易过拟合。当训练数据不足500条时,我建议退回IO标注或只使用BIO,因为IOES的边界标签(E、S)会让转移矩阵膨胀到原来的2.5倍,在数据稀疏时学不动。CRF转移矩阵初始化一般为均匀分布,不要手动设成对角矩阵,那会诱导模型只喜欢原地打转。到这里,原理层面已经说清:BERT提供特征,BiLSTM整理次序,CRF保证合法性。这三者不是机械拼接,而是让每个模型做自己最擅长的事。接下来进入实操,我会用最小代码把模型在本地跑起来。
3. 从零跑通一个Bert-BiLSTM-CRF:环境、数据和三个代码块
3.1 安装环境:PyTorch和transformers的版本搭配
动手第一步是建一个干净的Python环境。常见做法是用Anaconda创建虚拟环境,然后安装PyTorch。如果你有NVIDIA显卡,先在PyTorch官网找到匹配你CUDA版本的安装命令;如果没有显卡,CPU版也能跑通小数据,只是慢得让人想放弃。我建议在模型开发阶段至少用一张显存6G以上的卡,因为BERT本身要占掉1G,加上梯度、优化器状态和中间激活,batch_size=16时差不多要3G以上。
下面这组安装命令是我在Ubuntu 20.04上经常用的组合:
# 创建并激活环境 conda create -n bert_ner python=3.8 conda activate bert_ner # 安装pytorch,注意cuda版本要和本机驱动兼容 pip install torch==1.13.1 torchvision==0.14.1 torchaudio==0.13.1 --extra-index-url https://download.pytorch.org/whl/cu117 pip install transformers==4.30.2 pip install torchcrf seqeval这里为什么把版本卡这么死?torchcrf很老,它依赖torch的某些接口,新版torch大体兼容,但偶尔会有api变动导致报错。transformers的新版则把BertModel的加载逻辑改了不少,旧代码里的参数名可能不再识别。这组版本是2023年经过用户群验证的稳定搭配。如果你用Python 3.9以上,可以装torch 2.x,但注意transformers也要升到4.36以上,否则会提示缺少某些内部模块。
装完后不要急着跑,先验证PyTorch能不能用显卡:
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"这段检查代码会输出两个信息:torch版本和CUDA是否可用。如果输出False,大概率你装的是CPU版。去官网重新下匹配的wheel,或者把--extra-index-url中的cu117改成你本机CUDA版本。这一步看似基础,却是很多pytorch项目跑不起来的第一道坎。除了核心库,还需要一个包叫seqeval,它用于实体级评估。torchcrf在PyPI上的名字是torchcrf,不要拼错。
3.2 数据准备:原始文本转BIO token ids
先定一个标签集。比如我们抽取症状和药品两类实体,标签为O、B-Symptom、I-Symptom、B-Drug、I-Drug,共5类。原始数据一行一个样本,格式是“text\t标签序列”,标签之间用空格分隔。中文BERT的tokenizer会把句子切成单个字符,大部分中文是一个token一个字符,但数字、英文连写或生僻字可能被切成subword,这会导致标签数量对不上。最稳妥的做法是:先用tokenizer.encode拿到word_ids,再根据word_ids把标签映射到每个token上。不过为了展示最简单流程,下面的函数假设切出的token和人工标注的token一一对应:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') label2id = {'O':0, 'B-Symptom':1, 'I-Symptom':2, 'B-Drug':3, 'I-Drug':4} id2label = {v:k for k,v in label2id.items()} def encode_with_labels(text, labels, max_len=64): tokens = tokenizer.tokenize(text) if len(tokens) != len(labels): raise ValueError(f"tokens长度{len(tokens)}与labels长度{len(labels)}不一致") token_ids = tokenizer.convert_tokens_to_ids(['[CLS]'] + tokens + ['[SEP]']) label_ids = [0] + [label2id[label] for label in labels] + [0] attention_mask = [1]*len(token_ids) if len(token_ids) < max_len: pad_len = max_len - len(token_ids) token_ids += [tokenizer.pad_token_id]*pad_len label_ids += [label2id['O']]*pad_len attention_mask += [0]*pad_len else: token_ids = token_ids[:max_len] label_ids = label_ids[:max_len] attention_mask = attention_mask[:max_len] return token_ids, label_ids, attention_mask代码说明:[CLS]和[SEP]两个特殊token的标签设为O,padding位置的标签也是O,但attention_mask设为0,这样BERT不会把padding纳入注意力计算,CRF也会在路径得分里忽略它们。如果tokenizer切出来的token数量和标签数量不一致,我这里直接抛异常。真实业务里要做一个对齐逻辑,遍历每个token的word_ids,取该词对应的第一个字符的标签,通常这样能覆盖大多数切分情况。
数据加载我习惯用PyTorch的Dataset和DataLoader。Dataset的返回值是一个字典,包含token_ids、attention_mask、label_ids,每个字段都已转成tensor。既然目标是展示模型结构,数据部分不再堆更多代码。你只需要构造一个list of samples,每个sample是这三个列表,然后用DataLoader(dataset, batch_size=8, shuffle=True)批量送进去。
3.3 核心模型:用PyTorch定义Bert-BiLSTM-CRF
模型定义是整个项目的核心。注意几个要点:BERT的from_pretrained会加载预训练权重并自动冻结部分层(除非你在requires_grad里放开),这里我保持全参数可训练,但要配合分层学习率。LSTM用bidirectional=True,方向数传拉伸到hidden_size * 2。分类器一个线性层足够,不需要多余的非线性,因为CRF已经是很强的结构化解码器。
import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_name='bert-base-chinese', num_tags=5, lstm_hidden=256, lstm_layers=1, dropout=0.2): super().__init__() self.bert = BertModel.from_pretrained(bert_name) self.lstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=lstm_hidden, num_layers=lstm_layers, batch_first=True, bidirectional=True, ) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(lstm_hidden * 2, num_tags) self.crf = CRF(num_tags) def forward(self, token_ids, attention_mask): outputs = self.bert(input_ids=token_ids, attention_mask=attention_mask) sequence_output = outputs.last_hidden_state lstm_out, _ = self.lstm(sequence_output) lstm_out = self.dropout(lstm_out) emissions = self.classifier(lstm_out) return emissions def loss(self, token_ids, attention_mask, label_ids): emissions = self.forward(token_ids, attention_mask) mask = attention_mask.bool() return -self.crf(emissions, label_ids, mask=mask, reduction='mean') def decode(self, token_ids, attention_mask): emissions = self.forward(token_ids, attention_mask) mask = attention_mask.bool() return self.crf.decode(emissions, mask=mask)这段代码的逻辑说明:forward返回的是发射得分,形状[B, L, num_tags],不代表概率。loss把发射得分、真实标签和mask传给CRF,CRF返回正的对数似然,外面加负号就是损失。decode直接返回维特比最优标签序列,每个样本的长度可能不同,因为mask把padding位置截掉了。这在预测后处理时很方便,不需要额外过滤。
参数细节:bert.config.hidden_size对中文base模型是768。lstm_hidden设256时,classifier输入是双向拼接后的512维。num_layers我一般保持1层,多层的LSTM在小数据集上容易过拟合,而且每多一层,反向传播的耗时至少增加30%。dropout放在LSTM输出后面,不对BERT内部dropout生效。如果你在训练中发现验证F1严重抖动,可以把dropout从0.2提到0.5。
3.4 训练循环:分层学习率、梯度裁剪与长期不收敛排查
训练阶段,优化器和损失函数的配合比模型结构更容易出问题。BERT部分建议使用较低学习率,BiLSTM、线性层和CRF可以使用较高的学习率,因为前者有大量预训练参数,后者是从零开始。AdamW是transformers官方推荐的优化器,它实现了权重衰减解耦,能防止一类论文中常见的L2正则效果被冲淡。
from transformers import AdamW from tqdm import tqdm import torch.nn.utils as utils model = BertBiLSTMCRF() optimizer = AdamW([ {'params': model.bert.parameters(), 'lr': 2e-5}, {'params': model.lstm.parameters(), 'lr': 1e-3}, {'params': model.classifier.parameters(), 'lr': 1e-3}, {'params': model.crf.parameters(), 'lr': 1e-3}, ]) dataloader = DataLoader(trainset, batch_size=8, shuffle=True) for epoch in range(10): model.train() total_loss = 0.0 for step, batch in enumerate(tqdm(dataloader)): loss = model.loss(batch['token_ids'], batch['attention_mask'], batch['label_ids']) optimizer.zero_grad() loss.backward() utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() print(f"epoch {epoch+1}, avg_loss {total_loss / len(dataloader):.4f}")这里的梯度裁剪max_norm=5.0是我长期调出来的经验值,太小的裁剪会让训练非常慢,太大则无法阻止梯度爆炸。我见过很多新手训练三个epoch后loss仍然在0.8~1.2附近下不去,其实不是模型问题,而是没做clip_grad_norm_。CRF的似然项在长句子上会累积较大的梯度,不裁一下直接让权重更新跨了一大步。
关于训练轮数,数据量在1万条以内时,我建议10~20个epoch,并且每个epoch都计算验证F1,保存最佳模型。如果loss从第二epoch开始不再下降,可以试着把BERT部分lr提高到5e-5,但不要超过1e-4,否则前面的语义特征会被快速破坏。序列标注任务最容易犯的错误是让BERT的学习率和其他层一致,这是过拟合最快的通道。
4. 训练中那些防不胜防的坑:5条必须背下的排错记录
4.1 IndexError: Target size (32, 512) must be the same as input size (32, 512, 13)
现象:训练脚本跑到loss计算时,报出shape不匹配,提示input是三维张量而target是二维张量。你以为是CrossEntropyLoss的使用问题。
原因:你大概率没用CRF的loss,而是把model.forward()的输出当成了logits,手动套了nn.CrossEntropyLoss()。但CRF的目标是整条标签路径,不是逐token分类。forward返回的emissions是三维的,CrossEntropyLoss需要input是[B,C,L]或[B,L,C],你这里[B,L,C]放在第二位不是类别维,所以报错。解决:不要用CrossEntropyLoss,调用我们定义的model.loss()。如果非要用CE,那就不需要CRF了,模型结构就变了。
这个坑在BERT+BILSTM+CRF的初学人群中特别常见,因为很多早期复现代码会把CRF的loss封装在model里,一部分人自己重写forward时就漏了。记住:CRF的输入是整条路径的发射得分,不要手动把维度reshape成二维来迎合CE。这个报错还有一个变种:input size是[16, 64, 13],target size是[16, 64],但用的是CRF的negative log likelihood,报错说target shape不匹配。这时请检查label_ids是否被softmax过,或者是否被one-hot过。CRF需要integer标签,long类型,值域0到num_tags-1。
4.2 预训练模型下载超时,或者加载到一半中断
现象:BertModel.from_pretrained('bert-base-chinese')运行到Downloading ...后长期无响应,或下载到90%断掉。公司内网环境尤其严重。
原因:HuggingFace默认从huggingface.co下载模型权重,这个域名在部分环境中访问不稳定。解决:提前下载到本地目录,然后用本地路径替代。文件包括config.json、pytorch_model.bin、vocab.txt等。你可以通过镜像站获取,下载完成后放到项目下的./bert-base-chinese/,然后代码改为BertModel.from_pretrained('./bert-base-chinese')。
另一个解决方式是通过环境变量配置镜像,比如设置HF_ENDPOINT指向镜像地址,但需要注意镜像的稳定性。我这里推荐直接手动下载存本地,因为模型文件大小约400MB,下载一次可以反复使用,避免了每次启动都检查远程版本。下载后检查文件是否完整,可以用ls -l看大小,也可以sha256sum和官方对比,如果大小不对,很可能是运营商缓存了损坏文件,重新下载即可。有些公司的内网会把huggingface.co屏蔽,这时只能走离线包或内部CDN,提前把模型放到共享存储,比每次从零下载省太多。
4.3 CRF的mask忘记传,导致padding位置出现实体标签
现象:验证时decode输出中,句子末尾的padding token上出现了I-Person或I-Location,看起来像是一串无意义的标签被拼在真实实体后面。
原因:crf.decode()在没有mask时,把padding也当成普通token去搜路径。虽然BERT通过attention_mask忽略了padding,但CRF不知道哪些位置是padding,它的维特比算法会在padding位置上做转移,得到这些鬼产物。解决:在decode和loss方法中,都要显式传入mask,也就是attention_mask.bool()。我自己的代码里,decode的签名永远是decode(self, token_ids, attention_mask),训练和推理都执行这个接口,从不让CRF在没有mask的情况下工作。
要彻底理解这个坑,需要明白CRF路径搜索的数学过程:一条路径的得分是所有位置发射得分加上全部转移得分的总和,加法会遍历每一个位置,包括padding。如果padding位置也有发射得分,维特比当然会把它们算进去。唯一的例外是你的padding长度为零,但这几乎不可能。所以,早早在模型封装里强制要求mask,比在训练脚本里手动传更可靠。我在模型预测封装里,规定输出时直接过滤掉标签为O的positions,不需要手动处理padding,因为CRF的mask已经让padding位置的decode输出为空。如果decode返回的长度比句子长,就要怀疑mask是否生效。
4.4 第一个epoch后loss变成NaN
现象:训练几个batch后,loss输出nan,之后一直是nan,模型输出的标签全部变成0。
原因:最常见的是学习率过大或梯度爆炸。BERT预训练的数值范围和随机初始化的BiLSTM、CRF差了好几个数量级,你用统一学习率1e-3去更新,直接吹飞了整个参数面。另一个原因是长句子的LSTM梯度累加,导致梯度值超出Float32表示范围。解决:采用分层学习率,BERT用2e-5,其余用1e-3或5e-4;加梯度裁剪max_norm=5.0。如果仍是nan,检查数据中是否有空白字符串、标签编号是否从0到num_tags-1连续,或者出现了不存在的标签索引。
在PyTorch里还有一个隐蔽原因:混合精度。如果你用torch.cuda.amp,但没有给loss backward做梯度缩放,FP16的指数范围太小,loss增长到一定程度后溢出变成nan。常规做法是使用GradScaler,像这样:
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): loss = model.loss(...) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()这个写法能有效防止梯度溢出。如果你不用AMP,就可以忽略这部分。大多数情况下,第一个epoch的NaN是学习率或裁剪问题,先把这两项固定再排查别的。还有,如果你的训练数据里面存在标点符号缺失导致的超长样本,也会让一个batch里最长的样本撑爆显存,这个可以在DataLoader的collate_fn里做长度排序,按长度分桶。
4.5 显存够用,但batch_size=16就OOM
现象:输入[16,64]的token ids,BERT forward没问题,但反向传播时报CUDA out of memory。把batch_size降到8又正常。
原因:BiLSTM在前向时保存了所有时间步的隐藏状态和细胞状态,反向传播要计算它们之间的梯度,显存占用随序列长度×batch_size线性增长。CRF的动态规划同样会缓存中间得分矩阵。所以总内存不是简单按BERT参数大小估算的。解决:优先降低batch_size到8或4;如果想保持大batch效果,用梯度累积,代码示意:
accumulation_steps = 4 for step, batch in enumerate(dataloader): loss = model.loss(...) / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意:loss除以accumulation_steps,这样累积4次后梯度均值等价于batch_size扩大4倍。很多人漏了这个除法,导致梯度是单batch的4倍,反而相当于学习率放大4倍,训练震荡。另外,还可以用torch.cuda.empty_cache()在验证阶段释放临时缓存,或者减少max_len到48,这些小改动都能让一个6G的显卡多喘几口气。我曾经遇到过一个更恶心的情况:batch_size=8不OOM,到了batch_size=12就OOM,但显存剩余还有1G。后来发现是PyTorch的缓存分配器没有及时释放验证阶段的临时张量,在训练循环前加torch.cuda.empty_cache()解决。
这五个坑像是这个模型家族的季节性流感,几乎每个新人都会中一两个。处理它们不需要高深算法,只是一个习惯:把关键细节写在代码注释里。我自己的模型文件里,CRF的mask和梯度裁剪是反复强调的,因为它们静默出错时最消耗时间。
5. 验证与部署打通的最后一公里:seqeval、早停和ONNX导出
模型训练完,第一件事一定是用seqeval做实体级评估,而不是看token级准确率。seqeval会先把标签序列按实体类型拼起来,再计算每个实体的precision、recall、F1,最后report里能看到每个类别的指标。这个库很小,但价值极大。
from seqeval.metrics import classification_report true_labels = [['O', 'B-Person', 'I-Person'], ['B-Location']] pred_labels = [['O', 'B-Person', 'I-Person'], ['B-Location']] print(classification_report(true_labels, pred_labels))这段代码中,true_labels和pred_labels都是字符串列表的列表,每个子列表对应一个样本的全部有效标签,一定不要包含padding。输出会看到Person、Location的精确率、召回率、F1,以及macro average。我习惯在每个epoch结束后跑一遍,保存F1最高的那一版权重。早停的判断标准是连续3个epoch验证F1没有上升,就把当前学习率乘以0.1,继续再跑2~3个epoch。这一招比盲目堆epoch更省电。
部署时,直接导出ONNX会遇到一个麻烦:CRF的解码是维特比动态规划,本质是一个循环,ONNX exporter对循环的支持并不稳定。我常用的做法是只导出BERT+BiLSTM部分,得到发射得分,然后在后端用Python写一个维特比函数,将CRF的转移矩阵作为numpy数组传过去。这样既绕开了ONNX的循环限制,又保留了CRF的全局约束能力。如果你非要用PyTorch直接部署,也可以考虑用torch.jit.trace配合torch.jit.script,但脚本话的CRF代码需要手动实现,工作量会大一些。
还有一个从实践里验证过的进阶方案:蒸馏。把完整BERT+BiLSTM+CRF的发射概率保存下来,用一个小BiLSTM-CRF模型去逼近这些概率。具体做法是,在大模型推理时用model.forward()拿到发射得分(忽略CRF解码),然后对这些得分做softmax当作软标签,让小学生模型去预测同样的softmax分布,损失用KL散度。小模型参数通常只有几兆,在CPU上能做到延迟在10毫秒级。代价是实体F1会掉大约1到2个点,但很多业务场景可以接受。
我自己的习惯是:先在小规模demo数据上把整个pipeline跑通,再全量训练。所谓pipeline,包括数据读取、对齐、训练、评估、测试、预测封装。我吃过亏——直接全量训练,结果数据里一个标签冲突,跑了8小时出来一堆垃圾。后来我把数据校验函数放在训练前,检查每个样本的token长度和标签长度是否一致、标签集合是否在预设范围内。这步只要10秒,却省了我一整天。
最后,模型上线前我会做一次盲测:让没参与开发的同事随便给几段话,模型预测的实体手工检查。这不叫测试,叫“现实毒打”,但它能暴露很多你和开发数据里看不到的边界。这套Bert-BiLSTM-CRF方案,我用在过好几个项目里,最深的体会是,真正决定项目成败的不是模型结构本身,而是数据对齐、信号保护、评估闭环这些看似不酷的工程细节。踩过的坑多一次,模型就稳一分。希望帮到你。
本文还有配套的精品资源,点击获取