Transformer 详解(上) — 编码器【附pytorch代码实现】

Transformer结构

论文: Attention is All You Need
Transformer模型是2017年Google公司在论文《Attention is All You Need》中提出的。自提出伊始,该模型便在NLP和CV界大杀四方,多次达到SOTA效果。2018年,Google公司再次发布论文《Pre-training of Deep Bidirectional Transformers for Language Understanding》,在Transformer的基础上,提出了当红炸子鸡BERT模型,成功在多项NLP任务中取得领先的结果。
所以,学习Transformer模型也是以后能够掌握BERT模型的前提,正是怀着这一目的,我展开了一段时间对Transformer模型的学习,务必在细微处也通透了解,达到知其然还能编其然的效果。写此博客,最重要是为了理清知识脉络,达到知识巩固的效果。
本文是对近期关于Transformer论文、相关文章、代码进行学习后的知识梳理,仅为自己学习交流之用。因笔者精力有限,如果文中因引用了某些文章观点未标出处还望作者海涵,也希望各位一起学习的读者对文中不恰当的地方进行批评指正。
Transformer模型结构

该图引用于论文《Attention is All You Need》

上图是Transformer模型的结构图。从图中,我们可以看出模型宏观上可分为两个大模块。
一个是编码器,一个是解码器
这有点类似于NLP中的seq2seq结构。对于seq2seq结构的学习,大家可以点击这里
在编码器之前,输入数据又经过了词嵌入和位置编码操作,这些都是非常有必要的。

文本嵌入层

文本向量表示一般有三种方法:

  1. one-hot编码
  2. 词汇映射 (Word2Vec)
  3. Word Embedding(广义上Word2Vec也属于Word Embedding的一种)

词嵌入(Word Embedding)是单词的一种数值化表示方式,
一般情况下会将一个单词映射到一个高维的向量中(词向量)来代表这个单词。
文本嵌入层的作用就是,将文本中词汇的数字表示转变为高维的向量表示。旨在高维空间捕捉词汇间的关系。

class Embedding(nn.Module):
    def __init__(self,vocab,embe_dim):
        '''
        :param vocab:  词表的大小
        :param embe_dim: 词嵌入的维度
        '''
        super(Embedding,self).__init__()
        self.lut = nn.Embedding(vocab,embe_dim,padding_idx=0)
        self.embe_dim= embe_dim

    def forward(self,x):
        '''
        :param x: 输入给模型的文本通过词汇映射后的张量
        :return:
        '''
        return self.lut(x) * math.sqrt(self.d_model)

位置编码

数据经过文本嵌入层就会流向位置编码层。

Transformer 和 LSTM 的最大区别,就是 LSTM 的训练是迭代的、串行的,必须要等当前字处理完,才可以处理下一个字。而Transformer 的训练时并行的,即所有字是同时训练的,这样就大大增加了计算效率。Transformer 使用了位置嵌入(Positional Encoding) 来理解语言的顺序,使用自注意力机制(Self Attention Mechanism)和全连接层进行计算。

Transformer结构中没有针对词汇位置信息的处理,因此需要在Embedding最后加入位置编码器。
将词汇位置不同可能会产生不同语义的信息加入到词嵌入张量中,以弥补位置信息的缺失。

class PositionalEnconding(nn.Module):
    def __init__(self,d_model,dropout,max_len=5000):
        '''

        :param d_model: 词嵌入维度
        :param dropout: 丢失率
        :param max_len: 每个句子的最长长度
        '''
        super(PositionalEnconding,self).__init__()
        # 实例化dropout层
        self.dpot = nn.Dropout(p=dropout)
        # 初始化位置编码矩阵
        pe = torch.zeros(max_len,d_model)
        # 初始化绝对位置矩阵
        # position矩阵size为(max_len,1)
        position = torch.arange(0,max_len).unsqueeze(1)
        # 将绝对位置矩阵和位置编码矩阵特征融合
        # 定义一个变换矩阵 跳跃式初始化
        div_term = torch.exp(torch.arange(0,d_model,2) * -(math.log(10000)/d_model))
        pe[:,0::2] = torch.sin(position * div_term)
        pe[:,1::2] = torch.cos(position * div_term)
        # 将二维张量扩充成三维张量
        pe = pe.unsqueeze((0))
        # 把pe位置编码矩阵注册成模型的buffer
        # 模型保存后重加载时和模型结构与参数一同被加载
        self.register_buffer('pe',pe)

    def forward(self,x):
        '''
        :param x: 文本的词嵌入表示
        :return:
        '''
        x = x + Variable(self.pe[:,:x.size(1)],requires_grad=False)
        return self.dpot(x)

注意力机制

我们观察事物时,大脑很快把注意力放在事物最具有辨识度的部分从而作出判断。
并非从头到尾的观察一遍事物后,才能有判断结果 。
正是基于这样的理论,产生了注意力机制。
注意力机制是,注意力计算规则能够应用的深度学习网络的载体。

'''
    掩码张量
    掩码张量一般只有1和0元素,代表位置被遮掩或者不被遮掩
    在transformer中,掩码张量的作用在应用attention时有一些生成的attention变量中的值有可能已知了未来信息而得到的
    未来信息被看到是因为训练时会把整个输出结果都一次性进行Embedding
    但是理论上解码器的输出不是一次就能产生最终结果,而是一次次通过上一次结果综合得出的
    因此,未来信息可能被提前利用
'''
def subsequent_mask(size):
    atten_shape = (1,size,size)
    # 对角线下就是负 对角线上就是正 对角线就是0
    mask = np.triu(np.ones(atten_shape),k=1).astype('uint8')
    return torch.from_numpy(1-mask)
    
def attention(query,key,value,mask=None,dropout=None):
    '''

    :param query:
    :param key:
    :param value:
    :param mask:  掩码张量
    :param dropout:
    :return: query在key和value作用下的表示
    '''
    # 获取query的最后一维的大小,一般情况下就等同于我们的词嵌入维度
    d_k = query.size(-1)

    # 按照注意力公式,将query与key转置相乘,这里面key是将最后两个维度进行转置,再除以缩放系数
    # 得到注意力的得分张量score
    score = torch.matmul(query,key.transpose(-2,-1))/math.sqrt(d_k)
    if mask is not None:
        # 使用tensor的masked_fill方法,将掩码张量和scores张量每个位置一一比较
        # 如果掩码张量处为0 则对应的score张量用-1e9替换
        score = score.masked_fill(mask==0,-1e9)

    p_atten= F.softmax(score,dim=-1)

    if dropout is not None:
        p_atten = dropout(p_atten)
    # 返回注意力表示
    return torch.matmul(p_atten,value.float()),p_atten

编码器之多头注意力机制层

数据从位置编码输出,就作为编码器的输入部分。

每个编码器部分由n个编码器层堆叠而成
每个编码器层由两个子层连接结构组成
第一个子层连接结构包括一个多头注意力子层和规范化层以及一个残差连接
第二个子层连接结构包括一个前馈全连接子层和规范化层以及一个残差连接

所谓的多头,就是使用一组线性变化层对Q,K,V分别进行线性变换。
这些变换不会改变原有张量的尺寸,因此每个变换矩阵都是方阵。
每个头从词义层面分割输出张量 也就是每个头都想获得一组Q,K,V。
是句子中的每个词的表示只获得一部分,也就是只分割了最后一维的词嵌入向量。
把每个头的获得的输入送到注意力机制中,就形成多头注意力机制。

def clones(module,N):
    '''
    生成相同的网络层的克隆函数
    :param module:  目标网络层
    :param N: 克隆数量
    :return:
    '''
    return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])

