动手学深度学习 Part4

循环神经网络#

到目前为止, 我们遇到过两种类型的数据: 表格数据和图像数据. 对于图像数据, 我们设计了专门的卷积神经网络架构来为这类特殊的数据结构建模. 换句话说, 如果我们拥有一张图像, 我们需要有效地利用其像素位置, 假若我们对图像中的像素位置进行重排, 就会对图像中内容的推断造成极大的困难.

最重要的是, 到目前为止我们默认数据都来自于某种分布, 并且所有样本都是独立同分布 (i.i.d.) 的. 然而, 大多数的数据并非如此. 例如, 文章中的单词是按顺序写的, 如果顺序被随机地重排, 就很难理解文章原始的意思. 同样, 视频中的图像帧, 对话中的音频信号以及网站上的浏览行为都是有顺序的. 因此, 针对此类数据而设计特定模型, 可能效果会更好.

另一个问题来自这样一个事实: 我们不仅仅可以接收一个序列作为输入, 而是还可能期望继续猜测这个序列的后续.

简言之, 如果说卷积神经网络可以有效地处理空间信息, 那么本章的循环神经网络 (recurrent neural network, RNN) 则可以更好地处理序列信息. 循环神经网络通过引入状态变量存储过去的信息和当前的输入, 从而可以确定当前的输出.

序列模型#

自回归模型#

使用这个变量自身过去的值, 预测当前或未来的值.

一般的 p 阶自回归模型使用前 p 个时刻的信息预测当前时刻, 即:

xt=i=1paixti+εtx_t = \sum_{i=1}^{p} a_i x_{t-i} + \varepsilon_t

语言模型也是自回归模型.

主要问题在于输入数据的数量会随 t 变化而变化. 一种策略是只满足某个特定长度的时间跨度, 另一种策略是保留一些对过去观测的总结, 同时更新预测和总结. 总结不会被观测到, 所以也叫隐变量自回归模型.

马尔可夫模型#

核心假设是: 已知当前状态后, 更早的历史对未来不再提供额外信息.

和自回归模型的区别在于, 自回归明确规定了当前值怎样由过去值计算出来, 且传统 AR 模型通常还是线性的.

因果关系#

原则上, 将 P(x1,,xT)P (x_1, \dots, x_T) 倒序展开也没什么问题. 毕竟, 基于条件概率公式, 我们总是可以写出:

P(x1,,xT)=t=T1P(xtxt+1,,xT).P(x_1, \dots, x_T) = \prod_{t=T}^{1} P(x_t \mid x_{t+1}, \dots, x_T).

然而, 在许多情况下, 数据存在一个自然的方向, 即在时间上是前进的, 按时间顺序解释数据应该更容易, 且向前推进的方向也通常是我们感兴趣的方向.

文本预处理#

词元化, 词表.

语言模型和数据集#

「我想吃奶奶」

——《动手学深度学习》

马尔可夫模型与 n 元语法.

循环神经网络#

import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader
from typing import Optional

torch.manual_seed(233)

num_samples = 5000
sequence_length = 20
input_size = 1

hidden_size = 32
num_layers = 1
num_classes = 2

batch_size = 64
learning_rate = 0.003
num_epochs = 10

x = torch.randn(
num_samples,
sequence_length,
input_size
)

seq_sum = x.sum(dim=1).squeeze(dim=-1)

y = (seq_sum > 0).long()

print("X 的形状: ", x.shape)
print("y 的形状: ", y.shape)
print("第一个序列: ", x[0].squeeze())
print("第一个序列的和: ", seq_sum[0])
print("第一个序列的标签: ", y[0])

train_x = x[:4000]
train_y = y[:4000]

test_x = x[4000:]
test_y = y[4000:]

train_dataset = TensorDataset(train_x, train_y)
test_dataset = TensorDataset(test_x, test_y)

train_loader = DataLoader(
train_dataset,
batch_size=64,
shuffle=True
)

test_loader = DataLoader(
test_dataset,
batch_size=256,
shuffle=False
)

class RNNClassifier(nn.Module):
def __init__(self, input_size: int, hidden_size: int, num_classes: int, num_layers:int):
super().__init__()
self.hidden_size = hidden_size
self.num_layers = num_layers
self.rnn = nn.RNN(
input_size=input_size,
hidden_size=hidden_size,
num_layers=1,
nonlinearity="tanh",
batch_first=True
)
self.classifier = nn.Linear(
hidden_size,
num_classes
)

def init_hidden(self, batch_size: int, device: torch.device) -> torch.Tensor:
return torch.zeros(
self.num_layers,
batch_size,
self.hidden_size,
device=device
)

def forward(self, x: torch.Tensor, hidden: Optional[torch.Tensor] = None):
batch_size = x.size(0)
if hidden is None:
hidden = self.init_hidden(
batch_size=batch_size,
device=x.device
)
output, final_hidden = self.rnn(x, hidden)
last_hidden = final_hidden[-1]
logits = self.classifier(last_hidden)
return logits, final_hidden, output


hidden_size = 32
num_classes = 2


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

print("运行设备: ", device)



model = RNNClassifier(
input_size=input_size,
hidden_size=hidden_size,
num_classes=num_classes,
num_layers=num_layers
).to(device)

loss_fn = nn.CrossEntropyLoss()

optimizer = torch.optim.Adam(
model.parameters(),
lr = learning_rate
)

def evaluate(
model: nn.Module,
data_loader: DataLoader,
device: torch.device
) -> tuple[float, float]:
model.eval()

total_loss = 0.0
correct = 0
total = 0

with torch.no_grad():
for batch_X, batch_y in data_loader:
batch_X = batch_X.to(device)
batch_y = batch_y.to(device)

logits, final_hidden, output = model(batch_X)

