ARTICLE DETAIL

资讯详情

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

PyTorch与BERT联合训练:意图识别和槽位填充实战

PyTorch与BERT联合训练:意图识别和槽位填充实战 简介面向中文自然语言处理入门及进阶开发者这份资源提供了一套基于PyTorch与BERT的意图识别与槽位填充联合训练实现。项目融合文本分类与序列标注双重任务采用chinese-bert-wwm-ext预训练模型适用于客服问答、任务型对话等场景适合希望掌握多任务学习与模型微调流程的读者。压缩包共含25个文件大小仅15KB主要包含8个Python脚本负责数据预处理、模型构建、训练预测、7个txt文本存放意图标签与槽位语料、2个json配置以及模型配置文件等目录结构清晰便于快速定位核心代码。当前已有44人学习浏览。通过阅读源码与配置文件可完整了解从数据处理、模型设计到验证测试的工程化流程并直接调整config.py中的超参数与数据路径进行二次实验。资源来源于网络分享适合学习交流使用。1. 为什么把意图识别与槽位填充放进同一个模型意图识别管“用户想干什么”槽位填充管“用户这句话里哪些词是参数”这两者是自然语言理解里长期捆绑在一起的双任务。常规做法是拆成两个独立模型上线时先跑意图再按意图分支去调用对应的槽位抽取服务一次请求要经历两次BERT前向。而基于PyTorch与BERT做联合训练一次前向推理就能同时拿到意图分类和槽位序列标注两个结果——两个任务共享同一个BERT编码器意图信息会反过来约束槽位预测比如模型看到“订机票”这个意图就更倾向把“周三”标成出发日期而不是目的地名称。这篇文章写给正准备落地中文或中英混合NLU能力的工程师我会把从数据格式、标签对齐、模型结构、损失配比到训练循环的完整实现写清楚连踩过的坑一起。2. BERT输入构建与标签对齐联合训练从数据准备就开始2.1 中英文token切分差异决定标签对齐策略BERT的输入不是原始文本而是一个subword序列。用transformers的AutoTokenizer切词时英文场景里booking可能被切成book和##ing中文场景按字切“订机票”切出来就是[订, 机, 票]。这个差异直接决定了槽位标签怎么对齐。中文NLU是最舒服的情况BERT切出来的第i个token对应原始第i个字的标签按位复制即可。中英混合或纯英文文本就要处理subword了。假设一条样本Book a flight to Washington DCWashington可能被拆成Wash、##ington两个token原始标注只给整个词标了一个槽位类型对齐到token层面后这两个token签发给谁常见做法有三种把原始标签扩展给所有subword只给第一个subword真实标签其余用-1在损失里忽略或者把槽位抽取改成span抽取绕开对齐。实际项目里我几乎都用第一种。原因是BERT内部每个subword都有独立的表征如果##ington对应的标签被设成-1这个位置的梯度完全不参与更新等于白费了模型在这个位置学到的语义。用BIO扩展后后处理时只需要取一个词第一个subword的预测作为这个词的标签把##开头的token拼回原词。数据特点建议max_len说明中文短文本客服、搜索64~128意图和槽位几乎都在一两句话内中英混合邮件、工单256英文subword膨胀明显128可能截掉实体超长文档场景512需要配滑窗或多段汇总这个表的核心意思是max_len不是越大越好。设太长padding浪费显存训练变慢设太短实体在截断时被切掉槽位标签直接错位。先统计你数据里样本的token长度分布取95分位再加余量比拍脑袋设128靠谱得多。2.2 构建Dataset文本、token、槽位标签同步截断下面给一份可以直接跑起来的Dataset实现。它负责切词、截断、标签对齐和padding目标是让__getitem__返回的每一个张量长度完全一致。import torch from torch.utils.data import Dataset from transformers import BertTokenizer class JointNLUDataset(Dataset): def __init__(self, texts, intent_labels, slot_labels, tokenizer, max_len128): self.texts texts self.intent_labels intent_labels self.slot_labels slot_labels # list[list[int]]与切词后token一一对应 self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] intent_label self.intent_labels[idx] slot_label self.slot_labels[idx] # 先切词拿到不含[CLS]/[SEP]的token列表 tokens self.tokenizer.tokenize(text) # 最多保留 max_len-2 个token留两个位置给[CLS]和[SEP] tokens tokens[: self.max_len - 2] slot_label slot_label[: len(tokens)] # 标签同步截断这一步不能漏 input_ids [self.tokenizer.cls_token_id] input_ids self.tokenizer.convert_tokens_to_ids(tokens) input_ids [self.tokenizer.sep_token_id] attention_mask [1] * len(input_ids) # padding到max_len pad_len self.max_len - len(input_ids) input_ids [self.tokenizer.pad_token_id] * pad_len attention_mask [0] * pad_len # 对齐槽位标签[CLS]对应-100token对应原始标签[SEP]和padding也设-100 aligned_slot_labels [-100] aligned_slot_labels slot_label[: len(tokens)] aligned_slot_labels [-100] aligned_slot_labels [-100] * pad_len return { input_ids: torch.tensor(input_ids, dtypetorch.long), attention_mask: torch.tensor(attention_mask, dtypetorch.long), intent_label: torch.tensor(intent_label, dtypetorch.long), slot_labels: torch.tensor(aligned_slot_labels, dtypetorch.long), }这里有几个关键参数和设计决策。-100在PyTorch的CrossEntropyLoss里是默认的ignore_index损失计算时这些位置的梯度自动被跳过所以[CLS]、[SEP]和padding位置都不会对槽位学习产生干扰。slot_label[:len(tokens)]这一步是截断同步token截到多长标签就跟到多长少这行代码后面跑出来的槽位会错得莫名其妙——token数量永远大于标签数量直接报维度不匹配的错。tokenizer.tokenize()和tokenizer(text)的区别也值得注意。前者只返回切词结果不含特殊符号后者返回完整的编码。我在这里先切词再手动拼[CLS]和[SEP]目的就是能在拼标签时精确知道哪些位置该设真实值、哪些位置该设-100。2.3 动态padding的collate_fn省显存的第一道工序上面的Dataset固定padding到max_len写起来省事但一批样本平均长度只有60个字时一半以上是padding token显存白烧。BERT照样要对padding位置做attention计算只是被mask而已。所以我一般会在DataLoader里挂一个自定义collate_fn按batch内最长样本做padding。from torch.utils.data import DataLoader def collate_fn(batch): input_ids [item[input_ids] for item in batch] attention_mask [item[attention_mask] for item in batch] slot_labels [item[slot_labels] for item in batch] max_len max(len(seq) for seq in input_ids) def pad_sequence(seqs, pad_value): return torch.stack([ torch.cat([seq, torch.full((max_len - len(seq),), pad_value, dtypetorch.long)]) for seq in seqs ]) return { input_ids: pad_sequence(input_ids, 0), attention_mask: pad_sequence(attention_mask, 0), slot_labels: pad_sequence(slot_labels, -100), intent_label: torch.stack([item[intent_label] for item in batch]), } loader DataLoader(dataset, batch_size32, shuffleTrue, collate_fncollate_fn)slot_labels的pad值必须传-100而不是0。常见翻车就是图省事把所有序列pad成0而0恰好是O标签的编号模型会学到“padding位置是O”推理时输入长度变化后预测结果在句子尾部莫名其妙多出几个O标签虽然不影响实体抽取但会把指标算乱。attention_mask的pad值是0BERT在自注意力计算时会忽略这些位置input_ids的pad值用pad_token_id对中文BERT来说就是0。到这里数据管道已经通了。顺序是先构建Dataset同步截断再写collate_fn做动态padding最后才轮到模型。这一步不做扎实后面模型训练出来的槽位指标会忽高忽低而且很难排查因为错误样本看起来都只差一点点。3. 双任务头的PyTorch模型架构共享编码器怎么搭3.1 共享编码器为什么赢意图和槽位本质上是同一句话的两个观察角度。意图是句子级摘要槽位是token级细粒度标注。如果分开训练两个模型各自的BERT只能看到自己任务的局限视角槽位模型看不到“这句话整体意图是订机票”意图模型也看不到“周三”这个词频繁出现在日期槽位里。共享编码器后最后一层表征同时被两个任务头读取反向传播时两个任务梯度在BERT内部叠加等于强制模型学一个对两个任务都友好的中间表征。对小规模语料来说这个特性收益很大。联合训练几乎总是优于独立训练因为意图分类提供的高层语义信号能帮槽位模型消歧。推理成本是另一个理由一次前向两个输出线上延迟直接砍半。如果拆成两个模型跑每次请求就是两次完整BERT推理吞吐量压力翻倍。3.2 双任务头的实现最小可跑的JointModel模型结构不复杂base模型用BertModel从pooler_output接意图头从last_hidden_state接槽位头。import torch.nn as nn from transformers import BertModel class JointIntentSlotModel(nn.Module): def __init__(self, model_namebert-base-chinese, num_intents10, num_slots15): super().__init__() self.bert BertModel.from_pretrained(model_name) hidden_size self.bert.config.hidden_size # 中文base是768 self.intent_head nn.Sequential( nn.Dropout(0.1), nn.Linear(hidden_size, num_intents), ) self.slot_head nn.Linear(hidden_size, num_slots) def forward(self, input_ids, attention_mask): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, ) # last_hidden_state: [batch, seq_len, hidden] sequence_output outputs.last_hidden_state # pooler_output: [batch, hidden]来自[CLS]再经过tanh和线性层 pooled_output outputs.pooler_output intent_logits self.intent_head(pooled_output) # [batch, num_intents] slot_logits self.slot_head(sequence_output) # [batch, seq_len, num_slots] return intent_logits, slot_logits参数字段要先说清。num_intents是你意图体系里的类别总数比如查询天气、订机票、订酒店、退改签一个类别一个编号。num_slots是槽位标签总数通常等于实体类型乘以BIO展开数再加一个O比如3种实体日期、城市、航班号对应O B-date I-date B-city I-city B-flight I-flight 7。intent_head里Dropout设0.1是经验值。BERT输出层已经很大加0.1的正则足够slot_head我没重复加Dropout因为实践里两个头都加Dropout后槽位F1反而微降估计是重复正则把细粒度特征削掉了你可按自己数据再试。3.3 损失函数怎么配比alpha不是随便拍的联合训练的核心是两项损失的加权和intent_criterion nn.CrossEntropyLoss() slot_criterion nn.CrossEntropyLoss(ignore_index-100) intent_loss intent_criterion(intent_logits, intent_labels) slot_loss slot_criterion( slot_logits.view(-1, num_slots), slot_labels.view(-1), ) loss alpha * intent_loss (1 - alpha) * slot_lossslot_logits.view(-1, num_slots)把batch和序列长度两个维度合并每个token变成一个独立预测样本配合slot_labels.view(-1)计算token级交叉熵。alpha是意图任务的权重常见起点0.3到0.5。原因在于槽位任务有大量token参与损失计算天然在总损失里占大头如果把alpha设太高模型会牺牲槽位去照顾意图设太低意图准确率会开始波动。调参经验意图准但槽位F1上不去把alpha往小调槽位准但意图乱了把alpha往大调。我一般是先在0.4跑一版等验证集曲线稳定后看两个指标谁拖后腿再动。3.4 学习率与warmupBERT微调不是普通分类任务BERT已经用海量文本预训练过微调时的学习率不能照搬从头训练的模型。PyTorch里用AdamW配lr2e-5到5e-5是业内常见区间低于1e-5收敛太慢高于5e-5很容易在第二个epoch就把预训练知识冲掉验证集指标突然崩断。warmup是另一个容易被忽略的点BERT微调普遍用linear schedule with warmupfrom transformers import get_linear_schedule_with_warmup total_steps len(loader) * epochs warmup_steps int(0.1 * total_steps) optimizer torch.optim.AdamW(model.parameters(), lr3e-5) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps, )warmup_steps让学习率从0线性升到目标值避免一开始就大步踩进预训练参数的敏感区域。0.1倍总步数是常见起点。还有个选择要提前做全量微调还是冻结BERT前11层只放开最后一层和两个头。全量微调效果通常最好但显存多占用30%上下冻结策略省显存但槽位F1会明显下降。我的习惯是数据量小于2万条时用全量微调配合早停数据量大且想省训练时间再考虑冻结。4. 基于PyTorch与BERT的联合训练落地从环境到训练循环4.1 环境搭建PyTorch、CUDA、transformers三者版本对齐PyTorch安装教程里最常见的翻车点是先装torch再装CUDA toolkit最后torch.cuda.is_available()返回False。正确顺序是先跑nvidia-smi看驱动支持的CUDA版本再按这个版本去PyTorch官网选对应的安装命令。驱动版本直接决定你能不能上GPU训练这个顺序错了一切白搭。conda create -n nlu python3.10 -y conda activate nlu # 先确认nvidia-smi里的CUDA版本再选torch安装命令 pip install torch pip install transformerstransformers库负责加载BERT模型和BertTokenizerPyTorch提供训练框架。两者的版本一般不需要刻意锁定但要保证transformers能识别你下载的BERT预训练权重文件。国内网络环境下bert-base-chinese权重会在第一次from_pretrained时自动下载如果下载超时可以先用huggingface-cli download单独把模型拉到本地缓存在指定目录再设置环境变量指向离线路径。4.2 主训练循环梯度裁剪和scheduler步进时机训练循环里最容易忽略的三件事梯度裁剪、scheduler每个step步进一次、zero_grad放在backward之前。from tqdm import tqdm import torch def train_one_epoch(model, loader, optimizer, scheduler, criteria, alpha, devicecuda): model.train() total_loss 0.0 intent_criterion, slot_criterion criteria progress tqdm(loader) for batch in progress: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) intent_labels batch[intent_label].to(device) slot_labels batch[slot_labels].to(device) intent_logits, slot_logits model(input_ids, attention_mask) intent_loss intent_criterion(intent_logits, intent_labels) slot_loss slot_criterion( slot_logits.view(-1, slot_logits.size(-1)), slot_labels.view(-1), ) loss alpha * intent_loss (1 - alpha) * slot_loss optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() progress.set_postfix(lossloss.item()) return total_loss / len(loader)clip_grad_norm_(..., max_norm1.0)把整个模型的梯度范数裁剪到1.0防止个别异常样本让BERT参数一步跳飞。不裁的话训练到后期loss会出现很大尖刺验证集指标跟着抖。scheduler必须在每个step后调用不是每个epoch后否则warmup阶段的计算就不对了。4.3 评估循环token级accuracy和真正的span F1def evaluate(model, loader, devicecuda): model.eval() total 0 intent_correct 0 slot_correct 0 slot_total 0 with torch.no_grad(): for batch in loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) intent_labels batch[intent_label].to(device) slot_labels batch[slot_labels].to(device) intent_logits, slot_logits model(input_ids, attention_mask) intent_pred intent_logits.argmax(-1) slot_pred slot_logits.argmax(-1) intent_correct (intent_pred intent_labels).sum().item() valid slot_labels ! -100 slot_correct ((slot_pred slot_labels) valid).sum().item() slot_total valid.sum().item() total intent_labels.size(0) intent_acc intent_correct / total slot_acc slot_correct / slot_total return intent_acc, slot_accslot_correct和slot_total的统计里都用valid slot_labels ! -100排除padding位置的贡献不然padding位置如果恰好被预测成O会让slot accuracy虚高。token级accuracy只是训练过程里方便盯的代理指标最终槽位效果要按span级F1算用seqeval库直接吃BIO序列即可。意图准确率加槽位span F1这两个指标共同决定联合训练模型能不能上生产。5. 联合训练避坑指南五个翻车现场5.1 槽位指标上不去意图准确率倒是很高现象训练10个epoch意图acc稳定在96%槽位F1只有70%。原因标签对齐错误。最常见两种padding位置用了0或1做标签而0恰好是O标签编号模型学到把padding当O预测另一种是英文subword对齐策略错了后半个词被标成了错类型。解决先查slot_labels里padding位置是不是-100再随机抽batch打印真实标签和预测看预测是否落在padding位置上如果是中英混合数据单独算英文样本子集的F1通常会比中文子集低5到10个点差距明显就是对齐策略的问题。5.2 训练Loss稳步下降验证集第三个epoch突然崩掉现象train loss持续下降val槽位F1从82跌到60而且再也回不来。原因学习率过高或者warmup没生效。BERT微调最怕大步踩坑学习率超过5e-5后预训练知识在前两轮就被扰动得面目全非。解决把学习率从5e-5降到2e-5同时确认warmup_steps占总步数不低于10%。还有一个判断技巧如果第二个epoch的val指标明显好于第三个epoch基本可以断定学习率过高立刻调低重跑。5.3 显存OOMbatch size已经调到16还炸现象bert-base 12层max_len 12816条样本就OOM。原因固定padding到max_len的写法让每条样本都按最大长度计算attention显存被无效token吃掉了。PyTorch默认也不复用显存碎片。解决换第2章的collate_fn做动态padding显存占用通常能降30%到50%。还不够就开梯度累积accum_steps 4 loss loss / accum_steps loss.backward() if (step 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()注意loss / accum_steps这行不能省它让累积4步的梯度总和等效于一个更大batch的梯度。不加这行等效batch size变大但学习率没变优化器会震荡。5.4 训练完离线验证指标不错线上单条推理结果反而差现象验证集槽位F1有85单条调用线上服务时预测结果差一截尤其当服务端一次性传入多句话时。原因训练时DataLoader动态padding并正确传了attention_mask线上推理代码如果把多句话拼成一个batch却没有给短的样本补mask或者干脆省略了attention_maskBERT就得靠padding token的表征瞎猜。解决推理代码保持和训练一致的collate设置attention_mask一个都不能省。线上服务建议固定max_len64并对超长样本直接截断推理吞吐比动态变长更稳定因为请求并发时长度差异大GPU调度和显存分配都会抖动。我线上固定max_len后P95延迟降了约两成。5.5 中文语气词“啊”“嗯”总是被预测成特定实体现象槽位预测里“啊”频繁被识别为某个实体类型但它在业务上就是语气词不该有任何槽位标签。原因语料里包含语气词的样本太少模型没学会“这类词位置应该是O”的规律只能从有限的标注里硬猜。解决给槽位解码加一条规则约束类似CRF的穷举限制——在手写规则层把“语气词列表命中且预测为实体”的token强制改判为O。治本的办法是给槽位层挂CRF让标签转移矩阵显式学到“O后面不能直接跟I-xxx”这类约束。6. 进阶CRF约束与联合训练验证技巧6.1 给槽位输出挂一层CRFtoken级softmax预测只能保证每个token独立概率最大但“B-date后面接I-city”这种不合法序列它拦不住。CRF层通过一个标签转移矩阵显式建模相邻标签的合法性在序列标注任务里是常见增强。# 需要提前安装torchcrf from torchcrf import CRF class JointModelWithCRF(nn.Module): def __init__(self, model_namebert-base-chinese, num_intents10, num_slots7): super().__init__() self.bert BertModel.from_pretrained(model_name) self.intent_head nn.Linear(768, num_intents) self.slot_head nn.Linear(768, num_slots) self.crf CRF(num_slots) def forward(self, input_ids, attention_mask, slot_labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state pooled outputs.pooler_output intent_logits self.intent_head(pooled) slot_logits self.slot_head(sequence_output) if slot_labels is not None: mask (slot_labels ! -100).bool() slot_loss -self.crf(slot_logits, slot_labels, maskmask) return intent_logits, slot_loss, mask else: slot_pred self.crf.decode(slot_logits, maskattention_mask.bool()) return intent_logits, slot_predCRF的decode方法内部跑维特比算法返回整个序列的最优标签路径。slot_loss取负号因为torchcrf实现里前向算法返回的是对数似然我们要最小化的是负对数似然。CRF对槽位F1的增益通常在1到3个点代价是解码比纯argmax慢但单条100 token以内延迟影响很小。我的经验是先跑一版不带CRF的模型确认数据没问题再加CRF做最终效果提升不然CRF的转移矩阵会帮你掩盖数据里的对齐错误。6.2 联合训练的自洽性验证别只盯两个指标联合训练最大的验证盲区是“意图和槽位是否自洽”。预测意图是“查询天气”槽位里却抽出了“目的地北京”这俩任务出自同一个模型不该互相打架。我每个epoch结束时会从验证集随机抽300条样本统计意图为A但槽位包含明显不属于A的实体类型的比例。做法很朴素把验证集预测结果导出成CSV用Excel或Python脚本按意图分组筛实体。如果矛盾样本比例超过2%说明两个任务头各自在学各自的表征联合训练没有真正互相渗透。这时我会反查两件事alpha是不是被某个任务的loss绑架了共享的BERT层是不是冻结得太深导致梯度只在标题头部流动。这个自洽性检查也是我上线前必做的一步。线上模型出了问题老板拿意图和槽位打架的case来问有一个已经跑通的检查脚本能少挨很多骂。跑完CRF版本再顺手把这个自洽性指标加进CI里每次训练完自动输出报告比肉眼看tensorboard可靠。这是我被坑过之后养成的习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表