初学者福音:Transformer全面图文教程

Structure

我们首先将该模型视为一个黑匣子。在机器翻译应用程序中,它将采用一种语言的句子,并以另一种语言输出其翻译。这个图上,我们将法语Je suis étudiant 翻译为 I am a student
请添加图片描述

将中间的THE TRANSFORMER结构打开,我们可以看到一个编码器组件,一个解码器组件以及他们之间的链接:
请添加图片描述

编码组件是一堆Encoders ,这里将6个Encoders堆叠在一起,堆叠几个无所谓。
解码组件是相同数量的Decoders的堆栈。
请添加图片描述

编码组件

这些编码器在结构上都是相同的(但它们不共享权重)。每一层又分为两个子层:Self-Attention layerFeed-Forward Neural Network
请添加图片描述

输入首先经过EncodersSelf-Attention layer,该层的作用在于帮助Encoders对特定的单词进行编码能够注意到句子中的其他单词
Self-Attention layer的输出进入到Feed-Forward Neural Network(FFNN),需要注意的是,所有位置的FFNN都相同,但是分别独立。意味着它们都使用了相同的线性变换和激活函数,但是他们所学习到的权重是不同的。

解码组件

Decoder不止拥有与Encoders相同结构的Self-Attention layerFFNN,还拥有一个非常关键的层叫做“编码器-解码器注意力层”(Encoder-Decoder Attention Layer),它允许Encoders关注到Decoder的输出。
请添加图片描述

Model Input

与NLP的应用一样,我们需要输入的是一个向量,所以我们现需要用词嵌入的方法,把词转化为向量。
什么是词嵌入(Word Embedding)?可以看这篇文章:
Glossary of Deep Learning: Word Embedding | by Jaron Collis | Deeper Learning | Medium

请添加图片描述

注:如图所示,我们将需要翻译的法语语句Je suis étudiant中的每一个单词,嵌入到大小为 512 的词嵌入向量 x 1 x_1 x1 x 2 x_2 x2 , x 3 x_3 x3 中。

对于一个单词或者句子,它们在被输入到最底层的Encoder前被转化为词嵌入向量,是的,Encoder输入的是一个向量!而不是词“本身”。所有的Encoders共有一个特点:他们都是接收一个向量作为输入,而且每个向量的维度都是512,当然这个维度是一个超参数,是我们训练集中句子的最大长度。所以,对于最底层的Encoder的输入是由词嵌入转化而来的向量,随后所有的Encoder的输入都是前一层Encoder的输出。

经过词嵌入转化之后的单词,也就是一个向量,每个向量都会经过Encoder的两层:
请添加图片描述

这个图上我们能看到一个关于Transformer 的一个关键属性:每个单词有自己的流经路径
Self-Attention layer中,每个输入向量(每个词)都会与句子中的其他向量(其他词)进行比较,计算注意力得分,在这个层中,这些向量之间存在依赖关系,每个向量(每个词)要考虑与其他向量(其他词)的关系。
但是在FFNN中,当数据流来到这里时,不需要考虑与其他向量的关系,直接计算。

[!tip]
对于一个词嵌入向量[1,2,3],在FFNN中,只需要单独处理1,单独处理2,单独处理3,但是在Self-Attention Layer,在处理1的时候,需要其考虑与2和3的关系,处理2和3也是这样,这就是注意力机制,之后我们会细讲。

Encoding

正如上面所提到的,Encoder 接收向量作为输入,这些向量首先传递到Attention layer中,然后再通过FFNN,然后输出向量给下一个编码器来处理。

请添加图片描述

注:每个位置的单词都会经过一个Self-Attention。然后,它们各分别自通过FFNN——一个完全相同的网络架构。

Self-Attention at a High Level

在上文中,你会注意到常出现的术语:关注注意或者其他的同义词,那么他们的含义是什么呢?

现在,我们假设以下是我们需要翻译的句子:
The animal didn't cross the street because it was too tired

那么,这句话中的it指的是什么?是animal还是street?对于人来说当然很容易分辨,但是对算法来说,就没这么简单了。但模型处理it这个词时,self-attention能够使itanimal联系起来,这就叫关注

