ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LSTM输入输出维度图解:从input_size到hidden_size一次说清

LSTM输入输出维度图解:从input_size到hidden_size一次说清

群里有朋友发来一张截图,说“LSTM输出输出维度怎么看都看不懂”。我猜是“输入输出”四个字打重了,可问题本身一点都不含糊:模型明明能跑,但中间张量到底长什么样,很多人完全说不清。LSTM的维度问题几乎每个入门者都会卡一下,因为它的数据不是一次性算完,而是按时间步滚动更新,再加上不同框架的默认排列顺序还不一样,input_size、hidden_size、seq_len、batch_size几个概念搅在一起,确实容易绕晕。

这篇文章就用表格、简单示意和几段能直接复制的小代码,把LSTM的输入输出维度一层层拆开。从单层到多层,从单向到双向,从多对一到多对多,最后把常见的维度报错也一并解决掉。读过之后你会发现,LSTM的维度没那么玄,关键是把“时间步”“隐藏层大小”“批大小”这三件事对应到张量的具体轴上。

1. 先搞清楚三个数字:input_size、hidden_size、seq_len

LSTM的维度问题,九成是这三个数字混在一起造成的。它们都叫“大小”,但含义完全不同。

1.1 input_size 是“每个时间步的特征数”,不是序列长度

input_size指的是单个时间步里携带的特征数量。比如一段文本经过词嵌入后,每个词可能用8维向量表示,那input_size=8;再比如你预测一只股票,每个历史时刻只有“收盘价”一个数字,那input_size=1;如果有5个传感器的读数,那input_size=5。

很多从全连接网络转过来的朋友会下意识把input_size理解成“输入神经元个数”,这个类比在LSTM里其实不太准确。LSTM每个时间步都是一个小型计算单元,它接收的是一整个向量,而不是一个标量。这个向量的长度就是input_size。

它跟序列长度完全无关。长度是“有多少个时间步”,特征数是“每个时间步里有多少个数字”。一个句子有10个词,每个词用8维向量表示,那么seq_len=10,input_size=8。这两个数字一旦搞反,模型肯定报错。

1.2 hidden_size 是“内部记忆的宽度”,不是层数

hidden_size表示LSTM单元内部状态向量的长度,你可以把它理解为“这个单元记忆有多宽”。它不是神经元的个数,而是每一组状态向量的维度。

例如hidden_size=16,意味着在内部会产生一个16维的隐状态向量h_t和一个16维的细胞状态向量c_t。这两个状态向量会在每个时间步不断被更新,用来携带上下文信息。

hidden_size和input_size不需要相等。哪怕输入特征只有1维,内部状态也可以设成128维。这就像一个人接收到的外部信息可能很简单,但他脑子里可以反复琢磨、展开很多内部细节。hidden_size越大,表达能力越强,但计算量和过拟合风险也更大。

1.3 seq_len 是“循环的次数”,不是数据维度

seq_len是时间步的数量,它决定LSTM要按顺序处理多少个输入。它不体现在单个向量的长度上,而是体现在张量的一个轴以及内部循环次数上。

把这三个数字放回张量里看,就清楚多了。一个标准的LSTM输入是三维张量,三个轴分别对应:

  • 轴0:批次大小batch_size
  • 轴1:序列长度seq_len
  • 轴2:每个时间步的特征数input_size

所以一个[batch_size, seq_len, input_size]的输入,经过LSTM层之后,一般会得到[batch_size, seq_len, hidden_size]的输出。这层变换的本质,就是“把每个时间步的特征向量从 input_size 映射到 hidden_size”,同时借助内部状态把整个序列的信息累积起来。

2. 单层LSTM的维度图示:输入输出到底怎么变

这一节是全文的基础。搞懂单层单向LSTM后,多层、双向、各种变体的维度都是在这个基础上加的。

2.1 最常见输入形态是三维张量,框架默认顺序不一样

LSTM输入一定是三维张量,但同一个数据在不同框架里摆放顺序不一样。这是很多新手疑惑的重灾区。

框架/参数输入张量形状说明
PyTorch 默认[seq_len, batch, input_size]时间轴放在最前面,time-major
PyTorch 开启batch_first=True[batch, seq_len, input_size]批量轴放最前,更直观
TensorFlow/Keras[batch, seq_len, input_size]默认就是批量轴在前

PyTorch 默认不把 batch 放前面,是因为LSTM类最初是从论文习惯里的“时间步优先”风格继承过来的,训练时可能在某些并行计算场景下更快。但大多数业务代码里,数据读进来通常都是batch在前的,所以 PyTorch 也提供了batch_first=True来切换。

