CCCMKホールディングス TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

因果探索と因果推論を調べて試してみました~ベイジアンネットワーク・ランダムフォレストで~

こんにちは、技術開発ユニットの三浦です。

最近出張で久しぶりに飛行機に乗りました。搭乗時間の間は、座先に備え付けられているディスプレイで色々なコンテンツを見て過ごしました。映画やアニメ、ドキュメンタリーに加えてYouTube系のコンテンツも用意されていて、見られるコンテンツも以前と比べてだいぶバラエティに富んでいるんだな、と感じました。

今回は最近調べている因果探索および因果推論の話について、まとめてみたいと思います。

因果関係を考える必要性

2つの変数が「因果関係にある」とは、どちらかが原因で、どちらかがそれによって生じている場合に言います。

因果関係を考えなければならない理由を簡単な例で見てみます。 2つの異なる商品AとBがあり、顧客一人一人のAとBの購入金額を集計しその結果が以下のように可視化されていたとします。

f:id:miu4930:20220321091652p:plain
Aの購入金額とBの購入金額を表す散布図

AとBの購入金額には正の相関があることが相関係数からも分かります。つまりAの購入金額が高い人はBの購入金額も高い傾向にあります。またこの結果により、Aの購入金額は分かるがBの購入金額が分からない、あるいはまだBを購入したことがない顧客のBの購入金額を、データからある程度予想することも出来そうです。

一方、ある顧客のAの購入金額を今よりも100円高くすることが出来たら、その顧客のBの購入金額もきっと高くなるはずだ、と考えることはどうでしょうか?もしこの考えが成り立つのであれば、Aを今よりも購入してもらえる施策を実施することで、AだけでなくBももっと購入してもらえそうです。

しかし、今回のデータに於いてはこの考え方は成り立ちません。AとBの購入金額の間には相関関係はあるが、因果関係はないからです。先ほどの散布図に顧客の年齢を重ねた図が以下です。

f:id:miu4930:20220321113947p:plain
AとBの購入金額の散布図に年齢を重ねた図
散布図の右上に向かうに従って年齢も増加していきます。さらに同じ年齢層の顧客が集まっている領域に注目して見ると、AとBの購入金額にはあまり相関がなさそうです。

f:id:miu4930:20220321114056p:plain
同じ年齢層の部分を抜き出すと相関関係がなさそうに見えます

このデータは、以下のPythonのプログラムで生成しています。ポイントは、AとBの購入金額を計算する式において登場する変数が年齢age(と誤差)のみである点です。つまり年齢が決まるとAとBの購入金額はそれぞれ独立した誤差によって決定されます。AとBの購入金額に因果関係はありません。

#誤差
e_A = np.random.normal(scale=30,size=2000) 
e_B = np.random.normal(scale=30,size=2000)

#年齢は一様分布
age = np.random.randint(low=20, high=80, size=2000)

#Aの購入金額
amount_A = 100 * np.log(age) + e_A

#Bの購入金額
amount_B = 150 * np.log(age) + e_B

A, Bの購入金額amount_A, amount_Bと年齢ageの因果関係を図示すると次のようになります。

f:id:miu4930:20220321093120p:plain
因果ダイアグラム

この図のように、変数間の因果関係を矢印であらわしたグラフを因果ダイアグラム(Causal diagram)と呼びます。因果ダイアグラムはDAG(Directed Acyclic Graph)という、各変数(ノード)が向きがある線(エッジ)で接続され、どのノードからスタートしても元のノードに戻ることが出来ない非巡回な構造を持つグラフです。

与えられたデータから、変数間の関係性を読み解いて適切な因果ダイアグラムを見つけるステップが因果探索(Causal discover)、変数間の因果関係の強さを推計するステップが因果推論(Causal inference)です。

商品A, Bの購入金額と年齢の例

先ほどのデータを使って、因果探索、因果推論を試してみます。なお、今回の記事を書くにあたりこちらの書籍を参考にさせて頂きました。

  • 小川雄太郎
  • つくりながら学ぶ! Pythonによる因果分析 ~因果推論・因果探索の実践入門
  • マイナビ出版
  • 2020/6/30

上記の書籍にも紹介されている、ベイジアンネットワークによる因果探索、ランダムフォレストによる因果推論を使って進めてみます。

因果探索

因果探索の方法は色々ありますが、ここではベイジアンネットワークという手法を使います。ベイジアンネットワークはPythonのライブラリpgmpyで利用することが出来ます。

pgmpy.org

pipでインストールすることが出来ます。pgmpyでは探索した因果ダイアグラムをグラフで可視化する機能がないので、グラフ描画用にpyvizというライブラリも使いました。こちらもpipでインストールすることが出来ます。

