理论篇讲完了「备料」的每一刀,这一篇用一道真实的广告菜——Kaggle Criteo Display Advertising Challenge——把整条链路从头炒一遍。数据是公开的 CTR 日志:13 个整数特征 I1–I13 + 26 个类别特征 C1–C26 + 点击标签。下面每一步都对应系列里某一篇的理论,这里只写在这道菜上怎么下刀。
Table of contents
Open Table of contents
一句话理解
Criteo 特征工程 = 后厨的一次完整开档:
- 验货清洗(缺失 / 异常 / 长尾类别)→ 数据预处理
- 切配变换(log、周期编码、分箱)→ 特征构建
- 统一秤(按特征脾气选 Scaler)→ 数据预处理 §3
- 吊高汤 / 只留好料(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])
口诀:分布近正态用 3σ;偏态用 IQR / 分位数;想保留样本量、只砍极端尾巴 → Winsorization(缩尾)。
2. 切配变换:把长尾和周期拆开
理论见特征构建。DSP / CTR 里最常用的三刀:
2.1 长尾计数 → log1p
df["I3_log"] = np.log1p(df["I3"]) # log(1+x),躲开 0
竞价价格、曝光次数同理。log1p 把「扎堆在左边的小字」拉开,线性模型才吃得动。
2.2 小时 → 周期编码
hour=23 和 hour=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 会炸内存。选型口诀见特征编码:
- 低频已并成
other后,中低基数可 One-Hot; - 仍很高的列:
Frequency Encoding/Target Encoding(必须 K 折 + 平滑)/ Hashing Trick; - 深度模型:Embedding。
# 频率编码示例(只在 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 体量大,推荐两段式:
- 过滤法粗筛:方差阈值砍掉几乎常值的列;互信息 / 卡方挑与
label相关的 Top-K。 - 嵌入法精修:用带 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])
7. 速查:现象 → 下哪一刀
| 现象 | 下刀 | 对应理论 |
|---|---|---|
| 缺失多、右偏 | 中位数 + _missing 标志 | 预处理 |
| 竞价/点击极端值 | Winsorize → RobustScaler | 预处理 · 本篇 §1–3 |
| 计数长尾 | log1p | 构建 |
| 小时/星期循环 | sin/cos 周期编码 | 构建 |
| 特征量纲差几个数量级 | 按列选 Scaler | 预处理 §3 |
C* 基数爆炸 | 低频合并 + Target/Hash/Freq | 编码 |
| 特征太多、训练慢 | 方差阈值 → MI Top-K → L1/树重要性 | 选择 |
| 离线高、线上崩 | Pipeline 共用,禁止双实现 | 总览 |
TL;DR — 三句话
- Criteo 实战 = 理论篇的一次联考:清洗 → 变换 → 缩放 → 编码 → 选择,每步都能指回系列里的某一篇。
- 按列下刀,不要一把梭:长尾用
log1p,周期用 sin/cos,离群用 Robust,高基数用 Target/Hash——树模型可以少缩放,线性/NN 不行。 - 离线 fit、在线 transform,同一条 Pipeline:这是广告特征工程不翻车的底线。
系列回顾
| # | 环节 | 文章 |
|---|---|---|
| 0 | 为什么特征决定上限 | 总览 |
| 1 | 特征怎么用/取/监控 | 系统化框架 |
| 2 | 洗菜择菜 | 数据预处理 |
| 3 | 切配调料 | 特征构建 |
| 4 | 吊高汤 | 特征提取与降维 |
| 5 | 只留好料 | 特征选择 |
| 6 | 翻译菜谱 | 特征编码 |
| 7 | 联考实战 | 本篇 · Criteo CTR |
大厨手艺再好,也炒不出烂菜叶的满汉全席。这一整圈「数据与特征工程」,就是在把那条上限一点点抬高——而 Criteo 这道菜,是检验备料师刀工的考题。