ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

CTR预估竞赛源码解析:特征工程与FFM/GBDT+LR实战要诀

CTR预估竞赛源码解析:特征工程与FFM/GBDT+LR实战要诀

简介:面向高校算法竞赛学习者的一份完整参赛源码包,聚焦第一届腾讯社交广告高校算法大赛,覆盖数据清洗、特征提取与筛选、模型训练与提交等完整流程。压缩包共43个文件,整体仅472KB,以17个Python脚本(py)为主体,负责特征工程、模型训练与预测等核心逻辑,配合9个Shell脚本(sh)串联数据处理与运行步骤;另有5个CSV特征数据、5个Markdown项目说明、4个TXT辅助说明和3个SQL查询脚本,适合计算机、数学、电子信息等专业学生作为课程设计、期末大作业或毕业设计参考资料。内容包含完整赛题方案、特征关联分析与筛选工具、数据填充与拆分工具,以及模型单折训练和LR模型脚本,配合项目说明文档可快速复现实验。目前已有81人学习或浏览该资源,对需要借鉴真实算法竞赛工程实现、从零搭建推荐或广告算法基线系统的学习者具有较高参考价值。

1. 一张竞赛源码包的CTR预估题解:它解决什么、谁能直接受益

磁盘里躺着一个“第一届腾讯社交广告高校算法大赛参赛源码+项目说明.zip”这类压缩包,很多人的第一反应是解压看榜单,但我更建议先看它的数据处理目录和特征脚本。社交广告点击率预估(CTR预估)是广告算法里最基础的命题:给定一次广告曝光,预测用户会不会点。赛题原始特征通常是几十维、高基数的类别特征,再加上运营画像,真实难点集中在特征工程、记忆与泛化能力的平衡,以及一套能对抗时间泄漏的验证方法。这个压缩包的价值,不在于某一个模型文件,而在于一套可以用在类似广告、推荐、搜索场景的完整工作流。这篇笔记要拆的就是:从数据集形态到FFM与树模型融合,哪些代码值得抄,哪些坑必须绕开。适合正在学CTR预估、准备打广告类算法比赛,或者想给业务搭一套点击率预估基线的从业者。

2. 数据形态与赛题模型选型:先看懂一次曝光的样本,再谈模型

2.1 广告日志数据的结构:一行一次曝光,label 是点击与否

在广告 CTR 预估竞赛里,数据通常是“一行一个曝光样本”。把训练集拉出来看,常见形态是:一行代表一次“广告曝光”,里面包含广告 ID、广告主 ID、App ID、广告位 ID、用户 ID、用户画像(性别、年龄段)、网络类型、运营商、设备型号、所在省份城市、消费场景,以及最关键的一列:是否点击。

先给出一段典型的数据读取脚本,用来把原始日志转成 DataFrame 并快速看分布。代码用 pandas 操作,常见做法是:把能转 category 的列一次性转好,避免反复 astype 带来额外的内存开销和计算耗时。实际竞赛数据里,这些字段往往被脱敏成整型 ID,有的字段值本身直接就是负数,说明做了加密映射,但不影响我们按类别特征处理。

import pandas as pd def load_ad_log(path, nrows=None): df = pd.read_csv(path, sep='\t', nrows=nrows, names=['log_date', 'user_id', 'ad_id', 'advertiser_id', 'app_id', 'position_id', 'age', 'gender', 'network', 'device', 'province', 'city', 'is_click'], dtype={'user_id': 'category'}) # is_click 就是 CTR 预估的标签 print('rows:', len(df), 'click rate:', df['is_click'].mean()) for col in ['ad_id', 'app_id', 'position_id']: print(col, 'cardinality:', df[col].nunique()) return df df = load_ad_log('train.log')

这段脚本干的事很简单:按字段名读入数据,把 user_id 转成 category,统计行数、点击率和几个关键列的基数。逻辑说明:cardinality 就是类别基数,CTR 预估里高基数特征的处理方式,会直接决定后续的 embedding 维度和 hash 冲突率,所以第一步就要看清楚。参数说明:nrows 用于抽样快速验证;sep 要根据赛题数据实际分隔符调整,如果是逗号分隔就换成 sep=','。读完后你会发现 user_id 的基数通常比 ad_id 高一个量级,这正是后面要做用户侧聚合统计的根本原因。

