简介:面向城市轨道交通客流预测场景的这套Django项目源码,以地铁自动售检票系统清分中心的用户行程和站点数据为基础,提供线路级与站点级客流分析与预测的完整实现。项目采用B/S架构,后端基于Django框架,前端使用Bootstrap、jQuery和Echarts完成交互式可视化,整体结构清晰,适合从事数据挖掘的开发者、交通专业学生以及准备课程设计或毕业设计的读者参考学习。压缩包共含26个文件,其中22个Python源文件是核心代码,负责模型构建、业务逻辑及数据处理,另有2个Markdown文档用于说明项目结构与部署用法,2个Git忽略文件用于版本管理,整个包仅38KB,体积小巧且层次清楚,便于快速查看和改造。当前已有365人学习下载。通过这份源码,可以较快掌握从数据读取、特征加工、客流统计到图形报表输出的整套思路,并能直接运行或修改后复用到类似场景,作为地铁大数据分析的入门实践也很合适。
1. 轨道交通客流预测为什么难:先别急着上LSTM,数据口径才是关键
轨道交通客流预测系统要解决的核心问题,是把“明天早高峰这个站会有多少人进站”从拍脑袋变成可量化的判断。很多拿到这个题目的开发者,第一反应是上 LSTM、Transformer,结果往往是花了大量时间调参,上线后一遇节假日就翻车。我见过不少类似案例,最后定位到的问题都不在模型,而在数据口径:AFC 刷卡记录怎么按车站和时间对齐、缺失值怎么补、节假日和调休怎么区分、验证集怎么切。写一套能跑的 Python 源码并不难,难的是把数据链路捋顺。这篇文章从数据、特征、模型、踩坑到落地,带你把客流预测系统的最小实现完整走一遍。适合正在用 Python 做交通数据分析的从业者,也适合刚做完 Python 入门、想找个真项目练手的开发者。
2. 客流预测系统的数据与模型选型:AFC刷卡记录如何变成预测结果
2.1 AFC刷卡数据怎么聚合:车站、时间粒度与换乘站口径
客流预测的数据源头通常是 AFC 自动售检票系统。每一次过闸机会留一条记录,字段大致包括卡号、进站或出站标识、车站编码、交易时间。注意,进站和出站是两条独立记录,原始表是千万行级别的明细数据,第一步永远是聚合。
聚合粒度一般选“车站 + 小时”。分钟级数据噪声太大,AFC 传输和处理有时延,分钟级序列里全是尖刺;日级数据又太粗,早上 7 点和晚上 9 点的客流形态完全不一样,对运营调度没有指导意义。按小时聚合正好兼顾精度和可用性。每个车站每天产生 24 条样本,一年就是 8760 条,一个中等城市的线网有几十个车站,样本量足以训练一个像样的模型。
这里有个非常容易踩坑的口径问题:换乘站。换乘站的“进站量”只是进闸机的人数,还有大量乘客从其他线路换乘经过,这部分人虽然也会在站台候车,但不会出现在“进站”数据里。所以换乘站要想预测真实站台负荷,必须叠加换乘通道的到达客流,这通常依赖 OD 矩阵反推。第一版系统可以先只预测进站量,但你要清楚模型给出的是“进站人数”,不等于“车站客流强度”。很多项目正是在这一步把概念混了,导致后面所有分析跟着偏。做系统前先把预测目标定义清楚,比选模型更重要。
2.2 特征工程是精度上限:四类特征缺一类,效果就差一截
把原始数据聚合成序列后,下一步是构造特征。我做过多个预测项目,统一感受是特征工程决定精度上限,模型只是在逼近这个上限。
第一类,时间结构特征。包含小时、星期几、是否周末、是否法定节假日、是否调休补班。这些字段看似基础,却有讲究。调休补班日要单独处理:周六补班那天,客流形态接近工作日;五一放假期间的工作日,客流形态又接近周末。只用一个“工作日/周末”布尔值,模型会在这两类日子上糊涂。正确做法是用三值标签:正常工作日为 0、法定假日为 1、调休补班为 -1。
第二类,历史滞后特征。也就是前 1 天、前 7 天、前 14 天同一小时的进出站量。这一组特征对客流预测的作用最大,尤其是对早高峰的预测,周周期效应非常强。为什么用 7 和 14?轨道客流基本以周为周期,7 天是主周期,14 天给模型一个“上上周同期”的记忆,能帮模型区分长期趋势还是偶然波动。如果你写过 Python 量化交易策略代码,这套滞后特征和滚动窗口的处理思路会非常眼熟,本质都是“只用过去的信息预测未来”。
第三类,外部环境特征。主要是天气和气温。天气数据通常通过开放接口或脚本每天拉一次,落到本地 CSV。雨水对地面出入口排队和公交换乘有影响,但影响幅度不大且不稳定;反而是冬夏极端气温对客流的影响更稳定。这部分特征做辅助可以,别指望靠它翻盘。
第四类,事件标签。大型活动、体育赛事、演唱会散场会造成某个站晚间客流出现不正常的尖峰。这类事件有明确的时间和地点,应该用标签显式告诉模型,而不是让模型自己从历史数据里猜。把“今晚 22 点附近有活动散场”变成一个 0/1 特征,模型对尖峰时段的预测能力会明显改善。
2.3 模型怎么选:为什么 LightGBM 比 LSTM 更适合当主力
先把结论放前面:我一般把 LightGBM 当默认模型,ARIMA 和 Prophet 拿来做基线对比,LSTM 留到有充足数据量和专门调优资源时再考虑。这不是说 LSTM 不好,而是它在真实客流场景里的收效往往不成正比。
模型对比可以从几个维度看:
| 模型 | 训练速度 | 多站点支持 | 节假日处理 | 调参成本 | 实际稳定性 |
|---|---|---|---|---|---|
| ARIMA | 快 | 差,每站一个模型 | 需额外外生变量 | 低 | 中等,遇节假日明显偏差 |
| Prophet | 快 | 差,每站一个模型 | 内置节假日参数 | 低 | 中等 |
| LightGBM | 快 | 好,站点作为类别特征 | 特征里加标签即可 | 中 | 高 |
| LSTM | 慢 | 好,但需构造序列输入 | 需额外特征对齐 | 高 | 数据不足时偏低 |
重点说 LightGBM 的工程优势。它天然支持类别特征,可以把车站 ID、小时、星期几直接当类别喂进去,几十个车站共享一个模型,不必每站单独训练。这对大规模线网预测很关键。再加上它对缺失值鲁棒、训练快,迭代一个特征只需要几分钟,非常适合工程环境。
我的建议是:就算你最终打算上 LSTM,也先把 LightGBM 基线跑通。用基线误差做参照,才能判断复杂模型到底有没有带来真实提升。我见过好几个项目,LSTM 调了一周还不如 LightGBM 基线,最后又退回 GBDT。先有基线,再谈先进,是工程里的基本原则。
3. 用Python搭一套最小客流预测系统:数据清洗、特征构建与训练代码
3.1 环境准备:Python版本、依赖与项目目录
先用最小依赖搭环境。这里假设你已经装好了 Python 3.9 以上版本,如果还在配置环境,优先用 Anaconda 或者 Visual Studio Code 的 Python 插件,把解释器指到同一个虚拟环境即可。如果你刚照着 Python 安装教程装完环境,下一步就是确认python --version能在终端里跑出结果。
pip install pandas numpy lightgbm scikit-learn joblib openpyxlpandas 负责数据聚合,numpy 做数组运算,lightgbm 是主模型,scikit-learn 用来算评估指标,joblib 保存模型,openpyxl 让 pandas 能写 Excel 格式的结果文件给运营同事看。这几个库里 pandas 和 lightgbm 最核心,其余都是配套。
项目目录建议保持扁平,方便快速跑通:
traffic_forecast/ ├── data/ │ ├── afc_raw.csv # AFC原始刷卡记录(必填) │ ├── holiday_calendar.csv # 节假日与调休日历(手工维护) │ └── weather.csv # 天气历史数据(可先不填) ├── features.py # 特征工程模块 ├── train.py # 训练入口 ├── predict.py # 预测入口 └── config.py # 全局参数这是最小结构。第一版保持这样足够,等要上生产再考虑拆分配置和加模型版本管理。
3.2 数据清洗:聚合、补缺失与车站口径
AFC 原始数据读进来后,第一步是聚合。下面代码把每次刷卡的明细记录聚合成“站 + 天 + 小时”的进站量与出站量:
import pandas as pd # 读AFC原始数据,trans_time是交易时间,trans_type为in/out df = pd.read_csv("data/afc_raw.csv", parse_dates=["trans_time"]) # 剔除无效记录 df = df[df["station_id"].notna()] # 提取日期与小时,作为聚合键 df["date"] = df["trans_time"].dt.date.astype(str) df["hour"] = df["trans_time"].dt.hour # 进站、出站分别计数,再按站/日期/小时合并成宽表 entry = (df[df["trans_type"] == "in"] .groupby(["station_id", "date", "hour"]) .size().rename("entry").reset_index()) exit_ = (df[df["trans_type"] == "out"] .groupby(["station_id", "date", "hour"]) .size().rename("exit").reset_index()) flow = entry.merge(exit_, on=["station_id", "date", "hour"], how="outer") flow = flow.fillna(0)这段代码逻辑直白,但有一个容易出错的点:进站和出站必须分开计数。高峰时段某些闸机会出现感应失败、人工放行的情况,混合计数会把两类数据搅在一起。合并时用how="outer"而不是inner,可以避免某一方向计数缺失时把另一方向的数据也丢掉。
fillna(0)会把缺失小时补成 0,这里埋了一个雷:夜间停运时段本来就没车,补 0 合理;但如果白天某一小时 AFC 系统故障,全表缺失,补 0 会严重拉低均值。严谨做法是分时段处理:夜间 0-5 点补 0,白天缺失用前 7 天同一小时的中位数填充。这个坑我放到第 4 章详细展开。
车站口径问题也要在这一步解决。不同年份的 AFC 数据里,车站编码可能因为换乘站开通、站名变更而不一致。常见做法是维护一张 station_map.csv,把旧编码映射到新编码,然后在聚合前替换:
# station_map.csv: old_code,new_code,station_name station_map = pd.read_csv("data/station_map.csv") df["station_id"] = (df["station_id"] .map(station_map.set_index("old_code")["new_code"]) .fillna(df["station_id"]))这里用map而不是replace,原因是map在遇到未收录的旧编码时会保留原值,不会因为一个笔误把整列编码清掉。映射完后跑一次df["station_id"].nunique(),确认合并后的车站数量与预期一致。
3.3 特征构建:时间、滞后项与节假日标签
数据清洗出干净的 flow 表后,构造模型特征。这是整个系统里最值得花时间的部分:
def build_features(flow, holiday_cal): df = flow.copy() # 把日期和小时拼成标准时间列,用于排序 df["dt"] = pd.to_datetime(df["date"] + " " + df["hour"].astype(str) + ":00") df = df.sort_values(["station_id", "dt"]).reset_index(drop=True) # 时间结构特征 df["weekday"] = df["dt"].dt.weekday # 0=周一,6=周日 df["hour"] = df["dt"].dt.hour df["is_weekend"] = (df["weekday"] >= 5).astype(int) # 合并节假日日历:holiday_type=1法定假,-1调休补班,0普通日 df = df.merge(holiday_cal, on="date", how="left") df["is_holiday"] = df["holiday_type"].fillna(0) # 滞后特征:按站、按小时分组,往前推 N 天 df["entry_lag_1d"] = ( df.groupby(["station_id", "hour"])["entry"].shift(24)) df["entry_lag_7d"] = ( df.groupby(["station_id", "hour"])["entry"].shift(24 * 7)) df["entry_lag_14d"] = ( df.groupby(["station_id", "hour"])["entry"].shift(24 * 14)) # 近期趋势:前3小时滚动均值,排除当前时刻 df["entry_rolling_3h"] = ( df.groupby("station_id")["entry"] .transform(lambda x: x.rolling(3, min_periods=1).mean().shift(1))) # 前推14天导致的开头缺失行,直接删除 df = df.dropna(subset=["entry_lag_14d"]) return df这段代码有几个细节要重点说。第一,groupby的键是["station_id", "hour"],保证滞后特征不会跨站错位。第二,shift(24)推的是 24 行,也就是“昨天同一小时”。第三,滚动均值后面接的shift(1)是为了排除当前小时自身,否则当前值混进特征,就是数据泄漏。前面提到过 Python 量化交易策略代码里的滞后特征和滚动窗口,处理逻辑几乎一样,核心原则只有一条:特征里绝不能出现预测目标本身。
节假日日历是整个系统里最手工的部分。我一般每年初从官方发布的节假日安排里整理一张表,包含日期和 holiday_type,然后全系统共用。调休补班日必须标记成 -1,不能用 0 表示,否则模型无法区分“周六补班”和“正常周六”。
3.4 训练与预测:LightGBM 的最小可用代码
特征准备好后,模型训练很短。下面是训练入口核心代码:
import lightgbm as lgb FEATS = ["station_id", "weekday", "hour", "is_weekend", "is_holiday", "entry_lag_1d", "entry_lag_7d", "entry_lag_14d", "entry_rolling_3h"] # 按时间切分,前80%训练,后20%验证,绝不随机打乱 split_idx = int(len(df) * 0.8) train_df = df.iloc[:split_idx] val_df = df.iloc[split_idx:] model = lgb.LGBMRegressor( n_estimators=500, learning_rate=0.05, num_leaves=63, min_child_samples=30, subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=50 ) model.fit( train_df[FEATS], train_df["entry"], categorical_feature=["station_id", "weekday", "hour", "is_weekend", "is_holiday"], eval_set=[(val_df[FEATS], val_df["entry"])], eval_metric="mae", callbacks=[lgb.early_stopping(50)] )训练参数说明如下。n_estimators=500是树数量上限,实际在 500 轮内,验证集 MAE 连续 50 轮不下降就会触发 early stopping 自动截断。learning_rate=0.05是步长,一般取 0.03-0.1 之间,调小会更稳但更慢。num_leaves=63控制树的复杂度和表达能力,客流预测特征维度不高,63 个叶子足够表达交叉效应。min_child_samples=30限制叶子节点最少样本数,防止过拟合。subsample=0.8和colsample_bytree=0.8分别是行采样和列采样,同样是防过拟合。
验证切分这里用了“按时间长度切 80%”,而不是按车站抽样本或随机打乱。原因是客流预测天然具有时序性,只有用未来数据验证,评估出的误差才是上线后真实能期望的水平。随机切分会把未来信息泄漏进训练集,验证指标虚高,这个坑我在第 4 章详细说。
训练完成后,保存模型并单步预测:
import joblib # 保存模型,供预测服务加载 joblib.dump(model, "models/lgbm_forecast.joblib") # 单步预测:构造一个目标时段的行 def predict_one(model, row, feats=FEATS): """row是构造好的未来特征行,返回进站人数预测值""" pred = model.predict(row[feats].values.reshape(1, -1)) return float(pred[0])单步预测很好理解:只要未来某一天的滞后特征能从历史表里取到,比如预测明天早上 8 点,我们知道昨天、上周同时间的进站量,就可以直接喂给模型算出一个数。要做连续 6 小时或 24 小时的滚动预测,需要把预测结果按顺序回填到滞后特征里,这个放到第 5 章说。
4. 客流预测最容易踩的5个坑:从数据泄漏到换乘站口径
4.1 坑1:滞后特征没按站分组,整个序列错位
现象是训练集误差很低,但验证集误差忽高忽低,某些站点的预测值整体滞后了一天。原因是写特征时图省事,把groupby(["station_id", "hour"])写成了groupby("station_id")。这样 shift 操作不是在“同一小时”内滑动,而是把整个车站序列按行数硬推,结果“昨天早上 8 点”的标签被当成了“今天早上 7 点”的特征。
解决方法是严格按["station_id", "hour"]分组,并且每个分组内部按时间排序。要验证特征是否错位,可以临时打印某一天、某一小时的特征行,人工核对滞后值与真实历史值是否一致。这一步值得做,因为一旦错位,后面所有模型迭代都是在错误地基上盖楼。
4.2 坑2:白天缺失补0,客流被凭空砍掉一大截
现象是某个雨天的下午,预测值突然比实际值低 30%。原因是 AFC 系统某小时故障,记录全丢,聚合后该小时被fillna(0)补成了 0。模型看到这个 0,把它当成真实客流低点,后续几天的滞后特征也被带偏。
解决方法是分时段补缺失:夜间 0 到 5 点补 0 合理;白天 6 到 23 点的缺失,用前 7 天同一小时的中位数填充。中位数比均值更稳,因为不会被补班日、节假日等特殊日干扰。代码实现就是在聚合后加一个条件判断,白天缺失行走groupby(["station_id", "hour"])["entry"].transform(lambda x: x.median()),夜间直接填 0。
4.3 坑3:随机切分训练集,模型被“剧透”未来
现象是验证集 MAE 好得惊人,但一旦拿真实未来数据做回测,误差立刻翻倍。原因是很多人习惯用train_test_split的默认参数,随机打乱再切。客流数据里有大量滞后特征,随机切分会让训练集里混进未来日期的滞后值,模型实际上看到了不该看到的“答案”。
解决方法是严格按时间顺序切分,最前面 80% 做训练,最后 20% 做验证。如果担心 20% 时间跨度不够覆盖节假日,可以改用滚动验证:先训练到 6 月,预测 7 月;再加进 7 月数据,预测 8 月,逐段评估。这样得到的是模型在真实上线场景里的可靠误差估计。
4.4 坑4:节假日与调休日混成一锅粥
现象是长假前后那几天误差特别大,尤其是国庆前一周和五一后一周。原因是只给了一个布尔字段“是否法定节假日”,模型无法区分真正的放假和调休补班。国庆假期的前两天,很多人已经在放假;而国庆前的那个周六,很多人还在上班,两者的客流形态完全不同,一个布尔值表达不了三种状态。
解决方法是引入三值标签:普通工作日为 0,法定节假日为 1,调休补班日为 -1。这个标签放在特征里,LightGBM 能直接学到三种状态的不同客流模式。还要注意把“节前一天”单独作为一个特征,因为节前一天下午很多人提前下班,晚高峰形态和普通工作日明显不同。
4.5 坑5:换乘站只预测进站量,站台负荷被低估
现象是大型活动散场后,换乘站的预测值显示“客流平缓”,但现场站台已经挤得水泄不通。原因是模型预测的是进站量,换乘站的换乘客流根本不经过进站闸机,进站量再准也无法代表站台真实负荷。
解决方法是区分站点类型。普通站用进站量做预测目标,换乘站要把换乘通道的估计客流叠加进去。实际工程里,换乘客流可以从 OD 矩阵反推,或者用换乘站历史刷卡数据的结构比例估算。第一版可以在特征里加一个“是否换乘站”的标识;更进一步则对换乘站单独建模,把进出站量、换乘通道监测数据一起纳入。做站台负荷预警类应用,这一步绕不开。
5. 把预测结果用起来:分时段验证、滚动预测与输出细节
5.1 验证结果要分时段看,别被总MAPE骗了
客流预测的常见错误,是只报一个整体 MAE 或 MAPE。整体指标会被平峰时段稀释,早高峰几站误差大,晚间平峰几十站的误差小,一平均看起来还不错,但真正需要预警的高峰时段恰恰是最不准的。我习惯把验证集按小时分组,分早高峰、平峰、晚高峰分别计算指标:
test["hour_group"] = pd.cut( test["hour"], bins=[-1, 6, 10, 16, 19, 23], labels=["深夜", "早高峰", "平峰", "晚高峰", "夜间"] ) for group, part in test.groupby("hour_group", observed=True): mape = (abs(part["entry"] - part["pred"]) / part["entry"]).mean() print(f"{group}: MAPE={mape:.2%}")这样的分组评估能快速定位问题:早高峰 MAPE 偏大,通常说明早高峰特征没有做透;晚高峰偏大,多半是节假日或事件标签缺失。光看总指标很容易把问题掩盖掉。
5.2 滚动预测:每15分钟跑一次,比一次预测24小时更稳
提前 24 小时一次性预测未来一天,看起来很美,实际误差会随着预测距离拉长快速放大。轨道运营真正需要的是“未来 1 到 6 小时”的预警。常见做法是每 15 分钟重跑一次预测,窗口覆盖未来 6 小时,每次预测都基于最新刷卡的 AFC 数据,把滞后特征更新到当前时刻。
这样做的好处是误差可控。提前 1 小时预测,前 1 天的滞后特征基本准确,预测误差主要来自随机波动;提前 6 小时预测,误差会大一些,但还在可接受范围。如果直接预测 24 小时,前 14 天的滞后特征全部来自历史现值,等于放弃了最近 14 天的信息,误差会明显恶化。客流预测系统上线时,我一般先做 6 小时滚动窗口,稳定后再考虑向 24 小时延伸。
5.3 预测值输出前的两个细节:取整与置信区间
模型输出的预测值是一个浮点数,比如 2134.876。直接把这个数给调度系统,会造成“假精确”的错觉。实际运营只需要 5 人或 10 人级别的粒度。我会在输出前取整到 5 的倍数,比如 2135,同时附上 90% 置信区间。LightGBM 本身不直接给区间,可以用历史预测误差的标准差来估算:如果过去 30 天早上 8 点的预测误差标准差是 120 人,那 90% 区间大约是预测值上下各 200 人。运营同事看到的不再是一个孤立数字,而是“预计 2100 到 2300 人”这样的判断依据。
做这套系统给我的一个深刻教训是:模型精度提升 1%,不如把输出格式和验证口径做好。我们曾经为了把 MAE 压下去投入了大量时间调参,后来发现调度人员真正依赖的只是“是否超过阈值、是否提前预警”这两个信号。把预测结果按时段拆开、按滚动窗口更新、输出附上误差范围,这三个习惯比换任何复杂模型都更能提升系统的实际价值。希望帮到你。
本文还有配套的精品资源,点击获取