class MultiHeadAtten(nn.Module):
    def __init__(self,head,embedding_dim,dropout=0.1):
        '''

        :param head: 头数
        :param embedding_dim: 词嵌入维度
        :param dropout:
        '''
        super(MultiHeadAtten,self).__init__()

        # 在函数中,首先使用了一个测试中常用的assert语句,判断h是否能被此嵌入维度整除
        # 因为要给每个头分配等量的词特征
        assert embedding_dim % head == 0

        # 得到每个头获得的分割词向量维度d_K
        self.d_k = embedding_dim // head
        # 获得头数
        self.head = head
        # 克隆四个全连接层对象,通过nn的Linear实例化
        self.linears = clones(nn.Linear(embedding_dim,embedding_dim),4)
        # self.attn为None,它代表最后得到的注意力张量,现在还没有结果所以未None
        self.attn = None
        # 最后就是一个self.dropout对象
        self.dpot = nn.Dropout(p=dropout)

    def forward(self,query,key,value,mask=None):
        # 前向逻辑函数
        if mask is not None:
            # 扩展维度 代表对头中的第i个头
            mask = mask.unsqueeze(1)
        # 获取样本数
        batch_size = query.size(0)

        # 之后就进入多头处理环节
        # 首先利用zip将输入QKV与三个全连接层组到一起,然后使用for循环,将输入QKV分别传到线性层中,
        # 做完线性变换后,开始为每个头风格输入,使用view方法对线性变换的结果进行维度重塑,
        # 这样就意味着每个头可以获得一部分词特征组成的句子,其中的-1代表自适应维度
        # 计算机会根据这种变换自动计算这里的值,然后对第二维和第三维进行转置操作
        # lis = [query,key,value]
        # r = []
        # for step,model in enumerate(self.linears):
        #     r.append(model(lis[step]))
        query,key,value = [model(x).view(batch_size,-1,self.head,self.d_k).transpose(1,2) for model,x in zip(self.linears,(query,key,value))]
        # 得到每个头的输入后,接下来就是将他们传入到attention中,
        # 这里直接attention函数
        x,self.attn = attention(query,key,value,mask=mask,dropout=self.dpot)

        # 通过多头注意力计算后,我们就得到了每个头计算结果组成的四维张量,我们需要将其转换为输入的形式
        # 对 第2,3维进行转置 然后使用contiguous方法
        # 这个方法的作用就是能够让转置后的张量应用view方法
        # contiguous()这个函数,把tensor变成在内存中连续分布的形式
        x = x.transpose(1,2).contiguous().view(batch_size,-1,self.head*self.d_k)

        # 使用线性层列表中的最后一个线性层对输入进行线性变
        return self.linears[-1](x)

编码器之前馈全连接层

前馈全连接层:两层全连接层
作用:考虑注意力机制可能对复杂过程的拟合程度不够,通过增加两层网络来增强模型的能力

class FeedForward(nn.Module):
    def __init__(self,d_model,d_ff,dropout=0.1):
        '''
        :param d_model: 线性层输入维度
        :param d_ff: 线性层输出维度
        :param dropout:
        '''
        super(FeedForward, self).__init__()
        self.w1 = nn.Linear(d_model,d_ff)
        self.w2 = nn.Linear(d_ff,d_model)
        self.dpot = nn.Dropout(dropout)

    def forward(self,x):
        return self.w2(self.dpot(F.relu(self.w1(x))))

规范化层和残差连接

随着网络层数的增加,通过多层的计算后参数可能开始出现过大或过小的情况
这样可能导致学习过程出现异常,模型收敛过慢
因此添加规范化层进行数值的规范化,使其特征数值在合理范围内

