#!/usr/bin/env python
# coding: utf-8

# # Transformer翻译项目

# <h1>Table of Contents<span class="tocSkip"></span></h1>
# <div class="toc"><ul class="toc-item"><li><span><a href="#一.-建立Transformer模型的直观认识" data-toc-modified-id="一.-建立Transformer模型的直观认识-1">一. 建立Transformer模型的直观认识</a></span></li><li><span><a href="#二.-编码器部分(Encoder)" data-toc-modified-id="二.-编码器部分(Encoder)-2">二. 编码器部分(Encoder)</a></span><ul class="toc-item"><li><span><a href="#0.-先准备好输入的数据" data-toc-modified-id="0.-先准备好输入的数据-2.1">0. 先准备好输入的数据</a></span></li><li><span><a href="#1.-positional-encoding(即位置嵌入或位置编码)" data-toc-modified-id="1.-positional-encoding(即位置嵌入或位置编码)-2.2">1. positional encoding(即位置嵌入或位置编码)</a></span></li><li><span><a href="#2.-self-attention(自注意力机制)" data-toc-modified-id="2.-self-attention(自注意力机制)-2.3">2. self attention(自注意力机制)</a></span></li><li><span><a href="#3.-Attention-Mask" data-toc-modified-id="3.-Attention-Mask-2.4">3. Attention Mask</a></span></li><li><span><a href="#4.-Layer-Normalization-和残差连接" data-toc-modified-id="4.-Layer-Normalization-和残差连接-2.5">4. Layer Normalization 和残差连接</a></span></li><li><span><a href="#5.-Transformer-Encoder-整体结构" data-toc-modified-id="5.-Transformer-Encoder-整体结构-2.6">5. Transformer Encoder 整体结构</a></span></li></ul></li><li><span><a href="#三.-解码器部分(Decoder)" data-toc-modified-id="三.-解码器部分(Decoder)-3">三. 解码器部分(Decoder)</a></span></li><li><span><a href="#四.-Transformer模型" data-toc-modified-id="四.-Transformer模型-4">四. Transformer模型</a></span></li><li><span><a href="#五.-模型训练" data-toc-modified-id="五.-模型训练-5">五. 模型训练</a></span></li><li><span><a href="#六.-模型预测" data-toc-modified-id="六.-模型预测-6">六. 模型预测</a></span></li></ul></div>

# 在这份notebook当中,我们会(尽可能)实现 $Transformer$ 模型来完成翻译任务。
# (参考论文:$Attention\; Is\; All\; You\; Need$   https://arxiv.org/pdf/1706.03762.pdf )
# > **其中的`TODO`内容即为待完成的部分**
#
# 我们的数据集非常小,只有一万多个句子的训练数据,从结果来看训练出来的模型在测试集上的表现其实已经算还可以了。
# 如果想得到更好的效果,则需要更大的数据量并进行更多的训练迭代次数,感兴趣(并且有硬件条件)的同学可以进行尝试。

# ## 一. 建立Transformer模型的直观认识
#
# 首先来说一下**Transformer**和**LSTM**的最大区别,就是LSTM的训练是迭代(自回归)的,是一个接一个字的来,当前这个字过完LSTM单元,才可以进下一个字,而 $Transformer$ 的训练是并行了,就是所有字是全部同时训练的,这样就大大加快了计算效率,$Transformer$ 使用了位置嵌入$(positional \ encoding)$来理解语言的顺序,使用自注意力机制和全连接层来进行计算,这些后面都会详细讲解。
#
# $Transformer$ 模型主要分为**两大部分**,分别是**编码器($Encoder$)**和**解码器($Decoder$)**:
# - **编码器($Encoder$)**负责把自然语言序列映射成为**隐藏层**(下图中**第2步**用九宫格比喻的部分),含有自然语言序列的数学表达
# - **解码器($Decoder$)**再把隐藏层映射为自然语言序列,从而使我们可以解决各种问题,如情感分类、命名实体识别、语义关系抽取、摘要生成、机器翻译等等。

# <img src="./imgs/intuition.jpg" width=650>

# ## 二. 编码器部分(Encoder)
#
#
# 我们会**重点介绍编码器的结构**,因为理解了编码器中的结构, 理解解码器就非常简单了。而且我们用编码器就能够完成一些自然语言处理中比较主流的任务, 如情感分类, 语义关系分析, 命名实体识别等。
#
# **编码器($Encoder$)**部分, 即把**自然语言序列映射为隐藏层的数学表达的过程**。

# ** 以下为一个Transformer Encoder Block结构示意图**
# > 注意: 为方便查看, 下面各部分的内容分别对应着图中第1, 2, 3, 4个方框的序号:

# <img src="./imgs/encoder.jpg" width=550>

# ### 0. 先准备好输入的数据

# In[2]:


import os
import math
import copy
import time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

from nltk import word_tokenize
from collections import Counter
from torch.autograd import Variable

from langconv import Converter


import jieba


# 初始化参数设置
UNK = 1  # 未登录词的标识符对应的词典id
PAD = 0  # padding占位符对应的词典id
BATCH_SIZE = 64  # 每批次训练数据数量
EPOCHS = 20  # 训练轮数
LAYERS = 6  # transformer中堆叠的encoder和decoder block层数
H_NUM = 8  # multihead attention hidden个数
D_MODEL = 256  # embedding维数
D_FF = 1024  # feed forward第一个全连接层维数
DROPOUT = 0.1  # dropout比例
MAX_LENGTH = 60  # 最大句子长度

TRAIN_FILE = 'nmt/en-cn/train.txt'  # 训练集数据文件
DEV_FILE = "nmt/en-cn/dev.txt"  # 验证(开发)集数据文件
SAVE_FILE = 'save/model.pt'  # 模型保存路径(注意如当前目录无save文件夹需要自己创建)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")


# In[5]:


def seq_padding(X, padding=PAD):
    """
    对一个batch批次(以单词id表示)的数据进行padding填充对齐长度
    """
    # 计算该批次数据各条数据句子长度
    L = [len(x) for x in X]
    # 获取该批次数据最大句子长度
    ML = max(L)
    # 对X中各条数据x进行遍历,如果长度短于该批次数据最大长度ML,则以padding id填充缺失长度ML-len(x)
    return np.array([
        np.concatenate([x, [padding] * (ML - len(x))]) if len(x) < ML else x for x in X
    ])

def cht_to_chs(sent):
    sent = Converter('zh-hans').convert(sent)
    sent.encode('utf-8')
    return sent


# In[6]:


class PrepareData:
    def __init__(self, train_file, dev_file):
        # 读取数据并分词
        self.train_en, self.train_cn = self.load_data(train_file)
        self.dev_en, self.dev_cn = self.load_data(dev_file)

        # 构建单词表
        self.en_word_dict, self.en_total_words, self.en_index_dict =             self.build_dict(self.train_en)
        self.cn_word_dict, self.cn_total_words, self.cn_index_dict =             self.build_dict(self.train_cn)

        # id化
        self.train_en, self.train_cn = self.word2id(self.train_en, self.train_cn, self.en_word_dict, self.cn_word_dict)
        self.dev_en, self.dev_cn = self.word2id(self.dev_en, self.dev_cn, self.en_word_dict, self.cn_word_dict)

        # 划分batch + padding + mask
        self.train_data = self.split_batch(self.train_en, self.train_cn, BATCH_SIZE)
        self.dev_data = self.split_batch(self.dev_en, self.dev_cn, BATCH_SIZE)

    def load_data(self, path):
        """
        读取翻译前(英文)和翻译后(中文)的数据文件
        每条数据都进行分词,然后构建成包含起始符(BOS)和终止符(EOS)的单词(中文为字符)列表
        形式如:en = [['BOS', 'i', 'love', 'you', 'EOS'], ['BOS', 'me', 'too', 'EOS'], ...]
                cn = [['BOS', '我', '爱', '你', 'EOS'], ['BOS', '我', '也', '是', 'EOS'], ...]
        """
        en = []
        cn = []
        # TODO ...
        with open(path, mode="r", encoding="utf-8") as f:

            for line in f.readlines():
                sent_en, sent_cn = line.strip().split("\t")
                sent_en = sent_en.lower()
                sent_cn = cht_to_chs(sent_cn)
                sent_en = ["BOS"] + word_tokenize(sent_en) + ["EOS"]
                # sent_cn = " ".join([char for char in sent_cn])
                # sent_cn = ["BOS"] + word_tokenize(sent_cn) + ["EOS"]
                sent_cn = ["BOS"] + [word for word in jieba.cut(sent_cn)] + ["EOS"]
                en.append(sent_en)
                cn.append(sent_cn)

        return en, cn

    def build_dict(self, sentences, max_words = 50000):
        """
        传入load_data构造的分词后的列表数据
        构建词典(key为单词,value为id值)
        """
        # 对数据中所有单词进行计数
        word_count = Counter()

        for sentence in sentences:
            for s in sentence:
                word_count[s] += 1
        # 只保留最高频的前max_words数的单词构建词典
        # 并添加上UNK和PAD两个单词,对应id已经初始化设置过
        ls = word_count.most_common(max_words)
        # 统计词典的总词数
        total_words = len(ls) + 2

        word_dict = {w[0]: index + 2 for index, w in enumerate(ls)}
        word_dict['UNK'] = UNK
        word_dict['PAD'] = PAD
        # 再构建一个反向的词典,供id转单词使用
        index_dict = {v: k for k, v in word_dict.items()}

        return word_dict, total_words, index_dict

    def word2id(self, en, cn, en_dict, cn_dict, sort=True):
        """
        该方法可以将翻译前(英文)数据和翻译后(中文)数据的单词列表表示的数据
        均转为id列表表示的数据
        如果sort参数设置为True,则会以翻译前(英文)的句子(单词数)长度排序
        以便后续分batch做padding时,同批次各句子需要padding的长度相近减少padding量
        """
        # 计算英文数据条数
        length = len(en)

        # TODO: 将翻译前(英文)数据和翻译后(中文)数据都转换为id表示的形式
        out_en_ids = [[en_dict.get(word, UNK) for word in sent] for sent in en]
        out_cn_ids = [[cn_dict.get(word, UNK) for word in sent] for sent in cn]

        # 构建一个按照句子长度排序的函数
        def len_argsort(seq):
            """
            传入一系列句子数据(分好词的列表形式),
            按照句子长度排序后,返回排序后原来各句子在数据中的索引下标
            """
            return sorted(range(len(seq)), key=lambda x: len(seq[x]))

        # 把中文和英文按照同样的顺序排序
        if sort:
            # 以英文句子长度排序的(句子下标)顺序为基准
            sorted_index = len_argsort(out_en_ids)

            # TODO: 对翻译前(英文)数据和翻译后(中文)数据都按此基准进行排序
            out_en_ids = [out_en_ids[idx] for idx in sorted_index]
            out_cn_ids = [out_cn_ids[idx] for idx in sorted_index]

        return out_en_ids, out_cn_ids

    def split_batch(self, en, cn, batch_size, shuffle=True):
        """
        将以单词id列表表示的翻译前(英文)数据和翻译后(中文)数据
        按照指定的batch_size进行划分
        如果shuffle参数为True,则会对这些batch数据顺序进行随机打乱
        """
        # 在按数据长度生成的各条数据下标列表[0, 1, ..., len(en)-1]中
        # 每隔指定长度(batch_size)取一个下标作为后续生成batch的起始下标
        idx_list = np.arange(0, len(en), batch_size)
        # 如果shuffle参数为True,则将这些各batch起始下标打乱
        if shuffle:
            np.random.shuffle(idx_list)
        # 存放各个batch批次的句子数据索引下标
        batch_indexs = []
        for idx in idx_list:
            # 注意,起始下标最大的那个batch可能会超出数据大小
            # 因此要限定其终止下标不能超过数据大小
            """
            形如[array([4, 5, 6, 7]),
                 array([0, 1, 2, 3]),
                 array([8, 9, 10, 11]),
                 ...]
            """
            batch_indexs.append(np.arange(idx, min(idx + batch_size, len(en))))

        # 按各batch批次的句子数据索引下标,构建实际的单词id列表表示的各batch句子数据
        batches = []
        for batch_index in batch_indexs:
            # 按当前batch的各句子下标(数组批量索引)提取对应的单词id列表句子表示数据
            batch_en = [en[index] for index in batch_index]
            batch_cn = [cn[index] for index in batch_index]
            # 对当前batch的各个句子都进行padding对齐长度
            # 维度为:batch数量×batch_size×每个batch最大句子长度
            batch_cn = seq_padding(batch_cn)
            batch_en = seq_padding(batch_en)
            # 将当前batch的英文和中文数据添加到存放所有batch数据的列表中
            batches.append(Batch(batch_en, batch_cn))

        return batches


# 注意,上述预处理中使用的 $Batch$ 类在后面的 $Encoder$ 内容的 $Attention\ Mask$ 部分定义

# **Embeddings**
#
# 与其他序列传导模型类似,我们使用learned embeddings将输入标记和输出标记转换为维度 $d_{model}$ 的向量。我们还使用通常学习的线性变换和softmax函数将 $Decoder$(解码器)的输出转换为预测的下一个标签的概率。
#
# 在我们的模型中,我们在两个embedding层和pre-softmax线性变换层之间共享相同的权重矩阵。这么做可以节省参数,也是一种正则化方式。
# 在其中的embedding层,我们会将这些权重乘以 $\sqrt{d_{model}}$ 。

# In[7]:


class Embeddings(nn.Module):
    def __init__(self, d_model, vocab):
        super(Embeddings, self).__init__()
        # Embedding层
        self.lut = nn.Embedding(vocab, d_model)
        # Embedding维数
        self.d_model = d_model

    def forward(self, x):
        # 返回x对应的embedding矩阵(需要乘以math.sqrt(d_model))
        return self.lut(x) * math.sqrt(self.d_model)


# 数据全部处理完成,现在我们开始理解和构建 $Transformer$ 模型

# ### 1. positional encoding(即位置嵌入或位置编码)

# 由于 $Transformer$ 模型**没有**循环神经网络的迭代操作,所以我们必须提供每个字的位置信息给 $Transformer$,才能识别出语言中的顺序关系。
#
# 因此,我们定义一个位置嵌入的概念,也就是$positional \ encoding$,位置嵌入的维度为$[max \ sequence \ length,\ embedding \ dimension]$,嵌入的维度同词向量的维度,$max \ sequence \ length$属于超参数,指的是限定的最大单个句长。
#
# 注意,我们一般以字为单位训练transformer模型,也就是说我们不用分词了,首先我们要初始化字向量为$[vocab \ size,\ embedding \ dimension]$,$vocab \ size$为总共的字库数量,$embedding \ dimension$为字向量的维度,也是每个字的数学表达。
#
# 在论文 **attention is all you need**( https://arxiv.org/pdf/1706.03762.pdf )中使用了$sine$和$cosine$函数的线性变换来提供给模型位置信息:
#
# $$PE_{(pos,2i)} = sin(pos / 10000^{2i/d_{\text{model}}}) \quad \quad PE_{(pos,2i+1)} = cos(pos / 10000^{2i/d_{\text{model}}})\tag{eq.1}$$
#
# 上式中$pos$指的是句中字的位置,取值范围是$[0, \ max \ sequence \ length)$,$i$指的是词向量的维度,取值范围是$[0, \ embedding \ dimension)$,上面有$sin$和$cos$一组公式,也就是对应着$embedding \ dimension$维度的一组奇数和偶数的序号的维度,例如$0, 1$一组,$2, 3$一组,分别用上面的$sin$和$cos$函数做处理,从而产生不同的周期性变化,而位置嵌入在$embedding \ dimension$维度上随着维度序号增大,周期变化会越来越慢,而产生一种包含位置信息的纹理,就像论文原文中第六页讲的,位置嵌入函数的波长从$2 \pi$到$10000 * 2 \pi$变化,而每一个位置在$embedding \ dimension$维度上都会得到不同周期的$sin$和$cos$函数的取值组合,从而产生独一的纹理位置信息,模型从而学到位置之间的依赖关系和自然语言的时序特性。

# In[8]:


# 导入依赖库
import matplotlib.pyplot as plt
import seaborn as sns


class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout, max_len=5000):
        super(PositionalEncoding, self).__init__()
        self.dropout = nn.Dropout(p=dropout)

        # 初始化一个size为 max_len(设定的最大长度)×embedding维度 的全零矩阵
        # 来存放所有小于这个长度位置对应的porisional embedding
        pe = torch.zeros(max_len, d_model, device=DEVICE)
        # 生成一个位置下标的tensor矩阵(每一行都是一个位置下标)
        """
        形式如:
        tensor([[0.],
                [1.],
                [2.],
                [3.],
                [4.],
                ...])
        """
        position = torch.arange(0., max_len, device=DEVICE)
        position.unsqueeze_(1)
        # 这里幂运算太多,我们使用exp和log来转换实现公式中pos下面要除以的分母(由于是分母,要注意带负号)
        div_term = torch.exp(torch.arange(0., d_model, 2, device=DEVICE) * (- math.log(1e4) / d_model))
        div_term.unsqueeze_(0)

        # TODO: 根据公式,计算各个位置在各embedding维度上的位置纹理值,存放到pe矩阵中
        pe[:, 0 : : 2] = torch.sin(torch.mm(position, div_term))
        pe[:, 1 : : 2] = torch.cos(torch.mm(position, div_term))

        # 加1个维度,使得pe维度变为:1×max_len×embedding维度
        # (方便后续与一个batch的句子所有词的embedding批量相加)
        pe.unsqueeze_(0)
        # 将pe矩阵以持久的buffer状态存下(不会作为要训练的参数)
        self.register_buffer('pe', pe)

    def forward(self, x):
        # 将一个batch的句子所有词的embedding与已构建好的positional embeding相加
        # (这里按照该批次数据的最大句子长度来取对应需要的那些positional embedding值)
        x = x + Variable(self.pe[:, : x.size(1), :], requires_grad=False)
        return self.dropout(x)


# 可见,这里首先是按照最大长度max_len生成一个位置,而后根据公式计算出所有的向量,在forward函数中根据长度取用即可,非常方便。
#
# > 注意要设置requires_grad=False,因其不参与训练。

# 下面画一下位置嵌入,可见纵向观察,随着$embedding \ dimension$增大,位置嵌入函数呈现不同的周期变化。

# In[10]:


pe = PositionalEncoding(16, 0, 100)
positional_encoding = pe.forward(torch.zeros(1, 100, 16, device=DEVICE))
plt.figure(figsize=(10,10))
sns.heatmap(positional_encoding.squeeze().to("cpu"))
plt.title("Sinusoidal Function")
plt.xlabel("hidden dimension")
plt.ylabel("sequence length")
plt.show()


# In[11]:


plt.figure(figsize=(15, 5))
pe = PositionalEncoding(20, 0)
y = pe.forward(torch.zeros(1, 100, 20, device=DEVICE))
y = y.to("cpu").numpy()
plt.plot(np.arange(100), y[0, :, 4:8])
plt.legend(["dim %d" % p for p in [4,5,6,7]])
plt.show()


# ### 2. self attention(自注意力机制)

# <img src="./imgs/attention_0.jpg">
#
# <img src="./imgs/attention_1.jpg">

# **除以$\sqrt{d_k}$的解释**
#
# 假设 $q$ 和 $k$ 是独立的随机变量,平均值为 0,方差 1,这样他们的点积后形成的注意力矩阵为 $q⋅k=\sum_{i=1}^{d_k}{q_i k_i}$,均值为 0 但方差放大为 $d_k$ 。为了抵消这种影响,我们用$\sqrt{d_k}$来缩放点积,可以使得Softmax归一化时结果更稳定(不至于点积后得到注意力矩阵的值差别太大),以便反向传播时获取平衡的梯度

# In[12]:


def clones(module, N):
    """
    克隆模型块,克隆的模型块参数不共享
    """
    return nn.ModuleList([
        copy.deepcopy(module) for _ in range(N)
    ])


# In[13]:


def attention(query, key, value, mask=None, dropout=None):
    # 将query矩阵的最后一个维度值作为d_k
    d_k = query.size(-1)

    # TODO: 将key的最后两个维度互换(转置),才能与query矩阵相乘,乘完了还要除以d_k开根号
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)

    # 如果存在要进行mask的内容,则将那些为0的部分替换成一个很大的负数
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)

    # TODO: 将mask后的attention矩阵按照最后一个维度进行softmax
    p_attn = F.softmax(scores, dim=-1)

    # 如果dropout参数设置为非空,则进行dropout操作
    if dropout is not None:
        p_attn = dropout(p_attn)
    # 最后返回注意力矩阵跟value的乘积,以及注意力矩阵
    return torch.matmul(p_attn, value), p_attn


class MultiHeadedAttention(nn.Module):
    def __init__(self, h, d_model, dropout=0.1):
        super(MultiHeadedAttention, self).__init__()
        # 保证可以整除
        assert d_model % h == 0
        # 得到一个head的attention表示维度
        self.d_k = d_model // h
        # head数量
        self.h = h
        # 定义4个全连接函数,供后续作为WQ,WK,WV矩阵和最后h个多头注意力矩阵concat之后进行变换的矩阵
        self.linears = clones(nn.Linear(d_model, d_model), 4)
        self.attn = None
        self.dropout = nn.Dropout(p=dropout)

    def forward(self, query, key, value, mask=None):
        if mask is not None:
            mask = mask.unsqueeze(1)
        # query的第一个维度值为batch size
        nbatches = query.size(0)
        # 将embedding层乘以WQ,WK,WV矩阵(均为全连接)
        # 并将结果拆成h块,然后将第二个和第三个维度值互换(具体过程见上述解析)
        query, key, value = [
            l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
            for l, x in zip(self.linears, (query, key, value))
        ]
        # 调用上述定义的attention函数计算得到h个注意力矩阵跟value的乘积,以及注意力矩阵
        x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
        # 将h个多头注意力矩阵concat起来(注意要先把h变回到第三维的位置)
        x = x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k)
        # 使用self.linears中构造的最后一个全连接函数来存放变换后的矩阵进行返回
        return self.linears[-1](x)


# 参数里面的`h`和`d_model`分别表示注意力头的个数,以及模型的隐层单元数。
#
# 另外在`__init__`函数中,我们定义了`self.linears = clones(nn.Linear(d_model, d_model), 4), clone(x, N)`即为深拷贝N份,这里定义了4个全连接函数,实际上是3+1,其中的3个分别是Q、K和V的变换矩阵,最后一个是用于最后将`h`个多头注意力矩阵concat之后进行变换的矩阵。
#
# 在`forward`函数中,是首先将`query`、`key`和`value`进行相应的变换,然后需要经过`attention`这个函数的计算,这个函数实际上就是论文中“Scaled Dot-Product Attention”这个模块的计算

# ### 3. Attention Mask

# <img src="./imgs/attention_mask.jpg">

# 注意, 在上面$self \ attention$的计算过程中, 我们通常使用$mini \ batch$来计算, 也就是一次计算多句话, 也就是$X$的维度是$[batch \ size, \ sequence \ length]$, $sequence \ length$是句长, 而一个$mini \ batch$是由多个不等长的句子组成的, 我们就需要按照这个$mini \ batch$中最大的句长对剩余的句子进行补齐长度, 我们一般用$0$来进行填充, 这个过程叫做$padding$.
#
# 但这时在进行$softmax$的时候就会产生问题, 回顾$softmax$函数$\sigma (\mathbf {z} )_{i}={\frac {e^{z_{i}}}{\sum _{j=1}^{K}e^{z_{j}}}}$, $e^0$是1, 是有值的, 这样的话$softmax$中被$padding$的部分就参与了运算, 就等于是让无效的部分参与了运算, 会产生很大隐患, 这时就需要做一个$mask$让这些无效区域不参与运算, 我们一般给无效区域加一个很大的负数的偏置, 也就是:
#
# $$z_{illegal} = z_{illegal} + bias_{illegal}$$
# $$bias_{illegal} \to -\infty$$
# $$e^{z_{illegal}} \to 0 $$
#
# 经过上式的$masking$我们使无效区域经过$softmax$计算之后几乎为$0$, 这样就避免了无效区域参与计算.

# 在`Transformer`里面,`Encoder`和`Decoder`的`Attention`计算都需要相应的`Mask`处理,但功能却不同。
#
# 在`Encoder`中,就如上述介绍的,`Mask`就是为了让那些在一个`batch`中长度较短的序列的`padding`部分不参与`Attention`的计算。因此我们定义一个`Batch`批处理对象,它包含用于训练的`src`(翻译前)和`trg`(翻译后)句子,以及构造其中的`Mask`掩码。
#
# **加了`Mask`的`Attention`原理如图(另附`Multi-Head Attention`):**
# > 注意:这里的`Attention Mask`是加在`Scale`和`Softmax`之间
#
# <img src="./imgs/attention_mask2.jpg">

# In[14]:


class Batch:
    "Object for holding a batch of data with mask during training."
    def __init__(self, src, trg=None, pad=PAD):
        # 将输入与输出的单词id表示的数据规范成整数类型
        src = torch.from_numpy(src).to(DEVICE).long()
        trg = torch.from_numpy(trg).to(DEVICE).long()
        self.src = src
        # 对于当前输入的句子非空部分进行判断成bool序列
        # 并在seq length前面增加一维,形成维度为 1×seq length 的矩阵
        self.src_mask = (src != pad).unsqueeze(-2)
        # 如果输出目标不为空,则需要对decoder要使用到的target句子进行mask
        if trg is not None:
            # decoder要用到的target输入部分
            self.trg = trg[:, : -1]
            # decoder训练时应预测输出的target结果
            self.trg_y = trg[:, 1 :]
            # 将target输入部分进行attention mask
            self.trg_mask = self.make_std_mask(self.trg, pad)
            # 将应输出的target结果中实际的词数进行统计
            self.ntokens = (self.trg_y != pad).data.sum()

    # Mask掩码操作
    @staticmethod
    def make_std_mask(tgt, pad):
        "Create a mask to hide padding and future words."
        tgt_mask = (tgt != pad).unsqueeze(-2)
        tgt_mask = tgt_mask & Variable(subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data))
        return tgt_mask


# ### 4. Layer Normalization 和残差连接

