見出し画像

機械学習初心者がKaggleに参加してみた

0. 始めに

これは、KMCアドベントカレンダー13日目の記事です。
他の記事はこちらからどうぞ。
https://adventar.org/calendars/8840

※注意
こういう記事を書くのは初めてなので、色々と不慣れです。
また、機械学習初心者なので、おかしなこと、間違ったことを言っていたらごめんなさい。"<(*_ _)>

1. 挨拶

こんにちは。KMC46th、feltです。
最近は課題に追われたり、コースに迷ったり、授業資料で「第10回」と書いてあるのを見て、時の流れを実感したりしています。早すぎませんか…?

さて、今回は 2023/10/24~2023/11/14 に行われた
'Binary Prediction of Smoker Status using Bio-Signals'
というKaggleのコンペに参加した際の記録のようなものです。
(こういうのって、「コンペ」って言いますよね?)

ちなみに最初の画像はこのコンペのヘッダーからとってきました。内容との関係はよくわかりません。

今回のコンペでは、ざっくりいうと、患者のいろんな指標(年齢、身長、体重 etc)から、その人が喫煙している確率を予測するというものです。AUCの値で評価されます。
AUCは、ROC曲線という曲線の下の部分の面積で、1だと完璧、0.5だと全くのランダムに分類しているということです。
(0だと、完璧に真逆に分類している、いわゆる「逆にすごい」状態。)
当時ROC曲線をまともに知らなかった私は以下のサイトを参考にしました。

上位38%の人は当時こんなことを考えていました、というほとんど需要の無い思考を、1ヶ月程経った今、思い出しながら書いていきます。コードも載せていきます。載せた方がそれっぽいからです(いい加減)。

2. 前処理

まずはデータの全容などをざっくりと理解したい。
今回のデータには欠損値が無かったので、その点は楽でした。

df_train = pd.read_csv('/kaggle/input/playground-series-s3e24/train.csv')
df_test = pd.read_csv('/kaggle/input/playground-series-s3e24/test.csv')

df_train.info()
train_dataはこんな感じ。とてもデータが大きい。

あとは、分布とか外れ値とかを見たいので、Akio Onoderaさんのコード
パクr、、、参考にしながらhistogramとboxplotで可視化しました。
その結果…

feats = df_train.columns.drop(['id', 'smoking']).tolist()
for feat in feats:
    
    # histogram
    plt.figure(figsize=(30,4))
    ax1 = plt.subplot(1,6,1)
    df_train[feat].plot(kind='hist', bins=50, color='blue')
    plt.title(feat + ' / train')
    ax2 = plt.subplot(1,6,2)
    df_test[feat].plot(kind='hist', bins=50, color='green')
    plt.title(feat + ' / test')
    ax3 = plt.subplot(1,6,3)
    original[feat].plot(kind='hist', bins=50, color='orange')
    plt.title(feat + ' / original')

    #boxplot
    ax4 = plt.subplot(1,6,4)
    sns.boxplot(data=df_train, x='smoking', y=feat)
    plt.title('smoking vs ' + feat + ' / train')
    ax5 = plt.subplot(1,6,5)
    sns.boxplot(data=df_test, y=feat)
    plt.title(feat + ' / test')
    ax6 = plt.subplot(1,6,6)
    sns.boxplot(data=original, x='smoking', y=feat)
    plt.title('smoking vs ' + feat + ' / original')
    plt.show()

「外れ値多いな…」

特にfasting blood sugar(空腹時血糖値)とSmokingの箱ひげ図とかひどい

恐らく上の場合だと、100くらいに値が集中しすぎているせいですね。ひげの上端や下端(?)のすぐ近くに外れ値とみなされた点があるのが分かります。
このまま箱ひげ図規準で外れ値を除去すると、とんでもない数が除去されそうですね。何か別の方法は無いか…

ここでIsolationForestなるものを知り、ngayope330さんの記事を見て試してみることにしました。

np.random.seed(0)
IF = IsolationForest()
IF.fit(df_train)
outlier = IF.predict(df_train)

df_train[outlier == -1]
df_trainのうち外れ値とみなされたもの。小さいですが、左下に8260 rowsとありますね。

trainデータ全体の159256人のうち、外れ値とされたのは8260人。よさそう。

(他にも外れ値除去の方法としてLocalOutlierFactorやOneClassSVMなんてものもあるそうです。どの手法を選択するかの判断は、少なくとも私にはかなり難しい。)


※補足
本当にIsolationForestで外れ値を除外できているのか今更気になったので、検証してみました。

#  eyesight_left (right) には、時々9.9という明らかな外れ値があるので、除外できていたらいいな。

plt.scatter(df_train[outlier == 1]['eyesight(left)'], df_train[outlier == 1]['eyesight(right)'], color = 'b', linewidths = 5)
plt.scatter(df_train[outlier == -1]['eyesight(left)'], df_train[outlier == -1]['eyesight(right)'], color = 'r')
plt.xlabel('eyesight(left)')
plt.ylabel('eyesight(right)')
外れ値でないものを大きさ5の青色の点で、外れ値を赤色の点で散布図を描いた。

あれ?
青い点も視力9.9の位置に普通にありますね。
もっとデータの前処理をしないといけないのかも…?
いずれ使いこなしたいです。
※補足終


後は、深い意味もなくMinMaxScalerをして、訓練用とテスト用にデータを分けました。
(Scalerも種類豊富なので、何を使えばいいか毎回分からないですが、私はとりあえず外れ値除去した後にMinMaxScalerをしています。)

# 外れ値ではなかったものを集める
df_train_non_outlier = df_train[outlier == 1]
x = df_train_non_outlier.drop(['id', 'smoking'], axis = 1)
y = df_train_non_outlier['smoking']

# MinMaxScaler(最小値0、最大値を1とする)
scaler = MinMaxScaler()
x_scaled = x.copy()
for i in x.columns:
    x_scaled[[i]] = scaler.fit_transform(x[[i]])
# わざわざxのコピーを作ったのは、その他のScalerの方法も試したかったからなのかもしれない(覚えていない)

# 訓練用とテスト用(評価用)に分ける
x_train, x_test, y_train, y_test = train_test_split(x_scaled, y, test_size = 0.3, random_state = 0, shuffle = True, stratify = y)

3. 学習ー予測

ここから学習させる段階に。
ひとまず、使用するモデルはRandomForestClassifier。
パラメータはデフォルトのまま学習させました。
(実行のたびに結果が変わられては困るため、random_stateだけ設定。)

ROC曲線を描いて、AUCを出してみたところ…

# RandomForestClassifierを用いて学習、予測
rfc = RandomForestClassifier(random_state = 0)
rfc.fit(x_train, y_train)
y_pred = rfc.predict_proba(x_test)[:, 1]

# ROC曲線を描く
fpr, tpr, thresholds = roc_curve(y_test, y_pred)
plt.plot(fpr, tpr, marker='o')
plt.xlabel('FPR: False positive rate')
plt.ylabel('TPR: True positive rate')
plt.grid()

# AUCの値も出力
print(roc_auc_score(y_test, y_pred))
果たして精度が良いのか悪いのか。

まぁ、パラメータを何もいじっていないのでそんなものですかね。
交差検証もしましたが、0.8577… とほぼ変わらず。

このコンペでは、1日最大5回まで提出できるので、とりあえず提出してみることに。スコアは…

0.8419

「下がってる!」
(ちなみにこのスコアはtest_dataの20%を用いて算出されたスコア、Public Scoreのことで、最終的なスコアはtest_dataの80%を用いたPrivate Scoreになるのですが、こちらは0.8398ともっと下がっていました。これは今もなお残る課題。)

次に、学習モデルを変えて実行してみました。
使ったのはLGBMClassifier。LightGBMはKaggle上位入賞者がよく使っている人気学習モデルらしい。

