这一次的内容甚至可以作为一个项目了,我最终得到BLEU是22.66。
RNN和神经机器翻译
机器翻译是指,给定一个源句子(比如西班牙语),输出一个目标句子(比如英语)。本次作业中要实现的是一个带注意力机制的Seq2Seq神经模型,用于构建神经机器翻译(NMT)系统。首先我们来看NMT系统的训练过程,它用到了双向LSTM作为编码器(encoder)和单向LSTM作为解码器(decoder)。
给定长度为m的源语言句子(source),经过嵌入层,得到输入序列 $x_1, x_2, …, x_m \in R^{e \times 1}$,e是词向量大小。经过双向Encoder后,得到前向(→)和反向(←)LSTM的隐藏层和神经元状态,将两个方向的状态连接起来得到时间步 $i$ 的隐藏状态 $h_i^{enc}$ 和 $c_i^{enc}$ :
接着我们使用一个线性层来初始化Decoder的初始隐藏、神经元的状态:
Decoder的时间步 $t$ 的输入为 $\bar{y}t$ ,它由目标语言句子 $y_t$ 和上一神经元的输出 $o{t-1}$ 经过连接得到,$o_0$ 是0向量,所以 $ \bar{y}_t \in R^{(e + h) \times 1}$
接着我们使用 $h^{dec}_t$ 来计算在 $h^{enc}_0, h^{enc}_1, …, h^{enc}_m$ 的乘积注意力(multiplicative attention):
然后将注意力 $\alpha_t$ 和解码器的隐藏状态 $h^{dec}_t$ 连接,送入线性层,得到 combined-output 向量 $o_t$
这样以来,目标词的概率分布则为:
使用交叉熵做目标函数即可。
关键在于每个向量的维度,把维度搞清楚,整个过程就清晰了。
实现
在写完代码后,需要训练很久才能得到结果,这是因为翻译系统比较复杂,所以建议在GPU上跑,可以试试CoLab,而我是在实验室的TeslaP100上跑的,所以比较快。
问题(b)
model_embeddings.py的__init__函数:
self.source = nn.Embedding(len(vocab.src), embed_size, padding_idx=src_pad_token_idx)
self.target = nn.Embedding(len(vocab.tgt), embed_size, padding_idx=tgt_pad_token_idx)
注意一定要把padding_idx参数填进去
问题(c)
nmt_model.py的__init__函数:
self.encoder = nn.LSTM(embed_size, hidden_size, bias=True, bidirectional=True)
self.decoder = nn.LSTMCell(embed_size + hidden_size, hidden_size, bias=True)
self.h_projection = nn.Linear(2*hidden_size, hidden_size , bias=False)
self.c_projection = nn.Linear(2*hidden_size, hidden_size, bias=False)
self.att_projection = nn.Linear(2*hidden_size, hidden_size, bias=False)
self.combined_output_projection = nn.Linear(3*hidden_size, hidden_size, bias=False)
self.target_vocab_projection = nn.Linear(hidden_size, len(vocab.tgt), bias=False)
self.dropout = nn.Dropout(p=dropout_rate)
特别要注意各个维度的变化
原始的数据是词索引,经过embedding,每个词变成了大小为 embed_size
的向量,所以encoder的输入大小为 embed_size
,隐藏层大小为 hidden_size
。
decoder的输入是神经元输出和目标语言句子的嵌入向量,所以输入大小为 embed_size + hidden_size
h_projection、c_projection的作用是将encoder的隐藏层状态降维,所以输入大小是 2*hidden_size
,输出大小是hidden_size
att_projection的作用也是降维,以便后续与decoder的隐藏层状态做矩阵乘法
combined_output_projection的作用也将解码输出降维,输入是注意力向量和隐藏层状态连接得到的向量,大小为3*hidden_size
,并保持输出大小为 hidden_size
target_vocab_projection是将输出投影到词库的词中去
问题(d)
nmt_model.py 的encode函数:
X = self.model_embeddings.source(source_padded) # (src_len, b, e)
X = pack_padded_sequence(X, source_lengths)
enc_hiddens, (last_hidden, last_cell) = self.encoder(X) # hidden/cell = (2, b, h)
enc_hiddens = pad_packed_sequence(enc_hiddens)[0] # (src_len, b, 2*h)
enc_hiddens = enc_hiddens.permute(1, 0, 2) # (b, src_len, 2*h)
init_decoder_hidden = self.h_projection(torch.cat((last_hidden[0], last_hidden[1]), dim=1))
init_decoder_cell = self.c_projection(torch.cat((last_cell[0], last_cell[1]), dim=1))
dec_init_state = (init_decoder_hidden, init_decoder_cell) # (b, h)
再次说明,一定要注意各个向量的维度。维度搞清楚了,过程就明了了。
这里用到了 pad
和 pack
两个概念。
pad
:填充。将几个大小不一样的Tensor以最长的tensor长度为标准进行填充,一般是填充 0
。
pack
:打包。将几个 tensor打包成一个,返回一个PackedSequence
对象。
经过pack后,RNN可以对不同长度的样本数据进行小批量训练,否则就只能一个一个样本进行训练了。
torch.cat
可以将两个tensor拼接成一个
torch.Tensor.permute
可以变换矩阵的维度比如 shape=(1,2,3) -> shape=(3,2,1)
问题(e)
nmt_model.py的decode函数:
enc_hiddens_proj = self.att_projection(enc_hiddens) # (b, src_len, h)
Y = self.model_embeddings.target(target_padded) # (tgt_len, b, e)
for Y_t in torch.split(Y, 1):
Y_t = torch.squeeze(Y_t, dim=0) # (b, e)
Ybar_t = torch.cat((Y_t, o_prev), dim=1) # (b, e + h)
dec_state, o_t, e_t = self.step(Ybar_t, dec_state, enc_hiddens, enc_hiddens_proj, enc_masks)
o_prev = o_t
combined_outputs.append(o_t)
combined_outputs = torch.stack(combined_outputs, dim=0) # (tgt_len, b, h)
还是强调一句,一定要注意向量的维度。
torch.stack
可以将一个list里的长度一样的tensor堆叠成一个tensor
torch.squeeze
可以将tensor里大小为1的维度给删掉,比如shape=(1,2,3) -> shape=(2,3)
问题(f)
nmt_model.py的step函数:
首先计算注意力系数
dec_state = self.decoder(Ybar_t, dec_state)
dec_hidden, dec_cell = dec_state # (b, h)
e_t = torch.bmm(enc_hiddens_proj, torch.unsqueeze(dec_hidden, dim=2)) # (b, src_len, 1)
e_t = torch.squeeze(e_t, dim=2) # (b, src_len)
接着计算注意力和combined output,并输出
alpha_t = torch.unsqueeze(F.softmax(e_t, dim=1), dim=1)
a_t = torch.squeeze(torch.bmm(alpha_t, enc_hiddens), dim=1) # (b, 2*h)
u_t = torch.cat((a_t, dec_hidden), dim=1) # (b, 3*h)
v_t = self.combined_output_projection(u_t) # (b, h)
O_t = self.dropout(torch.tanh(v_t))
一定要使用 torch.bmm
对小批量 数据进行矩阵乘法运算,不能用通常意义上的矩阵乘法
至此所有代码都写完了,运行
python sanity_check.py 1d
python sanity_check.py 1e
python sanity_check.py 1f
进行初步测试
完成后运行下述代码进行冒烟测试:
sh run.sh train_local
经过10或20次迭代后程序没崩,那就可以正式进行测试了:
sh run.sh train
参考资料
[1] CS224n: Natural Language Processing with Deep Learning, 2019-03-21.