☰
机器学习客流预测实战:时间序列特征工程与SVM调参避坑指南
2026/11/2 15:45:17 网站建设 项目流程

简介:面向天池IJCAI17口碑商家客流量预测赛题的完整代码与数据包,适合参加数据挖掘竞赛、做机器学习课程设计或练习特征工程与模型融合的读者使用。压缩包共15个文件,约418KB,以11个Python脚本为主体,配有Java实现、Markdown说明、txt与gz格式的天气辅助数据,覆盖数据预处理、用户支付与浏览行为分析、时间窗口统计、天气好坏判定、特征抽取、模型训练融合及后处理规则调整等环节。已有463人学习下载,说明该方案在同类赛题复现中具有一定参考价值。读者可据此了解从原始行为数据到客流量预测的完整流程,借鉴Lasso、stacking等建模思路和脚本组织方式,并直接运行代码进行结果复现与二次修改,适合作为竞赛方案参考和机器学习项目练习素材。

1. 从一份“跑不起来”的预测作业说起

带过几届做机器学习课设的学生之后,我发现一个挺反直觉的现象:真正卡住进度的,往往不是模型选型,而是数据管道和特征构造。有位同学拿着“基于机器学习的口碑商家客流量预测”这个题目找到我,代码里 SVM 写得很标准,sklearn.svm.SVR调得也不差,可程序一跑就报错,要么是日期列没法转成数值,要么是训练集和测试集按随机方式切分之后 R² 高得离谱、换个时间窗口就崩盘。客流量预测属于典型的时间序列回归问题,商家口碑评分、历史客流、节假日、天气这些字段之间的耦合关系,决定了它不能按普通表格回归的套路照搬。这份完整代码数据方案要解决的,就是把“读数据—做特征—训模型—出预测—能复现”这条链路一次性走通,适合正在交课程设计、做毕业设计,或者要给门店做一套客流排班参考的读者。

2. 需求拆解与数据管道:从原始表到可训练样本的四步流水线

2.1 先明确预测目标和评价口径

动手写代码之前,得先把“预测什么”钉死。客流量预测常见的口径有两种:一种是预测某商家在未来某一天的到店人数,属于按天粒度的回归;另一种是预测未来某时段(比如午市、晚市)的客流区间。这份方案按按天粒度回归来做,目标变量取每日客流计数,评价指标主用 RMSE 和 MAE,辅以 R² 看整体拟合程度。为什么不用准确率?因为客流是连续数值,分类指标在这里没有意义,这一点在答辩时经常被追问,提前想清楚能省不少事。

数据管道的设计目标很明确:输入一张原始业务表,输出一份列名规范、时间有序、无缺失的建模样本。通常的做法是拆成四步——字段清洗、时间对齐、缺失处理、划分数据集。四步分开写成函数,不要糊在一个main里,后面调参和复现都要靠这个结构撑着。

2.2 字段清洗与时间对齐的实现

原始表里最容易翻车的是日期字段。它可能是2023/6/1、2023-06-01、甚至带时分秒的字符串,直接pd.to_datetime有时会静默解析成错误格式而不报错。血泪经验是加errors='coerce'再检查空值数量,异常日期会变成 NaT,方便定位。

import pandas as pd import numpy as np def clean_and_align(df: pd.DataFrame, date_col: str = "date") -> pd.DataFrame: """清洗日期字段并按天对齐,返回按时间升序排列的 DataFrame""" df = df.copy() # 强制解析,非法日期转为 NaT,避免静默错位 df[date_col] = pd.to_datetime(df[date_col], errors="coerce") bad_rows = df[date_col].isna().sum() if bad_rows > 0: print(f"发现 {bad_rows} 行日期无法解析,已剔除") df = df.dropna(subset=[date_col]) # 统一到天粒度,去掉时分秒对聚合的干扰 df[date_col] = df[date_col].dt.normalize() # 同一天多条的按均值聚合,避免重复日期导致索引冲突 num_cols = df.select_dtypes(include=[np.number]).columns.tolist() df = df.groupby(date_col, as_index=False)[num_cols].mean() return df.sort_values(date_col).reset_index(drop=True)

这段代码的关键点有三个:errors="coerce"负责把脏日期暴露出来而不是悄悄吞掉;dt.normalize()把时间戳压到天,避免同一天出现多个时间点;groupby聚合解决重复日期,这一步在真实业务表里几乎必做,因为同一商家同一天可能有多条记录。参数上date_col做成可配置,是因为不同来源的表列名不一致,硬编码会在换数据时直接崩。

2.3 缺失值处理与异常值截断

客流数据缺失有两种来源:一种是商家当天没营业,另一种是采集系统漏了。这两种情况处理方式完全不同,混在一起填 0 会让模型学到错误规律。我的处理习惯是先用营业标记列筛掉闭店日,剩下的缺失按前后 7 天滚动中位数填补,比均值更抗异常。

