営業部長がこう聞きます。「うちの営業は、年に何回訪問すべきなんだ?」 集計では、年9回以上訪問した顧客の契約金額は0〜2回の顧客の3倍。「多いほど良い」と答えたくなります。
これまでの9回は「やる/やらない」の2値の施策でした。最終回は「何回やるか」、連続量の施策です。答えの形も「効く/効かない」ではなく「何回目まで効く」に変わります。
結論を先に3行で書きます。
- 直線で当てはめると「訪問1回あたり +18.5万円」。この数字は平均の傾きで、実際は8回で頭打ちです。直線は9回目以降の伸びを作り出します。
- カーブで当てはめると、増分は 0→2回 65万円、6→8回 13万円、8→10回 −4万円。伸びなくなる回数と、費用を引いた最適回数(6回)は別物です。
- 分析を現場に載せる前に、10項目のチェックリストに「はい」と言えるか確かめます。言えない項目は先に言います。
この記事を読み終えると、次のことができるようになります。
- 訪問回数や値引き率のような連続量の施策の効果を推定できる
- 「1回あたり」の直線と「何回目まで」のカーブの違いを説明できる
- 限界効果と費用を比べて、最適な回数を決められる
- 会議に出す前に、10項目で自己点検できる
事例とデータ
第8回と同じ法人営業の6,000社です。違いは施策の列で、年間の訪問回数 visits(0〜12回)が入っています。成果は今年度の契約金額 sales(万円)。
以下からダウンロードできます。
現場のルールは「大口の顧客とA担当は多く訪問する」なので、訪問回数は企業規模と担当者ランクに引っ張られています。第8回と同じ交絡です。
単純平均の差は、0回と比較して 4回 +253万円、8回 +695万円、12回 +1,168万円。正解は 8回で +160万円、それ以降はわずかに下がります。
まず、動かしてみる:直線で当てはめる
discrete_treatment を付けなければ、LinearDML は施策を連続量として扱います。効果は ate(X, T0=0, T1=1) と「何回から何回へ」を指定して取り出します。
以下、コードです。
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor
from econml.dml import LinearDML
# データの読み込み
df = pd.read_csv("btob_visits.csv")
# 変数の定義
Y = df["sales"] # 今年度の契約金額(万円)
T = df["visits"] # 年間の訪問回数(0〜12回)
IND = {"製造": 0, "卸売": 1, "サービス": 2, "IT": 3}
X = pd.DataFrame({
"size_class": df["size_class"],
"industry": df["industry"].map(IND),
"years": df["years"]
})
W = df[["rep_rank", "last_year_sales"]] # 担当者ランク、前年売上(交絡)
# モデルの学習
est = LinearDML(
model_y=GradientBoostingRegressor(random_state=0),
model_t=GradientBoostingRegressor(random_state=0),
cv=5, random_state=0
)
est.fit(Y, T, X=X, W=W)
slope = est.ate(X, T0=0, T1=1) # 1回増やしたときの効果
lo, hi = est.ate_interval(X, T0=0, T1=1, alpha=0.05) # 信頼区間
# 結果の表示
print(f"訪問1回あたりの効果: +{slope:.1f}万円 [{lo:.1f}, {hi:.1f}]")
print(f"直線で12回まで伸ばすと: +{slope * 12:.0f}万円")
以下、実行結果です。
訪問1回あたりの効果: +18.5万円 [17.6, 19.3] 直線で12回まで伸ばすと: +222万円
区間は狭く、いかにも確からしい数字です。でも「1回あたり」には「何回目でも同じ」という仮定が隠れています。12回で222万円は、正解の144万円より78万円多い。直線は頭打ちを表現できず、平均の傾きを最後まで伸ばします。
収穫逓減を捉える:treatment_featurizer
効果が曲がるなら、曲がる形を treatment_featurizer で渡します。PolynomialFeatures(degree=2) は施策 T を「T と T²」の2列に広げます。
以下、コードです。
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import PolynomialFeatures
# モデルの学習
est = LinearDML(
model_y=GradientBoostingRegressor(random_state=0),
model_t=RandomForestRegressor(
n_estimators=200,
min_samples_leaf=20,
random_state=0
),
treatment_featurizer=PolynomialFeatures(
degree=2, include_bias=False),
cv=5, random_state=0
)
est.fit(Y, T.values.reshape(-1, 1), X=X, W=W) # T は2次元にして渡す
truth = df.groupby("visits")["_true_gain"].mean() # 答え合わせ用の列
# 結果の表示
print("訪問を2回増やしたときの効果(万円)")
for k in range(0, 12, 2):
gain = est.ate(X, T0=k, T1=k + 2)
lo, hi = est.ate_interval(X, T0=k, T1=k + 2, alpha=0.05)
ans = truth[k + 2] - truth[k]
print(
f"{k:2d}回 → {k + 2:2d}回: {gain:6.1f} [{lo:6.1f}, {hi:6.1f}]"
f" 正解 {ans:5.1f}"
)
以下、実行結果です。
訪問を2回増やしたときの効果(万円) 0回 → 2回: 65.2 [ 61.0, 69.4] 正解 70.0 2回 → 4回: 47.9 [ 45.4, 50.3] 正解 50.0 4回 → 6回: 30.5 [ 28.6, 32.4] 正解 30.0 6回 → 8回: 13.2 [ 10.0, 16.4] 正解 10.0 8回 → 10回: -4.1 [ -9.2, 1.0] 正解 -8.0 10回 → 12回: -21.4 [ -28.5, -14.3] 正解 -8.0
0→2回は65万円、6→8回は13万円、8→10回は−4万円。効果は回を追うごとに減り、8回を超えると増えません。正解を区間がほぼ捉えています。1つ正直に書くと、10→12回は −21万円 [−29, −14] と、正解の −8万円より下に外れました。11回以上のデータが77社しかなく、2次のカーブは端で強く曲がるからです。データが薄い端の数字は信じすぎない。
何回目で止めるか:限界効果と費用
「8回で頭打ち」は答えの半分です。訪問には費用がかかります。ここでは1回10万円とします。決め方は、「もう1回増やしたときの効果(限界効果)」が費用を上回る間は続け、下回ったら止める。
以下、コードです。
COST = 10 # 訪問1回にかかる費用(万円)
# 訪問を1回増やしたときの効果と費用の比較
print(f"1回追加したときの効果と、費用{COST}万円との比較")
# 訪問を1回増やしたときの効果
for t in range(1, 13):
gain = est.ate(X, T0=t - 1, T1=t)
lo, hi = est.ate_interval(X, T0=t - 1, T1=t, alpha=0.05)
verdict = "続ける" if gain > COST else "止める"
print(
f"{t:2d}回目: {gain:6.1f}万円 "
f"[{lo:6.1f}, {hi:6.1f}] {verdict}"
)
# 訪問を8回増やしたときの効果
print(f"\n8回 → 20回の推定: {est.ate(X, T0=8, T1=20):.0f}万円")
以下、実行結果です。
1回追加したときの効果と、費用10万円との比較 1回目: 34.8万円 [ 32.4, 37.1] 続ける 2回目: 30.4万円 [ 28.6, 32.3] 続ける 3回目: 26.1万円 [ 24.7, 27.5] 続ける 4回目: 21.8万円 [ 20.7, 22.8] 続ける 5回目: 17.4万円 [ 16.5, 18.4] 続ける 6回目: 13.1万円 [ 12.1, 14.2] 続ける 7回目: 8.8万円 [ 7.4, 10.2] 止める 8回目: 4.4万円 [ 2.6, 6.3] 止める 9回目: 0.1万円 [ -2.2, 2.4] 止める 10回目: -4.2万円 [ -7.0, -1.4] 止める 11回目: -8.5万円 [ -11.8, -5.3] 止める 12回目: -12.9万円 [ -16.7, -9.1] 止める 8回 → 20回の推定: -284万円
6回目の13.1万円は費用を上回り、7回目の8.8万円は下回ります。費用を引いた最適回数は6回。正解のデータで計算しても6回です。「伸びなくなる回数」(正解8回、推定では9回目が+0.1万円で紙一重)と「最適回数」(6回)は違う数字です。伸びなくなるまで続けると、7・8回目の2回分で1社あたり約7万円の持ち出しです。
最後の行を見てください。「8回→20回で −284万円」。データは12回までなのに、20回を聞けば数字は返ってきます。カーブは端で急降下するので意味のない数字です。モデルはデータの外を聞かれても黙ってくれません。範囲を守るのは聞く側の仕事です。
裏で何が起きているのか:効果は「どこからどこへ」の差
2値の施策では、効果は「やる − やらない」の1つの差でした。連続量では「T0 から T1 に変えたときの差」になり、T0 と T1 を指定して初めて数字になります。ate(X, T0=6, T1=8) がその指定です。
直線モデルは効果 = a×T で、a が「1回あたり」の傾き。何回目でも同じなので頭打ちを表現できません。treatment_featurizer は T を「T, T²」に広げ、効果 = a×T + b×T² を推定します。b が負なら増分は減り続け、頂点を過ぎると下がります。中身は第2回と同じ LinearDML です。変わったのは、施策の列を1本から2本に広げたことだけ。
第1回で「効果とは差である」と書きました。連続量でも同じです。「どこからどこへの差か」を言わない数字は、まだ効果ではありません。
ありがちな失敗
1つ目:T の形と model_t を変え忘れる。 treatment_featurizer を使うとき、T を1次元のまま渡すと ValueError: Expected 2D array, got 1D array instead で止まります。
T.values.reshape(-1, 1) で列にします。もう1つ、施策が「T, T²」の2列になるので、model_t は2列を同時に予測できるモデルが必要です。GradientBoostingRegressor は ValueError: y should be a 1d array, got an array of shape (4800, 2) で止まります。RandomForestRegressor は多出力に対応しています。
2つ目:データにない回数を聞く。 20回の推定は返ってきますが、根拠になる顧客は1社もいません。報告する回数はデータがある範囲(今回は実質10回まで)に限ります。範囲の外を知りたければ、推定ではなく小さく試すのが答えです。
現場に載せる:実務チェックリスト10項目
会議に出す前の自己点検10項目です。連載のどの回で扱ったかを添えています。
10個に「はい」と言えない分析は、まだ会議に出しません。出すなら、言えない項目を先に言います。「④の重なりが薄いので、大口顧客の効果は幅が広いです」と自分から言う人が信用されます。
まとめ
今回のポイントです。
- 連続量の施策は、効果を「T0→T1の差」で定義する。「1回あたり」の直線は頭打ちを表現できない
treatment_featurizerにPolynomialFeatures(degree=2)を渡してカーブを当てはめる。T はreshape(-1, 1)、model_tは多出力対応のモデルにする- 伸びなくなる回数(8回)と、費用を引いた最適回数(6回)は別。限界効果>費用の間だけ続ける
- データの外(20回)を聞けば数字は返るが意味はない
- 10項目に「はい」と言えるか。言えない項目は先に言う
連載を振り返ると、第1回で「効果とは差」と定義し、第2〜4回で交絡と変数の仕分け、第5〜7回でばらつきを木と政策に、第8回で手法選び、第9回で頑健性、最終回で「何回やるか」を扱いました。全部に共通するのは「比べる相手を作る」ことです。

