第一章:序列的本质——超越静态数据的维度
在探索深度学习的宏伟蓝图之前,我们必须首先理解我们所面对的数据形态。传统意义上,许多机器学习算法,例如经典的线性回归、支持向量机,乃至基础的前馈神经网络(Feedforward Neural Networks, FNNs)或多层感知机(Multilayer Perceptrons, MLPs),其设计哲学都基于一个核心假设:数据样本之间是相互独立且同分布的(Independent and Identically Distributed, I.I.D.)。
一张图片的内容识别,一封邮件是否为垃圾邮件的判断,一个客户是否会流失的预测——在这些场景中,我们将一张图片输入模型,不会去考虑上一张被识别的图片是什么;判断一封邮件,也无需关心前一封邮件的内容。每一个数据点,在模型眼中都是一个孤立的事件。
然而,现实世界远比这复杂。信息并非总是以孤立的、无关联的片段形式存在,它们常常以一种有序的、前后关联的流——也就是“序列”——的形式呈现在我们面前。
思考以下几个例子:
-
自然语言:一句话的含义不仅仅是所有词语含义的简单堆砌。“我爱你”和“你爱我”包含了完全相同的词语,但顺序的颠覆彻底改变了其语义。更进一步,“他虽然看起来很冷漠,但内心其实非常温暖”这句话中,理解“温暖”这个词的真正含义,必须依赖于前半句“虽然……冷漠”所建立的语境。每一个词语的意义都深深植根于它之前的词语所构建的上下文之中。
-
时间序列数据:股票市场的价格波动不是随机的跳跃。今天的收盘价,在很大程度上受到了昨天、前天,乃至过去数月交易行为的影响。预测明天的气温,我们不能仅仅依赖今天的气g温,风速、湿度、气压在过去几个小时内的连续变化趋势,共同构成了预测未来的关键线索。
-
音频与语音:一段音频波形,本质上是空气压力随时间变化的序列。一个音素的发音,会受到前后音素的影响,形成所谓的“协同发音”现象。我们能够将连续的声波解码为有意义的语言,正是因为我们的大脑能够处理这种时间上的依赖关系。
-
生物信息学:DNA可以被看作是由四种核苷酸(A, T, C, G)组成的超长序列。基因的功能、蛋白质的折叠,都与这些碱基的排列顺序息息相关。序列中的一个微小变化,可能导致完全不同的生物学功能。
这些数据类型的共同特征是 “顺序依赖性” (Sequential Dependence)。数据点 (x_t) 的出现,不仅自身携带信息,它的价值和意义还由它在序列中的位置 (t) 以及它之前的历史信息 ((x_1, x_2, …, x_{t-1})) 所共同决定。
1.1 传统模型的“失忆症”:为何MLP在序列问题上举步维艰?
让我们用一个具体的例子来剖析传统模型处理序列数据时的根本性缺陷。假设我们要构建一个模型,来预测一个简单的正弦波序列的下一个值。
一个正弦波序列可以表示为 (y_t = \sin(t))。如果我们想预测 (y_{t+1}),一个直观的想法是,这个值应该和 (y_t, y_{t-1}, y_{t-2}, …) 相关。
现在,我们尝试使用一个标准的多层感知机(MLP)来解决这个问题。MLP的结构是固定的:一个输入层,若干个隐藏层,一个输出层。为了让MLP能够“看到”历史信息,我们必须将序列“扁平化”并作为其输入。
这个过程被称为“窗口化”(Windowing)。例如,我们决定使用过去5个时间步的数据 ((y_{t-4}, y_{t-3}, y_{t-2}, y_{t-1}, y_t)) 来预测 (y_{t+1})。那么,MLP的输入层就需要有5个神经元,分别接收这5个值。
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
# --- 生成一个简单的正弦波序列数据 ---
# 定义一个从0到100,步长为0.1的时间点序列
time_steps = np.arange(0, 100, 0.1)
# 根据时间点生成对应的正弦波值
data = np.sin(time_steps)
# --- 定义一个函数,用于将一维序列转换为窗口化的数据集 ---
# 这个函数是MLP处理序列问题的核心预处理步骤
def create_windowed_dataset(sequence, window_size):
"""
将一维时间序列数据转换为适用于MLP的监督学习数据集。
:param sequence: 输入的一维序列。
:param window_size: 定义输入窗口的大小,即用多少个过去的时间步来预测未来。
:return: X (输入特征), y (目标标签)
"""
X, y = [], [] # 初始化输入特征列表X和目标标签列表y
# 遍历序列,确保有足够的历史数据来创建一个完整的窗口
for i in range(len(sequence) - window_size):
# 截取从i到i+window_size的片段作为输入特征
feature = sequence[i:(i + window_size)]
# 该窗口后的下一个点作为预测目标
target = sequence[i + window_size]
X.append(feature) # 将特征添加到X列表
y.append(target) # 将目标添加到y列表
return np.array(X), np.array(y) # 将列表转换为NumPy数组,这是模型训练的标准格式
# --- 设置窗口参数并创建数据集 ---
window_size = 10 # 我们决定使用过去10个时间点的数据作为输入
X, y = create_windowed_dataset(data, window_size)
# --- 构建一个简单的MLP模型 ---
model_mlp = Sequential([
# 输入层维度为窗口大小,即10个神经元
Dense(64, activation='relu', input_shape=(window_size,)), # 第一个隐藏层,使用ReLU激活函数增加非线性
Dense(32, activation='relu'), # 第二个隐藏层
Dense(1) # 输出层,一个神经元,用于预测下一个时间点的值
])
# --- 编译模型 ---
# 'mean_squared_error' (MSE) 是回归问题中常用的损失函数,衡量预测值与真实值之差的平方的均值
# 'adam' 是一个高效的优化器
model_mlp.compile(optimizer='adam', loss='mean_squared_error')
# --- 训练模型 ---
# epochs=20 表示将整个数据集完整地训练20遍
# batch_size=16 表示每次更新模型权重时使用16个样本
# verbose=0 表示在训练过程中不打印详细日志
model_mlp.fit(X, y, epochs=20, batch_size=16, verbose=0)
# --- 使用模型进行预测 ---
# 我们取出数据集中的最后一个窗口作为预测的输入
test_input = X[-1].reshape(1, window_size) # 将其形状调整为(1, 10)以匹配模型输入
# 使用训练好的模型进行预测
predicted_value = model_mlp.predict(test_input)[0][0]
print(f"最后的窗口数据: {
test_input[0]}")
print(f"真实的下一个值: {
data[len(data)-1]}")
print(f"MLP模型预测的下一个值: {
predicted_value}")
# --- 可视化对比 ---
plt.figure(figsize=(15, 5))
plt.title("MLP for Sine Wave Prediction")
plt.plot(time_steps, data, label='Original Sine Wave', color='blue', linestyle='--')
# 绘制MLP的预测点
# 预测点的x坐标是最后一个时间点
# 预测点的y坐标是模型输出值
plt.scatter(time_steps[-1], predicted_value, color='red', s=100, zorder=5, label='MLP Prediction')
plt.xlim(90, 101) # 放大显示最后一段
plt.legend()
plt.grid(True)
plt.show()
这段代码清晰地展示了MLP的工作流程。它看似解决了问题,但其背后隐藏着三个致命的、无法克服的结构性缺陷:
缺陷一:固定窗口大小的局限性 (Limitation of Fixed Window Size)
在上面的代码中,我们武断地选择了window_size = 10。这个选择是基于什么?是直觉,还是实验?如果序列中的依赖关系跨度非常长,例如,某个事件的影响在100个时间步之后才显现出来,那么一个大小为10的窗口将永远无法捕捉到这种长期依赖(Long-Term Dependency)。反之,如果依赖关系非常短,一个过大的窗口则会引入不必要的噪声,并增加计算的复杂度。为每一个不同的序列问题去手动寻找最优的窗口大小,本身就是一项艰巨且低效的任务。MLP无法动态地、自适应地决定需要看多远的历史。
缺陷二:参数不共享导致的信息割裂 (Lack of Parameter Sharing)
观察MLP的第一个Dense层。它有window_size个输入,即10个。这意味着,模型为窗口中的第一个位置(t-9)、第二个位置(t-8)……直到最后一个位置(t)的数据,都学习了一套 完全独立 的权重。
这带来了两个严重问题:
- 参数爆炸:如果我们将窗口扩大到100,输入层的权重数量就会增加10倍。对于文本这种动辄成百上千长度的序列,这种模型的参数量将变得异常庞大,难以训练。
- 知识无法泛化:模型在位置1学到的关于数据模式的知识,无法被应用到位置2。它认为发生在序列开头和结尾的同一个模式是两件完全不同的事情。例如,在分析“我爱猫,你也爱猫”这句话时,MLP会为第一个“爱”和第二个“爱”分别学习不同的处理方式,它无法理解这两个“爱”背后共享着相同的语义和语法功能。这种设计违背了序列处理的一个基本直觉:一个事件的特征提取方式,不应该取决于它在序列中出现的位置。
缺陷三:输入与输出长度的僵化 (Inflexible Input/Output Length)
标准的MLP被设计为处理固定大小的输入并产生固定大小的输出。在我们的例子中,输入永远是10个点,输出永远是1个点。这使得它在许多真实场景中毫无用武之地。
- 机器翻译:源语言句子和目标语言句子的长度几乎不可能完全一样。“Hello” (1个词) 翻译成 “你好” (2个词)。MLP无法处理这种输入输出长度不匹配的问题。
- 文本摘要:输入一篇长文章,输出一段短小的摘要。输入输出的长度都是可变的。
- 语音识别:输入一段任意长度的音频,输出对应的文字转录。
MLP的“一次性”处理机制,强行将动态的、流动的序列数据,扭曲成了静态的、固定大小的向量,从而丧失了序列数据最宝贵的时序结构信息。它患上了严重的“失忆症”,无法在处理当前信息时,有效利用和整合过去积累的经验。
为了真正地赋予模型“记忆”的能力,我们需要一种全新的架构。这种架构的核心思想,不再是将序列展开为一个扁平的向量,而是引入一种能够循环往复、自我更新的机制。这种机制,就是**循环神经网络(Recurrent Neural Network, RNN)**的精髓所在。它将带领我们进入一个真正能够理解和驾驭序列数据的全新纪元。
第二章:循环的黎明——简单循环神经网络(Simple RNN)的深度解剖
为了克服前馈神经网络的“失忆症”,我们需要一种能够将历史信息“携带”到未来的机制。想象一下你在阅读一本书,你不会在读每一页时都忘记前面所有的内容。相反,你的大脑会维持一个对故事背景、人物关系、情节发展的动态理解,这个理解会随着你阅读的每一句话而不断更新。
简单循环神经网络(Simple RNN)正是对这种人类阅读行为的初步数学模拟。其核心思想是引入一个“状态”(State)或“记忆”(Memory)的概念,它像一个信息的传送带,在处理序列的每一步时,既接收来自外部的新输入,也接收来自上一步的“记忆”,然后将两者融合,生成当前步骤的输出,并更新“记忆”以传递给下一步。
2.1 RNN的核心结构:一个自反馈的循环
与MLP的“一往无前”不同,RNN的神经元(或一层神经元)拥有一个指向自身的连接,形成一个环路。这个环路允许信息在网络中持续存在。
让我们将这个环路在时间维度上展开(Unrolling in Time),以便更清晰地理解其工作原理。
(这是一个描述性的图片标签,实际内容将在下方用数学和代码解释)
假设我们有一个输入序列 (X = (x_1, x_2, …, x_T)),其中 (T) 是序列的总长度。RNN在每个时间步 (t) (从1到 (T)) 会执行以下计算:
-
隐藏状态的更新 (Hidden State Update):这是RNN的记忆核心。在时间步 (t),隐藏状态 (h_t) 的计算取决于两个来源:
- 当前时间步的输入 (x_t)。
- 上一个时间步的隐藏状态 (h_{t-1})。
这个更新过程由以下公式定义:
[ h_t = f(W_{hh} h_{t-1} + W_{xh} x_t + b_h) ]
其中:- (h_t):在时间步 (t) 的新隐藏状态(一个向量)。
- (h_{t-1}):上一个时间步的隐藏状态(一个向量)。在第一个时间步 (t=1) 时,通常初始化为一个全零向量 (h_0)。
- (x_t):在时间步 (t) 的输入数据(一个向量)。
- (W_{xh}):输入到隐藏层的权重矩阵。它负责转换和“解读”当前输入信息。
- (W_{hh}):循环权重矩阵 (Recurrent Weight Matrix)。这是RNN的灵魂所在,它负责转换和“解读”来自过去的记忆 (h_{t-1})。至关重要的一点是,在所有的时间步中,(W_{xh})、(W_{hh}) 和偏置项 (b_h) 是完全共享的。这解决了MLP的参数不共享问题,使得模型能够在序列的不同位置应用相同的知识。
- (b_h):隐藏层的偏置向量。
- (f):一个非线性激活函数,通常是
tanh(双曲正切) 或ReLU。tanh函数的值域为[-1, 1],这有助于控制信息流,防止其在循环中无限放大。
-
输出的计算 (Output Calculation):在每个时间步,模型也可以选择生成一个输出 (y_t)。这个输出是基于当前时间步的隐藏状态计算得出的:
[ y_t = g(W_{hy} h_t + b_y) ]
其中:- (y_t):在时间步 (t) 的输出。
- (W_{hy}):隐藏层到输出层的权重矩阵。
- (b_y):输出层的偏置向量。
- (g):输出层的激活函数。其选择取决于具体的任务。对于回归问题,可以是线性函数(即没有激活函数);对于分类问题,可以是
softmax。
这个过程就像一个多米诺骨牌:(h_0) 和 (x_1) 碰倒了第一块骨牌,计算出 (h_1);(h_1) 和 (x_2) 接着碰倒第二块,计算出 (h_2)……信息就这样沿着时间链条不断向前流动和演变。(h_t) 理论上编码了从 (x_1) 到 (x_t) 的所有历史信息。
2.2 从零开始:使用NumPy构建Simple RNN的前向传播
为了真正洞悉RNN的内部运作,我们将抛开所有深度学习框架,仅使用NumPy来实现一个完整的Simple RNN层的前向传播过程。这将迫使我们直面每一个矩阵运算和向量操作的细节。
我们的目标是创建一个SimpleRNN类,它能够处理一个批次(batch)的序列数据。
import numpy as np
# --- 为了保证实验的可复现性,我们设定一个随机种子 ---
# 这意味着每次运行代码,生成的随机权重都是相同的
np.random.seed(42)
class SimpleRNNNumpy:
"""
一个使用NumPy从零开始实现的简单循环神经网络层。
这个实现将揭示RNN在每个时间步内部发生的具体计算。
"""
def __init__(self, input_size, hidden_size, output_size):
"""
初始化RNN的参数。
:param input_size: 输入向量x_t的维度(例如,一个词向量的维度)。
:param hidden_size: 隐藏状态h_t的维度。这是一个超参数,决定了模型的“记忆容量”。
:param output_size: 输出向量y_t的维度。
"""
self.input_size = input_size # 记录输入维度
self.hidden_size = hidden_size # 记录隐藏层维度
self.output_size = output_size # 记录输出维度
# --- 初始化权重矩阵和偏置 ---
# 权重通常使用小的随机数进行初始化,以打破对称性,帮助模型学习。
# Wxh: 输入到隐藏层的权重矩阵
# 它的形状是 (hidden_size, input_size) 以便 (hidden_size, input_size) @ (input_size, 1) -> (hidden_size, 1)
self.Wxh = np.random.randn(hidden_size, input_size) * 0.01
# Whh: 隐藏层到隐藏层的循环权重矩阵
# 它的形状是 (hidden_size, hidden_size) 以便 (hidden_size, hidden_size) @ (hidden_size, 1) -> (hidden_size, 1)
self.Whh = np.random.randn(hidden_size, hidden_size) * 0.01
# Why: 隐藏层到输出层的权重矩阵
# 它的形状是 (output_size, hidden_size) 以便 (output_size, hidden_size) @ (hidden_size, 1) -> (output_size, 1)
self.Why = np.random.randn(output_size, hidden_size) * 0.01
# bh: 隐藏层的偏置向量,初始化为零
# 它的形状是 (hidden_size, 1)
self.bh = np.zeros((hidden_size, 1))
# by: 输出层的偏置向量,初始化为零
# 它的形状是 (output_size, 1)
self.by = np.zeros((output_size, 1))
def forward(self, inputs):
"""
执行完整序列的前向传播。
:param inputs: 输入数据,一个形状为 (batch_size, sequence_length, input_size) 的NumPy数组。
为了简化,我们这里的实现先处理 batch_size = 1 的情况。
所以输入的形状是 (sequence_length, input_size)。
:return: outputs (所有时间步的输出), hidden_states (所有时间步的隐藏状态)
"""
# 获取序列长度
sequence_length = inputs.shape[0]
# 初始化第一个隐藏状态 h_0 为零向量
# h_prev 用于存储上一个时间步的隐藏状态 h_{t-1}
h_prev = np.zeros((self.hidden_size, 1))
# 创建列表来存储每一步的计算结果,这对于后续的反向传播至关重要
self.inputs = inputs # 存储原始输入
self.hidden_states = {
0: h_prev} # 使用字典存储每个时间步的隐藏状态,键为时间步
self.outputs = np.zeros((sequence_length, self.output_size)) # 初始化输出数组
# --- 沿时间步循环 ---
for t in range(sequence_length):
# 从输入序列中获取当前时间步的输入向量 x_t
# 需要将其形状从 (input_size,) 调整为 (input_size, 1) 以进行矩阵乘法
x_t = inputs[t].reshape(-1, 1)
# --- 核心计算步骤 ---
# 1. 计算隐藏状态 h_t
# h_t = tanh(W_hh * h_{t-1} + W_xh * x_t + b_h)
# 计算来自上一个隐藏状态的部分
term_hh = np.dot(self.Whh, h_prev)
# 计算来自当前输入的部分
term_xh = np.dot(self.Wxh, x_t)
# 将它们与偏置相加
h_next_raw = term_hh + term_xh + self.bh
# 应用tanh激活函数
h_next = np.tanh(h_next_raw)
# 2. 计算输出 y_t
# y_t = W_hy * h_t + b_y (这里我们省略了输出激活函数g,默认为线性激活)
y_t = np.dot(self.Why, h_next) + self.by
# --- 存储结果并为下一步做准备 ---
# 将当前计算出的隐藏状态 h_next 存储起来
self.hidden_states[t + 1] = h_next
# 将当前计算出的输出 y_t 存储起来(需要将形状从(output_size, 1)转换回(output_size,))
self.outputs[t] = y_t.ravel()
# 更新 h_prev,让当前的 h_next 成为下一个时间步的 h_prev
h_prev = h_next
return self.outputs, self.hidden_states
# --- 演示如何使用这个Numpy实现的RNN ---
# 定义模型超参数
input_dim = 3 # 假设每个时间步的输入是一个3维向量
hidden_dim = 4 # 我们的记忆单元(隐藏状态)有4个维度
output_dim = 2 # 我们希望在每个时间步输出一个2维向量
# 实例化我们的RNN模型
rnn_numpy = SimpleRNNNumpy(input_size=input_dim, hidden_size=hidden_dim, output_size=output_dim)
# 创建一个模拟的输入序列
# 序列长度为5 (5个时间步)
sequence_length = 5
# 生成随机输入数据,形状为 (5, 3)
input_sequence = np.random.randn(sequence_length, input_dim)
print("--- 输入数据 ---")
print(f"输入序列的形状: {
input_sequence.shape}")
print("输入序列内容:\n", input_sequence)
print("\n" + "="*50 + "\n")
# 执行前向传播
final_outputs, all_hidden_states = rnn_numpy.forward(input_sequence)
print("--- 前向传播结果 ---")
print(f"所有时间步的最终输出形状: {
final_outputs.shape}")
print("所有时间步的最终输出内容:\n", final_outputs)
print("\n" + "="*50 + "\n")
print("--- 内部状态追踪 ---")
# 打印出每个时间步的隐藏状态,以观察其如何演变
for t, h_t in all_hidden_states.items():
print(f"时间步 {
t} 的隐藏状态 h_{
t} (记忆):\n{
h_t.T}") # .T转置是为了方便查看
代码深度解析:
-
__init__(初始化):Wxh,Whh,Why是模型需要学习的参数。我们用np.random.randn生成符合标准正态分布的随机数,并乘以一个小数(如0.01),这是一种常见的初始化策略,可以防止初始权重过大导致训练初期的不稳定。bh,by是偏置项,通常初始化为零。- 这些权重和偏置在整个前向传播过程中是固定不变和共享的。模型处理时间步1和时间步5的输入时,使用的是同一套
Wxh。
-
forward(前向传播):h_prev = np.zeros(...):这是整个记忆链条的起点,即 (h_0)。我们假设在序列开始之前,模型没有任何先验记忆。for t in range(sequence_length):这个循环是RNN在时间上展开的程序化体现。每一次循环,都代表着模型处理序列中的一个元素。x_t = inputs[t].reshape(-1, 1):NumPy的矩阵乘法np.dot对维度要求严格。我们将一维的输入向量(input_size,)转换为列向量(input_size, 1),以便与权重矩阵(hidden_size, input_size)进行运算。term_hh = np.dot(self.Whh, h_prev):这行代码体现了“循环”的本质。它将上一时刻的记忆h_prev通过循环权重Whh进行转换。term_xh = np.dot(self.Wxh, x_t):这行代码处理当前时刻的新信息x_t。h_next = np.tanh(...):将新信息和旧记忆融合后,通过tanh激活函数进行“压缩”和“规范化”,形成新的记忆h_next。tanh的非线性是至关重要的,如果没有非线性,无论RNN有多少层、循环多少次,其效果都等同于一个简单的线性变换,无法学习复杂的模式。y_t = np.dot(self.Why, h_next) + self.by:基于更新后的“理解”(即h_next),生成当前时刻的输出。h_prev = h_next:这是信息传递的关键。当前时刻计算出的新记忆,被赋值给h_prev,它将在下一次循环中作为“上一时刻的记忆”被使用。self.hidden_states[t + 1] = h_next:我们将每一步的中间结果(隐藏状态)都保存下来。这不仅是为了观察,更是为后续的反向传播算法——BPTT——做准备,因为在计算梯度时,我们需要用到这些中间值。
通过这个从零开始的实现,我们已经可以清晰地看到Simple RNN是如何通过一个循环结构,逐个处理序列元素,并不断更新其内部的“记忆”状态h_t。它解决了MLP的三个核心缺陷:
- 窗口大小:RNN理论上可以处理任意长度的序列,它的“窗口”是动态的,因为
h_t包含了从t=1到当前的所有信息。 - 参数共享:
Wxh,Whh,Why在所有时间步中共享,大大减少了参数量,并让模型学习到的模式具有时不变性。 - 输入输出:该结构天然支持在每个时间步都产生一个输出,可以轻松实现多对多(many-to-many)的序列任务。
然而,Simple RNN的旅程并非一帆风顺。虽然它在理论上能够捕捉到无限长的依赖关系,但在实践中,它很快就暴露出了一个致命的弱点,一个几乎让循环网络研究停滞不前的问题——梯度消失与梯度爆炸 (Vanishing and Exploding Gradients)。这正是我们下一节将要深入探索的,也是催生出更强大的LSTM网络的直接原因。在理解LSTM为什么如此设计之前,我们必须首先深刻理解Simple RNN的“痛苦”。
第三章:记忆的瓶颈——梯度消失与梯度爆炸的数学根源
我们已经构建了Simple RNN的前向传播机制,它像一台精密的机器,沿着时间轴一步步处理信息。但是,模型是如何学习的呢?与所有神经网络一样,学习的核心在于反向传播(Backpropagation),即根据模型的预测错误(损失),来计算每个参数(权重和偏置)对这个错误的“责任”有多大(即梯度),然后沿着梯度的反方向微调这些参数,以期在下一次预测中做得更好。
对于RNN,这个过程有其特殊性,被称为随时间反向传播(Backpropagation Through Time, BPTT)。BPTT本质上就是将时间维度上展开的RNN看作一个非常深的前馈神经网络,然后应用标准的链式法则来计算梯度。然而,正是这个“非常深”的特性,为RNN带来了灾难性的数学后果。
3.1 随时间反向传播(BPTT)的可视化理解
让我们回顾一下RNN在时间上展开的计算图。假设我们的序列长度为3,我们需要在最后一个时间步 (t=3) 计算输出 (y_3) 与真实标签 (\hat{y}3) 之间的损失 (L_3)。我们的目标是计算这个损失 (L_3) 对所有权重((W{xh}, W_{hh}, W_{hy}))的梯度。
根据链式法则,损失 (L_3) 对输出层权重 (W_{hy}) 的梯度计算相对直接,因为它只涉及到时间步3的计算:
[ \frac{\partial L_3}{\partial W_{hy}} = \frac{\partial L_3}{\partial y_3} \frac{\partial y_3}{\partial W_{hy}} ]
其中 (\frac{\partial y_3}{\partial W_{hy}}) 就是 (h_3^T)。
但当我们试图计算 (L_3) 对循环权重 (W_{hh}) 的梯度时,问题变得复杂起来。(W_{hh}) 不仅在计算 (h_3) 时被使用,它还在计算 (h_2) 和 (h_1) 时被使用,而 (h_3) 又依赖于 (h_2),(h_2) 依赖于 (h_1)。因此,(W_{hh}) 通过多条路径影响着最终的损失 (L_3)。
(描述性标签)
根据多变量链式法则,总梯度是所有路径梯度的总和:
[ \frac{\partial L_3}{\partial W_{hh}} = \sum_{k=1}^{3} \frac{\partial L_3}{\partial y_3} \frac{\partial y_3}{\partial h_3} \frac{\partial h_3}{\partial h_k} \frac{\partial h_k}{\partial W_{hh}} ]
这里的核心是 (\frac{\partial h_3}{\partial h_k}) 这一项,它表示在时间步 (k) 的隐藏状态对时间步 3 的隐藏状态的影响。让我们展开其中一条最长的路径,即 (k=1) 的情况,看看 (\frac{\partial h_3}{\partial h_1}) 是如何计算的:
[ \frac{\partial h_3}{\partial h_1} = \frac{\partial h_3}{\partial h_2} \frac{\partial h_2}{\partial h_1} ]
我们知道 (h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b_h))。那么,(h_t) 对 (h_{t-1}) 的导数是:
[ \frac{\partial h_t}{\partial h_{t-1}} = \text{diag}(1 - \tanh^2(z_t)) \cdot W_{hh} ]
其中 (z_t = W_{hh} h_{t-1} + W_{xh} x_t + b_h),(\text{diag}(…)) 表示将激活函数的导数构造成一个对角矩阵。
将这个关系代入 (\frac{\partial h_3}{\partial h_1}) 的计算中,我们得到:
[ \frac{\partial h_3}{\partial h_1} = \underbrace{\text{diag}(1 - \tanh^2(z_3)) \cdot W_{hh}}{\frac{\partial h_3}{\partial h_2}} \cdot \underbrace{\text{diag}(1 - \tanh^2(z_2)) \cdot W{hh}}{\frac{\partial h_2}{\partial h_1}} ]
可以看到,循环权重矩阵 (W{hh}) 在这个链式法则的表达式中被连乘了多次!如果序列长度为 (T),我们要计算 (L_T) 对 (h_1) 的影响,那么 (W_{hh}) 将被连乘 (T-1) 次。
3.2 梯度消失(Vanishing Gradients):长期记忆的丧失
这就是问题的根源。矩阵的连乘会产生极其不稳定的结果。让我们暂时忽略激活函数的导数部分,只关注 (W_{hh}) 的连乘。
如果 (W_{hh}) 的最大奇异值(可以粗略理解为矩阵的“范数”或“缩放能力”)小于1,那么当它被连乘多次后,结果矩阵中的元素将以指数级的速度趋向于0。
[ (W_{hh})^{T-k} \to 0 \quad \text{as} \quad T-k \to \infty ]
这意味着,从遥远过去(时间步 (k))传来的梯度信号 (\frac{\partial L_T}{\partial h_k}),在经过长长的反向传播路径后,会变得极其微弱,几乎为零。
后果是什么?
梯度是模型学习的驱动力。如果来自长期依赖的梯度消失了,模型就无法学习到输入序列中相隔很远但存在关联的模式。例如,在句子“我在法国长大,……(此处省略数百字),所以我能说流利的法语”中,要正确理解“法语”这个词的上下文,模型必须将它与句子开头的“法国”联系起来。如果梯度在反向传播到“法国”这个词时已经消失,模型就永远学不会这种关联。Simple RNN 最终会变成一个只关注短期依赖的模型,其记忆窗口在实践中非常有限,这违背了我们设计它的初衷。
3.3 梯度爆炸(Exploding Gradients):训练的崩溃
反之,如果 (W_{hh}) 的最大奇异值大于1,那么当它被连乘多次后,结果矩阵中的元素将以指数级的速度变得无限大。
[ (W_{hh})^{T-k} \to \infty \quad \text{as} \quad T-k \to \infty ]
这会导致梯度值变得异常巨大。在训练过程中,一个巨大的梯度会使得参数更新的步子迈得极大,瞬间将权重更新到一个非常糟糕、完全不合理的区域,导致损失函数的值变为 NaN (Not a Number)。整个训练过程会因此崩溃。
虽然梯度爆炸问题更容易被发现(因为训练会直接中断),并且可以通过一种名为**梯度裁剪(Gradient Clipping)**的技术来缓解(即设定一个梯度的上限,如果计算出的梯度超过这个阈值,就把它强制拉回到阈值),但梯度消失问题则更为隐蔽和根本,它悄无声息地扼杀了模型学习长期依赖的能力。
3.4 数值实验:亲眼见证梯度的衰减
为了更直观地感受梯度消失,让我们通过一个简单的数值实验来模拟这个过程。我们不进行完整的BPTT,只计算雅可比矩阵 (\frac{\partial h_t}{\partial h_k}) 的范数,来观察梯度范数是如何随着时间距离 (t-k) 的增加而变化的。
import numpy as np
import matplotlib.pyplot as plt
# --- 设置实验参数 ---
hidden_size = 10 # 隐藏层维度
time_steps = 50 # 我们观察的时间跨度
# --- 场景一:梯度消失 ---
# 创建一个循环权重矩阵,其最大奇异值小于1
# 通过将其所有元素除以一个比其最大奇异值稍大的数来实现
W_vanish = np.random.randn(hidden_size, hidden_size)
singular_values_vanish = np.linalg.svd(W_vanish, compute_uv=False) # 计算奇异值
W_vanish = W_vanish / (np.max(singular_values_vanish) * 1.1) # 确保最大奇异值小于1
# --- 场景二:梯度爆炸 ---
# 创建一个循环权重矩阵,其最大奇异值大于1
# 通过将其所有元素乘以一个数来实现
W_explode = np.random.randn(hidden_size, hidden_size)
singular_values_explode = np.linalg.svd(W_explode, compute_uv=False)
W_explode = W_explode * 1.1 / np.max(singular_values_explode) # 确保最大奇异值大于1
# --- 场景三:稳定(理想情况,但很难维持) ---
# 创建一个近似正交的矩阵,其奇异值接近1
# QR分解可以得到一个正交矩阵Q和一个上三角矩阵R
Q, _ = np.linalg.qr(np.random.randn(hidden_size, hidden_size))
W_stable = Q
# --- 模拟BPTT中的梯度传播 ---
def simulate_gradient_flow(W, T):
"""
模拟梯度从时间步 T 反向传播到时间步 1 的过程。
我们只关注循环权重 W 的连乘效应。
"""
norms = [] # 用于存储每个时间步的梯度范数
# d_h_t / d_h_{t-1} 的近似,我们暂时忽略激活函数导数的影响
# 它的影响是让小于1的更小,大于1的可能变小,但核心问题在W
J = W
# 梯度从当前时间步 t 开始反向传播
# J_tk = d_h_t / d_h_k
J_tk = np.eye(W.shape[0]) # 初始化 d_h_t / d_h_t = I (单位矩阵)
for k in range(T, 0, -1):
# 计算 J_tk = (d_h_{k+1}/d_h_k) * J_{t,k+1} = W * J_{t,k+1}
# 这里我们反向计算,从 J_t,t 开始,逐步计算 J_t,t-1, J_t,t-2, ...
# J_t,k-1 = J_t,k * (d_h_k / d_h_{k-1}) = J_t,k * W
J_tk = np.dot(J_tk, W)
# 计算雅可比矩阵的范数(Frobenius norm),衡量其大小
norm = np.linalg.norm(J_tk)
norms.append(norm)
return list(reversed(norms)) # 反转列表,使其与时间步 1..T 对应
# --- 运行模拟 ---
grad_flow_vanish = simulate_gradient_flow(W_vanish, time_steps)
grad_flow_explode = simulate_gradient_flow(W_explode, time_steps)
grad_flow_stable = simulate_gradient_flow(W_stable, time_steps)
# --- 可视化结果 ---
plt.figure(figsize=(18, 6))
plt.suptitle("The Problem of Vanishing & Exploding Gradients in RNNs", fontsize=16)
# 梯度消失图
plt.subplot(1, 3, 1)
plt.plot(range(1, time_steps + 1), grad_flow_vanish)
plt.yscale('log') # 使用对数尺度能更清晰地看到指数级衰减
plt.title(f"Vanishing Gradients (Max SV of W ≈ {
np.max(np.linalg.svd(W_vanish, compute_uv=False)):.2f})")
plt.xlabel("Time distance (t-k)")
plt.ylabel("Gradient Norm (log scale)")
plt.grid(True)
# 梯度爆炸图
plt.subplot(1, 3, 2)
plt.plot(range(1, time_steps + 1), grad_flow_explode)
plt.yscale('log') # 对数尺度也能清晰显示指数级增长
plt.title(f"Exploding Gradients (Max SV of W ≈ {
np.max(np.linalg.svd(W_explode, compute_uv=False)):.2f})")
plt.xlabel("Time distance (t-k)")
plt.ylabel("Gradient Norm (log scale)")
plt.grid(True)
# 稳定情况图
plt.subplot(1, 3, 3)
plt.plot(range(1, time_steps + 1), grad_flow_stable)
# plt.yscale('log') # 稳定情况下不需要对数尺度
plt.title(f"Stable Gradients (Max SV of W ≈ {
np.max(np.linalg.svd(W_stable, compute_uv=False)):.2f})")
plt.xlabel("Time distance (t-k)")
plt.ylabel("Gradient Norm")
plt.grid(True)
plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.show()
实验结果分析:
-
左图(梯度消失):我们可以清晰地看到,随着时间距离的增加(X轴向右),梯度的范数(Y轴)在对数尺度上呈线性下降,这意味着它实际上在以指数级速度衰减。仅仅经过了大约10-15个时间步,梯度范数就已经衰减到了可以忽略不计的程度。这证明了Simple RNN在捕捉超过这个长度的依赖关系时是多么无力。
-
中图(梯度爆炸):情况恰好相反。梯度范数以惊人的指数级速度增长。在实践中,这种增长会很快超出计算机浮点数的表示范围,导致
NaN错误。 -
右图(稳定):当我们使用一个奇异值接近1的(正交)矩阵时,梯度范数在传播过程中保持得非常稳定。然而,在真实的训练过程中,权重矩阵
W_hh会不断被更新,要一直强制它保持正交或奇异值接近1是非常困难且不切实际的。
第四章:门控革命——通往长短期记忆(LSTM)的 концептуальное введение
想象一下,你是一位技艺精湛的图书馆档案管理员,你的任务是阅读一部实时更新、永无止境的编年史。这部编年史的每一页(即序列中的每一个输入 (x_t))都记录着当下的事件。你的大脑(即隐藏状态 (h_t))需要根据新读到的内容,实时形成对整个历史脉络的理解。
Simple RNN这位管理员的工作方式非常朴素,甚至有些死板。他每读一页新内容,就会将旧的记忆((h_{t-1}))和新的信息((x_t))一股脑地塞进一个搅拌机(激活函数 tanh),然后得到一份全新的、混合后的记忆((h_t))。这个过程存在一个根本问题:他无法区分信息的重要性。无论是关乎整个王朝兴衰的关键转折,还是无关紧要的宫廷琐事,都会被同等力度地搅拌、稀释。经过数百页的阅读,最初的、可能至关重要的信息,早已在反复的搅拌中面目全非,失去了其原有的影响力。这就是梯度消失的隐喻。
现在,让我们认识一下新来的、更为智慧的档案管理员——LSTM。他深知,有效的记忆管理不在于“记住所有”,而在于“选择性地记住和遗忘”。为此,他设计了一套精密的、动态的规则系统,这套系统由三个“思想阀门”或“门控单元”(Gates)组成,它们协同工作,精妙地控制着信息流。
这套系统的核心,是一条独立于主要工作记忆((h_t))之外的、神圣的“长期记忆传送带”,我们称之为 细胞状态(Cell State, (C_t))。这条传送带是信息的高速公路,可以几乎无损地将信息从遥远的过去传送到现在。而三个门控单元,则像传送带沿线的智能安检站和调度员,决定着什么信息可以登上、离开或使用这条传送带。
1. 遗忘门 (Forget Gate):过去的,就让它过去吧
当LSTM管理员读到新的一页((x_t))时,他做的第一件事不是立刻吸收新知识,而是回顾他的长期记忆((C_{t-1}))。他会自问:“在我已有的长期记忆中,有哪些信息在当前这个新的语境下已经不再重要,或者说已经过时了?”
例如,在处理句子“他住在伦敦,但去年搬到了巴黎”时,当读到“巴黎”时,关于“他住在伦敦”这个旧的地理信息就应该被调低其重要性,甚至被遗忘。
遗忘门就是执行这个功能的审查官。它会审视当前的新输入((x_t))和管理员的短期工作记忆((h_{t-1})),然后输出一个介于0和1之间的数值向量。这个向量的每一个维度,都对应着长期记忆 (C_{t-1}) 中相应维度的“遗忘系数”。如果系数为0,意味着“彻底忘记这部分记忆”;如果为1,意味着“完全保留这部分记忆”;如果是0.3,则意味着“将这部分记忆的重要性降低到原来的30%”。
2. 输入门 (Input Gate):哪些新知识值得被铭记?
在决定了要遗忘哪些旧记忆之后,管理员开始处理新的信息。但他不会全盘接收。他会通过第二个阀门——输入门——来回答两个子问题:
- a) 筛选:在所有新涌入的信息中,哪些部分是值得关注和记录的“候选记忆”?(这由一个独立的
tanh层来生成候选值 (\tilde{C}_t)) - b) 准入:对于这些被筛选出来的“候选记忆”,我们应该以多大的强度将它们写入我们的长期记忆传送带?
输入门负责回答第二个问题。它同样会审视当前的新输入((x_t))和短期工作记忆((h_{t-1})),然后输出一个0到1之间的“准入系数”向量。这个系数决定了新生成的候选记忆 (\tilde{C}_t) 有多大的“音量”可以被添加到长期记忆中。如果准入系数为0,那么即使候选记忆再重要,也无法被写入;如果为1,则可以被完整地写入。
这个过程就像是在一个嘈杂的会议中,你不仅要识别出谁在说有价值的话(生成候选记忆),还要决定是否要把这些话记录到你的核心会议纪要中(输入门控制)。
3. 输出门 (Output Gate):此刻,我应该表现出什么?
记忆的最终目的是为了指导行动和决策。在更新了长期记忆(得到了新的 (C_t))之后,管理员需要决定在当前这个时间点,他应该对外“表现”出什么样的状态,即生成他的短期工作记忆((h_t))。
他不会直接把完整的、庞杂的长期记忆 (C_t) 全部暴露出来。这样做既不高效,也可能泄露不相关的信息。相反,他会启动第三个阀门——输出门。
输出门会再次审视当前的新输入((x_t))和之前的短期工作记忆((h_{t-1})),来判断:“基于我目前所有的长期记忆 (C_t),哪些部分对于生成当前时刻的输出是直接相关的?”
它会输出一个0到1之间的“表现系数”向量。与此同时,长期记忆 (C_t) 会先经过一个tanh函数的处理(将其数值压缩到-1到1之间,规范化一下)。最后,这个处理过的长期记忆,与输出门的“表现系数”相乘。系数为1的部分,其信息得以顺利输出,成为新的短期工作记忆 (h_t) 的一部分;系数为0的部分,其信息则被屏蔽,保留在长期记忆中,但不在当前时刻表现出来。
总结一下这场内部的认知革命:
- Simple RNN: (h_{t-1}) + (x_t) → 混合 → (h_t) (一条路径,强制融合)
- LSTM:
- 核心:引入了细胞状态 (C_t) 作为信息高速公路。
- 遗忘门:决定 (C_{t-1}) 中哪些信息被丢弃。
- 输入门:决定 (x_t) 和 (h_{t-1}) 形成的新信息中,哪些可以被写入 (C_t)。
- 输出门:决定 (C_t) 中哪些信息可以被用作当前的输出 (h_t)。
这种精巧的门控设计,将信息的流动从一种被动的、强制的混合,转变为一种主动的、受控的调节。正是这种调节能力,使得LSTM能够有效地克服梯度消失和爆炸问题,从而在序列建模领域开启了一个全新的篇章。
第五章:解构LSTM细胞——数学、代码与内在机制的深度透视
概念上的理解为我们指明了方向,但要真正掌握LSTM的威力,我们必须深入其内部,用数学的语言精确描述其运作,并通过代码将其每一个细节付诸实现。一个LSTM细胞(LSTM Cell)内部的计算过程,远比Simple RNN要复杂,但也正是这份复杂性赋予了它强大的记忆能力。
5.1 LSTM的核心数学公式
让我们再次明确一下符号。在时间步 (t),我们有:
- 输入:(x_t)
- 前一时刻的隐藏状态(短期记忆):(h_{t-1})
- 前一时刻的细胞状态(长期记忆):(C_{t-1})
LSTM的目标是计算出当前时刻的隐藏状态 (h_t) 和细胞状态 (C_t)。这个过程可以分解为以下六个核心计算步骤。请注意,在常见的实现中,为了方便计算,我们会将输入 (x_t) 和前一时刻的隐藏状态 (h_{t-1}) 沿着特征维度拼接(concatenate)在一起,形成一个新的向量 ([h_{t-1}, x_t])。这样做的好处是,我们可以用一个大的权重矩阵一次性地对两者进行线性变换,而不是用两个独立的权重矩阵分别处理后再相加。
-
遗忘门 (Forget Gate, (f_t)):决定从过去的细胞状态 (C_{t-1}) 中遗忘多少信息。
[ f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) ]- (\sigma): Sigmoid激活函数。它将任意实数压缩到(0, 1)区间。输出的 (f_t) 是一个向量,其每个元素的值都在0和1之间,代表“遗忘”的程度。1表示“完全保留”,0表示“完全遗忘”。
- (W_f), (b_f): 遗忘门的权重矩阵和偏置向量。这些是模型需要学习的参数。
-
输入门 (Input Gate, (i_t)):决定让多少新的信息进入细胞状态。
[ i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) ]- 同样使用Sigmoid函数,(i_t) 的每个元素也在0和1之间,代表新信息被“允许进入”的程度。1表示“允许全部进入”,0表示“完全阻止”。
- (W_i), (b_i): 输入门的权重矩阵和偏置向量。
-
候选细胞状态 (Candidate Cell State, (\tilde{C}_t)):生成一组新的“候选”信息,这些信息可能会被添加到细胞状态中。
[ \tilde{C}t = \tanh(W_C \cdot [h{t-1}, x_t] + b_C) ]- (\tanh): 双曲正切激活函数。它将任意实数压缩到(-1, 1)区间。这创建了一个包含新信息的向量。与门控不同,这里我们关心的是信息的内容和方向(正或负),而不仅仅是一个比例。
- (W_C), (b_C): 候选值生成网络的权重矩阵和偏置向量。
-
细胞状态更新 (Cell State Update, (C_t)):这是LSTM的核心。它分两步完成:首先,忘记旧信息;然后,添加新信息。
[ C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t ]- (\odot): 哈达玛积 (Hadamard Product),即元素级别的乘法 (element-wise multiplication)。这是门控机制发挥作用的地方。
- (f_t \odot C_{t-1}): 遗忘门与旧的细胞状态进行元素乘法。(C_{t-1}) 中对应 (f_t) 元素接近0的部分,其信息被“清空”;对应 (f_t) 元素接近1的部分,信息被保留。
- (i_t \odot \tilde{C}_t): 输入门与候选细胞状态进行元素乘法。这决定了新的候选信息中,哪些部分以及以多大的强度被实际写入。
- 最后,将这两部分相加,就得到了更新后的、融合了过去与现在的长期记忆 (C_t)。
-
输出门 (Output Gate, (o_t)):决定从新的细胞状态 (C_t) 中输出哪些信息作为当前的隐藏状态。
[ o_t = \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) ]- 同样使用Sigmoid函数,(o_t) 决定了细胞状态的哪些部分是对外可见的。
- (W_o), (b_o): 输出门的权重矩阵和偏置向量。
-
隐藏状态更新 (Hidden State Update, (h_t)):计算最终的输出/短期记忆。
[ h_t = o_t \odot \tanh(C_t) ]- 首先,将更新后的细胞状态 (C_t) 通过一个
tanh函数进行压缩,将其值规范化到(-1, 1)之间。 - 然后,将这个规范化后的细胞状态与输出门 (o_t) 进行元素乘法。这就像一个过滤器,只允许被输出门选中的信息部分通过,最终形成当前时间步的隐藏状态 (h_t)。同时,(h_t) 也将作为下一个时间步的输入之一。
- 首先,将更新后的细胞状态 (C_t) 通过一个
5.2 从零开始:使用NumPy构建LSTM的前向传播
理论是灰色的,而代码之树常青。为了将上述数学公式转化为可执行的、可触摸的现实,我们将再次仅使用 NumPy 来构建一个完整的LSTM层的前向传播。这个过程将迫使我们关注每一个矩阵的维度、每一次拼接操作和每一次元素级乘法。
import numpy as np
# --- 为了保证实验的可复现性,我们设定一个随机种子 ---
# 这意味着每次运行代码,生成的随机权重都是相同的
np.random.seed(42)
def sigmoid(x):
"""Sigmoid激活函数的稳定实现"""
# 1 / (1 + exp(-x))
# 使用 np.clip 来防止 exp(-x) 在 x 为很大负数时溢出
return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
def tanh(x):
"""tanh激活函数的Numpy实现"""
return np.tanh(x)
class LSTMNumpy:
"""
一个使用NumPy从零开始实现的LSTM层。
这个实现将揭示LSTM在每个时间步内部发生的具体计算,
包括四个门控单元和细胞状态的详细交互过程。
"""
def __init__(self, input_size, hidden_size):
"""
初始化LSTM的参数。
:param input_size: 输入向量x_t的维度。
:param hidden_size: 隐藏状态h_t和细胞状态C_t的维度。
"""
self.input_size = input_size # 记录输入维度
self.hidden_size = hidden_size # 记录隐藏层和细胞状态的维度
# --- 初始化权重矩阵和偏置 ---
# 对于LSTM,我们需要为四个部分初始化权重:遗忘门(f), 输入门(i), 候选细胞状态(g/C), 输出门(o)
# 拼接后的输入 [h_t-1, x_t] 的维度是 (hidden_size + input_size)
concat_len = hidden_size + input_size
# 为每个门和候选状态生成一个权重矩阵
# 权重通常使用Xavier/Glorot初始化,这里我们用标准正态分布乘以一个缩放因子来近似
# Wf: 遗忘门的权重矩阵
self.Wf = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / (hidden_size + concat_len))
# Wi: 输入门的权重矩阵
self.Wi = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / (hidden_size + concat_len))
# Wg: 候选细胞状态(G)的权重矩阵 (有时也用Wc表示)
self.Wg = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / (hidden_size + concat_len))
# Wo: 输出门的权重矩阵
self.Wo = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / (hidden_size + concat_len))
# --- 初始化偏置向量 ---
# 偏置通常初始化为零,但有一个重要的技巧:
# 将遗忘门的偏置(bf)初始化为一个正值(如1.0)。
# 这被称为“遗忘偏置技巧”(Forget Bias Trick)。
# 它的作用是在训练初期,让遗忘门的输出接近1,这意味着默认情况下模型会记住所有旧信息。
# 这为模型提供了一个更好的起点,防止梯度在训练初期就因遗忘过多而消失。
# bf: 遗忘门的偏置向量
self.bf = np.ones((hidden_size, 1))
# bi: 输入门的偏置向量
self.bi = np.zeros((hidden_size, 1))
# bg: 候选细胞状态的偏置向量
self.bg = np.zeros((hidden_size, 1))
# bo: 输出门的偏置向量
self.bo = np.zeros((hidden_size, 1))
# 为了简化,我们暂时不考虑输出到特定维度的投影层,
# 假设h_t就是最终输出,或者说后续还有其他层。
def forward(self, inputs):
"""
执行完整序列的前向传播。
:param inputs: 输入数据,一个形状为 (sequence_length, input_size) 的NumPy数组。
我们依然先处理 batch_size = 1 的情况。
:return: outputs (所有时间步的隐藏状态), cell_states (所有时间步的细胞状态)
"""
sequence_length, _ = inputs.shape # 获取序列长度和输入维度
# 初始化第一个隐藏状态 h_0 和细胞状态 C_0 为零向量
# h_prev 用于存储上一个时间步的隐藏状态 h_{t-1}
h_prev = np.zeros((self.hidden_size, 1))
# c_prev 用于存储上一个时间步的细胞状态 C_{t-1}
c_prev = np.zeros((self.hidden_size, 1))
# 创建列表来存储每一步的计算结果,这对于后续的反向传播至关重要
# 同时也便于我们观察内部状态的演变
self.inputs = inputs # 存储原始输入
self.hidden_states = {
0: h_prev} # 存储每个时间步的隐藏状态
self.cell_states = {
0: c_prev} # 存储每个时间步的细胞状态
self.gates_cache = {
} # 存储每个时间步的门控值和候选状态,用于BPTT
outputs = np.zeros((sequence_length, self.hidden_size)) # 初始化输出数组,用于存储所有h_t
# --- 沿时间步循环 ---
for t in range(sequence_length):
# 从输入序列中获取当前时间步的输入向量 x_t
x_t = inputs[t].reshape(-1, 1) # 将其形状从 (input_size,) 调整为 (input_size, 1)
# 1. 拼接(Concatenate)上一步的隐藏状态 h_{t-1} 和当前的输入 x_t
# 这是所有门控计算的共同输入
concat_input = np.vstack((h_prev, x_t)) # 垂直堆叠,形成 (hidden_size + input_size, 1) 的列向量
# 2. 计算遗忘门 f_t
# f_t = sigmoid(Wf * [h_{t-1}, x_t] + bf)
ft = sigmoid(np.dot(self.Wf, concat_input) + self.bf) # 矩阵乘法后加上偏置,再通过sigmoid
# 3. 计算输入门 i_t
# i_t = sigmoid(Wi * [h_{t-1}, x_t] + bi)
it = sigmoid(np.dot(self.Wi, concat_input) + self.bi) # 矩阵乘法后加上偏置,再通过sigmoid
# 4. 计算候选细胞状态 g_t (或称 \tilde{C}_t)
# g_t = tanh(Wg * [h_{t-1}, x_t] + bg)
gt = tanh(np.dot(self.Wg, concat_input) + self.bg) # 矩阵乘法后加上偏置,再通过tanh
# 5. 计算当前细胞状态 C_t
# C_t = f_t * C_{t-1} + i_t * g_t
# 这里的 * 是元素级别的乘法 (Hadamard Product)
c_next = ft * c_prev + it * gt # 遗忘旧记忆,并添加新选择的记忆
# 6. 计算输出门 o_t
# o_t = sigmoid(Wo * [h_{t-1}, x_t] + bo)
ot = sigmoid(np.dot(self.Wo, concat_input) + self.bo) # 矩阵乘法后加上偏置,再通过sigmoid
# 7. 计算当前隐藏状态 h_t
# h_t = o_t * tanh(C_t)
# 这里的 * 也是元素级别的乘法
h_next = ot * tanh(c_next) # 从更新后的长期记忆中,选择性地输出信息
# --- 存储结果并为下一步做准备 ---
outputs[t] = h_next.ravel() # 将当前计算出的隐藏状态 h_next 存储到输出数组中
self.hidden_states[t + 1] = h_next # 存储当前隐藏状态
self.cell_states[t + 1] = c_next # 存储当前细胞状态
self.gates_cache[t] = (ft, it, gt, ot) # 缓存所有门控值以备后用
# 更新 h_prev 和 c_prev,让当前状态成为下一个时间步的"前一个状态"
h_prev = h_next
c_prev = c_next
return outputs, self.cell_states
# --- 演示如何使用这个Numpy实现的LSTM ---
# 定义模型超参数
input_dim = 10 # 假设每个时间步的输入是一个10维向量 (例如一个词嵌入)
hidden_dim = 20 # 我们的记忆单元(隐藏和细胞状态)有20个维度
# 实例化我们的LSTM模型
lstm_numpy = LSTMNumpy(input_size=input_dim, hidden_size=hidden_dim)
# 创建一个模拟的输入序列
sequence_length = 8 # 序列长度为8 (8个时间步)
input_sequence = np.random.randn(sequence_length, input_dim) # 生成随机输入数据
print("--- 输入数据与模型参数 ---")
print(f"输入序列的形状: {
input_sequence.shape}")
print(f"隐藏/细胞状态维度: {
hidden_dim}")
print("\n" + "="*50 + "\n")
# 执行前向传播
final_outputs, all_cell_states = lstm_numpy.forward(input_sequence)
print("--- 前向传播结果 ---")
print(f"所有时间步的最终输出(h_t)形状: {
final_outputs.shape}")
print(f"最后一个时间步的输出 h_{
sequence_length}:\n", final_outputs[-1])
print("\n" + "="*50 + "\n")
print("--- 内部状态追踪:以最后一个时间步为例 ---")
last_t = sequence_length - 1 # 最后一个时间步的索引
ft, it, gt, ot = lstm_numpy.gates_cache[last_t] # 获取最后一个时间步的门控值
final_c = all_cell_states[sequence_length] # 获取最终的细胞状态
print(f"时间步 {
last_t} 的遗忘门(ft)激活值 (部分):\n{
ft.ravel()[:5]}...") # .ravel()将列向量展平为一维数组
print("-> 接近1的值表示保留了大部分之前的长期记忆。\n")
print(f"时间步 {
last_t} 的输入门(it)激活值 (部分):\n{
it.ravel()[:5]}...")
print("-> 这些值决定了新的候选信息有多少被写入了长期记忆。\n")
print(f"时间步 {
last_t} 的输出门(ot)激活值 (部分):\n{
ot.ravel()[:5]}...")
print("-> 这些值过滤了最终的细胞状态,决定了哪些信息被用作当前时刻的输出(h_t)。\n")
print(f"时间步 {
last_t+1} 的最终细胞状态(C_t) (部分):\n{
final_c.ravel()[:5]}...")
print("-> 这是长期记忆传送带上的最终内容,编码了从开始到此刻的全部关键信息。")
代码深度解析:
-
__init__(初始化):concat_len: 我们预先计算了拼接向量[h_{t-1}, x_t]的总长度,这使得我们可以为每个门控单元定义一个统一大小的权重矩阵,简化了代码结构。- 权重初始化:我们没有使用简单的
randn,而是采用了更稳定的一种形式np.random.randn(...) * np.sqrt(1. / (n_in + n_out)),这被称为Xavier/Glorot初始化。它的核心思想是让权重的大小考虑到输入和输出神经元的数量,使得信号在网络中传播时,方差能够保持相对稳定,这有助于缓解梯度消失/爆炸问题。 - 遗忘偏置技巧:
self.bf = np.ones(...)是一个虽小但极其重要的实践技巧。在训练刚开始时,权重是随机的,模型不知道该忘记什么。如果遗忘门的偏置为0,那么初始的遗忘门输出ft可能会接近0.5,导致长期记忆C在每一步都被削减一半,信息会很快丢失。通过将其初始化为1,我们使得ft在训练初期非常接近1,这鼓励模型“默认记住所有事”,直到它通过数据学习到哪些信息是真正可以被遗忘的。
-
forward(前向传播):h_prev,c_prev: LSTM比Simple RNN多维护了一个核心状态c_prev。这两个状态构成了LSTM的完整“记忆”。np.vstack((h_prev, x_t)):vstack(垂直堆叠)是实现向量拼接的关键。它将形状为(hidden_size, 1)的h_prev和形状为(input_size, 1)的x_t堆叠成一个形状为(hidden_size + input_size, 1)的新向量。- 计算顺序:代码严格遵循了我们之前讨论的数学公式顺序:先计算三个门(遗忘、输入、输出)和候选值,然后用门控值来更新细胞状态,最后再用更新后的细胞状态和输出门来计算新的隐藏状态。这个顺序是固定的。
ft * c_prev,it * gt,ot * tanh(c_next): 这三行代码是LSTM的魔法所在。*在NumPy中默认就是元素级别的乘法(element-wise product),完美地实现了哈达玛积 (\odot),将门控的“比例”作用于信息向量之上。- 缓存:
self.gates_cache将每一步的ft, it, gt, ot都保存了下来。这在BPTT中是必需的,因为在计算梯度时,我们需要这些中间值来应用链式法则。
第六章:梯度高速公路——LSTM反向传播的数学解剖与可视化
我们已经用代码构建了LSTM的前向传播机制,见证了信息如何通过精巧的门控系统,在细胞状态((C_t))和隐藏状态((h_t))之间流动。然而,模型的学习能力,即它如何调整数百万个权重以捕捉从简单到复杂的序列模式,完全取决于反向传播的过程。对于Simple RNN,我们发现其反向传播路径是其致命弱点,连乘的权重矩阵导致了梯度在时间的长河中不可避免地消失或爆炸。
LSTM的设计,从根本上就是为了解决这一问题。它的门控结构不仅控制着前向的信息流,更重要的是,它构建了一条稳定、可控的梯度流动的“高速公路”。要理解这其中的奥秘,我们必须手持链式法则的手术刀,深入到LSTM的BPTT(随时间反向传播)过程中,解剖其梯度的回传路径。
6.1 核心突破口:从乘法到加法的革命
在深入研究完整的BPTT之前,让我们先聚焦于那个改变了一切的核心方程式:
[ C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}t ]
这个公式看起来平淡无奇,但它与Simple RNN的隐藏状态更新公式相比,有着本质的区别:
[ h_t = \tanh(W{hh} h_{t-1} + W_{xh} x_t + b_h) ]
Simple RNN的状态更新是高度非线性的、包裹在权重矩阵乘法内的。(h_{t-1}) 必须先与权重矩阵 (W_{hh}) 相乘,然后与其他项相加,最后再被一个tanh函数整个包裹起来。当你对这个结构求导 (\frac{\partial h_t}{\partial h_{t-1}}) 时,权重矩阵 (W_{hh}) 成为了链式法则中一个不可避免的、反复出现的乘法因子,这正是梯度灾难的根源。
相比之下,LSTM的细胞状态更新是近乎线性的、加法主导的。新的细胞状态 (C_t) 是由两部分 直接相加 而成:被遗忘门调节过的旧状态 (f_t \odot C_{t-1}) 和被输入门调节过的新候选状态 (i_t \odot \tilde{C}_t)。
让我们看看当梯度从 (C_t) 回传到 (C_{t-1}) 时会发生什么。根据链式法则,我们关心的是 (\frac{\partial C_t}{\partial C_{t-1}})。观察上面的方程,(C_{t-1}) 只与第一项 (f_t \odot C_{t-1}) 有关。由于 (f_t) 本身也依赖于 (h_{t-1}) 和 (x_t),而不直接依赖于 (C_{t-1}),因此在求偏导时,我们可以将 (f_t) 视为常数。所以,这个求导变得异常简单:
[ \frac{\partial C_t}{\partial C_{t-1}} = f_t ]
这是一个元素级别的乘法,而不是一个矩阵乘法!
这意味着,当总损失 (L) 对 (C_t) 的梯度 (\frac{\partial L}{\partial C_t}) 想要回传到 (C_{t-1}) 时,其路径是:
[ \frac{\partial L}{\partial C_{t-1}} = \frac{\partial L}{\partial C_t} \frac{\partial C_t}{\partial C_{t-1}} = \frac{\partial L}{\partial C_t} \odot f_t ]
这个梯度回传路径上没有与权重矩阵 (W) 的直接相乘。梯度仅仅是与遗忘门 (f_t) 的激活值进行元素级别的相乘。因为遗忘门的值在0和1之间,并且是由网络在每个时间步动态控制的,所以网络拥有了前所未有的能力来调节梯度的流动。如果网络认为一段久远的记忆非常重要,它可以学着在中间的所有时间步都将对应的遗忘门 (f_t) 的值设置为接近1。这样,梯度就可以几乎无衰减地沿着这条由细胞状态构成的“高速公路”回传到遥远的过去。
这种加性结构(additive structure)是LSTM能够捕获长期依赖的关键。它为梯度提供了一条“绿色通道”,绕开了Simple RNN中那个充满风险的、反复与权重矩阵相乘的崎岖山路。
6.2 LSTM单细胞BPTT的完整解剖
现在,让我们来详细推导在一个时间步 (t) 内,梯度是如何在LSTM细胞的各个组件之间分配的。假设在时间步 (t) 之后,我们已经通过BPTT计算出了总损失对 (h_t) 的梯度 (\frac{\partial L}{\partial h_t}) 和对 (C_t) 的梯度 (\frac{\partial L}{\partial C_t})。在实践中,这两个梯度是未来所有时间步的梯度贡献之和。我们的目标是,基于这两个已知的“未来梯度”,计算出损失对当前时间步所有参数((W_f, b_f, W_i, b_i, \dots))的梯度,以及需要回传到上一个时间步的梯度 (\frac{\partial L}{\partial h_{t-1}}) 和 (\frac{\partial L}{\partial C_{t-1}})。
我们将严格按照前向传播的逆序来进行推导。
已知输入:
- (\delta h_t \equiv \frac{\partial L}{\partial h_t})
- (\delta C_t \equiv \frac{\partial L}{\partial C_t})
- 以及前向传播时缓存的所有中间值:(C_{t-1}, h_{t-1}, x_t, f_t, i_t, g_t, o_t, C_t)。
推导过程:
-
计算 (\delta o_t) (对输出门的梯度):
(h_t) 的计算公式为 (h_t = o_t \odot \tanh(C_t))。因此,损失 (L) 通过 (h_t) 对 (o_t) 产生影响。
[ \delta o_t = \frac{\partial L}{\partial o_t} = \frac{\partial L}{\partial h_t} \frac{\partial h_t}{\partial o_t} = \delta h_t \odot \tanh(C_t) ]
这一步计算了输出门对最终错误的贡献。 -
更新 (\delta C_t) (对细胞状态的梯度):
细胞状态 (C_t) 对损失 (L) 的影响有两条路径:一条是直接通过未来的细胞状态 (C_{t+1}) 传递过来的(我们已经假设其为 (\delta C_t) 的一部分),另一条是通过影响当前隐藏状态 (h_t) 间接影响损失。我们必须把这条间接路径的梯度也加进来。
[ \frac{\partial h_t}{\partial C_t} = o_t \odot \frac{\partial}{\partial C_t} \tanh(C_t) = o_t \odot (1 - \tanh^2(C_t)) ]
所以,从 (h_t) 回传到 (C_t) 的梯度为:
[ \frac{\partial L}{\partial C_t}\bigg|_{\text{from } h_t} = \frac{\partial L}{\partial h_t} \frac{\partial h_t}{\partial C_t} = \delta h_t \odot o_t \odot (1 - \tanh^2(C_t)) ]
因此,(C_t) 接收到的总梯度是来自未来的梯度和来自当前输出的梯度之和:
[ \delta C_t^{\text{total}} = \delta C_t + \delta h_t \odot o_t \odot (1 - \tanh^2(C_t)) ]
为了符号简洁,我们后续用 (\delta C_t) 表示这个更新后的总梯度。 -
计算 (\delta f_t) (对遗忘门的梯度):
(C_t) 的计算公式为 (C_t = f_t \odot C_{t-1} + \dots)。损失 (L) 通过 (C_t) 对 (f_t) 产生影响。
[ \delta f_t = \frac{\partial L}{\partial f_t} = \frac{\partial L}{\partial C_t} \frac{\partial C_t}{\partial f_t} = \delta C_t \odot C_{t-1} ]
这计算了遗忘门对错误的贡献,它与上一时刻的细胞状态值成正比。 -
计算 (\delta i_t) (对输入门的梯度):
(C_t) 的计算公式为 (\dots + i_t \odot g_t)。损失 (L) 通过 (C_t) 对 (i_t) 产生影响。
[ \delta i_t = \frac{\partial L}{\partial i_t} = \frac{\partial L}{\partial C_t} \frac{\partial C_t}{\partial i_t} = \delta C_t \odot g_t ]
这计算了输入门对错误的贡献,它与候选细胞状态的值成正比。 -
计算 (\delta g_t) (对候选细胞状态的梯度):
与输入门类似,损失 (L) 也通过 (C_t) 对 (g_t) 产生影响。
[ \delta g_t = \frac{\partial L}{\partial g_t} = \frac{\partial L}{\partial C_t} \frac{\partial C_t}{\partial g_t} = \delta C_t \odot i_t ] -
回传梯度到上一层细胞状态 (\delta C_{t-1}):
这是我们之前讨论过的“高速公路”。损失 (L) 通过 (C_t) 对 (C_{t-1}) 产生影响。
[ \delta C_{t-1} = \frac{\partial L}{\partial C_{t-1}} = \frac{\partial L}{\partial C_t} \frac{\partial C_t}{\partial C_{t-1}} = \delta C_t \odot f_t ]
这个计算出的 (\delta C_{t-1}) 将作为 (t-1) 时刻的 (\delta C_t) 输入,继续反向传播。 -
计算门控线性层的梯度:
现在我们有了对各个门控激活值 (f_t, i_t, g_t, o_t) 的梯度。我们需要进一步将这些梯度反向传播到它们各自的线性变换层。以遗忘门为例:
(f_t = \sigma(z_f)),其中 (z_f = W_f \cdot [h_{t-1}, x_t] + b_f)。
首先,计算对 (z_f) 的梯度,利用Sigmoid函数的导数性质 (\sigma’(z) = \sigma(z)(1-\sigma(z)))。
[ \delta z_f = \frac{\partial L}{\partial z_f} = \frac{\partial L}{\partial f_t} \frac{\partial f_t}{\partial z_f} = \delta f_t \odot (f_t \odot (1 - f_t)) ]
同理,可以计算出 (\delta z_i), (\delta z_o)。对于候选状态 (g_t),其激活函数是 tanh,导数为 (1-\tanh^2(z))。
[ \delta z_g = \delta g_t \odot (1 - g_t^2) ] -
计算对权重和偏置的梯度:
有了对线性层输入 (z_f, z_i, z_g, z_o) 的梯度,我们就可以计算对权重和偏置的梯度了。仍然以遗忘门为例,令concat_input(= [h_{t-1}, x_t]^T)。
[ \delta W_f = \frac{\partial L}{\partial W_f} = \frac{\partial L}{\partial z_f} \frac{\partial z_f}{\partial W_f} = \delta z_f \otimes \text{concat_input}^T ]
这里的 (\otimes) 是外积 (outer product)。在代码实现中,就是np.dot(delta_zf, concat_input.T)。
[ \delta b_f = \frac{\partial L}{\partial b_f} = \delta z_f ]
对其他权重和偏置((W_i, b_i, W_g, b_g, W_o, b_o))的梯度计算方式完全相同。重要的是,在整个序列的反向传播过程中,这些梯度是累加的。 -
回传梯度到上一层隐藏状态 (\delta h_{t-1}) 和输入 (\delta x_t):
这是最后一步,也是最复杂的一步,因为它汇集了所有四个门控单元对 (h_{t-1}) 和 (x_t) 的依赖。
(z_f, z_i, z_g, z_o) 都依赖于concat_input(= [h_{t-1}, x_t])。因此,从这四个路径回传的梯度需要全部加起来。
[ \delta \text{concat_input} = W_f^T \delta z_f + W_i^T \delta z_i + W_g^T \delta z_g + W_o^T \delta z_o ]
由于concat_input是由 (h_{t-1}) 和 (x_t) 拼接而成,我们可以将这个梯度分解,回传给它们各自。
[ \delta h_{t-1} = \delta \text{concat_input}[\text{:hidden_size}] ]
[ \delta x_t = \delta \text{concat_input}[\text{hidden_size:}] ]
这个 (\delta h_{t-1}) 会作为 (t-1) 时刻的 (\delta h_t) 输入,继续反向传播。
这个过程极为繁琐,但它精确地揭示了梯度如何在网络中被“分流”和“重组”。最关键的发现是,我们得到了两条独立的梯度回传路径:一条是为 (\delta C) 准备的、由遗忘门控制的、元素级别的“高速公路”;另一条是为 (\delta h) 准备的、经过所有权重矩阵转置的、复杂的“国道系统”。
6.3 数值实验:亲眼见证LSTM的梯度高速公路
言语和公式的描述或许仍显抽象。现在,让我们设计一个数值实验,来直观地、定量地比较梯度在Simple RNN和LSTM中穿越时间时的表现。我们将模拟一个长序列,并观察梯度从序列末端传播到序列开端时,其范数的变化情况。
我们将特别关注LSTM中两条路径的差异:
- 细胞状态路径 (Cell State Path):梯度通过 (\frac{\partial C_t}{\partial C_k}) 传播。
- 隐藏状态路径 (Hidden State Path):梯度通过 (\frac{\partial h_t}{\partial h_k}) 传播。
我们将看到,细胞状态路径能够将梯度信号保持得非常完好,而隐藏状态路径则不然。
import numpy as np
import matplotlib.pyplot as plt
# --- 首先,我们复用之前实现的Sigmoid和tanh函数 ---
def sigmoid(x):
# Sigmoid激活函数的稳定实现
return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
def tanh(x):
# tanh激活函数的Numpy实现
return np.tanh(x)
# --- 设定实验参数 ---
hidden_size = 15 # 隐藏层维度
time_steps = 100 # 我们将观察长达100个时间步的梯度传播
# --- 为Simple RNN创建一个不稳定的循环权重矩阵 (用于对比) ---
W_rnn = np.random.randn(hidden_size, hidden_size) * 0.5 # 确保其奇异值可能小于1,导致梯度消失
# --- 为LSTM创建权重矩阵 ---
# 我们使用之前LSTMNumpy类中的初始化逻辑
input_size = 10 # 假设一个输入维度
concat_len = hidden_size + input_size
Wf = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / concat_len)
Wi = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / concat_len)
Wg = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / concat_len)
Wo = np.random.randn(hidden_size, concat_len) * np.sqrt(1. / concat_len)
# --- 核心模拟函数 ---
def simulate_gradient_flow_comparison(T):
"""
模拟并比较Simple RNN和LSTM中的梯度流。
我们将计算雅可比矩阵的范数来衡量梯度信号的强度。
"""
# 初始化一个随机输入序列和初始状态
x = np.random.randn(T, input_size)
h_prev_rnn = np.zeros((hidden_size, 1))
h_prev_lstm = np.zeros((hidden_size, 1))
c_prev_lstm = np.zeros((hidden_size, 1))
# --- 用于存储每一步的雅可比矩阵范数 ---
# 雅可比矩阵 J_tk = d(state_t) / d(state_k)
rnn_h_norms = []
lstm_h_norms = []
lstm_c_norms = []
# 初始化 t=k 时的雅可比矩阵,即 d(state_k)/d(state_k) = I (单位矩阵)
J_rnn_h = np.eye(hidden_size)
J_lstm_h = np.eye(hidden_size)
J_lstm_c = np.eye(hidden_size)
# 我们从后向前遍历时间 (k = T-1, T-2, ..., 0)
# 计算 J_T,k = d(state_T)/d(state_k)
for t in range(T - 1, -1, -1):
# --- Simple RNN 传播一步 ---
# 首先需要前向计算得到 z_t = W_rnn * h_{t-1} + ...
# 为了简化,我们只关注循环部分,假设输入为0
z_rnn = np.dot(W_rnn, h_prev_rnn)
# 计算 d(h_t)/d(h_{t-1}) = diag(1-tanh^2(z_t)) * W_rnn
# 我们用一个平均值来近似激活函数导数的影响,以突出W_rnn的核心作用
dh_dprev_h_rnn = np.diag(1 - tanh(z_rnn)**2) @ W_rnn
# 更新雅可比矩阵 J_T,k-1 = J_T,k * d(h_k)/d(h_{k-1})
J_rnn_h = J_rnn_h @ dh_dprev_h_rnn
rnn_h_norms.append(np.linalg.norm(J_rnn_h))
# --- LSTM 传播一步 ---
# 同样需要前向计算来获得门控值
xt = x[t].reshape(-1, 1)
concat = np.vstack((h_prev_lstm, xt))
# 使用“遗忘偏置技巧”,让初始遗忘门偏置为1,鼓励记忆
bf = np.ones((hidden_size, 1))
bi = np.zeros((hidden_size, 1))
bg = np.zeros((hidden_size, 1))
bo = np.zeros((hidden_size, 1))
ft = sigmoid(Wf @ concat + bf)
it = sigmoid(Wi @ concat + bi)
gt = tanh(Wg @ concat + bg)
ot = sigmoid(Wo @ concat + bo)
ct = ft * c_prev_lstm + it * gt
ht = ot * tanh(ct)
# --- 计算LSTM中复杂的 d(state_t)/d(state_{t-1}) ---
# 我们需要 d(h_t)/d(h_{t-1}), d(h_t)/d(c_{t-1}), d(c_t)/d(h_{t-1}), d(c_t)/d(c_{t-1})
# 1. d(c_t)/d(c_{t-1}) = f_t (这是关键!)
dc_dprev_c = np.diag(ft.ravel())
# 2. 其他项更复杂,涉及对门控的求导,我们在这里进行简化和核心逻辑的实现
# 为了突出cell state的作用,我们先只追踪最纯粹的两条路径
# 路径1:梯度只通过细胞状态 C 传播
# J_c(t, k-1) = J_c(t, k) * d(c_k)/d(c_{k-1}) = J_c(t, k) * f_k
J_lstm_c = J_lstm_c @ dc_dprev_c
lstm_c_norms.append(np.linalg.norm(J_lstm_c))
# 路径2:梯度只通过隐藏状态 h 传播(这是一个混合路径,很复杂)
# d(h_t)/d(h_{t-1}) 涉及到所有门的变化,我们用一个近似
# 为了演示,我们假设它也受到一个类似RNN的权重矩阵连乘的影响
# 这是一个简化,但能说明 h->h 路径的固有不稳定性
# 真实的d(h_t)/d(h_{t-1})非常复杂,但最终还是会包含权重矩阵的乘积
Whh_effective = (Wf[:hidden_size, :hidden_size] + Wi[:hidden_size, :hidden_size] + Wg[:hidden_size, :hidden_size] + Wo[:hidden_size, :hidden_size])/4.0
J_lstm_h = J_lstm_h @ Whh_effective
lstm_h_norms.append(np.linalg.norm(J_lstm_h))
# 更新状态以进行下一次迭代
h_prev_rnn = z_rnn # 简化更新
h_prev_lstm, c_prev_lstm = ht, ct
# 反转列表,使其与时间距离 1..T 对应
return list(reversed(rnn_h_norms)), list(reversed(lstm_h_norms)), list(reversed(lstm_c_norms))
# --- 运行模拟 ---
rnn_grad_flow, lstm_h_grad_flow, lstm_c_grad_flow = simulate_gradient_flow_comparison(time_steps)
# --- 可视化结果 ---
plt.figure(figsize=(18, 8))
plt.style.use('seaborn-v0_8-whitegrid')
plt.suptitle("Gradient Flow Comparison: Simple RNN vs. LSTM", fontsize=20, y=1.02)
# Simple RNN 梯度流
plt.subplot(1, 2, 1)
plt.plot(range(1, time_steps + 1), rnn_grad_flow, label='Simple RNN: $h_t \\rightarrow h_k$', color='r', linewidth=2)
plt.yscale('log') # 使用对数尺度能更清晰地看到指数级衰减
plt.title("Simple RNN: Rapid Gradient Decay", fontsize=16)
plt.xlabel("Time Distance (t-k)", fontsize=12)
plt.ylabel("Gradient Norm (log scale)", fontsize=12)
plt.legend()
# LSTM 梯度流
plt.subplot(1, 2, 2)
plt.plot(range(1, time_steps + 1), lstm_c_grad_flow, label='LSTM Cell State Path: $C_t \\rightarrow C_k$', color='g', linewidth=3, linestyle='-')
plt.plot(range(1, time_steps + 1), lstm_h_grad_flow, label='LSTM Hidden State Path (Approx.): $h_t \\rightarrow h_k$', color='b', linewidth=2, linestyle='--')
plt.yscale('log')
plt.title("LSTM: The Gradient Superhighway", fontsize=16)
plt.xlabel("Time Distance (t-k)", fontsize=12)
plt.ylabel("Gradient Norm (log scale)", fontsize=12)
plt.legend(loc='lower left')
plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.show()
实验结果深度分析:
-
左图 (Simple RNN):正如我们之前所预见的,Simple RNN的梯度流(红色实线)表现出典型的指数级衰减。在对数坐标系下,我们看到一条陡峭的下降直线。仅仅经过20-30个时间步,梯度信号的强度就衰减了数个数量级,几乎变为零。这无可辩驳地证明了其在捕捉长距离依赖方面的结构性缺陷。任何跨度超过这个范围的模式,其梯度信号都无法有效回传,模型因此变成了一个“健忘”的学习者。
-
右图 (LSTM):这张图是理解LSTM成功的关键。
- 绿色实线 (细胞状态路径):这条线代表了梯度沿着细胞状态 (C_t) 的“高速公路”回传时的范数。我们能清晰地看到,这条线在很长的时间跨度内(甚至在100个时间步后)都几乎保持水平。梯度范数几乎没有衰减!这正是因为其回传机制是 (\delta C_{t-1} = \delta C_t \odot f_t),并且由于我们使用了遗忘偏置技巧,大部分 (f_t) 的值在初始阶段都接近1。这使得梯度信号可以像在一条没有摩擦力的轨道上滑行一样,几乎无损地穿越漫长的时间隧道。这就是所谓的“梯度高速公路”的直观体现。
- 蓝色虚线 (隐藏状态路径):这条线近似地展示了梯度如果试图主要通过隐藏状态 (h_t) 回传会发生什么。尽管这是一个简化,但它揭示了一个重要事实:即使在LSTM内部,单纯依赖 (h_t \rightarrow h_{t-1}) 的路径(这条路径上梯度必须穿过所有四个门控单元,并受到权重矩阵的反复作用)仍然具有类似Simple RNN的不稳定性,同样会遭遇梯度消失。
7.1 面向LSTM的数据重构:从2D到3D的升维之旅
这是从使用MLP/FNN转向使用RNN(包括LSTM、GRU等)时,最关键也最容易出错的一步。对于Keras中的循环层而言,它们期望的输入数据是一个三维的张量(Tensor),其形状严格遵循 (样本数, 时间步数, 特征数) 的格式。
- 样本数 (Samples): 你的数据集中有多少个独立的序列片段。在我们的窗口化数据中,每一个窗口就是一个样本。
- 时间步数 (Timesteps): 每个样本序列片段的长度。这对应于我们之前定义的
window_size。 - 特征数 (Features): 在每一个时间步,用来描述该时刻状态的数值有多少个。对于单变量时间序列(Univariate Time Series),比如我们的正弦波,在每个时间点只有一个值,所以特征数为1。对于多变量时间序列(Multivariate Time Series),比如用温度、湿度、气压三个指标来预测天气,那么特征数就是3。
回顾一下我们为MLP创建的数据。我们使用create_windowed_dataset函数,将一个一维的时间序列转换为了一个二维的NumPy数组 X,其形状为 (样本数, 窗口大小)。例如,如果有990个样本,窗口大小为10,那么X的形状就是 (990, 10)。
对于MLP来说,这10个输入被看作是10个独立的特征。但对于LSTM,我们需要明确地告诉它:这不是10个独立特征,而是一个包含10个时间步的序列,且每个时间步只有1个特征。
因此,我们必须对MLP的输入数据X进行升维,将其从 (990, 10) 变换为 (990, 10, 1)。这一个小小的reshape操作,在概念上却是一次巨大的飞跃,它标志着我们从处理静态向量转向了处理动态序列。
7.2 使用Keras构建LSTM预测模型
有了正确的数据格式,使用Keras构建LSTM模型就变得非常直观。Keras的LSTM层封装了我们之前用NumPy辛苦实现的所有复杂逻辑(四个门控、细胞状态更新等)。
我们将构建一个简单的序列到单点(Sequence-to-One / Many-to-One)的模型。它读取一个包含window_size个时间步的序列,并输出对下一个时间点的单个预测值。
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import LSTM, Dense, Input
from sklearn.preprocessing import MinMaxScaler
# --- 设定随机种子以保证结果可复现 ---
np.random.seed(42)
import tensorflow as tf
tf.random.set_seed(42)
# --- 1. 生成和准备数据 ---
# 定义一个从0到200,步长为0.1的时间点序列,以获得更长的数据
time_steps_long = np.arange(0, 200, 0.1)
# 根据时间点生成对应的正弦波值
data_long = np.sin(time_steps_long)
# --- 数据归一化 ---
# LSTM(尤其是使用tanh和sigmoid激活的)对输入的尺度非常敏感。
# 将数据归一化到[0, 1]或[-1, 1]区间是至关重要的预处理步骤。
# 这里我们使用MinMaxScaler将其缩放到[0, 1]范围。
scaler = MinMaxScaler(feature_range=(0, 1))
# scaler.fit_transform期望一个2D数组,所以我们先reshape
data_normalized = scaler.fit_transform(data_long.reshape(-1, 1))
# --- 定义窗口化函数 ---
# 这个函数与第一章相同,但我们会对它的输出做额外处理
def create_windowed_dataset(sequence, window_size):
"""
将一维时间序列数据转换为适用于监督学习的数据集。
:param sequence: 输入的一维序列 (已经归一化)。
:param window_size: 定义输入窗口的大小。
:return: X (输入特征), y (目标标签)
"""
X, y = [], []
# 遍历序列,确保有足够的历史数据来创建一个完整的窗口
for i in range(len(sequence) - window_size):
# 截取从i到i+window_size的片段作为输入特征
feature = sequence[i:(i + window_size)]
# 该窗口后的下一个点作为预测目标
target = sequence[i + window_size]
X.append(feature)
y.append(target)
return np.array(X), np.array(y)
# --- 创建数据集 ---
window_size = 20 # 使用一个比之前MLP更大的窗口,以测试LSTM的记忆能力
X_raw, y_raw = create_windowed_dataset(data_normalized, window_size)
# --- !!! 关键步骤:为LSTM重塑输入数据 !!! ---
# X_raw的当前形状: (样本数, 时间步数),例如 (1980, 20)
# 我们需要将其重塑为: (样本数, 时间步数, 特征数)
# 在这个单变量案例中,特征数为1
n_features = 1
X_lstm = X_raw.reshape((X_raw.shape[0], X_raw.shape[1], n_features))
print("--- 数据准备 ---")
print(f"原始一维数据长度: {
len(data_long)}")
print(f"窗口大小: {
window_size}")
print(f"MLP格式的X形状: {
X_raw.shape}")
print(f"LSTM格式的X形状: {
X_lstm.shape}")
print(f"y的形状: {
y_raw.shape}")
# --- 划分训练集和测试集 ---
# 我们使用前80%的数据进行训练,后20%进行测试
split_ratio = 0.8
split_index = int(len(X_lstm) * split_ratio)
X_train, X_test = X_lstm[:split_index], X_lstm[split_index:]
y_train, y_test = y_raw[:split_index], y_raw[split_index:]
print(f"\n训练集大小: {
len(X_train)} 样本")
print(f"测试集大小: {
len(X_test)} 样本")
# --- 2. 构建LSTM模型 ---
# 我们将构建一个包含一个LSTM层和一个全连接输出层的模型
model_lstm = Sequential([
# Input层可以显式定义输入形状,增加模型清晰度
Input(shape=(window_size, n_features)),
# LSTM层
# units=50: LSTM单元的数量,即隐藏状态和细胞状态的维度。这是模型记忆容量的关键超参数。
# activation='tanh': LSTM内部候选细胞状态和隐藏状态输出前的默认激活函数。
# recurrent_activation='sigmoid': 门控单元(遗忘、输入、输出门)的默认激活函数。
# Keras的LSTM层已经默认实现了我们之前讨论的所有内部逻辑。
LSTM(units=50),
# 全连接输出层
# units=1: 因为我们只想预测未来的一个点。
Dense(units=1)
])
# --- 3. 编译模型 ---
# 'mean_squared_error' (MSE) 损失函数,适用于回归任务。
# 'adam' 是一个高效的优化器。
model_lstm.compile(optimizer='adam', loss='mean_squared_error')
model_lstm.summary() # 打印模型结构
# --- 4. 训练模型 ---
# epochs=25: 将整个训练数据集完整地训练25遍。
# batch_size=32: 每次更新模型权重时使用32个样本。
# validation_data: 在每个epoch结束后,在测试集上评估模型性能,以便观察是否有过拟合。
history = model_lstm.fit(X_train, y_train, epochs=25, batch_size=32, validation_data=(X_test, y_test), verbose=1)
# --- 5. 评估和可视化 ---
# 绘制训练过程中的损失变化
plt.figure(figsize=(12, 6))
plt.plot(history.history['loss'], label='Training Loss') # 训练集损失
plt.plot(history.history['val_loss'], label='Validation Loss') # 验证集损失
plt.title('Model Loss During Training') # 标题
plt.xlabel('Epoch') # x轴标签
plt.ylabel('Loss (MSE)') # y轴标签
plt.legend() # 显示图例
plt.grid(True) # 显示网格
plt.show()
代码深度解析:
- 数据归一化 (
MinMaxScaler): 这是一个在处理时序数据时极其重要的步骤。LSTM内部的tanh和sigmoid函数都在特定的数值范围内工作得最好。如果输入数据尺度非常大(比如股票价格成千上万),会导致激活函数的梯度饱和(梯度接近于0),使得模型难以学习。归一化确保所有数据都在一个可控的范围内,极大地稳定了训练过程。 X_lstm = X_raw.reshape(...): 这是本节的核心操作。np.reshape函数在不改变数据本身的情况下,改变了NumPy数组的“视图”。通过增加最后一个维度n_features=1,我们向Keras的LSTM层传递了至关重要的结构信息。LSTM(units=50): 这是模型的“心脏”。units=50意味着 (h_t) 和 (C_t) 都是50维的向量。这个数字越大,模型的“记忆容量”和表达能力就越强,但同时也意味着更多的参数和更高的过拟合风险。这个值的选择是一个需要根据问题复杂度进行调整的超参数。return_sequences参数(此处为默认的False):keras.layers.LSTM有一个非常重要的参数return_sequences。return_sequences=False(默认值):LSTM层只返回整个序列处理完后的最后一个时间步的隐藏状态 (h_T)。这适用于“多对一”(Many-to-One)的任务,比如我们的例子:输入一个序列,预测一个值。return_sequences=True:LSTM层会返回每一个时间步的隐藏状态 ((h_1, h_2, …, h_T))。这适用于“多对多”(Many-to-Many)的任务,例如,如果要构建一个堆叠的LSTM网络(一个LSTM层的输出作为下一个LSTM层的输入),那么除了最后一层之外的所有LSTM层都必须设置return_sequences=True。
model_lstm.summary(): 这个函数非常有用,它打印出模型的架构,包括每一层的名称、输出形状和参数数量。你会发现一个LSTM层的参数量远多于一个Dense层。其参数量计算公式为:4 * (hidden_size * (input_size + hidden_size) + hidden_size)。4代表四个线性变换层(对应i, f, o, g),hidden_size * input_size是输入权重,hidden_size * hidden_size是循环权重,最后的hidden_size是偏置。
7.3 LSTM的真正威力:生成式多步预测
到目前为止,我们做的还只是“单步预测”,即给定一个真实的窗口,预测下一个点。这虽然有用,但并没有完全发挥出LSTM作为时序模型的潜力。LSTM的真正威力体现在生成式预测(Generative Prediction)或多步预测(Multi-step Prediction)上。
其思想是:我们先用一个真实的窗口进行第一次预测。然后,将这个预测出的值作为“新的真实值”,更新我们的输入窗口(去掉最旧的一个点,加上新预测的点),然后用这个更新后的窗口进行第二次预测。如此循环往复,模型就可以在没有任何真实数据的情况下,自己“想象”出未来的走势。这个过程是MLP完全无法做到的,因为它没有内部状态来承载序列演变的动态。
# --- 6. 生成式多步预测 ---
# 我们将从测试集的第一个窗口开始,让模型自己向未来预测
# 我们希望模型能预测接下来100个时间点
n_predict_steps = 100
# 从测试集中取出第一个窗口作为初始输入
current_window = X_test[0].copy() # 使用.copy()确保不修改原始数据
# 用于存储模型生成的所有预测值
predictions_multi_step = []
for _ in range(n_predict_steps):
# --- 预测下一个点 ---
# current_window 的形状是 (window_size, n_features),即 (20, 1)
# 模型期望的输入是 (batch_size, window_size, n_features),即 (1, 20, 1)
# 所以我们需要在最前面增加一个batch维度
input_for_model = current_window.reshape((1, window_size, n_features))
# 使用模型进行预测
predicted_value = model_lstm.predict(input_for_model, verbose=0)[0][0] # verbose=0不在预测时打印进度条
# --- 保存预测结果 ---
predictions_multi_step.append(predicted_value)
# --- 更新窗口,为下一次预测做准备 ---
# predicted_value是一个标量,需要将其转换为(1,1)的数组以进行拼接
new_value_reshaped = np.array([[predicted_value]])
# 将旧窗口去掉第一个时间点,然后在末尾添加上我们刚刚预测出的新值
current_window = np.append(current_window[1:], new_value_reshaped, axis=0)
# --- 7. 反归一化并可视化结果 ---
# 我们的预测值是在归一化尺度上的,需要转换回原始尺度以便理解
# 首先将单步预测结果反归一化
test_predictions_single_step = model_lstm.predict(X_test)
test_predictions_single_step = scaler.inverse_transform(test_predictions_single_step)
# 将真实的测试集

最低0.47元/天 解锁文章
3310

被折叠的 条评论
为什么被折叠?



