• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

Seq2Seq使用 RNN 编码器-解码器学习短语表示以进行统计机器翻译

武飞扬头像
Sonhhxg_柒
帮助1

🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

🎁欢迎各位→点赞👍 收藏⭐️ 留言📝

📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

学新通

 🖍foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟👋

文章目录

简介

数据准备

构建Seq2Seq模型

编码器

解码器

Seq2Seq模型

Training the Seq2Seq Model


在第二个关于使用PyTorch和TorchText的序列到序列模型的笔记本中,我们将从Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation该模型将实现改进的测试困惑,同时仅在编码器和解码器中使用单层RNN。

简介

让我们提醒自己一般的编码器-解码器模型。学新通

我们在嵌入的源序列(黄色)上使用编码器(绿色)来创建上下文向量(红色)。然后,我们将该上下文向量与解码器(蓝色)和线性层(紫色)一起使用,以生成目标句子。

在之前的模型中,我们使用多层 LSTM 作为编码器和解码器。

学新通

 先前模型的一个缺点是解码器试图将大量信息塞入隐藏状态。在解码时,隐藏状态需要包含有关整个源序列的信息,以及到目前为止已解码的所有token。通过减轻一些信息压缩,我们可以创建一个更好的模型!

我们还将使用GRU(门控循环单元)而不是LSTM(长短期记忆)。为什么?主要是因为这就是他们在论文中所做的(本文还介绍了GRU),也因为我们上次使用了LSTM。要了解 GRU(和 LSTM)与标准 RNNS 有何不同,请查看此链接。全球群组比语言环境与发展服务体系更好吗?研究表明,它们几乎相同,并且都比标准RNN更好。

数据准备

所有的数据准备都将(几乎)与上次相同,因此我们将非常简要地详细说明每个代码块的作用。有关回顾,请参阅上一个笔记本。

我们将导入PyTorch, TorchText, spaCy和一些标准模块。

  1.  
    import torch
  2.  
    import torch.nn as nn
  3.  
    import torch.optim as optim
  4.  
     
  5.  
    from torchtext.legacy.datasets import Multi30k
  6.  
    from torchtext.legacy.data import Field, BucketIterator
  7.  
     
  8.  
    import spacy
  9.  
    import numpy as np
  10.  
     
  11.  
    import random
  12.  
    import math
  13.  
    import time

然后设置一个随机种子,以获得确定性结果/可重复性。

  1.  
    SEED = 1234
  2.  
     
  3.  
    random.seed(SEED)
  4.  
    np.random.seed(SEED)
  5.  
    torch.manual_seed(SEED)
  6.  
    torch.cuda.manual_seed(SEED)
  7.  
    torch.backends.cudnn.deterministic = True

实例化我们的德语和英语 spaCy 模型。

  1.  
    spacy_de = spacy.load('de_core_news_sm')
  2.  
    spacy_en = spacy.load('en_core_web_sm')

以前我们反转了源(德语)句子,但是在我们正在实现的论文中,它们不会这样做,所以我们也不会这样做。

  1.  
    def tokenize_de(text):
  2.  
    """
  3.  
    将字符串中的德语文本标记化为字符串列表
  4.  
    """
  5.  
    return [tok.text for tok in spacy_de.tokenizer(text)]
  6.  
     
  7.  
    def tokenize_en(text):
  8.  
    """
  9.  
    将字符串中的英语文本标记化为字符串列表
  10.  
    """
  11.  
    return [tok.text for tok in spacy_en.tokenizer(text)]

创建我们的字段来处理我们的数据。这将附加“句子开头”和“句子结尾”标记,并将所有单词转换为小写。

  1.  
    SRC = Field(tokenize=tokenize_de,
  2.  
    init_token='<sos>',
  3.  
    eos_token='<eos>',
  4.  
    lower=True)
  5.  
     
  6.  
    TRG = Field(tokenize = tokenize_en,
  7.  
    init_token='<sos>',
  8.  
    eos_token='<eos>',
  9.  
    lower=True)

加载我们的数据

  1.  
    train_data, valid_data, test_data = Multi30k.splits(exts = ('.de', '.en'),
  2.  
    fields = (SRC, TRG))

