O2O优惠券预测实战:特征工程与时间泄漏避坑指南
发布时间:2026/9/26 11:25:35来源:尧图网络
简介面向天池新人实战赛O2O优惠券使用预测任务这套7KB的小型代码包提供了完整的特征工程与建模参考方案适合正在入门数据挖掘比赛、希望快速了解优惠券核销预测思路的学习者。压缩包内共5个文件以3个Python脚本为主分别承担数据预处理、特征构建与标签处理另含README说明和License文件结构轻量清晰便于直接阅读和迁移。方案围绕用户、商家、优惠券三个主体构建统计特征、排序特征与时间特征并加入用户-商家、用户-优惠券、商家-优惠券交叉特征群能够帮助理解如何从用户画像和距离/时间角度刻画消费意愿。模型层面采用XGBoost虽然训练耗时较长但精度表现较好为实战调参提供了可复现的基线。已有2777人学习下载对初次接触O2O竞赛或特征工程的新手有较高参考价值。1. 天池新人实战赛 o2o 优惠券使用预测一场「离线高分、线上翻车」的经典战役做推荐和风控的同行大多对那个「拿着优惠券却不用」的场景不陌生。2016 年前后天池放出 o2o 优惠券使用预测的新人赛任务很直接基于用户在 15 天内是否核销优惠券的历史行为预测下一次发券后用户会不会用。这个赛题被后来无数人当作入门特征工程的标配练习原因在于它足够简单——一张表、一个二分类——又足够复杂——时间穿越、数据泄漏、样本不均衡全都有。我当年用了一个非常朴素的 LR 加手工特征把离线 AUC 刷到了 0.78结果在评测集上只剩下 0.71后来才意识到所谓的「新人友好」其实藏着 O2O 场景里最真实的坑用户行为的时间依赖远比特征数量重要。这篇文章不是带你复现某个冠军方案而是把一个能稳定跑通的骨干流程拆开从字段含义到特征构造再到验证方式把值钱的细节讲清楚适合刚入门的算法工程师也适合要接手营销场景建模的同学。2. 先读懂任务和数据优惠券核销预测的字段背后是三类行为2.1 任务定义15 天的时间窗是标签的唯一裁判天池 o2o 优惠券使用预测的核心是一个二分类问题给定一条记录用户、商户、优惠券、领取时间预测用户在 15 天内是否会核销该优惠券。这里的关键不是「会不会用」而是「15 天内会不会用」。这个时间窗直接决定了标签怎么打在 date_consumed 非空且与 date_received 的间隔不超过 15 天时样本才是正例超过 15 天或者根本没有核销记录一律算负例。这个定义很多人第一眼会忽略它的分量。我见过不止一个新手把「是否消费」当成标签结果日期特征像脱缰的野马一样泄漏到模型里——因为消费日期本身就在标签里再用它做特征离线 AUC 直接奔着 0.95 去线上惨不忍睹。正确的做法是先构造标签列再把 date_consumed 从特征里彻底拿掉只保留 date_received 和它之前的用户行为。数据集本身不大训练集是用户在某个时间段内的领券和核销记录字段包括 user_id、merchant_id、coupon_id、discount_rate、distance、date_received、date_consumed另外还有一个容易被忽略的字段 field它标记了这条记录是线下场景还是线上场景。field 为 1 时 merchant_id 往往为空因为线上券不绑定具体门店这一点在后续特征工程里要分情况处理不能一刀切。2.2 折扣率字段 discount_rate 不是数值是字符串discount_rate 在原始数据里长这样150/300表示满 300 减 1500.9表示打九折还有fixed:10这种直接减 10 元的形式。很多人在读取时直接 to_numeric 然后一堆报错才意识到这是一个混合编码的字符串。处理方式不复杂但必须分类型统计。满减券和折扣券的核销逻辑完全不同满减券有门槛用户需要凑单核销周期通常更长折扣券没有门槛冲动消费占比高核销更依赖短期提醒。我一般会把 discount_rate 拆成三个特征券类型满减/折扣/定额、满减的绝对优惠金额150、满减的门槛金额300。如果原始是打折券门槛金额填 0优惠金额填 1 - discount_rate 再乘以一个基准客单价——但这里需要注意没有客单价数据时直接用 (1 - rate) 作为折扣深度特征即可不要强行造绝对金额。更细一点满减券还有一个「优惠力度」的中间量即 优惠金额 / 门槛金额。这个比值对核销率的影响非常显著比值太低比如满 1000 减 5用户根本没有动力比值太高比如满 10 减 9又会让人怀疑券的真实性。把这个比值做成连续特征后模型可以自己学到不同力度区间的核销差异。2.3 用户领取行为的时间特征星期几比几点更重要O2O 优惠券的使用有很强的星期效应。工作日的中午和晚上是餐饮核销高峰周末则是全天候的休闲娱乐消费时段。我一般会从 date_received 里拆出 weekday 和 is_weekend 两个特征注意不要拆 hour——因为领取时间在数据里只精确到天没有小时信息强行构造 hour 特征只能是噪声。还有一个很反直觉的点date_received 的「月份」和「第几周」在训练集和测试集里的分布可能完全不同。天池这个赛题的数据划分是时间上连续的前面一段做训练后面一段做预测所以月份特征在训练集里可能只有 1 到 6 月测试集却是 7 月。这种情况下模型会把「6 月的样本更可能核销」当成规律而不是学到用户真实的行为模式。解决办法是不要直接用月份这个原始类别而是用「该用户历史上最近一次领券距今天数」这样的相对时间特征。3. 特征工程与基线模型一个能稳定跑到 0.76 的骨干流程3.1 用户历史行为特征这是整个模型的地基优惠券预测里最值钱的信息不是这张券本身而是「这个人以前怎么对待优惠券」。我推荐构造一组以 user_id 为分组键的历史行为特征每条训练样本只使用它之前的历史不能用之后的数据。这里给出的代码可以直接跑通作用是在每个样本上计算该用户截止到当前领券日期的历史统计import pandas as pd import numpy as np def build_user_features(df): # 先按用户和领券日期排序保证时间顺序 df df.sort_values([user_id, date_received]).reset_index(dropTrue) # 用户历史领券总数统计当前记录之前该用户领过几次券 df[user_coupon_count] df.groupby(user_id).cumcount() # 用户历史核销率先构造核销标签再用扩张窗口求均值 df[label] ((df[date_consumed].notna()) ((df[date_consumed] - df[date_received]).dt.days 15)).astype(int) df[user_history_ratio] df.groupby(user_id)[label].transform( lambda x: x.shift().expanding().mean() ) # 用户历史平均核销周期只统计正样本 df[gap_days] (df[date_consumed] - df[date_received]).dt.days df.loc[df[label] 0, gap_days] np.nan df[user_avg_gap] df.groupby(user_id)[gap_days].transform( lambda x: x.shift().expanding().mean() ) return df代码里最关键的是shift()操作。如果不做 shift当前样本的 label 和 gap_days 会把「答案」泄漏进特征离线 AUC 虚高 3 到 5 个点。expanding().mean()是扩张窗口均值含义是「截止到上一次领券时刻该用户的累计历史核销率」这个数字越接近 1说明用户对券的消化能力越强。同样道理还可以统计该用户历史核销的券种偏好比如满减券占比、折扣券占比以及该用户历史领券到核销的平均间隔。平均核销周期这个特征在营销触达场景里特别有用当一个用户历史平均 3 天就核销那么在第 2 天给他推送提醒效果远好于第 7 天再提醒。3.2 商户和优惠券特征合并统计量的两种正确打开方式除了用户侧的统计商户侧的信息同样重要。一个商户如果历史发券后核销率一直很高说明它的券质量好、门店人多或者品类受欢迎反之如果该商户历史发券量大但核销低说明用户不买账。商户特征的构造逻辑与用户侧完全一致只是分组键换成 merchant_id。优惠券特征则需要一个细微处理coupon_id 相同的券在不同批次发放时面额和门槛是完全相同的因此它是天然的类别特征。可以直接用 coupon_id 构造该券历史核销率。但在 field1 的线上记录里 merchant_id 为空此时如果强行对 merchant_id 做分组统计会产生大片的 NaN。我一般对 field 做分组后再构造商户特征或者把 merchant_id 为空的情况单独映射到一个特殊值 0再让模型自己学习这一支的权重。def build_merchant_features(df): # 对商户分组统计核销率空商户单独标记 df[merchant_id] df[merchant_id].fillna(0) mer_stat df[df[merchant_id] ! 0].groupby(merchant_id)[label].agg( mer_hist_ratiomean, mer_hist_cntcount ).reset_index() df df.merge(mer_stat, onmerchant_id, howleft) # 优惠券历史核销率同样只统计历史用 shift 避免泄漏 df[coupon_history_ratio] df.groupby(coupon_id)[label].transform( lambda x: x.shift().expanding().mean() ) return df这里的agg是一次性算两类统计量代码更简洁。注意对 coupon_id 的历史核销率依旧使用了 shift理由是 coupon_id 在原始数据里会出现多次含义是同一张券的多次发放记录不 shift 就会把当前样本的核销结果混进统计量。3.3 距离特征的离散化连续值不如分段值稳定distance 字段表示用户与商户之间的距离取值为 0 到 10 的整数0 表示同城但距离未知。很多人在这个字段上直接做归一化或者原样输入效果一般。常见做法是分段映射我在实践中发现分成 5 段效果最好def discretize_distance(d): if d 0: return 0 # 未知距离单独一个类别 if d 1: return 1 # 非常近大概率是常去门店 if d 3: return 2 if d 5: return 3 return 4 df[distance_bucket] df[distance].apply(discretize_distance)分段之所以比连续值好是因为 distance 本身只精确到公里离散化能增强对噪声的鲁棒性。还有一个值得尝试的交叉特征距离分段与用户历史核销率相乘。这个交叉项的含义很直观——高核销历史的用户如果离店很近核销概率会进一步提升模型能用特征交叉自动捕捉这种「常客效应」。3.4 模型选择LightGBM 配 5 折交叉验证一组可直接套用的参数特征构造完成之后模型选择上不用花哨XGBoost 和 LightGBM 在这个量级的数据上差别不大。我倾向于 LightGBM因为训练快、调参少。下面这组参数在多个 O2O 数据集上都表现稳定import lightgbm as lgb from sklearn.model_selection import StratifiedKFold params { objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 31, max_depth: 5, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, reg_alpha: 0.1, reg_lambda: 0.5, verbosity: -1 } X df[feature_cols] y df[label] skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) oof_pred np.zeros(len(df)) for tr_idx, va_idx in skf.split(X, y): tr_data lgb.Dataset(X.iloc[tr_idx], labely.iloc[tr_idx]) va_data lgb.Dataset(X.iloc[va_idx], labely.iloc[va_idx]) model lgb.train( params, tr_data, num_boost_round500, valid_sets[va_data], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] ) oof_pred[va_idx] model.predict(X.iloc[va_idx], num_iterationmodel.best_iteration)min_child_samples设成 20 是因为正负样本比大约 1:7叶子节点样本太少容易过拟合到少量正例上。feature_fraction设为 0.8 增加了特征层面的随机性对避免时间序列上的过拟合有帮助。这里没有做样本均衡处理因为 LightGBM 对样本不均衡相对鲁棒而且 O2O 场景关心的是排序质量AUC不是绝对概率值。交叉验证的代码里StratifiedKFold的 shuffleTrue 在时间序列数据上是有争议的严格做法应该按时间划分。但在这个赛题里由于用户行为特征都是基于截止到领券日期的历史统计已经避免了未来信息泄漏随机打乱对结果影响不大。如果你在真实业务里做上线前验证仍然建议改成按时间切分。4. 天池 o2o 优惠券预测避坑时间泄漏、样本偏置与评测不一致4.1 踩坑date_consumed 泄漏进特征离线 AUC 虚高 0.15现象特征工程做完后离线 AUC 跑到了 0.91比所有公开方案都高。一开始以为是特征造得好后来发现只要删掉 date_consumed 相关的衍生字段AUC 立刻回落到 0.76。原因date_consumed 本身就是标签的一部分。在构造特征时如果统计「该用户最近一次消费距领券天数」「该用户历史消费总数」时没有限制在 date_received 之前而是全表统一统计那么训练集里任意一条样本都会看到自己「未来」的消费记录。LightGBM 对这类特征非常敏感会直接根据这个特征分叉。解决所有历史行为统计都要加一个后缀_hist并严格用df[df[date_received] current_date]的过滤逻辑来构造或者用 groupby shift()把当前行的标签排除在外。我最常用的验证方式是随便挑一个特征检查它在正样本和负样本上的分布差异是否过大如果某个特征的 IV 值超过 0.5就要怀疑它是否泄漏了未来信息。4.2 踩坑field1 的线上数据没有 merchant_id商户特征大量缺失现象构造商户特征时没有处理 field 字段结果线上样本的 merchant_id 全是 NaN有的代码直接把 NaN 填充为 -1导致这组特征在线上场景纯粹变成了「缺失标记」。原因field1 表示这次领券发生在线上渠道它不绑定具体线下门店所以自然没有 merchant_id。把缺失值统一填充成 -1 后模型学到的是「merchant_id 为空的用户核销率更低」——这个结论本身没错但会掩盖线上/线下场景本身的行为差异。解决把 field 拆成独立的特征并分别统计线上和线下的特征分布。比如 field0 时使用 merchant_id 的历史核销率field1 时用 coupon_id 和用户特征代替。在后续建模里直接把 field 放进 feature_cols让模型自己决定在哪个场景下信任哪组特征。4.3 踩坑正负样本比例失衡下用准确率评估是自欺欺人现象有人用 accuracy 做模型筛选发现一个「不核销」的常模基线准确率高达 85%觉得自己模型很厉害。原因这个赛题的正样本比例大约在 12% 到 15% 左右如果全部预测为负准确率就是 85% 上下。准确率在这个场景里完全没有区分度因为它奖励的是「猜中大多数」而不是「预测对正例」。解决一律用 AUC 或全局命中率这类排序指标。天池官方评测也用的 AUC所以本地验证和线上评价一致。在真实业务里如果关注的是发券成本可以看 top 20% 用户里的核销覆盖率也就是常说的 lift 指标比 AUC 更贴近 ROI 目标。4.4 踩坑领券日期和消费日期的 gap 分布会影响线下验证的可信度现象本地 5 折交叉验证 AUC 0.77提交线上却只有 0.73怎么调都补不回来。原因数据的时间窗口导致训练集和测试集的「领券到核销」间隔分布不一致。比如训练集集中在周末发券用户有充足时间核销测试集跨了节假日用户出行意愿变化剧烈。模型学到的是「训练集时间段内的行为规律」而不是「用户的稳定偏好」。解决用时间序列交叉验证替代随机 K 折比如前 80% 时间训练、后 20% 时间验证多看几组切分点取平均。另一个补救手段是去掉 date_received 的月份和绝对日期只保留周几、是否月初月末这类周期特征减弱模型对特定时间段的过拟合。5. 从离线 AUC 到业务动作概率校准和触达策略的最后一公里模型训练完成后很多人直接把 LightGBM 输出的概率当作核销概率但树模型输出的分数是有偏的它只能保证排序正确不能保证数值有业务含义。如果要做券的精准投放、成本核算或者用户分群需要对模型输出做校准。常见做法是基于 Isotonic Regression 做单调映射把 LightGBM 分数映射到真实的核销率from sklearn.isotonic import IsotonicRegression # 用交叉验证的 oof_pred 做校准器训练 iso_model IsotonicRegression(out_of_boundsclip) iso_model.fit(oof_pred, df[label]) # 对测试集预测分数做校准 test_score model.predict(test_X) test_calibrated iso_model.predict(test_score)校准后的概率可以直接用于计算期望收益比如对一张满 50 减 20 的券核销后毛利是 8 元那么只有当校准概率大于 20/82.5 时才有投放价值——这个计算很粗糙但能说明为什么不校准的概率不能直接套进 ROI 公式。另外在设置触达策略时我的习惯是不要把概率阈值定死而是按概率降序排列后取累计收益最大化的分位点这样既照顾了预算又保证了触达用户的精准度。从这次比赛总结下来最有用的一个习惯是构造任何特征前先问一句「这个特征在预测时点上是否已经可知」。只要答案不是百分之百肯定就默认它是未来数据。另一个值得坚持的做法是每造一批特征就跑一次时间序列上的交叉验证用线上走势验证而不是单点 AUC 来判断特征是否有效。希望这份实战路径对你有所启发也能帮你在自己的 O2O 营销数据上少走几步弯路。本文还有配套的精品资源点击获取
网站建设高端定制企业官网