简介IJCAI2017 天池大赛客流量预测 rank 9th 的完整代码与方案面向数据科学竞赛玩家及时间序列预测学习者基于口碑网商家客流数据解决未来时段用户访问量预测问题。压缩包共十七个文件以十二个 Python 脚本为核心辅以算法说明文档 docx、英文版讲解 PPT、README 和数据集说明文本整体约 5.05MB目录划分清晰便于按模块复现与二次开发。已有一百九十八人学习浏览。代码覆盖天气、位置、星期等多维特征工程提供 cluster.py、predict_type.py、Model_Plot.py 等脚本分别实现聚类分析、预测类型处理与结果可视化算法说明文档中进一步阐述了 ARIMA、LSTM 等时间序列模型及 XGBoost 集成方法的调优思路并针对缺失值处理、平滑差分、交叉验证和 SMAPE 评估指标给出了可落地的操作经验。从数据预处理到模型对比再到预测体系搭建为理解大赛 Top 方案、完善个人项目提供了详实的参考资料。1. 客流量预测为什么难做从 IJCAI2017 天池大赛第 9 名的经验说起排班排多少人、火锅店备多少份毛肚、奶茶店提前多久通知兼职到岗这些运营动作背后要的唯一数字就是明天这个时段会有多少客人。但把这个问题丢给标准时序模型的时候大多数人会翻车客流不像网流量那样平稳它叠加了工作日周末、节假日、天气、店铺商圈甚至平台活动多种节奏而且每个店铺的波动形态完全不同。这也是我当时打 IJCAI2017 天池大赛客流量预测赛的体会最终排在第 9 名的方案并没有用什么新鲜模型而是把预测对象定义、数据规约、特征组织、残差校准这条链子补完整了。这篇文章就按这条链子展开适合正在做门店流量预测、零售销量预测或者准备参加类似时序竞赛的从业者。2. 先把数据规约做对聚合粒度与样本窗口客流量预测的基本链条是数据到特征再到模型但大多数参赛方案死在了第一步把 15 分钟粒度的原始数据直接扔给模型或者反过来把所有时间全量聚合到天级。原始数据的时间片段越细噪声占比越高小店铺在半小时内可能只成交一两单这种数据上训练出来的模型预测值会被极端值拽着走。所以在碰任何模型之前先想清楚一件事你到底在预测什么粒度、用什么误差尺度来评价预测结果。2.1 预测目标与评估指标先弄清在优化什么比赛数据来自本地生活平台的商家流水记录每个店铺在连续时间片上会有一条表示客流或成交强度的数值字段。拿到数据后最关键的问题不是用什么模型而是预测哪个量、用什么误差来算分。常见评估方式有 MAPE 和 MAE 两类。MAPE 对低基数额外敏感某店铺某个时段流量为 0模型只要输出一个非零值误差就变成无穷大反过来流量只有个位数的店铺误差会被量级放大。我通常会先按流量量级把店铺分成几个桶在每个桶里单独看误差表现这比只看全局平均分更能定位是哪一类店铺拖了后腿。样本权重也要提前想好。如果评估指标对低量级店铺惩罚更大那训练损失就需要做适配。常见做法是给样本加一个与店铺日平均流量成反比的权重或者改用 Huber 损失来减轻离群样本的影响。这个决策要写在数据验证的第一步而不是等模型训练完再补救否则后期你会发现自己一直在调参却说不清模型为什么对某些店铺系统性偏移。2.2 15 分钟聚合到小时缓解稀疏与隐式平滑原始数据一般是 15 分钟一条的小片段一天 96 个点。对小店铺来说15 分钟窗口里大量为 0这些零值有两个坏处一是目标分布变成大量 0 加少量正数模型很难学到稳定规律二是在按小时或按天评估时15 分钟级的局部异常会干扰特征提取。我一般的做法是先把数据聚合到小时级保留日内峰值形态同时把零值比例压下来。下面是聚合代码。import pandas as pd import numpy as np # 读取原始数据字段按常见竞赛格式假设: shop_id, timestamp(15min), flow df pd.read_csv(flow_sample.csv) df[timestamp] pd.to_datetime(df[timestamp]) # 按店铺 小时聚合flow 取求和和均值 hourly ( df.groupby([shop_id, pd.Grouper(keytimestamp, freqH)])[flow] .agg([sum, mean, count]) .reset_index() ) hourly.columns [shop_id, time, flow_sum, flow_mean, sample_count]聚合用 sum 还是 mean 要看你最终的预测目标。如果业务侧要的是这个时段总到店人数用 sum如果要的是这个时段店铺的活跃水平用 mean。count 这一列用来诊断数据完整性某小时 sample_count 显著少于 4假设原始是 15 分钟粒度说明这段时间存在缺采样需要标记出来而不是当作正常缺失。聚合到小时只是第一步。店铺营业时间通常不是全天 24 小时比如很多餐饮店只在 11:00 到 22:00 活跃。直接把 0 点 到 6 点的全零时段一起喂给模型会增加特征稀疏度还会让树模型在这些时段上学习到无意义的分裂。我一般会先按店铺的活跃时间裁剪样本窗口只保留营业区间预测时也只输出该区间内的结果这样整个模型不需要处理永远为 0的时段。2.3 店铺分桶与异常窗口处理店铺之间的量级差异极大高流量店铺一天几千人低流量店铺一天不到几十人。所有店铺丢进同一个模型时树模型会优先去拟合高流量店铺因为它们的绝对误差贡献最大低流量店铺被系统性低估。这可以通过店铺分桶来规避——分桶不改变模型输入但用于分别评估和选择特征组合。分桶判断依据按历史日流量均值特征策略模型策略大流量均值 top 20%增加小时级滞后特征、日内峰值特征单独训练关注日内峰值拟合中流量均值 20%60%时序特征与均值先验各占一半主训练集主力小流量均值后 40%加强均值先验、减少滞后阶数全局模型 幅度纠正极低流量日流量均值小于 5以周均值插补为主通用模型兜底不做细粒度预测异常窗口是另一个隐蔽的坑。店铺临时停业、平台接口故障都会造成某一天或某几个小时流量归 0。这种 0 是缺失值而不是真实值如果直接留在特征序列里滞后特征会把 0 当作正常模式学习未来遇到类似条件时会输出偏低的结果。我的做法是识别连续为 0 超过 N 个时间片的窗口打上异常标记并在这个窗口里用前后同均值填充同时在特征矩阵里加一列is_outage来保留缺失信息。店铺层面的归一化也需要在数据规约阶段完成。简单做法是给每个店铺的流量除以它的历史均值让特征表示相对水平。但要注意标签如果是绝对流量特征归一化而标签不归一化树模型的分裂阈值会被不同店铺的量级干扰所以更稳妥的做法是特征层面做店铺均值中心化标签保留原始量纲把所有店铺样本放进一个模型训练但在验证时按店铺分桶评估。3. 特征体系设计把时间模式拆成模型能用的形式客流量预测的特征工程核心不是堆特征数量而是把时间序列里天然存在的多重周期拆开。客流数据有三层周期性日内峰谷、周内工作日与周末差异、节假日与大型活动的脉冲式冲击。这三层周期叠在一起如果只用时间戳数值建模树模型要自己在数据里挖这些规律效率低而且容易过拟合。显式构造出这些特征之后模型才能在有限深度内找到有效分裂点。3.1 多周期时间特征周、日、节假日时间特征是这一类竞赛里最重要的特征组比店铺编码、距离类特征都更直接。常见做法是从时间戳中拆出小时、星期几、是否周末、是否工作日、是否节假日以及它们两两交叉的维度。比如星期几和小时的交叉项比单独两个字段更有区分度因为周五晚上和周日晚上的流量模式完全不同。节假日不是简单一个布尔值就够了。节前和节后通常有持续的扰动比如五一假期前一天的下午很多店会出现一个明显的小高峰假期的最后一天反而回落。常见做法是给每个样本算距离最近假日的天数和节前节后方向两个连续特征让模型可以学习到临近效应。同时本地生活平台的促销或补贴活动会产生类似节假日的脉冲这类活动日期在训练集里如果存在也要显式标记出来。我给一个特征构造的框架这个框架在比赛里用了决赛阶段也没有删掉任何一组特征因为它们各自都在验证集上带来了正向贡献。def build_time_features(df): t df[time] df[hour] t.dt.hour df[weekday] t.dt.weekday df[is_weekend] (df[weekday] 5).astype(int) df[is_workday] ((df[weekday] 5) (df[is_holiday] 0)).astype(int) df[hour_weekday] df[hour].astype(str) _ df[weekday].astype(str) df[days_to_holiday] (df[holiday_start] - t.dt.normalize()).dt.days # 节前为负、节后为正方向信息很有用 df[holiday_direction] np.sign(df[days_to_holiday].fillna(0)) return df这段代码里 hour_weekday 是类别特征后续可以直接给 LightGBM 的 categorical_feature 参数使用。days_to_holiday 要小心边界如果样本发生在两个节日中间需要取最近的一个假日来计算差值而不是简单地把所有假日当成同一天处理。我在初赛阶段犯过这个错后来统一改用前后最近假日距离双特征之后验证集上的节假日窗口误差明显下降。3.2 店铺个性化特征量级、稳定性与近期趋势店铺之间的差异光靠一个 shop_id 让模型去学是低效的。显式构造店铺维度的统计特征能帮助模型在小样本条件下快速泛化。常用的是三类历史均值代表量级、历史标准差代表稳定性、最近 N 天的趋势代表当前所处周期。# 按店铺分组计算长期统计量 shop_stats data.groupby(shop_id)[flow].agg( shop_meanmean, shop_stdstd, shop_cvlambda x: x.std() / (x.mean() 1e-3), ).reset_index() # 与原始数据合并 data data.merge(shop_stats, onshop_id, howleft) # 近期趋势: 最近7天均值相对历史均值的偏移 recent data[data[time] cutoff].groupby(shop_id)[flow].mean().rename(recent_mean) data data.merge(recent, onshop_id, howleft) data[trend_offset] data[recent_mean] / (data[shop_mean] 1e-3)变异系数 shop_cv 这个特征容易被忽略但它很关键一个 CV 为 0.3 的稳定店铺和一个 CV 为 1.2 的波动店铺即使均值相同预测策略也完全不同。模型在分裂时如果能直接看到这个店铺的波动有多大就不会把均值型的规律套在波动型店铺上。trend_offset 的含义是最近 7 天的平均流量相对全历史平均水平是高了还是低了。这个特征能够捕捉店铺的新开业期、装修后重新营业、周边修路这类中周期变化。但这些近期窗口类特征要注意时间泄漏计算近期均值时只能使用预测点之前的数据不能用未来信息。3.3 滞后特征与差分给模型装上序列记忆滞后特征是时序预测的标配但在店铺粒度的数据上有一个最常见的错误跨店铺做 shift。不同店铺的序列是独立的如果整体 DataFrame 直接 shift上一个时刻的样本可能是另一家店铺的模型会学到一个毫无意义的上家店流量。正确做法是 groupby 之后再 shift。# 先按店铺排好时间顺序再做滚动特征 data data.sort_values([shop_id, time]).reset_index(dropTrue) grp data.groupby(shop_id)[flow] data[flow_lag1] grp.shift(1) data[flow_lag7] grp.shift(7) data[flow_lag14] grp.shift(14) data[flow_ma7] grp.transform(lambda x: x.rolling(7, min_periods1).mean()) data[flow_std7] grp.transform(lambda x: x.rolling(7, min_periods1).std()) data[flow_min7] grp.transform(lambda x: x.rolling(7, min_periods1).min()) data[flow_max7] grp.transform(lambda x: x.rolling(7, min_periods1).max())lag1 捕获的是昨天同一时刻的水平lag7 捕获的是上周同一天同小时的水平lag14 用于捕获更长期的小趋势。ma7 是最近 7 天的均值std7 是波动度min/max 用于标记异常峰值。滚动窗口的窗口长度选择是有讲究的如果是按小时预测7 天的滚动均值大致对应 7×营业时长个样本足够平滑日内的周期波动同时不至于拖入太长的历史趋势。滞后特征在高流量大店上非常有效但在小流量店铺上会引入噪声因为原始值本来就波动剧烈。我的处理方式是用小流量的滞后特征时先对 flow 做一次 3 小时滚动平均再构造 lag这样可以减少单点噪声的影响。这个细节未必会对全局分数产生巨大变化但在小流量店铺桶里的验证集 MAE 有明显改善。4. 模型选型与融合LightGBM 为主线后处理补残差客流量预测任务里主流方案几乎是树模型一统天下。原因是这种表格型时间特征场景里样本量中等、特征含有大量类别和缺失值树模型对特征尺度和缺失值的容忍度远优于深度学习。在打这个比赛的过程中我主要用的是 LightGBM偶尔用 XGBoost 做对照最后再按店铺残差做后处理校准。差异化的分数提升更多来自后处理和特征组织而不是换了更高级的模型。4.1 先跑一个简单基线理解数据的可预测性在堆特征之前先做一个最简单的基线用最近 7 天同时段的平均值作为预测值。这个基线的误差水平告诉你数据的噪声底在哪里如果连历史均值都打不过说明特征或目标定义出了问题。# 按 shop_id hour weekday 分组用前7天同一时段均值预测 base_train data[data[time] test_start] pred {} for (shop, hour, wd), grp in base_train.groupby([shop_id, hour, weekday]): pred[(shop, hour, wd)] grp[flow].tail(7).mean() data[baseline_pred] data.apply( lambda r: pred.get((r[shop_id], r[hour], r[weekday]), r[flow]), axis1 )这个基线的效果很直观高流量店铺表现尚可因为日均值本身相对稳定波动大的中流量店铺误差会比较大极低流量店铺的预测几乎是错的因为历史均值本身没有意义。基线的主要价值是把数据里能预测的部分和噪声主导的部分分开——前者交给特征和模型去拟合后者就要考虑做幅度压缩或直接输出区间。4.2 LightGBM 核心参数与训练流程回归任务要盯 MAELightGBM 适合这个场景的核心原因是它对大量类别特征 少量数值特征 缺失值的表格数据非常友好而且训练速度快可以快速迭代特征。下面这个参数组合是我在这个比赛中用下来比较稳的一组也是常见的竞赛通用参数。import lightgbm as lgb features [c for c in data.columns if c.startswith(f_)] X data[features] y data[target] # 时序切分不能用随机切分 split_idx int(len(data) * 0.8) X_train, X_valid X.iloc[:split_idx], X.iloc[split_idx:] y_train, y_valid y.iloc[:split_idx], y.iloc[split_idx:] params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 63, min_data_in_leaf: 30, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbose: -1, seed: 42, } model lgb.train( params, lgb.Dataset(X_train, y_train), num_boost_round1000, valid_sets[lgb.Dataset(X_valid, y_valid)], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)], )参数说明metric 用 mae 而不是默认的 l2因为客流量预测的评估指标通常更接近 MAEL2 会对高流量店铺的离群值产生过度惩罚导致模型为了降低大店误差而牺牲小店。num_leaves63 在这个特征规模下是够用的继续增大到 127 可以在验证集上看到轻微过拟合min_data_in_leaf 这个参数要重点调它控制叶子节点的最少样本数对噪声大的时序数据来说调高到 50100 往往比找 num_leaves 更有效。feature_fraction 和 bagging_fraction 我习惯都设在 0.8能显著提升泛化能力。还需要强调一点这里的时序切分只是粗略的 80/20 划分。真实比赛里验证方式还要更严格第 5 章会专门讲这个问题。训练时可以直接把 shop_id 当作特征或者作为 categorical_feature 传入让模型学习店铺本身的基准水平。4.3 残差分析与后处理修正模型输出不是最终答案树模型的回归输出本质上是训练集目标的加权平均这导致一个系统性现象对于极端波动的店铺和极端时间段预测值会向均值收缩——高峰期被低估低谷期被高估。这不是模型的 bug而是回归目标函数的固有倾向。所以拿到模型的原始预测之后还要做一层后处理。# 训练集上的残差 valid_pred model.predict(X_valid, num_iterationmodel.best_iteration) resid y_valid.values - valid_pred # 按店铺和星期几分组统计残差的p50分位数作为偏差修正 calib_df pd.DataFrame({ shop_id: X_valid[f_shop_id].values, weekday: X_valid[f_weekday].values, resid: resid, }) bias_table calib_df.groupby([shop_id, weekday])[resid].median().reset_index() bias_table.columns [shop_id, weekday, bias] test_pred_df pd.DataFrame({ shop_id: X_test[f_shop_id].values, weekday: X_test[f_weekday].values, raw_pred: test_raw_pred, }) final_pred test_pred_df.merge(bias_table, on[shop_id, weekday], howleft) final_pred[pred] final_pred[raw_pred] final_pred[bias].fillna(0)这个残差校准的思路是对每个店铺在每个星期几统计模型预测值与真实值之间的系统偏差然后把偏差加回到预测值上。如果某店铺在周三总是被低估 20模型自身无法修正但后处理可以直接把它加回来。这里用中位数而不是均值是为了避免个别极端样本把偏差估计拉偏。后处理的另一层是峰值修正。很多店铺的午餐高峰和晚餐高峰是明显不同的模型往往在两个高峰之间取了一个平均水平的预测。我按小时是否属于高峰时段单独计算残差修正效果比统一的店铺级修正更好。# 按店铺星期几是否高峰时段分组修正 calib_df[is_peak] calib_df[hour].isin([11, 12, 13, 17, 18, 19]) bias_table calib_df.groupby([shop_id, weekday, is_peak])[resid].median()这种残差后处理本质上是在利用模型残差中残留的规律性信息。树模型拟合的是全局结构残差里的店铺个性信息它没有完全捕捉到通过分组统计再喂回去就相当于给模型做了一次微调。我当时决赛阶段的有效提升一大半来自这类后处理而不是模型本身的调参。5. 避坑指南数据泄漏、店铺偏移、节假日伪规律与线上掉分从数据处理到提交流程这中间处处有坑。以下是四个我亲身踩过、或者在复盘时确认会对结果造成实际伤害的问题每条都写成现象 → 原因 → 解决的方式方便你对照排查。5.1 现象随机 K 折交叉验证分数很高提交后排名却大幅下滑原因时间序列数据里有严重的自相关性。相邻时刻的样本特征和标签高度相似随机切分会把同一家店邻近时间的数据同时分到训练集和验证集相当于让模型背答案。验证集里出现的模式训练集里已经见过几乎一模一样的样本所以分数虚高。解决统一改用时间序列切分。简单做法是按时间点排序后以某个日期为界切分训练/验证保证验证集中的所有样本在时间上晚于训练集。更严谨的做法是用 TimeSeriesSplit 做多折滚动验证每一折只使用该折之前的数据训练。我在比赛中后段全部换成了滚动验证分数可比性立刻恢复。5.2 现象高流量店铺预测得准中低流量店铺误差贡献了总误差的 70%原因树模型回归在默认损失下对大误差样本的惩罚更大而大流量店铺的绝对误差天然就大。模型把主要容量花在了拟合大流量店铺上中低流量店铺因为绝对误差小、梯度贡献弱被模型忽略。这不是模型的问题是目标函数与业务需求错配的结果。解决两招并用。第一在训练时给样本加权重权重大致与店铺流量量级成反比第二分桶训练或者在后处理阶段做分桶校准。我最终采取的是全局模型 分桶残差校准的组合比完全分开训练更稳因为全局模型能借用大流量店铺学到的时间模式。5.3 现象模型在节假日预测上整体偏低且节后第一天严重失真原因节假日样本在全年数据里占比很小模型训练时几乎没有足够的样本学到节假日与平日不同的流量规律。同时节假日通常和高流量、高峰值绑定在一起模型天然的回归到均值倾向会把这些峰值压低。解决三个手段配合。第一构造距最近假日天数和节前/节后方向两个连续特征把节假日的影响从稀疏的布尔值变成平滑的连续变量第二在损失函数里对节假日样本加权第三做独立的节假日残差校正也就是第 4 章里的分组修正逻辑把节假日的系统偏差单独统计出来。这三个手段叠加后节假日的误差基本被拉回正常水平。5.4 现象本地验证分数在涨线上却一直不涨甚至有时候下线涨线上跌原因最容易忽略的问题是验证集的构造方式与线上评估不一致。比赛的真实评估往往是跨天的多时间点预测比如一次性预测未来 7 天而本地验证如果只做单日滚动预测两者的误差结构完全不同。另一个常见原因是特征里的滞后值在线上预测时取不到真实值——线上环境里昨天的流量是通过模型预测得到的误差会顺着滞后链传递。解决在本地完整模拟线上流程每一轮预测都用上一轮的预测值而不是真实值来构建滞后特征再计算误差。这种方法叫自回归式滚动验证虽然更慢但它的分数才跟线上可比。我在比赛初期的排名波动一半以上都能归因到这个问题。6. 用分位数与残差校准逼近第 9 名最后一公里的提升做到第 4 章那些步骤之后整体框架已经完整但排名还停在几十名开外。复盘时发现剩余的空间几乎全部集中在两个地方模型输出去的是期望值而不是条件分位数以及残差校准时分组粒度不够细。先解决第一个问题。回归模型输出的预测是对条件的期望但业务上你真正想要的是最可能的那个值在偏态分布下两者并不重合。客流数据尤其是高峰时段呈右偏分布期望值会被高流量的少量样本拽向偏大的方向导致模型系统性高估高峰。常见做法是训练分位数模型或者对目标变量取 log1p 变换后再训练让模型拟合中位数而不是均值。# 对目标做 log1p 变换预测后 expm1 还原 y_train_log np.log1p(y_train) model_log lgb.train( params, lgb.Dataset(X_train, y_train_log), num_boost_round1000, valid_sets[lgb.Dataset(X_valid, np.log1p(y_valid))], callbacks[lgb.early_stopping(100)], ) raw_pred_log model_log.predict(X_valid, num_iterationmodel_log.best_iteration) pred np.expm1(raw_pred_log)第二个问题改进残差校准的分组粒度。之前是按店铺星期几分组后来发现同一家店铺在不同季节的流量基准完全不一样。改进做法是加入月份或季度分组让偏差表更贴近当前时段的实际情况。具体做法是把样本按店铺星期几月份三重分组来统计残差中位数虽然组内样本数量变少但时间上足够接近修正效果更好。最后还有一个值得尝试的细节对预测结果做业务约束。比如营业时段外流量必须为 0比如非营业时段预测值直接置零再比如正值的预测不允许超过该店铺历史峰值的某个倍数超过就截断。这类约束不会提升模型分数但它会让提交结果看起来更像一个正常人排班时会用的数字在业务落地时价值不亚于分数提升。这次的比赛经历让我养成一个习惯每次模型迭代先保存残差分布图再保存分数。分数会骗人残差不会——它告诉你模型在哪里产生误差、误差长什么样、是靠运气还是靠规律碰对。希望这篇笔记里能有一条对你当下的项目有用也祝你在做客流量预测时少走我走过的弯路。本文还有配套的精品资源点击获取