KFold + OputnaでLightgbmをチューニングする

#初めに

Optunaでlightgbmをチューニングして回帰分析をしてみたので、その忘備録です。

使用したデータは、こちらのコンペ↓で使用されるデータです。
House Prices - Advanced Regression Techniques

こちらのコンペは住宅の売値を予測するコンペとなっています。
住宅の値段なので、築年数や広さが重要だと思いますが、初手は可能な限り特徴量を使う方向で行きます。

モデルの精度については、RMSE約0.124がベストスコアとなっており、kaggleにsubmitして算出された値になります。
この記事執筆時点では、大体上位17%あたりの順位でした。
コンペの評価基準にはRMSEが使用されています。

#準備

```python
#今回使ったライブラリ
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import seaborn as sns
import warnings
warnings.filterwarnings("ignore")

from sklearn.model_selection import KFold, cross_validate
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import mean_squared_error as mse
import optuna
from optuna.visualization import plot_optimization_history, plot_param_importances
import lightgbm as lgb
```

次にデータの読み込みと欠損値のチェック。

```python
train_data = pd.read_csv("house-prices-advanced-regression-techniques/train.csv")
train = train_data.copy()
train.index = train["Id"]
del train['Id']
test_data = pd.read_csv("house-prices-advanced-regression-techniques/test.csv")
test = test_data.copy()
test_id = test['Id']
test.index = test["Id"]
del test['Id']

#欠損値をカウントしたDataFrameの作成
missing_train = pd.DataFrame(train.isnull().sum())
missing_train = missing_train.rename(columns={0:'missing_value_counts'})
missing_test = pd.DataFrame(test.isnull().sum())
missing_test = missing_test.rename(columns={0:'missing_value_counts'})

fig = plt.figure(figsize=(20,20))
plt.barh(missing_train.index, missing_train.missing_value_counts, color='blue')
plt.barh(missing_test.index, missing_test.missing_value_counts, color='green')
plt.legend(['Train', 'Test'], fontsize=15)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1010285/d5ac59d3-00d2-53a9-5820-39cf914543fe.png)
学習用とテストデータの欠損値を重ねてプロットすることで、欠損値の傾向を確認します。
青くなっている部分が両方のデータの差を表しています。
欠損値の分布には大きな差がないようです。

```python
#欠損値の多いカラムは削除
drop_col = ['MiscFeature', 'Fence', 'PoolQC', 'FireplaceQu', 'LotShape', 'LotFrontage', 'Alley']
train = train.drop(drop_col, axis=1)
test = test.drop(drop_col, axis=1)
```
学習用、テスト用それぞれのデータで同じ特徴量が多数の欠損値を持っているので削除します。
データ数が少ないので、多少の欠損値は許容範囲とします。

#ざっくりEDA

```python
#相関係数のヒートマップ
corr_tr = train.corr()
corr_tr = pd.DataFrame(corr_tr)
sns.heatmap(corr_tr);
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1010285/ceaca17f-943c-8edc-e09c-a363a3707c94.png)
数値データだけですが、ヒートマップで相関係数を確認できます。

```python
#特徴量同士の相関係数を確認※一部抜粋
corr_tr.iloc[:12, :12].style.background_gradient(cmap='coolwarm')
```
↑のコードでも特徴量同士の相関係数を確認できます。
特徴量同士で相関が大きい箇所もありますが、ここもデータをすべて使う方向で行きます。

```python
high_corr = corr_tr[corr_tr['SalePrice'] > 0.6]['SalePrice']
high_corr
[out]
#材料と仕上げの質
OverallQual    0.790982
#家の広さを表すデータ
TotalBsmtSF    0.613581
1stFlrSF       0.605852
GrLivArea      0.708624
#ガレージの広さ
GarageCars     0.640409
GarageArea     0.623431
#家の値段、目的変数
SalePrice      1.000000
Name: SalePrice, dtype: float64
```
目的変数と高い相関を持つ特徴量を確認します。
家の値段を左右すると思われる築年数がなかったので、この時点で相関係数をあてにするのは微妙かもと思いました。
家の広さを表す特徴量ですが、フロアの違いはあるものの、根本的には家の広さを表しています。
ガレージのデータですが、収容できる車の台数と広さを表しています。これはほぼ同義だと思います。

```python
#目的変数と特徴量に分ける
SalePrice = train["SalePrice"]
train = train.drop("SalePrice", axis=1)

#定性データの定量データのカラムリストを作る
cat_col = [col for col in train.select_dtypes(include=object)]
num_col = [col for col in train.select_dtypes(exclude=object)]
```
この後の処理を考え、数的データと質的データごとにリストを作りました。

定量データの特徴量を可視化して見てみます。

