• 【动手学深度学习-Pytorch版】序列到序列的学习(包含NLP常用的Mask技巧)


    序言

    这一节是对于“编码器-解码器”模型的实际应用,编码器和解码器架构可以使用长度可变的序列作为输入,并将其转换为固定形状的隐状态(编码器实现)。本小节将使用“fra-eng”数据集(这也是《动手学习深度学习-Pytorch版》提供的数据集)进行序列到序列的学习。在d2l官方文档中有很多的内容是根据英文版直译过来的,其中有很多空乏的句子,特别是对于每个模块的描述中,下面我提供一种全新的思路来理解整个代码(不得不说沐神团队的代码绝对值得推敲~)。
    这里也是按照官方给的目录架构对于整个项目复现,在复现的过程中详细理解每一行代码的作用(去除无关内容~)同时关注数据的变化,特别是在源和目标的shape变化方面。当然需要注明的是源指的是数据集中所有的英语短语,其按照batch_size的大小装入模型,同时增加了num_steps维度,也就是“时间步”【那对于区分时间步和batch_size的概念有个类似的方式便于理解:将它们映射到图像中,batch_size是每一次取出多少个样本图像,而num_steps可以理解为图像本身的维度问题】。下面将会按着官方给出的步骤进行代码复现:导包、设计编码器、设计解码器、修改交叉熵损失函数、模型训练、模型预测、使用BLEU进行模型的评估。
    在这里插入图片描述

    模型复现

    导包【无脑导包】

    # 无脑导包
    import torch
    import collections # 这个包还是需要注意一下
    import math
    from torch import nn
    from d2l import torch as d2l
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    设计编码器

    根据“编码器-解码器”的模型架构,梳理出编码器的主要任务,它的主要任务包括:

    1. 将某一个时刻t的输入特征向量 x t x_t xt和上一个时刻的隐状态 h t − 1 h_{t-1} ht1转变为 h t h_t ht h t = f ( x t , h t − 1 ) h_t = f(x_t , h_{t-1}) ht=f(xt,ht1)
    2. 编码器需要通过函数q实现把所有的隐状态转变为上下文变量:
      c = q ( h 1 , . . . . , h T ) c = q( h_1,....,h_T ) c=q(h1,....,hT)
    3. 使用嵌入层获取输入序列的每个词元的特征向量[嵌入层权重矩阵行数为vocab_size,列数是特征向量的维度]

    明确了编码器的主要任务后下面来看具体的代码复现:

    #@save
    class Seq2SeqEncoder(d2l.Encoder):
        """用于序列到序列学习的循环神经网络编码器"""
        def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
            super(Seq2SeqEncoder,self).__init__(**kwargs)
            # 实现嵌入层Embedding 将每一个词元转变成一个词向量
            self.embedding = nn.Embedding(vocab_size,embed_size)
            # print('Encoder中 self.embedding的size为:',self.embedding.size())
            # print('Encoder中 embed_size:   ',embed_size)
            with  open('D://pythonProject//Encoder_embed_pervir_size.txt', 'w') as f:
                f.write(str(embed_size))
            """----------embed_size为32----------"""
            """这里的embed_size为每一个词元对应的特征向量的长度"""
            self.rnn = nn.GRU(embed_size,num_hiddens,num_layers,dropout=dropout)
        def forward(self, X, *args):
            with  open('D://pythonProject//Encoder_Not_embed_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------未进行embedding的X: torch.Size([64, 10]) batch_size * num_steps----------"""
            # print('Encoder中 未进行embedding前的X的size',X.size())
            # embedding 的形状 (vocab_size,embed_size)
            # 输出'X'的形状:(batch_size,num_steps,embed_size)
            X = self.embedding(X)
            # print('Encoder中 进行embedding后的X的size',X.size())
            with  open('D://pythonProject//Encoder_embed_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------进行了embedding的X: torch.Size([64, 10, 32])----------"""
    
            #torch要求在循环神经网络模型中,第一个轴对应的必须是时间步
            X = X.permute(1,0,2)
            # print('Encoder中 permute后的X的size',X.size())
            with  open('D://pythonProject//Encoder_permute_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------进行了permute的X: torch.Size([10, 64, 32]) 10为时间步----------"""
            output,state = self.rnn(X)
            # output的输出形状: (num_steps,batch_size,num_hiddens)
            # state的输出形状: (num_layers,batch_size,num_hiddens)
            return output,state
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    在上述的编码器中,forward()完成了
    1、将输入值【形状为:batch_size*num_steps】输入到嵌入层Embedding,将输入的每个词元转成一个代表该词元的一个特征向量。【之所以用Embedding而不用One-Hot的原因在于:虽然One-Hot可将tokens转成稀疏矩阵便于运算,但是不适用于大批量数据的情况,容易导致运算过慢或者占用内存的情况,详细参考:一文读懂Embedding的概念,以及它和深度学习的关系】;
    1-1 注意:原来X的输入形状是
    torch.Size[64,10]
    —>torch.Size(bach_size,num_steps]
    经过Embedding后的X的形状为
    torch.Size([64, 10, 32]
    —>torch.Size(batch_size,num_steps,embedding_size)
    即在输入的X后增加一个维度,用来作为每一个takens的特征向量

            with  open('D://pythonProject//Encoder_Not_embed_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------未进行embedding的X: torch.Size([64, 10]) batch_size * num_steps----------"""
            # print('Encoder中 未进行embedding前的X的size',X.size())
            # embedding 的形状 (vocab_size,embed_size)
            # 输出'X'的形状:(batch_size,num_steps,embed_size)
            X = self.embedding(X)
            # print('Encoder中 进行embedding后的X的size',X.size())
            with  open('D://pythonProject//Encoder_embed_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------进行了embedding的X: torch.Size([64, 10, 32])----------"""
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    2、为了适应torch要求的循环神经网络模型中第一个维度需要为时间步的需求,这里做了一下permute操作,把第0个维度和第1个维度互换了一下,关于permute的详细操作可以参考:【PyTorch 两大转置函数 transpose() 和 permute()

    permute后的矩阵形状就变成了:
    torch.Size([10, 64, 32])
    —>torch.size([num_steps,batch_size,embedding_size])

            #torch要求在循环神经网络模型中,第一个轴对应的必须是时间步
            X = X.permute(1,0,2)
            # print('Encoder中 permute后的X的size',X.size())
            with  open('D://pythonProject//Encoder_permute_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------进行了permute的X: torch.Size([10, 64, 32]) 10为时间步----------"""
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    3、最后,编码器需要返回最后一个时间步的state隐状态和最后一个时间步的outputs。

            output,state = self.rnn(X)
            # output的输出形状: (num_steps,batch_size,num_hiddens)
            # state的输出形状: (num_layers,batch_size,num_hiddens)
            return output,state
    
    • 1
    • 2
    • 3
    • 4

    实例化编码器

    下面通过设计一个两层门控循环单元编码器,其隐藏单元是16,给定一个小批量的输入序列X(批量大小为4,时间步为7)。同时,在完成所有时间步后,最后一层的隐状态的输出是一个张量【output由编码器的循环层返回】,形状为(时间步数,批量大小,隐藏单元数)

    encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
                             num_layers=2)
    encoder.eval()
    X = torch.zeros((4, 7), dtype=torch.long)
    output, state = encoder(X)
    output.shape
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    注:这里使用的是门控循环单元GRU,最后一个时间步的多层隐状态的形状是(num_layers,batch_size,num_hiddens),如果使用LSTM 则state中还应该包含记忆单元信息。

    设计解码器

    编码器输出的整个上下文信息变量C需要作用于整个输入序列 x 1 , . . . , x r x_1,...,x_r x1,...,xr,对输入序列进行编码。解码器的输出 y t ′ y_t' yt与上下文变量C输出子序列 y 1 , . . . , ( y t ′ − 1 ) y_1,...,(yt'-1) y1,...,(yt1)的关系:
    在这里插入图片描述
    且隐状态与上一步的隐状态、上下文变量和上一个时间步的输出有关。在获得解码器的隐状态后,可以使用输出层+softmax操作来计算时间步 t ′ t' t时输出 y t ′ y_t' yt的概率分布:
    在这里插入图片描述
    解码器的主要任务包括:

    1. 直接使用编码器的最后一个时间步的隐状态来初始化解码器的隐状态及两者具有相同的隐藏层和隐藏单元
    2. 为了让上下文信息更好包含更多的信息,可以用上下文变量C在所有的时间步与解码器的输入进行拼接
    3. 为了输出预测词元的概率分布,在最后一层采用全连接层来变换隐状态
    class Seq2SeqDecoder(d2l.Decoder):
        """用于序列到序列学习的循环神经网络解码器"""
        def __init__(self,vocab_size,embed_size,num_hiddens,num_layers,dropout=0,**kwargs):
            super(Seq2SeqDecoder, self).__init__(**kwargs)
            self.embedding = nn.Embedding(vocab_size,embed_size)
            with  open('D://pythonProject//Decoder_vocab_size.txt', 'w') as f:
                f.write(str(vocab_size))
            """----------decoder的vocab_size为201----------"""
    
    
            with  open('D://pythonProject//Decoder_embed_size.txt', 'w') as f:
                f.write(str(embed_size))
    
            """----------decoder的embed_size为32----------"""
            self.rnn = nn.GRU(embed_size+num_hiddens,num_hiddens,num_layers,dropout=dropout)
            self.dense = nn.Linear(num_hiddens,vocab_size)
    
        def init_state(self,enc_outputs,*args):
            # enc_outputs[0]为编码器的输出
            # enc_outputs[1]为编码器最后一层输出的隐变量
            return enc_outputs[1]
    
        def forward(self, X, state):
            # print('Decoder中 未进行embedding的X的形状:',X.size())
            with  open('D://pythonProject//Decoder_X_size.txt', 'w') as f:
                f.write(str(X.size()))
            """Decoder的X的大小:torch.Size([25, 10])"""
            # 输出'X'的形状:(batch_size,num_steps,embed_size)
            X = self.embedding(X).permute(1,0,2)
            with  open('D://pythonProject//Decoder_X_embed_permute.txt', 'w') as f:
                f.write(str(X.size()))
            """Decoder的X_embed_permute的大小:torch.Size([10, 25, 32])"""
            # 广播context,使其具有与X相同的num_steps 即X.shape[0]
            context = state[-1].repeat(X.shape[0], 1, 1)
            X_and_Context = torch.cat((X,context),2)
            output,state = self.rnn(X_and_Context,state)
            output = self.dense(output).permute(1,0,2)
            # output的形状:(batch_size,num_steps,vocab_size)
            # state的形状:(num_layers,batch_size,num_hiddens)
            return output, state
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    在初始化__init__()函数中完成了将输入维度(batch_size,num_steps)进行Embedding操作,其输出维度变为了(batch_size,num_steps,num_embedding)
    同时,将embed+hiddens的大小同时送入GRU的输入层,同时不使用dropout操作。最后,初始化输出层要放入的Linear全连接层。
    forward()函数——前向传播中,首先对X进行embedding操作,并进行了permulate()将第一个维度变为了num_steps。将编码器得到的state隐状态通过repeat成与X第一维度num_steps相同后利用广播机制形成最终含有上下文信息的Context并最终通过torch,cat连接到X中【维度选用2】。最后利用了rnn输出output和最后的隐状态state。

    实例化解码器

    decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16,
                             num_layers=2)
    decoder.eval()
    state = decoder.init_state(encoder(X))
    output, state = decoder(X, state)
    output.shape, state.shape
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    有关于model.train()和model.eval()的区别可以参考:torch 中的 model.eval() 是什么?

    修改损失函数

    # 修改损失函数:将填充词元的预测排除在损失函数的计算之外
    """下面的sequence_mask函数 通过零值化屏蔽不相关的项"""
    #@save
    def sequence_mask(X,valid_len,value=0):
        # print('mask X的形状:',X.size())
        with  open('D://pythonProject//Mask_X_size.txt', 'w') as f:
            f.write(str(X.size()))
        """损失函数中的Mask_X_size的大小:torch.Size([25, 10]) 显然是没有进行Embedding的"""
        """在序列中屏蔽不相干的项"""
        maxlen = X.size(1)
        mask = torch.arange((maxlen),dtype=torch.float32,device=X.device)[None,:]<valid_len[:,None]
        X[~mask] = value
        return X
    X = torch.tensor([[1,2,3],[4,5,6]])
    res = sequence_mask(X,torch.tensor([1,2]))
    print('valid_len 分别为 1 和 2: ',res)
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    同时可以使用非0值替换要屏蔽的项

    X = torch.ones(2,3,4)
    res = sequence_mask(X,torch.tensor([1,2]),value=-1)
    
    • 1
    • 2

    我们可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。
    最初,所有预测词元的掩码都设置为1。 一旦给定了有效长度,与填充
    词元对应的掩码将被设置为0。 最后,将所有词元的损失乘以掩码,以
    过滤掉损失中填充词元产生的不相关预测。

    #@save
    class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
        """带遮蔽的softmax交叉熵损失函数"""
        # pred的形状:(batch_size,num_steps,vocab_size)
        # label的形状:(batch_size,num_steps)
        # valid_len的形状:(batch_size,)
        def forward(self, pred, label, valid_len):
            # 预测词元的掩码都设置为1
            weights = torch.ones_like(label)
            # 一旦给定了有效长度,与填充
            # 词元对应的掩码将被设置为0。
            weights = sequence_mask(weights, valid_len)
            self.reduction='none'
            unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
                pred.permute(0, 2, 1), label)
            weighted_loss = (unweighted_loss * weights).mean(dim=1)
            return weighted_loss
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    训练

    在训练部分,需要在原始的编码器输出序列前加入特定的序列开始词元 同时作为解码器的输入—>这种操作被称为强制教学

    """在训练部分,需要在原始的编码器输出序列前加入特定的序列开始词元 同时作为解码器的输入--->这种操作被称为强制教学"""
    #@save
    def train_seq2seq(net,data_iter,lr,num_epochs,tgt_vocab,device):
        """训练序列到序列模型"""
        def xavier_init_weights(m):
            if type(m) == nn.Linear:
                nn.init.xavier_uniform_(m.weight)
            if type(m) == nn.GRU:
                for param in m._flat_weights_names:
                    if "weight" in param:
                        nn.init.xavier_uniform_(m._parameters[param])
        net.apply(xavier_init_weights)
        net.to(device)
        optimizer = torch.optim.Adam(net.parameters(), lr=lr)
        loss = MaskedSoftmaxCELoss()
        """注意:这里使用的是net.train()"""
        net.train()
        animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                                xlim=[10, num_epochs])
        for epoch in range(num_epochs):
            timer = d2l.Timer()
            metric = d2l.Accumulator(2)  # 训练损失总和,词元数量
            for batch in data_iter:
                optimizer.zero_grad()
                X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
                # print('train-X:',X,'train-X_valid_len:',X_valid_len)
                # print('train-Y:',Y,'train-Y_valid_len:',Y_valid_len)
                with  open('D://pythonProject//X_valid_len.txt', 'w') as f:
                    f.write(str(X_valid_len))
                """
                tensor([4, 4, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
            5])---------->它的总长度为batch_size=25(最后一个batch_size)  之前的都是64
                """
                with  open('D://pythonProject//Y_valid_len.txt', 'w') as f:
                    f.write(str(Y_valid_len))
                """
                tensor([4, 4, 3, 5, 5, 4, 5, 3, 4, 4, 5, 4, 4, 4, 7, 5, 5, 4, 4, 3, 4, 4, 3, 3,
            5])---------->它的总长度为batch_size=25(最后一个batch_size)  之前的都是64
                """
                bos = torch.tensor([tgt_vocab['']] * Y.shape[0],
                              device=device).reshape(-1, 1)
                dec_input = torch.cat([bos, Y[:, :-1]], 1)  # 强制教学
                Y_hat, _ = net(X, dec_input, X_valid_len)
                l = loss(Y_hat, Y, Y_valid_len)
                l.sum().backward()      # 损失函数的标量进行“反向传播”
                d2l.grad_clipping(net, 1)
                num_tokens = Y_valid_len.sum()
                optimizer.step()
                with torch.no_grad():
                    metric.add(l.sum(), num_tokens)
            if (epoch + 1) % 10 == 0:
                animator.add(epoch + 1, (metric[0] / metric[1],))
        print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
            f'tokens/sec on {str(device)}')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54

    在机器翻译数据集上创建和训练一个循环神经网络‘编码器-解码器‘模型用于序列到序列的学习

    这里需要注意的是在decoder训练的时候丢进去的数据直接是真实的label值。

    embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
    batch_size, num_steps = 64, 10
    lr, num_epochs, device = 0.005, 300, d2l.try_gpu()
    train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
    encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,                      dropout)
    decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
                            dropout)
    net = d2l.EncoderDecoder(encoder, decoder)
    train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    预测

    为了采用一个接着一个词元的方式预测输出序列, 每个解码器当前时间步的输入都将来自于前一时间步的预测词元。
    在这里插入图片描述
    预测阶段的主要任务是:

    1. 将net设置为评估模式
    2. 在tokens后面加入< eos >;如果长度不够num_steps时在句子后填充< pad >拉长句子
    3. 将源tokens增加维度0,使得它变成一个二维向量
    4. 将编码器的输出(该输出包括outputs和state两个部分)传入解码器的初始化隐状态函数中初始化解码器的隐状态
    5. 将编码器的输入特征X转变成二维特征向量
    6. 预测过程:①利用预测最高可能性的词元作为解码器在下一个时间步的输入;②将解码器的输出转变成二维向量,如果预测的词元为< eos >则停止这个短句的预测;③最后利用join函数形成最终的预测短句
    # 预测
    #@save
    def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
                        device, save_attention_weights=False):
        """序列到序列模型的预测"""
        # 在预测时将net设置为评估模式
        net.eval()
        src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
            src_vocab['']]
        enc_valid_len = torch.tensor([len(src_tokens)], device=device)
        # 增加
        if len(src_tokens) > num_steps:
            with  open('D://pythonProject//predict_seq2seq-truncate.txt', 'w') as f:
                f.write(str('截断'))
        else:
            with  open('D://pythonProject//predict_seq2seq-pad.txt', 'w') as f:
                f.write(str('拉长'))
    
        src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab[''])
        # 添加批量轴--->增肌维度,将
        """input是一维,则dim=0时数据为行方向扩,dim=1时为列方向扩"""
        """这里的src_tokens是一个list对象"""
        print('len(src_tokens): ',len(src_tokens))  # len of src_tokens == 10
        enc_X = torch.unsqueeze(
            torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
        # enc_X的大小为 torch.Size([1, 10])
        """这里将src_tokens从list对象转成了一个tensor,增加了维度0"""
        with  open('D://pythonProject//predict_seq2seq-enc_X-enc_X.txt', 'w') as f:
            f.write(str(enc_X.size()))
    
        enc_outputs = net.encoder(enc_X, enc_valid_len)
        dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
        # 添加批量轴
        """这里将tgt_vocab从list对象转成了一个tensor,增加了维度0"""
        dec_X = torch.unsqueeze(torch.tensor(
            [tgt_vocab['']], dtype=torch.long, device=device), dim=0)
    
        output_seq, attention_weight_seq = [], []
        for _ in range(num_steps):
            Y, dec_state = net.decoder(dec_X, dec_state)
            # 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
            dec_X = Y.argmax(dim=2)  #返回可能性最大词元的索引位置
            pred = dec_X.squeeze(dim=0).type(torch.int32).item()
            print('pred:--->', pred)
            # 保存注意力权重(稍后讨论)
            if save_attention_weights:
                attention_weight_seq.append(net.decoder.attention_weights)
            # 一旦序列结束词元被预测,输出序列的生成就完成了
            if pred == tgt_vocab['']:
                print('pred:--->eos',pred)
                break
            output_seq.append(pred)
        return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53

    利用BLEU函数进行预测序列的评估

    BLEU函数:
    在这里插入图片描述

    正如上述式子所列,当预测的长度 l e n p r e d len_{pred} lenpred小于真实的label长度 l e n l a b e l len_{label} lenlabel时说明预测成功的可能性很低,此时整个分式就变得很大,最后出来的值就会很小,这就在一定程度上加强了短句子的权重惩罚。同时,如果后面的连乘加重了长句子的权重惩罚。

    # 预测序列的评估
    def bleu(pred_seq, label_seq, k):  #@save
        """计算BLEU"""
        pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
        len_pred, len_label = len(pred_tokens), len(label_tokens)
        score = math.exp(min(0, 1 - len_label / len_pred))
        for n in range(1, k + 1):
            num_matches, label_subs = 0, collections.defaultdict(int)
            for i in range(len_label - n + 1):
                label_subs[' '.join(label_tokens[i: i + n])] += 1
            for i in range(len_pred - n + 1):
                if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
                    num_matches += 1
                    label_subs[' '.join(pred_tokens[i: i + n])] -= 1
            score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
        return score
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    在“fra-eng”数据集上做预测

    """最后,利用训练好的循环神经网络“编码器-解码器”模型, 将几个英语句子翻译成法语,并计算BLEU的最终结果。"""
    engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
    fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
    for eng, fra in zip(engs, fras):
        translation, attention_weight_seq = predict_seq2seq(
            net, eng, src_vocab, tgt_vocab, num_steps, device)
        print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    省流—全部代码

    注意:这里为了debug,我增加了很多写文件的操作,主要是观察每一个向量的形状变化,具体的结果已经通过注释的方式写到了下面代码中,仅做参考~

    """模块torch已被修改
    def read_data_nmt():
        # Load the English-French dataset.
        data_dir = d2l.download_extract('fra-eng')
        with open(os.path.join(data_dir, 'fra.txt'), 'r',encoding='UTF-8') as f:
            return f.read()
    """
    """ 代码中出现的torch.Size([25, 10, 32])是因为将原始的数据按照batch_size进行划分
        最后一个batch的大小就是25
    """
    # 无脑导包
    import torch
    import collections # 这个包还是需要注意一下
    import math
    from torch import nn
    from d2l import torch as d2l
    
    # 实现Encoder编码器部分
    """
    内容部分:
    编码器的任务主要包括:将某一个时刻t的输入特征向量x_t和上一个时刻的隐状态h_(t-1)转变为h_t即
                    h_t = f(x_t , h_(t-1))
                      编码器需要通过函数q实现把所有的隐状态转变为上下文变量:
                    c  = q( h_1,....,h_T )
                      使用嵌入层获取输入序列的每个词元的特征向量[嵌入层权重矩阵行数为vocab_size,列数是特征向量的维度]
                      
                      采用GRU实现编码器
    """
    
    #@save
    class Seq2SeqEncoder(d2l.Encoder):
        """用于序列到序列学习的循环神经网络编码器"""
        def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0, **kwargs):
            super(Seq2SeqEncoder,self).__init__(**kwargs)
            # 实现嵌入层Embedding 将每一个词元转变成一个词向量
            self.embedding = nn.Embedding(vocab_size,embed_size)
            # print('Encoder中 self.embedding的size为:',self.embedding.size())
            # print('Encoder中 embed_size:   ',embed_size)
            with  open('D://pythonProject//Encoder_embed_pervir_size.txt', 'w') as f:
                f.write(str(embed_size))
            """----------embed_size为32----------"""
            """这里的embed_size为每一个词元对应的特征向量的长度"""
            self.rnn = nn.GRU(embed_size,num_hiddens,num_layers,dropout=dropout)
        def forward(self, X, *args):
            with  open('D://pythonProject//Encoder_Not_embed_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------未进行embedding的X: torch.Size([64, 10]) batch_size * num_steps----------"""
            # print('Encoder中 未进行embedding前的X的size',X.size())
            # embedding 的形状 (vocab_size,embed_size)
            # 输出'X'的形状:(batch_size,num_steps,embed_size)
            X = self.embedding(X)
            # print('Encoder中 进行embedding后的X的size',X.size())
            with  open('D://pythonProject//Encoder_embed_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------进行了embedding的X: torch.Size([64, 10, 32])----------"""
    
            #torch要求在循环神经网络模型中,第一个轴对应的必须是时间步
            X = X.permute(1,0,2)
            # print('Encoder中 permute后的X的size',X.size())
            with  open('D://pythonProject//Encoder_permute_size.txt', 'w') as f:
                f.write(str(X.size()))
            """----------进行了permute的X: torch.Size([10, 64, 32]) 10为时间步----------"""
            output,state = self.rnn(X)
            # output的输出形状: (num_steps,batch_size,num_hiddens)
            # state的输出形状: (num_layers,batch_size,num_hiddens)
            return output,state
    
    # 编码器实例化
    """
    输入:
    layer: 2层
    hiddens: 16个
    batch: 4
    steps: 7
    输出:
    tensor[时间步数,批量大小,隐藏单元数]
    """
    encoder = Seq2SeqEncoder(vocab_size=10,embed_size=8,num_hiddens=16,num_layers=2,dropout=0)
    X = torch.zeros((4,7),dtype=torch.long)
    output,state = encoder(X) # X的维度对应于forwoard中的X的维度
    # print('output.shape: ',output.shape)
    with  open('D://pythonProject//Encoder_output_size.txt', 'w') as f:
        f.write(str(output.shape))
    """----------output的形状: torch.Size([7, 4, 16]) 10为时间步----------"""
    
    """这里使用的是门控循环单元GRU,最后一个时间步的多层隐状态的形状是(num_layers,batch_size,num_hiddens)"""
    """如果使用LSTM 则state中还应该包含记忆单元信息"""
    # 实现Decoder部分
    """编码器输出的整个上下文信息变量C需要作用于整个输入序列x_1,...,x_r,对输入序列进行编码"""
    """解码器输出(star)y取决于输出子序列y1,...,(star)y_(t-1),C"""
    """P((star)y|y1,...,(star)y_(t-1),C)"""
    """
    ·使用解码器时,我们直接使用编码器的最后一个时间步的隐状态来初始化解码器的隐状态--->两者应该具有相同的隐藏层和隐藏单元
    ·为了让上下文信息更好包含更多的信息,可以用上下文变量C在所有的时间步与解码器的输入进行拼接
    ·为了输出预测词元的概率分布,在最后一层采用全连接层来变换隐状态
    """
    class Seq2SeqDecoder(d2l.Decoder):
        """用于序列到序列学习的循环神经网络解码器"""
        def __init__(self,vocab_size,embed_size,num_hiddens,num_layers,dropout=0,**kwargs):
            super(Seq2SeqDecoder, self).__init__(**kwargs)
            self.embedding = nn.Embedding(vocab_size,embed_size)
            with  open('D://pythonProject//Decoder_vocab_size.txt', 'w') as f:
                f.write(str(vocab_size))
            """----------decoder的vocab_size为201----------"""
    
    
            with  open('D://pythonProject//Decoder_embed_size.txt', 'w') as f:
                f.write(str(embed_size))
    
            """----------decoder的embed_size为32----------"""
            self.rnn = nn.GRU(embed_size+num_hiddens,num_hiddens,num_layers,dropout=dropout)
            self.dense = nn.Linear(num_hiddens,vocab_size)
    
        def init_state(self,enc_outputs,*args):
            # enc_outputs[0]为编码器的输出
            # enc_outputs[1]为编码器最后一层输出的隐变量
            return enc_outputs[1]
    
        def forward(self, X, state):
            # print('Decoder中 未进行embedding的X的形状:',X.size())
            with  open('D://pythonProject//Decoder_X_size.txt', 'w') as f:
                f.write(str(X.size()))
            """Decoder的X的大小:torch.Size([25, 10])"""
            # 输出'X'的形状:(batch_size,num_steps,embed_size)
            X = self.embedding(X).permute(1,0,2)
            with  open('D://pythonProject//Decoder_X_embed_permute.txt', 'w') as f:
                f.write(str(X.size()))
            """Decoder的X_embed_permute的大小:torch.Size([10, 25, 32])"""
            # 广播context,使其具有与X相同的num_steps 即X.shape[0]
            context = state[-1].repeat(X.shape[0], 1, 1)
            X_and_Context = torch.cat((X,context),2)
            output,state = self.rnn(X_and_Context,state)
            output = self.dense(output).permute(1,0,2)
            # output的形状:(batch_size,num_steps,vocab_size)
            # state的形状:(num_layers,batch_size,num_hiddens)
            return output, state
    
    # 实例化解码器
    decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16,
                             num_layers=2)
    decoder.eval()
    state = decoder.init_state(encoder(X))
    output, state = decoder(X, state)
    output.shape, state.shape
    
    # 修改损失函数:将填充词元的预测排除在损失函数的计算之外
    """下面的sequence_mask函数 通过零值化屏蔽不相关的项"""
    #@save
    def sequence_mask(X,valid_len,value=0):
        # print('mask X的形状:',X.size())
        with  open('D://pythonProject//Mask_X_size.txt', 'w') as f:
            f.write(str(X.size()))
        """损失函数中的Mask_X_size的大小:torch.Size([25, 10]) 显然是没有进行Embedding的"""
        """在序列中屏蔽不相干的项"""
        maxlen = X.size(1)
        mask = torch.arange((maxlen),dtype=torch.float32,device=X.device)[None,:]<valid_len[:,None]
        X[~mask] = value
        return X
    X = torch.tensor([[1,2,3],[4,5,6]])
    res = sequence_mask(X,torch.tensor([1,2]))
    print('valid_len 分别为 1 和 2: ',res)
    
    # 同时可以使用非0值替换要屏蔽的项
    X = torch.ones(2,3,4)
    res = sequence_mask(X,torch.tensor([1,2]),value=-1)
    """
    我们可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。 
    最初,所有预测词元的掩码都设置为1。 一旦给定了有效长度,与填充
    词元对应的掩码将被设置为0。 最后,将所有词元的损失乘以掩码,以
    过滤掉损失中填充词元产生的不相关预测。
    """
    #@save
    class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
        """带遮蔽的softmax交叉熵损失函数"""
        # pred的形状:(batch_size,num_steps,vocab_size)
        # label的形状:(batch_size,num_steps)
        # valid_len的形状:(batch_size,)
        def forward(self, pred, label, valid_len):
            # 预测词元的掩码都设置为1
            weights = torch.ones_like(label)
            # 一旦给定了有效长度,与填充
            # 词元对应的掩码将被设置为0。
            weights = sequence_mask(weights, valid_len)
            self.reduction='none'
            unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
                pred.permute(0, 2, 1), label)
            weighted_loss = (unweighted_loss * weights).mean(dim=1)
            return weighted_loss
    # 使用三个相同的序列 来进行代码健全性检查   分别指定这些序列的有效长度是4,2,0
    # 得出的损失结果为 第一个序列是第二个序列的两倍,第三个序列的损失直接为0
    
    # 训练
    """在训练部分,需要在原始的编码器输出序列前加入特定的序列开始词元 同时作为解码器的输入--->这种操作被称为强制教学"""
    #@save
    def train_seq2seq(net,data_iter,lr,num_epochs,tgt_vocab,device):
        """训练序列到序列模型"""
        def xavier_init_weights(m):
            if type(m) == nn.Linear:
                nn.init.xavier_uniform_(m.weight)
            if type(m) == nn.GRU:
                for param in m._flat_weights_names:
                    if "weight" in param:
                        nn.init.xavier_uniform_(m._parameters[param])
        net.apply(xavier_init_weights)
        net.to(device)
        optimizer = torch.optim.Adam(net.parameters(), lr=lr)
        loss = MaskedSoftmaxCELoss()
        """注意:这里使用的是net.train()"""
        net.train()
        animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                                xlim=[10, num_epochs])
        for epoch in range(num_epochs):
            timer = d2l.Timer()
            metric = d2l.Accumulator(2)  # 训练损失总和,词元数量
            for batch in data_iter:
                optimizer.zero_grad()
                X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
                # print('train-X:',X,'train-X_valid_len:',X_valid_len)
                # print('train-Y:',Y,'train-Y_valid_len:',Y_valid_len)
                with  open('D://pythonProject//X_valid_len.txt', 'w') as f:
                    f.write(str(X_valid_len))
                """
                tensor([4, 4, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
            5])---------->它的总长度为batch_size=25(最后一个batch_size)  之前的都是64
                """
                with  open('D://pythonProject//Y_valid_len.txt', 'w') as f:
                    f.write(str(Y_valid_len))
                """
                tensor([4, 4, 3, 5, 5, 4, 5, 3, 4, 4, 5, 4, 4, 4, 7, 5, 5, 4, 4, 3, 4, 4, 3, 3,
            5])---------->它的总长度为batch_size=25(最后一个batch_size)  之前的都是64
                """
                bos = torch.tensor([tgt_vocab['']] * Y.shape[0],
                              device=device).reshape(-1, 1)
                dec_input = torch.cat([bos, Y[:, :-1]], 1)  # 强制教学
                Y_hat, _ = net(X, dec_input, X_valid_len)
                l = loss(Y_hat, Y, Y_valid_len)
                l.sum().backward()      # 损失函数的标量进行“反向传播”
                d2l.grad_clipping(net, 1)
                num_tokens = Y_valid_len.sum()
                optimizer.step()
                with torch.no_grad():
                    metric.add(l.sum(), num_tokens)
            if (epoch + 1) % 10 == 0:
                animator.add(epoch + 1, (metric[0] / metric[1],))
        print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
            f'tokens/sec on {str(device)}')
    """在机器翻译数据集上创建和训练一个循环神经网络‘编码器-解码器‘模型用于序列到序列的学习"""
    embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
    batch_size, num_steps = 64, 10
    lr, num_epochs, device = 0.005, 300, d2l.try_gpu()
    train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
    encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,                      dropout)
    decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
                            dropout)
    net = d2l.EncoderDecoder(encoder, decoder)
    train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
    
    # 预测
    #@save
    def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
                        device, save_attention_weights=False):
        """序列到序列模型的预测"""
        # 在预测时将net设置为评估模式
        net.eval()
        src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
            src_vocab['']]
        enc_valid_len = torch.tensor([len(src_tokens)], device=device)
        # 增加
        if len(src_tokens) > num_steps:
            with  open('D://pythonProject//predict_seq2seq-truncate.txt', 'w') as f:
                f.write(str('截断'))
        else:
            with  open('D://pythonProject//predict_seq2seq-pad.txt', 'w') as f:
                f.write(str('拉长'))
    
        src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab[''])
        # 添加批量轴--->增肌维度,将
        """input是一维,则dim=0时数据为行方向扩,dim=1时为列方向扩"""
        """这里的src_tokens是一个list对象"""
        print('len(src_tokens): ',len(src_tokens))  # len of src_tokens == 10
        enc_X = torch.unsqueeze(
            torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
        # enc_X的大小为 torch.Size([1, 10])
        """这里将src_tokens从list对象转成了一个tensor,增加了维度0"""
        with  open('D://pythonProject//predict_seq2seq-enc_X-enc_X.txt', 'w') as f:
            f.write(str(enc_X.size()))
    
        enc_outputs = net.encoder(enc_X, enc_valid_len)
        dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
        # 添加批量轴
        """这里将tgt_vocab从list对象转成了一个tensor,增加了维度0"""
        dec_X = torch.unsqueeze(torch.tensor(
            [tgt_vocab['']], dtype=torch.long, device=device), dim=0)
    
        output_seq, attention_weight_seq = [], []
        for _ in range(num_steps):
            Y, dec_state = net.decoder(dec_X, dec_state)
            # 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
            dec_X = Y.argmax(dim=2)  #返回可能性最大词元的索引位置
            pred = dec_X.squeeze(dim=0).type(torch.int32).item()
            print('pred:--->', pred)
            # 保存注意力权重(稍后讨论)
            if save_attention_weights:
                attention_weight_seq.append(net.decoder.attention_weights)
            # 一旦序列结束词元被预测,输出序列的生成就完成了
            if pred == tgt_vocab['']:
                print('pred:--->eos',pred)
                break
            output_seq.append(pred)
        return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq
    # 预测序列的评估
    def bleu(pred_seq, label_seq, k):  #@save
        """计算BLEU"""
        pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
        len_pred, len_label = len(pred_tokens), len(label_tokens)
        score = math.exp(min(0, 1 - len_label / len_pred))
        for n in range(1, k + 1):
            num_matches, label_subs = 0, collections.defaultdict(int)
            for i in range(len_label - n + 1):
                label_subs[' '.join(label_tokens[i: i + n])] += 1
            for i in range(len_pred - n + 1):
                if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
                    num_matches += 1
                    label_subs[' '.join(pred_tokens[i: i + n])] -= 1
            score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
        return score
    """最后,利用训练好的循环神经网络“编码器-解码器”模型, 将几个英语句子翻译成法语,并计算BLEU的最终结果。"""
    engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
    fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
    for eng, fra in zip(engs, fras):
        translation, attention_weight_seq = predict_seq2seq(
            net, eng, src_vocab, tgt_vocab, num_steps, device)
        print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    • 293
    • 294
    • 295
    • 296
    • 297
    • 298
    • 299
    • 300
    • 301
    • 302
    • 303
    • 304
    • 305
    • 306
    • 307
    • 308
    • 309
    • 310
    • 311
    • 312
    • 313
    • 314
    • 315
    • 316
    • 317
    • 318
    • 319
    • 320
    • 321
    • 322
    • 323
    • 324
    • 325
    • 326
    • 327
    • 328
    • 329
    • 330
    • 331
    • 332
    • 333
    • 334
  • 相关阅读:
    Web开发介绍,制作小网站流程和需要的技术【详解】
    达梦8创建schema模式sql无法结束
    方舟开服务器游戏基础管理设置
    字节跳动 DanceCC 工具链系列之Xcode LLDB耗时监控统计方案
    使用python提取轮廓做定制的毛笔字帖
    C++ 四大强制类型转换
    搭建本地私有仓库
    云计算安全和云原生安全的关系
    08Maven中的继承和聚合的作用
    算法入门——冒泡排序、选择排序
  • 原文地址:https://blog.csdn.net/qq_43858783/article/details/133144349