Transformer 模型详解

概要

目前在序列建模和转换问题中,如语言建模和机器翻译,所采用的主流框架为Encoder-Decoder框架。传统的Encoder-Decoder一般采用RNN作为主要方法,基于RNN所发展出来的LSTM和GRU也被曾认为是解决该问题最先进的方法。但是RNN的主要缺陷在于并行训练的不足。针对机器翻译问题,原论文文(Attention is all you need)提出了一种“Transformer”结构同时使用了注意力机制,使得这个模型可以达到很好翻译效果的同时进行并行计算。

1. 问题描述、动机

目前在序列建模和转换问题中,如语言建模和机器翻译,所采用的主流框架为Encoder-Decoder框架。传统的Encoder-Decoder一般采用RNN作为主要方法,基于RNN所发展出来的LSTM和GRU也被曾认为是解决该问题最先进的方法。而RNN模型的计算被限制为顺序,这种机制阻碍了样本训练的并行化,又会导致在计算过程中信息丢失从而引发长期依赖问题。RNN及其衍生网络的缺点就是慢,问题在于前后隐藏状态的依赖性,无法实现并行。之后的工作提出了因子分解和条件计算等方法大大提高了计算效率,后者还同时提高了模型性能。然而,顺序计算的基本约束仍然存在。

RNN+Attention : 《Neural machine translation by jointly learning to align and translate》这篇论文首先将注意力机制运用在NLP上,提出了soft Attention Model,并将其应用到了机器翻译上面。其实,所谓Soft,意思是在求注意力分配概率分布的时候,对于输入句子X中任意一个单词都给出个概率,是个概率分布。加入注意力机制的模型表现确实更好,但也存在一定问题,例如:attention mechanism通常和RNN结合使用,我们都知道RNN依赖t-1的历史信息来计算t时刻的信息,因此不能并行实现,计算效率比较低,特别是训练样本量非常大的时候。

针对机器翻译这个领域,为了减少序列处理任务的计算量解决RNN无法并行的问题,已经有过一些解决方案。如Extended Neural GPU、ByteNet和ConvS2S等。这些网络都是以CNN为基础,并行计算所有输入和输出位置的隐藏表示。CNN不能直接用于处理变长的序列样本但可以实现并行计算。完全基于CNN的Seq2Seq模型虽然可以并行实现,但非常占内存,很多的trick,大数据量上参数调整并不容易。

CNN+Attention基于CNN的Seq2Seq+attention的优点:基于CNN的Seq2Seq模型具有基于RNN的Seq2Seq模型捕捉long distance dependency的能力,此外,最大的优点是可以并行化实现,效率比基于RNN的Seq2Seq模型高。缺点:计算量与观测序列X和输出序列Y的长度成正比。

本文(Attention is all you need)提出Transformer模型,这种模型避免使用RNN,完全依赖于Attention机制搭建了整个模型框架。该模型具有很高的并行度,在八个P100 GPU上接受十二小时的训练即可达到翻译质量的最佳结果。对于上面提到的CNN网络的并行计算,比如ByteNet按照对数形式增长,ConvS2s呈线性增长,这导致了学习距离较远的两位置间的依赖关系变得困难。而在Transformer中,计算量则被减少到常数级别。而在Transformer中,计算量则被减少到常数级别。自注意力机制(Self-attention)能够把输入序列上不同位置的信息联系起来,然后计算出整条序列的某种表达,目前自注意力机制主要应用于阅读理解、提取摘要、文本推论等领域。端到端的网络一般都是基于循环注意力机制而不是序列对齐循环,并且已经有证据表明在简单语言问答和语言建模任务上表现很好。Transformer是第一个完全依靠Self-attention而不使用序列对齐的RNN或卷积的方式来计算输入输出表示的转换模型。

Transformer模型完全摒弃了递归结构,并没有使用CNN或RNN结构,完全依赖注意力机制,挖掘输入和输出之间的关系,这样做最大的好处是能够并行计算了。这篇论文主要亮点在于
(1)不同于以往主流机器翻译使用基于RNN的seq2seq模型框架,该论文用attention机制代替了RNN搭建了整个模型框架。
(2)提出了多头注意力(Multi-headed attention)机制方法,在    编码器和解码器中大量的使用了多头自注意力机制(Multi-headed self-attention)。
(3)在WMT2014语料中的英德和英法任务上取得了先进结果,并且训练速度比主流模型更快。

深度学习里的Attention Model其实模拟的是人脑的注意力模型,举个例子来说,当我们观赏一幅画时,虽然我们可以看到整幅画的全貌,但是在我们深入仔细地观察时,其实眼睛聚焦的就只有很小的一块,这个时候人的大脑主要关注在这一小块图案上,也就是说这个时候人脑对整幅图的关注并不是均衡的,是有一定的权重区分的。这就是深度学习里的Attention Model的核心思想。所谓注意力机制,就是说在生成每个词的时候,对不同的输入词给予不同的关注权重。该机制通过分配权重系数的方法灵活的捕捉全局和局部的关系。既能够做到一步到位的全局联系捕捉又能够实现并行计算减少训练模型时间。由于上述优点,Attention机制已经成序列建模和转换模型中不可或缺的组成部分。但在大多数情况下这种机制都与循环网络一起使用,而且一般只将其用于解码端。通过注意力机制,我们将输入句子编码为一个向量序列,并自适应地选择这些向量的一个子集,同时对译文进行译码,例如where are you——>你在哪?现在我们在翻译“你”的时候给"you"更多的权重,那么就可以有效的解决对齐问题。

2. 方法和技术

让我们从整体上看看Transformer模型的编码器和解码器堆栈的是如何工作的:
(1)将输入序列的词嵌入(word embeddings)传递给第一个编码器。
(2)然后将它们进行转换并传播到下一个编码器。
(2)编码器堆栈中最后一个编码器的输出将传递给解码器堆栈中所有的解码器。

这是文中列出的几个超参数:

  • N : 编解码层数,默认为6
  • d m o d e l d_{model} dmodel : 模型维度(词嵌入维度),默认为512
  • d f f d_{ff} dff : 中间层(Feed Forward)的维度是,默认为2048
  • h h h : Multi-Head Attention 中的Head个数,默认为8
  • d k d_k dk : 注意力向量中Key的维度,默认为64(512/8)
  • d v d_v dv : 注意力向量中Value的维度,默认为64(512/8)
  • P d r o p P_{drop} Pdrop : 留出率,默认为0.1
  • ϵ l s \epsilon_{ls} ϵls : 默认为0.1

