首页 / 资讯中心 / 文章详情

从零构建回归预测模型:基于交易数据的特征工程与LightGBM实战

从零构建回归预测模型:基于交易数据的特征工程与LightGBM实战 ★ FEATURED ARTICLE
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_id3和city_id7之间并没有“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固定在31max_depth不限制然后通过min_data_in_leaf来控制叶子的最小样本量。样本量在几十万的场景下min_data_in_leaf设置在50到100之间比较稳妥。正则化参数主要是lambda_l1、lambda_l2和feature_fraction。这个比赛的特征数量虽然多但很多特征之间存在高度相关性供给树模型重复分裂同一个信息会让模型在某几个强势特征上过度置信。我实测下来feature_fraction0.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-FoldshuffleTruerandom_state42。每一折训练时只从训练部分构造目标编码避免信息泄漏。验证集上用RMSE作为评估指标最后五折RMSE取平均作为模型的线下分数。这个方法在这个比赛上表现很稳线下分数和线上提交分数之间的偏差一般在0.005以内。需要特别提醒的是不要同一份验证集反复尝试不同的随机种子选最高的那个这本质上是在做数据窥探。正确的做法是固定一个种子一旦确定了特征集和参数再换两三个种子确认稳定性即可。4. 实操过程与代码要点4.1 数据预处理与内存优化这个比赛的原始数据量不算特别大但我做特征工程时习惯先把内存优化做好因为数据会随着特征数量的膨胀翻好几倍。具体做法是把所有能降级的数值列全部降级int64降为int32甚至int8float64降为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_amountsum, avg_amountmean, std_amountstd, min_amountmin, max_amountmax, median_amountmedian )然后直接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这个案例的价值恰恰在于它结构完整、数据量适中、特征空间大适合把“多表关联聚合特征-目标编码-交叉验证-模型调优”这一整套流程练扎实。这套流程换到任何一个类似的多表回归问题上都能直接搬过去用。如果在实际动手时遇到线上线下分数对不齐先不要怀疑模型调参不够回头把特征的分布对比表打出来看一眼多半能发现问题所在。
阅读完成 · 觉得有帮助?
咨询建站