简介:面向中文自然语言处理入门及进阶开发者,这份资源提供了一套基于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 DC,Washington可能被拆成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_len=128): 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]对应-100,token对应原始标签,[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, dtype=torch.long), "attention_mask": torch.tensor(attention_mask, dtype=torch.long), "intent_label": torch.tensor(intent_label, dtype=torch.long), "slot_labels": torch.tensor(aligned_slot_labels, dtype=torch.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, dtype=torch.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_size=32, shuffle=True, collate_fn=collate_fn)slot_labels的pad值必须传-100而不是0。常见翻车就是图省事把所有序列pad成0,而0恰好是O标签的编号,模型会学到“padding位置是O”,推理时输入长度变化后,预测结果在句子尾部莫名其妙多出几个O标签,虽然不影响实体抽取,但会把指标算乱。attention_mask的pad值是0,BERT在自注意力计算时会忽略这些位置;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_name="bert-base-chinese", num_intents=10, num_slots=15): 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_ids=input_ids, attention_mask=attention_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 学习率与warmup:BERT微调不是普通分类任务
BERT已经用海量文本预训练过,微调时的学习率不能照搬从头训练的模型。PyTorch里用AdamW配lr=2e-5到5e-5是业内常见区间,低于1e-5收敛太慢,高于5e-5很容易在第二个epoch就把预训练知识冲掉,验证集指标突然崩断。
warmup是另一个容易被忽略的点,BERT微调普遍用linear schedule with warmup:
from 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(), lr=3e-5) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_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 python=3.10 -y conda activate nlu # 先确认nvidia-smi里的CUDA版本,再选torch安装命令 pip install torch pip install transformerstransformers库负责加载BERT模型和BertTokenizer,PyTorch提供训练框架。两者的版本一般不需要刻意锁定,但要保证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, device="cuda"): 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_norm=1.0) optimizer.step() scheduler.step() total_loss += loss.item() progress.set_postfix(loss=loss.item()) return total_loss / len(loader)clip_grad_norm_(..., max_norm=1.0)把整个模型的梯度范数裁剪到1.0,防止个别异常样本让BERT参数一步跳飞。不裁的话,训练到后期loss会出现很大尖刺,验证集指标跟着抖。scheduler必须在每个step后调用,不是每个epoch后,否则warmup阶段的计算就不对了。
4.3 评估循环:token级accuracy和真正的span F1
def evaluate(model, loader, device="cuda"): 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 显存OOM,batch size已经调到16还炸
现象:bert-base 12层,max_len 128,16条样本就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_mask,BERT就得靠padding token的表征瞎猜。
解决:推理代码保持和训练一致的collate设置,attention_mask一个都不能省。线上服务建议固定max_len=64并对超长样本直接截断,推理吞吐比动态变长更稳定,因为请求并发时长度差异大,GPU调度和显存分配都会抖动。我线上固定max_len后,P95延迟降了约两成。
5.5 中文语气词“啊”“嗯”总是被预测成特定实体
现象:槽位预测里,“啊”频繁被识别为某个实体类型,但它在业务上就是语气词,不该有任何槽位标签。
原因:语料里包含语气词的样本太少,模型没学会“这类词位置应该是O”的规律,只能从有限的标注里硬猜。
解决:给槽位解码加一条规则约束,类似CRF的穷举限制——在手写规则层把“语气词列表命中且预测为实体”的token强制改判为O。治本的办法是给槽位层挂CRF,让标签转移矩阵显式学到“O后面不能直接跟I-xxx”这类约束。
6. 进阶:CRF约束与联合训练验证技巧
6.1 给槽位输出挂一层CRF
token级softmax预测只能保证每个token独立概率最大,但“B-date后面接I-city”这种不合法序列它拦不住。CRF层通过一个标签转移矩阵显式建模相邻标签的合法性,在序列标注任务里是常见增强。
# 需要提前安装torchcrf from torchcrf import CRF class JointModelWithCRF(nn.Module): def __init__(self, model_name="bert-base-chinese", num_intents=10, num_slots=7): 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_labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_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, mask=mask) return intent_logits, slot_loss, mask else: slot_pred = self.crf.decode(slot_logits, mask=attention_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可靠。这是我被坑过之后养成的习惯,希望帮到你。
本文还有配套的精品资源,点击获取