LLM架构解析:循环神经网络(RNN)(第三部分)—— 从基础原理到实践应用的深度探索

发布于:2025-04-06 ⋅ 阅读:(13) ⋅ 点赞:(0)

本专栏深入探究从循环神经网络(RNN)到Transformer等自然语言处理(NLP)模型的架构,以及基于这些模型构建的应用程序。

本系列文章内容:

  1. NLP自然语言处理基础
  2. 词嵌入(Word Embeddings)
  3. 循环神经网络(RNN)、长短期记忆网络(LSTM)和门控循环单元(GRU)
    3.1 循环神经网络(RNN)(本文)
    3.2 长短期记忆网络(LSTM)
    3.3 门控循环单元(GRU)
  4. 编码器 - 解码器架构(Encoder - Decoder Architecture)
  5. 注意力机制(Attention Mechanism)
  6. Transformer
  7. 编写Transformer代码
  8. 双向编码器表征来自Transformer(BERT)
  9. 生成式预训练Transformer(GPT)
  10. 大语言模型(LLama)
  11. Mistral

1. 什么是神经网络?

神经网络,或称为人工神经网络(artificial neural network),是一种基于人类大脑功能模型的计算架构——因而得名“神经(neural)”。神经网络由一组被称为“节点”的处理单元组成。这些节点相互传递数据,就如同在大脑中,神经元相互传递电脉冲一样。

神经网络被应用于机器学习(machine learning)领域,机器学习指的是一类无需明确指令就能学习的计算机程序。具体而言,神经网络用于深度学习(deep learning)——一种先进的机器学习类型,它能够在无需人工干预的情况下,从无标签数据中得出结论。例如,一个基于神经网络构建并输入了足够训练数据的深度学习模型,或许能够识别出它之前从未见过的照片中的物体。
在这里插入图片描述

神经网络使得许多类型的人工智能(AI,artificial intelligence)成为可能。诸如ChatGPT这样的大语言模型(LLMs,Large language models)、像DALL-E这样的人工智能图像生成器,以及预测性人工智能模型,在一定程度上都依赖于神经网络。

1.1 神经网络是如何学习的?

神经网络的学习(训练)过程是一个迭代过程,在这个过程中,计算会在网络的每一层中进行前向和后向传递,直到损失函数最小化。

整个学习过程可以分为三个主要部分:

  • 前向传播(Forward pass)
  • 损失函数的计算
  • 反向传播(Backward pass/Backpropagation)

在这里插入图片描述
我们将从正向传播开始讲起。

前向传播

神经网络由多个神经元(感知器)组成,这些神经元被堆叠成层。层与层之间的连接是通过网络的参数(用箭头表示)实现的。这些参数是权重偏置

权重控制着每个输入的重要程度,而偏置决定了神经元触发或激活的难易程度。

首先,我们给权重和偏置赋予非零的随机值。这被称为网络的参数初始化。基于这些赋予的值和输入值,我们在网络的每个神经元中进行以下计算:

  • 神经元线性函数的计算
  • 神经元激活函数的计算

这些计算会在整个网络中进行。在完成输出层节点的计算后,我们就得到了第一次迭代中前向传播部分的最终输出。

在这里插入图片描述
在这里插入图片描述

在前向传播中,计算是从输入层到输出层(从左到右)在整个网络中进行的。

损失函数的计算

在前向传播中得到的最终输出被称为预测值。这个值需要与相应的真实值(实际值)进行比较,以衡量神经网络的性能。这就是损失函数(也称为目标函数或代价函数)发挥作用的地方。

在神经网络的背景下,代价函数和损失函数是相关的,但它们指的是模型性能评估的不同方面。

损失函数衡量单个样本的误差,而代价函数则汇总整个数据集上的这种误差,以指导训练。

在这里插入图片描述
在这里插入图片描述

损失函数计算出一个称为损失分数的数值,它介于预测值和真实值之间。这也被称为模型的误差。损失函数反映了模型在每次迭代中的表现。我们使用损失分数作为反馈信号,以便在反向传播部分更新参数。

损失函数的理想值是零(0)。我们的目标是在每次迭代中使损失函数尽可能最小化并接近0,这样模型就能做出更接近真实值的更好预测。

以下是神经网络训练中常用的损失函数列表:

  • 均方误差(MSE,Mean Squared Error)—— 用于衡量回归问题的性能。
  • 平均绝对误差(MAE,Mean Absolute Error)—— 用于衡量回归问题的性能。
  • 平均绝对百分比误差 —— 用于衡量回归问题的性能。
  • Huber损失 —— 用于衡量回归问题的性能。
  • 二元交叉熵(对数损失,Binary Cross-entropy (Log Loss))—— 用于衡量二元(二分类)分类问题的性能。
  • 多分类交叉熵/分类交叉熵(Multi-class Cross-entropy/Categorical Cross-entropy)—— 用于衡量多分类(超过两类)分类问题的性能。

在这里插入图片描述

可以在此处找到Keras中可用的损失函数的完整列表

反向传播

在第一次迭代中,预测值与真实值相差很远,距离分数会很高。这是因为我们最初给网络的参数(权重和偏置)赋予了任意值。这些值并非最优值。所以,我们需要更新这些参数的值,以便最小化损失函数。更新网络参数的过程称为参数学习或优化,这是通过实现反向传播的优化算法(优化器)来完成的。

优化算法的目标是找到损失函数取最小值的全局最小值点。然而,对于一个优化算法来说,要在避开所有局部最小值的情况下找到复杂损失函数的全局最小值,确实是一个挑战。如果算法在局部最小值处停止,我们就无法得到损失函数的最小值。因此,我们的模型表现就不会好。

在这里插入图片描述
以下是神经网络训练中常用的优化器列表:

  • 梯度下降(Gradient Descent)
  • 随机梯度下降(SGD,Stocasticc Gradeint Descent 此处应为 Stochastic Gradient Descent)
  • Adam
  • Adagrad
  • Adadelta
  • Adamax
  • Nadam
  • Ftrl
  • 均方根传播(RMSProp,Root Mean Squared Propagation)

在这里插入图片描述

在反向传播中,要计算损失函数关于每一层模型参数的偏导数(梯度)。这是通过应用微积分中的链式法则来完成的。

损失函数的导数就是它的斜率,它为我们提供了更新(改变)模型参数值时应该考虑的方向。

Keras中的神经网络库提供自动求导功能。这意味着,在你定义了神经网络架构之后,这些库会自动计算反向传播所需的所有导数。

在反向传播中,计算是从输出层到输入层(从右到左)在网络中进行的。

1.2 轮次(Epochs)、批量大小(Batch Size)与迭代次数(Iterations)

轮次(Epochs)

一轮(Epoch)是指整个数据集仅在神经网络中进行一次前向和后向传播。

由于一轮的数据量太大,无法一次性输入到计算机中,所以我们将其划分为几个较小的批次。

批量大小(Batch Size)

单个批次中包含的训练样本的总数。

注意:批量大小和批次数量是两个不同的概念。

但是,什么是一个批次(Batch)呢?

我们无法一次性将整个数据集输入到神经网络中。因此,我们将数据集划分为若干个批次、集合或部分。

迭代次数(Iterations)

迭代次数是完成一轮训练所需的批次数量。

注意:对于一轮训练来说,批次数量等于迭代次数。

假设我们有1000个要使用的训练样本。我们可以将这1000个样本的数据集划分为大小为500的批次,那么完成一轮训练就需要进行2次迭代,此时批量大小为500,迭代次数为2,这是完整的一轮训练情况(见下图中的情况2)。

在神经网络训练过程中,我们通常不会在一次迭代中使用所有的训练样本(实例/行)。相反,我们会指定批量大小,它决定了在训练过程中进行(前向和后向)传播的训练样本数量。
在这里插入图片描述

1.3 神经网络的类型

神经网络的节点数量和层数没有限制,并且这些节点几乎可以以任何方式进行交互。正因如此,神经网络的类型列表在不断扩充。不过,它们大致可以分为以下几类:

  • 浅层神经网络(通常只有一个隐藏层)
  • 深层神经网络(有多个隐藏层)

浅层神经网络运行速度快,与深层神经网络相比所需的处理能力更低,但它们无法像深层神经网络那样执行那么多复杂的任务。

以下是如今可能会用到的神经网络架构类型列表:

  • 感知器神经网络是简单的浅层网络,由一个输入层和一个输出层组成。
    在这里插入图片描述

  • 多层感知器神经网络在感知器网络的基础上增加了复杂性,包含一个隐藏层。
    在这里插入图片描述

  • 前馈神经网络只允许其节点将信息传递给下一个前向节点。
    在这里插入图片描述

  • 循环神经网络可以进行反向操作,允许某些节点的输出影响先前节点的输入。
    在这里插入图片描述

  • 模块化神经网络将两个或更多的神经网络组合起来以得出输出结果。
    在这里插入图片描述

  • 径向基函数(Radial basis function)神经网络的节点使用一种特定的数学函数,称为径向基函数。
    在这里插入图片描述

  • 液态机(Liquid state machine)神经网络的特点是节点之间随机连接。
    在这里插入图片描述

  • 残差神经网络允许数据通过一种称为恒等映射的过程直接跳过某些层,将早期层的输出与后期层的输出相结合。
    在这里插入图片描述

本篇博客主要关注循环神经网络(RNNs,Recurrent Neural Networks)。

2. 循环神经网络(RNNs,Recurrent Neural Networks)

循环神经网络(RNN)是一种专门设计用于处理序列数据的神经网络架构,它通过保留先前输入的记忆来实现这一功能。这是通过在网络中形成循环的连接来达成的,这些连接使得信息能够持续存在。与传统的前馈神经网络不同,前馈神经网络假设输入之间是相互独立的,而RNN则利用其内部状态(记忆)来处理输入序列。这使得它们在输入顺序很重要的任务中特别有用,比如时间序列数据处理、语言建模或视频序列分析等任务。

循环单元是用于处理序列数据的神经网络(通常规模较小)。正如我们所知,卷积层专门用于处理网格结构的值(即图像)。相反,循环层是为处理长序列而设计的,无需任何额外的基于序列的设计选择。

人们可以通过将时间步的输出连接到输入来实现这一点!这被称为序列展开。通过处理整个序列,我们得到了一种考虑到序列先前状态的算法。这样,我们就有了记忆(单元)的第一个概念!让我们来看一下:

在这里插入图片描述

简单循环神经网络单元

大多数常见的循环单元也能够处理可变长度的序列。这对于许多应用来说非常重要,例如视频,视频中包含数量不同的图像。人们可以将循环单元看作是一个在多个时间步中共享权重的普通神经网络。通过这种修改,单元的权重现在可以访问序列的先前状态。

2.1 什么是序列数据?

序列数据是具有特定顺序且顺序很重要的信息。序列中的每一个数据都与它前后的数据相关,并且这个顺序为整体数据提供了上下文和意义。

下面有一个例子来说明:

想象一个像“The quick brown fox jumps over the lazy dog.”这样的句子。句子中的每个单词都是一个数据。单词的顺序至关重要,因为它决定了句子的意思。“Fox brown quick the jumps over lazy dog” 就没什么意义了,对吧?

以下是一些其他常见的序列数据类型:

  • 时间序列数据:这是指随时间按固定间隔收集的数据点。例如股票价格、温度读数或网站流量。数据点的顺序很重要,因为它显示了值随时间的变化情况。
  • 自然语言文本:所有书面语言都是序列性的。句子或段落中单词的顺序对于传达意思以及理解观点之间的关系至关重要。
  • 语音信号:口语也是序列数据的一个例子。音素、音节和单词等声音的顺序对于理解口语信息至关重要。

2.2 循环神经网络与前馈神经网络

在这里插入图片描述

前馈人工神经网络只允许数据沿一个方向流动,即从输入到输出。这种网络的架构采用自上而下的方法,并且没有循环,也就是说,任何一层的输出都不会影响该层本身。它们主要用于模式识别

循环神经网络通过在网络中使用反馈循环,使信号能够在两个方向上传播。从早期输入中提取的特征会反馈到网络中,这使它们具有记忆能力。这些交互网络是动态的,因为在达到平衡点之前,其状态一直在不断变化。这些网络主要用于处理像时间序列这样的序列自相关数据。

2.3 为什么要使用循环神经网络?

传统的人工神经网络(ANNs)是强大的工具,但它们在处理像文本这样的序列数据时会遇到困难,因为它们需要固定大小的输入。人工神经网络中的每个输入都是独立处理的,这使得它们不适合处理元素之间的顺序和关系至关重要的任务。

假设我们使用零填充的概念,即较短的序列在末尾用零进行填充,以达到批次中最长序列的长度。这些零充当占位符,不携带任何有意义的信息。填充引入了不相关的零,网络需要将这些零与实际数据一起处理,从而增加了计算负担。

而且由于在人工神经网络中输入数据时没有序列信息,我们会丢失上下文或序列信息。除此之外,如果任何用户输入的长度比我们预期的要大,在那种情况下我们无能为力。例如,我们设置输入大小为5个单词,但如果任何用户一次输入15个单词,在这种情况下我们无法用人工神经网络来处理。

3. 循环神经网络的架构

3.1 循环神经网络的时间展开

循环神经网络与其他神经网络的主要区别在于,它们具有内部状态或记忆,能够跟踪已处理的数据。从根本上讲,一个循环神经网络由三个关键部分组成:输入层、一个或多个隐藏层以及输出层。
在这里插入图片描述
在这里插入图片描述

输入层

这一层随着时间接收输入序列。与一次性处理所有输入的前馈网络不同,循环神经网络在每个时间步处理一个输入。这种顺序处理方式使网络能够保持随时间变化的动态特性。

我们将时间步长为 t t t 时的输入表示为 X t X_t Xt。这个输入会一次一步地输入到循环神经网络中。
在这里插入图片描述

其中 n x n_x nx 是输入层中的单元(神经元)数量。

例如,在Python中我们可以这样初始化输入层:

self.weights_ih = np.random.randn(input_size, hidden_size) * 0.01

这里,input_size 是输入层的大小(神经元数量),hidden_size 是隐藏层的大小。self.weights_ih 是连接输入层和隐藏层的权重矩阵,用正态分布的随机值进行初始化,并乘以 0.01 使其值较小。

隐藏状态

隐藏层在循环神经网络中至关重要,因为它们不仅处理当前输入,还保留来自先前输入的信息。这些信息存储在我们所说的隐藏状态中,并被传递到后续步骤,以影响未来的处理过程。这种传递信息的能力赋予了循环神经网络记忆功能。
在这里插入图片描述

时间步长为 t t t 时的隐藏状态 h t h_t ht 是根据当前输入 X t X_t Xt 和先前的隐藏状态 h ( t − 1 ) h_{(t - 1)} h(t1) 计算得出的。其表达式为:
在这里插入图片描述
其中:

  • h t h_t ht 是时间步长为 t t t 时的隐藏状态,
  • W W W 是隐藏层的权重矩阵,
  • b h b_h bh 是隐藏层的偏置向量,
  • f f f 是一个非线性激活函数,通常是 tanh ⁡ \tanh tanh(双曲正切函数)或 ReLU(修正线性单元)。

我们最初将隐藏状态设置为零:h = np.zeros((1, self.hidden_size))。这将用零初始化第一个隐藏状态 h h h,为处理序列中的第一个输入做好准备。

当循环神经网络处理序列中的每个输入时,会使用当前输入 x x x 和先前的隐藏状态 h h h 来计算新的隐藏状态。这发生在我们稍后将构建的 forward 方法中的循环内:

for i, x in enumerate(inputs):
    x = x.reshape(1, -1)  # 确保 x 是一个行向量
    h = np.tanh(np.dot(x, self.weights_ih) + np.dot(h, self.weights_hh) + self.bias_h)
    self.last_hs[i + 1] = h

在循环的每次迭代中,当前输入 x x x 被转换为一个行向量,然后与输入到隐藏层的权重矩阵 self.weights_ih 相乘。

同时,先前的隐藏状态 h h h 与隐藏层到隐藏层的权重矩阵 self.weights_hh 相乘。这两个操作的结果与隐藏层偏置 self.bias_h 相加。

然后将总和传递给 np.tanh 函数,该函数应用非线性变换,并产生当前时间步的新隐藏状态 h h h

这个新的隐藏状态 h h h 会以当前时间步为键存储在字典 self.last_hs 中。这使得网络能够“记住”每个步骤的隐藏状态,这对于训练过程中的随时间反向传播(BPTT,Backpropagation Through Time)至关重要。

输出序列

循环神经网络在输出结果方面具有灵活性。它们可以在每个时间步输出(多对多),在序列结束时产生单个输出(多对一),甚至可以从单个输入生成一个序列(一对多)。这种灵活性使得循环神经网络在诸如语言建模和时间序列分析等一系列任务中非常有用。
在这里插入图片描述

每个时间步的输出 O t O_t Ot 可以从隐藏状态计算得出。对于多对多的循环神经网络:
在这里插入图片描述

其中:

  • O t O_t Ot 是时间步长为 t t t 时的输出,
  • V V V 是输出层的权重矩阵,
  • b o b_o bo 是输出层的偏置向量。

对于多对一的循环神经网络,你只需要在最后一个时间步计算输出,而对于一对多的循环神经网络,你将从单个输入开始生成一个输出序列。

如果循环神经网络用于分类任务,计算出的输出 O t O_t Ot 通常会通过一个 softmax 函数,以获得不同类别的概率。
在这里插入图片描述

其中 P ( y t ∣ X t , h ( t − 1 ) ) P(y_t \mid X_t, h_{(t - 1)}) P(ytXt,h(t1)) 是在给定输入 (X_t) 和先前隐藏状态 (h_{(t - 1)}) 的情况下输出 (y_t) 的概率。

