【动手学深度学习】李沐——循环神经网络

本文内容目录

    • 序列模型
    • 文本预处理
    • 语言模型和数据集
    • 循环神经网络
    • RNN的从零开始实现
    • RNN的简洁实现
    • 通过时间反向传播
    • 门控循环单元GRU
    • 长短期记忆网络(LSTM)
    • 深度循环神经网络
    • 双向循环神经网络
    • 机器翻译与数据集
    • 编码器-解码器架构
    • 序列到序列学习(Seq2Seq)
    • 束搜索

李沐老师在本章节中主要讲解了关于循环神经网络和编码器-解码器的内容,主要关注于实用方向,对于其理论并没有深挖,如果感兴趣可以看我这两篇博客,是李宏毅老师的学习笔记,对理论部分进行了较详细的说明:
【机器学习】李宏毅——Recurrent Neural Network(循环神经网络)
【机器学习】李宏毅——AE自编码器(Auto-encoder)

序列模型

在现实生活中很多数据都是有时序结构的,那么对于时序结构的研究也是必要的。

一般对于时序结构而言,在第t个时间点的观察值 x t x_t xt是与前面t-1个时刻的观察值有关的,但反过来在现实中不一定可行,即:
【动手学深度学习】李沐——循环神经网络_第1张图片

那么我们对条件概率进行建模,即:
【动手学深度学习】李沐——循环神经网络_第2张图片

那么如果能够学习到模型f,及概率计算方法p,就可以进行预测了。

那么针对这个问题,有两种比较常见的研究方法:


马尔科夫假设

因为前面我们的叙述是当前时刻的观察值跟前面所有时刻的观察值相关,那么马尔科夫假设就是假定当前数据只和过去 τ \tau τ个数据点相关,这样函数f的输入就从不定长转换为了定长,因此就方便很多:

在这里插入图片描述

那么就可以用一个简单的MLP来实现。


潜变量模型

即引入潜变量 h t h_t ht来表示过去信息 h t = f ( x 1 , . . . , x t − 1 ) h_t=f(x_1,...,x_{t-1}) ht=f(x1,...,xt1),那么 x t = p ( x t ∣ h t ) x_t=p(x_t \mid h_t) xt=p(xtht)

【动手学深度学习】李沐——循环神经网络_第3张图片


具体老师通过了一个小例子来为我们展示了训练以及预测:

import torch
from matplotlib import pyplot as plt
from torch import nn
from d2l import torch as d2l

T = 1000  # 总共产生1000个点
time = torch.arange(1, T + 1, dtype=torch.float32)
x = torch.sin(0.01 * time) + torch.normal(0, 0.2, (T,))  # 加上噪音
# d2l.plot(time, [x], 'time', 'x', xlim=[1, 1000], figsize=(6, 3))

tau = 4
features = torch.zeros((T - tau, tau))  # 因为前tau个之间没有tau个可以作为输入
for i in range(tau):
    features[:, i] = x[i: T - tau + i]  # 例如第0列就是每个数据的前面第4个
labels = x[tau:].reshape((-1, 1))  # 从第4个往后都是前面tau个造成的输出了

batch_size, n_train = 16, 600
# 用前600个样本来训练,然后后面400个完成预测任务
train_iter = d2l.load_array((features[:n_train], labels[:n_train]),
                            batch_size, is_train=True)


# 初始化网络权重的函数
def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.xavier_uniform_(m.weight)


# 一个简单的多层感知机
def get_net():
    net = nn.Sequential(nn.Linear(4, 10),
                        nn.ReLU(),
                        nn.Linear(10, 1))
    net.apply(init_weights)
    return net


loss = nn.MSELoss(reduction='none')


def train(net, train_iter, loss, epochs, lr):
    trainer = torch.optim.Adam(net.parameters(), lr)
    for epoch in range(epochs):
        for X, y in train_iter:
            trainer.zero_grad()
            l = loss(net(X), y)
            l.sum().backward()
            trainer.step()
        print(f'epoch {epoch + 1}, '
              f'loss: {d2l.evaluate_loss(net, train_iter, loss):f}')


net = get_net()
train(net, train_iter, loss, 5, 0.01)

onestep_preds = net(features)
# 单步预测,就是每次都给定4个真实值来让你预测下一个
# 注意这里采用detach是将本来含有梯度的变量,复制一个不含有梯度,不过都是指向同一个数值
# 不含有梯度是因为画图不需要计算梯度,防止在画图中发生计算过程而改变梯度
d2l.plot([time, time[tau:]],
         [x.detach().numpy(), onestep_preds.detach().numpy()], 'time',
         'x', legend=['data', '1-step preds'], xlim=[1, 1000],figsize=(6, 3))
plt.show()
# 多步预测,只知道600个,然后可以结合真实数据预测到604个,那么后面都是靠预测值来预测
multistep_preds = torch.zeros(T)
multistep_preds[: n_train + tau] = x[: n_train + tau]
for i in range(n_train + tau, T):
    multistep_preds[i] = net(multistep_preds[i - tau:i].reshape((1, -1)))

d2l.plot([time, time[tau:], time[n_train + tau:]],
         [x.detach().numpy(), onestep_preds.detach().numpy(),
          multistep_preds[n_train + tau:].detach().numpy()], 'time',
         'x', legend=['data', '1-step preds', 'multistep preds'],
         xlim=[1, 1000], figsize=(6, 3))
plt.show()
max_steps = 64
features = torch.zeros((T - tau - max_steps + 1, tau + max_steps))
# 列i(i
for i in range(tau):
    features[:, i] = x[i: i + T - tau - max_steps + 1]

# 列i(i>=tau)是来自(i-tau+1)步的预测,其时间步从(i)到(i+T-tau-max_steps+1)
for i in range(tau, tau + max_steps):
    features[:, i] = net(features[:, i - tau:i]).reshape(-1)

steps = (1, 4, 16, 64)
d2l.plot([time[tau + i - 1: T - max_steps + i] for i in steps],
         [features[:, (tau + i - 1)].detach().numpy() for i in steps], 'time', 'x',
         legend=[f'{i}-step preds' for i in steps], xlim=[5, 1000],
         figsize=(6, 3))

plt.show()

【动手学深度学习】李沐——循环神经网络_第4张图片

可以看到单步预测的结果还是很精确的。

【动手学深度学习】李沐——循环神经网络_第5张图片

但是在多步预测时,我们如果只给前面600个数据点,然后让其预测后面400个,就结果差的很离谱

【动手学深度学习】李沐——循环神经网络_第6张图片

可以看到,多步预测为1,4,16的结果都还算可以,但是增加到64时就出现了明显的差异。


小结

  • 时序模型中,当前数据跟之前观察到的数据相关
  • 自回归模型使用自身过去数据来预测未来
  • 马尔科夫模型假设当前只跟最近少数数据相关,从而简化模型
  • 潜变量模型使用潜变量来概括历史信息

文本预处理

该章节主要是介绍了对于一个简单文本文件的处理,生成可以用来使用的数据集

import collections
import re
from d2l import torch as d2l

# @save
d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt',
                                '090b5e7e70c295757f55df93cb0a180b9691891a')


# 下载数据集

def read_time_machine():
    with open(d2l.download('time_machine'), 'r') as f:
        lines = f.readlines()
    return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]


# 就是将除了A-Z和a-z,还有空格,其他的符号都去掉,再去掉回车,再转成小写

lines = read_time_machine()
print(f'# 文本总行数: {len(lines)}')
print(lines[0])
print(lines[10])


def tokenize(lines, token='word'):  # @save
    if token == 'word':
        return [line.split() for line in lines]
    elif token == 'char':
        return [list(line) for line in lines]
    else:
        print('错误:未知词元类型:' + token)


tokens = tokenize(lines)
for i in range(11):
    print(tokens[i])


def count_corpus(tokens):  # @save
    """统计词元的频率"""
    # 这里的tokens是1D列表或2D列表
    if len(tokens) == 0 or isinstance(tokens[0], list):
        # 所有单词都展开成一个列表
        tokens = [token for line in tokens for token in line]
    return collections.Counter(tokens)


class Vocab:
    def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
        if tokens is None:
            tokens = []
        if reserved_tokens is None:
            reserved_tokens = []
        # 按照出现的频率来进行排序
        counter = count_corpus(tokens)
        self._token_freqs = sorted(counter.items(), key=lambda x: x[1],
                                   reverse=True)
        # 按照出现频率从大到小排序
        # 未知词元索引在0,包括出现频率太少,还有句子起始和结尾的标志
        self.idx_to_token = [''] + reserved_tokens
        self.token_to_idx = {token: idx for idx, token in enumerate(self.idx_to_token)}

        for token, freq in self._token_freqs:
            if freq < min_freq:
                break
            if token not in self.token_to_idx:
                self.idx_to_token.append(token)
                self.token_to_idx[token] = len(self.idx_to_token) - 1
        # 不断添加进去词汇并建立和位置之间的索引关系

    def __len__(self):
        return len(self.idx_to_token)

    @property
    def unk(self):  # 未知词元的索引为0,装饰器,可以直接self.unk,不用加括号
        return 0

    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):  # 如果是单个
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]  # 如果是多个

    def to_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]  # 从索引到单词
        return [self.idx_to_token[index] for index in indices]

    @property
    def token_freqs(self):
        return self._token_freqs


vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[:10])


def load_corpus_time_machine(max_tokens=-1):  # @save
    """返回时光机器数据集的词元索引列表和词表"""
    lines = read_time_machine()
    tokens = tokenize(lines, 'char')
    vocab = Vocab(tokens)
    # 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落,
    # 所以将所有文本行展平到一个列表中
    corpus = [vocab[token] for line in tokens for token in line]
    if max_tokens > 0:
        corpus = corpus[:max_tokens]
    return corpus, vocab


corpus, vocab = load_corpus_time_machine()
len(corpus), len(vocab)

语言模型和数据集

语言模型是指,给定文本序列 x 1 , . . . , x T x_1,...,x_T x1,...,xT,其目标是估计联合概率 p ( x 1 , . . . , x T ) p(x_1,...,x_T) p(x1,...,xT),也就是该文本序列出现的概率。

那么假设序列长度为2,那我们可以使用计数的方法,简单计算为:
p ( x , x ′ ) = p ( x ) p ( x ′ ∣ x ) = n ( x ) n a l l n ( x , x ′ ) n ( x ) p(x,x^{\prime})=p(x)p(x^{\prime}\mid x)=\frac{n(x)}{n_{all}}\frac{n(x,x^{\prime})}{n(x)} p(x,x)=p(x)p(xx)=nalln(x)n(x)n(x,x)
那么继续拓展序列长度也可以采用类似的计数方法。

但是如果序列长度太长,如果文本量不够大的情况下可能会出现 n ( x 1 , . . . , x T ) ≤ 1 n(x_1,...,x_T)\leq 1 n(x1,...,xT)1的情况,那么就可以用马尔科夫假设来缓解这个问题:

  • 一元语法: p ( x 1 , x 2 , x 3 , x 4 ) = p ( x 1 ) p ( x 2 ) p ( x 3 ) p ( x 4 ) p(x_1,x_2,x_3,x_4)=p(x_1)p(x_2)p(x_3)p(x_4) p(x1,x2,x3,x4)=p(x1)p(x2)p(x3)p(x4)
  • 二元语法: p ( x 1 , x 2 , x 3 , x 4 ) = p ( x 1 ) p ( x 2 ∣ x 1 ) p ( x 3 ∣ x 2 ) p ( x 4 ∣ x 3 ) p(x_1,x_2,x_3,x_4)=p(x_1)p(x_2\mid x_1)p(x_3\mid x_2)p(x_4\mid x_3) p(x1,x2,x3,x4)=p(x1)p(x2x1)p(x3x2)p(x4x3)
  • 三元语法: p ( x 1 , x 2 , x 3 , x 4 ) = p ( x 1 ) p ( x 2 ∣ x 1 ) p ( x 3 ∣ x 1 , x 2 ) p ( x 4 ∣ x 2 , x 3 ) p(x_1,x_2,x_3,x_4)=p(x_1)p(x_2\mid x_1)p(x_3\mid x_1,x_2)p(x_4\mid x_2,x_3) p(x1,x2,x3,x4)=p(x1)p(x2x1)p(x3x1,x2)p(x4x2,x3)

代码为:

import random
import torch
from d2l import torch as d2l
from matplotlib import pyplot as plt

tokens = d2l.tokenize(d2l.read_time_machine())
# 因为每个文本行不一定是一个句子或一个段落,因此我们把所有文本行拼接到一起
corpus = [token for line in tokens for token in line]
vocab = d2l.Vocab(corpus)  # 计算频率得到的词汇列表

freqs = [freq for token, freq in vocab.token_freqs]  # 将频率变化画出来
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)', xscale='log', yscale='log')
plt.show()  # 以上这是单个单词的情况

# 我们来看看连续的两个单词和三个单词的情况,即二元语法和三元语法
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
bigram_vocab = d2l.Vocab(bigram_tokens)
trigram_tokens = [triple for triple in zip(corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)

bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]
d2l.plot([freqs, bigram_freqs, trigram_freqs], xlabel='token: x',
         ylabel='frequency: n(x)', xscale='log', yscale='log',
         legend=['unigram', 'bigram', 'trigram'])
plt.show()  # 画出来对比


# 下面我们对一个很长的文本序列,随机在上面采样得到我们指定长度的子序列,方便我们输入到模型中
def seq_data_iter_random(corpus, batch_size, num_steps):  # @save
    """使用随机抽样生成一个小批量子序列"""
    # 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
    corpus = corpus[random.randint(0, num_steps - 1):]
    # 因为长度为num_steps是肯定的,那我们如果每次都从0开始,那么例如2-7这种就得不到
    # 因此每次都随机的初始点开始就可以保证我们能够采样得到不同的数据
    # 减去1,是因为我们需要考虑标签
    num_subseqs = (len(corpus) - 1) // num_steps
    # 长度为num_steps的子序列的起始索引
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
    # 在随机抽样的迭代过程中,
    # 来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻
    random.shuffle(initial_indices)

    def data(pos):
        # 返回从pos位置开始的长度为num_steps的序列
        return corpus[pos: pos + num_steps]

    num_batches = num_subseqs // batch_size
    for i in range(0, batch_size * num_batches, batch_size):
        # 在这里,initial_indices包含子序列的随机起始索引
        initial_indices_per_batch = initial_indices[i: i + batch_size]
        X = [data(j) for j in initial_indices_per_batch]
        Y = [data(j + 1) for j in initial_indices_per_batch]
        # 这里解释一下,一开始我认为应该输入序列x之后我们要输出x之后的下一个单词,因此认为y应该为长度为1
        # 但是实际上在训练时我们并不是5个丢进去,然后生成1个出来
        # 我们是丢进去第一个,然后生成第二个,然后结合1,2的真实标签,去预测第三个,以此类推
        # 直到后面结合5个去预测第6个
        yield torch.tensor(X), torch.tensor(Y)


# 这个函数是让相邻两个小批量中的子序列在原始序列上是相邻的
def seq_data_iter_sequential(corpus, batch_size, num_steps):  # @save
    """使用顺序分区生成一个小批量子序列"""
    # 从随机偏移量开始划分序列
    offset = random.randint(0, num_steps)
    num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
    Xs = torch.tensor(corpus[offset: offset + num_tokens])
    Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
    Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
    num_batches = Xs.shape[1] // num_steps
    for i in range(0, num_steps * num_batches, num_steps):
        X = Xs[:, i: i + num_steps]
        Y = Ys[:, i: i + num_steps]
        yield X, Y


class SeqDataLoader:  # @save
    """加载序列数据的迭代器"""

    def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
        if use_random_iter:
            self.data_iter_fn = d2l.seq_data_iter_random
        else:
            self.data_iter_fn = d2l.seq_data_iter_sequential
        self.corpus, self.vocab = d2l.load_corpus_time_machine(max_tokens)
        self.batch_size, self.num_steps = batch_size, num_steps

    def __iter__(self):
        return self.data_iter_fn(self.corpus, self.batch_size, self.num_steps)

# 封装,同时返回数据迭代器和词汇表
def load_data_time_machine(batch_size, num_steps,  #@save
                           use_random_iter=False, max_tokens=10000):
    """返回时光机器数据集的迭代器和词表"""
    data_iter = SeqDataLoader(
        batch_size, num_steps, use_random_iter, max_tokens)
    return data_iter, data_iter.vocab

【动手学深度学习】李沐——循环神经网络_第7张图片
【动手学深度学习】李沐——循环神经网络_第8张图片

循环神经网络

其模型可以用下图来表示:

【动手学深度学习】李沐——循环神经网络_第9张图片

即中间的隐变量,是用来捕获并保留数序列直到其当前时间步的历史信息,其内部原理为:

  • 更新隐藏状态: h t = ϕ ( W h h h t − 1 + W h x x t − 1 + b h ) \pmb{h}_t=\phi(\pmb{W}_{hh}\pmb{h}_{t-1}+\pmb{W}_{hx}\pmb{x}_{t-1}+\pmb{b}_h) hht=ϕ(WWhhhht1+WWhxxxt1+bbh)
  • 输出: o t = ( W h o h t + b o ) \pmb{o}_t=(\pmb{W}_{ho}\pmb{h}_t+\pmb{b}_o) oot=(WWhohht+bbo)