pyviz.org

ベイジアンネットワークのグラフ探索方法にはいくつかのパターンがありますが、今回は条件付き独立性検定による構造学習を試してみました。条件付き独立性は、ある条件を決めると2つの変数が独立になることです。例えば年齢を決めるとAとBの購入金額は独立になるので、AとBは条件付き独立です。独立かどうかはカイ二乗検定で確かめられます。変数間の条件付き独立性に基づいてグラフの構造を探索していく方法が条件付き独立性検定による構造学習です。

条件付き独立性検定による構造学習を行うアルゴリズムとしてPCアルゴリズムがあります。pgmpyでもこのアルゴリズムが実装されています。

カイ二乗検定による独立性検定の際には変数は離散値である必要があるので、最初に変数の離散化を行います。それぞれ値を20分割に離散化しました。

import pandas as pd

#因果探索用に離散化データを作成する。
#あとで使うので、離散化前のデータも保持しておく。
data_org = pd.DataFrame({'amount_A':amount_A, 'amount_B':amount_B, 'age':age})
data_disc = pd.DataFrame({'amount_A':amount_A, 'amount_B':amount_B, 'age':age})

data_disc['amount_A'] = pd.cut(data_disc['amount_A'],20,labels=False)
data_disc['amount_B'] = pd.cut(data_disc['amount_B'],20,labels=False)
data_disc['age'] = pd.cut(data_disc['age'],20,labels=False)

data_disc.head()

出力結果

f:id:miu4930:20220321104950p:plain
離散化後のデータ

このデータを使って因果探索を行っていきます。まずノード間が方向のないエッジで接続された、因果ダイアグラムのベースとなるスケルトンというグラフを生成します。

est = PC(data_disc)
skel, seperating_sets = est.build_skeleton()
print(skel.edges())

結果

[('amount_A', 'age'), ('amount_B', 'age')]

pgmpyでは上記の結果のように、グラフの構造を接続されているノードのペアのリストで表現します。因果の方向は分かりませんが、Aの購入金額amount_Aと年齢age、Bの購入金額amount_Bと年齢ageの間に因果関係があることが分かります。ammount_Aamount_Bの間には直接の因果関係が無いことが、このタイミングで分かりました。

次に部分的にエッジに方向が付いた、PDAG(Partially Directed Acyclic Graph)というグラフをスケルトンをベースに求めます。

pdag = est.skeleton_to_pdag(skel, seperating_sets)
print(pdag.edges())

結果

[('amount_A', 'age'), ('age', 'amount_A'), ('age', 'amount_B'), ('amount_B', 'age')]

リストの要素のタプルの、左のノードがエッジの始点、右のノードが終点を表しています。どのエッジにも反対方向のエッジが存在しているため、今回のデータではこの段階で因果関係を決めることが出来ませんでした。

最後に全エッジの方向が確定した、DAGを求めます。

model = pdag.to_dag()
print(model.edges())

結果

[('age', 'amount_A'), ('amount_B', 'age')]

ageamount_Bの因果関係が逆に推定されました。結果としては間違った方向の因果ダイアログが求められたのですが、よくよく考えてみるとageamount_Bは式を変形するとどちらもそれぞれの原因になり得ます。データだけで因果関係の向きを正確に決定するのは難しいのかもしれません。最後は人の知見による確認や調整が必要そうです。この例で言えば、商品の購入金額によって顧客の年齢が決まるのではなく、顧客の年齢によって商品の購入金額が決まる、と考えるほうが自然です。

ということで、因果探索により因果ダイアログが求まりました。最後に以下のコードで可視化します。

from pyvis.network import Network

g = Network(directed=True)

g.add_node(0, label='amount_A')
g.add_node(1, label='amount_B')
g.add_node(2, label='age')
g.add_edge(2, 0)
g.add_edge(2, 1)

#以下を実行するとブラウザでグラフが表示されます
g.show('node.html')

f:id:miu4930:20220321093120p:plain
生成された因果ダイアグラム

次に変数間の因果関係の強さを因果推論によって求めていきます。

因果推論

因果探索により年齢によってAとBの購入金額が決まる、という因果関係が分かりました。次に仮にその顧客の年齢を+5することで、どれだけ商品の購入金額が変化するのかを因果推論によって求めてみたいと思います。ある顧客の年齢を意図的に変化させることは出来ませんが、モデルによって疑似的にシミュレーションすることは出来ます。年齢とBの購入金額の因果推論を行ってみます。

