循环神经网络
到目前为止, 我们遇到过两种类型的数据: 表格数据和图像数据. 对于图像数据, 我们设计了专门的卷积神经网络架构来为这类特殊的数据结构建模. 换句话说, 如果我们拥有一张图像, 我们需要有效地利用其像素位置, 假若我们对图像中的像素位置进行重排, 就会对图像中内容的推断造成极大的困难.
最重要的是, 到目前为止我们默认数据都来自于某种分布, 并且所有样本都是独立同分布 (i.i.d.) 的. 然而, 大多数的数据并非如此. 例如, 文章中的单词是按顺序写的, 如果顺序被随机地重排, 就很难理解文章原始的意思. 同样, 视频中的图像帧, 对话中的音频信号以及网站上的浏览行为都是有顺序的. 因此, 针对此类数据而设计特定模型, 可能效果会更好.
另一个问题来自这样一个事实: 我们不仅仅可以接收一个序列作为输入, 而是还可能期望继续猜测这个序列的后续.
简言之, 如果说卷积神经网络可以有效地处理空间信息, 那么本章的循环神经网络 (recurrent neural network, RNN) 则可以更好地处理序列信息. 循环神经网络通过引入状态变量存储过去的信息和当前的输入, 从而可以确定当前的输出.
序列模型
自回归模型
使用这个变量自身过去的值, 预测当前或未来的值.
一般的 p 阶自回归模型使用前 p 个时刻的信息预测当前时刻, 即:
xt=i=1∑paixt−i+εt
语言模型也是自回归模型.
主要问题在于输入数据的数量会随 t 变化而变化. 一种策略是只满足某个特定长度的时间跨度, 另一种策略是保留一些对过去观测的总结, 同时更新预测和总结. 总结不会被观测到, 所以也叫隐变量自回归模型.
马尔可夫模型
核心假设是: 已知当前状态后, 更早的历史对未来不再提供额外信息.
和自回归模型的区别在于, 自回归明确规定了当前值怎样由过去值计算出来, 且传统 AR 模型通常还是线性的.
因果关系
原则上, 将 P(x1,…,xT) 倒序展开也没什么问题. 毕竟, 基于条件概率公式, 我们总是可以写出:
P(x1,…,xT)=t=T∏1P(xt∣xt+1,…,xT).
然而, 在许多情况下, 数据存在一个自然的方向, 即在时间上是前进的, 按时间顺序解释数据应该更容易, 且向前推进的方向也通常是我们感兴趣的方向.
文本预处理
词元化, 词表.
语言模型和数据集
「我想吃奶奶」
——《动手学深度学习》
马尔可夫模型与 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,x3 , 那么计算过程是:
h1h2h3=f(x1,h0)=f(x2,h1)=f(x3,h2)
相当于:
x₁ x₂ x₃ ↓ ↓ ↓ h₁ ───→ h₂ ───→ h₃
|
那么我们在计算 h3 的 loss 时, 梯度就会沿着时间顺序反向传播.
由于
0≤1−tanh2(x)≤1
多层反向传播会很容易导致梯度消失.
如果序列特别长, 例如有几万个时间步, 完整 BPTT 会占用大量显存, 计算非常慢, 梯度传播距离过长. 因此, 实际训练常使用 Truncated BPTT, 即截断的随时间反向传播.
处理完一个片段后, 切断计算图, 保留隐藏状态的数值, 但不再沿前一个片段继续反向传播.
现代循环神经网络
长短期记忆网络 LSTM
普通 RNN:
ht=tanh(Wxxt+Whht−1+b)
新的 ht 是将旧状态 $ h_{t−1}$ 和当前输入 xt 混合后整体重新计算出来的.
LSTM 增加了一条专门的长期记忆通道 ct , 称为细胞记忆.
LSTM 的三个门:
- 遗忘门: 旧记忆保留多少
- 输入门: 新信息写入多少
- 输出门: 当前记忆输出多少
门的本质是一个值在 0 到 1 之间的向量, 通过 sigmoid 函数实现.
全流程:
ftitc~tctotht=σ(Wf[xt,ht−1]+bf)=σ(Wi[xt,ht−1]+bi)=tanh(Wc[xt,ht−1]+bc)=ft⊙ct−1+it⊙c~t=σ(Wo[xt,ht−1]+bo)=ot⊙tanh(ct)
上一时刻长期记忆 c(t-1) │ ├── 遗忘门: 旧信息保留多少 │ 当前输入 x(t) ── 输入门: 新信息写入多少 │ ▼ 新长期记忆 c(t) │ └── 输出门: 对外输出多少 │ ▼ h(t)
|
可以缓解普通 RNN 中严重的梯度消失.
门控循环单元 GRU
Gated Recurrent Unit, GRU
没有分别维护隐藏状态 ht 和细胞状态 ct, 而是只维护一个 ht.
拥有更新门 zt 和重置门 rt.
更新门决定新状态中应该保留多少旧状态, 以及写入多少新状态, 为 1 时主要保留旧状态, 为 0 时主要使用新状态.
重置门表示在生成候选状态之前, 先决定需要参考多少过去的信息, 为 0 时基本忽略过去, 为 1 时充分参考过去状态.
ztrth~tht=σ(Wzxt+Uzht−1+bz)=σ(Wrxt+Urht−1+br)=tanh(Whxt+Uh(rt⊙ht−1)+bh)=zt⊙ht−1+(1−zt)⊙h~t
上一隐藏状态 h(t-1) │ ├── 重置门: 生成新信息时参考多少过去 │ │ │ ▼ │ 候选状态 h~ │ │ └── 更新门: 旧状态和候选状态如何混合 │ ▼ h(t)
|