class LayerNorm(nn.Module):
    def __init__(self,features,eps=1e-6):
        '''
        :param features:  代表词嵌入的维度
        :param eps:
        '''
        super(LayerNorm, self).__init__()
        self.a2 = nn.Parameter(torch.ones(features))
        self.b2 = nn.Parameter(torch.zeros(features))
        # 防止分母为0
        self.eps = eps

    def forward(self,x):
        # 对输入变量x求其最后一个维度的均值,并保持输出维度与输入维度一致
        mean = x.mean(-1,keepdim=True)
        # 接着再求最后一个维度的标准差
        std = x.std(-1,keepdim=True)
        # 然后就是根据规范化公式,用x减去均值除以标准差获得规范化的结果
        # 最后对结果乘以我们的缩放参数,即a2,*号代表同型点乘,即对应位置进行乘法操作,加上位移参数
        return self.a2 * (x-mean) /(std + self.eps) + self.b2

残差连接实现

class ResConnect(nn.Module):
    def __init__(self,size,dropout=0.1):
        # size:  词嵌入维度的大小
        super(ResConnect, self).__init__()
        # 实例化规范化对象
        self.norm = LayerNorm(size)
        self.dpot = nn.Dropout(p=dropout)

    def forward(self,x,sublayer):
        '''
        接受上一个层或者子层的输入作为第一个参数
        将该子层连接中的子层函数作为第二个参数
        :param x:
        :param sublayer:
        :return:
        '''
        # 首先对输出进行规范化,然后将结果传给子层处理,之后再对子层进行dropout操作
        # 随机停止一些网络中神经元的作用 防止过拟合
        # 残差连接
        return x + self.dpot(sublayer(self.norm(x)))

代码实现Transformer

Github代码:Transformer-PyTorch

import torch.nn as nn
import torch
import numpy as np
import torch.nn.functional as F
from torch.autograd import Variable
import copy
import math

# 1.文本嵌入层
class TextEmbedding(nn.Module):
    def __init__(self,vocab,d_model):
        '''

        :param vocab:  词表的大小
        :param d_model: 词嵌入的维度
        '''
        super(TextEmbedding,self).__init__()
        self.lut = nn.Embedding(vocab,d_model,padding_idx=0)
        self.d_model = d_model

    def forward(self,x):
        '''

        :param x: 输入给模型的文本通过词汇映射后的张量
        :return:
        '''
        return self.lut(x) * math.sqrt(self.d_model)

# 2.位置编码层
class PositionalEnconding(nn.Module):
    def __init__(self,d_model,dropout,max_len=5000):
        '''

        :param d_model: 词嵌入维度
        :param dropout: 丢失率
        :param max_len: 每个句子的最长长度
        '''
        super(PositionalEnconding,self).__init__()
        # 实例化dropout层
        self.dpot = nn.Dropout(p=dropout)

        # 初始化位置编码矩阵
        pe = torch.zeros(max_len,d_model)

        # 初始化绝对位置矩阵
        # position矩阵size为(max_len,1)
        position = torch.arange(0,max_len).unsqueeze(1)

        # 将绝对位置矩阵和位置编码矩阵特征融合
        # 定义一个变换矩阵 跳跃式初始化
        div_term = torch.exp(torch.arange(0,d_model,2) * -(math.log(10000)/d_model))
        pe[:,0::2] = torch.sin(position * div_term)
        pe[:,1::2] = torch.cos(position * div_term)

        # 将二维张量扩充成三维张量
        pe = pe.unsqueeze((0))

        # 把pe位置编码矩阵注册成模型的buffer
        # 模型保存后重加载时和模型结构与参数一同被加载
        self.register_buffer('pe',pe)

    def forward(self,x):
        '''

        :param x: 文本的词嵌入表示
        :return:
        '''

        x = x + Variable(self.pe[:,:x.size(1)],requires_grad=False)
        return self.dpot(x)

# 3.注意力机制
def subsequent_mask(size):

    atten_shape = (1,size,size)
    # 对角线下就是负 对角线上就是正 对角线就是0
    mask = np.triu(np.ones(atten_shape),k=1).astype('uint8')
    return torch.from_numpy(1-mask)