例如在 t 1 t_1 t1时刻输入 x 1 = x_1= x1=“你”,那么我们希望它能够能够计算得到 h 1 h_1 h1并得到输出 o 1 o_1 o1=“好”,然后接下来输入为 x 2 x_2 x2=“好”,我们希望 o 2 o_2 o2="世"等等。

【动手学深度学习】李沐——循环神经网络_第10张图片

【动手学深度学习】李沐——循环神经网络_第11张图片


而衡量一个句子的质量,使用的是困惑度,其内部使用平均交叉熵来实现:
π = 1 n ∑ i = 1 n − log ⁡ p ( x t ∣ x t − 1 , . . . , x 1 ) \pi=\frac{1}{n}\sum_{i=1}^n-\log p(x_t\mid x_{t-1},...,x_1) π=n1i=1nlogp(xtxt1,...,x1)
注意这里指的是根据现有已知的 x 1 , . . . , x t − 1 x_{1},...,x_{t-1} x1,...,xt1的情况(都是真实标签),我们能够预测出正确结果 x t x_t xt的概率,那么如果每次都能够正确预测,就是p=1,那么log=0。而常见的是用 exp ⁡ ( π ) \exp(\pi) exp(π)来表达,因此1代表完美,无穷大为最差情况


下一个知识点是梯度裁剪

为了防止在迭代过程中计算T个时间步上的梯度时由于不断叠加而产生的数值不稳定的情况,而引入梯度裁剪:将所有层的梯度拼成一个向量g,那么如果该向量的L2范数超过了设定值 θ \theta θ就将其进行修正,修正为 θ \theta θ值,即:
g ← min ⁡ ( 1 , θ ∥ g ∥ ) g \pmb{g}\leftarrow \min (1,\frac{\theta}{\Vert \pmb{g} \Vert})\pmb{g} ggmin(1,ggθ)gg


RNN有非常多的应用场景:

【动手学深度学习】李沐——循环神经网络_第12张图片


小结

  • RNN的输出取决于当前输入和前一时刻的隐变量
  • 应用到语言模型中时,RNN根据当前词预测下一次时刻词
  • 通常使用困惑度来衡量语言模型的好坏

RNN的从零开始实现

完成代码如下,需要注意的地方和讲解的地方都在注释中了。

import math
import torch
from matplotlib import pyplot as plt
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l


batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)


# 接下来引入独热编码的使用
# print(F.one_hot(torch.tensor([0, 2]), len(vocab)))
# 第一个参数0和2,代表我有两个编码,第一个编码在0的位置取1,第二个在2的位置取1,而长度就是第二个参数

# 而我们每次采样得到都是批量大小*时间步数,将每个取值(标量)转换为独热编码就是三维
# 批量大小*时间步数*独热编码,那为了方便,我们将维度转换为时间步数*批量大小再去变成独热编码
# 这样每个时刻的数值就连在一起了方便使用,如下:
X = torch.arange(10).reshape((2, 5))  # 批量为2,时间步长为5
# print(F.one_hot(X.T, 28).shape)  # 输出为5,2,28


# 初始化模型参数
def get_params(vocab_size, num_hiddens, device):
    num_inputs = num_outputs = vocab_size
    # 因为输入是一个字符,就是1个独特编码,输出是预测的下一个字符也是独热编码,因此长度都是独特编码的长度
    def normal(shape):
        return torch.randn(size=shape, device=device) * 0.01

    # 隐藏层参数
    W_xh = normal((num_inputs, num_hiddens))
    W_hh = normal((num_hiddens, num_hiddens))
    b_h = torch.zeros(num_hiddens, device=device)
    # 输出层参数
    W_hq = normal((num_hiddens, num_outputs))
    b_q = torch.zeros(num_outputs, device=device)
    # 附加梯度
    params = [W_xh, W_hh, b_h, W_hq, b_q]
    for param in params:
        param.requires_grad_(True)  # 我们后面要计算梯度
    return params


# 下面是对RNN模型的定义
# 定义初始隐藏层的状态
def init_rnn_state(batch_size, num_hiddens, device):
    # 这里用元组的原因是为了和后面LSTM统一
    return (torch.zeros((batch_size, num_hiddens), device=device), )

def rnn(inputs, state, params):
    # inputs的形状:(时间步数量,批量大小,词表大小)
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state  # 注意state是元组,第二个参数我们暂时不要,所有不用接受,但是要有逗号,否则H是元组
    outputs = []
    # X的形状:(批量大小,词表大小),这就是我们前面转置的原因,方便对同一时间步的输入做预测
    for X in inputs:
        H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)  # 更新H
        Y = torch.mm(H, W_hq) + b_q  # 对Y做出预测
        outputs.append(Y)  # 这里outputs是数组,长度为时间步数量,每个元素都是批量大小*词表大小
    # 那个下面对output进行堆叠,就是将时间步维度去掉,行数为(时间步*批量大小),列为词表大小
    return torch.cat(outputs, dim=0), (H,)


# 用类来封装这些函数
class RNNModelScratch: #@save
    """从零开始实现的循环神经网络模型"""
    def __init__(self, vocab_size, num_hiddens, device,get_params, init_state, forward_fn):
        self.vocab_size, self.num_hiddens = vocab_size, num_hiddens
        self.params = get_params(vocab_size, num_hiddens, device)
        # 下面这两个其实是函数,第一个就是刚才初始化隐状态的函数,第二个就是rnn函数进行前向计算
        self.init_state, self.forward_fn = init_state, forward_fn

    def __call__(self, X, state):
        X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
        return self.forward_fn(X, state, self.params)  # 进行前向计算后返回

    def begin_state(self, batch_size, device):
        return self.init_state(batch_size, self.num_hiddens, device)

# 检查输出是否具有正确的形状
num_hiddens = 512
net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,
                      init_rnn_state, rnn)
state = net.begin_state(X.shape[0], d2l.try_gpu())
Y, new_state = net(X.to(d2l.try_gpu()), state)
print(Y.shape, "\n",len(new_state),"\n", new_state[0].shape)


# 定义预测函数
def predict_ch8(prefix, num_preds, net, vocab, device):  #@save
    """在prefix后面生成新字符"""
    # prefix是用户提供的一个包含多个字符的字符串
    state = net.begin_state(batch_size=1, device=device)
    outputs = [vocab[prefix[0]]]  # 将其第一个字符转换为数字放入其中
    get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
    # 上是一个匿名函数,可以在每次outputs更新后都调用outputs的最后一个元素
    for y in prefix[1:]:  # 预热期,此时不做预测,我们用这些字符不断来更新state
        _, state = net(get_input(), state)
        outputs.append(vocab[y])  # 将下一个待作为输入的转换为数字进入
    for _ in range(num_preds):  # 预测num_preds步
        y, state = net(get_input(), state)  # 预测并更新state
        outputs.append(int(y.argmax(dim=1).reshape(1)))  # 这就是将预测的放入,并作为下一次的输入
    return ''.join([vocab.idx_to_token[i] for i in outputs])  # 拼接成字符

print(predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu()))  # 看看效果


# 梯度裁剪
def grad_clipping(net, theta):  #@save
    """裁剪梯度"""
    if isinstance(net, nn.Module):
        params = [p for p in net.parameters() if p.requires_grad]
        # 取出那些需要更新的梯度
    else:
        params = net.params
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm  # 对梯度进行修剪


# 训练模型
#@save
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
    """训练网络一个迭代周期(定义见第8章)"""
    state, timer = None, d2l.Timer()
    metric = d2l.Accumulator(2)  # 训练损失之和,词元数量
    for X, Y in train_iter:
        if state is None or use_random_iter:
            # 如果用的是打乱的,那么后一个小批量和前一个小批量的样本之间并不是连接在一起的
            # 那么它们的隐变量不存在关系,所以必须初始化
            # 在第一次迭代或使用随机抽样时初始化state
            state = net.begin_state(batch_size=X.shape[0], device=device)
        else:  # 否则的话,我们就可以沿用上次计算完的隐变量,只不过detach是断掉链式求导,我们现在隐变量是数值了,跟之前的没有关系了
            if isinstance(net, nn.Module) and not isinstance(state, tuple):
                # state对于nn.GRU是个张量,这部分可以认为我们将state变换为常数
                # 那么梯度更新时就不会再和前面批次的梯度进行相乘,这里就直接断掉梯度的链式法则了
                state.detach_()
            else:
                # state对于nn.LSTM或对于我们从零开始实现的模型是个张量,这部分在后面有用
                for s in state:
                    s.detach_()
        y = Y.T.reshape(-1)
        X, y = X.to(device), y.to(device)
        y_hat, state = net(X, state)
        l = loss(y_hat, y.long()).mean()
        if isinstance(updater, torch.optim.Optimizer):
            updater.zero_grad()  # 清空梯度
            l.backward()
            grad_clipping(net, 1)
            updater.step()  # 更新参数
        else:
            l.backward()
            grad_clipping(net, 1)
            # 因为已经调用了mean函数
            updater(batch_size=1)
        metric.add(l * y.numel(), y.numel())
    return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()


