ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LSTM输出维度全解析:从张量形状到Keras/PyTorch实战

LSTM输出维度全解析:从张量形状到Keras/PyTorch实战

很多人在学 LSTM 的时候,都会在一个看似“不是重点”的地方卡很久:LSTM 的输出到底长什么样、维度是多少。我印象很深的一次,是在给团队画一份 LSTM 输出维度图示,画了半个多小时,满纸都是方块和箭头,结果没人看懂,我自己也解释不清。后来我把图彻底重画,只保留了“批次轴、时间轴、特征轴”这三样东西的尺寸变化,问题一下子就清楚了。

这篇文章想讲的,就是这张被重新画过的图:把 LSTM 一次前向计算里到底产生了哪些张量、每个张量的维度是怎么来的、不同框架下为什么输出形状不一样,以及遇到 shape 报错时怎么靠一张维度图快速定位,彻底讲清楚。适合刚接触循环神经网络、经常在维度上翻车的读者,也适合需要给同事或同学讲明白这个问题的朋友。看完你会发现,所谓“输出维度”,其实并没有那么玄。

1. 先把三类“输出”拆开:时间步输出、隐藏状态、单元状态

1.1 一次前向计算,LSTM 手里同时握着哪几个张量

LSTM 并不是把输入向量直接变成输出向量那么简单。它在处理序列的每一个时间步,都会同时维护、更新并对外暴露几种不同的数据。如果一上来就盯着“输出”两个字,很容易把它们混成一团。

假设我们有一个 batch 的样本,每条样本在时间步 t 的输入是 x_t,形状为 (batch_size, input_dim)。与此同时,上一个时间步会传来两个内部状态:

  • h_{t-1},形状为 (batch_size, hidden_size),叫做隐藏状态;
  • c_{t-1},形状为 (batch_size, hidden_size),叫做单元状态,也叫记忆单元。

LSTM 内部通过遗忘门、输入门、输出门、候选记忆值来更新这两个状态。遗忘门决定旧记忆保留多少,输入门决定新信息写入多少,两者组合后得到新的 c_t;输出门再决定从 c_t 中读取多少内容,从而得到新的 h_t。

所以严格来说,一个时间步结束后,LSTM 至少同时“产出”了三个量:

  1. 当前隐藏状态 h_t,形状 (batch_size, hidden_size);
  2. 当前单元状态 c_t,形状 (batch_size, hidden_size);
  3. 当前时间步的对外输出 y_t,在大多数框架里 y_t 就等于 h_t,所以形状同样是 (batch_size, hidden_size)。

这里最关键的一句话是:在 LSTM 里,“输出维度”和普通全连接层的“输出维度”含义不完全一样。它不是单一数字,而是一组沿时间轴展开的二维张量。

1.2 到底哪个才是“输出”?取决于你后面接什么

很多初学着会把 h_t 和 c_t 搞混,尤其是看到 Keras 里return_state=True返回三个值的时候,完全不知道哪个该用、哪个不该用。

其实判断方法很简单:看你的下游任务需要什么。

如果做的是词级别任务,比如序列标注、机器翻译的逐词解码,你需要每个时间步都有输出,于是把 y_1 到 y_T 全部收集起来,叠成一个 (batch_size, seq_len, hidden_size) 的张量。

如果做的是句级别任务,比如情感分类、文本分类,你只需要整个序列编码完毕之后的最后一个隐藏状态 h_T,也就是 (batch_size, hidden_size)。这个向量相当于把整句话的信息浓缩成了一个固定长度的向量。

而 c_t 呢?它通常不会直接交给外面的层,主要是在 LSTM 内部以及层与层之间传递记忆用的。不过当你要拿 LSTM 状态去初始化另一个 LSTM 时,c_t 也和 h_t 一样重要。

用一个表格概括一下:

输出量形状典型使用场景
单个时间步输出 y_t / h_t(batch_size, hidden_size)逐词预测、最后一时间步特征
全部时间步输出 y_1...y_T(batch_size, seq_len, hidden_size)序列标注、注意力机制源端表示
单元状态 c_t(batch_size, hidden_size)跨层传递记忆、初始化解码器

记不住的话,就记住一句话:对外看 h_t,对内传 c_t。

1.3 决定输出维度的四个超参数

LSTM 的输出维度不是凭空来的,它由四个核心参数共同决定。搞懂每个参数管哪一维,画图时才不会标错。