从输入到隐藏状态再到输出的一系列操作,体现了循环神经网络维护和利用时间信息的核心能力,使它们能够执行涉及序列和时间的复杂任务。

循环神经网络内部有一个循环,允许信息从模型的后期阶段反馈到早期阶段。这种循环机制使它们能够处理数据序列:它允许网络的输出影响同一网络后续处理的输入。这种根本区别使循环神经网络能够有效地执行涉及序列和时间序列数据的任务。
在这里插入图片描述

3.2 循环神经网络中的关键操作

在这里插入图片描述
理解循环神经网络(RNNs)的运行机制对于有效使用它们并提升其性能至关重要。让我们来剖析一下循环神经网络中的主要操作:

3.2.1 前向传播

在前向传播过程中,循环神经网络一次处理一个时间步的数据。对于每个时间步,它会将当前输入与先前的隐藏状态相结合,以计算出新的隐藏状态和输出。模型使用的是具有内在循环特性的特定函数,这意味着每个输出都依赖于之前的计算。像 sigmoid 或 tanh 这样的函数常被用于引入非线性,有助于控制信息在隐藏层中的转换方式。
在这里插入图片描述
下面是具体的数学运算过程:

最初,我们将隐藏状态 h h h 设置为一个零向量。用数学公式表示为:
在这里插入图片描述

或者用 Python 代码表示为:

h = np.zeros((1, self.hidden_size))

当我们处理序列中的每个输入时,会根据先前的隐藏状态 h ( t − 1 ) h_{(t - 1)} h(t1)、当前输入 x t x_t xt 以及相关的权重和偏置,计算时间步长为 t t t 时的新隐藏状态 h t h_t ht
在这里插入图片描述

我们可以将 U U U W W W b h b_h bh 定义为:

self.weights_ih = np.random.randn(input_size, hidden_size) * 0.01
self.weights_hh = np.random.randn(hidden_size, hidden_size) * 0.01
self.weights_ho = np.random.randn(hidden_size, output_size) * 0.01

这里:

  • U U Uself.weights_ih,即连接输入层和隐藏层的权重矩阵。
  • W W Wself.weights_hh,即连接一个时间步的隐藏层与下一个时间步隐藏层的权重矩阵。
  • b h b_h bhself.bias_h,即隐藏层的偏置项。
  • tanh ⁡ \tanh tanh 表示双曲正切函数,它为等式引入了非线性。

这与在 forward 方法中遍历每个输入的循环是相对应的。

然后,使用另一组权重和偏置,根据隐藏状态计算时间步长为 t t t 时的输出 y t y_t yt
在这里插入图片描述
在这种情况下:

  • V V Vself.weights_ho,即从隐藏层到输出层的权重矩阵。
  • b o b_o boself.bias_o,即输出层的偏置。

代码 y = np.dot(h, self.weights_ho) + self.bias_o 与这个公式相对应,它根据最后一个时间步的隐藏状态生成输出。
在这里插入图片描述
在这里插入图片描述

3.2.2 随时间反向传播(BPTT,Backpropagation Through Time)

训练循环神经网络涉及一种特殊的反向传播,称为随时间反向传播(BPTT)。与传统的反向传播不同,BPTT 会跨越时间展开——它会展开整个数据序列,并在每个时间步应用反向传播。这种方法为每个输出计算梯度,然后用这些梯度来调整权重并减少总体损失。然而,BPTT 可能会很复杂且资源消耗大,并且容易出现梯度消失梯度爆炸等问题,这些问题会影响网络从较长序列的数据中学习的能力。
在这里插入图片描述

给定一个包含 T T T 个时间步的序列,并假设在每个时间步 t t t 有一个简单的损失函数 L L L,例如回归任务中的均方误差或分类任务中的分类交叉熵,总损失 L t o t a l L_{total} Ltotal 是每个时间步损失的总和:
在这里插入图片描述
为了更新权重,我们需要计算总损失 L t o t a l L_{total} Ltotal 关于权重的梯度。对于权重矩阵 U U U(输入到隐藏层)、 W W W(隐藏层到隐藏层)和 V V V(隐藏层到输出层),我们有:
在这里插入图片描述

这些梯度是使用链式法则计算得出的。从最后一个时间步开始反向计算:
在这里插入图片描述

其中:

  • ∂ L t ∂ y t \frac{\partial L_t}{\partial y_t} ytLt 是时间步 t t t 时损失函数关于输出 y t y_t yt 的导数。
  • ∂ y t ∂ V \frac{\partial y_t}{\partial V} Vyt 可以直接计算为隐藏状态 h t h_t ht,因为 y t = V h t + b o y_t = Vh_t + b_o yt=Vht+bo

对于 W W W U U U,计算过程涉及网络的循环特性:
在这里插入图片描述
这里, ∂ L t + 1 ∂ h t + 1 \frac{\partial L_{t + 1}}{\partial h_{t + 1}} ht+1Lt+1 指的是时间步 t + 1 t + 1 t+1 时损失关于隐藏状态 h t + 1 h_{t + 1} ht+1 的梯度,而它又依赖于时间步 t t t 时的隐藏状态。这种递归关系构成了 BPTT 的核心。

3.2.3 权重更新

计算出梯度后,使用随机梯度下降(SGD,stochastic gradient descent)等优化算法来更新权重:
在这里插入图片描述
其中 η \eta η 是学习率。

4. 训练循环神经网络时面临的挑战

在这里插入图片描述

4.1 什么是梯度消失?

当反向传播算法从输出层向输入层反向传播时,梯度通常会越来越小并趋近于零,这最终会导致初始层或较低层的权重几乎保持不变。结果是,梯度下降永远无法收敛到最优值。这就是所谓的梯度消失问题。

4.2 什么是梯度爆炸?

相反,在某些情况下,随着反向传播算法的进行,梯度会不断增大。这反过来会导致权重更新幅度过大,从而使梯度下降发散。这就是所谓的梯度爆炸问题。

