尧图精选

骑士行为预测第一轮实战:特征工程与模型融合打好基线

🕒 发布时间:2026/10/2 1:09:56 📁 来源:尧图网络
简介面向智慧物流竞赛与骑手行为预测项目资源完整呈现饿了么骑士行为预估第一轮的解决方案从数据整理、骑手特征生成到分阶段建模与预测覆盖配送员后续动作预测的实战链路适合有一定机器学习基础、想了解深度学习与GBDT如何落地赛题的学习者。方案涵盖数据整理、数据集构建、特征生成、并行训练、样本扩增、测试数据构造、PairGBDT排序与回归预测等多个环节从数据到模型闭环清晰。压缩包共78个文件大小约102.24MB18个ipynb为分步Notebook33个txt提供行为/订单等原始或中间数据py脚本与pickle特征文件对应处理逻辑和缓存zbak、log与md保留备份、日志与说明目录结构便于按阶段复现。目前已有64人学习/下载。内容包含样本扩增、排序与回归任务、模型保存及README可直接对照运行也可用于复现特征工程和调参思路节省从零搭建的重复劳动。1. 骑士行为预测竞赛第一轮先解决“下一单会怎么走”拿到“饿了么骑士行为预测”这个赛题时大多数人第一反应是去做路线预测——骑士从 A 点怎么骑到 B 点。但真正拆开第一轮的评分标准后你会发现比赛要的其实是“行为预测”在未来某个时间窗口里骑士会不会接单、会不会取消、能不能按时送达。这是个典型的智慧物流场景下的时序决策问题深度学习能做但第一轮不建议直接上大模型。我见过太多队伍一上来就搭 Transformer结果连 baseline 都跑不过。这篇方案用“特征工程 集成模型打底、深度学习补序列信息”的思路给出一套第一轮就能稳定出分的完整做法。适合三类人第一次参加智慧物流竞赛、想快速进入前排的新手手里有轨迹数据但不知道怎么构造特征的从业者以及想验证深度学习在行为预测上到底值不值得投入的算法工程师。下面按数据理解、特征构造、模型选型、评估调参、避坑的顺序讲每步都能直接复现。2. 从赛题到特征把骑士轨迹变成一张行为表2.1 赛题给了什么数据第一轮到底要预测什么智慧物流竞赛第一轮通常给两类数据订单表和骑士轨迹表。订单表记录每条订单的创建时间、商家位置、用户位置、预计送达时间轨迹表按秒级或分钟级记录骑士的位置、状态、速度。骑士状态字段里就藏着“行为”——空闲、接单、到店、取餐、送达、取消这些离散状态就是预测标签。第一轮最常见的目标是给定骑士过去一段时间的行为序列预测未来某个时间窗口内他是否会完成配送、是否会取消订单或者直接预测下一个行为状态是什么。我建议把任务建模成多分类而不是回归。原因是取消、超时这些行为在时序上是强相关的分类目标更容易利用类别不平衡和状态转移规律。数据理解阶段最容易被忽略的是时间跨度。骑士的行为有很强的周期性午高峰、晚高峰、夜宵档完全不同。如果你拿到的数据只有三天就别指望模型能学到周期如果有一到两周一定要在特征里显式加入“星期几”和“小时段”的交叉。另外第一轮数据通常已经脱敏骑士 ID 是加密字符串但它的重复出现规律仍然能用千万别丢。2.2 特征构造窗口统计是基线序列特征是增量行为预测里最有效的特征不是单条订单的属性而是骑士近期的行为统计。核心思想是滑窗对每个骑士以当前时刻为终点向前取 15 分钟、30 分钟、60 分钟三个窗口统计窗口内的接单数、完成数、取消数、平均配送时长、平均等待时长。import pandas as pd import numpy as np def build_window_features(behavior_df, windows[15, 30, 60]): behavior_df 必须包含列: knight_id 骑士ID ts 行为发生时间(datetime) action 行为类型(接单/到店/取餐/送达/取消) duration 该条行为持续秒数(取餐/配送耗时, 其余填0) features [] for w in windows: # 对每个骑士分别计算滑窗 tmp behavior_df.sort_values([knight_id, ts]).copy() tmp[window_end] tmp[ts] pd.Timedelta(minutesw) # 用 asof merge 取窗口截止时间 tmp[ts_num] tmp[ts].astype(int64) // 10**9 tmp[window_end_num] tmp[window_end].astype(int64) // 10**9 for action in [接单, 送达, 取消]: act tmp[tmp[action] action][[knight_id, ts_num]].rename( columns{ts_num: event_ts}) merged pd.merge_asof( tmp.sort_values(ts_num), act.sort_values(event_ts), left_onts_num, right_onevent_ts, byknight_id, directionforward, allow_exact_matchesFalse ) # 判断事件是否落在窗口内 merged[hit] (merged[event_ts] merged[window_end_num]).astype(int) # 按骑士原行为时间聚合, 得到窗口内该行为发生次数 cnt merged.groupby([knight_id, ts])[hit].sum().rename( f{action}_cnt_{w}min) features.append(cnt) feat_df pd.concat(features, axis1).reset_index() return feat_df这段代码做了三件关键事按骑士分组排序保证时间序列有序用merge_asof做前向匹配找到每个行为时刻之后最近的一次目标行为判断这个目标行为是否落在窗口内。allow_exact_matchesFalse是为了排除“当前行为本身”干扰避免把同一时刻的事件算进窗口。窗口大小选 15/30/60 而不是更大是因为配送场景下骑士的决策周期很短超过一小时的历史对下一个行为的影响已经衰减得很厉害。这个方法的优点是纯 pandas 实现数据量在百万级时几分钟能跑完。缺点是你自己构造了“事件是否发生”的中间表日志级别和事件级别混在一起容易造成窗口计数偏多。更稳妥的做法是把窗口内的事件先按骑士聚合再用.rolling(15min)来算但merge_asof的思路更直观适合第一轮快速出基线。2.3 特征命名与存储别在特征工程阶段给自己挖坑特征表每列都要有稳定命名推荐格式{统计对象}_{聚合方式}_{窗口}_{业务含义}例如knight_avg_duration_30min、merchant_accept_rate_7d。理由很简单模型融合、跨人协作排错时你能一眼看出特征含义不用翻代码。第一轮时间紧张但特征命名省下的排错时间远大于命名的成本。特征存储建议用parquet格式列存加压缩读取速度比 CSV 快一个数量级。另外一定要把特征工程代码封装成函数训练和推理共用同一份代码。竞赛里最常见的翻车就是训练时特征用滑窗统计得出、推理时却用了全量统计结果线上分数和本地验证差一大截。把特征代码抽成build_features(day_start, day_end)这种形式就能从根本上避免这个坑。3. 模型选型为什么先用 LightGBM 打底再用深度学习补序列信息3.1 表格特征的快速基线LightGBM 多分类行为预测的特征表里大量是类别特征和数值统计这类数据最适合梯度提升树。LightGBM 做多分类非常快且对类别特征有原生支持不需要做独热编码。第一轮我不建议直接上深度模型原因有两个深度模型对表格特征的学习效率不如 GBDT且调参成本高竞赛第一轮要的是快速稳定的分数GBDT 能在半小时内给出可信的 baseline。import lightgbm as lgb from sklearn.model_selection import GroupKFold from sklearn.metrics import log_loss # 假设 feat_df 已有特征列和标签列 label(多分类: 0-接单,1-到店,2-取餐,3-送达,4-取消) feature_cols [c for c in feat_df.columns if c not in [knight_id, ts, label]] cat_cols [c for c in feature_cols if feat_df[c].dtype category] # 用 GroupKFold 按骑士分组, 防止同一骑士同时出现在训练和验证集 gkf GroupKFold(n_splits5) for fold, (tr_idx, va_idx) in enumerate(gkf.split(feat_df, feat_df[label], groupsfeat_df[knight_id])): tr, va feat_df.iloc[tr_idx], feat_df.iloc[va_idx] dtr lgb.Dataset(tr[feature_cols], labeltr[label], categorical_featurecat_cols) dva lgb.Dataset(va[feature_cols], labelva[label], categorical_featurecat_cols) params { objective: multiclass, num_class: 5, learning_rate: 0.05, num_leaves: 96, min_data_in_leaf: 100, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, metric: multi_logloss, verbose: -1 } model lgb.train( params, dtr, num_boost_round2000, valid_sets[dva], valid_names[valid], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)] ) break # 先跑一折看效果, 再循环全量参数需要重点解释的只有四个num_leaves96是叶子数决定了树的复杂度特征多时可以调到 128但叶子数翻倍容易过拟合min_data_in_leaf100限制每个叶子最少样本数对分类不平衡非常重要取消类样本少这个值小了会导致叶子把噪声学进去feature_fraction0.8每棵树随机抽 80% 特征缓解特征间的多重共线性bagging_fraction0.8行采样给集成增加多样性。GroupKFold按骑士分组切分是为了防止同一个骑士的行为样本同时出现在训练和验证集里这一点在行为预测里极其关键后面避坑章节会展开。3.2 序列特征交给深度模型一维 CNN 或 LSTM 的输入构造光靠统计特征会丢掉行为之间的顺序信息。比如“接单-到店-取餐-送达”是正常流程“到店-取餐-送达-取消”就异常了。这个顺序信息用滑窗统计很难表达需要序列模型。但第一轮不推荐直接用 LSTM一维卷积网络1D CNN更快、更稳收敛也快对短序列长度 20~50的行为预测效果并不比 LSTM 差。import torch import torch.nn as nn class BehaviorCNN(nn.Module): def __init__(self, num_actions5, seq_len30, embed_dim16, feat_dim8): super().__init__() # 行为ID嵌入 连续特征拼接 self.action_embed nn.Embedding(num_actions, embed_dim) self.seq_len seq_len input_dim embed_dim feat_dim self.conv1 nn.Conv1d(input_dim, 64, kernel_size3, padding1) self.conv2 nn.Conv1d(64, 128, kernel_size3, padding1) self.pool nn.AdaptiveAvgPool1d(1) self.fc nn.Linear(128, num_actions) def forward(self, action_seq, cont_feat): # action_seq: [batch, seq_len] 行为ID序列 # cont_feat: [batch, seq_len, feat_dim] 每个行为时刻的连续特征 emb self.action_embed(action_seq) # [batch, seq_len, embed_dim] x torch.cat([emb, cont_feat], dim-1) # [batch, seq_len, input_dim] x x.permute(0, 2, 1) # Conv1d 需要 [batch, input_dim, seq_len] x torch.relu(self.conv1(x)) x torch.relu(self.conv2(x)) x self.pool(x).squeeze(-1) return self.fc(x)这段代码把行为序列变成定长 30 的矩阵每个时间步拼接一个 8 维连续特征向量。行为 ID 走 Embedding 层连续特征直接拼在最后。两层 Conv1d 的作用是捕捉相邻行为间的局部依赖比如“接单后 5 分钟内有没有到店”。AdaptiveAvgPool1d(1)把整条序列压成一个向量——这比只取最后一个时间步的输出更稳因为行为预测关心的是整段历史的综合状态而不是最后一个时刻的状态。序列输入怎么构造是这类模型的核心。把每个骑士的轨迹按时间排序以每个预测时刻为终点向前取 30 条行为记录不足 30 条的前置补零。连续特征可以是每个行为时刻的等待秒数、同骑士并发单数、当前小时段。这里有一个第一轮容易犯的错误把序列里的连续特征也做了全局归一化。序列特征应该按骑士做归一化否则模型会学到“这个骑士整体很快”而不是“这一单是否延迟”泛化能力会差很多。3.3 模型融合概率平均与 Stacking 的选择GBDT 和 CNN 的输出可以直接做概率平均这是第一轮性价比最高的融合方式。方法很简单把两类模型的验证集概率取加权平均权重用网格搜索在 0.6:0.4 到 0.9:0.1 之间找最优。加权顺序从 0.7:0.3 开始调GBDT 占更大权重通常是对的因为它吃到的特征更丰富。Stacking 在第一轮不建议做。原因之一是行为预测的样本往往来自少数骑士Stacking 的次级模型很容易把训练集里的骑士分布学进元特征换一批骑士就直接失效。另一个原因是 Stacking 需要额外的时间做交叉验证预测才能生成元特征第一轮花这个时间不如去调num_leaves和序列长度。概率平均虽然简单但在多分类任务上通常能把 logloss 降 0.02~0.04足够第一轮用了。4. 评估与阈值别让分数骗了你4.1 竞赛评估指标与本地验证的不一致第一轮的多分类任务常用 logloss 或加权 F1 做评估指标。logloss 衡量的是预测概率与真实标签的差距它惩罚“自信的错误”当模型用 0.9 的概率预测了错误类别损失远大于用 0.5 预测错误。这意味着你不仅要预测对还要把概率校准准。本地验证最怕的是随机切分。行为预测的数据天然是时间序列如果你用train_test_split(random_state42)同一个骑士的行为会被切到两边。模型在训练时已经“看到”了这个骑士的历史习惯验证时自然分数虚高。正确做法是按时间切分用前 70% 时间的数据训练后 30% 时间的数据验证。# 按时间而不是随机切分 feat_df feat_df.sort_values(ts).reset_index(dropTrue) split_idx int(len(feat_df) * 0.7) train_df feat_df.iloc[:split_idx] valid_df feat_df.iloc[split_idx:] # 还要确认骑士集合不重叠 train_knights set(train_df[knight_id]) valid_knights set(valid_df[knight_id]) overlap train_knights valid_knights print(f骑士重叠数量: {len(overlap)}) # 理想情况下应为0或不大于总骑士数的5%这段代码做了两件事按时间排序后取前 70% 做训练集检查训练和验证骑士集合的重叠。如果重叠数量很大说明骑士的行为模式在时间上变化太快或者数据生成机制有问题需要改用按骑士分组的时间切分——先按骑士 ID 分组再从每组里取前 70% 时间作为训练。这个逻辑比单纯按时间切分更符合竞赛“预测新骑士行为”的真实诉求。4.2 概率校准与阈值调整多分类模型的输出概率往往不够校准。LightGBM 的 multiclass 输出受树的数量和叶子数影响概率容易偏极端。加上类别不平衡取消类的预测概率通常被压低。如果你用 logloss 评估校准就是刚需。常用的校准方法是温度缩放Temperature Scaling对深度学习模型的 logits 做一个缩放LightGBM 的输出用 isotonic regression 校准也可以但要注意校准数据必须来自验证集不能复用训练集。from sklearn.isotonic import IsotonicRegression # valid_pred 是模型在验证集上的输出概率, shape(n, num_class) # valid_label 是验证集真实标签 calibrators {} for c in range(5): # 对每个类别分别做等渗回归 iso IsotonicRegression(out_of_boundsclip) iso.fit(valid_pred[:, c], (valid_label c).astype(int)) calibrators[c] iso # 推理阶段 # for c in range(5): # final_prob[:, c] calibrators[c].predict(raw_prob[:, c])等渗回归是不假设分布的校准方法对任何模型的输出都适用。每个类别单独校准是因为多分类里各类别的置信度分布完全不同——取消类的高置信度样本可能比送达类少得多。校准后概率的和不一定等于 1所以最后要做一次softmax归一化。第一轮调阈值优先级排在调参后面。除非你要做业务决策“预测概率超过 0.6 才提醒骑士有取消风险”这种需求才需要调阈值。竞赛打榜看的是整体指标阈值不是重点。把时间花在特征和模型上而不是死磕阈值。5. 避坑这几个翻车点我第一轮全踩过5.1 标签泄漏用“未来”特征训出高分模型现象是本地验证 logloss 低到 0.3 附近但提交后线上分数排名中等偏后。排查时发现线上的 logloss 比本地高出一大截。原因是我在构造特征时把“当前骑士是否在配送中”算成了特征而这个状态是由未来行为决定的——当时刻 T 骑士在配送中意味着 T 之后他经历了取餐和送达。训练时这个特征与标签高度相关模型靠它刷分但推理时这条信息根本拿不到。解决方法是把特征严格限定在“当前时刻之前已经产生的数据”。逐列检查每个特征构造它时是否用到了 T 时刻之后的信息。比如“骑士当前是否在配送中”不能用“骑士过去 30 分钟平均配送时长”可以用。检查标准是一条删除这条特征需要的信息在推理时刻是否已知。5.2 随机切分导致骑士信息穿越现象是 GroupKFold 的分数比随机切分低很多排行榜排名却反而上升。原因是随机切分把同一个骑士的样本拆到训练和验证两侧。GBDT 的树结构学到了“这个骑士的平均配送时长是 25 分钟”这种个体画像验证时直接套用分数虚高约 0.1 的 logloss。线上预测的是没有见过的新骑士样本个体画像失效分数立刻回落。解决方法是统一用 GroupKFold 或按时间切分。第一轮就按“验证集中不能出现训练集中见过的骑士”来设计切分。另外提交前一定要看验证分数和线上分数的差距如果差超过 0.1先怀疑切分再怀疑特征泄漏。5.3 类不平衡取消行为样本太少模型学不到规律现象是取消类的 F1 始终在 0.1 以下模型几乎把所有样本都预测成送达。原因是取消行为占全部样本可能只有 3% 到 5%LightGBM 的min_data_in_leaf如果不调叶子会把取消样本当噪声过滤掉。而取消行为恰恰是最有价值的预测目标——能提前预判取消就可以做运力调度干预。解决方法是双管齐下min_data_in_leaf调到 50 以下并配合scale_pos_weight加重少数类或者对取消类做 SMOTE 过采样。第一轮更推荐调min_data_in_leaf和is_unbalanceTrue因为这不会改变原始数据分布至少在验证时分数更可信。如果做了过采样验证时必须用原始分布评估否则分数会虚高。5.4 特征穿越用了全局统计量当特征现象是某个特征重要性排第一且单看这个特征和标签的相关系数高达 0.5。细看发现是“骑士平均配送时长”直接用了全量数据算的均值。原因是全局均值的计算里包含了预测时刻之后的行为时长。在特征工程阶段图省事直接groupby(knight_id)[duration].mean()就写了这句完全没意识到这是穿越。解决方法是把所有统计特征都限定在“当前时刻之前”。实现方式是必须用滑窗或者按时间筛选后再聚合而不是对全表聚合。一个排查小技巧把全局均值特征替换成同一骑士前 30 天的滚动均值看验证分数是否显著下降——如果下降了说明原特征确实在偷看未来。6. 第一轮的进阶收尾用最少特征跑进前排的验证技巧第一轮最后几天很多队伍会疯狂加特征、换模型结构。但如果你想稳定出分反而要先做减法。我常用的一个方法是“单特征消融”把每个特征单独从特征集里去掉重新训练同一组参数记录验证分数的变化。去掉某个特征后分数升高说明这个特征是噪声分数大幅升高说明这个特征在泄漏。这个消融要趁早跑因为越到后期特征越多每组消融的成本越高。另一个高效的验证技巧是“时间外推测试”。用前 60% 时间训练、中间 15% 验证找最佳迭代轮数再用最后 25% 测试。这个测试的唯一目的是确认你的模型不是靠记忆样本分布撑起来的。如果你的 LightGBM 在时间外推测试里 logloss 升高超过 0.08说明特征里的时间衰减信号没有被充分刻画需要回到窗口特征里重新加“距离下一高峰的小时数”这类时间交叉特征。关于深度模型的投入我一般按这个标准判断如果 1D CNN 在相同特征输入下比 LightGBM 的 logloss 低 0.02 以上就值得投入做序列建模如果差距在噪声范围内不如把时间花在融合上。我做过一次对比发现加了“骑士并发单数”和“等待时长”序列特征后CNN 开始有明显优势说明序列模型的收益主要来自连续特征而不是行为 ID 本身。最后说一个我自己的坏习惯第一轮总是不停地调参总感觉模型还能涨零点几个百分点结果排在最后一晚才发现特征泄漏所有分数都是虚的。后来我给自己定了个规矩——每调一次参先用时间外推测试验证一遍不再用随机切分自欺欺人。第一轮不用追求把模型做到极限把数据吃透、切分做对、特征不泄漏这个方案就足够让你稳定站在排行榜中上游。希望这份方案能帮你在第一轮少踩几个坑把时间花在真正能提分的事情上。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →