本专栏深入探究从循环神经网络(RNN)到Transformer等自然语言处理(NLP)模型的架构,以及基于这些模型构建的应用程序。
本系列文章内容:
门控循环单元(GRU)由赵(Cho)等人于2014年提出,旨在解决标准循环神经网络(RNN)面临的梯度消失问题。GRU与长短期记忆网络(LSTM)有许多共同特性。这两种算法都采用门控机制来控制记忆过程。
想象一下,你正通过反复聆听来学习一首歌。一个基本的RNN可能在听到歌曲结尾时就忘记了开头。GRU通过使用门来解决这个问题,这些门可以控制哪些信息被记住,哪些被遗忘。
GRU通过将输入门和遗忘门合并为一个更新门,并增加一个重置门,简化了长短期记忆网络(LSTM)的结构。这使得GRU训练速度更快,更易于使用,同时仍能长时间记住重要信息。
这些门帮助GRU在记住重要细节和遗忘不重要信息之间保持平衡,就像你在听一首歌时,可能会专注于记住旋律而忽略背景噪音一样。
GRU非常适合处理序列数据的任务,比如预测股市、理解语言,甚至生成音乐。它们可以通过跟踪过去的信息并利用这些信息进行更好的预测,从而学习数据中的模式。这使得它们在任何需要理解先前数据点上下文的应用中都非常有用。
为了了解GRU的适用场景,让我们将它们与LSTM和普通RNN进行比较。
可以把普通RNN看作是循环神经网络的基本版本。它们的工作方式是将信息从一个时间步传递到下一个时间步,就像接力赛中每个跑步者将接力棒传递给下一个人一样。然而,它们有一个很大的缺陷:在处理长序列时容易遗忘信息。这是由于梯度消失问题,使得它们难以学习数据中的长期依赖关系。
长短期记忆网络就是为解决这个问题而设计的。它们采用了一种更复杂的结构,包含三种类型的门:输入门、遗忘门和输出门。这些门就像一个精密的文件管理系统,决定了哪些信息需要保留、哪些需要更新以及哪些需要丢弃。这使得LSTM能够长时间记住重要信息,非常适合那些需要考虑多个时间步上下文的任务,比如理解段落文本或识别长序列时间数据中的模式。
门控循环单元是LSTM的简化版本。它们通过将输入门和遗忘门合并为一个更新门,并增加一个重置门来简化结构。这使得GRU在计算上比LSTM的开销更小,训练速度更快,同时仍能有效处理长期依赖关系。
GRU支持门控和隐藏状态,以控制信息的流动。为了解决RNN中出现的问题,GRU使用了两个门:更新门和重置门。
可以将它们看作是两个向量元素(0,1),能够进行凸组合。这些组合决定了哪些隐藏状态信息需要更新(传递),或者在需要时重置隐藏状态。同样,网络学会跳过无关的临时观测值。
LSTM由三个门组成:输入门、遗忘门和输出门。与LSTM不同,GRU没有输出门,而是将输入门和遗忘门合并为一个更新门。
让我们进一步了解更新门和重置门。
更新门( z t z_t zt)负责确定需要将多少先前信息(前一时间步)传递到下一个状态。它是一个重要的单元。下面的示意图展示了更新门的结构。
这里, x t x_t xt 是输入到网络单元的向量。它与参数权重矩阵( W z W_z Wz)相乘。 h ( t − 1 ) h(t - 1) h(t−1) 中的 t − 1 t - 1 t−1 表示它包含前一个单元的信息,并且它也与相应的权重相乘。接下来,将这些参数的值相加,并通过Sigmoid激活函数。在这里,Sigmoid函数会生成介于0和1之间的值。
重置门( r t r_t rt)用于让模型决定需要忽略多少过去的信息。其公式与更新门相同。不过,它们的权重和门的用途有所不同,这将在接下来的部分进行讨论。下面的示意图展示了重置门。
有两个输入, x t x_t xt 和 h t − 1 h_{t - 1} ht−1。将它们分别与各自的权重相乘,进行逐点相加,然后通过Sigmoid函数。
首先,重置门会将上一个时间步的相关信息存储到新的记忆内容中。然后,它将输入向量和隐藏状态分别与它们的权重相乘。接着,在重置门和先前隐藏状态的乘积之间进行元素级乘法(哈达玛积)。求和之后,对上述步骤的结果应用非线性激活函数,从而得到 (h’_t)。
设想这样一个场景,一位顾客对一家度假村进行评价:“我到达这里的时候已经是深夜了。” 在写了几行之后,这条评价以 “我在这里住得很愉快,因为房间很舒适,工作人员也很友好。” 结尾。为了判断这位顾客的满意度水平,你将需要评价的最后两行内容。模型会扫描整条评价直到结尾,并将重置门的向量值设置为接近 “0”。
这意味着它将忽略前面的内容,只关注最后的句子。
这是最后一步。在当前时间步的最终记忆中,网络需要计算 h t h_t ht。在这里,更新门将起到关键作用。这个向量值将保存当前单元的信息,并将其传递到网络中。它将决定从当前的记忆内容 h t ′ h'_t ht′和上一个时间步 h t − 1 h_{t - 1} ht−1 中收集哪些信息。对更新门和 h t − 1 h_{t - 1} ht−1 进行元素级乘法(哈达玛积),并将其与 ( 1 − z t ) (1 - z_t) (1−zt) 和 h t ′ h'_t ht′ 之间的哈达玛积运算结果相加。
再回到度假村评价的例子:这次,用于预测的相关信息在文本的开头就被提到了。模型会将更新门的向量值设置为接近 1。在当前时间步, 1 − z t 1 - z_t 1−zt 将接近 0,它将忽略评价最后部分的内容。请参考下面的图片。
接着看,你可以看到 z t z_t zt 被用于计算 ( 1 − z t ) (1 - z_t) (1−zt), ( 1 − z t ) (1 - z_t) (1−zt) 与 h t ′ h'_t ht′ 结合以产生结果。在 h t − 1 h_{t - 1} ht−1 和 z t z_t zt 之间进行哈达玛积运算。该乘积的输出作为输入,与 h t ′ h'_t ht′ 进行逐元素相加,以在隐藏状态中产生最终结果。
为了巩固我们所涵盖的概念,让我们通过实践的方式,用Python从零开始实现一个基本的门控循环单元(GRU)。
下面的代码片段展示了一个简化的GRU类,突出了GRU架构中前向传播和反向传播的基本功能。
import numpy as np
class SimpleGRU:
def __init__(self, input_size, hidden_size, output_size):
self.input_size = input_size
self.hidden_size = hidden_size
self.output_size = output_size
# 初始化权重和偏置
self.W_z = np.random.randn(hidden_size, input_size)
self.U_z = np.random.randn(hidden_size, hidden_size)
self.b_z = np.zeros((hidden_size, 1))
self.W_r = np.random.randn(hidden_size, input_size)
self.U_r = np.random.randn(hidden_size, hidden_size)
self.b_r = np.zeros((hidden_size, 1))
self.W_h = np.random.randn(hidden_size, input_size)
self.U_h = np.random.randn(hidden_size, hidden_size)
self.b_h = np.zeros((hidden_size, 1))
self.W_y = np.random.randn(output_size, hidden_size)
self.b_y = np.zeros((output_size, 1))
def sigmoid(self, x):
return 1 / (1 + np.exp(-x))
def tanh(self, x):
return np.tanh(x)
def softmax(self, x):
exp_x = np.exp(x - np.max(x))
return exp_x / exp_x.sum(axis=0, keepdims=True)
def forward(self, x):
T = len(x)
h = np.zeros((self.hidden_size, 1))
y_list = []
for t in range(T):
x_t = x[t].reshape(-1, 1) # 将输入重塑为列向量
# 更新门
z = self.sigmoid(np.dot(self.W_z, x_t) + np.dot(self.U_z, h) + self.b_z)
# 重置门
r = self.sigmoid(np.dot(self.W_r, x_t) + np.dot(self.U_r, h) + self.b_r)
# 候选隐藏状态
h_tilde = self.tanh(np.dot(self.W_h, x_t) + np.dot(self.U_h, r * h) + self.b_h)
# 隐藏状态更新
h = (1 - z) * h + z * h_tilde
# 输出
y = np.dot(self.W_y, h) + self.b_y
y_list.append(y)
return y_list
def backward(self, x, y_list, target):
T = len(x)
dW_z = np.zeros_like(self.W_z)
dU_z = np.zeros_like(self.U_z)
db_z = np.zeros_like(self.b_z)
dW_r = np.zeros_like(self.W_r)
dU_r = np.zeros_like(self.U_r)
db_r = np.zeros_like(self.b_r)
dW_h = np.zeros_like(self.W_h)
dU_h = np.zeros_like(self.U_h)
db_h = np.zeros_like(self.b_h)
dW_y = np.zeros_like(self.W_y)
db_y = np.zeros_like(self.b_y)
dh_next = np.zeros_like(y_list[0])
for t in reversed(range(T)):
dy = y_list[t] - target[t]
dW_y += np.dot(dy, np.transpose(h))
db_y += dy
dh = np.dot(np.transpose(self.W_y), dy) + dh_next
dh_tilde = dh * (1 - self.sigmoid(np.dot(self.W_z, x[t].reshape(-1, 1)) + np.dot(self.U_z, h) + self.b_z))
dW_h += np.dot(dh_tilde, np.transpose(x[t].reshape(1, -1)))
db_h += dh_tilde
dr = np.dot(np.transpose(self.W_h), dh_tilde)
dU_h += np.dot(dr * h * (1 - self.tanh(np.dot(self.W_h, x[t].reshape(-1, 1)) + np.dot(self.U_h, r * h) + self.b_h)), np.transpose(h))
dW_h += np.dot(dr * h * (1 - self.tanh(np.dot(self.W_h, x[t].reshape(-1, 1)) + np.dot(self.U_h, r * h) + self.b_h)), np.transpose(x[t].reshape(1, -1)))
db_h += dr * h * (1 - self.tanh(np.dot(self.W_h, x[t].reshape(-1, 1)) + np.dot(self.U_h, r * h) + self.b_h))
dz = np.dot(np.transpose(self.U_r), dr * h * (self.tanh(np.dot(self.W_h, x[t].reshape(-1, 1)) + np.dot(self.U_h, r * h) + self.b_h) - h_tilde))
dU_z += np.dot(dz * h * z * (1 - z), np.transpose(h))
dW_z += np.dot(dz * h * z * (1 - z), np.transpose(x[t].reshape(1, -1)))
db_z += dz * h * z * (1 - z)
dh_next = np.dot(np.transpose(self.U_z), dz * h * z * (1 - z))
return dW_z, dU_z, db_z, dW_r, dU_r, db_r, dW_h, dU_h, db_h, dW_y, db_y
def update_parameters(self, dW_z, dU_z, db_z, dW_r, dU_r, db_r, dW_h, dU_h, db_h, dW_y, db_y, learning_rate):
self.W_z -= learning_rate * dW_z
self.U_z -= learning_rate * dU_z
self.b_z -= learning_rate * db_z
self.W_r -= learning_rate * dW_r
self.U_r -= learning_rate * dU_r
self.b_r -= learning_rate * db_r
self.W_h -= learning_rate * dW_h
self.U_h -= learning_rate * dU_h
self.b_h -= learning_rate * db_h
self.W_y -= learning_rate * dW_y
self.b_y -= learning_rate * db_y
# 示例用法
input_size = 4
hidden_size = 3
output_size = 2
gru = SimpleGRU(input_size, hidden_size, output_size)
# 生成随机数据
sequence_length = 5
data = [np.random.randn(input_size) for _ in range(sequence_length)]
target = [np.random.randn(output_size) for _ in range(sequence_length)]
# 前向传播
y_list = gru.forward(data)
# 反向传播
dW_z, dU_z, db_z, dW_r, dU_r, db_r, dW_h, dU_h, db_h, dW_y, db_y = gru.backward(data, y_list, target)
# 更新权重和偏置
learning_rate = 0.1
gru.update_parameters(dW_z, dU_z, db_z, dW_r, dU_r, db_r, dW_h, dU_h, db_h, dW_y, db_y, learning_rate)
在上述实现中,我们引入了一个简化的 SimpleGRU
类,以深入了解GRU的核心机制。示例用法展示了如何初始化GRU,为输入序列和目标输出创建随机数据,执行前向传播和反向传播,然后使用计算得到的梯度更新权重和偏置。
使用门控循环单元(GRU)还是长短期记忆(LSTM)网络的决策取决于你具体的问题和数据集。以下是一些需要考虑的因素:
在实践中,最好在你特定的任务上同时对GRU和LSTM进行实验,以确定哪种架构的性能更好。有时,两者之间的选择取决于对你的数据集进行的实证测试和验证。
在本文中,我们探讨了循环神经网络(RNN),深入研究了它们的核心机制、训练挑战以及能够提升其性能的先进设计。以下是一个快速回顾:
torch.nn.utils.clip_grad_norm_
来裁剪梯度。