CausalMLのCausalRandomForestClassifierを用いたアップリフトモデリング

# はじめに 
本稿の目的は、以下リンク(以下チュートリアル)先チュートリアルを学習し、Pythonのライブラリであるcausalmlを用いた所謂ツリーベースのアップリフトモデリングに関する理解を深めることです.

https://github.com/uber/causalml/blob/master/examples/causal_trees_interpretation.ipynb

私個人としては、ゆくゆくは以下のリンクのコンテンツに挑戦したく、本稿の位置付けはそのための基礎トレーニングです.

https://causal-machine-learning.github.io/kdd2021-tutorial/

# 環境など

- 私用Macパソコン(M2チップ)
- conda仮想環境
- VisualStudioCode

# ライブラリのインストール　

公式サイト(以下リンク)を参考にターミナル上で以下のコマンドを実行しました. 


https://github.com/uber/causalml/tree/master


```terminal 
$ conda create --name envforcausalml python=3.8
$ conda install -c conda-forge causalml 
```
::: note 
参考
消去法的に上記手法になった、という感じです. 
というのも、上記以外の方法も試みたのですが、エラーが解消できませんでした. 
:::

# 本編

## 関連ライブラリのインポート　
関連ライブラリをインポートするためのコードです. 

```python 
import pandas as pd
import numpy as np

import multiprocessing as mp
np.random.seed(42)
from sklearn.model_selection import train_test_split
from sklearn.inspection import permutation_importance
import shap
import causalml 
from causalml.metrics import plot_gain, plot_qini, qini_score 
from causalml.dataset import synthetic_data 
from causalml.inference.tree import plot_dist_tree_leaves_values, get_tree_leaves_mask 
from causalml.inference.tree import CausalRandomForestRegressor, CausalTreeRegressor 
from causalml.inference.tree.utils import timeit 
import matplotlib.pyplot as plt 
import seaborn as sns 
%config InlineBackend.figure_format = 'retina'
``` 

今回使用しないライブラリがインポートされていないとも限らないので、深入りせず、先へ進みます. 

## 合成データの生成 

以下のコードで合成データを作成します. 変数は全てndarrayで、Xは2次元配列, それ以外は1次元配列です. 

```python 
# Simulate randomized trial: mode=2 
y, X, w, tau, b, e = synthetic_data(
    mode=2, 
    n=2000, 
    p=10, 
    sigma=3.0
)
``` 
説明書きに記載のとおり、今回もRCT(Randomized Controlled Trial)による介入割当を前提としたデータであるようです. 

::: note
参考: Bingとのチャット
- causalmlのsynthetic_data()の引数について教えてください.

> synthetic_data() という関数は、人工的なデータを生成するためのものです。この関数には以下の引数があります。
> - mode: 人工データの生成方法を指定するパラメータです。1から4までの整数値を指定できます。デフォルトは1です。
> - n: 生成するデータのサンプル数を指定するパラメータです。デフォルトは10000です。
> - p: 生成するデータの特徴量の数を指定するパラメータです。デフォルトは8です。
> - sigma: 生成するデータのノイズの標準偏差を指定するパラメータです。デフォルトは1.0です。
random_state: 生成するデータの乱数シードを指定するパラメータです。デフォルトはNoneです。

- ノイズは正規分布に従うのでしょうか？ 

> はい、ノイズは正規分布に従います。synthetic_data() 関数のソースコードを見ると、ノイズは np.random.normal(0, sigma, size=n) というように生成されています。ここで、sigma は引数で指定した標準偏差です。正規分布の平均は0です。したがって、ノイズは平均0、標準偏差 sigma の正規分布に従うと言えます。

