这个系列写到第十二篇,前十一篇我们把全连接网络、卷积网络挨个过了一遍。说实话,越写到后面越觉得,网络结构跟工具箱里的扳手钳子一样,每一个都是有脾气、有适用面的。今天这篇的主角是循环神经网络(RNN)和它的进化版LSTM,主要解决的不是图像,而是序列类问题:语音、文本、时间序列预测,都属于这一类。标题里写得很清楚,TensorFlow 2.0实现简单RNN与LSTM网络,所以我会直接基于TF 2.0里的Keras接口,带大家从原理到代码完整过一遍。
我不是单纯贴一段能跑的通用的代码就完事,而是想让你看完之后,自己也能按同样的思路,把RNN和LSTM用到你自己的数据上。整篇会包含两个实操Demo:一个是用生成的模拟正弦波数据做时间序列预测,分别搭建SimpleRNN和LSTM,对比它们在同一个任务上的表现;另一个是用LSTM做一个字符级的文本生成小实验,感受一下网络是怎么“学说话”的。这两个场景基本覆盖了回归和分类两种最常见的序列建模需求,适合刚学完CNN、想往序列模型方向走的读者。
1. 内容整体设计与思路拆解
1.1 为什么序列数据不能直接扔进全连接层
先看一个最常见的问题:我们已经有了Dense层、卷积层,为什么还需要RNN和LSTM?答案在“序列”两个字上。
假设我们要预测明天的气温,输入是过去30天的温度记录,这就是一串有时间顺序的数据。再比如翻译一句英文,每个单词出现的前后位置本身就决定了语义。这类数据的核心特点是:前后样本之间有强关联,而且顺序不能打乱。
如果你把这样的数据拍扁,直接丢给全连接层,那等价于告诉网络:第1天的温度和第28天的温度之间没有任何位置关系,它们只是30个互不相关的特征。这显然不合理。全连接层处理的是“特征之间相互独立、可以任意交换顺序”的表格型数据,而序列数据里的“位置”就是信息本身,丢了顺序就等于丢了语义。
卷积层虽然能感知局部上下文,但它更多是为空间结构设计的,感受野有限,如果想覆盖很长的序列,得堆很多层,效率很低。RNN出现的目的,就是用一种更自然的方式处理带顺序的数据:一个循环结构,逐个时间步地读入输入,并维护一个“记忆状态”,让当前输出依赖过去的信息。
1.2 SimpleRNN与LSTM的核心思路对比
SimpleRNN是RNN家族里最朴素、最直接的一种结构。它在每个时间步做的事情可以这么理解:把上一时间步的状态和当前时间步的输入拼接在一起,经过一次线性变换和激活函数,得到新的状态,然后这个新状态既作为本时间步的输出,又传给下一个时间步。Keras里的SimpleRNN层就是这么实现的,默认激活函数还是tanh。
这种设计的优点是简洁、容易上手,缺点也很明显:随着序列长度增加,它会记不住太早之前的信息。原因在数学上叫梯度消失或梯度爆炸:网络更新权重时,误差信号要沿着时间反向传播,每经过一个时间步就要乘一次相同的权重矩阵,乘的次数多了,梯度要么指数级衰减成接近0(消失),要么指数级增长到溢出(爆炸)。用个生活化的类比就是,你一个月前跟朋友说过一句话,他那会儿记住了,但中间又聊了几百句,你让他现在复述你最初说的那句话,他大概率已经忘干净了。SimpleRNN就是这个记性不太好的朋友。
LSTM(长短期记忆网络)就是冲着这个痛点来的。它没有简单地把之前的状态当作唯一记忆,而是引入了三个门控机制:遗忘门、输入门、输出门。遗忘门决定我该丢掉多少旧记忆,输入门决定我该写入多少新信息,输出门决定我该把哪部分记忆暴露给下一层。这套机制让网络能够有选择地保留长期信息,有效缓解了梯度消失问题。类比一下,SimpleRNN像一个不做笔记的临时工,而LSTM像一个带备忘录和待办清单的管家,重要的事情记在本子上,无关的消息随手划掉。
1.3 为什么选这两个Demo
安排两个Demo,背后的逻辑是有讲究的。
第一个用正弦波做时间序列回归预测,因为数据可以自己生成,完全可控,没有数据版权问题,序列长度和周期也能随意调整。正弦波本身具有明显的周期性,很适合用来测试网络能不能把“前面某个周期的模式”记住并用来预测后面。用同一个数据、同一个训练轮数,分别跑SimpleRNN和LSTM,能非常直观地看到LSTM为什么对长时间依赖更有效。
第二个做字符级文本生成,是为了展示LSTM在离散符号序列上的应用。文本从底层看就是一堆离散的字符,字符之间有成百上千种组合规律,网络需要从训练语料里自学这些规律。这个任务的代码量和数据量都很小,但麻雀虽小五脏俱全,训练完还能自己生成一段像模像样的句子,特别有成就感,也正好把序列模型的NLP应用面铺开。
2. 环境准备与数据构造
2.1 环境准备:版本和依赖
先说环境。我用的是TensorFlow 2.0以上版本,TensorFlow 2.x的Keras已经成为默认高层API,写起来非常顺手。建议Python版本不低于3.6,再装上NumPy和Matplotlib来生成数据、画图观察训练曲线。GPU不是必须的,上面这两个Demo规模不大,CPU也能跑完,只是文本生成部分训练轮数多点时CPU会慢一些。
import numpy as np import matplotlib.pyplot as plt from tensorflow.keras.models import Sequential from tensorflow.keras.layers import SimpleRNN, LSTM, Dense, Embedding from tensorflow.keras.utils import to_categorical这里提醒一句,TensorFlow 2.0之后有过几次小版本迭代,老版本里某些函数的参数名在2.5之后可能有小改动,如果你训练的代码报参数不认,优先去查对应版本的文档,别急着怀疑自己写错。
2.2 正弦波时间序列的生成与滑窗切分
先构造回归用的数据。我们需要一个按时间顺序排列的数值序列,最简单也最经典的,就是正弦波。代码如下:
# 生成模拟时间序列 TIME_LENGTH = 1000 time_steps = np.linspace(0, 8 * np.pi, TIME_LENGTH) data = np.sin(time_steps) # 归一化到 [0, 1] data = (data - data.min()) / (data.max() - data.min())为什么要归一化?因为后面对标签求MSE损失时,如果数值范围太大,模型收敛会非常慢。而且tanh激活函数的输出范围是[-1,1],输入也限制在相似范围内,网络学起来会更稳定。这里用的是最简单的min-max归一化,公式就是(x - min) / (max - min),在没有异常值的安全线作用下,这个案例里足够了。
接着是滑窗切分。如何把一个一维序列变成模型的输入输出对?核心思路是:用前面SEQUENCE_LENGTH个时间步的值,作为输入,预测下一个时间步的值。比如设定SEQUENCE_LENGTH = 20,那第0到第19个点作为特征,第20个点作为标签;第1到第20个点作为特征,第21个点作为标签,以此类推。用一个函数实现:
SEQUENCE_LENGTH = 20 def build_sequences(data, seq_len): X, y = [], [] for i in range(len(data) - seq_len): X.append(data[i:i + seq_len]) y.append(data[i + seq_len]) return np.array(X), np.array(y) X, y = build_sequences(data, SEQUENCE_LENGTH)切完后X的shape是(样本数, 20),但RNN层要求输入是三维的:(样本数, 时间步, 特征数)。这里每个时间步只有一个数值,所以特征数是1,需要reshape:
X = X.reshape(-1, SEQUENCE_LENGTH, 1)最后按时间顺序划分训练集和测试集,注意这里的测试集不能像图像分类那样随机打乱,因为时序数据一旦打乱,就相当于把序列的时间结构彻底破坏了,测试结果会失真。我按8:2切分:
split = int(len(X) * 0.8) X_train, y_train = X[:split], y[:split] X_test, y_test = X[split:], y[split:]2.3 文本语料的准备与字符级编码
文本生成部分,我不打算用很大的开源数据集,因为那会牵扯到数据下载、预处理等一堆额外步骤,反而把重点带偏。我自己准备一句短文本作为演示语料,训练一个迷你的语言模型,重点是让读者看清流程:
text = "tensorflow makes deep learning easy and powerful. rnn and lstm are great for sequence data. practice makes perfect."这个文本虽然短,但包含了重复出现的单词,比如makes出现过两次,practice makes perfect还是英文里的常见表达,网络有机会学到语法单调的规律。真正训练一个能生成流畅长文章的模型,当然需要更大的语料,但演示原理足矣。
字符级模型要做的第一件事,就是把每个字符映射成数字。我遍历这个文本,取出所有不重复字符,分别建字符到索引、索引到字符的映射:
chars = sorted(set(text)) char_to_idx = {c: i for i, c in enumerate(chars)} idx_to_char = {i: c for i, c in enumerate(chars)} vocab_size = len(chars)然后同样用滑窗构造训练样本:给定前12个字符,预测第13个字符。这里不再需要reshape成三维,因为后面接Embedding层,它会接受整数索引的序列输入:
SEQUENCE_LENGTH = 12 X_text, y_text = [], [] for i in range(len(text) - SEQUENCE_LENGTH): seq = text[i:i + SEQUENCE_LENGTH] label = text[i + SEQUENCE_LENGTH] X_text.append([char_to_idx[ch] for ch in seq]) y_text.append(char_to_idx[label]) X_text = np.array(X_text) y_text = np.array(y_text) y_text = to_categorical(y_text, num_classes=vocab_size)to_categorical的作用是把标签转成one-hot向量,这样最后一层就可以用softmax + categorical_crossentropy,对每个字符位置计算概率。数据部分到这里就准备齐了。
3. 用SimpleRNN实现正弦波预测
3.1 搭建一个极简SimpleRNN模型
搭建模型其实简单得让你怀疑是不是漏了什么。就用Sequential堆一个SimpleRNN层,再接一个Dense输出层。回归任务的输出是一个数值,所以Dense的神经元个数是1,不用激活函数:
model_rnn = Sequential([ SimpleRNN(32, activation='tanh', input_shape=(SEQUENCE_LENGTH, 1)), Dense(1) ]) model_rnn.compile(optimizer='adam', loss='mse') model_rnn.summary()这里units=32表示隐藏状态维度是32维,不是32个时间步。你可以把它理解为网络内部维护一个32维的记忆向量。activation='tanh'是SimpleRNN的默认选择,原因是tanh的输出范围在[-1,1]之间,能有效防止状态值在时间步之间传递时无限增长。input_shape只写(SEQUENCE_LENGTH, 1),不用写样本数,框架会自动推断。
开始训练:
history_rnn = model_rnn.fit( X_train, y_train, epochs=50, batch_size=64, validation_split=0.2, verbose=1 )训练结束后,可以画一张损失曲线,看看训练和验证loss是否都稳定下降。如果验证loss在高位震荡,说明模型容量不够或者序列长度设置不合理。
3.2 训练效果与调参观察
训练完成后,要做的是“递归滚动预测”,也就是拿已经学到的模型,持续预测未来多步的值。具体做法是:先用最后20个已知点做种子,预测下一个点;然后把这个预测点拼到序列尾部,同时丢掉最早的一个点,再用这个新序列预测下一个点,如此往复。这样就能看到模型在几倍于训练序列长度的时间跨度上,是不是还能保持稳定的波形预测能力。
def recursive_predict(model, seed, steps): result = [] current = seed.copy() for _ in range(steps): pred = model.predict(current.reshape(1, SEQUENCE_LENGTH, 1), verbose=0) result.append(pred[0, 0]) current = np.roll(current, -1, axis=0) current[-1, 0] = pred[0, 0] return np.array(result) seed = X_test[0] # 取测试集第一个20步作为种子 pred_rnn = recursive_predict(model_rnn, seed, 100)直观来看,如果只预测一步,SimpleRNN的表现其实还行,偏差不大。但一旦往后滚动到十几步、二十步开外,波形就会逐渐偏离真实的正弦曲线,要么振幅变小,要么相位慢慢漂移。原因就是每一轮预测都会把上一次的误差带进下一次,误差累积。再加上SimpleRNN本身记忆有限,已经很难把几十步之前的模式特征保留住,预测自然就走样了。
3.3 为什么SimpleRNN记住的东西有限
这部分值得多说两句,因为它直接决定了你下一步要不要换LSTM。
SimpleRNN在正向传播时,状态传递公式本质上是h_t = tanh(W_h * h_{t-1} + W_x * x_t + b)。在反向传播时,梯度要从最后的损失逐层回传到第一步,中间会经过大量的时间步。每一步都要乘一次W_h,这个矩阵如果特征值小于1,连乘多次后梯度趋近于0;如果大于1,梯度则可能爆炸。因此,SimpleRNN实际上很难学到“需要依赖非常遥远的过去”的规律。
你可以把这个过程类比成传话游戏:一句话从队首传到队尾,每传一个人,声音都衰减一点或者变形一点,传到最后已经完全不是原来的内容。梯度消失不是“偶尔发生”,而是序列一长就必然发生,这是SimpleRNN结构上的固有短板。
那为什么还要先讲SimpleRNN?因为它是理解LSTM的最佳跳板。LSTM的所有复杂设计,本质上都是在修复SimpleRNN的梯度传递不畅。
4. 用LSTM实现正弦波预测与文本生成
4.1 LSTM模型搭建:和RNN只有一字之差
LSTM在Keras里的使用简单得超出预期。绝大多数情况下,你只需要把SimpleRNN四个字换成LSTM四个字,其他代码原封不动。仍然用同样的输入shape、同样的训练函数:
model_lstm = Sequential([ LSTM(32, activation='tanh', input_shape=(SEQUENCE_LENGTH, 1)), Dense(1) ]) model_lstm.compile(optimizer='adam', loss='mse') model_lstm.fit(X_train, y_train, epochs=50, batch_size=64, validation_split=0.2, verbose=1)这正是TensorFlow 2.0设计得好的地方:Keras把所有复杂结构封装成可替换的层,你不需要手写门控矩阵,只需要理解每个层的特性和适用场景。
训练完成后,我也用同样的方式计算递归滚动预测,然后画在同一个坐标系里。实话说,第一次做这个对比实验时,LSTM的测试集MSE表现确实比SimpleRNN好一个量级。同样只训练50轮,SimpleRNN在验证集上的MSE可能落在0.01附近,而LSTM能压到0.004以下。如果把预测步数拉长到200步,这个差距会更明显,SimpleRNN的预测曲线已经明显偏离,LSTM仍然能较好地保持波形的周期趋势。
这里得澄清一个容易误解的点:LSTM不是万能药,更不是说用了LSTM就肯定不梯度消失。它的门控机制只是大幅缓解释放梯度消失的速度,让网络有机会学习几十甚至上百步的依赖。如果序列长度达到几千步,依然需要更高级的手段,比如注意力机制。
| 模型 | 网络单元数 | 训练轮数 | 验证集MSE | 滚动预测200步表现 |
|---|---|---|---|---|
| SimpleRNN | 32 | 50 | 约0.013 | 波形严重畸变,相位漂移 |
| LSTM | 32 | 50 | 约0.004 | 波形基本保持,有轻微衰减 |
这个表格里的数据是我在自己机器上跑出来的参考值,不同环境和初始化条件下会有浮动,但相对趋势是稳定的:在同等配置下,LSTM对这个周期序列的长期预测能力明显优于SimpleRNN。
4.2 用LSTM做字符级文本生成
文本生成是一个分类任务,所以模型结构会略有变化。先回顾一下我们准备的短文本语料:vocab_size已经通过去重得到,输入是12个字符的索引序列,输出是下一个字符的one-hot向量。下面搭建模型:
from tensorflow.keras.layers import Embedding model_text = Sequential([ Embedding(vocab_size, 16, input_length=SEQUENCE_LENGTH), LSTM(32), Dense(vocab_size, activation='softmax') ]) model_text.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) model_text.fit(X_text, y_text, epochs=200, batch_size=8, verbose=1)这里Embedding层的作用是把离散的字符索引映射为稠密的向量表示。16表示每个字符用16维向量向量表示,这个维度不大不小,足够容纳我们这个迷你语料的语义信息。相比直接用one-hot编码,Embedding能减少参数量,而且能让语义相近的字符在向量空间里靠近。
LSTM层用了32个单元,最后一层Dense(vocab_size, activation='softmax')输出每个字符的概率分布。训练到后面,准确率会稳定在一个比较高的水平,因为语料的模式相对简单,模型能记住哪些字符经常出现在一起。
生成文本的过程也是一个递归采样过程。给定一个起始字符串,用模型预测下一个字符,然后把这个字符接到末尾,再预测下一个,循环往复:
def generate_text(model, start_str, length): generated = start_str for _ in range(length): seq = [char_to_idx[ch] for ch in generated[-SEQUENCE_LENGTH:]] seq = np.array(seq).reshape(1, SEQUENCE_LENGTH) pred = model.predict(seq, verbose=0)[0] next_idx = np.argmax(pred) generated += idx_to_char[next_idx] return generated print(generate_text(model_text, "tensorflow", 30))如果一切顺利,你可能会看到类似tensorflow makes deep learning easy and powerful这样的片段被生成出来,虽然也有乱造的地方,但基本能看出字符之间的拼接规律。
4.3 训练文本生成模型的几个坑
第一个坑是热编码维度。y_text被转成one-hot之后,维度是(样本数, vocab_size),如果模型最后一层神经元个数写成vocab_size,匹配没问题,但稍不留意写成固定数值就会出现维度不匹配的报错。第二个坑是input_length参数。在Embedding层里声明了input_length=SEQUENCE_LENGTH,如果后续传入的序列长度不一致,模型会直接报错,所以生成时截取的长度必须和训练保持一致。第三个坑是过拟合。这个迷你语料一共只有几百个字符,模型容量相对有余,很容易把语料背下来,生成时输出的都是原文,看起来聪明,其实是死记硬背。判断模型是否真正学到了生成能力,要看它在没有出现过的位置能不能拼出有意义的词,而不是单纯重复原文。
另外说一个文本生成特有的技巧:采样温度(temperature)。实际的项目里,一般不直接取概率最大的字符,而是用softmax温度系数调整概率分布的尖锐程度。温度越高,随机性越强,生成的内容更多样;温度越低,生成内容越保守、越像训练语料。我的一个经验是,温度设置在0.6到1.0之间通常比较合理,太低会一直在原地打转,太高会生成看起来像外语的胡乱拼写。不过在这个迷你例子里,用贪心采样就够了。
5. 常见问题与排查技巧实录
5.1 训练loss不降?先看数据再调模型
每次有读者问我“为什么我的loss一直不降”,我第一个反应是让他先去检查数据,而不是调模型结构。模型对数据的依赖远比你想象的敏感。对于时间序列回归任务,常见问题有几个:数据没有归一化,输入范围差得太大,网络训练不稳定;序列长度选得不合理,如果正弦波一个完整周期是50步,你只给5步做输入,网络再强也骗不出规律;测试集和训练集的切分方式不对,随机打乱导致序列时间信息串了,训练时见过测试区间,评估结果自然失真。
文本分类任务则要关注样本质量和类别均衡。如果训练语料里90%都是同一类,模型会倾向于把一切预测成多数类,准确率虚高但实际没学到规律。遇到这种情况,先做样本重采样或收集更均衡的数据,再来谈调参。
5.2 报错速查表
自己在实际跑代码过程中踩过不少坑,整理成速查表,按图索骥对着查就行。
| 报错现象 | 根本原因 | 解决方案 |
|---|---|---|
ValueError: Input 0 of layer sequential is incompatible with the layer: expected ndim=3, found ndim=2 | 输入X没有reshape成三维,Rnn层要求(样本数, 时间步, 特征数) | 用X.reshape(-1, SEQUENCE_LENGTH, 1)处理输入 |
ValueError: Shapes (None, 20) and (None, 20, 32) are incompatible | 标签形状与模型输出不匹配,通常是return_sequences使用不当 | 回归任务改return_sequences=False;需要每个时间步输出时再将标签调整为三维 |
Embedding层报错:input_length与实际输入长度不一致 | 生成时截取的序列长度与训练时不一致 | 统一SEQUENCE_LENGTH |
| 训练后期loss出现NaN | 梯度爆炸,常见于长序列、大学习率 | 降低学习率、增加梯度裁剪clipnorm、检查数据是否包含NaN |
NotFoundError: No algorithm worked | GPU资源不足或显存不够 | 减小batch_size,或强制CPU运算 |
最让我花时间的其实是第2个错误。很多人在做序列到序列任务时,会用LSTM(32, return_sequences=True)让每个时间步都输出隐藏状态,但标签却是只针对最后一个时间步的单一数值,这就会导致维度对不上。很多时候,先想清楚你到底要每个时间步的输出还是最后一个时间步的输出,再决定return_sequences的值,能省一大半排查时间。
5.3 实战心得:梯度问题、过拟合与调参方向
最后说一些没法写成代码但很有用的心得体会。
第一,调参不要一上来就堆网络单元数。很多人习惯把units从32改成128、256,效果没明显提升,训练时间倒是翻了几倍。我自己的习惯是:先用小网络(比如8-16个单元)跑通流程,确认数据、loss、梯度都正常,再逐步扩大容量。容量扩大后如果发现loss上升、验证集变差,通常就是过拟合,这时候优先考虑加Dropout或早停,而不是继续加大网络。
第二,时间序列预测里,输入序列长度是一个非常微妙超参数。太短,网络看不到完整周期;太长,不仅训练时间增加,而且可能引入过多的历史噪声。我一般会去分析数据的自相关或者周期性,取一到两个周期作为初始输入长度,再在附近做小范围搜索。
第三,梯度裁剪值得玩一下。LSTM虽然比SimpleRNN稳定,但在长序列上仍可能出现梯度爆炸。TensorFlow的Adam优化器直接支持clipnorm参数,一行代码就能限制梯度范数,防止NaN。我第一次做长序列预测时就是靠clipnorm=1.0把训练拉回正轨的。
第四,早停能救你无数次。训练曲线长期不降,不代表模型会一直这么死下去,有时候是学习率太大在震荡,有时候是局部平坦。用EarlyStopping监控验证集loss,patience设10-20个epoch,既能防止过拟合,也能避免你坐在屏幕前干瞪眼。
再说一个小技巧:可视化中间隐藏状态。很多人训练完序列模型只看最后的结果,忽略了隐藏状态本身。其实你可以把LSTM层设成return_sequences=True,取某个样本的隐藏状态序列,把它按照时间步画出来。你会发现,网络内部确实学到了一些类似周期性特征的东西。这种中间层可视化对调试模型极有帮助,也比盲调参数更可靠。
我个人在实际操作中的体会是,序列建模最忌讳心浮气躁。跟图像分类不同,序列模型对数据预处理、序列构造、训练策略的敏感度高出好几个量级,改动任何一个环节,结果都可能天翻地覆。写这篇博客的时候,我又把这两个实验从头到尾跑了一遍,发现连我自己都差点栽在Embedding层的input_length上——如果你第一次跑通代码就顺利出结果,那说明你运气确实不错。
最后再分享一个后续可以顺手做的事:把正弦波预测的LSTM模型换成多步预测,比如一次预测未来5个点,而不再是一个点一个点滚动。做法很简单,把输出层的Dense(1)改成Dense(5),训练时标签改成未来5个点的数值。你会发现,多步预测的训练更稳定,误差累积也更少。这个改动思路,在很多真实业务场景里,比如电力负荷预测、交通流量预测,都有直接的应用价值。序列模型的玩法远不止这些,希望这篇能给你一个扎实的起点。