最近整理自己打过的比赛时,翻到Elo Merchant Category Recommendation这一场,感触挺深。它是典型的“表格数据+时序用户行为”回归赛,涉及特征工程、目标编码、离线验证、模型融合这些常规招式,但每一招都比一般的入门教学赛藏得更深。如果你现阶段正在学机器学习竞赛,用这个题练手会非常值。
这场竞赛的核心目标是预测用户对商户类别的偏好得分,但是所有特征都不是现成的表格,数据分布在交易流水里,需要你自己动手聚合。当时我花在数据分析上的时间比建模多得多,最后拿到了一个不错的排名。这篇文章不是复刻官方高手的方案,而是我跑完整个流程后沉淀下来的完整思路,包括数据表关系、验证切分、特征聚合、调参和踩坑记录,希望能给准备入坑或正在做类似题目的朋友一些参考。
1. 赛题拆解:Elo这场回归赛真正在考什么
1.1 任务定义与评估指标:回归到交易行为里的偏好分数
这个赛题给的训练数据是一组脱敏后的信用卡交易流水,每个持卡人有若干条历史交易记录,每条记录关联了商户ID、商户类别、城市、交易金额、分期笔数等字段。我们需要预测的是目标字段——一个代表用户对该商户类别偏好强度的连续型分数,评测指标用的RMSE。
可以这么理解业务场景:平台要根据用户过去的刷卡习惯,对某个商户或商户类别给出一个推荐分数,分数越高,说明这位用户越可能在下一个时间段里选择这类商户消费。模型输出的不是分类标签,而是一个排序分,最终用作推荐列表的排序依据。
RMSE这个指标意味着我们不需要追求极端精确的绝对分数,更重要的是整体预测误差的平稳性。它比较容易被少数差异很大的样本带偏,所以在做特征和调参时,对异常大或异常小的目标值要格外关注。当时我的经验是,训练目标并不是完全平滑的分布,有一小部分分数很低或很高的样本,它们对RMSE的影响远超中间区域的样本。
1.2 几类表之间的协作关系:训练主表、交易明细表和商户属性表
赛题数据并不是一张大宽表,而是分成了多张表。主表每行是一个card_id,带若干脱敏的用户级匿名特征;交易明细表记录每条真实刷卡记录,一个card_id对应多行交易,交易时间、是否授权、金额、滞后月份都在这里;商户表则提供了商户ID本身的一些属性,比如类别、城市、活跃度等信息。
表之间的关联关系主要有两条:一是用card_id把主表跟历史交易明细关联,得到“这个用户过去刷过什么”;二是用merchant_id把交易明细跟商户表关联,得到“这笔交易发生在什么样的商户身上”。这些关联都是在做特征工程之前需要彻底搞清楚的,如果字段关系理解错了,后面造出来的特征全是错的。
我记得有相当一部分参赛者其实没有吃透这个关系,直接把交易明细表当主表去训练,导致一个card_id被重复输入到模型里,结果一定是极差且没有意义。正确做法永远是先定好预测目标:我们是站在card_id这一行去预测目标分数,所以最终特征表一定是主表级别的宽表,交易数据只是用来构造这个宽表的中间素材。
1.3 为什么这个题适合反复复盘
Elo赛题之所以经典,不只是因为数据量大、特征组合多,而是它几乎涵盖了表格类竞赛的所有关键环节:多表关联、时序泄漏风险、类别特征编码、稀疏交易处理、内存优化、单模调参和多模型融合。每一个环节单独拿出来,都可以作为一次专题练习。
更难得的是它特别像真实业务里的用户画像和推荐系统构造过程。你在实际工作中会遇到的用户冷启动、交易记录不完整、特征出现缺失值这些问题,这个赛题里全都有。所以我一直跟身边想学竞赛的朋友推荐说:Elo这场赛可以作为“第一个认真刷的比赛”,刷完它之后,大部分表格类比赛的处理思路你都大概能摸到边了。
2. 数据科学家的第一步不是建模,是理解每一列的含义
2.1 先回答三个关键问题:谁在交易、什么时候交易、交易算不算数
打开交易明细表之后,我建议先不要急着写代码,先回答几个业务问题:这张表里的每一行代表什么?一个用户的交易行为是按什么时间维度记录的?所谓“授权”和“未授权”对统计口径有什么影响?
我在Elo赛题里遇到最重要的一个问题是authorized_flag。它标记了这笔交易是否被授权通过。被拒掉的交易记录其实仍然存在表里,如果把它也当作正常消费去做统计,金额均值和频次都会被拉偏。我当时先单独拉出授权交易做一套特征,又拉出全部交易做第二套特征,模型结果明显是第一套更稳定。这个字段本身也值得做一个统计:某用户在过去一段时间的授权失败比例,就隐含了他的消费质量和信用层面的信息。
另外month_lag字段非常关键,它表示这笔交易距离当前预测时间点的月数。你自己想一下:如果一个用户在最近一个月频繁在某个品类消费,和他在一年前频繁消费,哪个对当下偏好的预测力更强?显然是近期行为。所以后续时间窗口特征的基础,就是先吃透这个字段的含义。
2.2 内存优化:把几百万行交易流水塞进内存的土办法
Elo赛题的数据量不算恐怖,但也不算太小,尤其是交易明细表通常有几千万行。如果你一上来就用默认的pandas读取方式,内存很容易直接爆炸,导致后面所有操作都卡顿。
我当时的做法很简单:读取时指定每列类型,把能转的整型列转成int32或int8,浮点列转成float32,ID类列转成category。全部处理完之后,内存占用能比原始加载方式降一半以上。不要小看这个步骤,在没有GPU内存资源紧张的环境里,这一步省出来的内存直接决定了你后续能不能放肆尝试特征。
另外,对于多表关联后产生的超大中间表,尽量不要反复保存成CSV再读取,CSV的读写成本太高,优先用parquet格式保存中间文件。我自己在调特征过程中保存过上百个特征中间表,parquet格式让重跑特征的成本变得非常低。
2.3 缺失值不是随机掉的,而是带着业务信息
在Elo赛题里,有几列缺失率很高。很多新手第一反应是全用均值或中位数填充,但如果你只这么做了,其实丢掉了很多信息。缺失本身经常意味着“没有发生过”或者“该商户类型下没有这个属性”,这种空值和0是完全不同的业务含义。
比较实用的做法是把缺失值单独造一个布尔特征出来,表示“是否缺失”,再让模型自己去学习该特征和缺失填充值的组合关系。比如某个类别特征缺失时填充-1,同时保留原缺失标志列,LightGBM这类树模型就能依靠分裂逻辑找到缺失组合的规律。我在做实验时明显感觉到,加上缺失标志列之后,线下验证的RMSE会微降一些。
2.4 匿名特征的作用比想象中大
主表里有若干匿名特征列,很多人会把大量精力放在交易明细的聚合上,却忽视这些匿名列。实际上,这些匿名特征往往是官方在构造这个挑战时就已经筛选过的强特征,它们本身可能就带有部分目标信息。我的一位一起打比赛的朋友,早期只做交易特征,忽视匿名特征,本地分数一直卡在一个瓶颈;后来把匿名特征简单做了组合和分箱,分数立刻提升了一个档次。
对待匿名特征的策略:先看它们与目标的单变量相关性或简单回归结果,把作用大的列直接作为基础特征输入;再用它们做交叉实验,比如两两相加、相减、相乘,观察是否有信息增益。通过这种简单方式,我找到了一些非常有效的组合特征。
3. 线下验证的陷阱:为什么本地分数很高,线上却不涨
3.1 随机切分为什么会给出虚假信心
很多新手入门时习惯用一个train_test_split随机切分来评估模型,但这个做法在Elo赛题里是致命的。原因在于目标分数本质上是由用户历史消费行为决定的,同一个用户出现在训练集和验证集里时,模型已经在训练时把“专属记忆”学到了,验证时自然表现得不差。
也就是说,如果不做分组切分,验证分数反映的只是模型对这些用户历史的记忆能力,而不是它对陌生新用户的真实泛化能力。这会导致两个后果:一是你为了提升“新特征”做了很多无用功,因为虚假分数怎么都会高;二是真正有效的特征反而不容易被看到,因为分数都被高估了。
3.2 我的CV方案:按card_id分组+时间裁剪
我当时采用的验证方案是两层逻辑:
- 分组:以card_id为组划分折数,保证同一张卡的所有数据不会同时出现在训练集和验证集里;
- 时间裁剪:在构造特征时,为了让每个用户都能有“历史”,我把特征聚合区间限制在预测时刻之前,绝不让验证集时间段里的交易数据被提前聚合进训练样本。
具体实现可以是定义一个函数,输入训练集的月份截止时间,输出对应时间之前的历史统计特征,然后在对验证集做同样操作时,把截止时间往后平移。这样在离线阶段就能模拟线上预测时的特征分布,降低时间穿越风险。
我还比较了随机切分和分组切分下的分数差距,结果非常吓人:随机切分下的本地RMSE低了非常多,但提交到线上之后完全对不上。后来我彻底放弃了随机切分,只用分组CV和自定义时间切分。
3.3 本地分数和线上排行:只盯公共榜会害了你
竞赛平台通常会把测试数据分成公开榜和私有榜两部分,公共榜在提交后能看到分数,私有榜只有比赛结束后才能看到。很多人会为了提升公共榜分数反复提交,其实这是在把公共榜当作验证集用,很容易过拟合到公共榜的随机噪声上。
我在Elo赛题中期也犯过这个毛病:公共榜分数提升了一点,结果私有榜出来后反而更差。之后我给自己定了一个规则:公共榜分数只走相对变化作为参考,最重要的永远是线下CV分数的稳定性。如果某个操作让CV分数提升,但公共榜没有同步提升,我倾向于相信CV,并且会更仔细检查特征是否有泄漏。这个原则在后来的很多比赛中都帮我避开了无效自嗨。
3.4 记录每一次实验的完整日志
这部分经验可能听起来没技术含量,但非常重要。每次实验都要记录特征文件路径、模型参数、CV分数、公共榜分数、备注说明。我见过太多参赛者,跑了很多轮实验,最后找不到最优模型对应的特征版本,只能从头再跑一遍,非常浪费时间。
我习惯用表格记录:
| 实验编号 | 特征版本 | 模型参数 | CV分数 | 公共榜分数 | 备注 |
|---|---|---|---|---|---|
| exp_001 | base_v1 | lgb_default | 3.6210 | 3.6312 | 基线 |
| exp_002 | base_v1 | lgb_tuned | 3.6100 | 3.6250 | 调参 |
| exp_003 | feat_v2 | lgb_tuned | 3.6095 | 3.6233 | 新增商户特征 |
这份日志在后期做特征删减和融合时帮了大忙。
4. 特征工程实战:把百万笔交易折叠成商户画像
4.1 聚合特征的黄金三件套:数量、金额、频率
教学赛里最常用的一句话是“groupby之后做聚合”,但具体聚合哪些函数、聚合到哪个层级,这才是经验所在。在Elo场景里,核心维度至少有三个:card_id、merchant_id、category_id。
我当时造了以下几类聚合特征:
- 交易次数:一个用户在某个商户/类别下总共消费了几笔,以及占该用户全部交易笔数的比例;
- 金额特征:消费总金额、金额均值、最近N笔的金额均值、金额标准差、金额的最大和最小值;
- 频率特征:平均多久消费一次,可以通过交易记录数除以覆盖的月份跨度近似得到。
有一个需要注意的地方:金额原始值不能直接参与跨用户的比较。因为用户消费能力差异巨大,一个高消费用户的金额均值天然就高。所以我在做金额特征时,会对每个用户单独计算其历史平均消费金额,然后用单次消费金额除以这个用户均值,得到标准化后的相对金额。这样对模型来说,特征表达的是“这个用户比平时花得多还是少”,而不是“绝对数字大不大”。
4.2 用户偏好:怎么用一笔交易算出一个偏好分
对推荐类任务来说,金额绝对值不重要,重要的是用户在不同类别之间的相对偏好。比如一位用户在“超市类”消费了20笔,每笔平均80元;同时在“服装类”消费了2笔,每笔平均500元。单看次数,用户更喜欢超市;单看单笔金额,服装类显然更吸引他。到底怎么判断真实偏好?
我用了一种比较朴素但有效的方法:先按(card_id, category_id)聚合出每个用户在每个类别下的总次数与总额,然后跟用户在所有类别下的总次数与总额做差分对比,得到一个比率特征。具体公式是:
ratio = 用户在该类别的平均消费金额 / (用户在所有类别下的平均消费金额 + 平滑项)
这个ratio大于1,说明用户在该类别花的钱高于个人平均水平。再加上次数占比、最近消费月份远近,组合起来就是一个非常稳定的偏好描述。这类特征在树模型中的重要度排序里基本都排前列。
4.3 目标编码与平滑参数:把类别特征变成信息特征
Elo赛题里有大量类别特征,比如category_id、city_id、merchant_category等。如果直接用原始类别ID喂树模型,往往会因为某些类别数量太少,模型记不住,泛化效果也不好。目标编码是常用解法,就是用该类别的历史目标均值替代类别ID本身。
使用目标编码时,注意要做fold内编码:在每一折的交叉验证里,用该折训练集的目标均值编码这一折的验证集特征,防止把未来信息泄漏进去。同时要加入平滑项。平滑项通常有两种:
- 直接给均值加上样本量加权:总均值乘以一个权,类别均值乘以另一个权,权重取决于样本数量;
- 用“类别目标总和+全局均值×平滑系数”除以“类别样本数+平滑系数”。
平滑系数很关键,太小会导致稀有类别过拟合,太大会让编码信息消失。我当时用网格搜索试了不同的平滑系数,最终选了一个让CV分数最稳定的值,并没有一味追求训练集得分。
4.4 时间窗口特征:近期偏好比历史习惯更重要
用户偏好不是恒定不变的,食品类消费可能每周都有,旅游类消费可能一年也就一两次。如果只用用户全量的历史统计,最近三个月的消费模式反而不突出。所以我额外构造了“最近1个月”“最近3个月”“最近6个月”几个时间窗口的聚合特征,再跟全量聚合特征做比值。
这个操作会让特征数量翻倍,但模型收益非常显著。因为树模型可以在不同窗口特征之间做切分,天然学习到“用户近期变化”的信号。比如某个用户过去半年都在超市消费,最近一个月突然频繁在健身房消费,这时候“最近1个月健身房消费次数/全量健身房消费次数”这个比率就能马上捕捉到变化的苗头。
需要注意,时间窗口太短会导致某些用户出现在窗口内的样本数为0,这时候要填充为一个特殊值,比如-100,表示“该窗口没有记录”,而不是简单地填0。
5. 模型环节:LightGBM为主力,XGBoost当辅助
5.1 为什么梯度提升树适合这种表格数据
Elo赛题的特征有上千维,其中大量特征是稀疏的、类别化的,还有不少缺失值。LightGBM作为基于直方图的梯度提升树,对这类数据的支持非常友好:内建处理缺失值机制、天然支持类别特征、训练速度快、能捕捉特征间的非线性交互。相比深度学习模型,它不需要做繁琐的特征缩放,也不需要把类别转为嵌入,更适合表格竞赛环境。
XGBoost在Elo这种规模的数据上也有不错表现,但训练速度会明显慢一些。我当时把XGBoost当作交叉验证模型和融合成员,在特征筛选阶段只用LightGBM做快速实验,到了融合阶段才让多个模型并行跑。
5.2 调参经验:先固定一块,再去动另一块
调参顺序很重要,不要上来就同时调所有参数。我当时采用的策略是:
- 固定学习率为0.05,用它来确定树的规模;
- 先调num_leaves和max_depth,观察训练集和验证集分数的差距变化,确定模型复杂度上限;
- 固定树规模后,再调min_data_in_leaf和feature_fraction,控制过拟合;
- 最后调bagging_fraction和正则化参数,再降低学习率到0.02或0.01,增大迭代次数。
我用的是类似下面这组初始配置:
params = { 'objective': 'regression', 'metric': 'rmse', 'learning_rate': 0.05, 'num_leaves': 63, 'max_depth': -1, 'min_data_in_leaf': 20, 'feature_fraction': 0.7, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l1': 0.1, 'lambda_l2': 0.1, 'verbose': -1 }每调一个参数,我都在表格里记录CV分数变化。不要只盯着训练集表现,因为训练集轻松逼近0.00不代表模型有价值。关键是在验证集上出现平台期时,就要考虑收手了。
5.3 模型融合:为什么简单的加权平均效果很好
到了后期,单个LightGBM很难再提升,我开始做模型融合。融合的基本逻辑很简单:多个模型对同一批样本的误差模式不同,把它们的结果加权相加,噪声互相抵消,信号会被加强。
但融合之前要看相关性。我计算了我的LightGBM预测结果与XGBoost预测结果的相关系数,也计算了不同随机种子下LightGBM自身的预测相关性。相关性太高的话,融合收益趋近于零。
我最终采用的融合方式:
- 用不同随机种子训练多个LightGBM;
- 用不同特征子集训练两个LightGBM;
- 增加一个XGBoost和CatBoost模型;
- 对所有人的预测做加权平均,权重通过在验证集上网格搜索得到。
权重搜索范围一般在0到1之间,步长0.05,目标是让验证集RMSE最低。最后得到的权重组合不会太极端,因为如果某个模型权重为0,说明它对整体反而是噪声,可以考虑直接去掉。
5.4 特征重要性的使用边界
LightGBM自带feature_importance,输出每个特征在分裂时带来的信息增益总和。这是一个有用的参考,但注意不要完全按重要性排序去删特征。对于高相关的特征组,多个重要特征之间会分摊重要性权重,导致单个特征的重要性显得很低,如果直接删掉,其实丢掉了整组信息。
我的做法是用模型验证“置换重要性”:把某个特征的值随机打乱,看验证集分数的下降幅度。如果打乱后分数变化很小,说明该特征在当前模型里作用不大,可以尝试删除。这个方法比原生特征重要性的稳定性好很多。
6. 复盘时最容易翻车的三个细节
6.1 训练时用了未来信息:历史交易聚合不小心覆盖了验证期
这是最隐蔽的泄漏问题。我早期在构造特征时,直接对整张交易明细表做groupby聚合,然后join到训练数据上,这时候问题就出来了:训练集里的某一个card_id在验证期对应的交易数据也被聚合进去,等于模型在训练时看到了未来的交易信息,线下CV自然虚高。
解决办法是在聚合前先对交易明细表按month_lag做时间裁剪。训练集里用month_lag小于某个阈值的交易做特征,验证集和测试集也要按照相应的时间线生成对应特征。这里不要偷懒,时间不对齐会让一切特征工程白做。
我在修复这个问题之后,本地CV分数涨得很难看,但线上分数反而更真实了。这也是一个反向信号:如果本地分数高得离谱,先别高兴,先检查变量有没有时间层面上的穿越。
6.2 新商户冷启动:特征全空,模型怎么猜
Elo赛题还有一个现实问题:测试阶段会有一批全新的商户,他们没有历史交易记录,所有由交易流水聚合出来的特征都是空值或者填充值。模型在训练时很少见到这种“全空特征”的样本,预测时就会往均值方向收缩,导致新商户的预测分数普遍偏低。
针对冷启动,我当时做了两件事:
- 对交易聚合特征,用相同商户类别、相同城市的已有商户的均值去填充新商户的特征,而不是用全局均值;
- 单独构造一个“历史交易次数是否为零”的冷启动标志特征,让模型意识到这个样本属于冷启动场景。
加了这个特征后,线上分数确实有了小幅提升。原因是模型学会了在识别到冷启动样本时,采用一组更合适的权重去使用商户属性特征。
6.3 公共榜和私有榜的反复横跳
这个坑很多人一辈子都踩不厌。比赛平台的公共榜只是一部分随机样本的结果,你用它来筛选模型时,实际上是在对这堆样本拟合。公共榜刷得越好,并不意味着私有榜就一定更好,反而往往因为过拟合到公共榜噪声,导致私有榜翻车。
我当时的处理方法是:把线下CV当作最权威的参考指标,公共榜分数只用来检查是否存在严重的时间/特征泄漏。如果线下提升但公共榜反而变差,我不会刷掉这个特征,而是仔细检查两者之间的数据分布差异。这种方式尽管偶尔会让公共榜排名看起来平庸,但最终私有榜排名往往能往前大幅提升。
6.4 如果重来一次,我会如何调整这套流程
如果现在让我重新打一场Elo赛题,我会在第一天就建立分组CV框架,把数据表关系和特征生成管线全部写成清晰可复用的函数,而不是每次临时修改。快速跑出一个全流程基线,哪怕只用最朴素的计数特征,也能让后续每个实验都在相同基准上比较。
我会花更多时间在数据分析上,比如画出目标分数的分布、交易金额随month_lag的走势、不同类别下交易笔数的偏态。很多时候特征为什么有效、为什么无效,光看数值看不出来,数据可视化能直接告诉你答案。
模型部分我会更早引入多模型交叉验证,而不是等到LightGBM调不动了才考虑融合。机器学习和体育竞技很像,稳定输出的最低分,往往比偶尔爆发的最高分更重要。