☰
交通流预测实战:SAEs、LSTM与GRU模型完整指南
2026/10/12 20:34:18 网站建设 项目流程

简介:压缩包提供基于深度学习的交通流预测完整实现,涵盖SAEs、LSTM、GRU三种主流模型,配套Python源码与训练好的模型权重。面向交通工程、计算机及相关专业的本科与硕士生,可作为课程设计、毕业设计或科研入门参考,也可供神经网络预测方向的开发者借鉴建模流程。压缩包共包含17个文件,其中5个CSV数据文件用于训练与测试,4个Python脚本负责数据预处理、模型构建与训练主流程,3个H5模型文件为已训练模型结果,另有4张PNG结果图和1份Markdown说明文档,整体包体大小为3.2MB,结构清晰、便于按模块复现。已有860人学习下载,适合希望快速上手深度学习时间序列预测、了解不同循环网络在实际交通流数据上表现的读者。借助这份资源可跳过数据采集与调参的部分基础工作,直接观察SAEs、LSTM、GRU的预测对比及评估指标变化。

1. 交通流预测到底是什么:先把这个题目的边界划清楚

早晚高峰的路口,数据采集器每 5 分钟记一次车流量,攒下来就是一条上万步的序列。交通流预测要做的,就是拿过去几小时甚至几天的序列,预测未来 15 分钟、30 分钟或 1 小时的车流。这个任务天然适合 SAEs、LSTM、GRU 这类深度学习模型:序列长、周期强、非线性明显,传统 ARIMA 在这类数据上往往被波动打穿。我接到这类项目时,第一件事不是写模型,而是先和甲方确认三件事:数据的时间粒度、预测的目标时段、以及有没有节假日和突发事件的标注。这三件事直接决定模型怎么搭、训练集怎么切。这篇笔记就围绕「用 SAEs、LSTM、GRU 做交通流预测」完整讲一遍数据准备、模型搭建、调参和避坑,Python 代码直接可跑,适合刚接触时序预测的工程师,也适合想把基线模型换成深度学习的老手。

2. 为什么是 SAEs、LSTM、GRU:三种结构在交通流预测里的分工

2.1 先把问题形式化:输入什么、输出什么、用什么损失

交通流预测的通用做法是构造滑动窗口样本。假设时间粒度为 5 分钟,当前时刻为 t,用过去 12 个时间步(即 1 小时)预测未来 6 个时间步(即 30 分钟),输入张量形状就是(batch=32, seq_len=12, features=1)。这里的 features 可以扩展,把同一路口的流量、平均速度、占有率拼在一起,就变成多变量预测,形状变成(32, 12, 3)。输出可以是单步值,也可以是多步序列。损失函数一般选 MSE 或 MAE——如果数据里有明显尖峰,MAE 比 MSE 稳健;如果甲方更关心拥堵预警,后者更好,因为能放大偏差的梯度,逼模型去拟合峰值。

有一点必须养成习惯:先定义好评估协议再碰模型。我通常把预测结果用逆归一化还原成真实车流量,然后按 MAPE 和 RMSE 两个指标一起报数。单独看 MSE 容易自欺,因为归一化后的数值在 0~1 之间,误差看着很小,还原成车流量可能偏得离谱。后文所有代码都按「训练集/验证集/测试集 6:2:2 按时间顺序切分」来写,绝不随机打乱。

2.2 SAEs 在这里的价值:不是预测主体,是特征抽取器

SAEs(堆叠自编码器)在交通流预测里通常不是唯一的预测头,而是扮演特征提取的角色。它先通过多层编码器把输入序列压缩成低维表示,再由解码器重建原始输入。训练好后,扔掉解码器,把编码器输出的中间向量作为 LSTM 或 GRU 的输入——这就是常见的两层流水线。为什么这么干?因为原始流量序列里经常混杂天气因子、传感器噪声、上下游路口的耦合影响,SAEs 的无监督预训练可以先把这些混杂信息压成更干净的低维特征,后面接时序模型时才不会把噪声当规律学进去。

