
简介这是一份基于LSTM神经网络与3层全连接层实现道路通行时间回归预测的源码及配套数据面向智慧交通、时空序列预测方向的开发者和学生。资源源自天池道路通行时间预测赛题从link属性均为不变量这一分析出发将上下游link的旅行时间作为特征借助LSTM全连接层拟合复杂的非线性关系并附有数据加载、模型训练、预测等完整Python脚本。包内共12个文件包含4个.py源码、4个txt数据文件以及PyDev工程配置整体约61.78MB目录结构清晰便于直接运行复现。已有132人学习浏览。除可复现基线实验外还展示了对事故时间剔除、空值前向填充等预处理思路的讨论适合想深入优化时空特征和序列建模方案的读者参考。1. 为什么道路通行时间偏偏要用LSTM来预测手机地图说“预计16分钟”晚高峰实际开了40分钟——这种偏差的根源在于通行时间不是一个孤立数值而是受上游排队长度、信号周期、时段规律和偶发事故共同影响的连续序列。今天的 08:05 和昨天的 08:05 高度相似但又不完全相等这正是 LSTM 擅长处理的形态通过遗忘门、输入门和输出门把过去几十分钟甚至几小时的路况状态有选择地带到当前预测里。标题里的“LSTM3层全连接层”结构本质是先用 LSTM 提取序列特征再交给全连接层做非线性压缩最终输出一个通行时间秒数属于典型的时间序列回归预测任务。这套方法同样可以迁移到水文径流预报、用电负荷预测等场景。下面从数据构造、滑窗样本、模型搭建到训练验证把整条链路完整讲透。2. 搭LSTM通行时间回归预测先备好数据和滑窗样本2.1 一条通行时间样本由哪些字段构成做回归预测第一步不是写模型而是把“通行时间”这个目标变量和它的解释变量整理成稳定的表结构。通行时间可以按路段采集也可以按起终点 OD 对采集最小时间粒度常见的是 5 分钟或 15 分钟一个桶。实际工程里常见字段如下字段名示例值说明segment_idR-1024路段编号区分不同预测对象time_bucket2025-03-18 08:055 分钟粒度的时间桶avg_speed_kmh21.4该时段平均速度vehicle_count183该时段通过车辆数duration_sec823目标变量通行时间is_workday1是否工作日rain_level0降雨等级0-3这张表的关键在于两点。第一数据必须按 segment_id 和 time_bucket 排序后再建模数据库里取出来的顺序不能直接信任乱序会让 LSTM 把未来的信息当历史学。第二duration_sec是待预测值其他列才是输入特征如果数据里同时有速度和通行时间两者本质是同一个交通状态的两种表达模型学到的可能是“用速度算时间”的代数关系泛化性会打折扣。2.2 用滑窗把时序数据构造成监督学习样本LSTM 不能直接吃一整段无限长的序列它吃的是固定长度窗口。常见做法是取过去 k 个时间片作为输入预测下一个时间片的通行时间。k 通常取 12、24、48分别对应 1 小时、2 小时、4 小时。窗口越大不一定越好因为早高峰前 6 小时的平峰信息对预测下一个 5 分钟没有帮助反而会稀释关键特征。import numpy as np def make_window_samples(df, feature_cols, target_col, window24): data df[feature_cols].values target df[target_col].values xs, ys [], [] for i in range(len(df) - window): xs.append(data[i: i window]) ys.append(target[i window]) return np.array(xs), np.array(ys)这个函数把时间序列变成了监督学习样本。data[i: iwindow]是左闭右开切片取 i 开始的连续 window 行target[iwindow]是窗口结束后那一个时间片的通行时间。返回的xs形状是(样本数, window, 特征数)这个三维矩阵正是 LSTM 的输入格式。相邻窗口之间有 23 行重叠这是滑窗做法的正常冗余相当于让模型在重叠片段上反复看到相邻状态有助于稳定学习。2.3 造一份能跑通的通行时间模拟数据真实路况数据往往涉及大量清洗工作这里先造一份可复现的模拟数据把整个回归预测流程跑通。数据按“基础通行时长 早晚高峰叠加 随机扰动”生成平峰 560 秒早高峰在 8 点附近增加约 180 秒晚高峰在 18 点附近增加约 240 秒。import numpy as np import pandas as pd rng np.random.default_rng(42) n_days 60 points_per_day 288 # 每5分钟一个点一天288个点 n n_days * points_per_day day_idx np.arange(n) % points_per_day base 560.0 morning 180.0 * np.exp(-((day_idx - 96) ** 2) / 90.0) # 第96个点是8:00 evening 240.0 * np.exp(-((day_idx - 216) ** 2) / 110.0) # 第216个点是18:00 noise rng.normal(0, 15, n) df pd.DataFrame({ time_bucket: pd.date_range(2025-03-01, periodsn, freq5min), day_sin: np.sin(2 * np.pi * day_idx / points_per_day), day_cos: np.cos(2 * np.pi * day_idx / points_per_day), duration_sec: base morning evening noise, })这里只保留三个核心特征让代码直接跑通day_sin、day_cos和duration_sec。day_sin和day_cos是把一天的时刻映射成周期变量避免模型把 “18 点” 和 “6 点” 当成相距 12 个单位的两个无关数值。morning和evening用高斯核模拟高峰渐变过程让曲线不是突变而是平滑上升和回落更接近真实路网表现。后续要接入 2.1 表格里的更多字段时把新增列加进feature_cols并同步调整后面模型的input_shape即可。2.4 归一化要在切分之后做且只 fit 训练集通行时间量级在数百到上千秒day_sin的取值只有 0 到 1量级不匹配会让全连接层的梯度更新极其不稳定。常见做法是使用MinMaxScaler或StandardScaler把特征压缩到同一尺度。这里的关键顺序是先按时间顺序切分训练集和测试集再在训练集上fit_transform最后用同一个 scaler 对测试集做transform。from sklearn.preprocessing import MinMaxScaler, StandardScaler split int(len(X) * 0.8) X_train, X_test X[:split], X[split:] y_train, y_test y[:split], y[split:] scaler MinMaxScaler() X_train_flat X_train.reshape(-1, X_train.shape[-1]) X_test_flat X_test.reshape(-1, X_test.shape[-1]) X_train_scaled scaler.fit_transform(X_train_flat).reshape(X_train.shape) X_test_scaled scaler.transform(X_test_flat).reshape(X_test.shape) scaler_y StandardScaler() y_train_scaled scaler_y.fit_transform(y_train.reshape(-1, 1)).ravel() y_test_scaled scaler_y.transform(y_test.reshape(-1, 1)).ravel()这里有两个高频踩坑点。第一如果先对全量数据做fit_transform再切分测试集的 min 和 max 已经被模型“偷看”过验证指标会虚高。第二时间序列不能用train_test_split默认的随机打散模式默认shuffleTrue会把早高峰样本和深夜样本混进同一批训练数据短期看 loss 降得快实则是把时间顺序糊掉了换到线上对未来预测时精度掉得厉害。y 单独用StandardScaler标准化的目的是让训练初期 loss 尺度在 1 附近收敛更稳预测完记得用scaler_y.inverse_transform把结果还原成秒。3. LSTM加3层全连接层的回归网络怎么组3.1 从三维张量到单值输出的维度变化路径先明确输入输出维度。LSTM 的输入必须是(batch_size, timesteps, features)。在刚才的滑窗配置里window24、特征数 3于是一条样本进入 LSTM 时是 24 行、每行 3 个特征。LSTM 单元数设为 64表示内部有 64 个记忆单元每个单元维护一个细胞状态和一个隐藏状态经过 24 个时间步的循环后输出一个 64 维向量。这个向量是“过去 2 小时路况的压缩摘要”。回归预测只需要一个数值所以后面接全连接层逐级压缩64 维 → 32 维 → 16 维 → 1 维。这就是标题里“LSTM3层全连接层”的分工LSTM 负责提取时序依赖全连接层负责把摘要非线性映射到目标值。如果去掉 LSTM把滑窗直接展平接全连接相当于把 24 个时间片当作 24 个无关特征顺序关系彻底丢失如果只保留 LSTM 而不做全连接输出维度又无法自然落到单个数值。顺便说一个 LSTM 内部的关键细节遗忘门的输入是上一时间步的隐藏状态h_{t-1}与当前输入x_t的拼接通过 sigmoid 生成一个 0 到 1 之间的保留系数再与记忆单元逐元素相乘。在通行时间场景里早高峰排队缓慢积累时遗忘门会把这几十分钟的状态保留下来如果突然发生事故导致通行时间跳变输入门会把新事件写入记忆遗忘门同时降低旧状态的权重。这种门控机制让梯度在反向传播时有一条“直通车道”所以能记住 10 个时间片之前的状态对当前预测的影响这是普通循环神经网络做不到的。3.2 用 Keras 把结构写出来import tensorflow as tf model tf.keras.Sequential([ tf.keras.layers.LSTM(64, return_sequencesFalse, input_shape(24, 3)), tf.keras.layers.Dense(32, activationrelu), tf.keras.layers.Dense(16, activationrelu), tf.keras.layers.Dense(1, activationlinear) ]) model.compile(optimizertf.keras.optimizers.Adam(learning_rate1e-3), lossmae, metrics[tf.keras.metrics.MeanAbsoluteError()]) model.summary()模型结构按顺序拆解LSTM(64, return_sequencesFalse)只返回最后一个时间步的隐藏状态输出形状是(batch, 64)。接着三个全连接层分别是Dense(32)、Dense(16)、Dense(1)前两层用 ReLU 激活最后一层必须用linear因为回归任务的输出没有上下界sigmoid 会把结果错误压缩到 0 到 1。关于return_sequences有个常见误用有人把它设为True再接Flatten于是 24 个时间步的隐藏状态被全部拍平成一个 1536 维向量全连接层参数量暴涨且模型被迫把每个时间步的隐藏状态当成独立特征失去了“压缩为整体摘要”的意义。对通行时间这种目标return_sequencesFalse接全连接是更合理的搭配。loss 选择mae而不是mse。通行时间有大量接近基线的样本少数极端拥堵场景如果使用均方误差误差会被平方放大好几倍训练过程被极值样本牵着走MAE 对离群点更稳健也更贴近业务关心的“平均差多少秒”。如果发现收敛后期震荡可以换Huberloss阈值设在 10 秒左右。3.3 回归预测的评估指标怎么选指标计算方式对通行时间的含义经验参考值MAE预测与真实之差的绝对值的平均平均偏差秒数直接对应导航体验快速路 30s主干道 45sMAPE绝对误差除以真实值的平均相对误差衡量不同道路公平性4%8% 属于优秀RMSE误差平方后取平均再开根号惩罚大偏差暴露极端预测失误通常比 MAE 高 20% 左右如果模型 MAE 只有 25 秒但 RMSE 高达 120 秒说明大多数时间预测准确偶尔出现完全偏离的预测。这种情况光看平均指标会误判模型质量必须回到长尾样本里看是哪些时段在爆雷。pred_scaled model.predict(X_test_scaled, verbose0).ravel() pred scaler_y.inverse_transform(pred_scaled.reshape(-1, 1)).ravel() mae float(tf.keras.metrics.mean_absolute_error(y_test, pred)) print(fMAE: {mae:.2f} sec)注意inverse_transform这一行不能省。模型输出是标准化尺度的数值直接拿去做业务展示会得到一堆平均值附近的小数看起来完全不像通行时间。4. 训练与调参LSTM通行时间模型的三个翻车点4.1 时间序列不能随机切分验证集要按时间顺序留在 2.4 节里已经按前 80%、后 20% 做了顺序切分这个做法的本质是评估口径问题。很多从分类任务切过来的同学习惯train_test_split(test_size0.2)默认的随机打散模式这在通行时间数据上是灾难性的模型在训练时见过与测试样本相邻甚至重叠的时间片段测试指标虚高。等到线上部署模型只能看到发布时刻之前的数据精度会断崖式下跌。如果数据横跨几个月建议把最后 5 到 7 天完全冻结作为测试集训练时连验证集也只从前面的数据里切。这样模拟的是“用过去预测未来”的真实部署结构验证结果才有参考价值。4.2 早停和学习率衰减把训练稳住LSTM 加三层全连接参数量在几千到几万级别数据量只有一万多条时很容易过拟合。实践中我会同时挂两个回调EarlyStopping监控验证集 loss连续 8 个 epoch 不降就停ReduceLROnPlateau在验证 loss 进入平台期后把学习率除以 5让 loss 下降到更细的尺度。from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau callbacks [ EarlyStopping(monitorval_loss, patience8, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.2, patience4, min_lr1e-6) ] history model.fit( X_train_scaled, y_train_scaled, validation_data(X_test_scaled, y_test_scaled), batch_size64, epochs100, callbackscallbacks )restore_best_weightsTrue是关键参数它让训练结束时回滚到验证集最优的权重而不是最后一次迭代的权重。不加这个参数虽然早停触发了但保存的模型可能还是过拟合末尾的状态。batch_size取 64 或 128 在时序回归里都比较稳定显存紧张可以降到 32但不要用 1LSTM 在 batch1 时梯度震荡极大loss 几乎不下降。4.3 单元数和全连接层宽度没有公式但有边界LSTM 单元数没有解析解但实践范围是清晰的。单元数 32 到 128 之间属于正常容量超过 128 后序列特征提取能力没有明显提升全连接层输入维度膨胀小数据集下基本是在过拟合噪声。三层全连接按倒金字塔排第一层取 LSTM 输出维度的 0.5 到 1 倍之后逐层减半最后一层压到 1。参数起点值调节方向调节原因LSTM units64欠拟合时加到 96特征容量不足Dense132MAE 高时加到 64需要更宽的非线性映射Dense216验证集抖动时降到 8过渡层太宽会学噪声Dropout0.2训练/验证 loss 差距大时加到 0.3缓解全连接层过拟合学习率1e-3loss 震荡时降到 3e-4大学习率容易跳过最优点Dropout 我一般放在 Dense 层之间而不是配置在 LSTM 内部。LSTM(dropout0.2)和recurrent_dropout在 CPU 上训练会明显变慢而数据量只有几千条时收益很不明显。全连接层间使用 0.2 的 Dropout 已经足够再大会把 LSTM 提取好的序列特征冲淡。5. 上线前怎么验证LSTM通行时间模型真的抓到了规律5.1 用“上一个时间片直接复用”做基线对比通行时间序列的自相关性本身很高早高峰是缓缓上升的直接拿 15 分钟前的观测值当预测值可能已经跑赢不少复杂模型。LSTM 如果连这个朴素基线都赢不了说明它学到的不是时序动态而是整体均值。这个验证步骤必须放在任何调参之前。y_true y_test.ravel() baseline_prev np.concatenate([y_true[:1], y_true[:-1]]) baseline_mae np.mean(np.abs(baseline_prev - y_true)) model_mae np.mean(np.abs(pred - y_true)) print(f上一时刻复用 MAE: {baseline_mae:.2f} sec) print(fLSTM 模型 MAE: {model_mae:.2f} sec)baseline_prev的含义是对每个待预测的时间片 t直接用 t-1 的真实观测值作为预测。如果 LSTM 的 MAE 只比这个基线好一点点优先检查窗口大小和特征设计而不是加宽全连接层。进一步的做法是按小时聚合误差观察模型在哪些时段改进最大。常见结果是LSTM 在早高峰开始阶段7:30 到 9:00比基线提升明显而凌晨平峰时段两者几乎无差异。5.2 线上滚动推理的窗口拼接方式真实系统里不是一次性拿到整个测试集而是每隔 5 分钟来一条新数据。预测时把最近 24 条历史记录拼成一个(1, 24, 3)的数组送进模型。def rolling_predict(model, recent_rows, feature_scaler, target_scaler): arr np.array(recent_rows[-24:], dtypenp.float32) arr feature_scaler.transform(arr.reshape(-1, arr.shape[-1])).reshape(1, 24, -1) pred_scaled model.predict(arr, verbose0)[0][0] return target_scaler.inverse_transform([[pred_scaled]])[0][0]recent_rows[-24:]保证窗口长度不足时也能截到最新数据避免索引越界。feature_scaler必须是训练时 fit 好的同一个对象不能用新数据重新计算缩放参数否则预测分布会产生漂移。每次预测完把新观测值追加到列表尾部再弹出最旧的一条保持窗口长度恒定。5.3 节假日和天气这类外部特征怎么接进去如果预测目标是跨长假的路况比如清明节前一天的晚高峰模型在历史序列里根本没见过这种样本。便宜的方案是把is_holiday、rain_level直接加进滑窗特征列随窗口一起进入 LSTM。但要注意LSTM 会把它们当作时间序列的一部分模型得自己学会忽略窗口前面 20 个小时的无效标记对 64 个单元的网络来说这个学习压力偏大。我一般会改用旁路拼接LSTM 继续吃历史通行时间序列输出 64 维向量后与外部特征向量做Concatenate再进入全连接层。# 伪代码LSTM输出后拼接外部特征 lstm_out tf.keras.layers.LSTM(64, return_sequencesFalse)(inputs_seq) merged tf.keras.layers.Concatenate()([lstm_out, external_inputs]) hidden tf.keras.layers.Dense(32, activationrelu)(merged) out tf.keras.layers.Dense(1, activationlinear)(hidden)LSTM 专注从历史通行时间中提取动态规律全连接层直接看到“今天是节假日”“当前有中雨”这类突变信号各司其职。验证时别忘了把外部特征做归一化否则 Dense 层输入尺度不均训练会出现某个特征主导梯度更新的问题。本文还有配套的精品资源点击获取