天猫复购预测实战:从用户行为日志到LightGBM的完整特征工程方案
2026/8/30 3:27:08 网站建设 项目流程

简介:在电商场景中,复购预测是用户行为分析与数据挖掘的核心任务之一,其本质并非简单的二分类问题,而是对用户与店铺之间关系强度的建模。通过解析用户行为日志中的浏览、收藏、加购、购买等动作序列,可以提取出反映用户粘性的关键信号。特征工程是提升模型效果的关键,尤其是基于时间窗口的行为节奏特征,能够显著刻画用户的复购倾向;而LightGBM作为高效的梯度提升树模型,在处理表格数据时兼具速度与精度。此类技术广泛应用于推荐系统、用户流失预警和精细化运营等场景。本文以天猫复购预测赛题为载体,从数据粒度、时间窗口特征、用户与商家维度聚合、模型验证等环节,完整展示一套可复现的机器学习工程实践方案,帮助读者掌握从行为日志中挖掘高价值特征并构建稳定分类模型的系统方法。

1. 赛题拆解:复购预测不是一个普通的二分类

1.1 先弄清官方到底在问什么

阿里天池上的"天猫复购预测"赛题,核心任务看起来很简单:给定一批用户在某家店铺里的行为日志,让参赛者预测这些用户之后还会不会在同一家店铺再次购买。每一个训练样本是"用户-店铺"二元组,标签是 0 或 1,评估指标是 AUC。

但如果你直接把它当成一个普通的二分类问题去做,大概率会翻车。原因是"复购"这个业务语义本身带有很强的时间属性和关系属性。它不是让你预测用户"会不会买",而是预测用户"会不会再买一次"。一个用户可能已经在这家店铺下过单,也可能只是反复浏览、收藏、加购但从没成交。这两类用户在特征上的初始状态完全不同,建模时不能混为一谈。

我当时看的赛题说明里,正样本比例并不高,整体不到 10%,也就是说大部分样本是没有复购行为的。这种情况下,如果你拿准确率当指标,随便预测全 0 也能拿到 90% 以上的准确率,但这毫无意义。AUC 只关注正负样本的排序能力,所以它才是这个赛题的主指标。理解了这层关系,你才会明白后续所有特征工程都应该是围绕"怎么把有复购倾向的样本排到前面"来展开,而不是纠结分类阈值怎么定。

1.2 数据文件的真实结构:user、user_log、train、test 之间的关系

赛题通常会提供几张表,核心的是以下四类:

文件字段粒度用途
user.csvuser_id、性别、年龄、职业、城市等级等用户维度描述用户画像
user_log.csvuser_id、item_id、cat_id、seller_id、brand_id、time_stamp、action_type用户-商品-行为最核心的行为序列
train.csvuser_id、seller_id、label用户-店铺训练样本与标签
test.csvuser_id、seller_id用户-店铺待预测样本

这里有个非常容易忽略的粒度问题:user_log 的最小记录单位是"用户-商品-时间戳-行为类型",而训练集和测试集的样本单位是"用户-店铺"。同一个用户可能在同一个店铺里对好几个商品有过浏览、收藏、加购、购买等不同动作,如果不做聚合,日志是没法直接 join 到样本上的。

我第一次跑的时候没注意这一点,直接把 user_log 按 user_id 和 seller_id join 到 train.csv 上,结果出现了大量重复行,特征维度错得离谱。正确做法是先把日志聚合到"用户-店铺"二元组上,再做关联。这一步是所有特征工程的地基,地基歪了后面全白搭。

另外一个值得留意的是时间口径。行为日志里每一条记录都有 time_stamp,而 train.csv 的标签是基于某一时间点之后是否产生复购来判定的。换句话说,特征构造时只能用该样本在行为窗口内能"看到"的数据,不能用窗口外的信息。很多人初赛时拼命堆特征,AUC 看着很高,但一提交就崩,多半就是在这里出了问题。

1.3 赛题的业务直觉:复购行为的本质是"关系强度"