我建议你在写代码时统一加batch_first=True,至少能少一半轴顺序方面的纠结。注意一点:这个参数只影响输入和输出张量中“时间轴和批量轴”的位置,不影响隐状态h_n和细胞状态c_n的排列方式。

2.2 输出其实有两种:全部时间步输出和最终隐状态

LSTM层会返回两类东西,很多人只盯着其中一类,然后就蒙了。

第一类是“所有时间步的输出”,通常叫output或者out。在 PyTorch 里,它的形状是:

  • batch_first=True:[batch, seq_len, hidden_size]
  • batch_first=False:[seq_len, batch, hidden_size]

第二类是“最后一个时间步的隐状态和细胞状态”,通常叫h_n和c_n。它们的形状是:

  • [num_layers, batch, hidden_size]

注意,即使你设置batch_first=True,h_n的形状也不会变成[batch, num_layers, hidden_size]。它的第一维永远是层数。

为什么会不一样?因为out保存的是“每个时间步的最后一层输出”,而h_n保存的是“每一层在最后那个时间步的隐状态”。单层单向时,out[:, -1, :]和h_n[-1]在数值上是同一个东西。多层时,h_n里面还装着前面层的最终状态,out里面则是每一时间步的最后一层输出,两者不能直接画等号。

2.3 一个可复现的维度打印实验

与其空想,不如直接打印维度。下面这段代码在 PyTorch 里验证单层单向LSTM的输入输出形状:

import torch import torch.nn as nn # 输入特征8维,隐状态16维,batch_first=True lstm = nn.LSTM(input_size=8, hidden_size=16, batch_first=True) # 4句话,每句话5个词,每个词8维向量 x = torch.randn(4, 5, 8) out, (h_n, c_n) = lstm(x) print("out.shape:", out.shape) # torch.Size([4, 5, 16]) print("h_n.shape:", h_n.shape) # torch.Size([1, 4, 16]) print("c_n.shape:", c_n.shape) # torch.Size([1, 4, 16]) # 所有时间步输出里的“最后一步” 和 h_n最后一层,数值一致 print(torch.equal(out[:, -1, :], h_n[-1])) # True

在 Keras 里,对应的参数是return_sequences和return_state:

from tensorflow.keras.layers import LSTM # return_sequences=True:返回所有时间步输出 [batch, seq_len, hidden_size] # return_state=True:额外返回最后隐状态 h 和细胞状态 c lstm_layer = LSTM(16, return_sequences=True, return_state=True)

Keras 的返回值顺序容易记混,依次是:output,h_state,c_state。其中h_state的形状是[batch, hidden_size],不是[1, batch, hidden_size]。这就跟 PyTorch 不一样,换框架时一定要重新打印一下。

3. 维度流动的内部逻辑:从 input_size 到 hidden_size

知道形状以后,还得理解它为什么会这样流动。否则下次遇到[batch, seq_len, input_size]变成什么,还是只能靠猜。

3.1 时间步是“循环”不是广播

LSTM不是像卷积那样在整个序列上一次性滑完,而是严格按时间顺序循环处理。理论上,LSTM单元在每个时间步只处理一个“长度为input_size”的向量,然后更新内部状态。

这个过程可以用一个手动展开的循环来理解:

h = torch.zeros(batch, hidden_size) c = torch.zeros(batch, hidden_size) for t in range(seq_len): x_t = x[:, t, :] # 取出第t个时间步的数据,形状 [batch, input_size] h, c = lstm_cell(x_t, (h, c)) # 内部状态更新,h仍然是 [batch, hidden_size]

所以seq_len是循环的次数,它决定张量中“时间轴”的长度。每个时间步的输入和输出向量长度分别由input_size和hidden_size决定。循环次数和向量长度是两回事,这也是很多人搞混的根源。

3.2 权重矩阵把外部特征映射到内部记忆空间

LSTM单元内部有一组门控机制,输入门、遗忘门、输出门、候选记忆,每个门本质上都是一次线性变换加激活函数。线性变换的输入是“当前输入向量”和“上一个隐状态”拼接在一起,然后乘上权重矩阵。

忽略掉具体门控细节后,可以简化理解为:

input_size 维向量 -> 线性变换 -> hidden_size 维隐状态

input_size和hidden_size不相等,完全没问题,因为中间有一个hidden_size x (input_size + hidden_size)的权重矩阵在做维度转换。换句话说,LSTM把“外部世界的特征表达”映射到“内部记忆空间”里。