- mode=2の場合どのようなデータが生成されますか？ 
> mode=2 の場合、以下のようなデータが生成されます。
> 特徴量 X は、標準正規分布から独立にサンプリングされます。
> 処置変数 treatment は、X[:, 0] + X[:, 3] + np.random.normal(0, 0.01, size=n) に基づいて、ロジスティック回帰で確率を計算し、ベルヌーイ分布からサンプリングされます。
> アウトカム変数 y は、treatment * (X[:, 0] + X[:, 9]) + X[:, 1] + np.random.normal(0, sigma, size=n) として計算されます。
> 因果効果変数 tau は、X[:, 0] + X[:, 9] として計算されます。
> つまり、mode=2 の場合、特徴量の一部が処置割り当てやアウトカムに影響を与えることになります。また、因果効果は特徴量に依存することになります。


:::




続いて、これらをpandasデータフレームへ格納します. 

```python 
df = pd.DataFrame(X) 
feature_names = [f'feature_{i}' for i in range(X.shape[1])]
df.columns = feature_names 
df['outcome'] = y 
df['treatment'] = w 
df['treatment_effect'] = tau 
``` 
続いて、データを分割します. 
```python 
# Split data to training and testing samples for model validation
df_train, df_test = train_test_split(df, test_size=0.2, random_state=111) 
n_train, n_test = df_train.shape[0], df_test.shape[0] 

X_train, y_train = df_train[feature_names], df_train['outcome'].values 
X_test, y_test = df_test[feature_names], df_test['outcome'].values 
treatment_train, treatment_test = df_train['treatment'].values, df_test['treatment'].values 
effect_test = df_test['treatment_effect'].values 

``` 
effect_testという変数は何に使うのか気になりますね. 
sklearnのtrain_test_split()について、よく分からない点があったのでBingに確認してみました. 

::: note
参考: Bingとのチャット
- sklearn.model_selection.train_test_split()の返り値のデータ型はなんですか？

> 返り値のデータ型は、入力配列と同じです。入力がスパースの場合は、scipy.sparse.csr_matrixになります。それ以外の場合は、numpyの配列やpandasのdataframeなど、入力と同じタイプになります。

:::

ここで、データの中身を確認しておきたいと思います. 
まずはdf_trainです. 

|                  |      1696 |
|:-----------------|----------:|
| feature_0        | -1.59201  |
| feature_1        |  0.452005 |
| feature_2        |  0        |
| feature_3        | -0.466855 |
| feature_4        | -0.363785 |
| feature_5        | -0.770119 |
| feature_6        | -0.348852 |
| feature_7        |  1.03256  |
| feature_8        | -0.713953 |
| feature_9        |  0.545141 |
| outcome          |  0.831146 |
| treatment        |  1        |
| treatment_effect | -0.647534 | 

続いて、dfをoutcomeでピボット集計したものです.  


| treatment   |   ('size', 'outcome') |   ('sum', 'outcome') |   ('mean', 'outcome') |
|:------------|----------------------:|---------------------:|----------------------:|
| 0           |                  1009 |              830.695 |              0.823286 |
| 1           |                   991 |             1360.44  |              1.3728   |
| All         |                  2000 |             2191.14  |              1.09557  |

上表のとおり、outcomeの平均はおよそ0.5異なっているようです.  確認のために、dfをtreatment_effectでピボット集計します. 

| treatment   |   ('size', 'treatment_effect') |   ('sum', 'treatment_effect') |   ('mean', 'treatment_effect') |
|:------------|-------------------------------:|------------------------------:|-------------------------------:|
| 0           |                           1009 |                       895.044 |                       0.88706  |
| 1           |                            991 |                       781.618 |                       0.788716 |
| All         |                           2000 |                      1676.66  |                       0.838331 |



# CausalTreeRegressorの実行 

ここでは以下のとおり、causalml.inference.tree.CausalTreeRegressorで学習を行います. 


```python 
ctree = CausalTreeRegressor()
ctree.fit(X=X_train.values, y=y_train, treatment=treatment_train)
```
公式サイトのコードを見ると、CausalTreeRegressor()の引数control_nameはデフォルトが0になっているようでした. 今回はデフォルト値で問題なさそうです. 

# CausalRandomForestClassifierの実行 
続いてCausal Random Forestを実施します. 

