当前位置:   article > 正文

Pytorch搭建Transformer_torch transformer

torch transformer

从零开始用pytorch搭建Transformer模型(中文可以翻译成变形金刚)。

训练它来实现一个有趣的实例:两数之和。

输入输出类似如下:

输入:"12345+54321" 输出:"66666"

我们把这个任务当做一个机器翻译任务来进行。输入是一个字符序列,输出也是一个字符序列(seq-to-seq).

这和机器翻译的输入输出结构是类似的,所以可以用Transformer来做。

参考资料:

论文《Attention is All you needed》: https://arxiv.org/pdf/1706.03762.pdf

哈佛博客:https://github.com/harvardnlp/annotated-transformer/

一,准备数据
  1. import random
  2. import numpy as np
  3. import torch
  4. from torch.utils.data import Dataset,DataLoader
  5. # 定义字典
  6. words_x = '<PAD>,1,2,3,4,5,6,7,8,9,0,<SOS>,<EOS>,+'
  7. vocab_x = {word: i for i, word in enumerate(words_x.split(','))}
  8. vocab_xr = [k for k, v in vocab_x.items()] #反查词典
  9. words_y = '<PAD>,1,2,3,4,5,6,7,8,9,0,<SOS>,<EOS>'
  10. vocab_y = {word: i for i, word in enumerate(words_y.split(','))}
  11. vocab_yr = [k for k, v in vocab_y.items()] #反查词典
  12. #两数相加数据集
  13. def get_data():
  14. # 定义词集合
  15. words = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
  16. # 每个词被选中的概率
  17. p = np.array([7, 5, 5, 7, 6, 5, 7, 6, 5, 7])
  18. p = p / p.sum()
  19. # 随机采样n1个词作为s1
  20. n1 = random.randint(10, 20)
  21. s1 = np.random.choice(words, size=n1, replace=True, p=p)
  22. s1 = s1.tolist()
  23. # 随机采样n2个词作为s2
  24. n2 = random.randint(10, 20)
  25. s2 = np.random.choice(words, size=n2, replace=True, p=p)
  26. s2 = s2.tolist()
  27. # x等于s1和s2字符上的相加
  28. x = s1 + ['+'] + s2
  29. # y等于s1和s2数值上的相加
  30. y = int(''.join(s1)) + int(''.join(s2))
  31. y = list(str(y))
  32. # 加上首尾符号
  33. x = ['<SOS>'] + x + ['<EOS>']
  34. y = ['<SOS>'] + y + ['<EOS>']
  35. # 补pad到固定长度
  36. x = x + ['<PAD>'] * 50
  37. y = y + ['<PAD>'] * 51
  38. x = x[:50]
  39. y = y[:51]
  40. # 编码成token
  41. token_x = [vocab_x[i] for i in x]
  42. token_y = [vocab_y[i] for i in y]
  43. # 转tensor
  44. tensor_x = torch.LongTensor(token_x)
  45. tensor_y = torch.LongTensor(token_y)
  46. return tensor_x, tensor_y
  47. def show_data(tensor_x,tensor_y) ->"str":
  48. words_x = "".join([vocab_xr[i] for i in tensor_x.tolist()])
  49. words_y = "".join([vocab_yr[i] for i in tensor_y.tolist()])
  50. return words_x,words_y
  51. x,y = get_data()
  52. print(x,y,"\n")
  53. print(show_data(x,y))
  1. # 定义数据集
  2. class TwoSumDataset(torch.utils.data.Dataset):
  3. def __init__(self,size = 100000):
  4. super(Dataset, self).__init__()
  5. self.size = size
  6. def __len__(self):
  7. return self.size
  8. def __getitem__(self, i):
  9. return get_data()
  10. ds_train = TwoSumDataset(size = 100000)
  11. ds_val = TwoSumDataset(size = 10000)
  12. # 数据加载器
  13. dl_train = DataLoader(dataset=ds_train,
  14. batch_size=200,
  15. drop_last=True,
  16. shuffle=True)
  17. dl_val = DataLoader(dataset=ds_val,
  18. batch_size=200,
  19. drop_last=True,
  20. shuffle=False)
  21. for src,tgt in dl_train:
  22. print(src.shape)
  23. print(tgt.shape)
  24. break
  25. torch.Size([200, 50])
  26. torch.Size([200, 51])
二,定义模型

下面,我们会像搭积木建城堡那样从低往高地构建Transformer模型。

先构建6个基础组件:多头注意力、前馈网络、层归一化、残差连接、单词嵌入、位置编码。类似用最基础的积木块搭建了 墙壁,屋顶,篱笆,厅柱,大门,窗户 这样的模块。

然后用这6个基础组件构建了3个中间成品: 编码器,解码器,产生器。类似用基础组件构建了城堡的主楼,塔楼,花园。

最后用这3个中间成品组装成Tranformer完整模型。类似用主楼,塔楼,花园这样的中间成品拼凑出一座完整美丽的城堡。

1, 多头注意力: MultiHeadAttention (用于融合不同单词之间的信息, 三处使用场景,①Encoder self-attention, ② Decoder masked-self-attention, ③ Encoder-Decoder cross-attention)

2, 前馈网络: PositionwiseFeedForward (用于逐位置将多头注意力融合后的信息进行高维映射变换,简称FFN)

3, 层归一化: LayerNorm (用于稳定输入,每个样本在Sequece和Feature维度归一化,相比BatchNorm更能适应NLP领域变长序列)

4, 残差连接: ResConnection (用于增强梯度流动以降低网络学习难度, 可以先LayerNorm再Add,LayerNorm也可以放在残差Add之后)

5, 单词嵌入: WordEmbedding (用于编码单词信息,权重要学习,输出乘了sqrt(d_model)来和位置编码保持相当量级)

6, 位置编码: PositionEncoding (用于编码位置信息,使用sin和cos函数直接编码绝对位置)

7, 编码器: TransformerEncoder (用于将输入Sequence编码成与Sequence等长的memory向量序列, 由N个TransformerEncoderLayer堆叠而成)

8, 解码器: TransformerDecoder (用于将编码器编码的memory向量解码成另一个不定长的向量序列, 由N个TransformerDecoderLayer堆叠而成)

9, 生成器: Generator (用于将解码器解码的向量序列中的每个向量映射成为输出词典中的词,一般由一个Linear层构成)