为什么需要这个差异?因为外部特征可能很稀疏、很短,而内部记忆需要足够宽才能承载长期上下文。比如一个单词的词向量只有8维,但句子里的语法角色、情感倾向、指代关系都需要被记忆,这就不止8维能装下。

3.3 文本分类任务的维度流动全过程

用文本分类举个例子。假设要判断4句话的情感,句子已经切好了词,每句话最多5个词,每个词用8维的词向量表示。

流程大致是这样:

原始文本:["我 爱 深 度 学 习", "这 部 电 影 好 看", ...] 分词后变成 token id:[4, 5] 经过 Embedding(词表大小, 8) 变为:[4, 5, 8] 经过 LSTM(input_size=8, hidden_size=16, batch_first=True) 变为:[4, 5, 16] 取最后一个时间步 out[:, -1, :] 变为:[4, 16] 接 Dense(2) 输出分类概率:[4, 2]

这里的[4, 5, 8]就是标准三维输入。5是时间步数,8是每个词向量维度,4是批量大小。LSTM层输出[4, 5, 16],每个词都有一个16维的隐状态表示。

如果任务是句子级分类,只关心整句话的情感,那就取最后一个时间步。如果任务是词性标注,需要每个词都输出标签,那就直接用[4, 5, 16]接全连接层,对每个时间步做分类。

3.4 时间序列预测任务的维度流动全过程

再举一个完全不同领域的例子:用过去10个时刻的传感器数据预测下一时刻的温度。

原始数据是一维的,总共10000个时刻。为了喂给LSTM,需要做滑动窗口切样本。假设每个样本取10个历史时刻,每个时刻只有1个温度值,批量大小取32。

此时输入张量是[32, 10, 1]:

  • 32是批量大小,一次处理32个窗口
  • 10是时间步数,即过去的10个时刻
  • 1是每个时刻的特征数,即温度
lstm = nn.LSTM(input_size=1, hidden_size=32, batch_first=True) out, (h_n, c_n) = lstm(x) # out.shape = [32, 10, 32] # 预测下一个温度:取序列最后一步,再接一个全连接层 last_step = out[:, -1, :] # [32, 32] pred = fc(last_step) # [32, 1]

如果每个时刻有多个传感器,比如温度、湿度、风速三个值,那input_size=3,输入形状就是[32, 10, 3]。LSTM内部会自动把每个时间步的3维特征映射到hidden_size维空间。

4. 不同任务形态下的输出维度变体

LSTM的输入输出形态并不只有一种。根据任务需要,大体分为多对一、多对多、一对多三类。维度选择完全取决于任务目标。

4.1 多对一、多对多、一对多的形状对照表

任务形态典型任务输入形状常用输出
多对一文本分类、时间序列点预测[batch, seq_len, input_size][batch, hidden_size],取最后一步
多对多(同步)序列标注、逐帧分类[batch, seq_len, input_size][batch, seq_len, hidden_size]
多对多(异步)机器翻译、摘要生成编码器输入[batch, seq_len_enc, input],解码器输入逐个生成解码器输出长度不定
一对多图像标题、文本生成条件向量 + 逐步输入生成seq_len_out步输出

“一对多”这个词容易让人误以为LSTM原生支持一个输入、多个输出。实际它并不是一个LSTM层就能完成的,而是靠“把同一个条件向量重复送到每个时间步”或者“把条件向量作为初始隐状态”来构造的。

4.2 多对一任务:取“最后一步”的两个等价方式

多对一任务最后都需要把一整条序列压缩成一个向量。最常用的是取序列最后一个时间步的隐状态。在 PyTorch 里有两种写法本质等价:

# 方式一:从 out 里取最后一步 feature = out[:, -1, :] # [batch, hidden_size] # 方式二:从 h_n 里取最后一层 feature = h_n[-1] # [batch, hidden_size]

单层单向时,这两个结果完全相同。不过有一个小坑:如果开了batch_first=True,out[:, -1, :]是最后一步;如果没开,就应该是out[-1, :, :]。所以每次写之前都要先确认你的输入轴顺序是什么。

多层时更需要注意。out是最后一层在每个时间步的输出,h_n是每一层的最终隐状态。如果你要的是“最后一层的最终状态”,h_n[-1]是对的;如果你要的是“倒数第二层的最终状态”,就得用h_n[-2]。只看out时,你拿不到中间层的输出。

4.3 一对多任务:靠“重复输入”和“初始状态”实现

