ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

PyTorch+BERT联合模型实战:意图识别与槽位填充 简介这份资源面向具备一定深度学习基础、希望上手意图识别与槽位填充联合建模的开发者与学习者提供了一套基于 PyTorch 与 BERT 的完整项目实践代码。核心思路是将意图分类与序列标注命名实体识别放在同一模型中联合训练预训练权重采用 Hugging Face 上的 chinese-bert-wwm-ext依赖 pytorch 1.6 与 transformers 4.5.0运行 python main.py 即可启动训练、验证、测试与预测相关参数均可在 config.py 中调整。压缩包共 18 个文件以 8 个 py 脚本为主体涵盖数据预处理、数据集构建、模型定义与推理入口另有 7 个 txt 存放意图与槽位标签及语料2 个 json 为训练与测试数据1 个 yaml 负责配置管理整体约 10KB结构紧凑、便于快速阅读与二次开发。目前已有 699 人学习下载适合作为对话系统、智能客服等场景下意图理解任务的入门与练手参考。1. 意图识别与槽位填充一个 PyTorch BERT 项目能解决什么真实问题用户说一句“帮我订明天下午三点从杭州到北京的高铁”系统要同时干两件事判断这句话属于「订票」这个意图再把「明天下午三点」「杭州」「北京」「高铁」这几个关键片段抽出来填进对应的槽位。前者叫意图识别后者叫槽位填充合起来就是任务型对话系统里最核心的 NLU 模块。这个 PyTorch BERT 项目实践做的就是把这套联合模型从零搭起来、跑通训练、验证效果。它适合正在做智能客服、语音助手、工单分类、对话机器人的工程师也适合刚学完 PyTorch 基础想找一个完整 NLP 项目练手的人。你不需要从零推导 Transformer但需要能看懂 Python、装过 PyTorch、知道 BERT 大概是什么。读完这篇你能拿到一套可复现的联合建模思路、关键参数设置和几个我实际踩过的坑。2. 为什么用 BERT 做联合模型意图识别和槽位填充的建模选择2.1 意图识别和槽位填充为什么不能各做各的最直觉的做法是训两个模型一个做文本分类输出意图一个做序列标注输出槽位。但这两个任务之间是有强关联的。比如意图是「订票」那槽位里大概率会出现出发地、目的地、时间意图是「查天气」槽位就是城市和日期。分开训等于把这个先验知识扔掉了。联合建模的常见做法是共享 BERT 编码层然后在上面接两个头一个池化后接分类头做意图识别一个接序列标注头做槽位填充。总损失是两个任务损失的加权和。这样 BERT 学到的表示同时服务两个任务小样本下效果通常比分开训好。我一般会用的损失权重是意图损失 1.0、槽位损失 1.0如果槽位效果明显差就调到 1.5。这个权重不是玄学它反映的是你更在意哪个任务的准确率。2.2 BERT 在这里到底做了什么BERT 在这个架构里的角色是特征提取器。输入一句话它输出每个 token 的上下文向量。意图头拿的是[CLS]位置的向量因为它在预训练时就被设计成聚合整句语义槽位头拿的是每个 token 位置的向量逐个分类到 BIO 标签。选 BERT 而不是 LSTM 或 TextCNN 的理由很直接预训练已经在大规模语料上学到了语法和语义知识你只需要在少量标注数据上微调。对于意图识别这种标注成本高的任务微调 BERT 的起点比从零训 LSTM 高出一大截。但要注意BERT 不是万能的。如果你的意图类别只有三五个、每类几百条数据BERT 可能过拟合这时候冻结底层、只训分类头反而更稳。2.3 用 HuggingFace 加载 BERT 并搭出双头模型下面是我常用的模型定义基于transformers库。先确认环境pip install torch transformers seqevalseqeval是用来算槽位填充的 F1 的后面会用到。import torch import torch.nn as nn from transformers import BertModel, BertPreTrainedModel class JointIntentSlotModel(BertPreTrainedModel): def __init__(self, config, num_intents, num_slots): super().__init__(config) self.bert BertModel(config) self.dropout nn.Dropout(0.1) # 意图分类头只用 [CLS] 向量 self.intent_classifier nn.Linear(config.hidden_size, num_intents) # 槽位标注头每个 token 一个分类 self.slot_classifier nn.Linear(config.hidden_size, num_slots) self.num_intents num_intents self.num_slots num_slots def forward(self, input_ids, attention_mask, token_type_ids, intent_labelsNone, slot_labelsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) sequence_output self.dropout(outputs.last_hidden_state) # [B, L, H] pooled_output self.dropout(outputs.pooler_output) # [B, H] intent_logits self.intent_classifier(pooled_output) # [B, num_intents] slot_logits self.slot_classifier(sequence_output) # [B, L, num_slots] loss None if intent_labels is not None and slot_labels is not None: intent_loss nn.CrossEntropyLoss()(intent_logits, intent_labels) # 槽位损失忽略 padding 位置的 -100 slot_loss nn.CrossEntropyLoss(ignore_index-100)( slot_logits.view(-1, self.num_slots), slot_labels.view(-1) ) loss intent_loss slot_loss # 权重可按需调整 return { loss: loss, intent_logits: intent_logits, slot_logits: slot_logits }逻辑说明BertModel输出last_hidden_state是每个 token 的向量pooler_output是[CLS]经过一层线性加 tanh 的结果。意图头用 pooler 输出槽位头用 sequence 输出。损失函数里槽位的ignore_index-100很关键因为一个 batch 里句子长度不同padding 位置不能参与 loss 计算否则模型会学会预测 padding。参数说明dropout0.1是 BERT 微调的常规值数据量小可以调到 0.2 到 0.3num_intents和num_slots根据你的标签集来定槽位标签要包含O和[PAD]对应的索引。2.4 数据格式和标签对齐BIO 标注怎么落到 token 上BERT 用的是 WordPiece 分词一个中文词可能被切成多个 subword。槽位标签是按字或词标的直接对齐会错位。常见做法是只给每个词的第一个 subword 打真实标签其余 subword 打-100忽略。这样槽位头只在第一个 subword 上学习预测时也只取第一个 subword 的结果。def align_labels_with_tokens(labels, word_ids): 把词级别的 BIO 标签对齐到 subword 级别 new_labels [] previous_word_id None for word_id in word_ids: if word_id is None: # 特殊 token如 [CLS] [SEP] new_labels.append(-100) elif word_id ! previous_word_id: # 每个词的第一个 subword 用真实标签 new_labels.append(labels[word_id]) else: # 同一个词的后续 subword 忽略 new_labels.append(-100) previous_word_id word_id return new_labels逻辑说明word_ids()是 HuggingFace tokenizer 提供的方法返回每个 token 对应的原始词索引。None表示特殊 token。只有词首 subword 保留标签其余置-100这样 loss 计算时自动跳过。参数说明如果你的数据是字级别标注每个字一个标签那就不需要这个对齐直接按 token 对齐即可但要确保 tokenizer 不做中文分词切分用BertTokenizer的字符级模式。3. 训练流程从 DataLoader 到评估指标的完整链路3.1 构建 Dataset 和 DataLoader数据通常是一个 JSON 或 TSV每行包含text、intent、slots三个字段。slots是和text等长的 BIO 标签列表。from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer class NLUDataset(Dataset): def __init__(self, data, tokenizer, intent2id, slot2id, max_len64): self.data data self.tokenizer tokenizer 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] text item[text] intent item[intent] slots item[slots] encoding self.tokenizer( text, max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) word_ids encoding.word_ids() aligned_slots align_labels_with_tokens( [self.slot2id[s] for s in slots], word_ids ) return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), token_type_ids: encoding[token_type_ids].squeeze(0), intent_label: torch.tensor(self.intent2id[intent]), slot_labels: torch.tensor(aligned_slots) }逻辑说明paddingmax_length统一长度方便 batch 拼接。word_ids()拿到对齐信息后调用前面的对齐函数。返回的slot_labels长度和input_ids一致。参数说明max_len64对大多数对话短句够用如果你的文本长可以调到 128但显存占用会翻倍。truncationTrue防止超长报错。3.2 训练循环和关键超参from transformers import AdamW, get_linear_schedule_with_warmup device torch.device(cuda if torch.cuda.is_available() else cpu) model JointIntentSlotModel.from_pretrained( bert-base-chinese, num_intentslen(intent2id), num_slotslen(slot2id) ).to(device) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) epochs 10 total_steps len(train_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 train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) token_type_ids batch[token_type_ids].to(device) intent_labels batch[intent_label].to(device) slot_labels batch[slot_labels].to(device) outputs model(input_ids, attention_mask, token_type_ids, intent_labels, slot_labels) loss outputs[loss] loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f})逻辑说明AdamW是 BERT 微调的标准优化器lr2e-5是经典值太大容易灾难性遗忘太小收敛慢。warmup占 10% 步数防止训练初期梯度震荡。clip_grad_norm_限制梯度范数避免梯度爆炸。参数说明weight_decay0.01是正则化数据少可以加到 0.1。epochs10配合早停通常 3 到 5 个 epoch 验证集 F1 就到峰值了继续训只会过拟合。3.3 评估意图准确率和槽位 F1 怎么算意图识别看准确率就行槽位填充必须用seqeval因为它按实体级别算 F1而不是按 token 算。from seqeval.metrics import classification_report, f1_score def evaluate(model, val_loader, id2slot): model.eval() all_intent_preds, all_intent_labels [], [] all_slot_preds, all_slot_labels [], [] with torch.no_grad(): for batch in val_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) token_type_ids batch[token_type_ids].to(device) outputs model(input_ids, attention_mask, token_type_ids) intent_preds outputs[intent_logits].argmax(dim-1) slot_preds outputs[slot_logits].argmax(dim-1) all_intent_preds.extend(intent_preds.cpu().tolist()) all_intent_labels.extend(batch[intent_label].tolist()) for pred, label in zip(slot_preds.cpu().tolist(), batch[slot_labels].tolist()): pred_seq, label_seq [], [] for p, l in zip(pred, label): if l -100: continue pred_seq.append(id2slot[p]) label_seq.append(id2slot[l]) all_slot_preds.append(pred_seq) all_slot_labels.append(label_seq) intent_acc sum(p l for p, l in zip(all_intent_preds, all_intent_labels)) / len(all_intent_labels) slot_f1 f1_score(all_slot_labels, all_slot_preds) print(fIntent Acc: {intent_acc:.4f}, Slot F1: {slot_f1:.4f}) print(classification_report(all_slot_labels, all_slot_preds)) return intent_acc, slot_f1逻辑说明槽位评估时要跳过-100的位置只比较有效 token。seqeval的f1_score接收的是标签序列列表自动按实体边界计算。参数说明如果slot_f1明显低于意图准确率说明槽位任务更难可以调大槽位损失权重或增加槽位头的学习率。4. 避坑与排查联合模型训练中最容易翻车的五个地方4.1 槽位 F1 一直是 0 或者极低现象训练 loss 在降但槽位 F1 始终接近 0。原因最常见的是标签对齐错了。比如word_ids()返回的索引和你的slots列表长度不一致或者slot2id里O标签的索引和-100冲突。解决打印一条样本的input_ids、word_ids、aligned_slots逐 token 核对。确保-100只出现在 padding 和特殊 token 位置真实标签的索引从 0 开始且不包含-100。4.2 意图准确率很高但槽位一塌糊涂现象意图准确率 95%槽位 F1 只有 60% 左右。原因两个任务的损失量级不匹配。意图分类的 loss 通常比槽位标注的 loss 小一个数量级因为意图是句子级、槽位是 token 级。总 loss 被槽位主导但梯度更新时意图头学得快、槽位头学得慢。解决给槽位损失加权重比如loss intent_loss 2.0 * slot_loss。或者给两个头设置不同的学习率槽位头用更大的 lr。4.3 训练集 loss 降但验证集 loss 上升现象典型的过拟合。训练 loss 持续下降验证集 F1 在第三个 epoch 后开始掉。原因数据量太小BERT 参数量太大。十万级参数量的分类头在几千条数据上很容易记住训练集。解决先冻结 BERT 底层只训分类头几个 epoch再解冻全部微调。或者加 dropout、weight_decay减少 epoch 数。早停是必须的patience 设 2 到 3。4.4 中文分词导致槽位边界错位现象预测出的槽位实体边界和标注不一致比如「杭州市」被拆成「杭州」和「市」两个实体。原因BERT 的 WordPiece 对中文是按字切分的但你的标注可能是按词。一个词被切成多个字如果每个字都预测了标签seqeval会把连续的同标签字符合并成一个实体但如果中间有O就会断开。解决确保标注粒度和 tokenizer 粒度一致。要么全按字标注要么用word_ids对齐后只取词首预测。评估时也要用同样的对齐逻辑。4.5 GPU 显存不够导致 batch size 只能设很小现象CUDA out of memorybatch size 降到 8 才能跑训练不稳定。原因BERT base 模型本身占 400MB 左右加上优化器状态和梯度batch size 16、序列长度 64 时大约需要 4GB 显存。如果序列长度 128显存翻倍。解决用梯度累积模拟大 batch。accumulation_steps4配合batch_size4等效于 16。另外可以开启混合精度训练from torch.cuda.amp import autocast, GradScaler scaler GradScaler() with autocast(): outputs model(...) loss outputs[loss] scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()逻辑说明autocast自动把部分计算转成 float16减少显存占用并加速。GradScaler防止 float16 梯度下溢。参数说明混合精度在 RTX 系列和 A 系列卡上效果明显老卡可能不支持。如果 loss 出现 NaN检查 scaler 的初始值或者暂时关掉混合精度排查。5. 进阶技巧用 CRF 层和对抗训练把槽位 F1 再提几个点5.1 在槽位头加 CRF 约束标签转移BERT 的槽位头是逐 token 独立分类不考虑标签之间的转移约束。比如B-城市后面跟I-城市是合法的但B-城市后面跟I-时间就非法。CRF 层可以学到这些转移规则通常能提 1 到 3 个点的 F1。from torchcrf import CRF class JointModelWithCRF(JointIntentSlotModel): def __init__(self, config, num_intents, num_slots): super().__init__(config, num_intents, num_slots) self.crf CRF(num_slots, batch_firstTrue) def forward(self, input_ids, attention_mask, token_type_ids, intent_labelsNone, slot_labelsNone): outputs self.bert(input_ids, attention_mask, token_type_ids) sequence_output self.dropout(outputs.last_hidden_state) pooled_output self.dropout(outputs.pooler_output) intent_logits self.intent_classifier(pooled_output) slot_logits self.slot_classifier(sequence_output) loss None if intent_labels is not None and slot_labels is not None: intent_loss nn.CrossEntropyLoss()(intent_logits, intent_labels) # CRF 的 loss 是负对数似然取负号 crf_labels slot_labels.clone() crf_labels[crf_labels -100] 0 # CRF 不接受 -100 crf_mask attention_mask.bool() crf_loss -self.crf(slot_logits, crf_labels, maskcrf_mask, reductionmean) loss intent_loss crf_loss return {loss: loss, intent_logits: intent_logits, slot_logits: slot_logits}逻辑说明pytorch-crf的crf前向返回对数似然取负号作为 loss。mask参数告诉 CRF 哪些位置是有效的。注意 CRF 不接受-100需要先把忽略位置替换成 0再用 mask 屏蔽。参数说明CRF 层会增加少量参数和计算量训练速度大概慢 10% 到 20%。如果槽位标签集很大超过 50 个CRF 的转移矩阵会很大收益可能不明显。5.2 对抗训练给 embedding 加扰动提升泛化对抗训练FGM的思路是在 embedding 上加一个小的扰动让模型在扰动下仍然预测正确从而学到更鲁棒的特征。实现很简单在 loss backward 之前做一次扰动再算一次梯度。class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {}逻辑说明attack在 embedding 的梯度方向上加上扰动restore恢复原始参数。训练时先正常 forward-backward然后 attack再 forward-backward 一次最后 restore 并更新。参数说明epsilon1.0是常用值太大训练不稳定太小没效果。对抗训练会让训练时间翻倍但通常能提 1 到 2 个点。5.3 验证方法用混淆矩阵定位意图和槽位的混淆对光看 F1 不够要知道错在哪。意图识别画混淆矩阵槽位填充看classification_report里哪些实体类型 F1 低。from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm confusion_matrix(all_intent_labels, all_intent_preds) sns.heatmap(cm, annotTrue, fmtd, xticklabelslist(intent2id.keys()), yticklabelslist(intent2id.keys())) plt.xlabel(Predicted) plt.ylabel(True) plt.show()逻辑说明混淆矩阵能直观看到哪些意图容易被混。比如「订票」和「改签」如果互相混说明模型没学到区分特征可能需要加更多这两类的样本。参数说明如果某个意图的召回率特别低先检查训练样本量再看标注是否一致。标注不一致是意图识别最大的隐形杀手。我自己的习惯是每训完一版模型先把验证集里所有预测错的样本导出来看一遍。十次里有八次能发现标注问题剩下两次才是模型问题。这个笨办法比调参管用。希望帮到你。本文还有配套的精品资源点击获取
返回列表