当模型在处理每个单词时,self-attention允许它们查看序列中其他位置的单词,这样能找到正在处理的这个单词与其他位置上的单词之间的关系,这个关系也可叫“重要程度”,这个重要程度也叫注意力分数
self-attention是Transformer 用来将其他相关单词的“理解”融入到我们当前正在处理的单词中的方法。

请添加图片描述

注:当我们在编码器#5(堆栈中的顶部编码器)中对单词it进行编码时,注意力机制的一部分集中在The Animal上,并将其表示的一部分“提炼”it的编码中。

Self-Attention in Detail

首先,我们先来看看它是如何用向量计算self-attention的。

Step 1 Transformer的核心组件

Query、Key和Value可以被视为Transformer模型中实现自注意力机制的核心组件。
那么,什么是 querykeyvalue
他们是用来组成注意力的抽象概念,接下来你将会知道他们是如何计算的,以及他们在Transformer模型中所扮演的角色。
为每个输入Encoder的向量创建三个独特的向量,分别为query向量 Q Q Q,key向量 K K K,value向量 V V V,这些向量是通过词嵌入向量 X X X 乘以三个我们需要训练的三个权重矩阵 W Q W_Q WQ , W K W_K WK , W V W_V WV 的到,如下图:
请添加图片描述

请注意,这里列举的是某一个self-attention layer中的权重矩阵 W Q W_Q WQ , W K W_K WK , W V W_V WV ,他们对于这同一层中的所有词嵌入向量 x i x_i xi 是共用的,也就是不同的 x i x_i xi 乘以同一个 W Q W_Q WQ来到对应的 q i q_i qi 。但是在不同的Self-Attention layer中,权重矩阵 W Q W_Q WQ , W K W_K WK , W V W_V WV 也各不相同。

请添加图片描述

注:将 x 1 x_1 x1 乘以 W Q W_Q WQ 权重矩阵会产生 q 1 q_1 q1,即与该单词关联的查询向量q。我们最终为输入句子中的每个单词创建一个“q”、一个“k”和一个“v”投影。

Step 2 计算注意力分数

假设我们正在计算本例中第一个单词“Thinking”的自注意力。我们需要根据输入句子的每个单词对这个单词进行评分。当我们对某个位置的单词进行编码时,注意力分数决定了它对句子中其他部分的关注程度。
注意力分数是Thinking这个单词的query向量与我们需要做注意力评分的各个单词(图上的Machines)的value向量点积的结果。因此,第一个分数是 q 1 q_1 q1 k 1 k_1 k1 点积来的(自己关注自己),第二个分数是 q 1 q_1 q1 k 2 k_2 k2 点积来的(自己关注“别人”——Machines)。
请添加图片描述

Step 3 稳定梯度

将计算出来的注意力分数除以8,原论文中使用key向量的维度的平方根——64,这可以让梯度更稳定。
Q K T d k \frac { Q K ^ { T } } { \sqrt { d _ { k } } } dk QKT

Step 4 归一化

顾名思义,将Step 3的结果传递给softmax函数进行运算,使所有的注意力分数全部为正并且和为1。经过softmax函数计算的结果代表了这个单词在这个位置上的表达程度或者说重要性:每个词的信息在模型生成当前位置输出时的贡献大小。通常情况下,由于自注意力机制的设计,当前位置的词自己会得到最高的softmax得分,意味着它对自己的表示贡献最大。

Step 5 加权value向量的准备

在上一步中,对于序列中的每一个词,我们已经将计算出来的各个单词之间的关注程度(注意力评分)转化为了权重(softmax分数),在这一步中,我们希望相关性高(注意力得分高的)的词之间的value值乘以它相应的softmax分数的值有较大数值,而对于不相关的词,这个数值越低越好,这样才能几乎不影响最终的输出。这样,经过加权后,模型就能够在计算当前词的新表示时,综合考虑到与它最相关的词的信息,而忽视掉那些不相关的信息。

Step 6 加权value向量求和

这是self-attention layer的最后一个操作,对所有经过softmax函数相乘运算之后的value值进行加权得到这一self-attention layer的输出这一层的Z值。
请添加图片描述

对于以上的6个步骤,我们可以总结为一个公式:

请添加图片描述

至此,self-attention layer的计算到此结束,我们得到了可以输入FFNN的向量Z

Multi-headed Attention

