地铁AFC客流量预测实战:从刷卡流水到分钟级进站量
简介这份资源面向城市交通、智能交通与时间序列预测方向的学习者和研究者围绕地铁站AFC客流量预测这一典型场景提供从数据处理到多模型对比的完整代码与实验数据。压缩包共61个文件约7.59MB包含10个Python脚本、41张结果图、5个指标文本、2个Excel数据表及CSV、HTML、PTH模型权重等覆盖数据预处理、特征工程、模型训练与可视化全流程。代码实现了LSTM、RNN、GRU、DNN、SVM、KNN六类模型的统一对比并输出损失曲线、预测对比、残差分析、雷达图与仪表盘等图表便于直接复现实验并撰写论文或报告。目前已有49人学习适合希望掌握客流量预测建模、模型评估与结果可视化的读者参考。1. 地铁 AFC 客流量预测从刷卡流水到分钟级进站量这套代码能直接跑早高峰的站厅里闸机每刷一次卡就吐出一条交易记录一天下来单站几十万条。很多人拿到这份 AFC 数据的第一反应是「先画个折线图看看」结果被 15 分钟粒度的毛刺和缺失时段搞得没法下手。这套地铁站 AFC 客流量预测的完整代码和数据解决的正是从原始刷卡流水到可用预测模型的全链路问题清洗、聚合、特征构造、模型训练、误差评估每一步都有对应脚本。它适合两类人——一类是刚接手客流分析、需要一份能跑通的基线代码的运营或数据同学另一类是做过时间序列、想拿真实 AFC 数据验证自己特征工程思路的算法同学。代码不依赖特定云平台本地 Python 环境就能复现数据字段和脚本命名都按实际 AFC 系统的输出习惯来不是那种玩具数据集。2. 数据长什么样AFC 字段解析与 15 分钟粒度聚合2.1 先认清 AFC 原始表的字段含义地铁 AFC 系统的交易表通常包含这几类字段卡号或脱敏后的卡 ID、进站站点编号、进站时间、出站站点编号、出站时间、票卡类型、交易金额。不同城市字段名有差异但结构大同小异。这份资源里的数据已经做过脱敏卡号是哈希后的字符串时间戳精确到秒。拿到手第一件事不是建模是确认时间字段的时区、是否有跨天记录、进出站是否成对。我一般会先跑一遍字段统计看空值率和取值范围。import pandas as pd # 读取 AFC 原始交易数据注意时间列要显式解析 df pd.read_csv(afc_raw.csv, parse_dates[进站时间, 出站时间]) # 基础体检行数、时间跨度、关键字段空值 print(总记录数:, len(df)) print(时间范围:, df[进站时间].min(), -, df[进站时间].max()) print(进站时间为空的记录:, df[进站时间].isna().sum()) print(出站时间为空的记录:, df[出站时间].isna().sum()) # 票卡类型分布判断是否需要分类型建模 print(df[票卡类型].value_counts())这段代码做的是数据体检不是清洗。parse_dates必须显式指定否则时间列会以字符串读入后续做时间差计算会直接报错。空值统计要分开看进站和出站——出站为空可能是乘客尚未出站也可能是数据采集丢包处理方式完全不同。票卡类型分布决定了要不要分类型建模比如学生卡和工作日通勤卡的出行规律差异很大混在一起会稀释特征。2.2 聚合到 15 分钟粒度为什么不是 5 分钟也不是 1 小时预测粒度选 15 分钟是折中结果。5 分钟粒度下单个站点的进站量经常是个位数序列噪声极大模型学到的全是随机波动1 小时粒度又太粗早高峰的峰值被抹平运营调度需要的是「哪个 15 分钟段需要加开列车」。所以这份代码默认按 15 分钟聚合同时保留了一个可配置的FREQ参数改成5min或30min都能跑。# 按进站站点和 15 分钟窗口聚合进站量 FREQ 15min df[进站窗口] df[进站时间].dt.floor(FREQ) # 只统计有效进站记录出站为空不影响进站量 flow ( df.dropna(subset[进站时间]) .groupby([进站站点编号, 进站窗口]) .size() .reset_index(name进站量) ) # 补齐缺失窗口没有记录的时段进站量应为 0而不是缺失 full_index pd.date_range( flow[进站窗口].min(), flow[进站窗口].max(), freqFREQ ) stations flow[进站站点编号].unique() grid pd.MultiIndex.from_product( [stations, full_index], names[进站站点编号, 进站窗口] ) flow flow.set_index([进站站点编号, 进站窗口]).reindex(grid, fill_value0).reset_index() print(flow.head())dt.floor(FREQ)把秒级时间戳向下取整到 15 分钟边界这是聚合的关键一步。groupby().size()统计的是记录条数等价于进站人次。重点在reindex那一段AFC 数据里某个站点某个 15 分钟窗口没有记录不代表进站量为零但如果不补齐后续做滞后特征时会出现时间跳跃模型会把「缺失」误读成「异常」。补齐成 0 之后序列才是等间隔的。这一步不做后面所有基于滑窗的特征都会翻车。3. 特征工程把时间戳拆成模型能吃的信号3.1 时间特征周期编码比直接塞小时数更稳把「小时」直接作为数值特征喂给模型是个常见错误——23 点和 0 点在数值上差 23但在客流规律上几乎连续。正确做法是做周期编码用正弦余弦把时间映射到圆上。这份代码里对小时和星期都做了编码同时保留了是否高峰、是否周末这类布尔特征。import numpy as np def add_time_features(df, time_col进站窗口): t df[time_col] df[小时] t.dt.hour t.dt.minute / 60.0 df[星期] t.dt.dayofweek # 周期编码小时以 24 为周期星期以 7 为周期 df[小时_sin] np.sin(2 * np.pi * df[小时] / 24) df[小时_cos] np.cos(2 * np.pi * df[小时] / 24) df[星期_sin] np.sin(2 * np.pi * df[星期] / 7) df[星期_cos] np.cos(2 * np.pi * df[星期] / 7) # 布尔特征早高峰 7-9 点晚高峰 17-19 点 df[是否早高峰] df[小时].between(7, 9).astype(int) df[是否晚高峰] df[小时].between(17, 19).astype(int) df[是否周末] (df[星期] 5).astype(int) return df flow add_time_features(flow)小时用小数表示是为了保留 15 分钟粒度信息比如 7.25 代表 7:15。周期编码后23:45 和 00:00 在特征空间里距离很近模型不会把它们当成两个极端。布尔特征是对周期编码的补充因为高峰时段的客流形态和普通时段差异不是平滑过渡而是有明确的阈值效应。这几列加上去树模型的特征重要性里「小时_sin」「小时_cos」通常排在前列。3.2 滞后与滑窗特征别让未来数据泄漏进训练集时间序列预测最容易踩的坑是数据泄漏。用shift(-1)构造「下一时刻」特征训练集里就混入了未来信息离线指标好看得离谱上线就崩。这份代码里所有滞后特征都用正数shift滑窗只用过去窗口。# 按站点分组后构造滞后和滑窗特征避免跨站串数据 flow flow.sort_values([进站站点编号, 进站窗口]) for lag in [1, 2, 4, 96]: # lag1 是上一个 15 分钟lag96 是前一天同一时刻 flow[flag_{lag}] flow.groupby(进站站点编号)[进站量].shift(lag) # 过去 1 小时的均值和标准差窗口 4 个点 flow[roll_mean_4] ( flow.groupby(进站站点编号)[进站量] .transform(lambda s: s.shift(1).rolling(4).mean()) ) flow[roll_std_4] ( flow.groupby(进站站点编号)[进站量] .transform(lambda s: s.shift(1).rolling(4).std()) ) # 去掉因滞后产生的空值行 flow flow.dropna().reset_index(dropTrue)lag_96是关键特征96 个 15 分钟正好是 24 小时它让模型能参考「昨天同一时刻」的客流。groupby必须在shift之前否则会把 A 站的数据串到 B 站。滑窗里的shift(1)是防泄漏的核心——先移一位再 rolling保证窗口内全是历史值。dropna会损失前 96 个时间点如果数据跨度短可以把lag_96去掉换成lag_48但别用fillna(0)糊弄那会引入虚假的零客流。4. 模型训练与评估LightGBM 基线加时间序列交叉验证4.1 为什么选 LightGBM 而不是 LSTM这份代码的默认模型是 LightGBM不是 LSTM。原因很实际AFC 客流预测的特征里滞后值和滑窗统计的贡献远大于序列本身的长期依赖树模型在表格特征上收敛快、调参少、可解释性强。LSTM 需要更长的序列输入和更多调参在单站数据量有限的情况下未必更优。代码里也留了 LSTM 的接口但基线用 LightGBM 能让你在半小时内看到第一版结果。import lightgbm as lgb from sklearn.metrics import mean_absolute_error, mean_squared_error # 按时间切分前 80% 训练后 20% 测试不能随机打乱 split int(len(flow) * 0.8) train, test flow.iloc[:split], flow.iloc[split:] feature_cols [ 小时_sin, 小时_cos, 星期_sin, 星期_cos, 是否早高峰, 是否晚高峰, 是否周末, lag_1, lag_2, lag_4, lag_96, roll_mean_4, roll_std_4, ] model lgb.LGBMRegressor( n_estimators500, learning_rate0.05, num_leaves31, min_child_samples20, ) model.fit(train[feature_cols], train[进站量]) pred model.predict(test[feature_cols]) print(MAE:, mean_absolute_error(test[进站量], pred)) print(RMSE:, np.sqrt(mean_squared_error(test[进站量], pred)))切分必须按时间顺序iloc[:split]和iloc[split:]绝不能train_test_split(shuffleTrue)。n_estimators500配合learning_rate0.05是保守配置先看收敛曲线再调。num_leaves31是默认值站点多、数据量大时可以加到 63但要注意过拟合。评估指标里 MAE 比 RMSE 更直观因为它和进站量同量纲可以直接说「平均每个 15 分钟窗口差 X 人」。4.2 时间序列交叉验证单次切分不够可信单次 80/20 切分只能看一个时间段的表現遇到节假日或突发客流结论会偏。代码里实现了滚动窗口交叉验证用多个训练/验证对来评估稳定性。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) scores [] for fold, (tr_idx, va_idx) in enumerate(tscv.split(flow)): tr, va flow.iloc[tr_idx], flow.iloc[va_idx] m lgb.LGBMRegressor(n_estimators500, learning_rate0.05, num_leaves31) m.fit(tr[feature_cols], tr[进站量]) p m.predict(va[feature_cols]) mae mean_absolute_error(va[进站量], p) scores.append(mae) print(fFold {fold1} MAE: {mae:.2f}) print(f平均 MAE: {np.mean(scores):.2f} ± {np.std(scores):.2f})TimeSeriesSplit保证每次验证集都在训练集之后不会出现「用未来预测过去」。5 折下来看 MAE 的均值和标准差如果某折明显偏高去查那段时间是不是有大型活动或天气异常。标准差大说明模型对时段敏感需要补特征比如加天气或节假日标记。5. 避坑与排查AFC 客流预测里最容易翻车的五件事5.1 现象模型离线 MAE 很低上线后预测值整体偏高原因训练集里混入了出站记录或重复刷卡记录导致进站量被高估。AFC 数据里同一张卡在短时间内可能有多条进站记录比如闸机误刷或乘客反复进出。解决在聚合前按卡号和进站时间做去重保留每个卡号在每个站点 15 分钟窗口内的第一条记录。代码里drop_duplicates(subset[卡号, 进站站点编号, 进站窗口])放在groupby之前。5.2 现象滞后特征重要性极高但验证集误差波动大原因lag_96在跨天时遇到缺失窗口补齐的 0 被当成真实客流模型学到了错误的「昨天此时为零」。解决补齐窗口后对lag_96增加一个缺失标记列告诉模型这个滞后值来自补齐还是真实记录。或者改用lag_95到lag_97的均值降低单点缺失的影响。5.3 现象早高峰预测普遍偏低晚高峰偏高原因早高峰进站量受通勤刚性影响波动小晚高峰受加班、购物等弹性影响波动大。模型用同一套参数拟合两种模式偏向均值。解决分时段建模或者把「是否早高峰」「是否晚高峰」作为交互特征与滞后值组合。代码里可以加lag_1 * 是否早高峰这类交叉项让模型在不同时段用不同的滞后权重。5.4 现象新开通站点预测误差远大于老站点原因新站点历史数据少滞后特征大量为空dropna后训练样本不足。解决对新站点单独处理用同线路相邻站点的客流比例做迁移或者降低对新站点滞后特征的依赖加大时间特征的权重。代码里可以按站点数据量分组数据量低于阈值的站点走简化模型。5.5 现象节假日预测完全失效原因训练数据里节假日样本占比低模型没学到节假日的特殊模式。解决加节假日标记特征并且在交叉验证时把节假日单独作为一折评估。如果节假日客流形态差异极大考虑单独训练节假日模型。代码里预留了is_holiday字段的接口需要自己接入节假日表。6. 进阶技巧用残差修正把高峰时段误差再压一截基线模型跑通后早高峰的误差通常还是最大的。我一般会做一步残差修正先用 LightGBM 预测然后对残差再训一个轻量模型专门学「基线在什么情况下偏得最多」。这不是 stacking是针对性的偏差纠正。# 基线预测后计算残差 train_pred model.predict(train[feature_cols]) train_resid train[进站量] - train_pred # 用同样的特征训残差模型但只关注高峰时段 peak_mask train[是否早高峰] train[是否晚高峰] 0 resid_model lgb.LGBMRegressor(n_estimators200, learning_rate0.03, num_leaves15) resid_model.fit(train.loc[peak_mask, feature_cols], train_resid[peak_mask]) # 测试时基线 残差修正 test_pred model.predict(test[feature_cols]) peak_test test[是否早高峰] test[是否晚高峰] 0 test_pred[peak_test] resid_model.predict(test.loc[peak_test, feature_cols]) print(修正后 MAE:, mean_absolute_error(test[进站量], test_pred))残差模型用更少的树和更小的学习率避免把噪声也学进去。只在高峰时段应用修正平峰时段基线已经够准强行修正反而引入波动。这个技巧在多个站点上试过早高峰 MAE 通常能再降 5% 到 10%。参数上num_leaves15是刻意压低的残差模型不需要太复杂否则会把基线的随机误差当成信号。验证修正是否有效不能只看整体 MAE要分时段对比。我习惯把一天切成 96 个窗口画出每个窗口的误差曲线看修正集中在哪些时段。如果修正后某些平峰时段误差反而变大说明残差模型的适用范围划错了把peak_mask收紧到只覆盖早晚高峰的核心窗口。从那以后我每次做客流预测都会先把「补齐窗口」和「防泄漏」这两步单独写成函数跑完立刻检查滞后特征的空值率和时间连续性确认无误再进模型。这份代码里这两步是拆开的你可以直接替换成自己站点的数据改一下FREQ和站点编号列名就能跑。希望帮到你。本文还有配套的精品资源点击获取