最近在学习transformer的时候我对transformer的理解不深刻就想着可不可以从代码的角度来加深我的理解。于是我在github上找到了一个使用transformer来实现机器翻译的项目,决定对这个项目进行一个详细的学习来增强我的理解,并且希望可以帮助到更多学习的人。
项目网址:GitHub - GaoChrishao/Transformer_MT: 使用transformer构建的机器翻译系统\
如下只是个人对于项目的理解,如有不同意见欢迎一起讨论学习
Transformer结构示意图
主要是对上图中的重要部分进行说明,想要有组织的查看可以去看github项目,而且本文不会对transformer的原理部分进行解读,网上资源很多,我想肯定比我说的好,我是菜鸟。
Encoder
前提假设:每个sequence_len的长度都是一样的,如果不一样也会进行填充,后面进行解释
-
Input Embedding
-
形状:(batch_size,sequence_len,embedding_len)
-
-
Positional Encoding(代码都是在类中,而且类是继承nn.Module中的,所以会出现self)
-
正余弦的方式:在奇数的位置使用cos函数生成位置编码,在偶数的位置使用sin函数生成位置编码
-
实现的方式:(生成不同的位置编码的方式不同,在这里用的是transformer论文中的)
-
生成一个全零的矩阵,其中形状是(sequence_len,embedding_len)
pe = torch.zeros(max_len, d_model, requires_grad=False)
-
生成要sin函数和cos函数所需要操作的矩阵
-
生成位置编码矩阵
# 因为这里要进行编码的是句子的长度所以在arange的时候输入的是max_len, position = torch.arange(0, max_len, dtype=torch.float,requires_grad=False).unsqueeze(1) # 这里为什么要传入的是d_model,而且步长是2呢?因为position代表的是句子的长度,而div_term是表现出编码的奇数位置还是偶数编码 div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) # position * div_term使用广播的方式拿到生成位置编码矩阵 pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) # pe是nn对象中的缓存不会在反向传播的时候更新参数 self.register_buffer('pe', pe)
-
生成输出的数据
# 注意这里需要注意的是批量 x = x + self.pe
-
-
输出形状:(batch_size,squence_len,embedding_len)
-
-
输入到模型中通过多头注意力层(默认情况下是了解注意力机制的)
-
注意力机制
-
这里的多头注意力是自注意力机制,所以要先了解自注意力机制
-
自注意力机制的优点:关注全部信息,而且可以拉近距离,其实可以看成是一个改进的全连接层
-
形状变化:输入形状:(batch_size,sequence_len,embedding_len);输出形状:(batch_size,sequence_len,embedding_len)
-
-
多头注意力:
-
多头注意力可以看成是一个“大头”注意力,因为每个头关注的特征不一样,但是因为要进行并行处理,所以要放到一个矩阵中进行计算,所以需要合成一个“大头”
-
-
多头注意力机制的实现
-
定义有几个头,这个头必须是可以被embedding_len整除的
# 这里为什么不进行定义d_v呢?因为v和头数是没有任何关系的,也就是q和v都是和特征数量响相关的 self.d_k = d_model // num_heads self.d_v = d_model // num_heads
-
计算K、Q、V,因为要保持输入和输出的形状是一样的,所以需要是方阵
# self.WQ、self.WK和self.WV中的参数就是需要学习的 self.WQ = nn.Linear(d_model, self.d_k * num_heads, bias=True) self.WK = nn.Linear(d_model, self.d_k * num_heads, bias=True) self.WV = nn.Linear(d_model, self.d_v * num_heads, bias=True) # 为什么要使用view函数来改变形状呢?这就是多头注意力的体现,因为每个头要负责不同的特征 # 那么为什么要使用transpose来改变维度的顺序呢?为改变前:(batch_size,sequence_len,num_headers,one_header_features)改变之后(batch_size,num_headers,sequence_len,one_header_features) # 原因:如果是只有一个头的时候要计算的后两个形状也是seruence_len,features只不过这里应该是一个头所负责的features Q = self.WQ(input_Q).view(batch_size, -1, self.num_heads,self.d_k).transpose(1, 2) K = self.WK(input_K).view(batch_size, -1, self.num_heads,self.d_k).transpose(1, 2) V = self.WV(input_V).view(batch_size, -1, self.num_heads,self.d_v).transpose(1, 2)
-
计算注意力分数(计算注意力分数的方式有很多中,这里还是使用transformer中的那种方式)
# (batch_size,num_headers,sequence_len,one_header_features) @ (batch_size,num_headers,one_header_features,sequence_len)=》(batch_size,num_headers,sequence_len,sequence_len) scores = torch.matmul(Q, K.transpose(-1, -2)) / (1.0 * np.sqrt(Q.size(-1))) attn = F.softmax(scores, dim=-1)
-
计算对应的V
# (batch_size,num_headers,sequence_len,sequence_len) @ (batch_size,num_headers,sequence_len,one_header_features)=》(batch_size,num_headers,sequence_len,one_header_features)=>(batch_size,sequence_len,num_headers,one_header_features)=>(batch_size,sequence_len,embedding_len) context = torch.matmul(attn, V) context = context.transpose(1, 2).reshape(batch_size, -1, self.num_heads * self.d_v) # 经过线性层之后输出 out = self.fc(context)
-
-
残差连接和前向传播
self.fc = nn.Sequential( nn.Linear(d_model, d_ff, bias=bias), nn.ReLU(), nn.Linear(d_ff, d_model, bias=bias) ) out = self.fc(x + out)
注意:在这里介绍的是一些transformer中比较重要的部分,一些比较简单的连接顺序比如pre-norm或者post-norm不进行讨论
Decoder
注意:在这里的前提还是输入的序列长度还是一样的,如何处理不一样的会在后面作为细节发表我个人的一些看法
训练
-
输入到decoder的形状就是encoder输出的形状(batch_size,sequence_len,embedding_len)
-
带有掩码的注意力层和不带掩码的注意力层之间的区别
-
首先需要知道掩码矩阵的结构(或者说是什么样子的)
attn_shape = seq.size() # 对角线以及对角线下面的都是0 mask = np.triu(np.ones(attn_shape),k=1) mask = torch.BoolTensor(mask) # 方便对每个头进行操作 mask = torch.unsqueeze(1) # 在计算完成QKV之后:这里为什么不使用0呢?个人感觉是如果都是0不“透露”一点消息的话模型的在训练之后效果不是很好 out = out.masked_fill(mask,-1e9)
-
-
输入是目标语的嵌入层编码和经过编码器之后的矩阵
-
形状:两者的形状都是:(batch_size,sequence_len,embedding_len)
-
目标语经过编码之后加入到位置编码的方式是一样的
-
-
经过带有掩码的注意力层
-
经过残差和layer norm
-
注意:上面的都是自注意力但是在这里属于编码器和解码器交互的部分使用的是交叉注意力机制,也就是解码器部分只是提供K而编码器的输出部分提供的是Q和V。但是这里的实现其实和编码部分的实现是一样的
# 编码部分的如下:其中的input_Q、input_K、input_V其实都是X(经过编码器的部分),但是在解码的时候K输入的是解码器经过嵌入之后的那部分,然后K和V都是编码器的输出部分 Q = self.WQ(input_Q).view(batch_size, -1, self.num_heads,self.d_k).transpose(1, 2) K = self.WK(input_K).view(batch_size, -1, self.num_heads,self.d_k).transpose(1, 2) V = self.WV(input_V).view(batch_size, -1, self.num_heads,self.d_v).transpose(1, 2)
-
之后的实现比较简单,这里省略
预测
训练和测试不同的地方在于,在训练的时候传入的是真实的标签,但是在预测的时候输入的是上一层的输出。因为输入的是一个句子也就是batch_size为1,所以在最后的时候就会一个词一个词的输出
注意:在这里会解决假设的部分:也就是如何使得批量中的每个句子的长度是相同的呢?---》使用掩码的方式,主要思想如下:以批量构建一个矩阵,行代表的是批量大小,列代表的是句子的长短,最长的那部分就是1,然后较短的(缺少的)部分就是0。
-
对上面实现的影响:
-
编码器部分也得引入mask,但是可以其实可以将解码器部分的用到解码器部分,也就是不用进行修改(实际是得改,但是因为在解码器的部分加入了,所以这里不用改变),掩码的位置是0
-
在进行词编码的时候要要排除0的影响
self.dec_embedding = nn.Embedding(vocab_size, d_model, padding_idx=0)
-
解码器部分的掩码改变:要将上面掩码的位置和由于长度问题引入的编码进行一个并集操作(如果全是0或者1的话就用|操作符号)
-