ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

PyTorch循环神经网络入门:从RNN原理到名字分类实战

PyTorch循环神经网络入门:从RNN原理到名字分类实战 如果你把一个PyTorch深度学习实践课从头跟到尾到了第11讲基本会碰到一个让不少人第一次卡住的东西——循环神经网络也就是RNN。前面几讲还在拿全连接网络和卷积神经网络处理图像、做多分类突然换成“按时间顺序输入”的序列数据很多人的第一反应是“这玩意到底怎么前向传播、怎么反向传播”。这一讲的定位其实很清晰它不追求让你把RNN的数学推导倒背如流而是通过一个能跑通的小项目把RNN的输入格式、隐藏状态、损失函数、训练流程一次性串起来。我当初就是在这节课上真正理解了“隐藏状态”不是玄学而是一个实实在在的向量。这篇文章适合正在跟这门课、或者刚刚接触RNN的读者。我会把第11讲涉及的RNN原理、Embedding、名字分类实战、双向RNN和注意力出现的动机都拆开讲一遍再补上我自己跑实验时踩过的坑。不管你是学生还是转行做算法把这讲吃透后面看LSTM、GRU、Transformer都会顺很多。1. 第11讲到底卡在哪从“看清一张图”到“读懂一串话”1.1 为什么前面学了CNN还得学RNN前10讲基本围绕全连接网络和CNN展开这两类网络有个共同前提输入是一个固定维度的向量或者图像。你给一张224×224的图模型把每个像素当作一个特征学习的是“像素空间里的模式”。可现实中大量数据不是一张图而是一串有先后顺序的序列比如一句话、一段音频、一条传感器读数甚至是一段DNA序列。这类数据有个特点长度不固定而且顺序本身就包含信息。“我喜欢看电影”和“电影喜欢看我”完全是两码事词一样顺序反了意思就变了。CNN虽然能用卷积核捕捉局部相邻特征但它本质上不擅长建模“长距离的先后依赖”。RNN就是冲着这个来的它让网络在处理当前输入的时候同时参考之前处理过的内容相当于给网络加了一个“短期记忆”。第11讲把RNN放在CNN之后其实是很合理的教学安排。CNN解决的是空间特征提取RNN解决的是时间或顺序特征建模。拿图像分类练熟了损失函数、反向传播这些基本功之后再学RNN你的注意力就可以集中在“多出来的那个隐藏状态是怎么流动的”这个问题上不会被训练流程本身绊住。1.2 第11讲的真实任务给外国人姓名猜国籍我在不少课程里看到讲解RNN时会用一个非常经典的小项目根据一个外国人名预测它来自哪个国家。这个任务看起来有点“玩具”但它特别适合练手。每个名字是一个字符序列长度不固定比如“Dostoevsky”和“Lee”长度差好几倍目标是把整串字符映射到一个国家标签上比如俄罗斯、中国、美国、日本等等。这个任务天然符合RNN的适用场景因为它需要按顺序读完整个名字前一个字符会影响后一个字符的语义。比如“ov”结尾的名字大概率跟斯拉夫语系有关“son”结尾在北欧名字里很常见。RNN在读字符时隐藏状态会不断累积这种“语言风格”信息最后一个时间步的隐藏状态就相当于整个名字的摘要喂给一个全连接层就能做分类。也正是因为这个任务足够简单你可以把注意力完全放在模型结构上不用担心数据增强、超参数调优这些杂事。我第一次跑这个项目时拿到90%左右的准确率只花了不到二十分钟成就感来得很快这是很多复杂项目给不了的。1.3 和课程前10讲相比这一讲的三个新概念第11讲之所以让人觉得“跟前面不一样”主要是因为一次性出现了三个新概念。第一个是RNNCell或nn.RNN这种循环结构它不再是一层直接算完而是把计算过程按时间步拆开每一步都吃一个输入和上一个时刻的隐藏状态。第二个是Embedding层它把离散的字符ID映射成稠密向量解决独热编码“太稀疏、维度爆炸”的问题。第三个是序列数据的数据预处理方式你需要自己决定每个序列怎么截断、怎么填充、怎么转成张量。这三个概念任何一个单独拿出来都不难但凑在一起容易让人懵。我见过很多同学卡在“Embedding之后张量维度从三维变成四维”这种问题上其实都是因为没把时间步、批量大小、特征维度这三者的关系在纸上画清楚。第11讲的项目刚好把这三点全部串起来所以只要跟着敲一遍代码理解深度会远超只看理论。2. RNN核心原理把“记忆”写进网络结构2.1 从全连接网络到RNN Cell的演化先回忆一下全连接网络的计算输入x经过一个线性变换加上偏置再过一个激活函数得到输出y。如果把这一步反复用在“序列的每一个时间步”上并且每次用同一个权重矩阵就成了一个最朴素的RNN。关键在于RNN每一步的输出不仅仅取决于当前的输入还取决于上一步计算出的隐藏状态。这里的“隐藏状态”我习惯把它理解成一个浓缩了历史信息的向量。假设你在读“Dostoevsky”这个名字处理到字符“v”时隐藏状态里已经包含了前面“Dosto”的特征信息虽然这些信息是隐式的不知道具体代表什么但网络能通过训练学会哪些历史信息值得保留。这种设计就是“参数共享”的体现同一个RNN Cell被所有时间步复用而不是每个时间步搞一组独立参数。PyTorch里最底层的循环单元是nn.RNNCell它只处理单个时间步。如果你传入一个时间步的输入和上一个隐藏状态它会返回当前时间步的隐藏状态。很多教学场景用RNNCell是为了让你看清每一步的计算过程实际工程里则通常直接用nn.RNN它帮你把整个序列的时间循环自动展开了。2.2 前向传播公式拆解h_t到底是怎么算出来的RNN的前向传播公式看起来简单但值得认真拆开看h_t tanh(W_ih * x_t b_ih W_hh * h_{t-1} b_hh)这里的x_t是当前时间步的输入h_{t-1}是上一个时间步的隐藏状态。W_ih负责把当前输入变换到隐藏空间W_hh负责把历史状态变换到当前时间步两者相加后再过tanh激活函数。我自己的理解方式是把W_ih * x_t看成“对这个新输入的初步解读”把W_hh * h_{t-1}看成“对历史记忆的加权调用”。两者加在一起相当于把新信息和旧记忆融合起来再经过tanh压缩到[-1, 1]区间防止数值无限膨胀。这样做的好处是模型可以学习到“哪些新信息需要写入记忆”“哪些旧记忆需要保留到下一时刻”。虽然这种融合比较粗糙比不上LSTM里的“遗忘门”“输入门”但作为入门理解完全够用。你只需要把这个公式在脑子里过一遍再去看PyTorch官方文档里nn.RNN的参数说明就会觉得顺理成章。PyTorch里的nn.RNN默认把隐藏状态的维度叫作hidden_size这个值需要你自己指定。hidden_size越大模型能记住的“潜在特征”越多但参数量和过拟合风险也会上升。我做名字分类时hidden_size设为64或128效果都不错再大反而提升有限。2.3 PyTorch里的RNN和RNNCell怎么选我建议初学者先看nn.RNNCell的代码再切换到nn.RNN。原因很简单RNNCell的输入输出都明明白白方便你把每个时间步的张量维度打出来验证。import torch import torch.nn as nn rnn_cell nn.RNNCell(input_size128, hidden_size64) x_t torch.randn(1, 128) # (batch, input_size) h_prev torch.zeros(1, 64) # (batch, hidden_size) h_t rnn_cell(x_t, h_prev) # (batch, hidden_size)而nn.RNN会一次性处理完整序列输出两个东西最后一层所有时间步的输出以及最后一个时间步的隐藏状态。如果设置batch_firstTrue输入的形状是(batch, seq_len, input_size)这是我比较推荐的写法因为更符合直觉。rnn nn.RNN(input_size128, hidden_size64, batch_firstTrue) x torch.randn(32, 10, 128) # batch32, seq_len10, input_size128 output, h_n rnn(x) # output: (32, 10, 64) # h_n: (1, 32, 64)实战里h_n经常被用来做序列级别的分类因为理论上它浓缩了整个序列的信息。名字分类就是这种用法把h_n去掉中间的时间步维度再接一个全连接层输出每个国籍的概率。注意h_n的形状是(num_layers, batch, hidden_size)即使只有一层RNN前面也会多出num_layers这一维很多时候要squeeze(0)或者h_n[-1]才能得到(batch, hidden_size)。2.4 为什么激活函数偏偏是tanh先回答一个新手常问的问题为什么RNN Cell里的激活函数默认是tanh而不是ReLU原因有两个层面。第一tanh的输出范围是[-1, 1]有上下界能让隐藏状态的数值在时间步之间传递时不容易爆炸。第二tanh在整个定义域内是光滑可导的梯度能比较稳定地传回去。ReLU虽然能缓解梯度消失但在RNN这种反复乘权重的结构里更容易让数值膨胀到不可控。当然这也不是说RNN只能用tanh。一些变体设计会换用别的激活函数但PyTorch官方实现里nn.RNN默认就选tanh这个选择经过了非常多的实验验证。你只要知道“这里是tanh是为了稳定训练”就够了不用自己去折腾替换。不过要记住tanh本身对梯度消失问题也只是“缓解”而不是“解决”。序列特别长的时候后面时间步的梯度传到前面会变得非常小这时候就需要LSTM、GRU这类带门控机制的变体。第11讲往往会把RNN梯度问题的解法放到后续内容里你可以先有个印象。3. Embedding把字符变成模型能“消化”的向量3.1 独热编码的问题到底在哪如果你接触过NLP第一个想到的文本表示方式很可能是独热编码。把每个字符或者单词映射成一个巨大的向量里面的位置只有当前字符对应的索引是1其它位置全是0。这种方式理论没错但有两个很实际的问题。第一个问题是维度爆炸。如果你的字符集有100个字符那每个字符的向量维度就是100看起来还能接受。但如果处理的是词级别的序列词汇表动辄几万每个词一个几万维的向量计算量和内存都会爆炸。第二个问题是“向量之间没有语义关系”。独热编码里“a”和“b”的距离跟“a”和“z”的距离一模一样模型完全看不出字符之间有什么相似性。Embedding层解决的就是这两个问题。它的核心思想是用一个可训练的查找表把每个离散ID映射到一个低维稠密向量。这个向量一开始是随机初始化的但随着训练进行它会学到字符或词语之间的“潜在语义”。比如在名字分类任务里模型可能会让“a”和“o”这两个元音的向量靠得比较近因为它们在不同语言里的出现模式有相似之处。3.2 embedding层到底在做什么PyTorch里用nn.Embedding做这件事非常简单embedding nn.Embedding(num_embeddingsvocab_size, embedding_dimembedding_dim)num_embeddings是字符集大小embedding_dim是你想映射到的向量维度。使用时只需要把字符ID组成的张量传进去它会自动返回对应的向量序列。关键要理解输入和输出的形状变化。假设一个批次里有32个名字每个名字截断成长度20的字符序列那么输入形状是(32, 20)里面每个元素都是字符ID。经过embedding层后输出形状变为(32, 20, embedding_dim)。这多出来的最后一维就是每个字符对应的稠密向量。我自己刚开始学的时候经常把nn.Embedding理解成“一个特殊版的全连接层”它的权重矩阵就是那个查找表。只不过全连接层是“输入特征值输出特征值”而Embedding是“输入索引直接取对应的行”。这样理解虽然不够严谨但特别有助于消除神秘感。3.3 序列长度不一致怎么处理名字分类任务里每个名字的长度都不一样有的3个字符有的15个字符。直接放进同一个batch会导致张量形状对不上这是初学者最容易掉进去的坑。常规做法是“填充”选一个最大长度把所有序列都补到这么长补的位置用一个专门的填充ID通常是0。在名字分类里因为输入是字符ID序列填充ID一般可以用0但要注意真实字符的ID不要从0开始否则会和填充位冲突。更严谨的方案是PyTorch里nn.utils.rnn.pad_sequence配合pack_padded_sequence不过第11讲的项目为了降低理解门槛通常先不搞这套而是直接把所有序列统一到固定长度。我当时处理的方法比较“粗暴”先统计数据集中最长的名字有多少个字符然后把所有名字循环左对齐、右边补0。这样虽然会浪费一些计算量但对一个小数据集来说完全无所谓。等以后做真实NLP任务再学pack_padded_sequence也不迟。学习阶段最重要的是先把模型跑通别让这些工程细节干扰你对RNN本身的理解。4. 名字分类实战从数据处理到模型训练4.1 数据集与预处理步骤刘二大人这个课程里用到的名字分类数据来自一个经典的公开数据集包含几十种语言的人名每个名字对应一个国籍标签。数据集的官方地址在很多教程里都能找到格式一般是名字 标签每行一条记录。拿到数据后预处理分三步。第一步是把所有名字里的字符收集起来构建一个char_to_index字典让每个字符对应一个唯一ID。第二步是把每个名字转换成ID列表比如Lee变成[23, 17, 17]具体数字取决于字典怎么排。第三步是统一长度同时把标签字符串比如Chinese映射成数字ID用来算交叉熵损失。这里有一个小细节训练集和测试集要使用同一个char_to_index字典否则测试集里出现训练集没见过的字符时会直接索引越界。我当时在图方便的情况下重写字典结果测试集里报错排查了十分钟才发现是字符集没统一。解决方法是先在完整数据集上构建字典再划分训练测试集。4.2 模型搭建一个足够用的RNNClassifier这个项目的模型结构非常经典由Embedding层、RNN层、全连接层三部分组成。我用PyTorch写过一个简化版结构如下class RNNClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_classes): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim) self.rnn nn.RNN( input_sizeembedding_dim, hidden_sizehidden_size, batch_firstTrue ) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): embedded self.embedding(x) # (batch, seq_len, embedding_dim) output, h_n self.rnn(embedded) # h_n: (num_layers, batch, hidden_size) h_n h_n.squeeze(0) # (batch, hidden_size) logits self.fc(h_n) # (batch, num_classes) return logits这里h_n.squeeze(0)是因为我们的RNN只有一层num_layers1。如果网络层数变成2层就不能直接squeeze而是用h_n[-1]获取最后一层的隐藏状态。这个细节非常容易踩坑尤其是从单层改成多层的时候我记得自己第一次改成两层RNN后准确率反而下降了最后查代码发现是拿错层的隐藏状态做了分类。4.3 训练流程与损失函数选择训练流程跟前面几讲的基本一致定义损失函数和优化器循环多个epoch每次取一个batch前向传播得到logits计算损失反向传播更新梯度。区别只在于“数据格式从图片变成了字符ID序列”。这里补充一个新手特别容易忽略的点nn.CrossEntropyLoss在PyTorch里已经包含了Softmax步骤所以你模型最后一层输出的是“未归一化的logits”不用自己在外面再套一层Softmax。很多人习惯性地想加Softmax其实加了反而会让损失计算变复杂得到的结果还可能出现数值精度问题。训练的时候我建议打印一下每个batch的loss走势。如果loss稳步下降说明模型在正常学习如果loss直接卡住或者变成NaN我后文会专门讲怎么排查。用Adam优化器、学习率设成0.001左右对这个项目来说通常不会出大问题。我跑这个项目时大概20个epoch就能在验证集上看到比较明显的准确率提升。名字分类数据集不大单机CPU训练也完全够用。第一次跑通全流程的时候我还特意试了试“不经过RNN、直接把所有字符做平均池化再接全连接”的基线模型效果确实比RNN差一截这也侧面印证了顺序信息对这类任务的重要性。4.4 为什么这一讲值得自己手写一遍很多人学PyTorch只看不敲觉得代码能看懂就行。第11讲是我强烈建议“必须自己敲一遍”的一讲原因是它把数据预处理、序列建模、维度变换、损失计算完整串起来了而且代码量不大半天就能搞定。自己手写一遍你会被迫面对很多“以为懂了但实际没懂”的细节比如为什么nn.RNN输入是三维张量、h_n的每个维度代表什么、为什么embedding之后维度会多出一维。这些问题在纸上画图能理解个大概但只有真的报错、真的去查、真的把shape调对才算是真正掌握。我当年学到这里时把model.forward里的每一行都加了print(x.shape)一跑起来就能看到数据流每一步的形状变化。这种做法虽然笨但对建立起“张量形状敏感度”特别有效。后来我去看更复杂的Transformer代码时发现很多位置编码、注意力矩阵的形状问题其实就是靠这个习惯解决的。5. 双向RNN与后续方向第11讲埋下的伏笔5.1 双向RNN解决什么问题到第11讲后半段通常会提一句双向RNN。它解决的问题很简单有些任务里当前位置的信息不仅依赖之前的内容还依赖后面的内容。比如给句子填空“我___了一个苹果”空里的词可能更多受后面“苹果”的影响而不仅是前面的“我”。双向RNN的做法是同时跑两个独立的RNN一个从左往右读序列一个从右往左读序列然后把两个方向的隐藏状态拼在一起。PyTorch里设置bidirectionalTrue就可以了rnn nn.RNN(input_sizeembedding_dim, hidden_sizehidden_size, batch_firstTrue, bidirectionalTrue)这里要注意如果设了双向output和h_n最后一维都会变成2 * hidden_size。因为两个方向的隐藏状态被拼接了。我在名字分类任务上试过双向RNN效果确实比单向好一些毕竟判断一个名字的国籍结尾和开头都有很强信号。5.2 注意力机制为什么会出现双向RNN已经能让模型看到“过去和未来”的信息但它仍然有一个问题不管中间经历了多少个时间步最终都靠最后一刻的隐藏状态或者拼接后的隐藏状态来代表整个序列。这个“压缩”过程是有损的序列越长前面时间步的信息越容易丢失。注意力机制的思路很直接与其只信最后一个隐藏状态不如让模型在输出时回头“看一看”所有时间步的隐藏状态然后给它们分配不同的权重。这个思想后来在Transformer里被发扬光大变成了自注意力机制。第11讲通常不会让你手写注意力但如果你理解了RNN做序列建模的痛点再去看注意力就会觉得顺理成章。我当时学到这里有个特别深的感受RNN、LSTM、注意力、Transformer本质上都是在回答同一个问题——“如何让模型更好地利用序列里的历史信息”。RNN用隐藏状态串联LSTM加门控让记忆更可控注意力则直接把所有历史信息摊开让模型自己挑重点。理解了这条主线深度学习里的模型演进就成了一条清晰的线而不是一堆孤立的名字。5.3 从RNN到LSTM/GRU再到Transformer第11讲一般不会展开讲LSTM但你已经具备了理解LSTM的基础。LSTM相对于RNN最大的变化是引入“记忆单元”和三个门遗忘门决定丢弃哪些旧信息输入门决定写入哪些新信息输出门决定输出哪些信息。你可以把RNN理解成一个只会“叠加”记忆的模型而LSTM学会了“选择性记忆”。GRU是LSTM的简化版把遗忘门和输入门合并成一个“更新门”参数更少训练更快。在实际工程里很多场景用GRU就能达到和LSTM差不多的效果所以它也是高频选择。等将来接触Transformer时你会看到它完全抛弃了“按时间步逐个处理”的方式改成了一次性看到整个序列、再用注意力矩阵计算两两之间的相关度。也是从这里开始并行计算效率大幅提升。不过这里先不展开那么多第11讲能处理好“RNN怎么工作、怎么用”已经足够。后面的LSTM和Transformer都是在“序列建模”这个大框架下替换了不同的组件理解了RNN这个起点后面成长起来会顺利很多。5.4 梯度消失与梯度裁剪每次讲RNN都会绕不开梯度消失。本质原因是RNN在处理长序列时权重矩阵会被反复相乘如果权重矩阵的最大奇异值小于1梯度会指数级衰减大于1梯度又会指数级膨胀。前者导致前面时间步学不到任何信息后者导致训练过程直接震荡甚至崩溃。实际工程里处理梯度爆炸有一个非常常用的技巧——梯度裁剪。PyTorch里一行代码就够torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)它的作用是把所有参数的梯度的总范数限制在一个上限内。如果超过上限就按比例缩放。这个操作非常简单但极其有效。我训练RNN模型时几乎都会加上这行除非明确知道自己要处理特殊的梯度分布。梯度消失则比梯度爆炸更难处理通常得靠改网络结构来解决比如换成LSTM、GRU、或者用注意力机制。6. 常见问题与排查技巧踩坑实录6.1 维度对不上先打印shape我见过太多人在学习RNN时报错提示信息基本都跟维度有关比如“Expected input batch_size (32) to match target batch_size (64)”。出现这类问题不要慌先打印每一步的张量shape。在forward方法里临时加几行print(x.shape)或者用Python的pdb调试器单步执行都比瞎猜快得多。通常容易出错的地方是embedding之后多出来的embedding_dim维度RNN输出里的num_layers维度以及全连接层的输入维度没有和hidden_size对齐。还有一个很常见的坑就是batch_first设置不一致。nn.RNN默认输入是(seq_len, batch, input_size)如果你一开始用(batch, seq_len, input_size)传入但忘了设置batch_firstTrue它不会报错但语义完全错了训练效果会变得很差。所以我的习惯是创建RNN时固定写batch_firstTrue并在代码注释里标明输入维度。6.2 loss不下降先看数据预处理和超参数如果你发现loss从第一个epoch开始就纹丝不动先别急着怀疑模型结构大概率是数据预处理或者损失函数出了问题。检查三件事。第一字符ID是否真的对应正确有没有因为填充位和真实字符ID冲突导致模型学到无效信息。第二标签ID是否连续从0开始交叉熵损失函数要求传入的target必须是0到类别数-1之间的整数。第三学习率是不是太小或者太大一般都先用0.001试试小数据集上这个学习率通常够用。如果以上都没问题可以试试过拟合一个小批量数据。比如只取16个样本训练看loss能不能降到接近0。这样做能快速验证模型的“表达能力”排除代码里隐藏的bug。这个方法我屡试不爽一旦在小批量上能过拟合再回到全量数据上训练心里就有底了。6.3 训练慢与设备问题第11讲的项目在CPU上跑完全没问题数据集很小。但如果你的序列很长或hidden_size很大还是建议用GPU。PyTorch里把模型和数据搬到GPU很简单device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) # 每个batch里 data data.to(device) target target.to(device)有一个容易忽略的问题是nn.RNN在处理长序列时即使batch很小也可能因为时间步太多导致显存占用高。因为RNN的反向传播需要保存每个时间步的中间状态。如果显存不够可以尝试减小batch_size、减小hidden_size或者使用torch.utils.checkpoint这类技术不过对小项目来说前两个方法更直接。另外在Windows上用Anaconda配置PyTorch环境时很多教程会让你用清华源下载安装包速度快很多。安装GPU版本时要注意PyTorch版本和CUDA版本的匹配官方默认安装通常带CPU版本需要专门指定cu118之类的渠道。环境配置问题虽然烦人但只要装好一次后面跑深度学习代码就不会被环境卡脖子了。6.4 我的几条实操心得最后分享几条我跑第11讲项目时的个人经验。第一条把随机种子固定住。深度学习的训练过程有很多随机性如果不固定种子每次跑出来的结果波动很大就很难判断一个改动是真正有效还是随机波动。代码里加这几行能省很多对比实验的烦恼import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)第二条测试阶段记得调用model.eval()并且在torch.no_grad()下计算预测结果。否则模型里的dropout、batch normalization这些层在测试时仍处于训练模式输出结果会有额外随机性导致准确率低估。虽然第11讲的小模型里不一定有dropout但养成这个习惯非常重要。第三条保存模型时不要只保存state_dict最好连char_to_index字典、embedding_dim、hidden_size这些配置一起保存。不然模型参数能恢复但字符映射表丢失了新数据就没法走完整推理流程。我当时第一次做推理功能时就因为没保存字典而重新写了一份结果字符ID对不上绕了不少弯路。我在实际跑这个项目的过程中最大的体会是初学RNN时别急着把LSTM、双向、注意力全部堆上去。先把最朴素的单向RNN跑通再逐步加复杂度。每加一个结构就对比一下效果有没有提升这样你既能看到模型的瓶颈也能理解每个改进到底解决什么问题。第11讲留给你的不是“记一堆模型名”而是建立一套“序列数据怎么处理、怎么建模”的方法论这个方法论一旦建立起来后面学任何序列模型都会轻松很多。
返回列表