def attention(query,key,value,mask=None,dropout=None):
    '''

    :param query:
    :param key:
    :param value:
    :param mask:  掩码张量
    :param dropout:
    :return: query在key和value作用下的表示
    '''
    # 获取query的最后一维的大小,一般情况下就等同于我们的词嵌入维度
    d_k = query.size(-1)

    # 按照注意力公式,将query与key转置相乘,这里面key是将最后两个维度进行转置,再除以缩放系数
    # 得到注意力的得分张量score
    score = torch.matmul(query,key.transpose(-2,-1))/math.sqrt(d_k)
    if mask is not None:
        # 使用tensor的masked_fill方法,将掩码张量和scores张量每个位置一一比较
        # 如果掩码张量处为0 则对应的score张量用-1e9替换
        score = score.masked_fill(mask==0,-1e9)

    p_atten= F.softmax(score,dim=-1)

    if dropout is not None:
        p_atten = dropout(p_atten)
    # 返回注意力表示
    return torch.matmul(p_atten,value.float()),p_atten

# 4.多头注意力机制
def clones(module,N):
    '''
    生成相同的网络层的克隆函数
    :param module:  目标网络层
    :param N: 克隆数量
    :return:
    '''
    return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])

class MultiHeadAtten(nn.Module):
    def __init__(self,head,embedding_dim,dropout=0.1):
        '''

        :param head: 头数
        :param embedding_dim: 词嵌入维度
        :param dropout:
        '''
        super(MultiHeadAtten,self).__init__()

        # 在函数中,首先使用了一个测试中常用的assert语句,判断h是否能被此嵌入维度整除
        # 因为要给每个头分配等量的词特征
        assert embedding_dim % head == 0

        # 得到每个头获得的分割词向量维度d_K
        self.d_k = embedding_dim // head
        # 获得头数
        self.head = head
        # 克隆四个全连接层对象,通过nn的Linear实例化
        self.linears = clones(nn.Linear(embedding_dim,embedding_dim),4)
        # self.attn为None,它代表最后得到的注意力张量,现在还没有结果所以未None
        self.attn = None
        # 最后就是一个self.dropout对象
        self.dpot = nn.Dropout(p=dropout)

    def forward(self,query,key,value,mask=None):
        # 前向逻辑函数
        if mask is not None:
            # 扩展维度 代表对头中的第i个头
            mask = mask.unsqueeze(1)
        # 获取样本数
        batch_size = query.size(0)

        # 之后就进入多头处理环节
        # 首先利用zip将输入QKV与三个全连接层组到一起,然后使用for循环,将输入QKV分别传到线性层中,
        # 做完线性变换后,开始为每个头风格输入,使用view方法对线性变换的结果进行维度重塑,
        # 这样就意味着每个头可以获得一部分词特征组成的句子,其中的-1代表自适应维度
        # 计算机会根据这种变换自动计算这里的值,然后对第二维和第三维进行转置操作
        # lis = [query,key,value]
        # r = []
        # for step,model in enumerate(self.linears):
        #     r.append(model(lis[step]))
        query,key,value = [model(x).view(batch_size,-1,self.head,self.d_k).transpose(1,2) for model,x in zip(self.linears,(query,key,value))]
        # 得到每个头的输入后,接下来就是将他们传入到attention中,
        # 这里直接attention函数
        x,self.attn = attention(query,key,value,mask=mask,dropout=self.dpot)

        # 通过多头注意力计算后,我们就得到了每个头计算结果组成的四维张量,我们需要将其转换为输入的形式
        # 对 第2,3维进行转置 然后使用contiguous方法
        # 这个方法的作用就是能够让转置后的张量应用view方法
        # contiguous()这个函数,把tensor变成在内存中连续分布的形式
        x = x.transpose(1,2).contiguous().view(batch_size,-1,self.head*self.d_k)

        # 使用线性层列表中的最后一个线性层对输入进行线性变
        return self.linears[-1](x)

# 5.前馈全连接层
class FeedForward(nn.Module):
    def __init__(self,d_model,d_ff,dropout=0.1):
        '''

        :param d_model: 线性层输入维度
        :param d_ff: 线性层输出维度
        :param dropout:
        '''
        super(FeedForward, self).__init__()
        self.w1 = nn.Linear(d_model,d_ff)
        self.w2 = nn.Linear(d_ff,d_model)
        self.dpot = nn.Dropout(dropout)

    def forward(self,x):
        return self.w2(self.dpot(F.relu(self.w1(x))))

# 6.规范化层
class LayerNorm(nn.Module):
    def __init__(self,features,eps=1e-6):
        '''

        :param features:  代表词嵌入的维度
        :param eps:
        '''
        super(LayerNorm, self).__init__()
        self.a2 = nn.Parameter(torch.ones(features))
        self.b2 = nn.Parameter(torch.zeros(features))
        # 防止分母为0
        self.eps = eps

    def forward(self,x):
        # 对输入变量x求其最后一个维度的均值,并保持输出维度与输入维度一致
        mean = x.mean(-1,keepdim=True)
        # 接着再求最后一个维度的标准差
        std = x.std(-1,keepdim=True)
        # 然后就是根据规范化公式,用x减去均值除以标准差获得规范化的结果
        # 最后对结果乘以我们的缩放参数,即a2,*号代表同型点乘,即对应位置进行乘法操作,加上位移参数
        return self.a2 * (x-mean) /(std + self.eps) + self.b2

# 7.子层连接结构
class SublayerConnection(nn.Module):
    def __init__(self,size,dropout=0.1):
        # size:  词嵌入维度的大小
        super(SublayerConnection, self).__init__()
        # 实例化规范化对象
        self.norm = LayerNorm(size)
        self.dpot = nn.Dropout(p=dropout)

    def forward(self,x,sublayer):
        '''
        接受上一个层或者子层的输入作为第一个参数
        将该子层连接中的子层函数作为第二个参数
        :param x:
        :param sublayer:
        :return:
        '''
        # 首先对输出进行规范化,然后将结果传给子层处理,之后再对子层进行dropout操作
        # 随机停止一些网络中神经元的作用 防止过拟合
        # 残差连接
        return x + self.dpot(sublayer(self.norm(x)))


if __name__ == '__main__':
    vocab = 500
    d_model = 512
    dropout = 0.2
    inputs = torch.randint(low=0,high=100,size=(5,10),dtype=torch.long)
    # 实例化文本嵌入层对象
    TE = TextEmbedding(vocab,d_model)
    # 实例化位置编码层
    PE = PositionalEnconding(d_model,dropout,max_len=10)
    TER = TE(inputs)
    print(TER.shape)
    PER = PE(TER)
    print(PER.shape)
    # 实例化多头注意力机制
    head = 8
    MHA = MultiHeadAtten(head=head,embedding_dim=d_model,dropout=dropout)
    # MHAR = MHA(PER,PER,PER)
    # print(MHAR.shape)
    # 实例化规范化层
    # LN = LayerNorm(d_model)
    # LNR = LN(MHAR)
    # print(LNR.shape)
    MHAR = lambda x:MHA(x,x,x)
    # 实例化 子层连接结构
    SLC1 = SublayerConnection(d_model,dropout=dropout)
    SLC1R = SLC1(PER,MHAR)
    print(SLC1R.shape)
    # 实例化 前馈全连接层
    FF = FeedForward(d_model,1024,dropout=dropout)
    SLC2 = SublayerConnection(d_model,dropout=dropout)
    SLC2R = SLC2(SLC1R,FF)
    print(SLC2R.shape)

参考文献

1. Transformer 详解
2. 经典Seq2Seq与注意力Seq2Seq模型结构详解
3. 一文读懂BERT(原理篇)
4. 嵌入层(Embedding Layer)与词向量(Word Embedding)详解

  • 14
    点赞
  • 78
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值