简介:面向自然语言处理入门与进阶开发者的 PyTorch + BERT 意图识别与槽位填充联合训练工程,聚焦中文理解中文本分类和序列标注的多任务建模,适合对话系统、任务型助手等应用场景。压缩包共 25 个文件,整体约 15KB,主要含 8 个 Python 脚本、7 个文本配置文件、2 个 JSON 数据文件,以及 YAML 和 Markdown 说明文档;脚本覆盖数据预处理、模型构建、训练验证与预测流程,文本与 JSON 文件用于维护意图、槽位标签和训练样本,目录清晰、便于按模块查阅。技术层面采用 chinese-bert-wwm-ext 预训练模型,环境要求 PyTorch 1.6 及以上、Transformers 4.5.0,运行主程序即可完成端到端训练;所有超参数与数据路径均集中在配置文件中,方便复现实验和二次改造。已有 44 人学习,适合希望从工程实践理解多任务学习、意图分类与槽位抽取联合训练思路的读者。
1. 为什么要把意图识别和槽位填充放进同一个模型
做任务型对话时,用户说一句“帮我订明早八点去北京的高铁”,系统先要判断这句话的意图是订票,再从里面抽出发车时间、目的地、出发地这类槽位。传统做法是把两个任务拆成两个模型,意图一个、槽位一个,各训各的。结果就是意图模型认为用户在订票,槽位模型却把“北京”抽成了出发地,两个模型的信息完全不互通。
基于PyTorch与BERT的意图识别与槽位填充联合训练,把这两个任务挂到同一个BERT编码器上,共享参数同时优化。这个方案在公开的中文NLU基准上通常能比分开训练高出3到5个点的联合准确率,推理时也只需要跑一次BERT,成本和效果都有明显优势。这篇文章会从任务建模讲起,覆盖标签对齐、双任务头、训练超参和落地避坑,适合正在做任务型对话、想把BERT模型真正跑起来而不是停留在demo阶段的工程师。
2. 从任务建模到联合训练的选型:先想清楚再写代码
2.1 意图是句子分类,槽位是序列标注,标签体系别混
意图识别处理的是整句话,输出一个离散标签,比如book_ticket、cancel_order、play_music。槽位填充处理的是每个token,输出的是BIO类的序列标签。拿“帮我订明早八点去北京的高铁”这句话来说,“明早八点”被标成B-time和I-time,“北京”被标成B-destination,“高铁”标成B-train_type,其余词是O。整句话的意图是book_ticket。所以一个样本的标签是两部分:一个intent_id和一个长度等于句子token数的slot_id序列。
标注体系上,我一般用BIO而不是BIOES。BIO只有B、I、O三个标签,实现简单,对人标注友好;BIOES对边界描述更细,在实体密集的场景更好一些,但标签数量多,小数据下反而容易学不稳。如果你手上标注工具已经输出了BIOES,转BIO也很快,把E标签改成I、S标签改成B就行。需要提醒的是,槽位标签是贴着“词”标还是贴着“字”标,会直接影响后续的标签对齐方式。中文标注通常按词标,但BERT的子词切分不一定跟中文分词一致,这一步在第3章会专门处理。
2.2 联合训练凭什么比两个模型单独训练更优
分开训练的路径是:两个BERT模型各自跑一遍,一个输出意图,一个输出槽位。推理开销翻倍不说,更重要的问题是两者互相无法感知。BERT编码出的上下文表示里,“去”和“从”这类词强烈暗示后面的槽位类型是目的地还是出发地,这种信息意图模型也能学到,但意图模型只输出一个类别,不会把细粒度的槽位影响带给槽位模型。
联合训练的关键在于共享BERT编码器,反向传播时两个任务的梯度同时更新同一组参数。意图任务得到的监督信号会增强模型对“这是个订票请求”的整体判断,这个判断通过共享表示传递给槽位分类头;槽位任务学到的实体边界信息也会反过来帮助意图分类。实际效果是,在标注数据只有几千条的场景下,联合训练比两个独立模型更早收敛,联合准确率也更高。
还有一个可供进阶的建模点:把意图信息显式注入槽位预测。常见做法是定义一个可学习的intent_embedding矩阵,训练时根据当前样本的真实意图取对应向量,加到序列表示的每个位置上,再送进槽位分类头;推理时用意图分类头的argmax结果去查这个矩阵。这个注入模块能让槽位预测显式感知意图,我试过几种,效果提升不稳定,对意图类别不均衡的数据甚至会翻车。所以我的建议是先跑通纯共享编码器的baseline,确认联合训练的收益存在之后,再决定要不要加这类交互模块。
2.3 BERT做编码器:为什么不是BiLSTM加CRF
老一代NLU方案里最常见的组合是BiLSTM加CRF,网上能搜到大量基于PyTorch的LSTM源码实现。BiLSTM的优势是轻量、推理快,在数据量小而且硬件受限的时候也能跑。但它需要从词向量开始学上下文,槽位和意图之间只能靠最终表示间接融合,遇到训练集里没出现过的说法,泛化明显弱于预训练模型。
BERT的优势是预训练阶段已经学到了大规模语料里的上下文表示,下游任务只需要在它基础上加两个分类头微调。对NLU这种标注成本高的任务来说,BERT能用更少的数据拿到更好的效果。BERT base的输出维度是768,加一个线性分类头就能出意图和槽位,结构上比LSTM加CRF干净得多。
遇到延迟敏感且GPU资源紧的线上服务,我也不会硬上BERT base,而是先蒸馏成小模型再部署。这点放最后一章讲。下面把两个方案的取舍列清楚:
| 方案 | 参数量级 | 数据量需求 | 推理成本 | 联合训练收益 |
|---|---|---|---|---|
| BiLSTM + CRF | 小 | 需要较多标注 | 低 | 一般,信息融合浅 |
| BERT共享编码器 | 大 | 几千条可启动 | 较高 | 明显,两个任务互相增强 |
| BERT + 意图注入 | 大 | 需要类别均衡 | 较高 | 在基线上小幅提升,不稳定 |
2.4 模型整体结构:一个BERT编码器,两个分类头
联合模型的整体结构并不复杂:输入句子经过BERT编码,取每个token的最后一层输出作为序列表示,送入槽位分类头,得到每个token属于各槽位标签的概率;取[CLS]位置的输出,送入意图分类头,得到整句的意图分布。训练时两个损失直接相加,用可调的权重平衡。
这里有个细节:序列表示的每个token向量其实可以继续叠一层BiLSTM再做槽位分类,有些论文这么干并有一定提升。我在自己的数据上对比过,BERT序列输出直接接Linear已经足够,额外加BiLSTM只是让参数量变大,联合训练时还更容易过拟合。所以本文的实现保持最简单的结构,只加了一个dropout层和一个全连接层,便于复现和排查问题。
3. 用PyTorch搭建联合模型:数据对齐、模型定义与训练循环
3.1 槽位标签对齐:BERT分词后,标签怎么贴回每个token
这部分是联合训练实现中最容易出错的一步,也是网上资料讲得最少的一步。BERT使用WordPiece分词,中文文本会被拆成更细的子词。例如“明早”可能被切分成“明”和“早”两个token;“八点”可能是一个token。如果标注时按词标了标签,那么一个词对应多个子词token时,标签需要做扩展。
我采用的方案是:每个词的第一个子词token继承这个词的标签,后续子词沿用同一个标签。另一种常见做法是把后续子词的标签全部置为-100,只在第一个子词上计算损失。两种方案都有人用,我个人倾向于前者,因为槽位填充在做推理时是按token输出的,让所有子词都预测同一个标签,解码时更稳定。padding位置统一用-100,配合PyTorch的CrossEntropyLoss的ignore_index参数,不参与损失计算。代码实现如下:
def align_labels_with_tokens(word_labels, word_ids, max_len): token_labels = [] prev_word_id = None for word_id in word_ids: if word_id is None: # [CLS]、[SEP] 以及 padding 位置统一用 -100 忽略 token_labels.append(-100) else: # 每个子词都继承所属词的标签 token_labels.append(word_labels[word_id]) prev_word_id = word_id # 截断到 max_len,超长样本丢弃后半段 token_labels = token_labels[:max_len] return token_labels这里的word_ids来自HuggingFace tokenizer的fast版本。在构造dataset时,把原始文本通过tokenizer处理,tokenizer会返回input_ids、attention_mask、token_type_ids和word_ids。word_ids把每个token映射回它在原始文本中属于第几个词,这正是对齐标签需要的索引。一个容易忽略的点是,普通tokenizer没有word_ids属性,必须用AutoTokenizer.from_pretrained(model_name)且确保底层是fast实现,比如tokenizer.is_fast为True。
3.2 模型定义:BERT共享编码器加两个分类头
模型定义在PyTorch里非常直接。继承nn.Module,加载BERT预训练权重,然后定义两个线性层。意图分类头输入是[CLS]的768维输出,输出维度等于意图类别数;槽位分类头输入是序列每个token的768维输出,输出维度等于槽位标签数。
import torch.nn as nn from transformers import AutoModel class JointBERT(nn.Module): def __init__(self, model_name, num_intents, num_slots, dropout=0.1): super().__init__() self.bert = AutoModel.from_pretrained(model_name) hidden_size = self.bert.config.hidden_size # bert-base 为 768 self.dropout = nn.Dropout(dropout) self.intent_head = nn.Linear(hidden_size, num_intents) self.slot_head = nn.Linear(hidden_size, num_slots) def forward(self, input_ids, attention_mask, token_type_ids=None): outputs = self.bert( input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ) sequence_output = outputs.last_hidden_state # [B, L, H] pooled_output = outputs.pooler_output # [B, H],对应 [CLS] intent_logits = self.intent_head(self.dropout(pooled_output)) slot_logits = self.slot_head(self.dropout(sequence_output)) return intent_logits, slot_logits逻辑说明:outputs.last_hidden_state是BERT最后一层所有token的向量,形状是[batch_size, seq_len, hidden_size];outputs.pooler_output是[CLS]位置经过tanh和线性变换后的向量,专门用于句子分类。槽位分类头把序列维度上的每个token独立映射到num_slots个标签上,本质上是一个token级全连接分类。
参数说明:dropout建议从0.1起步。数据量少的时候可以提高到0.2甚至0.3,防止两个分类头过拟合;但如果dropout太大,联合训练收敛会变慢,验证集损失容易震荡。model_name这里我用bert-base-chinese做中文场景,英文场景换成bert-base-uncased。第一次运行时transformers会自动下载权重并缓存到本地,如果你所在的网络环境下载不稳定,可以先手动下载权重文件,再用AutoModel.from_pretrained的本地路径加载,这一步在PyTorch环境搭建完成后很快就能验证。
3.3 联合训练循环与关键超参:一份可复用的PyTorch实战骨架
训练循环的核心是把两个损失按权重加起来。意图用普通交叉熵,槽位在计算时需要把slot_logits从[batch_size, seq_len, num_slots]转成[batch_size, num_slots, seq_len],才能对齐CrossEntropyLoss的输入要求。slot_labels里凡是-100的位置都会被忽略。
还需要设置AdamW优化器、带warmup的线性学习率调度器、梯度裁剪。BERT微调的通用经验是,学习率不能用得和从头训练一样大,否则预训练权重会被快速破坏。warmup的作用是让学习率从小到大过渡,前几步不把预训练表示冲坏。
from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup def train_one_epoch(model, loader, optimizer, scheduler, device, intent_weight=0.4, slot_weight=0.6): model.train() total_loss = 0.0 for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) intent_labels = batch["intent_labels"].to(device) slot_labels = batch["slot_labels"].to(device) intent_logits, slot_logits = model(input_ids, attention_mask) intent_loss = nn.functional.cross_entropy(intent_logits, intent_labels) slot_loss = nn.functional.cross_entropy( slot_logits.permute(0, 2, 1), slot_labels, ignore_index=-100, ) loss = intent_weight * intent_loss + slot_weight * slot_loss loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() return total_loss / len(loader)参数说明:intent_weight和slot_weight是联合损失的两个权重,两者不需要相加等于1,但建议初始在0.4和0.6附近,让槽位任务略占主导,因为槽位是token级任务,监督信号数量远多于意图。如果意图类别不均衡严重,意图loss会被少数类拖累,这时候把intent_weight提高到0.5以上反而有效。注意这里多一步对slot_loss取平均时,忽略的-100不参与分母计算。
下面是我在多个项目里调整后比较稳的超参组合,供参考:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| learning_rate | 3e-5 | BERT微调常用区间2e-5到5e-5 |
| batch_size | 32 | 短文本可上64,显存不够就减小batch加梯度累积 |
| warmup_ratio | 0.06 | 4000条数据时0.06到0.1都可用 |
| max_grad_norm | 1.0 | 防止梯度爆炸 |
| intent_weight:slot_weight | 0.4:0.6 | 意图不均衡时上调intent权重 |
| epochs | 5到10 | 配合验证集早停,不要固定epoch数 |
3.4 PyTorch环境与显存预算:显存不够怎么跑
在做PyTorch环境搭建时,第一原则是用conda创建独立虚拟环境,不要用系统自带的Python环境,否则pip装出来的包互相污染很难排查。安装时先用nvidia-smi查看本机GPU驱动支持的CUDA版本,再选择匹配的PyTorch版本。WSL环境下我还遇到过驱动没问题但PyTorch识别不到GPU的情况,根因往往是PyTorch版本和CUDA版本不匹配,更新到配套版本即可解决。
显存方面,BERT base加两个分类头,最大输入长度96,batch_size 32,在12G显存上能跑起来。如果只有8G,优先把batch_size降到16,同时开启gradient_accumulation,也就是累积几个小batch的梯度后再更新一次参数。训练循环里更省事的做法是开启自动混合精度,PyTorch的torch.cuda.amp模块里把前向和loss计算包进autocast就行,能省接近一半显存,速度还更快。
4. 联合训练避坑:五个真实踩过的坑与排查方法
4.1 槽位标签错位:train loss在降,槽位准确率却是零
现象:训练时loss正常下降,但验证集上槽位F1始终是0或者极低,模型输出的BIO标签几乎全是O。
原因:标签对齐时没有考虑[CLS]和[SEP]两个特殊token,导致所有token的标签整体向右偏移了一位。槽位分类头学到的就是错位标签,训练越久,错得越稳定。
解决:拿到tokenizer输出后,先做一步可视化校验。打印一句话的汉字、对应的token_ids、word_ids和aligned标签,逐行人工核对。我现在的做法是写一个debug函数,把第一个batch里的前三条样本完整打出来,确认[CLS]对应-100,第一个真实token的标签和标注一致,再进入训练流程。
4.2 padding参与损失计算:长句子的槽位被稀释
现象:验证集上短句槽位F1还挺好,句子一长,槽位预测就混乱,尤其是后半段。
原因:padding位置的slot_labels没有被置为-100,而CrossEntropyLoss默认把所有位置都算进损失。padding部分占的比例在batch里不稳定,导致模型被迫去学习预测一堆没意义的padding标签,真实token的梯度被稀释。
解决:对齐函数里把所有word_id为None的位置统一返回-100,同时损失函数里务必传ignore_index=-100。还要检查DataLoader里有没有把slot_labels错误地填充成0而不是-100,0是真实标签里的O,不能用来做padding标记。
4.3 意图类别严重不均衡:少数意图永远学不出来
现象:意图准确率看起来到了90%以上,但看混淆矩阵就会发现,占样本80%的“查天气”几乎全对,“退票”这类低频意图的召回率不到20%。
原因:联合损失里意图损失被多数类主导,模型倾向于把所有句子都判成高频意图,因为这样loss最小。BERT的预训练表示在这种情况下也不会自动生成一个低频意图的聚类。
解决:两种思路。第一种是在损失里给意图标签加class_weight,低频意图权重乘2到5,高频意图降权;第二种是数据层面做重采样,每个batch里对少数意图过采样。我实际项目里两种结合用,先重采样保证每个batch都有少数意图样本,再配合一个温和的class_weight,效果比只改权重稳定。
4.4 显存不够导致batch_size上不去:联合训练的常见卡点
现象:batch_size设成32,模型直接OOM;调成8能跑,但验证集loss震荡,收敛很慢。
原因:BERT base本身占用显存就高,序列长度96时每个batch的前向和反向都要缓存全部中间激活,两个分类头虽然参数量不大,但梯度也要占空间。直接在错误的方向上硬调batch_size,模型稳定性和收敛速度都会变差。
解决:先用梯度累积,原计划batch 32,实际batch 8,累积4步再更新一次,效果上和batch 32接近。再把自动混合精度打开,省下的显存足够让batch_size再往上提一档。还有个小技巧是把输入文本的最大长度从96压到64,业务上超过64个token的任务型query占比很低,这一步能显著降低显存压力。
4.5 单任务指标都高,联合准确率却低:模型在做内部妥协
现象:意图准确率95%,槽位F1也有88%,但联合准确率只有60%左右。
原因:联合准确率要求同一句话的意图和槽位全部正确才算对,两个任务各对各的,但不在同一句上同时成立。模型其实是在两个目标之间取了一个折中,每一个单独看都不差,合在一起就对不上。
解决:第一步先看错误样本,把intent预测错和slot预测错的那批数据分别抽出来。如果两个任务错误集中在不同样本上,说明共享编码器没有把两者的表征融合好,可以尝试把slot_weight调高,让槽位信号在梯度里占更大比重。第二步是加后处理约束,比如某个槽位类型要求输出必须是一个有效的时间表达,模型输出不符合时就拒绝该槽位,这在业务上能直接拉高联合准确率。
5. 评估指标与线上部署:别只盯着联合准确率
5.1 三个指标一起看:intent acc、slot F1 与 joint acc
联合训练里最常被引用的指标是联合准确率,也就是意图和槽位全部预测正确才算对的样本比例。但上线前只盯联合准确率会掩盖不少问题。意图准确率关注的是整句意图判断好坏,槽位F1关注的是每个实体边界的质量,联合准确率最严格,却对样本分布敏感。三个指标配合使用才能定位瓶颈在意图侧还是槽位侧。
| 指标 | 计算方式 | 关注点 |
|---|---|---|
| intent acc | 意图预测正确的样本数 / 总样本数 | 句子级意图判断 |
| slot F1 | 按实体级别计算BIO标签的精确率和召回率 | 槽位边界和类型 |
| joint acc | 意图和槽位全部正确的样本数 / 总样本数 | 端到端语义理解 |
槽位F1的计算,我建议直接用seqeval库而不是sklearn的classification_report。seqeval按实体级别计算,比如“明早八点”这个整体被预测成B-time、I-time、I-time,只有三个token全部正确才算这个实体对,这也符合业务上对槽位抽取的验收方式。
5.2 推理期后处理与模型瘦身:从BERT到可部署形态
联合模型训练完,线上部署前还可以做两件事。第一,给槽位输出加一层规则约束。比如时间槽位要求匹配“明天|明早|数字+点|数字:数字”等模式,模型输出的词序列如果匹配不上,直接丢弃该槽位。这不是给模型补洞,而是让模型输出落在业务可接受的范围内。第二是模型瘦身。BERT base在GPU上的推理延迟对线上实时对话还是偏高,常见做法是把联合模型当成teacher,蒸馏到一个参数量小得多的student模型上,比如ALBERT tiny或者带上CRF的BiLSTM。蒸馏时两个学生的输出目标分别是teacher的意图logits和槽位logits,损失用KL散度加原本的交叉熵。
如果暂时不想动模型结构,也可以先走ONNX导出加FP16量化,把BERT base在GPU上的推理延迟压到原来的三分之一左右,这是投入最小的一条路径。
我在自己做第一个联合训练版本时,总以为把两个CrossEntropy加起来就完事了,结果联合准确率一直上不去,最后定位到标签偏移和padding被计入损失这两件事。把这两件事处理好,模型才真正开始学到东西。这些细节是一轮一轮试出来的答案,希望帮到你。
本文还有配套的精品资源,点击获取