AFC地铁客流量预测:从数据清洗到模型实战全流程
简介自动售检票AFC系统记录了地铁乘客每次刷卡的进出站流水这些数据隐含了城市通勤的时空规律。客流预测的核心在于将个体交易聚合成站点级时间序列并通过特征工程提取周期、节假日、天气及滞后效应等关键信息。实践中常用LightGBM等树模型对高维表格特征建模同时以TimeSeriesSplit避免时序泄露。准确预测站点进出站客流能够辅助地铁运营方优化排班、预警拥堵并提升调度效率。针对数据清洗、特征构建、模型选型与误差评估等环节已有完整的开源代码与实战方案可帮助快速搭建可落地的AFC客流量预测流程。 AFC客流量预测这个方向我在实际项目里前前后后折腾过不少时间。每次跟同行聊起来大家最头疼的往往不是模型选型而是数据拿到手之后那段“脏活累活”——刷卡记录怎么清洗、时间特征怎么拆、站点属性怎么对齐、评估指标怎么定。这篇就围绕“地铁站 AFC 客流量预测完整代码数据分享”这个标题把从原始数据到最终预测结果的完整链路捋一遍。无论你是刚接触交通大数据的学生还是已经在做智慧交通项目的工程师只要目标是预测某个地铁站的进出站客流这套流程和代码都可以直接拿去改。1. AFC数据长什么样刷卡流水里有黄金也有不少坑AFCAutomatic Fare Collection系统就是地铁里的自动售检票系统乘客每次刷卡进站、出站都会产生一条交易流水。很多人以为拿到的数据就是一张“站点—时间—客流数”的现成表实际上远没那么简单。1.1 一条原始刷卡记录包含哪些关键字段以我处理过的某城市地铁AFC数据为例原始表通常包含这些字段字段名示例值含义card_id888866667777票卡编号物理卡或二维码账户entry_station0102进站站点代码entry_time2024-05-13 08:12:35进站刷卡时间exit_station0105出站站点代码exit_time2024-05-13 08:33:51出站刷卡时间transaction_type0/1/2交易类型正常/补登/异常device_idGATE-03-012闸机编号有些系统还会记录票价、折扣类型、线路编号。如果要预测某个站点未来一段时间的进出站客流量核心要做的就是先把这些个体交易记录按站点按时间段聚合成客流时间序列。聚合逻辑本身不复杂但有两个细节容易被忽略进站客流和出站客流是两条序列。早高峰一个站进站量暴涨出站量可能平平晚高峰正好反过来了。做预测时最好拆开建模或者至少分开统计别糊在一起。时间粒度怎么选。预测未来一天的小时级客流通常按30分钟或60分钟聚合预测整周趋势按天聚合就够。我做过对比30分钟粒度能保留早晚高峰的明显拐点对运营排班更有价值但数据波动更大对模型要求也更高。1.2 原始数据里最烦人的三类脏数据这一步是整条链路里最花时间、也最容易被新手低估的环节。我每次拿到新数据固定要做三件事第一是去重。AFC系统偶尔会因为网络重传、交易重试导致同一条刷卡记录出现多次。判断标准不能只看card_id和entry_time大概率同一个乘客在地铁站因闸机没开会连续刷两三次卡。稳妥的做法是对 card_id entry_station entry_time 三个字段做联合去重保留时间最早的那一条。第二是逻辑校验。最常见的异常是“出站时间早于进站时间”或“同站进出”。出现这种情况通常是乘客临时改变出行计划、刷卡成功但闸机故障或者数据本身采集故障。处理方式不是直接删而是打标记。我自己的项目里把非法记录按站点、按小时统计出一个“异常率”异常率超过5%的站点在后续特征工程里单独加一个标记特征比粗暴删除效果更好。第三是测试卡和内部卡剔除。地铁公司内部测试、设备维修时会产生大量非真实乘客的刷卡数据这些数据会让某几个站点同一时间点的客流凭空多出几百人。识别方式也很简单同一张卡一天刷卡次数超过30次或者同一张卡在极短时间内跨多个站点进出基本可以判定为异常卡。我在代码里直接把这些卡号拉黑跑完看训练集MAPE立竿见影。2. 特征工程的四个维度为什么说预测效果七成靠特征而不是模型很多人一上来就堆模型LightGBM、XGBoost、LSTM轮番上阵结果指标一直上不去。我的经验是特征工程做到位哪怕用最朴素的回归模型效果也可能超过调参调了一周的复杂模型。对地铁客流预测来说特征无非四大类。2.1 时间特征客流预测的基础盘时间特征是客流预测里最核心的变量。地铁客流有极强的周期性规律工作日早高峰集中在7:00-9:00晚高峰集中在17:00-19:00周末则全天相对平缓午高峰和晚高峰不明显。实际操作中我通常构造以下几种时间特征小时数0到23的整数直接作为特征让模型去拟合早晚高峰的非线性关系。星期几1到7的整数。注意周一和周五的客流模式跟周二到周四有明显差异模型需要捕捉这一点。是否工作日0/1二值特征这个尤其重要。因为有些站点在周末反而比工作日更热闹——比如靠近商圈、景点的站点。是否节假日0/1特征同时额外加一个“节假日前一天”和“节假日最后一天”特征。五一、国庆这类长假前后车站客流会出现明显的“脉冲式”变化。这里有个容易忽略的点节假日特征不能只看当天还要看窗口期。比如国庆前一天的晚高峰很多人提前下班赶火车/飞机重点枢纽站的客流会提前爆发长假最后一天则是返程高峰。如果只构造“是否节假日”这个特征模型没法学到这种窗口效应。我建议多加一个“距最近法定节假日天数”的数值特征连续值让模型自己学。2.2 站点属性特征同一个城市不同站点是两种生物地铁站和地铁站之间的客流模式差异有时候比不同城市之间的差异还大。一个靠近火车站的站点全天客流分布和写字楼密集区的站点完全是两回事一个换乘站和一个普通站客流基数也差好几倍。我处理数据时会为每个站点构造以下几类属性特征站点类型换乘站/普通站/终点站用one-hot编码。周边用地属性如果拿不到详细的POI数据可以用一个简化版本——“该站周边3公里内是否有大型商业综合体、学校、医院”通过公开地图数据就能粗略标注0/1特征即可。历史客流基数每个站点过去90天的平均日客流、平均早高峰客流、平均晚高峰客流。这些统计特征把“这个站本身有多大”的信息直接喂给模型效果立竿见影。有一次我发现两个相邻站预测效果一个很好一个很差排查了半天才发现差的那个站旁边新开了一家三甲医院半年来客流稳步上涨历史均值特征已经跟不上趋势了。后来我在特征里加了一个“近30天客流滑动平均与去年同期比值”的趋势类特征才把这个站的预测误差压下来。这也是我要强调的特征工程不是一次性工作要随着数据变化持续迭代。2.3 外部环境特征天气和温度的影响不能忽视天气对地铁客流的影响在不同城市、不同季节表现不一样。我在项目中实测下来强降雨天对地面通勤人群向地铁转移有正向刺激但极端暴雨反而会抑制整体出行气温则会影响平峰期客流夏天中午太热走路的人可能改为坐地铁。如果项目周期允许我建议至少加入以下几个天气特征日平均气温、最高气温、最低气温降雨量小时级数据要对应小时级预测是否雨雪天气0/1不过这里也有个现实问题做预测的时候未来的天气数据只能拿天气预报本身就有不确定性。所以天气特征更适合作为辅助特征权重不宜过高。我在实际项目中给天气特征做过一个消融实验去掉天气特征后MAPE只上升了2%左右但加上天气特征后模型的预测曲线在极端天气日更平滑不容易出现剧烈偏差。2.4 时序滞后特征让模型“看到”过去除了上述外部特征时序预测里还有一个非常关键的技巧——构造滞后特征。简单说就是把前3天同时段、前7天同时段、前14天同时段的客流值直接作为当前时刻的特征。这个做法的底层逻辑是地铁客流具有很强的“惯性”昨天的客流很大程度上能预示今天的客流。比如周五的客流和上周五的客流高度相关因为它们的通勤结构、活动安排都类似。代码里实现起来也很直接# 构造滞后特征示例以30分钟粒度为例 import pandas as pd # df 已按 station, time 排序target 为该时段进站客流 df[lag_1d] df.groupby(station)[target].shift(48) # 前一天同时段 df[lag_7d] df.groupby(station)[target].shift(48*7) # 前7天同时段 df[lag_14d] df.groupby(station)[target].shift(48*14) # 前14天同时段 df[rolling_7d_mean] df.groupby(station)[target].transform(lambda x: x.rolling(48*7, min_periods1).mean())注意用shift的时候一定要先按站点分组否则会把不同站点的数据错位衔接。这个错误我踩过一次当时没按station分组直接shift结果模型学出来的特征里混了邻近站点的数据看起来指标挺好实际上预测逻辑完全是错的。3. 模型选型对比从朴素baseline到LightGBM的实战记录很多初学者喜欢直接上最复杂的模型但我一贯的做法是“先建立基准再逐步升级”。没有baseline做参照你根本不知道复杂模型到底提升在哪里。3.1 先跑一个“历史均值”baseline最朴素的baseline就是预测某站某天的客流量 过去N周同一天同时段的平均客流量。比如预测下周三早8:00的进站量就直接取过去4个周三排除节假日早8:00进站量的平均值。这个方法简单到几乎不算一个“模型”但它的MAPE通常在15%-25%之间取决于站点类型和时段。之前我遇到过一个学员第一期项目预测结果MAPE是17%他觉得自己做得不错。后来我让他先跑baseline发现baseline的MAPE是18%——也就是说他的模型基本没有真正学到任何东西只是把均值预测微调了一下。baseline的真正价值是给你一个下限。后续所有模型都必须显著优于baseline否则说明特征或者模型设计有问题。3.2 树模型为什么适合这个场景LightGBM实测在客流预测这个任务上我最后的主力模型是LightGBM而不是LSTM或Transformer这类深度模型。原因很直接特征以表格为主树模型对特征分布没有假设能直接吃下各种类型的数据。LightGBM训练速度快迭代效率高。一个城市上百个站点每个站点单独建模也只要几分钟。树模型能输出特征重要性方便我做特征筛选和业务解释。用LightGBM的基本代码框架如下import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit # X: 特征矩阵y: 目标客流量 # 注意要用时间序列交叉验证不能用普通的KFold tscv TimeSeriesSplit(n_splits5) for train_idx, val_idx in tscv.split(X): X_train, X_val X.iloc[train_idx], X.iloc[val_idx] y_train, y_val y.iloc[train_idx], y.iloc[val_idx] model lgb.LGBMRegressor( n_estimators1000, learning_rate0.05, num_leaves63, colsample_bytree0.8, subsample0.8, random_state42 ) model.fit( X_train, y_train, eval_set[(X_val, y_val)], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] )这里有个关键点时间序列问题绝对不能使用普通的K折交叉验证。因为K折随机打乱数据会把未来的数据泄露到训练集里让验证指标虚高。我用TimeSeriesSplit保证训练集始终在验证集之前这样评估指标才接近真实上线效果。3.3 深度模型的适用边界LSTM不是灵丹妙药我也用LSTM做过对比实验。在小时级粒度、数据量大超过两年且站点客流规律稳定的情况下LSTM的长期趋势拟合能力确实比LightGBM好一点尤其在节假日前后的客流突变上LSTM能利用序列信息做出更平滑的过渡。但缺点也很明显训练时间长、调参难度大、对异常值敏感、特征重要性不可解释。对一个生产环境的落地项目来说如果LightGBM的MAPE能做到12%LSTM能做到10.5%我大概率还是选LightGBM——因为前者更容易维护出问题也更容易定位。3.4 多站点建模策略全局模型还是单站模型建模策略上有个常见选择每个站单独建一个模型还是所有站的数据合并训练一个全局模型我强烈建议先用全局模型。原因有两点地铁站的数量可能上百个如果每个站单独建模维护工作量巨大。很多站点的历史数据量不足新建站点可能只有几个月数据全局模型可以把所有站点的数据合并训练共享相似的客流规律对小样本站点尤其友好。具体做法是把站点的one-hot编码或者站点embedding作为特征传入模型。实测下来全局模型的整体MAPE比单站模型低2-3个百分点尤其是对小站点的提升非常明显。4. 评估指标与误差分析MAPE好看不代表真能用模型的评估是项目里最容易“自欺欺人”的环节。不是说大家故意做假而是指标选错了、分析方法不对会导致你得出错误的结论。4.1 三个核心指标缺一不可我每个项目都会同时给出三个指标绝不只用某一个指标公式适用场景注意事项MAEmean(|y_true - y_pred|)直观反映平均绝对误差受量纲影响不同站之间不可比RMSEsqrt(mean((y_true - y_pred)^2))放大大误差惩罚对异常值敏感MAPEmean(|y_true - y_pred| / y_true) * 100%无量纲相对误差适合对比客流接近0时数值爆炸MAPE是业内人士最常看的指标但有个明显的坑如果某个站点某个时段真实客流接近0比如凌晨1点到5点预测值稍微偏一点MAPE就会飙到几十甚至几百。所以在计算MAPE时我一般会过滤掉真实客流小于某个阈值比如小于50人/小时的样本单独报告“高峰时段MAPE”和“全时段MAPE”两个值。4.2 真实案例一个“低MAPE”模型的翻车现场曾经有一次我调整完特征全站点的MAPE从14%降到了11%心里还挺高兴。但上线运行一周后发现调度系统反馈的预警次数反而变多了。回去一查才发现MAPE的下降主要来自凌晨低客流时段的贡献——凌晨每站的客流基数本来就小预测偏差几个数字相对误差就变得很小把整体MAPE拉下来了。而高峰时段7:00-9:00、17:00-19:00的误差其实几乎没变个别站点甚至更差了。那之后我每次评估模型都会按小时时段、按站点类型分别输出误差报表重点关注工作日早高峰、晚高峰的MAE和MAPE。对运营方来说高峰时段的精准度远比凌晨时段的精准度重要。4.3 误差最大的几种场景与应对根据我的经验以下几个场景预测误差最容易放大节假日前后客流模式和日常工作日完全不同模型如果没见过往年的节假日数据几乎必跪。应对方法是构造节假日窗口特征并在模型中加入“节假日类型”的类别特征春节、国庆、普通周末各有不同模式。大型活动散场演唱会、体育赛事、展会散场附近站点会出现10-30分钟的短时客流脉冲远远超出正常波动范围。这类场景靠常规时间特征基本无法预测如果有活动排期数据可以额外加一个“是否有大型活动”特征否则只能在误差分析中识别出来人工干预。新开线路/站点新站开通后客流爬坡期长达3-6个月数据量不足导致模型预测偏差大。建议在新站的预测结果上乘以一个爬坡系数或者用相似类型站点的模型迁移过来。5. 完整代码走读从原始刷卡流水到预测结果一条龙前面讲了思路和原理这部分直接上可运行的完整代码。我会沿着“数据加载 → 聚合清洗 → 特征工程 → 模型训练 → 预测评估”的顺序走一遍。5.1 代码结构总览整个项目代码主要包含以下几个模块逻辑清晰也方便后续维护afc_flow_forecast/ ├── data/ │ ├── raw/ # 原始AFC刷卡流水 │ └── processed/ # 清洗聚合后的中间数据 ├── src/ │ ├── preprocess.py # 数据清洗、聚合 │ ├── features.py # 特征工程 │ ├── train.py # 模型训练与调参 │ └── predict.py # 预测与评估 ├── config.yaml # 全局配置 └── README.md5.2 数据加载与预处理代码原始AFC数据通常是CSV或者从数据库导出的表加载之后先做基础字段对齐。以下是我实际用过的预处理流程import pandas as pd import numpy as np from datetime import datetime # 1. 读取原始刷卡流水 df pd.read_csv(data/raw/afc_transactions.csv, parse_dates[entry_time, exit_time]) # 2. 剔除明显异常卡号 # 统计每张卡一天的刷卡次数超过30次视为测试卡/设备卡 card_counts df.groupby([card_id, df[entry_time].dt.date]).size().reset_index(namecounts) abnormal_cards card_counts[card_counts[counts] 30][card_id].unique() df df[~df[card_id].isin(abnormal_cards)] # 3. 逻辑校验剔除出站时间早于进站时间 df df[df[exit_time] df[entry_time]] # 4. 按站点 时间粒度聚合 # 这里以 30 分钟为粒度拆分进站客流和出站客流 df[entry_half_hour] df[entry_time].dt.floor(30min) df[exit_half_hour] df[exit_time].dt.floor(30min) entry_flow df.groupby([entry_station, entry_half_hour]).size().reset_index(nameentry_count) exit_flow df.groupby([exit_station, exit_half_hour]).size().reset_index(nameexit_count) entry_flow.rename(columns{entry_station: station, entry_half_hour: time}, inplaceTrue) exit_flow.rename(columns{exit_station: station, exit_half_hour: time}, inplaceTrue) # 合并进站和出站客流方便后续统一构造特征 flow entry_flow.merge(exit_flow, on[station, time], howouter).fillna(0) flow[total_flow] flow[entry_count] flow[exit_count]这里我特别提一句处理时间字段时一定要先转换成分箱floor而不是直接用原始的到分钟/秒的时间戳。否则同一个小时内的客流会被切分成几十个碎片时间特征没法用。半小时是最推荐的粒度能平衡细节和稳定性——15分钟粒度噪声太大1小时粒度会抹平早晚高峰的弧线。5.3 特征工程完整实现这一步我们把前文讲的四类特征全部实现。为了直观我以“预测某站未来某半小时的进站客流”为例构造训练集。# 构造时间相关特征 flow[hour] flow[time].dt.hour flow[minute] flow[time].dt.minute flow[dayofweek] flow[time].dt.dayofweek flow[is_weekend] (flow[dayofweek] 5).astype(int) # 法定节假日标记传入节假日日期列表 holiday_list pd.to_datetime([2024-01-01, 2024-02-10, 2024-04-04, ...]) # 根据实际年份补全 flow[is_holiday] flow[time].dt.date.isin(holiday_list.date).astype(int) # 构造节假日窗口距离最近节假日的天数 holiday_dates sorted(holiday_list) flow[days_to_holiday] flow[time].apply( lambda t: min([abs((t - h).days) for h in holiday_dates]) ) # 站点属性特征简版 # 这里用独热编码表示站点的类型实际中应根据业务标注 station_type_map {0101: transfer, 0102: normal, 0105: transit_hub} flow[station_type] flow[station].map(station_type_map) flow pd.get_dummies(flow, columns[station_type]) # 滞后特征 flow flow.sort_values([station, time]).reset_index(dropTrue) # 前一天同时段、前7天同时段的进站客流 flow[entry_lag_1d] flow.groupby(station)[entry_count].shift(48) flow[entry_lag_7d] flow.groupby(station)[entry_count].shift(48*7) # 近7天同时段进站客流的均值注意先按站点分组 flow[entry_rolling_7d_mean] ( flow.groupby(station)[entry_count] .transform(lambda x: x.rolling(48*7, min_periods1).mean()) ) # 天气特征外部数据合并 # weather_df 需要自行获取包含每个时间段的气温、降雨量 weather_df pd.read_csv(data/processed/weather.csv, parse_dates[time]) flow flow.merge(weather_df, ontime, howleft) # 丢弃缺失值过多的行 flow flow.dropna(subset[entry_lag_1d, entry_lag_7d]).reset_index(dropTrue)这里要特别提醒一个细节entry_lag_7d用了shift(48*7)之后前7天的数据必须是从上一周同一时刻来的不能简单地把所有滞后特征shift之后堆在一起。如果中间出现数据缺口比如某天设备宕机滞后特征就会错位建议填充缺失值或直接丢弃对应行。5.4 模型训练、预测与结果导出我们以LightGBM为主模型用TimeSeriesSplit做验证。这个环节我把每站的全时段数据整合成一份让模型直接学习“站点的个体差异”和时间模式。import lightgbm as lgb from sklearn.metrics import mean_absolute_error, mean_squared_error import numpy as np # 特征列设定 feature_cols [ hour, minute, dayofweek, is_weekend, is_holiday, days_to_holiday, entry_lag_1d, entry_lag_7d, entry_rolling_7d_mean, station_type_transfer, station_type_normal, station_type_transit_hub, temp, rainfall ] X flow[feature_cols] y flow[entry_count] # 时序切分用前4段训练最后一段验证 split_idx int(len(flow) * 0.8) X_train, X_val X.iloc[:split_idx], X.iloc[split_idx:] y_train, y_val y.iloc[:split_idx], y.iloc[split_idx:] model lgb.LGBMRegressor( n_estimators2000, learning_rate0.03, num_leaves63, colsample_bytree0.8, subsample0.8, subsample_freq1, random_state42 ) model.fit( X_train, y_train, eval_set[(X_val, y_val)], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)] ) # 预测与评估 y_pred model.predict(X_val) # 过滤凌晨低客流时段后再评估高峰时段误差 mask_peak (X_val[hour] 7) (X_val[hour] 9) | (X_val[hour] 17) (X_val[hour] 19) mae_all mean_absolute_error(y_val, y_pred) mae_peak mean_absolute_error(y_val[mask_peak], y_pred[mask_peak]) rmse_all np.sqrt(mean_squared_error(y_val, y_pred)) print(f全时段MAE: {mae_all:.2f} 人次/半小时) print(f早晚高峰MAE: {mae_peak:.2f} 人次/半小时) print(f全时段RMSE: {rmse_all:.2f} 人次/半小时) # 导出预测结果 result_df pd.DataFrame({ station: flow.iloc[split_idx:][station].values, time: flow.iloc[split_idx:][time].values, actual: y_val.values, predict: y_pred }) result_df.to_csv(outputs/test_predictions.csv, indexFalse)我在实际跑这个流程时用某个城市约80个站点、半年的数据整体训练半小时粒度进站客流预测的全时段MAPE在12%左右早晚高峰时段MAPE在10%以内足够支持车站的客流预警和人员排班参考。这个效果对生产环境来说已经具备实用价值而且整套代码在单机上跑完不到半小时。5.5 几个让代码更健壮的工程化细节把项目从“能跑”提升到“好维护”有几个细节值得做记录数据版本。AFC数据源可能有更新每次处理前记录一下原始数据的日期范围和行数出了问题能快速回溯。配置项抽取。时间粒度、预测目标进站量还是出站量、滞后天数、节假日日期列表都应该写到配置文件里不要硬编码在代码中。预测结果的合理性校验。上线前加一道规则预测值不能为负数单站半小时客流不能超过历史最大值的2倍超过则标记为“高风险预测”让运营人员人工复核。这一步能有效防止模型在极端情况下“放飞自我”。6. 写在最后这套方案还能怎么扩展AFC客流预测这个项目做完之后扩展空间其实非常大。我当时做完基础预测后顺手做了两件事一次性把项目价值翻了几倍一是把进站和出站客流联合起来做一个短时的“进出站不均衡度”指标——如果某站突然出现大量出站客流但进站很少大概率是附近有活动或临时管控把这个指标接入调度大屏能帮助工作人员提前预判站内拥堵风险。二是把预测结果按线路做汇总预测整条线路的断面客流某区段车厢拥挤度。这个指标对运营方排班、加车决策更有直接价值而且实现起来并不复杂——只需要在预测完每个站点的进出站量后按照历史OD矩阵比例拆分客流走向。另外如果你的数据量足够大一年以上可以尝试用图神经网络或Transformer模型替代LightGBM自动捕捉站点之间的空间关联。我在一个多站点联合预测的实验中把站点间历史客流的皮尔逊相关系数作为图结构引入预测误差又下降了1-2个百分点。不过这类深度模型对数据量和工程能力的要求都更高建议先把基于LightGBM的基础版本跑通上线再逐步考虑升级。如果你拿到了一份AFC数据想要复现这套流程建议先花半天时间看看数据的基本统计特征——哪些站点客流最大、哪些时段波动最剧烈、有没有明显的缺失和异常。摸清了数据的脾气后面的建模工作会顺手很多。我前前后后迭代了三四版才把标准化流程定下来希望你这次能少走点弯路。本文还有配套的精品资源点击获取