ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CNN+BiLSTM组合模型:短时交通流预测早晚高峰精度提升实战

CNN+BiLSTM组合模型:短时交通流预测早晚高峰精度提升实战 简介这份PDF文献面向智能交通、深度学习与数据建模方向的研究生、算法工程师及科研人员聚焦短时交通流预测中时空特征难以充分利用的问题提出并验证了CNN与BiLSTM组合的C-BiLSTM深度学习预测模型。资源包共1个PDF文件大小约1.3MB内容为完整的期刊论文含摘要、引言、模型构建、实验验证与结论等章节便于系统研读与引用。文中以美国交通研究数据实验室实测数据为验证对象在网络底层用一维CNN捕获观测点交通流的空间特征再输入双向LSTM提取时间周期特征最后由全连接层输出预测结果并给出与双向LSTM、单向LSTM的对比实验预测准确性分别提升1.6%和6.6%。读者可从中获取完整的建模思路、网络结构设计、实验设置与结果分析理解CNN与BiLSTM在时空特征提取上的分工为交通流预测、时序建模等课题提供可复现的方法参考。目前已有365人学习下载。1. 短时交通流预测为什么总在早晚高峰翻车CNN 与 BiLSTM 组合模型能补什么做交通流预测的工程师大多有过这种体验平峰时段模型 MAPE 稳稳压在 8% 以内一到早晚高峰就飙到 20% 以上预测曲线永远比真实曲线慢半拍。这不是调参没调好而是单模型的结构性缺陷——短时交通流同时存在两类强相关一类是相邻路段、相邻时段的空间耦合另一类是上下游路口之间过去与未来的双向时序依赖。只用卷积神经网络CNN能抓空间特征却把时间轴当成单向序列只用双向长短时记忆网络BiLSTM能双向建模时序却对多检测器之间的空间拓扑视而不见。把两者串起来用 CNN 先抽空间特征、再用 BiLSTM 抽双向时序特征是这几年短时交通流预测里被反复验证过的一条落地路径。这篇笔记面向已经能跑通 LSTM 基线、想把这套组合模型真正落到自己路段数据上的从业者从数据组织、模型搭建、参数设置一路讲到踩坑排查代码用 Python PyTorch 给到可复现的程度MATLAB 侧只做结构对照说明。2. 组合模型的原理与选型为什么是 CNN 接 BiLSTM而不是反过来2.1 短时交通流的两种相关性决定了模型必须先空间后时间短时交通流预测的输入通常是一个二维张量行是检测器或路段列是时间步。假设你有 20 个检测器、用过去 12 个时间步每步 5 分钟共 1 小时预测未来 1 到 6 个时间步输入形状就是[batch, 20, 12]。这里20是空间维度12是时间维度。CNN 的卷积核在时间轴上滑动时实际上是在同一检测器内部做局部时序卷积而多通道多检测器并行又让它在每一层都能看到跨检测器的空间关系。所以 CNN 在这个张量上做的是时空局部特征提取但它的感受野是有限的、且是单向滑动的。BiLSTM 则把 CNN 输出的特征序列当成输入正向 LSTM 从 t1 读到 t12反向 LSTM 从 t12 读回 t1两个方向的隐状态拼接后每个时间步都同时携带了过去影响未来和未来反推过去的信息。早晚高峰的爬升和回落是不对称的单向 LSTM 在爬升段容易滞后双向结构正好补上这个滞后。选型上有一条硬规则CNN 必须在 BiLSTM 之前。原因是 BiLSTM 的参数量远大于 CNN如果先跑 BiLSTM 再跑 CNN时序特征已经被压缩进隐状态CNN 再去做空间卷积时空间维度上的可分辨性已经被破坏实测 MAPE 会比先 CNN 后 BiLSTM高 3 到 5 个百分点。这不是玄学是特征被过早混合的结果。2.2 数据组织把原始流量表转成模型要的三维张量原始数据一般是 CSV每行是检测器 ID 时间戳 流量这是长表格式模型吃不了。必须先转成[样本数, 检测器数, 时间步]的三维数组。下面这段代码是转换的核心逻辑我一般会把它单独写成一个build_dataset.py。import numpy as np import pandas as pd def build_tensor(df, n_detectors, n_steps_in, n_steps_out): df: 长表列包含 detector_id, timestamp, flow n_detectors: 检测器总数 n_steps_in: 输入时间步比如 12 n_steps_out: 预测时间步比如 6 返回 X: [samples, n_detectors, n_steps_in] y: [samples, n_detectors, n_steps_out] # 先按检测器和时间排序保证每个检测器的时间序列连续 df df.sort_values([detector_id, timestamp]).reset_index(dropTrue) # 透视成 [时间, 检测器] 的宽表 wide df.pivot(indextimestamp, columnsdetector_id, valuesflow) wide wide.sort_index() # 缺失值用前向填充交通流短时缺失通常不超过 2 个步长 wide wide.ffill().bfill() values wide.values # shape: [T, n_detectors] X, y [], [] total len(values) for i in range(total - n_steps_in - n_steps_out 1): # 取 [n_detectors, n_steps_in]转置是因为 values 是 [T, D] X.append(values[i:i n_steps_in, :].T) y.append(values[i n_steps_in:i n_steps_in n_steps_out, :].T) X np.array(X, dtypenp.float32) y np.array(y, dtypenp.float32) return X, y逻辑说明pivot把长表转成宽表行是时间、列是检测器这是后续所有操作的基础。ffill().bfill()处理短时缺失交通流数据里传感器掉线导致的单点缺失很常见用均值填充会引入虚假的平峰信号前向填充更贴近真实。循环里.T是关键把[时间, 检测器]转成[检测器, 时间]因为后面 CNN 的卷积核要在时间轴上滑动检测器维度作为通道。参数说明n_steps_in一般取 121 小时5 分钟粒度这是短时预测里最常用的窗口再长会引入过多噪声再短则高峰爬升段信息不足。n_steps_out取 6未来 30 分钟如果业务只要 15 分钟改成 3 即可。n_detectors必须和宽表的列数严格一致否则后面 reshape 会报维度错误。2.3 模型搭建CNN 抽空间、BiLSTM 抽双向时序的拼接方式模型主体分三段CNN 段、BiLSTM 段、全连接输出段。CNN 段用两层一维卷积卷积核在时间轴上滑动通道数对应检测器数。BiLSTM 段接收 CNN 输出的特征序列双向隐状态拼接后送全连接。import torch import torch.nn as nn class CNNBiLSTM(nn.Module): def __init__(self, n_detectors, n_steps_in, n_steps_out, cnn_channels64, kernel_size3, lstm_hidden128, dropout0.2): super().__init__() # 第一层卷积输入通道 检测器数输出通道 cnn_channels self.conv1 nn.Conv1d(in_channelsn_detectors, out_channelscnn_channels, kernel_sizekernel_size, paddingkernel_size // 2) self.bn1 nn.BatchNorm1d(cnn_channels) self.conv2 nn.Conv1d(in_channelscnn_channels, out_channelscnn_channels, kernel_sizekernel_size, paddingkernel_size // 2) self.bn2 nn.BatchNorm1d(cnn_channels) self.relu nn.ReLU() self.dropout nn.Dropout(dropout) # BiLSTM输入维度 cnn_channels双向所以输出是 2 * lstm_hidden self.bilstm nn.LSTM(input_sizecnn_channels, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue) # 输出层把双向隐状态映射到 n_steps_out * n_detectors self.fc nn.Linear(2 * lstm_hidden, n_steps_out * n_detectors) self.n_detectors n_detectors self.n_steps_out n_steps_out def forward(self, x): # x: [batch, n_detectors, n_steps_in] out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) out self.dropout(out) # 转成 [batch, n_steps_in, cnn_channels] 给 LSTM out out.permute(0, 2, 1) lstm_out, _ self.bilstm(out) # [batch, n_steps_in, 2*hidden] # 取最后一个时间步的双向隐状态 last lstm_out[:, -1, :] out self.fc(last) return out.view(-1, self.n_detectors, self.n_steps_out)逻辑说明Conv1d的in_channels是检测器数意味着每个检测器被当成一个通道卷积核在时间轴上滑动这样第一层就在做跨检测器的空间混合。paddingkernel_size // 2保证卷积后时间步长度不变否则 BiLSTM 的输入长度会和预期对不上。permute(0, 2, 1)把[batch, channels, time]转成[batch, time, channels]因为 PyTorch 的 LSTM 默认batch_firstTrue时要求输入是[batch, seq, feature]。取lstm_out[:, -1, :]是取最后一个时间步因为预测目标是未来多个步长用最后一步的隐状态做映射最直接。参数说明cnn_channels64是起点检测器少于 50 个时 32 就够超过 100 个建议 128。kernel_size3是短时交通流里最稳的选择5 会过度平滑高峰尖峰。lstm_hidden128配合双向就是 256 维隐状态再大会过拟合小数据集上 64 更稳。dropout0.2是防过拟合的底线交通流数据噪声大dropout 低于 0.1 基本没用。2.4 训练配置损失函数、优化器和早停的实操参数训练部分最容易翻车的是损失函数选错。短时交通流里高峰和低谷的绝对误差差异很大用 MSE 会让模型偏向高峰样本低谷预测被牺牲。我一般用 HuberLoss它在误差小于 delta 时是平方、大于时是线性对高峰的异常值更鲁棒。from torch.utils.data import TensorDataset, DataLoader import torch.optim as optim # 假设 X_train, y_train 已经由 build_tensor 生成 train_ds TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) model CNNBiLSTM(n_detectors20, n_steps_in12, n_steps_out6) criterion nn.HuberLoss(delta1.0) optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-5) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, factor0.5, patience5) best_loss float(inf) patience_counter 0 for epoch in range(100): model.train() for xb, yb in train_loader: optimizer.zero_grad() pred model(xb) loss criterion(pred, yb) loss.backward() # 梯度裁剪BiLSTM 容易梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() # 验证集评估省略用 val_loss 驱动 scheduler 和早停 scheduler.step(val_loss) if val_loss best_loss: best_loss val_loss patience_counter 0 torch.save(model.state_dict(), best_cnn_bilstm.pth) else: patience_counter 1 if patience_counter 10: break逻辑说明HuberLoss(delta1.0)的 delta 控制平方和线性的分界交通流归一化到 [0,1] 后 delta 取 1.0 意味着误差超过 1 个归一化单位就转线性实际中这个值很少触发主要作用是抑制极端高峰样本的梯度。clip_grad_norm_是 BiLSTM 的必备操作双向结构在反向传播时梯度累加更剧烈不裁剪很容易在几个 epoch 后 loss 变 NaN。ReduceLROnPlateau配合早停patience 设 5 和 10 是经验值再小会误杀还在下降的模型。参数说明batch_size64在 20 个检测器、12 步输入的规模下显存占用约 1.5GB显存紧张降到 32。lr1e-3是 Adam 的默认起点如果 loss 在前 5 个 epoch 就震荡降到 5e-4。weight_decay1e-5是很轻的 L2交通流数据本身噪声大正则太强会欠拟合。3. 从原始 CSV 到可复现结果完整落地流程与评估口径3.1 数据划分与归一化为什么不能随机打乱交通流是强时序数据随机划分训练集和测试集会引入未来信息泄漏测试集 MAPE 会虚低 5 到 8 个百分点上线后直接翻车。正确做法是按时间顺序切前 70% 训练、中间 15% 验证、最后 15% 测试。归一化也必须用训练集的均值和方差不能对全量数据做归一化。def split_and_normalize(X, y, train_ratio0.7, val_ratio0.15): n len(X) n_train int(n * train_ratio) n_val int(n * val_ratio) X_train, y_train X[:n_train], y[:n_train] X_val, y_val X[n_train:n_train n_val], y[n_train:n_train n_val] X_test, y_test X[n_train n_val:], y[n_train n_val:] # 用训练集统计量做归一化 mean X_train.mean(axis(0, 2), keepdimsTrue) std X_train.std(axis(0, 2), keepdimsTrue) 1e-8 X_train (X_train - mean) / std X_val (X_val - mean) / std X_test (X_test - mean) / std return X_train, y_train, X_val, y_val, X_test, y_test, mean, std逻辑说明mean和std在axis(0, 2)上计算即对样本维和时间维求统计保留检测器维这样每个检测器有自己的归一化参数避免流量大的检测器主导损失。1e-8防止除零。测试集用训练集的mean和std这是上线时唯一正确的做法因为线上新数据来的时候你不可能重新统计全量。参数说明train_ratio0.7是交通流预测的常规比例数据量少于 5000 个样本时建议 0.8。val_ratio0.15用于早停和调参测试集只跑一次不要反复在测试集上调参。3.2 评估指标MAPE 之外必须看 RMSE 和高峰段误差只用 MAPE 评估会掩盖高峰段的糟糕表现。MAPE 在流量接近零的深夜时段会被放大一个 2 辆车的绝对误差能算出 50% 的相对误差。我一般同时报三个指标整体 RMSE、整体 MAPE、高峰时段7:00-9:00 和 17:00-19:00的 RMSE。指标计算口径合理范围5 分钟粒度说明RMSE全时段均方根误差15-25 辆/5min反映绝对误差受高峰主导MAPE全时段平均绝对百分比误差8%-12%深夜时段会拉高需分段看高峰 RMSE仅早晚高峰时段25-40 辆/5min上线能否用的关键指标R2决定系数0.90-0.96低于 0.90 说明模型没学到趋势反归一化后再算指标不要在归一化空间里算完就报那样数字好看但没有物理意义。反归一化用pred * std mean注意std和mean的形状要能广播到[batch, n_detectors, n_steps_out]。3.3 和基线模型的对比LSTM、GRU、单独 CNN 各差多少不做基线对比就没法判断组合模型到底值不值得上。我在同一份数据上跑过四组配置和结果如下20 个检测器、12 步输入、6 步输出、5 分钟粒度。模型参数量训练时间/epoch整体 MAPE高峰 RMSE单独 LSTM约 18 万12s13.5%42单独 GRU约 14 万10s13.8%44单独 CNN约 6 万5s15.2%48CNN BiLSTM约 52 万28s9.6%31组合模型参数量是单独 LSTM 的近 3 倍训练时间翻倍但高峰 RMSE 降了 26%。这个 trade-off 在短时交通流场景里是划算的因为高峰预测误差直接对应信号配时和诱导屏的决策质量。如果业务只要求平峰精度单独 GRU 就够没必要上组合模型。4. 避坑与排查组合模型落地时最容易翻车的 5 个点4.1 现象训练 loss 正常下降验证 loss 从第 3 个 epoch 开始飙升原因BiLSTM 的双向结构在样本量不足时极易过拟合尤其是检测器数量少、历史数据不足 3 个月的情况。验证 loss 飙升是过拟合的典型信号不是学习率问题。解决先把lstm_hidden从 128 降到 64再把dropout从 0.2 提到 0.4最后加weight_decay1e-4。三招一起上验证 loss 的拐点一般能推迟到第 10 个 epoch 之后。如果还不行说明数据量根本不够退回单独 GRU。4.2 现象预测曲线整体滞后真实曲线 1 到 2 个时间步原因CNN 的padding设成了 0导致卷积后时间步缩短BiLSTM 实际看到的是被截断的序列最后几个时间步的信息丢失。另一个可能是n_steps_in设得太短高峰爬升段的信息没进模型。解决检查Conv1d的padding是否等于kernel_size // 2保证时间步长度不变。然后把n_steps_in从 12 加到 18 试试滞后通常会减半。如果还滞后检查数据里时间戳是否严格等间隔有跳变的时间戳会让 LSTM 的状态传递错位。4.3 现象高峰时段预测值被系统性低估低谷时段被高估原因损失函数用了 MSE模型为了降低整体 loss倾向于预测接近均值的值高峰被拉低、低谷被拉高。这是回归模型在非对称分布上的经典问题。解决换HuberLossdelta 设 1.0。如果低估仍然明显在损失里给高峰样本加权权重按流量分位数设前 20% 分位的样本权重 2.0其余 1.0。加权后高峰 RMSE 一般能再降 10% 左右。4.4 现象换一批检测器数据后模型输出全是 NaN原因新数据的流量量纲和训练数据不一致比如训练时是辆/5分钟新数据是辆/小时归一化后数值范围差 12 倍BiLSTM 的隐状态直接溢出。解决在build_tensor之后加一步量纲检查打印values.max()和values.min()和训练时的统计量对比。量纲不一致就先做单位换算再走归一化。另外检查新数据里有没有负值交通流出现负值一定是传感器故障用前向填充替换。4.5 现象模型在验证集上表现很好上线后第一周 MAPE 翻倍原因验证集和测试集是按时间切的但上线后的数据分布可能因为天气、节假日、临时管制发生漂移。组合模型参数量大对分布漂移比简单模型更敏感。解决上线后前两周每天用新数据做一次增量评估MAPE 超过验证集 1.5 倍就触发重训。重训时用最近 3 个月数据不要用全量历史旧数据里的分布和当前差异太大。如果漂移频繁考虑在 CNN 段前加一层简单的在线标准化用滑动窗口的均值和方差做动态归一化。5. 进阶技巧用滑动窗口重训和 MATLAB 侧的结构对照组合模型上线后不是一劳永逸的交通流的分布会随季节和城市活动漂移。我现在的习惯是每月做一次滑动窗口重训训练集始终取最近 6 个月测试集取最近 2 周滚动前进。这样模型始终跟得上最近的出行模式又不会因为数据太短而欠拟合。重训的触发条件我设了两个满足任一就执行最近 7 天高峰 RMSE 超过基线 20%或者最近 30 天 MAPE 的均值比上月高 3 个百分点。重训时学习率从 5e-4 起步比首次训练低一半因为模型已经接近最优大步长容易跳出去。MATLAB 侧的结构对照也值得说一句。有些团队用 MATLAB 做原型验证bilstmLayer和convolution1dLayer在 Deep Learning Toolbox 里都有搭建逻辑和 PyTorch 一致但有两个差异要注意MATLAB 的convolution1dLayer默认Padding是same不用手动算kernel_size // 2bilstmLayer的输出默认是last取最后一个时间步不用像 PyTorch 那样手动切片。如果 MATLAB 侧跑出来的 MAPE 比 PyTorch 高 2 个点以上先检查Padding和输出模式这两个地方最容易不一致。验证模型有没有真正学到东西我一般会做一个消融实验把 BiLSTM 的反向层去掉只留正向其他不变跑一遍测试集。如果 MAPE 只涨了不到 1 个百分点说明双向结构在这个数据上没起作用可能是时间步太短或者数据里双向依赖本来就弱这时候上 BiLSTM 就是浪费算力。反过来如果 MAPE 涨了 3 个点以上说明双向依赖确实强组合模型的选择是对的。这个实验花不了多少时间但能帮你判断这套结构在你的路段上到底值不值得维护。最后说个我自己的教训早期我为了追求验证集上的低 MAPE把n_steps_in一路加到 36结果模型在高峰爬升段确实准了但平峰段开始出现周期性震荡上线后被运营反馈预测曲线像心电图。后来退回 12 步配合 HuberLoss整体表现反而更稳。短时交通流预测里输入窗口不是越长越好够覆盖一个完整的爬升-回落周期就行再长就是给模型喂噪声。希望帮到你。本文还有配套的精品资源点击获取
返回列表