4.3 为什么会出现梯度消失/爆炸?

某些激活函数,比如逻辑函数(sigmoid),其输入和输出的方差之间存在很大差异。简单来说,它们会将较大的输入空间收缩并转换为范围在 [0, 1] 之间的较小输出空间。
在这里插入图片描述

观察上面的 sigmoid 函数图像,我们可以看到,对于较大的输入(无论是正还是负),它会在 0 或 1 处饱和,导数非常接近零。因此,当反向传播算法起作用时,实际上在网络中没有梯度可以向后传播,并且无论存在多么小的残余梯度,随着算法从顶层向下传播,都会不断被稀释。所以,到了较低层就几乎没有梯度了。

同样,在某些情况下,假设分配给网络的初始权重产生了一些较大的损失。现在,在权重更新期间梯度可能会累积,从而导致非常大的梯度,最终导致对网络权重进行大幅更新,进而使网络不稳定。参数有时可能会变得非常大,以至于溢出并产生 NaN(非数字)值。
在这里插入图片描述

4.4 如何知道我们的模型是否存在梯度爆炸/消失问题?

以下是一些可以表明我们的模型存在梯度消失和梯度爆炸问题的迹象:
在这里插入图片描述

当然,我们既不希望信号爆炸或饱和,也不希望它消失。信号在进行预测的前向传播方向以及计算梯度的反向传播方向上都需要正确地流动。

5. 处理梯度消失/爆炸问题

既然我们已经了解了梯度消失/爆炸问题,那么就可以学习一些解决这些问题的技术。

5.1 恰当的权重初始化

研究人员泽维尔·格洛托(Xavier Glorot)、安托万·博尔德(Antoine Bordes)和约书亚·本吉奥(Yoshua Bengio)提出了一种显著缓解这一问题的方法。

为了使信号能够恰当流动,作者们认为:

  • 每一层的输出方差应该等于其输入方差。
  • 梯度在反向流经一层前后的方差应该相等。

尽管除非一层的输入数量(fanin)等于该层的神经元数量(fanout),否则这两个条件不可能对网络中的任何一层都成立,但他们提出了一个经过充分验证的折衷方案,在实践中效果非常好。他们使用以下公式对网络中每一层的连接权重进行随机初始化,这个公式通常被称为 Xavier 初始化(以作者的名字命名)或 Glorot 初始化(以作者的姓氏命名)。

其中 fanavg = ( fanin + fanout ) / 2

  • 服从均值为 0 且方差 σ2 = 1/ fanavg 的正态分布
  • 或者是在 -r+r 之间的均匀分布,其中 r = sqrt( 3 / fanavg )

以下是针对不同激活函数的一些更流行的权重初始化策略,它们的区别仅在于方差的大小以及使用 fanavg 还是 fanin

对于均匀分布,计算 r 如下:r = sqrt( 3*σ2 )
在这里插入图片描述

使用上述初始化策略可以显著加快训练速度,并增加梯度下降收敛到更低泛化误差的可能性。

但是我们如何将这些策略应用到代码中呢?

别担心!我们不需要硬编码任何东西,Keras 会为我们处理。

Keras 使用具有均匀分布的 Xavier 初始化策略。
如果我们希望使用不同于默认的策略,可以在创建层时使用 kernel_initializer 参数来实现。例如:

keras.layer.Dense(25, activation = "relu", kernel_initializer="he_normal")

或者

keras.layer.Dense(25, activation = "relu", kernel_initializer="he_uniform")

如果我们希望使用基于 fanavg 而不是 fanin 的初始化,可以像这样使用 VarianceScaling 初始化器:

he_avg_init = keras.initializers.VarianceScaling(scale=2., mode='fan_avg', distribution='uniform')
keras.layers.Dense(20, activation="sigmoid", kernel_initializer=he_avg_init)

5.2 使用非饱和激活函数

在前面的部分中,当研究 sigmoid 激活函数的特性时,我们发现它对于较大的输入(无论是正还是负)会出现饱和的特性,这是导致梯度消失和爆炸的一个主要原因,因此不建议在网络的隐藏层中使用它。

所以,为了解决像 sigmoid 和 tanh 这类激活函数的饱和问题,我们必须使用一些其他的非饱和函数,比如 ReLU 及其替代函数。

ReLU(修正线性单元)

在这里插入图片描述

Relu(z) = max(0,z)
  • 对于任何负输入,输出为 0。
  • 范围:[0, 正无穷]

不幸的是,“在某些情况下”,ReLU 函数对于网络的中间层来说也不是一个完美的选择。它存在一个被称为“死亡 ReLU”的问题,即一些神经元会“死亡”,这意味着随着训练的进行,它们会一直输出 0。

可以在此处详细了解“死亡 ReLU”问题。

一些流行的 ReLU 替代函数,当用作网络中间层的激活函数时,可以缓解梯度消失的问题,它们是 LReLU、PReLU、ELU、SELU:

LReLU(带泄露的修正线性单元)

在这里插入图片描述

LeakyReLUα(z) = max(αz, z)
  • “泄露”的程度由超参数 α 控制,它是函数在 z < 0 时的斜率。
  • 较小的泄露斜率确保了由带泄露的 ReLU 驱动的神经元永远不会“死亡”;尽管在长时间的训练阶段它们可能会进入“昏迷”状态,但它们始终有机会最终“苏醒”。
  • 模型也可以训练 α,在训练过程中学习它的值。这种将 α 视为参数而不是超参数的变体,被称为参数化带泄露的 ReLU(PReLU)。

ELU(指数线性单元)

在这里插入图片描述

对于 z < 0,它会取负值,这使得单元的平均输出更接近 0,从而缓解了梯度消失的问题。

  • 对于 z < 0,梯度不为 0。这避免了神经元“死亡”的问题。
  • α = 1 时,该函数在任何地方都是平滑的,这加快了梯度下降的速度,因为它不会在 z = 0 附近左右波动。

这个函数的一个缩放版本(SELU:缩放的 ELU)在深度学习中也经常使用。