复购预测和一般的转化率预估有个明显差异:它更强调用户与店铺之间的长期关系。一个用户可能因为一次大促在某家店买了东西,之后再也不来;也可能因为这家店的商品质量好、客服响应快、物流稳定,形成了稳定的复购习惯。这些业务层面的因素,在数据里不会直接给出,但可以通过行为日志的分布形态推断出来。

我习惯把它理解为"用户对店铺的粘性":粘性越强,复购概率越高。粘性的信号包括但不限于:行为次数是否持续增长、两次购买之间的间隔是否在缩短、收藏和加购之后有没有真正转化为订单、用户是不是把这家店当成了固定采购来源。带着这个直觉去构造特征,会比盲目堆砌统计量要高效得多。

2. 特征工程:从用户行为日志里挖出复购信号

2.1 用户-商家维度的基础聚合

特征工程的第一步,是把 user_log 聚合到"用户-店铺"维度。这个聚合可以按不同层面去拆:

第一层是行为次数。把 action_type 分成浏览、收藏、加购、购买四类,分别统计每个用户在每家店铺里的动作次数,再统计总行为次数。这一层是"兴趣浓度"的直接体现,一个在店铺里产生过 50 次浏览、10 次加购的用户,和一个只来过 1 次的用户,复购概率显然不同。

第二层是多样性维度。统计用户在这家店铺里浏览过多少去重商品、多少品类、多少品牌。多样性高意味着用户可能是在"逛店",多样性低但购买次数高则更可能是"复购熟客"。这两类特征在后续模型里可以形成很好的区分。

第三层是比率特征。把不同行为算比值,比如购买次数除以浏览次数、加购次数除以浏览次数、收藏次数除以加购次数。这些比值本质上是转化率,反映的是用户从感兴趣到真正下单的转化效率。复购用户往往有一个特点:首次购买之后,后续的浏览行为很大概率直接加购或下单,转化率会明显高于普通用户。

我贴一段当时实际使用的核心聚合代码,可以直接跑通:

import pandas as pd import numpy as np log = pd.read_csv('user_log.csv', parse_dates=['time_stamp']) train = pd.read_csv('train.csv') test = pd.read_csv('test.csv') train['is_train'] = 1 test['is_train'] = 0 sample = pd.concat([train, test], ignore_index=True) # 1. 行为计数:action_type 0=浏览 1=收藏 2=加购 3=购买 actions = log.groupby(['user_id', 'seller_id', 'action_type']).size().unstack(fill_value=0) actions.columns = ['act_0_browse', 'act_1_fav', 'act_2_cart', 'act_3_buy'] # 2. 多样性特征 uniq_item = log.groupby(['user_id', 'seller_id'])['item_id'].nunique().rename('uniq_item') uniq_cat = log.groupby(['user_id', 'seller_id'])['cat_id'].nunique().rename('uniq_cat') uniq_brand = log.groupby(['user_id', 'seller_id'])['brand_id'].nunique().rename('uniq_brand') uniq_day = log.groupby(['user_id', 'seller_id'])['time_stamp'].nunique().rename('uniq_day') pair_feat = actions.join(uniq_item).join(uniq_cat).join(uniq_brand).join(uniq_day).reset_index() # 3. 转化率特征,加 1 平滑避免除零 pair_feat['buy_browse_ratio'] = pair_feat['act_3_buy'] / (pair_feat['act_0_browse'] + 1) pair_feat['cart_browse_ratio'] = pair_feat['act_2_cart'] / (pair_feat['act_0_browse'] + 1) pair_feat['fav_cart_ratio'] = pair_feat['act_1_fav'] / (pair_feat['act_2_cart'] + 1) pair_feat['buy_cart_ratio'] = pair_feat['act_3_buy'] / (pair_feat['act_2_cart'] + 1) sample = sample.merge(pair_feat, on=['user_id', 'seller_id'], how='left') sample = sample.fillna(0)

这段代码本身不复杂,但有两点必须说明。一是 join 时要用 left join,不能把 test 里没有日志记录的样本丢弃,因为模型要能处理冷启动用户。二是加平滑分母的 1 不是为了数学严谨,而是为了防止出现除以 0 导致的无穷大值,让树模型在切分特征时更稳定。

