☰
多站点LSTM时序预测的PyTorch实现:共享单车停放数量联合建模实战
2026/10/3 4:08:55 网站建设 项目流程

简介:一套基于PyTorch与LSTM的城市共享单车多站点停放数量时序预测系统项目资源。面向深度学习入门者、交通数据分析人员及共享单车运营方,解决多站点历史用车序列建模与未来时段预测问题。资源共13个文件,约92KB,含5个Python脚本,覆盖数据解析、LSTM模型定义、训练与评估全流程;另有CSV历史数据、已训练模型权重、Markdown说明、TXT注释及附赠Word文档,可直接运行或二次开发。目前已有30人学习下载。模型利用LSTM门控机制缓解梯度消失,适合非平稳多变量序列;配套文档说明数据集字段、参数设置与训练注意事项。通过研读代码,可完整复现数据预处理、模型构建、参数调优到预测输出链路,掌握PyTorch动态计算图开发;也可基于现有权重验证多站点预测,或替换数据迁移实验,为共享单车智能调度与城市规划提供量化参考。

1. 多站点时序预测的本质:单车停放数量不是单条曲线问题

共享单车停放数量预测这个需求,落到工程上就是经典的时序回归问题,用 PyTorch 搭 LSTM 建模也是网上最常见的套路。但真正让这类项目翻车的不是网络结构,而是“多站点”三个字。一个城市的单车停放数据往往来自几十到上百个站点,每个站点都有自己独立的潮汐规律:写字楼站点工作日早晚高峰两个波峰,居民区站点晚上和早晨反向波动,学校站点节假日直接归零。如果把所有站点当成一条序列训练,模型做出来的预测会趋向于“平均站点”,每个站都预测得不准。

这里的关键不是把 LSTM 调得多深,而是先想清楚数据如何组织、按什么粒度建模、单站点与全局模型各自适合什么场景。这篇笔记会从建模思路、数据处理、PyTorch 实现、参数取舍和踩坑记录一路讲完,适合做共享单车调度预测、短时交通流预测以及类似的金融时序预测迁移场景的从业者。

2. LSTM 在共享单车多站点预测里的建模思路:为什么用一个网络训练所有站点

2.1 为什么不用 ARIMA 或给每个站点各训一个模型

共享单车停放数量有一个非常明显的特征:强周期 + 强扰动。日周期、周周期都很稳定,但天气突变、市政活动、节假日会让序列在某个时间点突然偏离常态。ARIMA 类统计模型能抓住周期,但对这类外部扰动几乎没有建模能力,因为它本质上是把过去值的线性组合外推到未来,突发变化会被当成噪声抹平。

另一个常见做法是给每个站点单独训一个 LSTM。站点少的时候没问题,但站点一旦到几十个,就会暴露两个现实问题:一是大部分站点日租还量只有几十辆,单站点样本量撑不起一个有泛化能力的循环网络,训练集 loss 很低,验证集一塌糊涂;二是维护成本失控,新增一个站点就要重新训练一套模型,线上部署时还要管理几十个权重文件。

所以工业场景里的共享单车停车数量预测,主流做法是用一个模型吃下所有站点的历史数据,输入和输出都带站点维度。网络同时看到所有站点的演变轨迹,就能学到“一个典型的市中心站点在雨天会怎样变化”这类跨站点共性的规律,而不是死记某一条曲线的形状。

2.2 多站点联合建模的取舍:共享稀疏性、平滑噪声、可扩展

多站点联合建模的第一好处是共享稀疏性。冷启动站点只有几个月数据,单独训练必然过拟合,但放进全局模型后,它可以借助其他站点学到的周期特征快速收敛,相当于迁移学习的雏形。第二个好处是噪声平滑,把几十条序列放在一起算梯度,单站点的异常波动对整体参数更新的影响被稀释,模型更稳。

代价是模型必须有能力区分站点差异,否则会退化成“平均预测”。解决办法是在数据层面按站点分别归一化,而不是全局做一次缩放;在模型层面则要让每个时间步的输入是一个包含全部站点数值的向量。这样 LSTM 在每个时间步看到的是全站点的截面状态,输出头再一次性预测所有站点的未来数值。

这种设计的另一个隐性好处是扩展性。新站点加入时,只需要把矩阵新增一列,用已有权重继续 fine-tune 几步就能上线,不需要重写数据处理逻辑,也不需要重训整个模型体系。

2.3 样本量怎么算:日粒度还是小时粒度

