第5回の因果フォレストの結果を持って、エライ人に報告に行ったとします。
「効果は1人ひとり違っていて、1,000本の木(ディシジョンツリー)の平均で……」と言いかけたところで、こう返されます。「で、誰に配ればいいの?」
因果フォレストの出力は5,000人分の数字です。そのままでは会議に出せません。今回は因果フォレストの答えを、人が覚えられる数行のルールに落とします。
結論を先に3行で書きます。
SingleTreeCateInterpreterで因果フォレストの答えを深さ2の木(ディシジョンツリー)に要約すると、「休眠41日以上・購入5回以下なら +563円」のような4行のルールになりました。この4行で、因果フォレストが出した効果のばらつきの88%を再現しています。- この木(ディシジョンツリー)は購入金額を当てているのではなく、因果フォレストが出した効果を当てています。葉ごとの信頼区間も因果フォレストから出ます。
- 深くすれば一致度は上がりますが、32行のルールは誰も覚えられません。地図の縮尺と同じで、覚えられる行数で止めます。
この記事を読み終えると、次のことができるようになります。
SingleTreeCateInterpreterで、因果フォレストの結果を数行のルールに要約できる- 葉ごとの効果と信頼区間を、「誰に・いくら」の日本語に翻訳できる
- 要約の木(ディシジョンツリー)が「何を当てているのか」を、人に説明できる
- 木(ディシジョンツリー)を何段まで切るかを、根拠をもって決められる
データは前回までと同じ ec_coupon.csv です。因果フォレストも第5回と同じものを使います。
以下からダウンロードできます。
まず、動かしてみる:因果フォレストを深さ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%区間を添え、いまの配布率も並べました。
葉ごとの数字は、次のコードで表にできます。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まで変えて、ルールの行数と「因果フォレストの答えとの一致度」を並べたのが以下の図です。
2行で56%、4行で88%、7行で93%。深さ2から先は、行が増えるわりに数%しか伸びません。覚えられる行数(4〜8行)で止めるのが目安です。
最初の分岐が「休眠日数」なのは、重要度と矛盾しないのか
第5回の重要度は購入頻度が1位、休眠日数が2位でした。ところが木(ディシジョンツリー)の最初の分岐は休眠日数です。矛盾ではありません。
重要度は1,000本の木(ディシジョンツリー)・すべての分岐の合計で、要約の木(ディシジョンツリー)は「1回で最も差が開く切り口」を1つ選びます。2段目では両方の枝で購入回数を使っていて、顔ぶれは同じです。
分岐の順番に意味を読みすぎないのがコツです。
答え合わせ:区間は正解を含んでいるか
今回もデータには正解を仕込んであります。葉ごとに、推定と区間、正解を並べたのが以下の図です。
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行)で止める。細かさは因果フォレストが持っている
- 葉の中で単純比較しても交絡は消えない。確認は因果フォレストの区間で

