构建自定义LSTM单元的循环神经网络指南

循环神经网络:构建自定义LSTM单元

简单RNN单元

循环细胞是用于处理序列数据的小型神经网络。与专门处理网格结构值(如图像)的卷积层不同,循环层专为处理长序列而设计,无需任何基于序列的额外设计选择。

通过将时间步输出连接到输入,可以实现这一目标!这称为序列展开。通过处理整个序列,我们获得了一个考虑序列先前状态的算法。通过这种方式,我们有了第一个记忆概念(细胞)!

大多数常见循环细胞还可以处理可变长度的序列。这对许多应用(如包含不同数量图像的视频)非常重要。可以将RNN细胞视为具有共享权重的普通神经网络,用于多个时间步。通过这种修改,细胞的权重现在可以访问序列的先前状态。

时间反向传播是什么?

RNN网络的魔力在于输入展开。这意味着给定长度为N的序列,将输入处理为时间步。

选择使用RNN建模时间维度,是因为我们希望学习时间依赖性,通常是长期依赖性。目前,卷积确实无法处理,因为它们具有有限的感受野。理论上,可以在任何维度应用循环模型。

在训练RNN模型时,问题在于现在我们有一个时间序列。这就是为什么输入展开是使反向传播工作的唯一方式!

那么如何学习时间序列?理想情况下,我们希望细胞的记忆(参数)考虑所有输入序列。否则,我们将无法学习所需的映射。本质上,反向传播需要为每个时间步设置一个单独的层,所有层具有相同的权重(输入展开)!

时间反向传播基于上述观察创建。因此,基于分块(展开)的输入,我们可以计算每个时间步的不同损失。然后,我们可以将多个损失的误差反向传播到记忆细胞。在这个方向上,可以从多个路径(时间步)计算梯度,然后相加计算最终梯度。因此,我们可能在循环架构中使用不同的优化器或归一化方法。

LSTM:长短期记忆细胞

为什么选择LSTM?

该领域最基础的工作之一是Greff等人2016年的研究。简而言之,他们表明与LSTM相比,在大规模研究中RNN的 proposed 变体没有提供任何显著改进。因此,LSTM是RNN中的主导架构。

LSTM如何工作?

在所有的方程中,权重矩阵(W)都有索引,第一个索引是它们处理的向量,而第二个索引指的是表示(即输入门、遗忘门)。

为避免混淆并最大化理解,我们将使用常见表示法:矩阵用大写粗体字母表示,向量用小写粗体字母表示。对于逐元素乘法,使用带外圈的点符号,在文献中称为Hadamard积。

LSTM细胞方程

对于 $x_t \in R^{N}$,其中N是每个时间步的特征长度,而 $i_t,f_t,o_t,h_t,h_{t-1},c_t,c_{t-1},b \in R^{H}$,其中H是隐藏状态维度,LSTM方程如下:

$i_t = \sigma( W_{xi} x_t + W_{hi} h_{t-1} + W_{ci} c_{t-1} + b_i) \quad\quad(1)$

$f_t = \sigma( W_{xf} x_t + W_{hf} h_{t-1} + W_{cf} c_{t-1} + b_f) \quad\quad(2)$

$c_t = f_t \odot c_{t-1} + i_t \odot tanh( W_{xc} x_t + W_{hc} h_{t-1} + b_c ) \quad\quad(3)$

$o_t = \sigma( W_{xo} x_t + W_{h0} h_{t-1} + W_{co} c_{t} + b_o) \quad\quad(4)$

$h_t = o_t \odot tanh(c_t) \quad\quad(5)$

LSTM细胞方程基于PyTorch文档编写,因为在项目中可能会使用现有层。在原始论文中,$c_{t-1}$ 包含在方程(1)和(2)中,但可以省略它。

方程1:输入门

$i_t = \sigma( W_{xi} x_t + W_{hi} h_{t-1} + W_{ci} c_{t-1} + b_i)$

描绘的权重矩阵表示细胞的记忆。输入 $x_t$ 在当前输入时间步,而h和c用前一个时间步索引。每个矩阵W是一个线性层(或简单的矩阵乘法)。这个方程使我们能够:

a) 获取x、h、c的多个线性组合
b) 将输入x的维度与h和c的维度匹配

h和c的维度基本上是深度学习框架中的隐藏状态参数。偏置项是线性层的一部分,只是一个可训练向量相加。输出也在隐藏和上下文/细胞向量的维度中。最后,在来自不同输入的3个线性层之后,我们有一个非线性激活函数来引入非线性,这使得能够学习更复杂的表示。在这种情况下,通常使用sigmoid函数。

方程2:遗忘门

$f_t = \sigma( W_{xf} x_t + W_{hf} h_{t-1} + W_{cf} c_{t-1} + b_f)$

