1. 门控循环单元(GRU)

传统的RNN在处理长序列时会遇到梯度消失或梯度爆炸的问题。为了解决这些问题,引入了门控机制的变种,如长短时记忆网络(LSTM, long short-term memory)和门控循环单元(GRU, gated recurrent unit)。GRU是LSTM的一个简化版本,它通过合并某些门并减少参数数量来提高效率。

1.1 门控隐藏状态

(1)重置门与更新门

通过支持对隐状态的门控,模型可以学习序列中相对重要的词元,跳过不太相关的词元。GRU包括了两个门控单元:

  • 重置门:决定上一时刻的隐藏状态(Ht−1)有多少信息需要被“重置”或忽略,计算候选隐状态;
  • 更新门:决定多少旧状态(Ht-1)应该被保留,多少新状态(即上面的候选隐状态,包括新输入的Xt)应该被添加到当前状态中。
  • 计算上述两个门控单元的方式与RNN中Ht的计算公式基本一致,只是激活函数选择不同。这里使用的Sigmoid,使得转换为0~1范围,方便后续的控制操作。

image-20240808102630079

(2)候选隐状态

  • 可通过重置门,来控制生成候选隐状态。
  • Rt = 1时,就是基本的RNN层(包含Ht-1与Xt);
  • Rt = 0时,上一步的隐状态会被重置/忽略,仅考虑Xt输入。

image-20240808103029769

(3)隐状态

  • 更新门可以控制最终隐状态的输出,用于预测Ot
  • Zt = 0时,会全部使用上述的候选隐状态(包含Xt)
  • Zt = 1时,会丢弃候选隐状态,直接继承前一隐藏状态的Ht-1
    • 若整个子序列的所有时间步的更新门都接近1,则序列起始时间步的隐状态将很容易保留、并传递到序列结束。

image-20240808104015096

综上,

  • 重置门可以控制多大程度获得上一步骤的隐状态,有助于捕获序列中的短期依赖关系。
  • 更新门可以控制多大程度学习当前步骤(Xt)的观测,有助于捕获序列中的长期依赖关系。

1.2 从零开始实现

  • 加载数据
1
2
3
4
5
6
import torch
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)

(1)初始化模型参数

  • 与RNN相比,多了更新门与重置门的模型参数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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

(2)定义模型

  • 隐状态初始化函数,与之前RNN一样
1
2
def init_gru_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device), )
  • 定义GRU模型的传播, @表示按元素相乘的矩阵乘法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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) #候选隐状态
        H = Z * H + (1 - Z) * H_tilda
        Y = H @ W_hq + b_q
        outputs.append(Y)
    return torch.cat(outputs, dim=0), (H,)
  • 训练预测,与RNN代码基本一致
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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)
# perplexity 1.1, 23869.1 tokens/sec on cuda:0
# time traveller for so it will be convenient to speak of himwas e
# traveller afweryhin ing sfor the three dimensions of space

1.3 简洁实现

  • 直接通过torch的nn.GRU()定义门控神经单元
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
num_inputs = vocab_size

gru_layer = nn.GRU(num_inputs, num_hiddens)

model = d2l.RNNModel(gru_layer, len(vocab))
model = model.to(device)

d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
# perplexity 1.0, 132652.9 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

2. 长短期记忆网络(LSTM)

LSTM与GRU较为类似,其被早提出20年,设计更加复杂一点

2.1 门控记忆元

  • 除了隐状态以外,LSTM又提出了记忆元的概念。它与隐状态的形状相同,设计目的是用于记录更多的信息。

(1)输入门、输出门和输出门

  • 共有三个门被提出用于控制记忆元:
    • 输出门用来控制从记忆元输出到隐状态;
    • 输入门用来控制如何从上一步的隐状态以及当前的观测中学习记忆元;
    • 遗忘门用来控制多大程度继承上一步的记忆元。
  • 这三个门的计算方式与之前都类似

image-20240808162220950

(2)候选记忆元

  • 候选记忆元的计算方式与上述也类似,只是激活函数为tanh,因此变换后的范围是(-1, 1)

image-20240808162522852

(3)记忆元

  • 如下图,计算当前时间步的记忆元基于两个门的控制;
  • 输入门It控制多大程度来自于上述计算的候选记忆元;
  • 遗忘门Ft控制多大程度来自于上一时间步的记忆元;

image-20240808163653615

(4)隐状态

  • 基于上述的计算,最终通过输出门Ot决定多大程度将记忆元输出作为隐状态

image-20240808164312153

2.2 从零开始实现

  • 加载数据
1
2
3
4
5
6
import torch
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)

(1)初始化模型参数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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))
    #与GRU相比,在数量上多了一组门参数
    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

