简介:本资源面向计算机、人工智能、自动化等专业的在校学生与开发者,提供一套基于深度学习的共享单车需求预测与调度优化完整方案,可作为毕业设计、课程设计或项目立项演示的参考实现。压缩包共16个文件,以11个Python脚本为核心,辅以4个npy数据文件与1个说明文档,整体约548KB,涵盖地理哈希解码、区域划分、POI画像融合、需求统计、训练测试数据生成、BP神经网络预测及蚁群算法调度路径规划等模块。方案通过神经网络建立单车需求量与时间段、地理画像之间的关联,预测不同区域用车需求,再借助蚁群算法求解最优调度路径,形成从数据预处理到预测调度的闭环流程。目前已有374人学习关注,代码经测试可运行,结构清晰,便于在此基础上修改扩展,适合希望快速理解并复现深度学习预测与智能调度思路的读者参考。
1. 共享单车潮汐预测:从“车不够用”到“提前 30 分钟布车”
早高峰地铁口一辆车都扫不到,晚高峰写字楼下的车堆成山没人骑——这是共享单车运营里最典型的潮汐现象。基于深度学习的共享单车预测与调度解决方案,核心就两件事:用历史骑行数据预测未来某个站点、某个时段的借还车需求,再把预测结果翻译成调度指令,告诉运维车该往哪补、从哪拉。它适合做城市交通数据分析的算法工程师、做运筹调度的后端开发,也适合拿它当毕设或课程项目的学生。整套方案通常包含数据清洗、特征工程、时序预测模型、调度优化四个模块,Python 源码包一般会把这四块拆成独立脚本。下面我按实际落地的顺序,把每个环节讲透。
2. 数据到手先别急着建模:共享单车数据的清洗与特征构造
2.1 原始数据长什么样,哪些字段真正有用
常见的共享单车公开数据集,一条记录通常包含:订单 ID、车辆 ID、用户 ID、借车时间、还车时间、借车站点 ID、还车站点 ID、站点经纬度。真正进模型的字段其实就几个——时间戳、站点 ID、借还类型、经纬度。用户 ID 和车辆 ID 在需求预测里基本是噪声,除非你要做用户画像或车辆调度路径,否则第一刀就该砍掉。
时间戳要拆成年、月、日、小时、星期几、是否周末、是否节假日。这里有个容易翻车的点:节假日字段不能只靠星期几推,得单独维护一张节假日表,否则国庆和普通周末会被模型当成一回事,预测直接偏掉。经纬度用来做站点聚类,把几百米内的站点合并成一个虚拟站点,减少稀疏性。
import pandas as pd import numpy as np # 读取原始订单数据,假设字段为 start_time, end_time, start_station, end_station df = pd.read_csv("bike_orders.csv", parse_dates=["start_time", "end_time"]) # 只保留需求预测需要的字段 df = df[["start_time", "start_station", "end_station"]].copy() # 构造借车需求:按小时、站点聚合 df["hour"] = df["start_time"].dt.floor("H") df["date"] = df["start_time"].dt.date df["weekday"] = df["start_time"].dt.weekday df["is_weekend"] = (df["weekday"] >= 5).astype(int) # 借车需求计数 demand = df.groupby(["start_station", "hour"]).size().reset_index(name="pickup_count") # 还车需求计数 return_df = df.groupby(["end_station", "hour"]).size().reset_index(name="return_count") return_df.columns = ["station_id", "hour", "return_count"] # 合并借还需求 demand.columns = ["station_id", "hour", "pickup_count"] merged = pd.merge(demand, return_df, on=["station_id", "hour"], how="outer").fillna(0) print(merged.head())这段代码做了三件事:字段裁剪、时间粒度对齐、借还需求分离再合并。floor("H")把时间戳压到整点,是因为调度决策通常以小时为窗口,分钟级预测对调度车来说太细,反而增加噪声。how="outer"保证某些站点只有还车没有借车的时段不会被丢掉,后面填充 0 表示该时段无需求。
参数上,时间粒度可以改成 30 分钟或 15 分钟,取决于你的调度响应速度。如果运维车 1 小时才能到现场,做 15 分钟预测没有意义。站点合并的半径一般取 300 到 500 米,太大会把不同功能区的站点混在一起,太小则稀疏站点样本不足。
2.2 缺失时段补零还是插值,这里有个坑
聚合之后你会发现,很多站点在某些小时完全没有订单。这不是数据丢失,而是真实需求为零。但如果你直接dropna(),模型会以为这些时段不存在,预测时反而给出离谱的高值。正确做法是构造完整的“站点 × 小时”笛卡尔积,再左连接需求数据,缺失填 0。
# 构造完整的时间-站点网格 all_stations = merged["station_id"].unique() all_hours = pd.date_range(merged["hour"].min(), merged["hour"].max(), freq="H") grid = pd.MultiIndex.from_product([all_stations, all_hours], names=["station_id", "hour"]) grid_df = pd.DataFrame(index=grid).reset_index() # 左连接,缺失填 0 full_df = pd.merge(grid_df, merged, on=["station_id", "hour"], how="left").fillna(0) print(f"完整网格行数: {len(full_df)}, 原始行数: {len(merged)}")补零之后还要做一步:检查每个站点的总需求量。如果某个站点 90% 的时段都是 0,说明它本身活跃度极低,建模时可以考虑过滤掉,否则模型会花大量精力去拟合这些无意义样本。我一般会设一个阈值,比如总借车量低于 50 的站点直接剔除。
2.3 特征工程:把“时间”和“空间”都喂给模型
时序预测模型吃的是特征,不是原始时间戳。除了小时、星期几,还要构造滞后特征和滑动窗口特征。滞后特征就是“前 1 小时、前 2 小时、前 24 小时的借车量”,滑动窗口是“过去 3 小时均值、过去 24 小时最大值”。这些特征能让模型捕捉到短期波动和日周期。
# 按站点分组后构造滞后和滑动窗口特征 full_df = full_df.sort_values(["station_id", "hour"]) for lag in [1, 2, 3, 24]: full_df[f"lag_{lag}"] = full_df.groupby("station_id")["pickup_count"].shift(lag) # 滑动窗口统计 full_df["rolling_3h_mean"] = full_df.groupby("station_id")["pickup_count"].transform( lambda x: x.rolling(3, min_periods=1).mean() ) full_df["rolling_24h_max"] = full_df.groupby("station_id")["pickup_count"].transform( lambda x: x.rolling(24, min_periods=1).max() ) # 去掉因滞后产生的空值 full_df = full_df.dropna().reset_index(drop=True) print(full_df.columns.tolist())滞后阶数的选择要看预测目标。如果预测未来 1 小时,lag_1和lag_24最关键;如果预测未来 6 小时,lag_24和滑动窗口更重要。注意shift之后前几行会变成 NaN,直接dropna()会丢掉每个站点开头的数据,这是可接受的代价。如果数据量本身很少,可以用fillna(0)代替,但会引入偏差。
3. 模型选型:LSTM、GRU 还是时序 Transformer
3.1 为什么共享单车预测偏爱 RNN 系
共享单车需求本质是时间序列,而且有明显的日周期和周周期。LSTM 和 GRU 能通过门控机制记住长期依赖,比 ARIMA 这类统计模型更能捕捉非线性关系。GRU 比 LSTM 少一个门,参数更少,训练更快,在数据量不是特别大的场景下表现往往差不多。我一般先用 GRU 跑基线,如果效果不够再换 LSTM 或加注意力。
时序 Transformer 这两年很火,但它对数据量要求高,共享单车单站点数据往往只有几千条,直接上 Transformer 容易过拟合。如果要做多站点联合预测,把站点 ID 做嵌入再送进 Transformer,效果可能比单站点 GRU 好,但工程复杂度也上去了。
3.2 用 PyTorch 搭一个 GRU 预测模型
下面是一个可复现的 GRU 模型,输入是过去 24 小时的多维特征,输出是未来 1 小时的借车量。
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader class BikeDataset(Dataset): def __init__(self, data, feature_cols, target_col, seq_len=24): self.data = data self.feature_cols = feature_cols self.target_col = target_col self.seq_len = seq_len def __len__(self): return len(self.data) - self.seq_len def __getitem__(self, idx): x = self.data[self.feature_cols].iloc[idx:idx+self.seq_len].values y = self.data[self.target_col].iloc[idx+self.seq_len] return torch.tensor(x, dtype=torch.float32), torch.tensor(y, dtype=torch.float32) class GRUModel(nn.Module): def __init__(self, input_dim, hidden_dim=64, num_layers=2, dropout=0.2): super().__init__() self.gru = nn.GRU(input_dim, hidden_dim, num_layers, batch_first=True, dropout=dropout) self.fc = nn.Linear(hidden_dim, 1) def forward(self, x): out, _ = self.gru(x) out = out[:, -1, :] # 取最后一个时间步 return self.fc(out).squeeze(-1)input_dim是特征数量,比如滞后 4 个 + 滑动窗口 2 个 + 小时/星期几等,大概 10 到 15 维。hidden_dim从 64 起步,数据量大可以加到 128。num_layers=2是常见选择,再深容易过拟合。dropout=0.2在 RNN 里是正则化手段,但注意 PyTorch 的 GRU dropout 只在多层之间生效,单层设了没用。
训练时用 MSE 损失,Adam 优化器,学习率 1e-3。如果发现验证集损失震荡,把学习率降到 5e-4 或加梯度裁剪。
model = GRUModel(input_dim=len(feature_cols)) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.MSELoss() for epoch in range(50): model.train() for x, y in train_loader: optimizer.zero_grad() pred = model(x) loss = criterion(pred, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()clip_grad_norm_是 RNN 训练的血泪经验,不加的话梯度爆炸会让损失变成 NaN。max_norm=1.0是常用值,太大没效果,太小会拖慢收敛。
3.3 评估指标别只看 MSE
MSE 对异常值敏感,而共享单车需求里偶尔会出现极端高峰。更合理的指标是 MAE 和 MAPE。MAE 反映平均绝对误差,MAPE 反映相对误差。但 MAPE 在真实值为 0 时会爆炸,所以实际报告时我会把零需求时段单独拿出来看。
from sklearn.metrics import mean_absolute_error, mean_squared_error model.eval() preds, trues = [], [] with torch.no_grad(): for x, y in val_loader: preds.extend(model(x).numpy()) trues.extend(y.numpy()) mae = mean_absolute_error(trues, preds) rmse = np.sqrt(mean_squared_error(trues, preds)) print(f"MAE: {mae:.2f}, RMSE: {rmse:.2f}")如果 MAE 在 2 到 3 之间(即平均每小时每个站点误差 2 到 3 辆车),对调度来说已经可用。超过 5 的话,要么特征不够,要么模型容量不足。
4. 从预测到调度:把数字变成运维车的行驶路线
4.1 调度问题的本质是供需匹配
预测出每个站点未来 1 小时的借车量和还车量后,净需求 = 借车量 - 还车量。净需求为正,说明车会被骑走,需要补车;净需求为负,说明车会堆积,需要拉走。调度车有容量限制,一辆车最多装 20 辆,路线有时间窗。这就是一个带容量约束的车辆路径问题(CVRP)。
实际落地时,很多团队不会直接上 CVRP 求解器,而是先用贪心策略:按净需求绝对值排序,优先处理缺口最大的站点,调度车依次经过。贪心解不一定最优,但计算快,适合实时调度。
def greedy_dispatch(net_demand, vehicle_capacity=20, max_stops=10): """ net_demand: dict, {station_id: net_demand} 正数表示需要补车,负数表示需要拉走 """ # 按绝对需求排序 sorted_stations = sorted(net_demand.items(), key=lambda x: abs(x[1]), reverse=True) route = [] current_load = 0 for station, demand in sorted_stations[:max_stops]: if demand > 0 and current_load + demand <= vehicle_capacity: route.append((station, "补车", demand)) current_load += demand elif demand < 0: route.append((station, "拉车", abs(demand))) current_load = max(0, current_load - abs(demand)) return routevehicle_capacity是调度车实际能装的车辆数,一般电动三轮车 15 到 20 辆。max_stops限制单次路线站点数,太多会导致路线过长,运维人员执行不下去。这个贪心策略的缺点是没考虑站点间距离,实际使用时可以按地理聚类先分区域,再在每个区域内贪心。
4.2 调度效果怎么验证
调度方案好不好,不能只看预测误差。更直接的指标是“缺车率”和“满桩率”。缺车率 = 用户到达站点无车可借的次数 / 总借车请求。满桩率 = 站点车位满导致无法还车的次数 / 总还车请求。这两个指标需要仿真或历史回放来算。
一个简单的回放方法:拿历史某天的真实借还数据,用模型预测当天需求,生成调度指令,再模拟执行调度后站点的车辆数变化,统计缺车和满桩次数。对比不调度的情况,看指标下降多少。
def simulate_dispatch(initial_bikes, demand_series, dispatch_plan): """ initial_bikes: 各站点初始车辆数 demand_series: 各站点各时段净需求 dispatch_plan: 调度指令 """ bikes = initial_bikes.copy() shortage = 0 overflow = 0 for hour, demands in demand_series.items(): for station, net in demands.items(): bikes[station] -= net # 净需求为正表示被骑走 if bikes[station] < 0: shortage += abs(bikes[station]) bikes[station] = 0 if bikes[station] > 30: # 假设站点容量 30 overflow += bikes[station] - 30 bikes[station] = 30 # 执行调度 for station, action, amount in dispatch_plan.get(hour, []): if action == "补车": bikes[station] += amount else: bikes[station] -= amount return shortage, overflow这个仿真很粗糙,但能快速对比不同调度策略的相对效果。实际项目中,站点容量、调度车到达时间、用户到达分布都要建模,复杂度会高很多。
5. 避坑指南:共享单车预测与调度里最容易翻车的 5 个点
5.1 用随机划分做时序数据集的交叉验证
现象:训练集和验证集随机划分后,验证集准确率很高,但上线后预测完全不准。原因:时序数据有强时间依赖性,随机划分会让未来数据泄露到训练集。解决:必须按时间切分,比如前 80% 时间做训练,后 20% 做验证。更严格的做法是滚动窗口验证。
5.2 忽略站点冷启动问题
现象:新站点没有历史数据,模型预测全是 0 或均值。原因:模型依赖滞后特征,新站点这些特征缺失。解决:对新站点用相似站点迁移,或者用站点属性(周边 POI 类型、地铁口距离)做冷启动预测。源码包里如果没处理这块,上线后新站点会一直是盲区。
5.3 调度车容量和实际装载量不符
现象:调度指令让补 20 辆车,但运维车实际只能装 15 辆。原因:算法里设的容量是理论值,没考虑车辆改装、电池占用空间。解决:容量参数要跟运维团队确认,留 10% 余量。我一般设 15 而不是 20。
5.4 预测粒度与调度响应时间脱节
现象:模型做 15 分钟预测,但调度车 1 小时才能到。原因:预测频率高于执行频率,大量预测结果作废。解决:预测粒度对齐调度响应时间,或者做多步预测,一次输出未来 1 到 3 小时的需求。
5.5 忽略天气和突发事件
现象:雨天预测偏高,因为模型没学过雨天需求下降。原因:特征里没有天气字段。解决:接入天气 API,把降雨、温度、风力作为特征。突发事件如演唱会、马拉松,需要人工标注或接入事件数据,模型自己学不会。
6. 进阶技巧:用多步预测和滚动调度把缺车率再降 15%
单步预测只能告诉你下一小时的需求,但调度车从出发到完成路线可能要 2 到 3 小时。更实用的做法是直接预测未来 3 到 6 小时的需求序列,然后做滚动调度:每 30 分钟重新预测一次,只执行最近 1 小时的调度指令,后面时段的指令作为参考。
多步预测有两种实现方式。一种是直接多输出:模型最后一层输出未来 6 个时间步的值。另一种是递归预测:用预测出的下一小时值作为输入,再预测下下小时。直接多输出误差不累积,但模型输出维度高;递归预测实现简单,但误差会逐步放大。我一般用直接多输出,因为共享单车需求的日周期很强,模型能学到“晚高峰之后需求下降”这种模式。
class MultiStepGRU(nn.Module): def __init__(self, input_dim, hidden_dim=64, output_steps=6): super().__init__() self.gru = nn.GRU(input_dim, hidden_dim, batch_first=True) self.fc = nn.Linear(hidden_dim, output_steps) def forward(self, x): out, _ = self.gru(x) return self.fc(out[:, -1, :]) # 输出未来 6 个时间步output_steps=6表示预测未来 6 小时。训练时损失函数用 MSE,但可以对不同时间步加权,近期权重高,远期权重低。比如第 1 小时权重 1.0,第 6 小时权重 0.3。
滚动调度的逻辑是:每 30 分钟跑一次多步预测,取前 2 个时间步生成调度指令,执行完后再重新预测。这样即使预测有偏差,也能通过高频滚动修正。实测下来,相比单步预测加固定调度,缺车率能再降 10% 到 15%。
最后说个我自己的习惯:每次模型上线前,一定拿历史数据做一次“如果当时用了这个调度方案会怎样”的回放。回放结果和真实上线效果通常有 20% 左右的差距,但方向对不对,回放能看出来。如果回放都不行,上线肯定翻车。希望帮到你。
本文还有配套的精品资源,点击获取