自然语言处理入门 (一)从序列到序列的翻译任务

'''
https://github.com/bentrevett/pytorch-seq2seq/blob/master/1%20-%20Sequence%20to%20Sequence%20Learning%20with%20Neural%20Networks.ipynb
'''
#coding=gbk
import torch
import torch.nn as nn
import torch.optim as optim

from torchtext.datasets import TranslationDataset,Multi30k
from torchtext.data import Field,BucketIterator

import spacy

import random
import math
import os
import time
'''
实现从序列到序列的文本转换
256+512=768   concatenate
'''

SEED=1

random.seed(SEED)
torch.manual_seed(SEED)
torch.backends.cudnn.deterministic=True

spacy_de=spacy.load('de')#德语分词器
spacy_en=spacy.load('en')#英语分词器
'''
加载分词器,将数据集中的每个训练样本(一个字符串)分词成为一个个单词
'''

def tokenize_de(text):#将输入的德语字符串(一句话)分词成列表,列表中的每个元素是一个单词/字符串/token
    '''
    :param text: 所需要翻译的字符串,
    :return: 将字符串分词成一个列表,列表中的每个元素是一个token/字符串,表示一个单词
    '''
    return [tok.text for tok in spacy_de.tokenizer(text)][::-1]
    #将对原始字符串分词完成的字符串列表进行逆序,论文中说这样使优化问题更简单

def tokenize_en(text):#将输入的英语字符串(一句话)分词成列表
    return [tok.text for tok in spacy_de.tokenizer(text)]

'''
实例化函数 Field,实现功能:
将数据集中的每个样本(一句话,字符串)切割成一个个单词(构成token列表)
再在每个列表头部加上SOS,尾部加上EOS,然后进行数值编码,即将字符串列表中
的每个单词编码成数值形式,这样每个字符串列表/token列表就会编码成torch.tensor
从字符串到数值的编码方式与词汇表有关
'''
SRC=Field(tokenize=tokenize_de,init_token='<sos>',eos_token='<eos>',lower=True)
TRG=Field(tokenize=tokenize_en,init_token='<sos>',eos_token='<eos>',lower=True)

train_data,valid_data,test_data=Multi30k.splits(exts=('.de','.en'),fields=(SRC,TRG))
print(f'Number of training examples:{len(train_data.examples)}')
print(f'Number of validation examples:{len(valid_data.examples)}')
print(f'Number of test examples:{len(test_data.examples)}')

print(vars(train_data.examples[0]))

'''
分别为训练数据中的训练样本(德语)以及对应的标签(英语)字符串列表构建词汇表
例如:在训练数据集中的训练样本(英语字符串),每个训练样本对应一个字符串/token列表
将所有列表中最少出现次数为2的单词取出,放到为训练样本创建的词汇表中
'''
SRC.build_vocab(train_data,min_freq=2)
TRG.build_vocab(train_data,min_freq=2)

print(f'unique tokens in source(de) vovalbulary:{len(SRC.vocab)}')
print(f'unique tokens in target(en) vovalbulary:{len(TRG.vocab)}')

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

BATCH_SIZE=128
train_iterator,valid_iterator,test_iterator=BucketIterator.splits(
    (train_data,valid_data,test_data),batch_size=BATCH_SIZE,device=device
)