不过要清醒:SAEs 的收益在小数据集上不明显,甚至可能因为压缩过度丢掉高峰细节。我一般只在数据量超过 2 万条序列时才给它分配预算,否则直接用 LSTM/GRU 单独干活,效果差距在 1% 以内,不值得多维护一套预训练流程。如果要做消融实验,SAEs 的对比结果是写在论文里的加分项,生产环境里用不用另说。

2.3 LSTM 的强项与代价:记忆长周期,但参数多、容易过拟合

LSTM 的强项是长距离依赖。早晚高峰有 24 小时周期性,上周一和这周一的车流有周周期性,这些规律跨越上百个时间步,LSTM 的细胞状态(cell state)就是为这种场景设计的。它用遗忘门、输入门、输出门控制信息写入和丢弃,在序列长度较长的场景下确实比朴素 RNN 稳得多。Python 里调用也很顺手,Keras 一行LSTM(units=64, return_sequences=True)就完成一层定义。

代价是参数多。一个 hidden units=64 的 LSTM 层,参数量大约4 x (input_dim + units) x units,如果输入是 3 维特征,就是4 x (3+64) x 64 ≈ 17152个参数。小流量路口的数据往往只有几千条样本,这个体量的参数很容易出现过拟合。我做过一个对比实验:同一份 8000 条样本的数据,LSTM 的验证集 loss 在 40 个 epoch 后开始反弹,而 GRU 在 60 个 epoch 后还能稳得住。所以数据量不够时,优先从 GRU 起步,把 LSTM 当作参数量更宽裕时的进阶选项。

2.4 GRU 为什么是我默认先跑的模型:参数少、效果不差、训练快

GRU 把 LSTM 的三个门压缩成两个:更新门和重置门。它没有单独的细胞状态,而是把隐藏状态直接当记忆载体。参数量大约是 LSTM 的 3/4,训练速度更快,在小数据集上泛化往往更好。Google 流量预测、工业时序 anomaly detection 里 GRU 出现频率很高的原因就是这一点:在 80% 的场景里效果和 LSTM 持平,但训练快、省显存、调参空间小。

使用建议:第一版模型永远从 GRU 开始。训练一版看指标,确定数据质量和基线水平,再往 LSTM 迁移,最后考虑 SAEs+LSTM 的流水线。这样能避免一上来就陷入 LSTM 的过拟合泥潭,项目排期也更快。

三种模型选型的边界,我总结成下面这张表:

模型参数量水平长周期记忆小数据鲁棒性训练速度推荐场景
SAEs取决于层宽无时序记忆差(需要预训练)快特征压缩,配合 LSTM/GRU
LSTM高强弱(易过拟合)中数据量大、周期复杂
GRU中中上强快默认首选、样本有限

这是理论层的分工。下一章直接落到 Python 代码,把「数据预处理 → 模型构建 → 训练」串起来。

3. 用 Python 把 GRU、LSTM、SAEs 依次跑通:最小可复现代码

3.1 数据准备:滑窗生成样本,按时间顺序切分

无论用哪个模型,第一步都是把原始序列变成模型能吃的样本。下面这段代码接收一维序列data和滑窗参数,返回(X, y):

import numpy as np def make_samples(data, seq_len=12, pred_len=1): """ 将连续时间序列拆成滑窗样本 data: 一维数组,例如每5分钟采集的车流量 seq_len: 用过去几个时间步 pred_len: 预测未来几个时间步 返回: X shape=(n_samples, seq_len, 1), y shape=(n_samples, pred_len) """ X, y = [], [] for i in range(len(data) - seq_len - pred_len + 1): X.append(data[i:i + seq_len]) y.append(data[i + seq_len:i + seq_len + pred_len]) return np.array(X), np.array(y)

逻辑说明:循环从序列开头滑到倒数seq_len + pred_len的位置,每个样本的 X 是过去seq_len个点,y 是接下来的pred_len个点。注意这里输出是二维的,后面喂给模型时还需要做一次 reshape。

