动手学深度学习——双向循环神经网络代码
1. 前言上一篇我们已经从概念上理解了双向循环神经网络它会同时建立前向和后向两条循环链每个位置都能同时利用左上下文和右上下文它更适合理解型任务而不适合标准自回归语言模型这一篇就继续按李沐的节奏把它真正落到代码上。这一节最关键的不是重新发明一个新单元而是看清楚当循环网络从单向变成双向时代码里到底发生了什么变化你会发现核心变化其实非常集中多了bidirectionalTrue输出维度翻倍状态第一维乘上 2线性层输入维度也要跟着变也就是说这一节的重点不是“代码特别多”而是要把形状变化真正看明白。2. 双向循环网络在代码里怎么开启PyTorch 对双向循环神经网络的支持非常直接。无论是nn.RNNnn.GRUnn.LSTM都可以通过同一个参数开启双向模式bidirectionalTrue例如lstm_layer nn.LSTM( input_sizevocab_size, hidden_sizenum_hiddens, bidirectionalTrue )这就表示使用 LSTM 作为循环单元同时构建一条前向链和一条后向链所以从 API 使用上看双向循环网络非常“轻量化”。3. 为什么说双向代码改动不大因为框架已经把最复杂的部分封装好了前向链怎么递推后向链怎么递推两个方向怎么拼接状态怎么管理这些你都不用手写。你真正需要做的是理解双向后这几件事第一隐藏表示维度变了输出不再只是hidden_size而是2 * hidden_size。第二状态维度变了状态第一维要乘上方向数。第三输出层输入维度也要变因为最后送给线性层的特征维度翻倍了。所以双向循环网络的代码重点是接口和形状不是底层递推本身。4. 先看一个最小例子先写一个最简单的双向 LSTMimport torch from torch import nn vocab_size 28 num_hiddens 256 lstm_layer nn.LSTM( input_sizevocab_size, hidden_sizenum_hiddens, bidirectionalTrue )这里输入维度是vocab_size隐藏状态维度是256开启双向到这里为止和单向 LSTM 唯一的区别就是多了bidirectionalTrue5. 输入张量形状会变化吗不会。双向循环网络的输入格式和单向完全一样。例如X torch.rand(size(35, 2, vocab_size))这表示35时间步长度num_steps2批量大小batch_sizevocab_size每个时间步输入向量维度也就是(num_steps, batch_size, input_size)所以你要先记住一点双向不会改变输入接口。它改变的是模型内部结构和输出表示。6. 初始状态为什么变了这才是双向代码里最重要的变化之一。对于单层单向 LSTM状态形状通常是(1, batch_size, hidden_size)因为1 层1 个方向而双向后会变成(2, batch_size, hidden_size)因为1 层2 个方向前向 后向所以初始化通常要写成state ( torch.zeros((2, 2, num_hiddens)), torch.zeros((2, 2, num_hiddens)) )这里两个2分别表示第一维方向数 2第二维batch_size 27. 如果是双向 LSTM为什么状态有两个张量因为 LSTM 本来就维护两个状态隐藏状态H记忆单元C所以完整状态是(H, C)而双向之后H和C各自都要为两个方向保存状态。因此H.shape (2, batch_size, hidden_size)C.shape (2, batch_size, hidden_size)这点和单向 LSTM 的区别本质在于第一维翻倍。8. 跑一次前向传播看看现在可以直接跑一下Y, state_new lstm_layer(X, state)如果打印形状Y.shape, state_new[0].shape, state_new[1].shape通常会得到(torch.Size([35, 2, 512]), torch.Size([2, 2, 256]), torch.Size([2, 2, 256]))这三个形状一定要完全看懂。9. 为什么Y.shape变成了(35, 2, 512)原来单向 LSTM 的输出通常是(num_steps, batch_size, hidden_size)也就是(35, 2, 256)但双向后会变成(num_steps, batch_size, 2 * hidden_size)也就是(35, 2, 512)原因很简单每个时间步现在有两个方向的隐藏表示通常会拼接在一起。也就是说前向给 256 维后向给 256 维拼起来就是 512 维所以双向最直接的结果就是输出特征维度翻倍。10. 为什么状态形状不是(35, 2, 512)因为状态保存的是每个方向在最终时刻的内部状态而不是每个时间步的全部输出。所以状态仍然是(num_layers * num_directions, batch_size, hidden_size)对于单层双向 LSTM 来说(1 * 2, batch_size, hidden_size) (2, batch_size, hidden_size)注意这里最后一维仍然是hidden_size不会翻倍。因为每个方向本身还是一个hidden_size维的状态只是方向数从 1 变成了 2。11. 双向 GRU / RNN 也是同样规律吗完全同样。例如双向 GRUgru_layer nn.GRU( input_sizevocab_size, hidden_sizenum_hiddens, bidirectionalTrue )那么输出Y形状会变成(num_steps, batch_size, 2 * hidden_size)状态形状会变成(2, batch_size, hidden_size)如果是双向普通 RNN也是一样。这说明双向机制和具体循环单元类型是解耦的。它不是 LSTM 专属也不是 GRU 专属而是一种方向维扩展。12. 如果再加上多层会变成什么样如果你同时使用多层双向那么状态第一维就会变成num_layers * num_directions例如两层双向 LSTMlstm_layer nn.LSTM( input_sizevocab_size, hidden_sizenum_hiddens, num_layers2, bidirectionalTrue )这时状态形状会是(4, batch_size, hidden_size)因为2 层 × 2 方向 4这点非常重要后面做编码器时经常会遇到。13. 双向语言模型封装类哪里需要改前面我们封装过这样的模型循环层提特征线性层映射到词表空间双向之后最大改动通常只在线性层输入维度。例如原来单向时写self.linear nn.Linear(self.num_hiddens, self.vocab_size)双向后就要改成self.num_directions 2 self.linear nn.Linear(self.num_hiddens * self.num_directions, self.vocab_size)为什么因为Y的最后一维已经变成了2 * hidden_size如果线性层还是只接hidden_size维度就对不上了。14. 这通常是双向代码里最容易忘的一点很多人第一次写双向循环网络时往往只记得加bidirectionalTrue但忘了改Linear 的输入维度结果就会报 shape mismatch 错误。所以双向网络代码里一定要形成条件反射双向一开输出维度翻倍输出维度翻倍线性层输入也要翻倍。这是这节代码最实用的一条经验。15. 一个典型的双向模型封装可以怎么写思路上通常类似这样class BiRNNModel(nn.Module): def __init__(self, rnn_layer, vocab_size): super().__init__() self.rnn rnn_layer self.vocab_size vocab_size self.num_hiddens self.rnn.hidden_size self.num_directions 2 if self.rnn.bidirectional else 1 self.linear nn.Linear(self.num_hiddens * self.num_directions, vocab_size)这里最关键的一句就是self.num_directions 2 if self.rnn.bidirectional else 1它让模型可以自动适配单向或双向情况。这样后面单向时线性层输入是hidden_size双向时线性层输入是2 * hidden_size都能统一处理。16. 前向传播里还需要改很多吗其实不需要。前向传播主体还是输入索引转 one-hot喂给循环层把输出 reshape 成二维送入线性层例如def forward(self, inputs, state): X nn.functional.one_hot(inputs.T.long(), self.vocab_size).type(torch.float32) Y, state self.rnn(X, state) output self.linear(Y.reshape((-1, Y.shape[-1]))) return output, state你会发现这段代码和单向版本几乎一样。为什么还能直接用因为这里用了Y.shape[-1]它会自动适配单向时hidden_size双向时2 * hidden_size所以只要线性层定义对了前向传播主体甚至都不需要大改。17.begin_state要怎么改主要还是改状态第一维。对于双向 RNN / GRU通常是def begin_state(self, device, batch_size1): return torch.zeros( (self.rnn.num_layers * self.num_directions, batch_size, self.num_hiddens), devicedevice )对于双向 LSTM则要返回def begin_state(self, device, batch_size1): shape (self.rnn.num_layers * self.num_directions, batch_size, self.num_hiddens) return (torch.zeros(shape, devicedevice), torch.zeros(shape, devicedevice))也就是说双向本质上就是把状态的第一维乘了个 2。18. 双向循环网络能直接拿来做字符级语言模型吗从“代码能不能跑”来说当然能。但从“任务是否合理”来说通常不推荐。原因前一篇已经讲过字符级语言模型属于自回归预测任务它不应该看到未来字符。如果你用双向结构做标准语言模型那么当前位置的表示已经包含了右侧未来信息训练目标会不干净。所以在课程里双向循环网络更多是作为结构理解编码器思路铺垫序列理解模型介绍而不是为了说“语言模型就该用双向”。这点一定要分清。19. 双向代码和单向代码最本质的区别总结如果一句话概括就是输入不变内部方向翻倍输出维度翻倍状态第一维乘 2线性层输入也翻倍。展开来说不变的输入组织方式前向传播主流程训练损失和优化流程变化的循环层多了bidirectionalTrue输出最后一维从hidden_size变成2 * hidden_size状态第一维从num_layers变成num_layers * 2输出层输入维度也要翻倍只要把这四点记清楚双向循环网络代码就不容易乱。20. 这一节最该掌握什么如果从学习重点看最关键的是下面几件事。20.1 会用bidirectionalTrue知道它的作用是增加方向维而不是增加层数。20.2 看懂输出维度翻倍因为前向和后向隐藏状态会拼接。20.3 看懂状态第一维乘上 2这是最容易在代码里出错的地方。20.4 知道线性层输入维度也要翻倍这点非常实用。20.5 分清“双向能做什么、不能做什么”它适合理解类任务不适合标准自回归预测。21. 本节总结这一节我们学习了双向循环神经网络的代码实现核心内容可以总结为以下几点。21.1 双向循环网络在框架中通常通过bidirectionalTrue开启这是最直接的实现接口。21.2 输入格式通常不变仍然按时间步、batch、特征维组织。21.3 输出最后一维会翻倍因为前向和后向隐藏状态通常会拼接。21.4 状态第一维会变成num_layers * 2因为每层有两个方向的状态。21.5 使用双向结构时输出层输入维度也必须相应翻倍这是代码里最容易忽视但最关键的一点。22. 学习感悟这一节很有意思因为它让你真正感受到一个模型变强不一定总是“更深”或者“更大”也可能只是“看问题的角度更多了”。单向循环网络只会从前往后看而双向循环网络多出来的其实是一种“反向阅读能力”。这在很多理解类任务中非常有价值。因为现实里我们理解一句话中的某个词本来也常常会参考前后文而不是只看左边。从这个角度看双向循环网络的增强并不是数学技巧而已而是一种非常符合语言理解直觉的结构设计。