数据粒度决定了这个项目能不能做成,而不是 LSTM 层数。以天为粒度,一年只有 365 个时间点,滑窗 28 天能切出 300 多个训练样本,对多站点联合模型来说偏少但勉强可用;以小时为粒度,一年有 8760 个点,样本量充足,可以抓到早晚高峰的精细变化,但噪声也大,模型要额外学习一天内不同时段的波动形态。

我一般按预测需求反推粒度。如果是给调度团队做次日车辆调运,日粒度加 7 天预测窗口就够用;如果是做站点潮汐预警、需要知道某个站点晚上 8 点会不会淤积,那就必须用小时粒度。小时粒度模型的输入特征里,最好显式加入“当天是星期几”和“当前是一天中的第几个小时”,因为 LSTM 虽然能自己学周期,但加上时间索引可以让它在数据量不足时收敛更快更稳。

样本量的经验判断是:小时粒度下,单站点至少要有 3 个月连续数据,联合模型的总样本数 = 滑动窗口切出的窗口数量,这个数量级在几千到几万都属正常。低于这个量级时,把预测粒度调粗比加复杂模型更实际。

3. 用 PyTorch 搭建 LSTM 多站点预测:数据管道与模型定义

3.1 把 CSV 变成站点×时间矩阵:pivot 与缺失值处理

原始数据通常是长表格式,每一行是一条记录,包含站点编号、时间戳和停放数量。第一步是把长表转成宽表,行为时间、列为站点,这样后续滑窗和模型输入都清晰。缺失值处理上,连续缺失不超过 2 个时间点可以用线性插值,超过 2 个的时间段直接置空并在构造样本时跳过,千万不要用整列均值填充,那会把站点个性抹掉。

import pandas as pd import numpy as np df = pd.read_csv("bike_usage.csv", parse_dates=["timestamp"]) # 长表转宽表:行是时间,列是站点 matrix = df.pivot_table( index="timestamp", columns="station_id", values="count", aggfunc="sum" ).sort_index() # 阈值内的缺失线性插值,超过阈值的置 NaN 后整段丢弃 matrix = matrix.interpolate(limit=2, limit_area="inside") matrix = matrix.dropna(axis=0, how="any") # 保留完整站点:缺失率超过 30% 的站点直接剔除 valid_stations = matrix.columns[matrix.isna().mean() < 0.3].tolist() matrix = matrix[valid_stations] print(matrix.shape)

逻辑说明:pivot_table 里 aggfunc 用 sum 是因为同一站点在同一时间戳可能出现多条记录,需要聚合;interpolate 只处理内部缺失,序列首尾的缺口不补;dropna(axis=0, how="any") 保证每条样本的所有站点数值完备,避免训练时出现 NaN 传播。valid_stations 的过滤是防止一个几乎没数据的站点拖累全局归一化。

3.2 按站点归一化 + 滑窗采样:Dataset 怎么写

归一化是数据管道里最重要的一步。停车数量分布极度不均匀,热门站点日均 300 辆,冷门站点可能只有 3 辆。全局 MinMax 会把冷门站点压缩到接近 0,LSTM 几乎学不到它们的信号。正确做法是每个站点单独做 MinMaxScaler,把每个站点的数值映射到 [0,1],预测完再反归一化回原始数量。

import torch from torch.utils.data import Dataset from sklearn.preprocessing import MinMaxScaler def fit_scalers(matrix): scalers = {} scaled = np.zeros_like(matrix, dtype=np.float32) for i, col in enumerate(matrix.columns): s = MinMaxScaler(feature_range=(0, 1)) scaled[:, i] = s.fit_transform(matrix[col].values.reshape(-1, 1)).ravel() scalers[col] = s return scaled, scalers class MultiSiteDataset(Dataset): def __init__(self, scaled, window=28, horizon=7): self.window = window self.horizon = horizon # 输入特征和预测目标都来自同一矩阵,但时间段错开 self.X, self.y = [], [] for i in range(len(scaled) - window - horizon + 1): self.X.append(scaled[i : i + window]) self.y.append(scaled[i + window : i + window + horizon]) self.X = torch.tensor(np.array(self.X), dtype=torch.float32) self.y = torch.tensor(np.array(self.y), dtype=torch.float32) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx]