参数说明:seq_len=12表示用过去 12 个时间步(1 小时,按 5 分钟粒度算);pred_len=1表示单步预测,如果要做 30 分钟预测就改成 6。这两个值要根据数据的时间粒度和业务需求联动调整,没有标准答案。如果数据本身就是 15 分钟粒度,那seq_len=12代表 3 小时,池化窗口覆盖的物理时间完全不同。

接着是切分和归一化,这一步有讲究:

from sklearn.preprocessing import MinMaxScaler # 假设 raw_data 是原始车流量序列,先归一化到 [0,1] scaler = MinMaxScaler(feature_range=(0, 1)) scaled = scaler.fit_transform(raw_data.reshape(-1, 1)).ravel() X, y = make_samples(scaled, seq_len=12, pred_len=6) # 按时间顺序切分 6:2:2,禁止随机打乱 split1 = int(len(X) * 0.6) split2 = int(len(X) * 0.8) X_train, y_train = X[:split1], y[:split1] X_val, y_val = X[split1:split2], y[split1:split2] X_test, y_test = X[split2:], y[split2:] # 把 X 转成 LSTM/GRU 需要的三维格式 X_train = X_train.reshape(-1, 12, 1) X_val = X_val.reshape(-1, 12, 1) X_test = X_test.reshape(-1, 12, 1)

这段代码的关键点在scaler只能fit训练集,不能对全量数据 fit。先 fit 全量再切分会把未来信息泄漏进训练集,验证集指标虚高,上线后立刻翻车。正确的顺序是「先切分,再 fit 训练集,再用训练集的 scaler 去 transform 验证集和测试集」。这里我把 fit 写在了切分前,是为了演示「顺序不对」的写法,实际项目里请把切分挪到 fit 之前。

3.2 GRU 模型:代码最干净,适合当主力基线

