动手做个DialoGPT:基于LM的生成式多轮对话模型
By 苏剑林 | 2020-09-07 | 103597位读者 |前段时间刷Arixv的时候,发现清华大学开源了一个大规模的中文闲聊语料库LCCC(论文链接,项目地址),从开源的文件上来看,这可能是目前开源的数量最大、质量最好的闲聊语料库了,而且还包含了部分多轮对话聊天,总的来说可玩性还是蛮强的。笔者也被它吸引到了,尝试着用它来训练了一个闲聊对话模型,结果看上去还是不错的,在此分享一下自己的经验。
语料简介 #
这里简单介绍一下LCCC这个数据集(Large-scale Cleaned Chinese Conversation),具体细节大家可以去Github上看,下载链接也在上面。LCCC分base和large两个版本,base主要是来源于微博对话,large则是在base的基础上融合了其他开源对话语料,按照作者的说法,LCCC经过了严格的清洗过程,所以整体质量看上去还是很不错的。
\begin{array}{c|c|c}
\hline
\text{LCCC-base} & \text{单轮对话} & \text{多轮对话} \\
\hline
\text{总对话轮次} & \text{3,354,382} & \text{3,466,607}\\
\hline
\text{总对话语句} & \text{6,708,554} & \text{13,365,268}\\
\hline
\end{array}\begin{array}{c|c|c}
\hline
\text{LCCC-large} & \text{单轮对话} & \text{多轮对话} \\
\hline
\text{总对话轮次} & \text{7,273,804} & \text{4,733,955}\\
\hline
\text{总对话语句} & \text{14,547,608} & \text{18,341,167}\\
\hline
\end{array}
为了简化任务,所有样本都被处理成双人对话。下面是一些样本示例:
A: 等过年咱们回去买点兔头好好吃顿火锅
B: 太原就没看见有好吃的兔头
A: 我从虹桥给你带个回去那天瞅到一正宗的
B: 最爱你了
A: 那是必须A: 嗯嗯,我再等等!你现在在上海吧?上海风好像比南京还大呢,少出门吧
B: 对啊,我在家,没事儿。一定要小心啊!A: 我去年也去转了一圈,还碰见以前的体育老师了,合了个影
B: 哈哈我还去找高一时侯的英语老师没找到她刚好有事情没在学校~
A: 你也是真心找回忆了哦
B: 哈哈毕业了没去过想去看看啊
模型设计 #
知道了数据长什么样之后,我们接下来就要去设计模型了。显然,我们需要做的就是训练一个模型,预测下一个该回复什么。既然语料里包含了多轮对话,那么我们还要求这个模型支持多轮对话。考虑对话历史的最简单的方式,就是把直到当前句的所有历史对话都拼接成单句文本,来作为模型的输入信息了。
给定一些输入,预测一个输出,从形式上来看我们应该用Seq2Seq模型。直接用Seq2Seq其实问题也不大,但标准的Seq2Seq一般用于形式比较固定的输入输出,比如输入的文本长度应该是集中在某个范围内,不宜变化太大,但考虑多轮对话的话,理论上我们也不知道前面有多少轮对话,因此原则上输入文本长度是无限制的。用Seq2Seq的话还有训练效率低的问题,就是我们每轮对话每次我们只能训练一句回复,如果一个多轮对话有$n$句回复,那么那么就要拆分为$n$个样本来训练了。
因此,我们需要一个长度能相当自由地变化的、同时能预测整一个多轮对话的模型,实现这个需求的比较适当的选择就是单向语言模型(LM、GPT),做法如下图:
如图所示,我们选择当前主流的Transformer模型,按照BERT的常规输入格式,将每句对话用[SEP]拼接起来,然后就训练一个从左往右的单向语言模型。为了区分不同的说话角色,我们对不同的说话者用不同的Segment Id区分。此外,考虑到BERT和GPT都是用了绝对位置编码,可处理的文本长度存在一个上限,而对话轮数理论上是无限的,所以这里我们采用了相对位置编码的NEZHA作为基本结构,并使用NEZHA的预训练权重作为模型的初始化权重。
说白了,就是往NEZHA里边加入了下三角形式的Attention Mask,使其变为一个语言模型,相关介绍请参考《从语言模型到Seq2Seq:Transformer如戏,全靠Mask》。
训练细节 #
首先,给出笔者的参考实现以及训练好的模型:
代码基于bert4keras实现,基本结构是NEZHA Base。为了使得训练更加“平民化”,笔者只用了一张卡(22G的RTX)配合梯度累积来训练,显存不够大的读者可以缩小batch_size。开源的模型是用$2\times 10^{-5}$的学习率、$0.01$的权重衰减率的Adam优化器进行训练的,训练了1000万步(20多天),单步batch_size为16,累积梯度步数为16,所以相当于以256的batch_size训练了62.5万步,最终的loss大概是2.83(平均每token的交叉熵)。
我们来测试一下我们的训练结果。
>>> print(chatbot.response([u'我最近天天吃火锅']))
火锅好吃啊
>>> print(chatbot.response([u'我最近天天吃火锅']))
我也是
>>> print(chatbot.response([u'我最近天天吃火锅']))
哈哈哈哈哈哈我最近一周吃了两顿火锅
>>> print(chatbot.response([u'我最近天天吃火锅']))
我也是,每次都吃的撑
>>> print(chatbot.response([u'我最近天天吃火锅']))
吃火锅不长胖
>>> print(chatbot.response([u'我最近天天吃火锅']))
火锅好啊
>>> print(chatbot.response([u'我最近天天吃火锅']))
你们那边有这种火锅?
>>> print(chatbot.response([u'我最近天天吃火锅']))
火锅好吃吗?我也想吃
>>> print(chatbot.response([u'我最近天天吃火锅']))
吃吧,吃了就开心了
>>> print(chatbot.response([u'我最近天天吃火锅']))
我也想天天吃火锅!
>>> print(chatbot.response([u'我最近天天吃火锅']))
火锅吃不了多久的。>>> print(chatbot.response([u'你那天气好吗']))
还好,不热,有点冷
>>> print(chatbot.response([u'你那天气好吗', u'还好,不热,有点冷', u'那你得穿多厚的衣服']))
我穿了两件
对比分析 #
CDial-GPT也开源了自己训练的预训练模型,笔者也将它转换为bert4keras能加载的格式了(CDial-GPT-tf),读者也可以测试比对一下。从训练上来看,CDial-GPT使用pytorch实现的模型,基本结构是GPT Base,使用了4张2080Ti,总batch_size为32,累积梯度64步,论文说训练了30个epoch,总步数约2100万步(笔者的两倍),因此大概相当于batch_size为2048训练了33万步。
在输入设计上,CDial-GPT也有所不同,如下图:
如图所示,CDial-GPT跟我们前述设计的主要不同是多轮对话之间的拼接方式,我们之前是直接用[SEP]连接,它是用[speaker1]、[speaker2](图中简记为S1、S2)这样的角色标记来连接,最后才用一个[SEP]表示回复结束。这样一来,由于预测部分的格式跟历史的格式不一样,因此每次只能训练一句回复,多轮对话要拆分为多个样本来训练,理论上是增加了训练复杂性的(要训练多步才能把一个多轮对话样本训练完)。
至于效果上,个人测试的感觉是两者没什么明显差别。有兴趣的读者也可以自行比较测试。
文章总结 #
本文主要分享了一次对话模型实践,基于CDial-GPT开源的LCCC闲聊语料库,利用语言模型(GPT)对多轮对话进行生成式建模,得到了一个相对通用的闲聊对话模型,最后将本文的思路与CDial-GPT本身开源的模型进行了比较。
转载到请包括本文地址:https://kexue.fm/archives/7718
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Sep. 07, 2020). 《动手做个DialoGPT:基于LM的生成式多轮对话模型 》[Blog post]. Retrieved from https://kexue.fm/archives/7718
@online{kexuefm-7718,
title={动手做个DialoGPT:基于LM的生成式多轮对话模型},
author={苏剑林},
year={2020},
month={Sep},
url={\url{https://kexue.fm/archives/7718}},
}
September 7th, 2020
苏神你好,我对这个项目也非常的感兴趣。我在重构他们的代码的时候发现,他们在训练的时候把history的token也mask掉不训练了。我这边大致看了看您的代码,我发现您并没有这么做,而是history和response一起全部用来训练语言模型。我之前发issue问过LCCC他们,他们对我的解释是mask掉history进行训练可以保证训练目标是生成多样的对话response,所以可能会更好一点,不知道您怎么看待这个问题呢?
我觉得两种方式不会有什么区别。他们这样做,是因为他们构造的训练数据格式没有递归性(中间不是用[SEP]连接),所以就算history也加入预测,预测结果也不符合他们的设计初衷。这一点本文已经有所提及。
感谢苏神的分析!
September 7th, 2020
感谢苏神捧场我们的项目。
好项目值得捧场。
是郑老师吗?我之前是贪心的学生,听你讲解论文的~
September 8th, 2020
苏神,选择nezha是因为他没有长度限制吗
是的。
训练自己的数据,直接加载nezha_gpt_dialog 权重就可以吧,不需要加载 nazha那个1G的了吧
可以的。
苏神,看到nezha里相对位置的实现,其实attention计算使用的还是一个绝对位置的大矩阵,只是里面的向量表示不再是绝对位置向量,而是相对位置的向量表示,貌似还是有长度限制。 跑了下输入长度大于500就爆了,T5就不会
nezha用的是 https://kexue.fm/archives/8130 里介绍的第一种相对位置编码,不会有长度限制。据我所知,目前所有有应用的相对位置编码设计,都不存在长度限制。
确实,又回去看了下nezha的实现,pytorch版本的,原relative_position_embeddings创建的同时,还预设了固定的relative_position_ids矩阵去进行查表(即维度512的relative_position_ids矩阵)导致长度大于512的序列无法使用预设的relative_position_ids矩阵去进行查表,已经去提了issue。
September 18th, 2020
Hi, 百度链接无法打开~
刚测了还能打开
刚又试了一次,可以,感谢
October 9th, 2020
苏神,我在跑test.py时,遇到一个error,提示type object AutoRegressiveDecoder has no attribute wraps,请教一下是不是bert4keras的版本不对啊?感谢!
是,一般情况下我都声明了所需的bert4keras版本吧。
October 16th, 2020
苏神, 你的那个 CDial-GPT-tf 版本,想继续训练是不是 和 nezha_gpt_dialog的 数据构造方式 一样就可以吧
仔细看了看明白了
有点差别,继续训练的话最好用CDial-GPT原始代码吧。
测试了一下,用你这个直接微调也可以
November 15th, 2020
苏神,看了代码,这个不限长度是我可以把maxlen设置大于512吗
可以的。
January 21st, 2021
苏神,请问在CDial-GPT对比分析时提到的,”这样一来,由于预测部分的格式跟历史的格式不一样,因此每次只能训练一句回复“,这里的格式是指的什么,可以稍微举一下例子吗,还是不明白为什么只能训练一句回复,谢谢~
格式就是指语料的拼接模式,你认真对比两个示意图就行了
January 21st, 2021
嗯嗯,我知道它用了S1,S2当间隔符,但是为什么这样就不能像GPT一样错位预测,同时预测整个多轮对话呢?谢谢~
因为它不满足递归性啊,你错位预测了也对实际预测效果没有什么帮助啊。
苏老师,细细思索了一番,还是没有搞懂你说的点。 预测的格式和 历史的格式,应该是指的预测部分的格式与 输入数据的格式把? 如果还是用GPT去预测,我能理解格式确实没有递归性,因为第一个人和第二个人说话分别是S1,S2开头的,然后因为什么就导致了你说的 对实际预测效果没有什么帮助呢,这中间不太理解。 感谢苏神
那你的意思是把多出来的token预测当作辅助任务?本来GPT做原始任务就不是一件特别容易的事情,然后你又多个辅助任务出来分摊了模型的性能,我确实不认为对实际预测效果有什么帮助。如果你觉得有,你可以自己做做实验然后告诉我结果~
兄弟,请问你理解苏神的回复了吗? 我好像还是没有理解,这个为什么对实际预测没什么帮助啊
February 2nd, 2021
苏神您好 阅读bert4keras源码读到了NEZHA 遇到了两个问题 请苏神指点一二
1.和之前能一样 我没有找到全词掩码的代码 我的思考过程是首先在 https://github.com/bojone/nezha_gpt_dialog 中找,发现在代码中没有找到全词掩码的相关处理 , 于是去model.py中中;发现apply_main_layers的self Attention处理中有一步是x.insert(3, attention_mask)和其他模型都是不一样的 , 我的直觉是在这里可能出现了全词掩码 , 但是我发现在后续的代码中似乎对这个在index=3的位置插入attention_mask 没有什么特殊的处理 ; 于是我往上去BERT里面找 , 直接定位到apply_final_layers里面的self.with_mlm , 定位到这里后就卡住了,因为我仍然没有发现哪里体现了全词掩码(我的直觉是可能与outputs = outputs[1:]有关,但是不确定;或者可能跟后面的'MLM-Bias': ['cls/predictions/output_bias']有关) 这里还请苏神指点一下究竟是哪里体现了全词掩码
2.第二个问题是上面的延伸 我在第一遍读bert4keras里面BERT的实现时没有注意到MLM的细节 我记得BERT中是随机mask语料中15%的token , 然后再15%的语料中海油一个80% 10% 10%的不同处理 , 我在bert和bert继承的Transformer类中也没有找到类似的操作 , 请苏神指点一下这个操作究竟体现在什么地方
(读源码还不是很多 没什么经验 感谢苏神)
bert4keras只负责构建keras下的bert模型,你说的全词掩码,那是预训练方案,不包含在模型构建的过程中。如果你是在想要阅读,可以参考:
https://github.com/bojone/bert4keras/blob/master/pretraining
谢谢苏神 我这就去看
我大概看懂了 也就是说NEZHA实际上训练的方式是RoBERTa模式 对吗苏神
对的
谢谢苏神~