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

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

特徴量のスパース性と特徴量重要度の関係性について調査しました。

こんにちは。データサイエンスグループの木下です。 今回は、スパースなカラムを含むデータにおける、二値分類モデルを作る際のモデルの性能に関して実験してみました。

背景

マーケティングの世界では、施策の効果を評価するために、 性別や年代などのデモグラフィック情報や、オンライン・オフラインの行動データを活用し、 特定の施策に対する反応を予測する二値分類モデルが用いられることがあります。

それらの説明変数の中で、行動データは特定期間内で0になるユーザーが多く、疎(スパース)なデータになっていることが想定されます。一方、デモグラフィックデータは基本的に全てのユーザーのデータを有しているので、密なデータになっているという状況を考えました。

このアンバランスなデータを学習データとしてLightGBMで決定木のモデルを作る場合、密なデータの特徴量重要度が過大評価されてしまうのではないか、という仮説を立てました。 それを検証するべく、機械学習の教材としてよく使われているtitanic生存予測のデータセットを用いて検証しました。

検証

元データでのモデル作成

まずは、元データでのモデル作成について簡単に説明します。 後ほどの検証のための準備が目的なので、最短でLightGBMでモデルを構築することを優先します。 やっていることは、使用するカラムを絞り、その中からカテゴリ変数を指定し、まとめてエンコーディングをして 最後にモデルに突っ込んでいるだけです。

ライブラリのインストールとインポート

まず、上述したように、今回はモデルにLightGBMを用います。 また、特徴量重要度にはSHAPを用います。 使用した各種ライブラリのバージョンは下記になります。 lightgbm==4.5.0 shap==0.46.0

そして、下記をインポートします。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

import lightgbm as lgb

from sklearn import metrics
from sklearn.model_selection import train_test_split,StratifiedKFold
from sklearn.preprocessing import OrdinalEncoder

import shap

学習データの準備

ここのtrain.csvはタイタニックの学習データになりますので、任意の位置に格納してください。

# データ読み込み
df = pd.read_csv('train.csv')

# 使用する特徴量
target = 'Survived'
cat_features = ['Pclass','Sex','Embarked']
num_features = ['Age','Fare','SibSp','Parch']
features = cat_features + num_features
df = df[features + [target]]

# 前処理
oe = OrdinalEncoder()
df[cat_features] = pd.DataFrame(oe.fit_transform(df[cat_features]),columns=cat_features)
df = df.astype('float')

作成されたデータは下記のとおりです。

titanicの前処理済みデータ

モデルの実行と結果の表示

モデルの実行と結果の表示はこの後に何度か行うので、 使いやすいようにmake_model関数を作成して処理をまとめました。 5分割してクロスバリデーションを行い、AUCとSHAP値はその平均値を出力するようにしております。

念のため、元のデータでもモデルを実行しておきます。

# 関数の作成
def make_model(df,features,cat_features,target):
  auc_arr = []
  shap_arr = []

  skf = StratifiedKFold(n_splits=5)

  for i,(train_idx,test_idx) in enumerate(skf.split(df[features], df[target])):
    X_train = df[features].iloc[train_idx]
    X_test = df[features].iloc[test_idx]
    y_train = df[target].iloc[train_idx]
    y_test = df[target].iloc[test_idx]

    params = {
      'objective': 'binary',
      'metric': 'auc',
      'verbose': -1
      }

    model = lgb.LGBMClassifier(**params)

    model.fit(X_train,
              y_train, 
              eval_set=(X_test,y_test),
              categorical_feature=cat_features, 
              callbacks=[lgb.early_stopping(stopping_rounds=20,verbose=True),
                         lgb.log_evaluation(0)]
              )


    y_pred = model.predict_proba(X_test, 
                                 num_iteration=model.best_iteration_)[:,1]
    
    fpr, tpr, thresholds = metrics.roc_curve(y_test, y_pred)
    auc = metrics.auc(fpr, tpr)

    explainer = shap.TreeExplainer(model=model)
    shap_values = explainer(X_test)
    values = np.abs(shap_values.values).mean(0)

    auc_arr.append(auc)
    shap_arr.append(values)
  
  mean_auc = np.mean(auc_arr)
  mean_shap = np.mean(shap_arr,axis=0)
  shap_dict = pd.Series(mean_shap,index=features).sort_values()

  print('auc:',mean_auc)

  shap_dict.plot(kind='barh',title='SHAP')

  return shap_dict

