EconMLで実践する ビジネス因果推論
(第1回)
「クーポンを使った人ほど買っている」は本当に効果か

EconMLで実践する ビジネス因果推論(第1回)「クーポンを使った人ほど買っている」は本当に効果か

クーポンを配った顧客の30日間の購入金額が、配らなかった顧客の1.8倍だった。

この数字を見て「クーポン施策は成功」と報告書に書いてしまう。

データ分析の現場で、最も頻繁に起きる間違いです。

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

  1. 「クーポンを使った人が買っている」と「クーポンが買わせた」は別の話です。前者はデータを見れば分かりますが、後者はデータを見ても分かりません。
  2. 差が大きく出る原因は、もともとよく買う優良顧客にクーポンが配られていたからかも。これを交絡(こうらく)と呼びます。
  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人分の会員データです。以下からダウンロードできます。

ec_coupon.csv

列名 意味 役割
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%にクーポンが配られていたため、「クーポンあり」の平均に、もともと高いゴールド会員の購入金額が大量に混ざり込んでいた。それだけのことです。

 

何が起きているのか:交絡という落とし穴

いま起きていることを図にすると、こうなります。

クーポン施策における交絡の構造会員ランク・購入頻度が、クーポンの配布されやすさと購入金額の両方に影響している。単純比較で見える2,268円は、本当の効果に会員ランク経由の遠回り分が足し算されたもの。なぜ単純比較では効果が過大に出るのか会員ランク・購入頻度交絡変数 Wクーポン配布施策 T30日購入金額成果 Y優良顧客ほど配られるもともとよく買う知りたいのはこの矢印だけ本当の効果単純比較の +2,268円 = 本当の効果 + 会員ランクを経由した遠回り分交絡変数を放置したまま平均を比べると、遠回りの経路が真ん中の矢印に足し算されて観測される。

会員ランクは、クーポンの配布されやすさと購入金額の両方に矢印を伸ばしています。この形になっている変数を交絡変数と呼びます。

交絡変数を放置したまま単純比較すると、遠回りの経路(会員ランク経由)が、真ん中の矢印に足し算されて観測されます。だから効果が過大に見えるのです。

よく使われるたとえがあります。

体調が悪い人ほど薬を飲む。だからデータを集めると「薬を飲んでいる人ほど体調が悪い」という結果になる。

このデータだけを見て「薬は体に悪い」と結論する人はいません。「もともと体調が悪いから飲んでいる」と誰でも分かるからです。ところが同じ構造でも、クーポンや広告や営業訪問の話になると、途端に見抜けなくなります。

 

なぜデータを見ても分からないのか

もっと根本的な問題があります。

本当に知りたいのは、クーポンを配った人がもし配られていなかったらいくら買ったか、です。この「もしも」の世界を反実仮想と呼びます。

反実仮想:観測できるのは片方の世界だけクーポンを配った会員Aさんについて、配った世界の購入金額6,200円は観測できるが、配らなかった世界の金額は記録が存在しない。両方を同時に観測することは原理的にできない。知りたいのは、観測できない「もしも」との差会員Aさんクーポンを配った配った世界6,200円実際に起きたこと観測できる配らなかった世界?円起こらなかったので記録がない観測できないこの差がAさんへの本当の効果一人の人について、配った場合と配らなかった場合を同時に見ることはできないこれが因果推論の根本問題。だから「観測できたデータから推し量る」道具立てが必要になる。

因果推論とは、この観測できない「もしも」を、観測できたデータから推し量るための道具立てのことです。連載の第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円。層別だけでは足りない