一对多任务最常见的是文本生成和图像标题,输入一个条件向量,输出一串序列。LSTM本身没有“一个输入产生多个时间步输出”的固定接口,实际操作中有两种常见构造方式。

方式一是把条件向量作为LSTM的初始隐状态,然后在每个时间步把上一时间步的输出当作当前输入。这种方式最接近“自回归生成”的逻辑,但训练时要费心处理“强制教学”和“自由生成”之间的差异。

方式二更简单:把同一个条件向量复制seq_len份,组成一个[batch, seq_len, condition_dim]的输入,送入LSTM。比如训练图像标题时,可以把图像特征向量复制10次,输出10个词的预测:

condition = img_feature.unsqueeze(1) # [batch, 1, hidden_size] condition_repeat = condition.repeat(1, 10, 1) # [batch, 10, hidden_size] out, (h_n, c_n) = lstm(condition_repeat) # out: [batch, 10, hidden_size]

方式二在训练时很好写,但生成阶段仍然要改成逐步预测,因为目标长度不固定。无论哪种方式,一对多任务里“输出序列长度”是你自己决定的,LSTM不会自动判断。

4.4 return_sequences 和 return_state 到底怎么配合

在 Keras/TensorFlow 里,return_sequences和return_state这两个布尔参数经常把人绕晕。

  • return_sequences=False:初始默认,返回最后一步隐状态,形状[batch, hidden_size]
  • return_sequences=True:返回所有时间步隐状态,形状[batch, seq_len, hidden_size]
  • return_state=True:不管上面那个,额外返回最后隐状态h和细胞状态c

在 Keras 中,LSTM(return_sequences=True, return_state=True)的返回值是三元组:output, h, c。其中output和h在最后一步是重合的,但维度不同,output包含完整的时间轴,h只代表最后一步。这个细节非常容易踩坑,我见过不少同事把output和h当成一回事,结果后续全连接层维度怎么都对不上。

5. 双向和多层LSTM的维度叠加

单层单向是基础,但实际项目里经常要上双向、上多层。维度这时候会发生变化,但规则很清晰。

5.1 双向LSTM:输出维度直接翻倍

双向LSTM用两条独立的LSTM分别处理原始序列和反向序列,然后把它们的输出拼接起来。所以不管hidden_size设多少,最终每个时间步的输出维度都是2 * hidden_size。

在 PyTorch 里:

lstm = nn.LSTM(input_size=8, hidden_size=16, bidirectional=True, batch_first=True) out, (h_n, c_n) = lstm(x) print("out.shape:", out.shape) # torch.Size([4, 5, 32]) print("h_n.shape:", h_n.shape) # torch.Size([2, 4, 16]) print("c_n.shape:", c_n.shape) # torch.Size([2, 4, 16])

out最后一维是32,正好是正向16维和反向16维拼起来的结果。

h_n的排列要注意:单层双向时,h_n第一维是2,第一个是正向最后时间步的隐状态,第二个是反向最后时间步的隐状态。如果你想拿“整个序列的压缩表示”,不要把h_n直接抻平,更好的做法是把正向和反向分别取出来拼接:

h_forward = h_n[0] # [batch, 16] h_backward = h_n[1] # [batch, 16] h_concat = torch.cat([h_forward, h_backward], dim=-1) # [batch, 32]

Keras 里用Bidirectional(LSTM(...))也是一样,merge_mode默认是concat,所以输出维度是2 * hidden_size。如果不想要翻倍,可以把merge_mode设成sum、mul或ave,但实际效果要看任务。

5.2 多层LSTM:h_n按层排布,out只表达最后一层

多层LSTM也叫堆叠LSTM,上一层每个时间步的输出作为下一层的输入。因为每一层的hidden_size通常设成相同值,所以out的形状不会随层数变化,仍然是[batch, seq_len, hidden_size]。

变化的是h_n和c_n。它们的第一维会等于num_layers(如果双向则等于num_layers * 2)。这意味着每层都有自己独立的最终隐状态,互不混杂。

lstm = nn.LSTM(input_size=8, hidden_size=16, num_layers=2, batch_first=True) out, (h_n, c_n) = lstm(x) print("out.shape:", out.shape) # torch.Size([4, 5, 16]) print("h_n.shape:", h_n.shape) # torch.Size([2, 4, 16])

第一层和第二层的隐状态各有各的含义。取最后一层的最后一时间步,可以用out[:, -1, :]或者h_n[-1]。取第一层的最后一时间步,只能用h_n[0],out里拿不到。

