文章目录
8.1. 序列模型
8.1.1. 统计工具
注意:这里序列模型估计是用之前的 x t − x x_{t-x} xt−x 来估计当前的 x t x_t xt,而不是根据横轴的时间 t t t 来估计当前的 x t x_t xt,这样就变成线性回归了,所以不要混淆了!
8.1.1.1. 自回归模型
这里没太明白啥意思,不过整体要表达的就是上面说的,要用历史数据来预测当前数据。因此生成训练集的时候,feature->label的组合就是 历史数据->当前数据。
8.1.2. 训练
首先,我们生成一些数据:使用正弦函数和一些可加性噪声来生成序列数据, 时间步为 1,2,…, 1000。
%matplotlib inline
import torch
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))
接下来,我们将这个序列转换为模型的特征-标签(feature-label)对。 基于嵌入维度 τ \tau τ,我们将数据映射为数据对 y t = x t y_t = x_t yt=xt 和 x t = [ x t − τ , . . . , x t − 1 ] \mathbf{x}_t = [x_{t-\tau}, ..., x_{t-1}] xt=[xt−τ,...,xt−1]。 这比我们提供的数据样本少了 τ \tau τ 个, 因为我们没有足够的历史记录来描述前 τ \tau τ 个数据样本。 一个简单的解决办法是:如果拥有足够长的序列就丢弃这几项; 另一个方法是用零填充序列。 在这里,我们丢弃前面 τ \tau τ 个数据,并选择前600个“特征-标签”对进行训练。
tau = 4
# 丢弃前 tau 个数据项
features = torch.zeros((T - tau, tau))
for i in range(tau):
# 注意 x[i: T - tau + i] 是一维向量,这里是对 features按照行赋值
features[:, i] = x[i: T - tau + i]
labels = x[tau:].reshape((-1, 1))
batch_size, n_train = 16, 600
# 只有前n_train个样本用于训练
train_iter = d2l.load_array((features[:n_train], labels[:n_train]),
batch_size, is_train=True)
在这里,我们使用一个相当简单的架构训练模型: 一个拥有两个全连接层的多层感知机,ReLU激活函数和平方损失。
# 初始化网络权重的函数
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
# 平方损失。注意:MSELoss计算平方误差时不带系数1/2
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)
8.1.3. 预测
首先是检查模型预测下一个时间步的能力, 也就是单步预测(one-step-ahead prediction)。
正如我们所料,单步预测效果不错。 即使这些预测的时间步超过了 600 + 4(n_train + tau), 其结果看起来仍然是可信的。注意:上面的预测时间步超过这个范围的时候,输入的还是历史真实的观测值!
然而有一个小问题:如果数据观察序列的时间步只到 604(真实情况,当前只有 600 帧历史数据,要预测 600 帧以后未来的数据), 我们需要一步一步地向前迈进:
通常,对于直到 x t x_t xt 的观测序列,其在时间步 t + k t+k t+k 处的预测输出 x ^ t + k \hat{x}_{t+k} x^t+k 为 k 步预测(k-step-ahead-prediction)。 由于我们的观察已经到了 x 604 x_{604} x604,它的 k k k 步预测是 x ^ 604 + k \hat{x}_{604 + k} x^604+k。 换句话说,我们必须使用我们自己的预测(而不是原始数据,因为实际情况下根本没有)来进行多步预测。 让我们看看效果如何。
8.1.4. 小结
- 内插法(在现有观测值之间进行估计)和外推法(对超出已知观测范围进行预测)在实践的难度上差别很大。因此,对于所拥有的序列数据,在训练时始终要尊重其时间顺序,即最好不要基于未来的数据进行训练。
- 序列模型的估计需要专门的统计工具,两种较流行的选择是自回归模型和隐变量自回归模型。
- 对于时间是向前推进的因果模型,正向估计通常比反向估计更容易。
- 对于直到时间步 t 的观测序列,其在时间步 t+k 的预测输出是“k步预测”。随着我们对预测时间 k 值的增加,会造成误差的快速累积和预测质量的极速下降。
8.2. 文本预处理
我文本的常见预处理步骤通常包括:
- 将文本作为字符串加载到内存中。
- 将字符串拆分为词元(如单词和字符)。
- 建立一个词表,将拆分的词元映射到数字索引。
- 将文本转换为数字索引序列,方便模型操作。
import collections # 用于后面统计词元出现的次数
import re # 正则表达式,把数据集中的大写都去掉,简化结果
from d2l import torch as d2l
8.2.1. 读取数据集
8.2.2. 词元化
下面的tokenize函数将文本行列表(lines)作为输入, 列表中的每个元素是一个文本序列(如一条文本行)。 每个文本序列又被拆分成一个词元列表,词元(token)是文本的基本单位。 最后,返回一个由词元列表组成的列表,其中的每个词元都是一个字符串(string)。
def tokenize(lines, token='word'): #@save
"""将文本行拆分为单词或字符词元"""
if token == 'word': # 用单词表示词元
# 注意 split 函数返回的是一个列表,所以最终返回二维列表
return [line.split() for line in lines]
elif token == 'char': # 用字符表示次元
# 比如 line: "test", list(line): ['t', 'e', 's', 't']
return [list(line) for line in lines]
else:
print('错误:未知词元类型:' + token)
tokens = tokenize(lines)
for i in range(11):
print(tokens[i])
# 输出:
['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
[]
[]
[]
[]
['i']
[]
[]
['the', 'time', 'traveller', 'for', 'so', 'it', 'will', 'be', 'convenient', 'to', 'speak', 'of', 'him']
['was', 'expounding', 'a', 'recondite', 'matter', 'to', 'us', 'his', 'grey', 'eyes', 'shone', 'and']
['twinkled', 'and', 'his', 'usually', 'pale', 'face', 'was', 'flushed', 'and', 'animated', 'the']
8.2.3. 词表
词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。 现在,让我们构建一个字典,通常也叫做词表(vocabulary), 用来将字符串类型的词元映射到从 0 开始的数字索引中。 我们先将训练集中的所有文档合并在一起,对它们的唯一词元进行统计, 得到的统计结果称之为语料(corpus)。 然后根据每个唯一词元的出现频率,为其分配一个数字索引(就是按照频率从大到小,从前往后分配索引)。 很少出现的词元通常被移除,这可以降低复杂性。 另外,语料库中不存在或已删除的任何词元都将映射到一个特定的未知词元“<unk>”。 我们可以选择增加一个列表,用于保存那些reserved(保留,这里不是说没被删掉保存下来的,而是指某些固定的、保留的)的词元, 例如:填充词元(“<pad>”); 序列开始词元(“<bos>”); 序列结束词元(“<eos>”)。
class Vocab: #@save
"""文本词表"""
def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
if tokens is None: # 输入词元,计算文本词表
tokens = []
# 保留词元,比如填充词元(“\<pad\>”); 序列开始词元(“\<bos\>”); 序列结束词元(“\<eos\>”)
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(在列表中第0个位置),加上后面保留词元
self.idx_to_token = ['<unk>'] + 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)
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 unk(self): # 未知词元的索引为0
return 0
@property
def token_freqs(self):
return self._token_freqs
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)
@property是一个内置的装饰器(decorator),用于将一个方法转换为一个相同名称的只读属性。这意味着你可以像访问数据属性一样访问这个方法,而不需要在方法名后加上括号(即不需要调用它)。这通常用于提供对对象内部状态的访问,同时仍然保持对这些状态的封装和可能的验证。比如上面对 token_freqs 的调用:
vocab = Vocab(token)
print(vocab.token_freqs)
8.2.4. 整合所有功能
在使用上述函数时,我们将所有功能打包到load_corpus_time_machine函数中, 该函数返回corpus(词元索引列表,即把词元通过词表映射成数字)和vocab(时光机器语料库的词表)。 我们在这里所做的改变是:
为了简化后面章节中的训练,我们使用字符(而不是单词)实现文本词元化,因此有效的字符就是 26 个小写字母;
时光机器数据集中的每个文本行不一定是一个句子或一个段落,还可能是一个单词,因此返回的corpus仅处理为单个列表,而不是使用多词元列表构成的一个列表。也就是说一行的内容并不一定完整,因此把所有行内容展开到一个列表中。
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)
# 输出:
(170580, 28) # 26个字母+'unk'+空格字符?
8.3. 语言模型和数据集
8.3.4. 读取长序列数据
由于序列数据本质上是连续的,因此我们在处理数据时需要解决这个问题。 在 8.1节中我们以一种相当特别的方式做到了这一点: 当序列变得太长而不能被模型一次性全部处理时, 我们可能希望拆分这样的序列方便模型读取。
在介绍该模型之前,我们看一下总体策略。 假设我们将使用神经网络来训练语言模型, 模型中的网络一次处理具有预定义长度 (例如 n 个时间步)的一个小批量序列(这里的一个序列指的就是一次取几个词元,比如下面是一次取5个字符,那么这5个字符就叫做一个序列,或者说一个样本,就可以认为是一张图像。然后小批量就是batch_size,也就是网络一次处理多少个这种序列)。 现在的问题是如何随机生成一个小批量数据的特征和标签以供读取。
首先,由于文本序列可以是任意长的, 例如整本《时光机器》(The Time Machine), 于是任意长的序列可以被我们划分为具有相同时间步数的子序列。 当训练我们的神经网络时,这样的小批量子序列将被输入到模型中。 假设网络一次只处理具有
个时间步的子序列。 图8.3.1画出了 从原始文本序列获得子序列的所有不同的方式, 其中 n=5,并且每个时间步的词元对应于一个字符。 请注意,因为我们可以选择任意偏移量来指示初始位置,所以我们有相当大的自由度。
因此,我们应该从 图8.3.1中选择哪一个呢? 事实上,他们都一样的好。 然而,如果我们只选择一个偏移量, 那么用于训练网络的、所有可能的子序列的覆盖范围将是有限的。 因此,我们可以从随机偏移量开始划分序列, 以同时获得覆盖性(coverage)和随机性(randomness)。 下面,我们将描述如何实现随机采样(random sampling)和 顺序分区(sequential partitioning)策略。
8.3.4.1. 随机采样
在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻(这里就是说,迭代取数据的过程中,每次取batch_size个的子序列,相邻两次取的子序列不一定相邻,其实就类似图像读取中的shuffle,也就是不按顺序读取每个样本)。 对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。
下面的代码每次可以从数据中随机生成一个小批量。 在这里,参数 batch_size 指定了每个小批量中子序列样本的数目, 参数num_steps是每个子序列中预定义的时间步数。
def seq_data_iter_random(corpus, batch_size, num_steps): #@save
"""使用随机抽样生成一个小批量子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
corpus = corpus[random.randint(0, num_steps - 1):]
# 所有子序列的个数,减去1是因为我们需要考虑标签
num_subseqs = (len(corpus) - 1) // num_steps
# 长度为num_steps的子序列的起始索引
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
# 在随机抽样的迭代过程中,来自两个相邻的、随机的、小批量中的
# 子序列不一定在原始序列上相邻,也就是这里对每个子序列的起始索引进行shuffle
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]
yield torch.tensor(X), torch.tensor(Y)
8.3.4.2. 顺序分区
在迭代过程中,除了对原始序列可以随机抽样外, 我们还可以保证两个相邻的小批量中的子序列在原始序列上也是相邻的(相比上面的代码,其实就是对每个子序列的起始索引不进行shuffle,就类似读取图像样本的时候按照顺序读取一样)。 这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称为顺序分区。
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])
# 这里reshape之后,按照列的顺序就是原始序列中的每个子序列
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
# 按照列的顺序计算,每个batch_size中一共有多少个子序列
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)
__iter__(self)
是一个特殊方法(也称为魔法方法或双下方法),在 Python 中用于定义一个对象的迭代器。当你尝试对一个对象使用循环(如 for 循环)或者调用它的 iter()
函数时,Python 会自动寻找并调用这个对象的 __iter__()
方法。
8.4. 循环神经网络
我们在多层感知机中讨论过的具有隐藏单元的隐藏层。 值得注意的是,隐藏层和隐状态指的是两个截然不同的概念。 如上所述,隐藏层是在从输入到输出的路径上(以观测角度来理解)的隐藏的层, 而隐状态则是在给定步骤所做的任何事情(以技术角度来定义)的输入, 并且这些状态只能通过先前时间步的数据来计算。
8.4.1. 无隐状态的神经网络
8.4.2. 有隐状态的循环神经网络
有了隐状态后,情况就完全不同了。 假设我们在时间步 t 有小批量输入 X t ∈ R n × d \mathbf{X}_t \in \mathbb{R}^{n\times d} Xt∈Rn×d。换言之,对于 n 个序列样本的小批量, X t \mathbf{X}_t Xt 的每一行对应于来自该序列的时间步 t 处的一个样本(其实就是 batch_size=n,每个样本的维度为 d)。 接下来,用 H t ∈ R n × h \mathbf{H}_t \in \mathbb{R}^{n\times h} Ht∈Rn×h表示时间步 t 的隐藏变量。 与多层感知机不同的是, 我们在这里保存了前一个时间步的隐藏变量 H t − 1 \mathbf{H}_{t-1} Ht−1, 并引入了一个新的权重参数 W h h ∈ R h × h \mathbf{W}_{hh} \in \mathbb{R}^{h \times h} Whh∈Rh×h , 来描述如何在当前时间步中使用前一个时间步的隐藏变量。 具体地说,当前时间步隐藏变量由当前时间步的输入 与前一个时间步的隐藏变量一起计算得出:
与 (8.4.3)相比, (8.4.5)多添加了一项 H t − 1 W h h \mathbf{H}_{t-1} \mathbf{W}_{hh} Ht−1Whh, 从而实例化了 (8.4.2)。 从相邻时间步的隐藏变量 H t \mathbf{H}_{t} Ht 和 H t − 1 \mathbf{H}_{t-1} Ht−1 之间的关系可知, 这些变量捕获并保留了序列直到其当前时间步的历史信息, 就如当前时间步下神经网络的状态或记忆, 因此这样的隐藏变量被称为隐状态(hidden state)。 由于在当前时间步中, 隐状态使用的定义与前一个时间步中使用的定义相同, 因此 (8.4.5)的计算是循环的(recurrent)。 于是基于循环计算的隐状态神经网络被命名为 循环神经网络(recurrent neural network)。 在循环神经网络中执行 (8.4.5)计算的层 称为循环层(recurrent layer)。
有许多不同的方法可以构建循环神经网络, 由 (8.4.5)定义的隐状态的循环神经网络是非常常见的一种。 对于时间步 t,输出层的输出类似于多层感知机中的计算:
循环神经网络的参数包括隐藏层的权重 W x h ∈ R d × h \mathbf{W}_{xh} \in \mathbb{R}^{d \times h} Wxh∈Rd×h, W h h ∈ R h × h \mathbf{W}_{hh} \in \mathbb{R}^{h \times h} Whh∈Rh×h 和偏置 b h ∈ R 1 × h \mathbf{b}_{h} \in \mathbb{R}^{1 \times h} bh∈R1×h, 以及输出层的权重 W h q ∈ R h × q \mathbf{W}_{hq} \in \mathbb{R}^{h \times q} Whq∈Rh×q 和偏置 b q ∈ R 1 × q \mathbf{b}_{q} \in \mathbb{R}^{1 \times q} bq∈R1×q。 值得一提的是,即使在不同的时间步,循环神经网络也总是使用这些模型参数。 因此,循环神经网络的参数开销不会随着时间步的增加而增加。
图8.4.1展示了循环神经网络在三个相邻时间步的计算逻辑。 在任意时间步t ,隐状态的计算可以被视为:
- 拼接当前时间步 t 的输入 X_t 和前一时间步 t-1 的隐状态 H t − 1 H_{t-1} Ht−1(注意这里的拼接就是 cat,是因为拼接后再进行矩阵乘法,和前面先矩阵乘法再把结果相加,结果上是一致的,但是拼接后使用一个全连接层就可以了);
- 将拼接的结果送入带有激活函数 ϕ \phi ϕ 的全连接层(注意这里全连接层中就包含了权重,并且同时包含了上面关于输入的权重以及隐状态的权重)。 全连接层的输出是当前时间步 t 的隐状态 H_t。
8.4.3. 基于循环神经网络的字符级语言模型
回想一下 8.3节中的语言模型, 我们的目标是根据过去的和当前的词元预测下一个词元, 因此我们将原始序列移位一个词元作为标签。设小批量大小为1,批量中的文本序列为“machine”。 为了简化后续部分的训练,我们考虑使用 字符级语言模型(character-level language model), 将文本词元化为字符而不是单词。 图8.4.2演示了 如何通过基于字符级语言建模的循环神经网络, 使用当前的和先前的字符预测下一个字符。
在训练过程中,我们对每个时间步的输出层的输出进行softmax操作, 然后利用交叉熵损失计算模型输出和标签之间的误差。 在实践中,我们使用的批量大小为 n>1, 每个词元都由一个 d维向量表示。 因此,在时间步 t 输入X_t 将是一个 n × d n \times d n×d 矩阵, 这与我们在 8.4.2节中的讨论相同。
8.4.4. 困惑度(Perplexity)
8.5. 循环神经网络的从零开始实现
从头开始基于循环神经网络实现字符级语言模型, 这样的模型将在H.G.Wells的时光机器数据集上训练。 和前面 8.3节中介绍过的一样, 我们先读取数据集。
%matplotlib inline
import math
import torch
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)
8.5.1. 独热编码
回想一下,在train_iter中,每个词元都表示为一个数字索引, 将这些索引直接输入神经网络可能会使学习变得困难。 我们通常将每个词元表示为更具表现力的特征向量。 最简单的表示称为独热编码(one-hot encoding)。简言之,将每个索引映射为相互不同的单位向量: 假设词表中不同词元的数目为 N(即len(vocab)), 词元索引的范围为 0 到 N-1。 如果词元的索引是整数 i , 那么我们将创建一个长度为 N 的全 0 向量, 并将第 i 处的元素设置为 1,此向量是原始词元的一个独热向量。 索引为 0 和 2 的独热向量如下所示:
# 第一个参数:one-hot向量设置为1的索引,注意范围 [0, len(vocab)-1]
# 第二个参数:one-hot向量的维度
F.one_hot(torch.tensor([0, 2]), len(vocab))
# 输出
tensor([[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0]])
我们每次采样的小批量数据形状是二维张量: (批量大小,时间步数)=(B, N)。 one_hot函数将这样一个小批量数据转换成三维张量, 张量的最后一个维度等于词表大小(len(vocab))。 我们经常转换输入的维度,以便获得形状为 (时间步数,批量大小,词表大小)= (N,B,D)的三维张量。 这将使我们能够更方便地通过最外层的维度, 一步一步地更新小批量数据的隐状态。这里就是会把 (B, N, D)的前两维进行调换,变成 (N,B,D),因为后面要 for 循环计算每个时间步的输出。
X = torch.arange(10).reshape((2, 5))
F.one_hot(X.T, 28).shape
# 输出
torch.Size([5, 2, 28])
8.5.2. 初始化模型参数
接下来,我们初始化循环神经网络模型的模型参数。 隐藏单元数num_hiddens是一个可调的超参数。 当训练语言模型时,输入和输出来自相同的词表。 因此,它们具有相同的维度,即词表的大小。(因为前面把变量转成了one-hot编码,因此输入、输出都是one-hot向量,其维度都是词表的大小)
def get_params(vocab_size, num_hiddens, device):
# 输入、输出都是one-hot编码
num_inputs = num_outputs = vocab_size
def normal(shape):
return torch.randn(size=shape, device=device) * 0.01
# 隐藏层参数
W_xh = normal((num_inputs, num_hiddens)) # (B, D)
W_hh = normal((num_hiddens, num_hiddens)) # (B, H)
b_h = torch.zeros(num_hiddens, device=device) # (H,)
# 输出层参数
W_hq = normal((num_hiddens, num_outputs)) # (H, D)
b_q = torch.zeros(num_outputs, device=device) # (D,)
# 附加梯度
params = [W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params
8.5.3. 循环神经网络模型
为了定义循环神经网络模型, 我们首先需要一个 init_rnn_state
函数在初始化时返回隐状态。 这个函数的返回是一个张量,张量全用0填充, 形状为 (批量大小,隐藏单元数)=(B,H)。 在后面的章节中我们将会遇到隐状态包含多个变量的情况, 而使用元组可以更容易地处理些(所以为了和后面同意,这里只有一个变量也使用元组)。
def init_rnn_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )
下面的rnn函数定义了如何在一个时间步内计算隐状态和输出。 循环神经网络模型通过 inputs 最外层的维度实现每个时间步的循环, 以便逐时间步更新小批量数据的隐状态 H。 此外,这里使用 tanh 函数作为激活函数。 如 4.1节所述, 当元素在实数上满足均匀分布时, tanh 函数的平均值为0。
def rnn(inputs, state, params):
# inputs的形状:(时间步数量,批量大小,词表大小)
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state # state是一个元组,只有一个变量也要用 , 进行解包
outputs = []
# X的形状:(批量大小,词表大小)
for X in inputs: # 遍历每个时间步,计算当前步骤的隐状态 H 和输出
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h) # (B, H)
Y = torch.mm(H, W_hq) + b_q # (B, D)
outputs.append(Y) # 保存当前时间步的输出
# 对 N 个时间步的输出 (B, D) 进行 cat,变成 (NxB, D)
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)
self.init_state, self.forward_fn = init_state, forward_fn
def __call__(self, X, state):
# 注意:输入X仍然是(B, N), 在这里转换one-hot向量的时候,
# 对(batch_size,时间步)进行了调换,变成 (N, B, D)
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)
# 输入X: (B, N) = (2, 5)
state = net.begin_state(X.shape[0], d2l.try_gpu()) # 先获取输出状态
Y, new_state = net(X.to(d2l.try_gpu()), state)
Y.shape, len(new_state), new_state[0].shape
# 输出
# (NxB, D) = (5x2, 28)
(torch.Size([10, 28]), 1, torch.Size([2, 512]))
我们可以看到输出形状是**(时间步数x批量大小,词表大小), 而隐状态形状保持不变,即(批量大小,隐藏单元数)**。
8.5.4. 预测
让我们首先定义预测函数来生成 prefix 之后的新字符, 其中的prefix是一个用户提供的包含多个字符的字符串。 在循环遍历prefix中的开始字符时, 我们不断地将隐状态传递到下一个时间步,但是不生成任何输出(其实还是有输出的,只不过这里不使用而已)。 这被称为预热(warm-up)期, 因为在此期间模型会自我更新(例如,更新隐状态), 但不会进行预测。 预热期结束后,隐状态的值通常比刚开始的初始值更适合预测, 从而预测字符并输出它们。
def predict_ch8(prefix, num_preds, net, vocab, device): #@save
"""在prefix后面生成新字符"""
# 整个模型的初始隐状态,全0
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))
for y in prefix[1:]: # 预热期
# 不断传入用户的输入,更新隐状态
_, state = net(get_input(), state)
outputs.append(vocab[y])
for _ in range(num_preds): # 预测num_preds步
y, state = net(get_input(), state)
# 把输出结果加入 outpus 中,作为下一次运行的输入
outputs.append(int(y.argmax(dim=1).reshape(1)))
return ''.join([vocab.idx_to_token[i] for i in outputs])
现在我们可以测试predict_ch8函数。 我们将前缀指定为time traveller, 并基于这个前缀生成10个后续字符。 鉴于我们还没有训练网络,它会生成荒谬的预测结果。
predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu())
# 输出
'time traveller aaaaaaaaaa'
8.5.5.梯度裁剪
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:
# 遍历模型的每个参数,梯度缩小 theta/norm 倍
for param in params:
param.grad[:] *= theta / norm
8.5.6. 训练
在训练模型之前,让我们定义一个函数在一个迭代周期内训练模型。 它与我们训练 3.6节模型的方式有三个不同之处:
- 序列数据的不同采样方法(随机采样和顺序分区)将导致隐状态初始化的差异。
- 我们在更新模型参数之前裁剪梯度。 这样的操作的目的是,即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散。
- 我们用困惑度来评价模型。如 8.4.4节所述, 这样的度量确保了不同长度的序列具有可比性。
具体来说,当使用顺序分区时, 我们只在每个迭代周期的开始位置初始化隐状态。 由于下一个小批量数据中的第 i 个子序列样本 与当前第 i 个子序列样本相邻, 因此当前小批量数据最后一个样本的隐状态, 将用于初始化下一个小批量数据第一个样本的隐状态。 这样,存储在隐状态中的序列的历史信息可以在一个迭代周期内流经相邻的子序列。 然而,在任何一点隐状态的计算, 都依赖于同一迭代周期中前面所有的小批量数据, 这使得梯度计算变得复杂。 为了降低计算量,在处理任何一个小批量数据之前, 我们先分离梯度,使得隐状态的梯度计算总是限制在一个小批量数据的时间步内。(这样操作隐状态还是在从前往后一直传递的,只是在反向传播的时候,不会计算对隐状态之前的所有变量的梯度了)
当使用随机抽样时,因为每个样本都是在一个随机位置抽样的, 因此需要为每个迭代周期重新初始化隐状态。 与 3.6节中的 train_epoch_ch3函数相同, updater是更新模型参数的常用函数。 它既可以是从头开始实现的d2l.sgd函数, 也可以是深度学习框架中内置的优化函数。
#@save
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
"""训练网络一个迭代周期(定义见第8章)"""
state, timer = None, d2l.Timer()
metric = d2l.Accumulator(2) # 训练损失之和,词元数量
# 遍历每一次迭代,会取出 batch_size 个数据
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:
if isinstance(net, nn.Module) and not isinstance(state, tuple):
# state对于nn.GRU是个张量
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()
# 模型所有参数的梯度的二范数>1,就裁剪梯度
grad_clipping(net, 1)
updater.step()
else:
l.backward()
grad_clipping(net, 1)
# 因为已经调用了mean函数
updater(batch_size=1)
# loss是平均损失,*y.numel()计算损失之和
metric.add(l * y.numel(), y.numel())
# 根据 8.4.4 中的公式计算困惑度,作为模型的评价指标
return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()
循环神经网络模型的训练函数既支持从零开始实现, 也可以使用高级API来实现。
#@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'))
8.5.7. 小结
- 我们可以训练一个基于循环神经网络的字符级语言模型,根据用户提供的文本的前缀生成后续文本。
- 一个简单的循环神经网络语言模型包括输入编码、循环神经网络模型和输出生成。
- 循环神经网络模型在训练以前需要初始化状态,不过随机抽样和顺序划分使用初始化方法不同。
- 当使用顺序划分时,我们需要分离梯度以减少计算量。
- 在进行任何预测之前,模型通过预热期进行自我更新(例如,获得比初始值更好的隐状态)。
- 梯度裁剪可以防止梯度爆炸,但不能应对梯度消失。
8.6. 循环神经网络的简洁实现
虽然 8.5节 对了解循环神经网络的实现方式具有指导意义,但并不方便。 本节将展示如何使用深度学习框架的高级API提供的函数更有效地实现相同的语言模型。 我们仍然从读取时光机器数据集开始。
import torch
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)
8.6.1. 定义模型
高级API提供了循环神经网络的实现。 我们构造一个具有256个隐藏单元的单隐藏层的循环神经网络层rnn_layer。 事实上,我们还没有讨论多层循环神经网络的意义(这将在 9.3节中介绍)。 现在仅需要将多层理解为一层循环神经网络的输出被用作下一层循环神经网络的输入就足够了。
num_hiddens = 256
# 输入维度,隐藏层(注意不是输出层)维度
rnn_layer = nn.RNN(len(vocab), num_hiddens)
我们使用张量来初始化隐状态,它的形状是**(隐藏层数,批量大小,隐藏单元数)**。(注意这里相比前面从零开始实现,多了一个隐藏层数)
state = torch.zeros((1, batch_size, num_hiddens))
通过一个隐状态和一个输入,我们就可以用更新后的隐状态计算输出。 需要强调的是,rnn_layer的“输出”(Y)不涉及输出层的计算: 它是指每个时间步的隐状态,这些隐状态可以用作后续输出层的输入。
# 输入: (N, B, D)
X = torch.rand(size=(num_steps, batch_size, len(vocab)))
Y, state_new = rnn_layer(X, state)
Y.shape, state_new.shape
# 输出
(torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))
注意:由于 nn.RNN 可以定义内部具有多个隐藏层(多个隐状态)的结构,所以输出的含义如下:
- Y: (N, B, H),是每个时间步,最后一个隐藏层输出的隐状态,后面接输出层用于计算每个时间步的输出
- state_new: (1, B, H),是最后一个时间步,所有隐藏层输出的隐状态,后面可以再次输入当前的 nn.RNN 层,计算下一次迭代
与 8.5节类似, 我们为一个完整的循环神经网络模型定义了一个RNNModel类。 注意,rnn_layer只包含隐藏的循环层,我们还需要创建一个单独的输出层。
#@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
# 最后的全连接层,输入维度为H,输出维度为D
self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
else:
self.num_directions = 2
self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)
def forward(self, inputs, state):
# inputs.T转置是把batch和时间步调换(B, N)-> (N, B)
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))
8.7. 通过时间反向传播
到目前为止,我们已经反复提到像梯度爆炸或梯度消失, 以及需要对循环神经网络分离梯度。 例如,在 8.5节中, 我们在序列上调用了detach函数。
我们在 4.7节中描述了多层感知机中的前向与反向传播及相关的计算图。 循环神经网络中的前向传播相对简单。 通过时间反向传播(backpropagation through time,BPTT) (Werbos, 1990)实际上是循环神经网络中反向传播技术的一个特定应用。 它要求我们将循环神经网络的计算图一次展开一个时间步, 以获得模型变量和参数之间的依赖关系。 然后,基于链式法则,应用反向传播来计算和存储梯度。 由于序列可能相当长,因此依赖关系也可能相当长。 例如,某个1000个字符的序列, 其第一个词元可能会对最后位置的词元产生重大影响。 这在计算上是不可行的(它需要的时间和内存都太多了), 并且还需要超过1000个矩阵的乘积才能得到非常难以捉摸的梯度。 这个过程充满了计算与统计的不确定性。
8.7.1. 循环神经网络的梯度分析
其实这里求梯度的时候,存在循环的就是 隐状态 h t h_t ht 对隐藏层权重 w h w_h wh 的梯度。因为在一个长度为 N 的时间序列中,所有时间步都是使用同一个隐藏层权重 w h w_h wh,所以每个时间步都会对其有梯度。
8.7.1.1. 完全计算
显然,我们可以仅仅计算 (8.7.7)中的全部总和, 然而,这样的计算非常缓慢,并且可能会发生梯度爆炸, 因为初始条件的微小变化就可能会对结果产生巨大的影响。 在实践中,这种方法几乎从未使用过。
8.7.1.2. 截断时间步
或者,我们可以在 τ \tau τ步后截断 (8.7.7)中的求和计算。 这是我们到目前为止一直在讨论的内容, 例如在 8.5节中分离梯度时。 这会带来真实梯度的近似, 只需将求和终止为 ∂ h t − τ / ∂ w h \partial{h_{t-\tau}} / \partial{w_h} ∂ht−τ/∂wh。 在实践中,这种方式工作得很好。 它通常被称为截断的通过时间反向传播 (Jaeger, 2002)。 这样做导致该模型主要侧重于短期影响,而不是长期影响。 这在现实中是可取的,因为它会将估计值偏向更简单和更稳定的模型。
8.7.1.3. 随机截断
8.7.1.4. 比较策略
图8.7.1说明了 当基于循环神经网络使用通过时间反向传播 分析《时间机器》书中前几个字符的三种策略:
- 第一行采用随机截断,方法是将文本划分为不同长度的片断;
- 第二行采用常规截断,方法是将文本分解为相同长度的子序列。 这也是我们在循环神经网络实验中一直在做的;
- 第三行采用通过时间的完全反向传播,结果是产生了在计算上不可行的表达式。
遗憾的是,虽然随机截断在理论上具有吸引力, 但很可能是由于多种因素在实践中并不比常规截断更好。 首先,在对过去若干个时间步经过反向传播后, 观测结果足以捕获实际的依赖关系。 其次,增加的方差抵消了时间步数越多梯度越精确的事实。 第三,我们真正想要的是只有短范围交互的模型。 因此,模型需要的正是截断的通过时间反向传播方法所具备的轻度正则化效果。
8.7.2. 通过时间反向传播的细节
我们可以从 (8.7.15)中看到, 这个简单的线性例子已经展现了长序列模型的一些关键问题: 它陷入到 W h h T \mathbf{W}_{hh}^T WhhT 的潜在的非常大的幂。 在这个幂中,小于1的特征值将会消失,大于1的特征值将会发散。 这在数值上是不稳定的,表现形式为梯度消失或梯度爆炸。 解决此问题的一种方法是按照计算方便的需要截断时间步长的尺寸 如 8.7.1节中所述。 实际上,这种截断是通过在给定数量的时间步之后分离梯度来实现的。 稍后,我们将学习更复杂的序列模型(如长短期记忆模型) 是如何进一步缓解这一问题的。