简单来说,方程2与方程1完全相同。但是,请注意这次权重矩阵不同。这意味着我们得到一组不同的线性组合,代表不同的东西!

方程3:新细胞/上下文向量

$c_t = f_t \odot c_{t-1} + i_t \odot tanh( W_{xc} x_t + W_{hc} h_{t-1} + b_c ) \quad\quad(3)$

我们已经学习了一个表示"遗忘"的表示,以及建模"输入向量"的表示,分别是f和i。让我们先把它们放在一边,首先检查tanh括号。

这里有另一个输入和隐藏向量的线性组合,这次完全不同!这个项是新的细胞信息,通过tanh函数传递以引入非线性和稳定训练。

但我们不想简单地用新状态更新细胞。直观地说,我们希望考虑先前的状态;这就是我们设计RNN的原因!这就是计算的输入门向量i发挥作用的地方。我们通过应用与输入门向量i的逐元素乘法来过滤新的细胞信息(类似于信号处理中的滤波器)。

遗忘门向量现在开始发挥作用。我们不是仅仅添加过滤后的输入信息,而是首先与先前的上下文向量执行逐元素向量乘法。为此,我们希望模型模仿人类的遗忘概念作为乘法滤波器。

通过将前面描述的项添加到tanh括号中,我们得到新的细胞状态,如方程3所示。

方程4:几乎新的输出

$o_t = \sigma( W_{xo} x_t + W_{h0} h_{t-1} + W_{co} c_{t} + b_o)$

很简单!让我们再进行一次线性组合!这次是我们的3个向量 $x_t, h_{t-1}, c_t$,同时在最后添加另一个非线性函数。注意,与方程1和2相反,我们现在将使用计算的新细胞状态(c_t)。我们几乎计算了特定时间步中单个细胞所需输出向量 $o_t$。

方程5:新上下文

$h_t = o_t tanh(c_t) \quad\quad(5)$

请注意,方程4中的标题是"几乎新的输出"。因此,可以基于方程5计算新输出(字面意思是新的隐藏状态)。想象一下,我们想以某种方式将新的上下文向量 $c_t$(经过另一个激活后!)与计算的输出向量 $o_t$ 混合。这正是我们声称LSTM建模上下文信息的点。我们不是产生方程4中所示的输出,而是进一步注入称为上下文的向量。回顾方程1和2,可以观察到先前的上下文参与其中。通过这种方式,基于先前时间步的信息被纳入。这种上下文概念使得能够在长期序列中建模时间相关性。

基本上,单个细胞接收来自先前时间步的细胞和隐藏状态,以及当前时间步的输入向量作为输入。

每个LSTM细胞输出新的细胞状态和隐藏状态,将用于处理下一个时间步。如果需要,细胞的输出(例如在下一层中)是其隐藏状态。

在PyTorch中编写自定义LSTM细胞 - LSTM的简化

基于我们当前的理解,让我们看看LSTM细胞的实现是什么样的。此实现使用了PyTorch。

多年来,原始LSTM的一个更简单版本经受住了时间的考验。为此,现代深度学习框架使用稍微简化的LSTM版本。实际上,它们从方程(1)和(2)中忽略了 $c_{t-1}$。我们也会这样做。这产生了一个不太复杂且更容易优化的模型。因此,我们将实现以下方程:

$i_t = \sigma( W_{xi} x_t + W_{hi} {h}_{t-1} + {b}_i) \quad\quad(1)$

${f}t = \sigma( {W} {x}t + {W} {h}_{t-1} + {b}_f) \quad\quad(2)$

${c}t = {f}t \odot {c} + {i}t \odot tanh( {W} x_t + {W} {h}_{t-1} + {b}_c ) \quad\quad(3)$

${o}t = \sigma( {W} {x}t + {W} {h}{t-1} + {W} {c}_{t} + {b}_o) \quad\quad(4)$

${h}_t = {o}_t \odot tanh({c}_t) \quad\quad(5)$

尽管如此,根据原始实现调整代码并在这个简单任务中比较结果会很有趣。PyTorch和Tensorflow在底层运行的简化LSTM代码如下:

import torch
from torch import nn