# 1). **LayerNorm**:
#
# $Layer Normalization$的作用是把神经网络中隐藏层归一为标准正态分布, 也就是 $i.i.d$(独立同分布), 以起到加快训练速度, 加速收敛的作用:
# $$\mu_{i}=\frac{1}{m} \sum^{m}_{i=1}x_{ij}$$
#
# 上式中以矩阵的行$(row)$为单位求均值;
#
# $$\sigma^{2}_{j}=\frac{1}{m} \sum^{m}_{i=1}
# (x_{ij}-\mu_{j})^{2}$$
#
# 上式中以矩阵的行$(row)$为单位求方差;
#
# $$LayerNorm(x)=\alpha \odot \frac{x_{ij}-\mu_{i}}
# {\sqrt{\sigma^{2}_{i}+\epsilon}} + \beta \tag{eq.5}$$
#
# 然后用**每一行**的**每一个元素**减去**这行的均值**, 再除以**这行的标准差**, 从而得到归一化后的数值, $\epsilon$是为了防止除$0$;
# 之后引入两个**可训练参数**$\alpha, \ \beta$来弥补归一化的过程中损失掉的信息, 注意$\odot$表示元素相乘而不是点积, 我们一般初始化$\alpha$为全$1$, 而$\beta$为全$0$.
#
# > 注:有关Batch Normalization和Layer Normalization的区别可参考如下文章——https://zhuanlan.zhihu.com/p/33173246

# 2). **残差连接**:
#
# 我们在上一步得到了经过注意力矩阵加权之后的`V`, 也就是`Attention(Q, K, V)`, 我们对它进行一下转置, 使其和`X_embedding`的维度一致, 也就是`[batch size, sequence length, embedding dimension]`, 然后把他们加起来做残差连接, 直接进行元素相加, 因为他们的维度一致:
#
# `X_embedding + Attention(Q, K, V)`
#
# 在之后的运算里, 每经过一个模块的运算, 都要把运算之前的值和运算之后的值相加, 从而得到残差连接, 训练的时候可以使梯度直接走捷径反传到最初始层:
#
# $$X + SubLayer(X) \tag{eq. 6}$$
#
# > **注意:这里我们对`SubLayer(X)`一般会进行dropout后再与X连接,即`X + Dropout(SubLayer(X))`**

# In[15]:


class LayerNorm(nn.Module):
    def __init__(self, features, eps=1e-6):
        super(LayerNorm, self).__init__()
        # 初始化α为全1, 而β为全0
        self.a_2 = nn.Parameter(torch.ones(features))
        self.b_2 = nn.Parameter(torch.zeros(features))
        # 平滑项
        self.eps = eps

    def forward(self, x):
        # TODO: 请利用init中的成员变量实现LayerNorm层的功能
        # 按最后一个维度计算均值和方差
        mean = x.mean(dim=-1, keepdim=True)
        std = x.std(dim=-1, keepdim=True)
        # mean = x.mean(dim=[-2, -1], keepdim=True)
        # std = x.std(dim=[-2, -1], keepdim=True)

        # TODO: 返回Layer Norm的结果
        x = (x - mean) / torch.sqrt(std ** 2 + self.eps)
        return self.a_2 * x + self.b_2


# 以上是`LayerNormalization`的实现,其实PyTorch里面已经集成好了`nn.LayerNorm`,这里实现出来是为了学习其中的原理。而实际中,为了代码简洁,可以直接使用PyTorch里面实现好的函数。

# In[16]:


class SublayerConnection(nn.Module):
    """
    SublayerConnection的作用就是把Multi-Head Attention和Feed Forward层连在一起
    只不过每一层输出之后都要先做Layer Norm再残差连接
    """
    def __init__(self, size, dropout):
        super(SublayerConnection, self).__init__()
        self.norm = LayerNorm(size)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x, sublayer):
        # TODO: 请利用init中的成员变量实现LayerNorm和残差连接的功能
        # 返回Layer Norm和残差连接后结果
        x_ = self.norm(x)
        x_ = sublayer(x_)
        x_ = self.dropout(x_)
        return x + x_


# ### 5. Transformer Encoder 整体结构

# 经过上面4个步骤, 我们已经基本了解到来$transformer$编码器的主要构成部分, 我们下面用公式把一个$transformer \ block$的计算过程整理一下:
#
# 1). **字向量与位置编码:**
# $$X = EmbeddingLookup(X) + PositionalEncoding \tag{eq.2}$$
# $$X \in \mathbb{R}^{batch \ size  \ * \  seq. \ len. \  * \  embed. \ dim.} $$
#
# 2). **自注意力机制:**
# $$Q = Linear(X) = XW_{Q}$$
# $$K = Linear(X) = XW_{K} \tag{eq.3}$$
# $$V = Linear(X) = XW_{V}$$
# $$X_{attention} = SelfAttention(Q, \ K, \ V) \tag{eq.4}$$
#
# 3). **残差连接与$Layer \ Normalization$**
# $$X_{attention} = LayerNorm(X_{attention}) \tag{eq. 5}$$
# $$X_{attention} = X + X_{attention} \tag{eq. 6}$$
#
# 4). **$FeedForward$,其实就是两层线性映射并用激活函数(比如说$ReLU$)激活:**
# $$X_{hidden} = Linear(Activate(Linear(X_{attention}))) \tag{eq. 7}$$
#
# 5). **重复3).:**
# $$X_{hidden} = LayerNorm(X_{hidden})$$
# $$X_{hidden} = X_{attention} + X_{hidden}$$
# $$X_{hidden} \in \mathbb{R}^{batch \ size  \ * \  seq. \ len. \  * \  embed. \ dim.} $$

# $Feed Forward$(前馈网络)层其实就是两层线性映射并用激活函数激活

# In[17]:


class PositionwiseFeedForward(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1):
        super(PositionwiseFeedForward, self).__init__()
        self.w_1 = nn.Linear(d_model, d_ff)
        self.w_2 = nn.Linear(d_ff, d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # TODO: 请利用init中的成员变量实现Feed Forward层的功能
        x = self.w_1(x)
        x = F.relu(x)
        x = self.dropout(x)
        x = self.w_2(x)
        return x


# $Encoder$ 由 $N=6$ 个相同的层组成。

# In[18]:


class Encoder(nn.Module):
    # layer = EncoderLayer
    # N = 6
    def __init__(self, layer, N):
        super(Encoder, self).__init__()
        # 复制N个encoder layer
        self.layers = clones(layer, N)
        # Layer Norm
        self.norm = LayerNorm(layer.size)

    def forward(self, x, mask):
        """
        使用循环连续encoder N次(这里为6次)
        这里的Eecoderlayer会接收一个对于输入的attention mask处理
        """
        for layer in self.layers:
            x = layer(x, mask)
        return self.norm(x)


# 每层 $Encoder\ Block$ 都有两个子层组成。第一个子层实现了“多头”的 $Self\text{-}attention$,第二个子层则是一个简单的 $Position\text{-}wise$ 的全连接前馈网络。

# In[19]:


class EncoderLayer(nn.Module):
    def __init__(self, size, self_attn, feed_forward, dropout):
        super(EncoderLayer, self).__init__()
        self.self_attn = self_attn
        self.feed_forward = feed_forward
        # SublayerConnection的作用就是把multi和ffn连在一起
        # 只不过每一层输出之后都要先做Layer Norm再残差连接
        self.sublayer = clones(SublayerConnection(size, dropout), 2)
        # d_model
        self.size = size

    def forward(self, x, mask):
        # 将embedding层进行Multi head Attention
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
        # 注意到attn得到的结果x直接作为了下一层的输入
        return self.sublayer[1](x, self.feed_forward)


# ## 三. 解码器部分(Decoder)

# <img src="./imgs/decoder.jpg">

# 接着来看 $Decoder$ 部分(右半部分),它同样也是由 $N$ 层(在论文中,仍取 $N=6$ )堆叠起来。
# > 对于其中的每一层,除了与 $Encoder$ 中相同的 $self\text{-}attention$ 及 $Feed Forward$ 两层之外,还在中间插入了一个传统的 $Encoder\text{-}Decoder$ 框架中的 $context\text{-}attention$ 层(上图中的$sub\text{-}layer\ 2$),即将 $Decoder$ 的输出作为 $query$ 去查询 $Encoder$ 的输出,同样用的是 $Multi\text{-}Head\ Attention$ ,使得在 $Decode$ 的时候能看到 $Encoder$ 的所有输出。
#
# 这里明确一下 **$Decoder$ 的输入输出和解码过程:**
#
# - 输入:$Encoder$ 的输出 & 对应 $i-1$ 位置 $Decoder$ 的输出。所以中间的 $Attention$ 不是 $Self\text{-}Attention$ ,它的 $K$,$V$ 来自 $Encoder$ ,Q来自上一位置 $Decoder$ 的输出
# - 输出:对应 $i$ 位置的输出词的概率分布
# - 解码:这里要特别注意一下,编码可以并行计算,一次性全部encoding出来,但解码不是一次把所有序列解出来的,而是像rnn一样一个一个解出来的,因为要用上一个位置的输入当作 $Attention$ 的 $query$

# 这里**`Encoder`和`Decoder`的`Attention`的区别如下图所示**
#
# <img src="./imgs/attention.png">

# In[20]:


class Decoder(nn.Module):
    def __init__(self, layer, N):
        super(Decoder, self).__init__()
        # TODO: 参照EncoderLayer完成成员变量定义
        self.layers = clones(layer, N)
        self.norm = LayerNorm(layer.size)


    def forward(self, x, memory, src_mask, tgt_mask):
        """
        使用循环连续decode N次(这里为6次)
        这里的Decoderlayer会接收一个对于输入的attention mask处理
        和一个对输出的attention mask + subsequent mask处理
        """
        for layer in self.layers:
            x = layer(x, memory, src_mask, tgt_mask)
        return self.norm(x)


class DecoderLayer(nn.Module):
    def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
        super(DecoderLayer, self).__init__()
        self.size = size
        # Self-Attention
        self.self_attn = self_attn
        # 与Encoder传入的Context进行Attention
        self.src_attn = src_attn
        self.feed_forward = feed_forward
        self.sublayer = clones(SublayerConnection(size, dropout), 3)

    def forward(self, x, memory, src_mask, tgt_mask):
        # 用m来存放encoder的最终hidden表示结果
        m = memory

        # TODO: 参照EncoderLayer完成DecoderLayer的forwark函数
        # Self-Attention:注意self-attention的q,k和v均为decoder hidden
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
        # Context-Attention:注意context-attention的q为decoder hidden,而k和v为encoder hidden
        x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))
        return self.sublayer[2](x, self.feed_forward)


# 明确了解码过程之后最上面的图就很好懂了,这里主要的不同就是新加的另外要说一下新加的attention多加了一个 $subsequent\_mask$ ,因为训练时的output都是ground truth,这样可以确保预测第 $i$ 个位置时不会接触到未来的信息,具体解释如下。
#
# 对于 $Encoder$ 中 $src$ 的 $mask$ 方式就比较简单,直接把 $pad$ 部分给 $mask$ 掉即可。
# 但对于 $Decoder$ 中 $trg$ 的 $mask$ 计算略微复杂一些,不仅需要把 $pad$ 部分 $mask$ 掉,还需要进行一个 $subsequent\_mask$ 的操作。
# 即作为 $decoder$,在预测当前步的时候,是不能知道后面的内容的,即 $attention$ 需要加上 $mask$,将当前步之后的分数全部置为$-\infty$,然后再计算 $softmax$,以防止发生数据泄露。这种 $Masked$ 的 $Attention$ 是考虑到输出 $Embedding$ 会偏移一个位置,确保了生成位置 $i$ 的预测时,仅依赖小于 $i$ 的位置处的已知输出,相当于把后面不该看到的信息屏蔽掉。

# In[21]:


def subsequent_mask(size):
    "Mask out subsequent positions."
    # 设定subsequent_mask矩阵的shape
    attn_shape = (1, size, size)

    # TODO: 生成一个右上角(不含主对角线)为全1,左下角(含主对角线)为全0的subsequent_mask矩阵
    subsequent_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8')

    # TODO: 返回一个右上角(不含主对角线)为全False,左下角(含主对角线)为全True的subsequent_mask矩阵
    return torch.from_numpy(subsequent_mask) == 0


# 我们可视化一下 $subsequent\_mask$ 矩阵的形式,直观进行理解。
# 这里的 $Attention mask$ 图显示了允许每个目标词(行)查看的位置(列)。在训练期间,当前解码位置的词不能 $Attend$ 到后续位置的词。
#
# > 这里是给定一个序列长度size,生成一个下三角矩阵,在主对角线右上的都是 False,其示意图如下:
# <img src="./imgs/subsequent_mask.png" width=450>
# 就是在decoder层的self-Attention中,由于生成 $s_i$ 时, $s_{i+1}$ 并没有产生,所以不能有 $s_i$ 和 $s_{i+1}$ 的关联系数,即只有下三角矩阵有系数,即下图中**`黄色部分`**。

# In[22]:


plt.figure(figsize=(5, 5))
plt.imshow(subsequent_mask(20)[0])
plt.show()


# ## 四. Transformer模型
#
# 最后,我们把 $Encoder$ 和 $Decoder$ 组成 $Transformer$ 模型

# In[23]:


class Transformer(nn.Module):
    def __init__(self, encoder, decoder, src_embed, tgt_embed, generator):
        super(Transformer, self).__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.src_embed = src_embed
        self.tgt_embed = tgt_embed
        self.generator = generator

    def encode(self, src, src_mask):
        return self.encoder(self.src_embed(src), src_mask)

    def decode(self, memory, src_mask, tgt, tgt_mask):
        return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)

    def forward(self, src, tgt, src_mask, tgt_mask):
        # encoder的结果作为decoder的memory参数传入,进行decode
        return self.decode(self.encode(src, src_mask), src_mask, tgt, tgt_mask)


# In[24]:


class Generator(nn.Module):
    # vocab: tgt_vocab
    def __init__(self, d_model, vocab):
        super(Generator, self).__init__()
        # decode后的结果,先进入一个全连接层变为词典大小的向量
        self.proj = nn.Linear(d_model, vocab)

    def forward(self, x):
        # 然后再进行log_softmax操作(在softmax结果上再做多一次log运算)
        return F.log_softmax(self.proj(x), dim=-1)


# **定义设置超参并连接完整模型的函数**

# In[25]:


def make_model(src_vocab, tgt_vocab, N=6, d_model=512, d_ff=2048, h = 8, dropout=0.1):
    c = copy.deepcopy
    # 实例化Attention对象
    attn = MultiHeadedAttention(h, d_model).to(DEVICE)
    # 实例化FeedForward对象
    ff = PositionwiseFeedForward(d_model, d_ff, dropout).to(DEVICE)
    # 实例化PositionalEncoding对象
    position = PositionalEncoding(d_model, dropout).to(DEVICE)
    # 实例化Transformer模型对象
    model = Transformer(
        Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout).to(DEVICE), N).to(DEVICE),
        Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout).to(DEVICE), N).to(DEVICE),
        nn.Sequential(Embeddings(d_model, src_vocab).to(DEVICE), c(position)),
        nn.Sequential(Embeddings(d_model, tgt_vocab).to(DEVICE), c(position)),
        Generator(d_model, tgt_vocab)).to(DEVICE)

    # This was important from their code.
    # Initialize parameters with Glorot / fan_avg.
    for p in model.parameters():
        if p.dim() > 1:
            # 这里初始化采用的是nn.init.xavier_uniform
            nn.init.xavier_uniform_(p)
    return model.to(DEVICE)


# ## 五. 模型训练

# **标签平滑**

# 在训练期间,我们采用了值$\epsilon_{ls}=0.1$的标签平滑(参见: https://arxiv.org/pdf/1512.00567.pdf ),其实还是从$Computer\; Vision$上搬过来的,具体操作可以看下面的代码实现,**在这里不作为重点**。
#
# 这种做法提高了困惑度,因为模型变得更加不确定,但提高了准确性和BLEU分数。
# >我们使用 $KL\; div\; loss$(KL散度损失)实现标签平滑。
# 对于输出的分布,从原始的 $one\text{-}hot$ 分布转为在groundtruth上使用一个confidence值,而后其他的所有非groudtruth标签上采用 $\frac{1 - confidence}{odim - 1}$ 作为概率值进行平滑。

# In[26]:


class LabelSmoothing(nn.Module):
    """标签平滑处理"""
    def __init__(self, size, padding_idx, smoothing=0.0):
        super(LabelSmoothing, self).__init__()
        self.criterion = nn.KLDivLoss(reduction='sum')
        self.padding_idx = padding_idx
        self.confidence = 1.0 - smoothing
        self.smoothing = smoothing
        self.size = size
        self.true_dist = None

    def forward(self, x, target):
        assert x.size(1) == self.size
        true_dist = x.data.clone()
        true_dist.fill_(self.smoothing / (self.size - 2))
        true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
        true_dist[:, self.padding_idx] = 0
        mask = torch.nonzero(target.data == self.padding_idx)
        if mask.dim() > 0:
            true_dist.index_fill_(0, mask.squeeze(), 0.0)
        self.true_dist = true_dist
        return self.criterion(x, Variable(true_dist, requires_grad=False))


# 这里的size是输出词表的大小,smoothing是用于分摊在非groundtruth上面的概率值。

# 在这里,我们可以看到标签平滑的示例。

# In[27]:


# Label smoothing的例子
crit = LabelSmoothing(5, 0, 0.4)  # 设定一个ϵ=0.4
predict = torch.FloatTensor([[0, 0.2, 0.7, 0.1, 0],
                             [0, 0.2, 0.7, 0.1, 0],
                             [0, 0.2, 0.7, 0.1, 0]])
v = crit(Variable(predict.log()),
         Variable(torch.LongTensor([2, 1, 0])))

# Show the target distributions expected by the system.
print(crit.true_dist)
plt.imshow(crit.true_dist)


# 如果对给定的选择非常有信心,标签平滑实际上会开始惩罚模型。

# In[28]:


crit = LabelSmoothing(5, 0, 0.1)
def loss(x):
    d = x + 3 * 1
    predict = torch.FloatTensor([[0, x / d, 1 / d, 1 / d, 1 / d]])
    #print(predict)
    return crit(Variable(predict.log()), Variable(torch.LongTensor([1]))).item()

plt.plot(np.arange(1, 100), [loss(x) for x in range(1, 100)])


# **计算损失**

# In[29]:


class SimpleLossCompute:
    """
    简单的计算损失和进行参数反向传播更新训练的函数
    """
    def __init__(self, generator, criterion, opt=None):
        self.generator = generator
        self.criterion = criterion
        self.opt = opt

    def __call__(self, x, y, norm):
        x = self.generator(x)
        loss = self.criterion(x.contiguous().view(-1, x.size(-1)),
                              y.contiguous().view(-1)) / norm
        loss.backward()
        if self.opt is not None:
            self.opt.step()
            self.opt.optimizer.zero_grad()
        return loss.data.item() * norm.float()


# **optimizer优化器**

# 论文里面提到了他们用的优化器,是以$\beta_1=0.9、\beta_2=0.98$ 和 $\epsilon = 10^{−9}$ 的 $Adam$ 为基础,而后使用一种warmup的学习率调整方式来进行调节。
# 具体公式如下:
#
# $$ lrate = d^{−0.5}_{model}⋅min(step\_num^{−0.5},\; step\_num⋅warmup\_steps^{−1.5})$$
#
# 基本上就是用一个固定的 $warmup\_steps$ **先进行学习率的线性增长(热身)**,而后到达 $warmup\_steps$ 之后会随着 $step\_num$ 的增长,以 $step\_num$(步数)的反平方根成比例地**逐渐减小它**,他们用的 $warmup\_steps = 4000$ ,这个可以针对不同的问题自己尝试。

# In[30]:


class NoamOpt:
    "Optim wrapper that implements rate."
    def __init__(self, model_size, factor, warmup, optimizer):
        self.optimizer = optimizer
        self._step = 0
        self.warmup = warmup
        self.factor = factor
        self.model_size = model_size
        self._rate = 0

    def step(self):
        "Update parameters and rate"
        self._step += 1
        rate = self.rate()
        for p in self.optimizer.param_groups:
            p['lr'] = rate
        self._rate = rate
        self.optimizer.step()

    def rate(self, step = None):
        "Implement `lrate` above"
        if step is None:
            step = self._step
        return self.factor * (self.model_size ** (-0.5) * min(step ** (-0.5), step * self.warmup ** (-1.5)))

def get_std_opt(model):
    return NoamOpt(model.src_embed[0].d_model, 2, 4000,
            torch.optim.Adam(model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))


