☰
PyTorch+BERT联合建模:意图识别与槽位填充实战
2026/9/26 14:20:51 网站建设 项目流程

简介:这份资源面向具备一定深度学习基础、希望上手意图识别与槽位填充联合建模的开发者与学习者,基于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 输入构造:从原始标注到模型可读的 batch

2.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_len=128): """ 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_len=128对大多数对话句子够用,如果你的场景里有长文本(比如客服工单),可以调到 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 组装 batch

PyTorch 的Dataset类负责单条样本的读取和转换,DataLoader负责批处理和打乱。下面是一个可直接复用的实现:

import torch from torch.utils.data import Dataset, DataLoader class JointIntentSlotDataset(Dataset): def __init__(self, data_list, intent2id, slot2id, max_len=128): 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, dtype=torch.long), "attention_mask": torch.tensor(attention_mask, dtype=torch.long), "token_type_ids": torch.tensor(token_type_ids, dtype=torch.long), "intent_label": torch.tensor(intent_label, dtype=torch.long), "slot_labels": torch.tensor(aligned_labels, dtype=torch.long) } # 使用示例 dataset = JointIntentSlotDataset(train_data, intent2id, slot2id) loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=0)

参数说明:batch_size=32在 8GB 显存上跑bert-base基本是安全的,如果 OOM 就降到 16。num_workers在 Windows 上建议设为 0,否则容易遇到多进程报错;Linux 下可以设 4 加速数据加载。shuffle=True只在训练集上用,验证集和测试集必须关掉。

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, dropout=0.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_label=None, slot_labels=None): outputs = self.bert( input_ids=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_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 不参与槽位损失。alpha=1.0是初始值,你可以根据验证集表现调整。

参数说明:dropout=0.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_num=10, slot_num=25) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) optimizer = AdamW(model.parameters(), lr=3e-5, weight_decay=0.01) epochs = 10 total_steps = len(loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_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_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {total_loss/len(loader):.4f}")

参数说明:lr=3e-5是 BERT 微调的经典值,如果 loss 震荡厉害就降到2e-5。warmup比例设 0.1 意味着前 10% 的步数学习率从 0 线性升到目标值,这对 BERT 微调很重要,能避免初期梯度爆炸。clip_grad_norm_的max_norm=1.0是防止梯度爆炸的保险,如果训练稳定可以不加,但加上更安心。

4. 避坑与排查:联合训练里最容易翻车的五个地方

4.1 槽位标签全预测为 O,F1 接近零

现象:训练几个 epoch 后,槽位 F1 一直在 0.1 以下,打印预测结果发现模型把所有 token 都标成了O。

原因:O标签在数据里占比通常超过 80%,交叉熵损失被O主导,模型发现全预测O就能拿到很低的 loss,于是躺平了。

解决:在CrossEntropyLoss里给O标签一个小于 1 的权重,比如weight=torch.tensor([0.1] + [1.0]*(slot_num-1)),其中O的索引是 0。或者改用 focal loss,让模型更关注难分类的少数类。我一般先用权重法,简单有效。

4.2 意图准确率虚高但实际推理时乱分类

现象:验证集意图准确率 95%,但拿几条真实用户输入测试,发现模型把“查天气”判成“订票”。

原因:训练集和验证集来自同一批数据,分布太相似。真实场景的用词、句式跟训练集差异大,模型过拟合了训练集的表面特征。

解决:划验证集时按时间或来源切分,不要随机切。另外,在训练时加一点数据增强,比如同义词替换、随机插入删除,能明显提升泛化。我习惯用nlpaug库做轻量增强,但注意不要改变槽位标签的对齐。

4.3 显存溢出(OOM)在 batch_size 调大后出现

现象:batch_size=32跑得好好的,调到 64 就报CUDA out of memory。

原因:BERT base 的参数量约 110M,加上优化器状态和梯度,显存占用大约是参数量的 4 到 5 倍。batch_size翻倍,激活值显存也翻倍。

解决:用梯度累积。把batch_size设回 32,但每 2 个 batch 才optimizer.step()一次,等效于batch_size=64。代码上就是加一个计数器,累积到指定步数再更新参数并清零梯度。另外,开启混合精度训练(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-5,warmup 比例设 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]], dtype=torch.long), # input_ids torch.tensor([[1, 1, 1, 1]], dtype=torch.long), # attention_mask torch.tensor([[0, 0, 0, 0]], dtype=torch.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_version=14 )

参数说明:dynamic_axes把 batch 和序列长度设为动态维度,这样导出的 ONNX 模型能处理任意长度的输入。opset_version=14对 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% 左右。数据量不够的时候,再好的模型结构也救不了,这是血泪经验。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询