在真实业务里,这份日志还会有 request_id、流量来源、广告创意类目等列。字段多少不是关键,关键是三件事:第一,样本是曝光样本而不是点击样本,正负样本比例失衡是常态;第二,类别特征的基数差异极大,从几十到几百万都有;第三,存在明显的时间顺序,训练集和测试集往往按时间切分,这决定了验证方式必须考虑时间维度。很多新手会直接把训练集随机切 8:2 做验证,这个做法在广告赛题里是大忌,因为测试集是未来时间的数据,随机切分会让离线指标虚高。

2.2 从离线指标反推模型选型:AUC、LogLoss 与赛题排名的关系

广告赛题普遍用 AUC 或 LogLoss 排名,少数赛题同时给出 AUC 和平均精度均值。理解指标,直接决定模型选型。我当时的第一判断是:如果赛题只给 AUC,那用户行为类的交叉特征和 GBDT 的排序能力会比单模型效果更明显;如果赛题给的是 LogLoss,那概率校准和负采样策略就必须拉进来考虑,因为 LogLoss 对预测概率的绝对值敏感,AUC 只关心相对排序。

常见选型逻辑是下面这张对照表:

指标侧重推荐的模型方向注意点
AUC排序质量,预测序FFM、GBDT+LR、DeepFM正负样本比例影响不大,但样本稳定性要保证
LogLoss概率准确度LR/FFM 做概率校准,加正则需要校准曲线,负采样后要回传修正
人均点击数等业务指标带时间窗口的线上效果序列特征+时间衰减权重验证集必须按时间后移,避免穿越

我一般会在一开始就在本地把 FFM、GBDT+LR、简单 DeepFM 各跑一条小实验基线,用少量迭代先比排序指标,再选主模型深挖。注意,这个“先比排序指标”不是让你刷 AUC 就完事,而是为了快速过滤掉方向错误的特征工程,省下后续调试的算力。小实验基线的特征只保留用户 ID、广告 ID、曝光位置这几个最原始字段,先确认框架能跑通,再加特征看增量。

参数层面,FFM 的常见坑是 latent factor 维度选太大导致过拟合,后面避坑章节会专门展开;GBDT 的常见坑是树太深,把训练集细节当成了规律。两者折中后,更多团队的主模型会落在 FFM 或“GBDT 叶子编码+LR”,原因是它们在数据量几百万、特征稀疏的场景下,训练速度和参数可控性更好。DeepFM 虽然效果好,但在特征字典过大时,训练时间和显存开销会直线上升,不适合作为第一个主模型。

3. 特征工程拆解:源码包里最值得抄的三份脚本

3.1 类别特征编码:从 One-Hot 到并行 Hash 编码

社交广告赛道的类别特征大多不适合做完整 One-Hot。ad_id 可能有百万级取值,直接 One-Hot 会让样本矩阵膨胀到无法落内存。常见做法是先用频次截断,把出现次数少于阈值的 ID 归入“低频桶”,再做 Hash 编码。下面这段脚本是竞赛源码里的高频写法,我按可复现的方式重写了一遍。

import numpy as np from sklearn.feature_extraction import FeatureHasher def frequency_cut_and_hash(df, cols, min_count=5, n_features=2**20): for col in cols: vc = df[col].value_counts() rare = set(vc[vc < min_count].index) df[col + '_bucket'] = df[col].apply(lambda x: x if x not in rare else '__RARE__') h = FeatureHasher(n_features=n_features, input_type='string', alternate_sign=False) # 把多个类别列拼成字符串列表,每行一个样本 rows = [list(map(str, df[c + '_bucket'] for c in cols))] X = h.transform(rows) # 返回稀疏 CSR 矩阵 return X

