☰
智慧物流骑手行为预估:从轨迹清洗到LightGBM模型实战
2026/9/25 4:32:28 网站建设 项目流程

简介:这份资源是饿了么“骑士行为预估比赛”第一轮的完整参赛项目源码,面向具备机器学习与深度学习基础、希望参与智慧物流赛题实战的数据科学学习者与算法工程师。压缩包共66个文件,约102MB,以33个txt数据说明、18个ipynb实验笔记、5个py脚本及pickle模型文件为主,覆盖数据整理、特征生成、训练增强与预测全流程。已有295人学习下载。资源围绕新冠疫情期间骑手下一步行动预测展开,包含数据清洗、特征工程、GBDT与回归任务建模、模型调参与评估等模块,读者可据此复现赛题方案,理解序列行为预测中LSTM、Transformer等模型的应用思路,并借鉴交叉验证、AUC-ROC评估与正则化防过拟合的实践方法,适合作为智慧物流方向竞赛入门与进阶的参考项目。

1. 智慧物流场景下,骑手行为预估到底在预估什么

2020 年初那段时间,外卖订单的时空分布被彻底打乱:商圈闭店、小区封闭、无接触配送上线,骑手在午高峰的取送路线和平时完全不是一套逻辑。饿了么办了一场骑士行为预估比赛,第一轮的任务很聚焦——给定骑手当前一段轨迹和订单上下文,预测他下一步会去哪里、做什么动作。这不是学术玩具,它直接对应调度系统里最贵的一个问题:运力在哪里、下一分钟往哪走。

把这个问题拆开看,它属于智慧物流里的时空行为预测分支,输入是轨迹点序列、订单状态、时间戳、区域属性,输出是骑手下一跳的位置或行为类别。适合谁做?做即时配送调度、网约车派单、同城即配路径优化的工程师,以及想拿真实业务序列练手的数据挖掘同学。它和普通时序预测的区别在于:骑手是带强意图的智能体,轨迹里混着等餐、绕路、临时改派,噪声结构比传感器数据脏得多。

2. 从原始日志到可训练样本:骑手轨迹的清洗与特征构造

2.1 先搞清楚一条样本长什么样

比赛给的数据通常是脱敏后的骑手轨迹日志,常见字段包括骑手 ID、时间戳、经纬度、订单 ID、事件类型(取餐、送达、到店、等餐)、区域编码。第一轮任务一般要求预测「下一步行为」,所以样本构造的核心是把连续轨迹切成「历史窗口 + 预测目标」的监督对。

我一般会先做一件事:按骑手 ID 和时间戳排序,把同一骑手在同一次配送任务内的点串成一条序列。跨任务的轨迹不能混,否则模型会学到「上一单终点到下一单起点」这种无意义跳变。判断任务边界靠订单 ID 变化或事件类型里的「送达」标记。

import pandas as pd # 假设原始日志字段:rider_id, ts, lng, lat, order_id, event_type df = pd.read_csv("rider_log.csv", parse_dates=["ts"]) df = df.sort_values(["rider_id", "ts"]).reset_index(drop=True) # 用订单变化 + 送达事件切分配送段 df["new_seg"] = ( (df["order_id"] != df["order_id"].shift(1)) | (df["event_type"].shift(1) == "delivered") ).astype(int) df["seg_id"] = df.groupby("rider_id")["new_seg"].cumsum() # 每段至少保留 5 个点,太短的段信息不足 seg_len = df.groupby(["rider_id", "seg_id"]).size() valid_segs = seg_len[seg_len >= 5].index df = df.set_index(["rider_id", "seg_id"]).loc[valid_segs].reset_index()

这段代码的逻辑是:先排序保证时序正确,再用订单切换和送达事件标记新配送段,最后过滤掉点数过少的段。参数上,>=5这个阈值不是固定的,如果你们业务里短单多,可以降到 3,但低于 3 个点的段基本只能看出起点和终点,预测价值很低。

2.2 特征工程:把经纬度变成模型能吃的信号

原始经纬度直接喂给树模型效果一般,因为模型很难从绝对坐标里学到「他在商圈里还是在高架上」。常见做法是构造相对特征和上下文特征:

  • 位移类:相邻点距离、速度、方向角变化
  • 时间类:小时、是否午晚高峰、距上一事件的时间差
  • 区域类:网格编码(把城市切成 500m×500m 的格子)、POI 密度
  • 订单类:当前订单剩余距离、是否已取餐
import numpy as np def haversine(lng1, lat1, lng2, lat2): R = 6371.0 phi1, phi2 = np.radians(lat1), np.radians(lat2) dphi = np.radians(lat2 - lat1) dlambda = np.radians(lng2 - lng1) a = np.sin(dphi/2)**2 + np.cos(phi1)*np.cos(phi2)*np.sin(dlambda/2)**2 return 2 * R * np.arcsin(np.sqrt(a)) df["dist_prev"] = haversine( df["lng"].shift(1), df["lat"].shift(1), df["lng"], df["lat"] ) df["time_gap"] = df.groupby(["rider_id", "seg_id"])["ts"].diff().dt.total_seconds() df["speed"] = df["dist_prev"] / df["time_gap"].replace(0, np.nan) df["hour"] = df["ts"].dt.hour df["is_peak"] = df["hour"].isin([11, 12, 17, 18]).astype(int) # 500m 网格编码,纬度 1 度约 111km,经度按纬度收缩 df["grid_x"] = (df["lng"] * 111 * np.cos(np.radians(df["lat"])) / 0.5).astype(int) df["grid_y"] = (df["lat"] * 111 / 0.5).astype(int)

