《动手学深度学习》读书笔记—10.4 Bahdanau注意力
本文记录了自己在阅读《动手学深度学习》时的一些思考,仅用来作为作者本人的学习笔记,不存在商业用途。
在学习本节前需要回顾9.7 序列到序列学习
中的内容,这里用两张图快速简化一下。
在9.7 序列到序列学习
中我们设计了一个基于两个循环神经网络的编码器-解码器架构用于序列到序列学习。 具体来说,循环神经网络编码器将长度可变的序列转换为固定形状的上下文变量, 然后循环神经网络解码器根据生成的词元和上下文变量 按词元生成输出(目标)序列词元。
在设计时我们并没有调整过上下文变量ccc,我们在目标序列(输出序列)的每个时间步中都使用源序列的最后一个时间步经过编码器得到的隐状态hT\mathbf{h}_\mathbf{T}hT作为解码器的初始状态decoderinit_statedecoder_{init \_ state}decoderinit_state,进而得到上下文变量ccc。现在思考怎么让上下文变量ccc在输出序列的不同时间步发生变化。
10.4.1 模型
假设输入序列中包含TTT个词元,当前处于时间步t′t't′,将上一个时间步t′−1t'-1t′−1解码器的隐状态st′−1\mathbf{s}_{t' - 1}st′−1看作查询,将编码器在时间步ttt的隐状态ht\mathbf{h}_tht看作键和值,则根据注意力汇聚公式f(x)=∑i=1nα(x,xi)yif(x) =\sum_{i=1}^n \alpha(x, x_i) y_if(x)=∑i=1nα(x,xi)yi可以写出∑t=1Tα(st′−1,ht)ht\sum_{t=1}^T \alpha(\mathbf{s}_{t' - 1}, \mathbf{h}_t) \mathbf{h}_t∑t=1Tα(st′−1,ht)ht,将其赋值给时间步ttt的上下文变量ct′c_{t'}ct′。于是得到了基于Bahdanau注意力的模型:
ct′=∑t=1Tα(st′−1,ht)ht\mathbf{c}_{t'} = \sum_{t=1}^T \alpha(\mathbf{s}_{t' - 1}, \mathbf{h}_t) \mathbf{h}_tct′=t=1∑Tα(st′−1,ht)ht
注意,解码器的时间步用t′t't′表示,编码器的时间步用ttt表示,编码器的总时间步(输入序列长度)TTT和解码器的总时间步(输出序列长度)T′T'T′多数情况下是不同的。
🏷一个带有Bahdanau注意力的循环神经网络编码器-解码器模型
10.4.2 定义注意力解码器
根据上面的讨论可知,我们的目的是让输出序列在不同时间步t′t't′有不同的上下文变量ct′c_{t'}ct′,这只和解码器有关,所以只修改解码器就行,编码器不需要调整。
AttentionDecoder类
定义了带有注意力机制解码器的基本接口。
import torch
from torch import nn
from d2l import torch as d2l#@save
class AttentionDecoder(d2l.Decoder):"""带有注意力机制解码器的基本接口"""def __init__(self, **kwargs):# super.__init__: 调用父类 d2l.Decoder 的初始化方法# **kwargs: 接受任意关键字参数(用于传递网络超参数等)super(AttentionDecoder, self).__init__(**kwargs)@property# @property: 将该方法声明为只读属性(可通过 decoder.attention_weights 直接访问)# raise NotImplementedError: 强制子类必须实现此属性def attention_weights(self):raise NotImplementedError
根据ct′=∑t=1Tα(st′−1,ht)ht\mathbf{c}_{t'} = \sum_{t=1}^T \alpha(\mathbf{s}_{t' - 1}, \mathbf{h}_t) \mathbf{h}_tct′=∑t=1Tα(st′−1,ht)ht可知,我们需要解码器在所有时间步的最终隐状态ht\mathbf{h}_tht作为键和值。解码器在时间步t′t't′的初始隐状态用上一时间步的编码器全层隐状态初始化。在每个解码时间步骤中,解码器上一个时间步的最终层隐状态将用作查询。还需要直到编码器的有效长度(排除填充词元)。
Seq2SeqAttentionDecoder类
实现带有Bahdanau注意力的循环神经网络解码器。
nn.GRU说明
class Seq2SeqAttentionDecoder(AttentionDecoder):def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,dropout=0, **kwargs):super(Seq2SeqAttentionDecoder, self).__init__(**kwargs)# 注意力评分函数使用加性注意力self.attention = d2l.AdditiveAttention(num_hiddens, num_hiddens, num_hiddens, dropout)# 嵌入层: 将每个词元在词表中的索引转换成向量self.embedding = nn.Embedding(vocab_size, embed_size)# 使用门控循环单元self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,dropout=dropout)# 线形层用于将预测词元的输出值转换成预测词元属于不同词元的概率self.dense = nn.Linear(num_hiddens, vocab_size)def init_state(self, enc_outputs, enc_valid_lens, *args):# outputs的形状为(时间长度,批量大小,隐藏层维数)# hidden_state的形状为(隐藏层数量,批量大小,隐藏层维数)outputs, hidden_state = enc_outputs# permute(1, 0, 2): outputs的形状为(批量大小,时间长度,隐藏层维数)return (outputs.permute(1, 0, 2), hidden_state, enc_valid_lens)def forward(self, X, state):# enc_outputs的形状为(批量大小, 时间长度, 隐藏层维数).# hidden_state的形状为(隐藏层数量, 批量大小, 隐藏层维数)enc_outputs, hidden_state, enc_valid_lens = state# 输出X的形状为(时间长度,批量大小,嵌入维数)X = self.embedding(X).permute(1, 0, 2)outputs, self._attention_weights = [], []for x in X:# hidden_state[-1]: 取编码器在上一时间步的最终层隐状态作为查询# hidden_state[-1]的形状为(批量大小, 隐藏层维数)# torch.unsqueeze(dim=1)插入一个维度: (批量大小, 1, 隐藏层维数)# 查询的形状为(批量大小,查询的步数或词元序列长度,查询的特征维数)# query的形状为(批量大小, 1, 隐藏层维数)query = torch.unsqueeze(hidden_state[-1], dim=1)# enc_outputs: 编码器所有时间步的最终层隐状态作为键和值# 键和值的形状为(批量大小,键/值的步数或词元序列长度,键/值的特征维数)# 因此键值对的个数就是时间长度, 值的特征维数就是隐藏层维数# 注意力汇聚输出的形状为(批量大小, 查询的步数, 值的特征维度)# context的形状为(批量大小,1,隐藏层维数)context = self.attention(query, enc_outputs, enc_outputs, enc_valid_lens)# unsqueeze(dim=1): x的形状(批量大小, 1, 嵌入维数)# torch.cat(dim=-1)在特征维度上连结# 连结后x的形状(批量大小, 1, 嵌入维数 + 隐藏层维数)x = torch.cat((context, torch.unsqueeze(x, dim=1)), dim=-1)# permute(1, 0, 2): x变形为(1, 批量大小, 嵌入维数 + 隐藏层维数)# nn.GRU的输入数据形状为(时间长度, 批量大小,输入特征维数)# nn.GRU的输入状态形状为(隐藏层数量*方向, 批量大小, 隐藏层维数)out, hidden_state = self.rnn(x.permute(1, 0, 2), hidden_state)# out的形状:(时间长度, 批量大小, 隐藏层维数)# 向outputs中添加输出outoutputs.append(out)# 获得当前时间步的权重矩阵self._attention_weights.append(self.attention.attention_weights)# 全连接层变换后,outputs的形状为(时间长度,批量大小,词表大小)outputs = self.dense(torch.cat(outputs, dim=0))# permute(1, 0, 2): outputs的形状为(批量大小, 时间长度, 词表大小)return outputs.permute(1, 0, 2), [enc_outputs, hidden_state,enc_valid_lens]# 返回self._attention_weights@propertydef attention_weights(self):return self._attention_weights
使用包含7个时间步的4个序列输入的小批量测试Bahdanau注意力解码器
# 编码器(源词表大小:10, 嵌入维数:8, 隐藏层维数:16, 隐藏层数量:2)
encoder = d2l.Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,num_layers=2)
# 设置评估模式
encoder.eval()
# 解码器(目标词表大小:10, 嵌入维数:8, 隐藏层维数:16, 隐藏层数量:2)
decoder = Seq2SeqAttentionDecoder(vocab_size=10, embed_size=8, num_hiddens=16,num_layers=2)
# 设置评估模式
decoder.eval()
# 假设输入数据批量大小为4,每个序列长度是7
X = torch.zeros((4, 7), dtype=torch.long)
# 初始化解码器状态
state = decoder.init_state(encoder(X), None)
# 得到解码器输出数据和状态
output, state = decoder(X, state)
output.shape, len(state), state[0].shape, len(state[1]), state[1][0].shape
(torch.Size([4, 7, 10]), 3, torch.Size([4, 7, 16]), 2, torch.Size([4, 16]))
10.4.3 训练
由于新增的注意力机制,训练要比没有注意力机制慢。
# 嵌入维数=32, 隐藏层维数=32, 隐藏层数量=2, dropout=0.1(丢到10%)
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
# 批量大小=64, 时间长度=10
batch_size, num_steps = 64, 10
# 学习率=0.005. 训练轮次=250, 尝试使用GPU
lr, num_epochs, device = 0.005, 250, d2l.try_gpu()
# 加载数据集, 返回数据迭代器, 源词表和目标词表
train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
# 配置编码器
encoder = d2l.Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
decoder = Seq2SeqAttentionDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
# 配置解码器
net = d2l.EncoderDecoder(encoder, decoder)
# 训练
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
模型训练后,我们用它将几个英语句子翻译成法语并计算它们的BLEU分数。
这段代码和
9.7序列到序列学习
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):translation, dec_attention_weight_seq = d2l.predict_seq2seq(net, eng, src_vocab, tgt_vocab, num_steps, device, True)print(f'{eng} => {translation}, ',f'bleu {d2l.bleu(translation, fra, k=2):.3f}')
运行结果
go . => va !, bleu 1.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est riche ., bleu 0.658
i'm home . => je suis chez moi ., bleu 1.000
下面通过可视化注意力权重发现,每个查询都会在键值对上分配不同的权重,这说明在每个解码步中,输入序列的不同部分被选择性地聚集在注意力池中。
attention_weights = torch.cat([step[0][0][0] for step in dec_attention_weight_seq], 0).reshape((1, 1, -1, num_steps))
# reshape(1, 1, -1, 时间长度), heatmap函数接受的形状是(子图行数, 子图列数, 查询数量, 键值对数量)
# attention_weights[:, :, :, :len(engs[-1].split()) + 1], engs里的序列最长包含3个词元, 训练的时候统一长度变成了填充词元
# 现在只看有效词元的注意力权重, 所以去掉attention时间步中使用填充词元的那些位置
d2l.show_heatmaps(attention_weights[:, :, :, :len(engs[-1].split()) + 1].cpu(),xlabel='Key positions', ylabel='Query positions')
# 解释step[0][0][0] for step in dec_attention_weight_seq, 打印dec_attention_weight看看
>>> dec_attention_weight_seq
[[tensor([[[0.0552, 0.3221, 0.3183, 0.3044, 0.0000, 0.0000, 0.0000, 0.0000,0.0000, 0.0000]]], grad_fn=<SoftmaxBackward0>)], [tensor([[[0.0560, 0.1778, 0.3223, 0.4439, 0.0000, 0.0000, 0.0000, 0.0000,0.0000, 0.0000]]], grad_fn=<SoftmaxBackward0>)], [tensor([[[0.0653, 0.1696, 0.3406, 0.4245, 0.0000, 0.0000, 0.0000, 0.0000,0.0000, 0.0000]]], grad_fn=<SoftmaxBackward0>)], [tensor([[[0.0945, 0.1607, 0.3618, 0.3830, 0.0000, 0.0000, 0.0000, 0.0000,0.0000, 0.0000]]], grad_fn=<SoftmaxBackward0>)], [tensor([[[0.1411, 0.2554, 0.3011, 0.3024, 0.0000, 0.0000, 0.0000, 0.0000,0.0000, 0.0000]]], grad_fn=<SoftmaxBackward0>)], [tensor([[[0.0899, 0.3256, 0.3054, 0.2792, 0.0000, 0.0000, 0.0000, 0.0000,0.0000, 0.0000]]], grad_fn=<SoftmaxBackward0>)]]
10.4.4 小结
- 在预测词元时,如果不是所有输入词元都是相关的,那么具有Bahdanau注意力的循环神经网络编码器-解码器会有选择地统计输入序列的不同部分。这是通过将上下文变量视为加性注意力池化的输出来实现的。
- 在循环神经网络编码器-解码器中,Bahdanau注意力将上一时间步的解码器隐状态视为查询,在所有时间步的编码器隐状态同时视为键和值。