参数名含义主要影响输出结构的位置
batch_size一次并行处理的样本数量输出的第 0 维
seq_len时间步数,即序列长度输出全部时间步时,占据第 1 维
input_dim每个时间步输入的特征维度只影响输入,不决定隐藏层输出维度
hidden_sizeLSTM 隐藏单元数量输出的最后一维,也是最核心的一维

特别提醒一下:input_dim 和 hidden_size 经常被混为一谈。输入特征维度可能是 128,隐藏单元数量可能是 64,两者可以完全不同。LSTM 内部的连接权重会把任意 input_dim 的输入投影到 hidden_size 上。也就是说,只要 hidden_size 定了,内部所有状态的最后一维就都是 hidden_size,和输入维度是多少没有关系。

理解了这四个参数,接下来就可以看它们如何随时间轴流动。

2. 从输入张量到最终输出:维度逐层演变的完整路径

2.1 先看输入:三维张量 (batch_size, seq_len, input_dim)

用一个特别具体的例子来走一遍。

假设我们做一个文本分类任务:一次处理 32 条句子,每条句子固定切成 50 个 token,每个 token 用一个 128 维向量表示,比如经过嵌入层之后得到了 128 维。那么整个网络的输入张量就是:

  • 第 0 维:32,代表批次里 32 条样本;
  • 第 1 维:50,代表每条样本有 50 个时间步;
  • 第 2 维:128,代表每个时间步的特征维度。

也就是 (32, 50, 128)。

这里可以把整个输入理解成一摞书:每一本是 32 条样本的一页,每页是 128 维特征,一共 50 页,LSTM 一页一页地翻。

LSTM 在每一个时间步 t,其实只“看见”了第 t 个时间步的数据,也就是一个切片 x_t,形状是 (batch_size, input_dim),在这个例子里是 (32, 128)。它会拿着这个切片,和上一个时间步传来的 h_{t-1}、c_{t-1} 一起计算,然后产出新的 h_t 和 c_t。

2.2 单个时间步内的维度变化:为什么中间所有张量形状都一样

继续用上面的例子,假设我们设定的 hidden_size = 64。

在时间步 t,下面这些东西的形状分别是:

  • 输入切片 x_t:(32, 128);
  • 上一个隐藏状态 h_{t-1}:(32, 64);
  • 上一个单元状态 c_{t-1}:(32, 64);
  • 遗忘门、输入门、输出门各自的输出:(32, 64);
  • 候选记忆值:(32, 64);
  • 更新后的单元状态 c_t:(32, 64);
  • 更新后的隐藏状态 h_t:(32, 64)。

为什么会这样?关键在权重矩阵的设计上。

以遗忘门为例,它的计算方式是:

f_t = σ(W_f · [x_t, h_{t-1}] + b_f)

这里 [x_t, h_{t-1}] 是把输入和状态拼起来,维度是 (32, 128 + 64) = (32, 192)。W_f 的形状是 (hidden_size, input_dim + hidden_size),也就是 (64, 192)。两者相乘之后,输出维度自然就是 (32, 64)。

也就是说,输入特征再宽,进门之后都会被压缩到 hidden_size 这个尺度上。这也是为什么前面强调 input_dim 不影响输出维度——它影响的只是 W_f 这个矩阵的宽度,不是最终表示的长度。

如果画成时间步内部的前向计算公式,可以写成下面这样:

i_t = σ(W_i · [x_t, h_{t-1}] + b_i) f_t = σ(W_f · [x_t, h_{t-1}] + b_f) o_t = σ(W_o · [x_t, h_{t-1}] + b_o) c~_t = tanh(W_c · [x_t, h_{t-1}] + b_c) c_t = f_t ⊙ c_{t-1} + i_t ⊙ c~_t h_t = o_t ⊙ tanh(c_t)

把形状一一对应上去,你会发现它们的最后一维全部是 hidden_size。

2.3 整个序列处理完成后的最终输出

现在序列走完 50 个时间步,LSTM 的“最终输出”会根据你的后续用途有两种形态。

第一种:只要最后一个时间步的状态。比如做文本分类,你把最后一个时间步的 h_T 拿出来,形状是 (batch_size, hidden_size),也就是 (32, 64)。这个向量已经包含了整个序列的压缩信息。

第二种:把每个时间步的输出都保留。比如做序列标注,你需要 y_1 到 y_T 每一个时间步的结果,于是把它们沿着时间轴叠起来,得到一个三维张量,形状是 (batch_size, seq_len, hidden_size),也就是 (32, 50, 64)。

