Transformer解读(附pytorch代码)

Transformer早在2017年就出现了,直到BERT问世,Transformer开始在NLP大放光彩,目前比较好的推进就是Transformer-XL(后期附上)。这里主要针对论文和程序进行解读,如有不详实之处,欢迎指出交流,如需了解更多细节之处,推荐知乎上川陀学者写的。本文程序的git地址在这里。程序如果有不详实之处,欢迎指出交流~

前言

2017年6月,Google发布了一篇论文《Attention is All You Need》,在这篇论文中,提出了 Transformer 的模型,其旨在全部利用Attention方式来替代掉RNN的循环机制,从而通过实现并行化计算提速。在Transformer出现之前,RNN系列网络以及seq2seq+attention架构基本上铸就了所有NLP任务的铁桶江山。由于Attention模型本身就可以看到全局的信息, Transformer实现了完全不依赖于RNN结构仅利用Attention机制,在其并行性和对全局信息的有效处理上获得了比之前更好的效果。

Transformer的整体结构

图1:Transformer整体结构

Transformer的整体结构就是分成编码器和解码器两部分,并且两部分之间是有联系的,可以注意到编码器的输出是解码器第二个Multi-head Attention中和的输入,这里,我们把编码器的输出称为state用来初始化解码器的状态,而实际上对于解码器而言,每一层的解码器的state是一样的(都是编码器的输出),并不会像RNN中的state一样改变。对应的pytorch程序如下:

class transformer(nn.Module):
    def __init__(self, enc_net, dec_net):
        super(transformer, self).__init__()
        self.enc_net = enc_net   # TransformerEncoder的对象   
        self.dec_net = dec_net   # TransformerDecoder的对象
    
    def forward(self, enc_X, dec_X, valid_length=None, max_seq_len=None):
        """
        enc_X: 编码器的输入
        dec_X: 解码器的输入
        valid_length: 编码器的输入对应的valid_length,主要用于编码器attention的masksoftmax中,
                      并且还用于解码器的第二个attention的masksoftmax中
        max_seq_len:  位置编码时调整sin和cos周期大小的,默认大小为enc_X的第一个维度seq_len
        """
        
        # 1、通过编码器得到编码器最后一层的输出enc_output
        enc_output = self.enc_net(enc_X, valid_length, max_seq_len)
        # 2、state为解码器的初始状态,state包含两个元素,分别为[enc_output, valid_length]
        state = self.dec_net.init_state(enc_output, valid_length)
        # 3、通过解码器得到编码器最后一层到线性层的输出output,这里的output不是解码器最后一层的输出,而是
        #    最后一层再连接线性层的输出
        output = self.dec_net(dec_X, state)
        return output

纵观图1整个Transformer的结构,其核心模块其实就是三个:Multi-Head attention、Feed Forward 以及 Add&Norm。这里关于Multi-Head attention部分只讲程序的实现,关于更多细节原理,请移至简书开头推荐的知乎链接。

Multi-Head Attention实现

Transformer中的attention采用的是多头的self-attention结构,并且在编码器中,由于不同的输入mask的部分不一样,因此在softmax之前采用了mask操作,并且解码时由于不能看到t时刻之后的数据,同样在解码器的第一个Multi-Head attention中采用了mask操作,但是二者是不同的。因为编码器被mask的部分是需要在输入到Transformer之前事先确定好,而解码器第一个Multi-Head attention被mask的部分其实就是从t=1时刻开始一直到t=seq_len结束,对应于图2。在图2中,横坐标表示解码器一个batch上的输入序列长度(也就是t),紫色部分为被mask的部分,黄色部分为未被mask的部分,可以看出,随着t的增加,被mask的部分逐一减少。而解码器第二个Multi-Head attention的mask操作和编码器中是一样的。


图2:解码器第一个Multi-Head attention中的mask操作

mask+softmax程序如下:

