2、循环神经网络基础:RNN结构、前向传播与时间展开、参数共享

好,咱们正式开始啃 RNN。很多人觉得循环神经网络是个黑盒,其实说白了,它就是给普通神经网络加了个“记忆轴”。我最早接触序列数据时,用全连接网络硬怼文本,结果一团糟。后来才明白——你需要一个能记住上下文的结构。

2.1 从全连接网络到循环网络的进化

全连接网络处理每个输入都是独立的。你给它一个词“我”,它不认识前后文。但序列数据不一样:句子、时间序列、甚至分子SMILES字符串,前后次序藏着关键信息。

我个人习惯把RNN想象成一个带小本子的处理器。每个时刻它读入一个新输入,同时翻一翻小本子上记的上一个时刻的状态,再更新本子。这样信息就能沿着时间流动了。

核心直觉:RNN = 前馈网络 + 隐藏状态反馈。隐藏状态就是那个“小本子”。

2.2 RNN的基本结构

一个标准的RNN单元,输入有三路:当前输入 \(x_t\)、上一时刻隐藏状态 \(h_{t-1}\),还有偏置。输出也有两路:当前时刻隐藏状态 \(h_t\)(传给下一时刻),以及可选的输出 \(y_t\)。

公式其实很简单(我用最基础的tanh版本):

h_t = tanh(W_hh · h_{t-1} + W_xh · x_t + b_h)
y_t = W_hy · h_t + b_y

嗯,这里要注意:\(W_{hh}\) 是隐藏到隐藏的权重,\(W_{xh}\) 是输入到隐藏的权重。这两个矩阵是RNN中“循环”的根源。

用numpy写一个最简单的RNN前向传播(只算单步)大概是这样的:

import numpy as np

def rnn_step(x_t, h_prev, W_xh, W_hh, b_h):
    # x_t: (input_dim,)
    # h_prev: (hidden_dim,)
    h_t = np.tanh(W_xh @ x_t + W_hh @ h_prev + b_h)
    return h_t

你看,就这几行。但实际项目里你会碰到各种坑——我后面会讲。

2.3 时间展开与计算图

RNN在时间上是循环的,但做反向传播时,我们需要把这个循环“展开”成一个链式计算图。这就是所谓的时间展开(unfolding)。

说白了,就是把循环的箭头一条条拉开,变成一个深层的、每层参数相同的网络。展开后有多少层?跟你输入序列的长度一样。

我记得第一次手动画时间展开图时,把 t=0 到 t=3 的4个框画得一塌糊涂。后来我习惯用下面的图来帮助理解:

RNN单元结构 + 时间展开 + 参数共享 单步RNN单元 x_t h_{t-1} tanh h_t y_t 时间展开 (t=0,1,2) 参数共享 RNN t=0 RNN t=1 RNN t=2 x₀ x₁ x₂ h₀ h₁ h₂ 所有RNN框共享同一组权重W

我的理解:把RNN展开后,你得到的实际上是一个非常深的前馈网络,但每一层的权重都一模一样。这就是参数共享的物理意义。

2.4 参数共享:RNN的核心思想

你想想看,如果每个时间步都用不同的权重矩阵,那模型参数会随着序列长度线性增长。面对不定长的分子序列,这种方案根本不可行。

参数共享让模型学到的是“一个通用的转换规则”:无论输入出现在序列的哪个位置,都用同一套 \(W_{xh}, W_{hh}\) 去处理。这就好比你学会一种加法规则,任意位置的数字都能加——不需要为每个位置单独学一种加法。

特性参数共享(RNN)无参数共享(堆叠独立FC)
参数量固定,与序列长度无关与序列长度成正比
泛化到变长序列自然支持需固定输入长度
学习效率高(数据越多,卷积式共享)低(每个位置独立学习)
常见问题梯度消失/爆炸参数爆炸,过拟合

我在做分子生成时,一开始尝试过用位置编码+全连接网络来处理SMILES序列。结果序列稍长一点,模型就崩了。后来切回参数共享的RNN,效果立马稳定了。

2.5 一个简单的手写RNN前向传播示例

下面给一个完整的手写RNN前向传播,处理3个时间步的序列。注意观察:每一步用的都是同一个 W_xh 和 W_hh。

import numpy as np

# 超参数
input_dim = 5
hidden_dim = 4
seq_len = 3

# 初始化参数(共享的!)
W_xh = np.random.randn(hidden_dim, input_dim) * 0.01
W_hh = np.random.randn(hidden_dim, hidden_dim) * 0.01
b_h  = np.zeros((hidden_dim,))

# 输入序列 (seq_len, input_dim)
x_seq = np.random.randn(seq_len, input_dim)

# 初始隐藏状态 h_{-1} 通常为零向量
h = np.zeros(hidden_dim)

# 前向传播
for t in range(seq_len):
    x_t = x_seq[t]
    h = np.tanh(W_xh @ x_t + W_hh @ h + b_h)
    print(f"t={t}, h={h}")

运行结果会输出每个时刻的隐藏状态。注意 h 会带着之前所有输入的信息往前传播。这就是RNN的“记忆”。

2.6 避坑指南

我曾经在初始化 RNN 时直接用默认的随机正态分布(标准差1),结果训练几个 epoch 就 NaN 了。后来养成了习惯:使用小标准差(0.01)初始化权重,偏置全零。对于隐藏状态矩阵 \(W_{hh}\),甚至可以用正交初始化来缓解梯度问题。

另外,时间展开后网络变得很深,容易出现梯度爆炸或消失。我的经验是:先跑了几个小序列确认前向传播没问题,再上反向传播。还有一种常见的陷阱是——不同批次序列长度不一致时,需要做 padding 并 mask 掉无效时间步。这个我们后面讲分子数据预处理时会细说。

一句话总结本章:RNN通过隐藏状态在时间维度上传递信息,时间展开让它变成一个深度前馈网络,而参数共享是它处理变长序列的核心能力。搞懂这三者的关系,后面的LSTM、GRU和分子生成模型就顺了。