轨道交通客流预测Python源码解析:从特征工程到LightGBM实战
简介这是一套用于轨道交通客流预测的Python项目源码面向城市交通数据分析人员、机器学习初学者及参加相关竞赛的开发者。项目基于历史客流数据完成数据清洗、特征构建、模型训练与效果评估等流程可帮助理解时间序列预测在实际交通场景中的落地方式。压缩包共26个文件其中22个Python脚本为主体代码另有2个Markdown说明文档和2个Git忽略配置文件包体仅32KB虽小巧但结构完整。源码清晰展示了从数据预处理、特征工程到模型验证与结果可视化的完整链路便于读者复盘每个环节的代码实现。代码中涉及Pandas、NumPy等数据处理手段以及随机森林、XGBoost等常用预测模型适合读者结合Django框架或实际运营需求进行二次开发。目前已有896人学习下载对于希望快速上手客流预测建模或研究轨道交通调度优化的人来说是一份值得参考的入门到进阶范例。1. 一套源码包背后是调度室每天都在算的那道题早高峰的换乘站站台人数从 8 点开始疯涨大屏上的预测曲线却还是昨晚跑出来的老数据——这就是轨道交通客流预测系统要解决的日常。你用 Python 拉取 AFC 刷卡记录按车站、按 15 分钟粒度聚合成客流序列再用模型预测未来 1 到 2 小时的进站量把结果推进调度系统里让班次间隔跟着客流走。这套源码正是这个流程的完整实现数据清洗、特征工程、模型训练、结果导出一应俱全。适合三类人正在做课程设计或毕设的学生、刚接手地铁或公交调度算法的工程师、想用 Python 把数据科学落到真实业务场景的开发者。拿到 zip 先别急着解压跑模型先把数据流和目录结构看明白否则你连 predict 出来的是什么都说不清。2. 解压之后先别急着跑源码包的结构、数据流与最小运行环境拿到这样一个 zip第一件事不是双击解压、打开 README 然后两眼一抹黑而是先确认三件事数据在哪、代码在哪个目录、模型怎么被调用。这类源码包通常长成一个标准化工程的样子但不同作者的打包习惯差异很大。我先讲通用的目录玩法再说怎么快速定位自己要改的文件。2.1 一份典型客流预测源码的目录拆解哪几个文件不要动常见做法是压缩包解压后是一个包含data/、src/或code/、models/、api/的工程目录。我见过的大部分 Python 客流预测项目目录结构长下面这样metro_flow/ ├── data/ │ ├── raw/ # 原始 AFC 刷卡记录、OD 数据 │ ├── processed/ # 按车站聚合后的 15 分钟客流序列 │ └── external/ # 天气、节假日、温度等外部特征 ├── src/ │ ├── preprocess.py # 原始数据清洗与聚合 │ ├── features.py # 特征工程时间特征、滞后特征 │ ├── train_lgb.py # LightGBM 训练入口 │ ├── train_lstm.py # LSTM 训练入口备选 │ └── predict.py # 加载模型输出预测结果 ├── models/ │ ├── lgb_model.pkl │ └── lstm_weights.pth ├── api/ │ └── server.py # Flask 接口 ├── notebooks/ │ └── 01_eda.ipynb # 探索性分析 ├── requirements.txt ├── config.yaml └── README.md这里的核心逻辑是分层解耦preprocess.py只负责把原始刷卡记录变成能直接喂给模型的宽表features.py只做特征列的拼接和时序对齐train_lgb.py只读data/processed/下的特征表输出模型文件到models/。你拿到包之后优先看config.yaml因为里面定义了数据路径、预测目标、滞后步长这些全局参数。路径千万别手改——你改了 config 里的data_dir但忘了preprocess.py里还写着一个相对路径就会在半夜跑任务时收到 FileNotFoundError。需要重点分辨的是src/和notebooks/的关系。有些源码包的作者会在 notebook 里做了一版特征工程又在src/里做了一版两处逻辑还不一致。这时候以src/里的脚本为准因为它通常是被正式调度程序调用的notebook 只是实验草稿。我的习惯是先把run命令找出来一般是python src/train_lgb.py --config config.yaml从入口反推依赖关系比从头读代码快得多。2.2 客流原始数据长什么样没有数据聊预测都是空中楼阁很多源码包附带的是模拟数据或脱敏样例真实 AFC 数据不会刻进 zip 里。你要先搞清楚样例数据里的字段命名再把自己的数据映射到同样的结构上。轨道交通客流预测最常见的数据源是 AFC 刷卡记录每条记录代表一次进站或出站行为核心字段包括字段名含义示例值card_id交通卡/二维码 ID6472901Estation_id车站编码0101device_id闸机编号B-02trx_time交易时间2023-05-15 08:23:11trx_type进站 / 出站0进站1出站line_id线路编码L2拿到这些原始记录后第一步是聚合。常见聚合粒度是 15 分钟因为调度员看的是班次间隔不是秒级波动。聚合逻辑看起来简单但有一个尺寸必须先定按站聚合还是按线聚合。如果是做车站级客流预警站台拥挤度、限流决策必须按station_id聚合如果是做线路级运力调配按line_id聚合。两种任务的特征工程完全不同源码包里的preprocess.py通常已经写好了其中一个方向你只需要改GROUP_COL这个常量。import pandas as pd df pd.read_csv(./data/raw/afc_sample.csv, parse_dates[trx_time]) df[time_bucket] df[trx_time].dt.floor(15min) # 按车站 15分钟窗口聚合进站客流 flow_in ( df[df[trx_type] 0] .groupby([station_id, time_bucket], as_indexFalse) .size() .rename(columns{size: flow_in}) ) # 按车站 15分钟窗口聚合出站客流 flow_out ( df[df[trx_type] 1] .groupby([station_id, time_bucket], as_indexFalse) .size() .rename(columns{size: flow_out}) ) flow flow_in.merge(flow_out, on[station_id, time_bucket], howouter) flow.to_csv(./data/processed/station_flow_15min.csv, indexFalse)这里有两个细节容易翻车。第一dt.floor(15min)会把时间对齐到 0/15/30/45 整点但如果你手里的数据是 5 分钟粒度先重采样到 15 分钟再聚合会更稳先groupby再floor会导致边界错位。第二trx_type的取值各城市不一样——有的城市用0/1有的用entry/exit有的用in/out。你从 CSV 读进来之后先跑一个df[trx_type].value_counts()看取值的数量只有两个取值才放心过滤。2.3 跑通第一个 demo 的环境准备requirements 版本别随便升源码包里的requirements.txt是作者当时跑通的环境快照版本号之间有兼容性约束尤其是 pandas 和 numpy 的搭配。常见做法是先创建一个干净的虚拟环境再按 requirements 安装。不要用系统自带的 Python 环境因为项目里可能用了torch或lightgbm装错了再卸会浪费一晚上。python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install --upgrade pip pip install -r requirements.txt我遇到过不少回读报错比如AttributeError: module pandas has no attribute Int64Index——这是 pandas 升到 1.5 之后删掉了旧 API而源码还是用老版本写的。如果安装后报类似的 API 消失错误优先降回 requirements 锁定的版本而不是改代码硬适配。另一个常见问题是 lightgbm 在 Windows 上安装时依赖的libomp没有自动拉起来日志会显示找不到libomp.dll。这时去安装 Visual C Redistributable 或用 conda 装一次 lightgbm比手动拷贝 dll 更省事。环境就绪后先跑python src/preprocess.py再跑python src/train_lgb.py。跑完看一眼models/下有没有生成新的.pkl文件如果生成了说明最小链路已经通了。这个最小链路能跑通的意义不是模型效果有多好而是让你确认「数据读取、特征表生成、模型训练、结果导出」这条管线的每一个环节是完整且可衔接的。之后你再开始改特征、调参数才有抓手。3. 把刷卡记录变成能喂模型的特征表特征工程是整个系统的定生死环节客流预测模型的精度七成在特征工程三成在模型选择。很多下载了源码的同学上来就把 LightGBM 的参数调一轮发现结果变化不大因为特征表里只剩时间戳和客流量两列模型再怎么调也学不到「周五晚高峰比周三更猛」这种规律。这章讲清楚特征表必须包含的几类信号以及哪些坑是特征工程里最容易踩的。3.1 时间特征拆解节假日、工作日、时段、早晚高峰模型才能分辨出行规律把时间戳拆成 hour、weekday、is_weekend 是最基础的操作。但轨道交通客流和普通电商流量最大的不同在于通勤刚需决定了它的形状高度受「是否为工作日」影响而节假日又完全打破工作日的早晚双峰结构。所以时间特征至少要覆盖三层绝对时间年月日、周期时间星期几、小时、业务时间是否是节假日、是否是节假日前一天、是否是调休上班日。import pandas as pd df pd.read_csv(./data/processed/station_flow_15min.csv, parse_dates[time_bucket]) df[hour] df[time_bucket].dt.hour df[minute] df[time_bucket].dt.minute df[weekday] df[time_bucket].dt.weekday df[is_weekend] df[weekday].isin([5, 6]).astype(int) # 用外部节假日表做标记比手工写死日期可靠 holiday pd.read_csv(./data/external/holiday.csv, parse_dates[date]) holiday[is_holiday] 1 df[date] df[time_bucket].dt.date df df.merge( holiday[[date, is_holiday]], ondate, howleft ) df[is_holiday] df[is_holiday].fillna(0).astype(int) # 节假日前一天对客流也有扰动这里做一次 shift 对齐 df[is_pre_holiday] ( df[date].shift(-1).map(dict(zip(holiday[date], [1] * len(holiday)))).fillna(0) )这段代码逻辑上完全自洽但有三个点必须解释清楚。第一dt.weekday返回从 0 到 6 的整数is_weekend用isin([5, 6])判断是周六周日这在大多数城市适用但像深圳这种有「周末也开行加班车」的城市周末的模式还要细分。第二h假日表需要你找国家法定节假日数据很多源码包里放的是样例你需要替换成自己所在城市年份的真实日历。第三节假日前一天的客流特征经常被忽略它往往比平时更接近节假日模式提前下班、提前出行merge之后再单独做一列既保持了可读性也不影响原表结构。特征做完了把这张宽表存一份备份。每做一个特征就存一版这是血泪经验——你调了一晚上特征第二天发现把预测目标列给 shift 没了没有备份就只能重来。3.2 滞后特征与滚动统计让模型自己看到「昨天同时刻」和「最近变化趋势」客流时序的强自相关性是预测模型的主要依据如果模型看不到过去一段时间的流量它是学不会任何东西的。特征工程里对应的操作就是滞后特征当前时刻往前推 1 步、推 96 步一天前同时刻、推 672 步一周前同时刻把这些滞后值作为当前时刻的特征。这是时间序列预测里最朴素也最强的一类信号无论是统计模型还是树模型都吃这一套。FEATURES [flow_in, flow_out] LAG_STEPS [1, 96, 672] # 1步15分钟后96步一天前672步一周前 df df.sort_values([station_id, time_bucket]).reset_index(dropTrue) for col in FEATURES: for lag in LAG_STEPS: df[f{col}_lag_{lag}] df.groupby(station_id)[col].shift(lag) # 滚动窗口统计过去6步、48步的均值与标准差 for col in FEATURES: df[f{col}_roll_mean_6] ( df.groupby(station_id)[col].transform(lambda x: x.rolling(6, min_periods1).mean()) ) df[f{col}_roll_std_48] ( df.groupby(station_id)[col].transform(lambda x: x.rolling(48, min_periods1).std()) )这里的转折点是groupby(station_id)。如果不分组直接shift(lag)跨车站的数据会串到一起——A 站lag_1跑到 B 站的当前记录上模型就被污染了。第二个容易错的地方是rolling的方向pandas 默认是向后窗口用过去的数据算这正好是我们想要的如果把数据时间顺序排反了滚动统计就成了「用未来预测过去」训练指标很好看上线就翻车。第三个点是shift之后会产生NaN因为序列开头没有更早的记录。对这种缺失值LightGBM 原生可以处理但如果你之后要接 LSTM就必须用前向填充fillna(methodffill)或者直接丢弃前 672 行。滞后特征越多训练集前面的行就越「脏」。这是正常现象不是 bug——预测任务天然要求你有足够的历史数据喂给滞后窗口。如果原始数据只有一个月lag_672一周前必然有大量空洞模型只能从更短的滞后里学。不要强撑一周前的特征把LAG_STEPS缩成[1, 96]反而更合理。3.3 特征泄漏的边界未来数据一旦混进来你训练出来的就是一个「占卜模型」特征泄漏在客流预测系统里太容易发生了。原因是时间序列的每一行都有明确的时间戳如果你在做特征时不小心把「当前时刻之后」的数据编成了特征列模型在训练集上会学到完美函数验证集上也表现惊人但一上线立刻变成垃圾。最常见的泄漏源有三个。第一用了预测目标当日整天或未来时段的平均值比如你预测 10:30 的客流特征表里却包含了 11:00 的实际客流——这是把答案写进了题目。第二用了未来时段的天气数据没做时间对齐天气预报是「预测值」不是「实测值」实测天气时间戳晚于预测时刻特征里拿到的是未来的实测值。第三rolling窗口没有做centerFalse的默认设置某些代码里写了rolling(window6, centerTrue)就会用未来 3 步推算当前均值这等于窥探了未来。# 反例这样构造特征就是泄漏 df[flow_in_future_mean] ( df.groupby(station_id)[flow_in] .transform(lambda x: x.rolling(6, centerTrue).mean()) ) # 正确做法只保留历史窗口且 shift 一格避免本期数据参与 df[flow_in_prev_mean] ( df.groupby(station_id)[flow_in] .transform(lambda x: x.shift(1).rolling(6, min_periods1).mean()) )当你拿到一份现成的源码包时第一件事不是改参数而是检查它有没有泄漏。方法很简单把时间排序乱掉看训练指标有没有崩或者随机把flow_in打乱看预测的 MAPE 是否仍然很低——如果打乱后模型依然准确说明特征表里一定藏着未来信息。这是验证一个客流预测源码是否可信的最快手段。4. 模型选型与训练LightGBM 和 LSTM哪个方案配得上你的调度场景特征表就绪之后模型选择决定了你在精度、训练速度、可解释性之间的取舍。客流预测系统里最常见的需求是按车站粒度预测未来 1 到 6 个 15 分钟窗口的进站量预测窗口短、特征规模中等几十到几百列、数据量从几十万到几百万行。这个场景下 LightGBM 通常是最先跑的 baselineLSTM 作为序列模型能做更细的时间依赖建模但训练成本和数据要求都高一个档次。4.1 用 LightGBM 打底训练脚本拆解与 4 个影响精度的必调参数先说结论如果你的目标是快速产出一个能用的预测结果LightGBM 是性价比最高的选择。它对特征尺度不敏感、自带缺失值处理、能输出特征重要性方便排查泄漏而且训练速度远快于深度学习模型在百万级数据上依然只需几分钟。对非线性特征交互的拟合能力足够强时间特征、滞后特征、天气特征放在一起几乎不需要预处理。在 CPU 机器上就能完成全流程不依赖 GPU 环境。import lightgbm as lgb import joblib train df[df[time_bucket] 2023-06-01].dropna() valid df[(df[time_bucket] 2023-06-01) (df[time_bucket] 2023-07-01)].dropna() target_col flow_in feature_cols [c for c in train.columns if c not in [station_id, time_bucket, flow_in, flow_out]] params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 63, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbose: -1, } model lgb.train( params, lgb.Dataset(train[feature_cols], train[target_col]), num_boost_round1000, valid_sets[lgb.Dataset(valid[feature_cols], valid[target_col])], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)], ) joblib.dump(model, ./models/lgb_model.pkl) feature_importance sorted( zip(feature_cols, model.feature_importance(gain)), keylambda x: x[1], reverseTrue, ) print(feature_importance[:10])这里最值得关注的是四个参数它们对客流预测的影响远高于max_depth这类常见参数。num_leaves控制树的复杂度客流数据里存在明显的尖峰特征早高峰 8 点半突然暴涨叶子太少学不到这种突变叶子太多容易过拟合到个别站的异常值。经验值是 63 到 127 之间先跑一轮确认验证集误差不震荡再微调。min_child_samples在客流场景里要调到 20 以上。因为一条记录代表一个车站某个时间点小车站的客流量只有几十如果min_child_samples太小模型会针对个别车站的特殊日子死记硬背。feature_fraction和bagging_fraction都是防止过拟合的随机化参数推荐都设为 0.8。客流特征里滞后特征之间强相关随机特征采样能让每棵树看到不同的特征子集泛化能力更好。metric推荐用mae而不是默认的rmse。调度的核心诉求是「客流高峰要接住」RMSE 会因为大站的高绝对值而拉高平均误差MAE 对每个时间点的误差一视同仁更能反映预测偏离真实值的平均程度。early_stopping(50)是训练流程里的后悔药验证集指标在 50 轮内不再改善就自动停止防止你用满num_boost_round1000导致过拟合。跑完打印前十个特征重要性你会看到flow_in_lag_96前一天同时刻和flow_in_lag_1上一时刻排在前面——这符合认知如果这两个特征没有进前十大概率你的滞后窗口设置有误。4.2 LSTM 做序列预测为什么说它更适合长窗口预测但别轻易做如果你的需求是预测未来 6 个以上窗口并且每个站点的历史序列稳定、数据量超过两年LSTM 值得认真考虑。它天然把客流看成一段序列通过记忆单元学习周期性规律理论上能比树模型捕捉到更细的时间依赖。但替代方案其实是把问题简化——用 LightGBM 做多模型预测每个预测步长训练一个模型即多输出回归效果有时和 LSTM 接近还不用处理长度不等的序列。import numpy as np import torch import torch.nn as nn class FlowLSTM(nn.Module): def __init__(self, input_size, hidden_size64, num_layers2): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, 1) def forward(self, x): out, _ self.lstm(x) return self.fc(out[:, -1, :]) def make_sequences(data, seq_len96): x, y [], [] for i in range(seq_len, len(data)): x.append(data[i - seq_len:i]) y.append(data[i, 0]) # 预测下一时刻 flow_in return np.array(x), np.array(y)注意这里seq_len96对应 24 小时。LSTM 窗口长度直接影响它能学到多长的周期记忆96 步意味着用过去一天预测未来 15 分钟。窗口太短学不到昨日同时刻的模式窗口太长训练数据量骤减且梯度在长序列上不稳定。一般建议先试 48 和 96 两个档位看验证集 MAPE 的差异。从实操层面说我一般不推荐在客流预测的初级阶段直接上 LSTM。它调起来慢、黑匣子性质明显、对数据量要求苛刻。很多源码包里附带 LSTM 训练脚本的完整代码但跑出来的效果经常不如调参后的 LightGBM——原因通常不是 LSTM 不够强而是代码里的make_sequences没有按车站分组把不同车站的序列混在一起训练了。单独一个车站的客流模式差异很大市中心的通勤站和郊区的社区站早高峰出现的时间可能错开两小时混在一起会让网络学到一条平均曲线两边都对不上。4.3 模型评估MAPE 之外调度场景真正关心的是方向命中率用 RMSE 和 MAPE 看完预测误差之后最好再算一个指标方向命中率。方向命中率衡量的是「预测值相对上一时刻的变化方向」是否与真实方向一致。调度系统里方向判断比精确数值更重要——如果预测说客流在涨而实际在跌值班员会按错误的趋势去加车反而浪费运力。from sklearn.metrics import mean_absolute_error, mean_absolute_percentage_error import numpy as np y_true valid[target_col].values y_pred model.predict(valid[feature_cols]) mae mean_absolute_error(y_true, y_pred) mape mean_absolute_percentage_error(y_true, y_pred) * 100 direction_hit np.mean((np.sign(np.diff(y_pred)) np.sign(np.diff(y_true)))) baseline_hit np.mean((np.sign(np.diff(y_true)) 0)) print(fMAE: {mae:.2f} MAPE: {mape:.2f}% Direction Hit: {direction_hit:.2%})方向命中率的一个关键细节是当真实客流变化很小时比如凌晨 2 点的平峰期流量几乎不变方向命中率会被一个「预测不变」的保守策略拉高看起来数值不错但实际没有意义。所以在算方向命中率时通常只统计真实变化幅度超过某个阈值比如 50 人的时段再计算方向正确率。这样评估出来的模型才是调度场景真正认可的。另外 MAPE 在客流量接近 0 的深夜时段会爆炸——分母接近 0一个几人的误差都能算成几百个百分点。常见做法是过滤掉真实值小于 30 再算 MAPE或改用对称 MAPESMAPE。如果你在源码包里看到赤裸裸的 MAPE先不要被数字吓到看清楚它有没有做这个过滤。5. 避坑指南这个源码包最容易翻车的六个现场不是每一个 zip 解压后都能顺利跑通。这一章把最常遇到的六个问题列成清单每条都按「现象 → 原因 → 解决」来写你按顺序对着排查能省下至少一个晚上的无头调试时间。5.1 解压后文件中文名变成乱码现象zip 解压后数据_2023年.csv显示成æ°æ®_2023å¹´.csv。原因Windows 下压缩文件时用了 GBK 编码保存文件名而 macOS 或 Linux 的unzip默认按 UTF-8 解码导致文件名乱码。也有小概率是 zip 的「伪加密」标志位被误设置导致明明知道没密码解压工具却弹出密码输入框。解决用 Python 的zipfile模块手动指定编码或者直接改用 7-Zip 解压它的编码兼容性最好。如果遇到伪加密问题不要慌——伪加密只是把 zip 的加密标志位改掉了数据本身没有加密。用 7-Zip 打开时会提示有密码此时把「加密」列表里对应条目的标志位改回来即可但更快的办法是找到ZipCrypto粘滞位然后直接换用 Python 标准库读取import zipfile with zipfile.ZipFile(metro_flow.zip) as zf: for name in zf.namelist(): try: correct_name name.encode(cp437).decode(gbk) except UnicodeDecodeError: correct_name name zf.extract(name, dst/) print(f{name} - {correct_name})这个知识很难在官网教程里找到属于社区里传的偏方但确实管用。你解压完先把README.md的内容读一遍跳过多数字乱码的文件名不会影响使用但如果data/目录的名字乱掉了后面所有脚本的路径都会失效必须提前修复。5.2 运行时提示KeyError: station_id或ValueError: cannot reindex现象运行preprocess.py时报列名错误或者合并的时候行数对不上。原因典型的「列名不一致」问题。源码作者在打包代码时用了自己的脱敏数据列名是stationID、time、in_flow而你的数据列名是从某文献里抄来的字段完全不同。解决不要改代码里的列名引用而是在引擎加载时做一次别名映射。把数据源中所有可能的列名变体收进来映射到源码期望的规范名。这一步放在preprocess.py的最前面后续所有逻辑都引用规范名避免重复修改。5.3lag_672列全是 NaN模型不报错但精度极差现象训练日志不报错但验证集 MAPE 高得离谱特征重要性里前几名都是station_id或一些无关特征。原因shift(672)生成一周前的滞后列如果你的原始数据不足 672 个时间步7 天 × 96 步/天滞后列全是空值。更有甚者groupby(station_id)里某个车站的数据条数不够该站所有滞后列全部为 NaN。LightGBM 不会因为你给了一堆 NaN 列就报错它只是把这些特征当作缺失处理模型精度自然下降。解决检查df.groupby(station_id).size().min()确认每个站的数据长度都超过最大滞后步数再检查df[lag_col].isna().mean()如果缺失比例超过 30%要么换更短的滞后窗口要么从数据日期源头往前多取几周数据。5.4 日期解析失败字符串时间戳变成 object 类型现象dt.floor(15min)报TypeError: dtype U26之类错误。原因AFC 导出数据里的时间格式可能是2023/05/15 08:23:11而源码里写的是pd.to_datetime(df[trx_time])当数据里混入空格或不同分隔符时解析不全。解决定义明确的解析格式。对2023/05/15 08:23:11用pd.to_datetime(df[trx_time], format%Y/%m/%d %H:%M:%S)如果实际上 CSV 是从数据库导出的原始时间可能带毫秒2023-05-15 08:23:11.472格式串就要加.%f。只要if not isinstance(df[trx_time].dtype, datetime64[ns]):这个判断写进启动前置检查后面能少一半调试时间。5.5 训练集和验证集的时间切分不对指标虚高现象一个看起来效果很好的 LSTM 模型训练集 MAPE 只有 3%验证集 4%但部署后连续预测两天都在早晚高峰方向性错误。原因切分没做时间隔离。比如源码里用train_test_split(shuffleTrue)随机切分这会让相邻时间点的数据同时出现在训练集和验证集里。前一时刻的客流几乎能直接推出下一时刻的客流模型学到的只是复制上一时刻的值换个时间范围就立刻失效。解决强制按时间顺序切分。前 80% 时间做训练后 20% 做验证验证集永远不早于训练集。这也可以直接作为你检查任何时序预测源码的第一道关卡看到train_test_split还没有shuffleFalse基本可以直接放弃这个代码作为生产基础。5.6 模型在节假日预测蹦极式失效现象工作日的预测误差小于 10%一到清明节、五一、国庆误差飙到 40% 以上。原因模型没见过节假日模式。节假日的客流分布和普通工作日完全不同——晚高峰消失、全天变成「M 形」双驼峰、早高峰延后两个小时。如果你的训练数据里节假日样本占比不足 5%LightGBM 只靠is_holiday一个特征很难学到完整的节假日分布形态。解决在特征工程里补充is_pre_holiday、is_post_holiday、以及「是否长假第几天」的计数特征。如果手头只有一年的数据更稳妥的做法是单独为节假日训练一个专模型或者在is_holiday1的样本上做重采样让训练集里节假日占的比例提到 15% 以上。此外把日期特征里的「公历日」单独做成列比如天,月,日三项比直接用timestamp数值更能让树模型拆出节日临近效应。6. 把模型变成可以调用的服务Flask 封装、结果可视化与增量重训到了这一步模型已经能跑出预测值了。但一个不能接入业务系统的模型价值只停留在 Jupyter Notebook 里。这章讲三件事如何把训练好的模型封装成 HTTP 接口调度系统随时可以拉你预测的实时客流如何把预测结果画出来让值班员一眼看明白趋势以及模型如何增量重训让预测精度跟上季节变化。6.1 用 Flask 把模型包成预测服务最常见做法是把predict.py里的核心逻辑抽出来放进一个 Flaskserver.py启动后对外提供/predict接口。请求体里传入车站 ID 和预测时间返回未来 6 个 15 分钟窗口的进站客流预测值。from flask import Flask, request, jsonify import joblib import pandas as pd app Flask(__name__) model joblib.load(./models/lgb_model.pkl) app.route(/predict, methods[POST]) def predict(): body request.get_json() station_id body[station_id] current_time pd.to_datetime(body[time]) # 从特征表取出过去96步的客流组装成模型的输入行 # 实际生产中建议直接查数据库而不是读全量CSV hist pd.read_csv(./data/processed/station_flow_15min.csv) station_hist hist[ (hist[station_id] station_id) (hist[time_bucket] current_time) ].tail(96) features build_features_from_history(station_hist, current_time) pred model.predict(features[feature_cols]) return jsonify({ station_id: station_id, predict_time: current_time.isoformat(), flow_forecast: pred.tolist(), unit: 人/15min, }) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)注意我故意留了build_features_from_history这个函数占位它必须与训练时features.py里的逻辑完全一致——同样的滞后步长、同样的滚动窗口、同样的时间特征提取。生产中最大的风险点就是这里不一致训练时用shift(1)预测接口里写成shift(2)差一个时间步结果输出就整体平移了 15 分钟调度看板上的「预测峰值」滞后于真实峰值预警就废了。部署时用waitress或gunicorn替换默认的 Werkzeug 开发服务器。并发量不大时内部调度系统一般同时几十个请求这两个服务器足够稳定不要引入 Celery、消息队列等重组件除非流量真的到了每分钟上千次请求。6.2 把预测画出来调度台需要的是曲线不是表格模型输出的是一串数字但值班人员要的是「看到趋势」。这里给出一个极简的可视化方案把真实客流和预测客流画成两条曲线再叠一条「昨天同时刻」的参考线——这是我做调度看板时最常用的结构import matplotlib.pyplot as plt import matplotlib.dates as mdates plt.figure(figsize(12, 4)) plt.plot(real_time, real_flow, labelreal, linewidth2) plt.plot(real_time, forecast_flow, labelforecast, linewidth2, linestyle--) plt.plot(yesterday_time, yesterday_flow, labelyesterday, linewidth1, alpha0.6) plt.axvline(now, colorgray, linestyle:, labelnow) plt.legend() plt.tight_layout() plt.savefig(./reports/forecast_chart.png, dpi150)这条参考线的价值极大——比起「预测值绝对值是否准确」值班员更关心「预测与昨天的差异在哪」。如果明天的早高峰比昨天晚到 30 分钟支撑这条判断的不仅是模型精度还有参考线叠上去的直觉。这个图在源码包里往往只是matplotlib的几行但拿去汇报时最有说服力的是它。6.3 增量重训不要让模型被季节变化甩开客流预测模型不是一劳永逸的。五一之后、暑假期间、九月开学季客流模式都会变化。常见做法是每周一次增量重训把最近 4 周的新数据追加到训练集尾部滚动移动训练窗口丢掉一年前的旧数据。因为旧数据里的节假日模式与今年不同全量训练反而会把模型拉偏。保持特征工程代码不变只换训练数据范围是增量重训成本最低的方式。跑完之后把新模型的 MAE 和旧模型在同一验证集上对比如果提升不足 3%就说明旧模型还在稳定期内没必要急着换。如果调度系统对「周维度」的客流变化非常敏感再加一个每周例行任务每到周一早上拉取上周全量数据自动重训并对比新旧模型效果把对比报告写进日志。这套半自动流程我用了几年最大的收获是——预测系统上线后真正的维护成本不是写代码而是建立对模型效果的监控习惯让每次翻车都有据可查。做这套系统时我养成了一个习惯每次训练完除了记录模型的指标还会把特征重要性前十名列进注释里。一周后再翻看能迅速定位有没有特征因为数据源缺失被悄悄挤出前列。这比任何测试方法都管用。把模型当成一个随时需要保养的工具而不是一次交付的结果你的预测系统才会越用越顺。希望这些落地细节能帮你在自己的客流预测项目里少走一段弯路。本文还有配套的精品资源点击获取