前回は、クーポン施策の「本当の効果」が 413円 だったことを、答えを仕込んだサンプルデータで確かめました。単純比較の2,268円も、会員ランクで層別した594円も、正解には届きませんでした。
では、正解を知らない実務のデータで、どうやってその413円に迫るのか。今回いよいよEconMLを動かします。
結論を先に3行で書きます。
LinearDMLを使うと、実質7行のコードで 422円(95%信頼区間 350〜493円) という推定値が出ます。正解の413円を区間の中に捉えています。- 仕組みは単純です。YからもTからも「交絡で説明できる分」を先に引き、残った“素のゆらぎ”同士を比べるだけです。
- ただし引き算のやり方に一手間あります。交差適合(cross-fitting)を省くと、推定値は決まった向きに偏ります。
この記事を読み終えると、次のことができるようになります。
LinearDMLで平均的な効果(ATE)を推定し、信頼区間まで報告できる- 推定値を「1件あたり何円の粗利か」という意思決定の言葉に翻訳できる
- 二重機械学習が裏で何をしているかを、人に説明できる
- 交差適合を省いてはいけない理由を、数字で示せる
データは前回と同じ ec_coupon.csv(5,000人分)を使います。以下からダウンロードできます。
| 列名 | 意味 | 役割 |
|---|---|---|
member_rank |
会員ランク(ブロンズ/シルバー/ゴールド) | 交絡しそうな変数 |
channel |
流入経路(自然検索/広告/メルマガ) | 交絡しそうな変数 |
tenure_months |
登録からの経過月数 | 交絡しそうな変数 |
past_freq |
過去1年の購入回数 | 効果を左右しそうな変数 |
days_since_last |
最終購入からの休眠日数 | 効果を左右しそうな変数 |
coupon |
クーポンを配ったか(0 / 1) | 施策 |
spend_30d |
その後30日間の購入金額 | 成果 |
_true_cate |
その人に対する本当の効果 | 答え合わせ用 |
まず、動かしてみる
説明は後回しにして、先に結果を出します。
以下、コードです。
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.ensemble import GradientBoostingClassifier
from econml.dml import LinearDML
df = pd.read_csv("ec_coupon.csv")
Y = df["spend_30d"] # 成果
T = df["coupon"] # 施策
X = df[["past_freq", "days_since_last"]] # 効果を左右しそうな変数
W = pd.get_dummies(df[["member_rank", "channel"]], drop_first=True)
W["tenure_months"] = df["tenure_months"] # 交絡しそうな変数
est = LinearDML(
model_y=GradientBoostingRegressor(random_state=0),
model_t=GradientBoostingClassifier(random_state=0),
discrete_treatment=True, cv=5, random_state=0,
)
est.fit(Y, T, X=X, W=W)
print(f"推定ATE: {est.ate(X):.0f}円")
print(
"95%信頼区間:",
[round(v) for v in est.ate_interval(X, alpha=0.05)]
)
以下、実行結果です。
推定ATE: 422円 95%信頼区間: [350, 493]
422円。前回明かした正解は413円でしたから、9円のずれで捉えられました。信頼区間 350〜493円も、正解をしっかり含んでいます。
単純比較の2,268円、層別の594円と比べてください。同じデータから、桁違いに正しい数字が出ています。
コードの読みどころは4つだけです。
| 書き方 | 意味 |
|---|---|
model_y |
Yを予測するモデル。ここでは購入金額を予測する |
model_t |
Tを予測するモデル。「この人にクーポンが配られやすかったか」を予測する |
discrete_treatment=True |
施策が0/1であることを伝える。忘れやすい |
cv=5 |
交差適合の分割数。この記事の後半のテーマ |
X と W の使い分けは、この連載で最も重要なところです。第4回で1本まるごと使って扱うので、今回は「効果を左右しそうな変数は X、交絡しそうな変数は W」とだけ覚えてください。
この数字を、報告書の言葉に直す
422円のままでは、まだ意思決定に使えません。クーポン1枚の原価が100円だとしましょう。
以下、コードです。
cost = 100
lo, hi = est.ate_interval(X, alpha=0.05)
print(f"1枚あたりの粗利: {est.ate(X) - cost:.0f}円")
print(
f"10万枚配ったときの利益: {(est.ate(X) - cost) * 100000 / 10000:,.0f}万円 "
f"(悪めに見て {(lo - cost) * 100000 / 10000:,.0f}万円)")
以下、実行結果です。
1枚あたりの粗利: 322円 10万枚配ったときの利益: 3,216万円 (悪めに見て 2,500万円)
ここまで来ると、会議で使える形になります。
クーポン1枚あたり322円の粗利。10万枚配れば3,200万円ほど、悪めに見積もっても2,500万円は残る。
信頼区間の下限を使った「悪めに見て」の数字を必ず添えてください。点推定だけを報告するのは、天気予報で降水確率を言わずに「晴れです」と言い切るようなものです。
裏で何が起きているのか
ここからが今回の本題です。LinearDML の DML は Double Machine Learning、日本語で二重機械学習といいます。「二重」は、機械学習モデルを2つ使うことから来ています。
やっていることは、拍子抜けするほど単純です。
まず、購入金額 Y のうち「会員ランクや流入経路で説明できる分」を機械学習で予測し、実際の値から引きます。残ったものが Yの残差 です。次に、クーポンが配られたかどうか T についても同じことをします。「この人は配られやすい人だったか」を予測して引くと、Tの残差 が残ります。
そして、この2つの残差だけを回帰します。数式にすると1行です。
Y − E[Y|W] = θ × ( T − E[T|W] ) + 誤差
日本語に直すと「Yの残差 = θ × Tの残差」。この θ が求めたい効果です。
身長と体重の関係を調べるとき、両方から「年齢で説明できる分」を先に取り除いてから比べますよね。あれとまったく同じ発想です。
残差を実際に見てみる
言葉だけだと掴みにくいので、実際のデータで描いてみます。
右のグラフで、横軸がTの残差、縦軸がYの残差です。この点群に引いた直線の傾きが、効果の推定値になります。
2つのかたまりに分かれているのはTが0か1しかないためです。左の灰色が配らなかった人、右のオレンジが配った人。ただし横位置はきれいに0と1ではなく、「配られやすさの予測」を引いたあとの位置にばらけています。もともと配られやすかった優良顧客は、配られていても中央寄りに来る。これが交絡を消している正体です。
縦方向のばらつきは大きいままですが、それで構いません。個人の購入金額を当てたいわけではなく、傾きだけが知りたいからです。
この最も単純な形(Xを使わず、全部Wに入れる)で傾きを計算すると357円になります。EconMLはここに
Xを加えて「属性ごとに効き方が違う」ことも織り込むので、422円まで正解に近づきます。仕組みの骨格はこの散布図がすべてで、Xはその精密化です。
なぜデータを分けるのか:交差適合
ひとつだけ、素直にやると失敗する箇所があります。
「Wで説明できる分を予測して引く」とき、予測モデルの学習に使ったデータで、そのまま予測してはいけません。自分が学習に使った人の値は、モデルが覚えてしまっているぶん当たりすぎます。すると残差が本来より小さくなり、傾きが歪みます。
そこでデータを5つに分け、4つで学習して残り1つを予測する、を5回繰り返します。これが交差適合(cross-fitting)で、cv=5 がその指定です。
どれくらい違うのか。同じ条件のデータセットを20個つくって、交差適合ありとなしで比べてみました。
交差適合ありは平均で +3円 のずれ。20回のうち9回が正解より小さく、11回が大きい。つまりずれる向きがばらばらで、これは単なる誤差です。
いっぽう交差適合なしは平均 −57円。しかも20回中19回で正解より小さく出ました。これはばらつきではなく、決まった向きの偏りです。データを増やしても消えません。
cv の既定値は2なので、指定しなくても交差適合自体は効いています。ただ2分割では学習に使えるデータが半分になるため、実務では5前後にしておくのが無難です。
ありがちな失敗:Wを渡し忘れる
fit() の引数は多く、W は省略してもエラーになりません。試しに外してみます。
以下、コードです。
est_bad = LinearDML(
model_y=GradientBoostingRegressor(random_state=0),
model_t=GradientBoostingClassifier(random_state=0),
discrete_treatment=True, cv=5, random_state=0,
)
est_bad.fit(Y, T, X=X) # W を渡していない
print(f"推定ATE: {est_bad.ate(X):.0f}円")
print("95%信頼区間:", [round(v) for v in est_bad.ate_interval(X, alpha=0.05)])
以下、実行結果です。
推定ATE: 932円 95%信頼区間: [846, 1019]
932円。正解413円の2倍以上です。しかも信頼区間は846〜1019円と狭く、正解を含んでいません。
ここが怖いところです。間違った推定値ほど、狭い区間で自信ありげに出てくることがあります。区間が狭いことは正しさの証明になりません。コードが動いたかどうかではなく、交絡になりうる変数を W に入れ切れたかを毎回確認してください。
まとめ
今回のポイントです。
LinearDMLで推定した結果は422円、95%信頼区間 350〜493円。正解413円を捉えた- 仕組みは「YからもTからもWで説明できる分を引き、残差同士を回帰する」だけ
- 交差適合を省くと、ばらつきではなく決まった向きの偏りが出る(20回中19回で過小)
- 点推定だけでなく信頼区間を必ず報告する。1枚322円の粗利、悪めに見て2,500万円、という形まで翻訳する
Wの入れ忘れは、狭い区間の間違った数字を生む