逻辑说明:fit_scalers 按列拟合,每列的缩放参数只由该站点自己的历史数据决定,这样模型输入里每个维度都处于可比尺度,又保留了站点间相对差异。MultiSiteDataset 每次返回一个形状为 (window, n_station) 的输入和一个形状为 (horizon, n_station) 的目标,网络要学的是从过去 28 天所有站点的状态,映射到未来 7 天所有站点的状态。

注意点:时序数据的 train/test 切分必须按时间顺序,不能用随机划分。通常按时间取前 80% 做训练、后 20% 做验证,并且验证集的归一化参数必须来自训练集的 scaler,不能重新 fit。这个坑在第五章还会展开。

3.3 LSTM 模型定义:input_size 为什么等于站点数

模型结构是这套方案的核心。既然前面把输入做成了多站点向量,LSTM 的 input_size 就应该是站点数,而不是 1。每个时间步 LSTM 接收一个向量,向量的每个维度对应一个站点的当日数值,hidden state 在两个时间步之间传递的是“整个城市停放状态的演变”信息。这样设计后,站点之间的相关性是模型天然学习的对象,而不是人工强加的特征。

import torch.nn as nn class MultiSiteLSTM(nn.Module): def __init__(self, n_station, hidden_size=64, num_layers=2, horizon=7, dropout=0.2): super().__init__() self.n_station = n_station self.horizon = horizon self.lstm = nn.LSTM( input_size=n_station, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0 ) self.head = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.ReLU(), nn.Linear(hidden_size // 2, horizon * n_station) ) def forward(self, x): # x: (batch, window, n_station) lstm_out, _ = self.lstm(x) # 取最后一个时间步的 hidden state last_hidden = lstm_out[:, -1, :] # (batch, hidden_size) out = self.head(last_hidden) # (batch, horizon * n_station) return out.view(-1, self.horizon, self.n_station)

逻辑说明:nn.LSTM 的 batch_first=True 表示输入形状是 (batch, seq_len, input_size),这里 seq_len 对应滑窗长度,input_size 对应站点数。取 lstm_out[:, -1, :] 是拿最后一个时间步的输出作为整段序列的编码,再接全连接头一次性输出未来 horizon 步、每步 n_station 个数值。

参数说明:hidden_size 控制网络的记忆容量,64 对几十个站点通常够用;num_layers=2 能捕捉更抽象的时序模式,但层数超过 2 在数据量不大时收益有限还容易过拟合;dropout 只加在多层 LSTM 的层间,单层 LSTM 加 dropout 会破坏记忆传递。预测头用了一个带 ReLU 的两层全连接,给非线性映射留空间,但如果你的数据噪声很大,也可以直接用一个 Linear 层,更不容易过拟合。

3.4 训练主循环里不能省的五件事

训练循环是新手最容易忽略细节的地方。很多人直接 for epoch 套 batch 就算完,结果 loss 曲线反复震荡或者训到一半 loss 变成 NaN。下面这个训练函数收敛了常见的最佳实践。

import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau def train_model(model, train_loader, val_loader, epochs=60, lr=1e-3): torch.manual_seed(42) np.random.seed(42) optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=8) criterion = nn.HuberLoss(delta=1.0) best_loss = float("inf") best_state = None wait = 0 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() # 梯度裁剪:防止循环网络梯度爆炸 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) val_loss += criterion(pred, y_batch).item() * x_batch.size(0) val_loss /= len(val_loader.dataset) scheduler.step(val_loss) # 早停:连续 12 轮验证 loss 不降就停 if val_loss < best_loss: best_loss = val_loss best_state = {k: v.clone() for k, v in model.state_dict().items()} wait = 0 else: wait += 1 if wait >= 12: break model.load_state_dict(best_state) return model

逻辑说明:clip_grad_norm 是 LSTM 训练的必备项,循环网络在长序列上反向传播时梯度范数很容易超过 1,不裁剪会出现 loss 突然跳到 NaN。ReduceLROnPlateau 在验证 loss 不降时把学习率减半,比固定学习率训到底更稳。早停保存的是验证集上最优的权重,不是最后一轮的权重,因为最后一轮很可能已经过拟合。

参数说明:HuberLoss 比 MSELoss 对极端值更鲁棒。停放数量序列里偶尔会有异常峰值(暴雨前的集中还车),MSE 会把 loss 抬高几个数量级导致训练震荡,Huber 在误差大时退化为线性损失,梯度更温和。weight_decay 给 LSTM 加上 L2 正则,建议从 1e-4 开始调。

4. 训练配置与多步预测参数取舍:窗口、批次、学习率与未来步长

