纽约出租车流量预测:基于LSTM/GRU/CNN的时序模型实现

纽约出租车流量预测:基于LSTM/GRU/CNN的时序模型实现 简介基于深度学习的纽约出租车流量预测项目包含完整Python源码与文档说明主要面向计算机相关专业正在完成期末大作业、毕业设计以及需要项目实战练习的学习者。项目经导师指导并认可评审分98分源码均在本地编译调试通过可放心运行。资源共31个文件压缩包约1.21MB涵盖9个Python源文件、2个NPZ数据文件、6个XML配置项、3张训练结果图及DOCX/MD说明文档等文件类型覆盖代码、数据、文档与配置体系完整。代码部分包括数据加载、模型构建、训练与可视化模块提供GRU、LSTM、CNN-GRU、CNN-LSTM等模型实现便于对比不同深度学习网络的预测效果数据文件为纽约出租车流量数据集文档包含数据说明与使用指南目录结构清晰方便按模块查阅与复现项目难度适中内容已经助教审定能够满足学习与使用需求。当前已有73人学习浏览适合需要完整方案参考、快速上手深度学习时序预测项目的读者下载使用。1. 从课程设计到可复现的流量预测为什么选择纽约出租车数据第一次拿到“纽约出租车流量预测”这个题目时我下意识以为重点会放在调参刷精度上。真正把这套基于深度学习的完整流程跑通后才发现最大的门槛其实在大作业的三个环节数据组织方式、序列模型选型、训练指标观察。用纽约出租车上下车记录聚合出的流量张量配合 LSTM、GRU以及加入 CNN 分支的混合网络构成一个很适合复现和答辩的基线系统。它适合正在做人工智能课程设计、期末大作业的同学也适合想把手里的 python 时序预测代码从单模型往多模型对比方向扩展的开发者。源码按模块拆成了 main.py、configuration.py、data_loader.py、func.py、draw.py模型分别放在 model 目录下边界清楚改起来不费劲。2. 数据加载与流量张量预处理volume_train.npz 与 data_loader.py2.1 流量张量的组织方式与字段含义项目提供的 NYC-stdn 目录下是训练和测试用的压缩数据volume_train.npz 与 volume_test.npz。NPZ 是 numpy 官方的压缩存储格式用 np.load 就能读出来典型的字段结构如下表所示字段名形状含义volume(T, H, W, C)按时间组织的流量张量T 是时间片数量H 和 W 是空间网格划分后的高宽C 是通道数time(T,) 或 (T, 2)每个时间片对应的时间标记可能包含星期几和一天中的时段grid(H, W)网格经纬度或索引信息用于空间定位实际读取时建议先打印形状而不是直接开始建模。常见做法是import numpy as np train_data np.load(NYC-stdn/volume_train.npz) test_data np.load(NYC-stdn/volume_test.npz) print(train_data.files) print(train_data[volume].shape, test_data[volume].shape)逻辑说明先通过 files 查看存在哪些键再检查 volume 的形状。通道 C 通常是两类流量上车量和下车量也可能包含速度或载客状态具体以数据说明.docx 里给出的通道顺序为准。这个动作虽然简单但能避免后面把测试集和训练集拼错也能确认网格分辨率是否和模型输入维度一致。参数说明T 在训练集和测试集里可能不同H、W、C 必须保持一致否则模型权重无法迁移。2.2 滑动窗口采样与归一化的实现流量预测本质上是时间序列预测不能直接把整个张量扔进模型。需要从连续的时间片上切出等长的窗口前 window 个时间片作为输入后 horizon 个时间片作为预测目标。data_loader.py 的核心工作就是这个滑动窗口切片我通常会在原有逻辑之外补一层 z-score 归一化因为原始流量数据在早晚高峰和深夜的数值差距很大直接进 LSTM 容易让梯度被极大值主导import numpy as np class TrafficDataset: def __init__(self, npz_path, window6, horizon1): data np.load(npz_path) volume data[volume] self.window window self.horizon horizon # 按通道分别归一化 self.mean volume.mean(axis(0, 1, 2), keepdimsTrue) self.std volume.std(axis(0, 1, 2), keepdimsTrue) 1e-6 self.features (volume - self.mean) / self.std self.samples, self.labels self._build_sequence() def _build_sequence(self): samples, labels [], [] total len(self.features) for i in range(total - self.window - self.horizon 1): x self.features[i: i self.window] y self.features[i self.window: i self.window self.horizon] samples.append(x) labels.append(y) return np.stack(samples), np.stack(labels) def __len__(self): return len(self.samples) def __getitem__(self, idx): return self.samples[idx], self.labels[idx]逻辑说明init里先计算每个通道的均值和标准差再用广播方式做标准化。手段是加载全部流量张量然后按时间轴滑动生成样本集。_build_sequence 中的样本形状是 (window, H, W, C)标签形状是 (horizon, H, W, C)。很多课程设计里的 bug 是因为标签直接用了原始值而输入用了归一化值导致模型训练时 loss 下降很快实际指标却异常差。参数说明window 推荐 6对应过去 6 个时间片如果你每 30 分钟聚合一次那就是过去 3 小时horizon 为 1 时是单步预测如果想要预测未来 2 小时可以调整成 4 并修改损失函数。2.3 数据集划分与常见错误npz 里已经区分了训练集和测试集但实际训练时仍要从训练集中再切一部分出来做验证否则无法判断模型是否过拟合。常见做法是按 8:2 的比例在时间轴上顺序切分不能随机打乱否则会把前一天的样本混到验证集里造成信息泄露。我一般会在 data_loader 里增加一个 split_ratio 参数在构造训练集时直接返回 train_loader 和 val_loadertrain_dataset TrafficDataset(NYC-stdn/volume_train.npz, window6, horizon1) split_idx int(len(train_dataset) * 0.8) from torch.utils.data import Subset, DataLoader train_loader DataLoader(Subset(train_dataset, list(range(split_idx))), batch_size64, shuffleTrue) val_loader DataLoader(Subset(train_dataset, list(range(split_idx, len(train_dataset)))), batch_size64, shuffleFalse)逻辑说明Subset 在 PyTorch 中用于包装原始数据集这里通过下标范围完成顺序切分。train_loader 设置 shuffleTrue 是为了让每个 batch 的样本分布更接近整体val_loader 保持原始时间顺序方便观察曲线走势。参数说明list(range(split_idx)) 会生成 0 到 split_idx-1 的索引序列batch_size 在实际训练中与显存容量相关64 是这套源码的默认值。常见错误是直接对 npz 做全局随机切分测试时会出现模型“看过未来”的假象答辩时一旦被追问数据切分细节很容易扣分。提示如果训练集和测试集的均值方差差异过大可以在加载测试集时复用训练集的 mean 和 std而不是重新计算。否则预测结果会被归一化参数偏移影响。3. 模型对比LSTM、GRU、CNN-LSTM 与 CNN-GRU 的选型逻辑3.1 四个模型在时间与空间建模上的差异model 目录下放着 gru.py、lstm.py、cnn_gru.py、cnn_lstm.py 四个模型文件它们之间不是简单的“换一个层”的区别。LSTM 和 GRU 负责时间维度的依赖建模但单独使用时它们把每个空间网格当作独立的特征通道并没有感知邻近网格之间的空间关系。CNN-GRU 和 CNN-LSTM 则在时间层之前或之后加入卷积层让模型先对空间网格做局部特征提取再把提取后的空间特征送入循环网络进行时间建模。模型时间建模空间建模参数量训练速度适用场景LSTMLSTM 单元无显式空间卷积中等快只关注时间依赖的基线GRUGRU 单元无显式空间卷积少于 LSTM较快数据量小防止过拟合CNN-LSTMLSTM 单元两层 Conv2d较大慢同时捕捉空间局部性和长周期依赖CNN-GRUGRU 单元两层 Conv2d小于 CNN-LSTM中等想在混合模型里减少参数从项目的 images 目录可以看到四个模型对应的训练曲线图文件名后缀 lr0.001_b64_h64_d0.5 直接标出了当时统一的超参数学习率 0.001、batch size 64、隐藏层维度 64、dropout 0.5。这个设计是有意的四个模型在完全相同的实验条件下对比才能判断网络结构本身带来的差异。3.2 核心代码从 LSTM 到 CNN-LSTM 的改造这里以 lstm.py 和 cnn_lstm.py 为例。LSTM 模型直接把输入张量展平成序列然后通过 LSTM 层输出预测import torch.nn as nn class LSTM(nn.Module): def __init__(self, in_channels, hidden_size, num_layers1, dropout0.5): super().__init__() self.lstm nn.LSTM( input_sizein_channels, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0.0 ) self.fc nn.Linear(hidden_size, in_channels) def forward(self, x): # x: (batch, window, H, W, C) batch, window, h, w, c x.shape x x.view(batch, window, -1) out, _ self.lstm(x) out self.fc(out[:, -1, :]) out out.view(batch, 1, h, w, c) return out逻辑说明x 的原始形状是 (batch, window, H, W, C)LSTM 要求输入维度是 (batch, seq_len, input_size)所以用 view 把 H、W、C 展平成一个向量。时间步 window 作为 seq_len。取最后一帧的隐藏状态通过全连接层映射回原始通道数。注意这里把输出 reshape 回 (batch, 1, H, W, C)表示预测一个未来时间片。参数说明in_channels 是每个空间网格的特征数也就是 npz 里的 Chidden_size 是 LSTM 隐状态维度在文件名中对应 h64num_layers 默认 1加深层数会显著降低训练速度。CNN-LSTM 的改造思路是在进入 LSTM 之前先做空间卷积class CNN_LSTM(nn.Module): def __init__(self, in_channels, hidden_size, seq_len, dropout0.5): super().__init__() self.cnn nn.Sequential( nn.Conv2d(in_channels, 32, kernel_size3, padding1), nn.ReLU(), nn.Conv2d(32, seq_len, kernel_size3, padding1), nn.ReLU(), ) self.lstm nn.LSTM( input_sizeseq_len, hidden_sizehidden_size, batch_firstTrue, dropoutdropout if True else 0.0 ) def forward(self, x): batch, window, h, w, c x.shape # 把时间窗口当作批次维逐帧做卷积 x x.view(batch * window, h, w, c) x x.permute(0, 3, 1, 2) x self.cnn(x) x x.view(batch, window, -1) out, _ self.lstm(x) return out[:, -1, :]逻辑说明先对每个时间帧分别做空间卷积再把卷积结果按时间顺序重新拼成 LSTM 的输入序列。最后输出的最后一个时间步对应预测目标。这里的第二个卷积层把通道数映射到 seq_len是因为 LSTM 的 input_size 需要和输入特征数一致。参数说明kernel_size 设置为 3 对应 3×3 邻域padding 为 1 保证卷积不改变 H 和 W 的尺寸out[:, -1, :] 取 LSTM 最后一个时间步的输出向量如果你要预测多个时间片需要改成返回整个序列的最后一帧全部输出。3.3 参数张量变化与 dropout 位置四个模型的张量流差异决定了调参时的注意力位置。LSTM 和 GRU 的参数量增长主要是 hidden_size 和 num_layers 在起作用而 CNN-LSTM 和 CNN-GRU 的参数量还会受到卷积核数量和 kernel_size 影响。dropout 的位置也值得单独说明循环层内部的 dropout 只在 num_layers 大于 1 时生效所以单个 LSTM 层配上 dropout0.5 实际并不会产生循环层内 dropout只有最后的全连接层前需要额外加 Dropout。源码里 h64_d0.5 的命名方式说明这是一个常见配置如果你在训练曲线上看到验证集 loss 持续高于训练 loss优先检查 dropout 是否真的落在了网络的正确位置。4. configuration.py 与 main.py训练流程、日志与模型保存4.1 configuration.py 里的超参数组织configuration.py 在整套源码里承担了几乎所有训练配置的集中管理避免每个文件里散落魔法数字。之前 images 文件名里的 default 值在代码里通常是这样组织的class Configuration: def __init__(self): self.lr 0.001 self.batch_size 64 self.hidden_size 64 self.dropout 0.5 self.num_epochs 80 self.window 6 self.horizon 1 self.train_npz NYC-stdn/volume_train.npz self.test_npz NYC-stdn/volume_test.npz self.device cuda if torch.cuda.is_available() else cpu self.model_name lstm逻辑说明把模型选择也放进配置类main.py 在启动时读取 model_name再根据字符串动态实例化对应的模型类这样切换模型时不需要改动训练循环。参数说明num_epochs 在实际项目里建议用早停来替代固定值模型可能在 40 轮左右就已经收敛device 根据 CUDA 是否可用自动选择不用在命令行里手动传。4.2 训练循环与 func.py 中的评价指标main.py 的训练循环是整套代码的主干。读取配置、加载数据、构建模型、迭代训练、计算损失、验证评估这几个步骤结构上是标准 PyTorch 训练流程model get_model(cfg.model_name, cfg) model.to(cfg.device) optimizer torch.optim.Adam(model.parameters(), lrcfg.lr) criterion nn.MSELoss() for epoch in range(cfg.num_epochs): model.train() train_loss 0.0 for x, y in train_loader: x x.float().to(cfg.device) y y.float().to(cfg.device) pred model(x) loss criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() * x.size(0) model.eval() val_loss evaluate(model, val_loader, criterion, cfg.device) print(fepoch {epoch 1}/{cfg.num_epochs} train_loss {train_loss / len(train_loader.dataset):.6f} val_loss {val_loss:.6f})逻辑说明每个 epoch 分训练和验证两个阶段。训练阶段模型处于 train 模式dropout 生效验证阶段切换到 eval 模式dropout 关闭反向传播不再执行。loss 通过 MSE 计算因为流量预测的回归目标本身是连续值。func.py 里通常还会补充 MAE、RMSE、MAPE 等指标的计算函数作为模型选型的补充依据。参数说明optimizer 使用 Adam是因为它对学习率的敏感程度比 SGD 低在课程设计里更容易收敛loss.item() 取出当前 batch 的标量值x.size(0) 是 batch 大小用 loss 乘以 batch size 累加是为了计算整个 epoch 的平均损失。4.3 早停、学习率调度和恢复断点固定 80 轮训练不是最优做法。我在跑这套代码时发现CNN-GRU 在验证集上通常在 30 轮之后就开始波动继续训练反而会过拟合。一个更稳妥的做法是早停与模型保存配合当验证集损失连续超过一定轮数不下降时截断训练并恢复历史最优权重best_val float(inf) patience 10 bad_counter 0 for epoch in range(cfg.num_epochs): # ...训练和验证... if val_loss best_val: best_val val_loss bad_counter 0 torch.save(model.state_dict(), fcheckpoints/best_{cfg.model_name}.pth) else: bad_counter 1 if bad_counter patience: print(early stop at epoch, epoch) break逻辑说明每次验证集损失下降时保存一份模型参数连续没下降的次数超过 patience 就结束训练。这样回到最优权重只需要直接加载检查点文件。参数说明patience 设为 10 表示最多容忍 10 轮不改善torch.save 保存的是 state_dict 而不是整个模型加载时需要用相同结构的模型类先实例化再 load_state_dict。log.txt 在项目里就是训练过程的日志输出通过 print 重定向保存方便后面写实验报告时直接引用数据。5. draw.py 与指标曲线从 metrics.png 判断模型是否真的学会5.1 训练曲线里的过拟合信号images 目录下四个 metrics.png 分别是 lstm、gru、cnn_lstm、cnn_gru 的训练曲线。按 lr0.001_b64_h64_d0.5 这一组统一超参跑出来的曲线最能说明问题的通常不是训练集 loss而是验证集 loss 的走势。如果训练 loss 稳定下降验证 loss 在 20 轮后开始反弹说明模型进入了过拟合区间如果两者都在高位震荡可能不是模型问题而是学习率过大。对于 LSTM 这类基础模型常见信号是验证 loss 降得很慢而 CNN-GRU 这类混合模型在前几轮就会出现明显的下降说明空间卷积确实帮助了特征提取。5.2 用 draw.py 复现指标曲线draw.py 做的事情是把训练过程记录下来的 loss 数据画成可视化图表方便答辩时展示。核心逻辑并不复杂import matplotlib.pyplot as plt def draw_metrics(train_losses, val_losses, mae_list, save_pathmetrics.png): fig, axes plt.subplots(1, 3, figsize(15, 4)) axes[0].plot(train_losses, labeltrain loss, color#2c7fb8) axes[0].plot(val_losses, labelval loss, color#d95f02) axes[0].set_xlabel(epoch) axes[0].set_ylabel(MSE loss) axes[0].legend() axes[1].plot(mae_list, labelMAE, color#31a354) axes[1].set_xlabel(epoch) axes[1].set_ylabel(MAE) plt.tight_layout() plt.savefig(save_path, dpi200)逻辑说明draw.py 读取训练过程中记录下的序列数据用 matplotlib 绘制训练集和验证集的 loss 曲线。第三个子图通常放 RMSE也可以换成预测值和真实值的散点对比。数据来源是训练循环里每次 epoch 计算的指标先保存在列表里训练结束后统一绘图。参数说明savefig 里的 dpi 影响图片清晰度课程设计报告中建议设置 200 以上figsize 的 (15, 4) 把三张子图横排适合 PPT 里放成一排展示。5.3 横向对比四个模型的评估维度只看 loss 曲线还不够。func.py 里应该维护一个统一的评估入口在测试集上同时计算 MAE、RMSE、MAPE并记录推理时间。这样四个模型的对比就有了完整的数据支撑。实际使用中MAPE 对接近零的流量值非常敏感深夜某些网格可能长时间没有上下车记录真实值是 0 时 MAPE 会变成无穷大。处理方法是只统计流量大于某个阈值的网格或者改用 SMAE 这类对稀疏数据更友好的指标。指标计算方式对稀疏数据的表现报告中的使用场景MAE绝对误差的平均值稳定受零值影响小常规精度说明RMSE误差平方均值的平方根放大峰值误差突出高峰时段预测不足MAPE百分比误差绝对值平均零值会导致异常值只在流量密集区域使用从项目现有的曲线命名方式推断四个模型在同一组超参数下的对比具有公平性。如果在你的实验中CNN-LSTM 的训练 loss 低于 CNN-GRU但验证 loss 更高说明 CNN-LSTM 的参数量在纽约出租车数据上偏大泛化能力不如 CNN-GRU这个结论本身就可以作为课程设计的重要讨论点。提示保存训练日志时建议同时记录每个 epoch 的 train loss、val loss、当前学习率以及本轮训练耗时。报告里写“模型收敛更快”时这些数据能直接支撑结论。6. 进阶注意力机制与超参数敏感性实验6.1 给 LSTM 加一层时间注意力当你已经能稳定跑通基础实验后下一个值得尝试的改进是在 LSTM 输出层加入时间注意力让模型不再只依赖最后一步的隐状态而是对全部时间步的隐状态加权求和。这个方向在课程设计里属于加分项也贴合当前时序预测的主流做法class AttentionLSTM(nn.Module): def __init__(self, hidden_size): super().__init__() self.attn nn.Linear(hidden_size, hidden_size) self.ctx nn.Linear(hidden_size, hidden_size) def forward(self, lstm_outputs): # lstm_outputs: (batch, seq_len, hidden_size) scores torch.softmax(self.attn(lstm_outputs).sum(dim-1), dim-1) context torch.bmm(scores.unsqueeze(1), lstm_outputs) return context.squeeze(1)逻辑说明对 LSTM 每个时间步的输出计算一个打分经过 softmax 得到归一化权重再按权重加权求和得到上下文向量。相比直接取最后一步这种方式能把较早时间片里“流量开始上升”的信号保留下来。参数说明attn 层把 hidden_size 映射到 hidden_sizesum(dim-1) 是简化的打分方式你也可以用全局池化或额外的全连接层替代。加权后的 context 再接全连接层输出预测结果。注意在超参数上做敏感性实验时不要一次改两个变量。常见做法是固定 lr0.001 和 batch_size64只把 hidden_size 从 64 调整到 128观察验证集 MAE 的变化然后再固定 hidden_size 调 dropout看是 0.3 还是 0.7 更适合当前数据量。每个结果都记录到表格里最后选出一个组合跑测试集并画图。这样写进项目报告能从“代码运行成功”升级成“实验设计完整”。6.2 超参数敏感性与实验记录在纽约出租车数据上dropout 从 0.5 降到 0.3 时训练集 loss 通常下降明显验证集 loss 却可能变差说明模型对随机失活率比较敏感。如果想要更稳定的收敛可以在每次 epoch 结束后按轮数衰减学习率或者使用 CosineAnnealingLR 调度器。学习率从 0.001 平均衰减到 0.0001能帮助模型在后期更精细地逼近最优解运行 80 轮的总训练时间相比固定学习率只增加很少。验证阶段除了计算测试集指标建议额外画一张“预测流量与真实流量对比图”横轴是时间片纵轴是流量值分别画出全天内某个区域网格的真实曲线和模型预测曲线。如果预测值在早高峰出现滞后而晚高峰误差更大就说明模型对突发流量建模不足下一步可以考虑在窗口输入里增加天气或节假日特征。用这套流程你可以把项目从固定模型、固定参数的可运行作品推进到有实验记录、有对比结论、有改进方向的完整深度学习实践。本文还有配套的精品资源点击获取