def fill_missing(df: pd.DataFrame, target: str, window: int = 7) -> pd.DataFrame: """对目标列做时间感知的缺失填补,并截断极端异常值""" df = df.copy() # 用前后窗口的中位数填补,比全局均值更贴近局部趋势 rolling_med = df[target].rolling(window=window, min_periods=1, center=True).median() df[target] = df[target].fillna(rolling_med) # 用 1% 和 99% 分位截断,防止极端值带偏 SVR 的间隔 low, high = df[target].quantile([0.01, 0.99]) df[target] = df[target].clip(lower=low, upper=high) return df

center=True让窗口以当前点为中心,避免只用历史值造成填补结果滞后。分位截断这一步对 SVM 尤其重要,因为 SVR 对离群点敏感,一个极端高峰值就能把回归面拽偏,后面第 5 章还会专门讲这个坑。

2.4 数据集划分:为什么不能随机切

时间序列预测最常见的错误,就是train_test_split(shuffle=True)。随机打乱之后,模型在训练时见过“未来”的样本,测试集指标会好看得离谱,上线就原形毕露。正确做法是按时间顺序切分,前 80% 作训练,后 20% 作测试;如果要做交叉验证,用TimeSeriesSplit而不是 KFold。这份代码里我一般会把切分逻辑单独抽出来,保证训练集的时间最大值严格小于测试集的时间最小值,答辩时这一条能直接讲出设计考量。

3. 特征工程:把日期、口碑和滞后信息变成模型能吃的数值

3.1 时间特征的构造与周期编码

客流量最直接的驱动因素就是时间。星期几、是否周末、是否节假日、月份,这些字段原始表里通常没有,得自己造。星期几不能直接填 1 到 7,因为模型会误以为周日和周一差距很大,正确做法是正弦余弦编码,把周期性映射到单位圆上。

def add_time_features(df: pd.DataFrame, date_col: str = "date") -> pd.DataFrame: df = df.copy() df["weekday"] = df[date_col].dt.weekday # 0=周一 df["is_weekend"] = (df["weekday"] >= 5).astype(int) df["month"] = df[date_col].dt.month df["day_of_month"] = df[date_col].dt.day # 周期编码:让周一和周日在一维上相邻,而不是首尾割裂 df["weekday_sin"] = np.sin(2 * np.pi * df["weekday"] / 7) df["weekday_cos"] = np.cos(2 * np.pi * df["weekday"] / 7) df["month_sin"] = np.sin(2 * np.pi * df["month"] / 12) df["month_cos"] = np.cos(2 * np.pi * df["month"] / 12) return df

原始weekday列保留没关系,树模型能自己分裂,但 SVR 这类基于距离的模型对周期编码更友好。两套特征都留着,后面模型对比时可以看哪套在验证集上更稳。节假日字段如果表里没有,用简化的“是否法定假日”二值列代替即可,不必引入外部日历库,代码可移植性更重要。

3.2 口碑评分的分箱与交互特征

题目里“口碑商家”四个字不是装饰,口碑评分对客流有滞后影响:评分高的店客流上升,但可能隔几天才体现。通常的做法是把评分做两件事:一是分箱,把连续评分切成“低/中/高”三档,降低噪声;二是造交互项,比如“评分 × 是否周末”,捕捉周末高评分店更吸客的现象。

def add_reputation_features(df: pd.DataFrame, score_col: str = "score") -> pd.DataFrame: df = df.copy() # 分箱边界按业务经验固定,避免用测试集分位数造成信息泄漏 bins = [0, 3.5, 4.2, 5.0] labels = [0, 1, 2] df["score_bin"] = pd.cut(df[score_col], bins=bins, labels=labels).astype(int) # 交互特征:高口碑在周末是否带来额外客流 df["score_weekend"] = df[score_col] * df["is_weekend"] return df

分箱边界写成固定值而不是qcut,是因为分位数依赖数据分布,训练集和测试集分位数不同会导致同一评分被分到不同箱,属于隐蔽的数据泄漏,这个坑在第 5 章会展开。

3.3 滞后特征与滚动统计:让模型看到历史趋势

客流量有强自相关性,昨天的客流对今天有解释力。滞后特征通常取 1 天、7 天、14 天三个跨度,分别对应短期惯性、周周期和双周趋势。滚动统计取 7 天滑动均值和标准差,前者刻画基线水平,后者刻画波动程度。

def add_lag_features(df: pd.DataFrame, target: str) -> pd.DataFrame: df = df.copy() # shift(1) 确保预测当天时只使用前一天及更早的信息 for lag in [1, 7, 14]: df[f"lag_{lag}"] = df[target].shift(lag) # 滚动窗口基于 shift(1) 后的序列,避免把当天值算进去 df["roll_mean_7"] = df[target].shift(1).rolling(7).mean() df["roll_std_7"] = df[target].shift(1).rolling(7).std() # 生成后必然产生前若干行空值,直接丢弃 return df.dropna().reset_index(drop=True)

这段逻辑里shift(1)是生命线。如果写成rolling(7).mean()而不先 shift,窗口里就包含了当天的目标值,模型等于在偷看答案,训练指标漂亮但线上毫无意义。滞后阶数不是越多越好,14 天之后边际收益骤降,还会拉长有效样本的起始时间,数据量小的商家可能直接不够训练。

3.4 特征组装与标准化顺序

所有特征函数按顺序串起来,形成一条可复现的管道。标准化放在特征组装之后、模型训练之前,而且必须用训练集的均值方差去变换测试集,不能各自标准化。

from sklearn.preprocessing import StandardScaler def build_features(raw: pd.DataFrame, target: str = "traffic") -> tuple: df = clean_and_align(raw) df = fill_missing(df, target) df = add_time_features(df) df = add_reputation_features(df) df = add_lag_features(df, target) feature_cols = [c for c in df.columns if c not in ("date", target)] # 按时间切分,绝不 shuffle split = int(len(df) * 0.8) train, test = df.iloc[:split], df.iloc[split:] scaler = StandardScaler().fit(train[feature_cols]) # 只在训练集上拟合 X_train = scaler.transform(train[feature_cols]) X_test = scaler.transform(test[feature_cols]) return X_train, train[target].values, X_test, test[target].values, feature_cols

参数0.8是训练占比,数据量少于一年时可以调到 0.7 留更多测试样本。feature_cols排除日期列和目标列,防止把字符串或标签直接喂进模型。这套组装函数返回标准化后的数组和特征名,后面的模型对比和重要性分析都基于它,改一处全链路生效。

4. 模型对比与 SVM 调参:为什么最终选了 RBF 核

4.1 基线模型对比:先跑通再调优

不要一上来就怼 SVM。我的习惯是先铺三个基线:线性回归看线性可解释性,决策树看非线性分裂能力,随机森林看集成效果。三个基线跑完,心里对数据的线性程度和噪声水平就有数了,再决定 SVM 的核函数和参数搜索范围。

from sklearn.linear_model import LinearRegression from sklearn.tree import DecisionTreeRegressor from sklearn.ensemble import RandomForestRegressor from sklearn.svm import SVR from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score import numpy as np def evaluate(model, X_tr, y_tr, X_te, y_te, name: str): model.fit(X_tr, y_tr) pred = model.predict(X_te) rmse = np.sqrt(mean_squared_error(y_te, pred)) mae = mean_absolute_error(y_te, pred) r2 = r2_score(y_te, pred) print(f"{name:16s} RMSE={rmse:8.2f} MAE={mae:7.2f} R2={r2:6.3f}") return {"name": name, "rmse": rmse, "mae": mae, "r2": r2}

这段评估函数统一了指标口径,四个模型用同一份切分数据跑,结果才可比。RMSE 对大误差更敏感,MAE 更贴近日常排班决策的实际偏差,两个都报出来比只报 R² 有说服力。基线对比表建议直接放进答辩 PPT,一眼能看出选型不是拍脑袋。

4.2 RBF 核 SVM 的参数含义与搜索策略

SVR 有三个关键参数:C控制对误差的容忍度,越大越容易过拟合;epsilon是不敏感带宽度,预测偏差在带内不计损失;gamma是 RBF 核的宽度参数,越大影响范围越小、越容易记住训练点。这三个参数互相耦合,网格搜索范围要有层次:先粗搜C和gamma的数量级,再在最优附近细化。

from sklearn.model_selection import GridSearchCV, TimeSeriesSplit def tune_svr(X_tr, y_tr): # 时间序列交叉验证,避免未来信息渗入验证折 tscv = TimeSeriesSplit(n_splits=5) param_grid = { "C": [0.1, 1, 10, 100], "gamma": ["scale", 0.01, 0.1, 1], "epsilon": [0.01, 0.1, 0.5], } grid = GridSearchCV( SVR(kernel="rbf"), param_grid, cv=tscv, scoring="neg_root_mean_squared_error", n_jobs=-1, ) grid.fit(X_tr, y_tr) print("最优参数:", grid.best_params_) return grid.best_estimator_

gamma="scale"是 sklearn 的默认启发式,等于 1/(特征数×方差),先把它放进候选里当参照,再和固定值比较。scoring用负 RMSE 是因为 GridSearchCV 默认最大化得分,取负号后等价于最小化误差。TimeSeriesSplit的每一折都保证验证集在训练集之后,这个细节直接决定了调参结果可不可信。

