简介:基于长短期记忆网络(LSTM)的共享单车使用情况预测项目,是一份面向高校学生与初学者的完整课程设计/大作业参考。内容覆盖数据预处理、多维度可视化分析、LSTM模型构建与预测结果对比,适合人工智能、数据科学、计算机等专业用于课设、毕设或项目初期验证。压缩包共32个文件,约8.06MB,包含4个Python脚本(模型训练、预测、可视化)、8个CSV数据文件、13张分析图表、H5模型文件及说明文档,目录结构清晰,配合README和依赖清单可快速复现。已有141人学习下载。项目附带完整共享单车骑行数据(按时和按天统计),提供训练/验证/测试集划分与标准化参数,并给出温度、湿度、风速、季节、工作日等多因素影响分析图,模型损失曲线与预测对比图也一并包含。代码均测试通过,适合作为高分作业参考,下载后也可私聊询问运行问题,支持远程教学。
1. 用 LSTM 预测共享单车使用量:这个高分大作业到底难在哪
如果你的课程设计或毕业设计选了“基于 LSTM 的共享单车使用情况预测”,那恭喜你,这是个典型到不能再典型的时间序列回归任务。它不像图像分类那样需要调一堆数据增强,也不像 NLP 那样要处理词表,它最核心的挑战就三个:把原始骑行记录整理成模型吃得下的序列、把 LSTM 的超参数调到不欠拟合也不过拟合、以及把预测结果用一张像样的图展示出来让答辩老师点头。这个标题里带的 Python 源码、文档说明和数据,其实就是把这三件事打包成一份可以交作业的工程。本文不假设你有现成源码,就按你手头有一份 CSV 骑行记录、一台能跑 PyTorch 或 TensorFlow 的电脑来展开,从数据构造讲到模型调优,最后落到你答辩时最可能被问的细节。
适用人群很明确:正在做课设、需要快速出成果但不想只调库跑通的本科生,以及刚接触时序预测、想弄明白 LSTM 每一步在干什么的研究生。读完你应该能独立完成数据预处理、滑窗构造、模型训练、反归一化和绘图展示,并知道哪些地方容易翻车、为什么翻车。
2. 先把数据收拾干净:从原始订单到能训练 LSTM 的序列
2.1 原始数据长什么样:时间戳、站点和骑行时长是三个关键字段
共享单车数据集通常来自政府开放平台或企业 API,字段一般包括开始时间、结束时间、起始站点、结束站点、骑行时长、车辆类型等。但你做 LSTM 预测,需要的是“按时间聚合后的使用量序列”,不是一条条订单记录。也就是说,你要先把数据从明细表压缩成“每半小时 / 每小时 / 每天有多少次骑行开始”这样的统计量。
常见做法是用 pandas 的resample按时间粒度聚合。之前我带过一个学生,他直接把 50 万条订单丢进模型,跑了一晚上没出结果,其实他连groupby都没做。记住:LSTM 吃的是序列,不是明细。先聚合,再滑窗,才是正确顺序。
import pandas as pd # 假设原始数据有两列:start_time 和 bike_id,其他列暂时不用 df = pd.read_csv('bike_trips.csv', parse_dates=['start_time']) df.set_index('start_time', inplace=True) # 按小时统计骑行开始次数 hourly = df['bike_id'].resample('1h').count().to_frame('count') # 补上缺失的小时段,避免时间轴空洞 hourly = hourly.asfreq('1h', fill_value=0) hourly.head()这段代码里resample('1h')是核心,它把任意时间戳归入所在小时,count()统计该小时内的订单数量。asfreq('1h', fill_value=0)用来填补那些没有订单的小时——比如凌晨三点可能一条记录都没有,但模型需要连续的序列,填空 0 比不填更合理,否则时间间隔不一致会让 LSTM 误以为中间有时间跳跃。
如果你拿到的是半小时粒度数据,把'1h'改成'30min'即可。注意resample默认左闭右开,意味着 09:00 的记录归入 09:00 那个桶,这点在答辩时经常被问,提前想好。
2.2 滑窗构造:为什么不能用全部数据直接训练
LSTM 的输入是一个三维张量(batch_size, time_step, feature_dim)。time_step是你用过去多少个小时去预测下个小时。例如用过去 24 小时预测下一小时,那time_step=24,feature_dim=1(只用历史使用量)。如果还想加入天气、温度或是否为工作日,feature_dim就变成对应列数。
构造滑窗时常见的错误是让相邻窗口重叠过多导致数据泄漏。严格来说,训练集、验证集、测试集必须按时间先后切分,不能用随机打乱。因为时序数据有自相关性,随机打乱等于让模型看到未来数据,测试集上的效果会虚高。
import numpy as np def create_sequences(data, time_step=24, pred_step=1): X, y = [], [] for i in range(len(data) - time_step - pred_step + 1): X.append(data[i:i+time_step]) y.append(data[i+time_step:i+time_step+pred_step]) return np.array(X), np.array(y) # 假设已经有归一化后的序列 values values = hourly['count'].values.astype('float32') # 先切分再归一化更严谨,但常见做法是先归一化再切分 from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler() values_scaled = scaler.fit_transform(values.reshape(-1, 1)).flatten() train_size = int(len(values_scaled) * 0.7) val_size = int(len(values_scaled) * 0.15) train_data = values_scaled[:train_size] val_data = values_scaled[train_size:train_size+val_size] test_data = values_scaled[train_size+val_size:] X_train, y_train = create_sequences(train_data, time_step=24, pred_step=1) X_val, y_val = create_sequences(val_data, time_step=24, pred_step=1) X_test, y_test = create_sequences(test_data, time_step=24, pred_step=1) # 调整维度到 (样本数, time_step, 1) X_train = X_train.reshape(-1, time_step, 1) X_val = X_val.reshape(-1, time_step, 1) X_test = X_test.reshape(-1, time_step, 1)这段代码有个容易踩坑的点:构造序列时range(len(data)-time_step-pred_step+1)决定了你从原始数据里取多少个窗口。如果你漏掉pred_step的偏移,最后几个样本会越界。另外,MinMaxScaler应该在构造序列之前对整体数据拟合,但这里有个细节——如果在切分前就 fit,那么 test 数据的信息已经参与了 scaler 的参数计算,严格上算轻微泄漏。对于课设作业通常可接受,但如果追求严谨,应该在训练集上 fit,再用同一个 scaler 转换验证集和测试集。这个差异值得你在文档说明里写一笔,能加分。
2.3 要不要加天气和日期特征:模型的第二根支柱
只用历史使用量预测未来,本质上是用过去的周期性推断未来。共享单车有明显的早高峰、晚高峰和周末效应,所以光靠使用量序列本身也能拟合出大概形状。但如果你想预测某天突如其来的雨天骑行量骤降,那纯序列模型做不到。这时候需要外生特征,比如温度、湿度、风速、是否节假日。
加特征的方式是在拼接滑窗时,把每个时间步的气象数据作为附加列拼在历史使用量后面。这样feature_dim从 1 变成 1 + 气象特征数。模型在每个时间步看到的是“那一小时的使用量 + 那一小时的气温”。
# 假设 weather_df 有温度列,索引与 hourly 对齐 merged = hourly.join(weather_df[['temp']], how='left') merged['temp'] = merged['temp'].fillna(method='ffill') features = merged[['count', 'temp']].values.astype('float32') # 每个特征单独归一化 scaler_count = MinMaxScaler() scaler_temp = MinMaxScaler() features[:, 0:1] = scaler_count.fit_transform(features[:, 0:1]) features[:, 1:2] = scaler_temp.fit_transform(features[:, 1:2])这里用fillna(method='ffill')处理气象站缺失值,是实战中不得已的办法。注意不要让归一化把两列混在一起,MinMaxScaler会对每一列独立计算范围,但不能在 2D 数组上直接用fit_transform对单列操作,容易广播出错。上面的切片写法保持了二维结构,比较稳妥。
加了外生特征的最大好处是模型能学到“下雨 + 低温 → 使用量下降”的组合模式,但这要求你的气象数据时间粒度和骑行数据一致,且没有大段缺失。如果数据里根本没有天气字段,那也别硬造,直接用纯使用量序列也能交一份合格的作业,只是性能天花板低一些。
3. 把 LSTM 模型搭起来:PyTorch 实现、参数选择和训练流程
3.1 为什么选 LSTM 而不是普通 RNN 或 Transformer
LSTM 引入门控机制,解决了长序列梯度消失问题。共享单车使用量的周期一般是 24 小时和 7 天,要用过去 24 小时预测下一小时,普通 RNN 在序列长度超过 20 时梯度已经很难传到最前面,LSTM 的遗忘门和输入门可以更好地保持长期记忆。对比 Transformer,它的计算复杂度更高,需要更多数据才能发挥优势,课设数据量通常就几千到几万小时,LSTM 更合适。而且 LSTM 的参数量小,CPU 上几分钟就能训练完,调试成本低。
PyTorch 里实现 LSTM 非常简洁。nn.LSTM接受input_size、hidden_size、num_layers三个主要参数,输出是output, (h_n, c_n)。你要预测的是下一个时刻的具体数值,所以取最后一个时间步的隐藏状态output[:, -1, :]接一个全连接层。
import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size=1, hidden_size=32, num_layers=2, output_size=1): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True) 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, :] y = self.fc(last) return y逻辑说明:batch_first=True让输入维度变成(batch, seq, feature),这比默认的(seq, batch, feature)更直观,不容易搞混维度。out[:, -1, :]表示对于每个样本,取序列最后一个位置的隐藏输出。hidden_size是 LSTM 内部记忆容量,设 32 到 64 通常够了,太大容易过拟合。
num_layers=2表示堆叠两层 LSTM,能增强模型表达能力,但也会增加训练时间和过拟合风险。如果数据只有几千个样本,建议先从 1 层试起,不行再加层。损失函数用nn.MSELoss(均方误差),因为这是回归任务。
3.2 训练循环里那几个容易写错的细节
训练 LSTM 最关键的坑是初始化隐状态。PyTorch 的nn.LSTM如果不传h_0, c_0,默认初始化为零向量,这在大多数情况下没问题,但如果你在循环里手动管理状态,必须记得每批数据重新初始化,否则上一批的隐状态会泄漏到下一批。
另一个坑是梯度裁剪。时序模型容易在某个异常峰值上产生大梯度,导致 loss 变成 NaN。加上clip_grad_norm_能有效避免这类翻车。
import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset model = LSTMPredictor(input_size=1, hidden_size=32, num_layers=2, output_size=1) loss_fn = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=0.001) # 将 numpy 数组转成 PyTorch Tensor train_dataset = TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) epochs = 50 for epoch in range(epochs): model.train() total_loss = 0.0 for X_batch, y_batch in train_loader: optimizer.zero_grad() output = model(X_batch).squeeze(1) loss = loss_fn(output, y_batch) loss.backward() # 梯度裁剪,防止梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() if (epoch + 1) % 10 == 0: print(f'Epoch {epoch+1:03d}, Loss: {total_loss/len(train_loader):.6f}')这段代码里shuffle=True是个容易争议的点。对时序数据,打乱训练样本会破坏时间顺序,但 LSTM 的每个样本内部已经包含了时间顺序,样本与样本之间本来就是独立的窗口,所以打乱不影响模型学到时间依赖,反而有利于优化器收敛。但要注意,验证集和测试集的 DataLoader 应设置shuffle=False,否则评估结果不稳定。
batch_size=64是权衡值。共享单车数据量不大,64 是稳妥选择。如果显存不足或数据量小,可以降到 32;如果追求更快训练,可以升到 128。lr=0.001对 Adam 是常用默认值,但如果你发现 loss 下降很慢或震荡剧烈,可以试试 0.0005 或 0.003。
3.3 验证和测试:如何判断模型真的在学习而不是背答案
训练过程中的 loss 下降只能说明模型在训练集上拟合了。你要在验证集上监控 loss,如果验证 loss 先降后升,就是过拟合信号。常见做法是保存验证 loss 最低时的模型参数,用这个最佳模型去跑测试集。
best_val_loss = float('inf') best_model_state = None model.eval() with torch.no_grad(): val_pred = model(torch.from_numpy(X_val)).squeeze(1).numpy() val_loss = loss_fn(torch.from_numpy(val_pred), torch.from_numpy(y_val)).item() # 测试阶段 test_pred = model(torch.from_numpy(X_test)).squeeze(1).numpy() # 反归一化 test_pred_inv = scaler.inverse_transform(test_pred.reshape(-1, 1)) y_test_inv = scaler.inverse_transform(y_test.reshape(-1, 1))反归一化这一步非常关键。模型输出的 0~1 之间的值要还原成真实骑行次数,才能算误差和画图。直接拿归一化后的值去算 RMSE 会得到一个小得离谱的数字,但答辩老师一眼就能看出你没做 inverse transform。
还有个细节:验证集 loss 的计算方式要和训练集一致,但不要做梯度裁剪,也不要用model.train()模式。因为 LSTM 里的 dropout 层在训练和测试时行为不同,你用model.eval()是必须的。如果你的模型加了很多 dropout,漏了这句会导致每次预测结果不一样。
4. 超参数和网络结构怎么调:从学习率到 hidden_size 的实用经验
4.1 学习率是最先要调的东西
我见过太多同学一上来就把lr设成 0.01,结果 loss 在 0.5 附近来回震荡,不下降也不爆炸,硬生生耗了半小时还以为是模型结构错了。先检查学习率,如果 loss 在一两个 epoch 内几乎不动,把lr降到 0.0005;如果 loss 变成 NaN,说明太大了,降到 0.0001 重跑。Adam 自带自适应学习率,但初始值还是有讲究。
使用ReduceLROnPlateau是个好习惯:当验证 loss 连续几个 epoch 不下降时,自动把学习率乘以 0.5。这在时间序列预测里通常比固定学习率多涨几个点的精度。
from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5, verbose=True) # 在每轮验证后调用 scheduler.step(val_loss)mode='min'表示监控指标越低越好,patience=5容忍 5 个 epoch 不下降再调整。损失曲线会变得平滑,但训练时间增加,适合做最终模型,不适合快速试错。
4.2 hidden_size 和 num_layers 怎么选
这不是玄学,但有规律。hidden_size控制 LSTM 记忆容量,太小欠拟合,太大过拟合。一个土办法是先用hidden_size=16跑通流程,看验证 loss 大概水平,然后依次试 32、64、128,画一条验证 loss 曲线,取最低点对应的值。对于共享单车这种有一定周期性的数据,32 或 64 通常足够。
num_layers从 1 到 2 提升明显,2 到 3 提升不大但训练时间翻倍。千万别用 4 层以上,除非你有上万小时的数据。层数越多,梯度在反向传播时越容易消失,即使 LSTM 有门控也不能完全抵消。
4.3 序列长度 time_step 到底该设多少
标题里没写序列长度,但作业通常默认用过去 24 小时预测下 1 小时。这个选择的价值在于能捕捉日内模式。如果你想预测的是次日的骑行量,time_step可以设 168(7 天)捕捉周周期。但要小心,序列越长,训练样本越少,因为滑窗会截掉开头部分。例如总共 3000 小时数据,time_step=24 能产生约 2976 个样本,time_step=168 就只剩 2832 个,差距不明显,但如果加上验证集构造时又从头部截掉一块,有效训练样本可能不够。
实际经验是:先测试 time_step=24 和 time_step=48,如果验证 loss 差不多,选较小的那个,因为训练更快。如果你要预测未来 24 小时,那pred_step=24,输出维度变成 24,模型最后一层 Linear 的输出要改成 24。这是一个大的结构调整,否则 loss 维度对不上会直接报错。
4.4 输出维度预测多步:从单步到多步的两种做法
多步预测有两种常见策略。第一种是直接多步输出,让模型一次输出未来 N 个值,修改output_size=N,损失函数在多个输出上取平均。第二种是递归预测,先用单步模型预测下一个值,然后把预测值当作输入,再预测下下个值。后者会累积误差,预测越远越不准;前者训练更容易,但 N 太大时模型负担重。共享单车课设一般只预测 1 到 24 小时,通常选直接多步输出,简单且误差可控。
实现直接多步输出时,模型改为:
class LSTMMultiStep(nn.Module): def __init__(self, input_size, hidden_size, num_layers, output_size): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): out, _ = self.lstm(x) last = out[:, -1, :] return self.fc(last) # 输出形状 (batch, output_size)然后标签y_train的形状也要从(batch, 1)换成(batch, output_size)。这个改动不大,但你在答辩时要能解释为什么直接输出多步比递归方法更常用:训练时目标值真实存在,模型显式学习未来 N 小时的变化;推理时不需要持续迭代,推理速度快。
5. 避坑与常见问题排查:LSTM 预测共享单车最容易翻车的 5 个地方
5.1 数据泄漏:切分顺序不对导致测试集虚高
现象:测试集 loss 比训练集还低,预测曲线几乎贴着真实值,但换一段新数据就完全不准。
原因:构造序列时使用了全量数据的MinMaxScaler,而且没有按时间切分,测试集里混入了训练集时间段附近的样本,模型“见过”类似序列。
解决:严格按时间比例切分原始数据,再在训练集上fit_transformscaler,对验证集和测试集只做transform。顺序是:原始序列 → 切分 → 分别归一化 → 构造滑窗。如果发现课程数据文件已经固定了一份叫 test.csv 的独立文件,那你直接分别归一化即可。
5.2 隐状态遗忘:模型在长序列推理时预测值趋近常数
现象:训练时 loss 正常,但测试时预测的后半段几乎是一条水平线,尤其长期预测时。
原因:模型只看到最近 24 小时或 48 小时窗口,如果测试序列开头偏离历史模式,模型只能依赖最后几个时间步的信息,长时间递归预测时误差累积,输出被拉向均值。
解决:检查是否用了递归多步预测。如果是,改用直接多步输出。如果一定要递归,可以在推理时加入真实观测值做 teacher forcing 的替代方案——即在每一步把真实前一个值喂给模型,但这只在在线预测场景可行,课设里用直接多步更稳。
5.3 数值不稳定:loss 出现 NaN
现象:训练到某个 epoch 后,loss 突然变成nan,后续无法恢复。
原因:输入数据里有 NaN 或无穷大,或者学习率过高导致权重爆炸。共享单车原始数据里可能有缺失start_time的行,聚合后出现空值,fillna(0)没做好。
解决:在聚合后做hourly.isna().sum()检查,有缺失就fillna(0)或前向填充。然后把lr降到 0.0001,加梯度裁剪clip_grad_norm_(max_norm=0.5)再重训。
5.4 预测值全在 0 到 1 之间,反归一化后曲线被压扁
现象:测试集 RMSE 很低,但画出来预测曲线峰值比真实值矮很多,低谷也高一点。
原因:用了 MinMaxScaler 归一化,且训练时 loss 在归一化空间优化,模型倾向于输出中间值以降低均方误差,因为这样对大峰值和小谷值的惩罚最小。
解决:换用 StandardScaler 让数据均值为 0、方差为 1,模型更容易预测相对波动。或者检查 min-max 缩放时是否有极端离群值,把 99% 分位数之外的截断掉。对于共享单车数据,偶尔会有体育赛事导致的 10 倍峰值,这种离群值直接影响 scaler 参数。
5.5 维度不匹配:out[:, -1, :]取出来后在反向传播时报错
现象:nn.LSTM输出维度明明是(batch, seq_len, hidden_size),但直接接nn.Linear时报错说输入维度不对。
原因:batch_first=False时(默认),输出是(seq_len, batch, hidden_size),你的切片逻辑全错。或者你在forward里对out做了 squeeze 操作,把 batch 维度挤掉了。
解决:在模型定义里显式写batch_first=True,并且不要对out使用squeeze(0)。保持三维形状,只取最后一维时间步。另外,打印out.shape是一个又快又直观的排错方法,别凭感觉写。
6. 把预测结果画成让答辩老师满意的图,并做误差分析
6.1 三线图:训练集、验证集、测试集上的预测对比
图的质量直接影响评分。不要只画测试集,应该把整个时间轴都画出来,用不同颜色区分数据集。由于数据量大,可以抽样展示,比如只画最近 200 个小时的测试集对比。
import matplotlib.pyplot as plt plt.figure(figsize=(14, 5)) # 测试集真实值(前 200 个点) plt.plot(y_test_inv[:200], label='True', color='#333') # 测试集预测值(前 200 个点) plt.plot(test_pred_inv[:200], label='Pred', color='#d9534f', alpha=0.8) plt.legend() plt.title('Test set: LSTM vs True Bike Usage') plt.xlabel('Hour') plt.ylabel('Trip Count') plt.tight_layout() plt.savefig('prediction_result.png', dpi=150)注意线条不要过密,200 个点已经足够看出趋势。alpha=0.8让预测线半透明以便重叠时容易观察。保存 PNG 时用dpi=150,放到论文里足够清晰。
除了直观的图,你还要算三个数值指标:MAE、RMSE、MAPE。MAPE 在有零值或接近零值时会爆炸,所以共享单车这种低谷可能为零的数据,优先用 MAE 和 RMSE 汇报。
from sklearn.metrics import mean_absolute_error, mean_squared_error mae = mean_absolute_error(y_test_inv, test_pred_inv) rmse = np.sqrt(mean_squared_error(y_test_inv, test_pred_inv)) # 对非零值计算 MAPE mask = y_test_inv.flatten() > 0 mape = np.mean(np.abs((y_test_inv[mask] - test_pred_inv[mask]) / y_test_inv[mask])) * 100 print(f'MAE: {mae:.2f} trips, RMSE: {rmse:.2f} trips, MAPE: {mape:.2f}%')6.2 误差分布图:用直方图判断是否存在系统偏差
如果预测值在高峰期系统性偏低,说明模型没有充分学习峰值模式。画误差直方图可以看到误差集中在哪个区间。
error = y_test_inv.flatten() - test_pred_inv.flatten() plt.hist(error, bins=50, color='#5bc0de', alpha=0.7) plt.xlabel('Prediction Error (trips)') plt.ylabel('Frequency') plt.title('Error Distribution on Test Set') plt.savefig('error_histogram.png', dpi=150)如果误差直方图明显左偏(负值多),说明模型普遍预测偏大;右偏则偏小。这时可以检查训练集最后的序列是否恰好处于上升期,模型学到的惯性让它继续上升。
6.3 一个进阶技巧:用滑动平均做后处理平滑误报尖峰
LSTM 预测有时会在某个点产生突刺,尤其真实数据里有特殊事件时。比赛或课设中,可以试试对预测结果做 3 小时的滑动平均,它会小幅降低 RMSE,但也会削弱峰值。我一般只在答辩展示时用它让曲线更平滑,作业提交里同时给原始预测和平滑后两条曲线,解释清楚各自优缺点,让老师看到你的思考深度。
项目文档说明里,建议把上面这些图和指标放到一张结果页里,辅以两段文字:第一段描述模型结构和训练配置,第二段解释误差来源并提出改进方向,比如加入天气特征、使用 Attention LSTM、改时间粒度。这样做完,技术含量和完成度都足够拿到高分。毕竟课设不只是跑通代码,更重要的是你能围绕着这个 LSTM 预测任务把数据处理、模型选择、评估闭环讲完整。希望这篇笔记里踩过的坑能帮你少走弯路,也祝你答辩顺利。
本文还有配套的精品资源,点击获取