haversine算的是球面距离,比欧氏距离更准,尤其在城市尺度跨纬度时。time_gap为 0 的情况要替换成 NaN,否则速度会变成无穷大。网格大小 0.5km 是我在即时配送场景里常用的粒度,太细会稀疏,太粗会丢失商圈内部差异。

2.3 标签怎么定:下一跳位置还是下一跳行为

第一轮比赛通常两种标签都可能有。如果是位置预测,标签是下一个轨迹点的网格 ID;如果是行为预测,标签是下一个事件类型。我的建议是:先做行为分类,再做位置回归。原因是行为类别少、噪声相对可控,能快速验证特征是否有效;位置预测的搜索空间大,直接上容易翻车。

# 行为标签:取当前点之后的下一个事件类型 df["next_event"] = df.groupby(["rider_id", "seg_id"])["event_type"].shift(-1) train = df.dropna(subset=["next_event"]).copy() # 位置标签:下一个点的网格 df["next_grid"] = df.groupby(["rider_id", "seg_id"])["grid_x"].shift(-1).astype("Int64")

注意shift(-1)之后最后一行会变成 NaN,必须 drop 掉,否则训练时标签泄漏。这个坑我见过不止一次,有人忘了 drop,线下 AUC 高得离谱,线上直接崩。

3. 模型选型:为什么我第一轮用 LightGBM 而不是 LSTM

3.1 结构化特征 + 树模型是第一轮的稳妥起点

骑手行为预估的输入里,大量是结构化特征:距离、时间差、网格、订单状态。这类特征树模型处理起来非常自然,缺失值不用填、类别特征可以直接编码、训练快、可解释。LSTM 或 Transformer 虽然能建模序列依赖,但在第一轮数据量有限、特征工程还没吃透的情况下,往往打不过调好的 LightGBM。

我的判断标准很简单:如果单条样本的特征已经能表达「他在哪、要去哪、还有多远」,树模型就够了;只有当特征里缺少长程依赖、必须靠历史序列才能推断意图时,才上序列模型。第一轮通常属于前者。

import lightgbm as lgb from sklearn.model_selection import GroupKFold features = ["dist_prev", "time_gap", "speed", "hour", "is_peak", "grid_x", "grid_y"] cat_features = ["is_peak"] X = train[features] y = train["next_event"].astype("category").cat.codes groups = train["rider_id"] params = { "objective": "multiclass", "num_class": y.nunique(), "learning_rate": 0.05, "num_leaves": 63, "min_data_in_leaf": 50, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 1, "verbose": -1 } gkf = GroupKFold(n_splits=5) for tr_idx, val_idx in gkf.split(X, y, groups): dtrain = lgb.Dataset(X.iloc[tr_idx], y.iloc[tr_idx], categorical_feature=cat_features) dval = lgb.Dataset(X.iloc[val_idx], y.iloc[val_idx], categorical_feature=cat_features) model = lgb.train(params, dtrain, num_boost_round=500, valid_sets=[dval], callbacks=[lgb.early_stopping(50)])

这里用GroupKFold而不是普通 KFold,是因为同一骑手的样本高度相关,随机切分会让验证集里出现训练集见过的骑手,线下分数虚高。按骑手分组能模拟「新骑手」场景,更接近线上真实分布。min_data_in_leaf=50是为了防止模型记住个别骑手的特殊路径,第一轮数据噪声大,叶子太细容易过拟合。

3.2 序列模型什么时候值得上

如果你发现行为预测的准确率卡在某个瓶颈,且错误集中在「等餐后是继续等还是去取下一单」这类需要看更长历史的场景,那可以考虑序列模型。常见做法是用 GRU 或 Transformer 编码最近 20 个轨迹点,再把编码向量和结构化特征拼接后分类。

但要注意:序列模型的训练成本高,调参空间大,第一轮如果时间有限,不建议一上来就搞。我一般会先用 LightGBM 跑出一个 baseline,看混淆矩阵里哪些类别容易混,再决定要不要上序列模型补长程依赖。

# 序列模型输入构造示意:每个样本取最近 20 个点的特征序列 SEQ_LEN = 20 seq_cols = ["dist_prev", "time_gap", "speed", "grid_x", "grid_y"] def build_sequences(df, seq_len): seqs, labels = [], [] for (rid, sid), g in df.groupby(["rider_id", "seg_id"]): g = g.sort_values("ts") arr = g[seq_cols].fillna(0).values lab = g["next_event"].values for i in range(seq_len, len(g)): seqs.append(arr[i-seq_len:i]) labels.append(lab[i]) return np.array(seqs), np.array(labels)