如果你在双层的 h_n 里看到第一维是2,别以为那是“方向”,它可能是“层”。更复杂的模型里,“层”和“方向”同时存在时,第一维会是num_layers * num_directions,顺序一般是按层排,每层内部再按方向排。想准确知道,打印验证最稳妥。

5.3 常见维度拼接和池化操作

实际项目里,拿到LSTM的输出后往往不会直接接分类器,而是会做各种聚合。这些操作的维度变化也很规律。

操作输入形状输出形状说明
取最后一步[batch, seq_len, hidden][batch, hidden]适合分类任务
全局平均池化[batch, seq_len, hidden][batch, hidden]对时间轴取平均
全局最大池化[batch, seq_len, hidden][batch, hidden]突出最强信号
拼接正反向最后状态[batch, hidden]两个[batch, 2*hidden]双向表示
拼接多层最终状态[num_layers, batch, hidden][batch, num_layers*hidden]不常见,但可用

接全连接层之前,一定要确保张量已经变成二维[batch, feature_dim]。把[batch, seq_len, hidden]直接塞进全连接层是最常见的报错来源。

6. 常见维度报错与排查技巧实录

最后分享一些实际排查经验。下面这些报错和信息提示,是我见过的最高频几类。

6.1 五条高频报错信息对照

报错提示通常原因解决思路
size mismatch/got ... expected ...输入最后一维不等于input_size检查数据和LSTM构造参数是否一致,尤其是Embedding输出维度和input_size
Expected hidden[0] size (..., num_layers, ...)传入初始隐状态h0的层数维度不对确认h0第一维是num_layers或num_layers * num_directions
Input tensor should have 3 dimensions输入不是三维张量把二维[batch, features]改成[batch, seq_len, features]
mat1 and mat2 shapes cannot be multiplied全连接层吃到了三维张量先用out[:, -1, :]或池化降到二维再接全连接
Given groups=1, weight size ...常见于Conv和LSTM混用时维度理解偏差检查卷积层和LSTM层之间是否做过正确的维度调整

这些报错里,最坑的是第三种。有人把[batch, features]当成LSTM输入,以为再加一维就完事,结果时间步维度是1,模型不报错但效果很差。还有人以“反正模型能跑就行”的心态忽略维度警告,结果训练出的模型毫无泛化能力。

6.2 用随机输入打印维度的自检法

结构越复杂,越应该在接真实数据前先用随机输入把维度验证一遍。我自己养成的习惯是写一个小函数:

import torch import torch.nn as nn def check_lstm(input_size=8, hidden_size=16, seq_len=5, batch=4, num_layers=1, bidirectional=False): lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, bidirectional=bidirectional, batch_first=True, ) x = torch.randn(batch, seq_len, input_size) out, (h_n, c_n) = lstm(x) print("input :", x.shape) print("out :", out.shape) print("h_n :", h_n.shape) print("c_n :", c_n.shape) check_lstm() check_lstm(bidirectional=True) check_lstm(num_layers=2)

用随机数做验证,可以先把“结构错误”和“数据错误”拆开。真实数据里常有填充、掩码、变长序列,维度问题混在里面会很难判断。先用随机数跑通结构,再套真实数据,能省下大量排查时间。

6.3 给新手的维度记忆口诀和自查清单

我自己平时喜欢用几句口诀快速判断LSTM维度:

  • 输入永远是三维:batch、seq_len、input_size。
  • hidden_size管的是输出向量长度,管不了你有多少个时间步。
  • 拿“最后一步”用out[:,-1,:],前提是batch_first=True。
  • h_n第一维一定是层数,不是批量,也不是序列长度。
  • 双向就是hidden_size翻倍,多层就是h_n变厚。
  • 接全连接前,先降到二维。

自查的时候,按这个顺序问一遍:“我的数据是不是三维?三个轴分别是什么?batch_first开没开?我想要每个时间步的输出还是最后一步?如果是双向,维度翻倍了吗?如果是多层,h_n的层数对吗?”

这套问题过完,绝大多数维度问题都能定位到具体那一行。

最后再分享一个实操小习惯。我的代码里第一版 forward 一定会加一句print(x.shape, out.shape, h_n.shape),等整个模型结构确认无误再删掉。别小看这个土办法,LSTM维度问题在纸面上推十遍,不如拿一个随机张量跑一遍,打印出来的形状就是最诚实的答案。卡维度不用慌,把“时间步、hidden_size、batch”三个数对到具体轴上,剩下的都是重复推演。

RELATED READING

延伸阅读

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