很多人提到手写体识别,第一反应都是卷积神经网络,毕竟MNIST在CNN里已经快被各种LeNet变体刷烂了。但你拿着这份数据集做RNN,效果其实也不差,甚至在理解“序列建模”这件事上比CNN更直观。本文把完整的TensorFlow实现、维度推导、训练调参和踩坑记录都放在一起,给想从CNN视角切到RNN视角的朋友一份可以照着抄的参考。
1. 为什么用RNN来做图像分类:打破“图像只能用CNN”的惯性思维
1.1 RNN处理图像的基本思路:把图片当成序列去读
MNIST图片是28×28的灰度图,每一张就是一个像素矩阵。CNN的做法是把这28×28的矩阵看成二维结构,用卷积核在空间上滑动。而RNN处理序列数据的天然模式是“一个时间步接收一个输入”,那么图像能不能拆成序列喂进去?当然可以。
把一张28×28的图片按行拆开,就得到28个长度为28的行向量。这28个行向量依次进入RNN:第一步看第0行,第二步看第1行,第三步看第2行……直到看完第27行。网络不是一次性看到完整图片,而是像从上往下扫描一样,逐步积累每一行的信息,最后用一个隐藏状态表示“整张图长什么样”。这个隐藏状态再经过全连接层,输出10个类别的概率。
为什么这个思路能work?因为手写数字的笔画有很强的行间连续性。比如数字“1”是一条竖线,上一行像素集中在图片中间,下一行像素通常也集中在中间;数字“7”上半部分有一条横线,下半部分有斜下来的笔画。RNN内部保存的隐藏状态能够携带上下行之间的关系,所以它实际上在学着捕捉“笔画的纵向传播规律”。
1.2 为什么是28个时间步,而不是直接把784个像素当序列
最常见的误操作是把图片直接reshape成784维向量,然后当作一个长度为784的序列,每个时间步输入一个像素。这样不是不行,但几乎等于没理解RNN的建模逻辑。序列建模的关键是“时间步之间具有局部连续性”,而MNIST图片按行切开后,每个时间步是整整一行像素,信息量更完整,行与行之间的语义关系也更稳定。
如果按像素维度展开,相邻时间步之间的依赖是“相邻像素”的关系,这种关系在图片中属于空间局部性,CNN已经做得很好,RNN反而浪费了参数。所以合理的设计是:
- 输入张量形状:
[batch_size, 28, 28] - 序列长度(time steps):28,代表28行
- 每个时间步的输入大小(input size):28,代表该行有28个像素
这也是为什么在代码里通常会写n_input=28、timesteps=28,而不是n_input=784。
1.3 和CNN相比,RNN在这类任务上的劣势和优势都很明显
CNN的优势是空间局部特征提取能力强,平移等变性好,像素周围的梯度信息能被卷积核高效捕捉。RNN在这方面天然吃亏,因为它只能沿着一个方向传递信息,一个位置要感知远处位置的特征,需要经过很多个时间步,路径更长,容易丢失早期信息。
但RNN也有它独特的价值:整张图的“阅读顺序”本身是一种强先验。比如手写数字的书写顺序是从左到右、从上到下,RNN的行扫描顺序与书写顺序一致,这让序列模型能够捕获笔画趋势。此外,RNN在时间序列任务、语音、文本等场景中是标配,用MNIST来验证RNN的序列建模能力,是一个成本很低的起点。
2. 训练之前先想清楚的几个关键维度问题
2.1 输入张量的形状到底怎么定
TensorFlow 1.x中,静态图里最常写的是占位符:
X = tf.placeholder(tf.float32, [None, 28, 28]) Y = tf.placeholder(tf.float32, [None, 10])第一个维度是batch size,用None表示运行时指定。第二个维度是时间步,第三个维度是每个时间步的特征数。如果你在代码里看到某个报错信息一直提到“Dimensions must be equal”,十有八九是这里的三个维度顺序搞错了。
对应关系是死的:
Tensor.shape[1]= 有多少个时间步,也就是把图片切成了几段Tensor.shape[2]= 每一段输入给LSTM cell的向量长度
所以[None, 28, 28]的意思是:批量大小不定,每张图切成28行,每行28个像素。
2.2 hidden_size 到底该取多大
hidden_size是LSTM内部隐藏状态的长度,也是这个网络真正承载记忆能力的容量。hidden_size越大,网络能够记住的细节越多,但参数量也越大,训练时间变长,还更容易过拟合。
比如一个BasicLSTMCell单元,输入维度是28,隐藏状态维度是128:
- 输入到隐藏的权重矩阵大小:128×28
- 隐藏到隐藏的权重矩阵大小:128×128
- 遗忘门、输入门、输出门、候选记忆共有4组这样的权重
所以单个LSTM cell的参数量大约是4 * (input_size + hidden_size) * hidden_size。代入28和128,就是4 × 156 × 128 = 79872,接近8万。这还只是一个cell,如果堆两层,参数量会翻倍。
我自己在MNIST上试过,hidden_size取64就能跑到95%以上,取128能稳定到98%左右,取256以上提升不明显,训练时间倒是明显增加。所以建议刚开始不要追求大,128是一个比较合理的起点。
2.3 用static_rnn还是dynamic_rnn,差别在哪
TensorFlow 1.x里有两套经典API:tf.nn.static_rnn和tf.nn.dynamic_rnn。
tf.nn.static_rnn要求输入预先被拆成时间步列表,也就是用tf.unstack(X, axis=1)把[batch, 28, 28]变成28个[batch, 28]张量组成的列表,然后再分别送入一个共享的LSTM cell。这种方式代码直观,但显式展开会延长图构建时间,而且序列长度必须固定。
tf.nn.dynamic_rnn不需要拆列表,直接接受[batch, time, input_size]的输入,内部通过循环控制流进行展开,构建图和执行效率都更好,还支持动态序列长度。除非你刻意想理解底层展开过程,不然推荐直接用dynamic_rnn。
lstm_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units=128) outputs, states = tf.nn.dynamic_rnn(lstm_cell, X, dtype=tf.float32)其中outputs的shape是[batch_size, 28, 128],表示每个时间步的输出;states是一个LSTMStateTuple,包含c(长期记忆)和h(隐藏状态)。做分类时,通常取最后一个时间步的outputs[:, -1, :],它等效于states.h。
3. 基于TensorFlow的RNN手写体识别完整实现
3.1 数据集加载与预处理
TensorFlow 1.x自带MNIST读取脚本,最省事的写法是:
from tensorflow.examples.tutorials.mnist import input_data mnist = input_data.read_data_sets("MNIST_data/", one_hot=True) train_data = mnist.train.images train_labels = mnist.train.labels test_data = mnist.test.images test_labels = mnist.test.labels这个脚本默认会把图片数据reshape成[num_samples, 784],所以拿回来后要手动reshape成[num_samples, 28, 28],这是我经常看到新手漏掉的一步。
如果因为网络原因下载脚本连不上,也可以改用tf.keras.datasets.mnist.load_data(),读出之后是numpy数组,自己归一化一下完全等价。
3.2 定义网络结构
用dynamic_rnn完成主体建模,最后接一个全连接输出层:
import tensorflow as tf learning_rate = 0.001 training_steps = 10000 batch_size = 128 display_step = 200 n_input = 28 n_steps = 28 n_hidden = 128 n_classes = 10 X = tf.placeholder(tf.float32, [None, n_steps, n_input]) Y = tf.placeholder(tf.float32, [None, n_classes]) weights = { 'out': tf.Variable(tf.random_normal([n_hidden, n_classes])) } biases = { 'out': tf.Variable(tf.random_normal([n_classes])) } lstm_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units=n_hidden, forget_bias=1.0) outputs, states = tf.nn.dynamic_rnn(lstm_cell, X, dtype=tf.float32) logits = tf.matmul(outputs[:, -1, :], weights['out']) + biases['out']这里有几个容易绕晕的点。
第一,outputs[:, -1, :]取出的是最后一个时间步的隐藏状态h,形状是[batch_size, n_hidden]。注意取的是h不是c,因为h才适合作为输出特征。
第二,如果要加Dropout,可以在LSTM cell外面包一层tf.nn.rnn_cell.DropoutWrapper:
lstm_cell = tf.nn.rnn_cell.BasicLSTMCell(num_units=n_hidden) lstm_cell = tf.nn.rnn_cell.DropoutWrapper(lstm_cell, output_keep_prob=0.8)不过MNIST本身不大,RNN的过拟合问题在简单的单层LSTM上不算严重,一般不加Dropout也能跑得不错。
第三,如果想把两层LSTM堆起来,用tf.nn.rnn_cell.MultiRNNCell:
cell = tf.nn.rnn_cell.BasicLSTMCell(n_hidden) cell = tf.nn.rnn_cell.MultiRNNCell([cell] * 2)这时候states是一个包含两个LSTMStateTuple的tuple,取输出仍然直接用outputs[:, -1, :]。
3.3 损失、优化器与准确率评估
用softmax交叉熵作为分类损失,优化器直接选Adam:
prediction = tf.nn.softmax(logits) loss_op = tf.reduce_mean( tf.nn.softmax_cross_entropy_with_logits( logits=logits, labels=Y ) ) optimizer = tf.train.AdamOptimizer(learning_rate=learning_rate) train_op = optimizer.minimize(loss_op) correct_pred = tf.equal(tf.argmax(prediction, 1), tf.argmax(Y, 1)) accuracy = tf.reduce_mean(tf.cast(correct_pred, tf.float32))tf.nn.softmax_cross_entropy_with_logits在TensorFlow 1.x中要求logits和labels的shape都是[batch_size, num_classes],传错维度会报rank错误。
3.4 训练主循环
init = tf.global_variables_initializer() with tf.Session() as sess: sess.run(init) for step in range(1, training_steps + 1): batch_x, batch_y = mnist.train.next_batch(batch_size) batch_x = batch_x.reshape((-1, n_steps, n_input)) sess.run(train_op, feed_dict={X: batch_x, Y: batch_y}) if step % display_step == 0 or step == 1: loss_val, acc_val = sess.run( [loss_op, accuracy], feed_dict={X: batch_x, Y: batch_y} ) print("step " + str(step) + ", Minibatch Loss= " + "{:.4f}".format(loss_val) + ", Training Accuracy= " + "{:.3f}".format(acc_val)) test_x = mnist.test.images.reshape((-1, n_steps, n_input)) test_y = mnist.test.labels print("Test Accuracy:", sess.run(accuracy, feed_dict={X: test_x, Y: test_y}))注意每次拿到batch后必须reshape成[batch_size, 28, 28],否则维度对不上。测试阶段一次性把所有测试集喂进去,因为测试集只有1万张,内存完全放得下。
4. 训练过程中的实测表现与调参观察
4.1 不同hidden_size和层数下的直观对比
我在自己机器上跑过几组配置,硬件是普通的单张消费级显卡,数据如下表。这些数字会因随机种子略有波动,但趋势很稳定:
| 配置 | 训练到第2000步时损失 | 测试准确率 | 训练耗时感受 |
|---|---|---|---|
| 单层LSTM, hidden=64 | 约0.25 | 97%左右 | 很快 |
| 单层LSTM, hidden=128 | 约0.12 | 98%左右 | 正常 |
| 单层LSTM, hidden=256 | 约0.08 | 98.2%左右 | 明显变慢 |
| 双层LSTM, hidden=128 | 约0.05 | 98.4%左右 | 慢,但提升有限 |
从损失曲线看,hidden=128比hidden=64收敛更快、最终准确率更高;hidden=256提升很少,性价比已经不高。双层LSTM在百万级以下的数据集上收益不明显,还会带来更长的训练时间和更大的显存占用。所以我的建议是:第一版直接用单层LSTM + hidden=128,跑通全流程之后再决定要不要堆层数。
4.2 训练轮数不是越多越好
训练跑到一定程度后,训练集loss还在下降,但测试准确率开始横盘甚至下跌,这就是过拟合信号。MNIST训练集只有5.5万张,对RNN来说并不算海量数据,网络容量足够时完全可能把训练集“背下来”。
判断是否过拟合,看两个数字就够了:训练集准确率和测试集准确率。如果训练集已经到99.9%,测试集只有97.5%,说明模型把训练集的噪声也记住了。此时该做的不是继续训练,而是加Dropout、减小hidden_size,或者提前停止训练。
4.3 学习率、batch_size和时间步长的连带影响
Adam优化器配上0.001的学习率在大多数任务上都能稳定收敛,MNIST也不例外。如果你想加快收敛,可以把前几百轮的学习率调到0.003,等loss下降变慢后再切回0.001,但这属于微调技巧,新手阶段没必要折腾。
batch_size的影响主要反映在训练稳定性和显存占用上。batch太小,梯度噪声大,loss波动明显;batch太大,每个step耗时长。128是比较稳的中间值,既能吃满GPU流水线,又不至于让loss曲线抖动太厉害。
还有一点容易被忽略:MNIST图片是灰度图,输入范围在0到255之间,需要在预处理阶段除以255归一化。如果不归一化,直接以原始像素值喂给LSTM,初始梯度会被放大,训练初期损失很容易冲高到几十甚至几百。
5. 常见报错与排查:我踩过的几个坑
5.1 维度不一致的报错,根源往往早于报错行
dynamic_rnn最常见的报错是“Dimensions must be equal”或者“Input size (depth of inputs) must match the hidden size”。如果你在代码里看到类似:
Input size (depth of inputs) must match the hidden size: 28 vs 128这通常不是hidden_size写错了,而是你把输入X的形状传成了[None, 784],让dynamic_rnn以为时间步是784,每个时间步的输入维度是1,然后和hidden_size做了匹配检查。
解决办法就是把数据reshape成[None, 28, 28]。我习惯在数据处理函数里强制加一步断言:
assert batch_x.shape[1:] == (n_steps, n_input)维度问题越早发现,排查成本越低。
5.2 outputs最后一个时间步和states.h到底该用哪个
dynamic_rnn返回的outputs是每个时间步的输出,states是最后一个时间步的状态。对于单层BasicLSTMCell,states.h和outputs[:, -1, :]数值上是同一个东西。所以下面两种写法等价:
logits = tf.matmul(outputs[:, -1, :], weights['out']) + biases['out'] logits = tf.matmul(states.h, weights['out']) + biases['out']但对多层LSTM来说,states是一个tuple,需要取[-1].h才能拿到最后一层的隐藏状态。初学者如果直接写states.h,可能会得到意料外的维度,因为MultiRNNCell的state不是单个LSTMStateTuple。相比之下,outputs[:, -1, :]不需要关心层数,永远拿到的是最后一个时间步所有层的叠加输出,写起来更省心。
5.3 MNIST下载失败时的存活方案
老教程里常用的input_data.read_data_sets依赖谷歌存储地址,在某些网络环境下会卡住。遇到这种情况不需要纠结,改用以下方式加载:
from tensorflow.keras.datasets import mnist (x_train, y_train), (x_test, y_test) = mnist.load_data() x_train = x_train.astype(np.float32) / 255.0 x_test = x_test.astype(np.float32) / 255.0 y_train = np.eye(10)[y_train] y_test = np.eye(10)[y_test]加载结果同样是numpy数组,喂给feed_dict没有任何问题。唯一要注意的是tf.keras.datasets.mnist返回的label是一维整数数组,做one-hot需要自己手动转,不像input_data默认返回one-hot向量。
6. 与CNN的对比以及从MNIST走出去的方向
6.1 说到底RNN在MNIST上比CNN弱在哪儿
一个简单的CNN,比如两个卷积层加两个池化层加两个全连接层,在MNIST测试集上很容易达到99%以上。同样的数据量,单层LSTM通常稳定在98%到98.5%。差距大约在1个百分点左右。
这个差距是用在长距离空间依赖建模上的代价。一个像素点距离另一个像素点20行时,RNN需要经过20个时间步才能把信息传递过去,中间每一步都会有信息损失。而CNN通过卷积核的局部感受野叠加,几层之内就能把大范围空间信息聚合起来,效率更高。
但RNN在MNIST上的价值不在于打败CNN,而在于用最小的成本暴露序列建模的本质问题:时间步如何切、状态如何传递、梯度如何消失。
| 对比维度 | RNN (LSTM) | CNN |
|---|---|---|
| 输入视角 | 按行扫描的28步序列 | 二维图像矩阵 |
| 局部建模 | 依赖隐藏状态跨时间步传播 | 卷积核空间滑窗 |
| 典型测试准确率 | 约98% | 约99%以上 |
| 训练速度 | 相对慢 | 相对快 |
| 主要优势场景 | 语音、文本、时间序列 | 图像、视觉识别 |
6.2 从单层LSTM到双向LSTM的扩展思路
单层LSTM只能从第0行读到第27行,方向是单向的。数字识别中有些笔画特征,比如数字“2”底部横线,可能更适合从后往前看。双向LSTM就是同时跑正向和反向两个LSTM,把两个方向的状态拼接起来送到输出层。
在TensorFlow 1.x中实现双向LSTM,可以直接用tf.nn.bidirectional_dynamic_rnn:
fw_cell = tf.nn.rnn_cell.BasicLSTMCell(n_hidden) bw_cell = tf.nn.rnn_cell.BasicLSTMCell(n_hidden) outputs, states = tf.nn.bidirectional_dynamic_rnn( fw_cell, bw_cell, X, dtype=tf.float32 ) fw_output, bw_output = outputs output = tf.concat([fw_output[:, -1, :], bw_output[:, -1, :]], axis=1)拼接后的输出维度是2 * n_hidden,所以weights['out']也要改成[2 * n_hidden, n_classes]。双向网络在MNIST上大概能把准确率再往上推一点,换来的是接近两倍的计算量。
6.3 别让这份代码只停留在MNIST
把RNN实现手写体识别的完整流程吃透之后,可以直接迁移到很多实际任务上。MNIST的输入是“28行数字图像”,本质上就是一个二维序列,换成语音帧序列、文本token序列、传感器时间序列,只是换输入维度,不需要改网络骨架。
后面可以考虑的方向包括:把LSTM换成GRU,看看收敛速度变化;在输出层加入tf.nn.attention或者手动实现简单的注意力机制;把一个字符的像素序列拼接成句子级别的序列,往手写体文本识别方向延伸。这些扩展的核心还是本文讲的这套:把数据组织成[batch, time, features],让LSTM沿着时间步消化信息,然后在合适的边界上取状态做分类或回归。
我自己的体会是,RNN做MNIST最大的门槛反而不是网络本身,而是“把图像当作序列”这个思维转换。一旦你能熟练地把任意二维数据拆成时间步,理解各种shape变化,后面迁移到真实业务数据时就顺畅很多。最后分享一个小技巧:训练时多打印最后10个batch的准确率,别只盯着第一个batch,不然会误以为网络不收敛。