#@save
def train_ch8(net, train_iter, vocab, lr, num_epochs, device,use_random_iter=False):
    """训练模型(定义见第8章)"""
    loss = nn.CrossEntropyLoss()
    animator = d2l.Animator(xlabel='epoch', ylabel='perplexity',
                            legend=['train'], xlim=[10, num_epochs])
    # 初始化
    if isinstance(net, nn.Module):
        updater = torch.optim.SGD(net.parameters(), lr)
    else:
        updater = lambda batch_size: d2l.sgd(net.params, lr, batch_size)
    predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)  # 预测函数
    # 训练和预测
    for epoch in range(num_epochs):
        ppl, speed = train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter)
        if (epoch + 1) % 10 == 0:
            print(predict('time traveller'))
            animator.add(epoch + 1, [ppl])
    print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
    print(predict('time traveller'))
    print(predict('traveller'))


# 顺序采样
num_epochs, lr = 500, 1
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())
plt.show()
# 随机采用
net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,init_rnn_state, rnn)
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu(),use_random_iter=True)
plt.show()

【动手学深度学习】李沐——循环神经网络_第13张图片

困惑度 1.0, 50189.8 词元/秒 cuda:0
time traveller for so it will be convenient to speak of himwas e
traveller with a slight accession ofcheerfulness really thi

【动手学深度学习】李沐——循环神经网络_第14张图片

困惑度 1.5, 47149.4 词元/秒 cuda:0
time traveller proceeded anyreal body must have extension in fou
traveller held in his hand was a glitteringmetallic furmime

小结

  • 我们可以训练一个基于循环神经网络的字符级语言模型,根据用户提供的文本的前缀 生成后续文本
  • 一个简单的循环神经网络语言模型包括输入编码、循环神经网络模型和输出生成
  • 循环神经网络模型在训练以前需要初始化状态,不过随机抽样和顺序划分使用初始化方法不同
  • 当使用顺序划分时,我们需要分离梯度以减少计算量(detach)
  • 在进行任何预测之前,模型通过预热期进行自我更新(获得比初始值更好的隐状态,训练只是修改参数,并没有改状态)
  • 梯度裁剪可以防止梯度爆炸,但不能应对梯度消失

RNN的简洁实现

import torch
from matplotlib import pyplot as plt
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)


# 定义模型
num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)  # 直接调用模型
# 初始化隐状态
state = torch.zeros((1, batch_size, num_hiddens))
X = torch.rand(size=(num_steps, batch_size, len(vocab)))
Y, state_new = rnn_layer(X, state)
# 这里要注意的是rnn_layer的输出Y并不是我们想要的预测变量!而是隐状态!里面只进行了隐状态的计算而已


# 完成的RNN模型
#@save
class RNNModel(nn.Module):
    """循环神经网络模型"""
    def __init__(self, rnn_layer, vocab_size, **kwargs):
        super(RNNModel, self).__init__(**kwargs)
        self.rnn = rnn_layer  # 计算隐状态
        self.vocab_size = vocab_size
        self.num_hiddens = self.rnn.hidden_size
        # 如果RNN是双向的(之后将介绍),num_directions应该是2,否则应该是1
        if not self.rnn.bidirectional:
            self.num_directions = 1
            self.linear = nn.Linear(self.num_hiddens, self.vocab_size)  # 输出层计算Y
        else:
            self.num_directions = 2
            self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)

    def forward(self, inputs, state):
        X = F.one_hot(inputs.T.long(), self.vocab_size)
        X = X.to(torch.float32)
        Y, state = self.rnn(X, state)
        # 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数)
        # 它的输出形状是(时间步数*批量大小,词表大小)。
        output = self.linear(Y.reshape((-1, Y.shape[-1])))
        return output, state

    def begin_state(self, device, batch_size=1):
        if not isinstance(self.rnn, nn.LSTM):
            # nn.GRU以张量作为隐状态
            return  torch.zeros((self.num_directions * self.rnn.num_layers,
                                 batch_size, self.num_hiddens),device=device)
        else:
            # nn.LSTM以元组作为隐状态
            return (torch.zeros((
                self.num_directions * self.rnn.num_layers,
                batch_size, self.num_hiddens), device=device),
                    torch.zeros((self.num_directions * self.rnn.num_layers,
                        batch_size, self.num_hiddens), device=device))

# 训练与预测
device = d2l.try_gpu()
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)
num_epochs, lr = 500, 1
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)
plt.show()

【动手学深度学习】李沐——循环神经网络_第15张图片

perplexity 1.3, 390784.5 tokens/sec on cuda:0
time travellerit s against reatou dimensions of space generally 
traveller pus he iryed it apredinnen it a mamul redoun abs 

小结

  • 深度学习框架的高级API提供了RNN层的实现
  • 高级API的RNN层返回一个输出和一个更新后的隐状态,我们还需要另外一个线型层来计算整个模型的输出
  • 相比从零开始实现的RNN,使用高级API实现可以加速训练

通过时间反向传播

在RNN中,前向传播的计算相对简单,但是其通过时间反向传播实际上要求我们将RNN每次对一个时间步进行展开,以获得模型变量和参数之间的依赖关系,然后基于链式法则去应用放反向传播计算和存储梯度,这就导致当时间长度T较大时,可能依赖关系会相当长

假设RNN可表示为:
h t = f ( x t , h t − 1 , w h ) o t = g ( h t , w o ) 损失函数为: L ( x 1 , . . , x T , y 1 , . . . , y T , w h , w o ) = 1 T ∑ t = 1 T l ( y t , o t ) h_t=f(x_t, h_{t-1},w_h)\\ o_t=g(h_t,w_o)\\ 损失函数为:L(x_1,..,x_T,y_1,...,y_T,w_h,w_o)=\frac{1}{T}\sum_{t=1}^Tl(y_t,o_t) ht=f(xt,ht1,wh)ot=g(ht,wo)损失函数为:L(x1,..,xT,y1,...,yT,wh,wo)=T1t=1Tl(yt,ot)
那么在计算梯度时:
∂ L ∂ w h = 1 T ∑ t = 1 T ∂ l ( y t , o t ) ∂ w h = 1 T ∑ t = 1 T ∂ l ( y t , o t ) ∂ o t ∂ g ( h t , w o ) ∂ h t ∂ h t ∂ w h \frac{\partial L}{\partial w_h}=\frac{1}{T}\sum_{t=1}^T \frac{\partial l(y_t,o_t)}{\partial w_h}\\ =\frac{1}{T}\sum_{t=1}^T\frac{\partial l(y_t,o_t)}{\partial o_t}\frac{\partial g(h_t,w_o)}{\partial h_t}\frac{\partial h_t}{\partial w_h} whL=T1t=1Twhl(yt,ot)=T1t=1Totl(yt,ot)htg(ht,wo)whht
上述计算中最麻烦的是第三个,因为 h t h_t ht不仅依赖于 w h w_h wh,还依赖于 h t − 1 h_{t-1} ht1,而 h t − 1 h_{t-1} ht1也依赖于 w h w_h wh,这样就会不停计算下去,即:
∂ h t ∂ w h = ∂ f ( x t , h t − 1 , w h ) ∂ w h + ∑ i = 1 t − 1 ( ∏ j = i + 1 t ∂ f ( x j , h j − 1 , w h ) ∂ h j − 1 ) ∂ f ( x i , h i − 1 , w h ) ∂ w h \frac{\partial h_t}{\partial w_h}=\frac{\partial f(x_t,h_{t-1},w_h)}{\partial w_h}+\sum_{i=1}^{t-1}(\prod_{j=i+1}^t \frac{\partial f(x_j,h_{j-1},w_h)}{\partial h_{j-1}})\frac{\partial f(x_i,h_{i-1},w_h)}{\partial w_h} whht=whf(xt,ht1,wh)+i=1t1(j=i+1thj1f(xj,hj1,wh))whf(xi,hi1,wh)
那么如果采用上述完成的链式计算,当t很大时这个链就会变得很长,难以计算。具体有以下几种办法:


完全计算

显然最简单的思想当然是直接计算,但是这样非常缓慢,并且很可能会发生梯度爆炸,因为初始条件的额微小变化就可能因为连乘而带给结果巨大的影响,就类似于蝴蝶效应,这是不可取的。


截断时间步

可以在 τ \tau τ步后截断上述的求和运算,即将链式法则终止于 ∂ h t − τ ∂ w h \frac{\partial h_{t-\tau}}{\partial w_h} whhtτ,这样通常被称为截断的通过时间反向传播。这么做会导致模型主要侧重于短期影响,而不是长期影响,它会将估计值偏向更简单和更稳定的模型