def masked_softmax(X, valid_length, value=-1e6):
    # 如果valid_length是一维的:valid_length的维度等于batch_size的大小
    # 对每一个batch去确定一个valid_length,因此valid_length的维度与batch_size大小相同
    # 再将valid_length内的元素通过repeat操作将valid_length内的元素repeat seq_len(X.size()[1])次
    # 结果就是对每一个batch上的X根据valid_length输出相应的attention weights,因此一个batch上的attention weights是一样的

    # 如果valid_length是二维的:valid_length的维度等于[batch_size, seq_length]
    # 此时是针对每一个batch的每一句话都设置了seq_length
    if valid_length is None:
        return F.softmax(X, dim=-1)
    else:
        X_size = X.size()
        device = valid_length.device
        if valid_length.dim() == 1:
            valid_length = torch.tensor(valid_length.cpu().numpy().repeat(X_size[1], axis=0),
                                        dtype=torch.float, device=device) if valid_length.is_cuda \
                else torch.tensor(valid_length.numpy().repeat(X_size[1], axis=0),
                                  dtype=torch.float, device=device)
        else:
            valid_length = valid_length.view([-1])
        X = X.view([-1, X_size[-1]])
        max_seq_length = X_size[-1]
        valid_length = valid_length.to(torch.device('cpu'))
        mask = torch.arange(max_seq_length, dtype=torch.float)[None, :] >= valid_length[:, None]
        X[mask] = value
        X = X.view(X_size)
        return F.softmax(X, dim=-1)

mask操作其实就是对于无效的输入,用一个负无穷的值代替这个输入,这样在softmax的时候其值就是0。而在attention中(attention操作见下式),softmax的操作出来的结果其实就是attention weights,当attention weights为0时,表示不需要attention该位置的信息。
softmax(\frac{QK^{T}}{\sqrt{d}})V
对于Multi-Head attention的实现,其实并没有像论文原文写的那样,逐一实现多个attention,再将最后的结果concat,并且通过一个输出权重输出。下面通过程序和公式讲解一下实际的实现过程,这里假设Q,K,V的来源是一样的,都是X,其维度为[batch_size, seq_len, input_size]。(需要注意的是在解码器中第二个Multi-Head的输入中QKV的来源不一样)

图3:论文原文中的attention操作

class DotProductAttention(nn.Module):
    # 经过DotProductAttention之后,输入输出的维度是不变的,都是[batch_size*h, seq_len, d_model//h]
    def __init__(self, dropout,):
        super(DotProductAttention, self).__init__()
        self.drop = nn.Dropout(dropout)

    def forward(self, Q, K, V, valid_length):
        # Q, K, V shape:[batch_size*h, seq_len, d_model//h]
        d_model = Q.size()[-1]  # int
        # torch.bmm表示批次之间(>2维)的矩阵相乘
        attention_scores = torch.bmm(Q, K.transpose(1, 2))/math.sqrt(d_model)
        # attention_scores shape: [batch_size*h, seq_len, seq_len]
        attention_weights = self.drop(masked_softmax(attention_scores, valid_length))
        return torch.bmm(attention_weights, V)  # [batch_size*h, seq_len, d_model//h]
