
简介基于PyTorch与LSTM的城市共享单车停放数量多站点时序预测系统项目包面向深度学习初学者、交通数据挖掘研究人员及共享单车调度相关开发者。项目完整实现了从历史骑行数据预处理、LSTM模型搭建训练到未来停放数量预测的闭环流程可帮助读者理解时序预测在实际场景中的落地方法。压缩包共13个文件包含5个Python脚本负责数据集解析、模型定义、训练与评估、1个模型权重文件pth、1个数据集csv、依赖清单、说明文档及附赠资源整包仅92KB轻量易用适合快速复现与二次开发。目前已有30人学习浏览虽体量不大但麻雀虽小五脏俱全。读者可以直接获取完整可运行的PyTorch LSTM预测代码参照README梳理项目结构借助附赠文档理解LSTM门控机制与多站点预测思路还可基于现有权重文件直接测试效果免去从头训练的时间成本是入门时间序列预测与共享单车智能调度方向的实用参考。1. 城市共享单车停放量预测为什么我选了PyTorch加LSTM凌晨的调度中心大屏上几十个站点同时告警——有的满了有的空了。共享单车停放数量预测本质上是一个多站点时序预测问题每个站点过去几十个小时的停车数变化都隐含了早晚高峰、通勤节奏和区域热度。这个训练好的系统做的事就是用PyTorch作为深度学习框架把LSTM长短期记忆网络搭起来用历史停车数据训练出模型再对未来多个站点的停放数做预测输出。这不是给数据科学爱好者做概念演示的而是给运营调度、算法工程师和做毕业设计的人一个能落地的方案。核心价值是不用写一堆if-else调度规则让模型自己从历史里找规律。接下来的内容会从数据整理、模型搭建、多站点处理一路讲到验证和避坑照着做能跑通一条完整链路。2. 把共享单车历史数据整理成LSTM能吃的样本数据结构与滑窗2.1 原始数据长什么样站点、时间戳、可用车辆数共享单车系统能留下的原始记录通常有两种形态一种是订单流水用户借车、还车的记录另一种是站点状态快照每隔几分钟记录一次每个站点的可用车数。做停放数量预测更推荐直接拿站点状态快照因为订单流水还需要自己聚合出“t时刻站点停了多少辆车”而快照本身就是时序点。如果只有订单流水聚合逻辑也很简单按站点、按小时统计“在库车辆数”的变化。常见做法是维护一个初始库存把借车减掉、还车加上然后按小时采样。这个清洗步骤决定了后续数据质量多花点时间值得。原始表格大概长这样station_idtimebike_countS0012024-05-01 07:00:0018S0012024-05-01 08:00:0026S0022024-05-01 07:00:003注意不同站点的时间点必须对齐否则后面做多站点预测时数据张量的形状会很难处理。对齐的常见做法是以整点为准做重采样缺失值用前向填充。如果某个站点连续缺失超过3个小时我一般会直接删掉那一段而不是硬补因为长时间填充会把“无数据”伪装成“车辆没变化”模型会学到错误规律。2.2 滑窗采样用过去N步预测未来M步LSTM处理的是序列我们不能把一整年数据直接丢进网络而是要把时间序列切成很多个“小片段”每个片段由一个输入窗口和一个预测目标组成。这里的N和M是整个系统最先要确定的参数。import pandas as pd import numpy as np df pd.read_csv(bike_history.csv, parse_dates[time]) df df.sort_values([station_id, time]).reset_index(dropTrue) N_STEPS 24 # 输入窗口过去24小时 M_STEPS 6 # 预测步长未来6小时 def build_samples(group, nN_STEPS, mM_STEPS): values group[bike_count].values.astype(np.float32) x, y [], [] for i in range(len(values) - n - m 1): x.append(values[i:in]) y.append(values[in:inm]) return np.array(x), np.array(y) X_list, y_list [], [] for station_id, group in df.groupby(station_id): x, y build_samples(group) X_list.append(x) y_list.append(y) X np.concatenate(X_list, axis0) y np.concatenate(y_list, axis0) print(X.shape, y.shape)代码逻辑说明这段代码先按站点分组对每个站点单独做滑窗采样。样本由长度为N_STEPS M_STEPS的完整序列切成输入和输出range(len(values) - n - m 1)保证窗口不越界。最后把各站点的样本拼成一个大数组作为模型输入。参数说明N_STEPS24意味着用过去24小时的数据。为什么不是12或48对共享单车来说24小时能覆盖一个完整的日周期模型能看到“昨天同一时刻”的停放水平这对停车数量预测非常关键。M_STEPS6对应未来6小时这通常是调度决策需要的时间尺度。如果你的调度是小时级的可以缩到M_STEPS3如果做次日运力规划建议把N_STEPS拉到72让模型看到最近3天的模式。2.3 数据归一化必须按站点分开做归一化是时序预测里的关键步骤尤其是多站点场景。共享单车不同站点的停放数量差异很大热门站点可能日常有40辆车冷门站点常年只有2到3辆。如果用一个全局的MinMaxScaler冷门站点的数值会被压到接近0模型很难区分“0”和“0.02”的差异。from sklearn.preprocessing import MinMaxScaler X_norm_list, y_norm_list [], [] scaler_dict {} for station_id, group in df.groupby(station_id): x, y build_samples(group) scaler MinMaxScaler(feature_range(0, 1)) # 用输入窗口的数据来fit避免把未来信息泄露进来 flat x.reshape(-1, 1) scaler.fit(flat) X_norm scaler.transform(x.reshape(-1, 1)).reshape(x.shape) y_norm scaler.transform(y.reshape(-1, 1)).reshape(y.shape) X_norm_list.append(X_norm) y_norm_list.append(y_norm) scaler_dict[station_id] scaler X np.concatenate(X_norm_list, axis0) y np.concatenate(y_norm_list, axis0) # 按时间顺序切分前80%训练后20%验证 split_idx int(len(X) * 0.8) X_train, X_val X[:split_idx], X[split_idx:] y_train, y_val y[:split_idx], y[split_idx:]逻辑说明这里用输入窗口的数据去fit scaler而不是用全量数据这是很多人会忽略的细节。如果先用全量数据归一化再做训练测试切分其实已经把测试集的最大值最小值泄漏给了训练过程验证效果会虚高上线后会被真实的极端值打脸。每个站点的scaler单独存在scaler_dict里预测完之后按站点还原。参数说明feature_range(0, 1)是LSTM的常用选择sigmoid/tanh激活函数在这个区间输出更敏感。如果你发现预测结果在0或1附近饱和可以改用(-1, 1)。切分时直接按样本序号切因为前面已经把每个站点的样本按时间排好前80%在时间上基本一致实际项目中如果站点时间跨度差异很大最好按每个站点的时间分别切再重组训练集。2.4 除了数量还喂什么特征时间编码与外部特征滑窗只用了bike_count一个维度但LSTM的输入通道是可以扩展的。最小成本的特征是时间编码把每个时间步对应的hour和weekday编码后和bike_count拼在一起作为多变量输入。这样模型每看一个时间步都知道“这是周几的几点”更容易学到早晚高峰的周期性。def add_time_features(df): hour df[time].dt.hour weekday df[time].dt.weekday df[hour_sin] np.sin(2 * np.pi * hour / 24) df[hour_cos] np.cos(2 * np.pi * hour / 24) df[week_sin] np.sin(2 * np.pi * weekday / 7) df[week_cos] np.cos(2 * np.pi * weekday / 7) return df这样每个时间步的特征从1维变成5维后面模型里的input_size也改成5。外部特征温度、降雨、节假日是否加入取决于你能否在预测时刻拿到对应数据。停车数量对天气很敏感雨天通勤需求会明显下降但如果你只能拿到历史天气而拿不到预报天气上线时会有偏差需要权衡。3. 用PyTorch搭建LSTM模型从单层到多层3.1 为什么选LSTM而不是ARIMA或Transformer对共享单车停放数量这种序列常见候选有ARIMA、LightGBM、Transformer和LSTM。ARIMA对线性趋势和周期性效果不错但面对多站点、强非线性早晚高峰突变时需要每个站点单独建模数据利用率低。Transformer在长序列上有优势但需要大量数据单车数据通常只有几个月到一年容易欠拟合。LSTM的门控结构让它能用少量数据学到中短期依赖而且PyTorch里实现LSTM只需要几行代码调试成本低。这不是说LSTM在所有时序任务里都比Transformer好而是在“站点多、数据量不大、需要快速上线”这个约束下LSTM是性价比最高的选择。如果你手里有两年以上的逐小时数据Transformer值得试但作为第一版系统LSTM的稳定性和可解释性明显更好。3.2 模型结构LSTM层加全连接输出层PyTorch封装好了LSTM单元我们只需要定义层数和隐状态维度不需要手动写门控逻辑。一个典型的多站点预测模型如下import torch import torch.nn as nn class LSTMMultiStep(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2, output_size6, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0.0 ) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): # x shape: (batch, seq_len, input_size) out, _ self.lstm(x) # 取最后一个时间步的隐状态作为序列表示 last out[:, -1, :] return self.fc(last)逻辑说明batch_firstTrue让输入张量形状更符合直觉第一个维度是batch。LSTM返回的out保存了每个时间步的输出我们只取最后一个时间步的隐状态因为预测未来需要的是“看完整个输入窗口之后”的压缩信息。全连接层负责把隐状态映射到未来6个值上。参数说明hidden_size64是个起步值对单车数量这种单变量输入一般够用。num_layers2让模型有更强的非线性拟合能力但不是越多越好层数过多在小数据集上很容易过拟合。dropout只在层数大于1时生效它随机丢弃一部分神经元输出降低过拟合风险。3.3 训练循环损失函数、优化器、学习率模型结构定了之后训练循环直接决定模型能不能收敛。我习惯用MSELoss配合Adam优化器再加上学习率衰减和梯度裁剪。import torch.optim as optim def train_model(model, train_loader, val_loader, epochs30, lr1e-3): criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lrlr) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3 ) for epoch in range(epochs): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() pred model(x_batch) loss criterion(pred, y_batch) loss.backward() # 梯度裁剪防止LSTM训练过程中梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() * x_batch.size(0) model.eval() val_loss 0.0 with torch.no_grad(): for x_batch, y_batch in val_loader: pred model(x_batch) loss criterion(pred, y_batch) val_loss loss.item() * x_batch.size(0) scheduler.step(val_loss) print(fepoch {epoch1:02d}, train_loss{train_loss:.4f}, val_loss{val_loss:.4f}) return model逻辑说明这是一个标准的PyTorch训练循环。clip_grad_norm_是LSTM训练里很实用的一个保护长序列反向传播时梯度很容易爆炸裁剪后训练更稳。ReduceLROnPlateau会在验证loss连续3个epoch不降时把学习率减半这比固定学习率省心不用手动调到半夜。参数说明lr1e-3是Adam的常见起点。如果发现loss下降慢可以试3e-3如果loss震荡不收敛降到3e-4。max_norm1.0对应梯度范数上限实际项目中在0.5到5.0之间都有人用我习惯先设1.0。训练完保存模型权重torch.save(model.state_dict(), bike_lstm.pt) # 加载时先实例化相同结构的模型 model LSTMMultiStep(input_size1, hidden_size64, num_layers2, output_size6) model.load_state_dict(torch.load(bike_lstm.pt)) model.eval()3.4 训练时看什么loss曲线与过拟合迹象训练30个epoch不要只看最后一个数值。我习惯在每个epoch打印训练loss和验证loss然后观察两条曲线的间距训练loss一直降、验证loss在某轮开始反弹过拟合的迹象就出来了。LSTM在小数据集上通常第10到15轮就会开始过拟合。如果出现这种情况先把num_layers减到1或2再看hidden_size是否偏大。不要急着加正则化结构简化往往比调参更有效。这算是时序预测里的一条常用排查路径比起调参玄学可解释性高很多。4. 多站点预测的落地做法从单站点到批量站点4.1 两种建模思路独立模型 vs 共享模型多站点时序预测有两种主流做法方案优点缺点每个站点一个独立LSTM模型模型简单各站互不干扰站点多时训练成本高冷门站点数据量不足所有站点共用一个LSTM模型数据量充分利用冷门站点也能借力需要把站点信息喂给模型否则模型无法区分站点对城市级别的共享单车系统站点往往上百个独立模型不现实。常见做法是共享模型加站点ID特征把站点ID做embedding或者直接用one-hot拼到输入特征里让同一个LSTM学会“不同站点的不同模式”。如果你的数据里只有几十个站点把站点ID当作一个特征维度拼进去即可如果站点有几百个embedding更省空间。4.2 用DataLoader组织多站点数据PyTorch的DataLoader负责把NumPy数组组织成batch。关键点是时序数据要不要shuffle以及batch_size怎么定。import torch from torch.utils.data import Dataset, DataLoader class BikeDataset(Dataset): def __init__(self, x, y): self.x torch.from_numpy(x).float() self.y torch.from_numpy(y).float() def __len__(self): return len(self.x) def __getitem__(self, idx): return self.x[idx], self.y[idx] train_loader DataLoader( BikeDataset(X_train, y_train), batch_size256, shuffleFalse # 时序数据按时间顺序组织不随机打乱 ) val_loader DataLoader( BikeDataset(X_val, y_val), batch_size256, shuffleFalse )逻辑说明shuffleFalse是故意设置的。时序预测里如果shuffle一个batch里同时出现上午和凌晨的样本模型虽然也能收敛但验证时按时间切分的意义会被削弱。多站点场景下如果你希望模型在每个batch里能看到不同站点的样本可以改成shuffleTrue但训练集和验证集必须严格按时间切好不要有重叠。参数说明batch_size256对LSTM来说是个稳妥值。如果GPU显存不够降到64或128如果序列特别长32也可以。batch太大会让梯度方向过于平滑LSTM学到的是“平均站点”而不是“每个站点的细节”。4.3 预测结果反归一化与输出模型输出的是归一化后的值要还原成真实停放数量。这里用到了2.3节存下来的scaler_dictdef inverse_scale_prediction(pred_norm, scaler): # pred_norm: (batch, M_STEPS) return scaler.inverse_transform(pred_norm) y_pred_norm model(x_val).detach().numpy() station_scaler scaler_dict[S001] y_pred inverse_scale_prediction(y_pred_norm, station_scaler)完整预测流程是加载模型 → 构造最近N_STEPS的输入窗口 → 前向预测得到M_STEPS个归一化值 → 按站点用对应scaler还原 → 输出成表格或写入数据库供调度系统读取。这里的detach().numpy()是把GPU上的张量转成NumPy前提是模型在CPU上跑如果在GPU上跑需要先.cpu().detach().numpy()。4.4 输入形态单变量还是多变量前面的例子输入是单变量——只有bike_count。但LSTM的input_size是可扩展的。多站点共享模型时常见做法是每个时间步的特征向量包含多个值当前站点的停车数、当前小时、星期几、温度、是否节假日。这种多变量输入会让模型更稳定因为时间上下文不再是“猜”出来的而是直接喂进去。# 假设每个时间步有5个特征bike_count, hour_sin, hour_cos, week_sin, week_cos model LSTMMultiStep(input_size5, hidden_size64, num_layers2, output_size6)特征扩展能显著提升预测效果尤其是hour_sin/hour_cos和week_sin/week_cos它们让模型直接感知时间上下文。我在实际项目里的经验是加了这4个时间特征后MAPE通常能下降3到8个百分点这比调hidden_size的效果明显得多。5. 多站点LSTM预测的避坑记录5个常见问题5.1 预测结果是“平均线”loss在降但预测没意义现象训练和验证loss都正常下降但把预测曲线画出来发现它基本是一条水平的平均线真实的高峰和低谷都被抹平了。原因LSTM在最小化MSE时对于波动大的序列一种稳妥策略是输出接近历史均值因为这样能保证整体误差最小。这不是模型坏了而是目标函数没有逼它“猜准峰值”。解决换成或叠加MAPE这种相对误差损失比如loss torch.mean(torch.abs((pred - y) / (y 1e-6)))。另外可以考虑预测差分值而非原始值让模型学习“变化量”而不是“绝对量”。5.2 冷门站点预测效果差样本不均衡现象热门站点预测误差在可接受范围但那些长期只有几辆车的冷门站点误差率很高甚至预测出负数。原因冷门站点在总样本里占比极低模型在共享权重时把主要精力放在拟合热门站点上。解决按站点做归一化已经能缓解一部分因为每个站点都被压到0-1区间。更进一步可以在损失函数里按站点样本量加权或者在采样时对冷门站点做过采样。如果站点实在太多也可以按热门、普通、冷门分桶训练三个模型。5.3 预测曲线整体滞后一拍现象预测曲线和真实曲线形状很像但整体向右平移了一个时间步峰值总是晚到。原因模型学会了“复制最近一个输入值”因为单步预测时上一步的值和当前值高度相关这个策略loss很低。但调度系统需要的是提前量滞后的预测没有决策价值。解决放弃滚动单步预测改用直接多步预测一次输出未来6小时评估时看第6小时而不是第1小时的误差。同时可以加入差分特征让模型更关注变化趋势。5.4 GPU训练OOM显存爆掉现象训练到中途报CUDA out of memory尤其是把序列长度从24拉到72之后。原因LSTM反向传播要保存每个时间步的中间状态显存占用随seq_len * batch_size * hidden_size增长。序列变长后显存快速增长。解决优先减小batch_size其次是减小hidden_size再不行就开梯度累积accumulation_steps 4 for step, (x_batch, y_batch) in enumerate(train_loader): loss criterion(model(x_batch), y_batch) loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()逻辑说明梯度累积让模型每4个小batch才更新一次参数等效于增大了batch_size但显存占用不变。loss需要除以accumulation_steps这样多个batch累计的梯度平均后等价于一次大batch更新。5.5 验证loss回升过拟合现象训练loss持续下降验证loss在第12个epoch触底后开始回升到第30个epoch已经明显高于最低点。原因模型层数多、hidden_size大训练数据只有几个月时LSTM很容易把训练集里的时间点背下来。解决做早停——保存验证loss最低的模型而不是最后一个epoch的模型给LSTM加dropout或者把num_layers从3减到2。不要觉得层数越多越厉害对单车数据2层通常已经足够。6. 让预测真正落地验证方法与进阶特征6.1 评估指标用MAPE和RMSE而不是只看lossMSE在训练时好用但解释性差。业务上我一般用MAPE看整体误差率、用RMSE看绝对偏差from sklearn.metrics import mean_absolute_percentage_error, mean_squared_error y_pred model(x_val).detach().numpy() y_true y_val mape mean_absolute_percentage_error(y_true, y_pred) rmse mean_squared_error(y_true, y_pred, squaredFalse) print(fMAPE{mape:.2%}, RMSE{rmse:.2f})注意MAPE在停放数量接近0的站点会爆炸所以可以设置一个下限只统计真实值大于某个阈值的样本。6.2 分时段评估早高峰、平峰、晚高峰分开看共享单车的需求有明显的时段性全天一个MAPE会掩盖问题。我会把预测结果按输入窗口的起始时间分组早高峰7-9点、晚高峰17-19点、平峰其余时段分别计算误差。如果晚高峰误差特别大说明模型对突变场景学习不足可以针对性增加高峰时段样本权重。6.3 时间特征与外部特征让模型知道“现在是几点”在2.4节提过时间编码实际操作是hour df[time].dt.hour df[hour_sin] np.sin(2 * np.pi * hour / 24) df[hour_cos] np.cos(2 * np.pi * hour / 24)直接用hour23和hour0在数字上相差23但时间上只差1小时丢整数会让模型学到错误距离。天气和节假日也能提升效果但注意预测未来时天气是预报值、节假日是确定值两种特征可靠度不同需要留意特征分布漂移。我自己的一个习惯是每次换数据集先把最简单的单站点、单变量基线跑通确认链路没问题再去加站点ID、天气、节假日。这样一旦效果变差能快速定位是哪一环引入的问题。这套流程帮我在多个停车数据上少踩了不少坑希望也能帮到你。本文还有配套的精品资源点击获取