loss = loss_fn(logits, batch_y)

predictions = logits.argmax(dim=1)

total_loss += loss.item() * batch_y.size(0)

correct += (
predictions == batch_y
).sum().item()

total += batch_y.size(0)

average_loss = total_loss / total
accuracy = correct / total

return average_loss, accuracy

for epoch in range(num_epochs):
model.train()
total_train_loss = 0
train_correct = 0
train_total = 0

for batch_x, batch_y in train_loader:
batch_x = batch_x.to(device)
batch_y = batch_y.to(device)

optimizer.zero_grad()
logits, final_hidden, output = model(batch_x)
loss = loss_fn(logits, batch_y)
loss.backward()

nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
total_train_loss += (
loss.item() * batch_y.size(0)
)

predictions = logits.argmax(dim=1)

train_correct += (
predictions == batch_y
).sum().item()

train_total += batch_y.size(0)

train_loss = total_train_loss / train_total
train_accuracy = train_correct / train_total

test_loss, test_accuracy = evaluate(
model=model,
data_loader=test_loader,
device=device
)

print(
f"Epoch [{epoch + 1:02d}/{num_epochs}] "
f"训练损失: {train_loss:.4f}, "
f"训练准确率: {train_accuracy:.2%}, "
f"测试损失: {test_loss:.4f}, "
f"测试准确率: {test_accuracy:.2%}"
)

通过时间反向传播#

Backpropagation Through Time, BPTT

对于普通 RNN, 假设序列有三个时间步 x1,x2,x3x_1, x_2, x_3 , 那么计算过程是:

h1=f(x1,h0)h2=f(x2,h1)h3=f(x3,h2)\begin{aligned} h_1 &= f(x_1, h_0) \\ h_2 &= f(x_2, h_1) \\ h_3 &= f(x_3, h_2) \end{aligned}

相当于:

x₁        x₂        x₃
↓ ↓ ↓
h₁ ───→ h₂ ───→ h₃

那么我们在计算 h3h_3 的 loss 时, 梯度就会沿着时间顺序反向传播.

由于

01tanh2(x)10 \le 1 - \tanh^2(x) \le 1

多层反向传播会很容易导致梯度消失.

如果序列特别长, 例如有几万个时间步, 完整 BPTT 会占用大量显存, 计算非常慢, 梯度传播距离过长. 因此, 实际训练常使用 Truncated BPTT, 即截断的随时间反向传播.

处理完一个片段后, 切断计算图, 保留隐藏状态的数值, 但不再沿前一个片段继续反向传播.

现代循环神经网络#

长短期记忆网络 LSTM#

普通 RNN:

ht=tanh(Wxxt+Whht1+b)h_t = \tanh(W_x x_t + W_h h_{t-1} + b)

新的 htht 是将旧状态 $ h_{t−1}$ 和当前输入 xtx_t 混合后整体重新计算出来的.

LSTM 增加了一条专门的长期记忆通道 ctc_t , 称为细胞记忆.

LSTM 的三个门:

  • 遗忘门: 旧记忆保留多少
  • 输入门: 新信息写入多少
  • 输出门: 当前记忆输出多少

门的本质是一个值在 0 到 1 之间的向量, 通过 sigmoid 函数实现.

全流程:

ft=σ(Wf[xt,ht1]+bf)it=σ(Wi[xt,ht1]+bi)c~t=tanh(Wc[xt,ht1]+bc)ct=ftct1+itc~tot=σ(Wo[xt,ht1]+bo)ht=ottanh(ct)\begin{aligned} f_t &= \sigma(W_f [x_t, h_{t-1}] + b_f) \\ i_t &= \sigma(W_i [x_t, h_{t-1}] + b_i) \\ \tilde{c}_t &= \tanh(W_c [x_t, h_{t-1}] + b_c) \\ c_t &= f_t \odot c_{t-1} + i_t \odot \tilde{c}_t \\ o_t &= \sigma(W_o [x_t, h_{t-1}] + b_o) \\ h_t &= o_t \odot \tanh(c_t) \end{aligned}

上一时刻长期记忆 c(t-1)

├── 遗忘门: 旧信息保留多少

当前输入 x(t) ── 输入门: 新信息写入多少


新长期记忆 c(t)

└── 输出门: 对外输出多少


h(t)

可以缓解普通 RNN 中严重的梯度消失.

门控循环单元 GRU#

Gated Recurrent Unit, GRU

没有分别维护隐藏状态 hth_t 和细胞状态 ctc_t, 而是只维护一个 hth_t.

拥有更新门 ztz_t 和重置门 rtr_t.

更新门决定新状态中应该保留多少旧状态, 以及写入多少新状态, 为 1 时主要保留旧状态, 为 0 时主要使用新状态.

重置门表示在生成候选状态之前, 先决定需要参考多少过去的信息, 为 0 时基本忽略过去, 为 1 时充分参考过去状态.

zt=σ(Wzxt+Uzht1+bz)rt=σ(Wrxt+Urht1+br)h~t=tanh(Whxt+Uh(rtht1)+bh)ht=ztht1+(1zt)h~t\begin{aligned} z_t &= \sigma(W_z x_t + U_z h_{t-1} + b_z) \\ r_t &= \sigma(W_r x_t + U_r h_{t-1} + b_r) \\ \tilde{h}_t &= \tanh(W_h x_t + U_h (r_t \odot h_{t-1}) + b_h) \\ h_t &= z_t \odot h_{t-1} + (1 - z_t) \odot \tilde{h}_t \end{aligned}

上一隐藏状态 h(t-1)

├── 重置门: 生成新信息时参考多少过去
│ │
│ ▼
│ 候选状态 h~
│ │
└── 更新门: 旧状态和候选状态如何混合


h(t)