RNN循环神经网络这个话题,网上教程一抓一大把,但大多数一上来就是公式、图、括号,不少初学者看到h_t = tanh(W_h h_{t-1} + W_x x_t + b)就直接关网页了。这篇文章我打算换个讲法,从“RNN到底在解决什么问题”这个源头出发,把 RNN结构和原理 一层一层剥开,然后用数字手算一遍,最后用 PyTorch 写一个能跑的字符级模型。你不需要什么数学基础,跟着走完,你会发现自己能看懂甚至能复现一个最小可用的RNN。如果你已经在项目里调过LSTM,也可以直接跳到后面看踩坑记录。
1. RNN为什么能“记得住”:从问题到结构
1.1 序列数据到底特殊在哪,为什么普通神经网络搞不定
先想一个问题:如果你要判断一段话的感情是正面还是负面,普通人看电影评论时会怎么做?肯定是一句一句读,读到“难看”就记下来,读到“精彩”也记下来,最后综合所有信息给结论。这个“读”的过程天然是顺序化的——你不可能同时看完所有字,必须一个字一个字地来,而且前面看到的词会留在脑子里,影响后面词的理解。
这种数据就是序列数据。文本是序列,语音是序列,股票价格是序列,甚至一段视频里的每一帧也可以看成时间序列。序列数据和普通表格数据最大的区别有三点:第一,长度不固定,一句话可以3个字也可以300个字,一个视频可以10秒也可以10分钟;第二,顺序本身携带信息,“我打你”和“你打我”用的词完全一样,意思完全相反;第三,前面和后面的内容会互相影响,也就是所谓的长短期依赖。
普通全连接神经网络处理这些东西会非常难受。你用固定长度输入做情感分类,就得把句子截断成固定比如“前50个字”,截断会丢信息,补零又浪费计算。更重要的是,全连接网络在结构上就没有“先后”概念,你把“我打你”的三个词向量按任意顺序拼进去,它输出的结果理论上都一样,因为全连接层对每个输入维度的处理是并行的、位置对称的。这显然不符合语言和时间的本质。
卷积神经网络比全连接好一点,至少能抓局部模式,比如连续几个词的空格、常用搭配。但卷积窗口通常是固定大小的,你用来抓“3-gram”,就抓不到“5-gram”;用来抓“5-gram”,就看不到跨段的长距离依赖。想让CNN处理任意长度的序列,你还得堆很多层,工程上很笨重。
所以人们开始琢磨:能不能做一个网络结构,它本身就是为了“一步步读数据”而生的?每读一步,就把自己当前的“状态”更新一次,这个状态里既包含当前输入的信息,也包含之前所有步骤带过来的信息。这就是RNN循环神经网络最初的设计动机。
1.2 循环结构如何一步一步攒记忆:公式拆解
RNN 的核心结构,你可以先把它理解成一个带有“内部小本子”的小盒子。每来一个新输入x_t,小本子上就多记一笔;那怎么记?RNN 给了一个非常朴素的规则:
h_t = tanh(W_h * h_{t-1} + W_x * x_t + b)
这个公式看起来吓人,拆开来其实特别简单。
x_t是当前时刻的输入,比如第 t 个字的向量表示。h_{t-1}是上一时刻的隐藏状态,也就是“旧的小本子”。W_h是隐藏状态到隐藏状态的权重矩阵,它决定“过去记忆”要以多大比例进入下一次更新;W_x是当前输入变换到隐藏状态的权重矩阵,它决定“新信息”要以多大比例进来;b是偏置项,类比成给小本子上写字的笔的初始重量。
最后套一个tanh激活函数,就是把“旧记忆 + 新输入 + 偏置”这堆数字压缩到 -1 到 1 之间。为什么要压缩?因为如果不压缩,经过很多时间步以后数字会越滚越大,直接爆炸;压缩以后,信息能保留但幅度受到限制,计算也更稳定。
你注意到没有,这里W_h、W_x、b不管在第几个时间步,都是同样一套参数。这就是“循环”这两个字的真正含义——它不是在一个时间点上循环,而是在时间轴上不断复用同一套变换规则。小本子被一遍遍翻开,但笔和字的规则不变。
用生活一点的话说,RNN 的行为很像记单词:你今天背一个单词,不是从零开始背,而是带着昨天背过的、上周背过的记忆一起,把新单词融合进自己的词汇系统里。旧记忆是h_{t-1},新单词是x_t,融合结果是h_t。
1.3 “循环”是共享的:RNN与全连接网络的关键差距
理解“参数共享”这件事,是理解 RNN结构和原理 的分水岭。
如果不用循环结构,而是把“我打你”三个词展开拼接成一个大向量,再丢进一个全连接网络,网络确实能在训练集上学到“前50个位置是主语”这种模式。但它一遇到长一点的句子就懵了:你的网络结构写死了固定维度,长度一变,连输入层都放不下。而且全连接网络对每个位置都要学一套独立的权重,句子越长,参数越多,很快超过你手里数据的负担能力,大部分位置根本没有足够样本去训练,学出来全是噪声。
RNN 怎么解决?它在时间维度上共享同一套W_h、W_x,不管序列是 10 步还是 100 步,需要学习的参数量固定不变,只取决于单词向量维度和隐藏单元数量。这意味着它天生就能处理变长序列,而且对“第 50 个位置出现主语”这种位置特化模式不敏感,它学的是“当前词在已有记忆的背景下如何被理解”,更贴近语言本身的规律。
代价也就在这里:因为同一个时间步被反复执行,展开以后整个网络其实就是把同一个“单元”复制了很多份。反向传播时,梯度要沿着这条时间轴一路传回去,连续乘上很多次权重矩阵,导致数值极其不稳定。这个问题我们后面专门用一节来讲,现在先记住这个感觉:循环共享参数是 RNN 解决问题的手段,也是它各种毛病的根源。
2. 手把手推演RNN计算:用数字把这套流程盘明白
2.1 搭建一个极小的RNN实例
公式从文字上理解是一回事,亲手算一遍是另一回事。我自己学这个东西时,最大突破就是找了一个极小的例子,拿计算器一步一步按出来的。
假设我们现在有一个词表,只有三个词:“我”“爱”“你”,用 one-hot 向量表示。也就是说:
- “我” = [1, 0, 0]
- “爱” = [0, 1, 0]
- “你” = [0, 0, 1]
输入维度 input_size = 3,隐藏单元数 hidden_size 也设为 3,这样手算起来不至于太复杂。
模型权重我们就随便指定一组:
W_h矩阵:[[0.5, 0, 0], [0, 0.6, 0], [0, 0, 0.8]]W_x矩阵:[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]- 偏置
b向量:[0.1, 0.1, 0.1] - 初始隐藏状态
h_0:[0, 0, 0]
这个初始化方式不是随便写的,对角线权重偏小、偏置常数,是为了让手算过程不出现特别夸张的数字,也方便看出变化规律。实际训练中你会用随机初始化,但这里要的是理解流程。
激活函数就用tanh,它的作用是让输出保持在 -1 到 1 之间。
2.2 三个时间步完整手算
现在输入句子是“我爱你”,三个字依次进入RNN。
第一步,输入“我”。
h_1 = tanh(W_h * h_0 + W_x * x_我 + b)
因为h_0是零向量,W_h * h_0就是零向量。
W_x * x_我:x_我 = [1, 0, 0],所以W_x的第一列被取出来,也就是 [0.1, 0.4, 0.7]。
再加偏置 b = [0.1, 0.1, 0.1]:
[0 + 0.1 + 0.1, 0 + 0.4 + 0.1, 0 + 0.7 + 0.1] = [0.2, 0.5, 0.8]
然后逐项取 tanh:
- tanh(0.2) ≈ 0.197
- tanh(0.5) ≈ 0.462
- tanh(0.8) ≈ 0.664
所以h_1= [0.197, 0.462, 0.664]。
第二步,输入“爱”。
h_2 = tanh(W_h * h_1 + W_x * x_爱 + b)
先算W_h * h_1:
[0.5 * 0.197, 0.6 * 0.462, 0.8 * 0.664] = [0.0985, 0.2772, 0.5312]
再算W_x * x_爱:x_爱 = [0, 1, 0],取第二列 [0.2, 0.5, 0.8]。
加起来:
[0.0985 + 0.2 + 0.1, 0.2772 + 0.5 + 0.1, 0.5312 + 0.8 + 0.1] = [0.3985, 0.8772, 1.4312]
取 tanh:
- tanh(0.3985) ≈ 0.378
- tanh(0.8772) ≈ 0.704
- tanh(1.4312) ≈ 0.891
所以h_2= [0.378, 0.704, 0.891]。
第三步,输入“你”。
h_3 = tanh(W_h * h_2 + W_x * x_你 + b)
W_h * h_2= [0.5 * 0.378, 0.6 * 0.704, 0.8 * 0.891] = [0.189, 0.4224, 0.7128]
W_x * x_你:x_你 = [0, 0, 1],取第三列 [0.3, 0.6, 0.9]。
加起来:
[0.189 + 0.3 + 0.1, 0.4224 + 0.6 + 0.1, 0.7128 + 0.9 + 0.1] = [0.589, 1.1224, 1.7128]
取 tanh:
- tanh(0.589) ≈ 0.529
- tanh(1.1224) ≈ 0.808
- tanh(1.7128) ≈ 0.936
所以h_3= [0.529, 0.808, 0.936]。
我整理了一张表,你一眼能看出变化趋势:
| 时间步 | 输入 | 计算后 h_t |
|---|---|---|
| 1 | 我 | [0.197, 0.462, 0.664] |
| 2 | 爱 | [0.378, 0.704, 0.891] |
| 3 | 你 | [0.529, 0.808, 0.936] |
最后一步,如果我们要做一个“预测下一个词”的任务,通常会再把h_3接一层全连接,映射回词表大小的向量,然后取最大概率的索引。这层映射和 RNN 本身是两回事,你可以理解成“把小本子最终的内容翻译成输出语言”。
2.3 从手算看懂信息流动
上面这组数字里,藏着一个非常重要的事实:h_3并不是只由“你”决定的,它里面有“我”和“爱”的痕迹。
你看第一步本来是 [0.197, 0.462, 0.664],第二步经过W_h * h_1把前面的状态带进来了,第三步又经过W_h * h_2把第一步加第二步的融合结果带进来了。等于说每一步都在更新“整个到目前为止”的摘要,而不只是“当前词”的摘要。这就是RNN循环神经网络能处理语言顺序性的根本原因。
还有个细节值得注意:在第二步里,输出 [0.378, 0.704, 0.891] 里,第三个维度的值涨得特别快,因为它继承了第一步里W_h第三维权重 0.8 的放大效果,再加上当前“爱”的第三维 0.8,两个来源一叠加,数值就上去了。这个现象可以看出RNN的一个特性:如果某个维度一直收到正向输入,它的隐藏状态会逐渐饱和,逼近 tanh 的极限 1,之后再怎么加都变化不大。真实模型里这就意味着信息饱和或遗忘,也是RNN训练不稳定的一个直觉来源。
如果你想自己体验,建议在纸上找一个更长的句子,比如“我爱学习”,换成 4 个时间步,把每一步的中间结果写下来。不要觉得这个动作幼稚,我见过不少同学觉得手算“浪费时间”,结果调模型时连输出维度都搞错。手算一次能帮你建立对张量形状和信息流动的体感,后面排错快得多。
3. 用PyTorch写一个能跑的字符级RNN
3.1 数据准备:把文本变成张量
理论讲再多,不跑代码等于白看。我建议你直接在本机装一个 PyTorch,没有 GPU 也没关系,CPU 跑下面这个小例子绰绰有余。
最简单的实验载体是“字符级语言模型”:给定一串字符,让模型预测下一个字符。我们用一个很短的文本串:
text = "你爱我我爱你"这个文本只有 3 种字符:我、你、爱。正好和我们前面的手算例子对上。
先把字符映射成索引:
chars = sorted(list(set(text))) # ['你', '我', '爱'] char_to_idx = {ch: i for i, ch in enumerate(chars)} idx_to_char = {i: ch for i, ch in enumerate(chars)} vocab_size = len(chars) print("字符表:", chars) print("索引映射:", char_to_idx)接下来构造训练数据。我们的目标是:给定“你爱我我爱”,预测“爱我我爱你”。也就是输入序列是除最后一个字符外的所有字符,输出序列是向后错一位的所有字符。
import torch import torch.nn.functional as F input_indices = [char_to_idx[ch] for ch in text[:-1]] target_indices = [char_to_idx[ch] for ch in text[1:]] # 转成 one-hot,形状为 (seq_len, 1, vocab_size),batch 维度先留 1 x_train = F.one_hot(torch.tensor(input_indices), num_classes=vocab_size).float().unsqueeze(1) y_train = torch.tensor(target_indices).unsqueeze(1) # (seq_len, 1) print("输入形状:", x_train.shape) print("目标形状:", y_train.shape)这段代码里有几个新手容易困惑的地方:: -1表示去掉最后一个字符,: 1:表示从第 2 个字符开始,这样每个输入字符都有对应的“下一个字符”作为标签。unsqueeze(1)是在中间加一个 batch 维度,PyTorch 的 RNN 默认接受(seq_len, batch, input_size)这种形状,我们这里只有一个样本,所以 batch 维度恒为 1。
3.2 模型定义:最简单的RNNCell
PyTorch 里封装好的nn.RNN用起来很方便,但第一次接触时我强烈推你用nn.RNNCell自己写一遍循环,这样能清楚看见“循环”到底是怎么发生的。RNNCell 就是“循环里会重复执行的那个小盒子”,它接受(input, hidden),输出一个新 hidden。
import torch.nn as nn class CharRNN(nn.Module): def __init__(self, vocab_size, hidden_size): super().__init__() self.hidden_size = hidden_size self.rnn_cell = nn.RNNCell(vocab_size, hidden_size) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, x): # x: (seq_len, batch, vocab_size) batch_size = x.size(1) h = torch.zeros(batch_size, self.hidden_size) seq_len = x.size(0) outputs = [] for t in range(seq_len): h = self.rnn_cell(x[t], h) outputs.append(h) # 把每个时间步的隐藏状态堆叠起来 out = torch.stack(outputs, dim=0) # (seq_len, batch, hidden_size) logits = self.fc(out) # 每个时间步都做一次映射 return logits你注意看那个for t in range(seq_len)循环,就是 RNN结构和原理 最直白的体现。x[t]是第 t 个字符的 one-hot 向量,h从零向量开始,每走一步就被更新一次。因为 PyTorch 的 RNNCell 内部已经实现了tanh(W_h * h_{t-1} + W_x * x_t + b),所以你没看到那些矩阵,但实际计算和我们手算时一模一样。
最后的self.fc层把每个时间步的隐藏状态映射回词表大小,便于计算损失。这在字符级模型里叫“逐时间步预测”,每个位置都输出一个词表上的概率分布,可以和该位置的标签直接算交叉熵。
3.3 训练循环和采样
训练部分,我们用最基础的 Adam 优化器,交叉熵损失,代码很简单:
import torch.optim as optim hidden_size = 16 model = CharRNN(vocab_size, hidden_size) optimizer = optim.Adam(model.parameters(), lr=0.01) loss_fn = nn.CrossEntropyLoss() epochs = 500 for epoch in range(epochs): model.train() logits = model(x_train) # (seq_len, batch, vocab_size) # 交叉熵需要把 logits 和 target 展平 loss = loss_fn(logits.view(-1, vocab_size), y_train.view(-1)) optimizer.zero_grad() loss.backward() # 梯度裁剪是好习惯,RNN 尤其需要 nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() if (epoch + 1) % 100 == 0: print(f"epoch {epoch + 1}, loss = {loss.item():.4f}")这里我加了clip_grad_norm_,它的作用是把反向传播出来的梯度向量的总范数限制在 1.0 以内。别小看这一行,RNN 训练时的梯度爆炸问题,十次有八次靠它压住,新手不写这一行很容易看到 loss 突然变成 NaN。
训练完以后,我们要让模型自己去“写”新文本。最简单的办法是从一个起始字符出发,用模型输出的概率分布去采样下一个字符,然后把新字符当成下一次输入,循环下去:
def sample(model, start_char, length=10): model.eval() idx = char_to_idx[start_char] result = start_char h = torch.zeros(1, hidden_size) with torch.no_grad(): for _ in range(length): x = F.one_hot(torch.tensor([idx]), num_classes=vocab_size).float().unsqueeze(0) h = model.rnn_cell(x[0], h) logits = model.fc(h) probs = F.softmax(logits.squeeze(0), dim=-1) idx = torch.multinomial(probs, 1).item() result += idx_to_char[idx] return result print(sample(model, "你", length=20))因为我训练文本太短,模型大概率只能学到一种循环模式,生成出来可能类似“你爱我我爱你我爱你”这种无限重复。这正是字符级RNN的正常表现——数据本身简单,模型当然也就只会重复套路。
3.4 实际运行效果和训练细节思考
上面这个例子在 CPU 上跑完 500 轮也就几秒钟,loss 会从最初的 1.0 左右一路降到 0.2 以下。如果你把text换成长一点的语料,比如莎士比亚全集、红楼梦,只要字符量够大,模型就能生成更有“风格”的句子。这就是最原始的 char-RNN 语言模型,虽然今天已经被 Transformer 抢了风头,但它依然是理解神经网络处理序列问题的最佳教学例子。
我试过一个小改动:把text改成"我是中国人我喜欢中国",同样训练 500 轮,模型很快记住“我”后面大概率接“是”或“喜”,“国”后面一定接“人”或“”。你会发现它学到的不是背答案,而是字符间的转移概率。这个直觉很关键。
还有一点,hidden_size=16是我故意调小的,目的是展示一个小隐藏层也能记住简单模式。如果你想看模型犯糊涂,把它调到 4,训练结果会变得乱七八糟;调到 32,效果也不会好太多。这说明在小数据集上,隐藏单元不是越多越好,参数过多反而容易过拟合,只学会死记硬背。
4. 新手避坑指南:RNN训练中的常见问题与改进
4.1 梯度消失/爆炸为什么偏爱RNN
RNN 反传时用的是 BPTT(时间反向传播)算法。你可以把训练过程理解为:模型先把整条序列跑一遍,得到一个损失,然后损失对每个参数求梯度;对于很早时间步的参数,梯度需要沿着时间轴一步步“传回去”,每传一步就要乘一次隐藏权重矩阵。
如果W_h里特征值小于 1,连续乘 30 次以后数值趋近于 0,早时间步的参数就收不到梯度信号,模型学不到长依赖;如果特征值大于 1,数值直接爆炸,训练发散。这就是梯度消失和爆炸的来源。所以我们在前面代码里加了clip_grad_norm_,就是为了把爆炸的梯度强行拉回来。
要更直观地理解,你可以想象在一条很长的流水线上传递一件物品,每经过一个工位,物品价值就要乘以一个略小于 1 的比例。一开始物品还有价值,传过 50 个工位以后,价值已经小到可以忽略不计。反向传播就是这样,越靠前的信息越容易被“磨损”掉。
这也是为什么经典 RNN 在实际任务里不太能处理“长距离依赖”问题:你再怎么训,它也记不住 100 步以前的信息。这个问题在 Transformer 出现以前,主要靠 LSTM、GRU 缓解,但它们也不是万能药,长序列任务照样吃力。
4.2 用LSTM/GRU替换RNN是否一定更好
LSTM 引入了“门”机制,有遗忘门、输入门、输出门,核心思路是让网络自己决定要记住什么、扔掉什么;GRU 则是 LSTM 的精简版,参数更少,训练更快。现在的深度学习框架里,你把nn.RNNCell换成nn.LSTMCell或nn.GRUCell几乎不需要改动其他代码。
但我得提醒一点:并不是所有场景都必须用 LSTM。如果你任务里的序列不长,几十个词以内,经典 RNN 配合梯度裁剪也能跑,参数少、过拟合风险低。我见过有些同学一上来就上 LSTM,结果小数据集上又慢又容易过拟合,还百思不得其解。选择模型结构之前,先看一看你的数据到底有多长、需要多远的依赖。
另外一个实践心得:如果序列长度差异很大,用 LSTM/GRU 之前最好先做一个简单的截断或者分桶,把长度相近的样本分到一批里。否则一个 5 个词的句子和一个 500 个词的句子在一个 batch 里,padding 部分会产生很多无意义计算,还容易让模型被空字符干扰。
4.3 提升RNN训练效果的几个土办法
这些方法都不是什么高深理论,但我在调模型时每次都管用:
第一,梯度裁剪一定加。RNN 最怕梯度爆炸,你永远不知道你的权重矩阵特征值在哪个 epoch 会突然把 loss 顶到上亿。clip_grad_norm_一行代码能省你大量排查时间。
第二,学习率宁小勿大。RNN 的一阶动量在时间轴上不平稳,学习率太大会让隐藏状态在几个时间步内震荡到发散。我常用 Adam 加 1e-3 起步,如果 loss 掉得慢就降一点,不要觉得 1e-2 更酷。
第三,初始化和模型权重检查。如果你用nn.RNN,PyTorch 默认的初始化其实还行;如果用nn.RNNCell,有时候你需要自己初始化 hidden 为很小的随机向量,不要直接全零。全零在简单任务里没问题,但遇到复杂有偏的数据会让早期梯度方向太单一。
第四,用双向 RNN 前先想清楚任务方向。双向 RNN 能看到未来信息,在情感分类、命名实体识别里有效,但在逐字生成任务里必须用单向,因为生成时没有未来。新手经常搞混这个点,在语言模型里加了双向结构,生成时直接报错或者效果很差。
我整理了一个常见的踩坑对照表:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| loss 突然 NaN | 梯度爆炸 | 加梯度裁剪,降低学习率 |
| loss 下降很慢 | 学习率太小/隐藏层太小 | 调大学习率,增加 hidden_size |
| 训练集 loss 低,测试集高 | 过拟合 | 减少 hidden_size,加 Dropout、加数据 |
| 效果不稳定 | 权重初始化不当 | 固定随机种子,改用正交初始化 |
| 长序列记不住 | 梯度消失 | 换 LSTM/GRU,或者截断 BPTT |
5. 我的实践心得与扩展思路
5.1 面试和项目里经常问到的RNN问题
聊到 RNN 时,除了“你调过多少层、效果怎样”,另一个绕不开的点就是对原理的掌握。我自己面试实习生的经验是,候选人大致分三类:一类只会调库,问一句“RNNCell 和 RNN 的区别”就卡壳,这类人我不敢放心把模型交给他;第二类能背公式,但问他“为什么用 tanh 不用 ReLU”,答不上来;第三类是真知道,他会告诉你 ReLU 在 RNN 神经元里容易把所有状态推向同一方向,导致整个隐藏层退化成一个一维曲线,这个解释让人一听就放心。
这里几个高频问题,你可以自己用纸笔过一遍:
- RNN 是怎么处理变长序列的?为什么全连接网络做不到?
- RNN 的参数共享具体指哪些参数?如果每个时间步用不同权重会怎样?
- 梯度消失和梯度爆炸的根本原因是什么?有哪些解决手段?
- LSTM 的三个门分别控制什么?和 GRU 的差异在哪里?
这些问题并不难,但你如果只看过公式不手算,回答时会很概念化。我强烈建议你用我们第二节的手算方式,自己推一遍,懂了之后这几个问题都能用顺口的话讲出来。
5.2 下一步往哪走:注意力与Transformer
这几年做序列建模,大家首选方案已经不是 RNN,而是 Transformer。Transformer 的核心是注意力机制,让每个位置的向量直接去和所有其他位置的向量计算相关度,等于一次就把全局上下文看个遍,不用像 RNN 那样一步步累积记忆。它的另一个优势是可以并行计算,训练速度远快于 RNN。
但我不建议你因为 RNN“过时”就跳过它。RNN 里反复强调的“状态”“记忆”“时间步展开”这些概念,在理解 Transformer 的位置编码、特征融合时非常有帮助。而且有些场景,比如实时流式任务、严格单向解码、轻量级模型,RNN 及其变种依然有它的身位。你掌握了 RNN,再去学 Transformer,等于有了一个小地图,知道序列模型在历史上是怎么一步步走到今天的。
我个人在实际教学和项目里,最推荐新手的学习路径是:先手算一次 RNN,再用 PyTorch 从 RNNCell 开始写一次字符级生成,然后再把例子扩展成 LSTM,等这些都跑顺了,再打开 Transformer 论文。这个路径每一步都有清晰的实物反馈,不会卡在抽象概念上空转。