☰
基于PyTorch LSTM的城市共享单车停放量预测实战
2026/10/2 18:28:52 网站建设 项目流程

简介:基于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_count
S0012024-05-01 07:00:0018
S0012024-05-01 08:00:0026
S0022024-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(drop=True) N_STEPS = 24 # 输入窗口:过去24小时 M_STEPS = 6 # 预测步长:未来6小时 def build_samples(group, n=N_STEPS, m=M_STEPS): values = group["bike_count"].values.astype(np.float32) x, y = [], [] for i in range(len(values) - n - m + 1): x.append(values[i:i+n]) y.append(values[i+n:i+n+m]) 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, axis=0) y = np.concatenate(y_list, axis=0) print(X.shape, y.shape)

代码逻辑说明:这段代码先按站点分组,对每个站点单独做滑窗采样。样本由长度为N_STEPS + M_STEPS的完整序列切成输入和输出,range(len(values) - n - m + 1)保证窗口不越界。最后把各站点的样本拼成一个大数组,作为模型输入。

参数说明:N_STEPS=24意味着用过去24小时的数据。为什么不是12或48?对共享单车来说,24小时能覆盖一个完整的日周期,模型能看到“昨天同一时刻”的停放水平,这对停车数量预测非常关键。M_STEPS=6对应未来6小时,这通常是调度决策需要的时间尺度。如果你的调度是小时级的,可以缩到M_STEPS=3;如果做次日运力规划,建议把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, axis=0) y = np.concatenate(y_norm_list, axis=0) # 按时间顺序切分,前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_size=1, hidden_size=64, num_layers=2, output_size=6, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout 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_first=True让输入张量形状更符合直觉,第一个维度是batch。LSTM返回的out保存了每个时间步的输出,我们只取最后一个时间步的隐状态,因为预测未来需要的是“看完整个输入窗口之后”的压缩信息。全连接层负责把隐状态映射到未来6个值上。

参数说明:hidden_size=64是个起步值,对单车数量这种单变量输入一般够用。num_layers=2让模型有更强的非线性拟合能力,但不是越多越好,层数过多在小数据集上很容易过拟合。dropout只在层数大于1时生效,它随机丢弃一部分神经元输出,降低过拟合风险。

3.3 训练循环:损失函数、优化器、学习率

模型结构定了之后,训练循环直接决定模型能不能收敛。我习惯用MSELoss配合Adam优化器,再加上学习率衰减和梯度裁剪。

import torch.optim as optim def train_model(model, train_loader, val_loader, epochs=30, lr=1e-3): criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=lr) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.5, patience=3 ) 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_norm=1.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(f"epoch {epoch+1:02d}, train_loss={train_loss:.4f}, val_loss={val_loss:.4f}") return model

逻辑说明:这是一个标准的PyTorch训练循环。clip_grad_norm_是LSTM训练里很实用的一个保护,长序列反向传播时梯度很容易爆炸,裁剪后训练更稳。ReduceLROnPlateau会在验证loss连续3个epoch不降时把学习率减半,这比固定学习率省心,不用手动调到半夜。

参数说明:lr=1e-3是Adam的常见起点。如果发现loss下降慢,可以试3e-3;如果loss震荡不收敛,降到3e-4。max_norm=1.0对应梯度范数上限,实际项目中在0.5到5.0之间都有人用,我习惯先设1.0。训练完保存模型权重:

torch.save(model.state_dict(), "bike_lstm.pt") # 加载时先实例化相同结构的模型 model = LSTMMultiStep(input_size=1, hidden_size=64, num_layers=2, output_size=6) 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_size=256, shuffle=False # 时序数据按时间顺序组织,不随机打乱 ) val_loader = DataLoader( BikeDataset(X_val, y_val), batch_size=256, shuffle=False )

逻辑说明:shuffle=False是故意设置的。时序预测里如果shuffle,一个batch里同时出现上午和凌晨的样本,模型虽然也能收敛,但验证时按时间切分的意义会被削弱。多站点场景下,如果你希望模型在每个batch里能看到不同站点的样本,可以改成shuffle=True,但训练集和验证集必须严格按时间切好,不要有重叠。

参数说明:batch_size=256对LSTM来说是个稳妥值。如果GPU显存不够,降到64或128;如果序列特别长,32也可以。batch太大会让梯度方向过于平滑,LSTM学到的是“平均站点”而不是“每个站点的细节”。

4.3 预测结果反归一化与输出

模型输出的是归一化后的值,要还原成真实停放数量。这里用到了2.3节存下来的scaler_dict:

def 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_size=5, hidden_size=64, num_layers=2, output_size=6)

特征扩展能显著提升预测效果,尤其是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而不是只看loss

MSE在训练时好用,但解释性差。业务上我一般用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, squared=False) print(f"MAPE={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)

直接用hour=23和hour=0在数字上相差23,但时间上只差1小时,丢整数会让模型学到错误距离。天气和节假日也能提升效果,但注意预测未来时天气是预报值、节假日是确定值,两种特征可靠度不同,需要留意特征分布漂移。

我自己的一个习惯是:每次换数据集,先把最简单的单站点、单变量基线跑通,确认链路没问题,再去加站点ID、天气、节假日。这样一旦效果变差,能快速定位是哪一环引入的问题。这套流程帮我在多个停车数据上少踩了不少坑,希望也能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询