2.2 时间窗口特征:行为节奏才是复购的核心信号

如果说基础聚合是及格线,那时间窗口特征就是把分数从 0.68 拉到 0.70 以上的关键。同一组行为次数,放在不同的时间分布形态下,含义完全不同。

举一个很简单的例子:用户 A 在店铺里总共产生 10 次购买,但这 10 次全集中在一个月里,之后三个月完全没有动静;用户 B 同样产生了 10 次购买,但每个月都有 2 到 3 次。显然用户 B 的复购概率会高很多。这就是行为节奏的重要性。

我常用的做法是把行为日志按时间切成"前半段"和"后半段",分别做统计。这样模型就能看到用户对这家店的兴趣是在上升还是在衰减。例如,前半段有 3 次购买、后半段有 7 次购买,说明用户粘性在增强;反过来则说明用户可能正在流失。

还可以增加一个"最近 7 天"窗口,专门统计临近窗口末尾时的行为。这个特征能捕捉到用户在即将进入预测期时的即时状态。一个在窗口最后一周还在频繁加购的用户,和一个月前就消失的用户,复购概率差异非常明显。

以下是时间窗口切分的参考实现,基于时间排序的百分比切分:

# 给每个用户-店铺内的行为按时间排序,并计算时间排名比例 log['time_rank'] = log.groupby(['user_id', 'seller_id'])['time_stamp'].rank(method='first', pct=True) # 前半段行为统计 first_half = log[log['time_rank'] <= 0.5].groupby( ['user_id', 'seller_id', 'action_type'] ).size().unstack(fill_value=0) first_half.columns = ['first_half_act_' + str(c) for c in first_half.columns] # 后半段行为统计 second_half = log[log['time_rank'] > 0.5].groupby( ['user_id', 'seller_id', 'action_type'] ).size().unstack(fill_value=0) second_half.columns = ['second_half_act_' + str(c) for c in second_half.columns] # 最近 7 天行为统计 last_7d = log[log['time_stamp'] >= log['time_stamp'].max() - pd.Timedelta(days=7)].groupby( ['user_id', 'seller_id', 'action_type'] ).size().unstack(fill_value=0) last_7d.columns = ['last_7d_act_' + str(c) for c in last_7d.columns] pair_feat = pair_feat.merge(first_half.reset_index(), on=['user_id', 'seller_id'], how='left') pair_feat = pair_feat.merge(second_half.reset_index(), on=['user_id', 'seller_id'], how='left') pair_feat = pair_feat.merge(last_7d.reset_index(), on=['user_id', 'seller_id'], how='left') pair_feat = pair_feat.fillna(0)

除了窗口统计,时间间隔类特征也非常值钱。我常用的有这几类:

  • 首次行为距日志窗口开始的天数
  • 最后一次行为距日志窗口结束的天数
  • 首次购买和最后一次购买之间的跨度
  • 相邻两次购买之间的平均间隔、最大间隔、最小间隔
  • 购买间隔的方差,用来刻画用户购买的规律性

购买间隔方差是我后期调参时发现的一个很稳的特征。复购用户的购买间隔往往比较有规律,方差小;偶尔下单的用户则可能隔很久才来一次,方差大。这个特征不需要太多计算量,但对 AUC 的贡献很明显。

2.3 用户级、商家级特征与交叉特征

pair 特征是骨架,但只靠它还不够。用户本身可能是一个高活跃用户,在几百家店铺都有购买记录,也可能是一个只在三四家店活动的低频用户。同样是在某家店买了 3 次,对前者来说这只是九牛一毛,对后者来说却是高度忠诚。因此需要把用户全局特征拉进来做参照。

用户级特征我习惯单独构建一张表,用 user_id 做 key,统计用户在全部店铺上的行为总量,包括总浏览、总收藏、总加购、总购买、总活跃天数、购买品类数等。然后把它 merge 回样本。通过"用户在这个店铺的购买次数 / 用户全店铺平均购买次数"这样的比值,模型可以知道这个店铺在用户所有消费行为中的占比有多高。