逻辑说明:先按词频截断,把低频值统一改成占位字符串,避免 Hash 碰撞把高频和低频值混在一起;再把每一行的多个类别字段组成字符串列表,交给 FeatureHasher 转成稀疏矩阵。参数说明:min_count 是截断阈值,我一般取 3~5,太小会放进来大量噪声,太大则丢失长尾信息;n_features 决定哈希桶数量,2^18 到 2^22 是广告场景的常用区间,桶数越少碰撞越高,桶数越大内存开销越大,需要按特征总量折中。

这里有一个容易被忽略的细节:FeatureHasher 默认会做符号哈希(正负随机),如果不设 alternate_sign=False,同一个特征值在两个桶里可能一正一负,FFM 这类对符号敏感的线性模型虽然在经过场内归一化后通常不受大影响,但在统计历史点击率这类“累加”特征时会出问题。所以尽量统一关闭符号哈希,或者自己保证特征值恒非负。另一个细节是,做 Hash 前最好把原始 ID 统一转成字符串,否则 int 和 str 混在一起,相同语义的不同类型会被 hash 成两个特征。

3.2 用户历史行为切片:用时间窗口做统计聚合

用户侧特征是 CTR 预估的胜负手。赛题训练数据里如果没有显式的用户历史点击序列,最常见的做法是用训练集按 user_id 做“历史统计”:过去 7 天该用户的曝光数、点击数、点击率、最近一次曝光距今的时间差。但这里有个致命陷阱:如果直接用全量训练集统计,再参与同一份训练集的训练,验证集上的效果会虚高,因为用户标签已经被提前“看到”了。

我推荐的写法是滚动窗口统计,即对每个样本,只使用它之前 N 天的样本统计该用户行为。伪代码如下。

def rolling_user_feature(df, user_col='user_id', label='is_click', window=7): df = df.sort_values(['log_date', user_col]).reset_index(drop=True) # 先按天聚合用户行为,再对每个样本按日期向前取窗口 daily = df.groupby([user_col, 'log_date'])[label].agg(['count', 'sum']) daily.columns = ['uv', 'uc'] daily['ctr'] = daily['uc'] / daily['uv'] # 这里用 shift(1) 保证不使用当天样本自身,避免信息泄漏 daily['uv_shift'] = daily['uv'].shift(1) daily['uc_shift'] = daily['uc'].shift(1) # 每个用户窗口内的累计值需要用 groupby + rolling daily['win_uv'] = daily.groupby(user_col)['uv'].transform( lambda s: s.rolling(window, min_periods=1).sum()) result = df.merge(daily, on=[user_col, 'log_date'], how='left') return result

这段脚本的典型问题是:对于当天同一用户的多条曝光,shift 只能错开一行,不能错开一天,严格做法应该按“样本时间戳”排序后,对每个样本只统计时间严格更早的样本。竞赛节奏紧张时,很多源码采用一天级窗口近似,也就是把当天样本视为属于同一个时间桶,桶内统计使用前一天及以前的数据。参数 window 的选择看数据时间跨度:如果赛题给了一个月数据,窗口 3、7、14 都会有人用;我一般会让这三列都进模型,让模型自己挑权重,而不是只留一列。窗口统计在广告业务里对应的是“用户黏性”和“兴趣衰减”,设置太长会把陈旧行为拖进来,太短则样本稀疏。

这里要特别提一句:不要只做用户侧统计,广告侧同样需要。某个广告创意的历史点击率、某个广告主近 7 天的曝光量,这些统计特征往往比用户侧特征更稳定。广告侧的统计要注意频次截断,一个只曝光过两次、点击过一次的广告,它的原始点击率 0.5 没有任何统计意义,必须用上平滑公式。

3.3 交叉特征与用户-广告匹配:低基数组合也要上

单独看 user_id 和 ad_id 都难以学习“这个用户是否喜欢这个广告创意”。但把 user_id 和 ad_id 直接做笛卡尔积交叉,维度会爆炸。竞赛里的折中方案往往分两步:第一步选“低基数”交叉,比如年龄段和性别交叉、网络类型和 App 类型交叉;第二步针对高基数的 user-ad 交叉用“统计型”表达,比如该用户对同类广告的历史点击率。

