简介:一份基于阿里天池大赛学习赛的天猫复购预测完整案例,面向计算机相关专业学生、算法初学者或毕业设计人群,适合用来学习用户行为分析与机器学习分类建模。项目覆盖从数据清洗、特征探索到逻辑回归与随机森林模型训练,以及复购结果可视化对比,可帮助读者系统理解复购预测的完整流程。资源共7个文件,包含3个CSV数据文件分别用于用户信息与训练/测试样本,2个Python脚本对应两种经典分类算法,1个Jupyter Notebook用于天猫复购数据分析与可视化,另有README说明与目录指引,压缩包仅4.01MB,轻量易部署。已有488人学习浏览,代码均测试运行成功,上传前经过验证;除模型实现外,还提供可视化分析示例,可作为课程设计、项目立项演示或天池入门赛的参考模板。下载后建议先查看README了解文件间关系,再按Notebook顺序逐步运行,便于快速复现与二次修改。
1. 这道学习赛难的不是模型,是特征边界
如果你刚打开天猫复购预测这套案例,第一反应大概率是"不就是二分类吗"。用户行为数据给你,预测某个时间点之后用户会不会再买,套路一搜一大把。但真正跑一轮你会发现,线下 AUC 和线上排名经常对不上,模型能调的空间很小,排名拉开全在特征工程和时间边界上。这个赛题能解决的,是电商运营里非常实际的问题:老客召回与触达。相比投放拉新,老客复购的成本低得多,数据团队要的是给每个用户一个复购概率,按概率排序决定触达优先级。适合入门表格型机器学习、准备打比赛、或者正在做用户增长与会员运营的数据同学,照着这套思路完整落一遍。
2. 把复购预测拆成一道二分类题:业务口径、数据形态和评估指标
2.1 观察窗口与预测窗口:先定义清楚"复购"的范围
复购预测不是"用户未来会不会买东西",而是一道有明确时间边界的二分类:给你一段观察窗口内(记为 T0 到 T1)的用户行为,让你预测 T1 之后 N 天内(预测窗口)用户是否有购买行为。这个 N 在赛题上通常写成"未来一周"或"未来一个月",具体天数以你拿到的赛题说明为准。我一般拿到需求会先把时间窗口画在纸上,后面的所有特征构造、验证集切分、甚至提交文件的行数,都由这个窗口定义决定。
一个容易被忽略的口径问题是:一个用户在观察窗口内产生过购买行为才算"复购",还是只要在预测窗口内买了就算正样本?如果赛题没有明说,常见做法是要求训练集中的用户在观察窗口内必须有一次购买,然后预测他在预测窗口内是否再次购买。要不要过滤掉"在预测窗口第一次购买"的用户,得看官方基线的定义。建议在数据分析阶段先把两类用户的比例算出来,这直接决定后面的正负样本比例,以及要不要调 scale_pos_weight 或 is_unbalance。
还有一个人很常见的粒度错误:把每条行为当作一个样本。正确形态是 user_id 一行一个样本,所有行为特征聚合到这一行上。标签是"该用户在预测窗口内是否复购",样本量是用户数,不是行为数。如果某个用户有 500 条行为,他在数据集里只占一行。先把这句话想清楚,后面所有 groupby 代码才不会跑偏。
窗口天数不是随便定的,它和品类的复购周期强相关。如果这份数据里日用品的平均购买间隔在 15 天左右,预测窗口 7 天就是一个偏短的设定,意味着正样本比例会比较低。跑特征之前先算一个全局平均购买间隔,能帮你判断窗口合不合理。如果平均购买间隔远大于预测窗口,复购预测就更偏向"近期强意图用户筛选",而不是周期性复购,特征设计也要跟着调整。
2.2 用GAUC评估排序质量,而不是只盯着全局AUC
赛题的评估指标可能是 AUC 或 GAUC。GAUC 是分组 AUC,按用户分组计算每个用户内部的 AUC 再加权平均:GAUC = Σ(w_u × AUC_u) / Σ(w_u)。权重 w_u 可以取该用户在评估期内的购买次数,也可以等权。训练时如果框架只能监控 AUC,我会在验证脚本里额外算一版 GAUC,用它作为最终调参依据。
为什么 GAUC 比全局 AUC 更能说明问题?复购预测的落地方式通常是运营按用户优先级做触达,本质是"用户内排序"。全局 AUC 会被高频用户主导,而这些用户往往是最容易预测的;GAUC 把每个用户拉平再平均,能看出你在中低频用户上是否真的分得开。
GAUC 的坑在于:如果某个用户正负样本数量悬殊,他的 AUC 要么接近 0 要么接近 1,加权后噪声很大。常见改进是只对样本数达到一定阈值的用户计 GAUC,或者用购买次数做平滑权重。比赛页面不一定写这些,但自己评估时心里要有数。
实际计算 GAUC 不需要太复杂的代码:给每个用户算出预测分数和标签后,用 roc_auc_score 逐用户算,再按权重平均。注意用户在验证集上行为很稀疏时,AUC 经常是 nan,需要跳过或赋默认值 0.5。到底哪些用户权重更高,取决于你最终触达策略更关注谁,这属于业务判断,不是模型能替你决定的。
顺带说一句,不要用 accuracy 做这个赛题的主评估。正负样本通常严重不平衡,全预测负样本也能拿到 90% 以上的 accuracy,没有任何业务意义。AUC 和 GAUC 只关心排序,不关心绝对阈值,和运营场景天然匹配。
2.3 行为表里到底有什么:从四类行为还原用户意图
这份案例的数据主体是一张用户行为表,字段大致包括 user_id(用户)、item_id(商品)、behavior_type(行为类型)、time(行为时间),关联表还可能带上 category_id(类目)、brand_id(品牌)、seller_id(卖家)。behavior_type 通常是枚举值,比如浏览、收藏、加购、购买,有些数据用数字 1/2/3/4 编码。动手前先做一次枚举分布统计,把每种行为在观察窗口内的占比打印出来,比直接跑模型重要得多。
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | int | 用户标识 |
| item_id | int | 商品标识 |
| behavior_type | str/int | 行为类型枚举 |
| time | datetime | 行为发生时间 |
| category_id | int | 商品类目 |
四类行为的业务含义完全不同。浏览是最浅的意图信号,量大但噪音大;加购和收藏是强意图信号,尤其是"加购后未购买"的记录,在很多复购模型里是贡献前三的特征;购买是最强反馈但最稀疏。做特征时不要把四种行为简单加成一个"总行为数"。我一般会分开统计,再用比率把它们关联起来,比如"加购转购买率""收藏转购买率"。
从业务角度看,复购预测的本质不是"预测下一次点击",而是"从行为序列里推断购买意图的残留强度"。加购后一周内复购和加购后三十天复购,驱动因素完全不同,前者可能是活动钩子,后者可能是周期性补货。第三章的特征设计基本都沿这条思路展开。
常见做法是先把行为表读进来,按枚举统计一遍,确认数据口径没有异常:
import pandas as pd behavior_df = pd.read_csv("user_behavior.csv", parse_dates=["time"]) print(behavior_df["behavior_type"].value_counts()) print(behavior_df["time"].min(), behavior_df["time"].max())读出来的分布会告诉你四类行为的占比是否合理,比如购买行为只占 2%,加购占 5%。如果比例明显异常,先检查数据过滤逻辑,不要急着往下走。time 的 min/max 也要和赛题说明对照,确认观察窗口是否已经帮你切好。这一步花的五分钟,能省掉后面排查泄露的两小时。
3. 特征工程决定排名:RFM、行为序列与滑窗统计
3.1 用户侧统计特征:把几百条行为折叠成一行
拿到行为表之后,第一个任务是把每个用户的几百条行为折叠成一行。Pandas 的 groupby + agg 就能完成大部分工作。常用的统计特征有:总行为数、浏览数、收藏数、加购数、购买数;购买商品去重数、加购商品去重数、活跃天数、购买天数;最后一次购买距离观察窗口结束的间隔、平均行为间隔。
import pandas as pd import numpy as np behavior_df["time"] = pd.to_datetime(behavior_df["time"]) train_df = behavior_df[behavior_df["time"] < as_of] user_feat = train_df.groupby("user_id").agg( total_actions=("item_id", "count"), n_buy=("behavior_type", lambda x: (x == "buy").sum()), n_cart=("behavior_type", lambda x: (x == "cart").sum()), n_fav=("behavior_type", lambda x: (x == "fav").sum()), n_pv=("behavior_type", lambda x: (x == "pv").sum()), active_days=("time", lambda x: x.dt.date.nunique()), )lambda 在这个数据量下能跑,但速度一般。数据量大时,先用布尔掩码筛出购买行为再分组,会快很多。注意这一版没有统计"购买商品去重数",因为直接在全部行为上 nunique 得到的是浏览商品数;要算购买商品数,需要先筛 behavior_type 为 buy 再按 item_id nunique。agg 的列名我用元组方式重命名,是为了后面 merge 时语义清楚,避免生成容易混淆的默认列名。
提示:as_of 的取值必须是观察窗口截止时间的 0 点,过滤条件用严格小于号,避免把预测起始日当天的行为算进特征。这个约定在第三章所有特征里通用。
接下来是 RFM 特征:R 是最后一次购买距离 as_of 的天数,F 是购买行为天数除以活跃天数,以及相邻两次购买的平均间隔,M 在没有金额字段时用购买类目数和购买商品数代替。这些特征和上面的计数特征合在一起,就是用户侧主特征。RFM 的经典之处在于它把"最近一次、频率、广度"三维拆开,树模型能针对不同用户群单独分裂,比单纯的总购买次数要好用。
3.2 商品、卖家与类目侧特征:把热度信息搬回用户行
用户侧特征只描述"用户有多活跃",但没描述"他碰到的商品长什么样"。一个总买爆款的用户和一个专逛长尾商品的用户,复购模式完全不同。所以还要从商品、卖家、类目维度做统计,再 merge 回用户。商品热度特征包括商品在观察窗口内的被购买次数、被加购次数、加购转购买率;卖家特征包括卖家下商品总数、卖家下总购买行为数;类目特征包括类目购买人数、类目平均购买间隔。
这里最大的坑是商品热度统计必须只用观察窗口内的数据。如果一个商品的热度混进了预测窗口内的购买次数,相当于把答案写进了特征。我一般把"as_of 之前"抽成一个工具函数,所有特征生成都复用同一个入口,避免不同脚本过滤条件不一致。
buy_df = train_df[train_df["behavior_type"] == "buy"] item_buy_cnt = buy_df.groupby("item_id").size().rename("item_buy_cnt") user_item = train_df[["user_id", "item_id"]].drop_duplicates() user_item = user_item.merge(item_buy_cnt, on="item_id", how="left") item_feat = user_item.groupby("user_id").agg( avg_item_buy_cnt=("item_buy_cnt", "mean"), max_item_buy_cnt=("item_buy_cnt", "max"), hot_item_ratio=("item_buy_cnt", lambda x: (x >= 100).mean()), )这段的逻辑是:先算商品购买热度,再把它按用户关联并聚合成用户侧特征。hot_item_ratio 表示"用户浏览的商品里有多大比例是高热度商品",对区分追爆款用户和逛长尾用户很有用。阈值 100 是经验值,上线前可以用全局商品购买次数的分位数来定,不要拍脑袋写死。seller_feat 和 category_feat 是同一套逻辑,分别替换分组键和聚合字段,最后把四个特征表按 user_id merge 成宽表。宽表字段控制在 30 到 60 个,LightGBM 在这个量级上效果和训练速度都很理想。
3.3 时间衰减与滑窗特征:把"最近"和"历史"拆开
复购预测里最强的行为信号通常是最近一周的行为。用户在预测窗口前七天加购过,跟他在前三个月加购过,完全是两个等级的信号。但所谓"更强"不是线性的,而是随时间指数衰减。所以我经常给每条行为加一个时间衰减权重:w = exp(-α × (T - t) / 86400),T 是观察窗口截止时间,t 是行为时间,α 控制半衰期。α=0.05 时半衰期约 14 天,适合复购周期偏短的品类;如果客单价高、购买周期长,可以降到 0.02。
更朴素也更好解释的做法是滑窗计数:把观察窗口切成若干段,分别统计每一段的行为数和购买数。比如 30 天窗口切成 [0-7], [7-14], [14-21], [21-30] 四段,模型能从"最近一周购买数"和"倒数第二周购买数"的差里学到趋势变化:最近一周活跃度下滑的用户,复购概率往往会下降。
train_df["days_to_end"] = (as_of - train_df["time"]).dt.days train_df["week_bucket"] = np.ceil(train_df["days_to_end"] / 7).astype(int) weekly = train_df[train_df["behavior_type"] == "buy"].groupby( ["user_id", "week_bucket"] ).size().unstack(fill_value=0).add_prefix("buy_cnt_week_")unstack 之后每个周段是一列,列名包含 buy_cnt_week_1 到 buy_cnt_week_4。缺失周用 fill_value=0 填平。滑窗特征和全量统计特征一起进树模型时,树的特征选择能力会自己决定该用哪一段。如果你看特征重要性,发现 week_1 的排名远高于 week_4,说明特征行为符合业务直觉,也侧面印证时间口径没写错。如果相反,就要检查是不是滑窗方向切反了。
3.4 交叉特征与 target encoding 的边界
交叉特征指 user_id × category_id、user_id × seller_id 这种细粒度交互。比如"用户在某类目下的购买次数",直接刻画用户在这个类目的深耕程度,通常是很强的特征。但越强越容易过拟合,因为分箱太细,很多分值只覆盖少量样本。
简单且稳的做法是比例特征:用户在该类目的购买次数除以用户总购买次数。它天然消除了不同活跃度用户的尺度差异。target encoding 是更激进的做法:用"该类目下用户的复购率"作为类目编码值。它的平滑公式是 enc = (cnt × rate + k × global_rate) / (cnt + k),其中 cnt 是类目样本数,rate 是类目复购率,global_rate 是全局复购率,k 通常取 10 到 50。实现时必须用 K-fold 在训练集内做编码,测试集使用全量训练集得到的编码,否则会把验证期的信息带进来。
我对新手的建议是:头一版先不上 target encoding,用比例特征顶上。等 LightGBM 分数到瓶颈,再引入 K-fold target encoding,同时盯住"验证集分数和测试集分数差距"。如果差距突然拉大,先怀疑编码泄露,再怀疑模型过拟合。target encoding 是典型的黑匣子,它提高线下分数很容易,但要提高到线上也稳定,需要扎实的交叉验证配合,没有捷径。
4. 用LightGBM训练复购模型:时序验证与参数调节
4.1 为什么选LightGBM而不是深度学习
特征工程完成后,模型选择基本不用纠结。这个赛题是用户数几十万、特征几十个的表格数据,LightGBM 是默认第一选择。它对特征缩放不敏感,省掉归一化;自带缺失值处理,稀疏列不用填;叶子节点分裂能自动做特征组合,省掉手动交互特征。深度学习在序列建模上上限更高,但在这种以统计特征为主的场景,同等效果下要花更多时间调参,数据量不够时还容易过拟合。
我也见过用 GRU 直接建模用户行为序列的方案,那是另一种技术路线,需要重新组织数据、设计序列采样、处理序列长度,训练时间也翻很多倍。作为学习赛,先把 LightGBM 这条路走到极致,拿到稳定分数后,再决定要不要往序列方向探。很多参赛者的经验是:特征没做透之前,换模型带来的提升远小于换特征。
4.2 训练代码与数据集划分
复购预测里,验证集怎么切比模型参数更重要。标准做法是按时序切:把观察窗口前 80% 的时间段作为训练特征,后 20% 作为验证特征,标签分别是各自窗口结束后的预测窗口购买情况。这样模拟真实预测场景:只有过去的数据能用来预测未来。随机切分会把同时期数据分到两边,线下 AUC 虚高,上线就露馅。
import lightgbm as lgb from sklearn.metrics import roc_auc_score feature_cols = [c for c in user_wide.columns if c.startswith("feat_")] X_train = user_wide[user_wide["part"] == "train"][feature_cols] y_train = user_wide.loc[user_wide["part"] == "train", "label"] X_valid = user_wide[user_wide["part"] == "valid"][feature_cols] y_valid = user_wide.loc[user_wide["part"] == "valid", "label"] dtrain = lgb.Dataset(X_train, label=y_train) dvalid = lgb.Dataset(X_valid, label=y_valid, reference=dtrain) params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 31, "max_depth": -1, "min_data_in_leaf": 50, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 1, "is_unbalance": False, "seed": 42, } model = lgb.train( params, dtrain, num_boost_round=2000, valid_sets=[dvalid], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(50)], )几个容易踩的位置说明一下。valid_sets 里的 dvalid 写 reference=dtrain,是让 LightGBM 保持 categorical feature 的一致性,虽然这里全是数值特征,但保留这个习惯没坏处。is_unbalance 我没有直接开,建议先看一眼正负样本比例再决定;如果正样本只有 5% 到 10%,开 is_unbalance=True 或用 scale_pos_weight 平衡,比随机下采样更稳。early_stopping 的 100 轮是经验值,learning_rate 越小,这个值可以越保守。训练时盯着日志里的 valid_auc 看,如果它还在上升但斜率已经很平,可以手动提前停。
4.3 三个必调参数:num_leaves、learning_rate、min_data_in_leaf
LightGBM 参数很多,但把三个调明白就能拿到大部分调参收益。第一个 num_leaves 控制树的复杂度,值越大模型越能拟合细小模式,也越容易过拟合。从 31 开始试,上下探 16 和 63,观察 valid_auc 是先涨后跌还是稳稳上升。第二个 learning_rate 控制收敛步长,小学习率配合多轮通常精度更高,但训练更慢。第三个 min_data_in_leaf 是防止过拟合的保险杠,叶子节点样本数太小,模型就会为几个样本单独分叉,把噪音学进去。
| 参数 | 作用 | 常见区间 | 调参建议 |
|---|---|---|---|
| num_leaves | 控制树规模与模型复杂度 | 16-128 | 从小往大调,valid_auc 开始掉就回头 |
| learning_rate | 控制每轮学习步长 | 0.01-0.1 | 时间充裕用 0.05,配合 early stopping |
| min_data_in_leaf | 叶子最少样本数 | 50-300 | 数据量小就调大,防止过拟合 |
三个参数的关系:num_leaves 决定模型复杂度上限,learning_rate 决定收敛速度,min_data_in_leaf 决定叶子节点能不能只照顾少数样本。新手最常见的错误是只调 num_leaves 和 learning_rate,忽略 min_data_in_leaf,导致树长得又深又碎,内存和时间都翻倍,分数却没有提升。调参不是玄学,核心就是这三件事:别让树太复杂,别让步子太大,别让叶子太孤单。
4.4 预测与提交格式
训练完成后,用最优迭代次数预测测试用户,生成提交文件。复购预测的提交通常是 user_id 和 probability 两列,评估方拿概率排序算 AUC 或 GAUC。
test_user_wide = pd.read_parquet("test_user_wide.parquet") test_prob = model.predict(test_user_wide[feature_cols], num_iteration=model.best_iteration) sub = pd.DataFrame({ "user_id": test_user_wide["user_id"], "probability": test_prob, }) sub.to_csv("submission.csv", index=False)predict 时指定 num_iteration=model.best_iteration,确保用的是验证集上最优的迭代次数,而不是最后一轮。提交前做两个检查:行数是否等于测试用户数,概率值是否都在 [0,1]。如果 probability 大量集中在 0.5 附近,先别急着调模型,回头检查特征截断和时间口径,大概率是 as_of 边界没对齐。这种情况下调多少轮 LightGBM 都救不回来。
5. 复购预测的5个经典大坑:几个让分数对不上线的排查方法
5.1 特征里混进了"未来"数据:时间穿越泄露
现象:离线验证 AUC 0.85,线上分数掉到 0.75 以下。原因:特征用到了观察窗口之后的数据。最常见的是对全量数据集做商品热度和用户总行为统计,而这些统计量天然包含了测试期的购买事件,模型等于提前看到了答案。排查方法:找出特征重要性最高的几个特征,人工验证该特征的统计是否只来自 as_of 之前。如果某个商品热度特征的 importance 异常高,十有八九是泄露。解决:所有特征聚合统一在 as_of 边界前完成,这是整个赛题里最值得背下来的血泪经验。
5.2 把预测起始日当天的行为算进特征
现象:线下验证稳定,线上明显偏低。原因:特征统计截止时间写成了预测起始日当天 24 点,而当天在真实线上场景还没发生。比如预测 1 月 8 日之后 7 天内是否购买,特征却包含了 1 月 8 日全天行为。解决:特征截止时间定在 as_of 的 0 点,过滤条件写成 time < as_of,不含等于。这个坑在复现别人的开源方案时特别常见,因为很多文档写的时候不会强调是闭合区间还是开区间。
5.3 用户重叠导致评估虚高
现象:随机切分训练验证集,AUC 比按时序切分高 0.03 以上。原因:同一个用户出现在训练和验证里,模型已经见过该用户的全部行为,验证失去意义。解决:用时间边界切分,而不是按用户随机切;如果赛题要求按用户切,要确认训练集和验证集的 user_id 没有交集。我的习惯是在评估脚本里加一行断言:assert len(set(train_user_ids) & set(valid_user_ids)) == 0。这一行能挡住绝大部分切分失误。
5.4 冷启动用户特征几乎全零
现象:特征表里有一大批用户所有计数都是 0 或 1,模型给他们的概率全在 0.5 附近。原因:观察窗口内这些用户只有一两次浏览,统计特征没有区分度。解决:不要硬套概率框架。常见做法是给这类用户加一个"有加购无购买"的规则分支,有强意图就直接给高优先级;特征上缺失值填 -999 而不是 0,让树模型把"缺失"当成一种有效状态。如果这类用户占比很大,可以考虑单独建模或单独分桶,不进主排序模型。
5.5 阈值定不好,AUC 高但实际效率低
现象:AUC 0.83 看着不错,按 0.5 阈值出名单,实际复购率没有提升。原因:AUC 衡量排序能力,不衡量概率校准;正负样本不平衡时 0.5 根本不是合理分割点。解决:在验证集上按概率降序切分人群,计算每个千人段能捕获多少真实复购用户,画累计增益曲线,再根据运营触达预算反推概率阈值。模型的任务是输出可靠排序,阈值是运营决策,不要在模型里硬卡 0.5。做用户增长的同学都知道,真正有价值的不是概率本身,而是概率排序带来的资源分配效率。
6. 把单模型往上拉的三个进阶招
6.1 用特征截断函数统一时间口径
把"as_of 之前"这个逻辑抽成一个函数,所有特征生成都必须接收这个参数。这样能防止不同脚本之间复制粘贴导致时间边界不一致,也能让排查泄露时只看一个入口。代码很短,但它是复购预测里性价比最高的防御性习惯:
def build_features(behavior_df, as_of): df = behavior_df[behavior_df["time"] < as_of].copy() # 所有特征生成代码统一写在这里 return df6.2 用行为间隔差分特征刻画决策节奏
统计特征描述"有多少",间隔特征描述"多规律"。复购是有节奏的,日用品可能集中在 20 到 30 天,冲动消费品集中在 3 到 7 天。把用户相邻两次购买的时间差算出来,取均值、方差、最小间隔,再和最后一次购买距窗口截止的时间拼在一起,模型能学到"该不该下单"的时机信号。这类特征不依赖外部数据,纯从行为时间推算,是性价比很高的增量。代码上用 sort_values 排序后 diff 就能算,注意过滤只购买过一次的用户。
6.3 用多模型软融合或分层决策收尾
走到最后阶段,常见提升是 LightGBM + CatBoost 的软融合,把两个模型预测的概率平均或加权平均。融合的前提是两个模型有互补性,比如一个用全部特征,一个去掉 target encoding 特征,相关度低才有融合意义。更贴合业务的收尾是把概率排序转成分层决策:top 1% 用户短信触达,前 10% 用户推送,其余不动,这样模型输出的排序直接对接运营预算,复购率的提升会比调 AUC 更直观。我现在的习惯是先画时间轴、再写特征函数、最后才碰模型参数。这套顺序让我少踩了很多坑,也让我在复现别人方案时能快速定位分数对不上的原因。希望帮到你。
本文还有配套的精品资源,点击获取