class Encoder(nn.Module):
    def __init__(self,input_dim,emb_dim,hid_dim,n_layers,dropout):
        '''
        :param input_dim: 表示训练样本词汇表的长度(即德语的词汇表)
        :param emb_dim:
               将训练样本中每个字符串分词成一个个单词之后,会编码成one-hot向量,
               再将input_dim维度的向量embedding到某个dense space(因为one hot太稀疏)
        :param hid_dim:LSTM的hidden vector和cell vector向量维度
        :param n_layers:LSTM层数
        :param dropout:多层LSTM时,应用在两层LSTM之间的dropout

        encoder使用的是两层的LSTM网络,LSTM模型相对于RNN的改进之处在于

        RNN每一个时间节点的输出     h(t)=RNN(x(t),h(t-1))


        '''
        super().__init__()
        self.input_dim=input_dim
        self.emb_dim=emb_dim
        self.hid_dim=hid_dim
        self.n_layers=n_layers
        self.dropout=dropout

        self.embedding=nn.Embedding(self.input_dim,self.emb_dim)
        '''
        nn.Embedding    定义模型所需要的参数
        num_embeddings (int) – size of the dictionary of embeddings   表示词汇表中的单词个数
        embedding_dim (int) – the size of each embedding vector  表示embedding后的需要将每个单词输出的特征向量维度
        
        nn.Embedding 中也会引入 learnable parameters  requires_grad=True
        其中的可学习参数  shape [num_embeddings,embedding_dim] 其中每一行的向量数值就等于对应索引值在词汇表中的单词的embedding vector编码
        
        则 nn.Embedding 将会生成 num_embeddings个tensor,每个tensor的维度是3
        就是说对于词汇表中的每个单词,将会生成3维的特征向量
        (如果不指定torch.manual_seed(SEED)中的SEED为固定值,则每次nn.Embedding对于相同的索引值产生的embedding vector都是
        随机的,并不相同,这将带来的问题是:decoder每个时间节点处的对于相同的词汇表编码出的embedding vector都不一样
        故而需要设定随机种子数相同,以保证每次nn.Embedding对于相同长度的词汇表相同的索引值产生的embedding vector相同)
        
        则如果下一次我给出一个在词汇表中的索引量([src sent length,batch_size]  source tensor矩阵中的一个数值)
        则将会对于当前索引值输出1个  embedding_dim 维度的embedding vector
        则embedding之后的输出tensor变成   [src sent length,batch_size,embedding_dim]
        '''
        self.rnn=nn.LSTM(self.emb_dim,self.hid_dim,self.n_layers,dropout=self.dropout)
        '''
        nn.LSTM
        input_size  所输入的时序序列特征向量维度,即当前时间的embedding vector维度,在这里为embedding_dim
        hidden_size  LSTM隐藏层特征维度
        num_layers   LSTM层数
        dropout      在使用多层LSTM时,第L+1层LSTM输入的时间序列是上一层LSTM输出的所有隐藏层时间序列
                    (只有hidden state,没有cell state),hidden state每个维度的特征向量进行一定概率地随机
                     dropout之后,再作为下一层LSTM的输入
                      注意:第0层LSTM的输入值(也就是最原始的输入时间序列embedding后的特征向量)也需要进行dropout
                      再输入到LSTM中
                      而最后一层LSTM的输出隐藏层状态序列以及最后一层最后一个时间节点输出的hidden state和cell state
                      也就是编码器输出的context vector,则不需要进行dropout操作
                    (注意encoder输出的context vector包含了多层LSTM每一层输出的hidden state 和cell state)
        '''
        self.dropout=nn.Dropout(dropout)
        '''
        以dropout的概率将tensor中的数值变成0,dropout层中不包含任何参数,如果dropout=0.9
        则以0.9的概率将tensor中的数值变成0
        '''

    def forward(self, src):
        '''
        :param src: torch.tensor shape[src sent length,batch_size]
        :return:

        输入到LSTM中的是 [src sent length,batch_size] 的2-dimension  tensor,其中的每个数值range (0,len(src.vocal))
        即表示batch size中当前输入字符串的当前单词,在词汇表中的序号(注意并不是将其编码成one-hot tensor,而是直接传入索引值)

        输入到encoder之后的步骤如下:
        (1)先进行embedding操作,将之前的序号(当前token在原序列词汇表中的序号)通过nn.Embedding对于每个索引值编码出来的
        embedding vector填充到source tensor中,得到  shape [src sent length,batch_size,embedding dim]
        (2)对于embedding之后的word vector进行dropout操作
        (3)将embedding和dropout之后的输入特征tensor送入到LSTM中
        '''
        embedded=self.dropout(self.emb(src))
        # embedded=[src sent length,batch_size,emb_dim]

        outputs,(hidden,cells)=self.rnn(embedded)
        '''
        nn.LSTM的输入参数有两组
        Inputs: input, (h_0, c_0)
        input  shape [sequence_length,batch_size,input_size]
               sequence_length  输入序列的时序长度
               input_size   输入序列每个时间节点特征向量的维度  embedding dim
        h_0    第0时刻的hidden state  dimension=LSTM的hidden_size
        c_0    第0时刻的cell state  dimension=LSTM的hidden_size
        对于encoder的LSTM而言,sequence_length等于训练数据集中每句话的最长单词数
        h_0  c_0 并没有传入的初始参数,而是被初始化为全0 vector
        
        对于decoder的LSTM而言,sequence_length等于1
        h_0     (num_layers * num_directions, batch, hidden_size)
        c_0     (num_layers * num_directions, batch, hidden_size)
        num_Layers表示的是decoder LSTM的层数,num_directions表示方向(可能是bidirectional LSTM)
        用于decoder LSTM每一层的解码输入
        
        nn.LSTM的输出
        output, (h_n, c_n)
        output  shape [sequence_length,batch_size,hidden_size]
                表示编码器/解码器LSTM最后一层每个时间节点输出的hidden state vector
         (h_n, c_n)   二者shape都为(num_layers * num_directions, batch, hidden_size)
                表示编码器/解码器LSTM每一层最后一个时间节点输出的hidden state vector和cellstate vector
        
        在LSTM的图示中,横向前行表示时间节点向前,竖直方向上前行表示LSTM层数不断加深
        '''

        '''
        #outputs = [sent lenght, batch_size,hid dim]
        #hidden = [n layers * n directions, batch size, hid dim]
        #cell = [n layers * n directions, batch size, hid dim]
        如果LSTM是多层的,则将每一层的隐含层特征和cell特征都保存下来作为context information 
        故而在没有引入attention时,只需要将encoder的context vector即编码器每一层最后一个时间节点
        的hidden state和cell state返回(如果使用了attention机制,则需要将编码器最后一层的每个时间节点的hidden state输出)
        '''
        return hidden,cells#编码器最终输出的是context vectors

