【NLP理论到实战】20 seq2seq模型原理及demo实例

文章目录

  • Seq2Seq模型的原理
    • 目标
    • 1. Seq2Seq的介绍
    • 2. Seq2Seq模型的实现
      • 2.1 实现流程
      • 2.2 准备数据集`Dataset`和`DataLoader`
      • 2.3 数字字符串转化为序列
      • 2.4 准备编码器
      • 2.5 实现解码器
      • 2.6 完成seq2seq模型
      • 2.7 完成训练逻辑
      • 2.8 完成模型评估与预测逻辑
    • 3. 加入GPU运算选择,完整代码参考如下:
      • 3.1 `dataset.py`
      • 2.`config.py`
      • 3.`num2sequence.py`
      • 4.`encoder.py`
      • 5.`decoder.py`
      • 6.`seq2seq.py`
      • 7.`train.py`
      • 8.`eval.py`

Seq2Seq模型的原理

目标

  1. 知道seq2seq的常见应用场景
  2. 能够说出常见的seq2seq的结构
  3. 能够使用代码完成基础的seq2seq的结构

1. Seq2Seq的介绍

【NLP理论到实战】20 seq2seq模型原理及demo实例_第1张图片

Sequence to sequence (seq2seq)是由encoder(编码器)decoder(解码器)两个RNN的组成的。其中encoder负责对输入句子的理解,转化为context vectordecoder负责对理解后的句子的向量进行处理,解码,获得输出。上述的过程和我们大脑理解东西的过程很相似,听到一句话,理解之后,尝试组装答案,进行回答


那么此时,就有一个问题,在encoder的过程中得到的context vector作为decoder的输入,那么这样一个输入,怎么能够得到多个输出呢?


其实就是当前一步的输出,作为下一个单元的输入,然后得到结果

outputs = []
while True:
   output = decoderd(output)
   outputs.append(output)

那么循环什么时候停止呢?


在训练数据集中,可以再输出的最后面添加一个结束符,如果遇到该结束符,则可以终止循环

outputs = []
while output!="":
   output = decoderd(output)
   outputs.append(output)

这个结束符只是一个标记,很多人也会使用(End Of Sentence)


总之:Seq2Seq模型中的encoder接受一个长度为M的序列,得到1个 context vector,之后decoder把这一个context vector转化为长度为N的序列作为输出,从而构成一个M to N的模型,能够处理很多不定长输入输出的问题,比如:文本翻译,问答,文章摘要,关键字写诗等等

2. Seq2Seq模型的实现

下面,我们通过一个简单的列子,来看看普通的Seq2Seq模型应该如何实现。


需求:完成一个模型,实现往模型输入一串数字字符,输出这串数字字符+0(不靠字符串拼接)


例如

  • 输入123456789,输出1234567890
  • 输入52555568,输出525555680

