☰
地铁AFC客流量预测:从刷卡流水到15分钟粒度进站人数
2026/9/28 22:36:42 网站建设 项目流程

简介:这份资源面向城市交通、智能交通方向的研究人员与数据分析学习者,围绕地铁站AFC客流量预测这一典型时间序列问题,提供从数据处理到多模型对比的完整代码与实验数据。压缩包共61个文件,约7.59MB,包含10个Python脚本、41张结果图、5个指标文本、2个Excel与1个CSV数据文件,以及训练好的LSTM模型权重,覆盖数据预处理、特征工程、模型训练与可视化全流程。代码实现了LSTM、RNN、GRU、DNN、SVM、KNN六类模型的统一对比,并配有模型比较、论文图表生成与实验报告脚本,输出损失曲线、预测对比、残差分析、雷达图与仪表盘等图表,便于直接复现实验结论。目前已有48人学习,适合希望掌握客流量预测建模思路、快速搭建对比实验或撰写相关论文的读者参考。

1. 地铁站 AFC 客流量预测:从刷卡流水到 15 分钟粒度进站人数

早高峰的地铁站,闸机每分钟吐出几百条刷卡记录,这些记录背后藏着一个很实际的问题:下一小时到底会有多少人进站?AFC 系统天然记录了每一笔进出站交易,客流量预测要做的,就是把这些原始流水聚合成时间序列,再喂给模型预测未来时段的人数。这件事的价值很直接——车站可以据此调整闸机开放数量、安排站务人员、联动限流措施。适合做这个方向的人包括轨道交通运营方的数据分析岗、做智慧交通项目的算法工程师,以及手里有 AFC 数据想做预测练手的学生。完整代码和数据分享的意义在于,AFC 数据不像公开数据集那样随手可得,能拿到一份可运行的代码和脱敏样本,比自己从零搭一套省太多时间。这一章先把问题边界划清楚,后面几章再拆具体怎么做。

2. 先搞清楚 AFC 数据长什么样:字段、粒度和清洗逻辑

2.1 AFC 交易记录的典型字段结构

AFC 原始数据通常是一张交易明细表,每行代表一次刷卡动作。不同城市的地铁系统字段命名有差异,但核心字段大同小异。下面是一张典型的脱敏后 AFC 交易表结构:

字段名类型含义示例
card_idstring卡号(脱敏后)C000****1234
txn_timedatetime交易时间2024-03-15 08:23:41
station_idstring车站编码ST0231
txn_typeint交易类型(1进站 2出站)1
line_idstring线路编码L04
card_typeint卡类型(普通/学生/老年)0

这张表最关键的三个字段是txn_time、station_id和txn_type。预测进站客流量时,只需要筛选txn_type=1的记录,按车站和时间窗口做聚合。出站记录可以用来做OD分析,但那是另一个问题,本文聚焦进站量预测。

2.2 从流水到时间序列:聚合粒度怎么选

原始流水是秒级的,直接拿来建模没有意义。需要按固定时间窗口聚合。常见粒度有 5 分钟、15 分钟、30 分钟、1 小时。粒度选择直接影响预测难度和实用性:

  • 5 分钟粒度:波动极大,噪声重,适合做短时预警,但模型很难压住误差
  • 15 分钟粒度:兼顾波动特征和可预测性,是多数运营场景的默认选择
  • 1 小时粒度:平滑度高,容易预测准,但对现场调度的指导意义偏弱

我一般先用 15 分钟粒度跑通全流程,再根据实际需求调整。下面是把原始流水聚合成 15 分钟进站量的 Python 代码:

import pandas as pd # 读取原始 AFC 交易数据 df = pd.read_csv("afc_transactions.csv", parse_dates=["txn_time"]) # 只保留进站记录 df_in = df[df["txn_type"] == 1].copy() # 按车站和 15 分钟窗口聚合 df_in["time_window"] = df_in["txn_time"].dt.floor("15min") flow = ( df_in.groupby(["station_id", "time_window"]) .size() .reset_index(name="inflow") ) # 补齐缺失时间窗口(没有交易的时段补 0) full_range = pd.date_range( flow["time_window"].min(), flow["time_window"].max(), freq="15min" ) stations = flow["station_id"].unique() idx = pd.MultiIndex.from_product( [stations, full_range], names=["station_id", "time_window"] ) flow = flow.set_index(["station_id", "time_window"]).reindex(idx, fill_value=0).reset_index() flow.to_csv("afc_inflow_15min.csv", index=False) print(flow.head(10))