class Decoder(nn.Module):
    def __init__(self,output_dim,emb_dim,hid_dim,n_layers,dropout):
        '''
        decoder的隐藏层维度必须要和encoder的隐藏层维度相等
        decoder的embedding维度和encoder的embedding维度可以不相等
        对于decoder和encoder分别而言,其hidden dimension和cell dimension都相等
        编码器和解码器的LSTM层数必须相等(为了保证hidden和cell context vector正常使用)
        :param output_dim:等于解码器词汇表单词个数
        :param emb_dim:相当于对解码器输出的预测单词进行embedding编码
        :param hid_dim:解码器LSTM隐藏层维度
        :param n_layers:解码器LSTM层数
        :param dropout:
        '''

        self.emb_dim=emb_dim
        self.hid_dim=hid_dim
        self.output_dim=output_dim
        self.n_layers=n_layers
        self.dropout=dropout

        self.embedding=nn.Embedding(output_dim,emb_dim)
        self.rnn=nn.LSTM(emb_dim,hid_dim,n_layers,dropout=dropout)
        self.out=nn.Linear(hid_dim,output_dim)#线性预测层的输出维度与解码器词汇表中单词数相等

        self.dropout=nn.Dropout(dropout)

    def forward(self, input,hidden,cell):
        '''
        :param input: [batch_size],是batch size个 sos
        :param hidden: 编码器输出的向量   shape [n_layers*n_directions,batch_size,hid_dim]
        :param cell: 编码器输出的向量     shape [n_layers*n_directions,batch_size,hid_dim]
        :return:
        '''
        input=input.unsqueeze(0)#shape[1,batch_size]
        embedded=self.dropout(self.embedding(input))#由于设定了随机种子,可以保证解码器每个时间序列相同单词embedding的是相同的vector
        # embedded  shape [1,batch_size,embed_dim]

        output,(hidden,cell)=self.rnn(embedded,(hidden,cell))
        # output = [sent len,batch_size,hidden_dim*n_directions]  表示最后一层LSTM输出的隐藏层状态
        # hidden = [n_layers*n_directions,batch_size,hidden_dim] 表示解码器每一层LSTM最后一个节点输出的特征向量
        # cell = [n_layers*n_directions,batch_size,hidden_dim] 表示解码器每一层LSTM最后一个节点输出的特征向量

        #输出序列为  [1,batch_size]  sent len=1  n_directions=1
        # output = [1,batch_size,hidden_dim]
        # hidden = [n_layers,batch_size,hidden_dim]
        # cell = [n_layers,batch_size,hidden_dim]

        predictions=self.out(output.squeeze(0))#解码器是一层层输出的,因为需要保留每个时间节点处的prediction vector
        #predictions = [batch_size,output_dim]

        '''
        解码器负责预测一个时间节点的输出,这是因为每个时刻所需要的input cell依赖于上一时刻LSTM的预测输出
        编码器和解码器的重要区别是,
        (1)编码器最后一层每个时间节点的hidden state和cell state只用于对下一个
        时间节点的LSTM的输入,但是解码器最后一层每个时间节点的hidden state和cell state除了需要作为下一个时间节点
        的输入,还需要输入到nn.Linear层中进行单词索引的prediction。
        (2)编码器每次是将所有的时间序列都输入,这是因为它知道每个时间节点处的input state是什么,但是解码器每次只能
        输入一个时间节点,因为下一个时间节点的input state依赖于上一个时间节点的prediction indices(在解码器词汇表中的索引值)   
        (3)初始hidden state和cell state不同,encoder 的hidden state和cell state都被初始化为0,而
        decoder的hidden state和cell state是来自于encoder输出的context vector
        (4)encoder hidden state用h表示,decoder hidden state用s表示
        
        他们之间的相同点是:
        对于不是最后一层的LSTM层,中间的LSTM层每个时间节点的输出值(指的是hidden state和cell state)都有两部分决定
        它的左边:上一时刻的 hidden state和cell state
        它的下面:当前时刻的输入   embedding vector(原始的单词根据在词汇表中的索引值经过embedding和dropout之后)
        '''
        return predictions,hidden,cell