(2)定义模型

  • 初始化隐状态以及记忆元,二者的形状相同
1
2
3
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))
  • 参照上述思路,定义LSTM的前向传播方式
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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)

(3)训练和预测

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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_lstm_params,
                            init_lstm_state, lstm)

d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
# perplexity 1.1, 20088.5 tokens/sec on cuda:0
# time traveller proceeded anyreal body must have extension in fou
# traveller and whyon geantating simong and why can thive yor

2.3 简洁实现

  • 基于torch的nn.LSTM(),快速实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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)
# perplexity 1.0, 130362.3 tokens/sec on cuda:0
# time traveller with a slight accession ofcheerfulness really thi
# travelleryou can show black is white by argument said filby

3. 深度RNN

  • 对于之前学习的RNN,以及更加复杂的GRU、LSTM,本质上都可以理解为单隐藏层(Ht)的MLP。
  • 可以搭建具有L个隐藏层的深度循环神经网络, 每个隐状态都连续地传递到当前层的下一个时间步和下一层的当前时间步。

image-20240808194229417

3.1 简洁实现

  • 加载数据
1
2
3
4
5
6
import torch
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)
  • 如下以LSTM模型为例,仅需要在nn.LSTM()模型中的第三个参数中设置层数即可
1
2
3
4
5
6
7
8
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
device = d2l.try_gpu()

# num_layers设置为两层
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)

3.2 训练与预测

1
2
3
4
5
num_epochs, lr = 500, 2
d2l.train_ch8(model, train_iter, vocab, lr*1.0, num_epochs, device)
# perplexity 1.0, 98815.1 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

4. 双向RNN

4.1 定义

  • 有时,序列的下文也对当前步的预测有帮助,例如我__, 请给我点吃的。

  • 双向RNN可以理解为有两个隐藏层的RNN模型,使用序列两端的信息预测输出。

    • 其中两层遍历序列的方向相反,分别计算前向与反向隐状态
    • 最后将两个隐状态结果合并起来,共同用于计算Ot

image-20240808203233504

  • 双向RNN模型的计算速度较慢,主要原因是网络的前向传播需要在双向层中进行前向和后向递归, 并且网络的反向传播还依赖于前向传播的结果。 因此,梯度求解将有一个非常长的链。
  • 此外,双向RNN模型的应用场景有限,不适合用于下文预测;可以用于填充缺失的单词、词元注释,机器翻译等。

4.2 错误应用

  • 如上,双向RNN在预测时需要用到过去和未来的数据,不适合用于预测未来词元,尽管有时模型训练性能较好。
  • 在torch中实现也很简单,仅需要设置相应的参数即可。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import torch
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)
# perplexity 1.1, 64036.4 tokens/sec on cuda:0
# time travellerererererererererererererererererererererererererer
# travellerererererererererererererererererererererererererer

5. 机器翻译与数据集

  • 机器翻译是将一种语言翻译为另一种语言;
  • 可以理解为是将输入序列转换成输出序列的序列转换模型。
  • 接下来的后面几节都将学习如何实现
1
2
3
import os
import torch
from d2l import torch as d2l

5.1 下载和预处理

  • 示例数据是一个’英语—法语’文本数据集。
  • 每一行都是由制表符分割的文本序列对。
  • 文本序列可以是单词,句子,段落(包含标点)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#@save
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[:20])
# Go.	Va !
# Hi.	Salut !
  • 文本预处理操作
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#@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()
    # 在单词和标点符号之间插入空格
    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[:25])
# go .	va !
# hi .	salut !
# ru

5.2 词元化

  • 将单词以及标点符号认为是词元
  • 在每一行中,对制表符前后的文本分别进行词元化,作为source与target
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#@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

#list of list
source, target = tokenize_nmt(text)
source[:3], target[:3]
# ([['go', '.'], ['hi', '.'], ['run', '!']],
#  [['va', '!'], ['salut', '!'], ['cours', '!']])

5.3 词表

  • 分别为源语言(英语)以及目标(法语)语言构建词表;
  • 将词频低于2的词元设置为’<unk>'
  • 此外,额外指定几个特殊词元
    • ‘<pad>’ 填充词元
    • ‘<bos>’ 开始词元
    • ‘<eos>’ 结束词元
1
2
3
4
5
# 如下以源语言为例
src_vocab = d2l.Vocab(source, min_freq=2,
                      reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab) #词元类别数
# 10012

5.4 加载数据集

  • 为了便于训练,需要将输入序列为设置为固定长度。此时会有如下两种情况
    • 截断:仅取前面预期序列长度的词元,丢弃后面的词元
    • 填充:当不满足预期序列长度时,在后面填充补齐
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#@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))  # 填充