注意,这两种形态在维度图上必须分清楚。很多框架里return_sequences=False和return_sequences=True这两个参数,控制的就是这个区别。返回最后一步时是二维张量,返回全部时间步时是三维张量,这两个张量直接决定了你后面全连接层的输入形状。

3. 把维度画成人能看懂的样子:图示方法与标注规范

3.1 常见的 LSTM 结构图为什么解决不了维度问题

网上能找到的 LSTM 结构图,九成都是画单元内部的“门控电路”:sigmoid 方块、tanh 方块、乘号、加号、各种连线。这种图对理解“LSTM 是怎么计算”非常有帮助,但对理解“LSTM 输入输出张量长什么样”基本没用。

原因很简单:结构图把所有连线都画在二维平面上,却从来不标注每条线代表的 shape。你不知道那条线是 (batch, hidden) 还是 (seq_len, hidden),更不知道时间轴在哪里展开。

所以画“LSTM 输出维度图示”的时候,必须换一种思路:不画内部电路,只画张量与张量之间的形状关系。这不是同一种图,前者叫计算图,后者叫数据流图。你要用的是后者。

3.2 一套可复制的“三轴标注法”

我自己比较推荐的做法,是画一个大长方体代表输入,然后在时间轴上切薄片,每一片代表一个时间步。具体分四步走。

第一步,画出输入长方体,三个维度分别对应 batch、seq_len、input_dim。比如 (32, 50, 128),就在图上标明这三条轴各自代表什么。注意,不是画三个数字就完了,而是要让看图的人一眼就能明白“时间在竖向走,样本在横向铺开,特征在纵深方向”。

第二步,把输入长方体沿着时间轴切成 50 片。每一片是一个 (32, 128) 的截面,这就是单个时间步的输入。这样做的好处是,把“三维输入”和“每个时间步实际看到的东西”直观连接了起来。

第三步,在每一个切片后面画一个 LSTM 单元块,单元块旁边标注 hidden_size = 64。然后从单元块引出两条平行输出线,一条是 h_t,一条是 c_t,两条线都标注为 (32, 64)。h_t 除了输出给外部,还要连向下一个时间步;c_t 主要用虚线连向下一个时间步,表示内部传递。

第四步,在图的右侧,画两个候选输出区域:

  • 如果把所有 h_t 纵向叠起来,就是一个 (32, 50, 64) 的长方体;
  • 如果只取最后一个 h_T,就是一个 (32, 64) 的平面块。

这两条路径要用不同颜色的框区分开,并且在旁边写清楚“路径 A:全序列输出”“路径 B:最后状态输出”。

这套方法不需要画得多精细,关键就是每个张量旁边必须写完整的 shape 数字,而不是只写“hidden state”“output”这种名称。

3.3 用 Python 快速生成维度示意

如果你需要把图放进文档或者做展示,可以直接用 matplotlib 画一个简单的维度示意。下面这段代码可以生成最基本的输入、LSTM、两种输出形态:

import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(9, 5)) def draw_block(x, y, w, h, label, color="#dbe9f6"): ax.add_patch(plt.Rectangle((x, y), w, h, facecolor=color, edgecolor="#333333", lw=1.5)) ax.text(x + w / 2, y + h / 2, label, ha="center", va="center", fontsize=12) # 输入张量 draw_block(0, 2, 2.6, 1.4, "输入\n(32, 50, 128)", color="#dbe9f6") # LSTM 单元 draw_block(3.4, 2.2, 1.0, 1.0, "LSTM\nhidden=64", color="#ffe8cc") # 最后状态输出 draw_block(5.4, 3.0, 1.8, 0.6, "h_T\n(32, 64)", color="#d5f5e3") # 全序列输出 draw_block(5.4, 0.7, 1.8, 1.4, "全序列输出\n(32, 50, 64)", color="#fde2e2") ax.set_xlim(-0.5, 7.8) ax.set_ylim(-0.2, 4.2) ax.axis("off") plt.title("LSTM 输出维度示意图") plt.show()

跑完这段代码,你会得到一个非常朴素的示意:输入框、LSTM 框、两个不同形状的输出框。实际使用中你可以再加箭头、加批次轴说明。画图工具不重要,重要的是每一张框图上都必须有真实的 shape 标注。

3.4 画图时最容易犯的三个错误

第一,把 seq_len 当成 hidden_size。Seq_len 是时间轴长度,hidden_size 是特征数量,两者完全不同。我在图上见过有人在 LSTM 输出框里写 (32, 50),这要么是没搞清楚时间轴,要么是没搞清楚输出到底取没取全序列。

