门控循环单元(GRU)
循环神经网络中的梯度计算
- 当时间步数较大或者时间步较小时,循环神经网络的梯度较容易出现衰减或爆炸。
- 虽然裁剪梯度可以应对梯度爆炸,但无法解决梯度衰减的问题。
- 通常由于这个原因,循环神经网络在实际中较难捕捉时间序列中时间步距离较大的依赖关系。
门控循环神经网络(gated recurrent neural network)的提出,正是为了更好地捕捉时间序列中时间步距离较大的依赖关系。它通过可以学习的门来控制信息的流动。其中,门控循环单元(gated recurrent unit,GRU)是一种常用的门控循环神经网络。
门控循环单元
门控循环单元引入了重置门(reset gate)和更新门(update gate)的概念,从而修改了循环神经网络中隐藏状态的计算方式。
门控循环单元中的重置门和更新门的
- 输入均为当前时间步输入Xt\boldsymbol{X}_tXt与上一时间步隐藏状态Ht−1\boldsymbol{H}_{t-1}Ht−1
- 输出由激活函数为sigmoid函数的全连接层计算得到。
假设
- 隐藏单元个数为hhh
- 给定时间步ttt的小批量输入Xt∈Rn×d\boldsymbol{X}_t \in \mathbb{R}^{n \times d}Xt∈Rn×d(样本数为nnn,输入个数为ddd)
- 和上一时间步隐藏状态Ht−1∈Rn×h\boldsymbol{H}_{t-1} \in \mathbb{R}^{n \times h}Ht−1∈Rn×h。
重置门Rt∈Rn×h\boldsymbol{R}_t \in \mathbb{R}^{n \times h}Rt∈Rn×h和更新门Zt∈Rn×h\boldsymbol{Z}_t \in \mathbb{R}^{n \times h}Zt∈Rn×h的计算如下:
Rt=σ(XtWxr+Ht−1Whr+br),Zt=σ(XtWxz+Ht−1Whz+bz),\begin{aligned} \boldsymbol{R}_t = \sigma(\boldsymbol{X}_t \boldsymbol{W}_{xr} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hr} + \boldsymbol{b}_r),\\ \boldsymbol{Z}_t = \sigma(\boldsymbol{X}_t \boldsymbol{W}_{xz} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hz} + \boldsymbol{b}_z), \end{aligned} Rt=σ(XtWxr+Ht−1Whr+br),Zt=σ(XtWxz+Ht−1Whz+bz),
其中
- Wxr,Wxz∈Rd×h\boldsymbol{W}_{xr}, \boldsymbol{W}_{xz} \in \mathbb{R}^{d \times h}Wxr,Wxz∈Rd×h和Whr,Whz∈Rh×h\boldsymbol{W}_{hr}, \boldsymbol{W}{hz} \in \mathbb{R}^{h \times h}Whr,Whz∈Rh×h是权重参数
- br,bz∈R1×h\boldsymbol{b}_r, \boldsymbol{b}_z \in \mathbb{R}^{1 \times h}br,bz∈R1×h是偏差参数。
- sigmoid函数可以将元素的值变换到0和1之间。因此,重置门Rt\boldsymbol{R}_tRt和更新门Zt\boldsymbol{Z}_tZt中每个元素的值域都是[0,1][0, 1][0,1]。
候选隐藏状态
门控循环单元将计算候选隐藏状态来辅助之后的隐藏状态计算。
- 我们将当前时间步重置门的输出与上一时间步隐藏状态做按元素乘法(符号为⊙\odot⊙)。
- 如果重置门中元素值接近0,那么意味着重置对应隐藏状态元素为0,即丢弃上一时间步的隐藏状态。
- 如果元素值接近1,那么表示保留上一时间步的隐藏状态。
- 然后,将按元素乘法的结果与当前时间步的输入连结,再通过含激活函数tanh的全连接层计算出候选隐藏状态,其所有元素的值域为[−1,1][-1, 1][−1,1]。
时间步ttt的候选隐藏状态H~t∈Rn×h\tilde{\boldsymbol{H}}_t \in \mathbb{R}^{n \times h}H~t∈Rn×h的计算为
H~t=tanh(XtWxh+(Rt⊙Ht−1)Whh+bh),\tilde{\boldsymbol{H}}_t = \text{tanh}(\boldsymbol{X}_t \boldsymbol{W}_{xh} + \left(\boldsymbol{R}_t \odot \boldsymbol{H}_{t-1}\right) \boldsymbol{W}_{hh} + \boldsymbol{b}_h),H~t=tanh(XtWxh+(Rt⊙Ht−1)Whh+bh),
其中
- Wxh∈Rd×h\boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h}Wxh∈Rd×h和Whh∈Rh×h\boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h}Whh∈Rh×h是权重参数
- bh∈R1×h\boldsymbol{b}_h \in \mathbb{R}^{1 \times h}bh∈R1×h是偏差参数。
从上面这个公式可以看出,重置门控制了上一时间步的隐藏状态如何流入当前时间步的候选隐藏状态。而上一时间步的隐藏状态可能包含了时间序列截至上一时间步的全部历史信息。因此,重置门可以用来丢弃与预测无关的历史信息。
隐藏状态
时间步ttt的隐藏状态Ht∈Rn×h\boldsymbol{H}_t \in \mathbb{R}^{n \times h}Ht∈Rn×h的计算使用当前时间步的更新门Zt\boldsymbol{Z}_tZt来对上一时间步的隐藏状态Ht−1\boldsymbol{H}_{t-1}Ht−1和当前时间步的候选隐藏状态H~t\tilde{\boldsymbol{H}}_tH~t做组合:
Ht=Zt⊙Ht−1+(1−Zt)⊙H~t.\boldsymbol{H}_t = \boldsymbol{Z}_t \odot \boldsymbol{H}_{t-1} + (1 - \boldsymbol{Z}_t) \odot \tilde{\boldsymbol{H}}_t.Ht=Zt⊙Ht−1+(1−Zt)⊙H~t.
更新门可以控制隐藏状态应该如何被包含当前时间步信息的候选隐藏状态所更新,如图所示。
- 假设更新门在时间步t′t't′到ttt(t′<tt' < tt′<t)之间一直近似1。那么,在时间步t′t't′到ttt之间的输入信息几乎没有流入时间步ttt的隐藏状态Ht\boldsymbol{H}tHt。
- 实际上,这可以看作是较早时刻的隐藏状态Ht′−1\boldsymbol{H}{t'-1}Ht′−1一直通过时间保存并传递至当前时间步ttt。
- 这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。
对门控循环单元的设计作总结:
- 重置门有助于捕捉时间序列里短期的依赖关系;
- 更新门有助于捕捉时间序列里长期的依赖关系。
实现GRU网络
读取数据集
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as Fimport sys
sys.path.append("..")
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')def load_data_jay_lyrics():"""加载周杰伦歌词数据集"""with zipfile.ZipFile('../data/jaychou_lyrics.txt.zip') as zin:with zin.open('jaychou_lyrics.txt') as f:corpus_chars = f.read().decode('utf-8')corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')corpus_chars = corpus_chars[0:10000]idx_to_char = list(set(corpus_chars))char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])vocab_size = len(char_to_idx)corpus_indices = [char_to_idx[char] for char in corpus_chars]return corpus_indices, char_to_idx, idx_to_char, vocab_size(corpus_indices, char_to_idx, idx_to_char, vocab_size) = load_data_jay_lyrics()
初始化模型参数
num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_sizedef get_params():def _one(shape):ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)return torch.nn.Parameter(ts, requires_grad=True)def _three():return (_one((num_inputs, num_hiddens)),_one((num_hiddens, num_hiddens)),torch.nn.Parameter(torch.zeros(num_hiddens, device=device, dtype=torch.float32), requires_grad=True))W_xz, W_hz, b_z = _three() # 更新门参数W_xr, W_hr, b_r = _three() # 重置门参数W_xh, W_hh, b_h = _three() # 候选隐藏状态参数# 输出层参数W_hq = _one((num_hiddens, num_outputs))b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, dtype=torch.float32), requires_grad=True)return nn.ParameterList([W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q])
定义模型
def init_gru_state(batch_size, num_hiddens, device):return (torch.zeros((batch_size, num_hiddens), device=device), )#形状为(批量大小, 隐藏单元个数)的值为0的Tensor组成的元组。
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 = paramsH, = stateoutputs = []for X in inputs:Z = torch.sigmoid(torch.matmul(X, W_xz) + torch.matmul(H, W_hz) + b_z)R = torch.sigmoid(torch.matmul(X, W_xr) + torch.matmul(H, W_hr) + b_r)H_tilda = torch.tanh(torch.matmul(X, W_xh) + torch.matmul(R * H, W_hh) + b_h)H = Z * H + (1 - Z) * H_tildaY = torch.matmul(H, W_hq) + b_qoutputs.append(Y)return outputs, (H,)
训练模型并创作歌词
num_epochs, num_steps, batch_size, lr, clipping_theta = 160, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,vocab_size, device, corpus_indices, idx_to_char,char_to_idx, is_random_iter, num_epochs, num_steps,lr, clipping_theta, batch_size, pred_period,pred_len, prefixes):if is_random_iter:data_iter_fn = data_iter_randomelse:data_iter_fn = data_iter_consecutiveparams = get_params()loss = nn.CrossEntropyLoss()for epoch in range(num_epochs):if not is_random_iter: # 如使用相邻采样,在epoch开始时初始化隐藏状态state = init_rnn_state(batch_size, num_hiddens, device)l_sum, n, start = 0.0, 0, time.time()data_iter = data_iter_fn(corpus_indices, batch_size, num_steps, device)for X, Y in data_iter:if is_random_iter: # 如使用随机采样,在每个小批量更新前初始化隐藏状态state = init_rnn_state(batch_size, num_hiddens, device)else: # 否则需要使用detach函数从计算图分离隐藏状态, 这是为了# 使模型参数的梯度计算只依赖一次迭代读取的小批量序列(防止梯度计算开销太大)for s in state:s.detach_()inputs = to_onehot(X, vocab_size)# outputs有num_steps个形状为(batch_size, vocab_size)的矩阵(outputs, state) = rnn(inputs, state, params)# 拼接之后形状为(num_steps * batch_size, vocab_size)outputs = torch.cat(outputs, dim=0)# Y的形状是(batch_size, num_steps),转置后再变成长度为# batch * num_steps 的向量,这样跟输出的行一一对应y = torch.transpose(Y, 0, 1).contiguous().view(-1)# 使用交叉熵损失计算平均分类误差l = loss(outputs, y.long())# 梯度清0if params[0].grad is not None:for param in params:param.grad.data.zero_()l.backward()grad_clipping(params, clipping_theta, device) # 裁剪梯度sgd(params, lr, 1) # 因为误差已经取过均值,梯度不用再做平均l_sum += l.item() * y.shape[0]n += y.shape[0]if (epoch + 1) % pred_period == 0:print('epoch %d, perplexity %f, time %.2f sec' % (epoch + 1, math.exp(l_sum / n), time.time() - start))for prefix in prefixes:print(' -', predict_rnn(prefix, pred_len, rnn, params, init_rnn_state,num_hiddens, vocab_size, device, idx_to_char, char_to_idx))train_and_predict_rnn(gru, get_params, init_gru_state, num_hiddens,vocab_size, device, corpus_indices, idx_to_char,char_to_idx, False, num_epochs, num_steps, lr,clipping_theta, batch_size, pred_period, pred_len,prefixes)