1. 这个案例到底在解决什么问题
1.1 比赛背景与任务定义
第一次看到“Elo Merchant Category Recommendation”这个比赛名称时,我下意识以为它是个推荐系统比赛——根据用户历史行为给用户推荐下一个要去的店铺类别。但真正读完数据说明后发现,这其实是一个回归预测问题,而且比我想象中要“朴素”得多。
比赛的核心任务是基于用户在某支付平台上的历史交易记录,预测每个用户对某类商家的忠诚度评分。这个忠诚度评分不是我们平时说的“复购率”或者“满意度打分”,而是平台通过一系列业务规则计算出来的一个标量值,范围大约在-33到33之间。训练集里每个用户对应一个或几个商家类别,每个样本有一个目标值;测试集里同样给出用户和商家类别,要求预测对应的目标值。评估指标是RMSE,也就是均方根误差。
这个任务看起来简单——就是个回归,给特征,训练LightGBM,提交预测,完事儿。但真正动手之后才发现,这里的核心难点全在特征工程上。商家类别本身只有几十种,用户数量倒是不少,但最关键的信息藏在每个用户半年左右的刷卡记录里,而不是直接给好的结构化特征里。换句话说,这个比赛考察的是你能不能从一堆原始交易流水里提炼出有预测力、稳定、不泄漏的统计特征。
我当时读这本书的第八章时,最大的收获不是它给了多少代码,而是它把这类问题抽象成了一个可以复用的方法论:**先做用户侧的画像聚合,再做商户侧的行为统计,再用历史交易的时间结构构造时序特征,最后才是调模型。**这一套流程放到今天的大多数表格类竞赛里,依然完全适用。
1.2 数据表结构与字段解读
这个比赛的数据集结构非常典型,适合用来理解什么叫“多表关联下的特征工程”。核心数据有三块:
- 训练集与测试集:每一行是一个
card_id与某个merchant_category_id的组合,目标是loyalty_score。 - 历史交易记录表:每个用户在若干个月内对某些商家的所有刷卡记录,包含交易时间、金额、商户类别、是否为新商户等字段。
- 补充信息表:包含商户的若干元信息,比如城市、类别等。
这里有一个非常关键的地方:训练集中每个card_id是唯一的,也就是说每个用户只出现一次。这就意味着,所有从历史交易表里构造出来的特征,最后都要聚合成每个用户一条,整个建模过程实际上是在用户粒度上做回归。
所以这个案例的时间泄露风险极高。交易记录的日期字段是真实时间,而训练集和测试集的划分并不是严格按时间切分的,也就是说,在构造窗口特征时必须格外小心,否则很容易把“未来信息”卷进来,导致线上的分数完全崩掉。
另外,这个比赛的测试集里包含大量新用户——也就是历史交易表里记录很少甚至为零的用户。对于这些用户,任何基于历史行为的特征都会退化为缺失值或默认值,所以设计特征时还需要额外考虑“如何在数据稀疏时依然保留预测能力”的问题。
1.3 从业务逻辑反推技术方案
这个案例对我启发最大的地方在于:它没有一上来就堆模型,而是先把业务逻辑翻译成了特征工程方案。什么叫“客户忠诚度”?放到交易数据里,最直观的刻画方式就是用户在某类商户的消费金额占比、消费频次占比、消费时间跨度、消费金额稳定性。
举个例子,如果一个用户在三个月内一共刷了20笔,其中15笔都发生在一个餐饮类商户,而且每笔金额都差不多,那么从业务上说,这个用户对这个餐饮类别的忠诚度大概率是高的。反过来,如果20笔分散在十几个类别里,每类只有一两笔,那他对任何一个类别的忠诚度都不会太高。
顺着这个思路,特征工程的骨架就搭起来了:
- 频次维度:某类商户的交易笔数、笔数占比、平均每天/每周的交易笔数。
- 金额维度:某类商户的总金额、平均金额、金额占比、金额的标准差。
- 时间维度:首次交易距今的天数、末次交易距今的天数、活跃天数、交易间隔的均值与标准差。
- 多样性维度:用户一共涉及多少个商户类别、某类商户涉及的独立商户数。
这些特征单独拿出来都很简单,组合起来就是完整的用户消费画像。我在实际复现这个案例时,最明显的感受就是:**模型选型几乎没有争议,大家比的纯粹是谁的特征更贴近业务本质、谁的统计口径更稳。**再加上Elo比赛的评估指标是RMSE,对异常值比较敏感,所以所有特征都必须考虑做稳健化处理,比如用中位数代替均值、对金额做对数变换。
2. 特征工程:这个案例的灵魂
2.1 聚合统计特征的正确打开方式
先说说最核心的聚合特征怎么构造。这里有一个原则:**不要在脑子里想象特征,而是从数据字典出发,穷举每个字段的聚合方式。**比如历史交易表里有purchase_amount、purchase_date、merchant_category_id、state_id、city_id这些字段,那么对每个字段,你可以做的聚合操作包括:count、sum、mean、std、min、max、median、skew、quantile。
把这些组合全部展开,一次就能生成几百个特征,然后再靠模型去筛选。我在复现时发现,构造完所有聚合特征后,特征数量从最初的十几列直接膨胀到了两百多列。一开始我担心特征多了会过拟合,但用LightGBM跑下来,只要调好min_data_in_leaf和正则化参数,过拟合并没有想象中严重。原因也很简单:这些特征大多数是单调重复的统计口径,真实信息密度并没有那么高。
不过,聚合特征有个坑:**同一批原始数据,不同的聚合粒度会产生完全不同的效果。**比如对用户做聚合,得到的是“这个用户的整体消费水平”;对“用户+商户类别”做聚合,得到的是“这个用户在某类商户的消费情况”。这两个粒度一个代表了全局行为,一个代表了局部偏好,都必须保留。我在这个案例里做得最多的操作,就是反复调整groupby的键,比如card_id、card_id + merchant_category_id、card_id + city_id,每种粒度都生成一份统计特征,最后横向拼接回主表。
还有一种很容易被忽略的聚合方式:**对商户做聚合。**比如每个商户的平均客单价、每个商户的日均交易笔数,这些特征传到用户侧之后,能帮助模型理解用户经常去的商户是什么样的——是高消费还是低消费、是大商场还是街边小店。这种“用户-商户-类别”三层结构的信息传递,正是这个案例的特征工程能够做深的基础。
2.2 时序特征的坑与处理
这个比赛的时间信息很有价值,但也很容易出错。历史交易表里的purchase_date是真实时间,所以你可以构造诸如“最近一次交易距今X天”“某个时间窗口内的交易笔数”之类的特征。但这里的隐藏陷阱是:训练集和测试集的用户交易时间窗口并不完全一致。
我记得书里特意强调了一个点:Elo比赛从第一个月的数据开始,比赛官方已经对训练集的目标值做了某种“剥离处理”,目标值是基于用户早期行为计算出来的。换句话说,你不能简单粗暴地把“最近30天的交易特征”直接放进模型,因为对训练集用户来说,最近30天可能包含了目标值的计算窗口;而对测试集用户来说,时间轴可能整体偏移。一旦这种偏差存在,模型在训练集上表现得再好,到线上都会打折扣。
我当时在这个环节踩过一个真实的坑:我构造了一个“最近14天交易金额占比”的特征,线下验证RMSE下降非常明显,但提交后线上分数比基线还差。后来把特征改成“最近90天交易笔数占比”之后,线上线下才趋向一致。原因其实不复杂——14天窗口太短,训练集和测试集在窗口内的数据分布差异被模型抓住了,相当于模型间接学到了一个和官方的目标值计算方式强相关的统计量,但这个规律并不能泛化到线上。
所以现在我做这类时序特征时,会刻意加一道保险:每个时序特征至少同时构造三个版本——长窗口(90天)、中窗口(30天)、短窗口(7天),然后用同一个验证集去对比线上线下。如果一个特征在某个窗口下线上线下表现差异巨大,就优先弃用短窗口版本。
2.3 类别特征的编码方案
这个比赛里的类别特征不算多,但处理方式很有代表性。核心有三个类别型字段:merchant_category_id、state_id、city_id,此外还有诸如category_1到category_4这样的离散字段。
最简单的方案当然是直接LabelEncoder,把类别映射成整数。但对树模型来说,LabelEncoder之后依然直接按整数分裂,如果类别本身没有序关系,这种编码就损失了语义。比如city_id=3和city_id=7之间并没有“3比7更接近”的含义。所以更稳妥的做法是目标编码——用目标值的均值替代类别本身。
目标编码在这个案例里表现很好,但有个致命问题:**容易过拟合。**特别是当某个类别下的样本量很少时,目标编码会把目标值本身的信息灌进去,造成线下虚高。解决办法有几种:
- 在目标编码中融入平滑系数,比如
(sum_y + mean_all * alpha) / (count + alpha)。 - 对目标编码做K-Fold内部统计,每个Fold只用其他Fold的数据计算编码值。
- 或者在生成目标编码特征后,再用LightGBM的
categorical_feature参数对原始类别做原生类别处理。
我实际复现时用的是第二种方案:先按K-Fold把训练集切好,每个Fold内只用训练部分计算目标均值,然后把编码值应用到验证部分。这样做出来的目标编码特征,线下和线上的表现更接近。书里给出的代码示例不算复杂,但思路是非常标准的。
还有一种处理方式是把这个比赛里的类别特征转成“跨特征交叉”,比如merchant_category_id + city_id的组合作为一个新类别,再对它做频次编码或目标编码。交叉后的类别往往比单个字段的区分度更强,因为“用户在某城市的某类商户消费”这个粒度更贴近业务。我把merchant_category_id和city_id交叉后做目标编码,RMSE大约又降了0.005左右,效果非常明显。
3. 模型选型与调参实战
3.1 为什么首选LightGBM
这个比赛的主流方案几乎都是LightGBM,少数人用XGBoost,几乎没人用神经网络。原因很直接:这类表格数据的特征维度高、字段类型杂、噪声大,树模型在处理这种结构时天然占优。
LightGBM相对XGBoost的优势主要体现在三点:
- 直方图算法让训练速度大幅提升,在特征数量两百多列、样本量几十万的规模下,训练一轮只需要几十秒,这对频繁迭代特征工程非常友好。
- 原生支持类别特征,可以直接传入
categorical_feature参数,省去手动编码的步骤。 - Leaf-wise的树生长策略在控制好
num_leaves和max_depth的情况下,往往比XGBoost的Level-wise精度更高。
当然,Leaf-wise也是一把双刃剑,它比Level-wise更容易过拟合。所以我的做法是固定num_leaves在31左右,同时把min_data_in_leaf调大到50甚至100,这样既能利用Leaf-wise的精度优势,又不至于让树生长得太深太碎。
调参这件事,我个人的理解是:**比赛里的调参收益远低于特征工程收益。**我在这个案例上把大量时间花在特征构造上,模型参数只是在一组合理的范围里做了简单的GridSearch。说实话,从默认参数到一个调得不错的参数,RMSE提升大概在0.01-0.02之间,而一个有效的特征一般能带来0.01以上的稳定提升,一个信息量大的特征甚至能接近0.03。所以优先把力气花在特征上,参数只要不出现明显过拟合或欠拟合,保证训练稳定就可以了。
3.2 参数调优的全局观
既然书里花了篇幅讲LightGBM调参,这里也把我实际用下来最有效的一组参数框架写出来。首先明确一个概念:LightGBM的参数可以粗略分成三组——树结构参数、正则化参数、训练控制参数,调参时要分层去调。
树结构参数里最重要的是num_leaves、max_depth和min_data_in_leaf。这三个参数共同决定了模型的复杂度。我习惯先把num_leaves固定在31,max_depth不限制,然后通过min_data_in_leaf来控制叶子的最小样本量。样本量在几十万的场景下,min_data_in_leaf设置在50到100之间比较稳妥。
正则化参数主要是lambda_l1、lambda_l2和feature_fraction。这个比赛的特征数量虽然多,但很多特征之间存在高度相关性,供给树模型重复分裂同一个信息,会让模型在某几个强势特征上过度置信。我实测下来,feature_fraction=0.7时比全特征训练更稳,虽然单折分数有时会略有一丁点波动,但整体泛化性更好。
训练控制参数里核心是learning_rate和n_estimators。我的建议是把learning_rate设在0.02到0.05之间,然后用Early Stopping去自动确定迭代轮数。学习率太低会明显拖慢迭代,太高又容易提前过拟合。在特征迭代频繁的阶段,用0.05快速验证特征有效性;在最终确定模型的阶段,再用0.02做一次精调。
3.3 交叉验证怎么设计才公平
交叉验证是这个案例里非常有争议的一个点,因为这个比赛本质上是一条时间线数据,但官方对目标值得处理方式使得随机K-Fold反而比时间序列切分更合理。书里也提到了这个现象,我当时看到时还挺惊讶的——多数排名靠前的方案都用了随机K-Fold,而不是按时间切分。
原因其实在于:官方在计算目标值时,已经基于用户的早期行为做了某种调整,所以对于一个用户,目标值反映的是他在“过去一段时间内”的真实行为模式,不依赖未来信息。同时,因为训练集和测试集的用户是随机混匀的,随机K-Fold能更好地模拟测试集的分布。
我实际采用的策略是5-Fold K-Fold,shuffle=True,random_state=42。每一折训练时只从训练部分构造目标编码,避免信息泄漏。验证集上用RMSE作为评估指标,最后五折RMSE取平均作为模型的线下分数。这个方法在这个比赛上表现很稳,线下分数和线上提交分数之间的偏差一般在0.005以内。
需要特别提醒的是:不要同一份验证集反复尝试不同的随机种子选最高的那个,这本质上是在做数据窥探。正确的做法是固定一个种子,一旦确定了特征集和参数,再换两三个种子确认稳定性即可。
4. 实操过程与代码要点
4.1 数据预处理与内存优化
这个比赛的原始数据量不算特别大,但我做特征工程时习惯先把内存优化做好,因为数据会随着特征数量的膨胀翻好几倍。具体做法是把所有能降级的数值列全部降级:int64降为int32甚至int8,float64降为float32。对于类别字段,如果值是整数型的,直接转成category类型,这样既省内存又能配合LightGBM的类别特征支持。
还有一个非常容易忽视的坑:**merge的顺序。**历史交易表是多行,特征表是一行,如果在做聚合前先把历史交易表和主表merge起来,会直接导致行数爆炸。我见过很多新手在这里翻车——本来训练集只有几万行,merge完之后变成几百万行,内存直接爆掉。
正确的做法是:先按目标粒度(用户、用户+类别、商户等)对历史交易表做groupby聚合,确认聚合结果的行数和主表匹配(或者比主表更细),再把聚合后的特征表merge回主表。这样可以保证每次merge都是“小表接小表”,不会出现爆炸性的中间结果。
我在每生成一个特征组之后,都会顺手做一个shape检查——打印出主表行数、特征表行数、merge后的行数,确保没有多出行也没有变少。这种看似笨拙的检查,在特征工程迭代几十次以后格外重要,因为一旦某一个特征组的merge出了问题,它会污染后面所有基于主表的特征,排查起来非常痛苦。
4.2 特征工程的实现细节
这里展示一个我在实现时最常用的特征构造框架,虽然书里给的代码也是类似结构,但我把它整理成了一个更适合反复扩展的流程。
第一步,先把历史交易表和补充信息表预处理成标准DataFrame,统一字段名。第二步,定义一批聚合特征函数,每个函数负责一个粒度的聚合。第三步,把每个特征函数产生的结果归并到主表。最后,对全表做缺失值统计,标记出缺失率过高的特征。
单看代码量,这些步骤并不复杂,核心是groupby后的agg操作。比如:
purchase_agg = transactions.groupby('card_id')['purchase_amount'].agg( total_amount='sum', avg_amount='mean', std_amount='std', min_amount='min', max_amount='max', median_amount='median' )然后直接merge回主表。对于“用户+商户类别”粒度的特征,只要把groupby的键换成['card_id', 'merchant_category_id']就行了。代码逻辑完全一样,只是统计口径不同。
但这里有一个性能细节要注意:像min、max、median这类聚合会在大数据量下产生开销,特别是反复对同一个数据集做几十次不同粒度的聚合,耗时累加起来会非常可观。所以我的做法是:**先把所有参与聚合的列一次性取出,预处理好,然后把多次聚合合并成一次groupby操作。**例如把要聚合的列都放在一起,用groupby(...).agg({...})一次性算完,再拆分成列。这样比循环几十次groupby高效得多,尤其是数据量到了几十万行之后,速度差异是分钟级的。
4.3 训练与预测的关键环节
在最终训练环节,我用的方案是对全量训练集做5-Fold K-Fold,每一折训练时用Early Stopping找回最优迭代轮数,然后用所有数据再训练一次达到相近的迭代轮数,在测试集上取各折预测的均值。
有一件事我必须强调:**不要在验证集上反复折腾Early Stopping的迭代轮数。**Early Stopping本身就是为了防止过拟合,如果验证集上的最佳迭代轮数波动很大,说明特征集或参数有问题,这时候应该回到特征工程环节去排查,而不是硬调参数。
关于预测结果的融合,这个比赛的提交目标是RMSE,所以我在最终阶段尝试过简单平均和加权平均两种融合方式。变量加权时,我用的是各折验证集RMSE的倒数作为权重,这样RMSE更低的模型对最终预测的贡献更大。这个方案在理论上比简单平均更合理,但我实测下来,两个方案之间的差距微乎其微。
所以我个人的建议是:**如果各模型的RMSE差异不大,优先用简单平均。**简单平均更稳,不容易被某个权重异常放大噪声。只有当某个模型明显优于其他模型时,才需要做加权融合。
5. 常见问题与排查经验实录
5.1 线上线下的分布偏差
这个比赛最常见的坑就是线上与线下分数不一致,偏差有时甚至达到0.02以上。我排查时最先看的是:**特征的缺失率在训练集和测试集之间是否一致。**比如某个新商户相关的特征,在训练集中缺失率是10%,在测试集中缺失率变成30%,那模型在测试集上就会面临大量它没见过的缺失模式,预测自然变差。
对这个问题的解决思路是:对缺失率差异过大的特征,要么删除,要么做特殊处理。比如把缺失值单独编码成一个特征,让模型显式学习到“这个用户没有在新商户消费过”的含义。这种方法不仅避免了分布偏差,还引入了额外的业务信息。
另外还有一个容易被忽视的偏差来源:**目标编码特征在测试集上的分布差异。**如果训练集中的某个类别在测试集中样本量很少,目标编码值会非常不稳定。我在构造目标编码时,专门生成了“目标编码值”和“该类别在训练集中的样本量”两个特征,样本量特征可以帮助模型判断目标编码特征的置信度——样本量越小,目标编码值越不可靠。
5.2 时间泄漏与排序错误
时间泄漏是这个案例里最隐蔽的错误。表面上看,目标值是过去行为的结果,跟未来无关,但如果你把测试集构造逻辑和训练集混在一起,很容易在无意中引入偏差。
举个我实际遇到的例子:我想构造一个“近期平均消费金额”的特征,于是对每个用户取了最近30天的消费记录做平均。但在训练集中,有些用户的交易记录恰好覆盖到目标值计算窗口之后的时间段,这些记录本身就和目标值有某种相关性。如果模型学到了这种相关性,它在线上的表现就会变得不可预测。
排查这类问题有一个非常有效的技巧:**把你的特征按照时间窗口分层检查。**比如分别构造“最近7天”“最近14天”“最近30天”“最近90天”的交易金额占比特征,然后观察这些特征在验证集上的RMSE变化。如果短窗口特征的提升幅度明显大于长窗口,那就要警惕是不是在“偷看”某种和时间强相关的信息。正常情况下,不同窗口的提升幅度应该比较均匀,一旦出现极端的不均匀,就需要重新审视特征口径。
5.3 特征有效性评估方法
最后说一个非常实用的经验:不要只看单特征的RMSE,要同时看特征在模型中的特征重要性(importance)和测试集上的预测贡献。
我通常用一个固定的基础特征集作为参照,然后每次加入一组新特征,对比RMSE变化。如果RMSE下降明显,说明这组特征信息量高;如果RMSE几乎没有变化,那就直接剔除,避免特征数量膨胀带来的训练开销。
但这里有个坑:LightGBM的特征重要性(按split次数或gain)排序靠前的特征,不一定是对RMSE贡献最大的特征。因为在高度相关的特征组里,模型会把重要性分散到多个特征上,导致单看某一个特征它排不到前面,但整组特征一起加入时对RMSE的改善很显著。所以评估特征时最好是“一组一组地评估”,而不是“一个特征一个特征地挑”。
我最终的方案是:把所有特征按构造的思想分组,比如“用户统计特征组”、“用户+类别统计特征组”、“商户特征组”、“时间窗口特征组”、“目标编码特征组”,在做消融实验时按组逐一剔除,看哪一组被剔除之后RMSE回升最明显。这个方法在迭代特征时非常高效,能让你清楚地知道哪些特征在真正供血。
写在最后的几点心得
这个案例我前前后后复现过两遍。第一遍基本是照着书写完,把结果跑通就放下了;第二遍是隔了大半年重新拿起来,不看书,自己从零开始推演特征方案、设计验证流程,最后发现同样的方法用起来明显更顺手,对RMSE的每一次波动也有更清晰的理解。
我最大的体会是:**算法竞赛实战类的内容,真正留下来的不是某个比赛的解法,而是一套可以复用的思考框架。**Elo这个案例的价值恰恰在于,它结构完整、数据量适中、特征空间大,适合把“多表关联聚合特征-目标编码-交叉验证-模型调优”这一整套流程练扎实。这套流程换到任何一个类似的多表回归问题上,都能直接搬过去用。
如果在实际动手时遇到线上线下分数对不齐,先不要怀疑模型调参不够,回头把特征的分布对比表打出来看一眼,多半能发现问题所在。