如何在双十一给自己送个“陪聊女友”——基于飞桨&Plato搭建多轮对话模型

2020-11-16 14:31:33 浏览数 (2)

【飞桨开发者说】闫冬阳,PPDE飞桨开发者技术专家,北京交通大学系统科学系在读博士。研究方向是基于复杂网络工具的文本处理方法,同时探索与复杂网络结合的深度学习方法。

近年来,机器人对话应该是NLP领域最火热的领域之一了。几乎每一个手机操作系统都内置了对话机器人,智能音箱的最大卖点之一也是智能对话。那么,这种对话是如何实现的呢?我们可不可以自己定制,训练自己的对话机器人呢?回答当然是肯定的。今天,我们就尝试用百度开源模型Plato训练一个对话模型。

百度Plato-2对话模型

Plato是百度推出的一个多轮对话模型。该模型的最大改进在于通过引入离散隐变量实现了对话场景中多回答中的择优,即,对于同一个问题,实现不同场景下的不同回答的选择。最新推出的Plato-2在中英文效果上,已全面超越 Google Meena、Facebook Blender、微软小冰等先进模型。

图一:Plato-2模型框架

模型的整体框架如图一所示。该模型采用了经典的Transformer结构,通过注意力机制提高了模型针对不同长度对话的生成效果。隐变量z的引入,使预训练模型依据z可以生成多种回答,最终从多种回答中择优。在训练中,该模型采用两阶段训练的方法。第一阶段,基于表现良好的预训练模型,训练出一对一回答的模型;第二阶段,引入评估和隐变量z,训练出一对多回答的模型。模型的具体原理可以参考原论文,论文地址:

https://arxiv.org/abs/2006.16779

对话模型训练工具

飞桨Knover工具包

百度飞桨推出的Konver工具包是一个非常强大的对话模型训练工具,我们可以通过Konver工具包快速地训练属于自己的Plato模型。Knover工具包目前支持了Plato-2模型的训练。具体参考链接:https://github.com/PaddlePaddle/Knover。在这里,我们解释一下Knover工具包中一些常见的问题。

1. 工具包分析

我们可以用train.py来进行微调训练,用infer.py 导出想要的结果。这里介绍一下这个模型的一些代码细节。

