機械学習でCVR予測モデルを作りクリエイティブ最適化する
広告クリエイティブのCVRを機械学習で予測し、その予測を配信配分に接続するまでを実装します。特徴量のリーク回避、時系列分割での正直な評価、確率の較正、運用後のドリフト監視までを、再現可能なPythonと実測値で通します。
by Shin
広告クリエイティブのCVRは機械学習で「予測」できるのか、そしてその予測は配信判断に使えるのか。結論から言うと、1インプレッションが成約するかの的中はほぼ不可能ですが、「率」の予測は配分の意思決定に十分使えます——正しく評価し、較正し(予測確率と実際の発生率を一致させ)、ドリフトを監視できれば、という条件つきで。本稿は特徴量のリーク回避から配分への接続、運用後の監視までを、再現可能なPythonと実測値で通します。「そもそも機械学習を使うべき場面か」の判断は別レイヤーの記事に譲り、ここでは「使うと決めた後」の実装だけを扱います。
特徴量設計とリーク回避
最初に決めるのは予測の単位です。ここでは1インプレッションを1行とする二値分類(成約/非成約)にしますが、狙いは個々の成約を当てることではなく、較正された率を出すことにあります。後段でこの率を配分に変換します。
特徴は3層に分けます。
- クリエイティブ属性: フォーマット、フック種別、オファー有無、配色、コピー長。新規クリエイティブにも必ず存在する「履歴に依存しない」特徴。
- 配信文脈: デバイス、面(placement)、オーディエンス、時間帯、曜日。
- 履歴特徴: そのクリエイティブの過去CVR、累積インプレッション(疲弊の代理)、オーディエンスの過去CVR。ここが最もリークを起こしやすい。
リーク回避には3つの鉄則があります。CVR予測でAUC(予測スコアの順位付けの良さを測る指標。当てずっぽうで0.5、完全なら1)が不自然に高いとき、その原因はほぼ例外なくこのどれかです。
鉄則1: 履歴特徴は必ず過去のみ(時間リーク)
過去CVRを作るとき、当日のデータを1行でも混ぜると未来の情報が漏れます。日次に集計してから shift(1) を挟むのが肝になります。
import numpy as np, pandas as pd
# 日次に集計してから「過去のみ」のローリング特徴を作る
cd = (df.groupby(["creative_id", "date"])
.agg(imp=("converted", "size"), conv=("converted", "sum"))
.reset_index().sort_values(["creative_id", "date"]))
def prior_roll(g, win):
cvr = (g["conv"].rolling(win, min_periods=1).sum()
/ g["imp"].rolling(win, min_periods=1).sum())
return cvr.shift(1) # ← 当日を絶対に含めない
cd["cre_cvr_7"] = cd.groupby("creative_id", group_keys=False).apply(lambda g: prior_roll(g, 7))
cd["cre_cum_imp_prior"] = cd.groupby("creative_id")["imp"].cumsum().shift(1) # 疲弊の代理鉄則2: 分割は時系列で切る(評価のリーク)
時系列データをランダムに8:2分割すると、同じ日の似た行が学習側とテスト側に分かれ、未来が過去に漏れます。必ず日付で前後に切ります。
cutoff = df["date"].quantile(0.75) # 前75%で学習, 後25%で検証
tr = df[df["date"] <= cutoff]
te = df[df["date"] > cutoff]鉄則3: ターゲットエンコーディングは過去のみ/out-of-fold
creative_id のような高カーディナリティ変数を「全データの平均CVR」で符号化するのは、現場で最も多い致命的リークです。テスト行の符号化値がテストの正解を既に知っている状態になります。
# やってはいけない: セルのCVRを「全データ」で平均符号化(テストの正解が漏れる)
df["LEAK_cell_te"] = df.groupby(
["creative_id", "audience", "placement", "hour"])["converted"].transform("mean")この3点を破った場合と守った場合で、同じLightGBMのROC-AUCがどう変わるかを実測しました。
| 構成 | ROC-AUC |
|---|---|
| (a) リーク特徴 + ランダム分割 | 0.912 |
| (b) リークなし + ランダム分割 | 0.614 |
| (c) リークなし + 時系列分割(正しい) | 0.595 |
なお、新規クリエイティブには履歴特徴が存在しません(コールドスタート)。これは欠損として扱い、has_history フラグを立てたうえで、全体の事前CVR(過去累積から算出)やオーディエンス事前分布で埋めます。属性特徴がこの局面の主役になります。
モデル選択と評価
ベースラインはロジスティック回帰(確率が素直で解釈しやすい)、本命候補はLightGBM(カテゴリをネイティブに扱え、非線形・交互作用を拾う)に置きます。表形式・中規模・弱シグナルのこの種のタスクでディープラーニングを持ち出す必要はありません——という判断の根拠は別レイヤーの記事に譲りますが、実測でもその通りになります。
import lightgbm as lgb
from sklearn.metrics import roc_auc_score, average_precision_score, log_loss, brier_score_loss
dtr = lgb.Dataset(tr[cols], label=tr["converted"], categorical_feature=CAT)
params = dict(objective="binary", learning_rate=0.05, num_leaves=31,
min_data_in_leaf=200, feature_fraction=0.8, verbose=-1)
booster = lgb.train(params, dtr, num_boost_round=300)
p = booster.predict(te[cols])評価指標は目的に合わせて選びます。不均衡(陽性率 2.55%)なのでROC-AUCだけ見るのは危険で、PR-AUC(平均適合率、ベースラインは陽性率そのもの)、log loss と Brier(確率の質)、そして較正を併せて見ます。後で確率を期待値計算に使うなら、較正が最重要になります。
| モデル | ROC-AUC | PR-AUC | log loss | Brier |
|---|---|---|---|---|
| LogisticRegression | 0.617 | 0.040 | 0.117 | 0.0247 |
| LightGBM | 0.595 | 0.037 | 0.118 | 0.0249 |
PR-AUCのベースライン(=陽性率)が 0.0255 なので、両モデルとも順位付けの上積みはわずかです。これは弱点ではなく、インプレッション単位予測の構造的な天井を示しています。当てているのは「どの1インプレッションが成約するか」ではなく「率」であり、その率を集計に使うのが正しい使い方になります。今回はロジスティック回帰がLightGBMを僅差で上回りました。データ量とシグナルが弱い局面では珍しくない結果で、複雑なモデルを足す前に立ち止まる理由になります。
確率を意思決定に使う以上、順位だけでなく較正——予測確率が実際の発生率と一致しているか——を必ず確認します。LightGBMの予測を10分位に分けて、各帯の予測平均と実測CVRを並べました。
| 予測10分位 | n | 予測平均 | 実測CVR |
|---|---|---|---|
| 第1分位(低) | 6117 | 0.0099 | 0.0134 |
| 第5分位 | 6115 | 0.0227 | 0.0242 |
| 第9分位 | 6115 | 0.0441 | 0.0296 |
| 第10分位(高) | 6116 | 0.0710 | 0.0474 |
中位はよく一致しますが、最上位の帯で過大予測しています(予測 7.1% に対し実測 4.7%)。未加工のスコアをそのまま期待値に投入すると、上位クリエイティブを過大評価して入札を盛りすぎます。配分に使う前に Isotonic か Platt で較正をかけるべき、という実務的な示唆がここから出ます。
重要度(gain)の上位は cre_cvr_7(過去CVR)、aud_cvr_14、log_cum_imp(疲弊)、時間帯、全体事前CVRでした。履歴と疲弊が効くということは、裏を返せば履歴のない新規クリエイティブにはモデルが弱いことを意味します。新規投入時は属性特徴と探索(後述)で補う前提で運用します。
予測をクリエイティブ選定に接続する
予測CVR p を意思決定に変換します。最も素直なのは1インプレッションあたりの期待利益です。
ここで v は1成約の価値、c は1インプレッションのコスト。あるいは期待CPA c / p で見てもかまいません。ただし点推定の期待値で勝者総取りにすると、コールドスタートや推定の不確実性に対して脆くなります。主軸に寄せつつ探索を残すため、期待値に温度をかけたソフトマックスで配分します。
温度 τ が大きいほど配分はなだらかになり、探索が増えます。
VALUE_PER_CONV, COST_PER_IMP = 3000.0, 1.2
C["pred_cvr"] = booster.predict(C[cols]) # 本番は較正後スコアを使う
C["ev_per_imp"] = C["pred_cvr"] * VALUE_PER_CONV - COST_PER_IMP
C["exp_cpa"] = COST_PER_IMP / C["pred_cvr"]
tau = 40.0 # 探索温度(EVの円単位)
ev = C["ev_per_imp"].clip(lower=0).to_numpy()
w = np.where(ev > 0, np.exp((ev - ev.max()) / tau), 0.0)
C["alloc"] = w / w.sum()ある配信文脈(mobile / feed / aud_2 / 平日19時、v = 3000円、c = 1.2円)で現役クリエイティブを総当たりにスコアした結果の上位は次の通りです。
| creative | format | hook | offer | 予測CVR | 期待CPA(円) | EV/imp(円) | 配分 |
|---|---|---|---|---|---|---|---|
| #30 | video | curiosity | 有 | 0.0842 | 14.3 | 251 | 33.2% |
| #20 | video | social_proof | 有 | 0.0633 | 19.0 | 189 | 6.9% |
| #9 | image | curiosity | 無 | 0.0570 | 21.0 | 170 | 4.3% |
| #36 | image | curiosity | 無 | 0.0569 | 21.1 | 169 | 4.3% |
上位3本で配分の約 44%、残りを多数の候補に薄く撒いて探索に回す形になりました。温度 τ を下げれば勝者総取りに、上げれば一様配分に近づきます。この一本のレバーが「活用と探索」のバランスになります。
上の例は説明のため未加工のスコアを使っていますが、較正の節で見た通り最上位帯は過大予測気味なので、本番では較正後スコアと、可能なら推定の不確実性(Thompsonサンプリング的な配分)を併用するのが堅いやり方です。
運用での再学習とドリフト監視
このモデルは放っておけば劣化していきます。クリエイティブの疲弊、季節性、オーディエンス構成の変化、プラットフォーム側のアルゴリズム変更——どれもCVRの構造を動かします。監視は3層で行います。
入力ドリフト(PSI)
特徴量の分布が学習時からどれだけ動いたかを Population Stability Index で測ります。
p が学習期、q が直近の分布。目安は PSI < 0.1 で安定、0.1〜0.25 で要観察、PSI > 0.25 で要対応。
def psi(ref, cur, bins=10, categorical=False):
if categorical:
cats = sorted(set(ref) | set(cur))
rp = pd.Series(ref).value_counts(normalize=True).reindex(cats).fillna(1e-6)
cp = pd.Series(cur).value_counts(normalize=True).reindex(cats).fillna(1e-6)
else:
edges = np.quantile(ref, np.linspace(0, 1, bins + 1)); edges[0], edges[-1] = -np.inf, np.inf
rc, _ = np.histogram(ref, edges); cc, _ = np.histogram(cur, edges)
rp, cp = rc / rc.sum() + 1e-6, cc / cc.sum() + 1e-6
return float(((cp - rp) * np.log(cp / rp)).sum())学習期から直近期へのPSIを測ると、終盤に仕込んだオーディエンス構成の変化を正しく検知できました。
| 特徴 | PSI | 判定 |
|---|---|---|
| audience | 0.203 | 要観察 |
| cre_cvr_7 | 0.154 | 要観察 |
| placement / device / hour | ≈0.000 | 安定 |
予測分布ドリフト
入力だけでなく、出力(予測スコア)の分布変化も見ます。今回は予測スコア分布のPSIが 0.018 で安定していました。入力が動いても出力が動かないなら影響は軽微、という切り分けに使えます。
性能ドリフト(較正ずれ)
成約ラベルが揃ったコホートに限って、週次で予測平均と実測CVRのずれを追います。
| 週 | n | 予測平均 | 実測CVR | 較正ずれ |
|---|---|---|---|---|
| 直近 第1週 | 30811 | 0.0294 | 0.0254 | -0.0039 |
| 直近 第2週 | 30345 | 0.0285 | 0.0255 | -0.0030 |
わずかな過大予測(較正ずれが負)が継続しています。単独では小さいのですが、PSIの観察判定と重なると再学習の根拠になります。
再学習は「定期 + 条件発火」の二段で組みます。週次や月次の定期再学習を土台に、PSI > 0.25・較正ずれが閾値超過・成熟コホートのPR-AUCが基準割れ、のいずれかで臨時再学習を発火させます。予測そのものより、この監視と再学習のループを止めないことが、CVR予測を「一度作って終わり」にしないための条件になります。
予測の精度を上げることより、予測を配分という意思決定に正しく接続し、壊れたら気づける状態を保つこと——CVR予測の実装で効くのは、結局そこに尽きます。