简介:一份面向高速公路短时交通流预测的Python实现方案,核心为LCTFP模型,采用1D CNN与LSTM混合结构,分别提取交通流的空间与时间特征,适合智能交通、深度学习应用开发者参考。资源共21个文件,压缩包约31MB,主要包含7个Python脚本、6个h5模型权重、4个json配置、2张结果图片、1个原始数据zip及1份说明文档,覆盖数据预处理、模型构建、超参数搜索与结果对比等环节。其中超参数搜索脚本支持基于hyperas的参数调优,可辅助优化模型;原始交通流数据经归一化等处理后可快速复现实验,数据处理流程完整,时间顺序与归一化步骤清晰。代码结构清晰,脚本分工明确,从数据读取、归一化到模型训练与对比均包含在内。目前已有5334人学习下载,适合有一定Python基础、希望用CNN与LSTM完成交通流预测项目的学习者。
1. 基于 CNN 和 LSTM 的高速公路交通流量预测:这份 Python 代码包到底解决了什么问题
高速公路短时交通流预测一直是个“看着简单、做起来玄学”的方向。输入是过去几十分钟的断面流量,输出是未来 5 到 15 分钟的车流变化,但难点全在数据形态:交通流既在空间上有上下游相关性,又在时间上有明显的周期性和突发性。如果你只用单一模型,比如纯 LSTM 或者纯 CNN,往往顾此失彼——LSTM 能抓住时序规律却忽略上下游断面之间的空间联动,CNN 能提取局部空间特征却对长程时间依赖无能为力。这份基于 Python 的“LCTFP”(CNN + LSTM 组合模型)代码包,把两件事同时做了:用 1D CNN 提取多断面流量在空间维度上的短期特征,再用 LSTM 去学时间维度上的演化规律,最后在输出层融合预测。它自带了从原始 PeMS 数据处理、归一化、训练、超参数搜索到模型对比的完整脚本,适合两类人:一是刚接触交通流预测、想找一个能直接跑通的基线项目的研究生或数据分析师;二是已经在做流量预测、想看看 CNN 与 LSTM 双分支结构在某高速数据集上真实效果如何、有哪些坑要避开的从业者。接下来我会按这份代码包的实际结构,把数据预处理、模型构建、超参数搜索和避坑经验逐条拆开,尽量让你拿到资源就能复现,不卡在中间某一步。
2. 数据从哪来、怎么喂给模型:PeMS 数据处理与时间序列切片的完整流程
2.1 理解 PeMS 数据格式:站点 ID、时间戳与流量字段的对应关系
这份代码包里的原始数据是PEMS.zip,解压后里面是若干个站点(某高速公路沿线的检测断面)的.txt文件,每个文件记录了一个检测站点在某个时间窗口内(一个半小时左右)的交通流数据。打开一个 txt 文件你会看到典型的 PeMS 格式:每一行包含检测站点编号、时间戳、流量值(某些文件还有车速和占有率,但本代码包主要读的是流量字段)。关键是字段之间用逗号分隔,且不同站点文件的时间起点可能不一致——有的从整点开始,有的从半点开始。这个问题在后续拼接时如果不处理,会让时间特征错位,模型学到的“时间规律”是乱的。
我在input_data.py里看到的处理方式是:扫描目录下的所有 txt 文件,逐个读取,把时间戳解析成统一的 datetime 对象,然后按时间排序;流量字段的缺失值直接丢弃,因为某个站点在某时刻没数据,下游对齐时该时刻就是空值。这里有一个值得注意的细节:代码里对读取顺序做了显式处理,并非简单使用os.listdir()就完事,而是先按文件名字排序,再按时间戳排序。很多人自己做预处理时会忽略这一步,导致后续拼接矩阵的行顺序不是时间升序,模型训练出来的权重完全不可用,而且表现很难排查——因为损失函数数值看起来正常,只是预测曲线整体平移了一个时间步。
# 核心思路:读取所有站点 txt,按时间对齐成二维矩阵 import pandas as pd import numpy as np from pathlib import Path def load_traffic_data(data_dir): files = sorted(Path(data_dir).glob("*.txt")) # 先按文件名排序,保证站点顺序稳定 all_series = {} for f in files: # 假设每行: station_id, timestamp, flow df = pd.read_csv(f, header=None, names=["station", "time", "flow"]) df["time"] = pd.to_datetime(df["time"]) df = df.dropna(subset=["flow"]) # 缺失流量直接丢弃 df = df.sort_values("time") # 每个站点内部按时间升序 all_series[f.stem] = df.set_index("time")["flow"] # 按时间索引做外连接,不同站点同一时刻对齐 flow_matrix = pd.concat(all_series.values(), axis=1, keys=all_series.keys()) flow_matrix = flow_matrix.sort_index() return flow_matrix # 返回的 flow_matrix:行 = 时间(升序),列 = 站点代码里用了pd.concat把不同站点对齐到同一时间轴,时间轴上缺失的单元格就是 NaN。你在实际使用时,可以直接把data_dir指向 PEMS 解压后的目录。对齐之后的flow_matrix就是后续滑窗切片的原材料。这里我提醒一句:如果某个站点缺失时间段太多(比如超过总长度的三分之一),建议直接把这个站点从矩阵里删掉,否则滑窗切片时会产生大量带 NaN 的样本,轻则影响归一化统计量,重则让 LSTM 学到“用 NaN 预测流量”的荒谬逻辑。
2.2 归一化为什么选 MinMax 而不是标准化:时间序列预测的常见误用
这份代码包在数据预处理里用的是最小最大归一化(MinMaxScaler),把流量值压缩到 [0, 1] 区间,而不是 Z-score 标准化。为什么要这么选?交通流量数据有明显的上下界(0 到道路通行能力上限),MinMax 能保留原始分布的相对间距,且预测完成后反归一化回真实流量值时非常直观——就是按训练集的最小值和最大值做一次线性反变换。而标准化(减均值除标准差)适合数据分布接近高斯、且上下界不明确的场景;用在交通流上,预测出的值经过反变换后容易出现负值,你需要额外裁切,多一步操作就多一个出错点。
from sklearn.preprocessing import MinMaxScaler def normalize_flow(matrix): # 只对流量列做归一化,fit 在训练集,transform 在验证/测试集 scaler = MinMaxScaler(feature_range=(0, 1)) # 注意:fit 用训练集所有非 NaN 值 train_data = matrix[: int(len(matrix) * 0.7)].values scaler.fit(train_data[~np.isnan(train_data)].reshape(-1, 1)) normalized = scaler.transform(matrix.values.reshape(-1, 1)).reshape(matrix.shape) return normalized, scaler这里有一个代码包不会帮你处理的隐藏细节:归一化器的fit只能基于训练集,不能拿全量数据去 fit——否则未来信息通过 min/max 泄漏到训练过程里,验证集上的误差会虚低。我看到代码包的 README 里没强调这一点,但脚本里确实是先按 70% 划分训练集再 fit scaler 的。这个边界在做真实项目时必须自己守住。另外,如果测试集出现了超过训练集 max 的流量值(比如某天事故后拥堵导致流量骤增),归一化后会大于 1,模型输入分布与训练不一致,预测自然会偏低。常见做法是保留 5% 的余量,即把 max 乘以 1.05 再作为归一化上界,给突发流量留一点空间。
数据预处理最后一步是生成滑窗样本:设定窗口长度为lookback(比如 6 个时间步,对应 30 分钟),预测未来horizon步(比如 2 步,对应 10 分钟)。每滑动一步生成一个样本,样本的输入是最近 6 步的多站点流量矩阵,输出是目标站点未来 2 步的流量值。这个环节有大量细节可挖,比如站点之间的空间顺序。如果想让 1D CNN 学到的空间特征有意义,输入矩阵的列(站点顺序)应该按照高速公路上下游物理位置排列,而不是按文件名字母序排列。代码包里train_test_split这一步用的是纯时间顺序切分,没有洗牌,这是时间序列预测的基本纪律——一旦按时间点随机洗牌,短期趋势会被打乱,模型就学会了“作弊”。
3. 模型结构拆解:1D CNN 提取空间特征、LSTM 捕捉时间依赖的双分支设计
3.1 双分支从零搭建:为什么需要两个分支,而不是简单串联
LCTFP 的核心结构不是把 CNN 和 LSTM 串成一条线,而是让它们分别处理问题的两个维度,然后在后半段拼接融合。具体来说:输入是一个三维张量,形状为(batch_size, lookback_steps, num_sites)。1D CNN 分支沿着lookback_steps维度(也就是时间维度)做卷积,但它的卷积核在空间维度(站点维度)上做了滑动——所以 CNN 的每个卷积核其实是“一个时间步内,相邻几个站点的流量加权组合”。LSTM 分支则把同样的输入按时间步展开,每个时间步输入一个站点流量的向量,让 LSTM 去学一段时间内序列的演变。两个分支的输出向量拼接在一起,过几层全连接,最后输出一个或多个时间步的流量预测值。
from tensorflow.keras import layers, Model def build_cnn_lstm_dual(lookback, num_sites, cnn_filters=64, lstm_units=32, dropout=0.3): inp = layers.Input(shape=(lookback, num_sites)) # CNN 分支:1D卷积沿时间轴,kernel 覆盖相邻站点 cnn = layers.Conv1D(filters=cnn_filters, kernel_size=3, padding="same", activation="relu")(inp) cnn = layers.MaxPooling1D(pool_size=2)(cnn) cnn = layers.Flatten()(cnn) cnn = layers.Dropout(dropout)(cnn) # LSTM 分支:逐时间步输入,学时间演化 lstm = layers.LSTM(lstm_units, return_sequences=False)(inp) lstm = layers.Dropout(dropout)(lstm) # 融合:拼接两个分支的输出 merged = layers.concatenate([cnn, lstm]) merged = layers.Dense(16, activation="relu")(merged) out = layers.Dense(1, activation="linear")(out) return Model(inp, out)这段代码里有两处参数直接影响效果。第一处是cnn_filters和kernel_size:kernel_size=3 表示卷积核覆盖 3 个连续站点,过滤器 64 个意味着模型学习 64 种不同的“相邻站点局部模式”,比如上游拥堵下传、流量潮汐等现象。第二处是lstm_units:隐藏单元数量决定了 LSTM 记住时间依赖的能力,太小记不住长程规律,太大容易过拟合。代码包默认给的 32 到 64 是一个在高速场景里比较稳的区间。
这里我补充一个很多人第一次跑会困惑的点:为什么 CNN 和 LSTM 两个分支都吃同样的输入,原文里没有解释清楚“为什么不直接串联”?实际原因是,串联结构(比如 CNN 先提取空间特征,再输入给 LSTM)会让 LSTM 看到的不是原始流量,而是 CNN 卷积后的特征——这等于 LSTM 完全依赖于 CNN 的抽象质量,CNN 没捕捉到的空间模式,LSTM 也学不到。而双分支并联,LSTM 同时能看到原始输入,两个模型互不干扰,融合时才结合信息,错误不向下游传播,训练稳定性强很多。
3.2 训练脚本里的参数设定:batch、epoch、early stopping 与学习率
cnn_lstm_final.py里封装了最终模型训练流程。它先加载预处理好的数据、按时间划分训练集/验证集/测试集、创建双分支模型实例,用 Adam 优化器配 mean squared error 损失函数,batch size 设为 32,max epochs 为 100,并带了 EarlyStopping 和 ModelCheckpoint——这套配置是时间序列预测的常见套路,但有几个参数“坑”值得你重新考虑。
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint def train_final_model(X_train, y_train, X_val, y_val): model = build_cnn_lstm_dual(lookback=6, num_sites=5) model.compile(optimizer="adam", loss="mse", metrics=["mae"]) early_stop = EarlyStopping(monitor="val_loss", patience=10, verbose=1) checkpoint = ModelCheckpoint( "Model/cnn_lstm_best.h5", monitor="val_loss", save_best_only=True, verbose=1, ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=32, callbacks=[early_stop, checkpoint], verbose=2, ) return model, historybatch_size=32 对 PeMS 这类数据量(样本数大概几万)是个合理值,但对小数据集(样本量几千)建议改成 16,有助于稳定梯度,避免验证损失震荡。EarlyStopping 的 patience=10 在流量预测里不一定够——很多真实数据集的验证损失曲线是阶梯形的:先降一段,然后平台期维持很久(10 个 epoch 没有明显下降),然后突然再降一截。如果 patience 太小,模型会被过早掐断,错过后面的收益。我自己做类似任务时会把 patience 调到 20 左右,并且配合 ReduceLROnPlateau——验证损失进入平台期时把学习率降一个量级(比如从 0.001 降到 0.0001),给模型第二次下降的机会。
另一个值得提的点是损失函数的选择。代码包用的 MSE 在交通流预测里会偏向“惩罚大误差”,比如某天高峰期的流量预测到 3000 而真实值是 2500,这个误差的平方会被放大很多倍,模型为了降低损失会倾向于保守预测(往均值附近收窄)。如果业务上你想让预测更贴近峰值形态,而不是追求均方误差最低,我建议把输出层的激活函数换成linear的同时,在损失函数里加一点 MAE 的混合,或者干脆换 Huber loss。但这属于优化层面,先按代码包默认跑通,再改也不迟。
4. 超参数搜索:用 hyperas 自动找 CNN 核数、LSTM 单元数与 dropout 的最佳组合
4.1 hyperas 搜索脚本的运行方式与搜索空间设置
cnn_lstm_param.py是这份代码包里“含金量”很高的一个脚本,因为它不是手动调参,而是用 hyperas 库在定义好的搜索空间里自动寻找最优超参数。hyperas 是 Keras 和 Hyperopt 之间的桥接层——你用 Hyperopt 定义搜索空间(比如整数范围、选择列表),hyperas 把 Keras 模型包装成目标函数,逐次采样不同参数组合,训练并返回验证损失,由 Hyperopt 的优化算法决定下一步搜哪里。运行这个脚本前你需要pip install hyperas,它依赖 Keras 2.x 和 hyperopt,这几个库的版本兼容性非常玄学,我在第 5 章会专门讲。
from hyperas import optim from hyperas.domains import hp, choice def create_model(X_train, y_train, X_val, y_val): cnn_filters = {{choice([32, 64, 128])}} lstm_units = {{choice([16, 32, 64])}} dropout = {{uniform(0.2, 0.5)}} learning_rate = {{choice([0.001, 0.0005])}} model = build_cnn_lstm_dual( lookback=6, num_sites=X_train.shape[2], cnn_filters=cnn_filters, lstm_units=lstm_units, dropout=dropout, ) model.compile(optimizer=keras.optimizers.Adam(learning_rate=learning_rate), loss="mse", metrics=["mae"]) model.fit(X_train, y_train, epochs=10, batch_size=32, validation_data=(X_val, y_val), verbose=0) val_loss = model.evaluate(X_val, y_val, verbose=0)[0] return {"loss": val_loss, "status": STATUS_OK} best_run, best_model = optim.minimize( model=create_model, data=(X_train, y_train, X_val, y_val), algo=tpe.suggest, max_evals=20, trials=Trials(), )代码里搜索空间覆盖了三个关键维度:CNN 卷积核个数(32/64/128)、LSTM 隐藏单元数(16/32/64)、dropout 比例(0.2 到 0.5 均匀采样)。这三个参数交互影响模型容量和正则化强度——CNN 核数太多,空间特征提取过度,会把相邻站点的噪声也学进来;LSTM 单元太多,训练慢且容易过拟合;dropout 太低,模型在验证集上的泛化能力明显下降。hyperas 的优势在于它不靠人工试错,而是用 TPE 算法(Tree-structured Parzen Estimator)基于历史评估结果偏向采样“看起来有希望”的参数区域,比网格搜索高效得多。
4.2 搜索完成之后:如何把最佳参数回填到最终训练脚本
hyperas 搜索结束后,会打印出最优参数组合,并生成一个best_model对象。但这个对象是在搜索过程中用其中一次采样训练的模型,并不等于“用最优参数重新完整训练”的模型。正确的做法是把搜索到的最佳参数记下来,手动回填到cnn_lstm_final.py的模型构建函数里,然后用全量训练数据(或按比例划分的训练集)重新训练,并保存成cnn_lstm_best.h5。代码包里 Model 目录下已经有了cnn_lstm_best.h5,说明作者跑通了这整个流程。
# 从 hyperas 输出中提取最佳参数(打印结果里会显示) # 例如: cnn_filters=128, lstm_units=32, dropout=0.35 best_params = {"cnn_filters": 128, "lstm_units": 32, "dropout": 0.35} model = build_cnn_lstm_dual( lookback=6, num_sites=num_sites, cnn_filters=best_params["cnn_filters"], lstm_units=best_params["lstm_units"], dropout=best_params["dropout"], ) # 然后执行与上一章同样的训练流程这里我要泼一盆冷水:hyperas 搜出来的“最优参数”,在交通流预测任务上常常只是局部最优,而且对数据划分非常敏感。如果你重新划分一次训练集和验证集,搜出来的最佳参数可能会变。所以我的习惯是:搜索阶段用固定的随机种子(比如 seed=42)保证可复现,找到参数后用 K 折交叉验证再验一次,避免过拟合到验证集的某一条流量曲线段上。代码包没有提供这个交叉验证步骤,但你在真实项目里应该补上——否则部署到新的时间窗口时,效果会明显打折。
另外需要留意的是,hyperas 的搜索过程默认会把训练 epoch 设为很短(比如代码里就是 10),这是为了在有限时间内完成多组参数评估。但短 epoch 下表现好的参数,在长训练下不一定也是最优的——因为有些参数组合收敛快,有些收敛慢但最终效果更好。这是一个天然限制,如果你数据量不大、算力有余,可以把 max_evals 提高到 50 甚至 100,同时把搜索时的 epoch 改成 20,会得到更接近真实最优的组合。
5. 避坑与常见问题:从版本兼容到训练失效的 5 条踩坑记录
5.1 Keras 与 TensorFlow 版本不兼容导致 hyperas 无法运行
现象:运行cnn_lstm_param.py时,在from hyperas import optim这行直接报ImportError,或者加载模型时提示AttributeError: module 'tensorflow' has no attribute 'get_default_graph'。
原因:hyperas 是为 TensorFlow 1.x / Keras 1.x 时代设计的库,它对 Keras 内部 API 有强依赖。如果你用的是 TensorFlow 2.x 并且以tf.keras方式加载,hyperas 内部调用的keras.backend.get_session()已经不存在了,自然跑不起来。
解决:最稳妥的做法是创建虚拟环境,安装tensorflow==1.15和匹配的keras==2.2.4,在这个环境里只做超参数搜索,搜索完把结果参数记下来,回到新版 TensorFlow 环境里做正式训练。如果不想装旧环境,也可以绕开 hyperas,手动用itertools.product生成参数组合做小规模网格搜索——效果差不多,代码量也就多几十行。
5.2 训练过程 loss 正常下降,但验证集 MAE 曲线先降后升,呈典型过拟合
现象:模型训练到第 30 个 epoch 时训练损失还在下降,但验证集 MAE 已经从 15 掉头涨到 23,且此后一路攀升。
原因:双分支模型参数量偏大(尤其是 CNN 分支加上 LSTM 分支的拼接层),而交通流数据量不够大时,模型很容易把训练集里某几天的特定流量形态背下来。代码包默认的 dropout=0.3 在这个结构上不够强。
解决:把 dropout 提高到 0.4~0.5 试试,另外在 LSTM 分支加recurrent_dropout=0.2——注意普通的 dropout 只作用在输入到 LSTM 的变换上,recurrent_dropout才作用在 LSTM 内部状态的时间传递上,对抑制时序过拟合更有效。如果还不行,就减少lstm_units到 16,或者在全连接层之间再加一层 Dropout(0.3)。
5.3 预测结果整条曲线往右偏移了将近一个时间步(滞后现象)
现象:预测流量与真实流量对比,峰值出现的时间点总是晚一个时间步,曲线形态很像但整体右移。
原因:这不是模型逻辑错误,而是数据切片时输入窗口和输出标签没有对齐。常见原因是滑窗生成样本时,输入取的是X[i: i+lookback],而输出取的是y[i+lookback-1]而不是y[i+lookback],导致预测目标其实是“当前时刻的流量”,模型学到的映射是“用过去预测现在”,天然带滞后。
解决:在生成样本时仔细检查偏移量。以代码包的例子,lookback=6 预测未来 2 步,输入应取flow[t-5: t+1](6 个时刻),标签应取flow[t+2](未来第 2 个时刻)。用下面的方式验证:X[len-1]对应的时间戳落点,必须晚于标签对应的时间戳。
def make_samples(flow, lookback=6, horizon=2): X, y = [], [] for t in range(lookback, len(flow) - horizon): X.append(flow[t - lookback: t]) y.append(flow[t + horizon]) return np.array(X), np.array(y)你可以在样本生成后打印第一组X[0][-1]和y[0]对应的时间戳差值,确保是horizon * 时间间隔而非 0。这一步是时间序列预测最容易翻车的地方,没有之一。
5.4 训练好模型后加载 h5 文件报结构不匹配
现象:用load_model("Model/cnn_lstm_final.h5")重新加载时,报ValueError: You are trying to load a weight file containing 5 layers into a model with 3 layers之类的错误。
原因:加载模型时没有带上自定义层或者模型结构定义。如果你在模型构建里用了Lambda层或自定义损失函数,load_model需要额外传custom_objects;另一种更常见的原因是只保存了权重(save_weights),而不是完整模型(save),加载时只恢复了参数没恢复结构。
解决:如果.h5文件是完整模型(代码包里 Model 目录下除了 h5 还有对应的 json 文件),用model_from_json先加载结构再load_weights载入权重,这样最保险。
import json from tensorflow.keras.models import model_from_json with open("Model/cnn_lstm_final.json", "r") as f: model = model_from_json(json.load(f)) model.load_weights("Model/cnn_lstm_final.h5")5.5 多位点输入时站点顺序不一致导致 train 和 predict 结果对不上
现象:在训练脚本里矩阵列顺序是站点 A、B、C、D、E,预测时新数据按字母序或读取顺序自动排列,结果模型输出与实际严重不符,但换回训练顺序又恢复正常。
原因:模型卷积核学的是“特定顺序下相邻站点的空间模式”。站点顺序一变,所谓“相邻”就变了语义——原本是物理上下游关系,被重新排列后变成随意的字母序关系,空间特征全部失效。
解决:把站点顺序固定写到一个配置文件里(比如代码包里的cnn_lstm_param.py或 README 中),预测时按同一顺序读取和排列输入矩阵。如果一个站点在预测时缺数据,不要丢掉那一列,而是保留列位置、填充 0(或前向填充),保证列数不变。这条经验是很多人在模型上线后踩到的第 5 个坑,也是我觉得代码包最应该加而没加的部分。
6. 模型验证与对比:把预测结果画出来,再用误差指标判断该不该换模型
6.1 加载最优模型做测试集预测,并打印多模型对比
代码包里的compare.py干的就是这件事:加载已经训练好的多个模型——lstm.h5、cnn.h5、sae.h5、cnn_lstm_final.h5——在同一个测试集上分别做预测,计算 MAE、RMSE 和 MAPE,直观展示 CNN+LSTM 对比单模型的收益。这个脚本的价值在于它告诉你“衡量预测模型的正确方式”:不是看训练集拟合得多好,而是看没见过的未来时间段预测得准不准。
from tensorflow.keras.models import load_model import numpy as np models = { "LSTM": "Model/lstm.h5", "CNN": "Model/cnn.h5", "SAE": "Model/sae.h5", "CNN+LSTM": "Model/cnn_lstm_final.h5", } results = {} for name, path in models.items(): model = load_model(path) preds = model.predict(X_test).reshape(-1) # 反归一化 preds = scaler.inverse_transform(preds.reshape(-1, 1)).reshape(-1) true = scaler.inverse_transform(y_test.reshape(-1, 1)).reshape(-1) mae = np.mean(np.abs(preds - true)) rmse = np.sqrt(np.mean((preds - true) ** 2)) mape = np.mean(np.abs((preds - true) / (true + 1e-6))) * 100 results[name] = {"MAE": mae, "RMSE": rmse, "MAPE": mape} for name, metrics in results.items(): print(f"{name}: MAE={metrics['MAE']:.2f}, RMSE={metrics['RMSE']:.2f}, MAPE={metrics['MAPE']:.2f}%")注意,inverse_transform前必须把预测值保持与训练时相同的形状(即(样本数, 1)),否则 sklearn 的 scaler 会报维度错误或者按错误轴反归一化。这类脚本跑通后,你会经常遇到 MAE 相差很小的情况(比如 LSTM 是 12.8,CNN+LSTM 是 11.9),这时不要急着下结论说新模型“更好”——需要进一步看预测曲线在峰值时段的表现,比如计算高峰时段(早晚高峰 4 小时窗口)的子集误差。模型在平均值上相近,但在峰值上往往差异显著,而高峰流量对交通管理决策才是更关键的。
6.2 画时间序列对比图:不能只看误差数值,要看到曲线拟合的“相位”
代码包里生成的cnn_lstm.png和threemodels.png就是把预测结果和真实流量画在同一张时间轴上。我强烈建议你拿到资源后自己重新生成一次对比图,并加入一下两个判断角度:
第一,看峰值是否“削平”。流量预测模型最常见的缺陷是预测高峰时偏低、低峰时偏高,整体被拉向均值。如果 CNN+LSTM 的预测曲线在 7:00~9:00 的峰值仍然比真实值低一截,说明模型容量或输入窗口的长度不够——这时考虑把 lookback 从 6 提高到 12(1 小时),给模型更多时间上下文去捕捉早高峰的上升趋势。
第二,看波峰出现的时间是否错位。如果你发现预测峰值虽然高度差不多,但时间上早或晚 1~2 个时间步,这通常是 LSTM 的长期记忆和 CNN 局部特征的融合权重不好,可以尝试在融合层。就是两个分支输出拼接后的全连接层之前,给两个分支各自分配一个可学习的加权参数(类似 attention)——但这是进阶改动,代码包里没有实现,有兴趣你可以自己加。
这最后一步我通常还会做一件事:把测试集按星期几分组重新计算误差。因为工作日和周末的流量规律完全不同,如果模型在工作日表现好、在周末误差大,说明训练数据里周末样本占比太少,需要在数据准备时对周末做个过采样或调整验证集划分方式。
6.3 评估后的模型落盘与管理:版本命名与参数记录的存档习惯
当你把代码包全部跑通并验证完,最后一个值得养成的习惯是关于模型文件的命名和记录。代码包里的名字是cnn_lstm_final.h5、cnn_lstm_best.h5、lstm.h5这样,但如果你迭代几次之后,很容易分不清哪个是最终版。我建议在每次训练完保存模型时,同时导出一个config.json记录这次训练的关键参数:lookback、horizon、cnn_filters、lstm_units、dropout、batch_size、epochs、训练 MAE、验证 MAE、测试集划分时间段。这样当你三个月后回来看这份资源时,看到cnn_lstm_best.h5.json就能立刻知道它是什么条件下训出来的,不需要重新跑一遍脚本去猜。这也算是一种“后悔药”——模型迭代翻车了,你还能快速回到上一个可靠版本重新对比,而不是从头开始。从那以后我每次训练完都会强制走一遍“记录参数、导图看曲线、保存 JSON 配置”这三件事,这个习惯让我在多次项目迭代里少走了很多弯路。希望这篇文章能帮你在跑通这份代码包时少踩几个坑。
本文还有配套的精品资源,点击获取