def cross_stat(df, group_cols, target='is_click', smooth=10): g = df.groupby(group_cols)[target].agg(['count', 'sum']) # 平滑点击率:处理冷启动样本,count 太小直接拉向全局均值 global_ctr = df[target].mean() g['ctr_smooth'] = (g['sum'] + smooth * global_ctr) / (g['count'] + smooth) g['count'] = g['count'].astype(np.int32) return g.reset_index() # 低基数交叉:age_gender, net_app for cols in [['age', 'gender'], ['network', 'app_id']]: stat = cross_stat(df, cols) df = df.merge(stat, on=cols, how='left')

逻辑说明:对每组交叉组合统计曝光数和点击数,再用贝叶斯平滑公式计算点击率,规避冷启动。平滑系数 smooth 越大,结果越偏向全局均值,越小越信任组内统计;当组内样本数只有个位数时,直接信心十足的组内点击率往往会产生大量噪声特征。参数说明:smooth 取值 5~20 是经验区间,可以单独做一次小规模网格搜索。merge 之后记得用记忆校验:用户-广告交叉是几百万甚至上亿的笛卡尔积,宁可只统计不交叉,也别硬塞进稀疏矩阵让训练内存翻倍。

有些赛题里还会给上下文特征,比如一周中的第几天、一天中的第几个小时。这些时间类特征和王牌广告位、广告主 ID 做交叉,往往能挖出“某广告在晚间时段的高点击”这类非常有区分度的规律。时间特征放进交叉组合时,注意把小时拆成 24 个取值而不是当成连续数值,否则模型会默认 23 点和 0 点相似,但实际场景里深夜流量和刚过零点的流量差异很大。

4. 模型层落地:FFM 主模型与 GBDT+LR 的组合打法

4.1 FFM 训练前要做什么:构造 field、index、value 三元组

FFM(场感知分解机)是 CTR 预估的最强传统模型之一,它的输入是三值稀疏格式,“field:index:value”。field 代表特征的场(例如用户场、广告场、上下文场),index 是特征在全局字典中的编号,value 是该特征的取值(类别特征通常取 1,数值特征取归一化后的值)。源码里常见的是这样一个转换函数。

def build_ffm_line(fields, indices, values, label): # 输出一行 FFM 格式:label field1:idx1:val1 field2:idx2:val2 ... parts = [str(label)] for f, idx, v in zip(fields, indices, values): parts.append(f'{f}:{idx}:{v}') return ' '.join(parts)

逻辑说明:把每个特征映射成“它的场编号+特征值全局编号+取值”,场编号需要手工指定或按特征来源自动分配。下列参数是必调项:latent_factor(潜向量维度,通常 4~8)、iteration(迭代轮数,按验证 AUC 收敛情况定)、eta(学习率,0.01~0.1 之间按验证集调节)、regularization(正则系数,类别特征多时调大防过拟合)。一个常见做法是把数值特征放同一个场,类别特征按业务域分场。FFM 对场划分相当敏感,划分太粗就和 FM 退化了,划分太细则训练指标波动很大。

实际写 FFM 训练文件时,还要考虑内存。几百万样本、几千万特征索引,如果一行行用 Python 字符串拼接再写盘,单是 IO 就能吃掉几个小时。经验做法是:先构造一个稀疏矩阵,再批量写出;或者用 NumPy 数组按字段拼装后一次性转字符串。不要小看这一步,我见过有团队在这个转换环节花掉的时间比调模型还多。

4.2 GBDT 叶子编码:把非线性组合交给树,把排序交给 LR

FFM 对高稀疏类别特征擅长,但难以自动组合“年龄=20且网络=4G且点击时段=深夜”这类深层交叉。GBDT 天然擅长找决策路径上的组合模式,但直接输出概率的排序能力排不过 FFM 这类刻意设计的 CTR 模型。所以常见组合是:GBDT 先吃数值特征和一部分低基数类别特征,输出每棵树叶子节点编号作为新特征;再加入 FFM 或 LR 的输入,构成 GBDT+LR 漏斗。

