この連載について
因果推論を「本を読んだ」で終わらせず、自分の言葉で説明でき、コードで再現できる状態まで落とす30日連載です。直前のDay 25では、木の分割規準を効果の異質性に変えたCausal Forestで、CATE $\tau(x)$ を信頼区間つきで推定しました。Phase 4 の締めくくりは、その $\tau(x)$ を意思決定に変える話です。マーケティングの言葉ではアップリフトモデリングと呼ばれます。図はすべて合成データで、顧客の4象限の構成比を私たちが握った状態で見せます。なお定番ライブラリに causalml がありますが、今日は使いません。Qini曲線を numpy で手組みしたほうが、縦軸の意味が骨身に染みるからです。
TL;DR(3行)
- 反応率が高い人と、効果が高い人は別人。 よく買ってくれる鉄板客はクーポンなしでも買うので増分ゼロ、天邪鬼は配ると買うのをやめます。今日の合成データはATE +0.10、中身は+1が2割と−1が1割。
- Qini曲線の縦軸は購買数ではなく増分。 T-learnerのスコア順に上位20%へ配ると増分 +2636件、「買いそうな人」順だと −959件。同じ予算で符号まで変わります。
- 配るべきは $\hat{\tau}(x)$ が費用/粗利を超える人だけ。 全員配布は顧客1万人あたり 185万円の赤字、スコア上位30%配布なら 77万円の黒字に変わります。
今日の問い
会員データで「購買確率の高い順」に1万人へクーポンを配った。受け取った人の購買率は高く、キャンペーン報告は上々だった。
でもその人たちは、配らなくても買っていたのではないか? クーポンが「増やした」購買は、いったい何件なのか?
今日のゴールは2つです。まず「反応率で配る」と「効果で配る」の違いを、4象限を仕込んだ合成データで数値にすること。次に、ターゲティングの良し悪しを測るものさし(Qini曲線とAUUC)を自分の手で組み、予算制約下で「何%まで配るか」の線引きまで降ろすことです。
概念① 4象限:反応率が高い人と、効果が高い人は別人
コーヒーチェーンの割引クーポンを考えます。毎朝来る常連に配れば、クーポンはよく使われ、反応率の指標は輝きます。でもその常連は、クーポンがなくても毎朝買っていました。増えた購買はゼロで、値引き分がまるごと持ち出しです。施策の価値は「処置した人がどれだけ反応したか」ではなく、「処置しなかった世界と比べてどれだけ増えたか」で決まります。Day 2 の潜在的結果がそのまま効いてきます。
アウトカムが「買う/買わない」の2値なら、潜在的結果の組 $(Y(0), Y(1))$ は4通りしかありません。マーケティングの慣用名を添えると、こうなります。
- 説得可能(persuadable):$Y(0)=0, Y(1)=1$。クーポンが来たときだけ買う人。増分の唯一の源泉で、効果 $\tau = +1$ です。
- 鉄板(sure thing):$Y(0)=Y(1)=1$。配らなくても買う人。反応率は最高なのに $\tau = 0$ で、値引きは全額無駄になります。
- 無関心(lost cause):$Y(0)=Y(1)=0$。配っても買わない人。$\tau = 0$ で、配布費だけかかります。
- 天邪鬼(sleeping dog):$Y(0)=1, Y(1)=0$。配ると買うのをやめる人。$\tau = -1$ です。しつこい販促に冷める、解約を思い出す、といった形で実在します。
この形、Day 21のコンプライヤー4分類とまったく同じです。あちらは受療 $D$ が割付 $Z$ にどう反応するかで人を分け、defier(天邪鬼)を単調性で追放しました。今日はアウトカム $Y$ が処置 $W$ にどう反応するかで分けます。そして今日は天邪鬼を追放しません。負の効果ごとモデルに拾わせるのがアップリフトの流儀です。もちろん、個人がどの象限かは観測できません(根本問題)。だからこそ、属性 $x$ から条件付き効果 $\tau(x) = E[Y(1) - Y(0) \mid X = x]$ を推定し、その高い順に配る。これがアップリフトモデリングの全体像で、推定部分は Day 23 のメタラーナーがそのまま使えます。
概念② Qini曲線とAUUC:並べ替えの良さを「増分」で測る
スコア $\hat{\tau}(x)$ ができたら、次の問いは「このスコア順に配ると、どれだけ得か」です。それを描くのがQini曲線です。横軸に「スコア上位から配った割合 $\varphi$」、縦軸に「そのとき増えた購買数」を取ります。
縦軸の意味に1段落使います。縦軸は売上でも反応数でもなく、増分(incremental)、つまり「配らなかった世界との差」です。反事実を含むこの量がなぜ描けるのかというと、検証データの割付がランダムだからです。スコアは配布前の属性 $x$ だけから計算されているので、「スコア上位 $\varphi$」という絞り込みをかけても、その内側で処置群と対照群はやはりランダムに分かれています。つまり上位 $\varphi$ の中での購買率の引き算 $\bar{y}_T(\varphi) - \bar{y}_C(\varphi)$ は、その集団での効果の不偏推定です(Day 4 の論理の再利用)。それに配布人数 $k$ を掛けた
$$
\mathrm{Qini}(\varphi) = \left( \bar{y}_T(\varphi) - \bar{y}_C(\varphi) \right) \times k(\varphi)
$$
が「上位 $\varphi$ に配ったときの増分購買数」になります。Radcliffe (2007) の元定義は処置群人数で換算しますが、割付が半々のRCTなら形は同じで縦軸の倍率が違うだけなので、本稿は解釈しやすい「全員換算の増分」で描きます。曲線の下の面積がAUUC(area under the uplift curve)で、ランダム配布の直線(原点から総増分への直線)との面積差が、モデルの並べ替えが生んだ価値です。大事な帰結をひとつ。$\varphi = 1$ では誰がどう並べても全員に配るので、どんなスコアでも曲線は同じ点に着地します。差がつくのは途中経過だけ、つまりQiniは純粋に「並べ替えの質」を測る道具です。
手を動かす①:4象限を仕込んだデータを作る
構成比を私たちが握った合成データを作ります。説得可能20%・鉄板30%・無関心40%・天邪鬼10%。顧客属性は2つで、購買意欲スコア $x_1$ と販促好感度スコア $x_2$ の分布中心を象限ごとにずらします(鉄板は意欲が高い、天邪鬼は意欲はあるが販促を嫌う、という気持ちです)。属性から象限が透けて見える、でも完全には見分けられない、という現実的な設定になります。
import numpy as np
rng = np.random.default_rng(26)
N = 40000
# 4象限を確率で仕込む(構成比を私たちが握る)
seg = rng.choice(["説得可能", "鉄板", "無関心", "天邪鬼"], size=N, p=[0.2, 0.3, 0.4, 0.1])
# 顧客属性:象限ごとに分布の中心をずらす(モデルが学べる手がかり)
mu = {"説得可能": (0.3, 1.5), "鉄板": (1.8, 0.5), "無関心": (-1.8, 0.0), "天邪鬼": (0.8, -1.8)}
x1 = np.array([mu[s][0] for s in seg]) + rng.normal(0, 1, N) # 購買意欲スコア
x2 = np.array([mu[s][1] for s in seg]) + rng.normal(0, 1, N) # 販促好感度スコア
# 潜在的結果は象限の定義そのもの
y0 = np.isin(seg, ["鉄板", "天邪鬼"]).astype(int) # クーポンなしで買うか
y1 = np.isin(seg, ["鉄板", "説得可能"]).astype(int) # クーポンありで買うか
w = rng.binomial(1, 0.5, N) # 配布はランダム(RCT型のキャンペーン)
y = np.where(w == 1, y1, y0) # 観測できるのは片方だけ
print("真のATE = %.3f" % (y1 - y0).mean())
print("RCTの引き算 = %.3f" % (y[w == 1].mean() - y[w == 0].mean()))
for s in ["説得可能", "鉄板", "無関心", "天邪鬼"]:
m = seg == s
print("%s: %2.0f%% 購買率 配布あり %.2f / なし %.2f → 効果 %+.0f"
% (s, 100 * m.mean(), y[m & (w == 1)].mean(), y[m & (w == 0)].mean(),
y1[m].mean() - y0[m].mean()))
真のATE = 0.104
RCTの引き算 = 0.109
説得可能: 20% 購買率 配布あり 1.00 / なし 0.00 → 効果 +1
鉄板: 30% 購買率 配布あり 1.00 / なし 1.00 → 効果 +0
無関心: 40% 購買率 配布あり 0.00 / なし 0.00 → 効果 +0
天邪鬼: 10% 購買率 配布あり 0.00 / なし 1.00 → 効果 -1
RCTの引き算は 0.109 で真のATE 0.104 をきちんと当てます。でも表を見てください。購買率がいちばん高いのは鉄板です(配布あり1.00)。反応率のものさしでは鉄板が最優良顧客に見えますが、配布なしでも1.00なので増分はゼロ。増分を生むのは説得可能(配布あり1.00、なし0.00)だけで、天邪鬼は符号が逆です。ATEという1つの数字は、この+1が2割と−1が1割を混ぜた平均にすぎません。Day 22 で見た「平均の裏の異質性」の、これが意思決定版です。
手を動かす②:T-learnerでスコアを作り、Qini曲線を手組みする
Day 23 のT-learnerで $\hat{\tau}(x)$ を作ります。処置群と対照群で別々に購買確率モデルを学び、予測の差を取るだけです。学習に使ったデータで評価すると過学習ごと評価してしまうので、半分を検証用に取り分けます。
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
X = np.column_stack([x1, x2])
X_tr, X_te, w_tr, w_te, y_tr, y_te, seg_tr, seg_te = train_test_split(
X, w, y, seg, test_size=0.5, random_state=26)
# T-learner:処置群と対照群で別々に購買確率モデルを学ぶ
m1 = RandomForestClassifier(n_estimators=300, min_samples_leaf=100, random_state=26)
m0 = RandomForestClassifier(n_estimators=300, min_samples_leaf=100, random_state=26)
m1.fit(X_tr[w_tr == 1], y_tr[w_tr == 1])
m0.fit(X_tr[w_tr == 0], y_tr[w_tr == 0])
p1 = m1.predict_proba(X_te)[:, 1] # 配ったら買う確率
p0 = m0.predict_proba(X_te)[:, 1] # 配らなくても買う確率
uplift = p1 - p0 # アップリフトスコア
for s in ["説得可能", "鉄板", "無関心", "天邪鬼"]:
print("%s: 予測アップリフト %+.2f(真の効果 %+d)"
% (s, uplift[seg_te == s].mean(), {"説得可能": 1, "天邪鬼": -1}.get(s, 0)))
説得可能: 予測アップリフト +0.48(真の効果 +1)
鉄板: 予測アップリフト +0.11(真の効果 +0)
無関心: 予測アップリフト +0.06(真の効果 +0)
天邪鬼: 予測アップリフト -0.50(真の効果 -1)
順序は完璧に復元できています(説得可能が最上位、天邪鬼が最下位)。一方で値は ±1 でなく +0.48 / −0.50 に縮んでいます。これは失敗ではありません。属性 $x$ からは象限を完全には見分けられないので、モデルが推定しているのは象限のラベルではなく $E[\tau \mid x]$、つまり「$x$ がこの値の人たちの平均増分」です。説得可能の人の周りにも無関心や鉄板が混ざって立っているぶん、期待値は縮みます。並べ替えて配る用途には、この縮んだスコアで十分です。
では並べ替えの価値をQini曲線で測ります。概念②の式をそのまま関数にして、3つの並べ方を比べます。アップリフト順、優良顧客順(配らなくても買う確率 $\hat{p}_0$ の高い順。冒頭の「購買確率の高い順に配る」施策そのものです)、そしてランダムです。
def qini_curve(score, y, w, grid):
"""スコア降順に上位φへ配った場合の増分購買数(検証データ全体換算)"""
order = np.argsort(-score)
y_s, w_s = y[order], w[order]
inc = [0.0]
for phi in grid[1:]:
k = int(round(phi * len(score))) # 上位φに入る人数
yt, yc = y_s[:k][w_s[:k] == 1], y_s[:k][w_s[:k] == 0] # その中の処置群と対照群
inc.append((yt.mean() - yc.mean()) * k) # 増分率 × 配布人数
return np.array(inc)
grid = np.linspace(0, 1, 21)
q_up = qini_curve(uplift, y_te, w_te, grid) # アップリフト順
q_re = qini_curve(p0, y_te, w_te, grid) # 優良顧客順(買いそうな人から配る)
q_rd = grid * q_up[-1] # ランダム配布の期待線
i20 = int(np.argmin(np.abs(grid - 0.2)))
print("検証データ %d人。全員に配った増分 = %.0f件" % (len(y_te), q_up[-1]))
print("上位20%%に配布: アップリフト順 %+.0f件 / 優良顧客順 %+.0f件 / ランダム %+.0f件"
% (q_up[i20], q_re[i20], q_rd[i20]))
print("AUUC(面積): アップリフト順 %.0f / ランダム %.0f / 差 %.0f"
% (np.trapezoid(q_up, grid), np.trapezoid(q_rd, grid),
np.trapezoid(q_up - q_rd, grid)))
検証データ 20000人。全員に配った増分 = 2294件
上位20%に配布: アップリフト順 +2636件 / 優良顧客順 -959件 / ランダム +459件
AUUC(面積): アップリフト順 3227 / ランダム 1147 / 差 2080
同じ予算(上位20%=4000人に配布)で、アップリフト順は +2636件、優良顧客順は −959件。並べ方だけで符号まで変わりました。優良顧客順が悲惨なのは偶然ではありません。「配らなくても買う確率が高い人」の正体は鉄板と天邪鬼の混成部隊で、鉄板からは増分ゼロ、天邪鬼からはマイナスだけが返ってくるからです。冒頭の問いの答えがこれです。反応率の高いキャンペーンが、購買を減らしていることすらあります。もうひとつ、アップリフト順の +2636件が全員配布の 2294件 を超えている点も見どころです。天邪鬼に配らない分、全員配布より増分が多い。「配る人を減らすと効果が増える」は、負の効果がいる世界では普通に起きます。
手を動かす③:予算制約下の最適カットオフ
最後に「何%まで配るか」をお金で決めます。増分1件の粗利を1000円、クーポン1通の費用(値引き原価と配布コストの合計)を300円に単純化します。上位 $\varphi$ に配ったときの顧客1万人あたりの利益は、1通あたりの利益(粗利×増分率 − 費用)に配布数 $\varphi \times 10000$ を掛けたもので、万円に直すと (1000 * inc_rate - 300) * grid がそのまま答えになります。
margin, cost = 1000, 300 # 増分1件の粗利1000円、クーポン1通の費用300円
inc_rate = np.zeros_like(grid)
inc_rate[1:] = q_up[1:] / (grid[1:] * len(y_te)) # 上位φの増分率(1通あたりの増分)
profit = (margin * inc_rate - cost) * grid # 顧客1万人あたりの利益(万円)
best = int(np.argmax(profit))
print("全員配布: 利益 %+.0f万円(顧客1万人あたり)" % profit[-1])
print("最適カットオフ: 上位%.0f%%配布で %+.0f万円" % (100 * grid[best], profit[best]))
print("そのときのスコア閾値 τ̂ ≳ %.2f(損益分岐 費用/粗利 = %.2f)"
% (np.quantile(uplift, 1 - grid[best]), cost / margin))
全員配布: 利益 -185万円(顧客1万人あたり)
最適カットオフ: 上位30%配布で +77万円
そのときのスコア閾値 τ̂ ≳ 0.23(損益分岐 費用/粗利 = 0.30)
全員配布は 185万円の赤字です。1通あたりの増分粗利は 1000円 × 0.115 ≒ 115円 で、費用300円に届きません。ところがスコア上位30%に絞るだけで +77万円の黒字に反転します。配る相手を選んだだけで、クーポンの原価も粗利も何ひとつ変えていません。線引きの理屈もきれいです。1通が黒字になる条件は $\tau(x) \times 1000 > 300$、つまり $\tau(x) > 0.3$。利益曲線の頂点(上位30%)でのスコア閾値は 0.23 で、この損益分岐の近くに来ています(ぴったり0.30でないのはスコアの較正誤差の分です)。Day 25 までに推定してきた CATE は、費用と粗利という2つの数字を添えるだけで、そのまま配布リストの線引きになります。
つまづき・誤解しやすい点
- 「購買確率の高い優良顧客に配る」は、アップリフトの世界では最悪の一手になりえます。 反応率・購買率のモデルが上位に集めるのは鉄板と天邪鬼で、増分はゼロかマイナスです(手を動かす②で−959件)。ランキングの元になる量が「買う確率」なのか「配ると増える確率」なのか、施策を設計する前に必ず言葉にしてください。Day 27 のチェックリストで「推定対象は何か」として再訪します。
- $\hat{\tau}(x)$ の良し悪しは、通常の予測精度では測れません。 正解ラベル $\tau_i$ は誰にも観測できないからです(Day 2 の根本問題、Day 23 のPEHEの話の再来)。実務の代替がランク評価としてのQini・AUUCですが、これにも無作為割付の検証データが必要です。観察データにQiniを当てると、増分ではなく交絡ごと評価してしまいます(Day 6 の非交絡性がここにも顔を出します)。
- 負の効果(天邪鬼)を最初から無いことにしない。 Day 21 の単調性はあちらの文脈では妥当な仮定でしたが、販促・通知・営業では逆効果の顧客が普通にいます。天邪鬼がいる世界では、今日見たとおり「全員配布」がスコア上位配布に負けます。$\hat{\tau}(x) < 0$ の領域が出たら、モデルの誤差と決めつける前に、施策が嫌われる機序を疑ってください。
GISデータ実務での使い方
- 「壊れそうな管路」と「調査・更新の効果が大きい管路」は別物です。 劣化予測モデルのリスク上位はいわば鉄板(何をしても近く壊れる)や無関心(まだ何ともない)を含みます。限られた更新予算で事故を最大限減らすなら、並べるべきはリスクではなく「介入したときのリスク低減の増分」、つまり管路版の $\hat{\tau}(x)$ です。反応率とアップリフトの取り違えは、そのまま更新計画の予算配分ミスになります。
- カットオフの議論は fig3 の形で持ち込めます。 更新単価と事故1件の社会的費用を添えれば、「上位何%の管路まで手を入れるか」は今日の損益分岐 $\hat{\tau} > \text{費用}/\text{便益}$ と同じ計算です。効果の推定と予算の線引きを1枚の曲線で会話できるのは、合意形成の道具として強力です。
- 評価用の対照を残す設計を最初に言う。 Qiniを描くには、スコア上位でも配らなかった(調査しなかった)比較対象が要ります。パイロット事業の段階で一部区画を無作為に据え置く設計を入れておかないと、施策の増分は永久に測れなくなります。Day 4 の設計の話は、モデル運用の評価にまで尾を引きます。
参考(本棚)
- Matheus Facure『Causal Inference in Python』第6章 — 効果の異質性を「誰に処置するか」の意思決定へつなぐ視点。今日の下敷き
- Robert Osazuwa Ness『因果AI』(共立出版) — ML×因果の見取り図。メタラーナーとアップリフトの位置づけの整理
- Radcliffe, N. J. (2007) "Using Control Groups to Target on Predicted Lift." Direct Marketing Analytics Journal — Qini曲線・Qini係数の原典。縦軸のスケーリング(処置群換算)の定義もここ
- Gutierrez & Gérardy (2017) "Causal Inference and Uplift Modelling: A Review of the Literature." PMLR 67 — アップリフト手法の系統的なレビュー。4象限の呼び名はマーケティング実務の慣用
- 図はすべて合成データです。4象限の構成比(20/30/40/10)と各象限の効果(+1/0/0/−1)を私たちが握ることで、反応率と効果の乖離を機構ごと再現しました
次回予告(Day 27)
Phase 4 が終わり、これで道具は出そろいました。設計で識別するRCT・傾向スコア・DAGと調整・デザイン系の準実験・MLで異質効果、と26日分の武器があります。でも道具が増えるほど、間違った使い方の組み合わせも増えます。次回Day 27は、分析を世に出す前の落とし穴チェックリストです。設計・調整・推定・解釈の4カテゴリ、15の問いに落とし込み、各項目に「破ると何が起きるか」とこの連載の該当Dayを添えます。総索引を兼ねた総点検の回です。