class Seq2Seq(nn.Module):
    def __init__(self,encoder,decoder,device):
        super.__init__()
        self.encoder=encoder
        self.decoder=decoder
        self.device=device

        assert encoder.hid_dim==decoder.hid_dim,'hidden dimension of encoder must equal decoder'
        assert encoder.n_layers==decoder.n_layers,'hidden LSTM layers of encoder must equal decoder'

    def forward(self,src,trg,teacher_forcing_rate=0.5):
        '''
        :param src:shape [src sent length,batch size]
        :param trg:shape [trg sent length,batch size]
        :param teacher_forcing_rate:使用正确单词标签的概率
        :return:
        '''
        batch_size=trg.shape[1]
        max_len=trg.shape[0]#target ground truth的最大序列长度
        trg_vocab_size=self.decoder.output_dim#target词汇表中的单词个数

        outputs=torch.zeros(max_len,batch_size,trg_vocab_size)
        #记录prediction输出的batch size中每个字符串序列每个元素处的单词在词汇表中的位置

        hidden,cell=self.encoder(src)
        #编码器输出的context vector
        #hidden = [n_layers*n_directions,batch_size,hid_dim]
        #cell = [n_layers*n_directions,batch_size,hid_dim]

        input=trg[0,:]# <sos> shape  [batch_size]
        '''
        在trg中的第0个token是sos,将第0个token作为decoder第0个时刻的输入,以预测出第1个时刻的trg单词
        然后将第0个时刻decoder预测的输出值直接给了output的第1行,就是说默认模型对于trg的预测的第0个
        单词是sos,故而计算损失的时候是从output[1:,:]计算的,并不计算output[0,:]的损失
        并没有让decoder预测输出是sos的过程
        '''
        for t in range(1,max_len):
            output,hidden,cell=self.decoder(input,hidden,cell)
            outputs[t,:,:]=output#output shape [batch_size,trg_vocab_size]
            teacher_force=random.random()<teacher_forcing_rate
            top1=output.max(1)[1]#shape [batch size]
            '''
            pytorch 中的  torch.tensor.max(dimension)函数
            沿着某个维度对于tensor取最大值,返回值有两项
            第一项表示每个维度上tensor最大的数值集合
            第二项表示沿着该取最大值维度上的最大值位置索引  
            '''
            input=trg[t,:] if teacher_force else top1
        return outputs

INPUT_DIM=len(SRC.vocab)
OUTPUT_DIM=len(TRG.vocab)
ENC_EMB_DIM=256
DEC_EMB_DIM=256
HID_DIM=512
N_LAYERS=2
ENC_DROPOUT=0.5
DEC_DROPOUT=0.5