在前文中,我们详细讲解了self-attention的计算方式,也可以理解为Single-Head Attention的计算方式,这种方式的局限性在于没法捕获输入单词之间更多的关系,缺乏信息多样性,此外表示能力单一,如一句话既有语义信息,也有语法关系,面对这种情况,Single-Head Attention就显得乏力。这现在,我们将采用multi-headed attention来解决这个问题。multi-headed attention在两个方面细化了self-attention layer

  1. 它提高模型关注不同位置信息的能力。
    在上文的图示中, z 1 z_1 z1 代表了通过自注意力机制计算得出的一个编码结果,它包含了一些来自句子中其他所有词的信息,但这个编码有可能主要由当前词本身的信息所主导,因为在上文Step 4 归一化中,对于一个单词,会对自己进行softmax函数运算计算得分,这个得分必定是这个单词相对于其它位置的得分中的最大值,因此在后续Step 5 中与value向量进行点积时,很有可能产生更大的值。因此,当进行softmax归一化后,自身的得分占比通常会比较大,从而在求和组合value向量时,自身的影响就会较为显著。所以当处理具有复杂上下文关系的句子时,多头注意力机制特别有用。

例如,在翻译句子The animal didn’t cross the street because it was too tired时,理解代词it指代的是哪个名词非常关键。在这个句子中,it可能指代The animal,而不是the street。单头注意力可能只关注到it本身或其最邻近的词,而多头注意力可以同时关注句子中多个不同的位置,这样每个头可以从不同的角度捕捉it的指代关系,增加了识别正确指代的可能性。

换句话说,通过多头注意力,模型可以在一个头中关注到itThe animal的关系,在另一个头中关注到itthe street的关系,然后综合所有头的信息,来更准确地理解it的指代内容。这种能力扩展了模型处理复杂句子中不同词汇间关系的能力,有助于改进模型对语言的理解和翻译的准确性。

  1. 为注意力层提供了多个“表示子空间”。

[!NOTE] 什么是“表示子空间”?
“表示子空间”(representation subspace)是机器学习和特别是深度学习中一个重要概念,指的是数据在特定的向量空间中的表达形式,这个向量空间由特定的维度和方向构成。在自然语言处理和其他任务中,不同的表示子空间可以捕获数据的不同方面或特征。通过将数据投影到较低维度的子空间,我们可以减少计算复杂性,同时保留数据的关键特征。

正如下面图片所展示的那样,通过multi-headed attention,我们可以使用多组不同的 W Q W_Q WQ , W K W_K WK , W V W_V WV 权重矩阵来增强模型的处理能力。在原论文中,Transformer 使用八个注意力头,因此我们最终为每个编码器/解码器提供八组 W Q W_Q WQ , W K W_K WK , W V W_V WV 权重矩阵,这些权重矩阵在刚开始时随机初始化的。

请添加图片描述

注:通过多头注意力,我们为每个头维护单独的 Q/K/V 权重矩阵,从而产生不同的 Q/K/V 矩阵。正如我们之前所做的那样,我们将 X X X 乘以 W Q W_Q WQ , W K W_K WK , W V W_V WV 矩阵以生成 Q/K/V 矩阵。

如果我们按照上面所述的自注意力计算方法,使用八组不同的权重矩阵进行八次计算,对于我们输入进去的所有词嵌入向量,它们各自最终都会得到属于自己的八个不同的 Z Z Z 矩阵。如下图的 Z 0 Z_0 Z0 , Z 1 Z_1 Z1 , Z 2 Z_2 Z2 Z 7 Z_7 Z7 :
请添加图片描述

这就带来了一个挑战:
前馈层(FFNN)并不是期望接收八个矩阵——它期望的是单个矩阵(每个词嵌入向量对应一个能输入FFNN的向量,而不是8个)。
因此,我们需要一种方法将这八个矩阵压缩成一个单一的矩阵。我们该如何做呢?
我们将 Z 0 Z_0 Z0 , Z 1 Z_1 Z1 , Z 2 Z_2 Z2 Z 7 Z_7 Z7 这些矩阵拼接起来,然后用一个额外的权重矩阵 W O W_O WO进行矩阵乘法运算。
如图:
请添加图片描述

以上就是Multi-headed Attention的所有内容,它其中是很多的矩阵运算,现在我们将他们放到一起,以便观察:
请添加图片描述