第二,把 c_t 和 h_t 画成不同维度。标准实现里两者最后一维都是 hidden_size,多数框架甚至会把它们打包成同样形状的两个张量。如果图上画成 c_t 比 h_t 多一维或少一维,后面接代码时会误导自己。

第三,只画结构图而不标 shape。画了 200 个圆圈,别人还是不知道实际张量怎么流。记住,这份图的唯一目的是表达形状关系,不是表达数学计算。

4. 跨框架对比:Keras 与 PyTorch 输出维度的真实差异

4.1 Keras 中 return_sequences 与 return_state 的使用

Keras 的 LSTM 层有三个关键参数:return_sequences、return_state、以及比较少用的time_major。

return_sequences=False时,层输出形状是 (batch_size, units)。这里的 units 就是我们说的 hidden_size。

return_sequences=True时,层输出形状是 (batch_size, seq_len, units)。因为要保留每个时间步的输出。

return_state=True时,返回值会变成一个列表或者一个元组,包含三个元素:output、h、c。即使return_sequences=False,三个返回值也都在,别只接一个变量导致报错。

看一个最简单的 Keras 例子:

from tensorflow.keras import layers x = layers.Input(shape=(50, 128)) # 不考虑 batch 维 lstm = layers.LSTM(64, return_sequences=True, return_state=True) out, h, c = lstm(x) print(out.shape) # (None, 50, 64) print(h.shape) # (None, 64) print(c.shape) # (None, 64)

注意这里第一个返回值 out 在return_sequences=True时是完整的三维张量。如果你只想要最后一个时间步,可以直接用out[:, -1, :]。

很多初学者以为第一个返回值就是 h 状态,其实不是。第一个返回是“层输出”,第二个才是隐藏状态 h。只有在return_sequences=False的时候,第一个返回值才会退化成 (None, 64),看起来像 h,但它是 h_T 的另一种表达方式。

4.2 PyTorch 的 batch_first 与 h_n、c_n

PyTorch 里的 LSTM 用起来比 Keras 更底层,维度也更直白,但同时更容易把索引写错。

默认情况下,PyTorch LSTM 的输入是 (seq_len, batch_size, input_dim),也就是时间轴在第一维。如果你用惯了 Keras 那种“batch 在前”的写法,第一次跑 PyTorch 十有八九会报维度错误。解决办法是设置batch_first=True。

看代码:

import torch import torch.nn as nn lstm = nn.LSTM(input_size=128, hidden_size=64, num_layers=1, batch_first=True) x = torch.randn(32, 50, 128) # (batch, seq, feature) out, (h_n, c_n) = lstm(x) print(out.shape) # (32, 50, 64) print(h_n.shape) # (1, 32, 64) print(c_n.shape) # (1, 32, 64) # 最后一个时间步的输出 last_output = out[:, -1, :] # (32, 64)

这里的 h_n 和 c_n 都是三维张量,第 0 维代表层数。单层单向 LSTM 时,第 0 维就是 1。如果设置了batch_first=False,输入变成 (50, 32, 128),out 变成 (50, 32, 64),取最后一个时间步就要用out[-1, :, :],很多人都是在这里把索引方向搞反的。

还有一个很容易忽略的细节:out[:, -1, :]与h_n[-1]是不是完全相等?在单层单向 LSTM 里,两者的确相等。但在多层 LSTM 里,out 永远是最后一层的输出序列,h_n[-1] 也是最后一层的最终隐藏状态,这两个仍然对应。不过如果你想取第一层的中间状态,就必须通过 h_n[0] 来拿,out 里面是没有的。

4.3 双向 LSTM 与多层 LSTM 的维度翻倍规则

双向 LSTM 会让输出维度直接翻倍,因为每一时间步会把正向隐藏状态和反向隐藏状态拼起来。假设 hidden_size = 64,双向之后输出最后一维变成 128。

Keras 里这样写:

from tensorflow.keras import layers x = layers.Input(shape=(50, 128)) lstm = layers.Bidirectional( layers.LSTM(64, return_sequences=True) )(x) print(lstm.shape) # (None, 50, 128)

PyTorch 里则是:

import torch.nn as nn lstm = nn.LSTM(input_size=128, hidden_size=64, num_layers=2, batch_first=True, bidirectional=True) x = torch.randn(32, 50, 128) out, (h_n, c_n) = lstm(x) print(out.shape) # (32, 50, 128) print(h_n.shape) # (4, 32, 64)