package文件夹中存放了其自带的试验数据的词集,语句切分模型(spm.model, 即sentencepiece model,这个模型用在语句的预处理上,必须给定),以及模型的细节参数(词集大小,隐含层个数,激活函数等等,具体查看package/dialog_en/24L.json。

models文件夹存放了模型的各个子模块,plato模块也在其中。data文件夹存放了实验用的小文件。tasks文件夹中包含了模型两种应用任务的实现,包括“下一句语句预测”和“会话生成”。需要注意的是,这个应用任务的选择是必须给出的,对应参数 --tasks, 分别写作NextSentencePrediction和DialogGeneration。不过实测只有DialogGeneration可以生成预测语句,NextSentencePrediction并不能生成预测语句(为打分模型,负责对回答的效果打分,以便Plato模型选择最佳的回答)。

需要指出的是,在infer.py中需要指出需要保存的内容,对应参数 --output_name,输出结果有3项:data_id, score,和response(response在NextSentencePrediction中并不生成),同时,需要在子目录创建output文件夹,否则会报错(因为源码中并没有自动创建这个文件夹,如果微调中保存了checkpoints,则会自动创建output文件夹)。

2. 常用参数

这里需要指出,--do_generation这个参数,推测是手动确定执行任务是否是生成句子的任务的,由于不需要提前指定,所以默认一直是False,但是这会在infer任务中报错,而且对于NSPModel来说,并不存在这个参数,但是在infer时仍然会报错。解决的方法是在执行Plato时加上--do_generation参数并赋值为True。

  • --init_pretraining_params 预训练模型所在文件夹,如果需要加载(当然需要)预训练模型需要给出这个参数
  • --init_checkpoint 保存节点的所在文件夹,如果给出了init_checkpoint则从该文件夹初始化训练参数(等于覆盖了init_pretraining_params的参数)
  • train.py --train_file 训练文件地址 --valid_file 评估文件地址 --model 用到的模型名称:Plato:plato;NSPModel:next_sentence_prediction model --config_path 模型细节参数配置文件,如24L.json --task 模型应用任务 NextSentencePrediction;DialogGeneration;UnifiedTransformer --vocab_path 词集路径 --spm_model_file sentencepiece model文件的路径 --num_epochs 训练周期数 --log_steps 输出训练详情的间隔步数 --validation_steps 评价间隔步数 --save_steps 保存间隔步数 infer.py --infer_file 需要推断的文件 --output_name 需要保存的对象,response;data_id;score --model 用到的模型名称:Plato:plato;NSPModel:next_sentence_prediction model --config_path 模型细节参数配置文件,如24L.json --task 模型应用任务 NextSentencePrediction;DialogGeneration;UnifiedTransformer --vocab_path 词集路径 --spm_model_file sentencepiece model文件的路径

训练Plato模型

1. 数据准备

Plato-2模型的输入采用了token,role,turn,position相融合的表示方式。在训练和测试过程中,我们需要搞清楚文本数据需要经过怎样的转化才能作为输入,以及输出数据需要怎样的处理才能转换成文本。目前我们可以获取各种开放的对话训练集,如百度千言数据集和清华开放数据集。目前飞桨正在进行的千言多技能会话比赛中,报名即可下载多个对话数据集,我们即用这些数据集进行以下的训练。

这里也号召有兴趣的朋友参加千言多技能会话比赛,奖励多多哟!地址在此:

https://aistudio.baidu.com/aistudio/competition/detail/55

1.1 中文分词

中文必须面对的一个问题就是如何实现分词。在公开的开放域对话数据集中,大多数已经做了分词,然而真实场景中语句是不可能时时刻刻都被分词了的。在Knover的源码中,对输入的处理是通过了sentencepiece工具(BERT也使用的这个)。sentencepiece提供了一种方便快捷的分词操作,我们可以直接将整个数据集放进去,设定分词的单元数量,然后等待训练出一个好用的分词模型(会输出一个预训练模型,之后对每个语句都会用这个模型进行编码和解码,即分词,转换成数字编码,输出再转换回句子)。

Knover中训练必须输入的参数spm_model,就是对应sentencepiece的预训练模型。我们当然可以自己训练一个sentencepiece的预训练模型出来,但是考虑到分词模型对效果的影响,推荐大家使用千言多技能对话中给出的baseline模型(luge-dialogue)中附带的spm.model文件,这个文件分词的效果已经非常出色了。当然,别忘了搭配词表vocab.txt使用。

仔细分析luge的spm.model我们可以发现,这个预训练模型其实是根据已经分词的句子训练的,虽说如此,因为分词单元足够多,也覆盖了所有常见的单个中文词。我们可以直接把语句送入得到编码,也可以先用jieba分词预先分一次,然后再编码。用sentencepiece模型的例子如下:

代码语言:javascript复制
import sentencepiece as sp
import jieba
text = "我今天做了一顿丰盛的晚餐!"

SPM = sp.SentencePieceProcessor()
SPM.load('spm.model')
# 直接分词
ids = SPM.encode_as_ids(text)
print(ids)
print(SPM.decode_ids(ids))

# 先用jieba分词,再用sentencepiece编码
text = ' '.join(list(jieba.cut(text)))
ids = SPM.encode_as_ids(text)
print(ids)
print(SPM.decode_ids(ids))

1.2 文本的输入

Plato对文本输入的支持还是挺多样化的,它支持直接输入原始文本,也支持输入经过tokenize的分词序列,或者是已经编码(encoded)的数字序列。但是无论Plato支持的格式如何,在进行训练和预测之前,都会转换成能够被识别的标准格式。在Knover中,这个格式是通过定义的Record完成的。Record的定义如下:

代码语言:javascript复制
from collections import namedtuple
Record = namedtuple("Record", fields, defaults=(None,) * len(fields))

在解释fields的值之前,我们先来思考一下Plato需要哪些输入。在完成一段对话时,我们通常会综合对话的历史和自己所知的历史知识来进行判断,来决定自己将要回答什么。而在对话生成中,这些信息也是需要考虑的。因此,Plato需要的输入有两个,首先,是当前对方的问话,其次是已经进行过的历史对话信息,最后是背景知识。

由于各种条件的限制,背景知识可能并没有办法获取,所以至少需要的是已进行的历史对话信息,和此时对方的问话。进一步,我们需要考虑更多的信息:如果纪录了历史对话,我们如何判断每段对话的起始位置,如何判断从什么时候开始生成需要的回答,在训练集中,我们还要知道哪一部分是训练中给出的回答用于调整模型的参数。我们通过图二可以直观地看出这些输入信息的复杂性:

图二:Plato的输入结构

上图给出了Plato模型需要的输入,当然这些是以Embedding的形式给出的,而Embedding是在模型中转化的,它在转化之前是以数字编码存在的。Embedding现在已经是语言处理技术的标配了,它把每一个标记映射到空间中,增加其表征能力。我们暂时忽略最前边的latent,它是表示不同回答方式的隐变量,用于Plato在众多可能回答中选择正确的回答,我们这里不关心这个是怎么实现的,所以不展开讨论。在latent之后,有contex和response两个内容,其中context包含了众多信息:历史对话,背景知识,以及对话与对话之间分隔的符号[EOU], [BOU]等等,如果有背景知识的话,也会列到context中response则是训练中需要的部分,在测试中这一部分是空的。

TokenEmbeddings表示各语言单元的Embedding(词向量);RoleEmbeddings是各个语言单元在其中扮演的角色,这个主要是用来区分内容是context(EA)还是response(EB)(亦或是背景知识,背景知识可以作为response的角色,也可以单独成为一类,即EC);TurnEmbeddings表示每一部分在当前回合中的相对回合数;PositionEmbeddings则是每个语言单元的位置,一般是range(0, len(context))。

知道了这些,我们回到Record上来看这个输入应该怎么得到。由定义可知,Record是带名称的元组,这样我们立马可以知道,这个元组是通过名称来调用其中的内容的。fields的内容是什么呢?从官方的源码中可以总结出:fields = ["token_ids", "type_ids", "pos_ids", "tgt_start_idx", "data_id"]。也就是说,输入需要给出5个部分,token_ids就是处理过的语言单位的编码;type_ids就是个语言单位扮演的角色,是context还是response;pos_ids是各个语言单位的位置;tgt_start_idx是回复生成的开始位置,也即context的最后一个词的位置;data_id就是这个训练样本的标记。

如下给出一个例子,可以清楚的知道一个输入是如何形成的:

代码语言:javascript复制
from collections import namedtuple

fields = ["token_ids", "type_ids", "pos_ids", "tgt_start_idx", "data_id"]
Record = namedtuple("Record", fields, defaults=(None,) * len(fields))

# 新的会话
question = '我刚刚去动物园了。'
# 历史对话
history = '你好?[SEP] 你好,谈谈你自己吧?[SEP] 我今天过得很开心呢![SEP] 是嘛,你今天干了什么?'
# 背景知识
background = '天气:晴朗,地点:北京,人物:男孩,动物园,熊猫'
# 回答
answer = '北京动物园吧,那里的熊猫很受欢迎呢!'

question = SPM.encode_as_ids(question)
history = [SPM.encode_as_ids(text) for text in history.split("[SEP]")]
background = SPM.encode_as_ids(background)
answer = SPM.encode_as_ids(answer)

token_ids = []
type_ids = []
data_id = 0  # 如果样本多的话,会按排序进行标记

token_ids  = [0]   background   [2]  # 0 表示语句开头,2表示句子结尾
type_ids  = [0]   len(background) * [0]   [0]  # 0 表示context类, 1表示response类

for line in history:
    token_ids  = line   [2]
    type_ids  = len(line) * [0]   [0]

token_ids  = question   [2]
type_ids  = len(question) * [0]   [0]

token_ids  = [0]   answer   [2]
type_ids  = [1]   len(answer) * [1]   [1]  # 注意符号的变化

fields_value = {}
fields_value["token_ids"] = token_ids
fields_value["type_ids"] = type_ids
fields_value["pos_ids"] = list(range(len(type_ids)))
fields_value["tgt_start_idx"] = fields_value["pos_ids"][-1]
fields_value["data_id"] = data_id

record = Record(**fields_value)
print(record)

1.3 文本的转换

得到record以后,Plato还会将其中的各个标签的元组分别转换成矩阵,这里涉及到填充的过程,由于与其他文本填充的操作基本相同,这里不再赘述。

2. 训练模型

对于Plato-2模型来说,其训练包括两个过程:首先,训练出一个一对一的对话模型(UnifiedTransformer),然后基于这个模型,训练Plato模型。Plato模型的模型结构和UnifiedTransformer很接近(参数中多了一个latent_type_size)。

2.1 配置准备

由于在训练模型的时候,需要输入--config_path,这个参数用来读取模型的配置(Transformer层数量等等),这里我们需要定义两个模型的配置文件(**.json)。如下参数生成两个配置文件,配置即为我数据集中附带的模型的配置,如果有兴趣和算力,可以自己改配置训练,最有效的参数是num_hidden_layers和hidden_size,增加这些值会增加模型的规模。

代码语言:javascript复制
import json

## 定义UnifiedTransformer的参数
key = {'pre_encoder_cmd': 'd', 'preprocess_cmd': 'n', 'postprocess_cmd': 'da', 'post_cls_cmd': 'n', 'cls_bias': True,
 'attention_probs_dropout_prob': 0.1, 'hidden_act': 'gelu', 'hidden_dropout_prob': 0.1, 'hidden_size': 768, 
 'initializer_range': 0.02, 'max_position_embeddings': 512, 'num_attention_heads': 12, 
 'num_hidden_layers': 12, 'type_vocab_size': 2, 'role_type_size': 32, 'vocab_size': 30004}
f = open("12L.json", "w")
json_data = json.dump(key, f)
f.close()

## 定义Plato的参数
key = {'pre_encoder_cmd': 'd', 'preprocess_cmd': 'n', 'postprocess_cmd': 'da', 'post_cls_cmd': 'n', 'cls_bias': True,
 'attention_probs_dropout_prob': 0.1, 'hidden_act': 'gelu', 'hidden_dropout_prob': 0.1, 'hidden_size': 768, 
 'initializer_range': 0.02, 'max_position_embeddings': 512, 'latent_type_size': 20, 'num_attention_heads': 12, 
 'num_hidden_layers': 12, 'type_vocab_size': 2, 'role_type_size': 32, 'vocab_size': 30004}
f = open("12L_P.json", "w")
json_data = json.dump(key, f)
f.close()

2.2 训练一对一模型(UnifiedTransformer)

用命令行的方式来调用.py文件进行训练,对于模型的训练,我们可以用Knover工具包中的train.py文件进行。代码如下:

代码语言:javascript复制
python Knover/train.py 
--model UnifiedTransformer --task DialogGeneration --vocab_path Knover/config/vocab.txt --spm_model_file Knover/config/spm.model 
--train_file pro_data/train.txt --valid_file pro_data/valid.txt --data_format numerical --file_format file --config_path Knover/config/12L.json 
--init_checkpoint Knover/latest_model/ut_model 
--in_tokens True --batch_size 16000 -lr 1e-5 --warmup_steps 1000 --weight_decay 0.01 --num_epochs 20 
--max_src_len 384 --max_tgt_len 128 --max_seq_len 512 
--log_step 100 --validation_steps 20000 --save_steps 5000 
--save_path Knover/output 
--is_distributed False 
--is_cn True 
--start_step

2.3 训练一对多模型(Plato)

用3.2中得到的模型,继续训练Plato模型。由于是接着3.2模型进行的调整,--lr最好不要设置的过大(3.2模型--lr的十分之一即可)。注意更改配置文件,否则会报错。

代码语言:javascript复制
python Knover/train.py 
--model Plato --task DialogGeneration --vocab_path Knover/config/vocab.txt --spm_model_file Knover/config/spm.model 
--train_file pro_data/train.txt --valid_file pro_data/valid.txt --data_format numerical --file_format file --config_path Knover/config/12L_P.json 
--init_checkpoint Knover/latest_model/pt_model 
--in_tokens True --batch_size 1000 -lr 1e-5 --warmup_steps 1000 --weight_decay 0.01 --num_epochs 20 
--max_src_len 384 --max_tgt_len 128 --max_seq_len 512 
--log_step 100 --validation_steps 20000 --save_steps 100 
--save_path Knover/output 
--start_step 
--is_cn True

3. 保存模型

当Plato训练完后,我们可以保存相应的模型,以较少模型参数的容量占用。我们需要把NSPModel和Plato模型分开储存,代码如下:

代码语言:javascript复制
##  保存NSPModel

python Knover/save_inference_model.py 
--model NSPModel 
--task NextSentencePrediction 
--vocab_path Knover/config/vocab.txt --spm_model_file Knover/config/spm.model 
--init_checkpoint Knover/latest_model/pt_model 
--inference_model_path NSP 
--config_path Knover/config/12L.json

## 保存Plato

python Knover/save_inference_model.py 
--model Plato 
--do_generation true 
--task DialogGeneration 
--vocab_path Knover/config/vocab.txt --spm_model_file Knover/config/spm.model 
--init_checkpoint Knover/latest_model/pt_model 
--inference_model_path Plato 
--config_path Knover/config/12L_P.json

训练模型的应用

在模型训练完之后,便用训练后的模型做一些应用了。比如生成下一句话,或者进行多轮对话。

1. 对话生成

我们可以直接用Knover提供的infer.py生成下一句话。我们可以通过生成的NSPModel和Plato进行上述操作,代码如下:

代码语言:javascript复制
python Knover/infer.py 
--model Plato --task DialogGeneration --vocab_path Knover/config/vocab.txt --spm_model_file Knover/config/spm.model 
--infer_file pro_data/test.txt --data_format numerical --file_format file --config_path Knover/config/12L_P.json 
--init_pretraining_params Plato --nsp_inference_model_path NSP --ranking_score nsp_score 
--batch_size 1 
--max_src_len 384 --max_tgt_len 128 --max_seq_len 512 
--output_name response 
--do_generation True --num_samples 20 --topk 5 --is_cn True 
--do_generation true --save_path Knover/output --log_step 1

2. 用Paddlehub实现多轮对话

PaddleHub是飞桨预训练模型应用工具,开发者可以便捷地使用高质量的预训练模型结合Fine-tune API快速完成模型迁移到部署的全流程工作。PaddleHub提供的预训练模型涵盖了图像分类、目标检测、词法分析、语义模型、情感分析、视频分类、图像生成、图像分割、文本审核、关键点检测等主流模型。更多详情可查看官网,链接:

https://www.paddlepaddle.org.cn/hub

2.1 英文多轮对话

在Paddlehub中已经集成了两个plato对话模型,分别是plato2_en_base和plato2_en_large。其中,第一个是小模型,占用1.2G;第二个是大模型,占用12G。小模型可以在4G显存以上的电脑上直接运行,但是如果需要运行大模型,则至少需要一块16G的V100了。官方给出的这两个模型其实对话效果已经很棒了,特别是大模型,效果相当惊艳。然而,遗憾的是并不支持中文对话。如果实在想用英文对话,可以用几个简单的代码实现该模型的调用:

代码语言:javascript复制
import paddlehub as hub
module = hub.module("plato2_en_base")
# 直接产生对话:
text = "what you want to say"
response = module.generate([text])
# 多轮对话
with module.interactive_mode(max_turn=3):
    your_words = input()
   response = module.generate(your_words)
   print("robot answer: %s" % response)

其中,module在非对话模式(interactive_mode)下,需要输入一个储存多个问句的list;在对话模式下,则每次输入一个问句。

2.2 实现中文多轮对话

当然,只有英文对话是不够的,我们不是已经有了中文模型了吗?通过对官方hub模块的修改,我们可以将自己的模型嵌入进去,实现中文对话!

当你调用了一次官方的plato2_en_base模型后(为什么我写plato2_en_base呢?因为plato2_en_large也跑不动啊!除非你有16G显存的显卡),对应的模型已经保存到了paddlehub的离线模型库中,你可以在C:Users你的计算机名paddlehubmodules路径下找到自己下载的plato2_en_base文件夹。

首先,我们来分析一下官方模块的组成部分,如下,当我们把plato2_en_base下载下来后,可以看到其主要由以下几个部分组成:

图三:plato2_en_base组成

我们需要改的只有红线标出的部分。首先我们要准备的东西有:save_inference_model.py导出的NSPModel和PlatoModel,分别对应图中的NSP和Plato;查询字典vocab.txt以及sentencepiece预训练模型spm.model。这些文件分别放到图中所示的对应位置。

以上工作完成后,如果你确保了你替换的文件都是按照官方模块中的名字进行的命名,那么你可以直接进行调用,即用官方的名称调用。当然,稍微有强迫症的人可能并不想用官方的名字,我们可以对module进行如下更改来更改模块的名字:

name即你想更改的名字,在调用模型时可以用这个名字来进行搜索。当然,如果你的模型时另外存到了其他地方,你可以用hub.module(directory=dirs)而非hub.module(name)来调用,dirs直接指定到你自己的模块所在的文件夹。更进一步地,你也可以对模块的文件夹更改名字,但是需要注意的是,如果你更改了文件夹的名字,记得将所有py文件中的import选项与文件名相关的import进行更改,即类似from plato2_en_base import ...字段的plato2_en_base更改为你想要定制的文件夹名称。

经过这些简单的操作,我们就可以通过以下几行简单的代码进行自己模型的对话了!

代码语言:javascript复制
import paddlehub as hub
import os
os.environ["CUDA_VISIBLE_DEVICES"] = '0'
module = hub.Module(directory='plato2_cn_small')

with module.interactive_mode(max_turn=3):
    while True:
        human_utterance = input()
        if len(human_utterance) > 0:
            print("You: "   human_utterance)
            robot_utterance = module.generate(human_utterance)
            print("Robot: %s" % robot_utterance[0])
        else:
            break

最后,下边是一个对话展示:

相关模型我已经公开到了AI Studio上,项目链接:

https://aistudio.baidu.com/aistudio/projectdetail/1197592

总结

本文带领大家了解了Knover工具箱和Plato对话模型,并系统详细的介绍了如何从无到有训练一个属于自己的对话模型。同时,也巧用Paddlehub,对自己的对话模型进行应用,并开始了机器人多轮对话。感兴趣的人会在此基础上进行进一步的训练,相信通过你们的智慧和调整,可以得到令自己满意的对话机器人。

0 人点赞