class MultiHeadAttention(nn.Module):
    def __init__(self, input_size, hidden_size, num_heads, dropout,):
        super(MultiHeadAttention, self).__init__()
        # 保证MultiHeadAttention的输入输出tensor的维度一样
        assert hidden_size % num_heads == 0
        # hidden_size => d_model
        self.num_heads = num_heads
        # num_heads => h
        self.hidden_size = hidden_size
        # 这里的d_model为中间隐层单元的神经元数目,d_model=h*d_v=h*d_k=h*d_q
        self.Wq = nn.Linear(input_size, hidden_size, bias=False)
        self.Wk = nn.Linear(input_size, hidden_size, bias=False)
        self.Wv = nn.Linear(input_size, hidden_size, bias=False)
        self.Wo = nn.Linear(hidden_size, hidden_size, bias=False)
        self.attention = DotProductAttention(dropout)

    def _transpose_qkv(self, X):
        # X的输入维度为[batch_size, seq_len, d_model]
        # 通过该函数将X的维度改变成[batch_size*num_heads, seq_len, d_model//num_heads]
        self._batch, self._seq_len = X.size()[0], X.size()[1]
        X = X.view([self._batch, self._seq_len, self.num_heads, self.hidden_size//self.num_heads])  # [batch_size, seq_len, num_heads, d_model//num_heads]
        X = X.permute([0, 2, 1, 3])  # [batch_size, num_heads, seq_len, d_model//num_heads]
        return X.contiguous().view([self._batch*self.num_heads, self._seq_len, self.hidden_size//self.num_heads])

    def _transpose_output(self, X):
        X = X.view([self._batch, self.num_heads, -1, self.hidden_size//self.num_heads])
        X = X.permute([0, 2, 1, 3])
        return X.contiguous().view([self._batch, -1, self.hidden_size])

    def forward(self, query, key, value, valid_length):
        Q = self._transpose_qkv(self.Wq(query))
        K = self._transpose_qkv(self.Wk(key))
        V = self._transpose_qkv(self.Wv(value))
        # 由于输入的valid_length是相对batch输入的,而经过_transpose_qkv之后,
        # batch的大小发生了改变,Q的第一维度由原来的batch改为batch*num_heads
        # 因此,需要对valid_length进行复制,也就是进行np.title的操作
        if valid_length is not None:
            device = valid_length.device
            valid_length = valid_length.cpu().numpy() if valid_length.is_cuda else valid_length.numpy()
            if valid_length.ndim == 1:
                valid_length = np.tile(valid_length, self.num_heads)
            else:
                valid_length = np.tile(valid_length, [self.num_heads, 1])
            valid_length = torch.tensor(valid_length, dtype=torch.float, device=device)
        output = self.attention(Q, K, V, valid_length)
        output_concat = self._transpose_output(output)
        return self.Wo(output_concat)

首先,对于输入X,通过三个权重变量得到Q,K,V,此时三者维度相同,都是[batch, seq_len, d_model],然后对其进行维度变换:[batch, seq_len, h, d_model//h]==>[batch, h, seq_len, d]==>[batch×h, seq_len, d],其中d=d_model//h,因此直接将变换后的Q,K,V直接做DotProductAttention就可以实现Multi-Head attention,最后只需要将DotProductAttention输出的维度依次变换回去,然后乘以输出权重就可以了。关于程序中的参数valid_length已在程序中做了详细的解读,这里不再赘述,注意的是输入的valid_length是针对batch这个维度的,而实际操作中由于X的batch维度发生了改变(由batch变成了batch×h),因此需要对valid_length进行复制。

PositionWiseFFN的实现

FFN的实现是很容易的,其实就是对输入进行第一个线性变换,其输出加上ReLU激活函数,然后在进行第二个线性变换就可以了。

class PositionWiseFFN(nn.Module):
    # y = w*[max(0, wx+b)]x+b
    def __init__(self, input_size, fft_hidden_size, output_size,):
        super(PositionWiseFFN, self).__init__()
        self.FFN1 = nn.Linear(input_size, fft_hidden_size)
        self.FFN2 = nn.Linear(fft_hidden_size, output_size)

    def forward(self, X):
        return self.FFN2(F.relu(self.FFN1(X)))

Add&Norm的实现

Add&norm的实现就是利用残差网络进行连接,最后将连接的结果接上LN,值得注意的是,程序在Y的输出中加入了dropout正则化。同样的正则化技术还出现在masked softmax之后和positional encoding之后。

class AddNorm(nn.Module):
    def __init__(self, hidden_size, dropout,):
        super(AddNorm, self).__init__()
        self.drop = nn.Dropout(dropout)
        self.LN = nn.LayerNorm(hidden_size)

    def forward(self, X, Y):
        assert X.size() == Y.size()
        return self.LN(self.drop(Y) + X)

positional encoding

positional encoding的实现很简单,其实就是对输入序列给定一个唯一的位置,采用sin和cos的方式给了一个位置编码,其中sin处理的是偶数位置,cos处理的是奇数位置。但是,这一块的工作确实非常重要的,因为对于序列而言最主要的就是位置信息,显然BERT是没有去采用positional encoding(尽管在BERT的论文里有一个Position Embeddings的输入,但是显然描述的不是Transformer中要描述的位置信息),后续BERT在这一方面的改进工作体现在了XLNet中(其采用了Transformer-XL的结构),后续的简书中再介绍该部分的内容。

class PositionalEncoding(nn.Module):
    def __init__(self, dropout,):
        super(PositionalEncoding, self).__init__()

    def forward(self, X, max_seq_len=None):
        if max_seq_len is None:
            max_seq_len = X.size()[1]
        # X为wordEmbedding的输入,PositionalEncoding与batch没有关系
        # max_seq_len越大,sin()或者cos()的周期越小,同样维度
        # 的X,针对不同的max_seq_len就可以得到不同的positionalEncoding
        assert X.size()[1] <= max_seq_len
        # X的维度为: [batch_size, seq_len, embed_size]
        # 其中: seq_len = l, embed_size = d
        l, d = X.size()[1], X.size()[-1]
        # P_{i,2j}   = sin(i/10000^{2j/d})
        # P_{i,2j+1} = cos(i/10000^{2j/d})
        # for i=0,1,...,l-1 and j=0,1,2,...,[(d-2)/2]
        max_seq_len = int((max_seq_len//l)*l)
        P = np.zeros([1, l, d])
        # T = i/10000^{2j/d}
        T = [i*1.0/10000**(2*j*1.0/d) for i in range(0, max_seq_len, max_seq_len//l) for j in range((d+1)//2)]
        T = np.array(T).reshape([l, (d+1)//2])
        if d % 2 != 0:
            P[0, :, 1::2] = np.cos(T[:, :-1])
        else:
            P[0, :, 1::2] = np.cos(T)
        P[0, :, 0::2] = np.sin(T)
        return torch.tensor(P, dtype=torch.float, device=X.device)

编码器实现和解码器的实现

无论是编码器还是解码器,其实都是用上面说的三个基本模块堆叠而成,具体的实现细节大家可以看简书开头的git地址,这里需要强调的是以下几点:

  • 无论是编码器还是解码器,都在word embedding后面乘 上\sqrt{d_{model}},防止其值过小;
  • 论文里面提到了他们用的优化器,是以\beta_1=0.9,\beta_2=0.98\epsilon=10^{-9}的Adam为基础,而后使用一种warmup的学习率调整方式来进行调节。具体公式如下:基本上就是先用一个固定warmup_steps进行学习率的线性增长,而后到达warmup_steps之后会随着step_num的增长而逐渐减小。
    l_{rate}=d_{model}^{-0.5}*min(step\_num^{-0.5},step\_num*warmup\_steps^{-1.5})
class NoamOpt:
    def __init__(self, model_size, factor, warmup, optimizer):
        self.optimizer = optimizer    # 优化器
        self._step = 0                # 步长
        self.warmup = warmup          # warmup_steps
        self.factor = factor          # 学习率因子(就是学习率前面的系数)
        self.model_size = model_size  # d_model
        self._rate = 0                # 学习率

    def step(self):
        "Update parameters and rate"
        self._step += 1
        rate = self.rate()
        for p in self.optimizer.param_groups:
            p['lr'] = rate
        self._rate = rate
        self.optimizer.step()

    def rate(self, step=None):
        "Implement `lrate` above"
        if step is None:
            step = self._step
        return self.factor * \
               (self.model_size ** (-0.5) *
                min(step ** (-0.5), step * self.warmup ** (-1.5)))

简书中出现的程序都在简书开头的git中了,直接执行main.ipynb就可以运行程序,如有不详实之处,还请指出~~~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342