はじめに
最近、CTR(Click-Through Rate)予測タスクを行う機会があり、その過程で学習した基本的なテクニックを整理しました。
CTR 予測の世界はとても広く、Deep Learning 系(DeepFM, DCN)や Online Learning 等、深いトピックが多々ありますが、本記事ではまず「テーブルデータ + GBDT」で着実に AUC を上げるための基礎レシピを中心にまとめます。
CTR 予測タスクの特徴
タスク定義
P(click=1 | 表示コンテキスト) を予測する二値分類。出力は確率値で、ランキング目的に使うことが多い。
評価指標は AUC が定番
強いクラス不均衡(クリック率 1〜10% が典型)で accuracy はほぼ無意味(全部 0 と予測しても 90%+)。AUC(ROC 下面積)か LogLoss が一般的。
典型的なデータ
例として EC サイトの商品インプレッションログを想定。
| 列 | 例 |
|---|---|
session_token |
セッション識別子(数百万〜) |
sku |
商品 SKU(数万〜数十万) |
brand |
ブランド(数百) |
category_path |
商品カテゴリ階層 |
price_band |
low / mid / high |
placement |
banner / feed / search の表示位置 |
impressed_at |
表示時刻 |
clicked |
クリック有無 |
→ 高カーディナリティ categorical(session, sku)が多く、時系列構造を持つのが特徴。
データ分割(Validation 戦略)
CTR 予測では 時系列構造 があり、また 同じユーザーが複数の表示を持つ ことが多いため、Validation の設計には少し配慮が必要です。
時系列分割
本番運用は「過去のデータで学習 → 未来のリクエストに対して推論」というワークフローです。Validation でもこれを再現するため、時系列に沿った分割が基本になります。
# train を時系列で 80:20 に分割
train_df = train_df.sort_values('impressed_at').reset_index(drop=True)
boundary = train_df['impressed_at'].quantile(0.8)
fit_df = train_df[train_df['impressed_at'] <= boundary]
valid_df = train_df[train_df['impressed_at'] > boundary]
ランダム K-fold では train と valid に同じ時間帯・同じセッションが混在しがちで、本番の分布と乖離します。
GroupKFold
新規ユーザー / 新規セッションへの汎化を測りたい場合は、グループ単位での分割が有効です。
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=5)
for tr_idx, va_idx in gkf.split(X, y, groups=session_tokens):
# tr と va で session_token が disjoint
...
本番で新規セッションの比率が高いことが想定される場合に選択肢になります。
valid 設計のポイント
valid AUC が test(本番)AUC と相関することが、ハイパラ選択や FE 改善判断の前提になります。具体的には:
- 時系列順を保つ
- 新規ユーザー比率など、本番の特徴を valid でも再現する
- valid サイズが小さすぎると AUC が noise に支配されるため、最低でも数万件は確保
これらを満たすと「valid AUC が上がれば test AUC も上がる」状態になり、その後の試行錯誤が効率化します。
特徴量エンジニアリング
GBDT モデルで CTR を上げる最大の鍵は特徴量設計です。代表的なものを挙げます。
Target Encoding(カテゴリの CTR エンコーディング)
カテゴリ列を「そのカテゴリ単位のクリック率」に変換する手法。GBDT は数値特徴量を好むため、高カーディナリティのカテゴリを 1 つの数値で要約できます。
ベイズ平滑化を入れるのが定番:
# (sku, smoothed CTR) のテーブルを作る
smoothing = 100.0 # 平滑化係数
mu = train_df['clicked'].mean() # 全体 CTR
agg = train_df.groupby('sku')['clicked'].agg(['sum', 'count'])
agg['ctr'] = (agg['sum'] + smoothing * mu) / (agg['count'] + smoothing)
sku_ctr = agg['ctr'].to_dict()
train_df['sku_ctr'] = train_df['sku'].map(sku_ctr).fillna(mu)
サンプル数が少ないキーは全体平均 μ に寄り、過学習を抑制できます。
時系列 OOF が有効: 単純に train 全体から集計すると、各行の CTR にその行自身の click が含まれリークになります。CTR 予測では時系列構造も強いため、ランダムな K-fold よりも、過去データで集計して未来側の fold に当てる時系列 OOF の方が自然です。
from sklearn.model_selection import TimeSeriesSplit
import numpy as np
train_df = train_df.sort_values('impressed_at').reset_index(drop=True)
tscv = TimeSeriesSplit(n_splits=5)
oof = np.full(len(train_df), mu)
for tr_idx, va_idx in tscv.split(train_df):
fit_part = train_df.iloc[tr_idx]
valid_part = train_df.iloc[va_idx]
mu_fold = fit_part['clicked'].mean()
agg = fit_part.groupby('sku')['clicked'].agg(['sum', 'count'])
agg['ctr'] = (agg['sum'] + smoothing * mu_fold) / (agg['count'] + smoothing)
mapping = agg['ctr'].to_dict()
oof[va_idx] = valid_part['sku'].map(mapping).fillna(mu_fold).values
train_df['sku_ctr_oof'] = oof
test には train 全体から集計したテーブルを当てる(test 行は train に含まれないのでリーク無し)。データに時系列性がほとんどない場合は K-fold OOF も選択肢になりますが、CTR ではまず時系列を意識しておくと安全です。
時刻特徴量
impressed_at のような表示時刻のカラムから、クリック傾向に関係しそうな時間帯情報を作成。
import pandas as pd
dt = pd.to_datetime(train_df['impressed_at'])
train_df['hour'] = dt.dt.hour # 0–23
train_df['weekday'] = dt.dt.weekday # 0–6
train_df['is_weekend'] = (dt.dt.weekday >= 5).astype('int8')
朝・昼・夜でクリック率が違う、平日と週末で違う、というパターンは CTR で頻出。
交差特徴量(Cross Features)
単一キーではなく複数キーの組み合わせで CTR を集計する。
agg = (train_df.groupby(['brand', 'hour'])['clicked']
.agg(['sum', 'count']))
agg['ctr'] = (agg['sum'] + smoothing * mu) / (agg['count'] + smoothing)
# train / test に merge
「ファッションブランドは夜に CTR が高い」「家電は朝が高い」のような交互作用を表現できます。代表的な組み合わせを 5〜10 個試すと効きやすい。
セッション / ユーザー履歴系
「セッション内何番目の表示か」「直前表示からの経過秒数」など。
train_df = train_df.sort_values(['session_token', 'impressed_at'])
train_df['pos_in_session'] = train_df.groupby('session_token').cumcount() + 1
セッション後半は CTR が落ちる傾向(広告疲れ)を捉えられます。
高カーディナリティ特徴量の扱い
CTR データでは user_id や sku のように 10⁴〜10⁷ 種類 のカテゴリ列が頻出します。
このような高カーディナリティの列を素のままモデルに渡すと:
- One-Hot Encoding: 列数が爆発し、メモリ・学習時間が破綻
-
LabelEncoder (整数化): 順序のない値に大小関係を仮定してしまい、線形モデルや
一部の GBDT では誤った学習要因に - GBDT の categorical split: 高カーディナリティでは split 候補が多すぎ、過学習しやすい
そこで一般的に使われる対処法を整理します。
Target Encoding
カテゴリを「そのカテゴリの平均 CTR」で置き換える方法。前章「特徴量エンジニアリング」で詳述。強力だが、K-fold OOF を組まないとリークする点に注意。
Frequency Encoding
カテゴリの出現頻度で置き換える方法。target を使わないのでリーク無し、実装も簡単。
freq = train_df['sku'].value_counts(normalize=True).to_dict()
train_df['sku_freq'] = train_df['sku'].map(freq).fillna(0)
「人気の SKU かどうか」のような情報を保持できる。CTR と相関する場面も多い。
Hashing Trick
カテゴリをハッシュ関数で固定次元の bucket に押し込める方法。次元数を制御できるが、別カテゴリが衝突するリスクあり。
import hashlib
def hash_bucket(value, n_buckets=1024):
return int(hashlib.md5(str(value).encode()).hexdigest(), 16) % n_buckets
train_df['sku_hash'] = train_df['sku'].apply(lambda v: hash_bucket(v))
オンライン学習や大規模 ML では定番。
CatBoost の Ordered Target Statistics
CatBoost に組み込まれた仕組みで、学習内部で各行の target を直接見ない形でカテゴリ統計量を作ることで、target leak を抑えながら categorical 特徴量を扱える。cat_features 引数にカラム名を渡すだけで有効になる。
Embedding
ニューラルネット系(DeepFM, DCN 等)で使われる方法。カテゴリを低次元の連続ベクトルに圧縮し、学習で最適化する。
各手法は排他的ではなく、組み合わせて使うのが一般的です。たとえば「Target Encoding + Frequency Encoding」を併用するだけでもベースラインから AUC を底上げできます。
モデル選択(GBDT 御三家)
CTR タスクで最も使われるのは GBDT (Gradient Boosting Decision Tree) 系のモデルです。LightGBM / XGBoost / CatBoost が「御三家」と呼ばれ、それぞれ異なる特性を持ちます。
LightGBM
Microsoft が 2017 年に公開。leaf-wise の木成長アルゴリズム(最も誤差を減らせる葉から優先的に伸ばす)と histogram-based の分岐選択により、学習速度とメモリ効率に優れる。Kaggle で最も使われている定番。
import lightgbm as lgb
model = lgb.LGBMClassifier(
objective='binary', n_estimators=1000, learning_rate=0.05,
num_leaves=63, min_child_samples=100,
)
model.fit(X_train, y_train, eval_set=[(X_valid, y_valid)],
callbacks=[lgb.early_stopping(50)])
XGBoost
2014 年に発表された GBDT 実装で、現代の Boosting 系の流れを作った歴史あるライブラリ。チューニング可能なハイパラが豊富で、産業界の運用実績も最も豊富。GPU 学習も成熟している。
import xgboost as xgb
model = xgb.XGBClassifier(
objective='binary:logistic', n_estimators=1000, learning_rate=0.05,
max_depth=7, tree_method='hist',
)
model.fit(X_train, y_train, eval_set=[(X_valid, y_valid)],
early_stopping_rounds=50)
CatBoost
Yandex が 2017 年に公開。最大の特徴は Ordered Target Statistics と Ordered Boosting で、順列ベースの仕組みにより target leak を抑えつつ categorical 特徴量を内部処理する。cat_features にカラム名を渡すだけで高カーディナリティ列を扱える。
from catboost import CatBoostClassifier
model = CatBoostClassifier(
iterations=1000, learning_rate=0.05, depth=7,
loss_function='Logloss', eval_metric='AUC',
)
model.fit(X_train, y_train, cat_features=['sku', 'brand'],
eval_set=(X_valid, y_valid), early_stopping_rounds=50)
三者の比較
| leaf 成長 | カテゴリ処理 | 速度 | 特徴 | |
|---|---|---|---|---|
| LightGBM | leaf-wise | partition method | ★★★ | Kaggle 定番、最高速 |
| XGBoost | depth-wise / leaf-wise | partition method | ★★ | 安定、機能豊富 |
| CatBoost | symmetric tree | Ordered TS | ★ | カテゴリに強い |
実務ではデータ特性・運用要件・チームのスキルセットに応じて、これらの中から選ぶことになります。性能差が出ない場面も多く、まずは扱い慣れたものから始めるので十分です。
リークの落とし穴
CTR タスクで発生しやすいリークを 3 種類に整理します。train で見えた高 AUC が test / 本番で大幅に劣化する場合、ほぼ間違いなくいずれかが起きています。
Target Leak(説明変数に正解情報が混入)
Target Encoding を K-fold OOF せず train 全体から集計するパターン。
# 悪い例: 自分自身の click を含む CTR を特徴量にしてしまう
agg = train_df.groupby('sku')['clicked'].mean()
train_df['sku_ctr'] = train_df['sku'].map(agg)
# → train AUC が異常に高くなるが、test では機能しない
対処は前述の K-fold OOF。「train 行 i の特徴量は、行 i 自身を含まないデータから作る」 が原則。
Temporal Leak(未来の情報を学習に使う)
集計や特徴量作成の際に、test 期間の情報を train に混ぜてしまうパターン。
# 悪い例: train + test 全体から CTR テーブルを作って train に lookup
all_data = pd.concat([train_df, test_df]) # test も使ってしまう
agg = all_data.groupby('sku')['clicked'].mean()
# → train の特徴量に未来情報が紛れ込む
対処は 集計は常に train のみから行う。本番運用は「過去で集計 → 未来で推論」なので、Validation 段階でこれを守らないと、本番との乖離が発生する。
Group Leak(同一エンティティが train/valid 両方に混入)
同じセッションやユーザーが train と valid に分散して入ってしまうパターン。ランダム K-fold で発生しやすい。
# 悪い例: ランダム分割
from sklearn.model_selection import train_test_split
train, valid = train_test_split(df, test_size=0.2, random_state=42)
# → 同じ session_token が両方に出現し、user 履歴が暗黙的にリーク
# 良い例: GroupKFold で session 単位に分ける
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=5)
for tr_idx, va_idx in gkf.split(df, df['clicked'], groups=df['session_token']):
...
新規ユーザーへの汎化を測りたいときは GroupKFold か時系列分割を組み合わせる。
リークの検出方法
リークの兆候:
- valid AUC > 0.95 など異常に高い(タスクの本質的な難易度を超える)
- train AUC と valid AUC の差が極端に大きい / 異常に小さい
- 特徴量重要度が極端に偏る(1 特徴量で 70% 以上など)
- best_iteration が極端に少ない(数十でモデルが「完成」してしまう)
これらに該当した時は、特徴量パイプラインを上から見直すとよい。
まとめ
CTR 予測タスクで最初に押さえるべき基礎を整理しました。
チェックリスト
| 項目 | 押さえるべきポイント |
|---|---|
| データ理解 | 分布、重複率、時系列構造の把握 |
| Validation | 時系列分割を基本。新規ユーザー重視なら GroupKFold |
| 特徴量 | Target Encoding(K-fold OOF 必須)+ 時刻系 + 交差系 |
| 高カーディナリティ | TE / Frequency / Hashing / CatBoost Ordered TS |
| モデル | データ特性に応じて LGB / XGB / CatBoost を選択 |
| リーク対策 | Target / Temporal / Group の 3 種類を意識 |
これらをやるだけでもそれなりの精度は出る印象です。
この先のステップ
基礎が固まったら、以下のような深いトピックに踏み込む選択肢があります。
- Deep Learning 系: DeepFM, DCN, AutoInt 等。embedding を学習可能にする利点があるが、tabular GBDT に勝つのは案外難しい
- Field-aware Factorization Machines (FFM): カテゴリ × カテゴリの交互作用を効率的に学習
- Online Learning: 配信中のデータで継続的にモデルを更新する仕組み
- Multi-Armed Bandit との組み合わせ: 「予測の良い候補を出す」だけでなく、「未知の候補を試す」探索を組み込む
- Counterfactual Estimation: 表示しなかったコンテンツの CTR を推定する技術
など