我们还将打印出一个示例,以仔细检查它们是否被反转。

print(vars(train_data.examples[0]))
{'src': ['zwei', 'junge', 'weiße', 'männer', 'sind', 'im', 'freien', 'in', 'der', 'nähe', 'vieler', 'büsche', '.'], 'trg': ['two', 'young', ',', 'white', 'males', 'are', 'outside', 'near', 'many', 'bushes', '.']}

然后创建我们的词汇表,将所有出现次数少于两次的<unk>token转换为token。

  1.  
    SRC.build_vocab(train_data, min_freq = 2)
  2.  
    TRG.build_vocab(train_data, min_freq = 2)

最后,定义设备并创建我们的迭代器。

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
  1.  
    BATCH_SIZE = 128
  2.  
     
  3.  
    train_iterator, valid_iterator, test_iterator = BucketIterator.splits(
  4.  
    (train_data, valid_data, test_data),
  5.  
    batch_size = BATCH_SIZE,
  6.  
    device = device)

构建Seq2Seq模型

编码器

编码器与上一个编码器类似,将多层 LSTM 替换为单层 GRU。我们也不会将丢弃作为参数传递给 GRU,因为该丢弃在多层 RNN 的每一层之间使用。由于我们只有一个图层,因此如果我们尝试使用向其传递丢弃值,PyTorch 将显示警告。

关于GRU需要注意的另一件事是,它只需要并返回一个隐藏状态,没有像LSTM中那样的单元状态。学新通

从上面的等式来看,RNN和GRU看起来是相同的。然而,在GRU内部,有许多门控机制,用于控制进出隐藏状态(类似于LSTM)的信息流。同样,有关更多信息,请查看这篇出色的帖子。

.编码器的其余部分应该从上一个教程中非常熟悉,它采用一个顺序,X = {x1,x2x...,xT}
,将其传递到嵌入层,反复计算隐藏状态,H = {h1,h2,...,hT},并返回上下文向量(最终的隐藏状态),z = hT
.学新通

 这与一般 seq2seq 模型的编码器相同,所有“magic”都发生在 GRU(绿色)内部。学新通

  1.  
    class Encoder(nn.Module):
  2.  
    def __init__(self, input_dim, emb_dim, hid_dim, dropout):
  3.  
    super().__init__()
  4.  
    self.hid_dim = hid_dim
  5.  
    self.embedding = nn.Embedding(input_dim, emb_dim) #no dropout as only one layer!
  6.  
    self.rnn = nn.GRU(emb_dim, hid_dim)
  7.  
     
  8.  
    self.dropout = nn.Dropout(dropout)
  9.  
     
  10.  
    def forward(self, src):
  11.  
     
  12.  
    #src = [src len, batch size]
  13.  
    embedded = self.dropout(self.embedding(src))
  14.  
     
  15.  
    #embedded = [src len, batch size, emb dim]
  16.  
    outputs, hidden = self.rnn(embedded) #no cell state!
  17.  
     
  18.  
    #outputs = [src len, batch size, hid dim * n directions]
  19.  
    #hidden = [n layers * n directions, batch size, hid dim]
  20.  
    #outputs are always from the top hidden layer
  21.  
     
  22.  
    return hidden

解码器

解码器是实现与以前的模型有很大不同的地方,我们减轻了一些信息压缩。

而不是解码器中的GRU只接受嵌入式目标令牌,d(y(t)) 和以前的隐藏状态st-1 作为输入,它还采用上下文向量z。学新通

请注意,此上下文向量 z没有下标t,这意味着我们为解码器中的每个时间步长重用编码器返回的相同上下文向量。

之前,我们预测了下一个令牌,y^t 1,与线性层, f,仅使用顶层解码器在该时间步长的隐藏状态,st如y^t 1 = f(stL).现在,我们还传递了当前令牌的嵌入,d(y(t)) 和上下文向量,z到线性层。学新通

 因此,我们的解码器现在看起来像这样:

学新通

注意,初始隐藏状态,s0,仍然是上下文向量z ,因此在生成第一个令牌时,我们实际上是将两个相同的上下文向量输入到 GRU 中。