商家级特征也用同样的思路。统计每个店铺的总曝光次数、总购买次数、购买转化率、有多少个用户在这里买过。如果一家店的平均复购率本来就高,那么新用户在这里产生复购的概率也会更高。这个逻辑有点类似推荐系统里的物品热度和物品质量。

交叉特征方面,树模型确实可以在内部自动做特征组合,但提前手动构造一些高价值的交叉项,往往能显著降低树的深度,提升泛化能力。比较有效的几个组合包括:

  • 用户在店铺的购买次数除以用户所有店铺的平均购买次数
  • 用户在店铺的购买次数除以该店铺的平均每用户购买次数
  • 用户在店铺的加购次数乘以购买转化率
  • 用户在店铺的行为天数除以用户全局活跃天数

注意,比率特征一定要注意稀疏数据条件下的稳定性。有些用户只在店铺里有一条浏览记录,算出来的转化率要么是 0 要么是 1,这种极端值对模型没有太大帮助。我一般会在特征工程阶段先统计一下特征值的分布,对极端值做截断或开方处理。

3. 模型选择与验证方案:高分和低分往往差在评估习惯上

3.1 为什么 LightGBM 是这个赛题的主力模型

这个赛题的样本量级大概在几十万,特征维度在几十到一两百之间,属于典型的中小规模表格数据问题。在这种场景下,GBDT 系列模型几乎是性价比最高的选择。我对比过 XGBoost 和 LightGBM,最终以 LightGBM 为主力,原因有四个:

第一,训练速度快。同样的特征集,LightGBM 比 XGBoost 能快 3 倍左右,这在特征迭代频繁的比赛阶段非常重要。你一天可能要做几十次实验,跑得慢意味着你一天只能验证十几次,差距很快就出来了。

第二,LightGBM 原生支持类别特征。user_id、seller_id 这类高基数列可以直接作为 categorical_feature 传入,它会自动做最优切分,不需要手动 one-hot,既省内存又省时间。

第三,对缺失值的处理很自然。GBDT 系列模型在切分特征时会把缺失值自动放到增益最大的一侧,不需要你提前做复杂的填充。相比之下,如果用深度模型,缺失值处理就要小心得多。

第四,参数调优空间相对友好。核心参数就那么几个:num_leaves、min_child_samples、learning_rate、feature_fraction、bagging_fraction、正则化系数。调一圈基本能稳定到一个不错的水平,不太会出现玄学波动。

3.2 验证集必须按时间切分,不能随机切

这是我在这个赛题上最深刻的教训之一,也是拉开高低分差距的关键环节。很多人拿到数据之后习惯性地用 sklearn 的 train_test_split 随机切分验证集,这在很多竞赛里问题不大,但在复购预测这类带时间序列属性的赛题上,随机切分会造成严重的数据泄漏。

原因是测试集的样本在时间上比训练集更靠后,而日志里的行为序列是连续的。如果你随机切分,训练集中会混入时间上较晚的行为样本,模型在训练时就已经"见过"了部分未来信息。离线验证分数虚高,但线上测试分布一旦变化,分数就会崩塌。

正确的做法是构造一个"时间上晚于训练集"的验证集。最简单的方案是把样本按照某个时间属性排序,取最后 20% 当验证集。比如你可以用用户最后一次购买行为的时间排序,也可以用用户首次出现在日志中的时间排序。还有一种做法是把日志按时间切成两段,用前一段构造特征训练,用后一段构造特征验证,这样能最大程度模拟真实测试环境。

我当时实际采用的是"按时间排序后按用户切分"的方式:先把所有 user_id 按照其最早行为时间排序,取前 80% 的用户作为训练,后 20% 的用户作为验证。这个方法虽然会损失少量样本,但验证结果的可靠性比随机切分高很多。之后所有特征工程和参数调优都以这个验证集为准,最后公共榜上的分数和验证集基本一致,几乎没有发生过大的偏差。

3.3 公共榜与私有榜:复购预测里最容易被忽视的过拟合

天池这类比赛通常会把测试集分成公共榜和私有榜两部分。公共榜分数是你提交后立刻能看到的,私有榜则是最终排名依据。很多人会陷入一个陷阱:疯狂在公共榜上刷分,结果私有榜分数大幅下跌。

