Skip to content
Charles Shao
Go back

Criteo CTR 特征工程实战:把理论串成一条能跑的广告管线

views

理论篇讲完了「备料」的每一刀,这一篇用一道真实的广告菜——Kaggle Criteo Display Advertising Challenge——把整条链路从头炒一遍。数据是公开的 CTR 日志:13 个整数特征 I1–I13 + 26 个类别特征 C1–C26 + 点击标签。下面每一步都对应系列里某一篇的理论,这里只写在这道菜上怎么下刀

Table of contents

Open Table of contents

一句话理解

Criteo 特征工程 = 后厨的一次完整开档

  1. 验货清洗(缺失 / 异常 / 长尾类别)→ 数据预处理
  2. 切配变换(log、周期编码、分箱)→ 特征构建
  3. 统一秤(按特征脾气选 Scaler)→ 数据预处理 §3
  4. 吊高汤 / 只留好料(PCA + 过滤/嵌入选特征)→ 降维 · 特征选择
  5. 翻译菜谱(高基数类别编码)→ 特征编码

最后用同一条 Pipeline 处理离线训练线上单次请求——口径必须一致。

Criteo CTR 特征工程管线:原始 I/C 特征 → 清洗(缺失标志+填充+缩尾)→ 非线性变换(log1p/周期/分箱)→ 按列选型缩放 → 编码高基数类别 → 过滤+嵌入选特征 → 可选 PCA → 入模;同一条 Pipeline 同时服务离线训练与在线请求


0. 先认识这批料

import pandas as pd
import numpy as np

cols = ["label"] + [f"I{i}" for i in range(1, 14)] + [f"C{i}" for i in range(1, 27)]
df = pd.read_csv("train.txt", sep="\t", header=None, names=cols, nrows=100_000)

num_cols = [f"I{i}" for i in range(1, 14)]
cat_cols = [f"C{i}" for i in range(1, 27)]

print(df.isnull().sum().sort_values(ascending=False).head(10))
特征族长什么样典型脾气
I* 数值计数、竞价相关整数,右偏、长尾、缺失多要 log / 缩尾,再缩放
C* 类别匿名哈希后的高基数 ID绝不能裸 One-Hot,要频率合并 + Target/Hashing
label是否点击正负极不均衡——评估用 AUC,别只看 Accuracy

1. 验货清洗:缺失、异常、长尾

理论见数据预处理。Criteo 上的实操要点:

1.1 缺失本身也是特征

别只 fillna。对数值列加一个「是否缺失」标志位——「这人没填」往往比填进去的数更有信息:

for col in num_cols:
    df[f"{col}_missing"] = df[col].isnull().astype(int)
    df[col] = df[col].fillna(df[col].median())  # 右偏用中位数,别用均值

for col in cat_cols:
    df[col] = df[col].fillna("missing")
