简介:面向参加口碑商家客流量预测类赛题、希望复现完整机器学习流程的开发者与高年级学生,这份代码数据包围绕用户支付与浏览行为、天气因素及商家经营特征展开,提供从数据清洗、特征提取、模型训练到规则调整的端到端方案,适合用作课程项目、竞赛复盘或算法练习底稿。包内共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(ascending=False) print(miss[miss > 0]) # 体检三:同一门店同一天只应有一条记录 dup = df.duplicated(subset=["shop_id", "date"]).sum() print("重复行数:", dup)三个体检各有含义。dtypes里如果traffic是 object,说明列里混了「暂无」「-」之类的字符串,得先pd.to_numeric(..., errors="coerce")转成数值再看缺失。缺失比例是决策依据:低于 5% 的列随便补,5%~30% 要按业务含义补,超过 30% 的列我一般直接不用,宁可少一个特征也不引一堆噪声。重复行则要看来源——如果是同一门店同一天有两条不同来源的记录,得先确认哪条权威;如果是导出时重复追加,直接drop_duplicates保留第一条。
参数上,parse_dates接受列名列表,日期格式不标准时(比如2023/1/5和2023-01-05混排)要显式传date_format="mixed"或者读进来之后统一pd.to_datetime(..., errors="coerce")再检查有多少变成了 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(drop=True) 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", ascending=False).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[f"lag_{l}"] = g.shift(l) # 滑窗统计:必须先 shift(1),否则窗口里含当日真实值 = 标签泄漏 for w in windows: df[f"roll_mean_{w}"] = df.groupby("shop_id")["traffic"].transform( lambda s: s.shift(1).rolling(w, min_periods=max(2, w // 2)).mean()) df[f"roll_std_{w}"] = df.groupby("shop_id")["traffic"].transform( lambda s: s.shift(1).rolling(w, min_periods=max(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-w+1 到 t,里面包含第 t 天的真实客流——而第 t 天的客流正是你要预测的标签。模型会瞬间「学会」答案,验证集 RMSE 低到 0.3,一上真实预测就崩。这个错误我在不同项目里见过至少四五次,而且它不会报错,代码跑得很顺,指标还特别好看,属于典型的黑匣子式翻车。
min_periods=max(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"], prefix="cat", dtype=int) # 节假日、天气用固定映射,不用自动编码 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_splits=4, gap=7)时序数据用train_test_split(shuffle=True)是这类项目里最隐蔽的错。随机打乱之后,验证集里的某一天,它的lag_1和roll_mean_7都是从训练集里算出来的真实值,等于模型在验证时偷看了邻居的答案。指标会好看 30% 以上,但那个数字没有任何意义。
gap=7的作用是在每一折的训练集末尾和验证集开头之间留出 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(kernel="rbf", C=10, gamma="scale", epsilon=0.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 内的样本不计损失。客流是整数、量级在几十到几百,epsilon=0.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_estimators=300, max_depth=12, min_samples_leaf=5, n_jobs=-1, random_state=42), "lgb": lgb.LGBMRegressor(n_estimators=800, learning_rate=0.05, num_leaves=31, subsample=0.8, colsample_bytree=0.8, random_state=42), } 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_leaf=5(RF)保证叶子节点至少 5 个样本,防止模型记住「某家店某一天」这种孤立点;colsample_bytree=0.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/1,log_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 或 WMAPE,MAPE 只作为辅助参考并且限定在客流大于某个阈值的样本上算。汇报时把口径写清楚,比纠结指标数值本身更重要。
6. 滚动回测:比单次切分更接近真实的验收方式
单次「前 80% 训练、后 20% 验证」只能告诉你模型在某一个时间段上的表现,而门店客流是有季节性的——如果验证集恰好落在淡季,指标会好得让你误判;落在春节前后,又会差得让你想放弃。真正能拿去汇报的,是滚动回测。
def walk_forward(df, feat_cols, fit_predict, horizon=14, rounds=6): """按时间向前滚动:每轮用历史训练,预测未来 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(kernel="rbf", C=10, gamma="scale", epsilon=0.5))]) pipe.fit(train[feat_cols], train["traffic"]) return pipe.predict(test[feat_cols]) print(walk_forward(df, feat_cols, fit_svr, horizon=14, rounds=6))这段代码的价值在于它输出的不是一行指标,而是一张按时间段排列的表。我第一次跑出这张表的时候盯着看了很久——第一轮 MAE 是 12.4,第三轮跳到 21.7,第五轮又回落到 14.1。差异全部来自节假日分布:第三轮正好覆盖了一个长假,而那段时间的促销特征在训练集里几乎没有样本。
看到这种波动,正确的反应不是「取平均当结论」,而是去补特征:把节假日类型拆得更细、把促销力度做成数值而不是 0/1、对节假日单独训一个模型。这套资源里如果只让我保留一个进阶技巧,就是这张滚动回测表——它把「模型行不行」从一个模糊判断变成了逐段的证据。
horizon的取值要和实际使用场景对齐:如果是给排班用,通常预测未来 7 天,那horizon=7;给备货用可能要 14 天。rounds决定回测覆盖多少个时间段,太少看不出季节性,太多早期数据太少训不动。6 轮 × 14 天大约覆盖三个月,是我常用的配置。
从那以后我每次交付这类客流预测项目,都强制先把滚动回测表跑出来,再谈调参——指标好看但逐段波动剧烈,那只是运气,不是模型。希望帮到你。
本文还有配套的精品资源,点击获取