from sklearn.ensemble import GradientBoostingClassifier gbdt = GradientBoostingClassifier( n_estimators=300, max_depth=4, learning_rate=0.05, subsample=0.8, random_state=42 ) # X_num 是归一化后的数值统计特征,y 是是否点击 gbdt.fit(X_num, y) leaf_idx = gbdt.apply(X_num) # shape=(n_samples, n_estimators) # 叶子编号转 one-hot 再交给 LR / FFM

逻辑说明:gbdt.apply 得出每个样本在每棵树落入的叶子编号,把这些叶子编号当作高基数类别特征做 one-hot。关键参数:n_estimators 300~500 就够,超过后训练收益递减且叶子矩阵稀疏度升高;max_depth 4~6 能让叶子组合有复杂度又不至于完全过拟合;subsample 0.8 在广告场景下会明显提升泛化。这里注意,GBDT+LR 的离线收益通常只有 0.3% 到 1% 的官分提升,属于“稳定性补充”,真正的分数涨幅仍然来自特征工程,不要在模型堆叠上过早投入过多算力。

GBDT 的输入特征要单独挑选,不能把 FFM 的全部稀疏类别特征原样丢进去。它们太稀疏,树的分裂效率极低。我一般只喂入平滑后的点击率、计数统计、用户活跃度这类稠密数值特征,以及几个经过频次截断的低基数类别特征。GBDT 对缺失值处理比较宽容,但要注意它对特征尺度不敏感,不需要做标准化。

4.3 集成策略:权重平均还是按验证集搜参

竞赛源码里的最终成绩往往是多模型集成。常见做法有两种。第一,直接对 FFM 和 GBDT+LR 的预测概率做加权平均,权重按验证集做一小段网格搜索。第二,层叠模型,把 FFM 和 GBDT+LR 的预测概率作为新特征喂给 LR,做一次轻量学习。集成前务必保证两个模型都在同一份时间验证集上对齐评估,否则会因为样本切割不同造成集成指标假衰减。

下表是我常用来权衡集成方式的参考:

方式适合场景复杂度翻车点
概率加权平均模型相关性低,AUC 接近低权重过拟合验证集
Rank 平均模型量纲不一致低只看排序不看置信度
Stacking(LR)两个模型差异大中新特征要严格做交叉验证
按样本置信度动态选择有明显分桶特征高分桶维度选错会引入噪声

我个人的做法是:先看两个模型的验证 AUC 是否都收敛在同一个区间,如果 A 比 B 高 0.5% 以上,集成时直接给 A 大权重;如果两者接近,才考虑 Stacking。堆太多模型会把训练时间线性拉长,而验证分数经常原地不动。集成之后一定要回看一眼验证集的 LogLoss,如果 AUC 没变但 LogLoss 抖动很大,说明两个模型的概率分布没有对齐,需要先做校准再融合。

5. 避坑记录:复现竞赛源码最容易翻车的 4 处细节

5.1 特征穿越:验证集偏高,线上 Score 崩盘

现象:离线验证 AUC 做到了 0.81,提交测试集后官方分数反而低于单纯版模型,排名直接掉出前排。

原因:在统计用户历史点击率或“某广告近几天曝光次数”时,直接用全量训练集计算,再切出验证集做评估,验证集标签信息以统计特征的形式泄漏进了训练集。线上预测时,测试样本没有对应的历史统计可用,特征缺失或整体统计口径不一致,排序自然崩掉。

解决:任何统计型特征都必须用“样本之前的时间窗口”计算。时间不足时可以按天切分训练集与验证集,并要求统计代码只消费前若干天的样本。给出一个最简校验:把用户点击率特征在验证集上单独计算与“全局均值”的相关性,如果相关性极高,很可能泄漏了。更稳妥的办法是写一个时间切分函数,训练集只取前 70% 天,验证集取后 30% 天,所有统计特征在训练集内完成后再拼接验证集。

5.2 类别基数过大导致内存翻倍,跑一半被 OOM 杀掉

现象:特征工程做完后内存占用从 8G 冲到 40G,训练到第 5 轮进程被杀。翻看监控才看到 DataFrame 里 category 列在 merge 后自动变成了 object。