まず因果関係があることが分かっているageを使ってamount_Bを予測するモデルを学習します。ここではscikit-learnRandomForestRegressorを使いました。そして現状よりも5歳年齢を増加させた時のBの購入金額の予測値を、モデルにage+5を入力することで求めます。最後にその予測値と元のデータのBの購入金額との差を見て、年齢による因果関係の大きさを調べます。

#モデルを学習する
x_data = data_org[['age']]
y_data = data_org['amount_B']
rfr = RandomForestRegressor(max_depth=4)
rfr.fit(X=x_data,y=y_data)

#仮に年齢(age)を今よりも+5した際の商品Bの推計購入金額
plus_5_age = data_org[['age']] + 5
plus_5_age_pred = rfr.predict(plus_5_age)

#年齢+5によるAの購入金額の変化
causal_effect = plus_5_age_pred - y_data

年齢に対し、5歳年齢を増加した時のBの購入金額の変化量を可視化しました。年齢による変化量の推移を見るため、最小二乗法により求めた回帰直線を重ねています。

f:id:miu4930:20220321111204p:plain
年齢とその年齢に+5した時のBの購入金額の変化量を示すグラフ

仮に年齢を今よりも5歳高く出来たとすると、Bの購入金額は平均で16.25円程度高くなることが分かりました。回帰直線を見ると、年齢が高いほど年齢の因果効果は減少していくことが分かります。これはデータを生成する式がamount_B = 150 * np.log(age) + e_Bであり、ageを対数関数に通していることに起因することが考えられます。対数関数は入力値が大きいほど、入力値を変化させた時の値の増加量は減っていく傾向があるからです。

最後に、仮に年齢だけでなくAの購入金額もBの購入金額に因果関係があったと仮定した場合、その因果効果はどれくらいになるかを確認してみます。amount_Bの予測モデルを学習することは同様ですが、今度はageに加えamount_Aも説明変数にします。そしてageは固定したまま、amount_Aを今よりも仮に+100した時のamount_Bの変化量を求めてみます。

from sklearn.ensemble import RandomForestRegressor

#モデルを学習する
x_data = data_org[['age','amount_A']]
y_data = data_org['amount_B']
rfr = RandomForestRegressor(max_depth=4)
rfr.fit(X=x_data,y=y_data)

#仮に今よりもAを100円多く購入した際の商品Bの推計購入金額
plus_100_A = x_data.copy()
plus_100_A['amount_A'] += 100
plus_100_A_pred = rfr.predict(plus_100_A)

#Aの購入金額+100円によるBの購入金額の変化
causal_effect = plus_100_A_pred - y_data
print(causal_effect.mean())

結果

0.48436740056210414

Aを今よりも仮に100円多く購入してもらったとしても、Bの購入金額はごくわずかしか増加しません。この結果からもAの購入金額を多くしてもBの購入金額の増加にはつながらないことが確認出来ます。

今回は年齢という、人が介入できない要素を仮に変化出来たら・・・というシナリオだったため、あまり実用的な結果ではありません。しかし、購買を促すキャンペーンなど、人が介入できる要素であれば、もし仮にキャンペーンを実施していればこれだけ購入金額を増やすことが出来ただろう、とか、反対にもし仮にキャンペーンを実施していなければ今よりもこれだけ購入金額は低かっただろう、といった効果検証を行うことが出来ます。

まとめ

今回は最近調べていた因果探索・因果推論についてまとめてみました。

データの相関性を見ると、つい因果関係についても成り立つのではないか、考えてしまいがちです。ある程度その分野に関する前提知識や知見があれば、その因果関係が間違っていると気づくことが出来ますが、前提知識が乏しい場合にはその間違いに気づかないこともあります。因果探索・因果推論を行うことで因果関係の有無を明らかにする手助けになりそうです。

与えられたデータを機械学習のモデルで表現し、抽出された変数の因果関係を今回試した方法で明らかにする・・・そんな使い方をすることで、今よりももっと表現力のある分析が出来そうだと思いました。

おまけ

実はもう一つ、因果探索・因果推論を試した例を用意しようと思っていました。scikit-learnでも利用できる、ワインの品種分類用のデータセットを使って試してみた結果です。しかし私のワインや化学に関する知識の乏しさにより、出てきた結果の妥当性の判断が出来ず、お蔵入りにしました・・・。何度か因果探索を試してみて、何となくそれっぽいのかな・・・と思った因果ダイアログを最後に掲載させて頂きます。

f:id:miu4930:20220321113317p:plain
それぞれの成分の意味を理解し、因果関係が妥当かを確かめようとしたのですが途中で力尽きました。