前阵子我接到一个共享单车需求预测系统的开发任务,核心就是用LSTM神经网络去预测不同站点未来几个小时的借还车需求。这个项目挺典型的,做智能交通、做城市数据分析的人应该都会遇到类似的场景:车总是"潮汐式"地从一个地方消失,又在另一个地方堆积,调度车永远在救火。预测做不好,调度就是盲人摸象。
这篇博客打算把我从数据清洗、特征构建、模型训练到最终部署的完整过程都写出来,包括踩过的坑和实测有效的方法。适合正在做时序预测、想用LSTM解决实际业务问题的朋友,也适合想了解共享单车调度系统是怎么运作的人。我会尽量把每一步为什么这么做讲清楚,而不是只给结论。
1. 先搞清楚:看起来是调度问题,本质上是预测问题
1.1 共享单车的潮汐效应到底有多夸张
共享单车最让人头疼的就是早晚高峰的"潮汐效应"。早高峰,住宅区的车在半小时内被骑走一大半,而地铁口、写字楼门口的车多到停不下;晚高峰正好反过来。你可以想象一下:一个热门住宅站点,早上七点半的库存是80辆,到了八点十分就空了;而三百米外的地铁站,此时已经有上百辆滞留车。调度员如果不知道接下来会发生什么,就只能等站点满了或者空了才派车,这种模式在运营上叫"事后调度",永远慢半拍。
所以这个项目的第一个关键判断是:与其花大力气去优化调度路径算法,不如先把"预测"这一步做扎实。只要能在高峰到来前的1到3个小时内大致知道每个站点会缺多少车、多多少车,调度车就可以提前行动。预测的精度每提高一点,调度效率的提升是成倍放大的。这也是为什么需求预测系统会成为整个调度系统的中枢模块。
1.2 为什么是LSTM,而不是ARIMA或者XGBoost
做时序预测,很容易纠结选什么模型。我最开始也把常见方案都过了一遍:ARIMA、SARIMA、XGBoost、LightGBM、LSTM,甚至Transformer也看了一眼。如果只是预测总量,SARIMA其实够用,它能把日周期和周周期都建模出来。但问题是这个项目需要做到站点级预测,一个城市几百个站点,每个站点的波动规律都不一样,有的站点工作日和周末完全两个形态,有的站点会受天气影响剧烈波动。ARIMA这种统计模型在面对多站点、多特征交叉影响的时候,调参成本高到离谱。
XGBoost这类树模型也能做,而且在小样本上表现经常比神经网络还稳。但树模型做时序预测有一个天然的劣势:它本身不理解"顺序"这件事。你要靠手工构造滞后特征、滑动窗口统计量去告诉它"昨天这个时候有多少人借车""过去三小时的平均用车量是多少"。LSTM不一样,它内部有门控机制,能自己从历史序列里学会"记住什么、忘掉什么"。数据质量一般、特征工程不想做得太重的时候,LSTM更容易端到端跑通。实测下来,在站点级预测这个任务上,LSTM比XGBoost的RMSE低了大概12%到15%,尤其是面对突发天气变化时,LSTM的响应更平滑,不会出现预测值剧烈跳变的情况。
2. 系统整体设计与技术选型思路
2.1 系统分层:数据怎么进门、模型在哪儿跑、结果往哪儿去
整个系统我分成了三层:数据层、预测层、应用层。这个分层不是拍脑袋定的,而是为了方便后面独立迭代。
数据层负责把多源数据汇总到一起。主要包括三块:历史骑行订单记录、天气数据、日历特征数据。订单记录的粒度是单次骑行流水,包含站点ID、借车时间、还车时间、借车站点、还车站点;天气数据按小时记录,包含温度、湿度、风速、天气现象编码;日历特征就是是否工作日、是否节假日、是否周末这一类标志位。数据层统一清洗后写入数据库,按小时粒度聚合成每个站点的借车量和还车量。
预测层是核心。它包含离线训练和在线推理两条链路。离线训练每天凌晨跑一次,用过去30天的数据重新训练模型;在线推理是一个常驻服务,接收"站点ID+当前时间"的请求,返回未来1到6小时每个小时的预测借还量。推理结果会缓存起来,因为同一个站点在五分钟内被频繁查询,没必要每次重新计算。
应用层面向运营人员。调度大屏上展示每个站点的供需热力图,颜色越红表示即将缺车越严重,颜色越蓝表示即将堆积越严重。调度工单系统会根据预测结果自动生成建议:某站点在某个时段预计缺多少车,建议调度车在什么时间窗口内调度多少辆车过去。
2.2 技术栈怎么选,为什么这么选
技术栈的选择我遵循一个原则:够用、好调试、生态成熟。开发语言用Python,这个没有悬念,深度学习生态基本都在Python这边。深度学习框架我选了PyTorch而不是TensorFlow,主要是因为动态图机制调试起来太方便了,训练中断了想打断点看中间张量,直接就可以操作,不用像静态图那样先把图编译一遍。而且PyTorch在时序模型上有大量现成组件可以使用。
数据库用了PostgreSQL。这里有个小考虑:虽然订单数据是典型的时序数据,理论上用时序数据库更合适,但考虑到项目中还要存站点元数据、调度工单、模型版本信息这些关系型数据,用PostgreSQL一把梭能省掉很多数据同步的麻烦。PostgreSQL的窗口函数在处理滑动窗口统计时也非常顺手,比如计算"过去7天同一时段平均借车量",一条SQL就能搞定。
推理服务用FastAPI,轻量、异步支持好,模型加载到内存后直接用PyTorch的CPU推理,单次预测在毫秒级。Redis用来做结果缓存,TTL设置5分钟。可视化用了Grafana,因为它的时序图表做得最好,而且支持从PostgreSQL直接拉数据,省去自己写前端图表的成本。整个系统跑在一台32核64G的服务器上,CPU训练完全够用,因为模型结构不大、数据量也就几十万条序列样本,不需要上GPU。
3. LSTM模型原理与需求预测的适配逻辑
3.1 把LSTM的门控机制讲得通俗一点
可能有人对LSTM不太熟悉,我用一个简单的方式解释一下。普通的RNN在处理长序列的时候有个问题:信息在传递过程中会逐渐丢失,传到后面几步,最开始的关键信息早就被冲淡了。你可以把它想象成一个人听一段很长的会议内容,听到最后,开头讲了什么都忘了。LSTM专门解决这个问题,它在内部加了一个"记忆通道",也叫细胞状态,可以理解为一个笔记本。网络可以选择往笔记本上写内容、保留内容、或者擦除内容。
往笔记本上写多少新东西,由输入门控制;之前的记忆保留多少,由遗忘门控制;最终输出什么,由输出门控制。这三个门都是可以学习的,模型在训练过程中会自动摸索出一套规律。放到共享单车场景里就很好理解:模型会学会记住"这个站点工作日早高峰的借车高峰大概在7点半到8点半之间"这种周期性记忆,同时会学会在新的天气信息飘进来的时候,比如突然下雨,把"昨天这个时候的借车量高"这个记忆适当地忘掉一部分,因为下雨天大家不太愿意骑车。
3.2 时间步与特征设计:LSTM吃什么、吐什么
LSTM的输入是一个三维张量,形状是(batch_size, time_step, feature_dim)。time_step是回溯窗口长度,也就是用过去多少个时间点的数据来做预测;feature_dim是每个时间点上的特征数量。在这个项目里,时间粒度是小时,time_step取的是24,也就是用过去24小时的数据预测未来。之所以不取更短或者更长,是经过了实际对比的:time_step小于12的时候,模型缺少对"上一整天同一时刻"的记忆,预测值明显滞后;取48甚至72虽然训练集增加了,但对RMSE的改善很小,训练时间却翻倍,性价比不高。
特征数量大概有15个左右,分成了三大类。第一类是历史序列特征,包括目标站点过去24小时每个小时的借车量、还车量、当前车辆库存量。第二类是时间特征,包括小时序号(0到23)、是否周末、是否节假日。第三类是天气特征,包括温度、湿度、风速、天气现象编码(0代表晴、1代表多云、2代表小雨、3代表大雨)。这里有个细节值得说:小时序号不能直接当作数值喂给模型,因为23点和0点虽然数值上相差很大,但在时间意义上只差一小时,直接用原始数值会让模型学出错误的关系。我的做法是做正弦余弦编码,把小时拆成sin和cos两个分量,这样23点和0点在编码空间里就是相邻的点。
模型的输出根据预测视界决定。如果只预测未来1小时,输出就是两个值:借车量和还车量。如果预测未来6小时,可以选择两种方案:一种是把输出层神经元数设为12,直接一步输出6个小时的借还量;另一种是用序列生成的方式,把上一个小时的预测结果作为输入喂给模型,滚动往下推。我实测下来,6小时以内直接一步输出更稳定,因为滚动生成会累积误差,越滚越偏。所以最终方案是对1小时、3小时、6小时分别训练了三个模型,而不是一个模型干所有事。
3.3 站点数量太多,怎么让模型训得动
一个城市可能有几百甚至上千个站点。如果给每个站点单独训练一个LSTM模型,训练和部署的成本都会爆炸;如果所有站点混在一起训练一个模型,又很难学出每个站点的个性化规律。我采用的折中方案是先聚类、再分组建模。
具体做法是根据每个站点过去30天的历史骑行曲线,提取几个统计特征:早晚高峰的形状、平峰期用车量水平、周末工作日差异程度、以及站点周边环境类型(居民区、办公区、地铁站、商圈)。用KMeans把这些站点聚成几类,在这个项目里聚成了6类。然后每一类训练一个模型,模型的输入特征里会加一个额外的"站点嵌入向量",用Embedding层把一个站点ID映射成一个8维向量。这样做的好处是同一类里不同站点之间可以互相学习,数据量少的站点也能借用同类站点的共性规律,有效缓解了小站点样本不足的问题。
实测下来,聚类分组模型的整体RMSE比不分组的全局模型低了约10%,比每个站点单独建模低了 примерно 5个百分点,主要收益来自中小型站点的预测稳定性。
4. 实操全流程:从数据清洗到模型训练的关键细节
4.1 数据清洗:时序数据最怕乱处理
这个环节看着不起眼,实际上对最终效果的影响比模型结构还大。我踩过的第一个坑就是缺失值处理。某站点某天的数据因为设备离线一整天全没有,如果直接把这一行删掉,时间序列就断了一截,LSTM在滑动窗口取样时会把这个断点误认为是"突然没人用车了",预测结果会出现诡异的低谷。正确做法是先用插值把缺失位置补上。对于单点缺失,用前后几个小时的均值填充;对于连续大段缺失,用上周同一天同一时段的数值来填充。
异常值也要小心处理。比如某个站点因为有人批量维保或者活动集中投放,单小时借车量突然飙到正常值的好几倍。这类异常值对模型训练的影响很大,因为LSTM用的是均方误差做损失函数,离群点会给梯度带来巨大的拉动。我统计了每个站点历史分布的95分位数和99分位数,把超过99.5分位数的值截断到99.5分位数。注意是"截断"而不是"直接删除",因为时间序列的长度不能随意变短,截断能保留数据点的存在性,只是把极端值拉回到合理范围。
训练集、验证集、测试集的划分也有讲究。时序数据绝对不能随机打乱后再划分,必须按时间顺序切分。我用的是前70%做训练,中间15%做验证,最后15%做测试。验证集用来做早停和调整超参数,测试集只在最终评估时碰一次,这样得到的评估指标才有说服力。
4.2 特征工程与数据集构建
数据集构建的核心是滑动窗口。我写了一个简单的窗口生成器:遍历每个站点每一天,以每个小时为预测起点,向后取24小时的历史数据作为输入,向前取未来1小时(或3小时、6小时)的借还量作为标签。滑动步长是1小时,也就是说数据在时间上是高度重叠的。这种重叠会让训练样本之间存在相关性,理论上对模型泛化有影响,实践中影响不大,但会让验证集的评估结果显得偏乐观,心里要有数。
除了模型直接用的特征,我还额外构造了一个强特征:过去7天同一时段的历史平均借车量。这个特征非常有效,因为它相当于给模型提供了一个"季节性基线",LSTM只需要学习这个基线之外的偏差就可以了。训练的时候我把这个特征也放进输入序列,让LSTM自己去决定参考权重。
归一化我用的是RobustScaler,而不是常见的MinMaxScaler。原因是共享单车的需求数据有长尾分布,某些热门站点在特殊事件期间的峰值远高于普通时段,MinMaxScaler会被这些极端值拉偏,导致普通时段的差异被压缩到很窄的区间里。RobustScaler使用中位数和四分位距,对异常值不那么敏感,训练出来的模型在峰值时段的预测也更稳健。
4.3 模型结构与训练参数配置
模型结构没有做得特别花哨,就是一个标准的双层LSTM加全连接输出层。具体参数是:隐藏层维度64,层数2,dropout设为0.2,输入特征维度15,输出维度根据预测视界决定(1小时预测就是2个值,6小时预测就是12个值)。优化器用AdamW,初始学习率0.001,batch size是64,训练最多跑80个epoch,同时用早停机制,验证集损失连续10个epoch没有下降就停止训练。
这里说一下为什么用AdamW而不是普通Adam。AdamW把权重衰减和梯度更新解耦了,在LSTM这种带大量正则项的结构里,训练稳定性更好一些,尤其是在学习率需要调低的时候不容易崩。dropout加在LSTM层之间而不是输入输出层,这个位置很关键。如果加在输入层,会把历史序列的关键信息随机丢弃,模型反而学不好;加在层间可以防止对训练数据的死记硬背。
训练过程中的收敛曲线也值得看一眼:前5个epoch训练loss下降很快,从初始的3.8左右降到0.9左右;后面进入平台期,loss下降变得很缓慢;到第20个epoch之后验证集loss开始出现微小波动,早停在epoch 43的位置,保存的模型参数是验证集loss最低的那一份。这里有个很多人容易犯的错:保存最后一个epoch的模型,而不是验证集最优的模型。因为LSTM训练到后期可能已经在过拟合了,最后一个epoch的模型往往不如第43个epoch的好。
4.4 模型评估与效果对比
评估指标我用了三个:RMSE、MAE、MAPE。RMSE对大的预测误差很敏感,适合衡量极端情况;MAE反映平均偏差;MAPE用于百分比理解。拿6小时预测视界的模型来说,在测试集上的表现是:RMSE约11.4辆/小时,MAE约7.8辆/小时,MAPE约23%。对于共享单车需求预测这个任务,这个精度已经能支撑运营决策了。调度人员不需要精确到"这个站点会少3辆车",只需要知道"这个站点在一小时后会缺大约10到15辆车"。
我还做了一个横向对比实验。同样的特征和数据,拿ARIMA和XGBoost分别训练,结果是:ARIMA的RMSE是18.7,XGBoost是14.2,LSTM是11.4。这个对比数据很多项目文档里都会用到,实际跑出来的结论也确实如此。但要注意,这个差距不是绝对的,如果站点数量少、规律简单,XGBoost可能反超LSTM。做工程选型不能只看模型排名,要结合自己的数据体量来定。
5. 常见问题与排查技巧实录
5.1 预测结果总是滞后一拍,怎么破
我最初训练出来的模型,做1小时预测时效果还行,但一旦做3小时以上的预测,预测曲线的形状总是比真实曲线"慢半拍"。比如真实需求在早上8点达到峰值,我的预测峰值却在8点半甚至9点才出现。这个问题很典型,本质原因有两个:一是滑动窗口里过去24小时的历史数据占主导,模型倾向于把最近的趋势延续下去,所以变化都被推迟了;二是时间特征不够强,模型没有充分学到"8点这个时刻本身就该有高峰"。
解决办法有两个方向。第一,把小时的正弦余弦编码加进输入,让模型知道"现在是一天中的哪个时刻",这是最直接有效的。第二,增加"过去7天同一时刻均值"这个特征,相当于给模型一个明确的周期性先验。两种方法叠加后,预测滞后的现象基本消失了,峰值时刻的误差从将近1小时缩小到15分钟以内。
5.2 部分站点预测值和实际相差很大,为什么
测试的时候我发现,某些特定站点的预测效果特别差,误差是同类站点的两倍以上。排查后发现这些站点都有一个共同点:周边有大型活动场所,比如体育馆或者会展中心。平时需求很平稳,一旦有演唱会、球赛,散场时段的借车需求会瞬间暴涨5倍以上。这种事件驱动型的突变,在历史数据里出现的次数太少,LSTM根本学不到规律。
这类问题靠模型本身很难彻底解决。我的处理方式是在系统里加了一个"活动事件"修正模块:数据层维护一个活动日历,当预测时段附近有大型活动时,对模型的预测值做规则化修正。比如散场时段加一个固定增量,增量的数值根据历史同类活动的平均影响来设定。这样既不改变模型的通用能力,又能覆盖特殊场景。
5.3 训练loss不稳定、动不动就nan
有段时间训练loss在中间几个epoch突然变成nan,排查了好久。最后发现是数据里有个站点的特征值出现了一个极端大的数值,因为有一个外部数据源的天气字段在个别行里存了异常大的温度值,比如999。这种情况在任何机器学习项目里都会遇到,但LSTM对梯度很敏感,NaN一旦出现基本上整个训练就废了。解决办法就是在数据入口处做严格的范围校验,温度范围限定在-30到50摄氏度之间,超出范围直接标记为缺失值进入插值流程。
另外还有一个训练不稳定的常见原因:学习率太大。我试过学习率调到0.01,训练刚开始的loss疯狂震荡,根本没法收敛。调到0.005勉强能收敛但精度较差,最后的0.001才稳定下来。如果是更大规模的数据,可以尝试学习率预热加余弦退火的调度策略,但在这个项目里简单的固定学习率就够用了。
5.4 新站点没有历史数据,怎么预测
运营方往往会在某个区域投放一批新站点,这些站点刚上线时没有任何历史骑行数据,标准模型完全没办法处理。我用的方案是冷启动迁移预测:根据新站点的地理位置和周边环境特征,先从已有的6类站点中找最相似的一类。具体方法是计算新站点周边500米范围内的POI分布(比如有多少个小区、多少个写字楼、多少个地铁口),和每一类站点的典型POI分布做相似度匹配。匹配完成后,直接把这一类站点的模型迁移过来,同时把站点Embedding向量初始化成所在类别的中心向量。新站点上线几天后积累了真实数据,再用增量训练的方式把Embedding向量和模型参数做微调。
这个方法实测效果不错,新站点上线第一天的预测MAPE大概在35%左右,三天后能降到25%以内,一周后基本达到普通站点水平。纯靠规则预测的话,第一天的误差可能会超过50%。
6. 部署上线与运营侧的落地效果复盘
6.1 离线训练与在线推演怎么衔接
整个系统上线后,我把它部署成了一套每天自动运行的流水线。每天凌晨2点,定时任务启动,拉取前一天的订单数据、天气数据,重新构建特征,增量训练模型。训练完成后自动评估验证集loss,如果优于线上当前版本,就更新模型文件。这个"验证集决定是否上线"的机制很重要,能防止某天脏数据导致模型变差,结果线上直接被替换成烂模型。
在线推理服务加载模型文件后常驻内存,对外提供两个接口:一个是站点需求预测,输入站点ID和预测起点时间,返回未来1、3、6小时每个小时预测的借车量和还车量;另一个是区域供需热力,输入一个经纬度范围,返回该范围所有站点的预测快照。第二个接口会先查Redis缓存,缓存命中的话直接返回,没命中才走模型推理,实测单次推理加上序列化返回,P95响应时间在180毫秒左右,完全满足大屏实时刷新的需求。
6.2 预测系统上线后的真实效果
在试运行的一个月里,我对比了上线前后的运营指标。核心改善体现在两个方面:一是早高峰时段"站点无车可借"的时长占比下降了大约30%,意味着用户到了站点却找不到车的糟糕体验少了很多;二是晚高峰时段"站点车满无法还车"的工单量减少了约25%。调度车的工作方式也从"接到报警再出发"变成了"根据预测结果提前调度",单车平均调度里程反而下降了。
这里要强调一个认知:预测系统不是为了取代调度人员,而是帮他们做决策。运营人员看到预测曲线之后,仍然需要结合自己的经验判断是否要派车,比如某个站点虽然预测缺车,但距离调度场站太远、过去一趟成本太高,这时候预测系统的作用就变成了"帮助决策者权衡调度优先级"。另外预测结果我也故意做了分位数输出而不是只给均值,因为运营上需要知道"最坏情况下可能缺多少车",用这个数值来定安全库存更稳妥。
6.3 预测误差如何转化成调度安全边界
调度决策不能直接拿预测均值去用。我花了很长时间和运营同事对需求,最后定了一套调度阈值逻辑:当站点预测未来1小时的需求量减去当前库存量大于10辆车时,判定为"即将缺车";大于20辆车时,判定为"严重缺车"。阈值不是拍脑袋定的,而是根据模型在验证集上的MAE约7.8来推导的。如果直接把阈值设成5辆,系统会因为预测误差频繁触发调度工单,调度车跑个不停,实际上很多工单是误报;设成10辆,误报率大幅下降,漏报率还在可接受范围内。
这个"预测误差驱动调度阈值"的思路,我觉得是所有预测类项目最值得借鉴的一点:不要追求预测值绝对准确,而是要理解预测的误差分布,把误差变成决策的输入参数之一。
做完这个项目,我最大的体会是:LSTM这种模型并没有多神秘,真正决定项目成败的往往在数据清洗、特征设计和预测结果的工程化包装上。如果你也要做类似的预测系统,我建议你前期多花点时间在理解业务上,搞清楚运营人员拿到预测值之后到底要做什么决策,这比调参更有价值。另外一个小的实战经验是,把预测结果用可视化的方式暴露给运营人员观察,他们在实际使用中提出的反馈,能帮你快速发现很多离线评估发现不了的问题。