简介:面向IJCAI 2017天池客流量预测挑战赛的第九名解决方案,场景为口碑网商家客流预测,适合对时间序列分析、数据竞赛及Python建模感兴趣的开发者参考。包内共17个文件,以12个Python脚本为核心,覆盖天气、地点、周规律、预测类型等建模模块,另含算法说明文档、演示PPT与README,完整呈现从数据预处理、特征工程到模型评估的思路。压缩包约5.05MB,轻量便携,已有198人学习。通过该项目可复现多模型集成与可视化流程,掌握客流预测的实战技巧,也可迁移至零售、交通等时序预测场景,兼顾竞赛方案与工程落地。
1. 先看懂这道题:客流预测赛题为什么让排名榜每周洗牌
如果让我把 IJCAI2017 天池大赛的客流量预测赛题(Koubei.com 场景)重做一遍,我不会把时间花在反复换模型上。这个赛题的表面任务很直白:根据某本地生活平台上商铺过去一段时间的访问数据,预测未来若干天里每个商铺每天到店客流的规模,评测端看预测误差,误差越小排名越靠前。看起来是个普通的回归问题,但真正拉开榜单差距的,是样本怎么聚合、特征怎么对齐、验证怎么切分。
这类比赛有个非常典型的现象:前十名之间的差距经常不到一个百分点,而一列错位的特征、一个泄漏的节假日字段,就能让线下得分虚高、线上掉出前列。比赛期间榜单每周洗牌,洗的不是模型,是数据处理口径。店铺-日粒度的样本定义、预测窗口的起点对齐、时序特征的索引方式,任何一个环节错位,都会在排名上留下痕迹。rank 9th 这个位置意味着模型已经不是主角,工程细节才是。
下面按我复盘这类客流预测题的习惯展开:先从聚合口径和数据表讲起,再构造三类核心特征、选择模型与验证切分方案,最后给一组翻车记录和残差检查法。无论你是想复现这套流程,还是遇到同类“按时间预测量级”的业务问题,都能直接照着落地。
2. 从原始表到训练样本:聚合口径与三类特征怎么搭
2.1 先把维度对齐:样本是“店铺-天”而不是“店铺-小时”
客流类赛题的原始表结构差异很大,但核心形态趋同:一行代表某个店铺在某天的某次访问或流量记录,可能带小时、入口、页面类型等冗余列。预测目标通常是“未来每天每个店铺的客流总量”,所以建模的最小样本单元是 shop_id + date,不是 shop_id + hour,更不是一行一条明细。
第一步永远是先把原始记录聚合成日粒度表。这里要特别小心“一行到底代表一次访问,还是一个数值”:如果一行就是一位顾客的一次到店,直接 count 行数;如果有一列是流量数值,用 sum 把这天所有明细加起来。两种口径混用会让客流总数翻倍,而且线上评估不会给你任何提示,只会表现为分数整体低一截。
import pandas as pd raw = pd.read_csv("raw_records.csv", parse_dates=["date"]) # 如果同一小时有多条明细,先按 “店铺 + 日期 + 小时” 去重 raw = raw.drop_duplicates(subset=["shop_id", "date", "hour"]) # 客流按日汇总,一个店铺一天一行 daily = ( raw.groupby(["shop_id", "date"], as_index=False)["flow_value"] .sum() .rename(columns={"flow_value": "flow"}) ) daily = daily.sort_values(["shop_id", "date"]).reset_index(drop=True)这段代码做完后,daily 里每个店铺每天只有一行,flow 是当天到店客流总量。drop_duplicates 这一步看情况:如果原始表本身已经保证一行一条记录,去掉不影响;但如果存在重复上报,就必须先做,否则 sum 会把同一批客流算两遍。groupby 的第一组 key 是店铺,第二组是日期,输出行数应当接近“店铺数乘以天数”,如果明显多出,说明有店铺在某一天存在多行,聚合漏了。
2.2 用日期骨架补全缺失天:没有记录和闭店是两回事
聚合完成后会出现一个不能跳过的现象:某些店铺在某些天没有任何记录。这有两种业务含义,一种是当天真的没有客流,比如闭店、停业整顿;另一种是采集端漏了。树模型不会自动区分这两种情况,它会把这天直接“看不见”,导致后续滞后特征错位——昨天的缺失会让今天的 lag_1 变成前天,而不是 0。
我一般先用 date_range 生成完整时间骨架,把缺失天显式补出来,让每个店铺每一天都有一行。这样滞后特征的对齐关系才是稳定的。
shops = daily["shop_id"].unique() all_dates = pd.date_range(daily["date"].min(), daily["date"].max(), freq="D") skeleton = pd.MultiIndex.from_product( [shops, all_dates], names=["shop_id", "date"] ) full = daily.set_index(["shop_id", "date"]).reindex(skeleton).reset_index() full["flow"] = full["flow"].fillna(0)这里把缺失天的 flow 填成 0,对应“当天无客流”的业务含义。参数上值得注意:min 和 max 只能取训练集的实际范围,不要把测试期日期并进来;如果预测起始日和训练集结束日中间隔着一天,测试样本要从预测窗口单独生成,而不是复用训练集的最大日期。还有一种可选策略是补一个 is_missing 标志列,让模型自己决定缺失天该按 0 处理还是按空值处理,在处理长假前夜这类极端场景时更有用。
2.3 三类特征的优先级:日历、画像、历史时序
样本骨架稳定以后,特征按重要性分成三类就够了:日历特征负责刻画星期、节假日这类全局周期;画像特征负责刻画店铺本身的属性,比如商圈、类别、历史客流水平;历史时序特征负责承接近期趋势。按我的经验,优先级是时序特征大于日历特征,大于画像特征,但画像特征在冷启动场景里不可替代。
| 类别 | 典型字段 | 作用 | 注意点 |
|---|---|---|---|
| 日历 | 星期几、是否节假日、月初/月末 | 刻画客流周期性 | 节假日表必须覆盖预测期 |
| 画像 | 店铺所在商圈、所属类别、历史均值 | 初始化新店基线 | 用计数编码而不是 one-hot |
| 时序 | 前 1 天/前 7 天客流、7 天滑动均值 | 承接近期趋势 | 按 shop_id 分组对齐 |
画像特征为什么放到最后?因为同商圈同类别的店铺,客流差异可能非常大,画像只能提供一个粗粒度先验。但遇到测试集里出现训练集没见过的店铺时,滞后特征全为空,画像特征几乎是唯一能给基线的信息,所以不能省。商圈这类高基数离散字段,我推荐用计数编码:统计该商圈在训练集中出现的店铺数,作为新列,而不是展开成几百列 one-hot。这样既压缩维度,又保留了“这个商圈有多少活跃店铺”的规模信息。
3. 客流时序特征构造:滞后、滑动与周对齐的三种做法
3.1 滞后特征:用 groupby shift,不要用全局 shift
时序特征里最基础的是滞后项。客流序列是按店铺分组的,做滞后必须按 shop_id 分组,否则会把上一家店铺的值错位到下一家店铺,整列特征变成噪声。这一步错位非常隐蔽,本地训练照样收敛,但特征重要性会变得不可解释。
def add_lag_features(df, cols, lags, group_key="shop_id"): for lag in lags: for col in cols: df[f"{col}_lag_{lag}"] = ( df.groupby(group_key)[col].shift(lag) ) return df full = add_lag_features( full, cols=["flow"], lags=[1, 2, 3, 7, 14] )groupby(group_key)[col].shift(lag) 的含义是:在同一店铺内部,把当前行向上移动 lag 行,拿到 lag 天前的客流值。每个店铺的前 lag 行天然是空值,这部分怎么填非常关键。我推荐填充该店铺的历史均值,而不是填 0——一家月均客流 500 的店,lag 填 0 会让树模型学出“0 之后立刻回到几百”这种完全错误的规律。填店铺均值则保留了“这家店大概什么量级”的先验。
3.2 滑动均值与波动特征:窗口选 7 还是选 14
滑动均值刻画的是近期趋势,对强周周期数据,窗口一般取 7 或 14,因为客流周期按“周”滚动。窗口取 3 会混入星期噪声,取 30 则反应太慢,等它反映过来,趋势已经走完。
for w in [7, 14]: full[f"flow_roll_{w}"] = ( full.groupby("shop_id")["flow"] .rolling(w, min_periods=1) .mean() .reset_index(level=0, drop=True) ) full["flow_std_7"] = ( full.groupby("shop_id")["flow"] .rolling(7, min_periods=1) .std() .reset_index(level=0, drop=True) )min_periods=1 保留了早期行数不足窗口时的计算结果,避免产生大量空值,但代价是最初几天滑动均值只用了一两天的数据,噪声偏大。如果预测目标集中在后半段,可以设成 min_periods=w,让早期样本对应特征为空,由模型走缺失分支。标准差这一列常常被忽略,但它能捕捉“这家店近期客流是否突然波动”的信号,对节假日和天气异常场景很有效。
3.3 上周同日:对周周期最直接的刻画
客流量预测里最强的一类单特征,往往是“上周同一天”。星期一对标的应该也是上周一,而不是昨天。构造方法还是 shift,但滞后步长取 7 和 14:lag_7 捕获最近一周的趋势,lag_14 提供更稳定的周基准。树模型在节点分裂时会自动组合 lag_7 和 lag_14,得到类似“同比变化率”的因子,这就是周对齐特征的价值来源。
节假日会打乱周周期,这是必须处理的例外。常规做法是加两个字段:“是否节假日”和“距最近节假日天数”。树模型可以在 lag_7 之外用这两个字段识别出“今天是节后第一天”之类的状态,避免拿普通周的客流规律去套节假日周。更进一步的常见做法是构造一个“节假日打折系数”:用上一周同日的客流乘一个权重来逼近节假日客流,权重放到验证集上拟合,不要拍脑袋定。这一步放到模型调参阶段做,前期先用基础字段跑通。
4. 模型与验证设计:GBDT 基线之上,滚动窗口和多步预测怎么选
4.1 选型思路:梯度提升树打底,深度学习做对照
把样本看成“横截面回归 + 时间索引”的组合,梯度提升树是这类比赛最稳的基线。它自带缺失值处理、对单调变换不敏感、训练速度快,而且特征工程里产生的计数编码、滞后空值都能直接吃进去。我一般先用 GBDT 跑透,再决定要不要做序列模型,不会一上来就上 LSTM。
| 参数 | 常用范围 | 说明 |
|---|---|---|
| learning_rate | 0.02 - 0.1 | 调低后配合早停 |
| max_depth | 4 - 7 | 客流特征交互不深,太深容易过拟合 |
| n_estimators | 500 - 2000 | 配 early stopping 看验证分数 |
| subsample / colsample | 0.7 - 0.9 | 降低方差 |
GBDT 训练完以后,有一件事值得做:打印特征重要性,检查排序是否符合业务直觉。如果 lag_1 排第一且远高于其他,说明模型基本在学“昨天决定今天”,一旦预测窗口进入外推阶段,这种模型会非常激进;此时可以考虑砍掉 lag_1,逼模型多依赖 lag_7 和滑动均值。如果日历特征整体排不上号,大概率是节假日字段构造得太粗,只有“是否节假日”,没有“距节假日第几天”这类结构化信息。
4.2 验证切分:用滚动窗口,不要用随机 K-Fold
随机 K-Fold 会把未来时间点混进训练集,造成前向泄漏,线下分数普遍虚高,而且虚高程度每个 Fold 不一样,调参时会被误导。客流预测必须按时间切分:用较早日期的数据训练,预测较晚日期的连续多天,再滚动重复。
import numpy as np from sklearn.model_selection import TimeSeriesSplit # full 必须先按 shop_id + date 排序 tscv = TimeSeriesSplit(n_splits=5, gap=0) for train_idx, valid_idx in tscv.split(full): train = full.iloc[train_idx] valid = full.iloc[valid_idx] # 在这里完成训练与验证,观察线下分数 breakTimeSeriesSplit 按行顺序切分,因此使用前必须保证 full 已经按店铺和时间排序。它本身不感知 shop_id 边界,如果每个店铺的历史天数一致,切分是安全的;如果店铺长短不一,建议自己按 date 阈值切分,保证验证集完全落在最新日期段。gap 参数值得关注,如果训练集结束日和验证集起始日之间存在数据间隔,gap 要设成对应的天数,避免紧贴边界的样本泄漏。
4.3 多步预测的三种策略:直接多输出、递推、分天模型
预测未来多天客流,有三种常见策略,它们的本质区别是“预测时到底拿什么当输入”。
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接多输出 | 实现简单,一次推理 | 每天规律被平均 | 窗口短、资源紧 |
| 递推预测 | 特征序列完整 | 误差逐日累积 | 窗口短、懒得拆模型 |
| 分天模型 | 无累积误差,可单独调 | 训练多个模型 | 窗口长、要求稳定 |
直接多输出适合预测窗口在 7 天以内的场景,模型同时输出未来每天的客流,代价是第 1 天和第 7 天共用一套参数,细节被平均掉。递推预测用前一天的预测值作为后一天的输入,理论上最贴合真实推理过程,但误差会像滚雪球一样累积,越往后预测越偏向店铺均值。分天模型为未来每一天各训练一个模型,每个模型都只用预测起始日之前的历史真实值构造特征,不存在累积误差,但训练和推理的成本线性增长。
我的选择习惯是:窗口超过 7 天就优先分天模型;如果时间紧,先用直接多输出拿稳定基线,再用分天模型精调。分天模型还有一个天然优势:它彻底绕开了“未来第 t 天的 lag_1 没有真实值”这个坑,因为第 10 天的模型在用特征时,只索引起点前最后已知的真实客流,不把任何预测值当输入。
5. 客流量预测避坑指南:五个让分数忽高忽低的翻车点
5.1 节假日字段提前泄漏
现象:线下验证分数非常高,线上分数明显低于线下,甚至名次掉出几十位。 原因:构造“是否节假日”特征时,用了覆盖整个赛题期间的节假日表,模型在训练样本里提前看到了预测期节假日的标记。时间切分不严的验证集无法暴露这个问题,因为节假日标记对训练和验证都是可见的,模型靠它“记住”了未来。 解决:节假日表按行 join,样本只有处在某一天时才取当天的节假日标记。“距最近节假日天数”这类派生字段也要逐行独立计算,不能对全表做偏移后再切分。检查方法很简单:看训练集范围之外是否还有节假日字段的非空值。
5.2 新店铺没有任何滞后特征
现象:测试集里 shop_id 数量比训练集多,新店铺的 lag 特征全是空值,模型给它们预测出一个奇怪的统一值。 原因:只做了时序特征,没有做画像特征兜底。滞后特征天然依赖历史,对无历史店铺完全失效。 解决:训练时加 has_history 标志位,让模型知道哪些样本是“无历史店”。对无历史店铺,用“同商圈 + 同类别”组合下的历史均值填充滞后特征。如果新店占比不小,可以拆成两步:先做二分类判断这家店近期是否正常营业,再分分支预测客流。
5.3 递推预测的误差雪球
现象:第 1 天预测误差正常,第 5 天以后整体偏低,越到后面预测曲线越平,慢慢逼近店铺均值。 原因:递推预测把前一天的预测值当滞后特征输入,一个负误差被当成真实低客流传下去,树模型在这个错误输入上继续外推,误差不断放大。 解决:优先换分天模型。一定要保留递推策略的话,做“预测值回灌”:用验证集上的预测值替换真实 lag_1、lag_2 来重新训练模型,让训练分布贴近预测分布。这个回灌操作是递推预测里最值得投入的工程点,比换任何参数都有效。
5.4 提交格式错位
现象:本地一切正常,在线报行数不对,或者分数直接异常到不可理解。 原因:测试集骨架生成时漏了某个时间段,或者排序时先按 date 排、忘了按 shop_id 分组,导致日期和店铺配对错乱。
submit_len = len(submit) skeleton_len = len(skeleton) assert submit_len == skeleton_len, ( f"expected {skeleton_len}, got {submit_len}" ) assert submit.groupby("shop_id")["date"].nunique().max() == horizon_days第二行断言检查每个店铺的天数是否正好等于预测窗口长度。如果不相等,说明骨架生成阶段就漏了天,先回查 date_range。提交前把这两行硬校验留成脚本固定步骤,是我处理所有时序赛题的习惯,能挡掉大部分低级错误。
5.5 客流零膨胀与极端值
现象:样本里大量 flow=0 的天,同时还有节假日的几十倍峰值。RMSE 被大值主导,模型为了照顾峰值误差,平时预测普遍偏高。 原因:直接用原始回归目标对极值敏感,流量整体呈长尾分布,均方误差给了峰值过高的权重。 解决:拆两步走。先做“当天是否营业”的二分类,正样本定义为 flow>0 或业务标记中的营业日;再用回归模型只拟合正样本的量级。或者更简单一些,对目标做 log1p 压缩,预测后 expm1 还原。无论用哪种,最终评估指标必须回到原始量纲计算,不能拿 log 空间上的 RMSE 去对比不同模型,否则会出现线上线下方向不一致的假象。
6. 残差检查是最后的上分手段:按天、按店切分找系统偏差
6.1 画一张残差-星期矩阵,找出没有学透的星期几
模型调完以后,在最后一段验证数据上计算残差,按星期几聚合。平均残差如果只在个别星期系统性为正或为负,说明模型没有捕捉到那一天的周期模式,这通常是特征缺口,不是参数问题。
check = valid[["date", "shop_id", "flow"]].copy() check["pred"] = pred check["resid"] = check["flow"] - check["pred"] weekday_map = {0: "周一", 1: "周二", 2: "周三", 3: "周四", 4: "周五", 5: "周六", 6: "周日"} check["weekday"] = check["date"].dt.weekday.map(weekday_map) resid_by_day = ( check.groupby("weekday")["resid"] .agg(["mean", "std", "count"]) .reindex(list(weekday_map.values())) ) print(resid_by_day.round(2))如果输出里周一的 mean 是 -80、周三的 mean 是 +60,说明模型周一把客流估高了,周三又估低了。优先补的特征是“星期几与最近节假日距离”的交互项,或者固定促销日标记,把它们做成日历交互特征再训练。这一张表比 feature importance 更能定位问题,因为它是直接针对误差本身做切片,误差大的地方就是特征缺的地方。
6.2 按量级切残差,判断要不要分组建模
第二张表按店铺历史平均客流分桶:把所有店铺按训练期 mean(flow) 分成 5 档,计算每档残差均值。常见结果是:高档店铺残差绝对值大,因为客流基数大;低档店铺残差小,但相对误差可能高得离谱。如果高低档误差结构差异明显,下一步应该做分组回归或者加权损失,而不是继续调全局学习率。
我每次提交前保留一个习惯:把这两张表固定存在工作目录里,等线上分数出来后对照。如果线上和线下显示同一个问题,说明验证切分布置得稳,问题出在特征;如果方向相反,先怀疑验证切分,再看特征。进入前十名之后,拼的通常不是模型结构,而是这种系统性的残差观察。找到一处星期级别的偏置,收益往往比调一轮参数更大。这个复盘流程我至今还在用,希望帮到你。
本文还有配套的精品资源,点击获取