```python 
crforest = CausalRandomForestRegressor(
    criterion="causal_mse", 
    min_samples_leaf=200, 
    control_name=0, 
    n_estimators=50, 
    n_jobs=mp.cpu_count() - 1
)
crforest.fit(X=X_train, y=y_train, treatment= treatment_train)
```
# 不純度ベースの特徴量重要度の計算 

以下のように特徴量の重要度をツリーモデル、フォレストモデルそれぞれについて対応する列に格納し、pandasデータフレームを作成します. 

```python 
df_importances = pd.DataFrame(
    {'tree': ctree.feature_importances_, 
     'forest': crforest.feature_importances_, 
     'feature': feature_names
     }) 

``` 

ツリーモデルやランダムフォレストでは、上記コードのように特徴量の重要度が.feature_importances_に格納されているようです. 

続いてランダムフォレストについて標準偏差を計算します. 

```python 
forest_std = np.std(
    [tree.feature_importances_ for tree in crforest.estimators_], 
    axis=0
)
``` 
なお、crforest.estimators_には50個の要素が格納されており、ランダムフォレスト分類器をインスタンス化する際の引数n_estimatorsに紐づいているようです. 

以下は参考です. 
```python 
print(type(crforest.estimators_))
print(len(crforest.estimators_))
print(type(crforest.estimators_[0])) 

"""
<class 'list'>
50
<class 'causalml.inference.tree.causal.causaltree.CausalTreeRegressor'>
"""
``` 

続いて、Causal Treeの特徴量重要度を棒グラフで表示します. 以下はそのコードです. 

```python 
fig, ax = plt.subplots() 
df_importances['tree'].plot.bar(ax=ax) 
ax.set_title("Causal Tree feature importances") 
ax.set_ylabel("Mean decrease in impurity") 
ax.set_xticklabels(feature_names, rotation=45) 
plt.show()

``` 
上記のコードの実行結果は下図のとおりです. 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/251e4ba6-7198-46b1-9c8f-5f04a3528b9a.png)

同様に、Causal Random Forest についても描画します. 以下はコードです. 
```python 
fig, ax = plt.subplots() 
df_importances['forest'].plot.bar(yerr=forest_std, ax=ax)
ax.set_title("Causal Forest feature importances") 
ax.set_ylabel("Mean decrease in impurity") 
ax.set_xticklabels(feature_names, rotation=45) 
plt.show()
``` 
下図は上記コードの実行結果です. 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/f6890518-88cb-b109-cea1-6a8a151099ae.png)　

Causal Random Forestの場合棒グラフ上にエラーバーが表示されていることが分かります. これは標準誤差でしょうか. 不純度ベースの特徴量重要度とは一体何を指すのかイマイチ判然としませんが、全ての特徴量について、エラーバーの範囲が0.0を含んでいます. つまり今回のケースですと、サンプル数不足で不純度ベースの特徴量重要度については何も言えないということでしょうか. 

# Permutation importance　

今度はpermutation importanceと呼ばれる数値を計算し、不純度ベースの特徴量重要度と同様に、棒グラフへ描画します. 

permutation importanceとは、任意の特徴量をレコード間で無作為にシャッフルした場合の精度悪化の度合いをもとに算出された特徴量重要度のことみたいです. 

以下はその計算及び描画のためのコードです. 

```python 
for name, model in zip(('Causal Tree', 'Causal Forest'), (ctree, crforest)): 
    imp = permutation_importance(model, X_test, y_test, 
                                 n_repeats=50, 
                                 random_state=0)
    fig, ax = plt.subplots() 
    ax.set_title(f'{name} feature importances') 
    ax.set_ylabel('Mean decrease in impurity') 
    plt.bar(feature_names, imp['importances_mean'], yerr=imp['importances_std']) 
    ax.set_xticklabels(feature_names, rotation=45) 
    plt.show()

``` 
permutation_importance()は冒頭にインポートしたsklearnの関数です. 返り値には標準誤差(標準偏差?)のフィールドも入っているようですね. 
下図は上記コードの実行結果です. 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/6b11f56b-889c-007d-821c-eaaca49a9905.png)  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/0f35542d-f0d5-bd6b-562e-2a3076e097f8.png)

