生成式聊天机器人 -- 基于Pytorch + Global Attention + 双向 GRU 实现的SeqToSeq模型 -- 上
前言
本文会介绍使用seq2seq模型实现一个chatbot,训练数据来自Cornell电影对话语料库,偏向于闲聊的语料库。
下面是 chatbot 实现的对话效果示例:
> hello?
Bot: hello .
> where am I?
Bot: you re in a hospital .
> who are you?
Bot: i m a lawyer .
> how are you doing?
Bot: i m fine .
> are you my friend?
Bot: no .
> you're under arrest
Bot: i m trying to help you !
> i'm just kidding
Bot: i m sorry .
> where are you from?
Bot: san francisco .
> it's time for me to leave
Bot: i know .
> goodbye
Bot: goodbye .
数据预处理
下载并加载数据
首先我们要下载训练语料库:
这是一个zip文件,把它下载后解压到项目目录的子目录data下。接下来我们需要对原始数据进行变换然后用合适的数据结构加载到内存里。
Cornell电影对话语料库是电影人物的对话数据,它包括:
- 10,292对电影人物(一部电影有多个人物,他们两两之间可能存在对话)的220,579个对话。
- 617部电影的9,035个人物。
- 总共304,713个utterance(utterance是对话中的语音片段,不一定是完整的句子)。
这个数据集是比较大并且多样的(diverse),语言形式、时代和情感都有很多样。这样的数据可以使得我们的chatbot对于不同的输入更加鲁棒(robust)。
解压后的目录有很多文件,我们会用到的文件包括movie_lines.txt:
首先我们来看一下原始数据长什么样,下面的代码输出这个文件的前10行:
import os
corpus_name = "cornell movie-dialogs corpus"
corpus = os.path.join("data", corpus_name)
def printLines(file, n=10):
with open(file, 'rb') as datafile:
lines = datafile.readlines()
for line in lines[:n]:
print(line)
printLines(os.path.join(corpus, "movie_lines.txt"))
结果如下:
b'L1045 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ They do not!\n'
b'L1044 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ They do to!\n'
b'L985 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ I hope so.\n'
b'L984 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ She okay?\n'
b"L925 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ Let's go.\n"
b'L924 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ Wow\n'
b"L872 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ Okay -- you're gonna need to learn how to lie.\n"
b'L871 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ No\n'
b'L870 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ I\'m kidding. You know how sometimes you just become this "persona"? And you don\'t know how to quit?\n'
b'L869 +++$+++ u0 +++$+++ m0 +++$+++ BIANCA +++$+++ Like my fear of wearing pastels?\n'
注意:上面的move_lines.txt每行都是一个utterance,但是这个文件看不出哪些utterance是组成一段对话的,这需要 movie_conversations.txt 文件:
该文件前10行结果如下:
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L194', 'L195', 'L196', 'L197']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L198', 'L199']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L200', 'L201', 'L202', 'L203']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L204', 'L205', 'L206']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L207', 'L208']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L271', 'L272', 'L273', 'L274', 'L275']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L276', 'L277']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L280', 'L281']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L363', 'L364']
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L365', 'L366']
每一行用”+++$+++”分割成4列,第一列表示第一个人物的ID,第二列表示第二个人物的ID,第三列表示电影的ID,第四列表示这两个人物在这部电影中的一段对话;
比如第一行表示人物u0和u2在电影m0中的一段对话包含ID为L194、L195、L196和L197的4个utterance。
注意:两个人物在一部电影中会有多段对话,中间可能穿插其他人之间的对话,而且即使中间没有其他人说话,这两个人物对话的内容从语义上也可能是属于不同的对话(话题)。所以我们看到第二行还是u0和u2在电影m0中的对话,它包含L198和L199两个utterance,L198是紧接着L197之后的,但是它们属于两个对话(话题)。
原始数据格式化
为了使用方便,我们会把原始数据处理成一个新的文件,这个新文件的每一行都是用TAB分割问题(query)和答案(response)对。为了实现这个目的,我们首先定义一些用于解析原始文件 movie_lines.txt 的辅助函数:
loadLines
: 把movie_lines.txt 文件切分成 (lineID, characterID, movieID, character, text)
# 把每一行都parse成一个dict,key是lineID、characterID、movieID、character和text
# 分别代表这一行的ID、人物ID、电影ID,人物名称和文本。
# 最终输出一个dict,key是lineID,value是一个dict。
# value这个dict的key是lineID、characterID、movieID、character和text
def loadLines(fileName, fields):
lines = {}
with open(fileName, 'r', encoding='iso-8859-1') as f:
for line in f:
values = line.split(" +++$+++ ")
# 抽取fields
lineObj = {}
for i, field in enumerate(fields):
lineObj[field] = values[i]
lines[lineObj['lineID']] = lineObj
return lines
下图展示loadlines处理后得到的数据形式:
每个dict字典实例保存一句话的信息,也就是某个角色在某部电影说了某句话。
loadConversations
: 把上面的行分组成一个个多轮的对话
# 根据movie_conversations.txt文件和上面输出的lines,把utterance组成对话。
# 最终输出一个list,这个list的每一个元素都是一个dict,
# key分别是character1ID、character2ID、movieID和utteranceIDs。
# 分别表示这对话的第一个人物的ID,第二个的ID,电影的ID以及它包含的utteranceIDs
# 最后根据lines,还给每一行的dict增加一个key为lines,其value是个list,
# 包含所有utterance(上面得到的lines的value)
def loadConversations(fileName, lines, fields):
conversations = []
with open(fileName, 'r', encoding='iso-8859-1') as f:
for line in f:
values = line.split(" +++$+++ ")
# 抽取fields
convObj = {}
for i, field in enumerate(fields):
convObj[field] = values[i]
# convObj["utteranceIDs"]是一个字符串,形如['L198', 'L199']
# 我们用eval把这个字符串变成一个字符串的list。
lineIds = eval(convObj["utteranceIDs"])
# 根据lineIds构造一个数组,根据lineId去lines里检索出存储utterance对象。
convObj["lines"] = []
for lineId in lineIds:
convObj["lines"].append(lines[lineId])
conversations.append(convObj)
return conversations
下图展示loadConversations处理后得到的数据形式:
extractSentencePairs
: 从上面的每个对话中抽取句对(一问一答)
# 从对话中抽取句对
# 假设一段对话包含s1,s2,s3,s4这4个utterance
# 那么会返回3个句对:s1-s2,s2-s3和s3-s4。
def extractSentencePairs(conversations):
qa_pairs = []
for conversation in conversations:
# 遍历对话中的每一个句子,忽略最后一个句子,因为没有答案。
for i in range(len(conversation["lines"]) - 1):
inputLine = conversation["lines"][i]["text"].strip()
targetLine = conversation["lines"][i+1]["text"].strip()
# 如果有空的句子就去掉
if inputLine and targetLine:
qa_pairs.append([inputLine, targetLine])
return qa_pairs
下图展示extractSentencePairs处理后得到的数据形式:
接下来我们利用上面的3个函数对原始数据进行处理,最终得到formatted_movie_lines.txt :
# 定义新的文件
datafile = os.path.join(corpus, "formatted_movie_lines.txt")
delimiter = '\t'
# 对分隔符delimiter进行decode,这里对tab进行decode结果并没有变
delimiter = str(codecs.decode(delimiter, "unicode_escape"))
# 初始化dict lines,list conversations以及前面我们介绍过的field的id数组。
lines = {}
conversations = []
MOVIE_LINES_FIELDS = ["lineID", "characterID", "movieID", "character", "text"]
MOVIE_CONVERSATIONS_FIELDS = ["character1ID", "character2ID", "movieID", "utteranceIDs"]
# 首先使用loadLines函数处理movie_lines.txt
print("\nProcessing corpus...")
lines = loadLines(os.path.join(corpus, "movie_lines.txt"), MOVIE_LINES_FIELDS)
# 接着使用loadConversations处理上一步的结果,得到conversations
print("\nLoading conversations...")
conversations = loadConversations(os.path.join(corpus, "movie_conversations.txt"),
lines, MOVIE_CONVERSATIONS_FIELDS)
# 输出到一个新的csv文件
print("\nWriting newly formatted file...")
with open(datafile, 'w', encoding='utf-8') as outputfile:
writer = csv.writer(outputfile, delimiter=delimiter, lineterminator='\n')
# 使用extractSentencePairs从conversations里抽取句对。
for pair in extractSentencePairs(conversations):
writer.writerow(pair)
# 输出一些行用于检查
print("\nSample lines from file:")
printLines(datafile)
上面的代码会生成一个新的文件formatted_movie_lines.txt :
这文件每一行包含一对句对,用tab分割。下面是前十行:
b"Can we make this quick? Roxanne Korrine and Andrew Barrett are having an incredibly horrendous public break- up on the quad. Again.\tWell, I thought we'd start with pronunciation, if that's okay with you.\r\n"
b"Well, I thought we'd start with pronunciation, if that's okay with you.\tNot the hacking and gagging and spitting part. Please.\r\n"
b"Not the hacking and gagging and spitting part. Please.\tOkay... then how 'bout we try out some French cuisine. Saturday? Night?\r\n"
b"You're asking me out. That's so cute. What's your name again?\tForget it.\r\n"
b"No, no, it's my fault -- we didn't have a proper introduction ---\tCameron.\r\n"
b"Cameron.\tThe thing is, Cameron -- I'm at the mercy of a particularly hideous breed of loser. My sister. I can't date until she does.\r\n"
b"The thing is, Cameron -- I'm at the mercy of a particularly hideous breed of loser. My sister. I can't date until she does.\tSeems like she could get a date easy enough...\r\n"
b'Why?\tUnsolved mystery. She used to be really popular when she started high school, then it was just like she got sick of it or something.\r\n'
b"Unsolved mystery. She used to be really popular when she started high school, then it was just like she got sick of it or something.\tThat's a shame.\r\n"
b'Gosh, if only we could find Kat a boyfriend...\tLet me see what I can do.\r\n'
数据清洗与字典映射
接下来我们需要构建词典然后把问答句对加载到内存里。
我们的输入是一个句对,每个句子都是词的序列,但是机器学习只能处理数值,因此我们需要建立词到数字ID的映射。
为此,我们会定义一个Voc类,它会保存词到ID的映射,同时也保存反向的从ID到词的映射。除此之外,它还记录每个词出现的次数,以及总共出现的词的个数。这个类提供addWord方法来增加一个词, addSentence方法来增加句子,也提供方法trim来去除低频的词。
# 预定义的token
PAD_token = 0 # 表示padding
SOS_token = 1 # 句子的开始
EOS_token = 2 # 句子的结束
class Voc:
def __init__(self, name):
self.name = name
self.trimmed = False
self.word2index = {}
self.word2count = {}
self.index2word = {PAD_token: "PAD", SOS_token: "SOS", EOS_token: "EOS"}
self.num_words = 3 # 目前有SOS, EOS, PAD这3个token。
def addSentence(self, sentence):
for word in sentence.split(' '):
self.addWord(word)
def addWord(self, word):
if word not in self.word2index:
self.word2index[word] = self.num_words
self.word2count[word] = 1
self.index2word[self.num_words] = word
self.num_words += 1
else:
self.word2count[word] += 1
# 删除频次小于min_count的token
def trim(self, min_count):
if self.trimmed:
return
self.trimmed = True
keep_words = []
for k, v in self.word2count.items():
if v >= min_count:
keep_words.append(k)
print('keep_words {} / {} = {:.4f}'.format(
len(keep_words), len(self.word2index), len(keep_words) / len(self.word2index)
))
# 重新构造词典
self.word2index = {}
self.word2count = {}
self.index2word = {PAD_token: "PAD", SOS_token: "SOS", EOS_token: "EOS"}
self.num_words = 3 # Count default tokens
# 重新构造后词频就没有意义了(都是1)
for word in keep_words:
self.addWord(word)
有了上面的Voc类我们就可以通过问答句对来构建词典了。但是在构建之前我们需要进行一些预处理。
首先我们需要使用函数unicodeToAscii来把unicode字符变成ascii,比如把à变成a。注意,这里的代码只是用于处理西方文字,如果是中文,这个函数直接会丢弃掉:
# 把Unicode字符串变成ASCII
# 参考https://stackoverflow.com/a/518232/2809427
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
)
接下来把所有字母变成小写同时丢弃掉字母和常见标点(.!?)之外的所有字符:
# 对字符串进行全面的规范化处理,包括小写转换、去除空格、处理标点符号和清理无效字符
def normalizeString(s):
# 变成小写、去掉前后空格,然后unicode变成ascii
s = unicodeToAscii(s.lower().strip())
# 在标点前增加空格,这样把标点当成一个词
s = re.sub(r"([.!?])", r" \1", s)
# 字母和标点之外的字符都变成空格
s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
# 因为把不用的字符都变成空格,所以可能存在多个连续空格
# 下面的正则替换把多个空格变成一个空格,最后去掉前后空格
s = re.sub(r"\s+", r" ", s).strip()
return s
最后为了训练收敛,我们会用函数filterPairs去掉长度超过MAX_LENGTH
的句子(句对):
MAX_LENGTH = 10 # 句子最大长度是10个词(包括EOS等特殊词)
# 一个句对包含问和答两句话,两句话要同时满足长度小于10个词才行
def filterPair(p):
return len(p[0].split(' ')) < MAX_LENGTH and len(p[1].split(' ')) < MAX_LENGTH
# 过滤太长的句对
def filterPairs(pairs):
return [pair for pair in pairs if filterPair(pair)]
下面开始对原始数据格式化阶段处理完毕得到的句对数据再次进行清洗:
# 读取问答句对并且返回Voc词典对象
def readVocs(datafile, corpus_name):
print("Reading lines...")
# 文件每行读取到list lines中。
lines = open(datafile, encoding='utf-8'). \
read().strip().split('\n')
# 每行用tab切分成问答两个句子,然后调用normalizeString函数进行处理。
pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]
voc = Voc(corpus_name)
return voc, pairs
# 使用上面的函数进行处理,返回Voc对象和句对的list
def loadPrepareData(corpus, corpus_name, datafile):
print("Start preparing training data ...")
voc, pairs = readVocs(datafile, corpus_name)
print("Read {!s} sentence pairs".format(len(pairs)))
pairs = filterPairs(pairs)
print("Trimmed to {!s} sentence pairs".format(len(pairs)))
print("Counting words...")
for pair in pairs:
voc.addSentence(pair[0])
voc.addSentence(pair[1])
print("Counted words:", voc.num_words)
return voc, pairs
# Load/Assemble voc and pairs
save_dir = os.path.join("data", "save")
voc, pairs = loadPrepareData(corpus, corpus_name, datafile)
# 输出一些句对
print("\npairs:")
for pair in pairs[:10]:
print(pair)
输出:
Start preparing training data ...
Reading lines...
Read 221282 sentence pairs
Trimmed to 64271 sentence pairs
Counting words...
Counted words: 18008
pairs:
['there .', 'where ?']
['you have my word . as a gentleman', 'you re sweet .']
['hi .', 'looks like things worked out tonight huh ?']
['you know chastity ?', 'i believe we share an art instructor']
['have fun tonight ?', 'tons']
['well no . . .', 'then that s all you had to say .']
['then that s all you had to say .', 'but']
['but', 'you always been this selfish ?']
['do you listen to this crap ?', 'what crap ?']
['what good stuff ?', 'the real you .']
我们可以看到,原来共有221282个句对,经过处理后我们只保留了 64271个句对。
另外为了收敛更快,我们可以去除掉一些低频词。这可以分为两步:
- 使用voc.trim函数去掉频次低于MIN_COUNT 的词。
- 去掉包含低频词的句子(只保留这样的句子——每一个词都是高频的,也就是在voc中出现的)。
MIN_COUNT = 3 # 阈值为3
def trimRareWords(voc, pairs, MIN_COUNT):
# 去掉voc中频次小于3的词
voc.trim(MIN_COUNT)
# 保留的句对
keep_pairs = []
for pair in pairs:
input_sentence = pair[0]
output_sentence = pair[1]
keep_input = True
keep_output = True
# 检查问题
for word in input_sentence.split(' '):
if word not in voc.word2index:
keep_input = False
break
# 检查答案
for word in output_sentence.split(' '):
if word not in voc.word2index:
keep_output = False
break
# 如果问题和答案都只包含高频词,我们才保留这个句对
if keep_input and keep_output:
keep_pairs.append(pair)
print("Trimmed from {} pairs to {}, {:.4f} of total".format(len(pairs),
len(keep_pairs), len(keep_pairs) / len(pairs)))
return keep_pairs
# 实际进行处理
pairs = trimRareWords(voc, pairs, MIN_COUNT)
输出:
keep_words 7823 / 18005 = 0.4345
Trimmed from 64271 pairs to 53165, 0.8272 of total
18005个词之中,频次大于等于3的只有43%,去掉低频的57%的词之后,保留的句子为53165,占比为82%。
转换为模型需要的数据格式
前面我们构建了词典,并且对训练数据进行预处理并且滤掉一些句对,但是模型最终用到的是Tensor。最简单的办法是一次处理一个句对,那么上面得到的句对直接就可以使用。但是为了加快训练速度,尤其是重复利用GPU的并行能力,我们需要一次处理一个batch的数据。
对于某些问题,比如图像来说,输入可能是固定大小的(或者通过预处理缩放成固定大小),但是对于文本来说,我们很难把一个二十个词的句子”缩放”成十个词同时还保持语义不变。但是为了充分利用GPU等计算自由,我们又必须变成固定大小的Tensor
,因此我们通常会使用Padding的技巧
,把短的句子补充上零使得输入大小是(batch, max_length)
,这样通过一次就能实现一个batch数据的forward或者backward计算。
当然padding的部分的结果是没有意义的,比如某个句子实际长度是5,而max_length是10,那么最终forward的输出应该是第5个时刻的输出,后面5个时刻计算是无用功。方向计算梯度的时候也是类似的,我们需要从第5个时刻开始反向计算梯度。为了提高效率,我们通常把长度接近的训练数据放到一个batch里面,这样无用的计算是最少的。因此我们通常把全部训练数据根据长度划分成一些组,比如长度小于4的一组,长度4到8的一组,长度8到12的一组,…。然后每次随机的选择一个组,再随机的从一组里选择batch个数据。不过本文并没有这么做,而是每次随机的从所有pair里随机选择batch个数据。
原始的输入通常是batch个list,表示batch个句子,因此自然的表示方法为(batch, max_length)
,这种表示方法第一维是batch,每移动一个下标得到的是一个样本的max_length个词(包括padding)。因为RNN的依赖关系,我们在计算t+1时刻必须知道t时刻的结果,因此我们无法用多个核同时计算一个样本的forward。但是不同样本之间是没有依赖关系的,因此我们可以在根据t时刻batch样本的当前状态计算batch个样本的输出和新状态,然后再计算t+2时刻,…。为了便于GPU一次取出t时刻的batch个数据,我们通常把输入从(batch, max_length)
变成(max_length, batch)
,这样使得t时刻的batch个数据在内存(显存)中是连续的,从而读取效率更高。这个过程如下图所示,原始输入的大小是(batch=6, max_length=4)
,转置之后变成(4,6)
。这样某个时刻的6个样本数据在内存中是连续的。
因此我们会用一些工具函数来实现上述处理:
- inputVar函数:
- 把batch个句子padding后变成一个LongTensor,大小是(max_length, batch),同时会返回一个大小是batch的list lengths,说明每个句子的实际长度,这个参数后面会传给PyTorch,从而在forward和backward计算的时候使用实际的长度。
# 把句子的词变成ID
def indexesFromSentence(voc, sentence):
return [voc.word2index[word] for word in sentence.split(' ')] + [EOS_token]
# l是多个长度不同句子(list),使用zip_longest padding成定长,长度为最长句子的长度。
# zeroPadding 同时通过 itertools.zip_longest 实现维度转换:
# 原始维度:(batch_size, max_length),即每个句子是一个子列表。
# 转换后维度:(max_length, batch_size),即每个时间步是一个子列表。
def zeroPadding(l, fillvalue=PAD_token):
return list(itertools.zip_longest(*l, fillvalue=fillvalue))
# 把输入句子变成ID,然后再padding,同时返回lengths这个list,标识实际长度。
# 返回的padVar是一个LongTensor,shape是(batch, max_length),
# lengths是一个list,长度为(batch,),表示每个句子的实际长度。
def inputVar(l, voc):
indexes_batch = [indexesFromSentence(voc, sentence) for sentence in l]
lengths = torch.tensor([len(indexes) for indexes in indexes_batch])
padList = zeroPadding(indexes_batch)
padVar = torch.LongTensor(padList)
return padVar, lengths
inputVar处理后返回的padVar和lengths格式如下图所示:
zeroPadding处理过程举例说明:
l = [
[1, 2, 3], # 句子 1
[4, 5], # 句子 2
[6, 7, 8, 9] # 句子 3
]
# zeroPadding 转换后
result = [
[1, 4, 6], # 时间步 1
[2, 5, 7], # 时间步 2
[3, PAD_token, 8], # 时间步 3
[PAD_token, PAD_token, 9] # 时间步 4
]
- outputVar函数:
- 和inputVar类似,但是它输出的第二个参数不是lengths,而是一个大小为(max_length, batch)的mask矩阵(tensor),某位是0表示这个位置是padding,1表示不是padding,这样做的目的是后面计算方便。当然这两种表示是等价的,只不过lengths表示更加紧凑,但是计算起来不方便,而mask矩阵和outputVar直接相乘就可以把padding的位置给mask(变成0)掉,这在计算loss时会非常方便。
# l是二维的padding后的list
# 返回m和l的大小一样,如果某个位置是padding,那么值为0,否则为1
def binaryMatrix(l, value=PAD_token):
m = []
for i, seq in enumerate(l):
m.append([])
for token in seq:
if token == PAD_token:
m[i].append(0)
else:
m[i].append(1)
return m
# 对输出句子进行padding,然后用binaryMatrix得到每个位置是padding(0)还是非padding,
# 同时返回最大最长句子的长度(也就是padding后的长度)
# 返回值padVar是LongTensor,shape是(batch, max_target_length)
# mask是ByteTensor,shape也是(batch, max_target_length)
def outputVar(l, voc):
indexes_batch = [indexesFromSentence(voc, sentence) for sentence in l]
max_target_len = max([len(indexes) for indexes in indexes_batch])
padList = zeroPadding(indexes_batch)
mask = binaryMatrix(padList)
mask = torch.BoolTensor(mask)
padVar = torch.LongTensor(padList)
return padVar, mask, max_target_len
outputVar处理后返回的padVar,mask,max_target_len格式如下图所示:
- batch2TrainData函数:
- 则利用上面的两个函数把一个batch的句对处理成合适的输入和输出Tensor。
# 处理一个batch的pair句对
def batch2TrainData(voc, pair_batch):
# 按照句子的长度(词数)排序
pair_batch.sort(key=lambda x: len(x[0].split(" ")), reverse=True)
input_batch, output_batch = [], []
for pair in pair_batch:
input_batch.append(pair[0])
output_batch.append(pair[1])
# inp 维度为: (max_length,batch_size)
inp, lengths = inputVar(input_batch, voc)
# output 维度为: (max_length,batch_size)
output, mask, max_target_len = outputVar(output_batch, voc)
return inp, lengths, output, mask, max_target_len
测试代码:
small_batch_size = 5
batches = batch2TrainData(voc, [random.choice(pairs) for _ in range(small_batch_size)])
input_variable, lengths, target_variable, mask, max_target_len = batches
print("input_variable:", input_variable)
print("lengths:", lengths)
print("target_variable:", target_variable)
print("mask:", mask)
print("max_target_len:", max_target_len)
batch2TrainData 处理后返回的inp,lengths,output,mask,max_target_len格式如下图所示:
SeqToSeq 模型
我们这个chatbot的核心是一个sequence-to-sequence(seq2seq)
模型。 seq2seq模型的输入是一个变长的序列,而输出也是一个变长的序列。而且这两个序列的长度并不相同。一般我们使用RNN来处理变长的序列,Sutskever等人的论文发现通过使用两个RNN可以解决这类问题。这类问题的输入和输出都是变长的而且长度不一样,包括问答系统、机器翻译、自动摘要等等都可以使用seq2seq模型来解决。
其中一个RNN叫做Encoder,它把变长的输入序列编码成一个固定长度的context向量,我们一般可以认为这个向量包含了输入句子的语义。而第二个RNN叫做Decoder,初始隐状态是Encoder的输出context向量,输入是(表示句子开始的特殊Token),然后用RNN计算第一个时刻的输出,接着用第一个时刻的输出和隐状态计算第二个时刻的输出和新的隐状态,…,直到某个时刻输出特殊的(表示句子结束的特殊Token)或者长度超过一个阈值。
本文中提到的RNN某个时刻的输出,指的是Rnn Cell计算得到的隐藏状态经过前馈层线性变换后的输出结果,而隐藏状态则代表Rnn Cell计算得到的输出,注意区分!
Seq2Seq模型如下图所示:
Encoder 编码器
Encoder是个RNN,它会遍历输入的每一个Token(词),每个时刻的输入是上一个时刻的隐状态和当前时刻的输入,然后会有一个输出和新的隐状态。这个新的隐状态会作为下一个时刻的输入隐状态。
每个时刻都有一个输出,对于seq2seq模型来说,我们通常只保留最后一个时刻的隐状态,认为它编码了整个句子的语义,但是后面我们会用到Attention机制,它还会用到Encoder每个时刻的输出。Encoder处理结束后会把最后一个时刻的隐状态作为Decoder的初始隐状态。
实际我们通常使用多层的Gated Recurrent Unit(GRU)或者LSTM来作为Encoder,这里使用GRU,此外我们会使用双向的RNN,如下图所示:
注意在接入RNN之前会有一个embedding层,用来把每一个词(ID或者one-hot向量)映射成一个连续的稠密的向量,我们可以认为这个向量编码了一个词的语义。在我们的模型里,我们把它的大小定义成和RNN的隐状态大小一样(但是并不是一定要一样)。有了Embedding之后,模型会把相似的词编码成相似的向量(距离比较近)。
最后,为了把padding的batch数据传给RNN,我们需要使用下面的两个函数来进行pack和unpack,后面我们会详细介绍它们。这两个函数是:
- torch.nn.utils.rnn.pack_padded_sequence
- torch.nn.utils.rnn.pad_packed_sequence
Encoder数据流向过程:
- 把词的ID通过Embedding层变成向量。
- 把padding后的数据进行pack。
- 传入GRU进行Forward计算。
- Unpack计算结果。
- 把双向GRU的结果向量加起来。
- 返回(所有时刻的)输出和最后时刻的隐状态。
输入:
- input_seq: 一个batch的输入句子,shape是(max_length, batch_size)
- input_lengths: 一个长度为batch的list,表示句子的实际长度。
- hidden: 初始化隐状态(通常是零),shape是(n_layers x num_directions, batch_size, hidden_size)
输出:
- outputs: 最后一层GRU的输出向量(
双向的向量加在了一起
),shape(max_length, batch_size, hidden_size) - hidden: 最后一个时刻的隐状态,shape是(n_layers x num_directions, batch_size, hidden_size)
关于GRU输入输出维度问题不太清楚的,可以看我之前写的这篇文章: 单向/双向,单层/多层RNN输入输出维度问题
EncoderRNN代码如下:
import torch
from torch import nn
class EncoderRNN(nn.Module):
def __init__(self, hidden_size, embedding, n_layers=1, dropout=0):
super(EncoderRNN, self).__init__()
self.n_layers = n_layers
self.hidden_size = hidden_size
self.embedding = embedding
# 初始化GRU,这里输入和hidden大小都是hidden_size,这里假设embedding层的输出大小是hidden_size
# 如果只有一层,那么不进行Dropout,否则使用传入的参数dropout进行GRU的Dropout。
self.gru = nn.GRU(hidden_size, hidden_size, n_layers,
dropout=(0 if n_layers == 1 else dropout), bidirectional=True)
def forward(self, input_seq, input_lengths, hidden=None):
# 输入是(max_length, batch),Embedding之后变成(max_length, batch, hidden_size)
embedded = self.embedding(input_seq)
# Pack padded batch of sequences for RNN module
# 因为RNN(GRU)要知道实际长度,所以PyTorch提供了函数pack_padded_sequence把输入向量和长度
# pack到一个对象PackedSequence里,这样便于使用。
input_lengths = input_lengths.to(dtype=torch.int64)
input_lengths = input_lengths.cpu()
packed = torch.nn.utils.rnn.pack_padded_sequence(embedded, input_lengths)
# 通过GRU进行forward计算,需要传入输入和隐变量
# 如果传入的输入是一个Tensor (max_length, batch, hidden_size)
# 那么输出outputs是(max_length, batch, hidden_size*num_directions)。
# 第三维是hidden_size和num_directions的混合,它们实际排列顺序是num_directions在前面,
# 因此我们可以使用outputs.view(seq_len, batch, num_directions, hidden_size)得到4维的向量。
# 其中第三维是方向,第四位是隐状态。
# 而如果输入是PackedSequence对象,那么输出outputs也是一个PackedSequence对象,我们需要用
# 函数pad_packed_sequence把它变成shape为(max_length, batch, hidden*num_directions)的向量以及
# 一个list,表示输出的长度,当然这个list和输入的input_lengths完全一样,因此通常我们不需要它。
outputs, hidden = self.gru(packed, hidden)
# 参考前面的注释,我们得到outputs为(max_length, batch, hidden*num_directions)
outputs, _ = torch.nn.utils.rnn.pad_packed_sequence(outputs)
# 我们需要把输出的num_directions双向的向量加起来
# 因为outputs的第三维是先放前向的hidden_size个结果,然后再放后向的hidden_size个结果
# 所以outputs[:, :, :self.hidden_size]得到前向的结果
# outputs[:, :, self.hidden_size:]是后向的结果
# 注意,如果bidirectional是False,则outputs第三维的大小就是hidden_size,
# 这时outputs[:, : ,self.hidden_size:]是不存在的,因此也不会加上去。
# 对Python slicing不熟的读者可以看看下面的例子:
# >>> a=[1,2,3]
# >>> a[:3]
# [1, 2, 3]
# >>> a[3:]
# []
# >>> a[:3]+a[3:]
# [1, 2, 3]
# 这样就不用写下面的代码了:
# if bidirectional:
# outputs = outputs[:, :, :self.hidden_size] + outputs[:, : ,self.hidden_size:]
outputs = outputs[:, :, :self.hidden_size] + outputs[:, :, self.hidden_size:]
# 返回最终的输出和最后时刻的隐状态。
return outputs, hidden
Decoder 解码器
Decoder也是一个RNN,它每个时刻输出一个词。每个时刻的输入是上一个时刻的隐状态和上一个时刻的输出。一开始的隐状态是Encoder最后时刻的隐状态,输入是特殊的。然后使用RNN计算新的隐状态和输出第一个词,接着用新的隐状态和第一个词计算第二个词,…,直到遇到,结束输出。普通的RNN Decoder的问题是它只依赖与Encoder最后一个时刻的隐状态,虽然理论上这个隐状态(context向量
)可以编码输入句子的语义,但是实际会比较困难。因此当输入句子很长的时候,效果会很差。
全局注意力机制
为了解决这个问题,Bahdanau等人在论文里提出了注意力机制(attention mechanism),在Decoder进行t时刻计算的时候,除了t-1时刻的隐状态,当前时刻的输入,注意力机制还可以参考Encoder所有时刻的输入
。
拿机器翻译来说,我们在翻译以句子的第t个词的时候会把注意力机制在某个词上。当然常见的注意力是一种soft的注意力,假设输入有5个词,注意力可能是一个概率,比如(0.6,0.1,0.1,0.1,0.1),表示当前最关注的是输入的第一个词。同时我们之前也计算出每个时刻的输出向量,假设5个时刻分别是 y 1 , … , y 5 y1,…,y5 y1,…,y5,那么我们可以用attention概率加权得到当前时刻的context向量
0.6 y 1 + 0.1 y 2 + … + 0.1 y 5 0.6y1+0.1y2+…+0.1y5 0.6y1+0.1y2+…+0.1y5。
注意力有很多方法计算,我们这里介绍Luong等人在论文提出的方法。它是用当前时刻的GRU计算出的新的隐状态来计算注意力得分
,首先它用一个score函数计算这个隐状态和Encoder的输出的相似度得分,得分越大,说明越应该注意这个词。然后再用softmax函数把score变成概率。
以机器翻译为例,在t时刻, h t ht ht表示t时刻的GRU输出的新的隐状态,我们可以认为 h t ht ht表示当前需要翻译的语义。通过计算 h t ht ht与 y 1 , … , y n y1,…,yn y1,…,yn的得分,如果 h t ht ht与 y 1 y1 y1的得分很高,那么我们可以认为当前主要翻译词 x 1 x1 x1的语义。
有很多中score函数的计算方法,如下图所示:
上式中 h t ht ht表示 t t t时刻的隐状态,比如第一种计算 s c o r e score score的方法,直接计算 h t ht ht与 h s hs hs的内积,内积越大,说明这两个向量越相似,因此注意力也更多的放到这个词上。
第二种方法也类似,只是引入了一个可以学习的矩阵,我们可以认为它先对 h t ht ht做一个线性变换,然后在与 h s hs hs计算内积。
而第三种方法把它们拼接起来然后用一个全连接网络来计算 s c o r e score score。
注意,我们前面介绍的是分别计算 h t ht ht和 y 1 y1 y1的内积、 h t ht ht和 y 2 y2 y2的内积,…。但是为了效率,可以一次计算 h t ht ht与 h s = [ y 1 , y 2 , … , y n ] hs=[y1,y2,…,yn] hs=[y1,y2,…,yn]的乘积。 计算过程如下图所示。
import torch
import torch.nn.functional as F
class Attn(torch.nn.Module):
def __init__(self, method, hidden_size):
super(Attn, self).__init__()
self.method = method
if self.method not in ['dot', 'general', 'concat']:
raise ValueError(self.method, "is not an appropriate attention method.")
self.hidden_size = hidden_size
if self.method == 'general':
self.attn = torch.nn.Linear(self.hidden_size, hidden_size)
elif self.method == 'concat':
self.attn = torch.nn.Linear(self.hidden_size * 2, hidden_size)
self.v = torch.nn.Parameter(torch.FloatTensor(hidden_size))
def dot_score(self, hidden, encoder_output):
# 输入hidden的shape是(1, batch=64, hidden_size=500)
# encoder_outputs的shape是(input_lengths=10, batch=64, hidden_size=500)
# hidden * encoder_output得到的shape是(10, 64, 500),然后对第3维求和就可以计算出score。
return torch.sum(hidden * encoder_output, dim=2)
def general_score(self, hidden, encoder_output):
energy = self.attn(encoder_output)
return torch.sum(hidden * energy, dim=2)
def concat_score(self, hidden, encoder_output):
energy = self.attn(torch.cat((hidden.expand(encoder_output.size(0), -1, -1),
encoder_output), 2)).tanh()
return torch.sum(self.v * energy, dim=2)
# 输入是上一个时刻的隐状态hidden和所有时刻的Encoder的输出encoder_outputs
# 输出是注意力的概率,也就是长度为input_lengths的向量,它的和加起来是1。
def forward(self, hidden, encoder_outputs):
# 计算注意力的score,输入hidden的shape是(1, batch=64, hidden_size=500),
# 表示t时刻batch数据的隐状态
# encoder_outputs的shape是(input_lengths=10, batch=64, hidden_size=500)
if self.method == 'general':
attn_energies = self.general_score(hidden, encoder_outputs)
elif self.method == 'concat':
attn_energies = self.concat_score(hidden, encoder_outputs)
elif self.method == 'dot':
# 计算内积,参考dot_score函数
attn_energies = self.dot_score(hidden, encoder_outputs)
# Transpose max_length and batch_size dimensions
# 把attn_energies从(max_length=10, batch=64)转置成(64, 10)
attn_energies = attn_energies.t()
# 使用softmax函数把score变成概率,shape仍然是(64, 10),然后用unsqueeze(1)变成
# (64, 1, 10)
return F.softmax(attn_energies, dim=1).unsqueeze(1)
上面的代码实现了dot、general和concat三种score计算方法,分别和前面的三个公式对应,我们这里介绍最简单的dot方法。代码里也有一些注释,只有dot_score函数比较难以理解,我们来分析一下。首先这个函数的输入hidden的shape是(1, batch=64, hidden_size=500)
,encoder_outputs的shape是(input_lengths=10, batch=64, hidden_size=500)
。
怎么计算hidden和10个encoder输出向量的内积呢?
- 为了简便,我们先假设batch是1,这样可以把第二维(batch维)去掉,因此hidden是(1, 500),而encoder_outputs是(10, 500)。
- 内积的定义是两个向量对应位相乘然后相加,但是encoder_outputs是10个500维的向量。当然我们可以写一个for循环来计算,但是效率很低。
- 这里用到一个小的技巧,利用
broadcasting
,hidden * encoder_outputs可以理解为把hidden从(1,500)复制成(10, 500)(当然实际实现并不会这么做),然后两个(10, 500)的矩阵进行乘法。注意,这里的乘法不是矩阵乘法,而是所谓的Hadamard乘法,其实就是把对应位置的乘起来
,比如下面的例子:
因此hidden * encoder_outputs就可以把hidden向量(500个数)与encoder_outputs的10个向量(500个数)对应的位置相乘。而内积还需要把这500个乘积加起来,因此后面使用torch.sum(hidden * encoder_output, dim=2),把第2维500个乘积加起来,最终得到10个score值。
当然我们实际还有一个batch维度,因此最终得到的attn_energies是(10, 64)。接着在forward函数里把attn_energies转置成(64, 10),然后使用softmax函数把10个score变成概率,shape仍然是(64, 10),为了后面使用方便,我们用unsqueeze(1)把它变成(64, 1, 10)。
说明 : 说明: 说明:
- encoder_outputs 指的是EncoderRNN的forward方法返回的outputs结果,如下所示
# encoder的Forward计算 --- (max_len,batch_size) , 每个序列的长度
encoder_outputs, encoder_hidden = encoder(input_variable, lengths)
解码器实现
有了注意力的子模块之后,我们就可以实现Decoder了。Encoder可以一次把一个序列输入GRU,得到整个序列的输出。但是Decoder t时刻的输入是t-1时刻的输出,在t-1时刻计算完成之前是未知的,因此只能一次处理一个时刻的数据。因此Encoder的GRU的输入是(max_length, batch, hidden_size)
,而Decoder的输入是(1, batch, hidden_size)
。
此外Decoder只能利用前面的信息,所以只能使用单向(而不是双向)的GRU,而Encoder的GRU是双向的
,如果两种的hidden_size是一样的,则Decoder的隐单元个数少了一半,那怎么把Encoder的最后时刻的隐状态作为Decoder的初始隐状态呢?
- 这里是把每个时刻双向结果加起来的,因此它们的大小就能匹配了(请读者参考前面Encoder双向相加的部分代码)。
Decoder数据流向过程:
- 把词ID输入Embedding层。
- 使用单向的GRU继续Forward进行一个时刻的计算。
- 使用新的隐状态计算注意力权重。
- 用注意力权重得到context向量。
- context向量和GRU的输出拼接起来,然后再进过一个全连接网络,使得输出大小仍然是hidden_size。
- 使用一个投影矩阵把输出从hidden_size变成词典大小,然后用softmax变成概率 。
- 返回输出和新的隐状态。
输入:
- input_step: shape是(1, batch_size)
- last_hidden: 上一个时刻的隐状态, shape是(n_layers x num_directions, batch_size, hidden_size)
- encoder_outputs: encoder的输出, shape是(max_length, batch_size, hidden_size)
输出:
- output: 当前时刻输出每个词的概率,shape是(batch_size, voc.num_words)
- hidden: 新的隐状态,shape是(n_layers x num_directions, batch_size, hidden_size)
import torch
from torch import nn
import torch.nn.functional as F
from Attention import Attn
class GlobalAttnDecoderRNN(nn.Module):
def __init__(self, attn_model, embedding, hidden_size, output_size, n_layers=1, dropout=0.1):
super(GlobalAttnDecoderRNN, self).__init__()
# 保存到self里,attn_model就是前面定义的Attn类的对象。
self.attn_model = attn_model
self.hidden_size = hidden_size
self.output_size = output_size
self.n_layers = n_layers
self.dropout = dropout
# 定义Decoder的layers
self.embedding = embedding
self.embedding_dropout = nn.Dropout(dropout)
self.gru = nn.GRU(hidden_size, hidden_size, n_layers, dropout=(0 if n_layers == 1 else dropout))
# [context , hidden]
self.concat = nn.Linear(hidden_size * 2, hidden_size) # 上下文信息和隐藏层信息做融合
self.out = nn.Linear(hidden_size, output_size)
self.attn = Attn(attn_model, hidden_size)
def forward(self, input_step, last_hidden, encoder_outputs):
# 注意:decoder每一步只能处理一个时刻的数据,因为t时刻计算完了才能计算t+1时刻。
# input_step的shape是(1, 64),64是batch,1是当前输入的词ID(来自上一个时刻的输出)
# 通过embedding层变成(1, 64, 500),然后进行dropout,shape不变。
embedded = self.embedding(input_step)
embedded = self.embedding_dropout(embedded)
# 把embedded传入GRU进行forward计算
# 得到rnn_output的shape是(1, 64, 500)
# hidden是(2, 64, 500),因为是两层的GRU,所以第一维是2。
rnn_output, hidden = self.gru(embedded, last_hidden)
# 计算注意力权重, 根据前面的分析,attn_weights的shape是(64, 1, 10)
attn_weights = self.attn(rnn_output, encoder_outputs)
# encoder_outputs是(10, 64, 500)
# encoder_outputs.transpose(0, 1)后的shape是(64, 10, 500)
# attn_weights.bmm后是(64, 1, 500)
# bmm是批量的矩阵乘法,第一维是batch,我们可以把attn_weights看成64个(1,10)的矩阵
# 把encoder_outputs.transpose(0, 1)看成64个(10, 500)的矩阵
# 那么bmm就是64个(1, 10)矩阵 x (10, 500)矩阵,最终得到(64, 1, 500)
#
context = attn_weights.bmm(encoder_outputs.transpose(0, 1))
# 把context向量和GRU的输出拼接起来
# rnn_output从(1, 64, 500)变成(64, 500)
rnn_output = rnn_output.squeeze(0)
# context从(64, 1, 500)变成(64, 500)
context = context.squeeze(1)
# 拼接得到(64, 1000)
concat_input = torch.cat((rnn_output, context), 1)
# self.concat是一个矩阵(1000, 500) -- concat 是全连接层,负责将上下文信息和隐藏层信息做融合,并转换维度
# self.concat(concat_input)的输出是(64, 500)
# 然后用tanh把输出返回变成(-1,1),concat_output的shape是(64, 500)
concat_output = torch.tanh(self.concat(concat_input))
# out是(500, 词典大小=7826)
output = self.out(concat_output)
# 用softmax变成概率,表示当前时刻输出每个词的概率。
output = F.softmax(output, dim=1)
# 返回 output和新的隐状态
return output, hidden
这里重点讲解一下attn_weights.bmm
干的事情,还是以batch_size=1
为背景,当我们获取到了归一化后的attn_weights注意力权重向量时,下面要做的事情就是将attn_weights中每个分量作为信息融合的权重依次分配给encoder的10个Rnn Cell的隐藏层最终的输出向量,然后累加在一起,得到我们需要的context上下文向量:
如果大家有了解过Self-Attention机制,这里就可以将decoder的某个Time Step对应的Rnn cell输出的隐藏层向量视为Query向量,而encoder所有隐藏层输出的结果集合encoder_outputs中每个隐藏层输出结果都作为一个独立Key向量,同时也作为Value向量;由于Query和Key,Value向量来源不同,也可以看做是一种交叉自注意力机制的简单实现。
我们拿着Query去依次与所有Key向量计算相似度(向量内积),得到注意力分数向量,每一个分数表明某个Key与我们的Query的匹配程度;
再对注意力分数做归一化处理为注意力权重向量,然后再用这组权重向量作为信息融合依据,分配给每个Key对应的Value向量,再做信息的累加聚合,得到当前关注的上下文信息。