SIGNATE 【第1回_Beginner限定コンペ】銀行の顧客ターゲティング の解法

# はじめに
機械学習コンペの国内プラットフォームのひとつであるSIGNATEにて、2020年8月に開催された「【第1回_Beginner限定コンペ】銀行の顧客ターゲティング」に参加したので、自分の備忘録も兼ねて解法を記載します。**なお、特にオリジナリティのある解法を載せてるわけではありません。機械学習 初学者の方の参考になれば幸いです**（あと文章長いです）。

# Begginer限定コンペ
SIGNATEではコンペの成績によって称号が与えられますが、SIGNATEに登録した時点の称号が「Begginer」になります。今回のコンペは一番下のBegginerクラスの人のみが参加できるコンペでした（Begginer限定コンペというのは今回が初開催のようです）。

通常、Begginerから次の称号Intermediateにはコンペに参加し、1回でも上位60%に入れば昇格となりますが、今回のコンペでは指定スコアを達成すれば、その時点でIntermediateに自動昇格させてくれるという趣旨のコンペになります。

私もSIGNATEは登録しかしておらず、Begginerだったので参加してみました。

![piramid.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692165/0a3c8490-9d1c-1882-50a4-c2fba5d29c12.png)


#コンペ概要
ある銀行が実施したキャンペーンの結果、顧客が口座を開設したのかどうかを、顧客属性データおよび、過去のキャンペーンでの接触情報などをもとに予測します。機械学習の中ではいわゆる「分類」にあたる問題です。

提供されたデータは以下の通り。trainデータが27100レコード、testデータが18050レコードでした。

|カラム|ヘッダ名称|データ型|説明|
|:---:|:---|:---|:---|
|0|id|int|行の通し番号|
|1|age|int|年齢|
|2|job|varchar|職種|
|3|marital|varchar|未婚/既婚|
|4|education|varchar|教育水準|
|5|default|varchar|債務不履行があるか（yes, no）|
|6|balance|int|年間平均残高（€）|
|7|housing|varchar|住宅ローン（yes, no）|
|8|loan|varchar|個人ローン（yes, no）|
|9|contact|varchar|連絡方法|
|10|day|int|最終接触日|
|11|month|char|最終接触月|
|12|duration|int|最終接触時間（秒）|
|13|compaign|int|現キャンペーンにおける接触回数|
|14|pdays|int|経過日数：前キャンペーン接触後の日数|
|15|previous|int|接触実績：現キャンペーン以前までに顧客に接触した回数|
|16|poutcome|varchar|前回のキャンペーンの成果|
|17|y|boolean|定額預金申し込み有無（1:有り, 0:無し）|


# 実行環境
OS: Windows10
プロセッサ: core i7 5500U
メモリ:16GB
Anaconda3環境(Python3.7.6)

#ディレクトリ構成
Bank_Prediction
　├ notebook/ ●●●.ipynb
　├ input/ train.csv、test.csv
　└ output/ ここに予測結果を出力

# 予測モデル作成の流れ
以下の順序で予測モデルを作成していきます。

※入力値に対してある予測値を出力する変換器を、ここでは予測モデルと定義します。

1．EDA（探索的データ分析）
2．データ前処理（Data Preprocessing）
3．学習と予測
4．結果

# 1．EDA（探索的データ分析）
まず初めに、与えられたデータの構造や特徴を確認するための分析を行います。
なお、記事上は簡素化のため、testデータのEDA結果については割愛します。

### データの読み込み
```python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from pandas.plotting import scatter_matrix
import seaborn as sns

#最大表示列数50に設定
pd.set_option('display.max_columns', 50)

#各種データの読み込み
train = pd.read_csv("../input/train.csv")
test = pd.read_csv("../input/test.csv")
```

### レコード数、特徴量数、データ型、欠損値有無の確認
```
train.info()
```
![info.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692165/12e2f17b-6d45-e948-0856-0c20a0ad1b37.png)
レコード数が27100、特徴量数が18、どの特徴量が数値変数、カテゴリカル変数なのかが分かりました。また、今回の与えられたデータに欠損値はないようです。今回のデータはコンペ用に作られたデータのようなので、欠損値がない綺麗なデータになっていますが、現実ベースのデータだと欠損値だらけで補完処理をするのが一般的です。

###基本統計量の確認
```python
train.describe()
```
![describe.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692165/5385f315-f3c2-30a9-433c-62572de28ed1.png)

###各特徴量のヒストグラム確認
```
train.hist(figsize=(20,20), color='r')
```
![histgram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692165/b96ee6e6-0c43-015a-aa3f-a6322a7c4f20.png)
yが口座開設の有無ですが、開設(1)が未開設(0)に対して非常に少なく、不均衡データになっていることが分かります。

###相関係数
```python
colormap = plt.cm.RdBu
plt.figure(figsize=(14,12))
plt.title('Pearson Correlation of Features', y=1.05, size=15)
sns.heatmap(train.select_dtypes(exclude='object').astype(int).corr(),linewidths=0.1,vmax=1.0, vmin=-1.0, 
            square=True, cmap=colormap, linecolor='white', annot=True)
```
![corr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692165/2d714615-1264-1aad-5a68-adcd184330f5.png)
特徴量の中ではprevious(これまでの顧客との接触回数)が最も、口座開設有無 yと相関がありそうです。