今度は標準誤差(偏差?)が0.0から離陸することができたようです. 
先ほどBingチャットに教えてもらった真のモデルの生成ロジックと照らし合わせると違和感があるのでもとのコードを確認したところ以下のようになっていました.  

```python 
def simulate_randomized_trial(n=1000, p=5, sigma=1.0, adj=0.0):
    """Synthetic data of a randomized trial
        From Setup B in Nie X. and Wager S. (2018) 'Quasi-Oracle Estimation of Heterogeneous Treatment Effects'
    Args:
        n (int, optional): number of observations
        p (int optional): number of covariates (>=5)
        sigma (float): standard deviation of the error term
        adj (float): no effect. added for consistency
    Returns:
        (tuple): Synthetically generated samples with the following outputs:
            - y ((n,)-array): outcome variable.
            - X ((n,p)-ndarray): independent variables.
            - w ((n,)-array): treatment flag with value 0 or 1.
            - tau ((n,)-array): individual treatment effect.
            - b ((n,)-array): expected outcome.
            - e ((n,)-array): propensity of receiving treatment.
    """

    X = np.random.normal(size=n * p).reshape((n, -1))
    b = np.maximum.reduce([np.repeat(0.0, n), X[:, 0] + X[:, 1], X[:, 2]]) + np.maximum(
        np.repeat(0.0, n), X[:, 3] + X[:, 4]
    )
    e = np.repeat(0.5, n)
    tau = X[:, 0] + np.log1p(np.exp(X[:, 1]))

    w = np.random.binomial(1, e, size=n)
    y = b + (w - 0.5) * tau + sigma * np.random.normal(size=n)

    return y, X, w, tau, b, e

``` 
上記だと、outcomeにはインデックス0〜4番目の特徴量のみが使用されているようです. またプロペンシティスコアは定数0.5です. どうやら生成系AIに出まかせを吹き込まれていたようです. 

ともあれ、こちらの棒グラフは、真のモデルと平仄がとれていそうな佇まいであることが分かりました. 

# SHAP値 

続いて、SHAP値です. 

以下のコードでは、レコードごとのSHAP値を特徴量ごとに打点します. 

```python 
# The error "Model type not yet supported by TreeExplainer" will occur 
# if causalml support PR is not closed yet. 
# git clone https://github.com/alexander-pv/shap.git && cd shap
# pip install .
import shap
tree_explainer = shap.TreeExplainer(ctree) 
shap.initjs() 
shap.summary_plot(tree_explainer.shap_values(X_test)[0], X_test, plot_type="dot")
``` 

shap_values()[0]のように最初のインデックスを指定する必要があるようで、チュートリアルどおりのコードだとうまくいかなかったので修正しました.  shap_values(X_test)は2要素あり、実は2要素目が何なのか、イマイチ理解が出来ていないのですが先に進みます. 下図は上記コードの実行結果です. 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/8f4ea4bc-2d6a-8c11-8c0e-6b62ebe24cbe.png)

これは一見すると何が言いたいのか分かりませんね. 
続いてCausal Random Forestについても同様の手順を実施します. 以下はそのためのコードです. 

```python 
cforest_explainer = shap.TreeExplainer(crforest)
shap.initjs()
shap.summary_plot(cforest_explainer.shap_values(X_test)[0], X_test)

```
以下は実行結果です. 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/31dd8722-4c59-eaec-81bb-3ee42ea53fd2.png)

これはよく分かりませんね. 例えば真のモデルではfeature_0が使われていますが、このグラフを見ると説明力があるのかないのか判然としません. また、そもそも論ですが、チュートリアルの描画と全く異なります. そこで、shap_values(X_test)[1]で再描画してみました. 以下はそのコードです. 


