资讯动态

从交易流水到用户忠诚度预测:Elo赛题特征工程实战

发布时间:2026/10/24 5:02:54 来源:尧图企业网站定制
最近我在整理《机器学习算法竞赛实战》的笔记第八章的实战案例选的是 Elo Merchant Category Recommendation一个很有名的用户忠诚度预测赛题。这题我前后读了两遍第一遍只跟着代码跑第二遍才真正把特征工程的思路吃透。如果你接触数据竞赛不久或者想把原始交易流水怎么变成模型输入这件事彻底搞清楚这题是最合适的教材之一。它是一个典型的特征工程主导型赛题——模型主力就是 LightGBM真正拉开差距的是对业务数据的理解以及对特征的构建方式。这一篇整理把赛题背景、数据探索、特征工程、建模调参和踩坑实录都串一遍。所有数据规模和结论都来自公开方案与我自己复跑的验证不是教科书式的罗列而是解决真实问题时的决策过程。1. 看懂赛题一场关于用户忠诚度预测的数据竞赛1.1 赛题背景与业务逻辑Elo 是拉美地区一家主流支付品牌赛题给了一批用户一张卡对应一个用户在数月内的真实交易流水要求预测每个用户的忠诚度得分。这个分数是一个连续值大致落在 0.8 到 1.15 之间值越高代表用户粘性越强值越低意味着流失风险越大。对支付公司来说识别低忠诚度用户是精准营销和商户推荐的基础所以赛题名叫商户类别推荐——真正要解决的问题其实是理解用户的消费行为再决定给谁推什么样的商户。这个赛题在算法竞赛里有一个非常典型的特征模型本身并不复杂最常见的主力模型就是 LightGBM真正拉开差距的是特征工程。原始数据不是规规整整的一行一个样本而是一个用户对应几十上百条交易记录必须自己把流水压缩成用户画像特征这非常考验对业务的理解。我经常拿它对比那些模型结构决定上限的CV赛题Elo 恰好相反数据理解决定上限。1.2 数据清单与赛题难点赛题给出了四张关键表。主表是 train.csv 和 test.csv一行是一个用户字段包括 card_id、first_active_month以及一组匿名特征 feature_1 到 feature_38。历史交易表 historical_transactions 记录了每个用户在过往约三个月的所有交易流水行数大概在 3000 万的量级。新商户交易表 new_merchant_transactions 记录的是用户和新商户之间的交易约 700 万行。商户表 merchants 提供商户维度的静态属性包括商户分组、商户类别、子行业ID等。文件行数量级主要字段作用train.csv / test.csv20万 / 12万card_id, first_active_month, feature_1~38主表一行一个用户historical_transactions.csv约3000万card_id, merchant_id, purchase_amount, purchase_date, installments, authorized_flag, category_*, month_lag历史交易流水new_merchant_transactions.csv约700万同上用户与新商户的交易merchants.csv约3000merchant_id, category_, subsector_id, numerical_商户静态属性难点可以归纳成四点。第一数据量大3000 万行流水如果不会高效聚合光跑特征就会把内存打爆。第二缺失值极多交易表里不是每个用户都有完整记录新商户表尤其稀疏。第三赛题在部分数值字段里故意注入了噪声直接拿原始数值做聚合会吃亏。第四评估指标是 RMSE优化这类连续值预测不仅要平均误差小还要控制极端样本的预测偏差不能只盯着均值看。这个赛题最值得学习的地方不是某个花哨模型而是从事件流水到用户画像的完整思考链条这也是后面所有章节的基石。2. 数据探索先读懂交易数据再说建模2.1 三张交易表的差别与联系历史交易表覆盖的是用户在观测期内与已有商户发生的主要交易字段里有 card_id、merchant_id、purchase_amount购买金额、purchase_date购买时间、installments分期数、authorized_flag授权状态以及几列类别特征和 month_lag。新商户交易表的字段结构几乎一样但业务含义完全不同它记录的是用户最近与新商户的往来某种程度上反映的是用户尝试新消费场景的意愿。刚开始做这个赛题的人最容易犯的错就是把两张交易表合并在一起做聚合。表面上字段一样但观测窗口、业务含义都不同混合后会丢掉新老商户分开看这个信息维度。正确做法是先把两张表分别聚合出用户特征再在最后的特征表里做交叉比如新商户交易占比、新商户平均金额与老商户平均金额之比。还有一个关键点不管 card_id 在训练表还是测试表它都可能出现在交易表里。也就是说测试用户的历史交易我们能看到这在赛事规则里是合法的。这意味着做商户维度的统计特征时可以把训练和测试的全部流水放在一起聚合得到更稳定的商户画像再回填给用户。这个操作能让 CV 和线上都明显变稳属于这题必做的一步。2.2 目标值扫描与泄露检查拿到主表之后第一步永远是看目标。loyalty_score 是一个连续变量整体分布近似钟形均值接近 1绝大多数样本落在 0.8 到 1.15 之间。用 RMSE 做评估时不应该看到分布偏了就无脑做 log 变换。因为 log 变换改变的是平方误差的权重RMSE 是在原始空间计算的log 变换往往偏激。我复跑时试过把目标做标准化再做逆变换结果 CV 不升反降这个赛题里老老实实用原始目标就够了。接下来要做的是一次快速泄露检查把主表里的 feature_1 到 feature_38 与目标值做相关性分析。匿名特征里确实有几个和目标存在明显相关这些特征会成为模型的骨架。但注意赛题说明里明确提到部分字段是被噪声污染的所以相关性偏高的特征要谨慎某些相关性高的假特征可能诱导模型过拟合。我的经验是先看相关性再进模型看重要性结合业务含义判断而不是单纯依赖统计量。2.3 别忽略数据里的脏东西交易流水表里最特殊的字段是 purchase_amount它的数值往往带有多达八位的小数。公开讨论里很早就有人发现这是刻意注入的噪声真实的金额本来应该只有两位小数多出来的尾数是干扰项。社区里的通用做法是在聚合前对金额列做一次 round保留两位小数这样聚合出来的均值、方差会更稳定。我在后面的实验里单独验证过round 之后小幅但稳定地提升了 CV。日期字段也要做归一化。把 purchase_date 解析成 datetime 之后一般会转成时间戳再计算距离观测截止日期的天数。这个距今多少天比绝对值更有用因为交易时间离现在越近行为越能反映用户当下的状态。installments 和 authorized_flag 这两个字段缺失值很多不要直接删除它们本身有含义authorized_flag 为 1 代表授权成功为 0 代表出现了未授权或撤销的交易这类记录在用户画像里能提供负面信号。缺失的部分单独用 -1 填充让模型自己去学习缺失这个状态也是一种信息。3. 特征工程交易数据竞赛的真正主战场3.1 用户维度聚合特征特征工程的核心目标是一行一位用户。交易表是一行一笔交易所以要按 card_id 分组把几十条、上百条交易压缩成几个统计量。复跑时只保留最基本的几类就能拿到可用的 baseline。第一类是交易规模特征总交易次数、总金额、平均金额、金额标准差、金额偏度。标准差和偏度非常重要因为它们能描述用户消费的稳定性。第二类是时间特征最早交易到最晚交易的时间跨度、活跃天数、每月的平均交易次数。这些特征刻画用户的消费频率。第三类是商户多样性特征去重商户数、去重商户类别数。多样性高说明用户消费场景广。代码大概是这样的gr hist.groupby(card_id) feat pd.DataFrame(indexgr.indices.keys()) feat[hist_count] gr[purchase_amount].count().astype(np.float32) feat[hist_amount_sum] gr[purchase_amount].sum().astype(np.float32) feat[hist_amount_mean] gr[purchase_amount].mean().astype(np.float32) feat[hist_amount_std] gr[purchase_amount].std().astype(np.float32) feat[hist_amount_skew] gr[purchase_amount].skew().astype(np.float32) feat[hist_merchant_nunique] gr[merchant_id].nunique().astype(np.float32)这段代码的细节是全部转成了 float32。3000 万行流水做 groupby如果不控制 dtype特征表很容易膨胀到几个 G影响迭代速度。这种内存意识在真实竞赛里不是可有可无的而是能不能快速跑通实验的前提。3.2 商户维度与跨表格特征用户聚合只是第一层。第二层是先对 merchant_id 做聚合得到这个商户通常卖多少钱、交易频次多高、用户对它的粘性如何再把商户特征通过用户历史交易记录回填给用户。这一层本质上是把每个用户的消费对象画像融入模型。实操上分两步。第一步对商户聚合出平均每笔金额、商户总交易笔数、商户的类别分布。第二步把这个商户特征表 join 回交易表再按 card_id 做平均或加权平均。公开方案里表现最好的特征很多都来自这一层。我自己实验得到的结论是商户级别的特征和用户级别的特征在模型里的重要性大致五五开只做用户聚合不做商户聚合CV 会明显差一截。merchants.csv 里的静态字段也一样把商户表 join 到交易表后商户分组 ID、商户类别 ID、子行业 ID 这些类别特征需要做数值化处理然后按用户聚合出众数或占比。有一类特征特别值得做用户在某个一级商户类别上的消费占比。比如日用百货类消费占比高还是餐饮类消费占比高这种偏好特征对忠诚度预测的区分度很好因为消费结构本身就反映了用户的生活方式和支付习惯。3.3 新商户交易表的特殊价值新商户交易表要单独拿出来说因为它在业务含义上太特殊了。用户找新商户消费往往意味着他在探索新的消费场景这种探索意愿本身就是区分忠诚度的一个信号。做法上还是老一套聚合但要注意三点。第一新商户表的窗口比历史表短样本量少聚合结果更稀疏缺失值更多不要拿历史表的口径硬套。第二维度要分开做新商户表单独出一组特征不要混在历史特征里。第三可以加交叉特征比如新商户交易金额占总交易金额的比例、新商户交易次数占总交易次数的比例。这两个比例特征我复跑时印象很深它们比新商户表的绝对金额特征更稳健因为天然消掉了用户本身的消费量级差异更能反映行为结构的变化。经验法则凡是涉及占比比例的交叉特征通常比绝对量特征稳定因为绝对量受用户基础消费水平影响太大比例特征则是在同一用户内部做归一化。3.4 特征评估与迭代策略特征工程最忌讳的就是闷头造特征最后一次性验证。我个人的工作流是先搭好 5 折 CV 框架然后每一次只新增一小批特征观察 CV 和线上的变化。新的聚合特征加进来如果 CV 不涨果断砍掉如果 CV 涨了但幅度很小也要结合线上判断不要为了零点几个千分位去增加系统的复杂度。另外特征数量一旦超过几百个就要留意特征之间的共线性。LightGBM 对共线性不算敏感但完全重复的特征只会拖慢训练。我用的是简单办法每轮迭代后输出特征重要性把重要性几乎为 0 的特征清掉。这个方法看起来粗糙但在实践中非常有效能帮你省下大量调参时间。4. 建模与调参把 LightGBM 用到极致4.1 交叉验证设计Elo 这个赛题的测试集和训练集是从同一个用户池里随机切出来的没有很强的时序结构所以直接用 KFold 就可以不需要做时间序列切分。训练集大约 20 万行我用 5 折每折 4 万样本训练时间完全可接受。很多人喜欢在这里加 StratifiedKFold但 loyalty_score 是连续值没法直接分层。如果一定要分层可以按目标分位数切成几个桶再做分层。我测试下来普通 KFold 和分位数分层在 CV 上几乎没有差别反而普通 KFold 更稳定。这个赛题里我更推荐一个实用做法固定随机种子把训练与验证的划分做成可复现的整个特征迭代周期都用同一组划分这样不同特征之间的对比噪音最小。4.2 参数配置与训练细节主力模型用 LightGBMobjective 是 regressionmetric 用 rmse。复跑时一组合适的参数是learning_rate0.01num_leaves128max_depth12min_child_samples20feature_fraction0.7bagging_fraction0.8bagging_freq1lambda_l21.0。小学习率加大叶子数配合早停是这套赛题里最不容易出错的组合。import lightgbm as lgb params { objective: regression, metric: rmse, boosting_type: gbdt, learning_rate: 0.01, num_leaves: 128, max_depth: 12, min_child_samples: 20, feature_fraction: 0.7, bagging_fraction: 0.8, bagging_freq: 1, lambda_l1: 0.3, lambda_l2: 1.0, verbosity: -1, seed: 42, }训练时每个 fold 做 early_stoppingrounds200最大迭代 1 万轮。特征数量在几百的量级时单折一般几百轮就会触发早停整个 5 折跑完用不了太久。细节上建议把 oof 预测和 test 预测都保存下来一方面可以验证 CV另一方面后续做模型融合直接复用不用重训。4.3 预测校准与后处理这个赛题后处理有一个公认的加分项把预测值裁剪到 [0.8, 1.15] 区间。因为 loyalty_score 物理上就落在这个范围测试集没有理由出现超出区间的预测。预测被裁掉极端值之后RMSE 一般能小幅下降。我复跑时的测试结果是裁剪让 CV 微涨了大约 0.001 到 0.002虽然幅度不大但这是白捡的分数。另一个常用的后处理是多折训练后做预测平均即每折模型对同一测试样本各预测一次取平均。它比单折预测更稳但本质只是平滑。如果预算允许可以再叠加多种子平均换三个随机种子重训融合出最终的提交。到了这一步LightGBM 单模型的分数基本就到天花板了想继续提升需要引入 XGBoost、CatBoost 做集成或者尝试类似用户相似度矩阵的高级思路后者复杂度高新手不建议一上来就碰。5. 常见问题与坑点实录5.1 购买金额里的隐藏噪声这个坑对所有做交易流水整理的赛题都有普适性。purchase_amount 列带有多位小数噪声我在第一次复跑时没处理直接把原始金额拿去做聚合得到的平均金额特征总感觉不如公开讨论里的那些稳定。后来照着社区方案加了一行 np.round(purchase_amount, 2)CV 立刻有了小幅改善。这里有一个提醒不是所有情况下都应该 round。如果后续特征设计依赖金额的精确定位比如判断一笔交易是不是某个特定商品价格round 可能会损失信息。我的经验是聚合类特征用 round 后的金额原始金额只保留在一个备用副本里两种都跑一版看 CV 决定。把选择的依据落到数据上而不是凭感觉。5.2 线下 CV 涨了线上 LB 却跌Elo 这个赛题出现过不少CV 涨、LB 跌的案例。原因主要有三个第一测试集里不少用户在新商户表和历史表里记录都很少特征稀疏模型在这些人身上的表现很容易波动第二部分高重要性匿名特征本身被注入了噪声模型把噪声模式学进去后线上自然翻车第三后处理里对分布的假设不一定在测试集上也成立。解决方向是特征不是越多越好优先保业务含义清晰、覆盖率高的特征CV 要多折取平均不要盯单折分数提交之前对预测值做区间裁剪。这套组合打下来CV 和 LB 的相关性会明显好很多。现象可能原因建议处理聚合金额特征不稳定金额列含多位小数噪声在聚合前对金额做 round 到 2 位小数CV 和 LB 相差明显特征覆盖稀疏、噪声被学进去多折平均、加区间裁剪、优先保留高覆盖特征训练太慢特征数过多、dtype 过大转 float32、清理重要性接近 0 的特征新商户特征缺失严重窗口短、样本稀疏单独分组做特征不要和历史表混聚5.3 从公开方案里应该抄什么每次竞赛结束都会有很多高分方案公开Elo 这题的公开方案里信息量非常大。我最常被问到的问题是方案这么多从哪里下手。我的建议是分层看第一层看特征工程框架了解别人是从哪些角度切分用户行为的第二层看交叉验证和后处理这部分几乎可以直接复用第三层才看模型集成结构。比如公开方案里被反复提到的用户-商户行为相似度矩阵核心思路是把每个用户在不同商户上的消费强度看成一个向量用近似近邻算法找到相似用户再用相似用户的忠诚度做后处理或特征。这个思路对 Elo 这种测试用户也能在交易表里找到的赛题特别适配但它涉及到矩阵构建和近邻计算新手第一次跑很可能被复杂度劝退。我的建议是先死磕基础聚合特征和 LightGBM 调优把分数推到能进前排门槛的水平再回来研究这类高阶结构。这个赛题我前后复跑了两次第一次只看代码第二次按自己的理解从特征工程一步步搭回去收获完全不一样。如果只让我保留一条经验那就是交易流水类竞赛的胜负手永远在对行为的理解上同一个字段放错维度、选错窗口、混错表效果天差地别。希望这篇整理笔记对正在啃《机器学习算法竞赛实战》第八章的你有点帮助。赛题本身已经过去很久了但它的方法论到现在依然能被复用。如果你也把 Elo 这套流程完整跑通了再回头去看其他用户行为预测类的赛题会发现很多框架都是相通的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