5.3 批量归一化

结合使用 He 初始化和任何一种 ReLU 激活函数的变体,可以在一开始显著降低梯度消失/爆炸问题出现的概率。然而,这并不能保证在训练过程中这个问题不会再次出现。

2015 年,谢尔盖·伊夫(Sergey Ioffe)和克里斯蒂安·塞吉迪(Christian Szegedy)发表了一篇论文,在论文中他们引入了一种被称为批量归一化(Batch Normalization)的技术,以解决梯度消失/爆炸问题。

以下要点解释了批量归一化背后的原理以及它的工作方式:

  • 它包括在模型中,恰好在每个隐藏层的激活函数之前或之后添加一个操作。
  • 这个操作只是将每个输入进行零中心化和归一化,然后使用每层的两个新参数向量对结果进行缩放和平移:一个用于缩放,另一个用于平移。
  • 换句话说,这个操作让模型学习每一层输入的最优缩放比例和均值。
  • 为了对输入进行零中心化和归一化,算法需要估计每个输入的均值和标准差。
  • 它通过评估当前小批量数据上的输入均值和标准差来实现这一点(因此称为“批量归一化”)。
model = keras.models.Sequential([
    keras.layers.Flatten(input_shape=[28, 28]),
    keras.layers.BatchNormalization(),
    keras.layers.Dense(300, activation="relu"),
    keras.layers.BatchNormalization(),
    keras.layers.Dense(100, activation="relu"),
    keras.layers.BatchNormalization(),
    keras.layers.Dense(10, activation="softmax")
])
# 我们只是在每一层之后添加了批量归一化(数据集:FMNIST)
model.summary()

在这里插入图片描述

5.4 梯度裁剪

另一种缓解梯度爆炸问题的常用技术是在反向传播过程中裁剪梯度,使它们永远不会超过某个阈值。这被称为梯度裁剪。

  • 这个优化器会将梯度向量的每个分量裁剪到 -1.0 到 1.0 之间的值。
  • 这意味着我们会将损失关于每个可训练参数的所有偏导数裁剪到 -1.0 到 1.0 之间。
optimizer = keras.optimizers.SGD(clipvalue = 1.0)

这个阈值是一个我们可以调整的超参数。

由于这种裁剪,梯度向量的方向可能会改变:例如,假设原始梯度向量是 [0.9, 100.0],主要指向第二个轴的方向,但是一旦我们用某个值进行裁剪,我们会得到 [0.9, 1.0],现在它指向两个轴之间的对角线附近的某个方向。

为了确保即使在裁剪后梯度向量的方向仍然保持不变,我们应该按范数而不是按值来裁剪。

optimizer = keras.optimizers.SGD(clipnorm = 1.0)

如果我们选择的阈值小于 ℓ2 范数,我们将裁剪整个梯度。例如,如果 clipnorm=1,我们会将向量 [0.9, 100.0] 裁剪为 [0.00899, 0.999995],从而保留其方向。

6. 从零开始构建循环神经网络

在这个演示中,我们将使用航空乘客数据集,这是一个托管在 GitHub 上的小型开源数据集。

让我们深入了解代码中每个组件的细节,以便全面了解如何从零开始实现这个循环神经网络!

6.1 定义循环神经网络类

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

class RNN:
    def __init__(self, input_size, hidden_size, output_size, init_method="random"):
        self.weights_ih, self.weights_hh, self.weights_ho = self.initialize_weights(input_size, hidden_size, output_size, init_method)
        self.bias_h = np.zeros((1, hidden_size))
        self.bias_o = np.zeros((1, output_size))
        self.hidden_size = hidden_size

    def initialize_weights(self, input_size, hidden_size, output_size, method):
        if method == "random":
            weights_ih = np.random.randn(input_size, hidden_size) * 0.01
            weights_hh = np.random.randn(hidden_size, hidden_size) * 0.01
            weights_ho = np.random.randn(hidden_size, output_size) * 0.01
        elif method == "xavier":
            weights_ih = np.random.randn(input_size, hidden_size) / np.sqrt(input_size / 2)
            weights_hh = np.random.randn(hidden_size, hidden_size) / np.sqrt(hidden_size / 2)
            weights_ho = np.random.randn(hidden_size, output_size) / np.sqrt(hidden_size / 2)
        elif method == "he":
            weights_ih = np.random.randn(input_size, hidden_size) * np.sqrt(2 / input_size)
            weights_hh = np.random.randn(hidden_size, hidden_size) * np.sqrt(2 / hidden_size)
            weights_ho = np.random.randn(hidden_size, output_size) * np.sqrt(2 / hidden_size)
        else:
            raise ValueError("Invalid initialization method")
        return weights_ih, weights_hh, weights_ho


    def forward(self, inputs):
        h = np.zeros((1, self.hidden_size))
        self.last_inputs = inputs
        self.last_hs = {0: h}

        for i, x in enumerate(inputs):
            x = x.reshape(1, -1)  # Ensure x is a row vector
            h = np.tanh(np.dot(x, self.weights_ih) + np.dot(h, self.weights_hh) + self.bias_h)
            self.last_hs[i + 1] = h

        y = np.dot(h, self.weights_ho) + self.bias_o
        self.last_outputs = y
        return y

    def backprop(self, d_y, learning_rate, clip_value=1):
        n = len(self.last_inputs)

        d_y_pred = (self.last_outputs - d_y) / d_y.size
        d_Whh = np.zeros_like(self.weights_hh)
        d_Wxh = np.zeros_like(self.weights_ih)
        d_Why = np.zeros_like(self.weights_ho)
        d_bh = np.zeros_like(self.bias_h)
        d_by = np.zeros_like(self.bias_o)
        d_h = np.dot(d_y_pred, self.weights_ho.T)

        for t in reversed(range(1, n + 1)):
            d_h_raw = (1 - self.last_hs[t] ** 2) * d_h
            d_bh += d_h_raw
            d_Whh += np.dot(self.last_hs[t - 1].T, d_h_raw)
            d_Wxh += np.dot(self.last_inputs[t - 1].reshape(1, -1).T, d_h_raw)
            d_h = np.dot(d_h_raw, self.weights_hh.T)

        for d in [d_Wxh, d_Whh, d_Why, d_bh, d_by]:
            np.clip(d, -clip_value, clip_value, out=d)
            
        self.weights_ih -= learning_rate * d_Wxh
        self.weights_hh -= learning_rate * d_Whh
        self.weights_ho -= learning_rate * d_Why
        self.bias_h -= learning_rate * d_bh
        self.bias_o -= learning_rate * d_by