# 主要调节是在 $rate$ 这个函数中,其中
# - $model\_size$ 即为 $d_{model}$
# - $warmup$ 即为 $warmup\_steps$
# - $factor$ 可以理解为初始的学习率

# 以下对该优化器在**不同模型大小($model\_size$)**和**不同超参数($marmup$)值**的情况下的学习率($lrate$)曲线进行示例。

# In[31]:


# Three settings of the lrate hyperparameters.
opts = [NoamOpt(512, 1, 4000, None),
        NoamOpt(512, 1, 8000, None),
        NoamOpt(256, 1, 4000, None)]
plt.plot(np.arange(1, 20000), [[opt.rate(i) for opt in opts] for i in range(1, 20000)])
plt.legend(["512:4000", "512:8000", "256:4000"])


# **训练迭代**

# 接下来,我们创建一个通用的训练和评分功能来跟踪损失。 我们传入一个上面定义的损失计算函数,它也处理参数更新。

# In[32]:


def run_epoch(data, model, loss_compute, epoch):
    start = time.time()
    total_tokens = 0.
    total_loss = 0.
    tokens = 0.

    for i , batch in enumerate(data):
        out = model(batch.src, batch.trg, batch.src_mask, batch.trg_mask)
        loss = loss_compute(out, batch.trg_y, batch.ntokens)

        total_loss += loss
        total_tokens += batch.ntokens
        tokens += batch.ntokens

        if i % 50 == 1:
            elapsed = time.time() - start
            print("Epoch %d Batch: %d Loss: %f Tokens per Sec: %fs" % (epoch, i - 1, loss / batch.ntokens, (tokens.float() / elapsed / 1000.)))
            start = time.time()
            tokens = 0

    return total_loss / total_tokens


def train(data, model, criterion, optimizer):
    """
    训练并保存模型
    """
    # 初始化模型在dev集上的最优Loss为一个较大值
    best_dev_loss = 1e5

    for epoch in range(EPOCHS):
        # 模型训练
        model.train()
        run_epoch(data.train_data, model, SimpleLossCompute(model.generator, criterion, optimizer), epoch)
        model.eval()

        # 在dev集上进行loss评估
        print('>>>>> Evaluate')
        dev_loss = run_epoch(data.dev_data, model, SimpleLossCompute(model.generator, criterion, None), epoch)
        print('<<<<< Evaluate loss: %f' % dev_loss)

        # TODO: 如果当前epoch的模型在dev集上的loss优于之前记录的最优loss则保存当前模型,并更新最优loss值
        if dev_loss < best_dev_loss:
            torch.save(model.state_dict(), SAVE_FILE)
            best_dev_loss = dev_loss
            print('****** Save model done... ******')

        print()


# In[33]:


# 数据预处理
data = PrepareData(TRAIN_FILE, DEV_FILE)
src_vocab = len(data.en_word_dict)
tgt_vocab = len(data.cn_word_dict)
print("src_vocab %d" % src_vocab)
print("tgt_vocab %d" % tgt_vocab)

# 初始化模型
model = make_model(
                    src_vocab,
                    tgt_vocab,
                    LAYERS,
                    D_MODEL,
                    D_FF,
                    H_NUM,
                    DROPOUT
                )

# 训练
print(">>>>>>> start train")
train_start = time.time()
criterion = LabelSmoothing(tgt_vocab, padding_idx = 0, smoothing= 0.0)
optimizer = NoamOpt(D_MODEL, 1, 2000, torch.optim.Adam(model.parameters(), lr=0, betas=(0.9,0.98), eps=1e-9))

train(data, model, criterion, optimizer)
print(f"<<<<<<< finished train, cost {time.time()-train_start:.4f} seconds")


# ## 六. 模型预测

# In[ ]:


def greedy_decode(model, src, src_mask, max_len, start_symbol):
    """
    传入一个训练好的模型,对指定数据进行预测
    """
    # 先用encoder进行encode
    memory = model.encode(src, src_mask)
    # 初始化预测内容为1×1的tensor,填入开始符('BOS')的id,并将type设置为输入数据类型(LongTensor)
    ys = torch.ones(1, 1).fill_(start_symbol).type_as(src.data)
    # 遍历输出的长度下标
    for i in range(max_len-1):
        # decode得到隐层表示
        out = model.decode(memory,
                           src_mask,
                           Variable(ys),
                           Variable(subsequent_mask(ys.size(1)).type_as(src.data)))
        # 将隐藏表示转为对词典各词的log_softmax概率分布表示
        prob = model.generator(out[:, -1])
        # 获取当前位置最大概率的预测词id
        _, next_word = torch.max(prob, dim = 1)
        next_word = next_word.data[0]
        # 将当前位置预测的字符id与之前的预测内容拼接起来
        ys = torch.cat([ys,
                        torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=1)
    return ys


def evaluate(data, model):
    """
    在data上用训练好的模型进行预测,打印模型翻译结果
    """
    # 梯度清零
    with torch.no_grad():
        # 在data的英文数据长度上遍历下标
        for i in range(len(data.dev_en)):
            # TODO: 打印待翻译的英文句子
            en_sent = " ".join([data.en_index_dict[w] for w in data.dev_en[i]])
            print("\n" + en_sent)

            # TODO: 打印对应的中文句子答案
            cn_sent = " ".join([data.cn_index_dict[w] for w in data.dev_cn[i]])
            print("".join(cn_sent))

            # 将当前以单词id表示的英文句子数据转为tensor,并放如DEVICE中
            src = torch.from_numpy(np.array(data.dev_en[i])).long().to(DEVICE)
            # 增加一维
            src = src.unsqueeze(0)
            # 设置attention mask
            src_mask = (src != 0).unsqueeze(-2)
            # 用训练好的模型进行decode预测
            out = greedy_decode(model, src, src_mask, max_len=MAX_LENGTH, start_symbol=data.cn_word_dict["BOS"])
            # 初始化一个用于存放模型翻译结果句子单词的列表
            translation = []
            # 遍历翻译输出字符的下标(注意:开始符"BOS"的索引0不遍历)
            for j in range(1, out.size(1)):
                # 获取当前下标的输出字符
                sym = data.cn_index_dict[out[0, j].item()]
                # 如果输出字符不为'EOS'终止符,则添加到当前句子的翻译结果列表
                if sym != 'EOS':
                    translation.append(sym)
                # 否则终止遍历
                else:
                    break
            # 打印模型翻译输出的中文句子结果
            print("translation: %s" % " ".join(translation))


# In[ ]:


# 预测
# 加载模型
model.load_state_dict(torch.load(SAVE_FILE))
# 开始预测
print(">>>>>>> start evaluate")
evaluate_start  = time.time()
evaluate(data, model)
print(f"<<<<<<< finished evaluate, cost {time.time()-evaluate_start:.4f} seconds")


# In[ ]: