
简介LCTFP是一份基于1D CNN与LSTM组合结构的高速公路短时交通流量预测Python实现适合深度学习、智能交通领域的研究者与工程师。模型利用CNN提取交通流序列的空间特征LSTM捕捉时间依赖附带完整的数据预处理流程涵盖PeMS原始txt站点数据读取、归一化与按时间顺序整理等环节。压缩包共21个文件、31MB包含7个Python脚本模型训练、超参数搜索、数据预处理与对比分析、6个训练好的H5权重、4个JSON配置、结构图与说明文档可直接运行或调参其中cnn_lstm_param.py借助hyperas自动搜索超参数并提供了LSTM、SAE等对比模型方便评估LCTFP效果。已有5334人学习下载适合希望快速搭建交通流预测基线或深入理解CNN与LSTM时空建模的开发者。1. 高速公路交通流量预测最磨人的不是模型是数据LCTFP 为什么把 CNN 和 LSTM 绑在一起高速公路流量预测最磨人的不是模型而是数据本身上游一堵下游十来分钟才缓过来早高峰和深夜完全是两种脾气。只靠 LSTM 能记住先后顺序却抓不住相邻断面之间的联动只靠 CNN 能提局部特征却记不住长时段的演化。LCTFP基于 CNN 和 LSTM 的高速公路交通流量预测模型把两者串成一条流水线先用卷积在流量序列上提局部模式再交给 LSTM 建模时间依赖最后输出未来一个或多个时段的断面流量。这套组合在智能交通场景里已经是公认的基线方案关键是数据怎么喂、结构怎么拼、坑怎么躲。这篇文章给出可直接照着跑的 Python 实现覆盖数据处理、窗口构造、模型搭建、指标评估和踩坑清单。适合正在做智能交通项目或者想把流量预测做成算法基线的工程师。2. 数据准备流量矩阵、滑动窗口和归一化顺序错了就会翻车2.1 断面流量怎么变成 (时间步, 站点) 矩阵高速公路上最常见的流量数据来自断面检测器按固定间隔上报经过该断面的车辆数间隔一般是 5 分钟或 15 分钟。以 5 分钟为例一天 24 小时就是 288 个时间点一个断面一天产出一条长度为 288 的序列一条路段上有 N 个断面把 N 条序列按时间对齐就成了模型看到的基础输入——一张 (时间步, 站点数) 的矩阵。我处理数据的第一件事永远是补缺失和截尖峰。检测器断电、通信抖动都会产生整段空值直接用 NaN 喂给模型会整批报错而事故、临时管制会造成远超正常水平的流量尖峰不处理的话归一化后大部分正常数据会被压到很窄的区间里模型学半天等于白学。import numpy as np import pandas as pd df pd.read_csv(flow.csv, parse_dates[time], index_coltime) # 每列是一个断面站点每行是一个 5 分钟采集点 matrix df.values.astype(float32) # 缺失值线性插值首尾用前后值填充 matrix pd.DataFrame(matrix).interpolate(limit_directionboth).values # 只用训练段前 70%算 99 分位把极端尖峰截断避免信息泄漏 n_train int(len(matrix) * 0.7) clip_max np.percentile(matrix[:n_train], 99) matrix np.clip(matrix, 0, clip_max) print(matrix.shape) # (总时间步, 断面数)这里的逻辑是先补缺失因为后续滑动窗口一旦碰上 NaN 会污染整批样本再截尖峰而且分位数只在训练段上计算防止测试段的极端值被预先“看穿”。clip_max取 99 分位而不是最大值是给事故等极端情况留一点尾巴让模型见过异常但不被异常主导。流量本身是非负的所以下界设 0。2.2 滑动窗口history 和 horizon 的取值逻辑矩阵准备好之后要把连续序列切成监督学习的样本。核心就两个参数history 是回看多少步horizon 是预测未来多少步。以 5 分钟粒度为例history12 就是回看 1 小时horizon1 就是预测下一个 5 分钟。def make_windows(data, history12, horizon1): X, y [], [] for i in range(len(data) - history - horizon 1): X.append(data[i : i history]) # 过去 history 步 y.append(data[i history : i history horizon]) # 未来 horizon 步 return np.array(X), np.array(y) # data: (时间步, 断面数) # X: (样本数, history, 断面数) # y: (样本数, horizon, 断面数)history 的选择直接决定模型能看到多长的“记忆”。我的经验是至少覆盖一个完整的早高峰过程5 分钟粒度下就是 36 步3 小时如果只做短时预测未来 5~15 分钟12 步到 24 步就够。horizon 超过 12 步1 小时时建议同步把 history 加长否则长预测缺少周期上下文。另注意 step 参数上面代码默认 step1相邻窗口只差一个时间点样本高度重叠样本量大但信息增量有限训练太慢时可改成 step2 抽样但指标通常会略降这个取舍要自己定。2.3 归一化与时间切分先 fit 训练段别让未来数据偷看答案流量数据必须按时间顺序切分不能随机打乱。交通流是强时序数据随机切分等于把未来数据混进训练集模型在验证集上的“好成绩”上线后立刻失效。另一个高频翻车点是归一化MinMaxScaler 必须先 fit 训练段再用同一套参数 transform 验证段和测试段。如果对全量数据 fitscaler 已经看过了测试段的分布范围结果会系统性偏乐观这个偏差在流量预测里尤其明显因为深夜和高峰的流量量级差了一个数量级。提示滑动窗口要在切分后的各段内部生成不要让窗口跨过训练/验证边界否则验证集里混入训练段信息。from sklearn.preprocessing import MinMaxScaler n_val int(len(matrix) * (0.7 0.15)) # 前 70% 训练70%~85% 验证最后 15% 测试 n_train int(len(matrix) * 0.7) scaler MinMaxScaler(feature_range(0, 1)) scaler.fit(matrix[:n_train]) # 只用训练段拟合 train_scaled scaler.transform(matrix[:n_train]) val_scaled scaler.transform(matrix[n_train:n_val]) test_scaled scaler.transform(matrix[n_val:]) X_train, y_train make_windows(train_scaled, 12, 1) X_val, y_val make_windows(val_scaled, 12, 1) X_test, y_test make_windows(test_scaled, 12, 1)切分比例用 70/15/15 是常见起点但有一个细节容易忽略验证段和测试段必须各自包含完整的周末和工作日。高速公路流量周一到周五和周六日完全两个形态如果验证段恰好落在周一模型对周末的拟合能力你就是盲的。我一般会在切分前先看一眼日期按“完整自然周”来切必要时牺牲一点比例均衡。3. LCTFP 模型结构卷积核卷时间还是卷站点LSTM 放在哪一层3.1 时间维卷积与空间维卷积两种 CNNLSTM 组合的取舍同样是 CNNLSTM卷积核滑动的维度不一样模型看到的“空间”完全不同。Keras 的 Conv1D 默认在输入的第一维上滑动输入形状是 (batch, history, n_stations) 时卷积核沿 history时间滑动断面数被当作通道处理这叫时间维卷积如果先转置成 (batch, n_stations, history)卷积核就沿站点滑动提取的是相邻断面之间的空间联动。组合方式输入形状卷积核滑动维度提取的特征适用场景时间维卷积(batch, history, stations)时间单断面流量的局部趋势、拐点站数少、各站相对独立空间维卷积(batch, stations, history)站点相邻断面间的同步变化与传播多断面联动、上下游影响明显LCTFP 的主实现我采用时间维卷积输入直接就是 (history, n_stations)Conv1D 核沿时间滑n_stations 当作通道代码最直接单站数据也能跑。空间维卷积多用于你明确知道“相邻三个断面才是最有解释力的组合”的场景但它的代价是张量布局要来回折腾卷积后序列轴变成了站点维而 LSTM 要求时间轴在序列维上中间通常要补一个 Permute 或 Reshape这一层正是新手最容易写错的地方。# 时间维卷积核沿 history 滑动站点作为通道 inputs Input(shape(history, n_stations)) x Conv1D(64, 3, paddingsame)(inputs) # 输出还是 (batch, history, 64) # 空间维卷积的变体写法先转置让核沿站点滑动 spatial Permute((2, 1))(inputs) # (batch, n_stations, history) x Conv1D(64, 3, paddingsame)(spatial) # 核沿站点滑我的建议是先跑通时间维卷积的版本确认指标达到基线预期再试空间维。不要一上来两套都做流量预测的收益改进通常不在这一层而在数据切分和窗口参数上。3.2 层数与参数64 滤波器、kernel3、LSTM64 这个起点怎么来的LCTFP 的主体结构是两层 Conv1D 加两层 LSTM。卷积层负责把局部时序模式压缩成特征LSTM 层负责在压缩后的特征序列上建模长短依赖。每层卷积后面接 BatchNormalization 和 Dropout流量序列噪声大事故尖峰会带来梯度抖动BN 能稳定训练Dropout 直接压制全连接层的过拟合。参数量的分配值得算一笔账。输入 (history12, stations10)Conv1D(64, 3) 的参数量约 64×3×1064≈1984而 LSTM(64) 的参数量约 4×64×(64641)≈33024。LSTM 才是参数量的大头所以不要盲目堆 LSTM 单元数64 起步、最多 128超过这个量级在小数据集上几乎必然过拟合。kernel3 是经验起点。交通流的局部时间相关性很少超过 3~5 个步长核太大反而会把相邻时段里相对独立的信息搅在一起。连续两层 kernel3 的卷积感受野等效覆盖 5 个步长对 5 分钟粒度来说就是 25 分钟正好覆盖一次短时拥堵传播的尺度。padding 用 same 保证卷积后序列长度不变LSTM 拿到的序列长度和输入一致形状不易出错。3.3 输出设计单步、多步直接输出与递归预测怎么选输出层的设计取决于你要预测什么。只有三种常见做法单步预测输出层 Dense(N)预测下一个时刻所有断面的流量。最简单、误差最小但只能给出未来 5 分钟的断面值。多步直接输出输出层 Dense(horizon*N) 后 Reshape 成 (horizon, N)训练时所有未来步都被损失函数监督避免了误差累积但 horizon 越大拟合难度越高。递归预测先用单步模型预测时把上一步输出拼回输入再预测下一步。实现最省事但误差会滚雪球horizon6 时误差常常比直接输出高一倍。LCTFP 的常见做法是选多步直接输出。我的经验是把 horizon 控制在 1 到 12 之间超过 1 小时就分两段模型接力第一个模型预测 0~30 分钟第二个模型在第一个模型的输出基础上预测 30~60 分钟。这比一个模型硬扛 12 步更稳。4. 用 Python 实现 LCTFP从原始 CSV 到训练完成的完整代码4.1 数据管线CSV → 矩阵 → 分段时间窗口下面把第 2 章的步骤合并成一个可复用的函数。注意它在内部先切分、再分别对每段造窗口这是和“先全量造窗口再切分”最大的差别保证窗口不会跨过训练/验证边界。import numpy as np import pandas as pd from sklearn.preprocessing import MinMaxScaler def make_windows(data, history12, horizon1): X, y [], [] for i in range(len(data) - history - horizon 1): X.append(data[i : i history]) # 历史窗口 y.append(data[i history : i history horizon]) # 未来标签 return np.array(X), np.array(y) def load_and_prepare(path, history12, horizon1): df pd.read_csv(path, parse_dates[time], index_coltime) matrix df.values.astype(float32) matrix pd.DataFrame(matrix).interpolate(limit_directionboth).values n len(matrix) n_train int(n * 0.7) n_val int(n * 0.85) clip_max np.percentile(matrix[:n_train], 99) matrix np.clip(matrix, 0, clip_max) scaler MinMaxScaler(feature_range(0, 1)) scaler.fit(matrix[:n_train]) X_train, y_train make_windows(scaler.transform(matrix[:n_train]), history, horizon) X_val, y_val make_windows(scaler.transform(matrix[n_train:n_val]), history, horizon) X_test, y_test make_windows(scaler.transform(matrix[n_val:]), history, horizon) return X_train, y_train, X_val, y_val, X_test, y_test, scaler这份代码把两个最容易错的动作固定住了一是 scaler 只 fit 训练段二是窗口在切分之后各自生成。返回的 X 形状是 (样本数, history, 断面数)y 形状是 (样本数, horizon, 断面数)。调用前确认 CSV 的列顺序就是你要的断面顺序因为站点编号在矩阵里的排列顺序会直接影响空间卷积的语义业务上一般按高速公路里程桩号排序。4.2 模型构建Conv1D LSTM Dense 逐层说明模型定义我固定成函数式写法方便改参数复用。from tensorflow.keras.models import Model from tensorflow.keras.layers import (Input, Conv1D, BatchNormalization, Dropout, LSTM, Dense, Reshape) def build_lctfp(history12, n_stations10, horizon1, filters64, kernel3, lstm_units64, dropout0.2): inputs Input(shape(history, n_stations)) # 第一层卷积提取局部时序模式核沿时间滑 x Conv1D(filters, kernel, paddingsame, activationrelu)(inputs) x BatchNormalization()(x) x Dropout(dropout)(x) # 第二层卷积在特征序列上继续提局部组合 x Conv1D(filters, kernel, paddingsame, activationrelu)(x) x BatchNormalization()(x) x Dropout(dropout)(x) # LSTM 层在压缩后的序列上建模长短依赖 x LSTM(lstm_units, return_sequencesTrue)(x) x Dropout(dropout)(x) x LSTM(max(lstm_units // 2, 8), return_sequencesFalse)(x) if horizon 1: outputs Dense(n_stations)(x) else: outputs Dense(horizon * n_stations)(x) outputs Reshape((horizon, n_stations))(outputs) return Model(inputs, outputs) model build_lctfp(history12, n_stations10, horizon1) model.summary()逐层看第一层卷积核大小为 3在时间维上滑动感受野覆盖 3 个时间步输出通道数变成 64BN 和 Dropout 稳定训练dropout0.2 是流量场景的安全起点。两层卷积之后特征序列仍然是 history 长度因为 paddingsame。第一个 LSTM 返回每个时间步的隐状态第二个 LSTM 只在最后一步输出把整段序列压缩成一个向量。最后的 Dense 层要么直接输出 N 个站的单步预测要么输出 horizon*N 再 Reshape 成多步张量。运行model.summary()时重点检查两点一是 LSTM 层的输入序列长度是否为 history二是最终输出形状是否为 (None, horizon, n_stations)。这两处是 CNNLSTM 结构最常见的黑匣子错了模型照样能跑但训练出的张量形状和你的标签对不上评估阶段才会爆出诡异错误。建议在加载数据后先用一个小批量跑一次model.predict(X_train[:2])确认形状一致再开始正经训练。4.3 训练配置huber 损失、早停与学习率衰减编译和训练固定用一套我已经跑顺的配置huber 损失、Adam 优化器、带早停和学习率衰减。from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model.compile(optimizeradam, losshuber, metrics[mae]) callbacks [ EarlyStopping(monitorval_loss, patience12, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience4, min_lr1e-5) ] history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs100, batch_size64, callbackscallbacks, verbose1 )损失函数我选 huber 而不是 mse。流量序列里有事故尖峰和检测器异常值mse 会对这些离群点施加平方级惩罚把模型拽着去拟合几次事故huber 在误差超过阈值后从平方退化为线性对离群点稳健得多。如果你用 mse 训出来指标一直不降先换成 huber 试一次这个改动经常带来立竿见影的变化。早停 patience12 意味着验证损失连续 12 个 epoch 不下降就停restore_best_weightsTrue会把参数回滚到验证损失最低的位置。配合ReduceLROnPlateau验证损失连续 4 个 epoch 不降学习率减半最低降到 1e-5。对流量数据我一般允许训练走到 100 epoch但实际早停通常发生在 40~60 epoch超过这个数还在涨的基本是参数配置有问题不是欠拟合。5. 评估指标与避坑清单MAE/RMSE/MAPE 怎么看5 个血泪教训5.1 三个指标各管一件事MAE 主、RMSE 抓尖峰、MAPE 要设阈值评估阶段要把预测结果先反归一化再算指标否则算出的 MAE 是 0~1 区间里的无量纲数没法向业务解释“误差多少辆车”。反归一化时注意 reshape 的顺序多步输出的 y 形状是 (样本, horizon, 站点)要先压平再还原。from sklearn.metrics import mean_absolute_error, mean_squared_error def evaluate_multi(y_true, y_pred, scaler, n_stations, threshold5): # y_true / y_pred 是标准化后的值 y_true_inv scaler.inverse_transform( y_true.reshape(-1, n_stations)).reshape(y_true.shape) y_pred_inv scaler.inverse_transform( y_pred.reshape(-1, n_stations)).reshape(y_pred.shape) for h in range(y_true.shape[1]): t y_true_inv[:, h, :].ravel() p y_pred_inv[:, h, :].ravel() mae mean_absolute_error(t, p) rmse np.sqrt(mean_squared_error(t, p)) mask t threshold # 只统计流量大于阈值的样本避免除零 mape np.mean(np.abs((t[mask] - p[mask]) / t[mask])) * 100 if mask.sum() else np.nan print(fhorizon{h1}: MAE{mae:.2f} RMSE{rmse:.2f} MAPE{mape:.2f}%)三个指标各管一件事MAE 是主指标单位是辆/5 分钟业务汇报就用它RMSE 对尖峰误差敏感事故时段预测差会把它拉得很高所以 RMSE 和 MAE 的差距越大说明你的模型对异常时段越不稳MAPE 是百分比直观但有个致命问题——深夜流量接近 0 时分母趋近 0一个很小的绝对误差也会算出几百个百分点甚至出现 NaN。所以代码里用threshold5把流量过低的样本排除掉MAPE 才能作为参考。多步预测时按 horizon 分别输出你能清楚看到误差随预测步长放大的速度。5.2 超参数参考表与调参顺序超参调优是门玄学但有个靠谱的起点和顺序。下面这张表是 LCTFP 在新数据集上的默认起点按这个顺序调而不是一上来就 Grid Search。超参数默认起点调节范围说明history126~48至少要覆盖一个完整高峰过程horizon11~12超过 12 建议分段接力filters6432~128卷积通道数翻倍看指标收益kernel33~5超过 5 收益很小还容易过拟合lstm_units6432~128参数量大头谨慎加大dropout0.20.1~0.4过拟合明显时往上加batch_size6432~256样本十万级时调大learning_rate1e-31e-4~1e-2配合 ReduceLROnPlateau 使用我的调参顺序固定是先定 history 和 horizon这是业务语义问题不是模型问题然后调 filters 和 lstm_units从 64 翻倍到 128 看验证集 MAE 是否明显下降没下降就回到 64最后才动 dropout 和学习率。如果前两个阶段指标就达标了后面两个根本不用碰。5.3 五个高频踩坑现象、原因、解决第一个坑是归一化泄漏。现象验证集 MAPE 低得离谱低到让你怀疑人生。原因对全量数据 fit 了 MinMaxScalerscaler 提前看到了测试段的取值范围。解决切分之后只 fit 训练段测试段和验证段只用 transform。第二个坑是随机切分。现象训练指标漂亮部署到线上全线拉胯。原因随机打乱把未来数据混进了训练集模型实际在“作弊”它见过未来。解决按时间顺序切分不 shuffle且保证验证集和测试集各自包含完整的周末。第三个坑是 MAPE 深夜爆炸。现象MAPE 算出 800% 甚至 NaN领导看了直摇头。原因凌晨流量接近 0除零或者除以极小值。解决只对流量大于阈值的样本统计 MAPE主指标用 MAEMAPE 只当辅助汇报口径。第四个坑是多步递归误差累积。现象horizon6 的指标比单步差出一倍还多。原因递归预测把上一步的误差喂给了下一步误差滚雪球。解决改用多步直接输出训练时每个未来步都进损失函数评估时按 horizon 分步打印指标别只报一个平均。第五个坑是过拟合在 LSTM 层。现象训练损失一路降验证损失先降后升两个曲线开口越来越大。原因LSTM 单元数堆到 256 以上dropout 又没加够模型把训练集的噪声背了下来。解决LSTM 从 32~64 起步dropout0.2加 EarlyStopping 兜底先跑 30 epoch 看曲线形状再决定要不要加深而不是直接堆大网络。6. 进阶周期特征拼接、多步验证与上线前的基线对比6.1 把小时和星期几拼进输入周周期性立刻被模型接住流量有极强的周期工作日早晚双峰、周末平峰、午夜低谷。CNN 和 LSTM 能从历史窗口里学出部分周期但遇到节假日、周一早高峰这种“稀疏但关键”的模式纯靠数据自己悟经常不够。我习惯把时间特征直接拼进输入通道。hour (df.index.hour df.index.minute / 60.0) / 24.0 is_weekend (df.index.dayofweek 5).astype(float32) aux np.stack([hour, is_weekend], axis1) # (总时间步, 2) def make_windows_with_aux(data, aux, history12, horizon1): X, y [], [] for i in range(len(data) - history - horizon 1): window np.concatenate([data[i:ihistory], aux[i:ihistory]], axis1) X.append(window) y.append(data[ihistory : ihistoryhorizon]) return np.array(X), np.array(y)hour 和 is_weekend 都映射到 0~1 区间拼在矩阵最后一维。这样模型每个时间步看到的特征从“N 个断面的流量”变成“N 个断面的流量 当前时刻 是否周末”周维度不用再靠玄学训练收敛也明显更稳。注意辅助特征也要在切分后各自构造避免窗口跨边界。6.2 上线前必做的一次纸面验证模型训完别急着接业务。我每次上线前固定做三件事第一跑一个 persistence 基线——用上一时刻的流量直接当预测值LCTFP 的 MAE 至少要低于这个基线 15% 以上否则说明卷积和 LSTM 根本没学到有效模式第二分场景算指标早高峰 7~9 点单独算、平峰单独算、周末单独算平均指标好看但高峰时段拉胯的情况太常见第三抽连续 7 天的测试段把预测曲线画出来肉眼确认早晚高峰的相位是否对齐曲线形态是否被提前或滞后这个检查比任何指标都直观。我现在每换一个数据集流程永远是先跑 persistence 基线和纯 LSTM有了下界再上 LCTFP。这个顺序帮我少走了很多弯路——大多数翻车不是模型问题而是数据切分、归一化和基线对比没做扎实。这套流程你直接拿去用至少能绕开我踩过的那几个坑希望帮到你。本文还有配套的精品资源点击获取