# 如下示例,对source的第一个词元序列进行处理,使用填充字符将长度补到10
truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])
# [47, 4, 1, 1, 1, 1, 1, 1, 1, 1]
  • 定义一个函数,对source/target进行上述处理,并在序列结尾加上一个’<eos>‘词元
    • 此外,会统计一下每个序列的有效长度(除去填充词元)
1
2
3
4
5
6
7
8
9
#@save
def build_array_nmt(lines, vocab, num_steps):
    """将机器翻译的文本序列转换成小批量"""
    lines = [vocab[l] for l in lines]
    lines = [l + [vocab['<eos>']] for l in lines]
    array = torch.tensor([truncate_pad(
        l, num_steps, vocab['<pad>']) for l in lines])
    valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)
    return array, valid_len

5.5 最终形式

  • 定义一个综合函数,返回数据迭代器,源语言词表,目标语言词表
    • 数据迭代器每次返回一个批量的输入序列与输出序列
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#@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=['<pad>', '<bos>', '<eos>'])
    tgt_vocab = d2l.Vocab(target, min_freq=2,
                          reserved_tokens=['<pad>', '<bos>', '<eos>'])
    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)
    return data_iter, src_vocab, tgt_vocab
  • 示例
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
# X: tensor([[ 58,  47,   4,   3,   1,   1,   1,   1],
#         [  7, 102,   5,   3,   1,   1,   1,   1]], dtype=torch.int32)
# X的有效长度: tensor([4, 4])
# Y: tensor([[ 18,  14,  34,   4,   3,   1,   1,   1],
#         [  6,   7, 161,   5,   3,   1,   1,   1]], dtype=torch.int32)
# Y的有效长度: tensor([5, 5])

6. 编码器与解码器架构

  • 对于之前学习的CNN以及现在学习的RNN,都可以理解为编码器与解码器架构;
image-20240809072826713
  • 编码器:将输入变换为中间表达形式(特征);
    • 对于RNN,可将长度可变的序列作为输入,转换为具有固定形状的编码状态
  • 解码器:将提取的中间表示编码成输出。
    • 对于RNN,将固定形状的编码状态映射到长度可变的序列

image-20240809073527675

接下来定义一个抽象的编码器-解码器接口,以方便后续的实现

6.1 编码器

  • 在编码器接口中,指定长度可变的序列作为输入X
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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

raise NotImplementedError 是一种编程模式,用于表明某个方法或功能还没有准备好或者需要被子类覆盖以提供实际的行为。

6.2 解码器

  • init_state用于初始化解码器的状态。
    • 主要是将编码器的输出转换为编码后的状态
  • 根据RNN思路,解码器在每个时间步都会将输入X (例如:在前一时间步生成的词元)和编码后的状态 映射成当前时间步的输出词元。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#@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

6.3 合并编码器与解码器

在前向传播中,

  • 编码器的输出用于生成编码状态;
  • 这个状态又被解码器作为其输入的一部分。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#@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)

7. 序列到序列学习(seq2seq)

  • 序列到序列学习模型由两个RNN的编码器与解码器组成
    • 编码器RNN:将输入序列的信息编码为固定形状的隐状态。
    • 解码器RNN:基于编码器输入序列的编码信息以及当前时间步的词元,来预测下一个词元。
image-20240809142553682
1
2
3
4
5
import collections
import math
import torch
from torch import nn
from d2l import torch as d2l

7.1 编码器

  • 在编码器RNN部分,主要目的是得到输入序列最后一个时间步的隐状态表示(可以有多层)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#@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)
        # 这里使用多层的GRU循环神经网络
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers,
                          dropout=dropout)

    def forward(self, X, *args):
        # 输入'X'的形状:(batch_size,num_steps)
        # 输出'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

output是基于每个时间步最后一层的隐状态(一般情况下后面需要跟全连接层进行预测输出);

state是最后一个时间步的多层的隐状态。

  • 示例
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16,
                         num_layers=2)
encoder.eval()
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
output.shape
# torch.Size([7, 4, 16])  #(时间步数,批量大小,隐藏单元数)
state.shape
# torch.Size([2, 4, 16])  #(隐藏层的数量,批量大小,隐藏单元的数量)

# output 批量内第1个子序列的第7个时间步的前5个值
# state 批量内第1个子序列的最后一个时间步的第2层的前5个值
output[6, 0, :5], state[1, 0, :5]
# (tensor([ 0.0533, -0.2092,  0.0406, -0.0956, -0.3704], grad_fn=<SliceBackward0>),
#  tensor([ 0.0533, -0.2092,  0.0406, -0.0956, -0.3704], grad_fn=<SliceBackward0>))