现在,让我们来回顾一下之前的示例,看看当我们在示例句子中对单词“it”进行编码时,不同的注意力头聚焦在哪里:
请添加图片描述

注:图上的黄色(颜色淡和深)和绿色(颜色淡和深)分别代表两个 Z Z Z 矩阵的值,矩阵中某处的值越大,颜色越深,也就是注意力值越大。在编码词“it”时,模型中的一个注意力头(黄色)主要关注了the animal,而另一个头(绿色)则关注了tired。这意味着在模型内部,代词it的表示不仅仅由它自身的信息构成,还融入了与the animaltired有关的信息。

然而,如果我们将所有注意力头添加到图片中,事情可能会更难以解释,因为没法区分输入序列的顺序:
请添加图片描述

The Order of The Sequence Using Positional Encoding

正如我们到目前为止所描述的,模型中缺少的一件事:一种解释输入序列中单词顺序的方法。
为了让模型能够理解序列中每个词的位置,以及不同词之间的距离,Transformer在每个输入嵌入(embedding)向量中加入了一个位置向量。通过将这些位置向量添加到每个词的嵌入向量上,每个词的表示就不仅包含了关于词本身的信息,还包含了其在句子中的位置信息。这样一来,当这些加入了位置信息的嵌入向量被进一步映射成QueryKeyValue向量,然后在点积注意力得分计算中使用时,模型就能够识别出词与词之间的相对或绝对位置。
请添加图片描述

注:为了让模型了解单词的顺序,我们添加了位置编码向量——其值遵循特定的模式

如果我们假设词嵌入向量的维数为 4,则实际的位置编码将如下所示:
请添加图片描述

注:这个维度为4的小向量编码的真实案例

这个特定的模式是什么样子的呢?
下图中,每一行对应一个向量的位置编码。因此,第一行将是我们添加到输入序列中第一个单词的嵌入中的向量。每行包含 512 个值 ——每个值都在 1-1 之间。我们对它们进行了颜色编码,以便图案可见。
请添加图片描述

词嵌入大小为 512(列)的 20 个单词(行)的位置编码的真实示例。可以看到它从中心分成两半。这是因为左半部分的值是由一个函数(使用正弦)生成的,右半部分是由另一个函数(使用余弦)生成的。然后将它们连接起来形成每个位置编码向量。

位置编码是通过正弦和余弦函数的不同频率来生成的,使得每个位置的编码是唯一的,并且对于任意固定的偏移量,位置编码可以线性地表达位置间的距离。
具体来说,第 i i i 个位置的编码中的第 2 j 2j 2j 个元素可以表示为:
P E ( p o s , 2 j ) = sin ⁡ ( p o s / 1000 0 2 j / d m o d e l ) P E _ { ( p o s , 2 j ) } = \sin ( p o s / 1 0 0 0 0 ^ { 2 j / d _ { m o d e l } } ) PE(pos,2j)=sin(pos/100002j/dmodel)
2 j + 1 2j+1 2j+1 个元素则表示为:
P E ( p o s , 2 j + 1 ) = cos ⁡ ( p o s / 1000 0 2 j / d m o d e l ) P E _ { ( p o s , 2 j + 1 ) } = \cos ( p o s / 1 0 0 0 0 ^ { 2 j / d _ { m o d e l } } ) PE(pos,2j+1)=cos(pos/100002j/dmodel)
这里 p o s pos pos 的是词在序列中的位置, j j j 是编码中的维度索引, d m o d e l d_{model} dmodel 是模型的维度。

The Residuals

在继续之前,我们需要提及编码器架构中的一个细节,即每个编码器中的每个子层(self-attention layerFFNN)周围都有一个残差连接,并且后面是层归一化步骤。
请添加图片描述

如果我们可视化与自注意力相关的向量和层数操作,它看起来像这样:
请添加图片描述

这也适用于解码器的子层。如果我们考虑一个由 2 个堆叠编码器和解码器组成的 Transformer,它看起来会是这样的:
请添加图片描述

The Decoder Side

现在我们已经涵盖了编码器方面的大部分概念,我们基本上也知道了解码器的组件是如何工作的。但让我们看看它们是如何协同工作的。
编码器首先处理输入序列。然后,顶部编码器的输出被转换为一组注意力向量 KV。这些向量将由每个解码器在其编码器-解码器注意力层(也称为交叉注意力(cross-attention))中使用,这有助于解码器关注输入序列中的适当位置:
请添加图片描述