###特徴量間分布の確認
```python

g = sns.pairplot(train, hue='y', palette = 'seismic',size=1.2,diag_kind = 'kde',diag_kws=dict(shade=True),plot_kws=dict(s=10) )
g.set(xticklabels=[])
```
![histgram2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692165/1902fd86-8a5f-e1a1-9364-a311a88801ec.png)

青が口座未開設の顧客、赤が口座を開設した顧客の分布です。対角線のヒストグラムのageを見ると、若い人ほど口座開設しない傾向が強いようです。また、day(最終接触日)でも分布の違いが見受けられます。

###各カテゴリカル変数の要素数の確認
```python

for _ in range(len(train.select_dtypes(include='object').columns)):
    print(train.select_dtypes(include='object').columns.values[_])
    print(len(train.select_dtypes(include='object').iloc[:,_].value_counts().to_dict()))
    print(train.select_dtypes(include='object').iloc[:,_].value_counts().to_dict())
```
![category.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/692165/5f1489db-f3a6-92d2-4375-78d184132741.png)
各カテゴリカル変数に含まれる要素数を確認できました。

# 2．データ前処理(Data Preprocessing)
ここからは、予測モデルの作成に向けたデータ前処理(Data Preprocessing)を行っていきます。

### 特徴量の追加
まず、カテゴリカル変数に関して、ローンに関わる3つの特徴量を組み合わせた特徴量を1つ追加しました。

数値変数に関しても特徴量の追加を行いました。ここでは、既存の各特徴量の平均と各レコードの特徴量の差分を取った特徴量を追加しました。特徴量を2乗したもの、3乗したものを新規に特徴量として追加することで汎化性能が上がることもあるそうですが、今回はそこまでは試しませんでした。

```python
# trainデータとtestデータをマージ
train2 =  pd.concat([train,test],axis=0)

#特徴量追加
train2['default_housing_loan'] = train2['default'].str.cat([train2['housing'],train2['loan']], sep='_')
train2['age_median'] = train2['age'] - train2['age'].median()
train2['day_median'] = train2['day'] - train2['day'].median()
train2['duration_median'] = train2['duration'] - train2['duration'].median()
train2['campaign_median'] = train2['campaign'] - train2['campaign'].median()
train2['previous_median'] = train2['previous'] - train2['previous'].median()
```

### Label Encoding
カテゴリカル変数は、そのままでは学習データとして予測モデルに入力できないため、エンコーディングする必要があります。エンコーディング手法にはいくつかありますが、今回学習に使うアルゴリズムが勾配ブースティング木のためLabel Encodingを使います（「回帰」などの問題を解く場合は、One-Hot-Encodingのほうが良い）。

以下に、特徴量のmaritalをLabel Encodingする例を示します。

married → 0
single → 1
divorced → 2

```python
#Label Encoding
from sklearn.preprocessing import LabelEncoder

category = train2.select_dtypes(include='object')

for col in list(category):
  le = LabelEncoder()
  le.fit(train2[col])
  le.transform(train2[col])
  train2[col] = le.transform(train2[col])
```


# 3．学習と予測
与えられたデータに対するデータ前処理が完了したので、学習と予測を行っていきます。学習において使用するアルゴリズムはLightGBMです。今回は、学習データと検証データに分割するときの乱数を変えて20個のモデルを作り、それぞれの予測値の平均を取り、最終的な予測結果としました（Random Seed Average）。なお、ハイパーパラメータのチューニングはOputunaで行っています。

~~また、**不均衡データのため、LightGBMのparamsに「'class_weight': 'balanced'」を指定**しました。~~
**（訂正）AUCはデータの偏りの影響を受けない評価指標のため不要でした。また、class_weightをパラメータとして指定できるのはLightGBMClassiefierでした。**