这两个变化如何减少信息压缩?好吧,假设解码器隐藏状态,st,则不再需要包含有关源序列的信息,因为它始终可用作输入。因此,它只需要包含有关到目前为止已生成哪些令牌的信息。新增yt
 到线性层也意味着该层可以直接看到令牌是什么,而不必从隐藏状态中获取此信息。

然而,这个假设只是一个假设,不可能确定模型实际上如何使用提供给它的信息(不要听任何说不同的话)。然而,这是一个坚实的直觉,结果似乎表明这种修改是一个好主意!

在实施过程中,我们将通过d(y(t))并通过将它们连接在一起来连接到GRU,因此GRU的输入维度现在emb_dim hid_dim(因为上下文向量的大小hid_dim)。

线性层将采取d(y(t)),st和z 并且通过将它们连接在一起,因此输入维度现在emb_dim hid_dim *2。我们也不会将丢弃值传递给 GRU,因为它只使用单个层。

前进现在采取上下文参数。在forward,的内部,我们连接yt和z 作为emb_con在向GRU提供信息之前,我们将d(y(t)),st和z
,
 并一起作为 output,然后通过线性层馈送它以接收我们的预测,y^t 1.

  1.  
    class Decoder(nn.Module):
  2.  
    def __init__(self, output_dim, emb_dim, hid_dim, dropout):
  3.  
    super().__init__()
  4.  
    self.hid_dim = hid_dim
  5.  
    self.output_dim = output_dim
  6.  
    self.embedding = nn.Embedding(output_dim, emb_dim)
  7.  
    self.rnn = nn.GRU(emb_dim hid_dim, hid_dim)
  8.  
    self.fc_out = nn.Linear(emb_dim hid_dim * 2, output_dim)
  9.  
    self.dropout = nn.Dropout(dropout)
  10.  
     
  11.  
    def forward(self, input, hidden, context):
  12.  
     
  13.  
    #input = [batch size]
  14.  
    #hidden = [n layers * n directions, batch size, hid dim]
  15.  
    #context = [n layers * n directions, batch size, hid dim]
  16.  
    #n layers and n directions in the decoder will both always be 1, therefore:
  17.  
    #hidden = [1, batch size, hid dim]
  18.  
    #context = [1, batch size, hid dim]
  19.  
    input = input.unsqueeze(0)
  20.  
     
  21.  
    #input = [1, batch size]
  22.  
    embedded = self.dropout(self.embedding(input))
  23.  
     
  24.  
    #embedded = [1, batch size, emb dim]
  25.  
    emb_con = torch.cat((embedded, context), dim = 2)
  26.  
     
  27.  
    #emb_con = [1, batch size, emb dim hid dim]
  28.  
    output, hidden = self.rnn(emb_con, hidden)
  29.  
     
  30.  
    #output = [seq len, batch size, hid dim * n directions]
  31.  
    #hidden = [n layers * n directions, batch size, hid dim]
  32.  
    #seq len, n layers and n directions will always be 1 in the decoder, therefore:
  33.  
    #output = [1, batch size, hid dim]
  34.  
    #hidden = [1, batch size, hid dim]
  35.  
    output = torch.cat((embedded.squeeze(0), hidden.squeeze(0), context.squeeze(0)),
  36.  
    dim = 1)
  37.  
     
  38.  
    #output = [batch size, emb dim hid dim * 2]
  39.  
    prediction = self.fc_out(output)
  40.  
     
  41.  
    #prediction = [batch size, output dim]
  42.  
    return prediction, hidden

Seq2Seq模型


将编码器和解码器放在一起,我们得到:

学新通

同样,在此实现中,我们需要确保编码器和解码器中的隐藏尺寸相同。