随机截断

引入一个随机变量来代替 ∂ h t ∂ w h \frac{\partial h_t}{\partial w_h} whht,即定义 P ( ξ t = 0 ) = 1 − π t , P ( ξ t = π t − 1 ) = π t P(\xi_t=0)=1-\pi_t,P(\xi_t=\pi_t^{-1})=\pi_t P(ξt=0)=1πt,P(ξt=πt1)=πt,那么 E [ ξ t ] = 1 E[\xi_t]=1 E[ξt]=1,令:
z t = ∂ f ( x t , h t − 1 , w h ) ∂ w h + ξ t ∂ f ( x t , h t − 1 , w h ) ∂ h t − 1 ∂ h t − 1 ∂ w h z_t=\frac{\partial f(x_t,h_{t-1},w_h)}{\partial w_h}+\xi_t \frac{\partial f(x_t,h_{t-1},w_h)}{\partial h_{t-1}}\frac{\partial h_{t-1}}{\partial w_h} zt=whf(xt,ht1,wh)+ξtht1f(xt,ht1,wh)whht1
那么可以推导出 E [ z t ] = ∂ h t ∂ w h E[z_t]=\frac{\partial h_t}{\partial w_h} E[zt]=whht,这就导致了不同长度的截断,

门控循环单元GRU

这个机制是引入了重置门和更新门来更好地控制时序信息的传递,具体如下:

【动手学深度学习】李沐——循环神经网络_第16张图片

可以看到其中 R t 、 Z t R_t、Z_t RtZt分别称为重置门和更新门,那么 Z t = 1 Z_t=1 Zt=1时, H t = H t − 1 H_t=H_{t-1} Ht=Ht1,相当于信息完全不更新直接传递过去;而当 Z t = 0 , R t = 0 Z_t=0,R_t=0 Zt=0,Rt=0时,相当于此时不关注 H t − 1 H_{t-1} Ht1的信息,截断时序的传递,那就相当于初始化了

注意这里 R t 和 H t − 1 R_t和H_{t-1} RtHt1之间的计算是按照元素相乘。

import torch
from matplotlib import pyplot as plt
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)


# 初始化模型参数,这部分和RNN不同
def get_params(vocab_size, num_hiddens, device):
    num_inputs = num_outputs = vocab_size  # 输入输出都是这个长度的向量

    def normal(shape):
        return torch.randn(size=shape, device=device) * 0.01

    def three():  # 用这个函数可以减少重复写
        return (normal((num_inputs, num_hiddens)),
                normal((num_hiddens, num_hiddens)),
                torch.zeros(num_hiddens, device=device))

    W_xz, W_hz, b_z = three()  # 更新门参数
    W_xr, W_hr, b_r = three()  # 重置门参数
    W_xh, W_hh, b_h = three()  # 候选隐状态参数
    # 输出层参数
    W_hq = normal((num_hiddens, num_outputs))
    b_q = torch.zeros(num_outputs, device=device)
    # 附加梯度
    params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q]
    for param in params:
        param.requires_grad_(True)
    return params


# 初始化隐状态
def init_gru_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device),)


# 定义模型
def gru(inputs, state, params):
    W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params  # 获取参数
    H, = state  # 隐状态
    outputs = []
    for X in inputs:
        Z = torch.sigmoid((X @ W_xz) + (H @ W_hz) + b_z)  # 计算更新门,@是矩阵乘法
        R = torch.sigmoid((X @ W_xr) + (H @ W_hr) + b_r)
        H_tilda = torch.tanh((X @ W_xh) + ((R * H) @ W_hh) + b_h)  # 注意这里R*H是按元素
        H = Z * H + (1 - Z) * H_tilda  # 这里也是按元素
        Y = H @ W_hq + b_q  # 输出
        outputs.append(Y)
    return torch.cat(outputs, dim=0), (H,)  # 同样是叠在一起


vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_params,
                            init_gru_state, gru)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
plt.show()

【动手学深度学习】李沐——循环神经网络_第17张图片

perplexity 1.1, 16015.2 tokens/sec on cuda:0
time traveller for so it will be convenient to speak of himwas e
traveller for so it will be convenient to speak of himwas e

那么GRU的简洁实现也很简单:

num_inputs = vocab_size
gru_layer = nn.GRU(num_inputs, num_hiddens)
model = d2l.RNNModel(gru_layer, len(vocab))  # 封装成model的同时会加上线型层
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
plt.show()

【动手学深度学习】李沐——循环神经网络_第18张图片

perplexity 1.0, 256679.5 tokens/sec on cuda:0
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby

可以看到我们调用高级API比从零实现快很多。


小结

  • 门控循环神经网络可以更好地捕获时间步距离很长的序列上的依赖关系
  • 重置门有助于捕获系列中的短期依赖关系
  • 更新门有助于捕获序列中的长期依赖关系
  • 重置门打开时,门控循环单元包含基本循环神经网络;更新门被打开时,门控循环单元可以跳过子序列

长短期记忆网络(LSTM)

这一部分老师讲得比较简单,更关注于实现方面,那么关于LSTM的比较全面的介绍内容可以观看李宏毅老师的课程中相关章节,或者阅读我这篇博客[此处]([机器学习]李宏毅——Recurrent Neural Network(循环神经网络)_FavoriteStar的博客-CSDN博客)。

LSTM的结构具体如下:

【动手学深度学习】李沐——循环神经网络_第19张图片

它最主要的特点就是引入了三个门控以及另外一个状态 C t C_t Ct来更好地存储和控制信息,三个门控分别为:

  • 输入门:决定是否忽略输入数据
  • 忘记门:将数值朝零减少
  • 输出门:决定是否使用隐状态

I t = σ ( X t W x i + H t − 1 W h i + b i ) F t = σ ( X t W x f + H t − 1 W h f + b f ) O t = σ ( X t W x o + H t − 1 W h o + b o ) C ~ t = tanh ⁡ ( X t W x c + H t − 1 W h c + b c ) C t = F t ⊙ C t − 1 + I t ⊙ C ~ t H t = O t ⊙ tanh ⁡ ( C t ) I_t=\sigma(X_tW_{xi}+H_{t-1}W_{hi}+b_i)\\ F_t=\sigma(X_tW_{xf}+H_{t-1}W_{hf}+b_f)\\ O_t=\sigma(X_tW_{xo}+H_{t-1}W_{ho}+b_o)\\ \tilde{C}_t=\tanh(X_tW_{xc}+H_{t-1}W_{hc}+b_c)\\ C_t=F_t\odot C_{t-1}+I_t\odot \tilde{C}_t\\ H_t=O_t\odot \tanh(C_t) It=σ(XtWxi+Ht1Whi+bi)Ft=σ(XtWxf+Ht1Whf+bf)Ot=σ(XtWxo+Ht1Who+bo)C~t=tanh(XtWxc+Ht1Whc+bc)Ct=FtCt1+ItC~tHt=Ottanh(Ct)

具体讲解可以看我上述提到的那篇博客,讲得比较仔细。

import torch
from matplotlib import pyplot as plt
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

# 初始化模型参数
def get_lstm_params(vocab_size, num_hiddens, device):
    num_inputs = num_outputs = vocab_size

    def normal(shape):
        return torch.randn(size=shape, device=device)*0.01

    def three():
        return (normal((num_inputs, num_hiddens)),
                normal((num_hiddens, num_hiddens)),
                torch.zeros(num_hiddens, device=device))

    W_xi, W_hi, b_i = three()  # 输入门参数
    W_xf, W_hf, b_f = three()  # 遗忘门参数
    W_xo, W_ho, b_o = three()  # 输出门参数
    W_xc, W_hc, b_c = three()  # 候选记忆元参数
    # 输出层参数
    W_hq = normal((num_hiddens, num_outputs))
    b_q = torch.zeros(num_outputs, device=device)
    # 附加梯度
    params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
              b_c, W_hq, b_q]
    for param in params:
        param.requires_grad_(True)
    return params

# 初始化隐状态,这部分就是两个了
def init_lstm_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device),
            torch.zeros((batch_size, num_hiddens), device=device))

# 定义模型
def lstm(inputs, state, params):
    [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
     W_hq, b_q] = params  # 获取参数
    (H, C) = state  # 获取隐状态
    outputs = []
    for X in inputs:
        I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
        F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
        O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
        C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
        C = F * C + I * C_tilda
        H = O * torch.tanh(C)
        Y = (H @ W_hq) + b_q  # 计算输出
        outputs.append(Y)
    return torch.cat(outputs, dim=0), (H, C)

vocab_size, num_hiddens, device = len(vocab), 512, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params,
                            init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
plt.show()

【动手学深度学习】李沐——循环神经网络_第20张图片