首先,看一下的它的整个模型架构:
framework

左边绿色部分是编码器模块,输入需要翻译的原始句子,输出编码向量,在论文中有6(也就是超参数中的N)层编码层。右边红色部分是解码层,和编码层一样有6层。

再来看看具体的内容细节:
detail

左边这部分就是编码模块,右边是解码模块。注意到这里的 Nx 就是代表了前面说的6层的意思。

我们来依次实现各个模块和功能。

2.1 编码器模块

参照上图,整个编码器模块Encoder的定义:

class Encoder(nn.Module):
    ''' A encoder model with self attention mechanism. '''
    def __init__(
            self,
            n_src_vocab, len_max_seq, d_word_vec,
            n_layers, n_head, d_k, d_v,
            d_model, d_inner, dropout=0.1):

        super().__init__()
        
        # 词嵌入
        self.src_word_emb = nn.Embedding(n_src_vocab, d_word_vec, padding_idx=Constants.PAD)
        # 位置编码
        self.position_enc = PositionalEncoding(d_model, len_max_seq)
        # 6*解码层
        self.layer_stack = nn.ModuleList([
            EncoderLayer(d_model, d_inner, n_head, d_k, d_v, dropout=dropout)
            for _ in range(n_layers)])

    def forward(self, src_seq, src_seq_len, return_attns=False):

        enc_slf_attn_list = []

        # -- Prepare masks
        slf_attn_mask = get_attn_key_pad_mask(seq_k=src_seq, seq_q=src_seq)
        non_pad_mask = get_non_pad_mask(src_seq)

        # 一开始的词嵌入和位置编码
        enc_output = self.src_word_emb(src_seq) + self.position_enc(src_seq_len)

        # 这里就是依次进行6层编码
        for enc_layer in self.layer_stack:
            enc_output, enc_slf_attn = enc_layer(
                enc_output,
                non_pad_mask=non_pad_mask,
                slf_attn_mask=slf_attn_mask)
            if return_attns:
                enc_slf_attn_list += [enc_slf_attn]

        if return_attns:
            return enc_output, enc_slf_attn_list
        return enc_output,
2.2 词嵌入(Word Embedding)

它实际上就是一个二维浮点矩阵,里面的权重是可训练参数,我们只需要把这个矩阵构建出来就完成了word embedding的工作。

我们可以预先使用 FastNLP 构建词汇表,然后将每句话的单词转化为词汇表中对应的key,得到两个的句子作为测试输入: [[3,2,1,7,5],[4,2,6,0,0]]

具体的实现很简单:

我们取batch_size = 2
n_src_vocab = 864
d_model = 512

测试代码:

import torch.nn as nn
import torch as torch
n_src_vocab = 864
d_model = 512
test_sentences =  torch.LongTensor([[3,2,1,7,5],[4,2,6,0,0]])
print(test_sentences.size())
src_word_emb = nn.Embedding(n_src_vocab, d_model, padding_idx=0)
print(src_word_emb(test_sentences).size())

看一下词嵌入的测试输出:

torch.Size([2, 5])
torch.Size([2, 5, 512])

于是就将原来的size为(batch_size, seq_len)的句子转换成了size为(batch_size, max_seq_len, d_model)的向量矩阵了。

2.3 位置编码(Positional Encoding)

可以回想一下RNN中当前位置的这个状态总是依赖于前一个位置的状态以及当前位置的这个单词,也就是一个字一个字地输入进去训练的。而Transformer中则是将单词组成的整个句子作为一个向量输入到Decoder中(这也是它可以并行训练的原因),Attention机制本身也只知道词和词之间的影响力,这样带来的问题就是训练时没办法识别出单词之间的位置关系(单词在源句子中位置的相对或绝对的信息),所以我们必须提供给encoder单词之间的关系,也就是论文里的Positional Encoding。那么为什么要通过这种位置向量的方式呢?其实还是为了可以并行训练,因为输入的句子是一个整体向量,那么再加上(和词嵌入直接相加,不是拼接)这个位置向量,然后还是以向量的方式继续进行编码,避免了RNN那样显示的对位置的依赖,从而可以实现捕捉顺序信息同时兼顾并行训练。

Position Encoding既可以用一个固定的函数表达,也可以用一个可学习的函数表达(Position Embedding)。实验发现两者效果差不多,干脆就采取固定的。那么具体怎么做呢?论文的实现很有意思,使用正余弦函数。公式如下:
P E ( p o s , 2 i ) = s i n ( p o s / 100 0 2 i / d m o d e l ) PE_{(pos,2i)}=sin(pos/1000^{2i/d_{model}}) PE(pos,2i)=sin(pos/10002i/dmodel)
P E ( p o s , 2 i + 1 ) = c o s ( p o s / 100 0 2 i / d m o d e l ) PE_{(pos,2i+1)}=cos(pos/1000^{2i/d_{model}}) PE(pos,2i+1)=cos(pos/10002i/dmodel)

其中,pos 是指词语在序列中的位置,i是词嵌入的某一个维度。可以看出,在偶数位置,使用正弦编码,在奇数位置,使用余弦编码 d m o d e l d_{model} dmodel 就是模型的维度,论文中值为512。每个词的位置编码仅仅与模型维度 d m o d e l d_{model} dmodel 和当前词的位置 p o s pos pos 有关

这个编码公式的意思就是:给定词语的位置 pos,我们可以把它编码成 d m o d e l d_{model} dmodel 维的向量!也就是说,位置编码的每一个维度对应正弦曲线,波长构成了从 2 π 2\pi 2π 1000 ∗ 2 π 1000 * 2\pi 10002π 的等比序列。

上面的位置编码是绝对位置编码。但是词语的相对位置也非常重要。这就是论文为什么要使用三角函数的原因!正弦函数能够表达相对位置信息。主要数学依据是以下两个公式:

s i n ( α + β ) = s i n α c o s β + c o s α s i n β sin(\alpha+\beta)=sin \alpha cos \beta+cos \alpha sin \beta sin(α+β)=sinαcosβ+cosαsinβ
c o s ( α + β ) = c o s α c o s β + s i n α s i n β cos(\alpha+\beta)=cos \alpha cos \beta+sin \alpha sin \beta cos(α+β)=cosαcosβ+sinαsinβ

上面的公式说明,对于词汇之间的位置偏移 k,PE(pos+k)可以表示成PE(pos)和PE(k)的组合形式,这就是表达相对位置的能力!
PE
下面来看一个简单的例子。我们可以先把这个函数的pos看做一个固定值,然会绘制出 i (位置编码的索引)对PE值的影响:

%matplotlib inline
%matplotlib inline

import matplotlib.pyplot as plt
import math
def positional_enc(pos, i):
    return math.sin(pos / (10000**(2*i/50)) if i%2==0 else math.cos(pos / (10000**(2*i/50))) )

plt.figure(figsize=(20,10))
for pos_value in range(3):
    embedding_idxs = list(range(0,50))
    sin_values = [positional_enc(pos_value, i) for i in embedding_idxs]
    plt.plot(embedding_idxs,sin_values,label=str(pos_value))
plt.xlabel("embedding_idx")
plt.ylabel("sin_value")
plt.legend(loc = 'upper right')
plt.show()

pe)

为了方便显示,这里一共是不同位置的3个单词,位置编码长度为50,这是他们的位置编码索引和PE值的关系。注意到这里坐标轴右半部分其实已经可以反映出周期性,并且各个位置都趋向于相同的周期变化,这说明对于某个长度的序列,也没有必要使用更高维的位置编码。

然后我们再用热力图的形式来看一下位置编码。在下图中,每一行对应一个词向量的位置编码,所以第一行对应着输入序列的第一个词。位置编码的每个值介于1和-1之间:

import random
from matplotlib import pyplot as plt
from matplotlib import cm
from matplotlib import axes
from matplotlib.font_manager import FontProperties

#定义热图的横纵坐标
pos_idxs = list(range(0,16,1)) # 纵坐标
embedding_idxs = list(range(50))# 横坐标
sin_values = [] # (30*50)
for pos_idx in range(len(pos_idxs)):
    temp = []
    for emb_idx in range(len(embedding_idxs)):
        value = positional_enc(pos_idx,emb_idx)
        temp.append(value)
    sin_values.append(temp)

#作图阶段
fig = plt.figure(figsize=(20,10))

#定义画布为1*1个划分,并在第1个位置上进行作图
ax = fig.add_subplot(111)
#定义横纵坐标的刻度
ax.set_xticks(range(len(embedding_idxs)))
ax.set_xticklabels(embedding_idxs)
ax.set_yticks(range(len(pos_idxs)))
ax.set_yticklabels(pos_idxs)
#作图并选择热图的颜色填充风格,这里选择hot
im = ax.imshow(sin_values, cmap=plt.cm.hot_r)
#增加右侧的颜色刻度条
plt.colorbar(im)
plt.xlabel("embedding_idx")
plt.ylabel("pos_idx")
plt.show()

PE
和上图一一对应,每一行对应了上面的一条线(可以看做俯视图),很显然,到了9左右,位置编码长度的增加就对PE值的区分没什么大用了。它的优点是能够扩展到未知的序列长度(例如,当我们训练出的模型需要翻译远比训练集里的句子更长的句子时)。

按照论文的公式Transformer的PE实现代码如下:


class PositionalEncoding(nn.Module):
    """Positional encoding.
    This class is modified from https://github.com/JayParks/transformer/blob/master/transformer/modules.py.
    """

    def __init__(self, model_dim=512, max_seq_len=50):
       
        super(PositionalEncoding, self).__init__()

        # j//2 because we have sin and cos tow channels
        position_encoding = np.array([
            [pos / np.power(10000, 2.0 * (j // 2) / model_dim) for j in range(model_dim)]
            for pos in range(max_seq_len)])
        position_encoding[:, 0::2] = np.sin(position_encoding[:, 0::2])
        position_encoding[:, 1::2] = np.cos(position_encoding[:, 1::2])

        pad_row = torch.zeros([1, model_dim])
        position_encoding = torch.FloatTensor(np.concatenate([pad_row, position_encoding]).astype(np.float32))

        # additional PAD position index
        self.position_encoding = nn.Embedding(max_seq_len + 1, model_dim)

        self.position_encoding.weight = nn.Parameter(position_encoding, requires_grad=False)

    def forward(self, input_len):
      
        # max(B,seq_len),找出最长句子
        max_len = torch.max(input_len)
        tensor = torch.cuda.LongTensor if input_len.is_cuda else torch.LongTensor
    
        all_list = []

        for len in input_len:
            a = np.asarray(list(range(1, len + 1)))  # 3,7
            b = np.zeros((1, max_len - len), dtype=np.int8)[0]

            all_list.append(np.append(a, b))

        in_pos = tensor(all_list)

        return self.position_encoding(in_pos)

测试代码


test_sentences =  [[3,2,1,7,5,3,2,1,7,5,3,2,1,7,5],[4,2,6,3,2]]
test_sentences_len = torch.LongTensor([len(test_sentences[0]),len(test_sentences[1])])
print(test_sentences_len)
PE = PositionalEncoding(model_dim=64)
pe_enc_out = PE(test_sentences_len)
print(pe_enc_out.size())

plt.figure(figsize=(16, 5))
plt.plot(np.arange(15), pe_enc_out[0, :, :].data.numpy()[::-1])
plt.ylabel("value")
plt.xlabel("sequence_length")

plt.show()

最终将size为 (batch_size, 1) 的长度矩阵,转换成size 为 (batch_size, max_seq_len, d_model) 的位置矩阵。

2.4 编码层 EncoderLayer

每一层编码器都包含了一个MultiHeadAttention多头注意力和一个FeedForward全连接网络

class EncoderLayer(nn.Module):
    ''' Compose with two layers '''

    def __init__(self, d_model, d_inner, n_head, d_k, d_v, dropout=0.1):
        super(EncoderLayer, self).__init__()
        self.slf_attn = MultiHeadAttention(
            n_head, d_model, d_k, d_v, dropout=dropout)
        self.pos_ffn = PositionwiseFeedForward(d_model, d_inner, dropout=dropout)

    def forward(self, enc_input, non_pad_mask=None, slf_attn_mask=None):
        enc_output, enc_slf_attn = self.slf_attn(
            enc_input, enc_input, enc_input, mask=slf_attn_mask)
        enc_output *= non_pad_mask

        enc_output = self.pos_ffn(enc_output)
        enc_output *= non_pad_mask

        return enc_output, enc_slf_attn
2.5 缩放点积注意力(Scaled Do-tProduct Attention)

每个编码层中多头注意力MultiHeadAttention又包含N(N=6)个 Scaled Dot-Product Attention。先来看看这个Scaled Do-tProduct Attention的结构。
sda
其实这就是我们所说的自注意力,自注意力达到的效果是一个句子中的某个单词对同一个句子中其他单词得关注。比如说下面这个图,当我们在编码器中编码“it”这个单词的时,自注意力机制的部分会去更多地关注“The Animal”,将它的表示的一部分编入“it”的编码中。
sa
以单个向量举例,计算自注意力的步骤如下:
(1)计算自注意力的第一步就是向每个编码器中输入词向量(每个单词的词向量),然后通过词嵌入与三个权重矩阵 W Q , W K , W V W^Q,W^K,W^V WQ,WK,WV,需要通过训练学习到)相乘生成三个向量。也就是说对于每个单词,我们创造一个查询向量Q、一个键向量K和一个值向量V

  • query向量:query顾名思义,是负责寻找这个字的于其他字的相关度(通过其它字的key)
  • key向量:key向量就是用来于query向量作匹配,得到相关度评分的 。
  • value向量:Value vectors 是实际上的字的表示, 一旦我们得到了字的相关度评分,这些表示是用来加权求和的
    qkv
    X 1 X_1 X1 W Q W^Q WQ权重矩阵相乘得到q1, 就是与这个单词相关的查询向量,同理可以使得输入序列的每个单词的创建一个查询向量 q 1 q_1 q1、一个键向量 k 1 k_1 k1和一个值向量 v 1 v_1 v1。那么什么是查询向量、键向量和值向量呢?它们都是有助于计算和理解注意力机制的抽象概念,下面会详细降到。

(2)计算得分。假设我们在为这个例子中的第一个词“Thinking”计算自注意力向量,我们需要拿输入句子中的每个单词对“Thinking”打分。这些分数决定了在编码单词“Thinking”的过程中有多重视句子的其它部分。
score
(3)将分数除以8(8是论文中使用的键向量的维数64的平方根,这会让梯度更稳定。这里也可以使用其它值,8只是默认值),然后通过softmax传递结果。softmax的作用是使所有单词的分数归一化,得到的分数都是正值且和为1。
softmat
这些分数是通过打分单词(所有输入句子的单词)的键向量与“Thinking”的查询向量相点积来计算的。所以如果我们是处理位置最靠前的词的自注意力的话,第一个分数是q1和k1的点积,第二个分数是q1和k2的点积。
这个softmax分数决定了每个单词对编码当下位置(“Thinking”)的贡献。显然,已经在这个位置上的单词将获得最高的softmax分数,但有时关注另一个与当前单词相关的单词也会有帮助。
(4)将每个值向量乘以softmax分数(这是为了准备之后将它们求和)。这里的直觉是希望关注语义上相关的单词,并弱化不相关的单词(例如,让它们乘以0.001这样的小数)。
(5)是对加权值向量求和(译注:自注意力的另一种解释就是在编码某个单词时,就是将所有单词的表示(值向量v)进行加权求和,而权重是通过该词的表示(键向量k)与被编码词表示(查询向量q)的点积并通过softmax得到。),然后即得到自注意力层在该位置的输出z(在我们的例子中是对于第一个单词)。
z
下面就是论文中的缩放点积注意力的计算公式,和上面的步骤可以一一对应:
sa
公式(2.5.1): A t t e n t i o n ( Q , K , V ) = s o f t m a x ( Q K ⊤ d k ) V Attention(Q,K,V) = softmax\left(\frac{QK^{\top}}{\sqrt{d_k}}\right)V Attention(Q,K,V)=softmax(dk QK)V

这样自自注意力的计算就完成了。得到的向量就可以传给前馈神经网络。然而实际中,这些计算是以矩阵形式完成的,以便算得更快。

那我们接下来就看看如何用矩阵实现的:
第一步是计算查询矩阵、键矩阵和值矩阵。为此,我们将将输入句子的词嵌入装进矩阵X中,将其乘以我们训练的权重矩阵( W Q , W K , W V W_Q,W_K,W_V WQWKWV)。
matrix
X矩阵中的每一行对应于输入句子中的一个单词。我们再次看到词嵌入向量 (512,或图中的4个格子)和q/k/v向量(64,或图中的3个格子)的大小差异。

最后,由于我们处理的是矩阵,我们可以将上面步骤2到步骤5按照公式(2.5.1)合并起来一次计算自注意力层的输出。
sa
根据论文中的公式2.5.1很容易得到:

class ScaledDotProductAttention(nn.Module):
    ''' Scaled Dot-Product Attention '''

    def __init__(self, scaler, attn_dropout=0.1):
        super().__init__()
        self.scaler = scaler
        self.dropout = nn.Dropout(attn_dropout)
        self.softmax = nn.Softmax(dim=2)

    def forward(self, q, k, v, mask=None):

        attn = torch.bmm(q, k.transpose(1, 2))
        attn = attn / self.scaler

        if mask is not None:
            attn = attn.masked_fill(mask, -np.inf)

        attn = self.softmax(attn)
        attn = self.dropout(attn)
        output = torch.bmm(attn, v)

        return output, attn
2.6 多头注意力(Multi-Head Attention)

Transformer模型通过增加一种叫做“多头”注意力(“multi-headed” attention)的机制,进一步完善了自注意力层,并在两方面提高了注意力层的性能:

  • 它扩展了模型专注于不同位置的能力。在上面的例子中,虽然每个编码都在z1中有或多或少的体现,但是它可能被实际的单词本身所支配。如果我们翻译一个句子,比如“The animal didn’t cross the street because it was too tired”,我们会想知道“it”指的是哪个词,这时模型的“多头”注意机制会起到作用。
  • 它给出了注意力层的多个“表示子空间”(representation subspaces)。接下来我们将看到,对于“多头”注意机制,我们有多个查询/键/值权重矩阵集(Transformer使用八个注意力头,因此我们对于每个编码器/解码器有八个矩阵集合)。这些集合中的每一个都是随机初始化的,在训练之后,每个集合都被用来将输入词嵌入(或来自较低编码器/解码器的向量)投影到不同的表示子空间中。

看一下Multi-Head Attention的内部细节:
ma
公式 : h e a d i = A t t e n t i o n ( Q W i Q , K W i K , V W i V ) head_i = Attention(QW_i^Q,KW_i^K,VW_i^V) headi=Attention(QWiQ,KWiK,VWiV)

公式 : M u l t i H e a d ( Q , K , V ) = C o n c a t ( h e a d 1 , . . . , h e a d h ) MultiHead({Q},{K},{V}) = Concat(head_1,...,head_h) MultiHead(Q,K,V)=Concat(head1,...,headh)

这里包含3个Linear变换用于拆分V,K,Q,以及h(h=8)个Scaled Dot-Product Attention。然后再将经过自注意计算后的h个向量拼接起来,最终经由另外一个Linear变换输出。

在“多头”注意机制下,我们为每个头保持独立的查询/键/值权重矩阵,从而产生不同的查询/键/值矩阵。和之前一样,我们拿X乘以 W Q , W K , W V W^Q,W^K,W^V WQ,WK,WV矩阵来产生查询/键/值矩阵。

如果我们做与上述相同的自注意力计算,只需八次不同的权重矩阵运算,我们就会得到八个不同的Z矩阵:
8matrix
但是后面的前馈层不需要8个矩阵,它只需要一个矩阵(由每一个单词的表示向量组成)。所以我们需要一种方法把这八个矩阵压缩成一个矩阵。那该怎么做?其实可以直接把这些矩阵拼接在一起,然后用一个附加的权重矩阵 W O W^O WO与它们相乘:
WO
我们试着把它们集中在一个图片中,这样可以一眼看清:
Multihead
既然我们已经摸到了注意力机制的这么多“头”,那么让我们重温之前的例子,看看我们在例句中编码“it”一词时,不同的注意力“头”集中在哪里:
sample
当我们编码“it”一词时,一个注意力头集中在“animal”上,而另一个则集中在“tired”上,从某种意义上说,模型对“it”一词的表达在某种程度上是“animal”和“tired”的代表。

Transformer模型中的MultiHeadAttention代码实现:


class MultiHeadAttention(nn.Module):
    ''' Multi-Head Attention module '''

    def __init__(self, n_head=8, d_model=512, d_k=64, d_v=64, dropout=0.1):
        super().__init__()

        self.n_head = n_head
        self.d_k = d_k
        self.d_v = d_v

        self.w_qs = nn.Linear(d_model, n_head * d_k)
        self.w_ks = nn.Linear(d_model, n_head * d_k)
        self.w_vs = nn.Linear(d_model, n_head * d_v)
        
        nn.init.normal_(self.w_qs.weight, mean=0, std=np.sqrt(2.0 / (d_model + d_k)))
        nn.init.normal_(self.w_ks.weight, mean=0, std=np.sqrt(2.0 / (d_model + d_k)))
        nn.init.normal_(self.w_vs.weight, mean=0, std=np.sqrt(2.0 / (d_model + d_v)))

        self.attention = ScaledDotProductAttention(scaler=np.power(d_k, 0.5))
        self.layer_norm = nn.LayerNorm(d_model)

        self.fc = nn.Linear(n_head * d_v, d_model)
        nn.init.xavier_normal_(self.fc.weight)

        self.dropout = nn.Dropout(dropout)


    def forward(self, q, k, v, mask=None):

        d_k, d_v, n_head = self.d_k, self.d_v, self.n_head

        sz_b, len_q, _ = q.size()
        sz_b, len_k, _ = k.size()
        sz_b, len_v, _ = v.size()

        residual = q

        q = self.w_qs(q).view(sz_b, len_q, n_head, d_k)
        k = self.w_ks(k).view(sz_b, len_k, n_head, d_k)
        v = self.w_vs(v).view(sz_b, len_v, n_head, d_v)

        q = q.permute(2, 0, 1, 3).contiguous().view(-1, len_q, d_k) # (n*b) x lq x dk
        k = k.permute(2, 0, 1, 3).contiguous().view(-1, len_k, d_k) # (n*b) x lk x dk
        v = v.permute(2, 0, 1, 3).contiguous().view(-1, len_v, d_v) # (n*b) x lv x dv

        mask = mask.repeat(n_head, 1, 1) # (n*b) x .. x ..
        output, attn = self.attention(q, k, v, mask=mask)

        output = output.view(n_head, sz_b, len_q, d_v)
        output = output.permute(1, 2, 0, 3).contiguous().view(sz_b, len_q, -1) # b x lq x (n*dv)

        output = self.dropout(self.fc(output))
        output = self.layer_norm(output + residual)

        return output, attn

这里直接测试多头注意力:

max_sentence_len=15
mha=MultiHeadAttention()
input_tensor = torch.FloatTensor(np.random.rand(batch_size,max_sentence_len,d_model)+1)
mha_out = mha(input_tensor,input_tensor,input_tensor)
print(mha_out[0].size(),mha_out[1].size())

输出结果:

torch.Size([2, 15, 512]) torch.Size([16, 15, 15])

这里输出的size为 (batch_size,max_seq_len,d_model)

2.7 前向网络(Feed Forward Netwrok)

编码器和解码器中的每一层除了注意子层外,还包含一个全连接的前馈网络,这个FFN包含两个线性变换,中间有一个ReLU激活。这个线性变换在不同的位置都表现地一样,但是在不同的层之间使用不同的参数。它包。公式如下:

F F N ( x ) = m a x ( 0 , x W 1 + b 1 ) W 2 + b 2 FFN(x)=max(0,xW_1+b_1)W_2+b_2 FFN(x)=max(0,xW1+b1)W2+b2

论文提到,这个公式还可以用两个核大小为1的一维卷积来解释,卷积的输入输出都是 d m o d e l = 512 d_{model}=512 dmodel=512,中间层的维度是 d f f = 2048 d_{ff}=2048 dff=2048

具体实现使用pytorch.nn.Module中的Conv1d:

import torch
import torch.nn as nn

class PositionwiseFeedForward(nn.Module):
    ''' A two-feed-forward-layer module '''

    def __init__(self, d_model=512, d_ff=2048, dropout=0.1):
        super().__init__()
        self.w_1 = nn.Conv1d(d_model, d_ff, 1) # position-wise
        self.w_2 = nn.Conv1d(d_ff, d_model, 1) # position-wise
        self.layer_norm = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        residual = x
        output = x.transpose(1, 2)
        output = self.w_2(F.relu(self.w_1(output)))
        output = output.transpose(1, 2)
        output = self.dropout(output)
        output = self.layer_norm(output + residual)
        return output

同时为了更好的优化深度网络,这里可以看到还使用了 residual 残差连接(ADD)Layer Normalization(Norm),Layer Normalization也是归一化数据的一种方式,可以加快模型收敛速度。不过LN是在每一个样本上计算均值和方差,而不是BN那种在批方向计算均值和方差。这里 LN 的实现,我们也是用pytorch自带的 nn.LayerNorm。

FFN = PositionwiseFeedForward()
print(FFN(mha_out[0]).size())

这里使用前面多头注意力层编码之后的输出作为FFN的输入,测试结果为:

torch.Size([2, 15, 512])

这样经过6个编码层之后,输出了一个size为(batch_size, max_seq_len, d_model)的矩阵。

2.8 mask
2.8.1 Padding mask

什么是padding mask呢?由于我们的每个批次输入序列长度是不一样的,也就是说,我们要对输入序列进行对齐。具体来说,就是给在较短的序列后面填充0。因为这些填充的位置,其实是没什么意义的,所以我们的attention机制不应该把注意力放在这些位置上,所以我们需要进行一些处理。
具体的做法是,把这些位置的值加上一个非常大的负数(可以是负无穷),这样的话,经过softmax,这些位置的概率就会接近0!
而我们的padding mask实际上是一个张量,每个值都是一个Boolen,值为False的地方就是我们要进行处理的地方。
下面是实现:

def get_attn_key_pad_mask(seq_k, seq_q):
    ''' For masking out the padding part of key sequence. '''

    # Expand to fit the shape of key query attention matrix.
    len_q = seq_q.size(1)
    padding_mask = seq_k.eq(Constants.PAD)
    padding_mask = padding_mask.unsqueeze(1).expand(-1, len_q, -1)  # b x lq x lk

    return padding_mask

def testPaddingMask():
    seq_k = np.random.rand(2,3)
    seq_q = np.random.rand(2,7)
    pad_mask = get_attn_key_pad_mask(torch.FloatTensor(seq_k),torch.FloatTensor(seq_q))
    print(pad_mask.size())
    
testPaddingMask()    

测试输出结果:

torch.Size([2, 7, 3])
2.8.2 Sequence mask

sequence mask是为了使得decoder不能看见未来的信息。也就是对于一个序列,在time_step为t的时刻,我们的解码输出应该只能依赖于t时刻之前的输出,而不能依赖t之后的输出。因此我们需要想一个办法,把t之后的信息给隐藏起来。
那么具体怎么做呢?也很简单:产生一个上三角矩阵,上三角的值全为0,下三角的值全为1,对角线也是0。把这个矩阵作用在每一个序列上,就可以达到我们的目的啦。
具体的代码实现如下:

def get_subsequent_mask(seq):
    ''' For masking out the subsequent info. '''

    sz_b, len_s = seq.size()
    subsequent_mask = torch.triu(
        torch.ones((len_s, len_s), device=seq.device, dtype=torch.uint8), diagonal=1)
    subsequent_mask = subsequent_mask.unsqueeze(0).expand(sz_b, -1, -1)  # b x ls x ls

    return subsequent_mask

def testSequenceMask():
    seq = np.random.rand(2,3)
    seq_mask = get_subsequent_mask(torch.FloatTensor(seq))
    print(seq_mask.size())

testSequenceMask()

测试输出结果:

torch.Size([2, 3, 3])

值得注意的是,本来mask只需要二维的矩阵即可,但是考虑到我们的输入序列都是批量的,所以我们要把原本二维的矩阵扩张成3维的张量(加入了一个batch_size维度)。上面的代码可以看出,我们已经进行了处理。

attn_mask 参数有几种情况:

  • 对于decoder的第一个多头注意力子层,里面使用到的scaled dot-product attention,同时需要padding mask和sequence mask作为attn_mask,具体实现就是两个mask相加作为attn_mask。
  • 其他情况,attn_mask一律等于padding mask。
编码器模块整体测试

编码器模块里的所有单独功能都已实现和测试通过,现在来对整个编码器模块测试:

n_src_vocab = 864
src_seq =  torch.LongTensor([[3,2,1,7,5],[4,2,6,0,0]])
src_seq_len = torch.LongTensor([5,5])
encoder = Encoder(n_src_vocab)
print(encoder(src_seq,src_seq_len).size())

测试编码模块输出:

torch.Size([2, 5, 512])

所以经过整个编码器编码之后,输出一个size为 (batch_size, max_seq_len, d_model)的矩阵。

2.8 解码器模块

解码器模块里每一层解码层只比编码器层多了中间一层编码-解码注意力层(本质也是多头注意力)。看一下解码层的实现:

class DecoderLayer(nn.Module):
    ''' Compose with three layers '''

    def __init__(self, d_model, d_inner, n_head, d_k, d_v, dropout=0.1):
        super(DecoderLayer, self).__init__()
        self.slf_attn = MultiHeadAttention(n_head, d_model, d_k, d_v, dropout=dropout)
        self.enc_attn = MultiHeadAttention(n_head, d_model, d_k, d_v, dropout=dropout)
        self.pos_ffn = PositionwiseFeedForward(d_model, d_inner, dropout=dropout)

    def forward(self, dec_input, enc_output, non_pad_mask=None, slf_attn_mask=None, dec_enc_attn_mask=None):
        dec_output, dec_slf_attn = self.slf_attn(
            dec_input, dec_input, dec_input, mask=slf_attn_mask)
        dec_output *= non_pad_mask

        dec_output, dec_enc_attn = self.enc_attn(
            dec_output, enc_output, enc_output, mask=dec_enc_attn_mask)
        dec_output *= non_pad_mask

        dec_output = self.pos_ffn(dec_output)
        dec_output *= non_pad_mask

        return dec_output, dec_slf_attn, dec_enc_attn

可以它里面的enc_attn这个多头注意力就是我们刚说的编码-解码注意力层,其他实现都编码曾一样。

再来看一下整个解码器模块的实现:

class Decoder(nn.Module):
    ''' A decoder model with self attention mechanism. '''

    def __init__(
            self,
            n_tgt_vocab, len_max_seq, d_word_vec,
            n_layers, n_head, d_k, d_v,
            d_model, d_inner, dropout=0.1):
        super().__init__()
        n_position = len_max_seq + 1

        self.tgt_word_emb = nn.Embedding(
            n_tgt_vocab, d_word_vec, padding_idx=Constants.PAD)

        self.position_enc = PositionalEncoding(d_model, len_max_seq)

        self.layer_stack = nn.ModuleList([
            DecoderLayer(d_model, d_inner, n_head, d_k, d_v, dropout=dropout)
            for _ in range(n_layers)])

    def forward(self, tgt_seq, tgt_len, src_seq, enc_output, return_attns=False):

        dec_slf_attn_list, dec_enc_attn_list = [], []

        # -- Prepare masks
        non_pad_mask = get_non_pad_mask(tgt_seq)

        slf_attn_mask_subseq = get_subsequent_mask(tgt_seq)
        slf_attn_mask_keypad = get_attn_key_pad_mask(seq_k=tgt_seq, seq_q=tgt_seq)
        slf_attn_mask = (slf_attn_mask_keypad + slf_attn_mask_subseq).gt(0)

        dec_enc_attn_mask = get_attn_key_pad_mask(seq_k=src_seq, seq_q=tgt_seq)

        # -- Forward
        dec_output = self.tgt_word_emb(tgt_seq) + self.position_enc(tgt_len)

        for dec_layer in self.layer_stack:
            dec_output, dec_slf_attn, dec_enc_attn = dec_layer(
                dec_output, enc_output,
                non_pad_mask=non_pad_mask,
                slf_attn_mask=slf_attn_mask,
                dec_enc_attn_mask=dec_enc_attn_mask)

            if return_attns is not None:
                dec_slf_attn_list += [dec_slf_attn]
                dec_enc_attn_list += [dec_enc_attn]

        if return_attns is not None:
            return dec_output, dec_slf_attn_list, dec_enc_attn_list
        return dec_output,

现在再来对解码器进行测试:

n_src_vocab = 864
src_seq =  torch.LongTensor([[3,2,1,7,5],[4,2,6,0,0]])
src_seq_len = torch.LongTensor([5,5])
encoder = Encoder(n_src_vocab)
enc_out = encoder(src_seq,src_seq_len)
print("编码输出 enc_out : ",enc_out.size())

tgt_seq =  torch.LongTensor([[9,8,6,0],[1,3,5,7]])
tgt_seq_len = torch.LongTensor([4,4])

context_attn_mask = get_attn_key_pad_mask(tgt_seq, src_seq)
    
decoder = Decoder(n_src_vocab)
dec_out = decoder(tgt_seq, tgt_seq_len, src_seq, enc_out, context_attn_mask)
print("解码输出 enc_out : ",dec_out[0].size())

测试输出:

编码输出 enc_out :  torch.Size([2, 5, 512])
解码输出 enc_out :  torch.Size([2, 4, 512])

这里可以看到解码输出的矩阵的size 是(batch_size, max_tgt_len, d_model)

2.9 Transformer 模型实现

编解码模块我们已经实现,最后还有一个 Linear和Softmax 层,我们来实现一下最终的 Transformer 模型:

class Transformer(nn.Module):
    ''' A sequence to sequence model with attention mechanism. '''

    def __init__(
            self,
            n_src_vocab, n_tgt_vocab, len_max_seq=50,
            d_word_vec=512, d_model=512, d_inner=2048,
            n_layers=6, n_head=8, d_k=64, d_v=64, dropout=0.1,
            tgt_emb_prj_weight_sharing=False,
            emb_src_tgt_weight_sharing=False):

        super().__init__()

        self.encoder = Encoder(
            n_src_vocab=n_src_vocab, len_max_seq=len_max_seq,
            d_word_vec=d_word_vec, d_model=d_model, d_inner=d_inner,
            n_layers=n_layers, n_head=n_head, d_k=d_k, d_v=d_v,
            dropout=dropout)

        self.decoder = Decoder(
            n_tgt_vocab=n_tgt_vocab, len_max_seq=len_max_seq,
            d_word_vec=d_word_vec, d_model=d_model, d_inner=d_inner,
            n_layers=n_layers, n_head=n_head, d_k=d_k, d_v=d_v,
            dropout=dropout)

        self.tgt_word_prj = nn.Linear(d_model, n_tgt_vocab, bias=False)
        nn.init.xavier_normal_(self.tgt_word_prj.weight)

        if tgt_emb_prj_weight_sharing:
            # Share the weight matrix between target word embedding & the final logit dense layer
            self.tgt_word_prj.weight = self.decoder.tgt_word_emb.weight
            self.x_logit_scale = (d_model ** -0.5)
        else:
            self.x_logit_scale = 1.

        if emb_src_tgt_weight_sharing:
            # Share the weight matrix between source & target word embeddings
            assert n_src_vocab == n_tgt_vocab, \
                "To share word embedding table, the vocabulary size of src/tgt shall be the same."
            self.encoder.src_word_emb.weight = self.decoder.tgt_word_emb.weight

    def forward(self, enc_inputs, enc_inputs_len, dec_inputs, dec_inputs_len):

        # tgt_seq, tgt_pos = tgt_seq[:, :-1], tgt_pos[:, :-1]
        context_attn_mask = get_attn_key_pad_mask(dec_inputs, enc_inputs)

        enc_output, *_ = self.encoder(enc_inputs, enc_inputs_len)

        dec_output, *_ = self.decoder(dec_inputs, dec_inputs_len, enc_inputs, enc_output, context_attn_mask)

        # print(">>>>> forward 原始输出 oridec_output <<<<<< : ", oridec_output.size())

        seq_logit = (self.tgt_word_prj(dec_output) * self.x_logit_scale)

        print(">>>>> forward 线性变换 seq_logit: ", seq_logit.size())

        seq_logit = F.log_softmax(seq_logit, dim=1)

        print(">>>>> forward  log_softmax dec_inputs : ", seq_logit.size())

    
        word_prob = seq_logit.argmax(-1).float()
        seq_logit = seq_logit.transpose(1,2)

        nlloss = CrossEntropyLoss()
        loss = nlloss.get_loss(seq_logit,dec_inputs)
        print(self.epoch, ">>> loss : ",loss)

        accmetric = AccuracyMetric()
        accmetric.evaluate(word_prob, dec_inputs)
        acc = accmetric.get_metric()
        print(self.epoch, ">>> acc : ",acc)

        self.writer.add_scalar("loss",loss, self.epoch)
        self.writer.add_scalar("acc",acc['acc'], self.epoch)
    
        self.epoch = self.epoch+1

        return {'aaa': seq_logit, 'pred': word_prob }

测试Transformer模型:

n_src_vocab = 864
src_seq =  torch.LongTensor([[3,2,1,7,5],[4,2,6,0,1]])
src_seq_len = torch.LongTensor([5,5])


tgt_seq =  torch.LongTensor([[9,8,6,4,3],[1,3,5,7,2]])
tgt_seq_len = torch.LongTensor([5,5])

transformer = Transformer(n_src_vocab,n_src_vocab)
train = transformer(src_seq,src_seq_len,tgt_seq,tgt_seq_len)

print("transformer 测试结果:",train['aaa'].size(),train)

输出:

transformer 测试结果: torch.Size([2, 864, 5]) {'aaa': tensor([[[-1.2090, -1.7367, -1.5010, -1.8703, -1.9080],
         [-1.7516, -1.6337, -1.4290, -1.4592, -1.8368],
         [-1.4904, -2.3420, -1.5521, -1.5306, -1.3848],
         ...,
         [-1.3971, -2.2221, -1.1340, -1.5516, -2.2015],
         [-1.4468, -1.6650, -1.7098, -1.5695, -1.6797],
         [-1.8674, -1.6675, -1.4286, -1.4044, -1.7626]],

        [[-2.0044, -1.3701, -1.7005, -1.8963, -1.2785],
         [-1.6097, -1.3744, -1.7881, -1.6436, -1.6793],
         [-1.8992, -1.4508, -1.4514, -1.8176, -1.5175],
         ...,
         [-1.9292, -1.7645, -1.7321, -1.3167, -1.4332],
         [-1.9429, -1.7204, -1.6580, -1.2143, -1.6592],
         [-2.3239, -0.9632, -1.6613, -1.8807, -1.7256]]],
       grad_fn=<LogSoftmaxBackward>), 'pred': tensor([[677., 660., 231., 279., 819.],
        [668., 716.,   8., 167., 811.]])}

值得注意的是这里 Transformer 的 forward方法返回的是一个dict。这是由于在实际训练模型的时候,我们采用的是FastNLP的Trainer。它要求返回一个dict,

这里我们使用英语-德语数据集(29000条数据集)来训练模型。由于我们这里使用了FastNLP,所以在实现模型的时候,注意一下它的forward方法。

这里我们在forward内部计算交叉熵损失:

 nlloss = CrossEntropyLoss()
loss = nlloss.get_loss(seq_logit,dec_inputs)
print(self.epoch, ">>> loss : ",loss)

以及精确度:

accmetric = AccuracyMetric()
accmetric.evaluate(word_prob, dec_inputs)
acc = accmetric.get_metric()
print(self.epoch, ">>> acc : ",acc)

同时使用tensorboardX来实时观测loss和acc的变化:

self.writer.add_scalar("loss",loss, self.epoch)
self.writer.add_scalar("acc",acc['acc'], self.epoch)
self.epoch = self.epoch+1

然后forward方法的返回值是一个dict:

 {'aaa': seq_logit, 'pred': word_prob}

aaa这个key值对应了Trainer中设置的pred参数的值:

trainer = Trainer(model=model,
                      train_data=train_data,
                      loss=CrossEntropyLoss(pred="aaa",target="dec_inputs"),
                      save_path='./xpattention',
                      use_cuda=False,
                      batch_size=opt.batch_size,
                      n_epochs=opt.max_epochs)
trainer.train()

而这个pred是为了使用FastNLP自带的AccuracyMetric计算精确度时所需要的

tester = Tester(data=dev_data, model=copymodel, metrics=AccuracyMetric(pred='pred', target="dec_inputs"))
acc = tester.test()

3. 小结

3.1 Transformer 模型本身的亮点

Transformer中用attention代替原本的RNN,使得所有的计算都能够并行进行,从而提高了训练的速度。它所带来的最大性能提升的关键是它直接把序列两两比较,能够一步捕捉到全局的联系。这就使得任意两个单词之间的路径长度变成了常数,这对于解决NLP中棘手的长期依赖问题非常有效。

Transformer模型不仅仅可以应用的NLP的机器翻译领域甚至可以不局限于NLP领域,具有非常大的科研潜力。

3.2 整个训练过程数据size的变化过程

同时因为我们的项目报告中,把每一个单元测试的顺序设置为和数据输入到输出整个流传过程相一致。所以根据单元测试的结果我们可以很容易的看到数据从输入到输出的size的变化。这样也很容易验证我们的代码实现是否争取。下面总结了一下主要的几个过程中数据size的变化:

假设我们取:
batch_size = 2
d_model = 512
max_seq_len 表示输入句子的最大长度,不足的位置补0.
max_tgt_len 表示输出的翻译结果的最大长度,不足的位置也补0.

  • 首先输入句子向量的size:(batch_size, max_seq_len)。当然要求最终的输出size的第一维,也就是batch_size,肯定和他相同,第二维度,则是输出句子的最大长度,我们最后看是不是这样的输出size。

  • word bedding转换,经过词向量编码之后输出的size:(batch_size, max_seq_len, d_model)

  • position encoding 位置编码,输入句子长度的size:(batch_size,1)
    经过PE转换的矩阵size 为 (batch_size, max_seq_len, d_model)

  • 得到word encoding输出和position encoding输出之后,把他们相加,就得到了编码输入的最终值。两个size相同的矩阵相加,很显然输出的矩阵还是size为 :(batch_size, max_seq_len, d_model)

  • 送入编码器的注意力层,中间先经过多头注意力层,输出:
    enc_out 的 size: [batch_size, max_seq_len, d_model)

  • 然后enc_out再经过FFN层,这一步输出size为还是([2, 15, 512])。还是(batch_size, max_seq_len, d_model)的size。

  • 所以编码器模块的输出是(batch_size, max_seq_len, d_model)

  • 然后编码器模块的输入进入到解码器模块,经过解码输出得到的size为:(batch_size, max_seq_len, d_model)

  • 最后经过一个Linear变化之后size为:(batch_size, max_tgt_len, d_model)

  • 以及经过一层Softmax后得到的最终的输出size:(batch_size, max_tgt_len)很显然,这正是我们要的结果。


参考:
【1】从中文Transformer到BERT的模型精讲,以及基于BERT情感分类实战

  • 14
    点赞
  • 62
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值