简要介绍所有步骤:

  • 创建输出张量来保存所有预测,Y^
  • 源序列X 被馈送到编码器中以接收context向量
  • 初始解码器隐藏状态设置为context向量,s0 = z = hT
  • 我们使用一批<sos>代币作为第一个输入,y1
  • 然后,我们在一个循环中解码:
  • 插入输入token yt ,以前的隐藏状态,st-1和上下文向量 z, 放入解码器中
  • 接收预测,y^t 1,以及一个新的隐藏状态,st
  • 然后,我们决定是否要教师强制,根据需要设置下一个输入(目标序列中的下一个令牌或预测的最高下一个令牌)
  1.  
    class Seq2Seq(nn.Module):
  2.  
    def __init__(self, encoder, decoder, device):
  3.  
    super().__init__()
  4.  
     
  5.  
    self.encoder = encoder
  6.  
    self.decoder = decoder
  7.  
    self.device = device
  8.  
     
  9.  
    assert encoder.hid_dim == decoder.hid_dim, \
  10.  
    "Hidden dimensions of encoder and decoder must be equal!"
  11.  
     
  12.  
    def forward(self, src, trg, teacher_forcing_ratio = 0.5):
  13.  
     
  14.  
    #src = [src len, batch size]
  15.  
    #trg = [trg len, batch size]
  16.  
    #teacher_forcing_ratio is probability to use teacher forcing
  17.  
    #e.g. if teacher_forcing_ratio is 0.75 we use ground-truth inputs 75% of the time
  18.  
     
  19.  
    batch_size = trg.shape[1]
  20.  
    trg_len = trg.shape[0]
  21.  
    trg_vocab_size = self.decoder.output_dim
  22.  
     
  23.  
    #tensor to store decoder outputs
  24.  
    outputs = torch.zeros(trg_len, batch_size, trg_vocab_size).to(self.device)
  25.  
     
  26.  
    #last hidden state of the encoder is the context
  27.  
    context = self.encoder(src)
  28.  
     
  29.  
    #context also used as the initial hidden state of the decoder
  30.  
    hidden = context
  31.  
     
  32.  
    #first input to the decoder is the <sos> tokens
  33.  
    input = trg[0,:]
  34.  
     
  35.  
    for t in range(1, trg_len):
  36.  
     
  37.  
    #insert input token embedding, previous hidden state and the context state
  38.  
    #receive output tensor (predictions) and new hidden state
  39.  
    output, hidden = self.decoder(input, hidden, context)
  40.  
     
  41.  
    #place predictions in a tensor holding predictions for each token
  42.  
    outputs[t] = output
  43.  
     
  44.  
    #decide if we are going to use teacher forcing or not
  45.  
    teacher_force = random.random() < teacher_forcing_ratio
  46.  
     
  47.  
    #get the highest predicted token from our predictions
  48.  
    top1 = output.argmax(1)
  49.  
     
  50.  
    #if teacher forcing, use actual next token as next input
  51.  
    #if not, use predicted token
  52.  
    input = trg[t] if teacher_force else top1
  53.  
     
  54.  
    return outputs

Training the Seq2Seq Model

本教程的其余部分与上一个非常相似。

我们初始化编码器,解码器和seq2seq模型(如果有的话,将其放在GPU上)。与以前一样,编码器和解码器之间的嵌入尺寸和使用的压差量可能不同,但隐藏的尺寸必须保持不变。

  1.  
    INPUT_DIM = len(SRC.vocab)
  2.  
    OUTPUT_DIM = len(TRG.vocab)
  3.  
    ENC_EMB_DIM = 256
  4.  
    DEC_EMB_DIM = 256
  5.  
    HID_DIM = 512
  6.  
    ENC_DROPOUT = 0.5
  7.  
    DEC_DROPOUT = 0.5
  8.  
     
  9.  
    enc = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, ENC_DROPOUT)
  10.  
    dec = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, DEC_DROPOUT)
  11.  
     
  12.  
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
  13.  
     
  14.  
    model = Seq2Seq(enc, dec, device).to(device)

接下来,我们初始化参数。该论文指出,参数是从均值为0,标准差为0.01的正态分布初始化的,即N(0,0.01)。

它还声明我们应该将循环参数初始化为特殊初始化,但是为了简单起见,我们也会将它们初始化为 N(0,0.01)。

  1.  
    def init_weights(m):
  2.  
    for name, param in m.named_parameters():
  3.  
    nn.init.normal_(param.data, mean=0, std=0.01)
  4.  
     
  5.  
    model.apply(init_weights)