复购预测赛题里,公共榜和私有榜的样本分布如果存在细微差异,就容易暴露模型的过拟合问题。最常见的情况是,某些高基数的用户特征被模型直接当成了记忆工具。比如 user_id 本身有几十万个取值,LightGBM 如果把它当作类别特征,确实可以记住很多训练样本的标签,使得公共榜分数很好看,但一旦遇到没有见过的新用户 ID,模型就失灵了。

我的应对策略有三个:

第一,在特征工程阶段,尽量避免把原始 ID 直接加入模型。如果一定要加,也要先验证它在时间切分验证集上的稳定性。我最后实际使用的特征里只保留了一部分低基数的店铺 ID 聚合特征,user_id 完全没有进模型。

第二,做多次不同的时间切分,取平均 AUC 作为模型选择的依据。如果你换了三种切分方式,分数都能稳定在某个水平,那这个特征或者参数才值得信任;如果只有一种切分下分数高,其他切分下明显下跌,那就是过拟合的信号。

第三,伪标签技术要谨慎使用。伪标签确实可以帮助模型利用无标签的测试样本,但它的前提是模型对伪标签的置信度足够高。在特征还没稳定的时候就用伪标签,相当于把模型的偏见放大再喂回去,最终结果往往适得其反。我建议等模型在验证集上稳定之后,再在最终提交前做一轮伪标签微调。

4. 高分代码的关键实现:可直接复现的完整流程

4.1 特征构建与样本合并的工程细节

在正式进入训练之前,特征工程的代码组织方式也会影响整个项目的调试效率。我建议把所有特征构建函数拆成一个个独立函数,每个函数接受 log 和 sample,返回一份以 user_id、seller_id 为 key 的特征表,最后在统一的地方进行 merge。

这样做的好处是,你可以单独验证每一份特征的质量。比如某个特征列全是 0,说明你的聚合逻辑写错了,这时候单独调试比在几千行代码里找问题轻松得多。

有一点要特别注意:所有特征表的 key 必须是 user_id 和 seller_id 的组合,而且两列的 dtype 要保持一致,否则 merge 的时候会出现大量 NaN。我遇到过好几次因为 user_id 在一个表里是 int64,另一个表里被读成了 float64,导致 merge 后特征全部丢失的问题。

下面给出一份整合后的特征工程代码框架,包括 pair 特征、用户级特征和商家级特征:

def build_user_features(log): # 用户全局行为特征 u = log.groupby('user_id').agg( total_browse=('action_type', lambda s: (s == 0).sum()), total_fav=('action_type', lambda s: (s == 1).sum()), total_cart=('action_type', lambda s: (s == 2).sum()), total_buy=('action_type', lambda s: (s == 3).sum()), total_act=('action_type', 'count'), active_days=('time_stamp', 'nunique'), total_item=('item_id', 'nunique'), total_cat=('cat_id', 'nunique'), ).reset_index() return u def build_seller_features(log): # 商家全局行为特征 s = log.groupby('seller_id').agg( seller_buy=('action_type', lambda s: (s == 3).sum()), seller_browse=('action_type', lambda s: (s == 0).sum()), seller_paid_users=('user_id', 'nunique'), seller_items=('item_id', 'nunique'), seller_cats=('cat_id', 'nunique'), ).reset_index() s['seller_buy_rate'] = s['seller_buy'] / (s['seller_browse'] + 1) return s

然后统一做样本合并:

user_feat = build_user_features(log) seller_feat = build_seller_features(log) sample = sample.merge(user_feat, on='user_id', how='left') sample = sample.merge(seller_feat, on='seller_id', how='left') # 最后合并 pair 特征 sample = sample.merge(pair_feat, on=['user_id', 'seller_id'], how='left') # 填充缺失值,并区分"无日志用户" sample = sample.fillna(0) # 定义特征列 ignore_cols = ['user_id', 'seller_id', 'label', 'is_train'] feat_cols = [c for c in sample.columns if c not in ignore_cols]

