ARTICLE DETAIL

资讯详情

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

共享单车多站点停放预测:基于PyTorch的LSTM实战解析

共享单车多站点停放预测:基于PyTorch的LSTM实战解析 简介面向城市交通管理单位、共享单车运营商及深度学习实践者这套基于PyTorch框架与长短期记忆网络的多站点共享单车停放量时序预测项目可利用历史骑行数据预测未来各站点用车需求辅助调度与运维决策。压缩包体积仅约92KB共含13个文件以Python脚本为主涵盖模型定义、训练、评估与数据解析另附模型权重文件、训练数据集、依赖清单、说明文档及附赠资料方便快速复现与二次开发。截至目前已有30人学习下载适合具备一定Python和神经网络基础、希望将时序模型应用于真实场景的读者。通过本项目可完整掌握LSTM在交通数据上的应用流程包括数据预处理、网络搭建、参数调优与结果评估附带的说明文档和附赠资源还能帮助理解多站点协同预测的工程实现思路为后续扩展研究提供参考起点。1. 共享单车多站点停放预测为什么这件事非 LSTM 不可共享单车调度一直是城市运维里最头疼的环节早晚高峰站点之间车辆分布极不均匀调度车跑得再勤也总有站点空桩或堆满。市面上的做法大多是靠人工经验或者简单的历史均值估算遇到节假日、天气突变就全线失效。这份基于 PyTorch 深度学习框架与 LSTM 长短期记忆神经网络模型实现的城市共享单车停放数量多站点时序预测系统核心思路是把每个站点的停放数量当作一条时间序列用过去几十个小时的变化规律去预测未来几十个小时的停放量从而指导调度车提前行动。整个资源开箱即跑适合刚接触深度学习时序预测的工程师拿来做基线也适合已经跑通过单站点预测、想扩展到多站点的同学参考数据组装和多站点训练的技巧。2. 先把数据理顺多站点样本构造与滑动窗口设计2.1 原始骑行记录的三个清洗口径拿到共享单车原始数据通常的格式是每 5 分钟一条的站点快照字段包含站点 ID、时间戳、当前停放数量、可用桩位数。问题是裸数据脏得很最典型的三种情况是站点临时关停期间数值一直为 0、传输中断产生大段缺失、同一时间戳出现重复记录。我处理这类数据的第一步是做三件事去重、补缺、对齐时间。去重直接按 (station_id, timestamp) 取最新一条缺失部分用前向填充再加一个滑动平均平滑时间对齐则是把 5 分钟粒度聚合到 30 分钟。聚合的意义不只是降数据量更重要的是平滑掉短时波动让 LSTM 学到的规律更稳定。半小时一个时点一天就是 48 个时点预测未来 24 小时就是预测 48 步这个尺度对站点级调度来说足够用了。import pandas as pd def clean_station_data(df, freq30min): # 去重同一站点同一时间戳保留最新记录 df df.sort_values(ts).drop_duplicates( subset[station_id, ts], keeplast ) # 时间对齐 聚合重采样 df[ts] pd.to_datetime(df[ts]) df df.set_index(ts).groupby(station_id)[bike_count].resample(freq).mean() # 前向填充缺失值再用 3 点滑动平均平滑 df df.fillna(methodffill).rolling(3, min_periods1).mean() return df.reset_index()这段代码的resample(freq)是按时频窗口做均值聚合rolling(3, min_periods1)控制平滑窗口大小。高频数据转低频时缺失点通常不会连续太多前向填充能兜住大部分场景。如果某个站点连续缺失超过 12 个小时直接把这个时间段裁掉不要硬补否则模型会学到一段虚假的“零值平稳规律”。2.2 滑动窗口lookback、horizon 与多站点 batch 组装时序预测的样本构造和普通监督学习不一样不能用随机划分要用滑动窗口从长序列里切样本。这里的两个关键参数是lookback看过去多少个时点和horizon预测未来多少个时点一般约定 lookback 取 72 或 96对应过去一天半到两天horizon 取 24 或 48。多站点场景有一个容易搞混的点不要把每个站点单独拉出来训练一个模型那样参数太多且小站点数据量不够。常见做法是把站点 ID 塞进 batch 维度所有站点共享同一个 LSTM输入形状变成[batch_size, seq_len, feature_dim]时让 batch 里的样本跨越不同站点。整个训练集的组织方式是把所有站点的序列横向拼在一起再统一用滑动窗口切。def build_samples(df, station_ids, lookback72, horizon24): X, y [], [] for sid in station_ids: station_data df[df[station_id] sid][bike_count].values for i in range(len(station_data) - lookback - horizon): X.append(station_data[i:ilookback]) y.append(station_data[ilookback:ilookbackhorizon]) return np.array(X), np.array(y)X的纬度是[样本数, lookback, 1]y是[样本数, horizon]。这里每个样本只用了停放数量这一个序列特征实际工程可以在这个基础上追加时间特征比如把hour_of_day做 sin/cos 编码后拼进特征维。注意拼特征时一定要在滑动窗口内部拼不能在窗口外拼完再切否则时间特征和序列值错位。2.3 train/val/test 切分与归一化时间序列不能犯的错误时间序列的切分必须按时间顺序禁止随机 shuffle。这一点新手最容易翻车普通机器学习里随机划分是为了消除样本顺序相关性但时序预测里样本顺序就是时间顺序一旦 shuffle验证集里会出现“未来”的样本模型等于开卷考试指标虚高得离谱。正确的切分比例通常按 7:1.5:1.5 或者 7:2:1按时间点切保证训练集的时间段完全早于验证集验证集完全早于测试集。归一化也有讲究我一般只在训练集上计算 min/max然后把训练集、验证集、测试集都用训练集统计量去归一化测试集的统计量不参与拟合。from sklearn.preprocessing import MinMaxScaler train_size int(len(data) * 0.7) val_size int(len(data) * 0.15) train_data data.iloc[:train_size] val_data data.iloc[train_size:train_sizeval_size] test_data data.iloc[train_sizeval_size:] scaler MinMaxScaler() train_scaled scaler.fit_transform(train_data[bike_count].values.reshape(-1, 1)) val_scaled scaler.transform(val_data[bike_count].values.reshape(-1, 1)) test_scaled scaler.transform(test_data[bike_count].values.reshape(-1, 1))提示MinMaxScaler 的 fit 只能作用在训练集上验证集和测试集只做 transform。如果整个序列一起归一化窗口前面部分和后面部分的数值尺度互相影响相当于把未来信息透传给了训练阶段。2.4 特征工程时间编码不是可选项纯靠停放数量一条序列跑 LSTM能学到周期规律但学不到“今天是周末”“现在是早高峰”这类先验知识。我的习惯是把时间特征作为额外输入拼进每个时点的特征向量里。小时用 sin/cos 双通道编码保留周期性星期几用 one-hot。这样输入特征维度从 1 变成 12710模型能区分周内和周末的完全不同的用车模式。def add_time_features(df): df[hour] df[ts].dt.hour df[weekday] df[ts].dt.weekday df[hour_sin] np.sin(2 * np.pi * df[hour] / 24) df[hour_cos] np.cos(2 * np.pi * df[hour] / 24) # 星期几 one-hotdrop_first 避免共线性 weekday_dummies pd.get_dummies(df[weekday], prefixwd) return pd.concat([df, weekday_dummies], axis1)hour_sin 和 hour_cos 两个通道配合能让 23 点和 0 点在数值空间里靠得很近这是用整数编码做不到的。加了这组特征之后模型对夜间时段和凌晨时段的预测误差下降非常明显尤其是凌晨 1 点到 5 点这种停放量接近零的时段纯数值序列经常预测成负值加了时间编码后会把负值拉回来。3. 搭一个能跑的多站点 LSTM模型结构、训练配置与参数选择3.1 PyTorch 环境搭建与 GPU 验证先解决环境问题。PyTorch 的安装版本要和 CUDA 对应得上这一步错了后面全是坑。用nvidia-smi看驱动支持的 CUDA 版本再对应安装匹配的 PyTorch。我的建议是直接用 Anaconda 建独立环境避免和系统 Python 打架。conda create -n bike_lstm python3.10 conda activate bike_lstm pip install torch --index-url https://download.pytorch.org/whl/cu121跑之前先确认 GPU 可用。PyTorch 安装版本和 CUDA 版本不匹配时torch.cuda.is_available()会静默返回 False模型直接跑 CPU训练速度慢十倍起步这就是最常见的“环境装好了但其实没装对”的陷阱。import torch print(fPyTorch版本: {torch.__version__}) print(fCUDA可用: {torch.cuda.is_available()}) if torch.cuda.is_available(): print(fGPU型号: {torch.cuda.get_device_name(0)})3.2 LSTM 模型定义hidden_size、num_layers 怎么定多站点 LSTM 的核心结构是 LSTM 层接全连接回归头。输入的特征维度决定input_size输出取决于horizon。中间层的 hidden_size 和 num_layers 是一对需要联调的参数我的经验是站点级时序数据先试 hidden_size64、num_layers2数据集特别大再加到 128。import torch.nn as nn class MultiStationLSTM(nn.Module): def __init__(self, input_size10, hidden_size64, num_layers2, horizon24, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.regressor nn.Sequential( nn.Linear(hidden_size, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, horizon) ) def forward(self, x): # x: [batch, seq_len, input_size] lstm_out, _ self.lstm(x) # 取最后一个时间步的输出 last_hidden lstm_out[:, -1, :] return self.regressor(last_hidden)batch_firstTrue让输入纬度的 batch 放在第一位和build_samples的输出形状能直接对齐dropout只对多层 LSTM 的非首层生效单层时设了也没用。这里取lstm_out[:, -1, :]是只用最后一个时点的隐藏状态做回归因为前面的隐藏状态对最终预测权重贡献很小不如直接丢掉省显存。3.3 损失函数与优化器Huber 损失和 AdamW 的取舍停放数量预测回归损失函数的选择会影响极端值的处理方式。MSE 对异常值敏感一个站点故障导致的离群点会把 loss 拉高模型被迫去拟合噪声MAE 又对梯度更新不够平滑。我一般用 Huber 损失结合两者的优点误差小时按 MSE 走误差大时按 MAE 走离群点对训练过程的影响被限制住了。criterion nn.SmoothL1Loss(beta1.0) # 或者手动实现 Huber等价效果 # criterion nn.HuberLoss(delta1.0) optimizer torch.optim.AdamW( model.parameters(), lr1e-3, weight_decay1e-5 )优化器从 Adam 换成 AdamW 是近几年实践下来更稳的选择weight_decay 对 LSTM 这种参数多的网络能起到约束作用。学习率 1e-3 是起点训练过程中配合余弦退火或 ReduceLROnPlateau 往下调这个后面细说。注意数据集如果只有几万条样本hidden_size 拉到 128 以上很容易过拟合验证集 loss 会在某个 epoch 后掉头往上走。先看训练集 loss 是否持续下降如果训练集也降不下去再考虑调结构不要一上来就堆参数。4. 训练与评估早停、学习率调度与预测结果还原4.1 训练循环梯度裁剪和早停是标配训练 LSTM 和训练普通全连接网络有个明显区别梯度爆炸出现的概率更高。时间步长越大梯度在反向传播过程中连乘的次数越多数值稍不注意就变成 NaN。torch.nn.utils.clip_grad_norm_是必不可少的一道保险把所有参数的梯度范数限制在 1.0 以内。import torch.nn.utils as utils def train_one_epoch(model, train_loader, optimizer, criterion, device): model.train() total_loss 0.0 for x_batch, y_batch in train_loader: x_batch, y_batch x_batch.to(device), y_batch.to(device) optimizer.zero_grad() pred model(x_batch) loss criterion(pred, y_batch) loss.backward() utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() * x_batch.size(0) return total_loss / len(train_loader.dataset)clip_grad_norm_不是消除梯度爆炸而是把梯度范数压到阈值内防止参数一步更新过大跳出最优点。早停的逻辑也很直接记录验证集 loss 最低的那一个 epoch连续 N 个 epoch一般 15没有刷新最低值就停止训练并回滚到最佳权重。这是防止 LSTM 过拟合最实用的手段比任何正则化都直观。4.2 评估指标MAE、RMSE、MAPE 怎么读模型训练完评估指标要看三维度。MAE 告诉平均误差多少个车RMSE 放大较大误差的惩罚MAPE 看相对偏差百分比。调度场景额外关注 MAE因为调度车每次搬运的车辆数通常以十为单位MAE 在 3 以内算是可用的基线。def compute_metrics(pred, y_true): diff pred - y_true mae torch.abs(diff).mean().item() rmse torch.sqrt((diff ** 2).mean()).item() mape (torch.abs(diff) / (y_true.abs() 1e-6)).mean().item() * 100 return {MAE: mae, RMSE: rmse, MAPE: mape}MAPE 计算加了个1e-6的极小值是因为凌晨时段停放数量经常为零直接除会得到无穷大。这个细节看着小但实际跑起来只要有一个样本是零值整个 MAPE 指标就废了评估结果完全不可信。4.3 反归一化还原与可视化验证模型输出的预测值是在归一化空间里的直接画曲线和真实停放数量根本对不上。必须用训练时那个 scaler 的inverse_transform还原回实际车辆数。这一步和之前切分时的操作要严格对称归一化用的是训练集统计量反归一化也只能用同一个 scaler。pred_inverse scaler.inverse_transform(pred_cpu.reshape(-1, 1)).reshape(-1) true_inverse scaler.inverse_transform(true_cpu.reshape(-1, 1)).reshape(-1)提示反归一化时如果预测值小于 0要手动截断为 0共享单车停放数量不可能为负数。这一点在评估 MPAE 时也影响很大负值会同时拉高 MAE 和 RMSE。可视化比看指标更直观。我通常把某个站点某一天的真实值和预测值画在同一个折线图里重点看早晚高峰两个峰的位置和高度有没有偏移。如果预测曲线的波峰比真实晚了一个时点说明模型学到的是“昨天同一时刻的值”也就是滞后效应而不是真正的周期规律——这时候需要调整 lookback 或模型结构而不是继续调学习率。5. 实战避坑多站点 LSTM 时序预测的 6 条踩坑记录5.1 现象验证集指标很漂亮一上线就翻车原因数据在归一化和切分时用了整个时间段的统计量测试集的信息泄露到训练集或者训练时 shuffle 了样本导致验证集里混入了相邻时段的数据。解决切分时必须按时间排序先切好 train/val/test 再做归一化。fit_transform和transform严格分开测试集的统计量只读不参与计算。血泪教训是有一次我偷懒直接对全量数据调用fit_transform再切分验证集 MAE 1.8上线之后直接 7.5折腾了两周才定位到是数据泄露。5.2 现象训练 loss 不降反升有时直接变 NaN原因学习率偏大加上梯度爆炸。LSTM 反向传播路径长梯度连乘导致数值溢出loss 在某个 epoch 突然变成 NaN 之后无法恢复。解决clip_grad_norm_(max_norm1.0)一定要加学习率从 1e-3 降到 3e-4 再试。训练时打印每个 epoch 的 loss发现 NaN 尽早停掉调参不要让它跑完——后面所有 epoch 都是无效的。5.3 现象多站点模型输出了同一个数值所有站点预测结果几乎一样原因站点间的差异被模型“平均”掉了常见于小站点数据量太少、大站点数据量太大模型学成了全局均值回归。解决训练样本按站点做加权采样小站点多抽几次大站点限制单 epoch 的样本数。更有效的手段是把站点 ID 做 embedding 作为额外特征让模型知道不同站点有不同基线模型结构上可以参考 LSTM 后接条件层的方式。5.4 现象预测曲线整体滞后波峰永远比真实晚一步原因单步滚动预测的误差累积在推理阶段被放大LSTM 在训练时是 teacher forcing用真实值作为下一步输入推理时只能用自己的预测输出当输入误差一级级传递。解决降低对远期预测的期望把预测结果的重心放在前 12 个时点内。或者改用 seq2seq 架构用独立的 decoder 来生成预测序列避免 teacher forcing 和 inference 阶段的输入分布不一致。5.5 现象换了一批站点数据训练报维度错误原因数据清洗阶段没有把站点 ID 枚举化新站点的 ID 范围变化导致 one-hot 维度或索引对不上。解决把所有站点映射成一个自增 ID 配置表建一个station2id字典保存重新训练时只加载配置表不依赖原始 station_id 字符串。这一步看似小实际运营数据里站点会不断新增和下线没有映射表的代码换数据必炸。5.6 现象同一个模型两次训练结果不一样复现不了原因没有固定随机种子PyTorch 的初始化、DataLoader 的 shuffle 顺序、GPU 运算的随机性都会影响最终结果。解决训练脚本开头固定三处随机种子Python 内置 random、numpy、PyTorch 的torch.manual_seed。如果用了 DataLoader 的 shuffle还要给 DataLoader 也传入generatortorch.Generator().manual_seed(42)否则前面固定了也没用。6. 一个实战技巧用前向验证选 lookback 超参数lookback 这个参数很多人是拍脑袋定的我踩过坑之后学到的习惯是选参数不能只看训练集和验证集的一刀切指标要模拟真实上线场景。固定 train/val 切分只能验证一次但模型上线后是在滚动更新的每周重新训练一次用到的数据覆盖到当前时间点真实场景是不断向前走的。前向验证的做法是把测试集按时间切成多段每段先训练再预测再向后推进最后把所有预测误差汇总。这个过程完全模拟了线上模型的运行方式比单次切分得到的指标可靠得多。def rolling_forward_validate(df, lookback_candidates, horizon24, folds5): results {} for lb in lookback_candidates: errors [] for fold in range(folds): split_idx len(df) - (folds - fold) * (len(df) // folds) train_df df.iloc[:split_idx] test_df df.iloc[split_idx: split_idx (len(df) // folds)] model build_lstm(lookbacklb) model.fit(train_df) pred model.predict(test_df) errors.append(mae(pred, test_df[bike_count])) results[lb] np.mean(errors) print(flookback{lb}, CV MAE{results[lb]:.2f}) return resultsfold 的划分也要按时间切不能让后面的数据混进前面的训练集。我拿这个函数对比过 lookback48、72、96 三组取值结果是 72 的滚动 MAE 比 48 低了大约 12 个百分点比 96 低了 5 个点。这个结论在单次切分上完全看不出来——单次切分时 96 表现最好但上线后明显变差原因就是过拟合了较早时间段的模式对最近的数据适应性不足。从那以后我每换一批数据都强制走一遍前向验证lookback 不敢直接照搬别人代码里的默认值。超参数这东西数据分布一变结论就变老老实实滚一遍比什么都有说服力。希望帮到你。本文还有配套的精品资源点击获取
返回列表