perplexity 1.1, 13369.1 tokens/sec on cuda:0
time traveller well the wild the urais diff me time srivelly are
travelleryou can show black is white by argument said filby

下面是简洁实现:

num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))  # 同样会补上输出的线型层实现
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
plt.show()

【动手学深度学习】李沐——循环神经网络_第21张图片

perplexity 1.0, 147043.6 tokens/sec on cuda:0
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

调用高级API的速度是从零实现是十倍往上。


小结

  • LSTM有三种类型的门:输入门、遗忘门和输出门
  • LSTM隐藏层输出包括隐状态和记忆元,只有隐状态会传递到输出层,而记忆元完全属于内部信息
  • LSTM可以缓解梯度消失和梯度爆炸的问题,因此多次使用到tanh将输出映射到[-1,1]之间,具体可以看我那篇博客最后。

深度循环神经网络

为了能够获得更多的非线性以及更强的表示能力,我们可以在深度上拓展循环神经网络:

【动手学深度学习】李沐——循环神经网络_第22张图片

这部分还是很简单很好理解的,对于GRU和LSTM同样可以采用。

import torch
from matplotlib import pyplot as plt
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

vocab_size, num_hiddens, num_layers = len(vocab), 256, 2  # 指定隐藏层的层数
num_inputs = vocab_size
device = d2l.try_gpu()
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers)  # 第三个参数指定隐藏层数目
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)

num_epochs, lr = 500, 2
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
plt.show()

【动手学深度学习】李沐——循环神经网络_第23张图片

perplexity 1.0, 128068.2 tokens/sec on cuda:0
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

小结

  • 在深度循环神经网络中,隐状态的信息被传递到当前层的下一时间步和下一层的当前时间步
  • 有许多不同风格的深度循环神经网络,如LSTM、GRU、RNN等,这些模型都可以用深度学习框架的高级API实现
  • 总体而言,深度循环神经网络需要大量的调参(如学习率和修剪) 来确保合适的收敛,模型的初始化也需要谨慎。

双向循环神经网络

之前的模型都是观察历史的数据来预测未来的数据,但是如果是在一些填空之类的任务中,未来的信息对这个空也是至关重要的:

【动手学深度学习】李沐——循环神经网络_第24张图片

因此双向循环神经网络就是可以观察未来的信息,它拥有一个前向RNN隐层和一个反向RNN隐层,然后输出层的输入是这两个层隐状态的合并,如下:

【动手学深度学习】李沐——循环神经网络_第25张图片

虽然这在训练的时候是没问题的,但是这种模型不能用于做预测任务,因此它无法得知未来的信息,这会造成很糟糕的结果。它最主要的用处是用来对序列进行特征抽取,因为它能够观察到未来的信息,因此特征抽取会更加全面

import torch
from matplotlib import pyplot as plt
from torch import nn
from d2l import torch as d2l

# 加载数据
batch_size, num_steps, device = 32, 35, d2l.try_gpu()
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
# 通过设置“bidirective=True”来定义双向LSTM模型
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers, bidirectional=True)
model = d2l.RNNModel(lstm_layer, len(vocab))  # 里面已经设置了当为双向时线性层会不同
model = model.to(device)
# 训练模型
num_epochs, lr = 500, 1
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
plt.show()

【动手学深度学习】李沐——循环神经网络_第26张图片

perplexity 1.1, 76187.7 tokens/sec on cuda:0
time travellerererererererererererererererererererererererererer
travellerererererererererererererererererererererererererer

可以看到预测的效果是极差的。


小结

  • 在双向循环神经网络中,每个时间步的隐状态由当前时间步的前后数据同时决定。
  • 双向循环神经网络与概率图模型中的“前向-后向”算法具有相似性。
  • 双向循环神经网络主要用于序列编码和给定双向上下文的观测估计。
  • 由于梯度链更长,因此双向循环神经网络的训练代价非常高。

机器翻译与数据集

import os
import torch
from d2l import torch as d2l

#@save
from matplotlib import pyplot as plt

d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip',
                           '94646ad1522d915e7b0f9296181140edcf86a4f5')

#@save
def read_data_nmt():
    """载入“英语-法语”数据集"""
    data_dir = d2l.download_extract('fra-eng')
    with open(os.path.join(data_dir, 'fra.txt'), 'r',encoding='utf-8') as f:
        return f.read()

raw_text = read_data_nmt()
print(raw_text[:75])

#@save
def preprocess_nmt(text):
    """预处理“英语-法语”数据集"""
    def no_space(char, prev_char):
        return char in set(',.!?') and prev_char != ' '

    # 使用空格替换不间断空格
    # 使用小写字母替换大写字母
    text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()  # 将utf-8中半角全角空格都换成空格
    # 在单词和标点符号之间插入空格
    out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
           for i, char in enumerate(text)]
    return ''.join(out)

text = preprocess_nmt(raw_text)
print(text[:80])


#@save
def tokenize_nmt(text, num_examples=None):
    """词元化“英语-法语”数据数据集"""
    source, target = [], []
    for i, line in enumerate(text.split('\n')):
        if num_examples and i > num_examples:
            break
        parts = line.split('\t')  # 按照制表符将英文和法文分开
        if len(parts) == 2:  # 说明前面是英文,后面是法文
            source.append(parts[0].split(' '))  # 按照我们前面插入的空格来划分
            target.append(parts[1].split(' '))
    return source, target

source, target = tokenize_nmt(text)
print(source[:6], target[:6])

def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist):
    """绘制列表长度对的直方图"""
    d2l.set_figsize()
    _, _, patches = d2l.plt.hist(
        [[len(l) for l in xlist], [len(l) for l in ylist]])
    d2l.plt.xlabel(xlabel)
    d2l.plt.ylabel(ylabel)
    for patch in patches[1].patches:
        patch.set_hatch('/')
    d2l.plt.legend(legend)

show_list_len_pair_hist(['source', 'target'], '# tokens per sequence',
                        'count', source, target)
plt.show()

src_vocab = d2l.Vocab(source, min_freq=2,reserved_tokens=['', '', ''])
# 转换成词表,然后加入一些特殊的词,分别是填充、开始、结尾
print(len(src_vocab))

#@save
def truncate_pad(line, num_steps, padding_token):  # 这是为了保证我们的输入都是等长的
    """截断或填充文本序列"""
    if len(line) > num_steps:  # 如果这个句子的长度大于设定长度,我们就截断
        return line[:num_steps]  # 截断
    return line + [padding_token] * (num_steps - len(line))  # 如果小于就进行填充

print(truncate_pad(src_vocab[source[0]], 10, src_vocab['']))

#@save
def build_array_nmt(lines, vocab, num_steps):
    """将机器翻译的文本序列转换成小批量"""
    lines = [vocab[l] for l in lines]  # 将文本转换为向量
    lines = [l + [vocab['']] for l in lines]  # 每一个都要加上结尾符
    array = torch.tensor([truncate_pad(l, num_steps, vocab['']) for l in lines])
    # 进行填充或截断
    valid_len = (array != vocab['']).type(torch.int32).sum(1)
    # 这是把每个句子除填充外的有效长度都标注出来,之后计算会用到
    return array, valid_len

#@save
def load_data_nmt(batch_size, num_steps, num_examples=600):
    """返回翻译数据集的迭代器和词表"""
    text = preprocess_nmt(read_data_nmt())  # 预处理
    source, target = tokenize_nmt(text, num_examples)  # 生成英文和法文两部分
    # 转成词典
    src_vocab = d2l.Vocab(source, min_freq=2,reserved_tokens=['', '', ''])
    tgt_vocab = d2l.Vocab(target, min_freq=2,reserved_tokens=['', '', ''])
    src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
    tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
    data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
    data_iter = d2l.load_array(data_arrays, batch_size)  # 一次迭代含有4个变量
    return data_iter, src_vocab, tgt_vocab

train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
    print('X:', X.type(torch.int32))
    print('X的有效长度:', X_valid_len)
    print('Y:', Y.type(torch.int32))
    print('Y的有效长度:', Y_valid_len)
    break

【动手学深度学习】李沐——循环神经网络_第27张图片

Go.	Va !
Hi.	Salut !
Run!	Cours !
Run!	Courez !
Who?	Qui ?
Wow!	Ça alors !

go .	va !
hi .	salut !
run !	cours !
run !	courez !
who ?	qui ?
wow !	ça alors !
[['go', '.'], ['hi', '.'], ['run', '!'], ['run', '!'], ['who', '?'], ['wow', '!']] [['va', '!'], ['salut', '!'], ['cours', '!'], ['courez', '!'], ['qui', '?'], ['ça', 'alors', '!']]
10012
[47, 4, 1, 1, 1, 1, 1, 1, 1, 1]
X: tensor([[  7,   0,   4,   3,   1,   1,   1,   1],
        [118,  55,   4,   3,   1,   1,   1,   1]], dtype=torch.int32)
