EconMLで実践する ビジネス因果推論
(第6回)
エライ人に説明できる形にする
(SingleTreeCateInterpreterで因果フォレストを4行のルールに)

EconMLで実践する ビジネス因果推論(第6回)エライ人に説明できる形にする(SingleTreeCateInterpreterで因果フォレストを4行のルールに)

第5回の因果フォレストの結果を持って、エライ人に報告に行ったとします。

「効果は1人ひとり違っていて、1,000本の木(ディシジョンツリー)の平均で……」と言いかけたところで、こう返されます。「で、誰に配ればいいの?」

因果フォレストの出力は5,000人分の数字です。そのままでは会議に出せません。今回は因果フォレストの答えを、人が覚えられる数行のルールに落とします。

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

  1. SingleTreeCateInterpreter で因果フォレストの答えを深さ2の木(ディシジョンツリー)に要約すると、「休眠41日以上・購入5回以下なら +563円」のような4行のルールになりました。この4行で、因果フォレストが出した効果のばらつきの88%を再現しています。
  2. この木(ディシジョンツリー)は購入金額を当てているのではなく、因果フォレストが出した効果を当てています。葉ごとの信頼区間も因果フォレストから出ます。
  3. 深くすれば一致度は上がりますが、32行のルールは誰も覚えられません。地図の縮尺と同じで、覚えられる行数で止めます。

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

  • SingleTreeCateInterpreter で、因果フォレストの結果を数行のルールに要約できる
  • 葉ごとの効果と信頼区間を、「誰に・いくら」の日本語に翻訳できる
  • 要約の木(ディシジョンツリー)が「何を当てているのか」を、人に説明できる
  • 木(ディシジョンツリー)を何段まで切るかを、根拠をもって決められる

データは前回までと同じ ec_coupon.csv です。因果フォレストも第5回と同じものを使います。

以下からダウンロードできます。

ec_coupon.csv

まず、動かしてみる:因果フォレストを深さ2の木(ディシジョンツリー)に要約する

前半は第5回のコードそのままです。最後の3行が今回の追加です。

以下、コードです。

import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.ensemble import GradientBoostingClassifier
from econml.dml import CausalForestDML
from econml.cate_interpreter import SingleTreeCateInterpreter

df = pd.read_csv("ec_coupon.csv")
Y = df["spend_30d"]
T = df["coupon"]
X = pd.DataFrame({
    "past_freq":
        df["past_freq"],
    "days_since_last":
        df["days_since_last"],
    "member_rank":
        df["member_rank"].map({"ブロンズ": 0, "シルバー": 1, "ゴールド": 2}),
    "channel":
        df["channel"].map({"自然検索": 0, "広告": 1, "メルマガ": 2}),
    "tenure_months":
        df["tenure_months"],
})

# 前回と同じ因果フォレスト
est = CausalForestDML(
    model_y=GradientBoostingRegressor(random_state=0),
    model_t=GradientBoostingClassifier(random_state=0),
    discrete_treatment=True, n_estimators=1000, min_samples_leaf=100,
    cv=5, random_state=0
)
est.fit(Y, T, X=X)

# 因果フォレストの答えを、深さ2の木(ディシジョンツリー)に要約する
intr = SingleTreeCateInterpreter(
    max_depth=2, min_samples_leaf=200,
    include_model_uncertainty=True, random_state=0
)
intr.interpret(est, X)

 

max_depth=2 は「2回まで分ける」。葉(枝の末端)は最大4枚、つまりルールは最大4行です。

min_samples_leaf=200 は「葉に最低200人は残す」。include_model_uncertainty=True で、葉ごとに信頼区間が付きます。

 

できた木(ディシジョンツリー)は scikit-learn の木(ディシジョンツリー)なので、export_text で文字にできます。

以下、コードです。

from sklearn.tree import export_text
print(
    export_text(
        intr.tree_model_, 
        feature_names=list(X.columns), 
        decimals=1
    )
)

 

以下、実行結果です。

|--- days_since_last <= 40.5
|   |--- past_freq <= 6.5
|   |   |--- value: [333.6]
|   |--- past_freq >  6.5
|   |   |--- value: [91.7]
|--- days_since_last >  40.5
|   |--- past_freq <= 5.5
|   |   |--- value: [563.4]
|   |--- past_freq >  5.5
|   |   |--- value: [295.3]

 

上から読みます。

最終購入からの休眠日数(days_since_last)が40日以下で、購入回数(past_freq)が6回以下なら効果は334円。同じ休眠40日以下でも、購入7回以上なら92円。

休眠41日以上なら、購入5回以下で563円、6回以上で295円。

5変数を渡したのに、木(ディシジョンツリー)が使ったのは休眠日数と購入回数の2つだけでした。第5回の重要度と同じ顔ぶれです。

木(ディシジョンツリー)の絵が欲しければ intr.plot() で描けます。feature_names=list(X.columns) を渡すと分岐に列名が入ります(matplotlib が必要です)。

 

出力を日本語にする

上の出力を、人に見せる形に描き直したのが以下です。葉ごとに人数・効果・95%区間を添え、いまの配布率も並べました。

因果フォレストの結果を深さ2の木(ディシジョンツリー)で要約した4行のルール根は全員5,000人・平均+381円。休眠日数40日以下か、購入回数6回(右枝は5回)以下かで4つの葉に分かれる。休眠40日以下・購入6回以下は+334円で配る、休眠40日以下・購入7回以上は+92円で配らない、休眠41日以上・購入5回以下は+563円で配る、休眠41日以上・購入6回以上は+295円だが区間下限49円で保留。因果フォレストの答えを、4行のルールにする:役員会の1枚CausalForestDML の結果を SingleTreeCateInterpreter(深さ2)で要約。効果は1人あたりの30日購入金額の増分全員 5,000人平均の効果 +381円休眠日数は 40日以下?はいいいえ休眠 40日以下 2,878人平均 +270円購入回数は 6回以下?はいいいえ休眠 41日以上 2,122人平均 +531円購入回数は 5回以下?はいいいえ休眠 40日以下購入 6回以下2,121人・配布率 24%+334円95%区間 [+201, +466]配る休眠 40日以下購入 7回以上757人・配布率 75%+92円95%区間 [-114, +297]配らない休眠 41日以上購入 5回以下1,866人・配布率 16%+563円95%区間 [+400, +726]配る休眠 41日以上購入 6回以上256人・配布率 56%+295円95%区間 [+49, +541]保留(小さく試す)配布率:いまクーポンを受け取っている人の割合判断の目安:原価100円と比べ、区間の下限まで超えたら「配る」いまの配布率は、いちばん効く層で16%、効かない層で75%。配り方がほぼ逆になっている

葉ごとの数字は、次のコードで表にできます。apply で「各顧客がどの葉に入ったか」が取れ、区間は node_dict_ に入っています。

以下、コードです。

df["effect"] = est.effect(X)
df["leaf"] = intr.tree_model_.apply(X)

tbl = df.groupby("leaf").agg(
    人数=("leaf", "size"),
    休眠日数=("days_since_last", lambda s: f"{s.min()}-{s.max()}"),
    購入回数=("past_freq", lambda s: f"{s.min()}-{s.max()}"),
    効果=("effect", "mean"),
    正解=("_true_cate", "mean"),
).round(0)

tbl["下限"] = [round(intr.node_dict_[i]["ci"][0][0]) for i in tbl.index]
tbl["上限"] = [round(intr.node_dict_[i]["ci"][1][0]) for i in tbl.index]

print(tbl)

 

以下、実行結果です。

        人数    休眠日数  購入回数     効果     正解   下限   上限
leaf
2     2121    3-40   1-6  334.0  427.0  201  466
3      757    3-40  7-18   92.0  -45.0 -114  297
5     1866  41-335   1-5  563.0  616.0  400  726
6      256  41-116  6-14  295.0  164.0   49  541

 

この表を、エライ人に見せる言葉にします。

効くのは「しばらく買っていない人」です。休眠41日以上・購入5回以下の1,866人は1人あたり+563円で、区間の下限でも+400円。いまの配布率は16%しかありません。
逆に、休眠40日以下・購入7回以上の757人は+92円で、区間はマイナスにかかります。ここには75%も配っています。配り方がほぼ逆になっています。

「効くか」の判断には、原価100円(第2回と同じ前提)と比べます。区間の下限まで原価を超えていれば「配る」、点推定だけ超えていれば「小さく試す」。上図の4枚の札はその目安です。配布のコストと利益を正面から扱うのは第7回です。

 

裏で何が起きているのか:木(ディシジョンツリー)は「因果フォレストの要約」

ここが今回いちばん大事な点です。この木(ディシジョンツリー)は、購入金額 Y を当てていません。

手順はこうです。

まず、因果フォレストが5,000人分の効果 est.effect(X) を出す。

次に、その5,000個の数字を目的変数にして、普通の木(ディシジョンツリー)を育てる。だから葉の値は「その葉に入った人たちの、因果フォレストが出した効果の平均」です。区間は、因果フォレストに「この葉(リーフ)の人たちの平均効果」を改めて推論させたものです。

Y を当てる木(ディシジョンツリー)だと、第5回の左側の図、つまり「購入金額が近い人をまとめる木(ディシジョンツリー)」になってしまいます。効果が近い人をまとめたいのなら、効果を当てる木(ディシジョンツリー)でなければいけません。

 

 たとえ:地図の縮尺

因果フォレストは住宅地図です。1軒1軒の番地まで載っていますが、全体を眺めるのには向きません。要約の木(ディシジョンツリー)は全国地図です。番地は載りませんが、どこへ向かうかを決めるにはそれで足ります。細かい情報は因果フォレストが持っているので、木(ディシジョンツリー)が捨てても失われません。

では何段まで切るか。深さを1から5まで変えて、ルールの行数と「因果フォレストの答えとの一致度」を並べたのが以下の図です。

木(ディシジョンツリー)の深さとルール行数・因果フォレストとの一致度の関係深さ1は2行で56%、深さ2は4行で88%、深さ3は7行で93%、深さ4は12行で94%、深さ5は16行で95%。深さ5で葉の人数制限をなくすと32行・98%だが最小の葉は6人。何段まで切るか:地図の縮尺を選ぶのと同じ木(ディシジョンツリー)の深さを変えたときの、ルールの行数と「因果フォレストの答えをどれだけ再現できたか」(葉に最低200人の条件つき)0%25%50%75%100%因果フォレストの答えとの一致度(決定係数)56%深さ12行全国地図88%深さ24行地方の地図93%深さ37行県の地図94%深さ412行市街地図95%深さ516行住宅地図ここで止める:4行で88%右へ進んでも、行が増えるわりに数%しか伸びない深さ5・人数制限なし32行一致度 98%最小の葉 6人50人未満の葉 13個誰も覚えられない覚えられる行数(4〜8行)で止める。細かさは因果フォレストが持っている。木(ディシジョンツリー)はその「要約」でよい

2行で56%、4行で88%、7行で93%。深さ2から先は、行が増えるわりに数%しか伸びません。覚えられる行数(4〜8行)で止めるのが目安です。

 

 最初の分岐が「休眠日数」なのは、重要度と矛盾しないのか

第5回の重要度は購入頻度が1位、休眠日数が2位でした。ところが木(ディシジョンツリー)の最初の分岐は休眠日数です。矛盾ではありません。

重要度は1,000本の木(ディシジョンツリー)・すべての分岐の合計で、要約の木(ディシジョンツリー)は「1回で最も差が開く切り口」を1つ選びます。2段目では両方の枝で購入回数を使っていて、顔ぶれは同じです。

分岐の順番に意味を読みすぎないのがコツです。

 

答え合わせ:区間は正解を含んでいるか

今回もデータには正解を仕込んであります。葉ごとに、推定と区間、正解を並べたのが以下の図です。

葉ごとの推定・95%区間・正解・単純比較の比較4つの葉すべてで95%区間が正解を含む。休眠40日以下・購入6回以下は推定+334円で正解+427円、休眠40日以下・購入7回以上は+92円で正解-45円、休眠41日以上・購入5回以下は+563円で正解+616円、休眠41日以上・購入6回以上は+295円で正解+164円。葉の中の単純比較は+628〜+1,663円と大きく外れる。4つの葉で答え合わせ:区間は正解を含んでいるか葉ごとの推定(点と95%区間)、仕込んでおいた正解、葉の中で配布あり−なしを単純比較した値0500円1,000円1,500円原価100円休眠 40日以下・購入 6回以下2,121人+334正解 +427+1,510休眠 40日以下・購入 7回以上757人+92正解 -45+628休眠 41日以上・購入 5回以下1,866人+563正解 +616+1,663休眠 41日以上・購入 6回以上256人+295正解 +164+876推定と95%区間正解葉の中の単純比較4つとも区間が正解を含む。葉の中で単純比較すると、また第1回の「1.8倍」の世界に戻る

4つの葉すべてで、区間が正解を含みました。

ただし休眠40日以下・購入7回以上の葉は、正解が−45円なのに推定は+92円。第5回で見た「因果フォレストはデータの端で平均のほうへ縮む」が、ここにも出ています。

点推定だけ見て「少しは効く」と読まず、区間が0をまたいでいることを見てください。

 

ありがちな失敗

1つ目:木(ディシジョンツリー)を深くして、誰も覚えられないルールを作る。

max_depth=5 にして葉の人数制限も外すと、一致度は98%に上がりますが、ルールは32行、いちばん小さい葉は6人です。6人の平均に区間を付けても、会議で使える数字にはなりません。深くしたくなったら、先に「誰がこのルールを運用するのか」を考えます。

2つ目:葉の中で単純比較して「確認」する。

ルールができると、葉ごとに「配布あり」と「配布なし」の平均購入金額を比べて確かめたくなります。やってみると、休眠40日以下・購入7回以上の葉で+628円。正解は−45円です。交絡は、葉の中で消えていません。第1回の「1.8倍」の世界に戻るだけです。確認は因果フォレストの区間(図3)で行います。

 

まとめ

今回のポイントです。

  • SingleTreeCateInterpreter は、因果フォレストの答えを浅い木(ディシジョンツリー)に要約する。葉の値は「因果フォレストが出した効果の平均」で、Y を当てているのではない
  • このデータでは4行のルールで因果フォレストの88%を再現。使われた変数は休眠日数と購入回数の2つ
  • 葉ごとの人数・効果・区間・いまの配布率を1枚にすると、配り方が逆になっていることが伝わる
  • 深さは地図の縮尺。覚えられる行数(4〜8行)で止める。細かさは因果フォレストが持っている
  • 葉の中で単純比較しても交絡は消えない。確認は因果フォレストの区間で
Screenshot

【月1 特定テーマ講座(10月)】
Python で学ぶ 明日からできる「欠損値処理」超入門

【開催日時】 全2回(土)2026/10/17,10/31(13:30〜18:00)
【受講形式】 当日Zoom( or 復習用に後日動画視聴)
【参加費用】 2万2千円(税込み)/人