10, 变形金刚: Transformer (用于Seq2Seq转码,例如用于机器翻译,采用EncoderDecoder架构,由Encoder, Decoder 和 Generator组成)

  1. import torch   
  2. from torch import nn   
  3. import torch.nn.functional as F  
  4. import copy   
  5. import math   
  6. import numpy as np  
  7. import pandas as pd   
  8.   
  9. def clones(module, N):  
  10.     "Produce N identical layers."  
  11.     return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])  
  12.   

1,多头注意力 MultiHeadAttention

需要逐步理解 ScaledDotProductAttention->MultiHeadAttention->MaskedMultiHeadAttention

先理解什么是 ScaledDotProductAttention,再理解MultiHeadAttention, 然后理解MaskedMultiHeadAttention

  1. class ScaledDotProductAttention(nn.Module):  
  2.     "Compute 'Scaled Dot Product Attention'"  
  3.     def __init__(self):  
  4.         super(ScaledDotProductAttention, self).__init__()  
  5.   
  6.     def forward(self,query, key, value, mask=None, dropout=None):  
  7.         d_k = query.size(-1)  
  8.         scores = query@key.transpose(-2,-1) / math.sqrt(d_k)       
  9.         if mask is not None:  
  10.             scores = scores.masked_fill(mask == 0, -1e20)  
  11.         p_attn = F.softmax(scores, dim = -1)  
  12.         if dropout is not None:  
  13.             p_attn = dropout(p_attn)  
  14.         return p_attn@value, p_attn  
  15.       
  16. class MultiHeadAttention(nn.Module):  
  17.     def __init__(self, h, d_model, dropout=0.1):  
  18.         "Take in model size and number of heads."  
  19.         super(MultiHeadAttention, self).__init__()  
  20.         assert d_model % h == 0  
  21.         # We assume d_v always equals d_k  
  22.         self.d_k = d_model // h  
  23.         self.h = h  
  24.         self.linears = clones(nn.Linear(d_model, d_model), 4)  
  25.         self.attn = None #记录 attention矩阵结果  
  26.         self.dropout = nn.Dropout(p=dropout)  
  27.         self.attention = ScaledDotProductAttention()  
  28.           
  29.     def forward(self, query, key, value, mask=None):  
  30.         if mask is not None:  
  31.             # Same mask applied to all h heads.  
  32.             mask = mask.unsqueeze(1)  
  33.         nbatches = query.size(0)  
  34.           
  35.         # 1) Do all the linear projections in batch from d_model => h x d_k   
  36.         query, key, value = [  
  37.             l(x).view(nbatches, -1, self.h, self.d_k).transpose(12)  
  38.              for l, x in zip(self.linears, (query, key, value))  
  39.         ]  
  40.           
  41.         # 2) Apply attention on all the projected vectors in batch.   
  42.         x, self.attn = self.attention(query, key, value, mask=mask,   
  43.                                  dropout=self.dropout)  
  44.           
  45.         # 3) "Concat" using a view and apply a final linear.   
  46.         x = x.transpose(12).contiguous() \  
  47.              .view(nbatches, -1, self.h * self.d_k)  
  48.         return self.linears[-1](x)  
  49.   
  50.   
  51. #为了让训练过程与解码过程信息流一致,遮挡tgt序列后面元素,设置其注意力为0  
  52. def tril_mask(data):  
  53.     "Mask out future positions."  
  54.     size = data.size(-1#size为序列长度  
  55.     full = torch.full((1,size,size),1,dtype=torch.int,device=data.device)  
  56.     mask = torch.tril(full).bool()   
  57.     return mask  
  58.   
  59.   
  60. #设置对<PAD>的注意力为0  
  61. def pad_mask(data, pad=0):  
  62.     "Mask out pad positions."  
  63.     mask = (data!=pad).unsqueeze(-2)  
  64.     return mask   
  65.   
  66.   
  67. #计算一个batch数据的src_mask和tgt_mask  
  68. class MaskedBatch:  
  69.     "Object for holding a batch of data with mask during training."  
  70.     def __init__(self, src, tgt=None, pad=0):  
  71.         self.src = src  
  72.         self.src_mask = pad_mask(src,pad)  
  73.         if tgt is not None:  
  74.             self.tgt = tgt[:,:-1#训练时,拿tgt的每一个词输入,去预测下一个词,所以最后一个词无需输入  
  75.             self.tgt_y = tgt[:, 1:] #第一个总是<SOS>无需预测,预测从第二个词开始  
  76.             self.tgt_mask = \  
  77.                 self.make_tgt_mask(self.tgt, pad)  
  78.             self.ntokens = (self.tgt_y!= pad).sum()   
  79.       
  80.     @staticmethod  
  81.     def make_tgt_mask(tgt, pad):  
  82.         "Create a mask to hide padding and future words."  
  83.         tgt_pad_mask = pad_mask(tgt,pad)  
  84.         tgt_tril_mask = tril_mask(tgt)  
  85.         tgt_mask = tgt_pad_mask & (tgt_tril_mask)  
  86.         return tgt_mask  
  87.       
  1. import plotly.express as px   
  2. # 测试tril_mask   
  3. mask = tril_mask(torch.zeros(1,10)) #序列长度为10  
  4. #sns.heatmap(mask[0],cmap=sns.cm.rocket);  
  5. px.imshow(mask[0],color_continuous_scale="blues",height=600,width=600)  

  1. #测试 ScaledDotProductAttention
  2. query = torch.tensor([[[0.0,1.414],[1.414,0.0],[1.0,1.0],[-1.0,1.0],[1.0,-1.0]]])
  3. key = query.clone()
  4. value = query.clone()
  5. attention = ScaledDotProductAttention()
  6. #没有mask
  7. out,p_att = attention(query, key, value)
  8. fig = px.imshow(p_att[0],color_continuous_scale="blues",
  9. title="without mask",height=600,width=600)
  10. fig.show()

  1. #考虑mask  
  2. out,p_att = attention(query, key, value, mask = tril_mask(torch.zeros(3,5)))  
  3. fig = px.imshow(p_att[0],color_continuous_scale="blues",  
  4.                 height=600,width=600,  
  5.                 title="with mask")  
  6. fig.show()   

 

  1. # 测试MultiHeadAttention  
  2. cross_attn = MultiHeadAttention(h=2, d_model=4)  
  3. cross_attn.eval()  
  4. q1 = torch.tensor([[[0.1,0.1,0.1,0.1],[0.1,0.3,0.1,0.3]]])  
  5. k1 = q1.clone()  
  6. v1 = q1.clone()  
  7. tgt_mask = tril_mask(torch.zeros(2,2))  
  8.   
  9. out1 = cross_attn.forward(q1,k1,v1,mask = tgt_mask)  
  10. print("out1:\n",out1)  
  11.   
  12. #改变序列的第2个元素取值,由于有mask的遮挡,不会影响第1个输出  
  13. q2 = torch.tensor([[[0.1,0.1,0.1,0.1],[0.4,0.5,0.5,0.8]]])  
  14. k2 = q2.clone()  
  15. v2 = q2.clone()  
  16. tgt_mask = tril_mask(torch.zeros(2,2))  
  17. out2 = cross_attn.forward(q2,k2,v2,mask = tgt_mask)  
  18. print("out2:\n",out2)  
  19.   

  1. # 测试MaskedBatch  
  2. mbatch = MaskedBatch(src = src,tgt = tgt, pad = 0)  
  3. print(mbatch.src.shape)  
  4. print(mbatch.tgt.shape)  
  5. print(mbatch.tgt_y.shape)  
  6.   
  7. print(mbatch.src_mask.shape)  
  8. print(mbatch.tgt_mask.shape)  
  9. px.imshow(mbatch.tgt_mask[0],color_continuous_scale="blues",width=600,height=600)  

关于Transformer的多头注意力机制,有几个要点问题,此处做一些梳理:

(1),Transformer是如何解决长距离依赖的问题的?

Transformer是通过引入Scale-Dot-Product注意力机制来融合序列上不同位置的信息,从而解决长距离依赖问题。以文本数据为例,在循环神经网络LSTM结构中,输入序列上相距很远的两个单词无法直接发生交互,只能通过隐藏层输出或者细胞状态按照时间步骤一个一个向后进行传递。对于两个在序列上相距非常远的单词,中间经过的其它单词让隐藏层输出和细胞状态混入了太多的信息,很难有效地捕捉这种长距离依赖特征。但是在Scale-Dot-Product注意力机制中,序列上的每个单词都会和其它所有单词做一次点积计算注意力得分,这种注意力机制中单词之间的交互是强制的不受距离影响的,所以可以解决长距离依赖问题。

(2),Transformer在训练和测试阶段可以在时间(序列)维度上进行并行吗?

在训练阶段,Encoder和Decoder在时间(序列)维度都是并行的,在测试阶段,Encoder在序列维度是并行的,Decoder是串行的。

首先,Encoder部分在训练阶段和预测阶段都可以并行比较好理解,无论在训练还是预测阶段,它干的事情都是把已知的完整输入编码成memory,在序列维度可以并行。

对于Decoder部分有些微妙。在预测阶段Decoder肯定是不能并行的,因为Decoder实际上是一个自回归,它前面k-1位置的输出会变成第k位的输入的。前面没有计算完,后面是拿不到输入的,肯定不可以并行。那么训练阶段能否并行呢?虽然训练阶段知道了全部的解码结果,但是训练阶段要和预测阶段一致啊,前面的解码输出不能受到后面解码结果的影响啊。但Transformer通过在Decoder中巧妙地引入Mask技巧,使得在用Attention机制做序列特征融合的时候,每个单词对位于它之后的单词的注意力得分都为0,这样就保证了前面的解码输出不会受到后面解码结果的影响,因此Decoder在训练阶段可以在序列维度做并行。2,前馈网络: PositionwiseFeedForward

用于逐位置将多头注意力融合后的信息进行高维映射变换,简称FFN。

FFN仅有两个线性层,第一层将模型向量维度 从 d_model(512) 升到 d_ff(2048), 第二层再降回 d_model(512)

两个线性层之间加了一个0.1的Dropout

  1. class PositionwiseFeedForward(nn.Module):  
  2.     "Implements FFN equation."  
  3.     def __init__(self, d_model, d_ff, dropout=0.1):  
  4.         super(PositionwiseFeedForward, self).__init__()  
  5.         self.linear1 = nn.Linear(d_model, d_ff)  #线性层默认作用在最后一维度  
  6.         self.linear2 = nn.Linear(d_ff, d_model)  
  7.         self.dropout = nn.Dropout(dropout)  
  8.   
  9.     def forward(self, x):  
  10.         return self.linear2(self.dropout(F.relu(self.linear1(x))))  
  11.       
  12.   

3,层归一化:LayerNorm

在视觉领域,归一化一般用BatchNorm,但是在NLP领域,归一化一般用LayerNorm。

这是由于NLP领域的输入常常是不等长的Sequence,使用BatchNorm会让较长的Sequence输入的后面特征能够使用的参与归一化的样本数太少,让输入变得不稳定。

同时同一个Sequence的被PADDING填充的特征也会因BatchNorm获得不同的非零值,这对模型非常不友好。

相比之下,LayerNorm总是对一个样本自己的特征进行归一化,没有上述问题。

  1. class LayerNorm(nn.Module):  
  2.     "Construct a layernorm module (similar to torch.nn.LayerNorm)."  
  3.     def __init__(self, features, eps=1e-6):  
  4.         super(LayerNorm, self).__init__()  
  5.         self.weight = nn.Parameter(torch.ones(features))  
  6.         self.bias = nn.Parameter(torch.zeros(features))  
  7.         self.eps = eps  
  8.   
  9.     def forward(self, x):  
  10.         mean = x.mean(-1, keepdim=True)  
  11.         std = x.std(-1, keepdim=True)  
  12.         return self.weight * (x - mean) / (std + self.eps) + self.bias  
  13.       
  14.   

4,残差连接:ResConnection

用于增强梯度流动以降低网络学习难度。

ResConnection 包括LayerNorm和Add残差连接操作, LayerNorm可以放在最开始(norm_first=True),也可以放在最后(norm_first=False)。

《Attention is All you needed》论文原文是残差连接之后再 LayerNorm,但后面的一些研究发现最开始的时候就LayerNorm更好一些。

残差连接对于训练深度网络至关重要。有许多研究残差连接(ResNet)作用机制,解释它为什么有效的文章,主要的一些观点如下。

1,残差连接增强了梯度流动。直观上看,loss端的梯度能够通过跳跃连接快速传递到不同深度的各个层,增强了梯度流动,降低了网络的学习难度。数学上看,残差块的导数 f(x)=x+h(x) 为 f'(x)=1+h'(x) 在1.0附近,避免了梯度消失问题。

2,残差连接减轻了网络退化。一个网络层h(x)可以用一个变换矩阵H来表示,由于许多神经元有相同的反应模式,h(x)等价的变换矩阵H可能有许多行是线性相关的,这使得H的行列式为0,H为非可逆矩阵,h(x)会导致网络的退化和信息丢失。但增加了残差连接之后,f(x)=x+h(x)对应的变换矩阵F=H+I,单位阵I消除了H中相关行的线性相关性,减轻了退化的可能。

3,残差连接实现了模型集成。如果将训练好的ResNet的一些block移除,模型的预测精度并不会崩溃式下降,但是如果将训练好的VGG的一些block移除,模型的预测精度会雪崩。这说明ResNet中的各个Block类似基模型,ResNet通过残差连接将它们整合成了一个ensemble集成模型,增强了泛化能力。

4,残差连接增强了表达能力。使用残差块构建的深层网络所代表的函数簇集合是浅层网络所代表的的函数簇集合的超集,表达能力更强,所以可以通过添加残差块不断扩充模型表达能力。如果不使用残差连接,一个一层的网络f(x) = h1(x)  所能表示的函数簇 不一定能被一个二层的网络 f(x) = h2(h1(x))所覆盖,但是使用残差连接后,f(x) = h1(x)+h2(h1(x))一定可以覆盖一层的网络所表示的函数簇,只要h2的全部权重取0即可。

  1. class ResConnection(nn.Module):  
  2.     """  
  3.     A residual connection with a layer norm.  
  4.     Note the norm is at last according to the paper, but it may be better at first.  
  5.     """  
  6.     def __init__(self, size, dropout, norm_first=True):  
  7.         super(ResConnection, self).__init__()  
  8.         self.norm = LayerNorm(size)  
  9.         self.dropout = nn.Dropout(dropout)  
  10.         self.norm_first = norm_first  
  11.   
  12.     def forward(self, x, sublayer):  
  13.         "Apply residual connection to any sublayer with the same size."  
  14.         if self.norm_first:  
  15.             return x + self.dropout(sublayer(self.norm(x)))  
  16.         else:  
  17.             return self.norm(x + self.dropout(sublayer(x)))  
  18.           

5,单词嵌入: WordEmbedding(权重要学习)

用于编码单词信息,权重要学习,输出乘了sqrt(d_model)来和位置编码保持相当量级。

当d_model越大的时候,根据 nn.init.xavier_uniform 初始化策略初始化的权重取值会越小。

  1. # 单词嵌入  
  2. class WordEmbedding(nn.Module):  
  3.     def __init__(self, d_model, vocab):  
  4.         super(WordEmbedding, self).__init__()  
  5.         self.embedding = nn.Embedding(vocab, d_model)  
  6.         self.d_model = d_model  
  7.   
  8.     def forward(self, x):  
  9.         return self.embedding(x) * math.sqrt(self.d_model) #note here, multiply sqrt(d_model)  
  10.       

6,位置编码:PositionEncoding(直接编码)

PositionEncoding用于编码位置信息,使用sin和cos函数直接编码绝对位置。

单词和单词顺序对语言意义都非常重要。

"你欠我1000块钱"和"我欠你1000块钱"是由完全相同的单词组成,但由于词的顺序不同,含义截然相反。

在Transformer之前,一般用RNN模型来处理句子序列。

RNN模型本身蕴含了对顺序的建模,单词是按照它们在句子中的自然顺序一个个地被RNN单元处理,逐个地被编码。

但Transformer是并行地处理句子中的单词的,缺少单词的位置信息表征。

为了有效地表征单词的位置信息,Transformer设计了位置编码 PositionalEncoding,并添加到模型的输入中。

于是,Transformer 用单词嵌入(权重要学习)向量 和位置编码(直接编码)向量 之和 来表示输入。

如何构造位置编码呢?即如何 把 pos = 0,1,2,3,4,5,... 这样的位置序列映射成为 一个一个的向量呢?

Transformer设计了基于正弦函数和余弦函数的位置编码方法。

这种编码方法有以下几个优点:

1,编码值分布在[-1,1]之间,这样的分布对神经网络是比较友好的。

2,编码了绝对位置信息,对于0<=pos<=2_pi_10000,每个pos的位置编码向量都是不一样的。

更多位置编码的讨论参考如下博客:《

让研究人员绞尽脑汁的Transformer位置编码》

https://kexue.fm/archives/8130

  1. # 位置编码
  2. class PositionEncoding(nn.Module):
  3. "Implement the PE function."
  4. def __init__(self, d_model, dropout, max_len=5000):
  5. super(PositionEncoding, self).__init__()
  6. self.dropout = nn.Dropout(p=dropout)
  7. # Compute the positional encodings once in log space.
  8. pe = torch.zeros(max_len, d_model)
  9. position = torch.arange(0, max_len).unsqueeze(1)
  10. div_term = torch.exp(torch.arange(0, d_model, 2) *
  11. -(math.log(10000.0) / d_model))
  12. pe[:, 0::2] = torch.sin(position * div_term)
  13. pe[:, 1::2] = torch.cos(position * div_term)
  14. pe = pe.unsqueeze(0)
  15. self.register_buffer('pe', pe)
  16. def forward(self, x):
  17. x = x + self.pe[:, :x.size(1)]
  18. return self.dropout(x)
  19. pe = PositionEncoding(120, 0)
  20. z = pe.forward(torch.zeros(1, 100, 120))
  21. df = pd.DataFrame(z[0, :, [0,20,60,110]].data.numpy(),columns = ["dim"+c for c in ['0','20','60','110']])
  22. df.insert(0,"x",np.arange(100))
  23. px.line(df, x = "x",y = ["dim"+c for c in ['0','20','60','110']]).show()

  1. px.imshow(np.squeeze(z.data.numpy()) ,color_continuous_scale="blues",width=1000,height=800)   
  2.   

7, 编码器: TransformerEncoder

用于将输入Sequence编码成与Sequence等长的memory向量序列, 由N个TransformerEncoderLayer堆叠而成

用于将输入Sequence编码成与Sequence等长的memory向量序列, 由N个TransformerEncoderLayer堆叠而成

  1. class TransformerEncoderLayer(nn.Module):
  2. "TransformerEncoderLayer is made up of self-attn and feed forward (defined below)"
  3. def __init__(self, size, self_attn, feed_forward, dropout):
  4. super(TransformerEncoderLayer, self).__init__()
  5. self.self_attn = self_attn
  6. self.feed_forward = feed_forward
  7. self.res_layers = clones(ResConnection(size, dropout), 2)
  8. self.size = size
  9. def forward(self, x, mask):
  10. "Follow Figure 1 (left) for connections."
  11. x = self.res_layers[0](x, lambda x: self.self_attn(x, x, x, mask))
  12. return self.res_layers[1](x, self.feed_forward)
  13. class TransformerEncoder(nn.Module):
  14. "TransformerEncoder is a stack of N TransformerEncoderLayer"
  15. def __init__(self, layer, N):
  16. super(TransformerEncoder, self).__init__()
  17. self.layers = clones(layer, N)
  18. self.norm = LayerNorm(layer.size)
  19. def forward(self, x, mask):
  20. "Pass the input (and mask) through each layer in turn."
  21. for layer in self.layers:
  22. x = layer(x, mask)
  23. return self.norm(x)
  24. @classmethod
  25. def from_config(cls,N=6,d_model=512, d_ff=2048, h=8, dropout=0.1):
  26. attn = MultiHeadAttention(h, d_model)
  27. ff = PositionwiseFeedForward(d_model, d_ff, dropout)
  28. layer = TransformerEncoderLayer(d_model, attn, ff, dropout)
  29. return cls(layer,N)
  30. from torchkeras import summary
  31. src_embed = nn.Sequential(WordEmbedding(d_model=32, vocab = len(vocab_x)),
  32. PositionEncoding(d_model=32, dropout=0.1))
  33. encoder = TransformerEncoder.from_config(N=3,d_model=32, d_ff=128, h=8, dropout=0.1)
  34. src_mask = pad_mask(src)
  35. memory = encoder(*[src_embed(src),src_mask])
  36. summary(encoder,input_data_args = [src_embed(src),src_mask]);

8,解码器:TransformerDecoder

用于将编码器编码的memory向量解码成另一个不定长的向量序列, 由N个TransformerDecoderLayer堆叠而成

  1. class TransformerDecoderLayer(nn.Module):
  2. "TransformerDecoderLayer is made of self-attn, cross-attn, and feed forward (defined below)"
  3. def __init__(self, size, self_attn, cross_attn, feed_forward, dropout):
  4. super(TransformerDecoderLayer, self).__init__()
  5. self.size = size
  6. self.self_attn = self_attn
  7. self.cross_attn = cross_attn
  8. self.feed_forward = feed_forward
  9. self.res_layers = clones(ResConnection(size, dropout), 3)
  10. def forward(self, x, memory, src_mask, tgt_mask):
  11. "Follow Figure 1 (right) for connections."
  12. m = memory
  13. x = self.res_layers[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
  14. x = self.res_layers[1](x, lambda x: self.cross_attn(x, m, m, src_mask))
  15. return self.res_layers[2](x, self.feed_forward)
  16. class TransformerDecoder(nn.Module):
  17. "Generic N layer decoder with masking."
  18. def __init__(self, layer, N):
  19. super(TransformerDecoder, self).__init__()
  20. self.layers = clones(layer, N)
  21. self.norm = LayerNorm(layer.size)
  22. def forward(self, x, memory, src_mask, tgt_mask):
  23. for layer in self.layers:
  24. x = layer(x, memory, src_mask, tgt_mask)
  25. return self.norm(x)
  26. @classmethod
  27. def from_config(cls,N=6,d_model=512, d_ff=2048, h=8, dropout=0.1):
  28. self_attn = MultiHeadAttention(h, d_model)
  29. cross_attn = MultiHeadAttention(h, d_model)
  30. ff = PositionwiseFeedForward(d_model, d_ff, dropout)
  31. layer = TransformerDecoderLayer(d_model, self_attn, cross_attn, ff, dropout)
  32. return cls(layer,N)
  33. from torchkeras import summary
  34. mbatch = MaskedBatch(src=src,tgt=tgt,pad=0)
  35. src_embed = nn.Sequential(WordEmbedding(d_model=32, vocab = len(vocab_x)),
  36. PositionEncoding(d_model=32, dropout=0.1))
  37. encoder = TransformerEncoder.from_config(N=3,d_model=32, d_ff=128, h=8, dropout=0.1)
  38. memory = encoder(src_embed(src),mbatch.src_mask)
  39. tgt_embed = nn.Sequential(WordEmbedding(d_model=32, vocab = len(vocab_y)),
  40. PositionEncoding(d_model=32, dropout=0.1))
  41. decoder = TransformerDecoder.from_config(N=3,d_model=32, d_ff=128, h=8, dropout=0.1)
  42. result = decoder.forward(tgt_embed(mbatch.tgt),memory,mbatch.src_mask,mbatch.tgt_mask)
  43. summary(decoder,input_data_args = [tgt_embed(mbatch.tgt),memory,
  44. mbatch.src_mask,mbatch.tgt_mask]);
  45. decoder.eval()
  46. mbatch.tgt[0][1]=8
  47. result = decoder.forward(tgt_embed(mbatch.tgt),memory,mbatch.src_mask,mbatch.tgt_mask)
  48. print(torch.sum(result[0][0]))
  49. mbatch.tgt[0][1]=7
  50. result = decoder.forward(tgt_embed(mbatch.tgt),memory,mbatch.src_mask,mbatch.tgt_mask)
  51. print(torch.sum(result[0][0]))

9,生成器: Generator

用于将解码器解码输出的向量序列中的每个向量逐个映射成为输出词典中各个词的取词概率。一般由一个Linear层接F.log_softmax构成,比较简单。接F.log_softmax而不接F.softmax的原因是对于一些特别小的概率如1e-100,在精度约束条件下,F.log_softmax能够更加准确地表示其大小。

  1. class Generator(nn.Module):
  2. "Define standard linear + softmax generation step."
  3. def __init__(self, d_model, vocab):
  4. super(Generator, self).__init__()
  5. self.proj = nn.Linear(d_model, vocab)
  6. def forward(self, x):
  7. return F.log_softmax(self.proj(x), dim=-1)
  8. generator = Generator(d_model = 32, vocab = len(vocab_y))
  9. log_probs = generator(result)
  10. probs = torch.exp(log_probs)
  11. print("output_probs.shape:",probs.shape)
  12. print("sum(probs)=1:")
  13. print(torch.sum(probs,dim = -1)[0])
  14. summary(generator,input_data = result);

10,变形金刚:Transformer

用于Seq2Seq转码,例如用于机器翻译,采用EncoderDecoder架构,由Encoder, Decoder 和 Generator组成

  1. from torch import nn
  2. class Transformer(nn.Module):
  3. """
  4. A standard Encoder-Decoder architecture. Base for this and many other models.
  5. """
  6. def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):
  7. super(Transformer, self).__init__()
  8. self.encoder = encoder
  9. self.decoder = decoder
  10. self.src_embed = src_embed
  11. self.tgt_embed = tgt_embed
  12. self.generator = generator
  13. self.reset_parameters()
  14. def forward(self, src, tgt, src_mask, tgt_mask):
  15. "Take in and process masked src and target sequences."
  16. return self.generator(self.decode(self.encode(src, src_mask),
  17. src_mask, tgt, tgt_mask))
  18. def encode(self, src, src_mask):
  19. return self.encoder(self.src_embed(src), src_mask)
  20. def decode(self, memory, src_mask, tgt, tgt_mask):
  21. return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)
  22. @classmethod
  23. def from_config(cls,src_vocab,tgt_vocab,N=6,d_model=512, d_ff=2048, h=8, dropout=0.1):
  24. encoder = TransformerEncoder.from_config(N=N,d_model=d_model,
  25. d_ff=d_ff, h=h, dropout=dropout)
  26. decoder = TransformerDecoder.from_config(N=N,d_model=d_model,
  27. d_ff=d_ff, h=h, dropout=dropout)
  28. src_embed = nn.Sequential(WordEmbedding(d_model, src_vocab), PositionEncoding(d_model, dropout))
  29. tgt_embed = nn.Sequential(WordEmbedding(d_model, tgt_vocab), PositionEncoding(d_model, dropout))
  30. generator = Generator(d_model, tgt_vocab)
  31. return cls(encoder, decoder, src_embed, tgt_embed, generator)
  32. def reset_parameters(self):
  33. for p in self.parameters():
  34. if p.dim() > 1:
  35. nn.init.xavier_uniform_(p)
  36. from torchkeras import summary
  37. net = Transformer.from_config(src_vocab = len(vocab_x),tgt_vocab = len(vocab_y),
  38. N=2, d_model=32, d_ff=128, h=8, dropout=0.1)
  39. mbatch = MaskedBatch(src=src,tgt=tgt,pad=0)
  40. summary(net,input_data_args = [mbatch.src,mbatch.tgt,mbatch.src_mask,mbatch.tgt_mask]);

三,训练模型

Transformer的训练主要用到了以下两个技巧:

1,学习率调度: Learning Rate Scheduler (用于提升模型学习稳定性。做法是学习率先warm up线性增长,再按照 1/sqrt(step) 规律缓慢下降)

2,标签平滑: Label Smoothing. (用于让模型更加集中在对分类错误的样本的学习,而不是扩大已经分类正确样本中正负样本预测差距。做法是将正例标签由1改成0.1,负例标签由0改成0.9/vocab_size)

介绍了用这两个方法封装的 Optimizer和 Loss 后,我们进一步实现完整训练代码。

3,完整训练代码。

1,学习率调度:Learning Rate Scheduler

用于提升模型学习稳定性。

做法是学习率先warm up线性增长,再按照 1/sqrt(step) 规律缓慢下降。

学习率的warm up为何有效呢?

一种解释性观点是认为这能够让模型初始学习时参数平稳变化并避免对开始的几个batch数据过拟合陷入局部最优。

由于刚学习时,loss比较大,梯度会很大,如果学习率也很大,两者相乘会更大,那么模型参数会随着不同batch数据的差异剧烈抖动,无法有效地学习,也容易对开始的几个batch数据过拟合,后期很难拉回来。

等到模型学习了一些时候,loss变小了,梯度也会小,学习率调大,两者相乘也不会很大,模型依然可以平稳有效地学习。

后期为何又要让调低学习率呢?

这是因为后期模型loss已经很小了,在最优参数附近了,如果学习率过大,容易在最优参数附近震荡,无法逼近最优参数。

  1. #注1:此处通过继承方法将学习率调度策略融入Optimizer
  2. #注2:NoamOpt中的Noam是论文作者之一的名字
  3. #注3:学习率是按照step而非epoch去改变的
  4. class NoamOpt(torch.optim.AdamW):
  5. def __init__(self, params, model_size=512, factor=1.0, warmup=4000,
  6. lr=0, betas=(0.9, 0.98), eps=1e-9,
  7. weight_decay=0, amsgrad=False):
  8. super(NoamOpt,self).__init__(params, lr=lr, betas=betas, eps=eps,
  9. weight_decay=weight_decay, amsgrad=amsgrad)
  10. self._step = 0
  11. self.warmup = warmup
  12. self.factor = factor
  13. self.model_size = model_size
  14. def step(self,closure=None):
  15. "Update parameters and rate"
  16. self._step += 1
  17. rate = self.rate()
  18. for p in self.param_groups:
  19. p['lr'] = rate
  20. super(NoamOpt,self).step(closure=closure)
  21. def rate(self, step = None):
  22. "Implement `lrate` above"
  23. if step is None:
  24. step = self._step
  25. return self.factor * \
  26. (self.model_size ** (-0.5) *
  27. min(step * self.warmup ** (-1.5),step ** (-0.5)))
  28. optimizer = NoamOpt(net.parameters(),
  29. &nbnbsp;model_size=net.src_embed[0].d_model, factor=1.0,
  30. warmup=400)
  31. import plotly.express as px
  32. opts = [NoamOpt(net.parameters(),model_size=512, factor =1, warmup=4000),
  33. NoamOpt(net.parameters(),model_size=512, factor=1, warmup=8000),
  34. NoamOpt(net.parameters(),model_size=256, factor=1, warmup=4000)]
  35. steps = np.arange(1, 20000)
  36. rates = [[opt.rate(i) for opt in opts] for i in steps]
  37. dfrates = pd.DataFrame(rates,columns = ["512:4000", "512:8000", "256:4000"])
  38. dfrates["steps"] = steps
  39. fig = px.line(dfrates,x="steps",y=["512:4000", "512:8000", "256:4000"])
  40. fig.layout.yaxis.title = "lr"
  41. fig

2,标签平滑:Label Smoothing

用于让模型更加集中在对分类错误的样本的学习,而不是扩大已经分类正确样本中正负样本预测差距。

做法是将正例标签由1改成0.1,负例标签由0改成0.9/vocab_size

多分类一般用softmax激活函数,要让模型对正例标签预测值为1是非常困难的,那需要输出正无穷才可以.

对负例标签预测值为0也是非常困难的,那需要输出负无穷才可以。

但实际上我们不需要模型那么确信,只要正例标签的预测值比负例标签大就行了。

因此可以做标签平滑,让模型不必费劲地无限扩大分类正确样本中正负样本之间的预测差距,而是集中在对分类错误的样本的学习。

由于在激活函数中已经采用了F.log_softmax, 所以损失函数不能用nn.CrossEntropyLoss,而需要使用 nn.NLLoss.

(注:nn.LogSoftmax + nn.NLLLoss = nn.CrossEntropyLoss)

同时由于使用了标签平滑,采用nn.NLLoss时损失的最小值无法变成0,需要扣除标签分布本身的熵,损失函数进一步变成 nn.KLDivLoss.

在采用标签平滑的时候,nn.KLDivLoss和nn.NLLoss的梯度相同,优化效果相同,但其最小值是0,更符合我们对损失的直观理解。

  1. class LabelSmoothingLoss(nn.Module):
  2. "Implement label smoothing."
  3. def __init__(self, size, padding_idx, smoothing=0.0): #size为词典大小
  4. super(LabelSmoothingLoss, self).__init__()
  5. self.criterion = nn.KLDivLoss(reduction="sum")
  6. self.padding_idx = padding_idx
  7. self.confidence = 1.0 - smoothing
  8. self.smoothing = smoothing
  9. self.size = size
  10. self.true_dist = None
  11. def forward(self, x, target):
  12. assert x.size(1) == self.size
  13. true_dist = x.data.clone()
  14. true_dist.fill_(self.smoothing / (self.size - 2)) #预测结果不会是<SOS> #和<PAD>
  15. true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
  16. true_dist[:, self.padding_idx] = 0
  17. mask = torch.nonzero((target.data == self.padding_idx).int())
  18. if mask.dim() > 0:
  19. true_dist.index_fill_(0, mask.squeeze(), 0.0)
  20. self.true_dist = true_dist
  21. return self.criterion(x, true_dist)
  22. # Example of label smoothing.
  23. smooth_loss = LabelSmoothingLoss(5, 0, 0.4)
  24. predict = torch.FloatTensor([[1e-10, 0.2, 0.7, 0.1, 1e-10],
  25. [1e-10, 0.2, 0.7, 0.1, 1e-10],
  26. [1e-10, 0.2, 0.7, 0.1, 1e-10]])
  27. loss = smooth_loss(predict.log(), torch.LongTensor([2, 1, 0]))
  28. print("smoothed target:\n",smooth_loss.true_dist,"\n")
  29. print("loss:",loss)
  30. px.imshow(smooth_loss.true_dist,color_continuous_scale="blues",height=600,width=1000)
smoothed target: tensor([[0.0000, 0.1333, 0.6000, 0.1333, 0.1333],        [0.0000, 0.6000, 0.1333, 0.1333, 0.1333],        [0.0000, 0.0000, 0.0000, 0.0000, 0.0000]]) loss: tensor(5.9712)

3,完整训练代码

有了优化器和Loss后,我们便可以训练模型了。

我们先整体试算loss和metric,然后再套上torchkeras的训练模版。

  1. #整体流程试算
  2. for src,tgt in dl_train:
  3. break
  4. mbatch = MaskedBatch(src=src,tgt=tgt,pad = 0)
  5. net = Transformer.from_config(src_vocab = len(vocab_x),tgt_vocab = len(vocab_y),
  6. N=3, d_model=64, d_ff=128, h=8, dropout=0.1)
  7. #loss
  8. loss_fn = LabelSmoothingLoss(size=len(vocab_y),
  9. padding_idx=0, smoothing=0.2)
  10. preds = net.forward(mbatch.src, mbatch.tgt, mbatch.src_mask, mbatch.tgt_mask)
  11. preds = preds.reshape(-1, preds.size(-1))
  12. labels = mbatch.tgt_y.reshape(-1)
  13. loss = loss_fn(preds, labels)/mbatch.ntokens
  14. print('loss=',loss.item())
  15. #metric
  16. preds = preds.argmax(dim=-1).view(-1)[labels!=0]
  17. labels = labels[labels!=0]
  18. acc = (preds==labels).sum()/(labels==labels).sum()
  19. print('acc=',acc.item())
  20. loss= 2.1108953952789307acc= 0.08041179925203323
  21. from torchmetrics import Accuracy
  22. #使用torchmetrics中的指标
  23. accuracy = Accuracy(task='multiclass',num_classes=len(vocab_y))
  24. accuracy.update(preds,labels)
  25. print('acc=',accuracy.compute().item())
acc= 0.08041179925203323

下面使用我们的梦中情炉来实现最优雅的训练循环~

  1. from torchkeras import KerasModel
  2. class StepRunner:
  3. def __init__(self, net, loss_fn,
  4. accelerator=None, stage = "train", metrics_dict = None,
  5. optimizer = None, lr_scheduler = None
  6. ):
  7. self.net,self.loss_fn,self.metrics_dict,self.stage = net,loss_fn,metrics_dict,stage
  8. self.optimizer,self.lr_scheduler = optimizer,lr_scheduler
  9. self.accelerator = accelerator
  10. if self.stage=='train':
  11. self.net.train()
  12. else:
  13. self.net.eval()
  14. def __call__(self, batch):
  15. src,tgt = batch
  16. mbatch = MaskedBatch(src=src,tgt=tgt,pad = 0)
  17. #loss
  18. with self.accelerator.autocast():
  19. preds = net.forward(mbatch.src, mbatch.tgt, mbatch.src_mask, mbatch.tgt_mask)
  20. preds = preds.reshape(-1, preds.size(-1))
  21. labels = mbatch.tgt_y.reshape(-1)
  22. loss = loss_fn(preds, labels)/mbatch.ntokens
  23. #filter padding
  24. preds = preds.argmax(dim=-1).view(-1)[labels!=0]
  25. labels = labels[labels!=0]
  26. #backward()
  27. if self.stage=="train" and self.optimizer is not None:
  28. self.accelerator.backward(loss)
  29. if self.accelerator.sync_gradients:
  30. self.accelerator.clip_grad_norm_(self.net.parameters(), 1.0)
  31. self.optimizer.step()
  32. if self.lr_scheduler is not None:
  33. self.lr_scheduler.step()
  34. self.optimizer.zero_grad()
  35. all_loss = self.accelerator.gather(loss).sum()
  36. all_preds = self.accelerator.gather(preds)
  37. all_labels = self.accelerator.gather(labels)
  38. #losses (or plain metrics that can be averaged)
  39. step_losses = {self.stage+"_loss":all_loss.item()}
  40. step_metrics = {self.stage+"_"+name:metric_fn(all_preds, all_labels).item()
  41. for name,metric_fn in self.metrics_dict.items()}
  42. if self.stage=="train":
  43. if self.optimizer is not None:
  44. step_metrics['lr'] = self.optimizer.state_dict()['param_groups'][0]['lr']
  45. else:
  46. step_metrics['lr'] = 0.0
  47. return step_losses,step_metrics
  48. KerasModel.StepRunner = StepRunner
  49. from torchmetrics import Accuracy
  50. net = Transformer.from_config(src_vocab = len(vocab_x),tgt_vocab = len(vocab_y),
  51. N=5, d_model=64, d_ff=128, h=8, dropout=0.1)
  52. loss_fn = LabelSmoothingLoss(size=len(vocab_y),
  53. padding_idx=0, smoothing=0.1)
  54. metrics_dict = {'acc':Accuracy(task='multiclass',num_classes=len(vocab_y))}
  55. optimizer = NoamOpt(net.parameters(),model_size=64)
  56. model = KerasModel(net,
  57. loss_fn=loss_fn,
  58. metrics_dict=metrics_dict,
  59. optimizer = optimizer)
  60. model.fit(
  61. train_data=dl_train,
  62. val_data=dl_val,
  63. epochs=100,
  64. ckpt_path='checkpoint',
  65. patience=10,
  66. monitor='val_acc',
  67. mode='max',
  68. callbacks=None,
  69. plot=True
  70. )
四,使用模型

下面使用贪心法进行翻译推理过程。

和训练过程可以通过掩码遮挡未来token,从而实现一个句子在序列长度方向并行训练不同。

翻译推理过程只有先翻译了前面的内容,添加到输出中,才能够翻译后面的内容,这个过程是无法在序列维度并行的。whaosoft aiot http://143ai.com  

Decoder&Generator第k位的输出实际上对应的是 已知 输入编码后的memory和前k位Deocder输入(解码序列)

的情况下解码序列第k+1位取 输出词典中各个词的概率。

贪心法是获取解码结果的简化方案,工程实践当中一般使用束搜索方法(Beam Search)

  1. def greedy_decode(net, src, src_mask, max_len, start_symbol):
  2. net.eval()
  3. memory = net.encode(src, src_mask)
  4. ys = torch.full((len(src),max_len),start_symbol,dtype = src.dtype).to(src.device)
  5. for i in range(max_len-1):
  6. out = net.generator(net.decode(memory, src_mask,
  7. ys, tril_mask(ys)))
  8. ys[:,i+1]=out.argmax(dim=-1)[:,i]
  9. return ys
  10. def get_raw_words(tensor,vocab_r) ->"str":
  11. words = [vocab_r[i] for i in tensor.tolist()]
  12. return words
  13. def get_words(tensor,vocab_r) ->"str":
  14. s = "".join([vocab_r[i] for i in tensor.tolist()])
  15. words = s[:s.find('<EOS>')].replace('<SOS>','')
  16. return words
  17. def prepare(x,accelerator=model.accelerator):
  18. return x.to(accelerator.device)
  19. ##解码翻译结果
  20. net = model.net
  21. net.eval()
  22. net = prepare(net)
  23. src,tgt = get_data()
  24. src,tgt = prepare(src),prepare(tgt)
  25. mbatch = MaskedBatch(src=src.unsqueeze(dim=0),tgt=tgt.unsqueeze(dim=0))
  26. y_pred = greedy_decode(net,mbatch.src,mbatch.src_mask,50,vocab_y["<SOS>"])
  27. print("input:")
  28. print(get_words(mbatch.src[0],vocab_xr),'\n') #标签结果
  29. print("ground truth:")
  30. print(get_words(mbatch.tgt[0],vocab_yr),'\n') #标签结果
  31. print("prediction:")
  32. print(get_words(y_pred[0],vocab_yr)) #解码预测结果,原始标签中<PAD>位置的预测可以忽略
  1. input: 744905345112863593+7323038062936802655
  2. ground truth: 8067943408049666248
  3. prediction: 8067943408049666248
五,评估模型

我们训练过程中监控的acc实际上是字符级别的acc,现在我们来计算样本级别的准确率。

  1. from tqdm.auto import tqdm  
  2.   
  3. net = prepare(net)  
  4. loop = tqdm(range(1,201))  
  5. correct = 0  
  6. for i in loop:  
  7.     src,tgt = get_data()  
  8.     src,tgt = prepare(src),prepare(tgt)  
  9.     mbatch = MaskedBatch(src=src.unsqueeze(dim=0),tgt=tgt.unsqueeze(dim=0))  
  10.     y_pred = greedy_decode(net,mbatch.src,mbatch.src_mask,50,vocab_y["<SOS>"])  
  11.   
  12.     inputs = get_words(mbatch.src[0],vocab_xr) #标签结果  
  13.     gt = get_words(mbatch.tgt[0],vocab_yr) #标签结果  
  14.     preds = get_words(y_pred[0],vocab_yr) #解码预测结果,原始标签中<PAD>位置的预测可以忽略  
  15.     if preds==gt:  
  16.         correct+=1  
  17.     loop.set_postfix(acc = correct/i)  
  18.       
  19. print("acc=",correct/len(loop))  

 perfect,基本完美实现两数之和。 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/花生_TL007/article/detail/350999
推荐阅读
相关标签
  

闽ICP备14008679号