```python
i = 1
plt.figure()
fig, ax = plt.subplots(9, 4, figsize=(20,15))

for feature in num_col:
    plt.subplot(9,4,i)
    sns.histplot(train[feature], color='blue', kde=True, bins=100, 
                label='train')
    sns.histplot(test[feature], color='green', kde=True, bins=100, 
                label='test')
    plt.title(feature, fontsize=8)
    plt.xlabel(' ')
    plt.legend()
    i += 1
plt.tight_layout()
plt.show();
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1010285/c6f04024-775c-9d24-6640-e7709816c9c4.png)
ざっくりとですが、学習用とテスト用データともに大まかな分布の傾向は似ているようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1010285/d510473b-9231-6845-b709-8faf93ccbbb9.png)
同じ要領で、定性データの可視化も行います。
こっちは両方のデータで違う傾向を表す特徴量もあるようです。

目的変数SalePrice(住宅の売値)も可視化します。
```sns.histplot(SalePrice, kde=True)```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1010285/571af67a-7a44-ac6c-0d1c-e35f45c0d738.png)
目的変数は右の裾が長い分布のため、外れ値の影響で平均が中央値より大きくなる分布です。
まずは外れ値を除去しない方向で進めます。

#lgihtgbm用にデータ処理

```python
all_data = pd.concat([train, test], axis=0)

all_data[num_col] = all_data[num_col].astype('float')
all_data[cat_col] = all_data[cat_col].astype('category')

train_size = len(train)
train = all_data[:train_size]
test = all_data[train_size:]

print("train size:", train.shape)
print("test size:", test.shape)
#train size: (1460, 72)
#test size: (1459, 72)
```
lightgbmに適したデータ型に変更する処理をしています。
int型もfloat型にしたほうが、精度良くなりました。
後でLabeEncoderを使いやすいようにconcatしていますが、まずはデータ型の変換のみでモデルに学習させます。

#チューニングとモデル構築

ここから本題のチューニングです。

```python
#チューニング用のモデルを作成
def objective(trial):
    params = {
        'objective':'regression',
        'metric':'r2',
        'verbosity':-1,
        'num_leaves':trial.suggest_int('num_leaves', 10, 1000),
        'max_depth':trial.suggest_int('max_depth', 8, 24),
        'learning_rate':trial.suggest_loguniform('learning_rate', 1e-8, 1.0),
        'reg_alpha':trial.suggest_loguniform('reg_alpha', 1e-5, 1.0),
        'reg_lambda':trial.suggest_loguniform('reg_lambda', 1e-5, 1.0),
        'feature_fraction':trial.suggest_uniform('feature_fraction', 0.3, 1.0),
        'bagging_fraction':trial.suggest_uniform('bagging_fraction', 0.3, 1.0),
        'bagging_freq':trial.suggest_int('bagging_freq', 1, 8),
        'min_child_samples':trial.suggest_int('min_child_samples', 5, 80),
        'random_state':8
        }
#見込みのないtrialの切り捨て
    pruning_callback = optuna.integration.LightGBMPruningCallback(trial, 'r2')
#モデルにチューニングしたパラメータを渡す
    model = lgb.LGBMRegressor(**params, n_estimators=500)
#KFold分割し、評価を行う
    kf = KFold(n_splits=4, shuffle=True, random_state=8)
    scores = cross_validate(model, X=train, y=SalePrice, scoring='r2', cv=kf)
    return scores['test_score'].mean()
```
Optunaに渡すためのモデルを定義しています。
ただデータ数が少ないため、元のデータをいきなりKFold分割してます。
ほんとは学習用、検証用、テスト用に分割してモデルの精度を確認したかったのですが、データ量が少ないので断念。
分かりやすくするため、あえて'regression'を明記してますが、デフォルトではこれになってます。
コンペの評価指標はRMSEですが、cross_validateで使えないので、一目で分かりやすい決定係数にしています。

モデルを作成したので、チューニングさせます。

```python
#Optunaによるチューニング
%%time
study = optuna.create_study(direction='maximize')
study.optimize(objective, timeout=600)
```
```python
#ベストなパラメーターの表示
best_params = study.best_params
print('best_params:')
[out]
best_params:
{'num_leaves': 488, 'max_depth': 12, 
'learning_rate': 0.09946325984953863, 'reg_alpha': 0.002492041819920163,
 'reg_lambda': 0.0016166727035490307, 'feature_fraction': 0.300655728931028, 
'bagging_fraction': 0.9867819626446563, 'bagging_freq': 4, 'min_child_samples': 11}
```
これで一番良かったtrialと最適なパラメータを確認できます。
ちなみに、`study.best_trial`で一番良かったtrialを確認できます。

`plot_optimization_history(study)`で学習の履歴を確認できますが、出力結果の画像は割愛します。
`plot_param_importances(study)`で学習に影響を与えているパラメータを確認したところ、圧倒的にlearning_rateが影響を与えてました。

```python
#チューニングしたパラメータで再度学習と予測をする
#こっちは普通にKFold
%%time
sub_preds = np.zeros(test.shape[0])

kfolds = KFold(n_splits=4, shuffle=True, random_state=8)
for train_cv_no, eval_cv_no in kfolds.split(train):
    train_x_cv = train.iloc[train_cv_no]
    train_y_cv = SalePrice.iloc[train_cv_no]
    val_x_cv = train.iloc[eval_cv_no]
    val_y_cv = SalePrice.iloc[eval_cv_no]
    
    params = {
        'objective':'regression',
        'metric':'rmse',
        'verbosity':-1,
        'num_leaves':best_params['num_leaves'],
        'max_depth':best_params['max_depth'],
        'learning_rate':best_params['learning_rate'],
        'reg_alpha':best_params['reg_lambda'],
        'reg_lambda':best_params['reg_lambda'],
        'feature_fraction':best_params['feature_fraction'],
        'bagging_fraction':best_params['bagging_fraction'],
        'bagging_freq':best_params['bagging_freq'],
        'min_child_samples':best_params['min_child_samples'],
        'random_state':8   
    }

    model = lgb.LGBMRegressor(**params, n_estimators=500, early_stopping_rounds=200)
    model.fit(train_x_cv, train_y_cv, eval_set=[(val_x_cv, val_y_cv)])
    val_pred = model.predict(val_x_cv, num_iteration=model.best_iteration_)
    #submit用の予測をする。Fold数で割って平均値を算出する。
    sub_preds += model.predict(test, num_iteration=model.best_iteration_) / kfolds.n_splits
#損失関数
rmse = mse(val_y_cv, val_pred, squared=False)
print('RMSE:', rmse)
```
チューニングした最適なパラメータで、モデルに学習と予測を行わせます。
こっちは普通にKFoldで交差検証をします。
submit用の予測ですが、KFoldなのでn_splits分だけ大きくなります。
そのため、Fold数で割ることで平均の値を提出用の予測とします。
モデルの評価にはコンペで使われているRSMEを使います。
`squared=False`でRMSEが出力されます。

```python
#特徴量の重要度を可視化
lgb.plot_importance(model, max_num_features=15, figsize=(10,10))
plt.show()
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1010285/196e06e8-a539-b58d-cb8f-a07db1ad6dcb.png)

モデルの予測に重要だったパラメータは、最初の仮説にほぼ沿っています。
SFやAreaは広さに対するデータ、YearBuiltが家が建てられた年です。
Bsmtは地下部分のデータを意味します。
MasVnrArea(塀で囲まれているエリア、のはず)、OpenPorch(玄関ドア前の屋根下の広さ)も家が大きいほど広くなるはずなので、広さを表していると言えますね。
そこまで仮説との乖離がなかった重要度でした。
YearRemodAddは住居をリフォームした年になります。アメリカの住居事情ではリフォームすることで、資産価値が高める家主が多いようです。
築年数が重要というより綺麗に見える、手入れがされていて家の寿命が長くなる、ということの方が重要みたいです。
確かに、築年数が古くても綺麗にリフォームされていれば住みたくなりますね。

```python
#submit用のCSVを作成
sub = pd.DataFrame({"id":test_id, "SalePrice":sub_preds})
sub.to_csv("submission.csv", index=False)
```
最後に、今回のコンペで指定されている形式のCSVファイルを作成してsubmitします。

#所感

submitしたスコアですが、一番良かったのが約0.124でした。
順位も変動していますが、大体上位17%あたりを彷徨ってます。

精度を上げるために試したのは、下記です。
-重要度の高い特徴量だけで学習
-相関係数の高い特徴量と築年数だけで学習
-特徴量のうち、相関の高い組み合わせがある場合は片方の削除
-PCAで定量データの次元圧縮を行う
-外れ値の除去
-数的データは列ごとの平均値、質的データは列ごとの最頻値で埋める
-LabelEncoder
-目的変数の対数化
単にデータを削る作戦は失敗でした。多重共線性の確認は割愛したので、別の機会にやろうと思います。
`第三四分位数 + 1.5×IQR`、つまり比較的高い価格に外れ値があったので、除去しましたが効果なし。
LabelEncoderを使って見たのですが、特に精度が良くなったりはしませんでした。
そもそも、lightgbmにカテゴリ型でデータを渡せば自動で処理してくれるので、わざわざ値を変換する必要はなさげですね。
また、int型のデータもfloat型にした方が精度が良くなりました。

結局、予測の精度上昇に貢献したのは相関の高い特徴量の削除、目的変数の対数化です。

#まとめ

今回は不動産でしたが、業務外で様々な分野のデータに触れられるのはKaggleのよさだなと思います。
業務では使わない分野のデータを扱うたびに思うのですが、データ分析にはドメイン知識が重要であることを改めて実感しました。
Kaggleで得た知見が業務に役立つこともあるので、継続していきたいです。

最後まで見てくださり、ありがとうございました。