X的有效长度: tensor([4, 4])
Y: tensor([[6, 7, 0, 4, 3, 1, 1, 1],
        [0, 4, 3, 1, 1, 1, 1, 1]], dtype=torch.int32)
Y的有效长度: tensor([5, 3])

小结

  • 机器翻译指的是将文本序列从一种语言自动翻译成另一种语言。
  • 使用单词级词元化时的词表大小,将明显大于使用字符级词元化时的词表大小。为了缓解这一问题,我们可以将低频词元视为相同的未知词元。
  • 通过截断和填充文本序列,可以保证所有的文本序列都具有相同的长度,以便以小批量的方式加载。

编码器-解码器架构

这是一个很重要的模型。因为机器翻译是序列转换模型中的一个核心问题,其输入和输出都是长度可变的序列。那么为了处理这种类型的结构,我们便使用到了编码器-解码器架构。

首先是编码器,它接受一个长度可变的序列作为输入,然后将其转换为具有固定形状的编码状态;然后是解码器,它将固定形状的编码状态映射到长度可变的序列,如下:

【动手学深度学习】李沐——循环神经网络_第28张图片

对于AE自编码器的介绍可以看我这篇博客,讲得比较仔细,有助于理解这种结构。

from torch import nn


#@save
class Encoder(nn.Module):
    """编码器-解码器架构的基本编码器接口"""
    def __init__(self, **kwargs):
        super(Encoder, self).__init__(**kwargs)

    def forward(self, X, *args):
        raise NotImplementedError

#@save
class Decoder(nn.Module):
    """编码器-解码器架构的基本解码器接口"""
    def __init__(self, **kwargs):
        super(Decoder, self).__init__(**kwargs)

    def init_state(self, enc_outputs, *args):  # 这部分就是编码后的状态
        raise NotImplementedError

    def forward(self, X, state):
        raise NotImplementedError

#@save
class EncoderDecoder(nn.Module):
    """编码器-解码器架构的基类"""
    def __init__(self, encoder, decoder, **kwargs):
        super(EncoderDecoder, self).__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, enc_X, dec_X, *args):
        enc_outputs = self.encoder(enc_X, *args)  # 计算编码后的值
        dec_state = self.decoder.init_state(enc_outputs, *args)
        return self.decoder(dec_X, dec_state)  # 解码

小结

  • 编码器-解码器架构可以将长度可变的序列作为输入和输出,因此适用于机器翻译等序列转换问题。
  • 编码器将长度可变的序列作为输入,并将其转换为具有固定形状的编码状态。
  • 解码器将具有固定形状的编码状态映射为长度可变的序列。

序列到序列学习(Seq2Seq)

这种任务就是给定一个序列,我们希望将其变换为另一个序列,最典型的应用就是机器翻译,它给定一个源语言的句子并将其翻译为目标语言。那么这就要求给定句子的长度是可变的,而且翻译后的句子可以有不同的长度。

那么这个任务最开始用的是编码器-解码器架构来做的:
【动手学深度学习】李沐——循环神经网络_第29张图片

且编码器和解码器用的都是RNN的模型。

编码器中RNN使用长度可变的序列作为输入,将其转换为固定形状的隐状态,此时输入序列的信息都被编码到隐状态中;然后将该编码器的最后一个隐状态作为解码器的初始隐状态,解码器的RNN根据该初始隐状态和自己的输入,开始进行预测

那么这种架构在训练和预测的时候有所不同,在训练时解码器的输入一直都是正确的预测结果,而在预测的时候解码器的输入就是本身预测的上一个结果,不一定正确

【动手学深度学习】李沐——循环神经网络_第30张图片

而因为现在我们不仅仅是预测字母,我们是预测整个句子,因此需要一个新的衡量指标来量化预测句子的好坏。常用的是BLEU,其具体如下:

p n p_n pn是预测中所有n-gram的精度,例如真实序列ABCDEF和预测序列ABBCD,那么 p 1 p_1 p1就是预测序列中单个单元(A,B,B,C,D)在真实序列中是否出现,可以看到总共有4个出现了(B只出现1次)因此 p 1 = 4 5 p_1=\frac{4}{5} p1=54,同理 p 2 = 3 4 p_2=\frac{3}{4} p2=43 p 3 = 1 3 p_3=\frac{1}{3} p3=31 p 4 = 0 p_4=0 p4=0

而BLEU的定义如下:
exp ⁡ ( min ⁡ ( 0 ,    1 − l e n l a b e l l e n p r e d ) ) ∏ n = 1 k p n 1 2 n \exp \bigg( \min \Big( 0,~~1-\frac{len_{label}}{len_{pred}} \Big) \bigg)\prod_{n=1}^k p_n^{\frac{1}{2^n}} exp(min(0,  1lenpredlenlabel))n=1kpn2n1
其中指数项是为了惩罚过短的预测,因此如果我只预测单个单元,那么只要其出现了我所有的 p n p_n pn(也就是 p 1 p_1 p1)就是1了,但这是不行的。第二项因为p都是小于1的,因此较长的匹配其指数( 1 2 n \frac{1}{2^n} 2n1)会较小,因此可以认为其具有更大的权重


import collections
import math
import torch
from matplotlib import pyplot as plt
from torch import nn
from d2l import torch as d2l


# @save
class Seq2SeqEncoder(d2l.Encoder):
    """用于序列到序列学习的循环神经网络编码器"""

    def __init__(self, vocab_size, embed_size, num_hiddens,
                 num_layers, dropout=0, **kwargs):
        super(Seq2SeqEncoder, self).__init__(**kwargs)
        # 嵌入层
        self.embedding = nn.Embedding(vocab_size, embed_size)
        # 词嵌入,将文字自动转换成词向量
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=dropout)
        # 因为已经转换为词向量,因此输入为词向量的长度

    def forward(self, X, *args):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X)  # 先转换为词向量
        # 在循环神经网络模型中,第一个轴对应于时间步
        X = X.permute(1, 0, 2)  # 转换为时间步*批量大小*长度
        # 如果未提及状态,则默认为0
        output, state = self.rnn(X)
        # output的形状:(num_steps,batch_size,num_hiddens),
        # 因为有多层,它可以认为是最后一层的所有时间步的隐状态输出
        # state的形状:(num_layers,batch_size,num_hiddens)
        # 它是所有层的最后一个时间步的隐状态输出
        return output, state


class Seq2SeqDecoder(d2l.Decoder):
    """用于序列到序列学习的循环神经网络解码器"""

    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 dropout=0, **kwargs):
        super(Seq2SeqDecoder, self).__init__(**kwargs)
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
                          dropout=dropout)
        # 因为下面做了拼接处理,因此这里输入的维度为embed_size+num_hiddens
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1]  # 里面有output,state,【1】就是把state拿出来

    def forward(self, X, state):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X).permute(1, 0, 2)
        # 广播context,使其具有与X相同的num_steps  state[-1]是前面最后一层的最后一个隐状态
        context = state[-1].repeat(X.shape[0], 1, 1)
        # context的维度为num_steps,1,num_hiddens
        X_and_context = torch.cat((X, context), 2)
        # 将它们拼在一起,即输入embed_size+num_hiddens
        # 这里可以认为是:我觉得单纯的隐状态的传递不够,我再将最后一层的最后一个隐状态
        # 和我的第一个输入拼在一起,我觉得它浓缩了很多信息,也一起来作为输入
        output, state = self.rnn(X_and_context, state)
        output = self.dense(output).permute(1, 0, 2)
        # output的形状:(batch_size,num_steps,vocab_size)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state


# @save
def sequence_mask(X, valid_len, value=0):  # 该函数生成mask并进行遮挡
    """在序列中屏蔽不相关的项"""
    maxlen = X.size(1)  # 取出X中的第一维度的数量
    mask = torch.arange((maxlen), dtype=torch.float32,
                        device=X.device)[None, :] < valid_len[:, None]
    # arange生成一个一维的tensor,[None,:]是将其变成二维的,1*maxlen的tensor
    # 而valid_len是长度为max_len的向量,[:,None]就变成了max_len*1的tensor
    # 然后小于就会触发广播机制,例如max_len=4,那么arange生成的[[1,2,3,4]]就会广播
    # 变成4行,每一行都是[1,2,3,4],那么将每一列和valid_len这个列比较
    # 因为valid_len中的元素就是多少个有效的,那么假设2,就是前两个为true,后两个为false
    # 这样就可以将其提取出来了
    X[~mask] = value
    return X


