本文参考 pytorch 官方 tutorial CHATBOT TUTORIAL。
需要掌握的pytorch用法
torch.Tensor
是默认的 tensor 类型torch.FlaotTensor
的简称。32-bit floating point。torch.cat((x, x, x), dim=1)
在给定维度上对输入的张量序列进行连接操作。例如 x shape = (2, 3), 经过上面的变换之后的输出为 shape = (2, 9)torch.topk(input, k, dim)
返回按第dim维最大的 k 个值,以及出现的位置。torch.gather(input, dim, index)
沿给定轴dim,将输入索引张量index指定位置的值进行聚合。1
2
3
4a = torch.tensor([[1, 2, 3], [4, 5, 6]])
index = torch.LongTensor([[0, 1], [2, 0]])
b = torch.gather(a, dim=1, index=index)
# b: [[1, 2], [6, 4]]torch.nn.utils.rnn.pack_padded_sequence(input, lengths, batch_first=False)
在 rnn 中,将一个填充过的变长序列压紧。输入的形状可以是(T×B×*)
。T 是最长序列长度,B 是 batch size,*
代表任意维度(可以是0)。如果 batch_first=True 的话,那么相应的 input size 就是(B×T×*)
。
输入的序列,应该按序列长度的长短排序,长的在前,短的在后。即input[:,0]
代表的是最长的序列,input[:, B-1]
保存的是最短的序列。lengths
表示每个序列的长度。torch.nn.utils.rnn.pad_packed_sequence(sequence, batch_first=False)
这个操作和pack_padded_sequence()
是相反的。把压紧的序列再填充回来。
返回的 tensor 的 size 是T×B×*
, T 是最长序列的长度,B 是 batch_size,如果 batch_first=True, 那么返回值是B×T×*
。Batch 中的元素将会以它们长度的逆序排列。torch.masked_select(input, mask, out=None)
Returns a new 1-D tensor which indexes the input tensor according to the binary mask mask which is a ByteTensor.
The shapes of the mask tensor and the input tensor don’t need to match, but they must be broadcastable.torch.nn.utils.clip_grad_norm_(parameters, max_norm, norm_type=2)
梯度裁剪
载入并处理数据
1 | %matplotlib inline |
device(type='cuda')
接下来重新格式化我们的数据文件,并将数据加载到我们可以使用的结构中。
Cornell Movie-Dialogs Corpus是一个丰富的电影角色对话数据集:
- 包括10292对电影角色之间的220579次会话
- 617部电影中的9035个角色
这个数据集庞大而多样,语言形式,时间段,情感等都有很大差异。我们希望这种多样性使我们的模型能够适应多种形式的输入和查询。
1 | corpus_name = "cornell movie-dialogs corpus" |
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'
创建格式化的数据文件
现在我们来创建格式合适的格式化的数据文件,这个新文件的每一行都包含一个以制表符分隔的查询语句和一个响应语句对。
以下函数用来解析原始 movie_lines.txt
数据文件。
loadLines
将文件的每一行拆分为字段字典(lineID,characterID,movieID,character,text)loadConversations
将来自loadLines
的行字段分组为基于movie_conversations.txt
的对话extractSentencePairs
从会话中提取一对句子
1 | # Splits each line of the file into a dictionary of fields |
现在我们调用这些函数并创建新文件,命名为formatted_movie_lines.txt
。
1 | # Define path to new file |
1 | # Load lines and process conversations |
Processing corpus...
1 | print("\nLoading conversations...") |
Loading conversations...
1 | # Write new csv file |
Writing newly formatted file...
Sample lines from file:
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'
加载并裁剪数据
接下来我们创建一个字典来并且将 query/response 的句子加载到内存中
1 | # Default word tokens |
现在我们汇总我们的词汇和查询/回应句子对。 在我们使用这些数据之前,我们必须执行一些预处理。
首先,我们必须使用 unicodeToAscii
将 Unicode
字符串转换为 ASCII
。 接下来,我们应该将所有字母转换为小写并修剪除基本标点符号(normalizeString
)之外的所有非字母字符。最后,为了加速训练收敛,我们将过滤掉长度大于MAX_LENGTH阈值的句子(filterPairs
)。
1 | MAX_LENGTH = 10 # Maximum sentence length to consider |
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 .']
另一种有利于在训练期间实现更快收敛的策略是修剪我们词汇表中很少使用的单词。减小特征空间也会降低模型学习的难度。我们将通过两个步骤来完成此操作:
- 使用
voc.trim
函数修剪MIN_COUNT
阈值下使用的单词 - 过滤出带有修剪单词的句子对
1 | MIN_COUNT = 3 # Minimum word count threshold for trimming |
keep_words 7823 / 18005 = 0.4345
Trimmed from 64271 pairs to 53165, 0.8272 of total
为模型准备数据
我们的最终给模型输入的应该是一个数值张量,使用小批量数据进行训练。
而句子长度有长有短,使用小批量也意味着我们必须注意批量中句子长度的变化。为了适应同一批次中不同大小的句子,我们批量输入的张量形状设置为(max_length,batch_size)
,其中短于 max_length
的句子在之后全部用 EOS_token
填充。
这里读者可以考虑一些为什么我们将张量形状设置为 (max_length,batch_size)
而不是 (batch_size,max_length)
(从时间序列的角度考虑)。
1 | def indexesFromSentence(voc, sentence): |
input_variable: tensor([[ 271, 25, 34, 25, 4164],
[ 117, 247, 4, 197, 329],
[3232, 117, 101, 117, 5736],
[2095, 47, 37, 24, 6],
[ 96, 349, 34, 4, 2],
[ 53, 33, 4, 2, 0],
[ 4, 98, 2, 0, 0],
[ 4, 7, 0, 0, 0],
[ 4, 4, 0, 0, 0],
[ 2, 2, 0, 0, 0]])
lengths: tensor([10, 10, 7, 6, 5])
target_variable: tensor([[ 34, 34, 124, 410, 371],
[ 101, 7, 125, 53, 7],
[ 37, 68, 4, 851, 89],
[ 479, 274, 124, 47, 534],
[ 96, 4, 125, 371, 4],
[7435, 2, 4, 40, 2],
[ 4, 0, 2, 170, 0],
[ 2, 0, 0, 6, 0],
[ 0, 0, 0, 2, 0]])
mask: tensor([[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 0, 1, 1, 0],
[1, 0, 0, 1, 0],
[0, 0, 0, 1, 0]], dtype=torch.uint8)
max_target_len: 9
定义模型
我们使用 seq2seq
模型,它有一个编码器和解码器。在这里,编码器和解码器都使用GRU。
总体模型结构图如下:
编码器
编码器使用一个双向的GRU。
计算流图如下:
- 将单词索引转换为词向量。
- 对填充批量序列进行一个pack操作。
- 通过GRU前向传播。
- unpack操作。
- 将双向GRU的输出求和。
- 返回输出和最终隐藏状态。
输入:
input_seq
:一个batch
,shape =(max_length,batch_size)
input_lengths
:对应批量中每个句子的长度,shape=(batch_size,)
hidden
:隐藏状态,shape =(n_layers × num_directions,batch_size,hidden_size)
输出:
output
:GRU
最后一个隐藏层的输出特征(双向输出之和),shape =(max_length,batch_size,hidden_size)
hidden
:从GRU更新的隐藏状态;shape =(n_layers x num_directions,batch_size,hidden_size)
1 | class EncoderRNN(nn.Module): |
解码器
编码器的输出的张量中包含了输入句子的信息,我们使用解码器中的隐藏状态和编码器的输出共同产生序列中的下一个词,直到输出一个 EOS_token
。一般的 seq2seq 解码器的一个常见问题是,如果我们依赖于上下文向量(就是解码器的输出)来编码整个输入序列的含义,那么我们很可能会丢失信息。在处理长输入序列时尤为明显,这极大地限制了的解码器的能力。
一个解决方案是使用注意力机制。详细的注意力机制模型解释请移步注意力机制
我们实现三种计算注意力机制中 score 的方法:
下面是注意力机制的实现。
1 | # Luong attention layer |
下面我们实现解码器模型。 对于解码器,我们每次提供一个batch的数据。 这意味着我们的词嵌入张量和GRU输出都具有形状(1,batch_size,hidden_size)
。
计算图:
- 获取当前输入的词向量。
- 通过GRU前向传播。
- 根据GRU输出计算Attention。
- 将Attention权重乘以编码器输出以获得新的“加权和”上下文向量。
- 连接加权上下文向量和GRU输出。
- 经过两个线性层和一个softmax预测下一个单词
- 返回输出和隐藏状态。
输入:
input_step
:输入序列的一个时间步,shape =(1,batch_size)
last_hidden
:GRU的最后隐藏层,shape =(n_layers x num_directions,batch_size,hidden_size)
encoder_outputs
:编码器的输出,shape =(time_steps,batch_size,hidden_size)
输出:
output
:softmax归一化张量,给出每个字的概率是解码序列中正确的下一个字;shape =(batch_size,voc.num_words)
hidden
:GRU的最终隐藏状态;shape =(n_layers x num_directions,batch_size,hidden_size)
1 | class LuongAttnDecoderRNN(nn.Module): |
定义训练步骤
损失计算
我们的输出是加了pad字符的输入,计算loss时,我们要不pad字符产生的loss排除在外。单独定义一个 makeNLLLoss
来计算。
1 | def maskNLLLoss(inp, target, mask): |
单批次训练
训练的时候我们的编码器输入是一个 mini-batch,但解码器的输入是一个 single-batch。
我们使用一些技巧来帮助收敛:
第一个技巧是使用
teacher forcing
。 这意味着,我们以一定的概率使用当前目标单词作为解码器的下一个输入,而不是使用解码器的上一个预测输出。 该技术有助于更有效的训练。 然而,teacher forcing
可能导致预测期间的模型不稳定,因为解码器可能没有足够的机会在训练期间真正的使用自己的输出序列。 因此,我们必须注意我们如何设置teacher_forcing_ratio
,而不是被快速收敛所迷惑。第二个技巧是梯度裁剪。 这是一种用于对抗“爆炸梯度”问题的常用技术。 本质上,通过将梯度剪切或阈值化到最大值,可以防止梯度以指数方式增长并且在代价函数中溢出(NaN)。
操作顺序:
- 将整个批次通过编码器前向传播。
- 将解码器的输入初始化为
SOS_token
,将隐藏状态初始化为编码器的最后时间步的隐藏状态。 - 通过解码器前向传播,一次只处理一个时间步的数据。
- 如果使用
teacher forcing
:将下一个解码器输入设置为当前目标; 否则:将下一个解码器输入设置为当前解码器输出。 - 计算并累积损失。
- 执行反向传播。
- 梯度裁剪。
- 更新编码器和解码器模型参数。
1 | def train(input_variable, lengths, target_variable, mask, max_target_len, encoder, decoder, embedding, |
迭代训练
1 | def trainIters(model_name, voc, pairs, encoder, decoder, encoder_optimizer, decoder_optimizer, embedding, encoder_n_layers, decoder_n_layers, save_dir, n_iteration, batch_size, print_every, save_every, clip, corpus_name, loadFilename): |
定义评估方法
在训练模型后,我们希望能够自己与机器人交谈。 首先,我们必须定义我们希望模型如何解码编码我们的输入。
Greedy decoding
Greedy decoding 是我们在训练期间不使用 teacher forcing
时使用的解码方法。换句话说,对于每个时间步,我们只需从具有最高 softmax 值的 decoder_output 中选择单词。该解码方法在单个时间步长上是最优选择。
为了方便 Greedy decoding 的解码操作,我们定义了一个 GreedySearchDecoder 类。 当运行时,该类的一个对象接受shape为 (input_seq length,1)
的输入序列,一个input_length
的标量输入和一个约束相应句子长度的 max_length
。
计算图:
- 通过编码器模型前向传播输入。
- 将编码器的最后时间步的隐藏层状态作为解码器的第一个隐藏层输入。
- 将解码器的第一个输入初始化为
SOS_token
。 - 初始化要append的张量。
- 一次迭代解码一个单词:
- 正向通过解码器。
- 获得最可能的单词标记及其softmax分数。
- 记录单词标记和分数。
- 准备当前单词标记作为下一次解码器输入。
- 返回单词标记和分数的集合。
1 | class GreedySearchDecoder(nn.Module): |
用自己的输入评估
现在我们已经定义了解码方法,我们可以编写用于评估字符串输入句子的函数。 evaluate
函数管理处理输入句子的低级过程。我们首先使用 batch_size == 1
将句子格式化为输入批量的单词索引。 我们将句子的单词转换为相应的索引,并转换维度来为我们的模型准备张量输入。 我们还创建了一个长度张量,其中包含输入句子的长度。在这种情况下,长度是标量,因为我们一次只评估一个句子(batch_size == 1)
。 接下来,我们使用我们的 GreedySearchDecoder
对象获得 decoder 的响应句子张量。最后,我们将响应的索引转换为单词并返回已解码单词的列表。
evaluateInput
充当聊天机器人的用户界面。 调用时,将生成一个输入文本字段,我们可以在其中输入询问语句。 在输入我们的输入句子并按 Enter 后,文本以与训练数据相同的方式标准化,并最终被输入到评估函数以获得 decoder 的输出句子。 我们循环这个过程,直到我们输入“q”或“quit”。
最后,如果输入的句子包含不在词汇表中的单词,我们会通过打印错误消息并提示用户输入另一个句子来优雅地处理此问题。
1 | def evaluate(encoder, decoder, searcher, voc, sentence, max_length=MAX_LENGTH): |
运行模型
无论我们是训练还是测试聊天机器人模型,我们都必须初始化各个编码器和解码器模型。 在下面的块中,我们设置了所需的配置,选择从头开始训练活着从 checkpoint 中加载,以及构建和初始化模型。
1 | # Configure models |
Building encoder and decoder ...
Models built and ready to go!
训练
1 | # Configure training/optimization |
Building optimizers ...
Starting Training!
Initializing ...
Training...
Iteration: 200; Percent complete: 5.0%; Average loss: 4.0215
Iteration: 400; Percent complete: 10.0%; Average loss: 3.3358
Iteration: 600; Percent complete: 15.0%; Average loss: 3.1570
Iteration: 800; Percent complete: 20.0%; Average loss: 3.0571
Iteration: 1000; Percent complete: 25.0%; Average loss: 2.9542
Iteration: 1200; Percent complete: 30.0%; Average loss: 2.9113
Iteration: 1400; Percent complete: 35.0%; Average loss: 2.8514
Iteration: 1600; Percent complete: 40.0%; Average loss: 2.7897
Iteration: 1800; Percent complete: 45.0%; Average loss: 2.7300
Iteration: 2000; Percent complete: 50.0%; Average loss: 2.6705
Iteration: 2200; Percent complete: 55.0%; Average loss: 2.6352
Iteration: 2400; Percent complete: 60.0%; Average loss: 2.5717
Iteration: 2600; Percent complete: 65.0%; Average loss: 2.5346
Iteration: 2800; Percent complete: 70.0%; Average loss: 2.4772
Iteration: 3000; Percent complete: 75.0%; Average loss: 2.4238
Iteration: 3200; Percent complete: 80.0%; Average loss: 2.3706
Iteration: 3400; Percent complete: 85.0%; Average loss: 2.3249
Iteration: 3600; Percent complete: 90.0%; Average loss: 2.2789
Iteration: 3800; Percent complete: 95.0%; Average loss: 2.2169
Iteration: 4000; Percent complete: 100.0%; Average loss: 2.1741
评估
1 | # Set dropout layers to eval mode |