尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RNN循环神经网络详解:从原理到PyTorch字符级模型实战

RNN循环神经网络详解:从原理到PyTorch字符级模型实战 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]然后逐项取 tanhtanh(0.2) ≈ 0.197tanh(0.5) ≈ 0.462tanh(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]取 tanhtanh(0.3985) ≈ 0.378tanh(0.8772) ≈ 0.704tanh(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]取 tanhtanh(0.589) ≈ 0.529tanh(1.1224) ≈ 0.808tanh(1.7128) ≈ 0.936所以h_3 [0.529, 0.808, 0.936]。我整理了一张表你一眼能看出变化趋势时间步输入计算后 h_t1我[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写一个能跑的字符级RNN3.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_classesvocab_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 模型定义最简单的RNNCellPyTorch 里封装好的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, dim0) # (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(), lr0.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(fepoch {epoch 1}, loss {loss.item():.4f})这里我加了clip_grad_norm_它的作用是把反向传播出来的梯度向量的总范数限制在 1.0 以内。别小看这一行RNN 训练时的梯度爆炸问题十次有八次靠它压住新手不写这一行很容易看到 loss 突然变成 NaN。训练完以后我们要让模型自己去“写”新文本。最简单的办法是从一个起始字符出发用模型输出的概率分布去采样下一个字符然后把新字符当成下一次输入循环下去def sample(model, start_char, length10): 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_classesvocab_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, 你, length20))因为我训练文本太短模型大概率只能学到一种循环模式生成出来可能类似“你爱我我爱你我爱你”这种无限重复。这正是字符级RNN的正常表现——数据本身简单模型当然也就只会重复套路。3.4 实际运行效果和训练细节思考上面这个例子在 CPU 上跑完 500 轮也就几秒钟loss 会从最初的 1.0 左右一路降到 0.2 以下。如果你把text换成长一点的语料比如莎士比亚全集、红楼梦只要字符量够大模型就能生成更有“风格”的句子。这就是最原始的 char-RNN 语言模型虽然今天已经被 Transformer 抢了风头但它依然是理解神经网络处理序列问题的最佳教学例子。我试过一个小改动把text改成我是中国人我喜欢中国同样训练 500 轮模型很快记住“我”后面大概率接“是”或“喜”“国”后面一定接“人”或“”。你会发现它学到的不是背答案而是字符间的转移概率。这个直觉很关键。还有一点hidden_size16是我故意调小的目的是展示一个小隐藏层也能记住简单模式。如果你想看模型犯糊涂把它调到 4训练结果会变得乱七八糟调到 32效果也不会好太多。这说明在小数据集上隐藏单元不是越多越好参数过多反而容易过拟合只学会死记硬背。4. 新手避坑指南RNN训练中的常见问题与改进4.1 梯度消失/爆炸为什么偏爱RNNRNN 反传时用的是 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.RNNPyTorch 默认的初始化其实还行如果用nn.RNNCell有时候你需要自己初始化 hidden 为很小的随机向量不要直接全零。全零在简单任务里没问题但遇到复杂有偏的数据会让早期梯度方向太单一。第四用双向 RNN 前先想清楚任务方向。双向 RNN 能看到未来信息在情感分类、命名实体识别里有效但在逐字生成任务里必须用单向因为生成时没有未来。新手经常搞混这个点在语言模型里加了双向结构生成时直接报错或者效果很差。我整理了一个常见的踩坑对照表现象常见原因解决办法loss 突然 NaN梯度爆炸加梯度裁剪降低学习率loss 下降很慢学习率太小/隐藏层太小调大学习率增加 hidden_size训练集 loss 低测试集高过拟合减少 hidden_size加 Dropout、加数据效果不稳定权重初始化不当固定随机种子改用正交初始化长序列记不住梯度消失换 LSTM/GRU或者截断 BPTT5. 我的实践心得与扩展思路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 论文。这个路径每一步都有清晰的实物反馈不会卡在抽象概念上空转。
返回列表