class LSTM_cell_AI_SUMMER(torch.nn.Module):
    """
    A simple LSTM cell network for educational AI-summer purposes
    """
    def __init__(self, input_length=10, hidden_length=20):
        super(LSTM_cell_AI_SUMMER, self).__init__()
        self.input_length = input_length
        self.hidden_length = hidden_length

        # forget gate components
        self.linear_forget_w1 = nn.Linear(self.input_length, self.hidden_length, bias=True)
        self.linear_forget_r1 = nn.Linear(self.hidden_length, self.hidden_length, bias=False)
        self.sigmoid_forget = nn.Sigmoid()

        # input gate components
        self.linear_gate_w2 = nn.Linear(self.input_length, self.hidden_length, bias=True)
        self.linear_gate_r2 = nn.Linear(self.hidden_length, self.hidden_length, bias=False)
        self.sigmoid_gate = nn.Sigmoid()

        # cell memory components
        self.linear_gate_w3 = nn.Linear(self.input_length, self.hidden_length, bias=True)
        self.linear_gate_r3 = nn.Linear(self.hidden_length, self.hidden_length, bias=False)
        self.activation_gate = nn.Tanh()

        # out gate components
        self.linear_gate_w4 = nn.Linear(self.input_length, self.hidden_length, bias=True)
        self.linear_gate_r4 = nn.Linear(self.hidden_length, self.hidden_length, bias=False)
        self.sigmoid_hidden_out = nn.Sigmoid()

        self.activation_final = nn.Tanh()

    def forget(self, x, h):
        x = self.linear_forget_w1(x)
        h = self.linear_forget_r1(h)
        return self.sigmoid_forget(x + h)

    def input_gate(self, x, h):
        # Equation 1. input gate
        x_temp = self.linear_gate_w2(x)
        h_temp = self.linear_gate_r2(h)
        i = self.sigmoid_gate(x_temp + h_temp)
        return i

    def cell_memory_gate(self, i, f, x, h, c_prev):
        x = self.linear_gate_w3(x)
        h = self.linear_gate_r3(h)

        # new information part that will be injected in the new context
        k = self.activation_gate(x + h)
        g = k * i

        # forget old context/cell info
        c = f * c_prev
        # learn new context/cell info
        c_next = g + c
        return c_next

    def out_gate(self, x, h):
        x = self.linear_gate_w4(x)
        h = self.linear_gate_r4(h)
        return self.sigmoid_hidden_out(x + h)

    def forward(self, x, tuple_in ):
        (h, c_prev) = tuple_in
        # Equation 1. input gate
        i = self.input_gate(x, h)

        # Equation 2. forget gate
        f = self.forget(x, h)

        # Equation 3. updating the cell memory
        c_next = self.cell_memory_gate(i, f, x, h,c_prev)

        # Equation 4. calculate the main output gate
        o = self.out_gate(x, h)

        # Equation 5. produce next hidden output
        h_next = o * self.activation_final(c_next)

        return h_next, c_next

跨时间和空间连接LSTM细胞

让我们看看LSTM如何在时间和空间中连接。让我们从时间角度开始,考虑一个N个时间步的单个序列和一个细胞,这样更容易理解。

如第一张图像所示,我们连接上下文向量和隐藏状态向量,即所谓的展开。类似地,我们在相应的时间步连接输入序列,x_3到展开的细胞3,等等。展开的共享权重细胞的大小对应于输入序列时间步。当从时间角度思考时,请在脑海中保持展开。

现在让我们想象如何在空间中连接细胞。假设我们有2个细胞和一个单时间步。

这2个细胞更像是不同的层。要理解这一点,只需想到上下文向量封装在细胞内,而隐藏状态向量是输出。因此,我们只需要将第一个细胞的输出隐藏状态作为输入向量插入下一个细胞,如下所示:

$hid_{cell1}^{out}(t)= x_{cell2}^{in}(t)$

但是,不要将其与第二个细胞的隐藏向量混淆,因为它完全不同!更少字面上,先前细胞状态的隐藏输出作为输入向量连接到下一个。最终输出是最后一个隐藏细胞!另外,请记住不同的任务可能需要所有隐藏层输出。

验证:使用LSTM学习正弦波

对于这个概念验证,我们使用了官方的PyTorch示例来测试LSTM细胞。代码已授权给作者权利。我们创建了一个交互式Google Collab笔记本,以便您可以重现我们的结果。基本上,我们所做的是用本教程中 presented 的自己的实现替换 torch.nn.LSTMcell()。您可以使用我们的Google colab笔记本进行实验,但请注意此示例的原始学分属于官方PyTorch存储库。我们稍微修改它以最大化理解,用于严格的教育目的。任务是预测正弦波序列的值。网络将随后给出一些预测结果,显示为虚线。

简单替换:

self.lstm1 = nn.LSTMCell(1, 51)
self.lstm2 = nn.LSTMCell(51, 51)

使用我们自己的自定义实现,并将所有内容组合在一个笔记本中:

self.lstm1 = LSTM_cell_AI_SUMMER(1,51)
self.lstm2 = LSTM_cell_AI_SUMMER(51,51)

通过这种方式,我们专注于确保其余代码正常工作,如果遇到问题,它将来自我们的自定义代码。让我们看看结果!

结果:

Voila!

