资讯详情

资讯详情

门店客流预测实战:特征工程、SVR与树模型对比及滚动回测

简介面向参加口碑商家客流量预测类赛题、希望复现完整机器学习流程的开发者与高年级学生这份代码数据包围绕用户支付与浏览行为、天气因素及商家经营特征展开提供从数据清洗、特征提取、模型训练到规则调整的端到端方案适合用作课程项目、竞赛复盘或算法练习底稿。包内共15个文件以11个Python脚本为核心辅以1个Java程序、1个文本说明、1个压缩数据包和1个Markdown文档整体约418KB体积轻便却覆盖数据分析、建模与结果修正等关键环节。目前已有463人学习下载说明其在同类赛题资料中具备一定参考度。读者可据此梳理多源特征构造、Lasso与Stacking等模型融合思路并借鉴按天聚合、天气判断和规则后处理的组织方式快速搭建可运行基线并开展后续调优。1. 门店客流预测这件事卡住人的往往不是模型一套标着「基于机器学习的口碑商家客流量预测」的完整代码数据真正值钱的地方不在于它用了多新的算法而在于它把从原始表到预测结果之间那些没人愿意写的脏活——字段对齐、缺失填补、滞后特征构造、时序切分——全都摆出来了。适合三类人要交课程设计的学生、要给门店排班和备货做数据支撑的运营开发、以及想拿一个完整回归项目把 SVM 和树模型放在一起对比的工程师。我见过太多人一上来就冲着调 SVR 的C和gamma去折腾两天发现模型 AUC 似的指标漂亮得离谱最后一查——把当天客流当成特征喂进去了。这类项目真正容易弄错的地方永远是特征和切分不是模型本身。2. 数据体检字段口径、缺失口径和清洗边界一份门店日粒度客流表看起来就是「门店 日期 客流」三列实际落到建模上字段会分成四类标签、门店静态属性、日级外部变量、时间戳。这四类字段的清洗策略完全不同混着处理是这个项目里最常见的第一个坑。下面按「先看清 → 再清洗 → 最后做完整性校验」的顺序走一遍。2.1 先认清字段分成哪几类拿到表别急着read_csv之后describe()一把梭先把每一列归到下面这张表里后面的所有处理都围绕这个分类展开。字段类别常见类型建模时的处理口径shop_id主键字符串分组依据不进特征date时间戳datetime切分依据 派生时间特征traffic标签整数缺失直接丢行不做插补score门店静态浮点按门店中位数补可进特征comments门店静态整数建议取对数压长尾avg_price门店静态浮点按门店中位数补category门店静态类别one-hot别用序号编码weather日级外部类别前后向填充按门店内holiday_type日级外部类别固定映射成整数promo日级外部0/1缺失当 0 处理更保守分好类之后你会发现一件事只有 traffic 是绝对不能插补的。标签缺失的行意味着这一天这家店到底来了多少人你不知道任何填法都是在给模型喂假答案模型会认真地把这个假答案学进去。而 score、avg_price 这类静态属性缺失补一个值损失很小因为它本来就长期稳定。2.2 读入与三张体检表import pandas as pd import numpy as np # parse_dates 在读入阶段就解析掉日期后面不用每处都写 to_datetime df pd.read_csv(data/shop_traffic.csv, parse_dates[date]) # 表头统一小写去空格避免多人导出时列名大小写不一致 df.columns [c.strip().lower() for c in df.columns] # 体检一类型 print(df.dtypes) # 体检二缺失比例超过 30% 的列要单独决策别无脑填 miss df.isna().mean().sort_values(ascendingFalse) print(miss[miss 0]) # 体检三同一门店同一天只应有一条记录 dup df.duplicated(subset[shop_id, date]).sum() print(重复行数:, dup)三个体检各有含义。dtypes里如果traffic是 object说明列里混了「暂无」「-」之类的字符串得先pd.to_numeric(..., errorscoerce)转成数值再看缺失。缺失比例是决策依据低于 5% 的列随便补5%~30% 要按业务含义补超过 30% 的列我一般直接不用宁可少一个特征也不引一堆噪声。重复行则要看来源——如果是同一门店同一天有两条不同来源的记录得先确认哪条权威如果是导出时重复追加直接drop_duplicates保留第一条。参数上parse_dates接受列名列表日期格式不标准时比如2023/1/5和2023-01-05混排要显式传date_formatmixed或者读进来之后统一pd.to_datetime(..., errorscoerce)再检查有多少变成了 NaT。subset明确指定去重键比全列去重安全得多因为全列去重会把「同一门店同一天但评论数更新了」的两条都留下。2.3 缺失、异常与门店完整性# 标签缺失的行直接丢这一行不承担任何训练价值 df df.dropna(subset[traffic]).copy() # 静态属性按门店自身中位数补避免跨店污染大店均值会带偏小店 for col in [score, avg_price, comments]: df[col] df.groupby(shop_id)[col].transform( lambda s: s.fillna(s.median())) df[col] df[col].fillna(df[col].median()) # 整店全缺时的兜底 # 日级外部变量按门店内前后向填充保证时间连续性 df df.sort_values([shop_id, date]).reset_index(dropTrue) for col in [weather, holiday_type, promo]: df[col] df.groupby(shop_id)[col].transform(lambda s: s.ffill().bfill()) # 异常值用分位数截断而不是删除保留样本量 lo, hi df[traffic].quantile([0.005, 0.995]) df[traffic] df[traffic].clip(lo, hi)这段代码有三个决定值得说清楚。第一静态属性用门店内中位数而不是全局均值因为评分和人均消费的店间差异极大拿全表均值去填一家小店等于凭空给它安了一个「中高端门店」的身份模型会照着这个假身份预测客流。第二ffill().bfill()只在门店内做不能跨门店否则 A 店最后一天的天气会填到 B 店第一天去。第三异常值选择clip而不是删除是因为客流序列本来就有真实的大促尖峰删掉会把模型的预测上限压低导致高峰期系统性低估用 0.5% 和 99.5% 分位截断既压住了明显录错的四位数又保住了大部分尖峰。提示截断前先看一眼df[traffic].describe()如果最大值和 99% 分位差了十倍以上那大概率是录入错误或者单位不一致有人填的是月累计这时候应该先查数据源而不是靠 clip 掩盖。最后做一次门店完整性校验这一步很多人会跳过直接导致后面的滞后特征大面积为空# 每家门店的记录数、时间跨度、日期断档检查 g df.groupby(shop_id)[date] summary pd.DataFrame({ n: g.count(), start: g.min(), end: g.max(), span: (g.max() - g.min()).dt.days 1, }) summary[gap_days] summary[span] - summary[n] # 断档天数 print(summary.sort_values(gap_days, ascendingFalse).head(10))gap_days不为 0 说明这家店中间有日子没数据。少量断档可以在构造滞后特征时自然变成 NaN训练时按行丢弃断档超过三成的门店我建议整店剔除因为「上周同日客流」这种特征对它来说根本算不出来留着只会污染统计。3. 特征工程时间、滞后与外部变量三件套回归模型本身不会「理解」日期它只认数字。一份只有shop_id / date / traffic的表喂给任何模型都跑不出有意义的结果中间必须有一层把时间翻译成特征的工序。这一章是整套代码里最值得反复看的部分也是复现时最容易写错的地方。3.1 时间特征把日期拆成模型能吃的数字def add_time_features(df): d df[date].dt df[weekday] d.weekday # 0周一6周日 df[is_weekend] (d.weekday() 5).astype(int) df[day] d.day # 月初月末效应 df[month] d.month # 季节效应 df[dayofyear] d.dayofyear # 连续时间轴捕捉长期趋势 df[is_month_start] (d.day 3).astype(int) df[is_month_end] (d.day 28).astype(int) return df df add_time_features(df)这里有个取舍weekday用的是序号 0~6模型会默认认为周三2和周四3的距离比周一0和周日6近可现实中周末和周一的行为模式往往更接近。如果发现模型在周末预测上偏差明显把weekday换成 7 列 one-hot或者加一列is_sunday通常能拉回来一部分。dayofyear是给树模型用的让它能沿着时间轴做分段切分捕捉「这家店从 5 月开始客流整体上了一个台阶」这种趋势变化线性模型和 SVR 对这一列不敏感可以不加。is_month_start和is_month_end这两列来自一个很朴素的业务观察发薪日前后、月初月末的到店行为差异明显。这种特征不增加任何数据依赖纯从日期派生属于「加了不亏」的类型唯一的代价是特征维度。3.2 滞后与滑窗唯一不能出错的环节滞后特征是这类项目的发动机也是最容易把未来信息漏进来的地方。def add_lag_features(df, lags(1, 2, 7, 14, 28), windows(7, 14, 28)): df df.sort_values([shop_id, date]).copy() g df.groupby(shop_id)[traffic] # 纯滞后直接取前 n 天的真实客流 for l in lags: df[flag_{l}] g.shift(l) # 滑窗统计必须先 shift(1)否则窗口里含当日真实值 标签泄漏 for w in windows: df[froll_mean_{w}] df.groupby(shop_id)[traffic].transform( lambda s: s.shift(1).rolling(w, min_periodsmax(2, w // 2)).mean()) df[froll_std_{w}] df.groupby(shop_id)[traffic].transform( lambda s: s.shift(1).rolling(w, min_periodsmax(2, w // 2)).std()) # 门店开业至今的历史均值作为“这家店基础盘子多大”的先验 df[shop_hist_mean] df.groupby(shop_id)[traffic].transform( lambda s: s.shift(1).expanding().mean()) return df df add_lag_features(df)关键在s.shift(1).rolling(w)这个顺序。如果写成s.rolling(w).mean()第 t 行的窗口覆盖的是 t-w1 到 t里面包含第 t 天的真实客流——而第 t 天的客流正是你要预测的标签。模型会瞬间「学会」答案验证集 RMSE 低到 0.3一上真实预测就崩。这个错误我在不同项目里见过至少四五次而且它不会报错代码跑得很顺指标还特别好看属于典型的黑匣子式翻车。min_periodsmax(2, w // 2)的含义是窗口内至少要有这么多非空值才算出一个结果。28 天窗口如果严格要求 28 个值那每家店前 27 天全是 NaN样本直接少一大截放宽到 14 个前期样本能保住代价是早期窗口的统计量噪声大一些。这是个经验值样本量紧张时可以放宽到w // 3。shop_hist_mean用expanding()而不是固定窗口是想让模型知道「这家店从开业到现在平均每天多少人」。新店和老店的这个值差异很大但它对预测的价值在于提供了一个基线水平让模型可以在这个基线上做加减而不是从零开始估。3.3 静态属性与外部变量# 评论数长尾严重取 log1p 之后再进模型 df[log_comments] np.log1p(df[comments]) # 品类 one-hot类别数不多时这比目标编码稳 df pd.get_dummies(df, columns[category], prefixcat, dtypeint) # 节假日、天气用固定映射不用自动编码 holiday_map {none: 0, weekend: 1, legal: 2} df[holiday_type] df[holiday_type].map(holiday_map).fillna(0).astype(int) weather_map {晴: 0, 阴: 1, 小雨: 2, 大雨: 3, 雪: 4} df[weather] df[weather].map(weather_map).fillna(0).astype(int)为什么节假日和天气不用pd.get_dummies自动展开因为线上预测时你拿到的是「明天是小雨」如果训练集里没出现过「中雨」one-hot 展开出来的列会对不上整个预测管线直接报维度错。固定映射的好处是任何没见过的取值都落到 0模型按「正常天气」处理宁可保守也不崩。这类问题在实际部署里出现频率远比想象中高。log_comments这一步值得单独说。评论数从几十到几万不等直接进 SVR 会把距离计算完全带偏SVR 基于样本间距离一个量级大的特征会主导整个核函数。取对数之后分布接近正态对距离类模型友好对树模型也无害。4. 模型训练SVR 基线到树模型的对比实验到这一步数据已经干净、特征已经齐了可以开始建模。但我建议的顺序不是「直接上最强的模型」而是先把一个简单基线跑通、把评估口径固定下来再往上叠。否则你没法判断后面调参调出来的提升是真实的还是切分方式变了导致的。4.1 切分方式决定指标可不可信from sklearn.model_selection import TimeSeriesSplit # 按时间切前 80% 训练后 20% 验证绝不随机打乱 cut df[date].quantile(0.8) train df[df[date] cut].copy() valid df[df[date] cut].copy() # 时序交叉验证的 gap 参数同样重要训练集和验证集之间要留间隔 tscv TimeSeriesSplit(n_splits4, gap7)时序数据用train_test_split(shuffleTrue)是这类项目里最隐蔽的错。随机打乱之后验证集里的某一天它的lag_1和roll_mean_7都是从训练集里算出来的真实值等于模型在验证时偷看了邻居的答案。指标会好看 30% 以上但那个数字没有任何意义。gap7的作用是在每一折的训练集末尾和验证集开头之间留出 7 天空档。因为验证集第一天的lag_7依赖训练集最后一天如果两边紧挨着这个特征在边界上仍然是「未来信息」。留一周的 gap边界效应基本可以忽略。4.2 SVR 基线标准化不是可选项from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVR from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score feat_cols [c for c in df.columns if c.startswith((lag_, roll_, cat_, log_)) or c in [weekday, is_weekend, score, avg_price, holiday_type, weather, shop_hist_mean, month, dayofyear, is_month_start, is_month_end]] X_train, y_train train[feat_cols], train[traffic] X_valid, y_valid valid[feat_cols], valid[traffic] svr Pipeline([ (scaler, StandardScaler()), # SVR 对量纲极敏感这步不能省 (model, SVR(kernelrbf, C10, gammascale, epsilon0.5)), ]) svr.fit(X_train, y_train) pred_svr svr.predict(X_valid)SVR 的三个核心参数C是惩罚系数越大越不允许训练误差容易过拟合越小越容忍偏差模型更平滑。客流数据噪声不小我一般从 1、10、100 三档试起很少超过 1000。gamma控制单个样本的影响半径越大越「只看近邻」越容易过拟合。scale是 1/(n_features × X.var())作为默认起点够用。epsilon是不敏感带宽度落在预测值 ±epsilon 内的样本不计损失。客流是整数、量级在几十到几百epsilon0.5意味着半个人以内的偏差不惩罚比较合理量级上千的门店可以放大到 5~10。StandardScaler放在Pipeline里而不是手动在外部做是为了防止「用验证集统计量做标准化」这种隐蔽泄漏——Pipeline 保证 fit 只在训练数据上发生。这是写这类项目时我强制自己遵守的习惯。4.3 树模型对照与统一的评估函数import lightgbm as lgb from sklearn.ensemble import RandomForestRegressor def report(y_true, y_pred, name): mae mean_absolute_error(y_true, y_pred) rmse mean_squared_error(y_true, y_pred) ** 0.5 # MAPE 在真实值接近 0 时会爆先过滤掉低客流样本再算 mask y_true 1 mape (abs((y_true[mask] - y_pred[mask]) / y_true[mask])).mean() print(f{name:6} | MAE{mae:6.2f} | RMSE{rmse:6.2f} f| MAPE{mape:6.2%} | R2{r2_score(y_true, y_pred):.3f}) report(y_valid, pred_svr, svr) models { rf: RandomForestRegressor(n_estimators300, max_depth12, min_samples_leaf5, n_jobs-1, random_state42), lgb: lgb.LGBMRegressor(n_estimators800, learning_rate0.05, num_leaves31, subsample0.8, colsample_bytree0.8, random_state42), } for name, m in models.items(): m.fit(X_train, y_train) report(y_valid, m.predict(X_valid), name)report里之所以要过滤y_true 1是因为 MAPE 的分母是真实值。一家新店某天只来了 1 个人模型预测 3 个人这一个样本的误差就是 200%它会把整体 MAPE 拉得完全失真。行业里通常的做法是要么过滤低客流样本要么改用 WMAPE把分母换成总客流之和后者对门店组合的整体评估更稳。树模型这边有三个参数值得关注num_leaves控制单棵树的复杂度客流数据用 31 起步够了超过 63 基本都在过拟合min_samples_leaf5RF保证叶子节点至少 5 个样本防止模型记住「某家店某一天」这种孤立点colsample_bytree0.8让每棵树只看到 80% 的特征在滞后特征高度相关lag_1 和 roll_mean_7 相关性很高的情况下能显著降低方差。跑完你大概率会看到这样的结果SVR 的 MAE 比树模型高 10%~20%但训练时间短、模型小、不需要额外依赖。如果项目只要求「跑通 指标合理」SVR 完全够用如果追求精度LightGBM 一般是这套数据上的赢家。这个对比结论本身就是这份代码最想让你看到的东西之一。4.4 指标怎么读四个数字各管一件事指标含义什么时候看典型陷阱MAE平均绝对误差最贴近业务感受对大误差不敏感RMSE均方根误差关注尖峰预测被单个极端样本带偏MAPE平均百分比误差跨门店横向比较低客流时爆炸R²解释方差比例判断模型是否比均值强时序数据上容易虚高如果 R² 是负的别急着调参先检查特征里有没有当天的traffic八成是漏了。5. 避坑与排查五条血泪记录这一章列的五条全部来自这套代码在复现过程中最容易翻车的位置。每一条我都见过有人踩包括我自己。5.1 现象验证集 RMSE 只有 0.8上线后误差翻十倍原因几乎可以锁定为时序泄漏。最常见的三种train_test_split没关掉 shuffle、滑动窗口忘了shift(1)、标准化/编码在切分前就对全量数据做了 fit。这三种的共同特征是不报错代码一路绿灯只有那个好得不像话的指标在提示你有问题。解决方式先算一遍「用历史均值直接预测」的基线 MAE如果模型指标比基线好三倍以上基本可以判定泄漏。然后把特征逐个删掉重跑找出那个一删指标就崩的特征——它往往就是泄漏源。5.2 现象lag_7这一列大面积 NaN模型只在最近两个月有效原因是门店时间序列有断档或者某家店开业不足 7 天。7 天窗口要求前 7 天连续断了就出 NaN。默认dropna()会把这些行全砍掉样本量腰斩而且砍掉的往往是新店——恰恰是你最想预测的那批。解决方式滞后特征用min_periods放宽比如 3或者对开业不足的门店单独建一个冷启动模型用同品类同城市门店的均值做先验。5.3 现象SVR 训练完预测值几乎是一条直线原因是没做标准化。avg_price是几十到几百is_weekend是 0/1log_comments是 0~10三个量级差了两个数量级RBF 核的距离计算被avg_price完全主导模型实际上只学了这一个特征输出自然趋平。解决方式StandardScaler塞进Pipeline别手动在外面做。另外 SVR 的复杂度是 O(n²)~O(n³)样本超过五万行就该考虑LinearSVR或者对训练集做分层采样否则训练时间会失控。5.4 现象weather一列在预测时抛出维度不匹配原因是训练时用get_dummies展开了天气线上来了一个训练集没出现过的取值比如「雾霾」展开后列数少一列。这类错误在本地验证时发现不了一上线就报。解决方式天气、节假日这类低频类别一律用固定字典映射 fillna(0)不要用自动编码。品类这种相对稳定的字段才用 one-hot并且把训练时的列顺序pickle下来预测时按同一顺序对齐。5.5 现象MAPE 高达 80%但 MAE 看起来很正常不是模型坏了是 MAPE 这个指标在长尾客流分布上本身就不可靠。大量低客流门店的百分比误差极大把整体均值拉爆。同时如果你的客流分布右偏严重少数大店贡献了大部分客流MAPE 会系统性地惩罚模型在大店上的表现而大店恰恰是最该预测准的。解决方式主指标用 MAE 或 WMAPEMAPE 只作为辅助参考并且限定在客流大于某个阈值的样本上算。汇报时把口径写清楚比纠结指标数值本身更重要。6. 滚动回测比单次切分更接近真实的验收方式单次「前 80% 训练、后 20% 验证」只能告诉你模型在某一个时间段上的表现而门店客流是有季节性的——如果验证集恰好落在淡季指标会好得让你误判落在春节前后又会差得让你想放弃。真正能拿去汇报的是滚动回测。def walk_forward(df, feat_cols, fit_predict, horizon14, rounds6): 按时间向前滚动每轮用历史训练预测未来 horizon 天再整体前移 dates np.sort(df[date].unique()) rows [] for i in range(rounds, 0, -1): test_start dates[-i * horizon] test_end dates[-i * horizon horizon - 1] if -i * horizon horizon - 1 0 else dates[-1] train df[df[date] test_start] test df[(df[date] test_start) (df[date] test_end)] if len(train) 500 or len(test) 0: continue pred fit_predict(train, test) rows.append({ test_from: pd.Timestamp(test_start).date(), test_to: pd.Timestamp(test_end).date(), MAE: mean_absolute_error(test[traffic], pred), WMAPE: abs(test[traffic] - pred).sum() / test[traffic].sum(), }) return pd.DataFrame(rows) def fit_svr(train, test): pipe Pipeline([(s, StandardScaler()), (m, SVR(kernelrbf, C10, gammascale, epsilon0.5))]) pipe.fit(train[feat_cols], train[traffic]) return pipe.predict(test[feat_cols]) print(walk_forward(df, feat_cols, fit_svr, horizon14, rounds6))这段代码的价值在于它输出的不是一行指标而是一张按时间段排列的表。我第一次跑出这张表的时候盯着看了很久——第一轮 MAE 是 12.4第三轮跳到 21.7第五轮又回落到 14.1。差异全部来自节假日分布第三轮正好覆盖了一个长假而那段时间的促销特征在训练集里几乎没有样本。看到这种波动正确的反应不是「取平均当结论」而是去补特征把节假日类型拆得更细、把促销力度做成数值而不是 0/1、对节假日单独训一个模型。这套资源里如果只让我保留一个进阶技巧就是这张滚动回测表——它把「模型行不行」从一个模糊判断变成了逐段的证据。horizon的取值要和实际使用场景对齐如果是给排班用通常预测未来 7 天那horizon7给备货用可能要 14 天。rounds决定回测覆盖多少个时间段太少看不出季节性太多早期数据太少训不动。6 轮 × 14 天大约覆盖三个月是我常用的配置。从那以后我每次交付这类客流预测项目都强制先把滚动回测表跑出来再谈调参——指标好看但逐段波动剧烈那只是运气不是模型。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →