ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

广告算法大赛实战:从特征工程到模型融合的完整链路

广告算法大赛实战:从特征工程到模型融合的完整链路

1. 赛题拆解与整体思路设计

1.1 这道题到底在考什么

先把题目翻译成人话。广告算法大赛的赛题,本质是一个用户行为序列预测问题:给定一批用户在历史时间窗口内的广告曝光、点击、转化等行为记录,要求预测未来某个时间窗口内,某个用户对某条广告发生某种行为的概率。评价指标通常是AUC,也就是模型对正负样本的排序能力。

这个问题的难点不在于模型本身有多深,而在于三个字:稀疏、时序、冷启动。

  • 稀疏:用户和广告的交互矩阵极度稀疏,绝大多数用户对绝大多数广告没有行为记录。
  • 时序:用户兴趣随时间衰减,昨天的点击和上个月的点击权重完全不同。
  • 冷启动:新广告没有历史数据,新用户没有行为轨迹,模型必须靠特征工程和泛化能力兜底。

我当时的定位很明确:不是来拿名次的,是来把工业级推荐系统的完整链路跑通一遍的。所以我的整体思路不是堆模型,而是先把数据理解透,再搭一个能跑通、能迭代、能解释的baseline,然后逐步加特征、调模型。

1.2 为什么选择“先特征后模型”的路线

很多新手一上来就想上DeepFM、DIN、DIEN这些深度模型,觉得模型越复杂效果越好。我踩过这个坑。实测下来,在广告行为预测这类任务里,特征工程的天花板远高于模型结构的天花板。一个精心构造的统计特征,带来的AUC提升可能比换一个SOTA模型还大。

所以我的路线是:

  1. 先做EDA,搞清楚数据分布、正负样本比例、时间跨度。
  2. 构造基础统计特征,跑一个LightGBM baseline。
  3. 在baseline稳定后,再考虑加序列特征和深度模型。
  4. 最后做模型融合。

这个顺序的好处是,每一步都有可解释的反馈。你知道涨分是哪个特征带来的,而不是黑盒模型玄学调参。

1.3 整体方案架构

我的方案分四层:

  • 数据层:原始日志清洗、去重、时间对齐、负采样。
  • 特征层:用户侧统计、广告侧统计、交叉统计、时间衰减特征。
  • 模型层:LightGBM为主,后期尝试了DeepFM做融合。
  • 后处理层:概率校准、阈值选择、结果格式化。

这四层里,我花时间最多的是特征层,大概占了整个项目70%的精力。模型层反而很快,LightGBM调参两天就收敛了。

提示:不要低估数据清洗的时间。原始日志里的重复曝光、时间戳错乱、字段缺失,处理起来比建模麻烦得多。

2. 数据理解与特征工程核心细节

2.1 数据探索阶段的关键发现

拿到数据后,我做的第一件事不是写模型,而是画图。几个关键发现直接决定了后续特征设计:

发现一:正负样本极度不平衡。点击率大概在百分之几的量级,转化率更低。这意味着不能用准确率做指标,必须用AUC,而且训练时要考虑负采样。

发现二:用户行为有明显的时间周期性。工作日和周末的行为模式不同,白天和晚上的曝光量差异很大。所以我加了“小时”“星期几”这类时间特征。

发现三:广告ID的分布是长尾的。头部广告占了大部分曝光,尾部广告几乎没有数据。这导致广告侧统计特征对尾部广告不可靠,需要做平滑处理。

发现四:用户历史行为序列长度差异极大。活跃用户有几百条记录,沉默用户只有几条。序列建模时必须做截断和padding。

这些发现听起来简单,但如果你不做EDA直接建模,很可能在特征设计上走弯路。

2.2 用户侧特征构造

用户侧特征我分了四组:

基础属性特征:用户ID的哈希分桶、注册时长、设备类型、操作系统。这些是静态特征,直接one-hot或label encode。

行为统计特征:历史曝光数、点击数、转化数、点击率、转化率。这里有个细节,统计窗口要分多个粒度:最近1天、最近3天、最近7天、全量。不同窗口的统计值反映了不同时间尺度的兴趣。

时间衰减特征:这是我认为最有价值的一组。用户最近一次点击距今天数、最近一次曝光距今天数、平均点击间隔。构造方式是用指数衰减函数给历史行为加权:

import numpy as np def time_decay_weight(days, half_life=7): """指数衰减权重,half_life为半衰期天数""" return np.exp(-np.log(2) * days / half_life)

半衰期我试了3天、7天、14天,最后7天效果最好。这个参数没有理论最优,必须靠实验。

序列特征:用户最近N次点击的广告ID序列、类别序列。这部分后来喂给了DeepFM。

2.3 广告侧与交叉特征

广告侧特征相对简单:广告ID统计、广告主统计、广告类别统计、广告的曝光点击率。关键是平滑。对于曝光量很低的广告,直接用原始点击率会噪声很大,我用贝叶斯平滑:

def bayesian_smooth(clicks, impressions, prior_ctr=0.01, prior_weight=100): """贝叶斯平滑点击率""" return (clicks + prior_ctr * prior_weight) / (impressions + prior_weight)

prior_weight这个参数控制平滑强度,我设的是100,意思是相当于给每个广告先验地加了100次曝光和1次点击。这个值太小起不到平滑作用,太大又会让所有广告趋同。

交叉特征我做了几组:用户类别偏好(用户历史点击中某类别的占比)、用户-广告主交互统计、用户-广告类别交互统计。交叉特征是涨分最明显的部分,但也是最容易过拟合的部分,必须配合交叉验证。

2.4 特征筛选与重要性分析

特征不是越多越好。我一开始构造了200多个特征,LightGBM跑出来AUC反而比100个特征时低。原因是噪声特征稀释了有效信号。

我的筛选流程:

  1. 先按LightGBM的feature importance排序,去掉重要性为0的特征。
  2. 对重要性接近的特征做相关性分析,相关性高于0.95的只保留一个。
  3. 用前向选择法逐步加特征,每次加一批,看验证集AUC是否提升。

最后保留了大概120个特征。这个过程花了整整三天,但值得。特征干净了,后面调参才有意义。

注意:特征重要性高不代表这个特征一定有用,可能是它和标签有泄漏。比如“用户是否点击”这个字段如果直接来自未来窗口,那就是标签泄漏,必须严格检查每个特征的时间边界。

3. 模型训练与实操过程记录

3.1 LightGBM baseline的搭建

LightGBM是我推荐的第一个模型,原因很简单:训练快、对特征工程友好、可解释性强、调参相对容易。

我的baseline参数:

params = { 'objective': 'binary', 'metric': 'auc', 'boosting_type': 'gbdt', 'num_leaves': 63, 'learning_rate': 0.05, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5, 'min_data_in_leaf': 100, 'lambda_l2': 1.0, 'verbose': -1 }

几个关键参数的解释:

  • num_leaves:控制树的复杂度。太大容易过拟合,太小欠拟合。63是我试出来的平衡点。
  • min_data_in_leaf:叶子节点最小样本数。设100是为了防止模型学到只有几个样本的噪声模式。
  • lambda_l2:L2正则化,防止过拟合。
  • learning_rate:学习率0.05配合早停,比0.1更稳。

训练时用5折交叉验证,每折用早停(early stopping)防止过拟合。早停的轮数设50,意思是验证集AUC连续50轮不提升就停。

3.2 负采样策略的调整

原始数据正负比大概是1:50。直接用全量数据训练,模型会偏向预测负样本。我试了两种方案:

方案一:全量数据 + scale_pos_weight。这个参数让正样本的权重变大,相当于告诉模型“正样本更重要”。我设的是10,效果一般。

方案二:负采样。从负样本中随机采样,把正负比降到1:5左右。实测这个方案效果更好,训练也更快。

但负采样有个坑:采样后的模型输出的概率不是真实概率,需要做校准。我用的是Platt scaling,在验证集上拟合一个逻辑回归把输出概率映射回真实概率。

3.3 深度模型尝试与融合

LightGBM跑稳之后,我尝试了DeepFM。结构是:Embedding层 + FM层 + DNN层。输入是稀疏特征和序列特征。

DeepFM的训练比LightGBM麻烦得多:

  • 需要把类别特征做Embedding,维度我设的是8。
  • 序列特征用GRU编码,取最后一个时间步的隐状态。
  • 学习率用Adam,初始0.001,配合余弦退火。
  • Batch size设1024,太大显存不够,太小训练慢。

结果:DeepFM单模型AUC比LightGBM低0.003左右。但融合之后,AUC比单LightGBM高了0.005。融合方式很简单,就是加权平均:

final_score = 0.7 * lgb_score + 0.3 * deepfm_score

权重是在验证集上网格搜索出来的。这个提升不大,但说明深度模型确实捕捉到了LightGBM没捕捉到的模式。

3.4 完整实操流程清单

把整个流程整理成可复现的步骤:

  1. 数据加载与清洗:去重、处理缺失值、时间戳对齐。
  2. EDA:画分布图、统计正负比、分析时间模式。
  3. 特征构造:用户侧、广告侧、交叉、时间衰减。
  4. 特征筛选:重要性排序、相关性去重、前向选择。
  5. 负采样:降到1:5,记录采样索引。
  6. 模型训练:5折CV,LightGBM + 早停。
  7. 概率校准:Platt scaling。
  8. 深度模型:DeepFM训练,序列特征编码。
  9. 模型融合:加权平均,权重网格搜索。
  10. 结果输出:按格式要求生成预测文件。

每一步都有对应的脚本,我用配置文件管理参数,方便复现和调参。

4. 常见问题与排查技巧实录

4.1 训练过程中的典型问题

问题一:验证集AUC波动大。原因是数据按时间划分时,不同时间窗口的分布差异大。解决方法是做时间序列交叉验证,而不是随机划分。

问题二:模型过拟合。训练集AUC 0.95,验证集0.75。原因是特征太多、树太深。解决方法是减特征、降num_leaves、加正则。

问题三:预测结果全是负样本。原因是正负比太悬殊,模型学会了“全预测负样本”这个偷懒策略。解决方法是负采样或调scale_pos_weight。

问题四:特征重要性异常。某个特征重要性极高,但去掉后AUC没变化。这通常是标签泄漏。检查这个特征的计算是否用到了未来信息。

4.2 问题排查速查表

现象可能原因排查方法解决方案
验证AUC远低于训练AUC过拟合对比训练/验证曲线减特征、加正则、降复杂度
验证AUC波动大数据分布不稳定按时间划分验证集时间序列CV
预测全为负正负不平衡统计预测分布负采样、调权重
特征重要性异常高标签泄漏检查特征时间边界移除泄漏特征
训练速度慢特征维度高看特征数量特征筛选、降维
融合后无提升模型同质化看模型相关性换不同类模型融合

4.3 独家避坑经验

坑一:时间边界。构造特征时,只能用预测时间点之前的数据。我一开始不小心用了全量数据算统计特征,导致验证AUC虚高,实际提交后掉分严重。后来严格按时间切分,每个特征都标注了时间窗口。

坑二:ID类特征的处理。用户ID和广告ID直接做label encode会引入虚假的序关系。正确做法是做频率编码或目标编码。目标编码要注意用CV方式计算,防止泄漏。

坑三:序列截断。用户行为序列长度差异大,截断时我试了取最近N条和取全部。取最近N条效果更好,因为远期行为噪声大。N我设的是50。

坑四:模型融合的权重。不要拍脑袋设权重,一定要在验证集上搜。我试过0.5:0.5,效果不如0.7:0.3。

坑五:提交格式。比赛最后因为格式问题白跑一次提交,浪费了机会。一定要提前用样例数据验证输出格式。

提示:每次实验都要记录配置和结果。我用的是一个简单的CSV日志,记录实验编号、特征版本、模型参数、验证AUC。没有这个日志,一周后你根本记不清哪个配置效果最好。

5. 赛后复盘与可迁移的方法论

5.1 这次比赛我真正学到了什么

名次不重要,重要的是我把推荐系统从数据到上线的完整链路走了一遍。具体来说:

  • 数据理解优先于建模。EDA花的时间越多,后面走弯路越少。
  • 特征工程是核心竞争力。模型结构可以抄,特征构造的直觉抄不来。
  • 验证策略决定成败。验证集划分不对,后面所有调优都是自欺欺人。
  • 工程化能力是瓶颈。特征计算、模型训练、结果输出的自动化,比单次调参重要得多。

5.2 可迁移到其他场景的经验

这套方法论不只适用于广告算法,任何用户行为预测类问题都可以复用:

  • 电商推荐:预测用户购买概率。
  • 内容推荐:预测用户点击/完播概率。
  • 金融风控:预测用户违约概率。
  • 流失预警:预测用户流失概率。

核心逻辑是一样的:理解数据、构造特征、选择模型、验证调优、融合输出。

5.3 如果重来一次我会怎么做

如果重新参加一次,我会调整几个策略:

第一,更早开始做特征版本管理。我中期因为特征版本混乱,浪费了两天重新跑实验。

第二,更早引入深度模型。我是在LightGBM调了很久之后才尝试DeepFM,其实可以并行做。

第三,更重视线上验证。比赛只有离线AUC,但真实场景要看线上效果。如果有条件,应该做A/B测试。

第四,把代码工程化。我前期是notebook里写代码,后期迁移到脚本花了不少时间。一开始就用模块化脚本,效率更高。

最后分享一个小技巧:比赛期间建一个“实验日志”文档,每次实验记录三行——改了什么、为什么改、结果如何。这个习惯让我在最后一周快速定位到最优配置,而不是在几十个实验里翻找。这个习惯我后来一直保留,做任何项目都受益。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进