因果×機械学習 — 「誰に効くか」を推定する(CATE・uplift)
平均効果の次は異質性です。CATE・メタラーナー・Double ML、そして「打つと効く相手」を当てるuplift modelingまで。架空のD2C企業SOLNAのholdout配信ログを使い、配信を効く人へ寄せる流れをコードで実装します。
by Shin
「このキャンペーンは効いた」の次に来る問いは、「では、誰に効いたのか」です。平均効果(ATE)がプラスでも、全員に効いているとは限りません。強く効く人、まったく効かない人、送ると逆効果の人が同じ配信に混ざっています。この回では、その異質性を (CATE)として推定し、メタラーナー・Double ML・upliftで「打つと効く相手」に配信を寄せるところまでを、過去の受託案件をもとに設計した架空のD2C企業SOLNAの holdout(配信を止めて残した対照群)つき配信ログで実装します。
この回の到達目標
- ATE(平均処置効果)とCATE(条件付き平均処置効果 )の違いを、式とコードの両方で説明できる。
- メタラーナー(S / T / X-Learner)とDouble ML・Doubly Robustが「何をして」CATEを取りに行くのか、最小構成で実装できる。
- uplift modelingがランダム化配信(holdout)を前提とする理由を、評価まで含めて言える。
- 予測モデル(買いそうな人)とupliftモデル(送ると効く人)が別物であることを、順位の食い違いとして実装で体感できる。
- 因果探索(LiNGAM)が連載のどこに位置づくか、入り口だけ掴む。
前提(前回までで身についていること)
第4回で因果の言語(潜在結果・ATE・DAG・バックドア基準)を、第5回で観測データの道具箱(回帰調整・傾向スコア・DiD・RDD)を扱いました。本回はその延長線上にあります。決定的に違うのは、第5回までが「平均としての効果」を取りに行っていたのに対し、この回は「効果のばらつき(異質性)」そのものを推定対象にすることです。
また第3回で「予測 ≠ 因果」の伏線を置きました。この回はその伏線を回収します。予測モデルは「Yがどうなるか」を当て、upliftモデルは「介入でYがどれだけ変わるか」を当てます。狙う相手がずれる、という実害をコードで見ます。
平均では見えない異質性 — ATEからCATEへ
第4回でATEをこう定義しました。処置を 、結果を 、潜在結果を として、
これは「全員に同じだけ効く」という意味ではなく、「効果を全員で平均した値」にすぎません。効果が人によって違う(異質性がある)とき、平均の裏で「強く効く層」と「効かない層」が打ち消し合っています。これを共変量 で条件づけたものがCATEです。
が手に入れば、配信を「効く人」に絞れます。問題は、 と は同一人物で同時に観測できない(反実仮想)ことです。ここでholdout配信が効いてきます。SOLNAでは messages.holdout_flag で配信対象の一部をランダムに「送らない群」として確保しています。ランダム割付なので処置と交絡が独立になり、第5回のような交絡調整を経ずに を直接取りに行けます。これは取引件数が少なく単一市場のB2Bには無い贅沢です(後述)。
SOLNAの配信ログを学習テーブルにする
最初の山場はモデルではなくデータ整形です。upliftの学習テーブルは、各顧客について「処置(送ったか)」「結果(再購入したか)」「特徴量(配信時点までの顧客状態)」の3点セットで作ります。落とし穴は2つ、リークと未成熟コホートです。
-- SOLNA: uplift 学習テーブルの作成(winbackキャンペーン1本分)
-- 原則: 特徴量は「配信時点(sent_at)まで」の情報だけで作る(リーク防止)
WITH sent AS (
SELECT
m.customer_id,
m.sent_at,
-- holdout_flag = TRUE が「送らなかった対照群」。処置 = 送った群。
IF(m.holdout_flag, 0, 1) AS treated
FROM `solna.messages` AS m
WHERE m.campaign = 'winback_2026q2'
),
features AS ( -- 配信時点までの顧客状態(as-of で取る)
SELECT
s.customer_id,
c.age_band,
c.region,
c.membership_tier,
c.ltv_to_date, -- 本来は sent_at 時点のスナップショットを使う
DATE_DIFF(DATE(s.sent_at), DATE(c.first_order_at), DAY) AS days_since_first,
-- 直近90日の行動は「配信前」のみ集計する
COUNTIF(o.order_at < s.sent_at
AND o.order_at >= TIMESTAMP_SUB(s.sent_at, INTERVAL 90 DAY)) AS orders_90d,
COUNTIF(se.session_start < s.sent_at
AND se.session_start >= TIMESTAMP_SUB(s.sent_at, INTERVAL 90 DAY)) AS sessions_90d
FROM sent AS s
JOIN `solna.customers` AS c USING (customer_id)
LEFT JOIN `solna.orders` AS o USING (customer_id)
LEFT JOIN `solna.sessions` AS se USING (customer_id)
GROUP BY 1, 2, 3, 4, 5, 6
),
outcome AS ( -- 結果: 配信後14日以内の再購入(窓は固定)
SELECT
s.customer_id,
MAX(IF(o.order_at > s.sent_at
AND o.order_at <= TIMESTAMP_ADD(s.sent_at, INTERVAL 14 DAY)
AND o.order_type IN ('repeat', 'subscription'), 1, 0)) AS y
FROM sent AS s
LEFT JOIN `solna.orders` AS o USING (customer_id)
GROUP BY 1
)
SELECT f.*, s.treated, o.y
FROM features f
JOIN sent s USING (customer_id)
JOIN outcome o USING (customer_id)
-- 未成熟コホート(配信から14日経っていない人)は除外する
WHERE DATE(s.sent_at) <= DATE_SUB(CURRENT_DATE(), INTERVAL 14 DAY);メタラーナー(S / T / X-Learner)
CATEを推定する最も素直な道が、既存の予測器(LightGBM等)を流用するメタラーナーです。考え方の差だけ押さえれば実装は短いです。S-Learnerは処置フラグを特徴量に混ぜた1モデル、T-Learnerはアームごとに2モデル、X-Learnerは群サイズが偏るとき(holdoutが小さいときなど)に効く改良版です。
import pandas as pd, numpy as np
from lightgbm import LGBMClassifier
df = pd.read_gbq("SELECT * FROM solna.uplift_winback_2026q2") # 上のSQL結果
X_cols = ["age_band", "region", "membership_tier", "ltv_to_date",
"days_since_first", "orders_90d", "sessions_90d"]
X = pd.get_dummies(df[X_cols], columns=["age_band", "region", "membership_tier"])
t = df["treated"].values # 1=送った, 0=holdout(対照)
y = df["y"].values # 1=14日以内に再購入
# --- S-Learner: 処置フラグを特徴量に混ぜた1モデル ---
Xs = X.assign(treated=t)
s_model = LGBMClassifier().fit(Xs, y)
def cate_s(Xnew):
p1 = s_model.predict_proba(Xnew.assign(treated=1))[:, 1]
p0 = s_model.predict_proba(Xnew.assign(treated=0))[:, 1]
return p1 - p0 # tau_hat(x)
# --- T-Learner: アーム別に2モデル ---
m1 = LGBMClassifier().fit(X[t == 1], y[t == 1]) # 送った群
m0 = LGBMClassifier().fit(X[t == 0], y[t == 0]) # holdout群
tau_t = m1.predict_proba(X)[:, 1] - m0.predict_proba(X)[:, 1]
# ATE の素朴な確認: ランダム化配信なので単純差で不偏になる
ate = y[t == 1].mean() - y[t == 0].mean()
print(f"ATE(再購入率の差) = {ate:.3f}") # SOLNA例: 0.02〜0.04 程度ここでの教訓は、ランダム化があるとATEが「送った群と対照群の単純差」で不偏に取れる、という点です(SOLNA例で再購入率の差は 0.02〜0.04 程度、つまり数ポイントの底上げ)。第5回で苦労した交絡調整が要らないのは、holdoutの恩恵です。一方でメタラーナーには弱点があり、S-Learnerは処置フラグが他の強い特徴量に埋もれて効果を見落としがち、T-Learnerは2モデルの誤差が引き算で増幅されがちです。そこで直交化を使うのが次の道具です。
Doubly Robust と Double Machine Learning
Double Machine Learning(DML)は、結果モデルと処置モデル(傾向スコア)を別々に当てて「直交化」し、交差適合(cross-fitting)で過学習を抑えてからCATEを推定します。Doubly Robust(DR)は、結果モデルか傾向モデルのどちらか一方が正しければ一致推定になる、という保険付きの推定量です。SOLNAはランダム化配信なので傾向は既知(送信率)ですが、観測補正の作法として明示的に置いておくと、後でholdoutが崩れた配信にも転用できます。
from econml.dml import CausalForestDML
from econml.dr import DRLearner
from sklearn.ensemble import GradientBoostingRegressor, GradientBoostingClassifier
# Double ML: 結果モデルと傾向モデルを直交化し、cross-fitting でCATEを推定
cf = CausalForestDML(
model_y=GradientBoostingRegressor(),
model_t=GradientBoostingClassifier(),
discrete_treatment=True,
cv=5, # cross-fitting でリーク・過学習を抑える
random_state=0,
)
cf.fit(Y=y, T=t, X=X)
tau_hat = cf.effect(X) # 各顧客の推定 uplift tau_hat(x)
lb, ub = cf.effect_interval(X) # 信頼区間(個人レベルは広い点に注意)
# Doubly Robust: 結果モデル・傾向モデルのどちらかが正しければ一致推定
dr = DRLearner().fit(Y=y, T=t, X=X)uplift modeling — ランダム化配信が前提
ここまでの をビジネスに接続するのがuplift modelingです。本質は「送ると効く相手」を上位に並べ、配信を絞ること。評価にはQini曲線(uplift曲線)を使います。 の降順に並べ、上位から送ったときに増分CV(処置群と対照群の差)がどれだけ積み上がるかを見ます。重要なのは、この評価にもランダム化されたholdoutが必要なことです。観測配信ログだけではuplift自体を評価できません。
import numpy as np, pandas as pd
# Qini / uplift 曲線: tau_hat 降順に並べ、上位から送ると増分CVがどれだけ積むか
def qini_points(tau, t, y, bins=20):
order = np.argsort(-tau) # uplift 高い順
t, y = t[order], y[order]
rows = []
for k in np.linspace(0, 1, bins + 1)[1:]:
n = int(len(t) * k)
ts, ys = t[:n], y[:n]
nt, nc = ts.sum(), (1 - ts).sum()
# 上位n人を送った場合の増分CV(規模補正した処置群CV - 対照群CV)
gain = ys[ts == 1].sum() - ys[ts == 0].sum() * (nt / max(nc, 1))
rows.append((k, gain))
return pd.DataFrame(rows, columns=["frac_targeted", "incremental_conv"])
q = qini_points(tau_hat, t, y)
# SOLNA例: 上位30%への配信で、全配信の増分CVの7〜8割を回収(配信量は約1/3)
# ターゲティング方針: tau_hat がプラスの相手にだけ送り、負の相手は外す
send_to = tau_hat > 0
print(f"配信対象: {send_to.mean():.0%} / 除外: {(~send_to).mean():.0%}")upliftの視点で顧客は4象限に分かれます。説得可能層(送ると買う)、確実層(送らなくても買う)、無反応層(送っても買わない)、そして寝た子を起こす層(送ると逆効果=負のuplift。解約・配信停止を誘発)です。狙うのは説得可能層だけ。SOLNA例では、下位の 10〜15% 前後に負のuplift層が現れることがあり、ここを除外するだけで配信コストと離反リスクを同時に下げられます。「全員に送る」が最適でないのは、確実層への配信が予算を溶かし、寝た子層への配信が害になるからです。
予測モデルとの違いを実装で体感
第3回の伏線「予測 ≠ 因果」を回収します。予測モデルは「放っておいても買う確率」を当て、upliftは「送ると変わる量」を当てます。両者の順位は一致しません。確実層(買いそうな人)はP(購入)が高いのにupliftは低い——送る価値がないのに、予測順位だと最上位に来てしまいます。
# 「買いそうな人」の順位 と 「送ると効く人」の順位は一致しない
p_convert = m0.predict_proba(X)[:, 1] # 送らなくても買う確率(baseline)
rank_pred = pd.Series(p_convert).rank(ascending=False)
rank_uplift = pd.Series(tau_hat).rank(ascending=False)
corr = rank_pred.corr(rank_uplift, method="spearman")
print(f"順位相関(予測 vs uplift) = {corr:.2f}") # しばしば低い〜負になる
# 予測順位で配信すると "確実層" に予算を溶かす = 予測≠因果 の実害順位相関がしばしば低い(時に負)になるのが、この回でいちばん体に入れてほしい事実です。CVR予測モデルを作って「スコア上位に送る」のは、直感的だが因果的には誤り。動かすべきは「baseline購入確率」ではなく「介入による増分」です。
因果探索(LiNGAM)の入り口
ここまでは「処置はこれ」と決まっていました。では、何が原因で何が結果かをデータから探したい場合はどうするか。それが因果探索です。線形・非ガウス・非循環という仮定の下でDAGの向きまで推定するLiNGAMが代表で、lingam パッケージで動かせます。
import lingam
model = lingam.DirectLiNGAM()
model.fit(X_numeric) # 数値特徴量の行列
print(model.adjacency_matrix_) # 推定された因果の向き(係数つき隣接行列)ただし因果探索は仮定への依存が強く、結果は「探索の出発点となる仮説」として扱うのが安全です。本連載では入り口の紹介に留め、深掘りは Molak『Pythonライブラリによる因果推論・因果探索』に送ります。位置づけとしては、第4回でDAGを「人が描いた」のに対し、因果探索は「データから候補を出す」逆方向のアプローチ、と覚えておけば十分です。
つまずきポイント(まとめ)
- リーク: 特徴量は必ず
sent_atより前。配信後の挙動を混ぜると効果が水増しされる。 - 未成熟コホート: 結果の窓を満たさない直近配信を学習・評価に入れない。
- holdoutの死守: ランダム化が崩れると、CATEは交絡まみれになり、Qini評価もできなくなる。
- 個人での断定: CATEの個人区間は広い。ポリシー(上位N%)やセグメントで使う。
- 計測方式の不連続: 配信タグやトラッキングを途中で変えた期間は、変更自体が交絡になる。
- 予測との混同: 「買いそうな人」に送るのは因果的に誤り。「送ると変わる人」に送る。
対応書籍と読みどころ
- 施策デザインのための機械学習入門(ホクソエム): 本回の中心に据えた一冊。uplift modelingと介入設計を、評価(Qini)まで含めて実務目線で扱う一冊です。この回の「説得可能層を狙う」「holdoutを設計に組み込む」という発想の土台になります。
- 金本『因果推論』後半: 理論の軸に据えた一冊の後半。CATE・メタラーナー・Double ML・Doubly Robustの理論的な接続を、第4〜5回の識別の議論から地続きで追えます。
- Molak『Pythonライブラリによる因果推論・因果探索』: DoWhy・EconML・CausalMLといった本回のコードと同じライブラリ群を横断的に扱い、因果探索まで広くカバーします。実装で詰まったときの参照に向きます。
フレームとの接続
この回は診断フレームでいうユーザー単位の手法にあたり、成熟度のステップでは因果段階の上端です。第4回で因果のゲート(識別性)をくぐり、第5回で平均効果を取り、この回で「単位=個人」まで因果を降ろしました。軸としては「比較可能な単位」と並んで「ランダム化できる面があるか」が発動条件で、SOLNAは messages.holdout_flag という面を持っているからこの段に到達できます。次の第7回は集計レベル(時系列・MMM)へ視点を上げ、フレーム上段の素養に移ります。
章末チェックリスト
- ATE(平均)とCATE (条件付き)の違いを式で説明できるか
- 学習テーブルの特徴量を、すべて
sent_at以前で作ったか(リーク防止) - 結果の窓を固定し、未成熟コホートを除外したか
- ランダム化されたholdoutを、推定と評価の両方に確保しているか
- メタラーナーとDML/DRの違い(直交化・cross-fitting)を一言で言えるか
- Qiniで上位何割に絞るとどれだけ回収できるかを見たか
- 負のuplift層(寝た子を起こす層)を配信から外したか
- 予測順位とuplift順位の食い違いを確認したか(予測 ≠ 因果)
ここまでは「誰に効くかをどう実装するか(HOW)」でした。自社のデータ構造でuplift・CATEがそもそも成立するか(WHAT/WHY)の判断は、対になる因果編で扱います。SOLNAはholdout付き配信という「ランダム化できる面」を持っているのでupliftが成立しますが、取引件数が少なく単一市場のB2B(例: オフィス仲介)はランダム化配信面そのものが無く、upliftは原理的に発動しません。同じ手法でも成立条件が逆になる典型です。土台となる「何を測るか」が曖昧なら記述・予測編へ戻ってください。