クーポンを配った顧客の30日間の購入金額が、配らなかった顧客の1.8倍だった。
この数字を見て「クーポン施策は成功」と報告書に書いてしまう。
データ分析の現場で、最も頻繁に起きる間違いです。
結論を先に3行で書きます。
- 「クーポンを使った人が買っている」と「クーポンが買わせた」は別の話です。前者はデータを見れば分かりますが、後者はデータを見ても分かりません。
- 差が大きく出る原因は、もともとよく買う優良顧客にクーポンが配られていたからかも。これを交絡(こうらく)と呼びます。
- この連載では、その「本当の効果」をPythonライブラリ EconML で推定できるようになることを目指します。
実行環境は Python 3.11 / econml 0.17.0 / pandas / scikit-learn です
この記事を読み終えると、次のことができるようになります。
- 「相関がある」と「効果がある」の違いを、自分の言葉で説明できる
- 単純な平均比較が、なぜ効果を過大に見せるのかを説明できる
- 予測精度の高いモデルが、なぜ施策判断に使えないのかを説明できる
- 因果推論のライブラリの中で、EconMLがどこを担当するのかが分かる
今回はEconMLをまだ動かしません。まず「何が問題なのか」を、数字で腹落ちさせるところまでをゴールにします。
その報告書、こう聞かれたら答えられますか
あるECサイトで、会員にクーポンを配る施策をやったとします。1か月後、データを集計するとこうなりました。
- クーポンを配った人:30日間の購入金額 平均 5,151円
- 配らなかった人:平均 2,883円
差は2,268円、1.79倍です。「クーポン1枚あたり2,268円の売上増」と報告書に書きたくなります。
ここで上司からこう聞かれます。
「そのクーポン、誰に配ったんだっけ?」
配布リストを確認すると、ゴールド会員の82%に配られていて、ブロンズ会員には8%しか配られていませんでした。もともとたくさん買う人に、集中してクーポンを配っていたのです。
この時点で、2,268円という数字は信用できなくなります。クーポンが効いたのか、それとも「よく買う人が、いつも通り買っただけ」なのか、区別がついていないからです。
今回使うデータ
連載を通して使うサンプルデータのうち、今回は ec_coupon を使います。5,000人分の会員データです。以下からダウンロードできます。
| 列名 | 意味 | 役割 |
|---|---|---|
member_rank |
会員ランク(ブロンズ/シルバー/ゴールド) | 交絡しそうな変数 |
channel |
流入経路(自然検索/広告/メルマガ) | 交絡しそうな変数 |
tenure_months |
登録からの経過月数 | 交絡しそうな変数 |
past_freq |
過去1年の購入回数 | 効果を左右しそうな変数 |
days_since_last |
最終購入からの休眠日数 | 効果を左右しそうな変数 |
coupon |
クーポンを配ったか(0 / 1) | 施策 |
spend_30d |
その後30日間の購入金額 | 成果 |
_true_cate |
その人に対する本当の効果 | 答え合わせ用 |
最後の _true_cate に注目してください。このデータはシミュレーションで作っているので、一人ひとりの本当の効果が分かっています。実務データには絶対に存在しない列ですが、学習用としては最強の武器です。推定値と正解を、毎回並べて確認できます。
まず、単純に比べてみる
クーポンの有無で、購入金額の平均を比べます。
以下、コードです。
import pandas as pd
df = pd.read_csv("ec_coupon.csv")
# クーポンの有無で平均購入金額を比べる
print(df.groupby("coupon")["spend_30d"].agg(["count", "mean"]).round(0))
以下、実行結果です。
count mean coupon 0 3490 2883.0 1 1510 5151.0
差は 5,151 − 2,883 = 2,268円。冒頭の数字はこれです。
会員ランクで分けると、差が縮む
では、会員ランクごとに同じ比較をしてみます。ランクを揃えれば、少なくとも「ゴールド会員だから買った」分は取り除けるはずです。
以下、コードです。
t = df.pivot_table(index="member_rank", columns="coupon",
values="spend_30d", aggfunc="mean").round(0)
t.columns = ["配布なし", "配布あり"]
t["差"] = t["配布あり"] - t["配布なし"]
t["配布率"] = df.groupby("member_rank")["coupon"].mean().round(3)
print(t.reindex(["ブロンズ", "シルバー", "ゴールド"]))
以下、実行結果です。
配布なし 配布あり 差 配布率 member_rank ブロンズ 2172.0 2798.0 626.0 0.083 シルバー 4096.0 4746.0 650.0 0.402 ゴールド 6054.0 6411.0 357.0 0.823
ランクごとに見ると、差は 626円 / 650円 / 357円 です。人数で加重平均すると約594円になります。
全体で見たときの2,268円が、ランクを揃えただけで4分の1近くまで縮みました。
消えた1,700円は「クーポンの効果」ではありません。ゴールド会員の82%にクーポンが配られていたため、「クーポンあり」の平均に、もともと高いゴールド会員の購入金額が大量に混ざり込んでいた。それだけのことです。
何が起きているのか:交絡という落とし穴
いま起きていることを図にすると、こうなります。
会員ランクは、クーポンの配布されやすさと購入金額の両方に矢印を伸ばしています。この形になっている変数を交絡変数と呼びます。
交絡変数を放置したまま単純比較すると、遠回りの経路(会員ランク経由)が、真ん中の矢印に足し算されて観測されます。だから効果が過大に見えるのです。
よく使われるたとえがあります。
体調が悪い人ほど薬を飲む。だからデータを集めると「薬を飲んでいる人ほど体調が悪い」という結果になる。
このデータだけを見て「薬は体に悪い」と結論する人はいません。「もともと体調が悪いから飲んでいる」と誰でも分かるからです。ところが同じ構造でも、クーポンや広告や営業訪問の話になると、途端に見抜けなくなります。
なぜデータを見ても分からないのか
もっと根本的な問題があります。
本当に知りたいのは、クーポンを配った人がもし配られていなかったらいくら買ったか、です。この「もしも」の世界を反実仮想と呼びます。
因果推論とは、この観測できない「もしも」を、観測できたデータから推し量るための道具立てのことです。連載の第4回までを使って、この推し量り方を身につけていきます。
予測モデルでは、この問題は解けない
ここで、機械学習に慣れている方ほど陥りやすい罠があります。
「購入金額を予測するモデルを作って、
couponの特徴量重要度を見ればいいのでは?」
残念ながら、これは効果の推定になりません。予測モデルが最適化しているのは「Yをどれだけ正確に当てられるか」であって、「Tを変えたらYがどれだけ動くか」ではないからです。
このデータでは、coupon は「ゴールド会員かどうか」の代理変数としてよく効きます。予測精度は上がりますし、重要度も高く出ます。しかしそれは、クーポンが売上を生んだからではなく、クーポンを持っている人はゴールド会員である確率が高いからです。
| 予測モデル | 因果推論 | |
|---|---|---|
| 答える問い | この人はいくら買うか | 施策を打つと、いくら増えるか |
| 評価指標 | RMSE、AUCなど | 効果の推定値と、その信頼区間 |
| 使いどころ | 在庫、需要、離反スコア | 施策の意思決定、予算配分 |
| 変数の入れ方 | 精度が上がるなら何でも入れる | 入れてはいけない変数がある |
一番下の行が重要です。予測モデルでは「とりあえず全部の列を入れる」が正解に近いのに対し、因果推論では入れる変数を間違えると答えが変わります。ここは連載の第4回で、1本まるごと使って扱います。
因果推論のライブラリ地図
Pythonで因果推論をやろうとすると、似たようなライブラリがいくつも出てきます。役割が違うので、先に整理しておきます。
| ライブラリ | 担当 | ひとことで言うと |
|---|---|---|
| statsmodels | 回帰分析 | 重回帰、傾向スコア。すべての土台 |
| DoWhy | 設計 | 「何を仮定すれば効果が言えるか」を組み立てる |
| EconML | 推定 | 機械学習を使って効果の大きさを出す |
| CausalML | 推定 | EconMLと守備範囲が近い。アップリフト分析寄り |
この連載の主役は EconML です。Microsoft Research発、現在はPyWhyコミュニティで開発されているライブラリで、ATE(全体の平均的な効果)とCATE(属性ごとの効果)を機械学習で推定することに特化しています。
今回はインストールとバージョン確認だけしておきます。
以下、コードです。
!pip install econml import econml print(econml.__version__)
以下、実行結果です。
0.17.0
ローカルのターミナルから実行する場合は、先頭の ! を付けません。
答え合わせ:本当の効果はいくらだったか
このデータには _true_cate という「正解」の列がありました。見てみます。
以下、コードです。
print(f"真の平均効果: {df['_true_cate'].mean():.0f}円")
以下、実行結果です。
真の平均効果: 413円
もう1つ見ておきます。効果は全員に同じだけあったのでしょうか。
以下、コードです。
print(f"効果の範囲: {df['_true_cate'].min():.0f} 〜 {df['_true_cate'].max():.0f}円")
print(f"効果がマイナスの人の割合: {(df['_true_cate'] < 0).mean():.1%}")
以下、実行結果です。
効果の範囲: -898 〜 1190円 効果がマイナスの人の割合: 7.6%
効果は人によって大きく違い、7.6%の人にはむしろ逆効果でした。「平均413円」の裏側には、これだけの幅があります。この「誰に、どれだけ効くのか」は連載の第3回で扱います。
ありがちな失敗:予測精度の高いモデルで施策を決める
今回いちばん避けてほしい失敗を挙げておきます。
予測精度の高いモデルを、そのまま施策の判断に使ってしまう。
AUC 0.85のモデルができたから、その特徴量重要度を見て施策を決める。この進め方は、驚くほどよく見かけます。
見分け方はシンプルです。「この変数を人為的に動かせるか」を自問してください。
- 会員ランク、購入頻度、休眠日数 → こちらが直接動かせない(結果として動くだけ)
- クーポン配布、広告出稿、営業訪問 → 動かせる
動かせる変数についての問いなら、それは因果の問いです。予測モデルの守備範囲ではありません。
まとめ
今回のポイントは4つです。
- 「クーポンを使った人ほど買っている」は相関、「クーポンが買わせた」は因果。データを見れば分かるのは前者だけ
- 差が過大に出る原因は交絡。会員ランクが、配布と購入金額の両方に影響していた
- 一人の人の「もし配らなかったら」は原理的に観測できない。これが因果推論の根本問題
- 単純比較 2,268円 → ランク層別 594円 → 本当の効果 413円。層別だけでは足りない

