NLP系列写到第二十五篇,终于轮到组合拳了:ALBERT + Bi-LSTM + CRF。这个组合在很长一段时间里是中文命名实体识别(NER)、词性标注等序列标注任务的高频方案,也是入门预训练微调绕不过去的一道坎。ALBERT负责把原始文本编码成有上下文的向量,Bi-LSTM在这个向量序列上继续抽取双向时序特征,CRF则给最终标签序列加一层全局约束。三层各管一摊,分工非常清楚。
这篇文章不绕弯子,只讲三件事:为什么这么组合、代码怎么从零落地、训练时最容易踩哪些坑。适合已经跑通基础BERT分类任务、想进一步接触序列标注的读者,也适合准备做NER的工程同学直接当脚手架用。
1. 为什么做这个组合:ALBERT、Bi-LSTM、CRF各自管什么
1.1 ALBERT:把预训练语言模型的入场成本压下来
BERT系列模型全家桶里,ALBERT算是“轻量级”选手。它的核心思路很简单:不是让模型变笨,而是让参数变得更省。ALBERT用了两项关键设计,一是词嵌入矩阵分解,先压到低维再接回高维;二是跨层参数共享,每一层Transformer用同一套权重。这两招让参数量大幅下降,但模型推理速度没有打折扣,而且在小规模数据上表现常常比原版BERT还稳。
在ALBERT + Bi-LSTM + CRF这个组合里,ALBERT担任“编码器”角色。输入一个句子,它输出的是每个token的语义特征向量。这个向量不是静态词向量,而是经过多层注意力融合过的上下文表示。比如“苹果”在“我爱吃苹果”和“苹果发布新机”里,会被编码成完全不同的向量。这一点对NER至关重要,因为实体边界很大程度上依赖上下文判断。
选ALBERT还有一个很现实的原因:显存门槛低。实验室或者个人电脑上跑16G以内的显卡,ALBERT base完全可以带得动,还可以留出显存给Bi-LSTM和CRF。如果说BERT是开一辆油耗高的SUV,ALBERT就是同为SUV但更省油的那款,目的地一样,路上压力小很多。
1.2 Bi-LSTM:在预训练特征上再做一次“双向扫描”
预训练模型已经很强了,为什么还要在它后面接Bi-LSTM?这里要分两层来看。
第一层,从信息流角度。ALBERT内部其实是双向Transformer,理论上已经捕获了前后文。但Transformer捕获的是长距离、深层次的语义关系,而实体标注这类任务对“局部时序”非常敏感,比如“B-LOC”后面大概率跟“I-LOC”,很少直接跳成“B-PER”。Bi-LSTM天然擅长建模这种局部时序依赖,前向状态记住前面的标签趋势,后向状态记住后面的标签趋势,两者拼接后能补充Transformer不那么强调的序列先验。
第二层,从工程落地角度看。很多工业项目里面,预训练模型只作为特征抽取器,后面接一个结构明确的时序层,便于后续替换主干模型。Bi-LSTM在这个位置起到“适配层”的作用,把768维的ALBERT输出压缩成一个更紧凑的序列特征。
我见过不少实验对比,在数据量适中(几千到几万条)时,ALBERT + Bi-LSTM + CRF会比直接ALBERT + CRF高零点几个到两个F1点。但这不绝对,有些公开数据集上加了Bi-LSTM反而没提升。后面我会专门说这个现象。
1.3 CRF:给标签序列戴上“纪律项”
如果前面两层都是“特征提取器”,CRF就是“决策器”。序列标注里有个经典问题:模型每个位置独立打分,很容易预测出“B-PER后面直接跟I-ORG”这种非法序列。CRF解决的就是这个问题,它不是孤立地看每个token,而是把整条标签序列当成一个整体来建模。
CRF层引入了一个转移矩阵,大小为num_tags × num_tags,表示从一个标签转移到另一个标签的得分。训练时它学习的是“哪些标签前后搭配是合理的”,推断时用Viterbi算法在整条序列上搜索最优路径,确保解出来的标签序列全局最优。
用生活类比就是:普通分类器像一个个独立投票的裁判,每个位置只根据自己看到的证据出结果;CRF则是在裁判后面加了一个总导演,他不光看每个裁判的投票,还要检查整场演出下来剧情逻辑通不通,不合理的情节直接剪掉。
1.4 为什么不直接BERT + CRF
不是不可以,很多人就是这么干的。BERT + CRF也是经典方案。选ALBERT是综合考虑显存和参数量后的选择。ALBERT在参数量上比BERT base小非常多,而且它自带词嵌入分解和参数共享,在小数据集上不容易过拟合。如果拿到的数据只有几千条标注,BERT很容易把训练集背下来,ALBERT相对会稳一点。
不过也要提醒一句:如果你的机器显存充足、数据规模也大(比如十万级),直接用更强大的预训练模型可能效果更好。ALBERT + Bi-LSTM + CRF更匹配的是“中等资源、想快速上线、要做对比实验”的场景。
2. 准备环境、数据与标签体系
2.1 依赖库选型
这个模型实现的代码栈我用的是PyTorch体系,依赖很少,就三件套:
- transformers:加载ALBERT预训练权重和分词器
- pytorch-crf:CRF层,我用的这个库封装很干净,一个类搞定loss和Viterbi解码
- torch:PyTorch本体,推荐1.10以上版本,AMP相关API更稳定
安装命令很简单,直接:
pip install transformers torch pytorch-crftransformers版本建议4.20以上,太老的话AlbertModel的输出格式会有差异。pytorch-crf是个轻量库,内部实现也不复杂,后面我会讲它到底怎么工作,这样即使不依赖这个库,也能自己写一个。
2.2 数据集格式与标签对齐
我用的是中文NER数据,标注格式就是最通用的BIO体系。B代表实体开头,I代表实体内部,O代表非实体。实体类型包含人名、地名、机构名。
标签集合可以定义成这个样子:
label2id = { "O": 0, "B-PER": 1, "I-PER": 2, "B-LOC": 3, "I-LOC": 4, "B-ORG": 5, "I-ORG": 6 } id2label = {v: k for k, v in label2id.items()} num_tags = len(label2id) # 7这里有个非常关键的预处理步骤:标签对齐。ALBERT的分词器不是按字切的,它可能把常见中文词切成一个整token,也可能切成子词。所以不能简单地把标签按字符顺序贴到token上,必须借助分词器返回的offset mapping,把每个token映射回原文的字符位置。
这里举一个实际例子,输入句子“张三在北京上班”,原始标签是“B-PER I-PER O B-LOC I-LOC O O”。ALBERT分词器可能把“张”和“三”合成一个token“张三”,也可能保持单字。我们要做的就是找到每个token对应原文的起始字符位置,然后用那个位置上的标签代表这个token。
2.3 预处理脚本参考
def tokenize_and_align_labels(text, char_labels, tokenizer, max_len=128): encoding = tokenizer( text, max_length=max_len, truncation=True, padding=False, return_offsets_mapping=True ) aligned_labels = [] for offsets in encoding["offset_mapping"]: start, end = offsets # 特殊token:CLS、SEP、padding在offset上是(0,0) if start == 0 and end == 0: aligned_labels.append(-1) continue char_idx = start # 取token对应原文的起始字符 aligned_labels.append(label2id[char_labels[char_idx]]) return encoding["input_ids"], encoding["attention_mask"], encoding["token_type_ids"], aligned_labels这里把无效位置标签设为-1。之所以用-1而不是0,是为了明确“这个位置不算loss”,跟CRF的mask配合使用。如果你后面用了pytorch-crf,它的mask机制会帮我们屏蔽这些位置,这一点在第5节踩坑部分我还要细讲。
3. 模型主体代码实现
3.1 加载ALBERT编码器
用transformers库加载ALBERT非常直接。注意这里我用的是AlbertModel而不是AlbertForTokenClassification,因为后者自带的分类头只是简单的Linear层,我们要替换成Bi-LSTM + CRF,所以只取底层的编码器。
from transformers import AlbertModel albert_model = AlbertModel.from_pretrained("你的本地路径或模型名") config = albert_model.config print(config.hidden_size) # 通常是768AlbertModel的forward返回一个结构体,其中最有用的是last_hidden_state,形状是(batch, seq_len, hidden_size),这就是每个token的上下文向量。
这里要特别强调:不能用pooler_output,那是拿[CLS]向量再过一层全连接的结果,专门给分类任务用的,对token级标注任务来说会把位置信息完全抹平。我最初实现时图省事取过pooler输出,结果Viterbi解码出来的标签全是一个类别,排查半天才发现问题。
3.2 自己接一个Bi-LSTM层
Bi-LSTM的作用是对ALBERT输出的序列向量再做双向编码。在PyTorch里,nn.LSTM只要设置bidirectional=True,输出维度就会自动翻倍。
import torch.nn as nn bilstm = nn.LSTM( input_size=768, # ALBERT hidden_size hidden_size=128, # 单向隐层维度 num_layers=1, # 层数,我建议先1层 batch_first=True, bidirectional=True ) # 双向输出维度 = 128 * 2 = 256设置hidden_size=128时,双向拼接后是256维。之后再接一个线性层映射到标签数。
为什么hidden_size选128而不是256?主要原因两点:一是ALBERT本身已经做了很强的特征抽取,Bi-LSTM只是辅助,不需要太宽;二是CRF层对特征维度并不敏感,它关心的是特征间的区分度,过宽的全连接层反而容易过拟合。如果你数据量很大,可以提到256试试,但在我这个几万条标注数据的场景下,128已经够用。
3.3 接入CRF层并计算损失
pytorch-crf库的核心用法如下:
from torchcrf import CRF crf = CRF(num_tags, batch_first=True) # 7个标签训练阶段,输入是模型输出的emissions、真实标签和mask,返回的是负对数似然损失(NLL loss),这个值越小越好:
loss = -crf(emissions, labels, mask=mask)注意这里对结果取负号。原因在于torchcrf的forward内部计算的是“该条真实标签序列在模型分布下的对数似然”,我们要最小化负对数似然,所以梯度正好能反向传播。
推理阶段,调用decode方法:
preds = crf.decode(emissions, mask=mask) # 返回list of list我自己翻过torchcrf的源码,它的实现分两部分:训练时算“真实路径得分”和“所有路径得分之和”,两者相减得到loss;预测时用动态规划实现Viterbi解码。整体逻辑清晰,也很适合当作阅读理解CRF内部机制的入门材料。
3.4 完整模型类代码
把上面三个部件组装成一个完整模型类:
import torch import torch.nn as nn from transformers import AlbertModel from torchcrf import CRF class ALBERTBiLSTMCRF(nn.Module): def __init__(self, model_path, num_tags, lstm_hidden=128, dropout=0.1): super().__init__() self.albert = AlbertModel.from_pretrained(model_path) hidden_size = self.albert.config.hidden_size self.dropout = nn.Dropout(dropout) self.bilstm = nn.LSTM( input_size=hidden_size, hidden_size=lstm_hidden // 2, num_layers=1, batch_first=True, bidirectional=True ) self.classifier = nn.Linear(lstm_hidden, num_tags) self.crf = CRF(num_tags, batch_first=True) def forward(self, input_ids, attention_mask, token_type_ids=None, labels=None): outputs = self.albert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids ) sequence_output = outputs.last_hidden_state sequence_output = self.dropout(sequence_output) lstm_out, _ = self.bilstm(sequence_output) emissions = self.classifier(self.dropout(lstm_out)) crf_mask = attention_mask.bool() if labels is not None: loss = -self.crf(emissions, labels, mask=crf_mask) return loss else: preds = self.crf.decode(emissions, mask=crf_mask) return preds代码里要注意的点:
crf_mask用的是attention_mask转换成的布尔张量,True代表有效token,False代表padding。CRF会计算有效位置之间的转移关系,并忽略padding位置的损失。token_type_ids在ALBERT里其实不是必须的,但如果数据管道里已经有了,就传进去,保持兼容。dropout加在ALBERT输出之后和全连接层之前。Bi-LSTM内部可以接dropout,也可以不接,因为这里只用了1层LSTM,没有堆叠,所以不需要给LSTM内部设置dropout。
4. 训练流程与调参经验
4.1 优化器、学习率与调度策略
这个模型的训练不能所有层用同一个学习率。ALBERT是预训练好的,只需要微调,学习率要小;Bi-LSTM和CRF是从零开始训练的,学习率可以大一些。它们俩收敛速度完全不同,放在一起用一个学习率,要么预训练层学得太猛导致灾难性遗忘,要么新加层学得太慢导致效果上不去。
我习惯用分层学习率:
optimizer_grouped_parameters = [ { "params": model.albert.parameters(), "lr": 2e-5 }, { "params": [ *model.bilstm.parameters(), *model.classifier.parameters(), *model.crf.parameters() ], "lr": 1e-3 } ] optimizer = torch.optim.AdamW(optimizer_grouped_parameters, weight_decay=0.01)预训练部分用2e-5是BERT家族微调的经典设置,建议不要超过5e-5。Bi-LSTM和CRF用1e-3,因为它们要从随机初始化开始学,太小的学习率会让他们在训练前几个epoch完全跟不上预训练层的节奏。
调度策略我用的是带warmup的线性衰减。warmup让模型前几百步用很小的学习率预热,避免刚起步就冲偏:
from transformers import get_linear_schedule_with_warmup total_steps = len(train_dataloader) * num_epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps )4.2 训练循环里两个关键细节
第一个细节是labels中无效位置全部用-1,并且CRF的mask要跟上。代码如下:
for batch in train_dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) token_type_ids = batch["token_type_ids"].to(device) labels = batch["labels"].to(device) # LongTensor, 无效位置为-1 loss = model(input_ids, attention_mask, token_type_ids, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()梯度裁剪这块经常被忽略。在Bi-LSTM + CRF这种结构里,CRF层对转移矩阵的梯度有时会很大,一旦某个batch里出现极少见的标签转移,梯度可能暴涨,加上梯度裁剪能有效避免loss变成NaN。
第二个细节是:验证集评估时不要用model的labels分支,而是用解码分支,然后自己算指标。
4.3 评估指标怎么算
NER任务上我基本只用一个指标:实体级别的严格F1。意思是预测的实体必须“边界完全一致,类型完全一致”,边界差一个字都算错。这个指标比token级别的准确率严格得多,也更贴近真实业务需求。
评估代码的核心是“把标签序列还原成实体集合”:
def extract_entities(label_seq): entities = [] cur_entity = None for i, tag in enumerate(label_seq): if tag.startswith("B-"): if cur_entity is not None: entities.append(cur_entity) cur_entity = [i, i, tag[2:]] elif tag.startswith("I-") and cur_entity is not None: if cur_entity[2] == tag[2:]: cur_entity[1] = i else: entities.append(cur_entity) cur_entity = None else: if cur_entity is not None: entities.append(cur_entity) cur_entity = None if cur_entity is not None: entities.append(cur_entity) return set( (start, end, typ) for start, end, typ in entities )然后对预测和真实分别抽取实体集合,计算交集、精确率、召回率和F1。要注意的是,预测结果里包含[CLS]、[SEP]和padding位置的标签,这些位置在抽取实体时必须跳过,一般从下标1开始,到seq_len-1结束。
4.4 小规模数据上的调参经验
如果标注数据只有几千条,有几个现象要注意。
第一个现象是ALBERT很快就在训练集上收敛到接近100%准确率,但验证集F1上不去,甚至掉。这是典型的过拟合信号。对策不是简单地加dropout,而是先考虑Bi-LSTM的hidden_size是不是太大,从256降到128;同时把ALBERT部分的学习率降到1e-5。
第二个现象是训练loss下降很快,但实体边界总是不对。比如“北京交通大学”预测成“北京”、“交通”、“大学”三个实体。这通常是CRF转移矩阵学出来的“切分偏好”过于激进,可以检查一下emissions里非实体类的得分分布,如果差距过大,可能需要在损失函数里适当提升O类的置信度,或者在数据层面补充一些O类样本。
第三个现象是如果用了类不平衡加权,别在CRF上乱加。CRF是一个序列层面的模型,它的转移矩阵是全局共享的,强行给某个token加权重会破坏转移约束的整体性。我自己实验下来,直接不加类别权重,仅在数据层面做数量控制(比如清洗掉过长、过空的O类句子)更有效。
5. 踩坑记录与常见问题排查
5.1 标签错位:预处理阶段最隐蔽的Bug
这是NER任务里我见过最多的错误,症状是:训练loss正常下降,验证集F1却一直很低,而且错误集中在“实体整体偏移一个token”。原因就是分词器和标签标准没对齐。
打个比方:原始中文是“张三在北京上班”,你的标签是按字符给的。ALBERT分词器把“张三”合并成了一个token,如果你按原来的顺序把“B-PER”贴给“张”,把“I-PER”贴给“三”,那合并后整个序列就少了一个token,后面所有标签全部错位。
解决办法只有一个:永远通过offset_mapping来对齐,不能在分词前盲贴。我在第2节写的预处理函数就是为此设计的,这个函数建议放在单独的脚本里,并输出一组对齐后的样本做可视化检查,亲眼看到“张三”对应一个B-PER,心里才踏实。
5.2 CRF的mask没有传对
另一个高频问题:pytorch-crf要求mask是(batch, seq_len)的布尔张量,True代表有效位置。有些人的习惯是attention_mask == 1,这在int张量上直接使用时经常会报类型错误或者奇怪的数据依赖错误。
还有更重要的一点:mask不仅作用于损失,还作用于Viterbi解码。解码时如果mask传错,[PAD]位置也会参与标签路径的搜索,模型会倾向于给padding位置输出一个极高概率的O,从而把最佳路径带偏。这会让你的解码结果看起来“最后一个token总被预测成某个固定标签”。
我习惯在训练前单独跑一次纯推理,打印几个batch的解码结果,人工确认padding位置被截断在有效序列尾部。
5.3 显存不足与训练发散
显存不足是ALBERT + Bi-LSTM + CRF最常见的硬性问题。ALBERT虽然参数少,但中间激活值并不小,序列长度128、batch size 16,一张12G的卡勉强能跑,再长就会OOM。
三个有效的缓解手段:
- 梯度累积:batch size先压到8,每两个batch累积一次梯度,等效于batch size 16,显存压力下降一半。
- 混合精度:用
torch.cuda.amp可以把显存占用再降三分之一,速度也有提升。 - 序列长度裁剪:很多实体其实集中在句子中间,超过100个token的尾部信息量很低,直接把
max_len=64,对短文本场景几乎没有影响。
训练发散的表现一般是loss到一定数值后突然变成NaN,或者validation F1在某个epoch后跳水。优先检查学习率,尤其是CRF和Bi-LSTM部分的学习率是否过大。我见过有人把新加层学习率设成1e-2,结果Viterbi解码出来的路径全部都是同一个标签。1e-3这个量级是比较安全的。
5.4 常见问题速查表
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| loss在几个batch后直接NaN | 学习率过大 / 梯度爆炸 | 调低LR,检查梯度裁剪 |
| 验证集F1低但训练集几乎满分 | 标签错位 / 过拟合 | 检查offset_mapping对齐,降低hidden_size |
| 预测结果实体普遍偏左或偏右 | 分词合并导致标签错位 | 可视化对齐结果 |
| padding位置被预测成实体 | CRF mask没传对 | 确认mask是布尔型且覆盖全部padding位 |
| 解码结果全是O | Bi-LSTM输出被随机初始化拖累 | 增大新加层学习率,检查emissions分布 |
| 显存OOM | batch_size / max_len过大 | 梯度累积、混合精度、缩短序列 |
表里这个“解码结果全是O”,很多人会误以为是CRF的锅,其实绝大部分时候是Bi-LSTM和全连接层还没学到有效特征,emissions里每个位置各项得分差不多,Viterbi解码当然倾向于输出占绝大多数的O类。这时候优先检查新加层的学习率是不是太低,别一上来就调CRF。
6. 效果复盘与后续还能怎么改
6.1 我实际跑完的效果观察
在几万条中文标注数据上,我用这套ALBERT + Bi-LSTM + CRF框架跑出的实体F1大约在86到90之间(具体数值依赖数据集领域,大家不用拿我当基准)。跟纯ALBERT + Softmax比,CRF带来的提升主要不在整体F1,而在“标签序列合理性”上。最直观的例子:纯Softmax预测结果里偶尔会出现“B-PER后直接跟I-ORG”这种明显违反BIO约束的错误,而CRF几乎不会犯这种错。
跟ALBERT + CRF(不带Bi-LSTM)相比,Bi-LSTM在我这个数据规模下提升有限,大约只有0.2到0.5个F1点。如果你资源紧张或者时间有限,先做ALBERT + CRF是完全合理的,把Bi-LSTM当成锦上添花的可选件。但如果你的标签体系比较复杂(比如嵌套实体、重叠实体),Bi-LSTM对局部特征的补充会更有价值。
6.2 后续还能怎么改
模型跑通之后,我建议往三个方向扩展。
第一个方向是替换主干骨架。ALBERT可以换成更新的轻量模型,比如RoBERTa系列、TinyBERT、MiniLM等,只要改一下加载预训练的模型类,Bi-LSTM和CRF这两层完全不用动。模块化的好处这时候就体现出来了。
第二个方向是做多任务学习。序列标注任务常常需要同时预测多个标签体系,比如既标注实体,又标注事件触发词。可以把CRF层复制多个,每个标签体系一个CRF,共享ALBERT和Bi-LSTM底层特征。我做过一次实体加事件联合标注的实验,效果比两个模型分别跑要好。
第三个方向是给CRF增加“约束词典”。实际业务中很多实体是有明确规则的,比如产品型号、证件号码,甚至可以从字典直接匹配。当匹配到某个位置时,把这个位置的转移得分强制置为某个大正数区间。可以在CRF的转移矩阵上做文章,也可以在emissions上叠加一个先验矩阵。这个技巧在工业场景里非常好用,能明显压低低置信度区域的错误率。
6.3 最后分享一个细节技巧
训练结束后保存模型,不要只保存整个模型对象,建议还是分别保存ALBERT的权重、Bi-LSTM和CRF的参数,用字典方式存state_dict。因为后续很可能要做两件事:一是把ALBERT部分单独拿出来做别的任务,二是对Bi-LSTM和CRF做增量训练。统一成state_dict,加载时灵活性高很多,也不会因为transformers库版本升级导致反序列化失败。
加载代码如下:
torch.save(model.state_dict(), "albert_bilstm_crf.pt") # 加载时 model = ALBERTBiLSTMCRF(model_path, num_tags) model.load_state_dict(torch.load("albert_bilstm_crf.pt"))我用这套代码搭起来的模型,后来换到另一个领域的NER数据上,只改了数据预处理部分,其余代码几乎没有变动,整个过程相当顺手。希望这篇文章能帮你少走我这二十几篇文章里踩过的一半弯路,尤其是标签对齐和CRF mask这两个细节,真的值得在动手前多花两分钟确认。