Seq2Seq(
  (encoder): Encoder(
    (embedding): Embedding(7853, 256)
    (rnn): GRU(256, 512)
    (dropout): Dropout(p=0.5, inplace=False)
  )
  (decoder): Decoder(
    (embedding): Embedding(5893, 256)
    (rnn): GRU(768, 512)
    (fc_out): Linear(in_features=1280, out_features=5893, bias=True)
    (dropout): Dropout(p=0.5, inplace=False)
  )
)

我们打印出参数的数量。

尽管我们的编码器和解码器只有一个单层RNN,但我们实际上比上一个模型有更多的参数。这是由于GRU和线性层的输入大小增加。但是,它不是大量的参数,并且会导致训练时间的最小增加量(每epoch额外约3秒)。

  1.  
    def count_parameters(model):
  2.  
    return sum(p.numel() for p in model.parameters() if p.requires_grad)
  3.  
     
  4.  
    print(f'The model has {count_parameters(model):,} trainable parameters')
The model has 14,219,781 trainable parameters

我们启动了优化器。

optimizer = optim.Adam(model.parameters())

我们还初始化损失函数,确保忽略token上的损失<pad>。

  1.  
    TRG_PAD_IDX = TRG.vocab.stoi[TRG.pad_token]
  2.  
     
  3.  
    criterion = nn.CrossEntropyLoss(ignore_index = TRG_PAD_IDX)

然后,我们创建训练循环...

  1.  
    def train(model, iterator, optimizer, criterion, clip):
  2.  
     
  3.  
    model.train()
  4.  
     
  5.  
    epoch_loss = 0
  6.  
     
  7.  
    for i, batch in enumerate(iterator):
  8.  
     
  9.  
    src = batch.src
  10.  
    trg = batch.trg
  11.  
     
  12.  
    optimizer.zero_grad()
  13.  
     
  14.  
    output = model(src, trg)
  15.  
     
  16.  
    #trg = [trg len, batch size]
  17.  
    #output = [trg len, batch size, output dim]
  18.  
     
  19.  
    output_dim = output.shape[-1]
  20.  
     
  21.  
    output = output[1:].view(-1, output_dim)
  22.  
    trg = trg[1:].view(-1)
  23.  
     
  24.  
    #trg = [(trg len - 1) * batch size]
  25.  
    #output = [(trg len - 1) * batch size, output dim]
  26.  
     
  27.  
    loss = criterion(output, trg)
  28.  
     
  29.  
    loss.backward()
  30.  
     
  31.  
    torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
  32.  
     
  33.  
    optimizer.step()
  34.  
     
  35.  
    epoch_loss = loss.item()
  36.  
     
  37.  
    return epoch_loss / len(iterator)

..和评估循环,记住将模型设置为评估模式并关闭teaching forcing。

  1.  
    def evaluate(model, iterator, criterion):
  2.  
     
  3.  
    model.eval()
  4.  
     
  5.  
    epoch_loss = 0
  6.  
     
  7.  
    with torch.no_grad():
  8.  
     
  9.  
    for i, batch in enumerate(iterator):
  10.  
     
  11.  
    src = batch.src
  12.  
    trg = batch.trg
  13.  
     
  14.  
    output = model(src, trg, 0) #turn off teacher forcing
  15.  
     
  16.  
    #trg = [trg len, batch size]
  17.  
    #output = [trg len, batch size, output dim]
  18.  
     
  19.  
    output_dim = output.shape[-1]
  20.  
     
  21.  
    output = output[1:].view(-1, output_dim)
  22.  
    trg = trg[1:].view(-1)
  23.  
     
  24.  
    #trg = [(trg len - 1) * batch size]
  25.  
    #output = [(trg len - 1) * batch size, output dim]
  26.  
     
  27.  
    loss = criterion(output, trg)
  28.  
     
  29.  
    epoch_loss = loss.item()
  30.  
     
  31.  
    return epoch_loss / len(iterator)

