第6回の1枚を見た上司が、次にこう言います。「で、何枚配る? いくら儲かる?」
ここで問いが変わります。これまでは「効果はいくらか」でした。今回は原価を入れて、「誰に配ると、いくら残るか」を答えます。クーポン1枚の原価は、第2回と同じ100円とします。
結論を先に3行で書きます。
- 原価100円を入れた木(ディシジョンツリー)は「購入6回以下の人に配る」の1行になりました。5,000人のうち82%に配って、期待利益は139万円。いまの配り方(30%に配布)は26万円です。
- 同じ1,500枚でも、効果の高い順に配り直すだけで26万円が73万円になります。枚数も決めてよいなら「効果>原価」の人まで配るのが最大です。
- 境目の判断は点推定ではなく区間の下限で行います。点推定で切ると、正解がマイナスの205人に配ってしまいました。
この記事を読み終えると、次のことができるようになります。
- 効果の推定値と原価から、配布案ごとの期待利益を計算できる
SingleTreePolicyInterpreterで「配る/配らない」を直接決める(ディシジョンツリー)を作れる- 「何枚配るか」を累積利益カーブで決められる
- 点推定と区間の下限、どちらで境目を切るべきかを説明できる
データは前回までと同じ ec_coupon.csv、因果フォレストも第5回・第6回と同じものです。
まず、動かしてみる:原価を入れて「配る/配らない」の木(ディシジョンツリー)を作る
データは前回までと同じ ec_coupon.csv です。因果フォレストも第5回と同じものを使います。
以下からダウンロードできます。
前半は第5回のコードそのまま。33行以降が今回の追加です。
以下、コードです。
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.ensemble import GradientBoostingClassifier
from econml.dml import CausalForestDML
from econml.cate_interpreter import SingleTreePolicyInterpreter
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"],
})
# 第5回・第6回と同じ因果フォレスト
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)
# 原価100円を入れて、「配る/配らない」を決める木(ディシジョンツリー)を作る
COST = 100
pol = SingleTreePolicyInterpreter(
risk_level=0.05, max_depth=2, min_samples_leaf=200,
random_state=0
)
pol.interpret(est, X, sample_treatment_costs=COST)
print(f"配る人の割合: {pol.treat(X).mean():.1%}")
以下、実行結果です。
配る人の割合: 81.7%
第6回の SingleTreeCateInterpreter と引数はほぼ同じで、違いは2つ。sample_treatment_costs=COST で原価を渡すこと、risk_level=0.05 で「効果の95%区間の下限」を使うことです。後者の意味は後半で扱います。
木(ディシジョンツリー)がどう分けたかは、第6回と同じ要領で葉(リーフ)ごとに集計できます。
以下、コードです。
df["effect"] = est.effect(X) # 効果の推定値
df["lower"] = est.effect_interval(X, alpha=0.05)[0] # 効果の下限
df["leaf"] = pol.tree_model_.apply(X) # どの葉(リーフ)に属するか
df["treat"] = pol.treat(X) # 配るかどうか
# 葉(リーフ)ごとに集計
tbl = df.groupby("leaf").agg(
人数=("leaf", "size"),
購入回数=("past_freq", lambda s: f"{s.min()}-{s.max()}"),
効果=("effect", "mean"),
下限=("lower", "mean"),
配る=("treat", "first"),
).round(0)
print(tbl)
以下、実行結果です。
人数 購入回数 効果 下限 配る leaf 1 4084 1-6 441.0 296.0 1 3 303 7-7 170.0 -28.0 0 4 613 8-18 84.0 -142.0 0
ルールは1行です。「過去1年の購入回数が6回以下なら配る。7回以上には配らない。」購入7回の303人は効果の点推定が170円で原価を超えていますが、下限が−28円なので「配らない」側に入りました。
配布案を期待利益で比べる
期待利益は「配った人の(効果−原価)の合計」です。5つの配布案を同じ物差しで並べます。_true_cate は仕込んでおいた正解で、実務データにはありません。
以下、コードです。
tau = df["effect"].values # 効果の推定値
lo = df["lower"].values # 効果の下限
truth = df["_true_cate"].values # 真の効果
# 各配布計画の利益を計算
plans = {
"いまの配り方": df["coupon"].values == 1,
"全員配布": np.ones(len(df), dtype=bool),
"効果の上位30%": tau >= np.quantile(tau, 0.7),
"効果 > 原価": tau > COST,
"木": df["treat"].values == 1,
}
# 各配布計画の利益を計算
for name, m in plans.items():
gain = (tau - COST)[m].sum() / 10000 # 利益の推定値
gain_lo = (lo - COST)[m].sum() / 10000 # 利益の下限
gain_true = (truth - COST)[m].sum() / 10000 # 真の利益
print(
f"{name:<8}\t配布率 {m.mean():<6.1%}, 期待利益 {gain:6.1f}万円, "
f"下限で{gain_lo:5.1f}万円, 正解で{gain_true:6.1f}万円"
)
以下、実行結果です。
いまの配り方 配布率 30.2% , 期待利益 25.5万円, 下限で -0.9万円, 正解で 16.3万円 全員配布 配布率 100.0%, 期待利益 140.4万円, 下限で 61.4万円, 正解で 156.3万円 効果の上位30% 配布率 30.0% , 期待利益 73.0万円, 下限で 48.5万円, 正解で 82.9万円 効果 > 原価 配布率 90.2% , 期待利益 142.1万円, 下限で 73.5万円, 正解で 166.8万円 木 配布率 81.7% , 期待利益 139.3万円, 下限で 80.1万円, 正解で 167.3万円
読みどころは3つあります。
1つ目、いまの配り方は同じ枚数の上位30%の3分の1しか稼いでいない。現場は優良顧客に配っていて、その人たちはクーポンがなくても買います。いまの配布先のうち、効果の上位30%に入っている人は13%だけでした。
2つ目、全員配布と木(ディシジョンツリー)は、推定ではほぼ同じ。140万円と139万円です。ところが下限で計算すると61万円と80万円、正解で計算すると156万円と167万円。配らないほうがよい18%を外した分が、確実性と正解の両方で効いています。
3つ目、「効果>原価」で機械的に切ると推定は最大だが、下限では木(ディシジョンツリー)に負ける。点推定が原価をわずかに超える450人を含むためです。
何枚配るか:累積利益カーブ
枚数の決め方は1つの図で片づきます。効果の高い人から順に配っていき、そこまでの利益を積み上げたのが以下の図です。
利益は「効果=原価」の人で頭打ちになり、その先は配るほど減ります。予算が決まっているなら効果の高い順に予算まで、枚数も決めてよいなら「効果>原価」の人まで。上位30%は、その途中で止めた形です。
この図は、アップリフト分析の Qini 曲線と同じ発想です。違いは、縦軸が「効果」ではなく「効果−原価」になっていること。原価を引くと、山の頂上が「何枚配るか」の答えになります。
裏で何が起きているのか
符号を当てる木(ディシジョンツリー)
第6回の木(ディシジョンツリー)は「効果がいくらか」を当てていました。今回の木(ディシジョンツリー)が当てるのは、「効果−原価」がプラスかマイナスか、つまり符号です。
期待利益を最大にする配り方は、理屈の上では「効果−原価がプラスの人全員」です。ただ、5,000人分の判定はルールになりません。木(ディシジョンツリー)は、覚えられる行数で符号をできるだけ正しく分ける木(ディシジョンツリー)です。
policy_value_ にはその木(ディシジョンツリー)で配ったときの1人あたり利益、always_treat_value_ には全員配布の値が入っています(risk_level を付けたときは、どちらも下限で計算した値です)。
なぜ下限で切るのか
売上は見込み、原価は確定額です。効果の点推定が原価をわずかに上回っていても、区間がマイナスにかかっていれば「配って損をする」可能性が残ります。
risk_level=0.05 は、効果の点推定を95%区間の下限に置き換えてから符号を判定する指定です。「下限で見ても原価を超える人にだけ配る」という、経営でいえば「見込みの弱い側で計画する」やり方です。
因果フォレストを経由せず、データから直接「配る/配らない」の木(ディシジョンツリー)を学習する DRPolicyTree もあります。今回は第5回からの流れで因果フォレストを使いました。
答え合わせ:点推定で切るとどうなるか
同じ因果フォレスト・同じ原価・同じ深さで、risk_level を付けない木(ディシジョンツリー)も作りました。
点推定の木(ディシジョンツリー)は「購入8回以上・休眠29日以上の205人」に配る判断をしました。効果の点推定は122円で原価を超えていますが、下限は−131円、正解は−37円。配ると2.8万円の損です。
下限の木(ディシジョンツリー)にも取りこぼしはあります。購入7回の303人は正解が+129円で、配ったほうがよかった。ただし見送りの代償は0.9万円です。慎重な側の間違いのほうが安い。これが下限で切る理由です。
ありがちな失敗
1つ目:「上位k%」を先に決める。 30%という数字に根拠がなければ、山の途中で止めているだけです。予算がないなら「効果>原価」で枚数が決まり、予算があるなら予算で決まります。k%は結果であって、入力ではありません。
2つ目:原価を引き忘れる。 全員配布の効果の合計は190万円です。ここから原価50万円(5,000枚×100円)を引いた140万円が利益ですが、190万円のまま報告される資料をよく見ます。sample_treatment_costs を渡し忘れると、木(ディシジョンツリー)も同じ間違いをします。
まとめ
今回のポイントです。
- 原価を入れると、問いは「効果はいくら」から「配ると、いくら残るか」に変わる。期待利益=配った人の(効果−原価)の合計
SingleTreePolicyInterpreterにsample_treatment_costsを渡すと、「配る/配らない」を直接決める木(ディシジョンツリー)ができる。このデータでは「購入6回以下に配る」の1行- 同じ枚数なら効果順に、枚数も決めるなら「効果>原価」まで。累積利益カーブの頂上が答え
- 境目は点推定ではなく区間の下限で切る(
risk_level)。慎重な側の間違いのほうが安い - 上位k%は結果であって入力ではない。原価を引き忘れない