这是我们循环神经网络的蓝图。

我们将在这个类中定义循环神经网络的初始化、前向传播和反向传播。

循环神经网络初始化

class RNN:
  def __init__(self, input_size, hidden_size, output_size, init_method="random"):
    self.weights_ih, self.weights_hh, self.weights_ho = self.initialize_weights(input_size, hidden_size, output_size, init_method)
    self.bias_h = np.zeros((1, hidden_size))
    self.bias_o = np.zeros((1, output_size))
    self.hidden_size = hidden_size

__init__ 方法使用每层(输入层、隐藏层、输出层)的神经元数量以及权重初始化方法来初始化循环神经网络。

self.weights_ih, self.weights_hh, self.weights_ho = self.initialize_weights(input_size, hidden_size, output_size, init_method)

在这里,我们调用 initialize_weights 方法,根据指定的初始化方法(‘random’、‘xavier’ 或 ‘he’)来设置权重。每组权重连接网络的不同层:weights_ih 连接输入层和隐藏层,weights_hh 连接隐藏层到下一个时间步的自身(体现了循环神经网络的“循环”部分),weights_ho 连接隐藏层和输出层。

self.bias_h = np.zeros((1, hidden_size))
self.bias_o = np.zeros((1, output_size))

偏置被初始化为零向量,它们将在训练过程中进行调整。隐藏层有一个偏置,输出层也有一个偏置。

前向传播方法

def forward(self, inputs):
    h = np.zeros((1, self.hidden_size))
    self.last_inputs = inputs
    self.last_hs = {0: h}
  
    for i, x in enumerate(inputs):
        x = x.reshape(1, -1)  # Ensure x is a row vector
        h = np.tanh(np.dot(x, self.weights_ih) + np.dot(h, self.weights_hh) + self.bias_h)
        self.last_hs[i + 1] = h
  
    y = np.dot(h, self.weights_ho) + self.bias_o
    self.last_outputs = y
    return y

forward 函数接受一个输入序列,并通过循环神经网络对其进行处理。它在输入序列长度的循环中计算隐藏状态和最终输出。

h = np.zeros((1, self.hidden_size))

这将隐藏状态初始化为一个零向量。随着网络处理更多的输入序列,这个状态将被更新以捕获来自输入的信息。

for i, x in enumerate(inputs):
    x = x.reshape(1, -1)  # Ensure x is a row vector
    h = np.tanh(np.dot(x, self.weights_ih) + np.dot(h, self.weights_hh) + self.bias_h)
    self.last_hs[i + 1] = h

对于序列中的每个输入,代码将输入重塑以确保它是一个行向量,然后使用当前输入、先前的隐藏状态、权重和偏置来更新隐藏状态。np.tanh 函数引入了复杂模式识别所需的非线性。

y = np.dot(h, self.weights_ho) + self.bias_o

在处理完整个序列后,我们使用最后一个隐藏状态、连接隐藏层和输出层的权重以及输出偏置来计算输出。

随时间反向传播

def backprop(self, d_y, learning_rate, clip_value=1):
    n = len(self.last_inputs)
  
    d_y_pred = (self.last_outputs - d_y) / d_y.size
    d_Whh = np.zeros_like(self.weights_hh)
    d_Wxh = np.zeros_like(self.weights_ih)
    d_Why = np.zeros_like(self.weights_ho)
    d_bh = np.zeros_like(self.bias_h)
    d_by = np.zeros_like(self.bias_o)
    d_h = np.dot(d_y_pred, self.weights_ho.T)
  
    for t in reversed(range(1, n + 1)):
        d_h_raw = (1 - self.last_hs[t] ** 2) * d_h
        d_bh += d_h_raw
        d_Whh += np.dot(self.last_hs[t - 1].T, d_h_raw)
        d_Wxh += np.dot(self.last_inputs[t - 1].reshape(1, -1).T, d_h_raw)
        d_h = np.dot(d_h_raw, self.weights_hh.T)
  
    for d in [d_Wxh, d_Whh, d_Why, d_bh, d_by]:
        np.clip(d, -clip_value, clip_value, out=d)
        
    self.weights_ih -= learning_rate * d_Wxh
    self.weights_hh -= learning_rate * d_Whh
    self.weights_ho -= learning_rate * d_Why
    self.bias_h -= learning_rate * d_bh
    self.bias_o -= learning_rate * d_by

backprop 方法实现了随时间反向传播(BPTT)算法。它计算每个时间步的梯度,并相应地更新权重和偏置。此外,它通过使用 np.clip 来结合梯度裁剪,以防止梯度爆炸问题。

6.2 提前停止机制

class EarlyStopping:
   def __init__(self, patience=7, verbose=False, delta=0):
        self.patience = patience
        self.verbose = verbose
        self.counter = 0
        self.best_score = None
        self.early_stop = False
        self.delta = delta

    def __call__(self, val_loss):
        score = -val_loss

        if self.best_score is None:
            self.best_score = score

        elif score < self.best_score + self.delta:
            self.counter += 1
            
            if self.counter >= self.patience:
                self.early_stop = True
        else:
            self.best_score = score
            self.counter = 0

这个类提供了训练过程中的提前停止机制。如果在一定数量的轮次(patience)之后验证损失没有改善,训练将停止,以防止过拟合。

我不会深入解释这个类,因为我在之前的这篇文章中已经详细解释过了:

6.3 循环神经网络训练器类