我们还将定义计算epoch所用时间的函数。

  1.  
    def epoch_time(start_time, end_time):
  2.  
    elapsed_time = end_time - start_time
  3.  
    elapsed_mins = int(elapsed_time / 60)
  4.  
    elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
  5.  
    return elapsed_mins, elapsed_secs

然后,我们训练模型,保存给我们最佳验证损失的参数。

  1.  
    N_EPOCHS = 10
  2.  
    CLIP = 1
  3.  
     
  4.  
    best_valid_loss = float('inf')
  5.  
     
  6.  
    for epoch in range(N_EPOCHS):
  7.  
     
  8.  
    start_time = time.time()
  9.  
     
  10.  
    train_loss = train(model, train_iterator, optimizer, criterion, CLIP)
  11.  
    valid_loss = evaluate(model, valid_iterator, criterion)
  12.  
     
  13.  
    end_time = time.time()
  14.  
     
  15.  
    epoch_mins, epoch_secs = epoch_time(start_time, end_time)
  16.  
     
  17.  
    if valid_loss < best_valid_loss:
  18.  
    best_valid_loss = valid_loss
  19.  
    torch.save(model.state_dict(), 'tut2-model.pt')
  20.  
     
  21.  
    print(f'Epoch: {epoch 1:02} | Time: {epoch_mins}m {epoch_secs}s')
  22.  
    print(f'\tTrain Loss: {train_loss:.3f} | Train PPL: {math.exp(train_loss):7.3f}')
  23.  
    print(f'\t Val. Loss: {valid_loss:.3f} | Val. PPL: {math.exp(valid_loss):7.3f}')
Epoch: 01 | Time: 0m 28s
	Train Loss: 5.072 | Train PPL: 159.429
	 Val. Loss: 5.041 |  Val. PPL: 154.646
Epoch: 02 | Time: 0m 28s
	Train Loss: 4.366 | Train PPL:  78.718
	 Val. Loss: 5.114 |  Val. PPL: 166.280
Epoch: 03 | Time: 0m 28s
	Train Loss: 4.011 | Train PPL:  55.202
	 Val. Loss: 4.613 |  Val. PPL: 100.795
Epoch: 04 | Time: 0m 28s
	Train Loss: 3.612 | Train PPL:  37.050
	 Val. Loss: 4.195 |  Val. PPL:  66.323
Epoch: 05 | Time: 0m 27s
	Train Loss: 3.252 | Train PPL:  25.848
	 Val. Loss: 3.981 |  Val. PPL:  53.584
Epoch: 06 | Time: 0m 28s
	Train Loss: 2.953 | Train PPL:  19.173
	 Val. Loss: 3.798 |  Val. PPL:  44.601
Epoch: 07 | Time: 0m 27s
	Train Loss: 2.701 | Train PPL:  14.892
	 Val. Loss: 3.653 |  Val. PPL:  38.593
Epoch: 08 | Time: 0m 28s
	Train Loss: 2.463 | Train PPL:  11.735
	 Val. Loss: 3.599 |  Val. PPL:  36.558
Epoch: 09 | Time: 0m 28s
	Train Loss: 2.247 | Train PPL:   9.456
	 Val. Loss: 3.563 |  Val. PPL:  35.269
Epoch: 10 | Time: 0m 28s
	Train Loss: 2.090 | Train PPL:   8.086
	 Val. Loss: 3.639 |  Val. PPL:  38.051

最后,我们使用这些“最佳”参数在测试集上测试模型。

  1.  
    model.load_state_dict(torch.load('tut2-model.pt'))
  2.  
     
  3.  
    test_loss = evaluate(model, test_iterator, criterion)
  4.  
     
  5.  
    print(f'| Test Loss: {test_loss:.3f} | Test PPL: {math.exp(test_loss):7.3f} |')
| Test Loss: 3.546 | Test PPL:  34.662 |

仅看测试损失,我们就获得了比以前模型更好的性能。这是一个很好的迹象,表明这个模型架构正在做正确的事情!缓解信息压缩似乎是forard的方式,在下一个教程中,我们将进一步扩展这一点。

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgcgeke
系列文章
更多 icon
同类精品
更多 icon
继续加载