EconMLで実践する ビジネス因果推論
(第3回)
平均の裏に隠れた「誰に効くか」を取り出す
(ATEからCATEへ)

EconMLで実践する ビジネス因果推論(第3回)平均の裏に隠れた「誰に効くか」を取り出す(ATEからCATEへ)

前回、クーポンの効果は 平均422円 と推定できました。


ここで一つ考えてみてください。

「クーポンは全員に422円ずつ効く」のと「効く人は800円、効かない人は0円で、ならすと422円」のとでは、施策の打ち方がまったく変わります。

今回は、この 平均の裏側 にある、個々人の効果を取り出します。

結論を先に3行で書きます。

  1. est.effect(X) と書くだけで、5,000人それぞれの効果が出ます。分布は −820円から +1,437円まで広がり、6.8%の人にはマイナスです。
  2. 効くのは「たまにしか買わず、しばらく来ていない人」。効かないのは「ヘビーユーザー」です。
  3. そして実際の配布は、この真逆でした。最も効く層には8%しか配られておらず、最も効かない層には81%配られていました。

この記事を読み終えると、次のことができるようになります。

  • ATE・CATE・ITE の3つの「効果」を区別して説明できる
  • est.effect(X) で一人ひとりの効果を取り出し、分布として眺められる
  • 「誰に効くか」を属性の組み合わせで表にできる
  • CATEを個人の予測値のように扱ってはいけない理由を、信頼区間で示せる

準備:前回と同じ推定器を作る

データも推定器も前回とまったく同じです。

データはec_coupon.csv(5,000人分)を使います。以下からダウンロードできます。

ec_coupon.csv

前回のノートブックを開いたままの方は、この部分は飛ばしてください。

以下、コードです。

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)

 

まず、動かしてみる:一人ひとりの効果を取り出す

前回は est.ate(X) で平均を出しました。

今回は ateeffect に変えるだけです。これだけで、一人ひとりの効果のようなものを取り出せます。

以下、コードです。

tau = est.effect(X)          # 5,000人ぶんの効果が配列で返る
df["cate"] = tau

print(
    f"平均: {tau.mean():.0f}円  "
    f"最小: {tau.min():.0f}円  最大: {tau.max():.0f}円")
print(f"効果がマイナスの人: {(tau < 0).mean():.1%}")
print(f"原価100円を下回る人: {(tau < 100).mean():.1%}")

 

以下、実行結果です。

平均: 422円  最小: -820円  最大: 1437円
効果がマイナスの人: 6.8%
原価100円を下回る人: 12.8%

 

平均は前回と同じ422円ですが、最小と最大の差は2,000円以上あります。分布を描いてみます。

推定された一人ひとりの効果の分布LinearDMLで推定した5,000人分の効果のヒストグラム。平均は422円だが、マイナスの人が6.8%、原価100円を下回る人が12.8%いる。5,000人それぞれの効果は、これだけ散らばっているLinearDML で推定した一人ひとりの効果(CATE)の分布効果ゼロ平均 422円-500円0円+500円+1,000円推定された効果 →マイナス(逆効果)0〜100円(原価割れ)100円超「平均422円」の中身は、−820円から+1,437円まで。12.8%の人は原価100円を下回る

山の中心は400〜500円あたりにありますが、左には裾が長く伸びています。原価100円を下回る人が12.8%、8人に1人です。この人たちに配った分は、そのまま損失になります。

 

ATE・CATE・ITE:3つの「効果」

ここで用語を整理しておきます。健康診断で考えると分かりやすいです。

用語 意味 健康診断で言うと EconMLでの出し方
ATE
平均処置効果
全員をならした効果 受診者全体の平均値 est.ate(X)
CATE
条件付き平均処置効果
属性が同じ人たちの平均的な効果 「40代男性」の平均値 est.effect(X)
ITE
個別処置効果
その人一人だけの効果 あなた自身の数値 原理的に推定できない

大事なのは3行目です。第1回で見たとおり、一人の人について「配った場合」と「配らなかった場合」を両方観測することはできません。だから ITE(その人一人の効果)は、どんな手法を使っても直接は測れないのです。

その代わりに測れるのが CATE です。「購入頻度3回・休眠45日の人たち」のように属性で条件を絞り、その中での平均的な効果を出す。est.effect(X) が返しているのは、一人ひとりの X に対応した CATE です。

つまり effect(X) の出力は「その人の効果」ではなく、「その人と同じ属性を持つ人たちの、平均的な効果」です。この違いは記事の後半でもう一度効いてきます。

 

誰に効くのか

X に入れた2つの変数、購入頻度と休眠日数で切って、効果の平均を並べてみます。

以下、コードです。

freq = pd.cut(
    df["past_freq"], [0, 2, 4, 6, 9, 100],
    labels=["1-2回", "3-4回", "5-6回", "7-9回", "10回以上"]
)
days = pd.cut(
    df["days_since_last"], [0, 14, 30, 60, 120, 400],
    labels=["〜14日", "15-30日", "31-60日", "61-120日", "121日〜"]
)
print(
    df.pivot_table(
        index=freq, 
        columns=days, 
        values="cate", 
        aggfunc="mean"
    ).round(0)
)

 

以下、実行結果です。

days_since_last   〜14日  15-30日  31-60日  61-120日  121日〜
past_freq
1-2回             525.0   567.0   632.0    754.0  971.0
3-4回             379.0   419.0   482.0    593.0  808.0
5-6回             217.0   251.0   316.0    422.0  622.0
7-9回              20.0    59.0   120.0    235.0    NaN
10回以上           -270.0  -218.0  -150.0    -30.0    NaN

 

購入頻度と休眠日数で見た効果の違い購入頻度が低く休眠日数が長い人ほど効果が大きく(最大+971円)、購入頻度が高く休眠が短いヘビーユーザーでは効果がマイナス(最小-270円)になる。誰に効くのか:購入頻度 × 休眠日数セルの数字は、その属性の人に対する平均的な効果(推定CATE、円)最終購入からの休眠日数 →〜14日15-30日31-60日61-120日121日〜過去1年の購入回数 ↓1-2回+525円+567円+632円+754円+971円3-4回+379円+419円+482円+593円+808円5-6回+217円+251円+316円+422円(3人)7-9回+20円+59円+120円+235円10回以上-270円-218円-150円(5人)逆効果よく効く0円灰色のセルは該当者が10人未満右上(たまにしか買わず、しばらく来ていない人)ほど効き、左下(ヘビーユーザー)は逆効果

きれいな階段になっています。右上ほど効き、左下ほど効かない。

右上は「年に1〜2回しか買わず、4か月以上来ていない人」で、効果は971円。左下は「年10回以上買っていて、2週間以内に来た人」で、効果は −270円です。

マイナスの意味を考えてみてください。ヘビーユーザーはクーポンがなくても買います。そこにクーポンを配ると、買う量は変わらず、値引きのぶんだけ購入金額が減る。つまり配らなければ得られていた売上を、自分で削っていることになります。

 

配り方が真逆だった

効果の上位10%と下位10%について、どんな人で、実際にどれくらい配られていたのかを見てみます。

以下、コードです。

top = df[df["cate"] >= df["cate"].quantile(0.9)]   # 効果の上位10%
bot = df[df["cate"] <= df["cate"].quantile(0.1)]   # 効果の下位10%

for name, g in [("上位10%", top), ("下位10%", bot)]:
    print(
        f"{name}: 効果 {g['cate'].mean():.0f}円 / "
        f"購入 {g['past_freq'].mean():.1f}回 / "
        f"休眠 {g['days_since_last'].mean():.0f}日 / "
        f"配布率 {g['coupon'].mean():.0%}"
    )

 

以下、実行結果です。

上位10%: 効果 846円 / 購入 1.5回 / 休眠 115日 / 配布率 8%
下位10%: 効果 -101円 / 購入 9.7回 / 休眠 21日 / 配布率 81%

 

最も効く層には8%しか配られておらず、最も効かない層には81%配られていました。

これは現場の判断としては自然です。「優良顧客を大事にしよう」と考えれば、ヘビーユーザーにクーポンを配ります。

しかし効果で見ると、配るべき相手と配っていた相手が、ほぼ裏返しになっていました。第1回で単純比較が2,268円と過大に出た原因も、まさにこの配り方にあります。

「では誰に配れば利益が最大になるか」は、原価と信頼区間を織り込んで別の記事で扱います。今回は「平均だけ見ていたら、この裏返しに気づけなかった」ことを押さえてください。

 

答え合わせ

このデータには正解の列 _true_cate があります。推定した5,000個の効果と、正解を突き合わせます。

以下、コードです。

print(f"推定と正解の相関: {df['cate'].corr(df['_true_cate']):.3f}")

 

以下、実行結果です。

推定と正解の相関: 0.994

 

推定した効果と真の効果の比較横軸に仕込んでおいた真の効果、縦軸にLinearDMLの推定値をとった散布図。点が45度線に沿って並び、相関は0.994。答え合わせ:推定した効果と、仕込んでおいた正解5,000人から10人おきに500人を抜き出して表示-500-50000+500+500+1,000+1,000正解(仕込んだ真の効果、円)→↑ 推定した効果(円)点線の上=正解ぴったり相関0.994点が斜めの線に沿っている= 効く人・効かない人の順序を  ほぼ正確に言い当てているただし実務データに正解の列はない= この図は学習用データだから描ける。  実務では第9回の検証手順で  信頼性を確かめる一人ひとりの効果の「順序」まで、機械学習ベースの推定はここまで捉えられる

相関0.994。「誰に効いて、誰に効かないか」の順序を、ほぼ正確に言い当てています。第1回の単純比較や層別では、そもそも一人ひとりの効果という発想すらありませんでした。

ただし、この図は正解を仕込んだ学習用データだから描けるものです。実務データに _true_cate はありません。実務での信頼性の確かめ方は、別の記事で扱います。

 

ありがちな失敗:CATEを個人の予測値として扱う

ここまで来ると、こう言いたくなります。「会員IDが1234のAさんは、効果が521円です」。

これは2つの意味で言い過ぎです。信頼区間を見てみます。

以下、コードです。

lo, hi = est.effect_interval(X, alpha=0.05)
df["lo"], df["hi"] = lo, hi

one = df[(df["past_freq"] == 3) & (df["days_since_last"] == 45)].iloc[0]

print(
    f"購入3回・休眠45日の人: 推定 {one['cate']:.0f}円  "
    f"95%区間 [{one['lo']:.0f}, {one['hi']:.0f}]"
)

 

以下、実行結果です。

購入3回・休眠45日の人: 推定 521円  95%区間 [435, 608]

 

1つ目。区間の幅は173円あります。5,000人分の区間幅の中央値は235円です。「521円」と言い切れるほどの精度はありません。

2つ目。先ほど整理したとおり、この521円は「購入3回・休眠45日という属性の人たちの平均的な効果」であって、Aさん個人の効果ではありません。同じ属性でも、Aさんには800円効き、Bさんには200円しか効かないかもしれない。それは原理的に分からないのです。

報告書にはこう書いてください。

購入3回・休眠45日前後の会員層では、クーポン1枚あたり平均500円程度の効果が見込める(95%信頼区間 435〜608円)。

主語を「Aさん」から「会員層」に変える。それだけで、言えることと言えないことの線が正しく引けます。

 

まとめ

今回のポイントです。

  • est.effect(X) で一人ひとりの効果(CATE)が出る。分布は −820〜1,437円、6.8%はマイナス、12.8%は原価割れ
  • ATE は全体の平均、CATE は属性が同じ人たちの平均、ITE はその人一人の効果。ITE は原理的に測れない
  • 効くのは「たまにしか買わず、しばらく来ていない人」、効かないのはヘビーユーザー。実際の配布は真逆だった
  • CATE は個人の予測値ではない。主語は「Aさん」ではなく「Aさんのような会員層」