客流量预测实战:特征工程、LightGBM+LSTM融合与避坑指南

发布时间:2026/10/23 3:08:55
客流量预测实战:特征工程、LightGBM+LSTM融合与避坑指南 简介IJCAI2017天池大赛客流量预测项目实战资源针对Koubei.com客户流量预测任务提供排名第9的完整解决方案。资源全程使用Python实现覆盖数据清洗、时间序列特征构建、多模型训练与调优等竞赛核心环节并附有算法说明文档和英文汇报PPT适合具备一定机器学习基础、希望参与数据竞赛或实践预测分析的读者。压缩包共17个文件以12个py源码脚本为主包括天气、区域、星期等特征处理脚本以及主模型、预测、可视化模块另含docx算法说明、pptx方案展示、README及gitattributes等文件整体大小5.05MB目录结构清晰便于按模块研读。目前已有198人学习通过源码和文档可还原第9名方案的赛题思路、基于SMAPE的评估方式及实际调参技巧对从零搭建时间序列预测项目或备战类似竞赛的开发者具有实用参考价值。1. 从 IJCAI 2017 客流量预测说起一份 rank 9th 的资源包是什么做本地生活服务的人都知道门店客流是个极其折磨人的变量。同一家奶茶店周一人少得能养老周五晚上却排队到门外雨水天可能门可罗雀商场活动日又能涌进三倍的人。排班、备货、活动推广全都建立在明天大概有多少人这个假设上。这个客流量预测赛题就是给你一批脱敏后的商家历史客流和属性让你预测未来某一天的分时客流。本质上是一个典型的多变量时间序列回归问题但变量之间还混着周期、节假日、商家类别等复杂因子。本文要拆解的这份资源包是当时在赛题上拿到第 9 名的完整 Python 方案包含特征工程、树模型与深度模型融合、后处理全套代码。适合两种人一是想系统入门时序预测的 Python 开发者二是在做排班、备货、分时段运营分析的从业者。它不提供所谓的标准答案但告诉你一条经过验证、可以稳定冲到头部排名的路。2. 数据先行读懂客流量预测的时间序列结构与特征抽取2.1 数据格式与粒度从分钟级到小时级聚合这类赛题的数据通常是按较短时间粒度记录的。以本赛题为例原始记录中每个商家shop_id每天有若干条时间戳time_stamp和对应的客流量字段flow_cnt有些甚至精确到 15 分钟或 10 分钟一个点。如果直接把这么细的粒度作为建模单位会带来两个问题一是单位时间内的流量数值波动极大同一个 15 分钟段隔天可能差出好几倍模型会疲于抓噪声二是特征矩阵会膨胀到难以处理几万个商家乘以几百个时间片内存和训练时间都吃不消。常见的预处理方式是聚合成小时粒度。资源包里第一步就是做这个降粒度操作import pandas as pd # 读取原始客流数据假设字段shop_id, time_stamp, flow_cnt df pd.read_csv(data/flow_data.csv, parse_dates[time_stamp]) # 聚合成小时级保留商家的日期和小时两个维度 df[day] df[time_stamp].dt.date df[hour] df[time_stamp].dt.hour hourly df.groupby([shop_id, day, hour], as_indexFalse)[flow_cnt].sum() hourly.rename(columns{flow_cnt: flow_hour}, inplaceTrue)这段代码的逻辑很直接把时间戳拆出日期和小时两个字段然后按商家-日期-小时做求和。为什么要求和而不是取平均因为客流量是加性指标一个小时内的总客流是分片客流的总和取平均会把量纲变小导致后续峰值预测失真。参数上需要注意groupby的键顺序先用商家再日期再小时保证输出结果按维度组织后续做滞后特征时能直接基于shop_id hour分组切片。聚合到小时还有一个隐含好处一天正好 24 个点天然适配后面的周期特征构造——昨天同一小时上周同一小时这些概念都是基于小时索引的。如果你直接按天建模就丢失了日内峰谷信息排班和分时备货就无从谈起。所以资源包把小时作为所有时序特征的最小单位。2.2 基础特征时间、节假日、商家属性的编码思路有了shop_id, day, hour, flow_hour这张小时表接下来就是疯狂补特征。时间序列预测的常见误区是只把历史客流丢给模型但客流量明显受到星期、月份、节假日和商家属性的影响。资源包的特征工程分为三个模块时间特征、节假日特征、商家静态属性特征。时间特征里最基础的是星期几、日、月这些日历字段。其中星期几特别重要因为工作日和休息日的客流形态完全不同。节假日特征则要小心不能只标记当天是不是假日还要考虑节日前后一天的效应。商家属性方面每个商家有类别、区域编码等静态字段这些字段能帮模型区分商场里的餐厅和街边的便利店在客流上质的差异。代码层面资源包用这样一个函数把基础特征合并进来def build_base_features(hourly): # 时间特征 dt pd.to_datetime(hourly[day]) hourly[weekday] dt.dt.weekday # 0 表示周一 hourly[day_of_month] dt.dt.day hourly[month] dt.dt.month hourly[is_weekend] (dt.dt.weekday 5).astype(int) # 节假日特征这里用内置的节假日映射表真实场景需外部数据 holiday_map {2016-05-01: 1, 2016-05-02: 1, 2016-06-09: 1} hourly[is_holiday] hourly[day].astype(str).map(holiday_map).fillna(0).astype(int) # 商家静态属性假设 shop_info.csv 有 category 和 region_code shop_attr pd.read_csv(data/shop_info.csv) hourly hourly.merge(shop_attr, onshop_id, howleft) # 类别和区域编码为整数 from sklearn.preprocessing import LabelEncoder for col in [category, region_code]: hourly[col _id] LabelEncoder().fit_transform(hourly[col].astype(str)) return hourlyis_weekend这个特征虽然和weekday信息有重叠但树模型在做切分时直接给一个二值特征往往比从weekday里自己学出一条规则更高效。holiday_map这里只是示例实际资源包里内置了完整的节假日文件除了法定假日还包括节前节后一日的标记。LabelEncoder在类别少的时候没问题但要注意它只在训练集上 fit否则测试集出现新类别会直接抛错。资源包里的做法是先把训练和测试集拼接起来统一编码保证类别编号一致。2.3 周期特征与滑窗统计让模型看到昨天和上周基础特征只是骨架真正让时间序列模型记住历史的是滞后特征。客流量有强烈的日周期和星期周期——今天的下午 2 点客流量大概率跟昨天下午 2 点、上周下午 2 点有较强相关性。所以我们需要显式地把前 1 天、前 7 天、甚至前 28 天同一小时的客流值作为当前小时的特征。构造滞后特征有个容易翻车的细节必须先按shop_id hour分组再在同一组内做 shift而不是对整个 DataFrame 直接 shift。否则会把A 店昨天 2 点的值关联到B 店今天 2 点模型学到的是跨商家的噪声而不是时序规律。def build_lag_features(hourly): # 先排序保证时间顺序 hourly hourly.sort_values([shop_id, day, hour]).reset_index(dropTrue) # 核心按 shop_id 和 hour 分组在同一小时位上做时间偏移 g hourly.groupby([shop_id, hour]) for lag in [1, 7, 28]: hourly[fflow_lag{lag}] g[flow_hour].shift(lag) # 近 7 天同一小时的滑窗均值与标准差 w 7 hourly[flow_lag7_mean] g[flow_hour].transform( lambda x: x.shift(1).rolling(w).mean()) hourly[flow_lag7_std] g[flow_hour].transform( lambda x: x.shift(1).rolling(w).std()) return hourly参数说明shift(1)在滚动窗口里是为了排除当前值避免用当前小时的数据预测当前小时这是最隐蔽的数据泄露形式。rolling(w)的窗口设为 7正好匹配一周的周期。标准差特征也很关键它表达了这个小时平时稳不稳定如果某天突然暴雨近期标准差会变大模型就能感知到异常波动。滞后阶数选 1、7、28 而不是 1、2、3是因为日周期和星期周期是客流的主导周期。28 天近似月周期主要捕捉月末、节假日等长周期影响。如果你想试更细还可以加lag14或半个月前的值但特征过多会导致树模型过拟合历史噪声反而不稳。资源包最终保留的就是这三档滞后加上滑窗统计总共约十几个时序特征。3. 建模实战用 LightGBM 与 XGBoost 搭建基线并加入时序模型融合3.1 为什么选梯度提升树而不是纯RNN看到时序数据很多新人第一反应是上 LSTM。但在这个赛题上纯 RNN 很难打得过梯度提升树。原因有三个第一客流量预测的特征是混合型的既有时间属性又有商家静态属性树模型对异构特征天然友好Embedding 或 One-Hot 都能塞进去第二树模型对量纲不敏感不需要做归一化省去一堆预处理第三训练效率上LightGBM 几分钟跑完一轮而 LSTM 要反复调整序列长度和网络层数实验迭代速度差一个数量级。这绝不意味着深度学习没有用。资源包的做法是先用树模型把趋势和周期拟合出来再用 LSTM 去拟合树模型留下的残差——特别是峰值部分的残差。这种树模型打底 深度模型纠偏的融合思路在几届竞赛里被反复验证往往比单独使用任何一类模型都能拿到更高的精度。3.2 第一步训练集构造与交叉验证划分时序预测的交叉验证不能随便用 KFold。如果随机打乱同一商家的不同日期会同时出现在训练集和验证集模型相当于见过未来的数据验证分数会虚高得没有任何参考价值。资源包里统一使用按时间顺序滑动的交叉验证。from sklearn.model_selection import TimeSeriesSplit # X 为所有特征列y 为小时客流 X hourly[[weekday, is_weekend, is_holiday, flow_lag1, flow_lag7, flow_lag28, flow_lag7_mean, flow_lag7_std, category_id]] y hourly[flow_hour] tscv TimeSeriesSplit(n_splits5) for fold, (train_idx, valid_idx) in enumerate(tscv.split(X)): X_train, X_valid X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid y.iloc[train_idx], y.iloc[valid_idx] # 每一折训练集时间永远早于验证集绝不泄露未来 print(fFold {fold}: train_size{len(X_train)}, valid_size{len(X_valid)})TimeSeriesSplit的参数n_splits5会被解释成 5 个验证段每一折的训练集都比上一折更大。这样做的好处是模拟了真实场景——你总是拿过去去预测未来。如果你手里的数据跨度较短可以调成 3让每段验证集更长更稳如果数据充足调到 8 也行但会增加训练时间。记住验证集分数只有在这种情况下才值得相信。3.3 第二步LightGBM 参数配置与训练资源包里的主模型是 LightGBMXGBoost 作为陪跑做融合。LightGBM 对时序回归任务非常友好速度快、效果稳健。下面是经过调参后稳定使用的一组参数import lightgbm as lgb params { objective: regression, metric: mae, # 客流量评价主要看平均绝对误差 learning_rate: 0.05, num_leaves: 63, max_depth: 8, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, verbose: -1 } model_lgb lgb.train( params, lgb.Dataset(X_train, y_train), num_boost_round2000, valid_setslgb.Dataset(X_valid, y_valid), callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] )objective选回归metric选mae而不是默认的l2因为竞赛评分用的是平均绝对误差而且 MAE 比 MSE 更抗极端峰值的影响——客流预测中最怕的是模型为了少数峰值而牺牲大多数平峰时段的精度。num_leaves设为 63配合max_depth8保证树有足够的复杂度去拟合节假日等强信号又不至于过深到记忆噪声。feature_fraction和bagging_fraction都设为 0.8每次迭代随机抽样特征和样本能有效防止局部过拟合。early_stopping(50)表示验证集 MAE 连续 50 轮没有下降就停止num_boost_round2000是最大轮数上限通常实际会在 500 轮以内停下来。训练结束后可以顺手保存模型文件model_lgb.txt后面对比融合时要用。3.4 第三步引入 LSTM 做残差拟合提升峰值预测树模型拟合的是整体规律但对某些突然的客流尖峰比如节假日当天的爆发树模型的预测往往偏保守。因为树模型通过切分生成阶梯状输出很难预测连续陡峭的上升曲线。资源包的做法是把树模型的预测残差提出来交给 LSTM 去学。import numpy as np from keras.models import Sequential from keras.layers import LSTM, Dense # 用训练好的树模型预测训练集自身求残差 pred_train model_lgb.predict(X_train) residual_train y_train.values - pred_train # 构造残差序列窗口长度取 24对应 1 天的周期 def make_sequences(data, window24): X_seq, y_seq [], [] for i in range(window, len(data)): X_seq.append(data[i-window:i]) y_seq.append(data[i]) return np.array(X_seq), np.array(y_seq) X_seq, y_seq make_sequences(residual_train, window24) X_seq X_seq.reshape((X_seq.shape[0], X_seq.shape[1], 1)) model_lstm Sequential() model_lstm.add(LSTM(32, input_shape(24, 1), return_sequencesTrue)) model_lstm.add(LSTM(16)) model_lstm.add(Dense(1)) model_lstm.compile(optimizeradam, lossmae) model_lstm.fit(X_seq, y_seq, epochs10, batch_size64, validation_split0.2)注意这里 LSTM 的输入不是原始客流量而是树模型在训练样本上的残差序列。这样 LSTM 可以专注于树模型没学到的部分避免重复学习已经拟合过的趋势。window24取一天的长度让 LSTM 能感知日内周期残差的变化。两个 LSTM 层第一层 32 个单元并return_sequencesTrue第二层 16 个单元输出一个 Dense 层直接给残差预测值。预测时最终结果 树模型预测值 LSTM 残差预测值。在验证集上做残差修正时必须用树模型对验证集的预测计算残差再输入 LSTM绝不能把验证集真实值喂进去否则就是开卷考试。资源包在融合时先对树模型和 LSTM 分别做交叉验证确认两者单独不泄露才进行组合。4. 客流量预测避坑指南数据泄露、负客流与峰值脉冲的 4 条踩坑记录4.1 验证集 MAE 很低线上分数却崩了数据泄露第一次复现时我用随机 KFold 划分验证集线下 MAE 漂亮得吓人只有 12 左右。结果一提交线上分数直接滑到 25 开外。排查下来发现是数据泄露随机 KFold 会把同一天的数据既分到训练集又分到验证集模型实际上见过了待预测日的部分时段客流当然预测得准。这属于最典型的时序数据泄露。解决的办法很简单——所有划分必须按时间切而且训练集的时间上限要严格小于验证集的时间下限。资源包里直接弃用 sklearn 的 KFold改用TimeSeriesSplit同时把时间字段显式传进去确保每个验证段都是训练段之后的连续区间。从那以后我对任何时序项目的第一反应都是检查划分方式不看随机分数。4.2 时段缺失被填成 0模型低估夜间客流这个坑出在数据预处理阶段。原始客流量表不是每个商家每个小时都有记录有些商家在深夜或凌晨就没有客流数据行直接不存在有些商家因为设备问题某几个小时间断缺失。我一开始为了省事直接用fillna(0)把缺失补齐。结果训练出来的模型几乎所有深夜小时的预测值都趋近于 0但真实数据里不少酒吧和宵夜店凌晨仍有稳定客流。原因是模型把缺失当成了零客流被带偏了。正确的处理方法是区分两种缺失如果某个小时在历史中从未有过记录那么它很可能是真没客流如果只是当天缺失而历史同期有值就应该用同商家、同小时、近 7 天的中位数填充。资源包里专门写了一个填充函数对所有缺失的小时用flow_lag7_mean去填实在没有历史数据的才填 0。这样既保留了缺失判断的空间又不会让模型学到假的零值。4.3 节假日特征只算当天预测整体平移一天一开始构造节假日特征时我只把节假日集合里的日期标记为is_holiday1。验证的时候发现节假日前一天和最后一天的真实客流经常被低估。比如国庆节 10 月 1 日客流爆发但 9 月 30 日晚上的客流已经明显高于普通周五长假最后一天 10 月 7 日客流回落但 10 月 8 日周六反而因为大家返工/返校调整而客流偏低。这是典型的节假日效应溢出。解决办法是构造节前节后窗口特征把节假日的前一天和后一天也标记出来holiday_window {cur_day: 1 for cur_day in holiday_days} # 前一天覆盖 for d in holiday_days: prev_day (pd.to_datetime(d) - pd.Timedelta(days1)).strftime(%Y-%m-%d) holiday_window[prev_day] holiday_window.get(prev_day, 0) # 保留当天标记更稳妥的做法是直接用距离最近节假日的天数作为数值特征比如days_to_holiday、days_after_holiday。树模型能自己切分出假期前第 1 天假期后第 2 天这类细节模式效果比二值特征好很多。资源包最终用的就是数值型距离特征节假日效应捕捉得干净许多。4.4 随机 K 折验证分数不错但换了时间验证就不对这是一个容易被忽略的验证陷阱。即使不用 KFold你也有可能把验证集划得不对。比如按shop_id划分不同商家可以同时出现在训练和验证集但同一商家的时间序列仍然是打断的。某次我用按商家划分的方式做验证分数也不错但线上掉队。原因在于客流预测要预测的是同一批商家的未来而按商家划分等于让模型去预测一批完全没见过的新商家这和赛题设置的已知商家未来不是一个任务。正确做法是验证集必须包含与训练集相同的商家、但时间上更晚的记录也就是leave-one-time-out思路。资源包里最终确定的划分方案是把时间切成两段前百分之七十做训练后百分之三十做验证并且验证段里每个商家都至少保留一个完整周的数据。这样线下分数才跟线上趋势一致。5. 让预测更稳后处理、异常值修正与多模型加权融合5.1 后处理客流不能为负峰值保护策略模型输出是浮点数任何一个回归模型都可能在某些低谷小时预测出负值这在物理上没有意义。后处理的第一件事就是clip到 0 以上。但clip有一个副作用如果你把所有小于 0 的值都强行变成 0那模型对本来就很低的时段的偏置会更大导致那些小时被系统性高估。更稳的办法是综合历史下限把同商家同小时在过去 28 天的min值作为下限如果预测值低于这个下限就拉回到历史下限。代码实现并不复杂def post_process(pred_df, hourly): # pred_df 包含 shop_id, day, hour, pred_flow # 计算历史下限同商家同小时过去 28 天的最小非零客流 hist_min hourly.groupby([shop_id, hour])[flow_hour].transform( lambda x: x[x 0].min() ) # 下限取历史最小值和 0 的较大部分防止极小店被抬高 lower_bound np.maximum(0, hist_min * 0.5) pred_df[pred_clipped] pred_df[pred_flow].clip(lowerlower_bound)这里lower_bound取了历史最小值的一半是为了给那些偶尔爆冷门的店铺留出弹性避免强制拉高到常规最低值。峰值保护则是另一个方向如果某天的预测值超过了历史同小时最大值的 1.5 倍基本上可以判定是预测脉冲需要拉回来。但要注意节假日爆发的真实峰值很容易超过 1.5 倍所以资源包只对非节假日的异常峰值做压缩节假日当天仍保留模型的爆发性预测。5.2 加权融合LightGBM、XGBoost 与 LSTM 的权重寻优有了三个模型的预测结果最简单的融合是取平均但三个模型的偏置不一样平均会弱化各自的长处。资源包里在验证集上用网格搜索寻找最优权重。from itertools import product # lgb_pred, xgb_pred, lstm_pred 均为验证集预测 def blend_score(w1, w2, w3, y_true, p1, p2, p3): blend w1 * p1 w2 * p2 w3 * p3 return np.mean(np.abs(blend - y_true)) best_score 1e9 best_weights (1, 0, 0) for w1 in np.arange(0, 1.01, 0.05): for w2 in np.arange(0, 1.01 - w1, 0.05): w3 1 - w1 - w2 score blend_score(w1, w2, w3, y_valid, lgb_pred, xgb_pred, lstm_pred) if score best_score: best_score score best_weights (w1, w2, w3) print(fBest weights: lgb{best_weights[0]:.2f}, xgb{best_weights[1]:.2f}, lstm{best_weights[2]:.2f}, MAE{best_score:.4f})步长设为 0.05足够拿到权重的大致比例。注意w3是自动计算的因为三个权重必须和为 1。如果你有四个模型就把权重维度扩到四维但步长要加大到 0.1 否则组合数太多。资源包的最终融合结果通常是 LightGBM 权重最大接近 0.5XGBoost 次之LSTM 占 0.2 左右。LSTM 虽然单独效果一般但在残差修正上贡献独特所以哪怕只有 0.2 权重也不该丢弃。5.3 特征重要性验证用 permutation importance 确认最后保留的特征树模型自带的feature_importance依赖分裂次数容易偏向高基数类别特征不能完全反映真实预测能力。资源包在定稿前用 permutation importance 做了最后一道筛选。from sklearn.inspection import permutation_importance result permutation_importance(model_lgb, X_valid, y_valid, scoringneg_mean_absolute_error, n_repeats5, random_state42) feature_names X.columns.tolist() importance_df pd.DataFrame({ feature: feature_names, importance: result.importances_mean, std: result.importances_std }).sort_values(importance, ascendingFalse) print(importance_df)n_repeats5意味着每个特征会被打乱 5 次取平均 importance减少随机性干扰。scoring用neg_mean_absolute_error与赛题评价一致。做完这个验证后你会发现flow_lag1、flow_lag7稳居前二is_weekend和is_holiday紧随其后而region_code_id的重要性很低。这时就可以把那些重要性为负的特征剔除掉减少训练噪声。资源包最后保留的特征大约二十个剩下的全部删掉模型反而更稳。6. 收束把这份 rank 9th 方案迁移到自己的业务数据6.1 关键参数速查表参数是复现的捷径也是迁移到新数据的起点。下表汇总了资源包里最值得参考的核心设置环节参数/做法推荐值说明数据粒度小时聚合24 点/天平衡噪声与维度过高滞后阶数flow_lag[1, 7, 28]日/周/月周期滑窗统计窗口宽度7与周期匹配交叉验证TimeSeriesSplitn_splits5严格按时间切LightGBMnum_leaves63控制复杂度LightGBMmetricmae与业务评价一致LSTM 窗口残差序列长度24一个完整日周期融合权重网格搜索步长 0.05三模型和1后处理下限历史最小客流min * 0.5防负值防高估6.2 复现结果的验证流程拿到资源包后不要急着改参数。按照下面的顺序走一遍确认环境能跑通再考虑调优。第一步把数据路径改成你本地数据的格式跑通特征工程第二步跑一次TimeSeriesSplit交叉验证记录 LightGBM 的 MAE第三步训练 LSTM 残差模型叠加后看融合 MAE第四步用网格搜索融合权重确认权重稳定第五步输出预测结果并做后处理。整个流程在普通四核笔记本上大约需要两到三个小时如果你有 GPULSTM 部分会快很多。复现时如果分数和资源包标注的对不上优先检查数据处理里有没有把缺失值填成 0以及节假日日期是否对齐你所在地区的日历。6.3 从竞赛到业务排班、备货、峰值预警的落地调整竞赛模型直接搬到业务场景有两处必须改。第一特征里如果依赖了实战中拿不到的未来信息——比如节假日表是提前一年的但天气、活动安排不是——就需要把所有未来特征全部剔除否则模型上线后预测结果会退化。第二业务上关心的不仅是平均值还有峰值时段的准确度。比如排班需要用上午十点到下午两点的客流总和来决定人手备货需要晚上六点到九点的总量这些分时段的加权误差比整体 MAE 更贴近成本。资源包里的融合权重是按整体 MAE 寻优的你可以按业务需求把blend_score改成高峰时段加权 MAE重新寻优一次。这个方案最让我印象深刻的不是某个模型多厉害而是它把数据泄露和节假日溢出这些时序预测里最阴险的坑一个个排掉了。从那以后我每次拿到新的时序任务都会强制走一遍先检查验证划分是否按时间再检查缺失值是否被误当作零最后才碰模型参数。希望这套方法论能帮你少走几步弯路。本文还有配套的精品资源点击获取