LightGBMの仕組みに関してはこちらを参照してください(丸投げ)。
私もこのサイトを参考にしながらコードを書いた記憶があります。

というわけで実装。

# LightGBMを用いて学習、予測。パラメータは未調整。
lgbm = lgb.LGBMClassifier(boosting_type='goss', max_depth=5, random_state=0)
lgbm.fit(x_train, y_train)

# ROC曲線, AUCの算出過程は先程と同じ


0.860と、モデルを変えたところ、AUCが上がりました。

提出スコアも0.8516と、先程よりおよそ0.01上がりました。
(AUCってどれだけ伸びたら「改善された」ってなるんでしょうかね…?)

4. パラメータチューニング

今までほとんどデフォルトの値で行っていましたが、ここでパラメータチューニングを行います。
パラメータチューニングにはOptunaを使います。Optunaは、パラメータの最適化を自動で行ってくれるものです。とても便利。

(Optunaは日本人が開発したはずなのにどうして英語のドキュメントしかないのでしょうね…)

以下、パラメータチューニングのコードです。

def objective(trial):
    params = {
        'reg_alpha': trial.suggest_float('reg_alpha', 0.0001, 0.1, log=True),
        'reg_lambda': trial.suggest_float('reg_lambda', 0.0001, 0.1, log=True),
        'num_leaves': trial.suggest_int('num_leaves', 2, 50),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.4, 1.0),
        'subsample': trial.suggest_float('subsample', 0.4, 1.0),
        'subsample_freq': trial.suggest_int('subsample_freq', 0, 7),
        'min_child_samples': trial.suggest_int('min_child_samples', 0, 100),
        'max_depth': trial.suggest_int('max_depth', 3,15)
    }
    
    lgbm = lgb.LGBMClassifier(**params, 
                              objective = 'binary', 
                              metric = 'binary_logloss', 
                              boosting_type =  'gbdt',
                              n_estimators = 10000,
                              verbose = -1,
                              random_state = 0)
    eval_set = [(x_test, y_test)]
    callbacks = []
    callbacks.append(lgb.early_stopping(stopping_rounds=10))
    callbacks.append(lgb.log_evaluation())
    lgbm.fit(x_train, y_train, eval_set=eval_set, callbacks=callbacks)
    
    y_pred = lgbm.predict_proba(x_test)
    return metrics.log_loss(y_test, y_pred)
    
study = optuna.create_study(direction = 'minimize')
study.optimize(objective, n_trials = 100, timeout = 600)
print('Best trial:', study.best_trial.params)

paramsにチューニングしたいパラメータを辞書の形式で入れることで、 lgbm = lgb.LGBMClassifier(**params)
という感じに書くことが出来ます。この時初めて知りました。

パラメータの調節範囲に関しては、ネットの記事から、「大抵これくらいの値で使われます。」と書いてあった値を参考に、範囲を決めています。

LightGBMは、一回学習するごとに誤差を計算し、その誤差が小さくなるように繰り返し学習を行うというものなのですが(超ざっくり)、この繰り返しの回数がn_estimatorsで、とにかく大きい値を入れておきます。

また、early_stoppingは、その誤差が指定回数(上の例では10回)改善されなかったときに学習を打ち切るというものです。先程n_estimatorsに大きい値を入れたのは、early_stoppingで打ち切られる前に学習の繰り返しが終わってほしくないからですね。

また、study.optimize(objective, n_trials = 100, timeout = 600) というのは、
「パラメータの探索を100回繰り返してください。でも600秒経ったら終わってください」
ということです(短気)。

OptunaでLightGBMのパラメータをチューニングすると、2重の繰り返し構造が出来上がるので、ちょっとおもしろいですね。

OptunaとLightGBMの重箱のような構造(イメージ)

あと'Light'GBMというだけあって学習が速いので、Optuna1回分のパラメータ探索で大体600回くらいLightGBMが学習を繰り返していたのですが、10分で85回分くらいパラメータ探索が終わってました。すごい。
パラメータを調整したこのモデルでROC曲線とAUCを算出したところ、