# 実行
shap_dict = make_model(df,features,cat_features,target)

実行結果はこうなりました。
auc:0.8767753161024355

df1:スパースデータでのモデル作成

では、ここから本題に入ります。 スパースなデータ(欠損値が多い特徴量)によるモデルの影響をシミュレーションするために まずはもとのtitanicデータから、行動データに該当するカラムを抽出し、ランダムにサンプリングして値を欠損させます。 すなわち、データが取得できていなかった、という状況に変換します。 欠損させる対象となるのは、SibSp,Parch,Pclass,Embarkedの4つのカラムになります。

欠損データの作成

データの欠損割合をratioという変数で定義します。 今回は80%にしたいので、ratio=0.8にします。 実装方法はこちらです。

# 欠損させる行動性カラム
reduce_col = ['SibSp','Parch','Pclass','Embarked']
# 欠損させる割合
ratio = 0.8

df1 = df.copy()

for idx,col in enumerate(reduce_col):
    df1.loc[df1.sample(frac=ratio,random_state=0).index,col] = np.nan

このdf1が欠損を含んだデータになります。

SibSp,Parch,Pclass,Embarkedを欠損させたデータ
ではこのデータでモデルを作成します。

モデルの実行

shap_dict1 = make_model(df1,features,cat_features,target)

結果はこちらです。
auc:0.8504582249914143

欠損がないSEX,FARE,AGEのSHAP値は0.3以上ですが、 それ以外の欠損を多く含むSibSp,Parch,Pclass,EmbarkedはSHAP値は0.2以下になっています。

多くのインスタンス(1行1行のこと)は、 SEX,FARE,AGEしか値が存在せず、それらのカラムのみで確率値を決定することになるので、 おのずとSHAP値が大きくなるのではないでしょうか?

df2:スパースデータの一部を除いてモデル作成

では、比較対象として 学習データからSibSp,Parch,Pclass,Embarkedが全て欠損しているカラムを除いて モデルを作ってみます。

対象カラムが全て欠損の行を削除

drop_idx = df1.query('Pclass.isnull()').query('Embarked.isnull()')\
    .query('SibSp.isnull()').query('Parch.isnull()').index
df2 = df1.drop(drop_idx)

学習データはこちらになります。 行数が減っていることが確認できました。

SibSp,Parch,Pclass,Embarkedが全て欠損しているカラムを除いたデータ
make_model(df2,features,cat_features,target)

では、このデータでモデルを作成してみます。

モデルの実行

shap_dict2 = make_model(df2,features,cat_features,target)

結果はこちらです。
AUC:0.851891996891997

df1とdf2の比較

AUCに関して、 欠損を多く含んだdf1のデータと一部を削除したdf2のデータで比べると、df2の方が少し改善されていることが分かります。 df1と比べて、df2は全ての行がSibSp,Parch,Pclass,Embarkedのいずれかは値が入っており、 モデルの分岐を作る際に、これらのカラムが少しでも精度改善に寄与したのではないかと考えています。

SHAP値のグラフはぱっと見では違いがあまり見られないので、 df1とdf2を重ねて表示してみました。

pd.concat([shap_dict1,shap_dict2],axis=1).plot(kind='barh',title='SHAP')
plt.legend(['df1','df2'])

df1に比べてdf2は、欠損を含んでいないSex,Fare,Ageに関しては、全てSHAP値が下がりました。 私の仮説通り、欠損を含んでいないカラムは、SHAP値が過大評価されてしまっているのではないでしょうか。

まとめ

今回は、titanicのデータセットをもとに、疑似的にスパースデータを作成して実験を行いました。 nullにする割合であるratioや、nullにする行のランダムサンプリングのシード値を変えるだけで、 結果も大きく変わりますので、一概にこれだけで結論付けることはできないとは思います。

ただ、このようなカラムによってデータの密度に大きな差がある場合には、 モデルを作成する際に注意が必要である、ということは提唱できたかと思います。 引き続き、研究を続けたいと思います。