原因:pandas 里 category 列与数值列 merge 时,往往退化为 object 类型,每个字符串都带指针开销;ad_id 几百万取值,光是这一列就从几十 MB 膨胀为几个 GB。数值统计列默认 int64 也浪费了一半空间。

解决:统一在 merge 前将整型列转成 int32,类别列保持 category dtype;最后写 FFM 文件时用 C 循环批量转换字符串,避免每一行重复拼接。真内存紧张时,不要一次性加载全量 DataFrame,按日期块处理并写临时文件再合并。一个很有效的习惯是:在特征工程脚本里加一段内存审计,打印每列 dtype 和 memory usage,快速定位是哪一列在 merge 后类型退化。

5.3 概率校准缺失:LogLoss 排名被方差拖后腿

现象:同一条实验,验证 AUC 从 0.780 升到 0.783,但 LogLoss 反而从 0.38 升到 0.39,官分退步。

原因:AUC 只看排序,LogLoss 还关注预测概率的绝对值。GBDT+LR 在样本不平衡时,输出的预测概率往往偏离真实点击率;遇到正样本极少的 batch,LR 概率整体偏高。

解决:在最终提交前对预测概率做一次等渗回归或 Platt 校准,校准集不能用训练集,必须用时间验证集。负采样场景更要小心,采样比例是多少,最终概率就要按比例换算回原始点击率口径。具体公式是:如果负样本采样率是 r,训练时用的正样本权重是 1/r,那最终概率 p 要经过变换:p' = p / (p + (1-p)/r)。

5.4 提交格式与特征字典错位:本地评分满分,提交却判无效

现象:本地验证 LogLoss 正常,提交后结果显示“测试集特征缺失”,成绩为 0 分或出现负损失。

原因:特征字典(index 映射)在训练和测试时没有共用同一份。因为测试集的类别取值可能有新值,如果转换脚本里用的是重新构建的字典,训练与测试的特征编号就会错位,模型看到一堆排列错乱的特征。

解决:把特征映射表保存成 dict 或 pickle,所有阶段共用同一个映射;遇到训练集中没见过的取值,统一映射到占位索引 0 或低频桶。最后一天留出半小时做提交前自检:用训练好的模型对一小段测试抽样做预测,确认预测值范围在 0~1 之间且没有 NaN。还有一个加快检查的操作:直接比对训练集和测试集的特征索引分布,如果测试集里出现大量训练集没见过的取值,说明字典构建逻辑没有对齐,多半是在构造时没有固定全局字典。

6. 把这份源码思路改造成自己的 CTR 预估基线

如果只是想从这套方案里提取一个可复用的最小基线,我的建议是只保留三件事:滚动窗口的用户行为统计、低基数类别交叉平滑点击率、FFM 主模型。树模型和集成可以等业务数据稳定之后再加。即便只保留这三步,线上也能拿到一个可解释、可排错、可迭代的 CTR 基线,后续添加新特征时只需要关注验证 AUC 与 LogLoss 的双指标。

具体做法上,我给自己的团队定过一个固定流程:先用 7 天窗口统计用户近 7 天曝光/点击/平滑 CTR 三列;再把年龄、性别、网络、App 四列的交叉平滑点击率加进去;最后用 FFM 训练 30 轮,观察验证 AUC 收敛情况,再做一次概率校准。日志里保留每个特征的缺失率与方差,两周后回头看哪些列一直没用上,直接删掉。写代码时顺手把特征字典持久化,避免下次实验重建,这是省时间最容易的一步。

这一套思路打底之后,再谈 DeepFM、网络结构调参才不会手忙脚乱。我在实际业务中遇到过一种典型反差:模型从 FFM 换到 DeepFM,验证集 AUC 只升了 0.1%,但训练时间从 40 分钟涨到 8 小时。后来排查发现,根本没有把用户历史行为特征喂进去,网络的表达能力再好,输入侧缺信息也无济于事。这也让我养成一个习惯:先检查特征是否存在有效信息,再怀疑模型容量。希望这几点踩坑经验能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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