这段代码的逻辑分三步:先过滤进站记录,再按车站和 15 分钟窗口计数,最后用reindex补齐没有交易的时间段。补零这一步很容易被忽略,但不补的话时间序列会断档,后面做滑动窗口会出错。dt.floor("15min")是把时间戳向下取整到最近的 15 分钟边界,比如 08:23:41 会归到 08:15 这个窗口。

2.3 异常值处理:哪些记录必须剔掉

AFC 数据里有一类记录会严重干扰预测:短时间内同一张卡在同一车站反复进出。这可能是设备故障、测试卡或者乘客误刷。处理方式是设定一个最小间隔阈值,比如同一卡号 2 分钟内在同一车站有多次进站记录,只保留第一条。

# 剔除同一卡号短时间重复进站 df_in = df_in.sort_values(["card_id", "txn_time"]) df_in["gap"] = df_in.groupby("card_id")["txn_time"].diff().dt.total_seconds() df_in = df_in[(df_in["gap"].isna()) | (df_in["gap"] > 120)]

阈值 120 秒是经验值,可以根据实际数据分布调整。如果发现某车站某时段流量异常高,先查是不是设备重复上报,而不是急着调模型。

3. 特征工程:把时间戳变成模型能吃的输入

3.1 时间特征:周期性和特殊日期的编码方式

客流量预测的核心特征是时间。地铁客流有非常强的日周期和周周期规律。15 分钟粒度下,一天有 96 个时间片,一周有 672 个。特征构造要覆盖这几个维度:

  • 时刻特征:当前时间片在一天中的序号(0-95)
  • 星期特征:星期几(0-6)
  • 是否高峰:早高峰 7:00-9:00、晚高峰 17:00-19:00 标记为 1
  • 是否周末:周六周日标记为 1
  • 是否节假日:需要额外维护一张节假日表
flow["hour"] = flow["time_window"].dt.hour flow["minute"] = flow["time_window"].dt.minute flow["slot_of_day"] = flow["hour"] * 4 + flow["minute"] // 15 # 0-95 flow["day_of_week"] = flow["time_window"].dt.dayofweek flow["is_weekend"] = (flow["day_of_week"] >= 5).astype(int) flow["is_peak"] = ( ((flow["hour"] >= 7) & (flow["hour"] < 9)) | ((flow["hour"] >= 17) & (flow["hour"] < 19)) ).astype(int)

slot_of_day这个特征比直接用小时更细,能捕捉到 15 分钟级别的周期模式。is_peak和is_weekend是布尔特征,用 0/1 编码即可,不需要做独热编码,因为树模型对这类二值特征处理得很好。

3.2 滞后特征和滑动窗口:让模型看到历史

仅有时间特征不够,模型需要看到过去几个时间片的流量才能预测下一个。滞后特征是最直接的做法:

# 按车站分组后构造滞后特征 flow = flow.sort_values(["station_id", "time_window"]) for lag in [1, 2, 4, 8, 96]: flow[f"lag_{lag}"] = flow.groupby("station_id")["inflow"].shift(lag) # 滑动窗口统计 flow["rolling_mean_4"] = ( flow.groupby("station_id")["inflow"] .transform(lambda x: x.shift(1).rolling(4).mean()) ) flow["rolling_std_4"] = ( flow.groupby("station_id")["inflow"] .transform(lambda x: x.shift(1).rolling(4).std()) )

lag_1是上一个 15 分钟的流量,lag_96是前一天同一时间片的流量。rolling_mean_4是过去 1 小时的平均流量。注意shift(1)的位置——滑动窗口必须先 shift 再 rolling,否则会把当前时刻的值也算进去,造成数据泄漏。这个坑我踩过不止一次,线下指标好看,上线就崩。

3.3 车站静态特征:不同站的流量基线差异

不同车站的客流量差异巨大,换乘站和郊区站的日均进站量可能差几十倍。把车站的静态属性编码进去,能帮模型区分基线:

station_info = pd.read_csv("station_info.csv") # station_info 包含 station_id, line_count, is_transfer, area_type flow = flow.merge(station_info, on="station_id", how="left")

is_transfer标记是否换乘站,area_type标记周边用地类型(商业/住宅/混合)。这些特征不需要实时更新,一次关联即可。如果拿不到车站属性数据,至少要把station_id做目标编码或者嵌入,否则模型会把所有车站当成同一个站来学。

4. 模型选型与训练:从 LightGBM 到时序深度模型

4.1 为什么先用 LightGBM 打底

客流量预测这个任务,特征工程做到位之后,梯度提升树(LightGBM/XGBoost)的表现往往不输深度模型,而且训练快、调参直观、可解释性好。我一般先用 LightGBM 跑一个基线,确认特征和标签的管道没问题,再考虑要不要上深度模型。

import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit feature_cols = [ "slot_of_day", "day_of_week", "is_weekend", "is_peak", "lag_1", "lag_2", "lag_4", "lag_8", "lag_96", "rolling_mean_4", "rolling_std_4", "line_count", "is_transfer" ] # 时间序列切分,不能随机打乱 tscv = TimeSeriesSplit(n_splits=5) for train_idx, val_idx in tscv.split(flow): train = flow.iloc[train_idx] val = flow.iloc[val_idx] model = lgb.LGBMRegressor( n_estimators=800, learning_rate=0.05, num_leaves=63, min_child_samples=20, subsample=0.8, colsample_bytree=0.8 ) model.fit( train[feature_cols], train["inflow"], eval_set=[(val[feature_cols], val["inflow"])], eval_metric="mae", callbacks=[lgb.early_stopping(50)] )

关键参数说明:num_leaves=63控制树的复杂度,客流数据噪声大,叶子太多容易过拟合;min_child_samples=20保证每个叶子至少有 20 个样本;early_stopping(50)在验证集 50 轮不提升时停止。TimeSeriesSplit是必须的,随机切分会让未来数据泄漏到训练集,指标虚高。

4.2 时序深度模型:LSTM 和 TFT 的适用边界

如果车站数量多、数据量大、且需要建模多站之间的空间关联,可以考虑深度模型。LSTM 适合单站序列建模,Temporal Fusion Transformer(TFT)适合多变量、多步预测场景。但深度模型对数据量和调参经验要求更高,小数据集上很容易不如 LightGBM。

import torch import torch.nn as nn class LSTMForecaster(nn.Module): def __init__(self, input_dim, hidden_dim=64, num_layers=2): super().__init__() self.lstm = nn.LSTM( input_dim, hidden_dim, num_layers, batch_first=True, dropout=0.2 ) self.fc = nn.Linear(hidden_dim, 1) def forward(self, x): # x: (batch, seq_len, input_dim) out, _ = self.lstm(x) return self.fc(out[:, -1, :]) # 取最后一个时间步

LSTM 的输入需要构造成滑动窗口样本,比如用过去 8 个时间片(2 小时)预测下一个。hidden_dim=64和num_layers=2是起步配置,数据量大的话可以加到 128 和 3 层。dropout 设 0.2 是为了防止过拟合,客流数据里噪声比例不低。

4.3 评价指标:MAE、RMSE 和 WMAPE 怎么选

客流预测的评价指标不能只看 RMSE。RMSE 对大误差敏感,但客流数据里高峰时段的绝对误差天然比平峰大,RMSE 会被高峰主导。我一般同时看三个指标:

指标公式适用场景
MAEmean(y - y_hat
RMSEsqrt(mean((y - y_hat)^2))惩罚大误差,看极端情况
WMAPEsum(y - y_hat

WMAPE 在客流预测里特别有用,因为它用总流量做分母,不同规模的车站可以直接比较。如果某个站的 WMAPE 明显高于其他站,优先排查那个站的数据质量。

5. 避坑与排查:AFC 客流量预测里最容易翻车的 5 个地方

5.1 现象:线下验证 MAE 很低,上线后误差翻倍

原因:最常见的是数据泄漏。滑动窗口特征没有 shift,或者用随机切分代替时间序列切分,导致模型在训练时看到了未来信息。另一个可能是训练数据和线上数据的聚合逻辑不一致,比如线下用 15 分钟对齐,线上用自然分钟对齐。

解决:检查所有滞后特征和滑动窗口特征是否都做了shift(1);切分必须用TimeSeriesSplit或按时间点硬切;上线前用最近一周的数据做一次「模拟线上」验证,确认管道一致。

5.2 现象:节假日预测误差暴增

原因:模型没见过节假日模式。训练数据里如果节假日样本很少,树模型会把它当成普通工作日处理,而节假日的客流模式可能完全不同——早高峰消失、晚高峰延后、总量下降。

解决:把节假日标记作为特征加入,并且确保训练集覆盖至少一个完整年度的节假日。如果数据不足一年,考虑用相似日匹配的方法做后处理修正。

5.3 现象:新开通车站没有历史数据,预测完全不可用

原因:滞后特征和滑动窗口特征对新站全是空值。LightGBM 能处理缺失值,但预测结果会退化成全局均值。

解决:对新站做冷启动处理。可以用同线路、同类型车站的流量模式做迁移,或者先用车站属性特征(周边用地、换乘线路数)做一个粗粒度预测,等积累几周数据后再切换到完整特征集。

5.4 现象:模型对突发大客流(演唱会、赛事)毫无反应

原因:突发客流的模式在历史数据里没有对应样本,模型学不到。AFC 数据本身也不包含事件信息。

解决:接入外部事件日历,把大型活动标记为特征。如果没有事件数据源,至少要在预测后加一层规则:当相邻车站或同线路其他站出现异常增长时,触发人工复核。

5.5 现象:不同车站的预测误差差异巨大

原因:车站之间的流量量级和波动模式差异大,统一模型可能对某些站欠拟合。另外,某些站的 AFC 设备可能存在系统性偏差,比如漏刷、重复上报。

解决:先按流量量级把车站分层,分别训练模型或者做目标编码。对误差持续偏高的站,单独排查 AFC 设备日志,确认数据质量没问题再调模型。

6. 把预测结果用起来:滚动预测和误差监控的实操技巧

模型训练完只是第一步,真正落地要做滚动预测。每天固定时间用最新数据重新生成未来 96 个时间片(一天)的预测值,写入数据库供调度系统读取。下面是一个滚动预测的骨架:

def rolling_forecast(model, flow, station_id, horizon=96): """用最新数据滚动预测未来 horizon 个时间片""" station_data = flow[flow["station_id"] == station_id].copy() station_data = station_data.sort_values("time_window") predictions = [] for step in range(horizon): latest = station_data.iloc[-1:].copy() # 构造特征(与训练时一致) features = build_features(latest, station_data) pred = model.predict(features)[0] predictions.append(pred) # 把预测值追加到序列末尾,供下一步使用 new_row = latest.copy() new_row["time_window"] = new_row["time_window"] + pd.Timedelta("15min") new_row["inflow"] = pred station_data = pd.concat([station_data, new_row], ignore_index=True) return predictions

这段代码的核心逻辑是「预测一步、追加一步、再预测下一步」。注意build_features必须和训练时的特征构造逻辑完全一致,否则会出现训练-推理偏差。滚动预测的误差会逐步累积,预测步长越长越不准,所以实际使用中一般只取前 4 到 8 个时间片(1 到 2 小时)作为有效预测。

误差监控同样重要。我习惯每天记录实际值和预测值的偏差,按车站和时段汇总。如果某个车站连续三天 WMAPE 超过 15%,就触发告警去查数据管道。监控指标不用太复杂,一个简单的偏差表就够:

日期车站时段实际值预测值绝对误差WMAPE
03-15ST023108:00-08:15342318247.0%
03-15ST023108:15-08:30401365369.0%

这张表每天自动生成,异常行标红。时间久了会发现一些规律,比如某站在雨天误差偏大,那就把天气特征加进去。模型不是一次训练就完事,持续监控和迭代才是常态。

最后说一个我自己的习惯:每次重新训练模型之前,先把上一版模型在最近一周数据上的表现跑一遍,作为基线。新模型如果在这个基线之上没有明显提升,就不替换。这个「后悔药」机制帮我避免了好几次因为数据管道变动导致的模型退化。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询