4.1 滑窗、horizon、batch 与站点数之间的张力

这几个参数不是独立调的,它们互相挤占。滑窗长度决定了模型能看到多长的历史;horizon 决定了要预测多远;batch 大小影响梯度稳定性和显存占用;站点数则固定了输入向量的维度。下面是我常用的经验参数表,按日粒度数据整理。

参数推荐范围说明
window_size7 ~ 28日粒度数据用 14 或 28 天;小时粒度数据可以到 48 ~ 168
horizon1 ~ 7预测超过 7 天时,中期预测会明显退化
hidden_size32 ~ 128站点数在 50 以内用 64,超过 100 可考虑 128
batch_size32 ~ 256样本数少时 batch 小一点,避免梯度过于平滑
num_layers1 ~ 2数据量低于 1 万样本,用单层更稳
learning_rate1e-4 ~ 3e-3多站点联合模型建议从 1e-3 起调

batch 与站点数的关系是:输入每个样本的形状是 (window, n_station),如果站点数上百,每步输入的向量就是上百维,LSTM 的参数量也随之增大,此时 hidden_size 和 batch 都要往低调,否则显存和过拟合风险同时上升。

多站点场景下 batch 还有一个选择:每次迭代只采样部分站点,而不是全部站点。这在站点数特别多时能显著降低显存压力,但代价是模型在一个 batch 内看不到完整的城市空间截面。我的经验是 100 个站点以内直接用全部站点,超过 200 个再考虑按站点采样。

4.2 优化器、学习率调度与损失函数的选择逻辑

AdamW 和 Adam 的差别在于 weight_decay 的实现方式,AdamW 把权重衰减和梯度更新解耦,正则效果更干净,是当前训练循环网络的主流选择。学习率调度上,ReduceLROnPlateau 比 CosineAnnealingLR 更适合时序预测,因为时序任务的验证 loss 曲线通常不是平滑下降的,Cosine 的周期性容易在 loss 上升阶段继续大步长更新。

损失函数的选择要看数据分布。停车数量序列是长尾的,大多数站点数值很小,少数热门站点数值很大。MSELoss 对热门站点的误差惩罚呈平方放大,训练过程会过度关注那两三个大站。HuberLoss 的 delta 参数控制从平方损失过渡到线性损失的阈值,delta=1.0 时误差小于 1 的样本用平方惩罚,大于 1 的样本用线性惩罚,既保留了梯度平滑性,又不会让极端站点主导训练。

如果调度需求是“不要预测得太离谱”,也可以考虑 MAE;如果要做不确定性估计,就得分位数损失,这个在第六章展开。

4.3 反归一化与无偏评估:指标必须在原始尺度算

训练 loss 是在归一化后的空间计算的,数值很小不代表真实预测精度好。评估时要把预测结果反归一化回原始数量,再计算 MAE、RMSE 这类指标,否则冷门站点和热门站点的误差权重是失真的。

def inverse_transform(pred_norm, scalers, stations): # pred_norm: (batch, horizon, n_station) pred_raw = np.zeros_like(pred_norm) for i, col in enumerate(stations): s = scalers[col] pred_raw[:, :, i] = s.inverse_transform( pred_norm[:, :, i].reshape(-1, 1) ).reshape(-1, pred_norm.shape[1]) return pred_raw

逻辑说明:反归一化是按站点逐个做的,每个站点用自己的 scaler 还原。聚合总误差时也要先还原到原始尺度再算,而不是把归一化空间里的误差直接平均。

评估指标还有一个常用细节:MAPE 在低流量站点上会爆炸。某个站点真实值只有 2,预测值 4,MAPE 就是 100%,而同一个误差发生在真实值 200 的站点上只有 2%。所以多站点评估建议分组看:按站点日均流量把站点分成高、中、低三档,分别统计 MAE,这样哪个档位预测差一目了然,也方便定向优化。

5. 多站点 LSTM 预测避坑指南:五个让模型翻车的细节

5.1 现象:随机打乱数据后训练 loss 下降正常,但实测一塌糊涂

原因:时间序列的样本之间不是独立的。如果把滑窗后的样本随机 shuffle 进训练集和验证集,测试集里会出现“未来数据训练过”的情况,这属于典型的标签泄露。时序预测的评估目标是预测未来,一旦未来混进训练集,loss 就会失真。

