简介:压缩包共十八个文件,是一份基于PyTorch与BERT的意图识别与槽位填充联合训练项目代码,面向NLP初学者、算法工程师以及任务型对话系统开发者。项目以分类与序列标注同训为核心思路,使用huggingface上的chinese-bert-wwm-ext预训练模型,同时完成意图分类与槽位抽取,适合快速搭建对话理解基线。包内八个Python脚本覆盖数据预处理、模型定义、训练、验证、测试与预测流程,七个txt文件存放意图标签、槽位标签及语句样本,两个json文件提供训练与测试语料,一个yaml文件集中管理可调参数,整体仅10KB,轻量易读。已有七百人学习下载,可帮助入门者理解联合训练的数据组织与模型交互方式,也可作为课程设计或工程原型参考;利用配置文件和脚本模块,开发者能快速迁移到自有数据集,开展意图识别与槽位填充的实验对比或二次开发。
1. 意图识别与槽位填充:为什么 PyTorch + BERT 成了这个项目的主流解法
用户说一句"帮我订一张明天去上海的机票",对话系统要先回答两个问题:这句话的意图是什么,"明天""上海"分别对应什么槽位。意图识别与槽位填充就是解决这两件事,一个做句子级分类,一个做 token 级序列标注。这个项目实践把它们收进同一个 PyTorch + BERT 框架,两个输出头一次 forward 同时出结果。
对从业者来说,这套方案的价值在于把两个耦合的子任务统一成联合模型,避免维护两套独立系统,也省掉意图出错后槽位解析跟着错的串联误差。适合刚接触对话系统、想跑通完整 pipeline 的工程师,也适合选型阶段想快速验证 BERT 在自己领域效果的人。
这条路径上的坑主要集中在数据标注和标签对齐,而不是模型结构本身。下面按"任务拆解 → 环境搭建 → 模型训练 → 踩坑排查 → 上线加速"的顺序,把每一步的参数和边界讲清楚。
2. 先拆任务:意图识别和槽位填充是两个难度不同的子问题
2.1 意图识别是句子分类,槽位填充是 token 级序列标注
两个子任务的性质完全不同,直接决定了模型设计和数据标注的方式。意图识别把整句话映射到一个类别,比如"订机票""查天气""退改签",这是 sentence-level 的分类问题,模型只需要捕捉全局语义,一句话里哪个词最重并不是关键。槽位填充则要对句子里的每个 token 标注语义角色,比如"明天"标成 B-date、"上海"标成 B-destination,这是 token-level 的序列标注问题,模型必须感知每个词在上下文里的局部角色,以及槽位片段的边界。
这两个任务单独做都不难,难在它们相互耦合。用户说"帮我改签明天的机票",如果系统只识别出"改签"意图,却不知道"明天"是时间槽,下游动作执行就拿不到参数;反过来,意图判错,槽位解析得再准也没有意义。所以实战里我不建议分开训练两套模型,而是做联合建模:共享 BERT 的语义表征,让意图分类和槽位标注互相补充信息。槽位序列里出现了地点实体,往往能反过来修正意图判断,这种依赖关系在独立模型里是学不到的。
数据规模上两者也有明显差距。意图识别通常几十个类别、每类几百条样本就能训出可用的分类器;槽位填充因为要标注每个 token,数据质量要求高得多,一条标签错位的数据会直接污染整句。项目实践里,数据准备阶段的时间投入通常占整个项目的一半以上,这一点要有心理准备。
2.2 为什么是 BERT 而不是 BiLSTM+CRF:预训练红利与成本边界
在 BERT 普及之前,槽位填充的主流方案是 BiLSTM+CRF,BiLSTM 负责编码上下文,CRF 层负责约束标签转移,比如"B-date 后面不能直接接 I-time"。这套方案在小规模、同分布数据上非常能打,训练快、推理也快,至今没有过时。
但它有两个绕不开的短板。第一,一词多义。用户说"我要查一下天气","查"是"查询";说"银行卡被查了","查"是"冻结"的语境。BiLSTM 的隐状态是固定维度的向量,同一个词在不同句子里几乎共享同一份表征,语境差异学不出来。BERT 通过 Transformer 的 attention 机制把每个 token 的表征和整句上下文动态耦合,天然解决这个问题。第二,迁移成本。从开源 checkpoint 起步,几百条标注数据就能 finetune 出像样的效果;BiLSTM+CRF 要从头训练词向量和网络,数据不够时效果很难看。
代价也很明确。BERT 参数量大,GPU 显存占用高,CPU 单句推理延迟普遍上百毫秒。如果线上对延迟极其敏感、领域数据又非常充分,BiLSTM+CRF 仍然有存在价值。但作为项目实践和大多数垂直场景的起点,PyTorch + BERT 是性价比最高的选择,先跑通再考虑蒸馏、量化和 ONNX 加速,后面第 6 章会展开。
2.3 数据怎么标:BIO 标签体系和意图标签表
标注格式直接决定代码怎么写。槽位填充最通用的是 BIO 体系:B 表示槽位片段开始,I 表示槽位片段内部,O 表示非槽位。以"帮我订一张明天去上海的机票"为例,标注结果如下:
帮 O 我 O 订 O 一 O 张 O 明 B-date 天 I-date 去 O 上 B-destination 海 I-destination 的 O 机 O 票 O意图标签单独维护一份映射表,常见做法是一个 intents.txt,每行一个意图名。槽位标签则分两类文件:slot_labels.txt 定义槽位类型(date、destination、city、time……),标注数据文件用上面这种 CONLL 风格,每行一个 token 加一个标签,空行分隔句子。
提示:项目实践里建议把意图和槽位标签都从 0 开始编号,单独存成 id2label / label2id 两份字典。后面接 PyTorch 的 CrossEntropyLoss 时直接取索引,避免在数据预处理阶段反复做字符串到数字的转换。
我还会在中间层加一个 JSON 格式的存档,token 列表、意图标签、槽位标签并列存在一条记录里。这样无论转 CONLL 还是直接喂给 DataLoader 都方便,也方便后面做数据增强时回退到原始标注。
3. 环境搭建与 BERT 模型实操:PyTorch 环境、分词与最小推理代码
3.1 用 Anaconda 配置 PyTorch 环境:CUDA 版本先对齐
拿到项目第一步是环境搭建。常见做法是用 Anaconda 创建独立虚拟环境,Python 版本选 3.8 或 3.9,都能兼容当前主流的 transformers 和 torch 版本。关键是 CUDA 版本要先对齐,否则装完 PyTorch 发现 torch.cuda.is_available() 返回 False,白白折腾半小时。这个适配问题在 Ubuntu 和 WSL 环境里都出现过,先检查驱动再安装是铁律。
先跑 nvidia-smi 看驱动支持的 CUDA 版本,比如驱动显示支持 12.8,那就可以安装对应 CUDA 编译的 PyTorch 轮子。安装命令用官方 index-url 指定版本:
conda create -n intent-slot python=3.8 -y conda activate intent-slot # 根据 nvidia-smi 输出的 CUDA 版本选择 cu118 / cu121 / cu128 pip install torch --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.38.2 pip install seqeval这里 cu118 是 CUDA 11.8 的预编译轮子,如果你的驱动支持 12.x,把尾号换成 cu121 或 cu128。PyTorch 的轮子自带 CUDA runtime,只要驱动版本大于等于对应 CUDA 版本就能跑。装完必须验证:
python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.device_count())"注意:在 WSL 里做 PyTorch 环境搭建时,Windows 侧的驱动和 WSL 里的 CUDA 是两回事,WSL 里直接用官方安装命令即可,不需要装 Windows 版 CUDA Toolkit。我当时第一次踩这个坑,在 Windows 装了全套 CUDA,进 WSL 发现根本不生效。
3.2 加载 bert-base-chinese:Tokenizer 和 Model 必须成对使用
transformers 库把加载过程封装得很干净,核心是两行:BertTokenizer 负责把文本切成 token,BertModel 负责产出向量。中文场景直接用 bert-base-chinese,它按字切分,词表 21128,绝大多数中文项目不需要自训练词表。
from transformers import BertTokenizer, BertModel tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = BertModel.from_pretrained("bert-base-chinese") text = "帮我订一张明天去上海的机票" inputs = tokenizer( text, max_length=64, padding="max_length", truncation=True, return_tensors="pt", ) print(inputs["input_ids"].shape) # torch.Size([1, 64]) print(inputs["attention_mask"].shape) # torch.Size([1, 64])逻辑说明:from_pretrained 首次运行会从 HuggingFace 下载权重到本地缓存。离线环境的话,提前在能联网的机器上下好整个模型目录,改 from_pretrained 的路径指向本地目录即可。编码后 input_ids 里 101 是 [CLS] 起始符,102 是 [SEP] 结束符,0 是 padding 出来的无意义位;attention_mask 里有效 token 是 1、padding 位是 0。这个 mask 在 forward 时必须原样传给 BERT,否则模型会把 padding 位也当成真实输入计算 attention。
3.3 组织 batch:padding 策略和 attention mask 不能漏
单个样例跑通之后,下一步是组织 batch。常见做法是把一个 batch 内的句子统一 pad 到相同长度,组成 tensor 一起喂给模型。padding 策略有两个选择:固定 "max_length" 或按 batch 动态 "longest"。我的习惯是训练时用 "longest" 省显存,推理时用固定长度配合 ONNX 导出,后面会解释原因。
texts = ["帮我订一张明天去上海的机票", "现在北京的天气怎么样"] encoded = tokenizer( texts, padding="longest", truncation=True, max_length=64, return_tensors="pt", ) print(encoded["input_ids"].shape) # torch.Size([2, 13]),两条短句等长参数说明:padding="longest" 只把短句补齐到 batch 内最长,比固定 max_length 平均省 30% 左右显存;truncation=True 配合 max_length 防止超长句子撑爆显存;return_tensors="pt" 直接返回 PyTorch tensor,省一步手动转换。这里容易埋雷的是英文和数字会被 tokenizer 拆成 subword,比如 "tomorrow" 拆成 to + ##morrow,后续做标签对齐时要知道每个 token 在原始文本里的位置,标准做法是取 tokenizer 返回的 offset_mapping 存下来备用。
4. 联合模型搭建与训练:多头输出、标签对齐和参数起点
4.1 模型结构:一个 BERT 主干,两个线性输出头
PyTorch 基础框架下,这个项目的模型结构非常简洁。BERT 主干输出 last_hidden_state,形状是 [batch, seq_len, hidden],bert-base 的 hidden 是 768。意图头只取 [CLS] 位置的向量过一层线性层,槽位头对每个 token 的向量过一层线性层,两个头的输出都是 logits,后面各接各的损失函数。
import torch import torch.nn as nn from transformers import BertModel class IntentSlotModel(nn.Module): def __init__(self, model_name="bert-base-chinese", num_intents=20, num_slots=31): super().__init__() self.bert = BertModel.from_pretrained(model_name) self.dropout = nn.Dropout(0.1) self.intent_head = nn.Linear(self.bert.config.hidden_size, num_intents) self.slot_head = nn.Linear(self.bert.config.hidden_size, num_slots) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) last_hidden = outputs.last_hidden_state # [batch, seq_len, 768] # [CLS] 位的向量代表整句语义,拿来做意图分类 intent_logits = self.intent_head(self.dropout(last_hidden[:, 0, :])) # 每个 token 过一个线性层,输出槽位 logits slot_logits = self.slot_head(self.dropout(last_hidden)) return intent_logits, slot_logits逻辑说明:last_hidden[:, 0, :] 取的是每个序列 [CLS] 位置的 768 维向量,BERT 在预训练阶段就把 [CLS] 训练成能代表整句语义的聚合向量,拿来做意图分类是标准操作。slot_head 输出形状是 [batch, seq_len, num_slots],后面算损失时要 permute 成 [batch, num_slots, seq_len] 才能喂给 CrossEntropyLoss。两个头都加了 dropout 防止过拟合,默认 0.1,小数据集上可以适当提到 0.2。
要不要在槽位头上再接 CRF?我的看法是项目起步阶段先不加。线性头加 softmax 已经能拿到可用的准确率,接 CRF 后训练代码复杂度上一个大台阶,收益在小数据集上不明显。数据量超过几万句、且槽位之间存在强约束关系时,再考虑把 CRF 接回来。
4.2 标签对齐:word 级标签到 token 级标签的映射
人工标注的槽位标签是 word 级的,也就是"明天"整体是 B-date/I-date;但 BERT 的 tokenizer 按字切分中文,英文单词还会被拆成 subword。word 级标签必须展开到 token 级,否则序列长度对不上,训练直接报错或者错位学习。
def align_slot_labels(tokens, word_labels): """ tokens: tokenizer 切出来的 token 序列,不含 [CLS]/[SEP] word_labels: 人工标注的 word 级 BIO 标签列表 返回与 tokens 等长的 token 级标签列表 """ aligned = [] label_idx = 0 for token in tokens: if token.startswith("##"): # subword 片段属于上一个完整词的内部,沿用 I 标签 if aligned and aligned[-1].startswith("B-"): aligned.append("I-" + aligned[-1][2:]) elif aligned: aligned.append(aligned[-1]) else: aligned.append("O") else: if label_idx < len(word_labels): aligned.append(word_labels[label_idx]) label_idx += 1 else: aligned.append("O") return aligned逻辑说明:虽然中文场景下每个 token 是一个字、token 数和字数基本一致,我仍然建议写成通用版,因为用户输入里混英文、数字、网址的情况太常见。核心逻辑是遇到 "##" 开头的 subword 时,它属于上一个完整词的内部,标签沿用上一个 token 的 I 标签;遇到完整词才推进 word_labels 的指针。对齐完成后把字符串标签转成 id,[CLS] 和 [SEP] 两个特殊 token 也要补标签,常见做法是补 O 对应的 id,或者在 loss 计算时用 ignore_index=-100 把这两个位置跳过去。
4.3 训练参数:学习率、batch size、epoch 的推荐起点
先给一组经过验证的起点参数,小数据集上基本能直接训出可用效果:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning rate | 2e-5 | BERT 微调的标准起点,太大会破坏预训练权重 |
| 分类头学习率 | 1e-4 | 与主干分开设,用 param groups 实现 |
| batch size | 16 或 32 | 显存不够就减半,同时打开梯度累积 |
| epoch | 3 ~ 5 | 配合早停,验证集 F1 不再上升就停 |
| warmup 比例 | 10% | 前 10% 的 step 线性升温 |
| weight decay | 0.01 | AdamW 自带的 L2 正则,防止过拟合 |
from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * 4 # 4 个 epoch scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps, )参数说明:BERT 主干从预训练权重起步,学习率超过 5e-5 很容易让权重剧烈漂移,2e-5 是微调任务里最稳的起点。分类头是随机初始化的线性层,可以用 1e-4 让它更快收敛,实现方式是构造 optimizer 时给两个 param group 分别设置 lr。
4.4 损失函数与训练循环:两个交叉熵相加的写法
联合模型的标准写法是意图损失和槽位损失直接相加,反向传播同时更新两个头。训练循环里的细节决定训练稳定性,代码里每一步都有讲究:
intent_loss = nn.CrossEntropyLoss()(intent_logits, intent_labels) slot_loss = nn.CrossEntropyLoss(ignore_index=-100)( slot_logits.permute(0, 2, 1), slot_labels ) loss = intent_loss + slot_loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()参数说明:槽位部分的 CrossEntropyLoss 必须加 ignore_index=-100,这是 PyTorch 的约定,标签里为 -100 的位置不参与 loss 计算。padding 位置和 [CLS]/[SEP] 位置的标签都置成 -100,既省算力,又避免模型学"预测 padding 为 O"这种无意义模式。clip_grad_norm_ 是关键一行,BERT 微调时偶尔会出现 loss 突变,梯度裁剪到 1.0 能防止一次异常 step 把损失炸到 nan。两个 loss 默认权重都是 1,如果槽位数据特别不平衡,把 slot_loss 的权重提到 1.5 到 2.0,实测对槽位 F1 有正向帮助。
5. 避坑与常见问题排查:联合模型最容易翻车的五个地方
这一章全是血泪经验,每一条都在真实项目里折腾过我半天以上。按"现象 → 原因 → 解决"写,方便你遇到同类问题时直接对照。
5.1 坑一:序列截断之后,槽位标签和目标值对不上
现象:训练时把 max_length 设成 32,数据里有长句子,跑着跑着 loss 变成 nan,或者训练不报错但验证集槽位 F1 极低。
原因:truncation 把长句子后半段截掉了,槽位标签没有同步截断,标签序列和 input_ids 长度对不上,DataLoader 拼 batch 时直接报错;即使侥幸拼上,token 和标签错位一位,模型学到的是乱序对应。
解决:tokenizer 之后立刻做对齐截断。先拿到 encoded 的 input_ids 长度,把标签序列截到同样的长度,再补 [CLS]/[SEP] 的标签。更稳妥的做法是先统计训练集句子长度分布,把 max_length 设到覆盖 95% 样本的长度。比如分布显示 95% 的句子不超过 48 个字,max_length 就设 48,而不是拍脑袋设 32。
5.2 坑二:标签对齐写错,训练 loss 一直不降
现象:训练 loss 一直在 2 以上波动,完全没有下降趋势,验证集准确率在随机水平附近。
原因:word 级标签展开 token 级时映射规则写错。典型错误是遇到 "##" subword 直接跳过不赋值,导致后面的标签整体左移一位;或者把 O 标签的 id 设成 0,和 padding 位冲突。
解决:用单独的小样例做单元测试。造一句话"帮我订明天去上海的机票",手工写出期望的 token 级标签序列,把对齐函数的输出打出来逐个对比。这类问题靠肉眼看 loss 曲线很难定位,必须从数据侧验证。调参调半天发现是数据错位,那种白费劲的感觉经历一次就不会再犯。
5.3 坑三:预测时漏传 attention_mask,长文本效果断崖
现象:训练时效果正常,一到预测接口效果明显变差,尤其是 pad 比较多的短句反而输出奇怪的槽位。
原因:训练时 attention_mask 参与 forward,模型学会了只在 mask=1 的位置计算注意力;预测时只传 input_ids 不传 attention_mask,BERT 默认所有位置一视同仁,padding 出来的 [PAD] token 被当成真实输入,注意力被稀释。
解决:预测代码和训练代码保持完全一致的 forward 参数。写推理函数时,把 tokenizer 返回的 input_ids 和 attention_mask 一起传进模型,不要图省事只取 input_ids。这个坑在短文本上不明显,因为 pad 占比小,句子一长差距立刻放大。
5.4 坑四:槽位类别严重不均衡,F1 分数常年上不去
现象:整体准确率 92%,看起来不错,一看槽位 F1 只有 60%,特别是地点、时间这类关键槽位召回率极低。
原因:真实对话数据里 O 标签占比极高,经常超过 80%。模型只要学会预测 O 就能拿高准确率,但对真正的槽位片段几乎没有学习动力,准确率这个指标在这里会骗人。
解决:三个手段组合。第一,loss 加权,给低频槽位类别更大的权重;第二,评估不用整体准确率,用 seqeval 的槽位 F1 作为唯一验收指标;第三,数据层面做槽位片段增强,把高频槽位的实体词做同义词替换,让模型见过更多表达形态。
5.5 坑五:直接用英文 BERT 处理中文,分词结果完全不对
现象:换成英文模型后,中文句子被切成一堆 [UNK],模型输出全部是 O。
原因:bert-base-uncased 的词表里没有中文字符,中文输入全部落到未登录词。很多人以为 BERT 是通用的,直接拿默认模型跑中文,这是 PyTorch + BERT 项目里最常犯的错。
解决:用 bert-base-chinese 或哈工大的 chinese-bert-wwm。中文里混英文也不用换模型,bert-base-chinese 的词表里包含英文字符,只是按字符切分,效果可接受。
6. 从跑通到能用:评估指标、ONNX 导出与增量迭代
6.1 评估指标:意图用准确率,槽位用 seqeval 的 F1
联合模型有两个输出,评估必须分开。意图部分直接算准确率即可,类别均衡时 accuracy 足够。槽位部分要用序列标注的标准指标,我一般用 seqeval 库,它按实体片段计算精确率、召回率和 F1,而不是按 token 计算,更能反映"槽位有没有被完整提取出来"。
from seqeval.metrics import f1_score true_slots = [["O", "B-date", "I-date", "O", "B-city"]] pred_slots = [["O", "B-date", "O", "O", "B-city"]] print(f1_score(true_slots, pred_slots)) # 0.666,date 被完整识别,city 只识别一半6.2 PyTorch 转 ONNX:推理提速与 dynamic axes
模型定稿后,PyTorch 在 CPU 上的推理速度通常不满足线上要求。常见做法是转 ONNX 再用 onnxruntime 推理,能拿到 2 到 3 倍提速,还能脱离 Python 环境部署。导出时注意把 batch 和 sequence 维度设成动态:
torch.onnx.export( model.cpu(), (dummy_input_ids, dummy_attention_mask), "intent_slot.onnx", input_names=["input_ids", "attention_mask"], output_names=["intent_logits", "slot_logits"], dynamic_axes={"input_ids": {0: "batch", 1: "seq_len"}, "attention_mask": {0: "batch", 1: "seq_len"}}, opset_version=12, )参数说明:dynamic_axes 把 batch 和 seq_len 声明为动态维度,导出的模型能接受任意长度输入,不用固定 64。opset_version 12 是兼容性和算子覆盖的平衡点,transformers 的模型在这个版本基本都能导出成功。导出后一定要用 onnxruntime 加载,跑一遍同样的输入对比输出误差,误差超过 1e-3 就检查动态维度的配置。
6.3 增量迭代:换领域时用少量数据 finetune
换到新领域(比如从机票切到酒店预订)时不用重头训。把原模型最后的两个线性头换掉,BERT 主干权重保留,用新领域几百条标注数据做少量 epoch 的 finetune,学习率比首次训练更低,我用 1e-5。两个领域的意图和槽位可以在同一个模型内并存,前提是把新老标签表做 Union,label2id 字典要兼容两套标注。
我自己的习惯是每轮迭代后,把评估脚本和一组固定测试集固化成一条命令,任何改动跑一遍就知道有没有回退。这个项目最值得投入的时间不在调参——模型结构反而是最省心的部分,数据对齐和评估流程才是决定效果上限的地方。希望帮到你。
本文还有配套的精品资源,点击获取