4.3 与随机森林的取舍:精度、解释性和训练成本

在中小规模客流数据上,RBF 核 SVR 和随机森林的精度往往咬得很紧,差距常在 5% 以内。取舍点不在精度,在另外两件事:一是解释性,随机森林能给特征重要性,SVR 给不了,如果答辩要求讲清“哪个因素最影响客流”,树模型更省事;二是训练成本,SVR 的复杂度随样本量增长较快,样本过万时网格搜索会明显变慢。我的做法是两个都训,用验证集指标选主力模型,再用另一个做交叉印证,结论写进报告里比单模型更稳。如果最终选 SVR,特征重要性可以用置换重要性补上,虽然不是原生输出,但足够支撑分析。

5. 避坑排查:五类最容易在验收时翻车的问题

5.1 随机划分导致指标虚高

现象是测试集 R² 逼近 0.95,换一批数据或者按时间重切之后掉到 0.5 以下。原因就是划分时打乱了时间顺序,滞后特征把未来信息带进了训练集。解决办法是全程按时间切分,交叉验证统一用TimeSeriesSplit,并在报告里写明切分方式。这个坑几乎每届都有人踩,改起来只需一行代码,但发现得晚就要重跑全部实验。

5.2 标准化在切分之前做

现象是模型离线指标很好,线上预测系统性偏大或偏小。原因是先对全量数据做了fit_transform,测试集的均值和方差信息渗进了训练过程。正确顺序永远是先切分、再在训练集上fit、最后transform测试集。检查方法很简单:看代码里StandardScaler的fit调用是不是只出现一次,且入参是训练集。

5.3 滞后特征把当天值算进去

现象是训练 loss 极低,但预测下一天时误差突然放大。原因是滚动窗口没做shift(1),窗口末尾包含了当天目标值,模型在训练阶段相当于看了答案。排查办法是造一个最小样例,手动核对某一行的roll_mean_7是否等于它前 7 天的均值。这个错误隐蔽性强,因为代码不报错、指标还好看,只能靠逻辑核对。

5.4 SVR 对未截断的极端值过度敏感

现象是整体 MAE 尚可,但个别高峰日预测值被明显压低,残差分布严重右偏。原因是 SVR 的 epsilon 不敏感带对离群点容忍有限,一个极端峰值会把回归面整体拉偏。解决办法是在特征管道里做分位截断(第 2 章那段clip),或者在训练前对目标做对数变换,预测后再指数还原。两种方式我都用过,数据波动大时对数变换更稳。

5.5 用测试集分位数做分箱边界

现象是离线评估没问题,换时间段后同一评分被分到不同箱,模型行为漂移。原因是分箱用了qcut或者在全量数据上算分位数,测试集分布参与了特征构造。解决办法是把分箱边界写成业务经验固定值,并在文档里注明依据来源。凡是“依赖数据分布”的特征变换,都要问一句:这个分布是训练集独有的,还是全量共享的?

6. 验收与答辩:用一份可复现清单把方案讲圆

验收这件事,评委和导师看的不是你模型多花哨,而是整条链路能不能被别人复现。我一般会准备一份四行的核对清单,答辩前逐项过一遍:数据切分是否严格按时间、标准化是否只在训练集拟合、滞后特征是否全部 shift、随机种子是否固定。这四条对应第 5 章的前三个坑加上复现性,任何一条不过,指标都要打问号。

现场演示建议按“输入—处理—输出”三段走,不要一上来就贴模型代码。先展示原始表长什么样、脏在哪,再展示清洗后的样本和特征列,最后给出测试集上的预测对比图:横轴时间、纵轴客流、两条线分别是真实值和预测值。评委最容易追问的三个问题提前准备好答案——为什么用 SVM 而不是树模型(答精度接近时看解释性和数据规模)、RMSE 的具体数值对应业务上多大偏差(答换算成日均多少人)、模型多久需要重训(答客流模式变化或误差连续超标时触发)。指标体系用一张小表收口更清楚:

指标数值业务含义
RMSE按实测填写大误差惩罚后的整体偏差
MAE按实测填写日均预测偏差人数
R²按实测填写对客流波动的解释比例

表格里的数值必须来自你自己跑出来的结果,不要照抄任何示例数字,答辩时被问到计算口径答不上来是很扣分的事。另外,把random_state、切分比例、分箱边界这些参数集中写进一个配置文件或脚本头部,别人拿到代码改一处就能复跑,这比在报告里写十页原理都有说服力。

从那以后我每次交付预测类项目,都强制把“切分顺序”和“特征生成时点”写进 README 第一页,哪怕需求方不看,自己复盘时也能一眼定位问题出在哪一层。这套习惯帮我省下的返工时间,远比多调几轮参来得值。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询