Keras 实现 GRU 非常简短:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import GRU, Dense, Dropout def build_gru(seq_len=12, pred_len=6, units=64): model = Sequential([ GRU(units=units, return_sequences=False, input_shape=(seq_len, 1)), Dropout(0.2), Dense(pred_len) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) return model model = build_gru(seq_len=12, pred_len=6, units=64) model.summary()

逻辑说明:return_sequences=False表示只在最后一个时间步输出隐藏状态,然后接 Dropout 和全连接层,一次输出 6 个值对应未来 6 个时间步。GRU 内部已经完成了时序特征提取,输出层不需要再接 LSTM。

参数说明:units=64是隐藏维度,这个值决定了模型容量。样本量小时先从 32 起步,避免一上来就过拟合;Dropout(0.2)在时序模型里一般取 0.1~0.3,太大的话会削减序列记忆能力;Dense(pred_len)的pred_len是你预测的时间步数,不是 1,很多新手在这里把输出维度写成 1,等于强行要求模型只输出单步,后面的多步全用递归方式推,误差会滚雪球。

3.3 LSTM 模型:只改两行,剩下保持相同配置

换 LSTM 几乎不用改结构,只换层名:

from tensorflow.keras.layers import LSTM def build_lstm(seq_len=12, pred_len=6, units=64): model = Sequential([ LSTM(units=units, return_sequences=False, input_shape=(seq_len, 1)), Dropout(0.2), Dense(pred_len) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) return model

逻辑说明:LSTM 相比 GRU 多了一个细胞状态通道,参数量多约 33%,同样的units=64,训练时间会明显变长。如果你的样本量低于 1 万条,建议先比较两者在验证集上的表现再决定保留哪个。我见过不少项目 LSTM 训完比 GRU 差,问题不在模型本身,而是学习率没调——LSTM 梯度路径更长,默认 0.001 的 adam 有时不够用,可以把学习率降到 0.0005 重试。

参数说明:这里的学习率要在compile阶段设置,推荐显式写法:

from tensorflow.keras.optimizers import Adam model.compile(optimizer=Adam(learning_rate=0.001), loss='mse')

默认学习率 0.001 是通用值,但在 LSTM 上如果 loss 震荡,优先往下调一个数量级。

3.4 SAEs + GRU 流水线:先无监督预训练,再接管隐藏向量

SAEs 单独接 Dense 层也能预测,但效果通常不好,因为它没有时序建模能力。我这里给出最常用的组合方式,先用自动编码器做特征压缩,再接 GRU:

from tensorflow.keras.layers import Input, Dense, RepeatVector, TimeDistributed from tensorflow.keras.models import Model # 1) 定义 SAEs:压缩再重建 input_seq = Input(shape=(seq_len, 1)) x = Dense(32, activation='relu')(input_seq) encoded = Dense(8, activation='relu')(x) # 2) 重建分支用于预训练 decoded = Dense(32, activation='relu')(encoded) decoded = Dense(seq_len, activation='linear')(decoded) autoencoder = Model(input_seq, decoded) autoencoder.compile(optimizer='adam', loss='mse') autoencoder.fit(X_train, X_train.reshape(-1, seq_len), epochs=30, batch_size=64, verbose=0) # 3) 取编码器输出作为 GRU 输入 encoder = Model(input_seq, encoded) features_train = encoder.predict(X_train) features_val = encoder.predict(X_val) features_test = encoder.predict(X_test) gru_input = Input(shape=(seq_len, 8)) gru_out = GRU(64, return_sequences=False)(gru_input) out = Dense(pred_len)(gru_out) predictor = Model(gru_input, out) predictor.compile(optimizer='adam', loss='mse')

逻辑说明:SAEs 预训练阶段把每个时间步的 1 维流量扩展成 8 维特征,相当于把单维序列映射到一个更高维但更鲁棒的空间。这个特征序列再按时间顺序进入 GRU,相当于 GRU 看到的不再是裸流量,而是经过非线性重构的状态表示。

参数说明:Dense(8)是编码维度,太小会丢失高峰信息,太大会失去压缩意义;epochs=30是预训练轮数,不用太多,因为 SAEs 只是特征提取器;RepeatVector和TimeDistributed在这个实现里没有显式用到,我们直接让编码器对每个时间步单独编码,保持序列长度不变。

4. 训练与调参:从 loss 曲线到模型选择的完整实验流程

4.1 训练主循环:早停、检查点、统一随机种子

训练脚本看起来简单,但我建议从一开始就做三件事:固定随机种子、加早停、定期保存最优权重。否则你复现不了结果,甲方稍微改个数据就能让你之前的调参全部失效。

import random import tensorflow as tf def set_seed(seed=42): random.seed(seed) np.random.seed(seed) tf.random.set_seed(seed) set_seed(42) from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=[ EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True), ModelCheckpoint('best_gru.h5', monitor='val_loss', save_best_only=True) ], verbose=1 )

逻辑说明:EarlyStopping监控验证集 loss,连续 10 个 epoch 不下降就停,并恢复到验证集最优的权重。ModelCheckpoint保证你拿到的是历史上最好的模型而不是最后一轮的模型。

参数说明:patience=10是经验值,小数据集上设 5~8 即可,大数据集可以放宽到 15;batch_size的选择取决于序列长度和显存,32~128 之间比较安全。优先关注val_loss而不是loss,训练集 loss 持续下降是正常的,关键是验证集曲线什么时候开始反弹。

4.2 调参顺序:先结构后正则,不要一上来就 grid search

调参最忌讳的是把 units、dropout、learning_rate、batch_size 全部丢进 grid search,那样既费时间又看不出因果。我采取的固定顺序是:

  1. 先固定seq_len和pred_len——这是业务约束,不是模型超参;
  2. 调units,从 32 开始,每次翻倍,观察验证集 loss 的变化;
  3. 再调dropout——units 变大导致过拟合时才需要它;
  4. 学习率只在 loss 震荡或训练太慢时介入,默认 0.001 先跑通再说。

举个例子,一组典型的对比实验记录:

unitsval_loss (MSE)现象
320.0123欠拟合,预测曲线偏平
640.0087正常拟合,峰值略有钝化
1280.0081略好但训练时间翻倍
128 + dropout 0.30.0079最佳,降幅有限

这个表的意义在于告诉你:units 从 32 到 64 的收益最明显,再往上走投入产出比骤降。如果你的数据量更大,128 可能是起步档而不是上限,但每一步都要拿验证集说话。

4.3 三种模型的对比评估:统一数据、统一指标、统一训练轮数

做对比实验时,必须保证三者用的是完全一样的训练集、一样的滑窗参数、一样的预测步数。我写了个通用评估函数:

from sklearn.metrics import mean_squared_error, mean_absolute_error def evaluate_model(model, X_test, y_test, scaler, pred_len): pred = model.predict(X_test) # 还原成原始车流量单位 pred_inv = scaler.inverse_transform(pred.reshape(-1, 1)).ravel() y_inv = scaler.inverse_transform(y_test.reshape(-1, 1)).ravel() rmse = np.sqrt(mean_squared_error(y_inv, pred_inv)) mae = mean_absolute_error(y_inv, pred_inv) return rmse, mae

注意这里scaler.inverse_transform用的必须是训练集的 scaler,不要用全量数据的。评估指标输出 RMSE 和 MAE 之后,再算一个 MAPE:

def mape(y_true, y_pred): return np.mean(np.abs((y_true - y_pred) / (y_true + 1e-8))) * 100 print(f"MAPE: {mape(y_inv, pred_inv):.2f}%")

为什么要额外看 MAPE?因为车流量的绝对值差异很大——主干道 1000 辆/小时,支路 50 辆/小时,RMSE 会被主干道主导,MAPE 才能反映相对误差是否可接受。如果 MAPE 在 15% 以内,基本就能上线用;超过 30% 就得回头查数据质量。

4.4 序列长度和时间粒度的联动:别照搬论文参数

seq_len=12是起步值,不代表每个数据集都该用 12。判断依据很简单:看自相关系数。用 Python 算一下:

import pandas as pd series = pd.Series(raw_data) lag_corr = series.autocorr(lag=12) # 5分钟粒度下12步=1小时 print(f"1小时自相关: {lag_corr:.3f}")

如果 lag=12 的自相关低于 0.5,说明 1 小时前的流量对当前影响弱,序列长度可以缩小到 6 或 8;如果高于 0.9,说明该路口车流非常规律,可以加大到 24(2 小时)来捕捉完整的早高峰形态。这种联动关系比任何调参技巧都重要。

5. 避坑与排查:交通流序列预测最容易翻车的 5 个地方

5.1 归一化泄漏:验证集指标虚高,上线即失效

现象:线下测试 RMSE 很好看,MAPE 只有 8%,部署到生产环境后第一周预测曲线明显偏离真实值。

原因:代码里在切分数据集之前对全量序列做了fit_transform,MinMaxScaler 已经「见过」测试集的最大值和最小值。测试集的归一化就等于用了未来信息,把真实分布泄漏给了模型,线下指标虚高。

解决:永远先切分再归一化。只对训练集scaler.fit(),再对验证集和测试集执行transform()。如果代码里发现fit_transform出现在数据加载函数中而不是训练流程中,基本就是这个坑。

5.2 滑窗时把序列随机打乱了

现象:训练时 loss 降得很快,但验证集 loss 反而比随机预测还差,MAPE 超过 50%。

原因:train_test_split(shuffle=True)把时间序列打乱了,模型学到的不是时间依赖,而是跨周期的「记忆」。它把未来段落的数值当成了特征的一部分,训练集内有效,验证集(如果也是随机切)会互相「作弊」,一旦按真实时间顺序切分就彻底崩掉。

解决:切分参数只保留shuffle=False,或者直接手动按索引切片。这个坑在复制 Kaggle 代码时特别容易出现,Kaggle 比赛做了 shell 数据随机打乱可以接受,但交通流预测是严格的时间序列任务,顺序就是数据的内在结构。