方法什么时候用
直接删行缺失 < 5% 且关键 ID 字段
中位数 + missing 标志右偏数值(默认首选)
分位数填充已知严重右偏的列(如 I1
众数 / "missing"类别列
低频合并为 other高基数类别,出现次数 < 阈值
# 高基数长尾:出现 < 100 次的类别并成 other
for col in cat_cols:
    counts = df[col].value_counts()
    rare = counts[counts < 100].index
    df[col] = df[col].where(~df[col].isin(rare), "other")

1.2 异常值:3σ、IQR、缩尾三选一

广告竞价、点击计数常有极端值。树模型可以硬吃,线性 / 神经网络建议先缩尾:

def winsorize(s, p=0.99):
    thr = s.quantile(p)
    return s.clip(upper=thr)

for col in ["I1", "I2", "I10"]:
    df[col] = winsorize(df[col])

口诀:分布近正态用 ;偏态用 IQR / 分位数;想保留样本量、只砍极端尾巴 → Winsorization(缩尾)


2. 切配变换:把长尾和周期拆开

理论见特征构建。DSP / CTR 里最常用的三刀:

DSP 特征变换三刀:竞价价格 log1p 压长尾;小时做 sin/cos 周期编码(23 点与 0 点相邻);展示次数等频/阈值分箱——变换后再按列选型缩放

2.1 长尾计数 → log1p

df["I3_log"] = np.log1p(df["I3"])  # log(1+x),躲开 0

竞价价格、曝光次数同理。log1p 把「扎堆在左边的小字」拉开,线性模型才吃得动。

2.2 小时 → 周期编码

hour=23hour=0 在整数上差 23,实际上相邻。用 sin/cos 保住环形结构:

df["hour_sin"] = np.sin(2 * np.pi * df["hour_of_day"] / 24)
df["hour_cos"] = np.cos(2 * np.pi * df["hour_of_day"] / 24)

2.3 分箱:把连续量切成档位

等频分箱对 CTR 统计更稳;需要解释性时再用等宽 / 卡方分箱(见预处理篇)。

from sklearn.preprocessing import KBinsDiscretizer

binner = KBinsDiscretizer(n_bins=5, encode="ordinal", strategy="quantile")
df["I3_bin"] = binner.fit_transform(df[["I3"]])

3. 统一秤:按列脾气选 Scaler

不是所有列都该 StandardScaler

特征脾气选谁为什么
竞价 / 有离群点RobustScaler用中位数和 IQR,抗异常
用户行为计数QuantileTransformer压成均匀/正态,管非线性
常规近正态数值StandardScaler均值 0、方差 1
要固定到 [0,1]MinMaxScaler神经网络友好,但对离群点敏感
树模型输入可以不缩放树只看切分点
from sklearn.preprocessing import RobustScaler, StandardScaler, QuantileTransformer
from sklearn.compose import ColumnTransformer

scaler = ColumnTransformer([
    ("bid", RobustScaler(quantile_range=(10, 90)), ["bid_price"]),
    ("behavior", QuantileTransformer(n_quantiles=500), ["user_click_count", "impression_count"]),
    ("numeric", StandardScaler(), [f"feature_{i}" for i in range(8)]),
], remainder="passthrough")

铁律fit 只在训练集上做,测试集 / 线上只用 transform。Scaler 的均值、分位数一旦泄漏测试信息,AUC 会虚高。


4. 翻译高基数:C* 怎么编码

26 个 C* 是匿名高基数——裸 One-Hot 会炸内存。选型口诀见特征编码

# 频率编码示例(只在 train 上统计)
freq = train["C14"].value_counts(normalize=True)
train["C14_freq"] = train["C14"].map(freq)
test["C14_freq"] = test["C14"].map(freq).fillna(0)  # 新类别给 0

5. 只留好料:过滤 → 嵌入

理论见特征选择。Criteo 体量大,推荐两段式

  1. 过滤法粗筛:方差阈值砍掉几乎常值的列;互信息 / 卡方挑与 label 相关的 Top-K。
  2. 嵌入法精修:用带 L1 的线性模型,或树模型特征重要性,再砍一轮。
from sklearn.feature_selection import VarianceThreshold, mutual_info_classif, SelectKBest

# 粗筛:去掉方差≈0 的列
vt = VarianceThreshold(threshold=0.01)
X_var = vt.fit_transform(X_num)

# 再按互信息取 Top-K
selector = SelectKBest(mutual_info_classif, k=40)
X_sel = selector.fit_transform(X_var, y)

包裹法(RFE / 前向)在百万级样本上太贵,留给小数据集或二次精调。


6. 串成一条 Pipeline(离线 = 在线)

把上面几步钉进 sklearn.Pipeline,训练和线上单次请求走同一条代码路径——这是总览里 train-serve skew 的解药:

from sklearn.pipeline import Pipeline
from sklearn.ensemble import HistGradientBoostingClassifier

pipe = Pipeline([
    ("impute_and_flag", ...),   # §1
    ("nonlinear", ...),         # §2
    ("encode", ...),            # §4
    ("scale", scaler),          # §3
    ("select", selector),       # §5
    # ("pca", PCA(n_components=0.95)),  # 可选,线性模型更吃香
    ("clf", HistGradientBoostingClassifier(max_depth=6)),
])

pipe.fit(X_train, y_train)
auc = roc_auc_score(y_test, pipe.predict_proba(X_test)[:, 1])

线上单次请求:

def score_request(req: dict) -> float:
    row = pd.DataFrame([req], columns=X_train.columns)
    return float(pipe.predict_proba(row)[:, 1][0])

同一条 Pipeline:离线用全量日志 fit,产出模型与变换状态;在线每个竞价请求只走 transform+predict——变换参数(中位数、Scaler、编码表)来自离线,禁止在线重算


7. 速查:现象 → 下哪一刀

现象下刀对应理论
缺失多、右偏中位数 + _missing 标志预处理
竞价/点击极端值Winsorize → RobustScaler预处理 · 本篇 §1–3
计数长尾log1p构建
小时/星期循环sin/cos 周期编码构建
特征量纲差几个数量级按列选 Scaler预处理 §3
C* 基数爆炸低频合并 + Target/Hash/Freq编码
特征太多、训练慢方差阈值 → MI Top-K → L1/树重要性选择
离线高、线上崩Pipeline 共用,禁止双实现总览

TL;DR — 三句话

  1. Criteo 实战 = 理论篇的一次联考:清洗 → 变换 → 缩放 → 编码 → 选择,每步都能指回系列里的某一篇。
  2. 按列下刀,不要一把梭:长尾用 log1p,周期用 sin/cos,离群用 Robust,高基数用 Target/Hash——树模型可以少缩放,线性/NN 不行。
  3. 离线 fit、在线 transform,同一条 Pipeline:这是广告特征工程不翻车的底线。

系列回顾

#环节文章
0为什么特征决定上限总览
1特征怎么用/取/监控系统化框架
2洗菜择菜数据预处理
3切配调料特征构建
4吊高汤特征提取与降维
5只留好料特征选择
6翻译菜谱特征编码
7联考实战本篇 · Criteo CTR

大厨手艺再好,也炒不出烂菜叶的满汉全席。这一整圈「数据与特征工程」,就是在把那条上限一点点抬高——而 Criteo 这道菜,是检验备料师刀工的考题。


views
Share this post on:

Previous Post
ZooKeeper 与 etcd 深挖 · ZAB、Raft、Watch 与租约
Next Post
分布式共识(开篇)· Paxos 与 Raft 图解