1. 前言
上一篇我们已经从概念上理解了双向循环神经网络:
它会同时建立前向和后向两条循环链
每个位置都能同时利用左上下文和右上下文
它更适合理解型任务,而不适合标准自回归语言模型
这一篇就继续按李沐的节奏,把它真正落到代码上。
这一节最关键的,不是重新发明一个新单元,
而是看清楚:
当循环网络从单向变成双向时,代码里到底发生了什么变化?
你会发现,核心变化其实非常集中:
多了
bidirectional=True输出维度翻倍
状态第一维乘上 2
线性层输入维度也要跟着变
也就是说,这一节的重点不是“代码特别多”,
而是要把形状变化真正看明白。
2. 双向循环网络在代码里怎么开启
PyTorch 对双向循环神经网络的支持非常直接。
无论是:
nn.RNNnn.GRUnn.LSTM
都可以通过同一个参数开启双向模式:
bidirectional=True例如:
lstm_layer = nn.LSTM( input_size=vocab_size, hidden_size=num_hiddens, bidirectional=True )这就表示:
使用 LSTM 作为循环单元
同时构建一条前向链和一条后向链
所以从 API 使用上看,双向循环网络非常“轻量化”。
3. 为什么说双向代码改动不大
因为框架已经把最复杂的部分封装好了:
前向链怎么递推
后向链怎么递推
两个方向怎么拼接
状态怎么管理
这些你都不用手写。
你真正需要做的,是理解双向后这几件事:
第一,隐藏表示维度变了
输出不再只是hidden_size,而是2 * hidden_size。
第二,状态维度变了
状态第一维要乘上方向数。
第三,输出层输入维度也要变
因为最后送给线性层的特征维度翻倍了。
所以双向循环网络的代码重点,是接口和形状,不是底层递推本身。
4. 先看一个最小例子
先写一个最简单的双向 LSTM:
import torch from torch import nn vocab_size = 28 num_hiddens = 256 lstm_layer = nn.LSTM( input_size=vocab_size, hidden_size=num_hiddens, bidirectional=True )这里:
输入维度是
vocab_size隐藏状态维度是
256开启双向
到这里为止,和单向 LSTM 唯一的区别就是多了:
bidirectional=True5. 输入张量形状会变化吗
不会。
双向循环网络的输入格式和单向完全一样。
例如:
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 = 2
7. 如果是双向 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 也是同样规律吗
完全同样。
例如双向 GRU:
gru_layer = nn.GRU( input_size=vocab_size, hidden_size=num_hiddens, bidirectional=True )那么:
输出
Y形状会变成(num_steps, batch_size, 2 * hidden_size)状态形状会变成
(2, batch_size, hidden_size)
如果是双向普通 RNN,也是一样。
这说明:
双向机制和具体循环单元类型是解耦的。
它不是 LSTM 专属,也不是 GRU 专属,而是一种方向维扩展。
12. 如果再加上多层,会变成什么样
如果你同时使用:
多层
双向
那么状态第一维就会变成:
num_layers * num_directions例如两层双向 LSTM:
lstm_layer = nn.LSTM( input_size=vocab_size, hidden_size=num_hiddens, num_layers=2, bidirectional=True )这时状态形状会是:
(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. 这通常是双向代码里最容易忘的一点
很多人第一次写双向循环网络时,往往只记得加:
bidirectional=True但忘了改:
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_size=1): return torch.zeros( (self.rnn.num_layers * self.num_directions, batch_size, self.num_hiddens), device=device )对于双向 LSTM,则要返回:
def begin_state(self, device, batch_size=1): shape = (self.rnn.num_layers * self.num_directions, batch_size, self.num_hiddens) return (torch.zeros(shape, device=device), torch.zeros(shape, device=device))也就是说,双向本质上就是把状态的第一维乘了个 2。
18. 双向循环网络能直接拿来做字符级语言模型吗
从“代码能不能跑”来说,当然能。
但从“任务是否合理”来说,通常不推荐。
原因前一篇已经讲过:
字符级语言模型属于自回归预测任务,它不应该看到未来字符。
如果你用双向结构做标准语言模型,
那么当前位置的表示已经包含了右侧未来信息,训练目标会不干净。
所以在课程里,双向循环网络更多是作为:
结构理解
编码器思路铺垫
序列理解模型介绍
而不是为了说“语言模型就该用双向”。
这点一定要分清。
19. 双向代码和单向代码最本质的区别总结
如果一句话概括,就是:
输入不变,内部方向翻倍,输出维度翻倍,状态第一维乘 2,线性层输入也翻倍。
展开来说:
不变的
输入组织方式
前向传播主流程
训练损失和优化流程
变化的
循环层多了
bidirectional=True输出最后一维从
hidden_size变成2 * hidden_size状态第一维从
num_layers变成num_layers * 2输出层输入维度也要翻倍
只要把这四点记清楚,双向循环网络代码就不容易乱。
20. 这一节最该掌握什么
如果从学习重点看,最关键的是下面几件事。
20.1 会用bidirectional=True
知道它的作用是增加方向维,而不是增加层数。
20.2 看懂输出维度翻倍
因为前向和后向隐藏状态会拼接。
20.3 看懂状态第一维乘上 2
这是最容易在代码里出错的地方。
20.4 知道线性层输入维度也要翻倍
这点非常实用。
20.5 分清“双向能做什么、不能做什么”
它适合理解类任务,不适合标准自回归预测。
21. 本节总结
这一节我们学习了双向循环神经网络的代码实现,核心内容可以总结为以下几点。
21.1 双向循环网络在框架中通常通过bidirectional=True开启
这是最直接的实现接口。
21.2 输入格式通常不变
仍然按时间步、batch、特征维组织。
21.3 输出最后一维会翻倍
因为前向和后向隐藏状态通常会拼接。
21.4 状态第一维会变成num_layers * 2
因为每层有两个方向的状态。
21.5 使用双向结构时,输出层输入维度也必须相应翻倍
这是代码里最容易忽视但最关键的一点。
22. 学习感悟
这一节很有意思,因为它让你真正感受到:
一个模型变强,不一定总是“更深”或者“更大”,也可能只是“看问题的角度更多了”。
单向循环网络只会从前往后看,
而双向循环网络多出来的,其实是一种“反向阅读能力”。
这在很多理解类任务中非常有价值。
因为现实里,我们理解一句话中的某个词,本来也常常会参考前后文,而不是只看左边。
从这个角度看,双向循环网络的增强,并不是数学技巧而已,
而是一种非常符合语言理解直觉的结构设计。