这里的 h_n 第 0 维是 4,因为 2 层乘以 2 个方向。不要以为它是 batch 或 seq 维。取值的时候,如果只要最后一个时间步用于分类,我习惯直接用out[:, -1, :],这样不用去管方向拼接的顺序,拿到的一定是拼接后的完整 128 维状态。

把常见配置整理成下面这张对照表,画图时直接参考:

配置Keras 输出PyTorch 输出
单层单向,只要最后状态(None, 64)out[:, -1, :] 为 (batch, 64)
单层单向,要全部时间步(None, 50, 64)out 为 (batch, 50, 64)
双层单向,只要最后状态(None, 64)h_n[-1] 为 (batch, 64)
单层双向,要全部时间步(None, 50, 128)out 为 (batch, 50, 128)
双层双向,只要最后状态(None, 128)out[:, -1, :] 为 (batch, 128)

5. 维度图在排查实战里的用法:踩过的坑与自查清单

5.1 三个高频维度事故的重现与定位

事故一:全序列输出直接接全连接层。模型想对整句话做情感分类,LSTM 设置成了return_sequences=True,得到 (32, 50, 64),然后直接接Dense(1)。你以为会得到一个二分类 logits,实际得到的是 (32, 50, 1),每一个时间步一个预测。这时维度图能帮你一眼看到问题:你画的是整条输出路径,但分类任务需要走的是 h_T 那条路径。要么改成return_sequences=False,要么把out[:, -1, :]或时间轴池化之后再接全连接层。

事故二:编码器-解码器的状态初始化错误。我用多层 LSTM 做编码器,取 h_n 想初始化解码器。当时 h_n 的形状是 (num_layers, batch, hidden),我却在代码里传成了 (batch, hidden),结果解码器第一个时间步直接维度崩溃。事后对着维度图才发现,h_n 的第 0 维是层数,不是批次。图里如果早一点把 h_n 的三个维度分别标上“层数、批次、隐藏维”,就不会踩这个坑。

事故三:注意力打分时维度错乘。在实现 attention 时,查询向量来自解码器的当前隐藏状态 h_t,形状是 (batch, hidden),键值向量来自编码器的全部输出,形状是 (batch, seq_len, hidden)。打分计算时需要把 h_t 扩展成 (batch, 1, hidden),再和 (batch, seq_len, hidden) 做点积,得到一个 (batch, seq_len, 1) 的注意力分数。少一个维度扩展,就会得到一堆奇怪的 broadcasting 结果。这种问题靠肉眼看代码很难发现,但只要你把每个参与计算的张量形状画到图上,错配位置通常三秒就暴露了。

5.2 维度自查清单

每次写完一个含 LSTM 的模型,我会在脑子里过一遍下面这个清单:

  • 输入张量的维度顺序是否和框架默认格式一致?PyTorch 默认 seq 在前,Keras 默认 batch 在前;
  • 到底需要全序列输出还是最后一个时间步输出?代码和维度图选的是同一条路径吗;
  • 双向设置之后,输出最后一维是否已经乘以 2;
  • 多层设置之后,h_n 的第 0 维是层数还是层数乘以方向数;
  • 用了return_state=True时,返回元组里的顺序是否确认过,output、h、c 有没有接反;
  • 图里填写的 hidden_size 和代码里的 units / hidden_size 保持一致了吗;
  • 每个关键节点之后是否有临时打印 shape 验证,而不是习惯性往下写。

这些问题只要有一个没确认,模型训练时大概率会在某些奇怪的地方报错,而且是那种看起来像数据问题、实际是维度问题导致的报错。

5.3 两个实测下来很好用的小习惯

第一个习惯,写在代码注释里。我在声明 LSTM 的上一行和下一行,一定会写上输入输出 shape 的注释,比如:

# 输入: (batch=32, seq=50, feature=128) lstm_out, h_n, c_n = lstm(x) # 输出: (32, 50, 64), h_n=(1, 32, 64), c_n=(1, 32, 64)

这个习惯看起来笨,但换项目、换框架、换同事的时候,真的能省下大量沟通成本。第二个习惯,画图时把“批次维”统一放在最左边,“时间维”统一放在中间,“特征维”统一放在最右边。不要这次画 (batch, seq, feature),下次画 (seq, batch, feature),否则对照代码时很容易产生错觉。

我自己在项目里见过太多因为维度混乱导致的返工,而每一次最后定位到根因时,原因都非常朴素:不知道某个中间张量到底是什么形状。所以我才一直推荐把维度图作为学习 LSTM 的第一张图,而不是最后一张图。把输入输出形状先刻在脑子里,再去研究门控原理,整个模型的抽象感会少很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进