简介:基于机器学习的口碑商家客流量预测项目完整代码包,面向天池IJCAI17竞赛学习者及对店铺客流预测感兴趣的数据挖掘工程师。项目围绕“用户-商家-天气-时间”多维数据,构建从数据清洗、特征提取、模型训练到规则修正的完整流水线,代码含Lasso回归、SVM及Stacking集成等主流方法,并配有天气数据预处理、用户浏览/支付行为分析、按天统计与最终规则调整模块,可直接运行复现赛题流程,便于验证不同建模策略的效果。包内共15个文件,以11个Python脚本为核心,分工覆盖前期分析、特征提取、建模与融合等核心环节,另含Java辅助程序、环境说明、Markdown指南及压缩的原始天气数据,整体仅418KB,轻量易部署,解压即可在Python环境中运行。目前已有460人学习下载,既适合刚接触机器学习预测任务的初学者作为完整项目范例,也适合竞赛选手借鉴特征工程、集成学习与参数调优的实际落地思路。
1. 机器学习客流量预测:口碑商家场景的完整代码与数据
做口碑商家客流量预测这件事,很多运营同学第一反应是玄学:节假日、天气、促销一叠加,客流到底能到多少,全凭经验拍脑袋。
但把近两年的历史客流、天气、促销、品类数据切好特征,交给机器学习里的 SVM 模型,预测偏差可以压到个位数。这份基于机器学习的口碑商家客流量预测资源,包含完整 Python 代码和一份可直接运行的 CSV 数据集,从特征工程、SVM 建模到参数调优、结果评估全流程都能一次跑通。
适合刚入门机器学习、想拿真实业务场景练手的人,也适合正在做商家运营数据工具的工程师直接复现改造。
2. 特征工程先行:时间特征、滞后特征与数据清洗的三个关键动作
2.1 口碑商家客流量的特征从哪来
客流量预测不是把日期丢给模型就完事。我拿到这份资源里的 koubei_flow.csv,第一件事是看列名和数据字典。数据集按商家、日期、时段记录了每日客流,同时带了一批外部变量。我把这些字段分成四类:时间特征、环境特征、运营特征、商家特征。
| 特征类别 | 字段 | 说明 |
|---|---|---|
| 时间特征 | date, time_slot, weekday, is_holiday | 捕捉周内波动与节假日效应 |
| 环境特征 | temperature, weather, precip | 天气对出门意愿的影响 |
| 运营特征 | promo, discount_rate | 促销带来的增量客流 |
| 商家特征 | category, area, avg_price | 品类与地段的基础差异 |
目标变量是 footfall,也就是某个商家在某个时段内的客流人数。这里有个容易被忽略的点:time_slot 不是普通数值,午餐、晚餐、夜宵三个时段的客流分布完全不同,如果不拆开处理,三个时段的差异会全部揉进残差里。is_holiday、promo 这类离散变量同理,直接当数值喂给 SVM 会制造出虚假的线性关系。
常见做法是离散变量做 one-hot 编码,连续变量做标准化。特征构造的原则是:宁可多给几个候选特征,也别在一开始替模型做删减。SVM 对特征数量不算敏感,真正怕的是特征里混进未来信息,这一点第 5 章专门展开。
为什么 SVR 之前必须做标准化?RBF 核计算样本距离时,量纲大的特征会主导距离。temperature 的范围是 -5 到 38,discount_rate 只有 0 到 1,如果不标准化,SVM 几乎只盯着温度看,discount_rate 等于白给。这里用 StandardScaler 做均值方差标准化,比 MinMaxScaler 更适合存在极端值的客流场景。
2.2 数据清洗:缺失值、异常值与零值处理
原始数据不可能干净。这份数据集中我遇到三个典型问题,处理方式如下。缺失值:部分商家某些时段没有客流记录,直接删除会损失样本,我按品类加时段分组,用组内中位数填充。为什么用中位数不用均值?客流量分布右偏,均值会被大促日拉高,中位数更稳。
import pandas as pd import numpy as np df = pd.read_csv('koubei_flow.csv', parse_dates=['date']) # 缺失值:按品类和时段分组,用组内中位数填充 df['footfall'] = df.groupby(['category', 'time_slot'])['footfall'] \ .transform(lambda x: x.fillna(x.median())) # 异常值:超过 99.5 分位数的客流视为异常,截断处理 cap = df['footfall'].quantile(0.995) df.loc[df['footfall'] > cap, 'footfall'] = cap # 零值标记:早市时段大量商家客流为 0,单独打标 df['is_zero_flow'] = (df['footfall'] == 0).astype(int)填充逻辑按组进行,避免全局中位数把不同品类的差异抹平。截断阈值取 99.5 分位数,是为了防住周年庆、突发活动这类极端日把模型带偏。is_zero_flow 是我额外加的标记位:如果直接把一堆零值丢给 SVM,模型会倾向输出一个折中小值,把真正有生意的日子也压低了。把零值情况显式编码成特征,等于告诉模型「这个时段本来就没客」。
处理完缺失和异常,还有一个取舍问题:是不是所有商家都进同一套模型?我的建议是先全量跑一遍基线,再按品类和时段拆开看误差分布。有些品类样本不足 200 条,放进统一模型只会贡献噪声,这种在后续迭代里单独拎出来或者直接剔除,比硬塞进模型更靠谱。
2.3 滞后特征与滚动统计:把历史变成特征
客流量是典型的时间序列。昨天来多少人、上周同一天来多少人,对今天有强参考价值,这就是滞后特征。我按 merchant_id 分组构造了滞后一天、滞后七天、滞后十四天三个特征,再加近三天滚动均值和滚动标准差。
# 按时间排序,滞后特征才不串位 df = df.sort_values(['merchant_id', 'date', 'time_slot']).reset_index(drop=True) # 滞后特征:前一天、上周同一天、两周前同一天 df['lag_1'] = df.groupby('merchant_id')['footfall'].shift(1) df['lag_7'] = df.groupby('merchant_id')['footfall'].shift(7) df['lag_14'] = df.groupby('merchant_id')['footfall'].shift(14) # 滚动统计:近 3 天均值与标准差,反映近期稳定性 df['roll_mean_3'] = df.groupby('merchant_id')['footfall'] \ .transform(lambda x: x.rolling(3, min_periods=1).mean()) df['roll_std_3'] = df.groupby('merchant_id')['footfall'] \ .transform(lambda x: x.rolling(3, min_periods=1).std().fillna(0)) # 前排没有足够历史的样本直接丢弃 df = df.dropna(subset=['lag_14']).reset_index(drop=True)三个细节要注意。第一,滞后特征必须按商家分组 shift,否则会把隔壁商家的客流串进来。第二,shift(7) 取的是上周同一位置的数据,对应「周一对比上周一」的业务习惯,而不是按自然周错位。第三,滚动标准差反映近期客流的稳定性:越稳定的商家预测把握越大,波动大的商家天然更难测。构造完滞后特征后,前排没有足够历史记录的样本直接丢弃,它们是噪音不是信息。
特征构造完之后,我习惯看一遍相关系数矩阵。lag_1 和 roll_mean_3 往往高度相关,它们和 footfall 的相关系数都在 0.7 以上。高度相关的特征不会让 SVM 崩溃,但会让 gamma 的调参结果更敏感,所以建模前我会检查有没有完全重复的列,窗口差太小的滚动均值基本可以只留一个。
3. SVM 建模与网格调参:RBF 核的 C、gamma、epsilon 怎么定
3.1 为什么选 SVM 而不是线性回归
客流量和特征之间不是线性关系。温度升高,客流先增后减;折扣加深,客流边际递减。线性回归对这种关系拟合得很吃力,决策树虽然能处理非线性,但在这份数据几百个商家、几千条样本的量级上,泛化不如 SVM 稳。SVM 用核函数把低维空间映射到高维,RBF 核能逼近任意非线性决策面,而且中小样本上对过拟合的控制比神经网络好。这是这份资源选 SVM 的核心逻辑。
我拿到代码包后第一反应是确认模型实现。train.py 里用的是 sklearn 的 SVR,kernel 默认 rbf。改项目时建议先别动核函数,把 C、gamma、epsilon 调明白再考虑其他核。三个参数的语义如下表。
| 参数 | 作用 | 搜索范围建议 |
|---|---|---|
| C | 正则化强度,越大越拟合训练集 | 0.1 到 100,按 10 倍步长 |
| gamma | RBF 影响半径,越大决策面越曲折 | 0.01 到 0.1 或 'scale' |
| epsilon | 不敏感带宽度,越大预测越平滑 | 0.01 到 0.5 |
gamma 是 RBF 核最敏感的旋钮。gamma 太大,模型只认邻近样本,决策面碎成一片,训练集拟合很好但测试集一塌糊涂;gamma 太小,决策面过于平滑,连明显的周期波动都抓不住。网格搜索时 gamma 从 0.01 起步,不要一上来就试 1。
3.2 训练脚本拆解:管道、归一化与 SVR
主训练脚本用 sklearn Pipeline 把预处理和模型串在一起,这是我认为代码包做得规范的地方。先看核心代码。
from sklearn.svm import SVR from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline from sklearn.model_selection import TimeSeriesSplit num_cols = ['temperature', 'precip', 'discount_rate', 'lag_1', 'lag_7', 'lag_14', 'roll_mean_3', 'roll_std_3'] cat_cols = ['category', 'area', 'time_slot', 'weekday', 'is_holiday', 'promo', 'is_zero_flow'] # 数值列标准化,类别列 one-hot,互不干扰 preprocessor = ColumnTransformer([ ('num', StandardScaler(), num_cols), ('cat', OneHotEncoder(handle_unknown='ignore'), cat_cols) ]) # 管道:先预处理,再进 SVR model = Pipeline([ ('prep', preprocessor), ('svr', SVR(kernel='rbf', C=1.0, gamma='scale', epsilon=0.1)) ]) # X 去掉目标列和标识列,y 为客流 X = df.drop(columns=['footfall', 'date', 'merchant_id']) y = df['footfall'] # 按时间切分:前 80% 训练,后 20% 测试 split_idx = int(len(df) * 0.8) X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:] y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:] model.fit(X_train, y_train)为什么把标准化放进管道而不是先在外面单独做?因为管道会保证网格搜索的每一折 CV 里,归一化参数只从训练折里学,不会偷看验证折的数据。这是很多人容易忽略的细节,也是后面排查数据泄漏时第一个要检查的地方。OneHotEncoder 的 handle_unknown='ignore' 是给上线准备的:如果新增了一个没见过的品类,编码器不会报错,而是全零向量,模型至少还能跑。
epsilon 决定回归管的宽度,即落在 epsilon 范围内的误差不参与损失计算。epsilon 越大,支持向量越少,预测越平滑,适合噪声大的客流数据;epsilon 太小,模型会被日常波动牵着走。0.1 这个初始值对客流这种几十到几百的量级是合理的。
3.3 网格搜索:C、gamma、epsilon 的搜索范围与结果
网格搜索用 TimeSeriesSplit 做交叉验证,这是这份代码里我认为最正确的一个决策。
from sklearn.model_selection import GridSearchCV # 时间序列切分:按顺序切 5 折,不打乱 tscv = TimeSeriesSplit(n_splits=5) param_grid = { 'svr__C': [0.1, 1, 10, 100], 'svr__gamma': [0.01, 0.05, 0.1, 'scale'], 'svr__epsilon': [0.01, 0.1, 0.5] } # 用 MAE 做评分,业务上更直观 grid = GridSearchCV(model, param_grid, cv=tscv, scoring='neg_mean_absolute_error', n_jobs=-1) grid.fit(X_train, y_train) print('best params:', grid.best_params_) print('best MAE:', -grid.best_score_)TimeSeriesSplit 按顺序切 5 折,每折用前面训练、后面验证,模拟「用过去预测未来」。如果用普通 KFold 随机切分,时序被打乱,模型提前看到未来,验证分数虚高。训练集必须按 date 排序,这是前提。
提示:TimeSeriesSplit 不会自动检查数据是否有序,使用前务必按 date 升序排序,最好加一句断言。
网格搜索在这份数据上大约跑十分钟,n_jobs=-1 会吃满所有核。如果笔记本跑不动,先把 C 的候选缩到 [1, 10],gamma 缩到 [0.05, 0.1],epsilon 固定 0.1,结果差异不大。在这份数据上,最优参数是 C=10、gamma=0.05、epsilon=0.1。注意这个结果只对这份数据集有效,换城市、换品类集合很可能要重搜。网格搜索的意义不是找万能参数,而是确认当前数据的最优区间,之后再用时间上严格靠后的测试集做最终评估。
4. 评估指标与结果解读:MAE、RMSE 之外还要看什么
4.1 三个核心指标怎么取舍
模型训练完直接看 score 是不负责任的。评估这一步决定后面所有调参方向,指标选错等于白调。我至少要确认三个指标:MAE、RMSE、R²,它们回答的问题不一样。
MAE 是平均绝对误差,单位是人,业务上最直观:预测和实际平均差多少人。RMSE 对大误差敏感,如果预测偶尔偏离几百人,RMSE 会被拉高,说明模型在极端日上崩了。R² 反映模型解释方差的比例,但客流量预测里 R² 虚高常常是滞后特征太强,不一定是模型真的强,检查方法见第 5 章。R² 还有一个使用前提:样本量要足够。几千条样本上算出的 R² 才稳定,如果只有两三百条,R² 的波动区间会很大,这时候优先看 MAE。
除这三个之外,我还会看 MAPE。小商家客流基数小,绝对误差不大,相对误差可能 50%,商家会觉得没法用;大商家绝对误差大,相对误差也许只有 10%。汇报指标时 MAE 和 MAPE 一起给,才不会被单一指标误导。
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score y_pred = grid.predict(X_test) mae = mean_absolute_error(y_test, y_pred) rmse = mean_squared_error(y_test, y_pred, squared=False) r2 = r2_score(y_test, y_pred) print(f'MAE: {mae:.2f} 人') print(f'RMSE: {rmse:.2f} 人') print(f'R2: {r2:.4f}')这里再强调一次:测试集必须和训练集在时间上严格先后分开。train_test_split 默认随机切分会把时间顺序打乱,客流量预测里绝对不要用。代码包里测试集取的是最后 30 天的数据,训练集是之前全部,这个口径是合理的,不要改成随机抽样。
4.2 预测值与实际值可视化对比
数字只说结论,图才能看出问题。我习惯把测试集的预测值和实际值画在同一张折线图上,按时间顺序排列,一眼看出模型在哪类日子翻车。
import matplotlib.pyplot as plt # 测试集是 df 的最后 len(y_test) 行 test_df = df.iloc[-len(y_test):].copy() test_df['pred'] = y_pred # 只画一个商家,避免几百个商家叠在一起 sample_merchant = test_df[test_df['merchant_id'] == 'M001'] plt.figure(figsize=(12, 5)) plt.plot(sample_merchant['date'], sample_merchant['footfall'], label='actual', linewidth=1.5) plt.plot(sample_merchant['date'], sample_merchant['pred'], label='pred', linestyle='--') plt.legend() plt.title('M001 商家客流预测 vs 实际') plt.show()画图不是为了好看,是找出 pattern。我在这份数据的图上看到两个典型现象:节假日那几天预测明显偏低,促销日预测偏高。节假日偏低,是因为训练集里节假日样本太少,模型没学会节假日的增量;促销日偏高,是因为折扣率这个特征的取值在训练后期才密集出现,模型把它当成了强信号。这两个现象指向不同的处理方向,一个是加节假日标记权重,一个是检查特征的时间分布。
对比图存下来还有个用处:拿给业务方看,比任何指标都有说服力。商家关心的是「你预测我明天来多少人」,而不是 R² 是 0.9 还是 0.95。图上预测线和中位线贴得越近,信任建立得越快。
4.3 分时段、分商家的误差分布
整体 MAE 好看不代表每个时段都好看。测试集的误差按 time_slot 拆开看。午餐样本多、规律强,MAE 通常最小;夜宵样本少、波动大,MAE 翻倍是常态。如果夜宵 MAE 是午餐的三倍以上,就该单独建模。
# 按时段分组计算 MAE test_df['abs_err'] = (test_df['footfall'] - test_df['pred']).abs() err_by_slot = test_df.groupby('time_slot')['abs_err'].mean() for slot, err in err_by_slot.items(): print(f'{slot}: MAE = {err:.2f} 人')误差按商家规模分桶看也值得做。把商家按近 90 天平均客流分成小、中、大三档,分别算 MAPE。通常小商家 MAPE 能做到 20% 以内,但大商家因为基数大、促销频繁,MAPE 反而偏高。这个结论反过来指导建模:大商家的数据应该单独加大权重,或者拆出来单独训练。我实际遇到过一家烧烤店,平时客流 30 人,预测误差 15 人,MAE 看着不大,商家那边已经觉得这模型没法用了,所以分桶评估是必须的,不是锦上添花。
5. 避坑排查:五个让客流预测翻车的实操问题
以下五个问题不是我拍脑袋编的,是拿这份代码包跑数据时实际撞上的,每一个都对应一个具体的翻车现场。前三个是会让结果直接失真的硬伤,后两个是调参阶段的典型陷阱。排查建议按编号从头到尾走一遍,大部分异常指标都能在这里找到出处。
5.1 数据泄漏:验证集 R² 高达 0.95,上线全崩
现象:训练时测试集 R² 高达 0.95,MAE 小得离谱,一上真实业务,预测值和实际差出一大截,模型看起来完全没学会规律。
原因:最常见的是滞后特征泄漏。构造 lag_1 时如果对全量数据统一 shift,测试集的 lag 特征会引用到训练集之后的真实值;另一种情况更隐蔽,StandardScaler 在切分之前就 fit 了全量数据,归一化的均值和标准差等于偷看了未来。模型在训练时见过「答案」,验证分数自然虚高。
解决:先按时间切分,再在训练集上 fit 预处理器,transform 测试集。滞后特征严格按商家分组 shift,构造完立即 dropna,确保测试集任何一行都不引用未来记录。检查方法很土但有效:把测试集第一行的 lag_1 拿出来,人工核对是不是等于前一天的真实客流。这一步是后悔药,能省下后面排查指标的半天时间。
5.2 乱序切分:同一份数据,两个结果差了一大截
现象:用 train_test_split 随机切分,验证 MAE 忽高忽低,换一次 random_state 结果就大变样,模型在别人眼里就是个黑匣子。
原因:客流量是时间序列,随机切分等于把先后顺序打散。模型在训练时见过「未来」,验证时又拿「过去」去测,本质上在测记忆而不是测预测能力,分数没有参考价值。
解决:统一用 TimeSeriesSplit,或者手动按日期索引切分。我一般会在代码开头加一行排序断言,确认 df 已经按 date 升序排列,否则直接报错不让往下跑。数据排序这件事看着基础,但它决定了后面所有时序特征的正确性,比调参重要得多。
5.3 预测值出现负数:SVR 不保证非负
现象:模型输出的预测客流出现负数,夜宵时段尤其明显,最低能到 -12 人,业务上完全没法解释。
原因:SVR 内部是一个回归面,不约束输出必须非负。夜宵时段大量样本客流为零,模型学到的回归面在这些点上被压低,外推时就穿到了零轴以下。
解决:对目标变量做 log1p 变换再训练,预测完用 expm1 还原,输出天然非负。另一种做法是拆成「是否有客流」的分类加「客流多少」的回归两个模型。简单场景先用 log1p 就够了,我给这份代码加的也是这个方案。
# 训练前对目标做 log1p 变换 y_train_log = np.log1p(y_train) grid.fit(X_train, y_train_log) # 预测后 expm1 还原 y_pred = np.expm1(grid.predict(X_test))改完重新跑第 4 章的评估代码,MAE 通常会明显下降,负值消失,图形上预测线也不再穿到零轴以下。
5.4 OneHot 编码把特征扩到上千维
现象:品类、商圈、时段全做 OneHot 之后,特征维度从 20 涨到 800 多,训练时间翻了不止一倍,验证集性能反而下降。
原因:高基数类别列被 OneHot 展开后,大量维度是稀疏的 0/1。SVM 在稀疏高维空间里更容易贴着个别样本走,把噪音也当成了规律。
解决:高基数列换用频率编码,用类别出现占比作为数值特征;或者只保留 top 20 品类做 OneHot,其余合并成「其他」。我在这个项目里把 category 换成频率编码后,特征维度砍掉一大半,MAE 几乎没有变化,训练速度快了三分之一。注意频率编码要在训练集上计算占比,再用同一个映射去 transform 测试集,不能全量统计,否则又是数据泄漏。
5.5 gamma 调太大,模型把训练集背了下来
现象:训练集 MAE 接近 0,测试集 MAE 是训练集的十倍以上,典型的过拟合脸谱。
原因:gamma 控制 RBF 核的影响半径。gamma 越大,只有距离极近的样本才会互相影响,模型逐步退化成「记住每个训练点」的查表器,对没见过的样本毫无泛化能力。
解决:网格搜索 gamma 从 0.01 起步,按 0.01、0.05、0.1、'scale' 四档搜索。如果最优 gamma 恰好落在搜索边界上,说明范围没搜到底,要继续往小扩。C 和 gamma 必须一起搜,两个参数互相牵制,单独调一个容易把另一个带偏。
6. 滚动验证与上线前检查:让模型真正经得住时间考验
6.1 用滚动验证测长期稳定性
单次训练测试只能说明模型在某一时间段的表现,说服力不够。我拿到这份代码包之后,第一件事是写了一个 walk-forward 滚动验证脚本:每 30 天重新训练一次,用训练好的模型预测后 7 天,然后滑动窗口继续。这样可以模拟模型在真实业务中持续运行半年以上的效果。
from sklearn.metrics import mean_absolute_error feature_cols = X.columns.tolist() def walk_forward_validate(df, train_days=180, pred_days=7): dates = sorted(df['date'].unique()) n = len(dates) mae_list = [] for start in range(train_days, n - pred_days, pred_days): train_dates = dates[max(0, start - train_days):start] test_dates = dates[start:start + pred_days] train_df = df[df['date'].isin(train_dates)] test_df = df[df['date'].isin(test_dates)] # 每次滚动都重新训练,模拟线上定期更新模型 grid.fit(train_df[feature_cols], np.log1p(train_df['footfall'])) pred = np.expm1(grid.predict(test_df[feature_cols])) mae_list.append(mean_absolute_error(test_df['footfall'], pred)) return np.mean(mae_list), mae_list滚动验证的价值在于暴露模型在跨季节、跨促销周期时的退化情况。如果后半段 MAE 一路走高,说明特征和参数需要随业务节奏更新,而不是训一次用一年。
6.2 上线前的数据口径检查
模型上线前,我最后强制走一遍的检查清单:一是确认实时特征和训练特征口径一致,比如 lag_7 在线上也是「上周同一天」,而不是「七天前的自然日」;二是确认预处理器的类别编码在新增商家时不会报错,OneHotEncoder 的 handle_unknown 参数必须打开;三是确认 log1p 变换在预测管道里有对应的 expm1 还原,这个坑我在生产环境踩过一次,模型跑了一个月才发现线上输出的不是真实客流口径。
从那以后,我每次做完客流预测项目,都会强制把「训练切分 → 滞后构造 → 归一化 → 模型保存 → 预测还原」这五步按顺序走一遍自查,任何一步出问题都能定位到具体环节。这份基于机器学习的口碑商家客流量预测代码包,胜在完整,从数据到评估一次跑通,省掉了我自己拼装脚本的大量时间。希望帮到你。
本文还有配套的精品资源,点击获取