enc=Encoder(INPUT_DIM,ENC_EMB_DIM,HID_DIM,N_LAYERS,ENC_DROPOUT)
dec=Decoder(OUTPUT_DIM,DEC_EMB_DIM,HID_DIM,N_LAYERS,DEC_DROPOUT)

model=Seq2Seq(enc,dec,device).to(device)

def count_parameters(model):
    return sum(p.numel() for p in model.parameters if p.requires_grad)
'''
torch.tensor.numel()是torch.tensor的方法,统计tensor中有多少个元素
'''
print(f'the model has {count_parameters(model)} parameters')

optimizer=optim.Adam(model.parameters())
pad_idx=TRG.vocab.stoi['<pad>']
criterion=nn.CrossEntropyLoss(ignore_index=pad_idx)
'''
pytorch 中的nn.CrossEntropyLoss
将log,softmax和non-negative likelihood loss整合到了一起
计算分类任务损失函数的标准配置:softmax+cross entropy
'''

def train(model,iterator,optimizer,criterion,clip):
    model.train()
    '''
    将模型调整到train模式下,Batch normalization参数要更新,并且dropout
    会随机裁剪
    '''
    epoch_loss=0
    for i,batch in enumerate(iterator):
        src=batch.src
        trg=batch.trg#shape [max len,batch size]

        optimizer.no_grad()
        output=model(src,trg)
        '''
        将trg数值传入到模型中仅仅是因为teaching force的存在,需要为
        decoder的RNN提供每个节点处的标签
        
        output shape [max len,batch size,len(trg.voclab)] 经过nn.Linear的输出值,并没有经过任何的激活函数
        max len表示target字符串中最长的字符串列表长度
        len(trg.voclab)表示target词汇表中的单词个数
        '''
        output=output[1:,:,:].view(-1,output.shape[-1])#len(trg.voclab)=output.shape[-1]
        #这是因为对于解码器的第一个节点 sos 不计算损失

        trg=trg[1:,:].view(-1)
        '''
        trg  shape [batch_size*max len]
        output  shape [batch_size*max len,len(trg.voclab)]
        相当于是len(trg.voclab)个类别的分类问题
        '''
        loss=criterion(output,trg)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(),clip)
        optimizer.step()
        epoch_loss+=loss.item()
    return epoch_loss/len(iterator)

def evaluate(model,iterator,criterion):
    model.eval()
    epoch_loss=0
    with torch.no_grad():
        for i,batch in enumerate(iterator):
            src=batch.src
            trg=batch.trg

            output=model(src,trg,0)
            #trg [trg sent len,batch size]
            #output [trg sent len,batch size,len(trg.vocab)]

            output=output[1:,:,:].view(-1,output.shape[-1])
            trg=trg[1:,:].view(-1)

            loss=criterion(output,trg)
            epoch_loss+=loss.item()
        return epoch_loss/len(iterator)

def epoch_time(start_time, end_time):
    elapsed_time = end_time - start_time
    elapsed_mins = int(elapsed_time / 60)
    elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
    return elapsed_mins, elapsed_secs

N_EPOCHS = 10
CLIP = 1
SAVE_DIR = 'models'
MODEL_SAVE_PATH = os.path.join(SAVE_DIR, 'tut1_model.pt')

best_valid_loss = float('inf')

if not os.path.isdir(f'{SAVE_DIR}'):
    os.makedirs(f'{SAVE_DIR}')

for epoch in range(N_EPOCHS):

    start_time = time.time()

    train_loss = train(model, train_iterator, optimizer, criterion, CLIP)
    valid_loss = evaluate(model, valid_iterator, criterion)

    end_time = time.time()

    epoch_mins, epoch_secs = epoch_time(start_time, end_time)

    if valid_loss < best_valid_loss:
        best_valid_loss = valid_loss
        torch.save(model.state_dict(), MODEL_SAVE_PATH)

    print(f'Epoch: {epoch + 1:02} | Time: {epoch_mins}m {epoch_secs}s')
    print(f'\tTrain Loss: {train_loss:.3f} | Train PPL: {math.exp(train_loss):7.3f}')
    print(f'\t Val. Loss: {valid_loss:.3f} |  Val. PPL: {math.exp(valid_loss):7.3f}')