ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch+BERT联合建模:意图识别与槽位填充实战

PyTorch+BERT联合建模:意图识别与槽位填充实战 简介这份资源面向具备一定深度学习基础、希望上手意图识别与槽位填充联合建模的开发者与学习者基于PyTorch与BERT实现分类与序列标注同时训练可应用于对话系统、智能客服等场景。包内共18个文件以8个Python脚本为核心涵盖数据预处理、模型定义、训练与测试流程另有7个txt语料文件、2个json数据文件及1个yaml配置整体约10KB结构紧凑便于快速阅读与二次开发。项目采用hugging face上的chinese-bert-wwm-ext预训练模型依赖pytorch 1.6与transformers 4.5.0运行python main.py即可启动并可在config.py中调整训练、验证、测试与预测相关参数。目前已有699人学习下载适合作为意图识别与槽位填充联合任务的入门实践参考帮助读者理解多任务训练的数据组织、模型搭建与参数配置思路。1. 意图识别与槽位填充为什么单模型双任务比两个独立模型更值得做你在做对话系统时一定遇到过这种局面用户说“帮我订一张明天下午从北京到上海的高铁票”系统需要同时回答两个问题——这句话想干什么订票以及这句话里的关键信息是什么明天下午、北京、上海、高铁。前者叫意图识别后者叫槽位填充。很多团队一开始会训两个模型分别处理上线后发现两个模型各错各的意图判对了槽位漏了槽位抽全了意图又跑偏联调阶段来回扯皮。更麻烦的是两个模型意味着两套推理链路、两份显存占用、两次前向计算延迟直接翻倍。用 PyTorch BERT 做意图识别与槽位填充的联合建模核心思路是让一个 BERT 编码器同时输出两个任务的结果一个句子级分类头负责意图一个 token 级序列标注头负责槽位。这样做的好处不是省事而是两个任务在底层语义表示上互相约束——槽位标注的边界信息会帮助意图分类更准意图的全局语义也会反过来抑制槽位的误标。我实测下来联合模型在 ATIS 和 Snips 这类公开数据集上意图准确率通常比单独训一个文本分类模型高 1 到 2 个百分点槽位 F1 也能涨 1 个点左右推理延迟反而只有双模型方案的一半。这个方案适合谁如果你手头有几千到几万条带意图标签和 BIO 槽位标注的对话数据想用一张消费级显卡比如 8GB 显存的卡跑起来并且希望推理时只加载一个模型那这套 PyTorch BERT 的实现路径就是为你准备的。下面我从数据格式、模型结构、训练循环、避坑排查到进阶技巧把整个落地过程拆开讲清楚。2. 数据准备与 BERT 输入构造从原始标注到模型可读的 batch2.1 联合标注数据的组织方式意图识别与槽位填充的联合训练数据格式必须同时携带两类标签。常见做法是每条样本存成一个字典包含text原始句子、intent意图类别字符串、slots与 token 对齐的 BIO 标签列表。这里有个关键点BERT 用的是 WordPiece 分词一个词可能被切成多个 subword而槽位标注通常是在词级别做的。你需要决定对齐策略——我一般把每个词的首个 subword 保留原标签后续 subword 标为X忽略这样损失计算时只对首 subword 生效。下面是一个数据预处理脚本的核心片段把原始词级标注转成 BERT 可用的 subword 级标签from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def align_labels_with_tokens(words, slot_labels, max_len128): words: 原始词列表如 [帮, 我, 订, 一张, 明天, 下午, 的, 票] slot_labels: 与 words 等长的 BIO 标签列表 返回: input_ids, attention_mask, token_type_ids, intent_label, aligned_slot_labels input_ids [tokenizer.cls_token_id] aligned_labels [-100] # CLS 位置不计算损失 for word, label in zip(words, slot_labels): subwords tokenizer.tokenize(word) if not subwords: continue sub_ids tokenizer.convert_tokens_to_ids(subwords) input_ids.extend(sub_ids) aligned_labels.append(label) # 首 subword 保留原标签 aligned_labels.extend([-100] * (len(sub_ids) - 1)) # 后续 subword 忽略 input_ids.append(tokenizer.sep_token_id) aligned_labels.append(-100) # 截断到 max_len input_ids input_ids[:max_len] aligned_labels aligned_labels[:max_len] attention_mask [1] * len(input_ids) token_type_ids [0] * len(input_ids) # padding pad_len max_len - len(input_ids) input_ids [tokenizer.pad_token_id] * pad_len attention_mask [0] * pad_len token_type_ids [0] * pad_len aligned_labels [-100] * pad_len return input_ids, attention_mask, token_type_ids, aligned_labels这段代码的逻辑说明-100是 PyTorch CrossEntropyLoss 的默认 ignore_index凡是标为-100的位置不参与槽位损失计算。参数上max_len128对大多数对话句子够用如果你的场景里有长文本比如客服工单可以调到 256但显存占用会明显上升。bert-base-chinese的 vocab 大小是 21128中文场景直接用这个预训练权重就行不需要自己重新训 tokenizer。2.2 意图标签与槽位标签的映射表意图标签通常是一个固定集合比如{订票: 0, 查天气: 1, 退票: 2}。槽位标签是 BIO 格式比如B-city、I-city、B-time、I-time、O。你需要构建两个映射字典并且在训练前统计一下各类别的样本数。如果某个意图类别样本少于 50 条建议先做数据增强或者合并到相近类别否则模型在这个类别上的 F1 会很难看。一个容易翻车的地方是槽位标签里B-和I-必须成对出现不能只有B-city没有I-city。我在预处理阶段会加一个校验遇到不合法的 BIO 序列直接打印出来人工检查。另外O标签的样本通常占绝大多数如果直接算 loss模型会倾向于全预测O。常见做法是在 loss 里给O标签一个较小的权重或者用 focal loss 替代交叉熵。2.3 用 Dataset 和 DataLoader 组装 batchPyTorch 的Dataset类负责单条样本的读取和转换DataLoader负责批处理和打乱。下面是一个可直接复用的实现import torch from torch.utils.data import Dataset, DataLoader class JointIntentSlotDataset(Dataset): def __init__(self, data_list, intent2id, slot2id, max_len128): self.data data_list self.intent2id intent2id self.slot2id slot2id self.max_len max_len def __len__(self): return len(self.data) def __getitem__(self, idx): item self.data[idx] words item[words] slot_labels [self.slot2id[s] for s in item[slots]] input_ids, attention_mask, token_type_ids, aligned_labels \ align_labels_with_tokens(words, slot_labels, self.max_len) intent_label self.intent2id[item[intent]] return { input_ids: torch.tensor(input_ids, dtypetorch.long), attention_mask: torch.tensor(attention_mask, dtypetorch.long), token_type_ids: torch.tensor(token_type_ids, dtypetorch.long), intent_label: torch.tensor(intent_label, dtypetorch.long), slot_labels: torch.tensor(aligned_labels, dtypetorch.long) } # 使用示例 dataset JointIntentSlotDataset(train_data, intent2id, slot2id) loader DataLoader(dataset, batch_size32, shuffleTrue, num_workers0)参数说明batch_size32在 8GB 显存上跑bert-base基本是安全的如果 OOM 就降到 16。num_workers在 Windows 上建议设为 0否则容易遇到多进程报错Linux 下可以设 4 加速数据加载。shuffleTrue只在训练集上用验证集和测试集必须关掉。3. 联合模型结构共享 BERT 编码器加双任务头3.1 为什么共享编码器比两个独立 BERT 更划算两个独立 BERT 的方案参数量直接翻倍base 版每个约 110M 参数显存占用也翻倍。共享编码器的联合模型只多出两个小头意图分类头通常是一个线性层加 softmax槽位头是一个线性层加 CRF 或者直接 softmax。新增参数量不到 1M几乎可以忽略。更重要的是共享编码器让两个任务在底层表示上强制对齐——槽位边界的学习会迫使 BERT 关注局部词序信息而意图分类会迫使它关注全局语义两者互补。我一般会用一个可学习的权重来平衡两个任务的损失total_loss intent_loss alpha * slot_loss。alpha的取值很关键如果槽位任务明显更难比如槽位类别多、样本不均衡可以把alpha设到 1.5 到 2.0如果两个任务难度相当设 1.0 就行。这个值不需要精细调但完全忽略它比如直接相加在某些数据集上会让槽位 F1 掉 2 个点以上。3.2 模型定义的完整代码下面是一个基于transformers库的联合模型实现意图头和槽位头都接在 BERT 的pooler_output和sequence_output上import torch.nn as nn from transformers import BertModel class JointBertForIntentSlot(nn.Module): def __init__(self, bert_path, intent_num, slot_num, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(bert_path) hidden_size self.bert.config.hidden_size # base 版是 768 self.dropout nn.Dropout(dropout) self.intent_classifier nn.Linear(hidden_size, intent_num) self.slot_classifier nn.Linear(hidden_size, slot_num) def forward(self, input_ids, attention_mask, token_type_ids, intent_labelNone, slot_labelsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) sequence_output outputs.last_hidden_state # (B, L, H) pooled_output outputs.pooler_output # (B, H) # 意图分类用 [CLS] 的池化表示 intent_logits self.intent_classifier(self.dropout(pooled_output)) # 槽位填充每个 token 独立分类 slot_logits self.slot_classifier(self.dropout(sequence_output)) loss None if intent_label is not None and slot_labels is not None: intent_loss nn.CrossEntropyLoss()(intent_logits, intent_label) slot_loss nn.CrossEntropyLoss(ignore_index-100)( slot_logits.view(-1, slot_logits.size(-1)), slot_labels.view(-1) ) loss intent_loss 1.0 * slot_loss # alpha 可调 return { loss: loss, intent_logits: intent_logits, slot_logits: slot_logits }逻辑说明pooler_output是 BERT 对[CLS]位置的表示经过一层线性变换和 tanh 激活后的结果适合做句子级分类。last_hidden_state是每个 token 的上下文表示接一个线性层做 token 级分类。ignore_index-100确保 padding 和后续 subword 不参与槽位损失。alpha1.0是初始值你可以根据验证集表现调整。参数说明dropout0.1是 BERT 微调时的常用值如果训练集很小少于 2000 条可以调到 0.2 到 0.3 防止过拟合。bert_path可以传bert-base-chinese或者你本地下载好的路径。intent_num和slot_num根据你的标签集大小设定。3.3 优化器与学习率设置BERT 微调的学习率不能太大我一般用2e-5到5e-5之间。优化器选AdamW权重衰减设0.01。下面是一个典型的训练循环骨架from transformers import AdamW, get_linear_schedule_with_warmup model JointBertForIntentSlot(bert-base-chinese, intent_num10, slot_num25) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) optimizer AdamW(model.parameters(), lr3e-5, weight_decay0.01) epochs 10 total_steps len(loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() total_loss 0 for batch in loader: batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs[loss] loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() print(fEpoch {epoch1}, Loss: {total_loss/len(loader):.4f})参数说明lr3e-5是 BERT 微调的经典值如果 loss 震荡厉害就降到2e-5。warmup比例设 0.1 意味着前 10% 的步数学习率从 0 线性升到目标值这对 BERT 微调很重要能避免初期梯度爆炸。clip_grad_norm_的max_norm1.0是防止梯度爆炸的保险如果训练稳定可以不加但加上更安心。4. 避坑与排查联合训练里最容易翻车的五个地方4.1 槽位标签全预测为 OF1 接近零现象训练几个 epoch 后槽位 F1 一直在 0.1 以下打印预测结果发现模型把所有 token 都标成了O。原因O标签在数据里占比通常超过 80%交叉熵损失被O主导模型发现全预测O就能拿到很低的 loss于是躺平了。解决在CrossEntropyLoss里给O标签一个小于 1 的权重比如weighttorch.tensor([0.1] [1.0]*(slot_num-1))其中O的索引是 0。或者改用 focal loss让模型更关注难分类的少数类。我一般先用权重法简单有效。4.2 意图准确率虚高但实际推理时乱分类现象验证集意图准确率 95%但拿几条真实用户输入测试发现模型把“查天气”判成“订票”。原因训练集和验证集来自同一批数据分布太相似。真实场景的用词、句式跟训练集差异大模型过拟合了训练集的表面特征。解决划验证集时按时间或来源切分不要随机切。另外在训练时加一点数据增强比如同义词替换、随机插入删除能明显提升泛化。我习惯用nlpaug库做轻量增强但注意不要改变槽位标签的对齐。4.3 显存溢出OOM在 batch_size 调大后出现现象batch_size32跑得好好的调到 64 就报CUDA out of memory。原因BERT base 的参数量约 110M加上优化器状态和梯度显存占用大约是参数量的 4 到 5 倍。batch_size翻倍激活值显存也翻倍。解决用梯度累积。把batch_size设回 32但每 2 个 batch 才optimizer.step()一次等效于batch_size64。代码上就是加一个计数器累积到指定步数再更新参数并清零梯度。另外开启混合精度训练torch.cuda.amp能省 30% 到 40% 显存对 BERT 微调几乎无精度损失。4.4 中文分词与 BERT tokenizer 对齐错位现象槽位预测结果整体偏移一个 token比如“北京”的B-city标到了“京”上。原因预处理时用 jieba 分词得到词列表但 BERT tokenizer 又对每个词做了 subword 切分如果对齐逻辑写错标签就会错位。解决不要混用两套分词。要么全部用 BERT tokenizer 的tokenize结果做标注要么在词级别标注后用我第 2 章给的align_labels_with_tokens函数严格对齐。检查方法是打印一条样本的input_ids和aligned_labels手动核对每个 token 对应的标签是否正确。4.5 学习率过大导致 loss 震荡不收敛现象训练前几个 step loss 正常下降突然跳到很大的值然后一直震荡。原因BERT 微调对学习率非常敏感1e-4以上很容易发散。另外如果 warmup 没设或者设得太短初期梯度更新太猛也会炸。解决学习率降到2e-5到3e-5warmup 比例设 0.1。如果已经发散了加载回退到上一个保存的 checkpoint调小学习率重跑。我一般会在训练脚本里加一个loss 10就跳过该 batch 的保护逻辑防止个别脏数据把模型带偏。5. 进阶技巧用 CRF 层提升槽位边界准确率与模型导出验证5.1 在槽位头上加 CRF 解决标签非法转移槽位填充本质是序列标注相邻标签之间有强约束比如I-city前面必须是B-city或I-city不能是O或B-time。直接用 softmax 独立分类每个 token模型可能会输出O - I-city这种非法序列。条件随机场CRF层能学习标签之间的转移概率在解码时用维特比算法找全局最优路径从根上杜绝非法转移。在 PyTorch 里可以用torchcrf库快速接入。把第 3 章的slot_classifier输出接一个 CRF 层训练时用 CRF 的负对数似然作为槽位损失推理时用crf.decode得到最优标签序列。我实测在 ATIS 数据集上加 CRF 后槽位 F1 能再涨 0.5 到 1 个点尤其是实体边界多的场景提升更明显。代价是训练速度慢 10% 到 15%因为 CRF 的前向计算比普通 softmax 复杂。5.2 导出 ONNX 做推理加速与跨框架部署训练完的 PyTorch 模型如果想部署到 C 或者移动端导出 ONNX 是常见做法。下面是一个导出脚本的核心片段import torch from torch.onnx import export model.eval() dummy_input ( torch.tensor([[101, 234, 567, 102]], dtypetorch.long), # input_ids torch.tensor([[1, 1, 1, 1]], dtypetorch.long), # attention_mask torch.tensor([[0, 0, 0, 0]], dtypetorch.long) # token_type_ids ) export( model, dummy_input, joint_intent_slot.onnx, input_names[input_ids, attention_mask, token_type_ids], output_names[intent_logits, slot_logits], dynamic_axes{ input_ids: {0: batch, 1: seq_len}, attention_mask: {0: batch, 1: seq_len}, token_type_ids: {0: batch, 1: seq_len}, intent_logits: {0: batch}, slot_logits: {0: batch, 1: seq_len} }, opset_version14 )参数说明dynamic_axes把 batch 和序列长度设为动态维度这样导出的 ONNX 模型能处理任意长度的输入。opset_version14对 BERT 里的 attention 算子支持较好如果遇到导出报错可以降到 12 试试。导出后务必用onnxruntime跑一遍推理对比 PyTorch 和 ONNX 的输出差异如果 logits 的 max 绝对误差超过 1e-3说明导出有问题需要检查算子兼容性。5.3 用混淆矩阵定位意图与槽位的联合错误单独看意图准确率和槽位 F1 不够因为两个任务的错误会互相影响。我习惯在验证集上画一个联合混淆矩阵横轴是真实意图纵轴是预测意图每个格子里再统计该意图下槽位 F1 的均值。这样能快速发现“哪些意图的槽位特别差”。比如“订票”意图的槽位 F1 只有 0.6而其他意图都在 0.9 以上那就要专门去看订票类样本的槽位标注是不是有问题或者这类句子的表达太多样导致模型学不好。这个分析用sklearn.metrics.confusion_matrix加seaborn.heatmap就能做代码不复杂但能省下大量盲目调参的时间。我一般每训完一个 epoch 就跑一次把结果存成图片训练结束后翻一遍比只看 loss 曲线有用得多。最后说一个我踩过的坑有次用bert-base-chinese微调训练集只有 800 条意图准确率冲到 98%我高兴得太早上线后发现真实流量里准确率不到 70%。后来把训练集扩到 5000 条又加了同义词替换增强真实场景才稳定到 88% 左右。数据量不够的时候再好的模型结构也救不了这是血泪经验。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进