2.1 实现流程

  1. 准备数据集和Dataloader,其中使用下面2中的转换序列函数
  2. 编写文本转化为序列函数(即数字字符串转化为数字序列,torch.LongTensor
  3. 完成编码器
  4. 完成解码器
  5. 完成seq2seq模型
  6. 完成模型训练函数,进行训练
  7. 完成模型评估函数,进行模型评估

2.2 准备数据集DatasetDataLoader

这里,我们使用随机创建的[0,100000000]的整型,来准备数据集

"""
一、准备数据集(dataset.py)
"""
from torch.utils.data import Dataset, DataLoader
import numpy as np
import config

class NumDataset(Dataset):
    def __init__(self,train=True):
        # 使用numpy随机创建一堆数据作为数据集
        np.random.seed(1) if train else np.random.seed(2)
        self.size = 400000 if train else 100000
        self.data = np.random.randint(1, 1e8, self.size)
        
    def __getitem__(self, index):
        input = list(str(self.data[index]))
        target = input + ['0']
        return input, target, len(input), len(target)
        
    def __len__(self):
        return self.size


def collate_fn(batch):
    batch = sorted(batch,key=lambda x:x[-2],reverse=True)  # 对每个batch中数据按照长度降序排列,用于后续打包操作的前置要求
    inputs,targets,input_lengths,target_lengths = zip(*batch)
    
    # todo in num2sequence.py
    
    return inputs,targets,input_lengths,target_lengths


def get_dataloader(train=True):
    num_dataset = NumDataset()
    batch_size = config.train_batch_size if train else config.test_batch_size
    num_dataloader = DataLoader(num_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn, drop_last=True)
    return num_dataloader


if __name__ == '__main__':
    for idx,(x,y_true,x_length,y_true_length) in enumerate(get_dataloader()):
        print('idx: ',idx)
        print('batch_simple: ',x)
        print('batch_target: ',y_true)
        print('*'*100)
        print(x_length)
        print(y_true_length)
        break
"""
二、配置文件(config.py)
"""
train_batch_size = 3
test_batch_size = 512

max_len = 10

输出结果如下:

idx:  0
batch_simple:  (['6', '7', '6', '7', '5', '3', '8', '5'], ['3', '9', '8', '3', '6', '9', '1', '9'], ['3', '7', '0', '1', '7', '4', '7'])
batch_target:  (['6', '7', '6', '7', '5', '3', '8', '5', '0'], ['3', '9', '8', '3', '6', '9', '1', '9', '0'], ['3', '7', '0', '1', '7', '4', '7', '0'])
****************************************************************************************************
(8, 8, 7)
(9, 9, 8)

通过随机数的结果,可以看到,大部分的数字长度为8,如果在目标值后面添加上0和EOS之后,最大长度为10


所以常见config配置文件,添加上max_len:文本最大长度,方便后续的修改

2.3 数字字符串转化为序列

由于输入的是数字字符串,为了把这些数字字符串中单个数字字符和词典中的真实数字进行对应


那么我们需要做的就是:

  1. 把字符串对应为数字序列
  2. 把数字序列转化为字符串

完成逻辑和之前文本情感分类中word2sequence相同,实现上述逻辑如下:

"""
三、实现数字字符串序列化
"""

class Num2Sequence:
    UNK_TAG = ''  # 特殊字符
    PAD_TAG = ''  # 填充字符
    SOS_TAG = ''  # 序列开始字符
    EOS_TAG = ''  # 句子结束字符
    UNK = 0
    PAD = 1
    SOS = 2
    EOS = 3
    def __init__(self):
        self.numToSequence_dict = {
     
            self.UNK_TAG: self.UNK,
            self.PAD_TAG: self.PAD,
            self.SOS_TAG: self.SOS,
            self.EOS_TAG: self.EOS
        }
        for i in range(10):
            self.numToSequence_dict[str(i)] = len(self.numToSequence_dict)
        self.sequenceToNum_dict = dict(zip(self.numToSequence_dict.values(),self.numToSequence_dict.keys()))
    
    def transform(self,stringNum,max_len=None,add_eos=False): # 把数字字符串转化为数字序列
        if add_eos:
            max_len -= 1  # 为了输入和目标句子的长度都等于设置的max_len
        if max_len is not None:
            if len(stringNum) > max_len:
                stringNum = stringNum[:max_len]
            else:
                stringNum += [self.PAD_TAG] * (max_len - len(stringNum))
        if add_eos:
            if stringNum[-1] == self.PAD_TAG:
                stringNum.insert(stringNum.index(self.PAD_TAG),self.EOS_TAG)  # 数字字符串中有PAD,在PAD之前添加EOS
            else:
                stringNum += [self.EOS_TAG]    # 数字字符串中没有PAD,在最后添加EOS
        return [self.numToSequence_dict.get(charNum,self.UNK) for charNum in stringNum]
    
    def inverse_transform(self,sequence):  # 把数字序列转化为数字字符串
        results = []
        for index in sequence:
            result = self.sequenceToNum_dict.get(index,self.UNK_TAG)
            if result != self.EOS_TAG:
                results.append(result)
            else:
                break
        return results
    
    def __len__(self):
        return len(self.numToSequence_dict)


if __name__=='__main__':
    num_sequence = Num2Sequence()
    print(num_sequence.numToSequence_dict)
    print(len(num_sequence.numToSequence_dict))
    print('*'*100)
    str1 = ['1','9','9','8']
    str2 = ['-1','2','0','2','0','1','2','3']
    str3 = ['2','0','2','0']
    num1 = num_sequence.transform(str1,max_len=5)
    num2 = num_sequence.transform(str2,max_len=5,add_eos=True)
    num3 = num_sequence.transform(str3,max_len=8,add_eos=True)
    reverse_num1 = num_sequence.inverse_transform(num1)
    reverse_num2 = num_sequence.inverse_transform(num2)
    reverse_num3 = num_sequence.inverse_transform(num3)
    print(num1)
    print(reverse_num1)
    print(num2)
    print(reverse_num2)
    print(num3)
    print(reverse_num3)

输出结果如下:

{
     '': 0, '': 1, '': 2, '': 3, '0': 4, '1': 5, '2': 6, '3': 7, '4': 8, '5': 9, '6': 10, '7': 11, '8': 12, '9': 13}
14
****************************************************************************************************
[5, 13, 13, 12, 1]
['1', '9', '9', '8', '']
[0, 6, 4, 6, 3]
['', '2', '0', '2']
[6, 4, 6, 4, 3, 1, 1, 1]
['2', '0', '2', '0']

此时在dataset.pyconfig.py文件添加修改代码如下,使得在数据集加载获得dataloader后每个batch数据就已经序列化:

其中collate_fn方法全部需要注意:

  1. 需要对batch中的数据进行排序,根据数据的真实长度进行降序排序(后面需要用到)
  2. 需要调用文本序列化的方法,把文本进行序列化的操作,同时target需要进行add eos的操作
  3. 最后返回序列的LongTensor格式一遍后续可以记录梯度
"""
一、准备数据集(dataset.py)
"""
import torch

def collate_fn(batch):
    batch = sorted(batch,key=lambda x:x[-2],reverse=True)  # 对每个batch中数据按照长度降序排列,用于后续打包操作的前置要求
    inputs,targets,input_lengths,target_lengths = zip(*batch)
    inputs = torch.LongTensor([config.ns.transform(input,max_len=config.max_len) for input in inputs])
    targets = torch.LongTensor([config.ns.transform(target,max_len=config.max_len,add_eos=True) for target in targets])
    input_lengths = torch.LongTensor(input_lengths)
    target_lengths = torch.LongTensor(target_lengths)
    return inputs,targets,input_lengths,target_lengths
"""
二、配置文件(config.py)
"""

from num2sequence import Num2Sequence

ns = Num2Sequence()

train_batch_size = 3
test_batch_size = 512
max_len = 10

运行dataset.py文件结果如下:

idx:  0
batch_simple:  tensor([[12,  6,  4, 11, 10,  7,  5,  6,  1,  1],
        [12,  4,  9, 13, 13, 10,  8, 13,  1,  1],
        [13,  4,  7,  6, 13,  5, 11,  1,  1,  1]])
batch_target:  tensor([[12,  6,  4, 11, 10,  7,  5,  6,  4,  3],
        [12,  4,  9, 13, 13, 10,  8, 13,  4,  3],
        [13,  4,  7,  6, 13,  5, 11,  4,  3,  1]])
****************************************************************************************************
tensor([8, 8, 7])
tensor([9, 9, 8])

这里解释下,以batch中一个样本数字字符串为例:

原本样本input为:[‘8’, ‘2’, ‘0’, ‘7’, ‘6’, ‘3’, ‘1’, ‘2’]
填充到max_len=10长度后为:[‘8’, ‘2’, ‘0’, ‘7’, ‘6’, ‘3’, ‘1’, ‘2’,‘PAD’,‘PAD’]
num2sequence后为:[12, 6, 4, 11, 10, 7, 5, 6, 1, 1]

原本样本target为:[‘8’, ‘2’, ‘0’, ‘7’, ‘6’, ‘3’, ‘1’, ‘2’,‘0’]
填充到max_len=10长度后为:[‘8’, ‘2’, ‘0’, ‘7’, ‘6’, ‘3’, ‘1’, ‘2’,‘0’,‘EOS’]
num2sequence后为:[12, 6, 4, 11, 10, 7, 5, 6, 4, 3]

2.4 准备编码器

编码器(encoder)的目的就是为了对文本进行编码,把编码后的结果交给后续的程序使用,所以在这里我们可以使用Embedding+GRU的结构来使用,使用最后一个time step的输出(hidden state)作为句子的编码结果
【NLP理论到实战】20 seq2seq模型原理及demo实例_第2张图片
注意点:

  1. Embedding和GRU的参数,这里我们让GRU中batch放在前面
  2. 输出结果的形状
  3. 在LSTM和GRU中,每个time step的输入会进行计算,得到结果,整个过程是一个和句子长度相关的一个循环,手动实现速度较慢
  • pytorch中实现了nn.utils.rnn.pack_padded_sequence 对padding后的句子进行打包的操作能够更快获得LSTM或GRU的结果
  • 同时实现了nn.utils.rnn.pad_packed_sequence对打包的内容进行解包的操作
  1. nn.utils.rnn.pack_padded_sequence使用过程中需要对batch中的内容按照句子的长度降序排序


    关于上述两个api详细介绍请见:pytorch中如何处理RNN输入变长序列padding

实现代码如下:

"""
四、编码器的实现(encoder.py)
"""

import torch.nn as nn
import config
from torch.nn.utils.rnn import pack_padded_sequence,pad_packed_sequence

class Encoder(nn.Module):
    def __init__(self):
        super(Encoder,self).__init__()
        self.emb = nn.Embedding(num_embeddings=len(config.ns),embedding_dim=config.embedding_dim,padding_idx=config.ns.PAD)  # padding_idx是对sequence填充字符的embedding初始化为全0向量,使得不用梯度计算,减少计算量
        self.gru = nn.GRU(input_size=config.embedding_dim,hidden_size=config.hidden_size,num_layers=config.num_layers,batch_first=True)


    def forward(self,input,input_length):
        input_embeded = self.emb(input)  # [3, 10, 2]
        input_packed = pack_padded_sequence(input_embeded,input_length,batch_first=True)  # 打包操作(去掉填充的embedding),加速循环过程,其中input_packed.data.size(): [3 * 8 - pad个数, 2]
        output,hidden = self.gru(input_packed)  # 通过一个GRU层,之后output: [3 * 8 - pad个数, 4], hidden为最后一个时间步隐藏状态: [1, 3, 4]
        output_paded,output_paded_length = pad_packed_sequence(output,batch_first=True,padding_value=config.Num2Sequence.PAD)  # 解包操作, 其中 output_paded: [3, 8, 4]
        return output_paded,hidden


if __name__=='__main__':
    import dataset
    data_loader = dataset.get_dataloader()
    encoder = Encoder()
    for input,target,input_length,target_length in data_loader:
        encoder_outputs,encoder_hidden = encoder(input,input_length)  # 一种情况eg: [3, 10] / [8, 8, 7]  (注意此时input实际为填充后长max_len的数据)
        print(encoder_outputs.size())  # [3, 8, 4]
        print(encoder_hidden.size())  # [1, 3, 4]
        break
"""
二、配置文件(config.py)
"""
from num2sequence import Num2Sequence
import torch

ns = Num2Sequence()

train_batch_size = 3
test_batch_size = 512
max_len = 10

embedding_dim = 2
num_layers = 1
hidden_size = 4

其中pack原理即encoder使得batch样本数据形状变化和获得数据如下图:
【NLP理论到实战】20 seq2seq模型原理及demo实例_第3张图片

2.5 实现解码器

解码器主要负责实现对编码之后结果的处理,得到预测值,为后续计算损失做准备


此时需要思考:

  1. 使用什么样的损失函数,预测值需要是什么格式的
  • 结合之前的经验,我们可以理解为当前的问题是一个分类的问题,即每次的输出其实对选择一个概率最大的词
  • 真实值的形状是[batch_size,max_len],从而我们知道输出的结果需要是一个[batch_size,max_len,vocab_size]的形状
  • 即预测值的最后一个维度进行计算log_softmax,然后和真实值进行相乘,从而得到损失
  1. 如何把编码结果[1,batch_size,hidden_size]进行操作,得到预测值。解码器也是一个RNN,即也可以使用LSTM or GRU的结构,所以在解码器中:
  • 通过循环,每次计算的一个time step的内容
  • 编码器的结果作为初始的隐层状态,另外定义一个[batch_size,1]的全为SOS的数据作为最开始的输入,告诉解码器,要开始工作了
  • 通过解码器预测一个输出[batch_size,hidden_size](最后结束之后会通过一个全连接层进行形状的调整,调整为[batch_size,vocab_size]),把这个输出作为输入再使用解码器进行解码
  • 上述是一个循环,循环次数就是句子的最大长度,那么就可以得到max_len个输出
  • 把所有输出的结果进行concate,得到[batch_size,max_len,vocab_size]存放在decoder_outputs
  1. 在RNN的训练过程中,使用前一个预测的结果作为下一个step的输入,可能会导致一步错,步步错的结果,如果提高模型的收敛速度?
  • 可以考虑在训练的过程中,把真实值作为下一步的输入,这样可以避免步步错的局面
  • 同时在使用真实值的过程中,仍然使用预测值作为下一步的输入,两种输入随机使用
  • 上述这种机制我们把它称为Teacher forcing,就像是一个指导老师,在每一步都会对我们的行为进行纠偏,从而达到在多次训练之后能够需要其中的规律
  • 【NLP理论到实战】20 seq2seq模型原理及demo实例_第4张图片
"""
五、实现解码器(decoder.py)
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
import config
import numpy as np
import random

class Decoder(nn.Module):
    def __init__(self):
        super(Decoder, self).__init__()
        self.emb = nn.Embedding(num_embeddings=len(config.ns), embedding_dim=config.embedding_dim, padding_idx=config.ns.PAD)
        self.gru = nn.GRU(input_size=config.embedding_dim, hidden_size=config.hidden_size,num_layers=config.num_layers, batch_first=True)
        self.fc = nn.Linear(config.hidden_size, len(config.ns))

    def forward(self, encoder_hidden):
        # 1. 获取encoder的输出hidden_state,作为decoder第一个时间步hidden_state的输入
        decoder_hidden = encoder_hidden  # decoder_hidden:[1, 3, 4]
        # 2. 准备decoder第一个时间步input的输入,即[batch_size,1] SOS
        batch_size = encoder_hidden.size(1)
        decoder_input = torch.LongTensor([[config.ns.SOS]]*batch_size)  # decoder_input:[3, 1]

        # 注:初始化decoder_outputs用来保存每一个时间步的输出,作为最终模型的输出,之后和真实target计算损失
        decoder_outputs = torch.zeros([batch_size, config.max_len, len(config.ns)])

        # 3. 对于以下步骤循环
        # 3.1 第一次:(传入预设全为SOS的输入input和编码器最后一个时间步的hidden,在第一个时间步上进行计算,得到当前时间步的output_t,hidden_state输出)
        # 3.2 第二次及之后:(取前一个时间步的output_t中最大值的位置,作为下一时间步output的输入;把前一个时间步的hidden_state,作为下一个时间步的hidden_state的输入)

        for t in range(config.max_len):
            decoder_output_t, decoder_hidden = self.forward_onestep(decoder_input, decoder_hidden)  # decoder_output_t:[3, 14], decoder_hidden:[1, 3, 4]

            # 保存每个时间步预测的结果, decoder_outputs:[3,10,14]
            decoder_outputs[:, t, :] = decoder_output_t

            #在训练的过程中,使用 teacher forcing,进行纠偏
            use_teacher_forcing = random.random() > 0.5
            if use_teacher_forcing:
                #下一次的输入使用真实值
                decoder_input =target[:,t].unsqueeze(1)  # 获取一个batch所有simple在时间步t上对应真实值 [batch_size,1]
            else:
                #使用预测值,topk中k=1,即获取最后一个维度的最大的一个值
                value, index = decoder_output_t.max(dim=-1)  # 得到一个batch中各个simple预测词即概率最大值及对应词典中词的序列号value/index:[3]
                decoder_input = index.unsqueeze(dim=-1)  # 扩展最外围为1的维度作为一个时间步的意思,decoder_input:[3, 1]


        return decoder_outputs, decoder_hidden # decoder_outputs:[3, 10, 14], decoder_hidden:[1, 3, 4]

    # 计算每个时间步上的output和hidden,其中decoder_input:[batch_size, 1], pre_decoder_hidden:[1, batch_size, hidden_size]
    def forward_onestep(self, decoder_input, pre_decoder_hidden):
        decoder_input_embeded = self.emb(decoder_input)  # [3, 1, 2]
        output, decoder_hidden = self.gru(decoder_input_embeded,pre_decoder_hidden)  # output:[3, 1, 4], decoder_hidden:[1, 3, 4]
        output_squeeze = output.squeeze(dim=1)  # 若第二维度值为1则去除第二维度,这里用于将形状改造为适合全连接层的output_squeeze:[3, 4]
        output_fc = F.log_softmax(self.fc(output_squeeze), dim=-1)  # 通过一个全连接层和softmax函数得到一个batch中各个simple单次输出对应于词典各个词可能的概率分布即output_fc:[3, 14]
        return output_fc, decoder_hidden


if __name__ == '__main__':
    import dataset
    import config
    from encoder import Encoder
    data_loader = dataset.get_dataloader()
    encoder = Encoder()
    decoder = Decoder()
    for input, target, input_length, target_length in data_loader:  # input/target:[3, 10]
        encoder_outputs, encoder_hidden = encoder(input, input_length)  # encoder_outputs:[3, 8, 4], encoder_hidden:[1, 3 ,4]
        decoder_outputs, decoder_hidden = decoder(encoder_hidden)
        _, index = decoder_outputs.max(dim=-1)
        print('预测结果:',[config.ns.inverse_transform(one_stringNum.numpy()) for one_stringNum in index],'\n')
        print(decoder_outputs.size())  # decoder_outputs:[3, 10, 14]
        print(decoder_hidden.size())  # decoder_hidden:[1, 3, 4]
        break

输出结果如下(这里刚开始仅第一轮第一个batch跑一遍Rnn,还没训练,所以误差很大):

(['3', '9', '9', '8', '2', '9', '9', '9'], ['2', '1', '7', '3', '3', '7', '6', '6'], ['3', '9', '4', '4', '4', '7', '8']) 

预测结果: [['5', '9', '9', '9', '9', '2', '2', '2', '2', '2'], ['5', '9', '9', '9', '9', '2', '2', '2', '2', '2'], ['9', '9', '9', '9', '2', '2', '2', '2', '2', '']] 

torch.Size([3, 10, 14])
torch.Size([1, 3, 4])

2.6 完成seq2seq模型

调用之前的encoderdecoder,完成模型的搭建

"""
六、实现seq2seq模型(seq2seq.py)
把encoder和decoder合并,得到seq2seq模型
"""

import torch.nn as nn
from encoder import Encoder
from decoder import Decoder

class Seq2Seq(nn.Module):
    def __init__(self):
        super(Seq2Seq,self).__init__()
        self.encoder = Encoder()
        self.decoder = Decoder()
    
    def forward(self,input,input_length):
        encoder_outputs,encoder_hidden = self.encoder(input,input_length)
        decoder_outputs,decoder_hidden = self.decoder(encoder_hidden)
        return decoder_outputs,decoder_hidden

2.7 完成训练逻辑

思路流程和之前相同:

"""
七、完成训练逻辑(train.py)
"""
from seq2seq import Seq2Seq
from torch.optim import Adam
from tqdm import tqdm
import os
import dataset
import config
import torch.nn.functional as F
import torch
import numpy as np
import matplotlib.pyplot as plt

"""
训练流程:
1、实例化model,optimizer,loss
2、遍历dataloader
3、调用得到output
4、计算损失loss
5、梯度置零
6、反向传播
7、梯度下降
8、模型保存和加载
"""

seq2seq_model = Seq2Seq()
optimizer = Adam(seq2seq_model.parameters(),lr=0.001)
if os.path.exists('./model/Seq2Seq_demo/seq2seq_model.pkl'):
    seq2seq_model.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_model.pkl'))
    optimizer.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_optimizer.pkl'))

loss_list = []

def train(epoch):
    data_loader = dataset.get_dataloader(train=True)
    bar = tqdm(data_loader,total=len(data_loader),ascii=True,desc='train')
    for idx,(input, target, input_length, target_length) in enumerate(bar):
    
        decoder_ouputs,_ = seq2seq_model(input, input_length)
        
        decoder_ouputs = decoder_ouputs.view(-1,len(config.ns))  # [3 * 10, 14]
        target = target.view(-1) # [3 * 10]
        
        loss = F.nll_loss(decoder_ouputs, target, ignore_index=config.ns.PAD)  # 注意计算交叉熵loss时会将target编码成one-hot形式
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        loss_list.append(loss.item())
        
        bar.set_description("epoch:{} idx:{} loss:{:.3f}".format(epoch,idx,np.mean(loss_list)))
        if not(idx % 100):
            torch.save(seq2seq_model.state_dict(),'./model/Seq2Seq_demo/seq2seq_model.pkl')
            torch.save(optimizer.state_dict(),'./model/Seq2Seq_demo/seq2seq_optimizer.pkl')



if __name__=='__main__':
    for i in range(5):
        train(i)
    
    plt.figure(figsize=(50,8))
    plt.plot(range(len(loss_list)),loss_list)
    plt.show()

输出结果如下(loss后期出现震荡,猜想可能这一个batch数据是脏数据):

epoch:0 idx:3124 loss:0.634: 100%|######################################################################################################################################################################| 3125/3125 [01:40<00:00, 31.00it/s] 
epoch:1 idx:3124 loss:0.336: 100%|######################################################################################################################################################################| 3125/3125 [01:38<00:00, 31.78it/s] 
epoch:2 idx:3124 loss:0.228: 100%|######################################################################################################################################################################| 3125/3125 [01:37<00:00, 31.90it/s] 
epoch:3 idx:3124 loss:0.175: 100%|######################################################################################################################################################################| 3125/3125 [01:39<00:00, 31.44it/s] 
epoch:4 idx:3124 loss:0.141: 100%|######################################################################################################################################################################| 3125/3125 [01:39<00:00, 31.38it/s] 

【NLP理论到实战】20 seq2seq模型原理及demo实例_第5张图片

2.8 完成模型评估与预测逻辑

完成评估逻辑,和decoder中的训练过程稍微不同,可以在其中新建evaluate的方法,传入encoder_hidden,得到预测的结果

"""
五、实现解码器(decoder.py)
"""

def evaluate(self,encoder_hidden):
        decoder_hidden = encoder_hidden
        batch_size = encoder_hidden.size(1)
        decoder_input = torch.LongTensor([[config.ns.SOS]]*batch_size)
        decoder_outputs = torch.zeros([batch_size, config.max_len, len(config.ns)])
        decoder_predict = []
        
        for t in range(config.max_len):
            decoder_output_t, decoder_hidden = self.forward_onestep(decoder_input, decoder_hidden)
            decoder_outputs[:, t, :] = decoder_output_t
            value, index = decoder_output_t.max(dim=-1)
            decoder_input = index.unsqueeze(dim=-1)
            decoder_predict.append(index.cpu().detach().numpy())  # 每一次append进去的是一个batch各个simple在同一时间步上的结果,即每一列依次是每个seq的输出结果
        
        decoder_predict = np.array(decoder_predict).transpose()  # 交换使得每一行是每个seq的输出结果,即[batch_size,max_len]
        return decoder_outputs, decoder_predict

之后再seq2seq的model中,添加evaluation的逻辑

"""
六、实现seq2seq模型(seq2seq.py)
把encoder和decoder合并,得到seq2seq模型
"""
def evaluate(self,input,input_length):
    encoder_outputs,encoder_hidden = self.encoder(input,input_length)
    decoder_outputs,decoder_predict = self.decoder.evaluate(encoder_hidden)
    return decoder_outputs,decoder_predict

创建eval.py,完成模型评估的逻辑

"""
八、模型的评估与预测(eval.py)
"""

import torch
from seq2seq import Seq2Seq
import config
from dataset import get_dataloader
import torch.nn.functional as F
from tqdm import tqdm
import numpy as np

def eval():
    seq2seq_model = Seq2Seq()
    seq2seq_model.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_model.pkl'))
    loss_list = []
    acc_list = []
    data_loader = get_dataloader(train=False)
    with torch.no_grad():
        data_loader = get_dataloader(train=True)
        bar = tqdm(data_loader,total=len(data_loader),ascii=True,desc='test')  
        for idx,(input, target, input_length, target_length) in enumerate(bar):           
            decoder_ouputs,decoder_predict = seq2seq_model.evaluate(input, input_length)
            loss = F.nll_loss(decoder_ouputs.view(-1,len(config.ns)), target.view(-1), ignore_index=config.ns.PAD)  # 注意计算交叉熵loss时会将target编码成one-hot形式
            loss_list.append(loss.item())
            
            target_inverse_transformed = [config.ns.inverse_transform(i) for i in target.numpy()]
            predict_inverse_transformed = [config.ns.inverse_transform(i) for i in decoder_predict]
            
            cur_eq = [1 if target_inverse_transformed[i] == predict_inverse_transformed[i] else 0 for i in range(len(target_inverse_transformed))]
            acc_list.extend(cur_eq)
                        
            bar.set_description("mean acc:{:.6f} mean loss:{:.6f}".format(np.mean(acc_list),np.mean(loss_list)))



def interface(_input):
    seq2seq_model = Seq2Seq()
    seq2seq_model.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_model.pkl'))
    input = list(_input)
    input_length = torch.LongTensor([len(input)])
    input = torch.LongTensor([config.ns.transform(input)])
    with torch.no_grad():
        _,decoder_predict = seq2seq_model.evaluate(input,input_length)
        predict = [config.ns.inverse_transform(idx)  for idx in decoder_predict]
        print(_input,'————>',predict[0])

if __name__ == "__main__":
    for i in range(3):
        str = input()
        interface(str)

在model训练完5个epoch后,评估输出如下:

12345678
12345678 ————> 123456780
87654321
87654321 ————> 876543210
1111101
1111101 ————> 11111010

3. 加入GPU运算选择,完整代码参考如下:

3.1 dataset.py

"""
一、准备数据集(dataset.py)
"""

from torch.utils.data import Dataset, DataLoader
import numpy as np
import torch
import config


class NumDataset(Dataset):
    def __init__(self,train=True):
        # 使用numpy随机创建一堆数据作为数据集
        np.random.seed(1) if train else np.random.seed(2)
        self.size = 400000 if train else 100000
        self.data = np.random.randint(1, 1e8, self.size)

    def __getitem__(self, index):
        input = list(str(self.data[index]))
        target = input + ['0']
        return input, target, len(input), len(target)

    def __len__(self):
        return self.size


def collate_fn(batch):
    batch = sorted(batch,key=lambda x:x[-2],reverse=True)  # 对每个batch中数据按照长度降序排列,用于后续打包操作的前置要求
    inputs,targets,input_lengths,target_lengths = zip(*batch)
    inputs = torch.LongTensor([config.ns.transform(input,max_len=config.max_len) for input in inputs])
    targets = torch.LongTensor([config.ns.transform(target,max_len=config.max_len,add_eos=True) for target in targets])
    input_lengths = torch.LongTensor(input_lengths)
    target_lengths = torch.LongTensor(target_lengths)
    return inputs,targets,input_lengths,target_lengths


def get_dataloader(train=True):
    num_dataset = NumDataset(train)
    batch_size = config.train_batch_size if train else config.test_batch_size
    num_dataloader = DataLoader(num_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn, drop_last=True)
    return num_dataloader


if __name__ == '__main__':
    for idx,(x,y_true,x_length,y_true_length) in enumerate(get_dataloader()):
        print('idx: ',idx)
        print('batch_simple: ',x)
        print('batch_target: ',y_true)
        print('*'*100)
        print(x_length)
        print(y_true_length)
        break

2.config.py

"""
二、配置文件(config.py)
"""

from num2sequence import Num2Sequence
import torch


ns = Num2Sequence()

train_batch_size = 256
test_batch_size = 1000
max_len = 10


embedding_dim = 50
num_layers = 1
hidden_size = 64


device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

3.num2sequence.py

"""
三、实现数字字符串序列化
"""

class Num2Sequence:
    UNK_TAG = ''  # 特殊字符
    PAD_TAG = ''  # 填充字符
    SOS_TAG = ''  # 序列开始字符
    EOS_TAG = ''  # 句子结束字符
    UNK = 0
    PAD = 1
    SOS = 2
    EOS = 3
    def __init__(self):
        self.numToSequence_dict = {
     
            self.UNK_TAG: self.UNK,
            self.PAD_TAG: self.PAD,
            self.SOS_TAG: self.SOS,
            self.EOS_TAG: self.EOS
        }
        for i in range(10):
            self.numToSequence_dict[str(i)] = len(self.numToSequence_dict)
        self.sequenceToNum_dict = dict(zip(self.numToSequence_dict.values(),self.numToSequence_dict.keys()))
    
    def transform(self,stringNum,max_len=None,add_eos=False): # 把数字字符串转化为数字序列
        if add_eos:
            max_len -= 1  # 为了输入和目标句子的长度都等于设置的max_len
        if max_len is not None:
            if len(stringNum) > max_len:
                stringNum = stringNum[:max_len]
            else:
                stringNum += [self.PAD_TAG] * (max_len - len(stringNum))
        if add_eos:
            if stringNum[-1] == self.PAD_TAG:
                stringNum.insert(stringNum.index(self.PAD_TAG),self.EOS_TAG)  # 数字字符串中有PAD,在PAD之前添加EOS
            else:
                stringNum += [self.EOS_TAG]    # 数字字符串中没有PAD,在最后添加EOS
        return [self.numToSequence_dict.get(charNum,self.UNK) for charNum in stringNum]
    
    def inverse_transform(self,sequence):  # 把数字序列转化为数字字符串
        results = []
        for index in sequence:
            result = self.sequenceToNum_dict.get(index,self.UNK_TAG)
            if result != self.EOS_TAG:
                results.append(result)
            else:
                break
        return "".join(results)
    
    def __len__(self):
        return len(self.numToSequence_dict)


if __name__=='__main__':
    num_sequence = Num2Sequence()
    print(num_sequence.numToSequence_dict)
    print(len(num_sequence.numToSequence_dict))
    print('*'*100)
    str1 = ['1','9','9','8']
    str2 = ['-1','2','0','2','0','1','2','3']
    str3 = ['2','0','2','0']
    num1 = num_sequence.transform(str1,max_len=5)
    num2 = num_sequence.transform(str2,max_len=5,add_eos=True)
    num3 = num_sequence.transform(str3,max_len=8,add_eos=True)
    reverse_num1 = num_sequence.inverse_transform(num1)
    reverse_num2 = num_sequence.inverse_transform(num2)
    reverse_num3 = num_sequence.inverse_transform(num3)
    print(num1)
    print(reverse_num1)
    print(num2)
    print(reverse_num2)
    print(num3)
    print(reverse_num3)

4.encoder.py

"""
四、编码器的实现(encoder.py)
"""

import torch.nn as nn
import config
from torch.nn.utils.rnn import pack_padded_sequence,pad_packed_sequence

class Encoder(nn.Module):
    def __init__(self):
        super(Encoder,self).__init__()
        self.emb = nn.Embedding(num_embeddings=len(config.ns),embedding_dim=config.embedding_dim,padding_idx=config.ns.PAD)  # padding_idx是对sequence填充字符的embedding初始化为全0向量,使得不用梯度计算,减少计算量
        self.gru = nn.GRU(input_size=config.embedding_dim,hidden_size=config.hidden_size,num_layers=config.num_layers,batch_first=True)


    def forward(self,input,input_length):
        input_embeded = self.emb(input)  # [3, 10, 2]
        input_packed = pack_padded_sequence(input_embeded,input_length,batch_first=True)  # 打包操作(去掉填充的embedding),加速循环过程,其中input_packed.data.size(): [3 * 8 - pad个数, 2]
        output,hidden = self.gru(input_packed)  # 通过一个GRU层,之后output: [3 * 8 - pad个数, 4], hidden为最后一个时间步隐藏状态: [1, 3, 4]
        output_paded,output_paded_length = pad_packed_sequence(output,batch_first=True,padding_value=config.Num2Sequence.PAD)  # 解包操作, 其中 output_paded: [3, 8, 4]
        return output_paded,hidden


if __name__=='__main__':
    import dataset
    data_loader = dataset.get_dataloader()
    encoder = Encoder()
    for input,target,input_length,target_length in data_loader:
        encoder_outputs,encoder_hidden = encoder(input,input_length)  # 一种情况eg: [3, 10] / [8, 8, 7]  (注意此时input实际为填充后长max_len的数据)
        print(encoder_outputs.size())  # [3, 8, 4]
        print(encoder_hidden.size())  # [1, 3, 4]
        break

5.decoder.py

"""
五、实现解码器(decoder.py)
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
import config
import numpy as np
import random

class Decoder(nn.Module):
    def __init__(self):
        super(Decoder, self).__init__()
        self.emb = nn.Embedding(num_embeddings=len(config.ns), embedding_dim=config.embedding_dim, padding_idx=config.ns.PAD)
        self.gru = nn.GRU(input_size=config.embedding_dim, hidden_size=config.hidden_size,num_layers=config.num_layers, batch_first=True)
        self.fc = nn.Linear(config.hidden_size, len(config.ns))

    def forward(self, encoder_hidden, target):
        # 1. 获取encoder的输出hidden_state,作为decoder第一个时间步hidden_state的输入
        decoder_hidden = encoder_hidden  # decoder_hidden:[1, 3, 4]
        # 2. 准备decoder第一个时间步input的输入,即[batch_size,1] SOS
        batch_size = encoder_hidden.size(1)
        decoder_input = torch.LongTensor([[config.ns.SOS]]*batch_size).to(config.device)  # decoder_input:[3, 1]

        # 注:初始化decoder_outputs用来保存每一个时间步的输出,作为最终模型的输出,之后和真实target计算损失
        decoder_outputs = torch.zeros([batch_size, config.max_len, len(config.ns)]).to(config.device)

        # 3. 对于以下步骤循环
        # 3.1 第一次:(传入预设全为SOS的输入input和编码器最后一个时间步的hidden,在第一个时间步上进行计算,得到当前时间步的output_t,hidden_state输出)
        # 3.2 第二次及之后:(取前一个时间步的output_t中最大值的位置,作为下一时间步output的输入;把前一个时间步的hidden_state,作为下一个时间步的hidden_state的输入)

        for t in range(config.max_len):
            decoder_output_t, decoder_hidden = self.forward_onestep(decoder_input, decoder_hidden)  # decoder_output_t:[3, 14], decoder_hidden:[1, 3, 4]

            # 保存每个时间步预测的结果, decoder_outputs:[3,10,14]
            decoder_outputs[:, t, :] = decoder_output_t
            
            #在训练的过程中,使用 teacher forcing,进行纠偏
            use_teacher_forcing = random.random() > 0.5
            if use_teacher_forcing:
                #下一次的输入使用真实值
                decoder_input =target[:,t].unsqueeze(1)  # 获取一个batch所有simple在时间步t上对应真实值 [batch_size,1]
            else:
                #使用预测值,topk中k=1,即获取最后一个维度的最大的一个值
                value, index = decoder_output_t.max(dim=-1)  # 得到一个batch中各个simple预测词即概率最大值及对应词典中词的序列号value/index:[3]
                decoder_input = index.unsqueeze(dim=-1)  # 扩展最外围为1的维度作为一个时间步的意思,decoder_input:[3, 1]

        return decoder_outputs, decoder_hidden # decoder_outputs:[3, 10, 14], decoder_hidden:[1, 3, 4]

    # 计算每个时间步上的output和hidden,其中decoder_input:[batch_size, 1], pre_decoder_hidden:[1, batch_size, hidden_size]
    def forward_onestep(self, decoder_input, pre_decoder_hidden):
        decoder_input_embeded = self.emb(decoder_input)  # [3, 1, 2]
        output, decoder_hidden = self.gru(decoder_input_embeded,pre_decoder_hidden)  # output:[3, 1, 4], decoder_hidden:[1, 3, 4]
        output_squeeze = output.squeeze(dim=1)  # 若第二维度值为1则去除第二维度,这里用于将形状改造为适合全连接层的output_squeeze:[3, 4]
        output_fc = F.log_softmax(self.fc(output_squeeze), dim=-1)  # 通过一个全连接层和softmax函数得到一个batch中各个simple单次输出对应于词典各个词可能的概率分布即output_fc:[3, 14]
        return output_fc, decoder_hidden

    def evaluate(self,encoder_hidden):
        decoder_hidden = encoder_hidden
        batch_size = encoder_hidden.size(1)
        decoder_input = torch.LongTensor([[config.ns.SOS]]*batch_size).to(config.device)
        decoder_outputs = torch.zeros([batch_size, config.max_len, len(config.ns)]).to(config.device)
        decoder_predict = []
        
        for t in range(config.max_len):
            decoder_output_t, decoder_hidden = self.forward_onestep(decoder_input, decoder_hidden)
            decoder_outputs[:, t, :] = decoder_output_t
            value, index = decoder_output_t.max(dim=-1)
            decoder_input = index.unsqueeze(dim=-1)
            decoder_predict.append(index.cpu().detach().numpy())  # 每一次append进去的是一个batch各个simple在同一时间步上的结果,即每一列依次是每个seq的输出结果
        
        decoder_predict = np.array(decoder_predict).transpose()  # 交换使得每一行是每个seq的输出结果,即[batch_size,max_len]
        return decoder_outputs, decoder_predict
        

if __name__ == '__main__':
    import dataset
    import config
    from encoder import Encoder
    data_loader = dataset.get_dataloader()
    encoder = Encoder()
    decoder = Decoder()
    for input, target, input_length, target_length in data_loader:  # input/target:[3, 10]
        encoder_outputs, encoder_hidden = encoder(input, input_length)  # encoder_outputs:[3, 8, 4], encoder_hidden:[1, 3 ,4]
        decoder_outputs, decoder_hidden = decoder(encoder_hidden, target)
        _, index = decoder_outputs.max(dim=-1)
        print('预测结果:',[config.ns.inverse_transform(one_stringNum.numpy()) for one_stringNum in index],'\n')
        print(decoder_outputs.size())  # decoder_outputs:[3, 10, 14]
        print(decoder_hidden.size())  # decoder_hidden:[1, 3, 4]
        break

6.seq2seq.py

"""
六、实现seq2seq模型(seq2seq.py)
把encoder和decoder合并,得到seq2seq模型
"""

import torch.nn as nn
from encoder import Encoder
from decoder import Decoder

class Seq2Seq(nn.Module):
    def __init__(self):
        super(Seq2Seq,self).__init__()
        self.encoder = Encoder()
        self.decoder = Decoder()
    
    def forward(self,input,input_length,target):
        encoder_outputs,encoder_hidden = self.encoder(input,input_length)
        decoder_outputs,decoder_hidden = self.decoder(encoder_hidden,target)
        return decoder_outputs,decoder_hidden
    
    def evaluate(self,input,input_length):
        encoder_outputs,encoder_hidden = self.encoder(input,input_length)
        decoder_outputs,decoder_predict = self.decoder.evaluate(encoder_hidden)
        return decoder_outputs,decoder_predict

7.train.py

"""
七、完成训练逻辑(train.py)
"""
from seq2seq import Seq2Seq
from torch.optim import Adam
from tqdm import tqdm
import os
import dataset
import config
import torch.nn.functional as F
import torch
import numpy as np
import matplotlib.pyplot as plt
from eval import eval

"""
训练流程:
1、实例化model,optimizer,loss
2、遍历dataloader
3、调用得到output
4、计算损失loss
5、梯度置零
6、反向传播
7、梯度下降
8、模型保存和加载
"""

seq2seq_model = Seq2Seq().to(config.device)
optimizer = Adam(seq2seq_model.parameters(),lr=0.001)
if os.path.exists('./model/Seq2Seq_demo/seq2seq_model.pkl'):
    seq2seq_model.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_model.pkl'))
    optimizer.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_optimizer.pkl'))

loss_list = []

def train(epoch):
    data_loader = dataset.get_dataloader(train=True)
    bar = tqdm(data_loader,total=len(data_loader),ascii=True,desc='train')
    for idx,(input, target, input_length, target_length) in enumerate(bar):
        input = input.to(config.device)
        target = target.to(config.device)
        input_length = input_length.to(config.device)
        
        decoder_ouputs,_ = seq2seq_model(input, input_length, target)
        
        decoder_ouputs = decoder_ouputs.view(-1,len(config.ns))  # [3 * 10, 14]
        target = target.view(-1) # [3 * 10]
        
        loss = F.nll_loss(decoder_ouputs, target, ignore_index=config.ns.PAD)  # 注意计算交叉熵loss时会将target编码成one-hot形式
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        loss_list.append(loss.item())
        
        bar.set_description("epoch:{} idx:{} loss:{:.3f}".format(epoch,idx,np.mean(loss_list)))
        if not(idx % 100):
            torch.save(seq2seq_model.state_dict(),'./model/Seq2Seq_demo/seq2seq_model.pkl')
            torch.save(optimizer.state_dict(),'./model/Seq2Seq_demo/seq2seq_optimizer.pkl')


if __name__=='__main__':
    for i in range(5):
        train(i)
        eval()
    
    plt.figure(figsize=(50,8))
    plt.plot(range(len(loss_list)),loss_list)
    plt.show()

8.eval.py

"""
八、模型的评估与预测(eval.py)
"""

import torch
from seq2seq import Seq2Seq
import config
from dataset import get_dataloader
import torch.nn.functional as F
from tqdm import tqdm
import numpy as np

def eval():
    seq2seq_model = Seq2Seq().to(config.device)
    seq2seq_model.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_model.pkl'))
    loss_list = []
    acc_list = []
    data_loader = get_dataloader(train=False)
    with torch.no_grad():
        data_loader = get_dataloader(train=True)
        bar = tqdm(data_loader,total=len(data_loader),ascii=True,desc='test')  
        for idx,(input, target, input_length, target_length) in enumerate(bar):
            input = input.to(config.device)
            target = target.to(config.device)
            input_length = input_length.to(config.device)
            
            decoder_ouputs,decoder_predict = seq2seq_model.evaluate(input, input_length)
            
            loss = F.nll_loss(decoder_ouputs.view(-1,len(config.ns)), target.view(-1), ignore_index=config.ns.PAD)  # 注意计算交叉熵loss时会将target编码成one-hot形式
            loss_list.append(loss.item())
            
            target_inverse_transformed = [config.ns.inverse_transform(i) for i in target.numpy()]
            predict_inverse_transformed = [config.ns.inverse_transform(i) for i in decoder_predict]
            
            cur_eq = [1 if target_inverse_transformed[i] == predict_inverse_transformed[i] else 0 for i in range(len(target_inverse_transformed))]
            acc_list.extend(cur_eq)
                        
            bar.set_description("mean acc:{:.6f} mean loss:{:.6f}".format(np.mean(acc_list),np.mean(loss_list)))



def interface(_input):
    seq2seq_model = Seq2Seq().to(config.device)
    seq2seq_model.load_state_dict(torch.load('./model/Seq2Seq_demo/seq2seq_model.pkl'))
    input = list(_input)
    input_length = torch.LongTensor([len(input)])
    input = torch.LongTensor([config.ns.transform(input)])
    with torch.no_grad():
        input = input.to(config.device)
        input_length = input_length.to(config.device)
        _,decoder_predict = seq2seq_model.evaluate(input,input_length)
        predict = [config.ns.inverse_transform(idx)  for idx in decoder_predict]
        print(_input,'————>',predict[0])

if __name__ == "__main__":
    for i in range(3):
        str = input()
        interface(str)

你可能感兴趣的:(NLP理论到实战)