```python:train&predict

# lightgbmのインポート
import optuna.integration.lightgbm as lgb #Optunaでハイパラチューニング
from sklearn.model_selection import  train_test_split
import datetime

#マージしていたtrain2を再度train、testに分割
train = train2[:27100]
test = train2[27100:].drop(['y'],axis=1)

# trainの目的変数と説明変数の値を取得
target = train['y'].values
features = train.drop(['id','y'],axis=1).values

# testデータ
test_X = test.drop(['id'],axis=1).values

lgb_params = {'objective': 'binary',
              'metric': 'auc', #コンペ指定の評価指標がAUC
              #'class_weight': 'balanced' #ここは不要でした
             }

# Random seed average 20回
for _ in range(20):

    # trainを学習データと検証データに分割
    (features , val_X , target , val_y) = train_test_split(features, target , test_size = 0.2)


    #LightGBM用データセットの作成
    lgb_train = lgb.Dataset(features, target,feature_name = list(train.drop(['id','y'],axis=1))) #学習用
    lgb_eval = lgb.Dataset(val_X, val_y, reference=lgb_train) #Boosting用
    
    #カテゴリ変数の指定
    categorical_features = ['job', 'marital', 'education', 'default', 'balance','month',
                            'housing', 'loan','poutcome', 'default_housing_loan']

    #学習
    model = lgb.train(lgb_params, lgb_train, valid_sets=lgb_eval,
                      categorical_feature = categorical_features,
                      num_boost_round=1000,
                      early_stopping_rounds=20,
                      verbose_eval=10)

    pred = model.predict(test_X) #口座申込み確率値
    
    #各予測結果を格納
    if _ == 0:
        output = pd.DataFrame(pred,columns=['pred' + str(_+1)])
        output2 = output
    
    else:
        output2 = pd.concat([output2,output],axis=1)
    
    #forの終わり

#各予測結果を平均
df_mean = output2.mean(axis='columns')
df_result = pd.concat([test['id'],df_mean],axis=1)

#時刻をファイル名に付けてエクスポート
now = datetime.datetime.now()
df_result.to_csv('../output/submission' + now.strftime('%Y%m%d_%H%M%S') + '.csv',index=None,header=None)
```

# 4．結果
コンペで指定されているスコア（AUC）が0.85でしたが、私の**最終スコアは0.855**でした。無事にIntermediateに昇格することができました。**最終順位は、787人中の62位**ということで悪くもなく極端に良くもないスコアでした。

ちなみに、スコアの推移は以下の通りです。

**0.8470：Random Seed Averageなし**
**↓  (+0.0034)**
**0.8504：Random Seed Average 5回**
**↓  (+0.0035)**
~~**0.8539：「'class_weight': 'balanced'」の指定**~~
**↓  (+0.0016)**
**0.8555：Random Seed Average 20回**

~~私の場合は、「'class_weight': 'balanced'」の指定が結構効果が大きかった気がします。~~

なお、Qiitaに掲載してるコードでは修正していますが、致命的なミスが1箇所あったのでそこがなければ0.857くらいまではいけた気がします（ちょっと悔しい）。

ちなみに、フォーラム（コンペ掲示板）のほうに、Random Seed Average 100回やれば結構スコア上がると書いてありました。もっとAverage回数を増やせば良かったです（10時間も学習させる覚悟が私にはなかったです笑）。

# 不均衡データ（Imbalancede Data）の扱い
**(訂正)上でも記載の通り、今回の評価指標AUCが、データの偏りの影響を受けない評価指標のため、今回は考慮不要でした。また、class_weightをパラメータとして指定できるのはLightGBMClassiefierでした。**

今回の学習データが、不均衡データになっていた点に注意しました。不均衡データで学習した場合、予測モデルが負例と予測しやすくなるので以下の処理をするのが一般的です。

1．正例数に合わせて負例数をアンダーサンプリングする
2．アンダーサンプリングせず、学習時にサンプル数の重みづけを行う

今回はアンダーサンプリングはせず、2のほうを行いました。以下のページを参考にさせていただきました。

[偏りのあるデータをランダムフォレストでクラス分類を行う際は class weight を設定した方がよい](https://qiita.com/kazuki_hayakawa/items/6d7a4597829f54ebdb83)

1のアンダーサンプリングを実装したい場合は以下のページが参考になります。

[LightGBMでdownsampling+bagging - u++の備忘録](https://upura.hatenablog.com/entry/2019/01/12/193000)

ちなみに、私の経験では、アンダーサンプリングするのが良いか重み付けするのが良いのかは問題ごとに変わります。そのため、一度どちらも試してみてスコアが良くなる方を採用することをお勧めします。

# 他に試したこと
他にもPseudo Labelingなども試しましたが、今回のコンペではあまり効果がなく採用しませんでした。

コンペに参加した他の人の話を聞くと、Target EncodingやStackingもあまり効果がないとのことで、オーソドックスにシングルモデルで攻めるのが良いコンペだったようです。

# 補足
今回のコンペはSIGNATEの練習問題にも同じ題材のものがあるため、以下のページからデータをダウンロードし、コードの動作を確認することができます。実際に動かしてみたい方はどうぞ。

[【練習問題】銀行の顧客ターゲティング](https://signate.jp/competitions/1/data)

※ただし、今回のコンペとはデータの形式は同じものの中身は異なります。練習問題のデータで本コードを実行するとAUC：0.95くらいはいきます。また、trainデータのレコード数も異なるので、実行する際は本コードの一部を修正する必要があります(「3.学習と予測」の項のコード内でtrain2をtrain、testに分割するときの「27100」という数字)。

# 最後に
Begginer限定コンペではありましたが色々と学ぶことが多く収穫のあるコンペでした。今後は、KaggleのMoA(薬物動態コンペ)や、ProbSpaceのスプラトゥーンコンペにもチャレンジしてみたいと思います。ちなみに、経済産業相主催のAI人材育成プログラム「AI QUEST」にも応募してみたので、運よく通過すれば、なかなか忙しい毎日になりそうです。

###P.S.
SIGNATEの称号ピラミッドの図を描くのに無駄に時間がかかった…