class RNNTrainer:
    def __init__(self, model, loss_func='mse'):
        self.model = model
        self.loss_func = loss_func
        self.train_loss = []
        self.val_loss = []

    def calculate_loss(self, y_true, y_pred):
        if self.loss_func == 'mse':
            return np.mean((y_pred - y_true)**2)
        
        elif self.loss_func == 'log_loss':
            return -np.mean(y_true*np.log(y_pred) + (1-y_true)*np.log(1-y_pred))
        
        elif self.loss_func == 'categorical_crossentropy':
            return -np.mean(y_true*np.log(y_pred))
        
        else:
            raise ValueError('Invalid loss function')

    def train(self, train_data, train_labels, val_data, val_labels, epochs, learning_rate, early_stopping=True, patience=10, clip_value=1):
        if early_stopping:
            early_stopping = EarlyStopping(patience=patience, verbose=True)
        for epoch in range(epochs):
            for X_train, y_train in zip(train_data, train_labels):
                outputs = self.model.forward(X_train)
                self.model.backprop(y_train, learning_rate, clip_value)
                train_loss = self.calculate_loss(y_train, outputs)
                self.train_loss.append(train_loss)

            val_loss_epoch = []
            for X_val, y_val in zip(val_data, val_labels):
                val_outputs = self.model.forward(X_val)
                val_loss = self.calculate_loss(y_val, val_outputs)
                val_loss_epoch.append(val_loss)

            val_loss = np.mean(val_loss_epoch)
            self.val_loss.append(val_loss)

            if early_stopping:
                early_stopping(val_loss)

                if early_stopping.early_stop:
                    print(f"Early stopping at epoch {epoch} | Best validation loss = {-early_stopping.best_score:.3f}")
                    break

            if epoch % 10 == 0:
                print(f'Epoch {epoch}: Train loss = {train_loss:.4f}, Validation loss = {val_loss:.4f}')

    def plot_gradients(self):
        for i, gradients in enumerate(zip(*self.gradients)):
            plt.plot(gradients, label=f'Neuron {i}')

        plt.xlabel('Time step')
        plt.ylabel('Gradient')
        plt.title('Gradients for each neuron over time')
        plt.legend()
        plt.show()

这个类封装了训练过程。它负责运行前向传播和反向传播,在每个轮次后计算损失,并记录训练损失和验证损失的历史记录。

训练方法

上面我们定义了用于训练循环神经网络模型的方法。它会在指定的轮次范围内循环,通过模型处理训练数据,应用反向传播,并跟踪训练损失和验证损失。

6.4 数据加载和预处理

class TimeSeriesDataset:
    def __init__(self, url, look_back=1, train_size=0.67):
        self.url = url
        self.look_back = look_back
        self.train_size = train_size

    def load_data(self):
        df = pd.read_csv(self.url, usecols=[1])
        df = self.MinMaxScaler(df.values)  # Convert DataFrame to numpy array
        train_size = int(len(df) * self.train_size)
        train, test = df[0:train_size,:], df[train_size:len(df),:]
        return train, test
    
    def MinMaxScaler(self, data):
        numerator = data - np.min(data, 0)
        denominator = np.max(data, 0) - np.min(data, 0)
        return numerator / (denominator + 1e-7)

    def create_dataset(self, dataset):
        dataX, dataY = [], []
        for i in range(len(dataset)-self.look_back-1):
            a = dataset[i:(i+self.look_back), 0]
            dataX.append(a)
            dataY.append(dataset[i + self.look_back, 0])
        return np.array(dataX), np.array(dataY)

    def get_train_test(self):
        train, test = self.load_data()
        trainX, trainY = self.create_dataset(train)
        testX, testY = self.create_dataset(test)
        return trainX, trainY, testX, testY

这个类负责时间序列数据的加载、预处理和批处理。它旨在方便处理将输入到循环神经网络中的数据。

  • def load_data(self):从指定 URL 的 CSV 文件中加载数据。它使用 Pandas 处理 CSV 文件并提取必要的列。
  • def MinMaxScaler(self, data):这是一个归一化函数,将数据缩放到 0 到 1 之间。这在时间序列和其他类型的数据处理中是常见的做法,有助于神经网络更有效地学习。
  • def create_dataset(self, dataset):它将加载的数据重新格式化为合适的格式,其中 dataX 包含模型的输入序列,dataY 包含每个序列对应的标签或目标值。
  • def get_train_test(self):根据指定的比例将加载的数据拆分为训练数据集和测试数据集。

加载和准备数据

url = 'https://raw.githubusercontent.com/jbrownlee/Datasets/master/daily-min-temperatures.csv'
dataset = TimeSeriesDataset(url, look_back=1)
trainX, trainY, testX, testY = dataset.get_train_test()

在这里,我们指定数据集的 URL,实例化 TimeSeriesDataset 并设置 look_back 为 1,这意味着每个输入序列(用于训练循环神经网络)将由 1 个时间步组成。然后将数据拆分为训练集和测试集。

trainX = np.reshape(trainX, (trainX.shape[0], 1, trainX.shape[1]))
testX = np.reshape(testX, (testX.shape[0], 1, testX.shape[1]))

输入数据需要进行重塑,以符合循环神经网络的输入要求,通常期望数据的格式为 [样本数量, 时间步数量, 特征数量]。

6.5 训练循环神经网络

rnn = RNN(look_back, 256, 1, init_method='xavier')
trainer = RNNTrainer(rnn, 'mse')
trainer.train(trainX, trainY, testX, testY, epochs=100, learning_rate=0.01, early_stopping=True, patience=10, clip_value=1)

循环神经网络模型使用 Xavier 初始化进行实例化,然后使用 RNNTrainer 进行训练。训练器使用均方误差(‘mse’)作为损失函数,这适用于像时间序列预测这样的回归任务。

这个实现涵盖了为简单的时间序列预测任务设置、训练和使用循环神经网络所需的所有基本组件。代码结构有助于理解,并且便于针对更复杂或不同类型的序列建模任务进行修改。