```python 
shap.summary_plot(cforest_explainer.shap_values(X_test)[1], X_test)
``` 
下図は上記コードの結果です. 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/5fed2580-9fb0-cb1a-a757-ed6a00c3d67c.png)

ここで、shap.TreeExplainer()の挙動を知るために公式サイト（以下リンク）を確認しました. 

https://shap-lrjball.readthedocs.io/en/latest/generated/shap.TreeExplainer.html


shap.TreeExplainer().shap_values()の返り値はlistかarrayであるようです. アウトプットがベクトルであるようなモデルの場合返り値はリストになり、リストの各要素はそれぞれのアウトプットに対応するshap値の行列になるそうです. 今回は返り値が要素数2のリストになっているので、モデルのアウトプットがベクトルということになりますね. 

以下はpredict()のコードで,返り値は1個, 又は3個になるように見えます. 



```python 
    def predict(self, X: np.ndarray, with_outcomes: bool = False) -> np.ndarray:
        """Predict individual treatment effects

        Args:
            X (np.matrix): a feature matrix
            with_outcomes (bool), default=False,
                                  include outcomes Y_hat(X|T=0), Y_hat(X|T=1) along with individual treatment effect
        Returns:
           (np.matrix): individual treatment effect (ITE), dim=nx1
                        or ITE with outcomes [Y_hat(X|T=0), Y_hat(X|T=1), ITE], dim=nx3
        """
        if with_outcomes:
            self.n_outputs_ = self.max_outputs_
            for estimator in self.estimators_:
                estimator._with_outcomes = True
        else:
            self.n_outputs_ = 1
        y_pred = super().predict(X)
        return y_pred
``` 

残念ながらこれ以上のことは分かりませんでしたが、先に進みたいと思います. 
以下では、shap.dependence_plotで、各特徴量のshap値について詳しく確認することができます. 以下はそのためのコードとその実行結果です. 


```python 
shap.dependence_plot("feature_0", 
                     cforest_explainer.shap_values(X_test)[1], 
                     X_test, 
                     interaction_index="feature_2")
``` 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/6f610c71-2c7e-1fbb-b749-01d3ce449d86.png)

feature_0とそのSHAP valueには正の相関があるといってよさそうです. またfeature_0とfeature_2に相関がありそうなことも見て取れます.ちなみにshap_values(X_test)[0]だとこうなります. 

```python 
shap.dependence_plot("feature_0", 
                     cforest_explainer.shap_values(X_test)[0], 
                     X_test, 
                     interaction_index="feature_2")
``` 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/05d1ec2b-63f1-2890-aacc-19d57da1118b.png)

上図を見ると、feature_0は数値が負のときにshap値と負の相関を持っているように見えますね. 数値が小さいほど、予測値の押し上げ要因になるということでしょうか. 真のモデルではそのようになっていなかったのでイマイチ釈然としません. 

チュートリアルは以上です. 
最後にアップリフトカーブを描画して終了したいと思います. 以下はそのコードと実行結果です. 



```python 
usecols = [
    'treatment', 
    'outcome'
]
auuc_metrics = df_test[usecols]
pred_uplift = crforest.predict(X=X_test, with_outcomes=True)
auuc_metrics['pred_uplift'] = pred_uplift[:, 0] 

from causalml.metrics import plot_lift, plot_gain 

plot_lift(auuc_metrics, 
          outcome_col='outcome', 
          treatment_col='treatment'
          )

``` 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276668/e82ecf84-3d12-49d2-d33f-efd04f30969b.png)

uplift降順に並び替えて最初の50人あたりまでが施策効果が平均以上に効いているようです. 

# まとめ 

- causalmlのCausalRandomForestClassifierを用いて学習、予測タスクを行うことができた
- causalmlのCausalRandomForestClassifierのfeature_importancesやsklearnのpermutation importance、shapのshap_valuesを用いて特徴量の重要度を可視化することができた
- SHAP値の可視化については、その数値の生成過程に不可解な点があり、未解決 
- UpliftRandomForestClassifierとCausalRandomForestClassifierの棲み分けは不明