温馨提示:本文共有8472个字,平均阅读时间约为34分钟
大家可以快速查看自己感兴趣的内容点击下面的目录:
目录
- 模型简介
- 整体架构
- Encoder结构
- 输入阶段
- 输入嵌入(Input Embedding)
- 位置编码(Position Encoding)
- 输入向量构建
- Attention结构
- 自注意力机制 Self-Attention 和 缩放点积注意力 Scaled Dot-Product Attention
- 第一步 生成QKV
- 第二步 计算注意力分数
- 第三步 缩放与Softmax
- 第四步 加权得到输出
- 多头(自)注意力机制 Multi-Head Attention
- Encoder结构中的多头注意力的输出
- 残差连接与层归一化 Add&Normalize
- 前馈神经网络 Feed Forward
- Encoder的流程与训练
- Decoder结构(训练)
- 输入阶段
- 目标序列的右移
- 掩码多头注意力机制
- Decoder的第二个多头注意力模块
- Decoder的流程与训练
- 输出
- Decoder结构(推理)
最近肝大模型综述和时序transformer相关的工作太多了,回头看似乎这个最基础的结构似乎还是有点忘得差不多了,所以抽出一个下午时间简单地做了一个简单的模型结构拆分,用了我最通俗的语言进行一个简单的解释吧。
模型简介
作为机器学习领域必读的经典模型,Transformer模型首次提出于《Atttention is all you need》这篇论文中。
最早应用于NLP领域,作为文字处理任务的一个解决方法。后来被引申应用于图像、时序等领域,并为大语言模型的构建提供了基础。
灵感来自于传统的“电力变压器”结构(Transformer),不是变形金刚电影(也叫Transformers)
整体架构
我们首先举一个例子,我们从整体的流程开始走一遍,从这个过程中了解transformer的架构构成。
假如我们的输入是“我”,“是”, “人”三个汉字,并且想使用它预测我下一个字,我们将这个输入的一个中文序列作为输入X
Encoder结构
Transformer的第一层架构Encoder结构,它的主要任务就是理解我们现在输入的向量内容,并且将其转化为一个中间表示。
输入阶段
输入嵌入(Input Embedding)
首先第一步,模型无法直接读取中文,我们首先要将这个中文序列转换为计算机可以认识的内容,这一步就是输入嵌入(IE)过程。在这个过程中,模型会使用词嵌入的方式将序列转化到一个向量。
通俗理解
transformer翻开词典,找到你输入的词,并且翻译成自己认识的数学语言
原文理解
在这里使用了一个可学习的嵌入矩阵,大小为[vacabulary_size * d_model](在原文中d_model尺寸为512)
每个词被映射为d_model维的向量,这个结果会被乘以\(\sqrt{d\_model}\)进行缩放
位置编码(Position Encoding)
众所周知我们现在的中文一般是从左往右进行阅读的,但是对于古人或者外国人(尤其是一些中东国家)来说,他们阅读和书写的顺序可能是从上到下或者从右到左。
所以对于模型来说,我们也需要对他说明这些输入的词语位置,方便他理解上下文信息。所以这里我们就需要用到位置编码(PE)了。
通俗理解
我们通过标注的方式,给每个词标记了相对位置,并且特别标注了“哪个词与哪个词意思相近”。比如说我输入了“男”、“树”和“女”,可能这里就把“男”和“女”标注意思相近,方便模型理解。
原文理解
Transformer结构与RNN不同,不能顺序读取序列,所以需要标注清楚每个词在句子中的位置
Transformer的位置编码使用了固定相对位置编码,运用了正弦函数和余弦函数共同标注词的位置
\(PE_{ (pos, 2i)} = sin(pos / 10000^{(2i/d\_model)})\)
\(PE_{ (pos, 2i+1)} = cos(pos / 10000^{(2i/d\_model)})\)
其中,pos是表示词语在句子里面的位置,i是索引维度(奇偶维度分开),d_model是模型维度
运用正余弦定理,任意相对位置都可以通过某一位置相加计算得到。
输入向量构建
经过上述的变化之后,我们的中文序列就转化为了可以被模型认识的向量表示了。最终的输入表示 X就由词嵌入 和位置编码 相加得到。
Attention结构
作为整个Transformer结构最为重要的部分之一(从论文名字就能看出来吧),看见他的名字我们就可以看出来,它的主要作用就是发掘序列中值得模型重点关注的地方。那么他是如何工作的,我们接下来看一看吧。
自注意力机制 Self-Attention 和 缩放点积注意力 Scaled Dot-Product Attention
PS:这里提到的缩放点积注意力是所有注意力机制数学化的基础计算原理(文中说明的),而自注意力机制则是其最基础的表现形式。很多人会把它们混用,但在技术严谨性上,它们是“机制”与“实现”的关系。
作为最基础的注意力单元,也是模型中所有注意力结构的最基础结构,按照缩放点积注意力构建的自注意力单元已经成为Transformer中最为独特的创新点。要想理解它的工作原理,我们这里还是拿我们已经经过构建的输入向量X做一个比喻吧。
我们暂且认为经过变换后的X仍然表示这“我”,“是”,“人”这三个词吧。
第一步 生成QKV
用到这个结构的时候,我们需要将我们的输入序列进行拆分,拆分为Q、K、V三个向量。
- Q(query)查询:“我在找什么”。在句子中,当前词想要了解自己与其他词与自己的关系。
- K(Key)键值:“我是谁”。在句子中,相当于自己的“标签”,每个词向外界展示的特征,用于匹配查询。
- V(value)数值:“我的内容是什么”。在句子中,每个词真正要传达的信息.
通俗理解:
想象注意力机制就是一个人在翻字典,就比如说输入了“我是人”这个句子,他翻到了“我”这个字:
Q就是在想知道“我”可以组成什么词语(或者和哪个词语关系大);
K就是每个词的页码;
V就是这个每个词在词典中对应的解释。
这样分开式地处理,让QK可以更加专注于词语的匹配,而V只需考虑信息的传递,避免了很多问题(自相关等)。
那么我们如何得到这三个主要的向量呢,在这里,输入向量X需要通过3个不同的线性变换矩阵得到Q、K、V,也就是:
Q = X·W_Q,K = X·W_K,V = X·W_V
这些权重矩阵W_Q、W_K、W_V是模型训练过程中学习得到的
第二步 计算注意力分数
了解了通过Q、K、V这样一个可以快速计算词与词之间关系的方法,我们就需要通过这一步计算每个词与每个词之间的关系,也就是计算注意力分数。
在这个阶段,一般是通过将所有词向量拼接为一个大的向量统一进行运算,但是这里方便理解我们就还是将每个词向量进行拆分计算吧。
所以在这一步我们需要计算每个词的Query和所有词Key的相似度:\(Q * K^T\)。相当于衡量"每个词想了解的内容"(\(Q\))与"其他词提供的特征"(\(K^T\))的匹配程度。
通俗理解:
这一步就好理解了。我们将“我是人”的Q * K^T简化为\([Q_1, Q_2, Q_3] * [K_1,K_2,K_3]^T\)(T为转置)。
\(Q * K^T\)这个过程就相当于:
注意力机制开始翻字典,当前的“我”字在第22页。他首先翻到了第33页,翻到了“是”这个词,看了看下面的解释,感觉他们语序之间有关系,那么这个\(Q_1*K_2\)计算结果就赋予一个0.5的相关性。
接下来它又翻到了55页,看到了“人”这个词,感觉他们语序之间有关系但不多,那么这个\(Q_1*K_3\)计算结果就赋予一个0.3的相关性。
按照这个顺序,就可以计算其他不同词之间的关系,最后统一得到\(Q * K^T\)的结果。也就是每个词与所有词只见的相似度。
第三步 缩放与Softmax
为了方式数值过大,这里使用了一个缩放。使用当前计算分数除以$\sqrt{d_k} $(d_k是Key的维度)防止数值过大。
这里有的文章说可以使用其他的值作为分母进行缩放,本质上都是为了防止内积过大的操作
然后通过softmax将这个分数转化为一个概率分布,所得到的结果就是注意力分布。
通俗理解:
一个不太恰当但浅显的例子,比如说得到的最终分数为1,2,3。那么经过softmax之后就变成了1/6,2/6,3/6,得到了注意力权重就是0.17,0.33,0.5这样子变成一个概率分布。
第四步 加权得到输出
最后使用注意力权重与每个词的V进行加权求和:\(Output=Attention\_weight * V\),这样的输出就可以让我们的向量本身包含更加丰富的语义信息。
通俗理解:
就拿上面的“我”这个词来说,比如说它对于“是”和“人”的注意力权重分别是0.5和0.3(现实应该是一个矩阵,这里方便理解化为一维),那么“我”这个词就会根据注意力权重有选择地学习到“是”和“人”的语义信息,成为了一个与“是”和“人”上下文信息的全新的“我”向量。
这样一个self-attention结构,就让每个词语可以学习到与自己相关的词语的上下文信息。回头我们再看它的整个公式,我们就可以更加清晰地理解其中的意思。
多头(自)注意力机制 Multi-Head Attention
在原文结构中,我们可以看到我们的输入序列进入的Encoder结构中遇到的第一个处理就是这个多头注意力,那么这个多头注意力和我们上述提到的自注意力机制有什么区别呢?
正如我们上文所说的那样,所谓的多头注意力机制就是由多个自注意力合并而来。
那么具体它的结构到底是什么,我们接下来会进行一个更加详细的解释。
多的“头”是什么
首先相信大家也很好奇这里面所谓的头指的是什么。我们继续将所谓的自注意力机制看为一个翻字典的人。
有的时候我们在解决问题的时候,每个人都有不同的看法,包括我们翻字典这件事情来说也是一样的。有的人认为“我”和“是”关系比较密切,有的人认为“我”和“人”的关系比较密切。
所以这个时候,我们就需要不同的人来一起翻字典,来让结果尽可能地“服众”(或者说趋于一个一致的结果)。
那么这个时候我们就构成了一个“多头”的概念。
通俗解释:
不同的翻字典的人得到自己对于句子的理解后,汇总起来出一个比较统一的结果。不同的翻字典的人就被视为“头”,也就是从不同的角度出发。
有的“头”从语言学的角度看“我是人”这个句子。“我”是主语,应该后面连上“是”才能使句子通畅,所以“我”和“是”注意力分数高;
有的“头”从生物学的角度看“我是人”这个句子。“我”在生物学标准上属于动物,所以“我”和“人”注意力分数高。
……
等所有的“头”处理完之后,将所有的结果汇总起来得到最终我们的输入句子“我是人”的注意力结果。
原文解释:
在原文中,一共分为了8个注意力头,每个头独立计算自己的注意力。
不同的是,他们独立随机初始化属于自己的QKV权重,并独立计算注意力,使得每个注意力头可以关注不同语义信息。
\(head_i = Attention(Q·W^Q_i, K·W^K_i, V·W^V_i)\)
这里的i指代的对应的头
最终将8个头的64维输出拼接成512维向量,并通过线性变换调整维度为与输入X相同的维度。
\(MultiHead = Concat(head_1,...,head_8)·W^O\)
多头的作用
为什么要将多个自注意力拼接起来使用呢?我觉得有着以下的原因:
- 使得模型可以对Q、K、V不同的向量进行更加细化深入的构建,每个"头"专注一种特定关系类型。
- 多个自注意力的随机初始化可以消除偏差的影响,让词与词之间的学习更加丰富。
原文中对于整个过程的定义为:
和我们分析的一致。
Encoder结构中的多头注意力的输出
最终,我们得到了一个和输入的向量X大小维度相同的输出变量Z。
这个Z变量和输入的X相比,它不仅包含了句子“我是人”的所有信息,还包含了每个词之间的注意力关系等丰富的语义。
残差连接与层归一化 Add&Normalize
在Encoder的多头注意力之后,向量进入了一个Add&Normalize层。它由残差连接和层归一化两部分组成。其实在Encoder结构中,在每次大型操作后(多头注意力操作、前馈神经网络)都会进行一次这个层。
下面我们用多头注意力操作后的Add&Normalize举一个例子,看一看它的具体结构和操作。它的公式也很简单:
\[Output = X + Attention(X)\]
\[LayerNorm(Output)\]
残差连接
根据公式可得,是一个非常简单的过程。
\[Output = X + Attention(X)\]
简单来说:把多头注意力之前的输入X和经过注意力处理的输出Z相加。
那么为什么要这么做呢,我们明明都处理好了,给句子加上了注意力,为什么还要加上没有经过注意力处理的句子呢?
通俗理解:
给句子加一个存档,我们可以通过比对存档让我们知道目前句子经过了哪些修改,防止经过大型操作之后句子的某些信息丢失。
原文理解:
我们首先要了解:神经网络退化指的是在达到最优网络层数之后,神经网络还在继续训练导致Loss增大。
如果没有残差链接,在训练时梯度会越来越小直至饱和,训练也会越来越困难。而有了残差链接后,可以有效解决梯度消失的问题。
残差连接后,可以让网络更加专注于存在差异的部分进行训练。
同样的,即使子层学习效果不佳,也能保证至少保留原始输入信息。
层归一化
层归一化是一个比较通用的技术,相当于通过全览向量信息之后,将所有向量整理到一个相同的水平上,方便后续操作。
\[LayerNorm(Output)\]
层归一化本身的公式:\(LayerNorm(x) = γ * (x - μ) / √(σ² + ε) + β\)。其中μ是均值,σ²是方差。γ和β是可学习的缩放和平移参数。ε是小常数,防止除零错误。
通俗理解:
对当前句子“我是人”这句话的字体统一设成“微软雅黑”,22字号。
原文理解:
这里的归一化,是对于当前样本的所有特征进行归一化。(区别于批归一化:对同一批次的不同样本的同一特征归一化)
归一化使优化曲面更平滑,梯度下降更高效。
通过这一层后,所有的词向量变得更加适合模型操作了。
前馈神经网络 Feed Forward
这一层主要是由两层全连接层构成,它的公式很简单:
\[FFN(X)=max(0,xW_1+b_1)W_2+b_2\]
讲解一下这个网络,X就是上述我们经过多头注意力和Add&Norm的输入词”我是人“的向量表示。它的作用就是将我们的向量转化为一个更高维度的向量进行语义特征联系,然后在恢复到原来维度。
第一层网络就是一个很简单的线性函数\(f(x) = (xW_1+b_1)\),其中\(W_1\)是第一层的权重,它的维度为\((d_model×d_ff)\),一般这个d_ff是d_model的4倍,它的作用就是将我们的词向量映射到更高维度。
通俗解释:
将句子”我是人“的每个词拆解成拼音”wo shi ren“,并且将每个词的笔画拆解,模型觉得他们之间可能存在更多的关系。
然后通过一个ReLU函数,\(f(x) = max(0,xW_1+b_1)\) 来进行非线性的引入,将强特征增强,抑制弱特征,去除杂项。
最后再回复到原有的维度\(f(x)=xW_2+b_2\) ,这里的W_2就是第二层的权重,它的权重就是$(d_ff×d_model) $ 让向量回归正常维度。
这一项的意义
多头注意力的本质上只是线性的加权,针对语义来说,可能只学习到了基础的上下文关联关系。而前馈网络项最为重要的升维+ReLU则是给这个向量带来更多非线性特征的学习能力。
通俗理解:
通过这一步,我们的模型学习再学习了上下文关系后,还能学到:
情感强度特征
语义角色特征
时态特征
与其他词的复杂关系
通过ReLU激活,只保留有意义的特征组合
Encoder的流程与训练
经过了我们的多头注意力机制——>Add&Norm——>Feed Forward——>Add&Norm 这样的一个结构,就构成了我们大名鼎鼎的Encoder结构。
在原文的结构中,我们的模型通过了6层堆叠的Encoder架构进行学习。也就是说,这一层Encoder结构的输出,会被作为下一层Encoder架构的输入,循环6次。
通过这个过程,我们的模型对于”我是人“这个句子的理解到达了”空前的高度“,那么接下来,就需要完成它的预测任务了,根据他的”理解“来生成了。
Decoder结构(训练)
有了Encoder结构,那肯定也有Decoder结构。在这个结构中,它的组成部分和Encoder有些类似,但是仍有部分不同:
- 包含两个多头注意力机制
- 第一个多头注意力使用了掩码操作,构成了掩码多头注意力机制
- 第二个多头注意力的Q使用了第一个多头注意力的输出,而它的K和V则是使用了Encoder的输出。
- 最后使用了Softmax来计算可能词语的概率。
PS:小建议,最好把Encoder和Decoder看作两个完全独立的工厂,Encoder看作原材料工厂,Decoder看作加工工厂。
接下来我们继续按照Encoder的分析方式,从输入到输出分析一下Decoder的工作原理。
输入阶段
相较于Encoder的输入,Decoder有两个输入源。
- 1. 目标序列的右移。
- 1. Encoder的输出。
复制代码 Encoder的输出好理解,那么目标序列的右移是什么玩意儿?
目标序列的右移
我们继续发挥想象力,先不看Decoder的整体结构,而是直接先看这个流程(Encoder-->Decoder-->输出)。
通俗理解:
Decoder拿到Encoder处理好的”我是人“的句子,在训练时,我们希望让他明白下一个字是”类“字(即”我是人类“这个句子)。
我们不是把完整的答案”我是人类“ 直接给解码器,而是将最后一个词给盖住,让他去猜。就是给他”我是人“这个句子,让他去猜下一个词,直到猜对来训练他。
原文理解:
Encoder处理完整输入序列 [“我”, “是”, “人”],并输出一个包含所有信息的“上下文向量”。
在训练的时候,我们不是把完整的答案 [“我”, “是”, “人”,“类”] 直接给解码器。而是制作一个“右移”的版本:[, “我”, “是”,“人”] 作为解码器的输入。
Decoder接收 [, “我”, “是”,“人”] 和编码器的上下文向量。解码器基于这些信息,一步步地计算输出。
Decoder尝试预测下一个词,我们希望它的第一个输出是 “我”,第二个输出是 “是”,第三个输出是 “人”,第四个输出是“类”。
我们将解码器的预测结果 ([“我”, “是”, “人”,“X”]) 与真正的答案 ([“我”, “是”, “人”,“类”]) 进行比较,计算损失并更新模型权重。
我们可以对比看到每次Decoder的输入和输出,就可以发现一个比较明显的右移现象。模型故意把解码器的输入(目标语句)整体向右移动了一位,目的是为了“欺骗”模型,让它学会根据“已经生成的词”来预测“下一个词”。
掩码多头注意力机制
我们首先将上面的右移的目标语句作为输入记为\(X_1\) ,他经过经典的位置编码后,传入到了一个特殊的多头注意力机制——掩码多头注意力机制中。
相较于普通的多头注意力机制,它多加了一层名为掩码的操作。
掩码
掩码,顾名思义,是要掩盖一些东西。正如它的名字一样,掩码的作用就是在训练时将一些词给“盖住”,不让注意力机制注意到,或者说模型给训练到。
它的原理解释起来也很简单,就是在预测或者翻译的过程中,不管是我们人来还是让模型来,都是要一句一句来顺序学习的。所以掩码的作用就是:掩盖住当前学习词后面的词语,防止模型过早地知道“答案”。
通俗理解:
举个例子,模型在学习“我是人类”这个句子的时候,当前他的输入只是“我是人”。
为了防止模型过早学习到这个答案,就会用掩码将“类”掩盖起来,让模型先去猜(推测出下一个词)
具体的结构上,我们在训练时,我们需要构造一个大小为k*k的下三角的单位矩阵(下三角为1,其余为-∞),k为输入词序列的长度。这样就可以实现将当前词后续的内容给掩盖掉的效果,让模型暂时不能学习到。
所以这个掩码是放在\(Q * K^T\)计算后,与V相乘之前参与运算的。
掩码类型
为什么我在说明了掩码多头注意力机制后再去讲掩码类型呢,因为其实在注意力机制中,不只一种掩码存在,其实在之前我们已经使用了掩码。
在之前的普通多头注意力中,我们就使用了一种名叫padding mask的掩码技术。
在我们之前计算所有词的Q和K的相似度的时候进行了\(Q * K^T\)的计算。在实际的训练中,我们不可能一条一条的语料让模型处理,当然是把所有语料都放给模型去训练,这个时候难免会出现句子长度不一的情况。那么这个时候,padding mask就起到了一个填充的作用。
它的用法很简单,打个比方就是给短的句子后面填充0让其长度变长。这个时候计算\(Q * K^T\)的时候就可以进行运算了。当然在进行学习的时候,填充的内容当然不是我们想让模型学习到的东西,所以就相当于这部分是一个掩码信息不让模型进行学习。
掩码多头注意力的输出
这个时候,我们也是和Encoder的多头注意力模块一样,拼接各个“头”的掩码输出成为一个输出Z,当然这个输出和输入的向量维度相同。
Decoder的第二个多头注意力模块
在经过了掩码多头注意力的输出与Add&Norm之后,就到了第二个Decoder的多头注意力模块。这个多头注意力看上去跟Encoder的架构一样,但是它的输入却很奇怪。
他的Q来自于同Decoder的掩码多头注意力机制中的输出,而他的K和V则是来自于Encoder层的输出**。
通俗理解:
我们得到了当前的词语“我是人”和盖住的答案,要预测下一个词。它好奇根据语境的话下个词应该是什么(Q)
看看Encoder给出的原文,他开始查找一些关键信息
Q = [D_我, D_是, D_人] ← 来自Decoder的疑问
K = [E_我, E_是, E_人] ← Encoder提供的"关键词"
V = [E_我, E_是, E_人] ← Encoder提供的"详细解释"
当处理最后一个字“人”的时候,它的内心OS(D_人)
"在中文'我是人'这个语境中,'人'具体指什么?"
Encoder通过注意力权重回答:
"主要看'人'本身(70%),其次看'是'(20%),'我'影响较小(10%)"
这样,模型可能就理解了包含了"人"在判断句中的特殊含义,识别出这不是单独的"人",而是"人类"概念。
就是这样一个过程,让Decoder可以理解Encoder指示的内容去进行判断。也是这个第二个多头注意力机制所关注的内容。
Decoder的流程与训练
经过了我们的掩码多头注意力机制——>Add&Norm——>多头注意力机制——>Add&Norm——>Feed Forward——>Add&Norm 这样的一个结构,就构成了Decoder的结构。
和Encoder一样,上面的Decoder过程也是反复进行了6次,Decoder已经对当前的这个词语已经完全理解了,现在的用处就是让他学会“说话”——也就是输出自己的答案。
输出
在这个部分,我们首先对上面Decoder的输出进行一个表示:\(Output Z ∈ ℝ^(batch_size × seq_len × d_model)\) ,每个位置的向量已包含完整上下文信息,而最后一个位置的向量最"富含"预测下一个词的信息。
首先,我们需要将输出结果Z的最后一个位置进行预测。经过一次线性变换(全连接神经网络)和概率分布变换。通过这一个线性层,我们将这个结果的输出向量投影到一个词汇空间。
在这个维度上,模型对每个可能的词计算一个"匹配分数"(logit),分数越高,表示该词越可能出现在当前位置。然后,将这个结果通过Softmax将对应的分数转化为概率。
想象一下,Decoder将自己的输出去查字典去了。然后比对词汇表的每一个词,看哪个词是概率最高的下一个词。这个字典是一开始训练的时候,模型根据学习的预料数据自己学习统计的。
这样,我们的模型得到了下一个概率最高的字进行输出。这样下来,就算模型得到了自己的学习结果。如果实在训练过程中,我们需要对这个过程进行批改和纠正,就是让他和原始的训练数据“我是人类”进行loss计算,从而反复进行训练,直到达到最好的loss值。
这样就是一个完整的Decoder训练过程。
Decoder结构(推理)
当我们已经训练好一个Transformer架构了,我们现在想要使用它进行预测。我们的输入还是“我是人”。这样,Encoder已经完成了语义的分析与注意力关注,接下来的重点就是Decoder的推理了。那么它可能是:
通俗理解:
我们的Decoder相当于一个预言家,它开始拿到我们Encoder给他处理好的句子”我是人“。此时Decoder已经学会了预测的方法(参透了符文的力量哈哈)。
它开始一个词一个词的预测,先是从”类“开始。然后它再猜下一个词是什么,它隐隐约约觉得下一个词好像是”你“……
最后,Decoder下定决心,它感觉有一股冥冥之中的旨意告诉他,让他根据自己的训练数据生成一段完整的话:
“我是人类你是人吗?”
原文理解:
在原文中,训练好的Decoder从起止符开始进行预测,它已经通过自回归学习让它从盲猜中找到规律。
开始:给定一个起始符、“我”、“是” 、”人“(输入为”,我,是,人“),预测第一个词 “类”。(输出为”我是人类“)
迭代:将、“我”、“是” 、”人“ 和 “类” 一起输入(输入为”,我,是,人,类“),预测下一个词 “你”。(输出为”我是人类你“)
……
结束:直到预测出结束符,生成结束。(输出为“我是人类你是人吗?”)
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |