第2回 金融データ活用チャレンジ（71th解法）

# この記事の内容
・[第2回金融データ活用チャレンジ](https://signate.jp/competitions/1325)で71位の解法共有。
・採用したモデルは、LightGBM、CatBoost、RandomForest。それぞれのモデルの前処理、モデル、アンサンブル、参考としていた評価指標について解説。


## 前処理_LightGBM

LightGBMは特徴量を増やせば増やすほど、精度が低下したので最低限の処理に留めました。また表記揺れを最低限修正しました。Cityは州ごとに同じ名前のCityがあるため、StateとCityの組み合わせとしました。実際の融資でも重要になりそうな、「1回あたりの支払い金額」、Sectorは1桁目にもかたまりとしての意味がありそうだったので、Sector１桁を追加しました。

```
　　# 表記ゆれの修正
df['State_City']=df['State']+df['City']
df['State_City']=df['State_City'].str.upper()
df['State_City']=df['State_City'].str.replace(' ','')
df['State_City']=df['State_City'].str.replace('\(.*', '',regex=True)

# １回あたり金額
df['Per_Term_Dis']=df['DisbursementGross']/df['Term']

# セクター１桁
df['Sector_one']=df['Sector']//10
```

## 前処理_CatBoost


LightGBMをシンプルにした分、CatBoostの特徴量は作り込みました。だいたいは、特徴量どうしの四則演算で増やしましたが、そのほかで特徴的なのは以下3点かなと思います。

#### 1.外部データの利用（Countyデータ）
<https://simplemaps.com/data/us-cities>
Cityのデータが細かすぎて特徴を捉えきれてないと感じたので、上のリンク先のBasic(無料版）を使用してCountyのデータ（雑に言うと日本の県に近い概念）を使用しました。また、人口と人口密度のデータがついていたので、こんな感じでAreSizeとしました。
```
# city_data加工
city_data['Area_Size']=city_data['population']/city_data['density']
```

#### 2.小文字を含むCityを特徴量化
City欄はほぼ大文字で記載されていましたが、稀に小文字を含むものがありました。
LightGBMではただ補正しただけでしたが、CatBoostをでは特徴量として取り込みました。現実の融資でも特定の申込書の注意書きから、記載の形式に特徴が出ることも考えられるのではないかなと思いました。

    # 小文字含みの特徴抽出
    df['City_islower']=0
    df['City_islower'][df['City'].str.isupper()]=1


#### 3.特徴的な名前から特徴量を作成

日本でも「芦屋」や「港区」といった高所得者が多い地域がありますが、アメリカでもCityの名前からそのような特徴を持つものを抽出できないかと考えました。高所得者が多そうなイメージとして真っ先に思いついたのが、「〜ヒルズ」だったので「Hill」が名前に入るCityといった要領で探しました。最終的には、特徴量を入れないときと入れた時で、精度が向上した以下単語を含む市を特徴量としました。

    # 特徴的な名前
    df['Name_flag']='0'
    df['Name_flag'][df['State_City'].str.contains('HILL')]='2'
    df['Name_flag'][df['State_City'].str.contains('RIVER')]='3'
    df['Name_flag'][df['State_City'].str.contains('MOUNT')]='4'
    df['Name_flag'][df['State_City'].str.contains('SANTA')]='6'

## 前処理　RandomForest
ランダムフォレストは、ほぼCatBoostから特徴量を除くだけのものを作りました。意識したポイントとしては、「各モデル精度を上げつつ、できるだけデータにバリエーションを持たせること」です。変数の変換はOrdinal Encoderを使いました。
```
    le = OrdinalEncoder()
    df= le.fit_transform(df)
```

## モデル
```
# モデル作成 lgb
def model_lgb_kf(df):

    k=5
    models = []
    valid_scores = []
    skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=1)

    col = 'MIS_Status'
    y = df[col]
    X = df.drop(col,axis=1)
    predicted = copy.copy(df[col])

    # kfoldで分割
    for train_index, test_index in skf.split(X, y):

        X_train = X.iloc[train_index]
        y_train = y.iloc[train_index]
        X_test = X.iloc[test_index]
        y_test = y.iloc[test_index]

        # データセットを生成する
        lgb_train = lgb.Dataset(X_train, y_train)

        # モデル評価用
        lgb_valid = lgb.Dataset(X_test,y_test)

        params =  {'objective': 'binary',
                    'metric': "binary_logloss",
                    'learning_rate': 0.006,
                    'feature_pre_filter': False,
                    'pre_partition':True,
                    'lambda_l1': 3,
                    'lambda_l2': 7.2,
                    'num_leaves': 18,
                    'feature_fraction': 0.3,
                    'max_depth': 6,
                    'bagging_fraction': 0.75,
                    'bagging_freq': 5,
                    'early_stopping_round': 50,
                    'min_child_samples': 30,
                    'colsample_bytree':0.7,
                    'subsample':0.8,
                    'num_iterations': 1500}

        model = lgb.train(params
                          ,lgb_train
                          ,valid_sets=lgb_valid
                        ,callbacks=[
                lgb.early_stopping(stopping_rounds=50, verbose=True),
                lgb.log_evaluation(100),
            ]
                          )

        models.append(model)
        pred =model.predict(X_test)
        predicted.iloc[test_index]=pred

    return models,predicted

def model_cat_kf(df):

    # category dataを分ける
    cat_features_list=[]
    for i in df.columns:
        if str(df[i].dtypes)=='category':
            cat_features_list.append(i)

    # データ分割
    k=5
    models = []
    valid_scores = []
    skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=0)

    col = 'MIS_Status'
    y = df[col]
    X = df.drop(col,axis=1)
    predicted = copy.copy(df[col])

    # kfoldで分割
    for train_index, test_index in skf.split(X, y):

        X_train = X.iloc[train_index]
        y_train = y.iloc[train_index]
        X_test = X.iloc[test_index]
        y_test = y.iloc[test_index]

        #CatBoost が扱うデータセットの形式に直す
        train_pool = Pool(X_train,label=y_train,cat_features=cat_features_list)
        test_pool = Pool(X_test,label=y_test,cat_features=cat_features_list)

        # モデルを学習する
        params ={'loss_function': 'CrossEntropy'
        , 'iterations': 1200
        , 'learning_rate': 0.008
        , 'l2_leaf_reg': 6.8
        , 'colsample_bylevel': 0.15
        , 'depth': 6
        , 'boosting_type': 'Plain'
        , 'min_data_in_leaf': 20
        ,'border_count':3000
        ,'use_best_model' : True
        ,'random_strength': 1
        ,'early_stopping_rounds': 50
        ,'bagging_temperature' :  0.5
                 }
        model = CatBoostClassifier(**params,verbose=100)
        model.fit(train_pool,eval_set=test_pool)

        models.append(model)
        pred = model.predict(X_test,prediction_type='RawFormulaVal')
        pred = np.exp(pred) / (1+ np.exp(pred))
        predicted.iloc[test_index]=pred


    return models,predicted

# モデル作成 randomforest
def model_rand_kf(df):

    k=6
    models = []
    valid_scores = []
    skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=1)


    col = 'MIS_Status'
    y = df[col]
    X = df.drop(col,axis=1)
    predicted = copy.copy(df[col])

    # kfoldで分割
    for train_index, test_index in skf.split(X, y):

        X_train = X.iloc[train_index]
        y_train = y.iloc[train_index]
        X_test = X.iloc[test_index]
        y_test = y.iloc[test_index]

        # モデルの学習
        model = RandomForestClassifier(
            max_depth=11,
            n_estimators=200,
            random_state=2,
            max_features=11,
            min_samples_split=30,
            min_samples_leaf=35,
        )
        model.fit(X_train, y_train)

        models.append(model)
        pred =model.predict_proba(X_test)
        predicted.iloc[test_index]=pred[:,1]

        v=model.predict_proba(X_train)[:,1]
        X_train['predict']=v
        print(log_loss(y_train,X_train['predict']))

    return models,predicted
```
各モデルのパラメータはこんな感じです。LightGBM、CatBoostは５分割のStratifiedKFold、RandomFrestは６分割のStratifiedKFoldを利用しました。一応ロジスティック回帰も作りましたが、戦力化させることができませんでした...

## スコア確認・閾値決定

3つのモデルのアンサンブルの比率を変えながらMeanF1スコアの最大値とLoglossを確認し、どちらかというとLoglossを減らす方を重視して試行錯誤していました。MeanF1スコアだと、順位付けだけが大事になるので、「惜しい誤り」といった要素が反映されず、汎化性能が下がりそうだなと考えました。RandomForestを10％ほど入れたほうがTrainデータでのスコアは伸びたのですが、リーダーボードのPublicスコアがイマイチだったので、最終的に3％程度に減らしました。

```
df['predict']=predicted_cat*0.49+predicted_lgb*0.48+predicted_rand*0.03

# 逆転しておく
precision_1, recall_1, thresholds = precision_recall_curve(df['MIS_Status'], df['predict'])
precision_0, recall_0, thresholds = precision_recall_curve(1-df['MIS_Status'], 1-df['predict'])

recall_0=recall_0[::-1]
precision_0=precision_0[::-1]

# F値計算
mean_f1_scores = []
f1_scores_1 = []
f1_scores_0 = []
for p_0, r_0 , p_1 , r_1 in zip(precision_0, recall_0,precision_1, recall_1):
    f1_0 = hmean([p_0, r_0])
    f1_1 = hmean([p_1, r_1])
    mean_f1=(f1_0+f1_1)/2
    mean_f1_scores.append(mean_f1)
    f1_scores_0.append(f1_0)
    f1_scores_1.append(f1_1)
# F値を描画
plt.plot(mean_f1_scores[:-1], label='mean_f1 score')
plt.plot(f1_scores_1[:-1], label='f1 score_1')
plt.plot(f1_scores_0[:-1], label='f1 score_0')

logloss = log_loss(df['MIS_Status'],df['predict'])
print(f'Logloss: {logloss}')
print(f'{np.argmax(mean_f1_scores)}th is highest f1 score ={np.max(mean_f1_scores):.5f}')

plt.xlabel('threshold')
plt.legend()
```
Logloss: 0.2746471984003839
3402th is highest f1 score =0.68832

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/443550/5f095529-39c3-dcf3-fadd-e0c68e7afcff.png)

私の場合、トレーニングデータでスコアが最大になる場所は3300~3500あたりが多かったです。[前書いたの記事](https://qiita.com/HIROSHI_jj/items/6527edc7e70e469ee29f)での調査で、「トレーニングデータの負例の数にこだわらなくていいこと」にはある程度自信があったので、本番の提出も３６００件あたりの負例数を狙いました（やや増えているのは、Publicスコアの出方を加味）。
提出は単純な平均としました。
```
d_pre=[]
for i in models_cat:
        pred = i.predict(test_df_2,prediction_type='RawFormulaVal')
        pred = np.exp(pred) / (1+ np.exp(pred))
        d_pre.append(pred)
predict_cat =sum(d_pre)/len(d_pre)

d_pre=[]
for i in models_lgb:
    d_pre.append(i.predict(test_df_1))
predict_lgb =sum(d_pre)/len(d_pre)

d_pre=[]
for i in models_rand:
    d_pre.append(i.predict(test_df_3))
predict_rand =sum(d_pre)/len(d_pre)

test_df['predict']=predict_cat*0.49+predict_lgb*0.48+predict_rand*0.03
test_df['predicted']=0
test_df['predicted'][test_df['predict']>=0.774]=1
```

一応、最終日までに、Publicスコアが一番高いのを選んでおいて、Privateとほぼ順位変わらずでした。汎化性能としては、可もなく不可もなくといったところでしょうか。本当は最終日にPublicスコアが一番高いものと、もう一つは手元でのスコアが高くて、一つ目の手法と離れているものを選ぶつもりでした。そして、手元には35位相当のものもあったのでちょっと悔しかったです(もっと悲惨な方もおられるでしょうが...)。

## 終わりに
結構初期でスコアが上がらなくなって辛いながらも、記事を書いたり、Slackで他の方が話している内容を覗いたりするのは楽しかったです。金融に特化した国内データ分析コンペは貴重な取組だと思いますので、第3回も開催されることを期待しています。そして、次こそは入賞できるよう1年間精進したいと思います。