工程化的思维在这个阶段非常重要。不要把所有代码写在一个大脚本里,而是按"数据读取 → pair 特征 → 用户特征 → 商家特征 → 合并 → 训练"的模块划分。后期你只需要改动其中一个函数,重新跑一遍主脚本就能看到效果。

4.2 LightGBM 训练与参数配置

模型训练部分,我直接给出一个可运行的 LightGBM 训练代码。注意,这里的参数是我经过多轮实验后相对稳定的配置,不是绝对最优,但作为起点非常合适。

import lightgbm as lgb from sklearn.model_selection import train_test_split X = sample[sample['is_train'] == 1][feat_cols].values y = sample[sample['is_train'] == 1]['label'].values X_test = sample[sample['is_train'] == 0][feat_cols].values # 演示用随机切分,实际请按时间切分 X_train, X_valid, y_train, y_valid = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) params = { 'objective': 'binary', 'metric': 'auc', 'learning_rate': 0.02, 'num_leaves': 31, 'min_child_samples': 100, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l1': 0.1, 'lambda_l2': 1.0, 'max_bin': 255, 'verbose': -1, } d_train = lgb.Dataset(X_train, label=y_train) d_valid = lgb.Dataset(X_valid, label=y_valid) model = lgb.train( params, d_train, num_boost_round=3000, valid_sets=[d_valid], valid_names=['valid'], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(50)] ) pred = model.predict(X_test, num_iteration=model.best_iteration)

参数里有几个值得细说的地方。

learning_rate 设成 0.02 是因为样本量不大,我宁愿用更多轮次去拟合,也能减少单棵树的置信度过高。num_leaves 控制在 31,配合 min_child_samples=100,能有效防止树模型在稀疏特征上过拟合。feature_fraction 和 bagging_fraction 都设成 0.8,本质上是给模型加了随机性,让每棵树看到的特征和样本不完全一样,增强集成效果。

正则化部分,lambda_l1 和 lambda_l2 分别控制 L1 和 L2 正则。复购预测这个场景下,L2 正则的效果比 L1 更明显,所以我把 L2 设得更大一些。如果你的特征数量特别多,可以适当增加 L1,让模型自动做一些特征选择。

还有一个容易被忽略的细节:early_stopping 的轮数。100 轮是个比较中庸的值,不会太早停导致欠拟合,也不会太晚停导致过拟合。如果训练集特别大,可以适当缩小到 50 轮。

4.3 从我自己的实验记录看 AUC 提升过程

为了让整套方案更直观,我贴一份当时实验记录的摘要。每一轮实验都是在同样的时间切分验证集上进行的,这样前后才有可比性。

实验阶段验证 AUC公共榜成绩主要新增内容
1. 基线0.68120.6830行为次数 + 去重商品/品类特征
2. 时间窗口0.70150.7038前半/后半行为统计、最近 7 天特征
3. 比率与交叉0.71080.7112转化率占比、用户店铺相对购买强度
4. 用户与商家全局特征0.71820.7196用户活跃度、商家热度
5. 模型融合与参数微调0.72100.7221LightGBM + XGBoost 结果平均

从表里能明显看到,第二阶段时间窗口特征的提升幅度最大,直接贡献了 0.02 的 AUC。这说明对于复购预测而言,"什么时候买"比"买了几次"更能刻画复购倾向。第三阶段的比率特征也有接近 0.01 的提升,这部分特征帮助模型区分了高活跃但低转化的用户。第四阶段加入用户和商家全局特征之后,提升幅度开始放缓,但也稳定贡献了 0.007 左右。

第五阶段的模型融合其实是我做的最犹豫的一个操作。LightGBM 和 XGBoost 在特征完全一致的条件下,结果相关性很高,融合带来的理论增益有限。但实际操作中发现,XGBoost 在部分样本上的排序能力确实和 LightGBM 有差异,简单做概率平均之后还是稳定提升了 0.003 左右。如果你的时间允许,这种轻量级融合值得一试。

5. 复现过程中最容易踩的坑

5.1 时间戳精度与切分泄漏

user_log 里的时间戳精度需要注意。如果时间只精确到天,那么同一个用户同一天内多条行为的先后顺序其实是未知的。你在做时间窗口切分时,如果按行数比例去切分,可能会把同一天的行为拆到训练集和验证集两边,造成轻微泄漏。

