简介:面向 2018 年 Swiggy Hackathon 的订单需求预测实战项目,围绕历史订单数据构建机器学习回归模型,适合想学习需求预测、Kaggle/黑客松实战的 Python 开发者。资源完整收录了从数据预处理、特征工程到 K 近邻回归与随机森林回归的训练评估流程,涵盖订单数据清洗、时间与地点特征构造、模型训练与 MSE/MAE/R² 指标对比,并配有 PDF 赛题方案、Readme 说明、已处理 CSV、预测结果 JSON,以及基于 Node.js/Vue 的可视化前端,便于对照复现与二次改进。压缩包共 75 个文件,以 Python 脚本、JavaScript、JSON、图片与 SVG 资源为主,另有少量 Markdown/PDF 文档,整体约 4.63MB,体量轻但结构清晰,从模型代码到展示界面都有覆盖。项目目前已有 412 人学习下载,适合作为外卖/即时配送场景下需求预测的入门到进阶参考,尤其能帮助理解时间特征构造、回归模型调参和结果可视化落地。
1. 用历史订单预测需求量:Swiggy Hackathon 2018 的 ML 模型到底在解决什么
周五傍晚下着雨,外卖平台在六点到八点之间涌进平时三倍订单,调度中心如果没提前预判,骑手不够、备货不足,投诉量直接翻倍。这个面向 Swiggy Hackathon 2018 的订单需求预测项目,就是用过去几个月的订单记录,训练一个机器学习模型,把未来一小时、未来一天的订单需求量预估出来,让运营能提前调配运力。它不是那种写进 PPT 里的概念演示,而是一套完整的回归建模流程:从历史订单表里灌入数据,做时间维度和区域维度的特征工程,再用 k 近邻回归和随机森林回归这两类模型去拟合“未来订单数”,最后给出可视化的预测结果。适合正在做配送需求预测、参加 Kaggle 或黑客马拉松、或者刚入门机器学习回归任务的人。
2. 把历史订单变成特征矩阵:日期、时段与区域交叉特征怎么构造
2.1 原始订单表长什么样:时间戳、区域与订单量是核心
拿到 Swiggy Hackathon 2018 的公开数据集,常见格式是一张订单流水表,每一行代表一条订单记录,关键字段包括订单创建时间created_at、餐厅所属区域restaurant_area、送餐耗时、订单金额等。要做需求预测,第一步不是直接把这张表丢给模型,而是先按“区域 + 时间桶”做聚合。比如把每小时看成一个时间桶,统计每个区域在该小时的订单总数,得到这样一张中间表:
area_id:区域编号hour:时间桶起点orders:该区域这个小时的订单量
这一步的逻辑是:模型学习的是“某个区域在某个时段大概会有多少订单”,而不是逐条预测每一单。原始订单数据一次性消费完,后面的特征工程全部基于这个聚合表,训练样本也大幅缩减,从几百万行压到几万行,内存压力和训练耗时都友好很多。
聚合时有个细节需要注意:created_at必须转成 pandas 的datetime64类型,再用floor('H')取整到小时,否则会因为时区或微秒误差导致同一小时的记录被拆到两个桶里。常见做法是:
import pandas as pd # 读取原始订单表,指定时间列解析 df = pd.read_csv('orders.csv', parse_dates=['created_at']) # 按小时桶和区域聚合 df['hour_bucket'] = df['created_at'].dt.floor('H') grouped = df.groupby(['restaurant_area', 'hour_bucket']).size() agg = grouped.reset_index(name='orders') # 顺便把时间拆成常用字段 agg['weekday'] = agg['hour_bucket'].dt.weekday # 周一=0 agg['hour_of_day'] = agg['hour_bucket'].dt.hour agg['is_weekend'] = (agg['weekday'] >= 5).astype(int) print(agg.head())参数说明:floor('H')是向下取整到小时,比如14:38会变成14:00;weekday取 0 到 6 的整数,is_weekend是基于这个整数构造的布尔标志。如果数据集跨月,建议再补一个month特征,因为餐饮需求有明显的月度趋势,比如节假日和开学季对订单影响很大。
2.2 时间特征与滞后特征:让模型看到“趋势”而不是只看到“点”
只给模型hour_of_day和weekday还不够,因为订单量是一个连续变化的序列,今天的订单量往往和过去几小时的订单量强相关。比如午高峰过后,下午两点到三点的订单会少一点,但不会突然跌到零。所以滞后特征(lag feature)是这类需求预测里最关键的工程步骤。
常见做法是对每个区域分别算历史订单的滞后值,比如lag_1是上一个小时的订单量,lag_24是昨天同一小时的订单量,lag_168是上周同一小时(一周 168 小时)的订单量。滞后特征能直接告诉模型“昨天这个时段人流量怎么样”,比让模型自己从星期和时间推断要省力得多。
# 按区域分组后,对 orders 做移位 agg = agg.sort_values(['restaurant_area', 'hour_bucket']).reset_index(drop=True) for lag in [1, 2, 24, 168]: agg[f'lag_{lag}'] = ( agg.groupby('restaurant_area')['orders'] .shift(lag) ) # 滚动统计:过去 3 小时平均订单量 agg['rolling_mean_3'] = ( agg.groupby('restaurant_area')['orders'] .rolling(3, min_periods=1).mean() .values ) print(agg.isnull().sum())逻辑说明:.shift()是按当前行向前推若干行,因为groupby之后已经按时间排好序,所以 lag 对应的是历史真实订单数。rolling_mean_3用的窗口是当前行以及前两行,注意要在groupby后的序列上直接调.rolling(),不要忘记末尾的.values,否则索引对齐会出问题。
参数说明:rolling(3, min_periods=1)里min_periods=1是允许前两行样本用已有数据计算平均值,避免前几个时间桶出现全部 NaN 而被直接丢弃。滞后值缺失的样本在训练时通常会直接删除,因为shift后开头几行没有历史数据,模型无法凭空学到“更早之前是什么情况”。
2.3 目标列与训练集切分:预测未来不是预测过去
需求预测的目标y不能是当前时间桶的orders,而应当是未来某个窗口的订单量。最常见的两种设定是:预测下一小时订单量,或者预测未来 24 小时的总量。这里拿调度场景来说,预测未来一小时更有操作价值,因为骑手排班是按小时调整的。
实现时,要把“未来”体现在标签上。比如想用当前特征预测后一小时订单数,就把orders向下移位负 1,得到target:
agg['target'] = ( agg.groupby('restaurant_area')['orders'] .shift(-1) # 下一小时的订单量 ) # 去掉没有 target 的最后一行,以及 lag 造成的缺失行 valid = agg.dropna(subset=['target', 'lag_1', 'lag_2', 'lag_24', 'lag_168']) valid = valid.sort_values('hour_bucket')这里.shift(-1)是向上取下一行的值,移位的方向别搞反。如果shift(1)是拿上一小时预测当前小时,等于模型在“用过去预测现在”,这在某些场景也有意义,但从业务上讲,想提前一小时做准备,就必须用当前特征预测未来一小时。
特征和标签确定后,训练集与测试集切分是整个项目最容易翻车的地方。常见误用是直接train_test_split随机切,这在时间序列里等于拿未来数据去训练模型,结果会异常好看,上生产立刻被打回原形。正确做法是按时间顺序切分,比如用 4 月份前 80% 的小时段样本做训练,后 20% 做验证,并且验证集时间段必选在训练集时间段之后。
split_date = valid['hour_bucket'].quantile(0.8) train = valid[valid['hour_bucket'] < split_date] test = valid[valid['hour_bucket'] >= split_date] feature_cols = [ 'area_id', 'hour_of_day', 'weekday', 'is_weekend', 'lag_1', 'lag_2', 'lag_24', 'lag_168', 'rolling_mean_3' ] X_train, y_train = train[feature_cols], train['target'] X_test, y_test = test[feature_cols], test['target']参数说明:quantile(0.8)是取时间列 80% 分位点,这样保证测试集约占整体的两成。这里area_id是类别型 ID,直接喂给树模型没问题,但如果后面要用 KNN,就得先做独热编码,不然会把地区编号当成连续数值参与距离计算,产生严重误导。
3. 回归模型选型:k 近邻与随机森林回归的代码实现
3.1 为什么是这两个模型:KNN 管局部相似,随机森林管复杂交互
这个项目里选择 k 近邻回归和随机森林回归,并不是随手拿两个模型充数,而是它们在需求预测场景各有不可替代的侧重。
KNN 回归的思路是“找相似的历史时段”,比如预测周一早上九点某个区域的订单量,KNN 会去历史数据里找同是周一早上九点、且滞后特征接近的样本,取这些样本订单量的平均值。它天然适合订单量变化存在周期性规律的场景,比如工作日高峰和周末高峰模式明显不同,只要特征构造得当,KNN 的预测误差甚至能接近随机森林。但 KNN 有三个硬伤:一是对特征缩放极其敏感,时间特征里的“小时数”可能只到 23,而滞后订单量可能到几千,不标准化的话距离计算会被大数值特征完全主导;二是类别特征必须做独热编码,否则 KNN 会把两个区域编号之间的距离计算成数字差;三是预测时要遍历全部训练样本做距离计算,数据量大时速度很慢。
随机森林回归的优势在于能自动处理特征交互和非线性关系,比如“周末 + 晚上八点 + 昨天同时段订单量高”这样的组合模式,随机森林可以用多棵决策树分裂去捕捉。它不需要特征缩放,也不会被类别 ID 的大整数数值干扰,而且自带特征重要性得分,能帮我们筛掉无用特征。缺点是比 KNN 更容易过拟合,尤其当max_depth不限制、min_samples_leaf设成 1 时,训练集表现可以近乎完美,验证集却惨不忍睹。
3.2 从默认参数到网格搜索:一份可以直接跑的 scikit-learn 代码
先放一段完整的训练代码,包含 KNN 和随机森林的对比。为了演示方便,这里依然用第 2 章构造好的X_train、y_train,但注意:如果数据量超过十万,KNN 的训练过程本身很快,预测阶段很慢,建议在完整跑之前先取十分之一样本试跑。
from sklearn.model_selection import GridSearchCV from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsRegressor from sklearn.ensemble import RandomForestRegressor from sklearn.pipeline import Pipeline from sklearn.metrics import mean_absolute_error import numpy as np # 特征列中既有小时、星期,也有订单量,KNN 必须先标准化 numeric_cols = ['hour_of_day', 'weekday', 'is_weekend', 'lag_1', 'lag_2', 'lag_24', 'lag_168', 'rolling_mean_3'] scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train[numeric_cols]) X_test_scaled = scaler.transform(X_test[numeric_cols]) # KNN 网格搜索 knn_params = { 'n_neighbors': [5, 15, 30], 'weights': ['distance'], 'p': [1, 2] } knn = KNeighborsRegressor() knn_search = GridSearchCV(knn, knn_params, cv=3, scoring='neg_mean_absolute_error') knn_search.fit(X_train_scaled, y_train) best_knn = knn_search.best_estimator_ # 随机森林网格搜索 rf_params = { 'n_estimators': [200, 400], 'max_depth': [10, 20, None], 'min_samples_leaf': [1, 5, 20] } rf = RandomForestRegressor(random_state=42) rf_search = GridSearchCV(rf, rf_params, cv=3, scoring='neg_mean_absolute_error') rf_search.fit(X_train[numeric_cols], y_train) # 随机森林不需要缩放 best_rf = rf_search.best_estimator_ # 验证集效果 for name, model, X_eval in [ ('KNN', best_knn, X_test_scaled), ('RandomForest', best_rf, X_test) ]: y_pred = model.predict(X_eval) mae = mean_absolute_error(y_test, y_pred) print(f'{name} MAE: {mae:.2f}')逻辑说明:numeric_cols里没有包含area_id,因为这里仅仅演示单区域数据;真实多区域场景需要把area_id做独热编码后再拼进矩阵,或者把它变成每个区域的统计特征(比如该区域最近一周平均订单量),否则 KNN 会对区域编号做无意义的距离计算。随机森林部分直接喂原始列,因为树模型不关心数值缩放。
参数说明:n_neighbors越大,模型越平滑,对局部噪声越不敏感,但会忽略特殊时段的独特模式;weights='distance'表示距离越近的样本权重越大,比uniform在订单波动较大的场景更稳定;p=1是曼哈顿距离,p=2是欧氏距离。配送数据中带有明显的“阶梯状”突增,比如午高峰前 30 分钟订单开始爬升,曼哈顿距离有时比欧氏距离更能容忍这种阶梯变化,我一般会让网格搜索自己去选,而不是拍脑袋定。随机森林的min_samples_leaf是防止过拟合最重要的一个参数,设成 20 通常比设成 1 在验证集上的 MAE 更低,因为树不再尝试把每个训练样本单独切一个叶子。
3.3 特征重要性与模型调优方向
随机森林训练完之后,不要急着上线,先看一眼特征重要性排序,这一步能帮你反思特征工程是否有漏项。常见做法是把feature_importances_和特征名放在一个 DataFrame 里,按大小排个序。
import pandas as pd importance_df = pd.DataFrame({ 'feature': numeric_cols, 'importance': best_rf.feature_importances_ }).sort_values('importance', ascending=False) print(importance_df)在这个数据集上,通常会看到lag_1和lag_168排在最前面,这说明上一个小时和上周同一时间段的订单量对预测下一小时起决定性作用。如果hour_of_day排在倒数,大概率是因为滞后特征已经隐含了时段信息,模型发现直接用昨天同小时的订单量比用小时编号更省事。这不是坏事,但也提醒你:如果没有把天气、节假日、城市大型活动等外部事件拼进来,模型只能学到“周期性”,学不到“突发增长”。Swiggy 当年一个重要的延伸方向是把降雨量数据做成特征,因为雨天订单量普遍上涨,这是纯粹从时间序列内部无法预测到的信号。
特征重要性还能帮你判断是否需要做特征去噪,比如is_weekend如果重要性极低,原因可能是它已经和weekday完全线性相关,对树模型来说属于可替代信息,删掉它不会降低效果,还能省一点训练时间。我一般会保留重要性低于 0.01 的特征做一次消融测试,如果删掉后验证集 MAE 没有变差,就直接从特征列表里移除。
4. 排错与避坑:需求预测模型最常见的五个问题
4.1 数据泄漏:模型用到了未来信息,验证集 MAE 低得离谱
现象:训练集和测试集都来自同一个时间段,随机抽样后模型在验证集上的 MAE 只有 1 到 2,但真正部署时预测误差翻了好几倍。
原因:时间序列数据是高度自相关的。直接train_test_split会把同一个区域、相近几个小时的样本同时分到训练集和测试集,模型等于“见过”测试集附近的订单量趋势,学到的不是泛化规律,而是记忆。
解决:必须按时间分割。用第 2 章末尾提到的基于时间阈值的切分方式,训练集必须严格早于验证集。如果样本跨度较长,还可以用sklearn.model_selection.TimeSeriesSplit做滚动验证,但那是第 5 章的内容,这里先强制要求自己不用train_test_split的随机模式。
4.2 滞后特征把 NaN 忘干净:缺失样本被悄悄过滤掉
现象:训练集和验证集的样本数量不一样,或者模型在验证集上的预测结果整体偏低。
原因:shift操作产生大量 NaN,数据处理时用了dropna(),但这个dropna可能同时也把目标列target的最后几行删掉了,导致验证集的最后几个时间桶缺失,预测对象不完整。
解决:分开处理特征 NaN 和目标 NaN。先构建train_valid_index = valid.dropna(subset=['target']).index,然后在这个索引上继续处理特征缺失。更稳妥的做法是把缺失的滞后特征先填为 0,再额外加一个布尔标志列has_lag_1,记录该特征是否缺失,让模型自己学习“缺失”是否代表该区域新开张。不要看到 NaN 就一律 drop,先检查缺失分布。
4.3 KNN 对特征缩放过敏:订单量几千,小时数只有 24
现象:KNN 的 MAE 比随机森林高很多,甚至比随便填个历史均值还差,而且网格搜索调参后变化不大。
原因:KNN 用欧氏距离衡量样本相似度,订单滞后值动辄几百几千,而hour_of_day只有 0 到 23,数值大的特征在距离计算中占绝对主导。两个样本即使时段完全不同,只要订单量近,就会被当成近邻。
解决:对所有连续特征做标准化。用StandardScaler将每个特征均值归零、方差归 1。注意必须先 fit 在训练集上,再 transform 到验证集,不能把全部数据一起 fit,否则验证集的均值信息会泄漏到训练阶段。另外area_id这种类别编号千万不能放进 KNN 的距离计算,要么独热编码,要么直接删除。独热编码后,建议用p=1的曼哈顿距离,因为它在高维稀疏特征上比欧氏距离更合理。
4.4 随机森林过拟合:训练 MAE 0.5,验证 MAE 9.8
现象:随机森林在训练集上预测误差极小,但在验证集上误差很大,而且随着n_estimators增大,验证集误差不再下降甚至略微上升。
原因:min_samples_leaf=1且max_depth=None时,每一棵决策树会疯狂生长到把训练样本完全分开,等于每棵树都在背样本,bagging 把方差降下去一部分,但单树的极端过拟合仍然没有被完全抵消。
解决:限制树分裂的复杂度。优先调min_samples_leaf到 20 以上,再调max_depth到 15 左右。n_estimators在 200 到 500 之间足够,超过 1000 对验证集提升微乎其微,却会让模型文件体积翻好几倍。实战中我常用早停法:每次训练后记录验证集 MAE,如果连续增加 20 棵树后 MAE 没有下降,就停止增加,虽然 sklearn 自带warm_start=True可以实现增量训练,但 GridSearchCV 里直接设n_estimators=400更省事。
4.5 回归任务用分类指标评估:MAE 还没算,先被 “accuracy” 坑了
现象:输出预测订单量后,有人用“预测值四舍五入是否与实际值相等”算准率,结果准确率只有 20%,于是认为模型不可用。
原因:需求预测是回归问题,连续值预测没有“对错”之分,只有误差大小之分。用分类准确率衡量回归模型,等于要求模型精确命中一个浮点数,这在数学上几乎不可能。
解决:使用 MAE(平均绝对误差)、RMSE(均方根误差)或 MAPE(平均绝对百分比误差)。MAE 对异常值不敏感,适合订单量分布偏斜比较大的场景;RMSE 会放大大的误差,适合惩罚那些预测严重偏低的样本;MAPE 适合比较不同区域之间的误差相对水平,但要注意订单量为 0 的时间桶会导致 MAPE 无穷大,需要先过滤。下面的代码给出三个指标计算:
from sklearn.metrics import mean_absolute_error, mean_squared_error import numpy as np mae = mean_absolute_error(y_test, y_pred) rmse = np.sqrt(mean_squared_error(y_test, y_pred)) mape = np.mean(np.abs((y_test - y_pred) / y_test)) * 100 print(f'MAE: {mae:.2f}, RMSE: {rmse:.2f}, MAPE: {mape:.2f}%')如果 y_test 中有值为 0,会导致 MAPE 计算报错或为无穷大。一种常见处理方式是把订单量为 0 的时间桶从 MAPE 计算中剔除,或者改用 SMAE(对称平均绝对百分比误差),它对 0 更宽容。但要不要过滤 0 需求样本,取决于业务目的——如果运营想预测“是否会有订单”,那 0 本身也是很重要的信号,不应该当作异常值删掉。
5. 模型上线前的一步:用滚动验证与基线对比确认模型真的有效
很多初学者验证模型时只看一次训练测试切分的结果,我说实话,这个数字不可信。为了确认模型不是靠运气,我总会再做两件事:滚动时间序列验证,以及跟一个最笨的基线模型对比。
滚动验证用TimeSeriesSplit把时间序列切成多段,每次用前面几段训练,预测后面一段,然后把多段验证误差合并起来看。这比单次切分更接近真实的上线场景——模型要面对的是从未见过的未来,而不是同分布下的随机一截。
from sklearn.model_selection import TimeSeriesSplit from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error import numpy as np tscv = TimeSeriesSplit(n_splits=5) mae_scores = [] # feature_cols 和数值特征列沿用第 3 章的设置 X_all = valid[feature_cols + ['target']].dropna(subset=feature_cols) X_features = X_all[feature_cols] y_all = X_all['target'] for train_idx, test_idx in tscv.split(X_features): X_t, X_v = X_features.iloc[train_idx], X_features.iloc[test_idx] y_t, y_v = y_all.iloc[train_idx], y_all.iloc[test_idx] rf = RandomForestRegressor( n_estimators=200, max_depth=15, min_samples_leaf=20, random_state=42 ) rf.fit(X_t, y_t) y_pred = rf.predict(X_v) mae_scores.append(mean_absolute_error(y_v, y_pred)) print(f'Rolling MAE: {np.mean(mae_scores):.2f} ± {np.std(mae_scores):.2f}')逻辑说明:TimeSeriesSplit默认把数据按行索引顺序切分成 5 段,每次训练集包含前面 4 段,验证集是第 5 段。注意它要求数据已经按时间排序,所以我们在构造X_features之前一定要确保valid是按hour_bucket排序的。如果切分后预测误差波动很大,比如某一段 MAE 是其他段的 3 倍,就去查那一段是否包含大型节假日或平台促销,如果属于业务上很特殊的时段,建议单独建模或给模型加节假日特征。
基线模型选择也很有讲究。在时间序列预测里,最简单的基线是“用前一时段订单量预测下一时段”,也就是 lag_1 模型。如果我们的特征工程和回归模型比这个基线提升很小,那说明模型可能只是在抄滞后特征,没有真正学习到时段和星期的规律。
baseline_pred = X_test['lag_1'] # 直接用上一个小时的订单量作为预测 baseline_mae = mean_absolute_error(y_test, baseline_pred) model_mae = mean_absolute_error(y_test, best_rf.predict(X_test)) print(f'Baseline MAE: {baseline_mae:.2f}') print(f'Model MAE: {model_mae:.2f}') print(f'Improvement: {(1 - model_mae / baseline_mae) * 100:.1f}%')如果模型 MAE 只比 lag_1 低不到 10%,我建议先别着急调更多参数,而是回去补特征——比如加入上周同时段的订单均值、过去 7 天同一星期的订单标准差、甚至天气数据。因为 lag_1 已经很强了,模型要超越它,必须有额外信息输入。Swiggy 场景里,降雨量和方圆三公里内的餐厅集中度是两个很有效的补充特征,这两类数据在当年的比赛中也经常被拿来当锦囊。
最后分享一个踩过无数坑之后养成的习惯:从那以后我每次做需求预测,不管项目多急,都强制自己走一遍“时间切分 + 基线对比 + 滚动验证”这三步,模型效果在验证集上看着多好都不算数,只有滚动验证的 MAE 均值比基线低出肉眼可见的幅度,我才敢把结果交给运营同学去排班。希望帮到你。
本文还有配套的精品资源,点击获取