赞
踩
意图识别是指分析用户的核心需求,输出与查询输入最相关的信息,例如在搜索中要找电影、查快递、市政办公等需求,这些需求在底层的检索策略会有很大的不同,错误的识别几乎可以确定找不到能满足用户需求的内容,导致产生非常差的用户体验;在对话过程中要准确理解对方所想表达的意思,这是具有很大挑战性的任务。
例如用户输入查询“仙剑奇侠传”时,我们知道“仙剑奇侠传”既有游戏又有电视剧还有新闻、图片等等,如果我们通过用户意图识别发现该用户是想看“仙剑奇侠传”电视剧的,那我们直接把电视剧作为结果返回给用户,就会节省用户的搜索点击次数,缩短搜索时间,大大提升使用体验。而在对话中如果对方说“我的苹果从不出现卡顿”,那么我们就能通过意图识别判断出此刻的苹果是一个电子设备,而非水果,这样对话就能顺利进行下去。
总之,意图识别的准确性能在很大程度上影响着搜索的准确性和对话系统的智能性。
本示例将展示如何使用ERNIE预训练模型完成任务型对话中的槽位填充和意图识别任务,这两个任务是一个pipeline型任务对话系统的基石。
本示例使用的数据集为CrossWOC中文对话数据集。该数据集包含多个领域,包括景点,餐馆,酒店,交通等。
原始数据和数据处理脚本请参见 CrossWOZ。
PaddlePaddle
本项目依赖于 PaddlePaddle 2.0 及以上版本,请参考 安装指南 进行安装
PaddleNLP
pip install --upgrade paddlenlp -i https://pypi.org/simple
Python
Python的版本要求 3.6+
AI Studio平台默认安装了Paddle和PaddleNLP,并定期更新版本。 如需手动更新Paddle,可参考飞桨安装说明,安装相应环境下最新版飞桨框架。
使用如下命令确保安装最新版PaddleNLP:
!pip install --upgrade paddlenlp -i https://pypi.org/simple
Collecting paddlenlp [?25l Downloading https://files.pythonhosted.org/packages/b0/7d/6c24cda54d018d350ee342f715523ade7871660444ed95f3d3e753d6f388/paddlenlp-2.0.8-py3-none-any.whl (571kB) [K |████████████████████████████████| 573kB 12kB/s eta 0:00:019 [?25hRequirement already satisfied, skipping upgrade: colorama in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (0.4.4) Requirement already satisfied, skipping upgrade: multiprocess in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (0.70.11.1) Requirement already satisfied, skipping upgrade: seqeval in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (1.2.2) Requirement already satisfied, skipping upgrade: jieba in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (0.42.1) Requirement already satisfied, skipping upgrade: h5py in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (2.9.0) Requirement already satisfied, skipping upgrade: colorlog in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from paddlenlp) (4.1.0) Requirement already satisfied, skipping upgrade: dill>=0.3.3 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from multiprocess->paddlenlp) (0.3.3) Requirement already satisfied, skipping upgrade: numpy>=1.14.0 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from seqeval->paddlenlp) (1.20.3) Requirement already satisfied, skipping upgrade: scikit-learn>=0.21.3 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from seqeval->paddlenlp) (0.24.2) Requirement already satisfied, skipping upgrade: six in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from h5py->paddlenlp) (1.15.0) Requirement already satisfied, skipping upgrade: joblib>=0.11 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from scikit-learn>=0.21.3->seqeval->paddlenlp) (0.14.1) Requirement already satisfied, skipping upgrade: scipy>=0.19.1 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from scikit-learn>=0.21.3->seqeval->paddlenlp) (1.6.3) Requirement already satisfied, skipping upgrade: threadpoolctl>=2.0.0 in /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages (from scikit-learn>=0.21.3->seqeval->paddlenlp) (2.1.0) Installing collected packages: paddlenlp Found existing installation: paddlenlp 2.0.7 Uninstalling paddlenlp-2.0.7: Successfully uninstalled paddlenlp-2.0.7 Successfully installed paddlenlp-2.0.8
与大多数NLP任务相同,本次示例的展示流程分为以下四步:
首先我们从数据准备开始。
数据准备流程如下:
load_dataset()
自定义数据集使用官方脚本预处理过的数据集已经上传至项目根目录,观察数据集格式后我们可以根据数据格式写出数据文件读取函数,传入load_dataset()
。即可创建数据集。
from paddlenlp.datasets import load_dataset import json # 读取标签文件并创建label_map def get_label_map(filename): with open(filename, "r", encoding="utf8") as f: label_list = json.load(f) id2label = dict([(idx, label) for idx, label in enumerate(label_list)]) label2id = dict([(label, idx) for idx, label in enumerate(label_list)]) return id2label, label2id id2slot, slot2id = get_label_map('slot_labels.json') id2intent, intent2id = get_label_map('intent_labels.json') intent_weight = [1] * len(intent2id) # 根据本地文件格式定义数据读取生成器 def read(filename): with open(filename, "r", encoding="utf8") as f: data = json.load(f) for entry in data: yield { 'words': entry[0], 'slots': entry[1], 'intents': entry[2], 'history': entry[4], } # 将生成器传入load_dataset train_ds = load_dataset(read, filename='train.json', lazy=False) dev_ds = load_dataset(read, filename='test.json', lazy=False) for idx in range(2): print(train_ds[idx]['words']) print(train_ds[idx]['slots']) print(train_ds[idx]['intents']) print(train_ds[idx]['history']) print()
['你', '好', ',', '麻', '烦', '帮', '我', '推', '荐', '一', '个', '门', '票', '免', '费', '的', '景', '点', '。']
['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B+Inform+景点+门票', 'I+Inform+景点+门票', 'O', 'O', 'O', 'O']
['General+greet+none+none', 'Request+景点+名称+']
[]
['你', '好', ',', '您', '可', '以', '选', '择', '故', '宫', ',', '八', '达', '岭', '长', '城', ',', '颐', '和', '园', '或', '者', '红', '砖', '美', '术', '馆', '。']
['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B+Inform+景点+名称', 'I+Inform+景点+名称', 'I+Inform+景点+名称', 'I+Inform+景点+名称', 'I+Inform+景点+名称', 'O']
['General+greet+none+none']
['你好,麻烦帮我推荐一个门票免费的景点。']
关于更多自定义数据集相关内容,请参考如何自定义数据集
paddlenlp.transformers.ErnieTokenizer
用于数据处理文本数据在输入ERNIE预训练模型之前,需要经过数据处理转化为Feature。这一过程通常包括分词,token to id,add special token等步骤。
PaddleNLP对于各种预训练模型已经内置了相应的tokenizer,指定想要使用的模型名字即可加载对应的tokenizer。
可以通过调用tokenizer中的方法简单的完成上述数据处理。
import paddlenlp
# 设置模型名称
MODEL_NAME = 'ernie-1.0'
tokenizer = paddlenlp.transformers.ErnieTokenizer.from_pretrained(MODEL_NAME)
[2021-08-25 10:25:53,201] [ INFO] - Downloading https://paddlenlp.bj.bcebos.com/models/transformers/ernie/vocab.txt and saved to /home/aistudio/.paddlenlp/models/ernie-1.0
[2021-08-25 10:25:53,292] [ INFO] - Downloading vocab.txt from https://paddlenlp.bj.bcebos.com/models/transformers/ernie/vocab.txt
100%|██████████| 90/90 [00:00<00:00, 2208.64it/s]
map()
方法批量处理数据由于我们传入了lazy=False
,所以我们使用load_dataset()
自定义的数据集是MapDataset
对象。
MapDataset
是paddle.io.Dataset
的功能增强版本。其内置的map()
方法适合用来进行批量数据集处理。
map()
方法传入的是一个用于数据处理的function。正好可以与tokenizer相配合。
以下是本示例中的用法:
from functools import partial import numpy as np def convert_example(example, tokenizer, use_history=False, no_entity_id=0, max_seq_len=512, mode='train'): # 调用tokenizer的数据处理方法把文本转为id tokenized_input = tokenizer( example['words'], is_split_into_words=True, max_seq_len=max_seq_len) # 对槽位标签做截断,保证和input_id等长 slot_labels = example['slots'] if len(tokenized_input['input_ids']) - 2 < len(slot_labels): slot_labels = slot_labels[:len(tokenized_input['input_ids']) - 2] # 根据label_map将槽位标签转为id tokenized_input['slot_labels'] = [no_entity_id] + [slot2id[label] for label in slot_labels] + [no_entity_id] # 由于任务中的意图识别是多标签分类,需要把意图标签转为类似one-hot的格式 intent_labels = np.zeros(len(intent2id), dtype='int64') for l in example['intents']: intent_labels[intent2id[l]] = 1 if mode == 'train': # 统计训练集中每个意图的正样本数 intent_weight[intent2id[l]] += 1 tokenized_input['intent_labels'] = intent_labels # 将历史对话用[SEP]拼起来并转成id if use_history: tokenized_history = tokenizer( tokenizer.cls_token+tokenizer.sep_token.join(example['history']), max_seq_len=max_seq_len) tokenized_input['history_ids'] = tokenized_history['input_ids'] else: tokenized_input['history_ids'] = [] return tokenized_input use_history = False max_seq_length = 512 train_trans_func = partial( convert_example, tokenizer=tokenizer, mode='train', use_history=use_history, max_seq_len=max_seq_length) dev_trans_func = partial( convert_example, tokenizer=tokenizer, mode='dev', use_history=use_history, max_seq_len=max_seq_length) train_ds.map(train_trans_func, lazy=False) dev_ds.map(dev_trans_func, lazy=False) # 根据意图的正样本数和总样本数为不同样本赋予不同的权重 for intent, intent_id in intent2id.items(): neg_pos = (len(train_ds) - intent_weight[intent_id]) / intent_weight[intent_id] intent_weight[intent_id] = np.log10(neg_pos)
for idx in range(2):
print(train_ds[idx]['input_ids'])
print(train_ds[idx]['token_type_ids'])
print(train_ds[idx]['slot_labels'])
print(train_ds[idx]['intent_labels'])
print()
[1, 226, 170, 4, 1298, 1934, 836, 75, 426, 1645, 7, 27, 232, 1039, 783, 453, 5, 561, 180, 12043, 2] [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0] [1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [1, 226, 170, 4, 892, 48, 22, 352, 790, 470, 915, 4, 643, 302, 1560, 84, 257, 4, 3540, 14, 509, 172, 123, 536, 1520, 188, 133, 774, 12043, 2] [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 4, 4, 4, 4, 0, 0] [1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
从以上结果可以看出,数据集中的example已经被转换成了模型可以接收的feature,包括input_ids、token_type_ids、slot_labels和intent_labels。
其中:
input_ids
: 表示输入文本的token ID。token_type_ids
: 表示token所属的句子(Transformer类预训练模型支持单句以及句对输入)。slot_labels
: 槽位标签,其长度与输入文本相同。intent_labels
: 意图标签,是一个长度等于总便签数的列表,标签对应位置为1,其余为0.更多有关数据处理的内容,请参考数据处理。
使用paddle.io.BatchSampler
和paddlenlp.data
中提供的方法把数据组成batch。
然后使用paddle.io.DataLoader
接口多线程异步加载数据。
batchify_fn
详解:
import paddle from paddlenlp.data import Stack, Dict, Pad batch_size = 20 # 初始化BatchSampler train_batch_sampler = paddle.io.BatchSampler( train_ds, batch_size=batch_size, shuffle=True) dev_batch_sampler = paddle.io.BatchSampler( dev_ds, batch_size=batch_size, shuffle=False) # 定义batchify_fn batchify_fn = lambda samples, fn=Dict({ "input_ids": Pad(axis=0, pad_val=tokenizer.pad_token_id), "token_type_ids": Pad(axis=0, pad_val=tokenizer.pad_token_type_id), "slot_labels": Pad(axis=0, pad_val=0, dtype="int64"), "intent_labels": Stack(dtype="float32"), "history_ids": Pad(axis=0, pad_val=tokenizer.pad_token_id) }): fn(samples) # 初始化DataLoader train_data_loader = paddle.io.DataLoader( dataset=train_ds, batch_sampler=train_batch_sampler, collate_fn=batchify_fn, return_list=True) dev_data_loader = paddle.io.DataLoader( dataset=dev_ds, batch_sampler=dev_batch_sampler, collate_fn=batchify_fn, return_list=True)
更多PaddleNLP内置的batchify相关API,请参考collate。
到这里数据集准备就全部完成了,下一步我们需要组网并设计loss function。
以下项目以ERNIE为例,介绍如何将预训练模型多任务学习同时完成意图识别和槽位填充任务。
本例中的意图识别和槽位填充本质上是一个句子分类任务和一个序列标注任务。将两者的loss结合即可实现多任务学习。
from src.models import JointErnie
model = JointErnie.from_pretrained(MODEL_NAME,
intent_dim=len(intent2id),
slot_dim=len(slot2id),
dropout=0.1,
use_history=use_history)
[2021-08-25 10:26:19,728] [ INFO] - Downloading https://paddlenlp.bj.bcebos.com/models/transformers/ernie/ernie_v1_chn_base.pdparams and saved to /home/aistudio/.paddlenlp/models/ernie-1.0
[2021-08-25 10:26:19,792] [ INFO] - Downloading ernie_v1_chn_base.pdparams from https://paddlenlp.bj.bcebos.com/models/transformers/ernie/ernie_v1_chn_base.pdparams
100%|██████████| 392507/392507 [00:05<00:00, 71424.49it/s]
JointErnie模型会取出ErnieModel的sequence_output接入一个输出维度为槽位类别数的线性层得到slot_logits,并将pooled_output接入一个输出维度为意图类别数的线性层得到intent_logit.
所以本示例中的loss由slot_loss和intent_loss两部分组成,我们需要自己定义loss function。
槽位填充相当于在每个token的位置进行一次多分类任务,意图识别相当于对整句话做一个多标签分类任务。所以设计的loss function如下:
class NLULoss(paddle.nn.Layer):
def __init__(self, pos_weight):
super(NLULoss, self).__init__()
self.intent_loss_fn = paddle.nn.BCEWithLogitsLoss(pos_weight=paddle.to_tensor(pos_weight))
self.slot_loss_fct = paddle.nn.CrossEntropyLoss()
def forward(self, logits, slot_labels, intent_labels):
slot_logits, intent_logits = logits
slot_loss = self.slot_loss_fct(slot_logits, slot_labels)
intent_loss = self.intent_loss_fn(intent_logits, intent_labels)
return slot_loss + intent_loss
选择网络结构后,我们需要设置Fine-Tune优化策略。
适用于ERNIE/BERT这类Transformer模型的学习率为warmup的动态学习率。
# 训练过程中的最大学习率 learning_rate = 3e-5 # 训练轮次 epochs = 10 # 学习率预热比例 warmup_proportion = 0.0 # 权重衰减系数,类似模型正则项策略,避免模型过拟合 weight_decay = 0.0 max_grad_norm = 1.0 num_training_steps = len(train_data_loader) * epochs # 学习率衰减策略 lr_scheduler = paddlenlp.transformers.LinearDecayWithWarmup(learning_rate, num_training_steps, warmup_proportion) decay_params = [ p.name for n, p in model.named_parameters() if not any(nd in n for nd in ["bias", "norm"]) ] # 定义优化器 optimizer = paddle.optimizer.AdamW( learning_rate=lr_scheduler, parameters=model.parameters(), weight_decay=weight_decay, apply_decay_param_fun=lambda x: x in decay_params, grad_clip=paddle.nn.ClipGradByGlobalNorm(max_grad_norm))
现在万事俱备,我们可以开始训练模型。
模型训练的过程通常有以下步骤:
每训练一个epoch后,程序对调用evaluation()
方法分别计算两个任务的F1 score。
from src.utils import evaluation criterion = NLULoss(intent_weight) global_step = 0 for epoch in range(1, epochs + 1): for step, batch in enumerate(train_data_loader, start=1): global_step += 1 input_ids, token_type_ids, slot_labels, intent_labels, history_ids = batch logits = model(input_ids, token_type_ids, history_ids=history_ids) loss = criterion(logits, slot_labels, intent_labels) if global_step % 1000 == 0 : print("global step %d, epoch: %d, batch: %d, loss: %.5f" % (global_step, epoch, step, loss)) loss.backward() optimizer.step() lr_scheduler.step() optimizer.clear_grad() print('\nEval begin...') zer.step() lr_scheduler.step() optimizer.clear_grad() print('\nEval begin...') evaluation(model, dev_data_loader)
在该数据集的原始论文中,提到了一种改进模型的手段。即将对话历史的特征传入模型。可以增强模型的表现。
这里给大家举个例子:
当前句:“好,玩累了周边有酒店可以休息吗?”
意图:“Request+景点+周边酒店”
历史:[“你好,帮我找一个20到50元之间的景点,谢谢。”,“八达岭长城怎么样?门票35元。”]
在本示例中,可以通过修改数据处理函数的的use_history
参数来控制是否使用这一策略。
关于该策略的详细信息和原始baseline模型,请参考CrossWOZ。
关于更多任务型对话的示例和数据集,可以参考PaddleNLP中的DGU
以上内容实现基于PaddleNLP,开源不易,希望大家多多支持~
记得给PaddleNLP点个小小的Star⭐
GitHub地址:https://github.com/PaddlePaddle/PaddleNLP
更多使用方法可参考PaddleNLP教程
现在就加入PaddleNLP的QQ技术交流群,一起交流NLP技术吧!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。