# @save
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
    """带遮蔽的softmax交叉熵损失函数"""

    # pred的形状:(batch_size,num_steps,vocab_size)
    # label的形状:(batch_size,num_steps)
    # valid_len的形状:(batch_size,)
    def forward(self, pred, label, valid_len):
        weights = torch.ones_like(label)
        weights = sequence_mask(weights, valid_len)
        self.reduction = 'none'  # 不对求出来的损失求和、平均等操作
        unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
            pred.permute(0, 2, 1), label)  # 这里维度转换为torch本身的要求
        weighted_loss = (unweighted_loss * weights).mean(dim=1)  # 按元素相乘
        return weighted_loss


# @save
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
    """训练序列到序列模型"""

    def xavier_init_weights(m):
        if type(m) == nn.Linear:
            nn.init.xavier_uniform_(m.weight)
        if type(m) == nn.GRU:
            for param in m._flat_weights_names:
                if "weight" in param:
                    nn.init.xavier_uniform_(m._parameters[param])

    net.apply(xavier_init_weights)
    net.to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    loss = MaskedSoftmaxCELoss()
    net.train()  # 开启训练模式
    animator = d2l.Animator(xlabel='epoch', ylabel='loss', xlim=[10, num_epochs])
    for epoch in range(num_epochs):
        timer = d2l.Timer()
        metric = d2l.Accumulator(2)  # 训练损失总和,词元数量
        for batch in data_iter:
            optimizer.zero_grad()  # 清空梯度
            X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
            # 英文、英文有效长度、法文、法文有效长度
            bos = torch.tensor([tgt_vocab['']] * Y.shape[0], device=device).reshape(-1, 1)  # 转换为相同纬度
            # Y是法文,在训练时是作为解码器的输入的,然后我们需要一个开始标注
            # 因此我们将Y的最后一个单词去掉,再在第一个前面加上一个开始标志bos
            # 这样我们强制让它学习bos去预测第一个词,而最后一个词它不会用来做预测,因此在预测时它去掉没关系
            # 那之后在真正预测的时候,我们就只需要给解码器第一个为bos,后面它自己生成的拿来做输入就可以
            dec_input = torch.cat([bos, Y[:, :-1]], 1)  # 强制教学
            Y_hat, _ = net(X, dec_input, X_valid_len)  # 这个模型的第一个参数的编码器输入,第二个是解码器输入
            # 第三个是编码器输入的有效长度
            l = loss(Y_hat, Y, Y_valid_len)  # 这里就是用原来的Y去和预测的做损失
            l.sum().backward()  # 损失函数的标量进行“反向传播”
            d2l.grad_clipping(net, 1)  # 梯度裁剪
            num_tokens = Y_valid_len.sum()
            optimizer.step()
            with torch.no_grad():
                metric.add(l.sum(), num_tokens)
        if (epoch + 1) % 10 == 0:
            animator.add(epoch + 1, (metric[0] / metric[1],))
    print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
          f'tokens/sec on {str(device)}')


embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10  # 10是句子最长为10,超过裁剪,不足就补充
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
net = d2l.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)
plt.show()


# @save
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps, device,
                    save_attention_weights=False):
    """序列到序列模型的预测"""
    # 在预测时将net设置为评估模式
    net.eval()
    # 将输入的句子变成小写再按空格分隔再加上结尾符,并且都经过src_vocab这个类转换成向量了
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [src_vocab['']]
    # 该句子的有效长度
    enc_valid_len = torch.tensor([len(src_tokens)], device=device)
    # 对该句子检查长度进行填充或者裁剪
    src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab[''])
    # 添加批量轴,将src_tokens添加上批量这个维度,因此变成批量*时间步*vocabsize
    enc_X = torch.unsqueeze(torch.tensor(src_tokens, dtype=torch.long, device=device),
                            dim=0)
    # 计算encoder的输出
    enc_outputs = net.encoder(enc_X, enc_valid_len)
    # 计算decoder应该接受的初始状态
    dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
    # 添加批量轴,因为现在是预测因此decoer的输入只有一个,那么为它添加一个批量轴
    dec_X = torch.unsqueeze(torch.tensor(
        [tgt_vocab['']], dtype=torch.long, device=device), dim=0)  # 增加一个维度
    output_seq, attention_weight_seq = [], []
    for _ in range(num_steps):
        Y, dec_state = net.decoder(dec_X, dec_state)  # 输出和隐状态
        # 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
        dec_X = Y.argmax(dim=2)  # 更新输入
        pred = dec_X.squeeze(dim=0).type(torch.int32).item()  # 去除批量这个维度
        # 保存注意力权重(稍后讨论)
        if save_attention_weights:
            attention_weight_seq.append(net.decoder.attention_weights)
        # 一旦序列结束词元被预测,输出序列的生成就完成了
        if pred == tgt_vocab['']:
            break
        output_seq.append(pred)
    return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq


def bleu(pred_seq, label_seq, k):  #@save
    """计算BLEU"""
    pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
    len_pred, len_label = len(pred_tokens), len(label_tokens)
    score = math.exp(min(0, 1 - len_label / len_pred))
    for n in range(1, k + 1):
        num_matches, label_subs = 0, collections.defaultdict(int)
        for i in range(len_label - n + 1):
            # 这个循环是将真实序列中的各种长度的连续词汇都变成词典计数
            label_subs[' '.join(label_tokens[i: i + n])] += 1
        for i in range(len_pred - n + 1):
            if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
                num_matches += 1  # 这里判断出来预测序列中有对应的n-gram
                label_subs[' '.join(pred_tokens[i: i + n])] -= 1  # 要减一
        score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
    return score

engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
    translation, attention_weight_seq = predict_seq2seq(
        net, eng, src_vocab, tgt_vocab, num_steps, device)
    print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')

【动手学深度学习】李沐——循环神经网络_第31张图片

loss 0.019, 12068.0 tokens/sec on cuda:0
go . => va !, bleu 1.000
i lost . => j'ai perdu ., bleu 1.000
he's calm . => il est riche ., bleu 0.658
i'm home . => je suis chez moi chez moi chez moi juste ., bleu 0.537

小结

  • 根据“编码器-解码器”架构的设计, 我们可以使用两个循环神经网络来设计一个序列到序列学习的模型。
  • 在实现编码器和解码器时,我们可以使用多层循环神经网络。
  • 我们可以使用遮蔽来过滤不相关的计算,例如在计算损失时。
  • 在“编码器-解码器”训练中,强制教学方法将原始输出序列(而非预测结果)输入解码器。
  • BLEU是一种常用的评估方法,它通过测量预测序列和标签序列之间的n元语法的匹配度来评估预测。

束搜索

在前面的预测之中,我们采用的策略是贪心策略,也就是每一次预测的时候都是选择当前概率最大的来作为结果。那么贪心策略的最终结果通常不是最优的,然后穷举搜索计算复杂度太大了,因此有另一种方法为束搜索来进行改进。

束搜索有一种关键的参数为束宽 k k k。在时间步1,也就是根据做第一次预测时,我们不止是选取概率最大的那个来进行输出,而是选择具有最高概率的 k k k个词元,例如下图中我们在第一个时间步选择到了A和C。那么在之后的时间步中,就会基于上一个时间步所选择的 k k k个候选序列,来从 k ∣ Y ∣ k\vert Y\vert kY个可能中挑选出具有最高条件概率的 k k k个候选输出序列:

【动手学深度学习】李沐——循环神经网络_第32张图片

并且,我们不止是考虑最终得到的长序列,而是考虑在选择过程中选择到的各个序列,即A,C,AB,CE,ABD,CED这两个序列。对它们的评估我们采用以下公式进行计算:
1 L α log ⁡ P ( y 1 , . . . , y L ) = 1 L α ∑ t ′ = 1 L log ⁡ P ( y t ′ ∣ y 1 , . . . , y t ′ − 1 ) \frac{1}{L^{\alpha}}\log P(y_1,...,y_L)=\frac{1}{L^{\alpha}}\sum_{t^{\prime}=1}^L \log P(y_{t^{\prime}}\mid y_1,...,y_{t^{\prime}-1}) Lα1logP(y1,...,yL)=Lα1t=1LlogP(yty1,...,yt1)
其中L为序列的长度, α \alpha α常取0.75,这部分是为了中和长短序列的差距,因为短序列乘的概率少总是会大一点,因此用这部分来进行中和,相当于给选择短序列加入了一定的惩罚。

束搜索的时间复杂度为 O ( k ∣ Y ∣ T ) O(k\vert Y\vert T) O(kYT)


小结

  • 序列搜索策略包括贪心搜索、穷举搜索和束搜索。
  • 贪心搜索所选取序列的计算量最小,但精度相对较低。
  • 穷举搜索所选取序列的精度最高,但计算量最大。
  • 束搜索通过灵活选择束宽,在正确率和计算代价之间进行权衡。

你可能感兴趣的:(深度学习,算法,深度学习,循环神经网络,lstm,gru)