注:完成编码阶段后,我们开始解码阶段。解码阶段的每个步骤都会输出输出序列中的一个元素(本例中为英文翻译句子)。

编码器-解码器注意力层

现在,我们来详细解释编码器-解码器注意力层
我们分为两部分进行解释:

  1. 编码器到解码器的注意力
  • 编码器通过自注意力机制处理输入序列,并产生一系列的输出向量,这些向量包含了输入序列的信息。
  • 这些输出向量作为编码器的最终输出,会被传递给解码器的每一层。
  1. 解码器的工作过程
  • 对于第一个解码器来说,由于它没有上一层的输出,其输入通常是序列的开始符号(比如一个特殊的开始符<sos>),它的任务是开始生成输出序列。
  • 第一个解码器在接收到开始符号的嵌入向量和位置编码后,会利用这些信息与编码器的输出进行交叉注意力计算。
  • 在交叉注意力阶段,解码器的Query来自于它自身的上一步输出,而Key和Value来自编码器的输出。这样可以使得解码器在生成下一个词时,能够考虑到输入序列的内容。
  • 解码器的每一步输出都会被用作下一步的输入,同时会结合编码器的输出来继续生成序列。
    上述的可视化如下图:
    请添加图片描述

[!NOTE] 可视化详解释
编码器阶段

  • 输入序列(例如法语中的Je suis étudiant)经过嵌入和位置编码后,被送入编码器。
  • 编码器通过一系列自注意力和前馈网络的层处理输入,并最终产生一系列的KeyValue向量,这些向量是对输入序列的编码表示。

解码器的第一步

  • 在开始翻译之前,解码器接收一个开始符号<sos>作为其初始输入。
  • 这个开始符号也经过嵌入和位置编码处理,以生成它的嵌入向量。
  • 解码器使用这个向量生成Query,并结合编码器输出的KeyValue进行交叉注意力计算。
  • 通过这个过程,解码器注意到了编码器输出中的相关信息,并生成了当前步骤的输出。

生成下一个预测词

  • 解码器的输出经过线性变换Softmax激活函数,产生一个概率分布,表示下一个词的预测。
  • 在这个例子中,解码器预测了英语中的词I

继续解码过程

  • 预测出的词I将作为下一个解码步骤的输入,同样会被嵌入和加上位置编码。
  • 这个新的输入将进入解码器的下一个时间步,同编码器的KeyValue结合,继续生成翻译序列,然后预测出了am,然后再把am作为输入。
  • 在每个时间步骤中,解码器会继续这个过程,一直到它生成了特殊的结束符号(如<end of sentence>),表明翻译输出已经完成。

这样,Transformer模型可以逐词地生成整个翻译序列。

“编码器-解码器注意力”层的工作方式与多头自注意力类似,只不过它从其下面的层创建查询矩阵,并从编码器堆栈的输出中获取键和值矩阵。

The Final Linear and Softmax Layer

解码器堆栈输出为浮点数的向量。那我们如何把它变成一个词?这就是最后一个 LinearSoftmax Layer的工作 。
线性层是一个简单的全连接神经网络,它将解码器堆栈产生的向量投影到一个更大的向量中,称为 logits向量。这个向量的长度和词汇表一样长,这样才能一一对应索引编号。

[!question] 什么是词汇表?
词汇表是指模型被设计和训练来识别和处理这个固定大小的单词集合。具体来说:

构建词汇表:在训练之前,我们首先需要构建一个词汇表。这个词汇表基于训练数据集中的所有单词,并且通常会包含数据集中最常见的单词。

词汇索引:词汇表中的每个单词会被分配一个唯一的索引编号。在模型的操作中,每个单词都会通过这个索引来表示。

训练过程:模型在训练过程中使用这个词汇表来处理文本数据。输入文本会被转换成词汇表索引的序列,模型的任务是学习如何将这些索引的序列转换成正确的输出,无论是在分类、翻译还是其他任务中。
例子:

  • 输入句子:I like apple
  • 词汇表索引对应关系:I -> 1, like -> 2, apple -> 3
  • 转换过程是这样的:
  • 输入句子中的单词根据词汇表被转换成索引:I 转换为 1like 转换为 2apple转换为 3
  • 转换后的索引序列:[1, 2, 3]

学习表示:通过训练,模型学习到每个索引的嵌入表示(embedding),这些表示是训练过程中自动学到的高维向量,能够捕捉单词的语义信息。

预测时使用词汇表:在生成文本或翻译时,模型会预测序列中下一个单词的索引,然后从词汇表中查找对应的单词。词汇表充当了模型输出空间的==“查找表”==。

通过线性层之后,模型接着使用Softmax函数来将logits向量中的分数转换成概率。Softmax确保所有的输出值都是正数,并且它们的总和为1,这样这些值就可以被当作概率来理解。然后,模型选择概率最高的单词作为这个时间步的输出。

请添加图片描述

注:该图从底部开始,将解码器堆栈输出的向量输入线性层,然后在经过softmax函数将其转换为词汇表中每个词对应的概率,最终选择概率最高的那个词作为最终的预测输出。

Recap Of Training

既然我们已经讨论了经过训练的Transformer模型的整个前向传递过程,回顾一下训练模型的直观理念会很有帮助。
在训练过程中,一个未训练的模型会执行与之前相同的前向传递。但由于我们是在一个带标签的训练数据集上进行训练,我们可以将它的输出与实际正确的输出进行比较。 为了形象化这一点,假设我们的输出词汇表仅包含六个单词(“a”、“am”、“i”、“thanks”、“student"以及”<eos>"(代表“句子结束”))。
请添加图片描述

注:模型的输出词汇是在我们开始训练之前的预处理阶段创建的

一旦我们定义了输出词汇表,我们就可以使用相同宽度的向量来表示词汇表中的每个单词。这也称为 one-hot 编码。例如,我们可以使用以下向量表示单词“am”:
请添加图片描述

示例:输出词汇的 one-hot 编码

在回顾之后,让我们讨论模型的损失函数——我们在训练阶段优化的指标,以形成经过训练的、希望非常准确的模型。

The Loss Function

假设我们正在训练我们的模型,这是我们训练阶段的第一步,我们正在用一个简单的例子来训练它——将“merci”翻译成“thanks”。
请添加图片描述

注:由于模型的参数(权重)都是随机初始化的,(未经训练的)模型会生成每个单元格/单词具有任意值的概率分布。我们可以将其与实际输出进行比较,然后使用反向传播调整所有模型的权重,以使输出更接近所需的输出。
如图上所示,未训练的模型输出分别对应aamthanksstudent的概率都为0.2,但我们希望正确的输出为thanks,其概率为1

如何比较两个概率分布?我们只需将其中一个减去另一个即可。有关更多详细信息,请查看交叉熵Visual Information Theory – colah’s blog和 Kullback-Leibler 散度Kullback-Leibler Divergence Explained — Count Bayesie

但请注意,这是一个过于简单化的示例。现实中,我们将使用比一个单词长的句子。例如输入:“je suis étudiant”,预期输出:“i am a student”。这真正的意思是,我们希望我们的模型能够连续输出概率分布,其中:

  • 每个概率分布由宽度 vocab_size 的向量表示(在我们的示例中为 6,但更实际的是 30,000 或 50,000 等数字)。
  • 第一个概率分布在与单词“i”相关的单元格中具有最高概率。
  • 第二个概率分布在与单词“am”相关的单元格中具有最高概率。
  • 依此类推,直到第五个输出分布指示“ <end of sentence> ”符号,该符号也有一个来自 10,000 个元素词汇表的与其关联的单元格。

请添加图片描述

在足够大的数据集上训练模型足够长的时间后,我们希望生成的概率分布如下所示:

请添加图片描述

注:希望经过训练,该模型能够输出我们期望的正确翻译。当然,这并不能真正表明该短语是否是训练数据集的一部分(请参阅:K则交叉验证:K-Fold Cross Validation - Intro to Machine Learning - YouTube)。请注意,每个位置都会有一点概率,即使它不太可能是该时间步的输出——这是 softmax 的一个非常有用的属性,有助于训练过程。

至此,以上就是Transformer的所有内容。

原文来自Site Unreachable,本文详细解释并可视化了该原文和原论文(Attention Is All You Need)中那些难以理解的部分,旨在帮助读者更深入地理解Transformer的结构和工作原理。

  • 39
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值