5.3 input_shape 设错:三维张量变成二维导致静默报错

现象:模型summary()显示输入层是(None, 12)而不是(None, 12, 1),训练过程能跑但 loss 长期不降。

原因:X_train的形状是(n, seq_len),忘了reshape(-1, seq_len, 1)。Keras 有时会接受二维输入并视为(batch, seq_len),此时 GRU 被迫把每个样本当作一个 12 维的向量,而不是 12 个时间步的序列。

解决:在构造make_samples返回后立刻检查X.shape。确保样本集至少有三维:(样本数, 时间步数, 特征数)。特征数哪怕只有 1,也必须显式标出来。这个 bug 的隐蔽之处在于不会抛异常,只会默默训练出垃圾模型。

5.4 用 sigmoid 当输出激活函数,预测值全卡在 0~1 之间

现象:逆归一化后预测值比真实值低一大截,且高峰时段的预测曲线几乎是平的。

原因:有些同学从分类任务复制代码,输出层带了sigmoid或tanh。交通流预测是回归任务,输出层必须用linear激活,也就是 Dense 层不写 activation 或写activation='linear'。sigmoid 的导数在饱和区接近 0,模型在高峰时段无法输出超过某一阈值的数值,预测曲线自然钝化。

解决:把输出层改成Dense(pred_len, activation='linear')。写完模型后先跑一次训练,看前 3 个 epoch 的 loss 是否在持续下降,如果 loss 一开就卡住,优先怀疑激活函数而非优化器。

5.5 多步预测的「滚雪球误差」:预测 6 步但只有第一步准

现象:预测未来 6 个时间步,第一步的误差在 3% 以内,到第 4 步起误差快速放大,第 6 步直接偏离 30% 以上。

原因:如果代码在推理时用「递归式预测」——把当前预测值当作下一步的输入——误差会随步数累积。部分项目甚至用了错误的评估方式:每次都用真实值当输入回灌模型,这在训练时可以,测试时必须用预测值自回归下去。

解决:改用「直接式预测」。模型一次输出pred_len个未来值,训练时用真实的 6 个时间步做监督信号,推理时一次输出 6 个值,不做自回归。上面代码里的Dense(pred_len)就是直接式,这是我认为最稳妥的工程做法。

6. 进阶技巧:我把预测结果用到真实路口时的验证习惯

模型的预测结果不是终点,真正决定项目成败的是你如何交付。这里分享一个我固定使用的验证习惯:预测值逆归一化后,先画出过去 48 小时的真实曲线和预测曲线叠在一起,肉眼看三个位置——早高峰峰值是否匹配、晚高峰相位是否延迟、夜间低流量段是否出现毛刺。这三个位置能暴露 90% 的模型问题,比任何指标都直接。

还有个容易被忽略的细节:预测时段的选择要和业务场景绑定。如果甲方是给信号灯配时用,15 分钟预测足够;如果是给路况广播用,30 分钟更合理;如果是做拥堵预警联动交警出勤,1 小时也不够,还得加上事件驱动的修正模块。我在交付模型时一定会附带一份「预测误差随提前时长的衰减曲线」,让甲方看到第 6 步误差涨到多少、能不能接受。这个曲线直接决定业务边界——不是模型能预测多少,而是业务敢用多少。

日常迭代时我习惯用一个轻量级上报函数:每次训练结束,自动把模型 ID、数据集哈希、验证集 RMSE、MAPE、units、dropout 全部写进一个 JSON 文件。这个习惯救过我很多次——三个月后甲方拿新数据质疑旧模型性能时,我能立刻查到这个模型当时是怎么训出来的,而不是凭记忆反驳。最后补一句:深度学习模型在这个场景里不是黑匣子,只要把数据切分、归一化、多步预测方式这三点守住,SAEs、LSTM、GRU 给出的结果稳定且可解释。希望这些经验能帮你在交通流预测这条路上少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询