7.2 解码器

在解码器RNN中,

  • 一方面,会继承编码器RNN的最后一个时间步的所有隐状态,作为解码器输入序列的初始化隐状态;
  • 另一方面会将编码器RNN的最后一个时间步的最后一层隐状态,作为解码器输入序列中每个观测词元的一部分特征(参考上图)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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)
        # 观测词元输入=词元本身特征+编码器的信息context
        self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
                          dropout=dropout)
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1] #取state,而非output

    def forward(self, X, state):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X).permute(1, 0, 2)
        # 广播context,使其具有与X相同的num_steps
        context = state[-1].repeat(X.shape[0], 1, 1)
        X_and_context = torch.cat((X, context), 2)
        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

image-20240809160845276

7.3 损失函数

  • 对于解码器的预测输出,一般使用平均交叉熵损失(Softmax)评价与标签序列的差异损失;
  • 在计算损失时,应不需要关注对于序列中的填充词元的预测正确与否
    • 换句话说,仅关注序列中有效词元的预测结果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#@save
def sequence_mask(X, valid_len, value=0):
    """在序列中屏蔽不相关的项"""
    maxlen = X.size(1)
    mask = torch.arange((maxlen), dtype=torch.float32,
                        device=X.device)[None, :] < valid_len[:, None]
    X[~mask] = value
    return X

X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2]))
# tensor([[1, 0, 0],   #第一个词元以外的部分被置换为0
#         [4, 5, 0]])  #前两个词元以外的部分被置换为0
  • 据此自定义一个损失函数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#@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)
        #无效词元的权重设置为0,即忽略
        weights = sequence_mask(weights, valid_len)
        self.reduction='none' #不会自动对输出进行平均或求和
        unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
            pred.permute(0, 2, 1), label) 
        #(batch_size, vocab_size, num_steps) 将类别概率放在第二维
        weighted_loss = (unweighted_loss * weights).mean(dim=1)
        return weighted_loss

7.4 训练

  • 在解码器中,’<bos>‘与原始的输出序列(不包含’<eos>’)连接在一起共同作为输入。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#@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]
            #在之前序列预处理里load_data_nmt中,已将每个序列末尾添加了<eos>词元
            #对于解码器的输入序列,在开头插入一个bos词元。为保证长度不变,需要截掉序列的最后一个词元(<eos>)
            bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],
                          device=device).reshape(-1, 1)
            dec_input = torch.cat([bos, Y[:, :-1]], 1)  # 强制教学
            # X_valid_len本章暂时用不到,Y_valid_len会用到
            Y_hat, _ = net(X, dec_input, X_valid_len) 
            l = loss(Y_hat, Y, Y_valid_len)
            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)}')
  • 实例化模型,并训练
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 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)
# loss 0.019, 10100.9 tokens/sec on cuda:0

7.5 预测

  • 与训练部分不同之处在于, 每个解码器当前时间步的输入都将来自于前一时间步的预测词元。而在训练时的解码器输入都是来自真实的词元。
    • src_sentence 表示用户的输入英语句子
    • src_vocab表示英语的索引词表
    • tgt_vocab表示法语的索引词表
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#@save
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
                    device, save_attention_weights=False):
    """序列到序列模型的预测"""
    # 在预测时将net设置为评估模式
    net.eval()
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
        src_vocab['<eos>']]
    enc_valid_len = torch.tensor([len(src_tokens)], device=device)
    src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>']) #固定长度
    # 添加批量轴(作为第一个维度)
    enc_X = torch.unsqueeze(
        torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
    enc_outputs = net.encoder(enc_X, enc_valid_len)
    dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
    # 添加批量轴
    dec_X = torch.unsqueeze(torch.tensor(
        [tgt_vocab['<bos>']], 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['<eos>']:
            break
        output_seq.append(pred)
    return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq

image-20240809171239840

7.6 预测序列的评估

  • 可使用Bleu值评估预测序列与真实序列的差异;值越大,且接近1表示效果越好
    • 当预测的序列长度小于真实序列长度时,前面的exp系数运算就会小于1,即对Bleu值惩罚;
    • 当n值较大时的n元预测准确率越高时,Pn项就越大。

具体地说,给定标签序列A、B、C、D、E、F 和预测序列A、B、B、C、D, 我们有p1=4/5、p2=3/4、p3=1/3和p4=0。

image-20240809172146342

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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
                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
  • 示例
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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}')
# go . => va <unk> <unk> ., bleu 0.000
# i lost . => j'ai perdu ., bleu 1.000
# he's calm . => il est bon de de essaye ., bleu 0.418
# i'm home . => je suis chez de moi <unk> emporté ., bleu 0.578