我的建议是:凡是涉及时间切分的操作,一律先统一切到"天"级别,再排序切分。这样至少在逻辑上保证了同一天的行为不会同时出现在两侧。更进一步,在构造用户"最后一天"特征时,要明确这个"最后一天"是指日志窗口的截止日期,而不是用户自身行为的最后日期。

另外,特征工程里最好不要直接用 max(time_stamp) 这样的全局统计量去做归一化,因为测试集的日志窗口可能和训练集不同,全局统计量会导致训练和测试特征分布不一致。如果确实需要归一化,建议以行为窗口的固定日期作为参照。

5.2 高基数 ID 特征与内存优化

user_id 和 seller_id 的取值数量通常可以达到几十万级别。如果你把这两个列直接作为 int64 类型处理,光是这两个列的内存就可能占掉几百 MB。更高效的做法是先 cast 成 int32 或直接使用 pandas 的 category 类型,再传给 LightGBM。

LightGBM 支持 categorical_feature 参数,可以在训练时指定哪些列需要按类别特征处理。但正如前文所说,user_id 这种超高基数的列直接进模型很容易过拟合,我更推荐的做法是只把 seller_id 这类数量级在一万以内的列作为类别特征,user_id 则完全不进模型,只保留它的聚合特征。这样既能利用类别信息的切分能力,又不会让模型过度记忆样本。

一个重要提醒:不同数据表里同一个 ID 列 dtype 必须一致。train.csv 和 user.csv 里的 user_id 可能一个被读成 int64,一个被读成 object,直接 merge 会出现大量 NaN 行。建议读取数据后立刻统一做 astype(int32) 或 astype(str),避免这类低级但又非常致命的错误。

5.3 冷启动用户与缺失值的处理

测试集里一定会有一些用户,在训练日志中完全没有出现。这些用户的行为特征是纯 0 或者 NaN,模型无法判断他们的复购倾向。如果你想硬生生填 0,模型可能会把它们和"有行为记录但特征值为 0"的用户混淆。

我尝试过比较有效的方法是加一个"是否出现过"的标记特征。也就是在构建特征时,专门计算一个 indicator 特征:用户是否在日志中存在,或者用户-店铺组合是否在日志中存在。这个标记特征能让模型明确区分"无数据"和"真零值",实测能带来约 0.003 的 AUC 提升。

缺失值填充方面,除非你有很强的领域先验,否则我建议把缺失直接留给 LightGBM 处理,不要自己填充。LightGBM 在训练时会自动学习缺失值的最佳方向,你的填充反而可能引入噪音。我见过很多人所有特征都 fillna(0),结果把"有缺失"和"真实为 0"混为一谈,模型效果反而变差。

5.4 实验记录的习惯:高分方案的隐形护城河

最后分享一个实战里特别受用的习惯:每一次跑实验,都记录下特征清单、参数配置、验证集 AUC、公共榜分数和备注。我之前用一张简单的 Markdown 表格维护实验日志,长这样:

时间特征版本参数版本验证 AUC公共榜备注
10-15v2lgb_0210.70150.7038加了时间窗口
10-15v2lgb_0220.70090.7012调低 feature_fraction
10-16v3lgb_0220.71080.7112加了转化率比例

这个习惯在比赛后期价值极大。当你有几十个特征版本、十几种参数配置时,如果没有记录,很容易忘记哪个版本的有效结果来自哪里,最终提交时全靠猜。反过来,如果记录完整,你能快速定位到最优组合,也能在私有榜翻车时快速回溯到更早的稳定版本。

我在这个赛题上的最终成绩不是靠某一个独门技巧,而是靠一整轮的实验闭环:先把数据粒度搞清楚,再按业务直觉构造特征,然后用可靠的时间切分验证,最后用完整的实验记录收束整个过程。复购预测这类问题,真正拉开差距的从来不是某个灵光一现的想法,而是这些看起来琐碎但环环相扣的工程细节。如果你照着上面的代码和流程完整跑一遍,大概率也能稳定拿到一个不错的分数。

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

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

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

立即咨询