パラメータ調節後のROC曲線とAUC

0.860から0.867と、再度AUCの値が増えました。

※余談
ここで一つ注意しないといけないなぁと思ったのは、'eval_set'、つまりearly_stoppingに用いるデータです。こいつをモデルに学習させていないかどうか、注意する必要があります。
上記の例では、一回しか学習しないので、問題になりにくいかなと思いますが、問題は交差検証をする時です。いつもの癖で交差検証用のデータにすべてのデータを使用すると、early_stoppingのデータとスコア評価用のデータが同じものとなるためにearly_stoppingのデータがリークしてしまうことになります。下の図が視覚的にとても分かりやすかったので拝借。

下記のサイトから画像をお借りしました。

いわば「あっ、これ◯研ゼミでやったところだ!」という状況です。

この状況を防ぐために、early_stopping用のデータは別途分けておく必要があります。こちらのサイトの初めの方に詳しく書かれています。
パラメータ探索の範囲を、グラフで可視化、そこから情報を読み取って決めたりもしていて、すごいなぁって思います(遠い目)。

5. 結果

そんなこんなで結果ですが…
Public Scoreは、最終的に0.871まで伸びました。
(0.87台にまで乗せられたのはかなり嬉しかった。)

しかし、Private Scoreは、0.869と、いつも通りきちんとスコアが下がり、最終順位もPublic Score基準の順位から20くらい落ち、ちゃんとシェイクダウンしました。

他の方のコードを見ていると、このコンペで与えられたデータは、元になったデータがあるようで、そのデータと、コンペで与えられたデータを一緒にして一つの学習用データというようにしていた人が多かったです。このことについては、Data Descriptionの項目にも書いてありました。

The dataset for this competition (both train and test) was generated from a deep learning model trained on the Smoker Status Prediction using Bio-Signals dataset.

https://www.kaggle.com/competitions/playground-series-s3e24/data

ちゃんと見てください。(戒め)

また、Public Scoreのリーダーボード(暫定順位)から順位を100上げて、4位になった人のSolutionが公開され、それによると、
「過学習を防ぐという戦略でした。だってPublic Scoreは、全体のたった20%しか使われていないんだよ?(意訳)」
だそうです。手元で出したスコアと比べ、Public Score及びPrivate Scoreが低い原因の一つとして過学習があるのかな?と思いました。過学習が発生しているとしたら、Optunaのパラメータチューニングでかなりの種類のパラメータを調節したため、そこが怪しいかなとも思いましたが、実際のところは、分からないです。過学習が起こっているかの判断もできるようになりたいですね。

6. 終わりに

「機械学習超初心者がKaggleに参加してみた」というタイトルでここまで書き進めてきたわけですが、振り返ると、色々迷走してたなぁ、と感じます。それと、リーダーボードで自分の位置が見えたり、最終的なスコア(順位)は変わる可能性があるという、ちょっとしたドキドキ要素だったりと、やっていて面白いなと感じました。疲れますが。
今後も時間と精神に余裕があれば、参加してみたいなと思いました。

ちなみに、今は、'Optiver - Trading at the Close' に参加していますが、

  • そもそも、何をやっているのか分かりにくい。

  • 提出用csvファイルの作成方法が変わっていて、その仕組みをコードとにらめっこしながら理解しないと、値を予測できたところで、まともに提出すらできない(私はまだできていない)。

という感じで思ったより大変です。右腕を大きく上げ下げして助けを求めたくなるほどには大変です(伝わるか…?)。

また、こういう記事を書くのも疲れますね、というのが正直な感想です。
中々に時間を使いました。慣れが足りないだけですが。

というわけで、最後までお読みいただきありがとうございました。
明日は、toeさんの記事です。
気づけばアドベントカレンダーも後半戦、お楽しみに!



この記事が気に入ったらサポートをしてみませんか?