SEQ_LEN=20是经验值,对应骑手大约 5 到 10 分钟的轨迹。太长会引入无关历史,太短捕捉不到等餐后的决策模式。这个参数建议用验证集扫一遍,别拍脑袋。

4. 避坑与排查:第一轮最容易翻车的五个地方

4.1 现象:线下准确率 0.9,线上提交掉到 0.6

原因:样本构造时用了未来信息。最常见的是shift(-1)之后没 drop 最后一行,或者特征里混入了「当前订单是否已送达」这种标签泄漏字段。

解决:构造完训练集后,逐列检查特征与标签的时间关系。任何在预测时刻之后才能知道的字段,一律删掉。我习惯在代码里加一个断言:特征列的时间戳必须全部小于标签时间戳。

4.2 现象:模型把大部分样本预测成「等餐」

原因:类别极度不平衡。骑手轨迹里等餐事件占比可能超过 50%,模型学到「全猜等餐」就能拿高准确率。

解决:换评估指标,用 macro-F1 或 per-class recall,别只看 accuracy。训练时设class_weight或对少数类过采样。LightGBM 里可以用is_unbalance=True,但更稳的做法是手动调权重。

4.3 现象:验证集分数波动很大,换个随机种子结果差 5 个点

原因:骑手之间行为差异大,某些骑手的轨迹模式特殊,如果验证集里恰好分到几个异常骑手,分数就会抖。

解决:用 GroupKFold 按骑手分组,并且多跑几个种子取平均。如果波动仍然大,说明数据量不够,考虑合并行为类别,把「到店」和「等餐」合成「在店」一类。

4.4 现象:网格编码后特征重要性很低

原因:网格 ID 是类别特征,但直接当成数值喂给模型,模型会认为 grid_x=100 和 grid_x=101 有数值关系,实际上它们只是相邻格子。

解决:网格 ID 要么做 target encoding,要么用 embedding。树模型里可以把它当类别特征声明,但高基数类别(几千个格子)效果有限。我一般会额外构造「网格内历史订单密度」这类统计特征,比裸网格 ID 有用得多。

4.5 现象:预测出的下一跳位置在河里或楼顶上

原因:位置预测没有加空间约束,模型回归出的坐标可能落在不可达区域。

解决:把连续坐标预测改成网格分类,或者在后处理阶段把预测点 snap 到最近的道路网格。比赛里如果评估指标是距离误差,snap 之后通常能降 10% 到 20% 的误差。

5. 进阶技巧:用目标编码和后处理把第一轮分数再抬一截

5.1 目标编码处理高基数区域特征

网格 ID、商圈 ID 这类特征基数高,One-Hot 会爆炸,直接当数值又不对。目标编码(target encoding)是常见解法:用该类别下标签的统计量替换原始值。但必须用交叉验证的方式算,否则会泄漏。

from sklearn.model_selection import KFold def target_encode(train, val, col, target, smooth=10): prior = train[target].mean() agg = train.groupby(col)[target].agg(["mean", "count"]) agg["encoded"] = (agg["mean"] * agg["count"] + prior * smooth) / (agg["count"] + smooth) return val[col].map(agg["encoded"]).fillna(prior) # 在 GroupKFold 内部对每个 fold 单独算 for tr_idx, val_idx in gkf.split(X, y, groups): tr, val = train.iloc[tr_idx], train.iloc[val_idx] val["grid_enc"] = target_encode(tr, val, "grid_x", "next_event")

smooth=10是平滑参数,防止样本量少的格子编码值极端。这个值越大,编码越靠近全局均值,适合噪声大的场景。我一般会在 5 到 20 之间扫一下。

5.2 后处理:用转移概率矩阵修正预测

骑手行为有很强的物理约束:不可能从「等餐」直接跳到「送达」,中间必须经过「取餐」。如果模型预测出这种非法转移,可以用历史转移概率矩阵做修正。

当前状态下一状态候选历史转移概率修正动作
等餐取餐0.72保留
等餐送达0.01降权
取餐送达0.85保留
取餐等餐0.03降权

具体做法是:模型输出各类别概率后,乘以转移概率矩阵对应列,再重新归一化。这一步在比赛里经常能带来 1 到 2 个点的提升,而且实现成本很低。

# trans_mat[i][j] 表示从状态 i 转移到状态 j 的历史频率 trans_mat = np.array([[...], [...]]) # 按实际类别顺序填 proba = model.predict(X_val) # shape: (n, n_class) adjusted = proba * trans_mat[current_state] # 按当前状态取对应行 adjusted = adjusted / adjusted.sum(axis=1, keepdims=True)

注意current_state要从特征里取,不能用标签。这一步的逻辑是:模型给的概率是「全局看起来像什么」,转移矩阵给的是「物理上可能是什么」,两者相乘相当于加了约束。

5.3 我自己的习惯

第一轮比赛我从来不上复杂模型,先把特征和验证框架搭稳,用 LightGBM 跑出一个可信的 baseline,再逐步加序列特征、目标编码、后处理。每次只改一个变量,记录线下和线上的变化。这样即使最后没拿名次,也知道哪一步真正有用。希望帮到你。

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

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

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

立即咨询