☰
基于BERT的NLU三合一:领域分类、意图识别与槽位填充
2026/11/2 10:24:36 网站建设 项目流程

简介:这是一套基于BERT完成领域分类、意图识别与槽位填充的实战项目包,面向自然语言处理入门者、算法工程师及参加SMP2019相关评测的开发者。压缩包共14个文件,总大小1.53MB,内部包含6个Python脚本,覆盖数据预处理、模型微调、评估与LSTM-CRF解码等环节;3个JSON文件为带槽位标签的训练/测试语料;3份PDF技术报告来自多个参赛团队,提供不同解决方案的对比分析,另有说明文档帮助快速上手。项目以SMP2019 ECDT任务为蓝本,从BERT的分词、特殊标记处理到微调与槽位填充联合建模均有完整实现,读者可依据数据与代码迅速搭建自己的实验环境,并对照报告理解领域分类、意图识别和槽位提取的细节。已有1079人学习下载,适合用于课程设计、论文基线复现或竞赛备赛,能够有效缩短从理论学习到动手实验的路径。

1. 用BERT做一个NLU三合一项目:领域分类、意图识别、槽位填充一次跑通

拿“帮我订一张明天早上9点去北京的火车票”这句话来说,一个对话系统要连过三关:先判断这是出行领域的请求,再识别出“订票”这个意图,最后把“明天早上9点”和“北京”抽出来作为槽位。传统做法是拆成三个独立模型串行跑,每个环节都会累积误差,线上延迟还高。这个项目正好给出一套基于BERT的联合NLU方案,一个模型同时扛下领域分类、意图识别和槽位填充,数据用的是SMP2019 ECDT任务1,代码里包含了完整的训练、评测脚本以及多份参赛队的技术报告。做人工智能课设、毕业设计,或者刚接手对话系统想找个能复现的基线项目,这套资源都合适。跑通一次,你就能搞清楚BERT的CLS向量和token向量在多任务里怎么分工,以及为什么槽位标注要挂LSTM+CRF。

2. BERT联合建模架构:CLS管分类、token管序列,为什么槽位必须配CRF

2.1 三任务放一个模型,而不是拆三个模型

领域分类、意图识别、槽位填充这三个任务本身是有层级关系的。领域是粗粒度的意图,意图是细粒度的动作,而槽位是意图里的参数化实体。拿火车票的例子来说,“出行”是领域,“订票”是意图,“明天早上9点”和“北京”是槽位。如果拆成三个独立模型,领域模型输出错了,后面的意图和槽位全跟着错,这种错误累积在流水线式架构里非常难排查。

这套代码走的是共享BERT编码器、三个任务头联合训练的路子。BERT吃进整句话,把语义表征抽出来,然后不同任务头各取所需。这样做的好处有两个:一是三任务共享底层语义,领域分类学到的知识能帮意图识别,意图识别学到的知识也能帮槽位标注,在数据量不大时相当于一种隐式的正则化;二是线上推理时只有一个模型,延迟比三个模型串行低得多。

从SMP2019几支参赛队的技术报告看,出门问问、咖啡NLU小队、沃丰时代他们虽然细节实现不同,但大方向都是“BERT共享编码器 + 多任务输出头”,差别主要在槽位层的结构上——有的直接接Softmax,有的接BiLSTM+CRF。这套代码用的是后者,也是我比较推荐的做法,后面会细说原因。

2.2 CLS向量和token向量分别干什么

BERT的最后一层隐藏状态会输出一个三维张量,形状是“batch_size × 序列长度 × 768”(以bert-base为例)。序列长度这一维里,第0个位置对应[CLS]这个特殊token的输出向量。这个向量被设计成浓缩了整个句子的语义信息,所以领域分类和意图识别都从它上面取特征。

token向量则是序列里每个token各自的输出,长度和输入序列一一对应。槽位填充是一个序列标注任务,每个token都要预测一个标签,所以要用token向量作为序列标注层的输入。下面是这套代码里JointBERT的前向骨架思路,我在本地复现时就是按这个结构搭建的:

class JointBERT(nn.Module): def __init__(self, bert_model, num_domains, num_intents, num_slot_labels): super().__init__() self.bert = bert_model # 领域分类头,输入是768维的[CLS]向量 self.domain_cls = nn.Linear(768, num_domains) # 意图分类头,同样吃[CLS]向量 self.intent_cls = nn.Linear(768, num_intents) # 槽位层:双向LSTM,768维输入映射到512维 self.bilstm = nn.LSTM(768, 256, bidirectional=True) # 全连接层把512维映射到槽位标签数 self.slot_proj = nn.Linear(512, num_slot_labels) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) cls_vec = outputs.last_hidden_state[:, 0] # 取出[CLS]位置的向量 token_vecs = outputs.last_hidden_state # 全部token的向量,供槽位层使用 domain_logits = self.domain_cls(cls_vec) intent_logits = self.intent_cls(cls_vec) # 过BiLSTM后再映射到槽位标签空间,作为CRF的发射分数 lstm_out, _ = self.bilstm(token_vecs) slot_logits = self.slot_proj(lstm_out) return domain_logits, intent_logits, slot_logits

这里几个参数说明一下:768是bert-base-chinese的隐藏层维度,如果你换用其他BERT变体,这个数值要跟着改;双向LSTM的hidden_size设为256,双向拼接后是512,全连接层把512维映射到槽位标签个数;num_domains、num_intents、num_slot_labels都是从训练数据统计出来的——领域数、意图数和去重后的槽位标签总数。

注意几个容易忽略的点。第一,槽位层的BiLSTM输入是整个token序列,不包括[CLS]和[SEP]位置也可以,但代码实现里通常保留整个序列,CRF解码时会用mask把特殊位置遮掉。第二,domain_logits和intent_logits各自过一个Softmax得到概率分布,slot部分则要交给CRF做整条序列的约束解码。

2.3 槽位填充为什么要CRF,而不是纯Softmax

很多初学的人会问:既然BERT的输出已经很强了,为什么槽位层不直接对每个token做Softmax分类?原因在于槽位标签之间是有依赖关系的。以“明天早上9点”为例,标签序列是“B-time I-time I-time I-time”,按BIO标注规则,B后面可以跟I,但O后面不能直接接I;B-time后面也不能直接接B-location,因为一个槽位的结束不是另一个槽位A类标签直接开始的标志,中间至少要有一个O过渡。

纯Softmax是对每个token独立预测,模型完全看不到相邻标签的约束,所以经常出现B-time后面接I-location这种非法序列。CRF的做法是引入一个可学习的转移矩阵,在解码时对整个标签序列打分,非法转移会被转移矩阵的负分压住。你可以在lstm_crf_layer.py里看到这套实现,里面定义了转移矩阵、发射分数和维特比解码逻辑。

从实际效果看,加了CRF之后往往槽位F1值能稳定提升两三个点。尤其是当训练数据不够多的时候,CRF的约束相当于一种先验知识,能把模型引向合法的标签路径。

三个任务的输出头、损失函数和指标口径,整理成表会清楚很多:

任务输入特征输出结构损失函数常用指标
领域分类[CLS]向量全连接 + Softmax交叉熵准确率
意图识别[CLS]向量全连接 + Softmax交叉熵准确率
槽位填充token向量BiLSTM + CRFCRF负对数似然槽位F1

训练时三个loss会直接相加,共同反传到BERT编码器。这样联合训练的好处是领域分类loss能帮助BERT把句子级别的语义学得更好,槽位loss则把token级别的特征逼得更准,整体上比分开训练稳定得多。

3. 跑通训练流程:SMP2019数据解析、标签对齐与main.py参数设置

3.1 数据长什么样,脚本怎么读它

项目根目录下直接给了train.json和test.json,同时保留了smp_2019_task1_train.json这个官方原始文件。train.json应该已经做过分词或字段标准化,可以直接被main.py读取。SMP2019 ECDT任务1的数据结构是典型的NLU多轮对话格式:每个样本是一个JSON对象,核心字段包括句子文本、领域标签、意图标签、以及按token对齐的槽位标签序列。

train.json里一条样本的结构大致是这样的,字段名以你解压后实际看到为准:

{ "text": "帮我订一张明天早上9点去北京的火车票", "domain": "travel", "intent": "book_train", "labels": ["O", "O", "O", "B-time", "I-time", "I-time", "O", "O", "B-location", "O", "O", "O", "O", "O"] }

text是原始句子,domain和intent是句子级别的标签,labels是token级别的槽位标签序列。这里labels的长度和text的字数是对齐的,因为中文BERT是按字切分的,每个字对应一个标签。test.json则只有text,需要模型预测domain、intent和labels,预测结果写到res.out里,最后由evaluation.py统一评测。

这个数据的难点在于:文本里时间表达、地点、人名、数字混杂,且口语化严重,比如“明早”“后天下午”这些缩写和不规则表达,会让槽位的边界判断变得很难。patterns.py在这套代码里就是干这个用的——用一些正则规则对日期、时间这类强规律槽位做兜底修正,典型场景是“明早八点”这种说法,BERT可能识别不全,规则能补一刀。

3.2 预处理:加特殊标记、做标签对齐

BERT的输入需要经过tokenizer处理,加上[CLS]和[SEP]两个特殊token,然后padding到固定长度。但槽位标签序列原来是对齐原始字序列的,加了特殊token之后,标签序列的长度就对不上了,必须同步做偏移。这是一处非常关键的细节。

下面是我复现时整理的编码函数骨架,逻辑和这套代码里的做法一致:

def encode_one_sample(text, labels, tokenizer, max_len): # 1. BERT切词,自动加上[CLS]和[SEP] encoded = tokenizer.encode_plus( text, max_length=max_len, padding='max_length', truncation=True, return_tensors='pt' ) input_ids = encoded['input_ids'][0] attention_mask = encoded['attention_mask'][0] # 2. 构建与input_ids等长的标签序列 label_ids = [-100] # [CLS]位置不参与损失计算 token_index = 0 for token_id in input_ids[1:]: if token_id == tokenizer.sep_token_id: break token = tokenizer.convert_ids_to_tokens(token_id.item()) if token.startswith('##'): # subword情况:继承前一个token的标签 label_ids.append(label_ids[-1] if label_ids[-1] != -100 else -100) else: # 正常字,取labels里对应的标签 tag = labels[token_index] if token_index < len(labels) else 'O' label_ids.append(tag2id[tag]) token_index += 1 # 3. [SEP]位置同样设为-100,剩余padding位也补-100 while len(label_ids) < max_len: label_ids.append(-100) return input_ids, attention_mask, torch.tensor(label_ids)

这段代码解释一下。第1步里encode_plus把原始文本转成input_ids,自动在开头和结尾插入了[CLS]和[SEP],padding到max_len。第2步是标签对齐的核心:普通token一个萝卜一个坑地对应原始标签;遇到以##开头的subword时,说明这个词被BERT切碎了,让子词继承前一个token的标签;特殊位置统一填-100,PyTorch的CrossEntropyLoss认出-100会自动忽略这些位置的loss。第3步把剩余padding位全部补-100。

这么做的好处是,模型的输出维度始终是max_len,但只有真实token位置参与loss计算,CLS、SEP和padding都不干扰训练。很多新手跑这个项目时指标上不去,大概率就是标签对齐这一步偷懒了,后面避坑章节会回到这个点上。

3.3 main.py训练循环和model_config.py关键参数

main.py的结构比较直白:加载数据、构造DataLoader、初始化模型和优化器、进入训练循环。值得关注的是它如何把三个任务的loss汇总。常见做法是把三个loss相加后再反传,但也可以给每个loss加一个权重,比如domain_loss * 0.5 + intent_loss * 1.0 + slot_loss * 1.0,代码里如果直接相加,说明作者认为三任务同等重要。我一般会建议把槽位loss权重略微调高,因为在三个任务里,槽位标注难度最大。

训练循环骨架大概是这个思路:

for epoch in range(epochs): for batch in train_dataloader: input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) domain_labels = batch['domain_labels'].to(device) intent_labels = batch['intent_labels'].to(device) slot_labels = batch['slot_labels'].to(device) domain_logits, intent_logits, slot_emissions = model(input_ids, attention_mask) # 领域和意图用交叉熵,槽位用CRF负对数似然 domain_loss = criterion(domain_logits, domain_labels) intent_loss = criterion(intent_logits, intent_labels) slot_loss = crf_layer(slot_emissions, slot_labels, attention_mask) total_loss = domain_loss + intent_loss + slot_loss total_loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()

这里注意slot_loss这行,CRF的损失函数和普通交叉熵不一样,它计算的是整条标签序列的负对数似然。传入的attention_mask不仅给BERT用,CRF计算转移路径时也要靠它排除padding部分,否则模型会在padding位置上瞎学转移关系。

model_config.py里集中管理了一批关键超参数,我复现时重点关注这几个:

参数建议值说明
max_seq_len128~256优先按训练集最长句设置,太长浪费显存
batch_size16~32显存不够时先降这里,而不是降max_seq_len
learning_rate2e-5~5e-5BERT微调标准范围,超过5e-5极易训练不稳
warmup_proportion0.1训练前10%的step做学习率预热
epochs3~5BERT微调一般不需要太多轮,看验证集早停
dropout0.1BERT自带dropout,通常不用额外加

这些参数不是拍脑袋定的,BERT微调最怕学习率过大导致灾难性遗忘。warmup的作用是训练初期让学习率缓慢爬升,避免大步长把预训练权重冲坏。这套代码里如果模型配置和默认值略有出入,以model_config.py实际内容为准,但方向一致。

4. 避坑记录:四个让BERT多任务训练指标上不去的真实原因

4.1 现象:loss在降,但意图准确率卡住不动

这是我第一次跑这种多任务NLU项目时遇到的头号问题。训练loss一路下降,但验证集上的意图准确率始终在低位徘徊,甚至不涨。

原因几乎都出在标签对齐上。tokenizer加上了[CLS]和[SEP],如果labels列表没有同步扩展,模型看到的标签序列长度和input_ids长度不一致,后面的标签全部错位。更隐蔽的是,如果labels还是按原始字序列的长度做交叉熵,把[CLS]和[SEP]位置的输出也算了loss,等于用句子级特征强行预测垃圾标签,把梯度带偏。

解决办法是在构造数据时就给特殊位置和padding位置填-100,用交叉熵的ignore_index机制过滤掉这些位置:

loss_fct = nn.CrossEntropyLoss(ignore_index=-100)

从那以后我每次构造训练集,都会先打印一段input_ids和label_ids的长度对比,确认完全一致再进训练循环。

4.2 现象:槽位F1很低,B和I标签乱序

有一次训练完,领域和意图准确率都过了90%,但槽位F1只有六十几。把预测结果拉出来看,发现模型大量输出“B-time后面直接跟B-location”这种非法序列。

原因有两层。第一层是subword问题:BERT中文虽然基本按字切,但有些数字、英文和特殊符号会被切碎,直接复制父token标签会让B变成BB这样的重复标签。第二层是纯Softmax槽位头本身就不带序列约束能力,B和I的先后关系模型完全不知道。

解决方法是两层一起改:subword的标签要么继承首token,要么干脆设成-100让模型忽略;槽位层换成CRF,让转移矩阵去约束合法的BIO路径。这套代码里lstm_crf_layer.py已经实现了CRF模块,main.py里也做了对接,所以重点检查你的数据预处理有没有把subword情况处理对。

4.3 现象:训练中途OOM,显存直接爆掉

用BERT的原因之一是它好吃,但显存开销也大。OOM常见发生在max_seq_len设得太大且batch_size没跟着缩的时候。中文句子虽然一般不长,但如果数据里有长文本,比如新闻式query,max_len=256下batch_size=32就能把一块12G的显卡吃满。

解决思路是优先降batch_size,而不是降max_len——因为max_len直接影响模型对长句的槽位召回。我一般会先保住max_len=128,把batch_size降到8,再用梯度累积来模拟更大的batch。如果你改的是代码里的训练循环,梯度累积大概是这个写法:

# 每accumulation_steps个batch更新一次参数 total_loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() scheduler.step() optimizer.zero_grad()

梯度累积不会改变最终参数的更新频率,只是把一个大batch拆成几个小batch挨个算梯度再累加,显存占用能从原来的三分之一减到更少,效果和用大batch训练基本一致。

提示:本地显存不够时,优先尝试max_seq_len=128 + batch_size=8 + 梯度累积,而不是直接把max_seq_len降到64。序列长度太短会让槽位标签被截断,训练出来的模型对长句基本是瘫的。

4.4 现象:训练集指标接近满分,验证集不涨反降

出现这个现象时别急着调结构,先检查你是不是没有早停。BERT参数量大,在几千条训练数据上跑上10个epoch,完全有能力把训练集背下来,但泛化能力会迅速崩溃。

解决的常规操作是三件事并行:把训练轮数控制在3~5轮,加一个验证集早停——连续3轮验证指标不上升就停止训练;再把学习率从5e-5降到2e-5。如果你改的是model_config.py,只需要调整对应字段后重跑脚本,三件事的成本都极低,但效果立竿见影。

5. 评测与快速调优:从res.out看指标,用三次对比实验找到最优配置

5.1 evaluation.py怎么用,指标口径是什么

训练完成后,模型需要对test.json做预测并把结果写入res.out,然后执行评测命令:

python evaluation.py res.out

evaluation.py会读取res.out和test.json里的标准答案,逐条比对三个任务的预测结果。注意res.out的格式必须严格按脚本预期的组织方式写,最稳妥的做法就是先跑一次main.py里自带的预测流程,生成一个baseline版本res.out,再改成你自己的预测逻辑,这样格式不会出错。

指标口径通常包含四类:领域准确率、意图准确率、槽位F1、句子准确率。前三者的含义比较直白,句子准确率指的是一条样本的领域、意图、槽位全部预测正确才计为对。SMP2019官方排名一般用句子准确率作为最终依据,因为它对三任务同时提出了要求,能真实反映NLU模块的综合能力。但工程上我更关注槽位F1,因为领域和意图分类相对简单,槽位才是影响下游对话管理的关键。

5.2 三次值得做的对比实验

拿到跑通的baseline后,我建议你先做三组对比实验,每组只改一个变量,每次跑完都保留res.out和指标记录:

实验组改动点重点观察
对照组原始配置记录baseline三指标
实验一去掉CRF,槽位头改成纯Softmax观察槽位F1下降幅度,验证CRF对序列约束的作用
实验二学习率从2e-5改成5e-5观察训练稳定性和最终指标,找当前数据的合适学习率区间
实验三三任务loss加权,槽位权重设为1.5观察槽位F1是否提升,意图准确率是否被拖累

这三组跑完,你基本能摸清这套模型在当前数据上的行为偏好。我自己的经验是:CRF对槽位F1的提升是最确定的,基本每次都在两个点以上;学习率的影响则要看数据量,数据量越小越要用低学习率;loss加权收益不稳定,但值得试一次,因为如果领域分类已经很简单,适当压低它的权重反而能让模型更专注槽位任务。

快速调优阶段还有一些小技巧:如果训练曲线波动大,检查warmup是不是没生效;如果验证集句子准确率上不去,单独看错误样本是领域错、意图错还是槽位错,三个任务头的错误分布往往能指向数据里哪类样本标注质量差。

调参这件事说到底是一门玄学,但它有迹可循。我从这个项目里学到的最重要习惯是:每跑一组实验,就把res.out和当时的超参数一起存档,哪怕只是改了一个batch_size,也要记录下来。很多次我以为是模型结构的问题,回头翻实验记录才发现是学习率设太大把权重冲坏了。从那以后我每次调整完数据预处理或者模型结构,都会强制自己先跑一遍测试集预测、生成res.out、对着evaluation.py的指标看一次,确认改动方向是对的再继续下一步。这套流程帮我避开过无数个回头排查的坑,希望也能帮到你。

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

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

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

立即咨询