解决:切分必须按时间顺序。常见做法是按时间取前 80% 做训练、后 20% 做验证,并且确保验证集的最小时间戳大于训练集的最大时间戳。shuffle 只允许发生在训练集内部,而且实际收益有限,我的习惯是不 shuffle 时序数据,让模型严格按时间顺序消化样本。

5.2 现象:冷门站点的预测值几乎恒定为该站点的历史均值

原因:全局 MinMax 归一化。所有站点共用一个缩放器时,热门站点的数值范围可能是 0 到 300,冷门站点是 0 到 5,缩放后热门站点的差异占据了绝大部分数值空间,模型完全感知不到冷门站点的波动。

解决:按站点独立归一化,这正是前面代码里 fit_scalers 的做法。另一个辅助手段是 loss 按站点加权,给低流量站点更高的权重,但这一步要在按站点归一化之后才有意义。优先做归一化,站点加权只在你确认某个站点群是业务重点时再用。

5.3 现象:无论输入什么,模型输出都趋向于近期均值

原因:这是多个因素叠加的结果。最常见的是数据不平稳,比如站点因为周边施工出现整体数量漂移,LSTM 对这种趋势性变化反应滞后;其次是 loss 函数和正则太强,把模型压得过于保守,预测值向均值收缩;还有可能是滑窗太长,模型在大量历史信息面前找不到真正关键的近期信号。

解决:先检查数据平稳性,如果序列有明显趋势,对序列做一阶差分后再训练,预测完再加回差分;其次调低 weight_decay,观察预测方差是否变大;最后缩短滑窗到 14 天或 7 天,让近期信号在输入中占比更高。不要一上来就加双向 LSTM 或 Attention,这些在单序列预测里很少是根因。

5.4 现象:平时预测还行,一到节假日误差突然翻倍

原因:纯数值序列的 LSTM 只能从历史数值里推断规律,节假日这种“时间索引特殊 + 行为模式突变”的情况,它只能靠相邻年份同一天的记忆,数据量不够时就只能猜。

解决:把日期特征作为额外输入拼进每个时间步。具体做法是在 (window, n_station) 的基础上,在每个时间步后面拼接“是否周末”“是否节假日”“星期几”三个特征,输入维度变成 n_station + 3。这个改动一般能让节假日预测误差下降 20% 以上,是这类系统中性价比最高的特征工程。

5.5 现象:预测长度越长,后期的预测越像一条平线

原因:多步预测有两条路线。直接预测是模型一次输出未来 7 天,训练时它已经看到中间时刻的误差反馈;递归预测是用 t+1 的预测值作为 t+2 的输入,误差会随步长累积,预测越长越失真。如果你用的显式递归推理,平线几乎必然出现。

解决:默认用直接多步输出,也就是前文模型里定义的 (batch, horizon, n_station) 结构。如果业务确实需要超长预测,把 horizon 拆成几段,分段训练三个模型分别预测 1-3 天、4-7 天、8-14 天,比一个模型硬撑 14 天可靠得多。

6. 预测结果的检验与进阶:损失值之外你要看的东西

单一 MAE 值无法证明这个系统能上线,我一般做三件事。第一,按站点日均流量分桶看误差,如果高流量站点 MAE 占比异常高,说明模型在“和稀泥”,优先检查归一化和损失函数;如果低流量站点误差大,优先加日期特征。第二,抽样画真实值 vs 预测值的曲线,重点看波峰和波谷是否对齐,错位一个时间步比数值偏差更容易被曲线图发现。第三,看预测是否具备业务可用性——对调度来说,点预测只告诉你会淤积多少辆车,但“50 到 80 辆”这个区间比“60 辆”这个单点更有决策价值。

进阶方向是把 LSTM 的输出头改成三个分位数头,分别预测 10%、50%、90% 分位数,损失函数用分位数损失,这样模型直接输出预测区间。改动量不大,只涉及模型输出维度和损失计算,但调度团队可以据此判断“是否需要提前调车”,实用性上了一个台阶。

如果以后数据量大了,可以对比一下把 LSTM 编码器替换成 Transformer 编码器的效果。Transformer 在长序列上能捕捉更远距离的依赖,但它需要的数据量也更大,样本量低于一万时,LSTM 往往表现得比 Transformer 更稳。

我做这类预测项目的习惯是:第一版永远只用最朴素的结构,把数据管道的坑全部踩平,再谈加复杂模块。多站点 LSTM 的价值不在模型花哨,而在于数据组织和评估方式是否对得起业务诉求。希望帮到你。

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

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

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

立即咨询