我们有了第一个验证,证明实现是正确的!尽管这不是一个伟大的任务,但在实现自定义层时执行这些类型的健全性检查很重要。此外,依我拙见,它增强了我们的理解,因为我们专注于掌握简单概念,而不是仅仅潜入复杂任务,如活动识别。

双向LSTM及其PyTorch文档

在我们迄今为止描述的方法中,我们从t=0到t=N处理时间步。然而,扩展这个想法的一种自然方式是从结束到开始处理输入序列。换句话说,我们从结束(t=N)开始向后(直到t=0)。相反方向的处理序列由不同的LSTM处理,但具有相同的架构。

在下一个项目中设置 bidirectional=True 之前,请考虑其影响。您想从头到尾学习时间相关性吗?它在您的问题中提供任何意义吗?您能否对数据做出任何假设来帮助您决定?请注意,通过指定LSTM为双向,您将参数数量加倍。最后,隐藏/输出向量大小也加倍,因为具有不同方向的LSTM的两个输出被连接。

最后,让我们重新审视PyTorch的LSTM模型的文档参数。层是我们想要放在一起的细胞数量,如我们所述。有时,在LSTM细胞之间添加dropout。

  • input_size – 输入x中预期特征的数量
  • hidden_size – 隐藏状态h中的特征数量
  • num_layers – 循环层数。例如,设置num_layers=2将意味着堆叠两个LSTM在一起形成堆叠LSTM,第二个LSTM接收第一个LSTM的输出并计算最终结果。默认值:1
  • bias – 如果为False,则该层不使用偏置权重b_ih和b_hh。默认值:True
  • batch_first – 如果为True,则输入和输出张量提供为(batch, seq, feature)。默认值:False
  • dropout – 如果非零,则在每个LSTM层(最后一层除外)的输出上引入Dropout层,dropout概率等于dropout。默认值:0
  • bidirectional – 如果为True,则变为双向LSTM。默认值:False

使用循环模型的输入到输出映射

为避免关于循环层的可能混淆,让我们先看下面的图像:

  • 红框表示输入到隐藏状态
  • 绿框表示隐藏到隐藏状态
  • 蓝框表示隐藏到输出状态

在实现这样的模型时,精确的输入和输出可能非常混乱和令人沮丧,因为时间的概念在深度学习中通常违反直觉。这就是为什么我想说明循环模型在从输入到输出序列的映射中非常灵活。只需根据问题修改输入到隐藏状态和隐藏到输出状态。根据定义,LSTM可以处理任意输入时间步。可以通过设计使用最后一个隐藏到隐藏层的哪些输出来计算所需输出来调整输出。

建模大维度的理论限制:循环VS卷积

将我们的讨论更进一步,您可以通过循环或卷积建模任何维度。为什么选择一个而不是另一个?您寻找的隐藏魔法词是感受野。根据问题,您需要特定大小的感受野。理论上,RNN可以建模无限大小的维度,这意味着它们的感受野是无限的。为此,我们仍然需要RNN来处理真正长期的依赖性,如口语和自然语言处理。但是,是否可以使用带有循环单元的预训练模型正在讨论中,这是一个实质性的缺点。另一方面,卷积神经网络具有有限的感受野。尽管如此,您可以做很多技巧来增加它,例如扩张卷积。

讨论和结论

作为最后说明,循环神经网络的思想可以推广到多个维度,如Graves等人2007年所述。理论上,我们可以有2D或一般N维展开,而不是1D输入展开。这项工作由Alex Graves presented,依我拙见,这是一个惊人的概念。另一个有趣的方法是在图结构数据中应用新近性。

用一句紧凑的话,我想说RNN的魔力在于能够通过上下文信息有效地建模长期依赖性。鉴于您理解了主要原理,您可以继续学习Google的一个精美TensorFlow教程,该教程 presents 了使用RNN进行文本生成的非常详细的方法。

为了进一步阅读,我建议这篇很棒的博客文章,它提供了关于提高循环层性能的技巧。或者,您可以观看DeepMind最近发布的精彩演讲:

尽管如此,如果您想要更全面的循环网络方法,Coursera有一个优秀的课程,我们强烈推荐。话虽如此,我们相信我们为所有不同类型的学习者提供了资源。请参阅此处以获取有关RNN优化的更详细分析。

总之,本文作为循环神经网络多个概念的说明。我们仔细建立在思想上,以理解处理时变数据的序列模型。我们尽力弥合计算机视觉中RNN之间的差距。在下一部分中,我们将看到GRU细胞的内部结构并并排分析它们。
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)或者 我的个人博客 https://blog.qife122.com/
对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)

公众号二维码

公众号二维码

posted @ 2025-10-16 19:15  CodeShare  阅读(5)  评论(0)    收藏  举报