Help us understand the problem. What is going on with this article?

OptunaとLightGBMを使って、Kaggle過去コンペにsubmitする

More than 1 year has passed since last update.

この記事はEnigmo Advent Calendar 2018の10日目です。

はじめに

OptunaはPFN社が公開したハイパーパラメータ自動最適化フレームワークです。

https://research.preferred.jp/2018/12/optuna-release/

目的関数さえ決めれば、直感的に最適化を走らせることが可能のようです。

今回、最適化自体の説明は割愛させていただきますが、機械学習の入門ということを考えるとハイパーパラメータの調整としては、gridsearchやRandomizedSearchCVで行う機会が多いと思います。
スキル、あるいはリソースでなんとかするということになるかと思いますが、特に、kaggleのような0.X%の精度が向上が重要になるような状況では、ハイパーパラメータのチューニングが大きなハードルの一つになります。
そこで、titanicでのsubmitはあるものの、Kaggleの経験がほぼゼロな筆者でも、Optunaで簡単にチューニングができるかどうかを試してみようと思います。

今回の対象コンペ

既にcloseしているコンペの中で、下記のPorto Seguro’s Safe Driver Predictionを選びました。
https://www.kaggle.com/c/porto-seguro-safe-driver-prediction
選定理由は以下の通りです。

  • データがそれほど大きくない
  • 手元(自宅)のラップトップのRAMは8GBと大きくないので、XGboostではなくメモリ消費が抑えられるLightGBMでやってみたい
  • 解法がシンプルかつ、LightGBMで上位のスコアを解法を公開しているカーネルがすぐに見つかった

公開解法の再現

https://www.kaggle.com/xiaozhouwang/2nd-place-lightgbm-solution

上記をそのままコピペして一回submitします。
Python2対応のようなので、下記のようにPython3で動くように修正しました。

# part of 2nd place solution: lightgbm model with private score 0.29124 and public lb score 0.28555

import lightgbm as lgbm
from scipy import sparse as ssp
from sklearn.model_selection import StratifiedKFold
import numpy as np
import pandas as pd
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import OneHotEncoder

def Gini(y_true, y_pred):
    # check and get number of samples
    assert y_true.shape == y_pred.shape
    n_samples = y_true.shape[0]

    # sort rows on prediction column
    # (from largest to smallest)
    arr = np.array([y_true, y_pred]).transpose()
    true_order = arr[arr[:, 0].argsort()][::-1, 0]
    pred_order = arr[arr[:, 1].argsort()][::-1, 0]

    # get Lorenz curves
    L_true = np.cumsum(true_order) * 1. / np.sum(true_order)
    L_pred = np.cumsum(pred_order) * 1. / np.sum(pred_order)
    L_ones = np.linspace(1 / n_samples, 1, n_samples)

    # get Gini coefficients (area between curves)
    G_true = np.sum(L_ones - L_true)
    G_pred = np.sum(L_ones - L_pred)

    # normalize to true Gini coefficient
    return G_pred * 1. / G_true


cv_only = True
save_cv = True
full_train = False


def evalerror(preds, dtrain):
    labels = dtrain.get_label()
    return 'gini', Gini(labels, preds), True


path = "input/"

train = pd.read_csv(path+'train.csv')
train_label = train['target']
train_id = train['id']
test = pd.read_csv(path+'test.csv')
test_id = test['id']

NFOLDS = 5
kfold = StratifiedKFold(n_splits=NFOLDS, shuffle=True, random_state=218)

y = train['target'].values
drop_feature = [
    'id',
    'target'
]

X = train.drop(drop_feature,axis=1)
feature_names = X.columns.tolist()
cat_features = [c for c in feature_names if ('cat' in c and 'count' not in c)]
num_features = [c for c in feature_names if ('cat' not in c and 'calc' not in c)]

train['missing'] = (train==-1).sum(axis=1).astype(float)
test['missing'] = (test==-1).sum(axis=1).astype(float)
num_features.append('missing')

for c in cat_features:
    le = LabelEncoder()
    le.fit(train[c])
    train[c] = le.transform(train[c])
    test[c] = le.transform(test[c])

enc = OneHotEncoder(categories='auto')
enc.fit(train[cat_features])
X_cat = enc.transform(train[cat_features])
X_t_cat = enc.transform(test[cat_features])

ind_features = [c for c in feature_names if 'ind' in c]
count=0
for c in ind_features:
    if count==0:
        train['new_ind'] = train[c].astype(str)+'_'
        test['new_ind'] = test[c].astype(str)+'_'
        count+=1
    else:
        train['new_ind'] += train[c].astype(str)+'_'
        test['new_ind'] += test[c].astype(str)+'_'

cat_count_features = []
for c in cat_features+['new_ind']:
    d = pd.concat([train[c],test[c]]).value_counts().to_dict()
    train['%s_count'%c] = train[c].apply(lambda x:d.get(x,0))
    test['%s_count'%c] = test[c].apply(lambda x:d.get(x,0))
    cat_count_features.append('%s_count'%c)

train_list = [train[num_features+cat_count_features].values,X_cat,]
test_list = [test[num_features+cat_count_features].values,X_t_cat,]

X = ssp.hstack(train_list).tocsr()
X_test = ssp.hstack(test_list).tocsr()

learning_rate = 0.1
num_leaves = 15
min_data_in_leaf = 2000
feature_fraction = 0.6
num_boost_round = 10000
params = {"objective": "binary",
          "boosting_type": "gbdt",
          "learning_rate": learning_rate,
          "num_leaves": num_leaves,
           "max_bin": 256,
          "feature_fraction": feature_fraction,
          "verbosity": 0,
          "drop_rate": 0.1,
          "is_unbalance": False,
          "max_drop": 50,
          "min_child_samples": 10,
          "min_child_weight": 150,
          "min_split_gain": 0,
          "subsample": 0.9
          }

x_score = []
final_cv_train = np.zeros(len(train_label))
final_cv_pred = np.zeros(len(test_id))
for s in range(16):
    cv_train = np.zeros(len(train_label))
    cv_pred = np.zeros(len(test_id))

    params['seed'] = s

    if cv_only:
        kf = kfold.split(X, train_label)

        best_trees = []
        fold_scores = []

        for i, (train_fold, validate) in enumerate(kf):
            X_train, X_validate, label_train, label_validate = \
                X[train_fold, :], X[validate, :], train_label[train_fold], train_label[validate]
            dtrain = lgbm.Dataset(X_train, label_train)
            dvalid = lgbm.Dataset(X_validate, label_validate, reference=dtrain)
            bst = lgbm.train(params, dtrain, num_boost_round, valid_sets=dvalid, feval=evalerror, verbose_eval=100,
                            early_stopping_rounds=100, )
            best_trees.append(bst.best_iteration)
            cv_pred += bst.predict(X_test, num_iteration=bst.best_iteration)
            cv_train[validate] += bst.predict(X_validate)

            score = Gini(label_validate, cv_train[validate])
            print(score)
            fold_scores.append(score)

        cv_pred /= NFOLDS
        final_cv_train += cv_train
        final_cv_pred += cv_pred

        print("cv score:")
        print(Gini(train_label, cv_train))
        print("current score:", Gini(train_label, final_cv_train / (s + 1.)), s+1)
        print(fold_scores)
        print(best_trees, np.mean(best_trees))

        x_score.append(Gini(train_label, cv_train))

print(x_score)
pd.DataFrame({'id': test_id, 'target': final_cv_pred / 16.}).to_csv('model/lgbm3_pred_avg.csv', index=False)
pd.DataFrame({'id': train_id, 'target': final_cv_train / 16.}).to_csv('model/lgbm3_cv_avg.csv', index=False)

公開解法でのsubmit

image.png

Private Scoreで0.29097。5169チーム中46位のスコアとなり、シルバーメダル圏内に入りました。
コンペは終了しているので、もちろんスコアボードの本体は更新はされません。

image.png

なお、実際のコンペでは、カーネルの著書から他のNeral Networkでの予測値の平均と記載があるので、2位のsubmitの再現というわけにならないようです。

しかし、このようなシンプルな方法でシルバーメダルのスコアを取れるのは、個人的にもKaggleに積極してみたいという励みになったと感じています。

ハイパーパラメータのチューニング

さて、ハイパーパラメータのチューニングをフレームワークの力を借りて、ハードルをぐっと下げようという、本題に移ります。

他のKaggleのコンペや、Stack over flowで雑に調査し、パラメータの範囲を決めました。
そうしてできた修正したソースコードが、以下のようになります。

import lightgbm as lgbm
import optuna
from scipy import sparse as ssp
from sklearn.model_selection import StratifiedKFold
import numpy as np
import pandas as pd
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import OneHotEncoder

def Gini(y_true, y_pred):
    # check and get number of samples
    assert y_true.shape == y_pred.shape
    n_samples = y_true.shape[0]

    # sort rows on prediction column
    # (from largest to smallest)
    arr = np.array([y_true, y_pred]).transpose()
    true_order = arr[arr[:, 0].argsort()][::-1, 0]
    pred_order = arr[arr[:, 1].argsort()][::-1, 0]

    # get Lorenz curves
    L_true = np.cumsum(true_order) * 1. / np.sum(true_order)
    L_pred = np.cumsum(pred_order) * 1. / np.sum(pred_order)
    L_ones = np.linspace(1 / n_samples, 1, n_samples)

    # get Gini coefficients (area between curves)
    G_true = np.sum(L_ones - L_true)
    G_pred = np.sum(L_ones - L_pred)

    # normalize to true Gini coefficient
    return G_pred * 1. / G_true

cv_only = True
save_cv = True
full_train = False

def evalerror(preds, dtrain):
    labels = dtrain.get_label()
    return 'gini', Gini(labels, preds), True

path = "input/"

train = pd.read_csv(path+'train.csv')
#train = train.sample(frac=0.1, random_state=0).reset_index(drop=True)
train_label = train['target']
train_id = train['id']
test = pd.read_csv(path+'test.csv')
#test = test.sample(frac=0.1, random_state=0).reset_index(drop=True)
test_id = test['id']

NFOLDS = 4
kfold = StratifiedKFold(n_splits=NFOLDS, shuffle=True, random_state=218)

y = train['target'].values
drop_feature = [
    'id',
    'target'
]

X = train.drop(drop_feature,axis=1)
feature_names = X.columns.tolist()
cat_features = [c for c in feature_names if ('cat' in c and 'count' not in c)]
num_features = [c for c in feature_names if ('cat' not in c and 'calc' not in c)]

train['missing'] = (train==-1).sum(axis=1).astype(float)
test['missing'] = (test==-1).sum(axis=1).astype(float)
num_features.append('missing')
train.shape
for c in cat_features:
    le = LabelEncoder()
    le.fit(train[c])
    train[c] = le.transform(train[c])
    test[c] = le.transform(test[c])

# 事前にlabelEncoderを行っているから、この使い方でユニークな値で割り当てられる。引数categories = 'auto'で警告を消す
enc = OneHotEncoder(categories='auto')
enc.fit(train[cat_features])
X_cat = enc.transform(train[cat_features])
X_t_cat = enc.transform(test[cat_features])


ind_features = [c for c in feature_names if 'ind' in c]
count=0
for c in ind_features:
    if count == 0:
        train['new_ind'] = train[c].astype(str)+'_'
        test['new_ind'] = test[c].astype(str)+'_'
        count += 1
    else:
        train['new_ind'] += train[c].astype(str)+'_'
        test['new_ind'] += test[c].astype(str)+'_'

cat_count_features = []
for c in cat_features+['new_ind']:
    d = pd.concat([train[c],test[c]]).value_counts().to_dict()
    train['%s_count'%c] = train[c].apply(lambda x:d.get(x,0))
    test['%s_count'%c] = test[c].apply(lambda x:d.get(x,0))
    cat_count_features.append('%s_count'%c)

train_list = [train[num_features+cat_count_features].values, X_cat]
test_list = [test[num_features+cat_count_features].values, X_t_cat]

X = ssp.hstack(train_list).tocsr()
X_test = ssp.hstack(test_list).tocsr()


def objective(trial):
    drop_rate = trial.suggest_uniform('drop_rate', 0, 1.0)
    feature_fraction = trial.suggest_uniform('feature_fraction', 0, 1.0)
    learning_rate = trial.suggest_uniform('learning_rate', 0, 1.0)
    subsample = trial.suggest_uniform('subsample', 0.8, 1.0)
    num_leaves = trial.suggest_int('num_leaves', 5, 1000)
    verbosity = trial.suggest_int('verbosity', -1, 1)
    num_boost_round = trial.suggest_int('num_boost_round', 10, 100000)
    min_data_in_leaf = trial.suggest_int('min_data_in_leaf', 10, 100000)
    min_child_samples = trial.suggest_int('min_child_samples', 5, 500)
    min_child_weight = trial.suggest_int('min_child_weight', 5, 500)

    params = {"objective": "binary",
              "boosting_type": "gbdt",
              "learning_rate": learning_rate,
              "num_leaves": num_leaves,
              "max_bin": 256,
              "feature_fraction": feature_fraction,
              "verbosity": verbosity,
              "drop_rate": drop_rate,
              "is_unbalance": False,
              "max_drop": 50,
              "min_child_samples": min_child_samples,
              "min_child_weight": min_child_weight,
              "min_split_gain": 0,
              "min_data_in_leaf": min_data_in_leaf,
              "subsample": subsample
              }

    x_score = []
    final_cv_train = np.zeros(len(train_label))
    final_cv_pred = np.zeros(len(test_id))

    cv_train = np.zeros(len(train_label))
    cv_pred = np.zeros(len(test_id))

    params['seed'] = 0

    kf = kfold.split(X, train_label)

    best_trees = []
    fold_scores = []

    for i, (train_fold, validate) in enumerate(kf):
        print('kfold_index:', i)
        X_train, X_validate, label_train, label_validate = \
            X[train_fold, :], X[validate, :], train_label[train_fold], train_label[validate]
        dtrain = lgbm.Dataset(X_train, label_train)
        dvalid = lgbm.Dataset(X_validate, label_validate, reference=dtrain)
        bst = lgbm.train(params, dtrain, num_boost_round, valid_sets=dvalid, feval=evalerror, verbose_eval=100,
                        early_stopping_rounds=100)
        best_trees.append(bst.best_iteration)
        cv_pred += bst.predict(X_test, num_iteration=bst.best_iteration)
        cv_train[validate] += bst.predict(X_validate)

        score = Gini(label_validate, cv_train[validate])
        print(score)
        fold_scores.append(score)


    cv_pred /= NFOLDS
    final_cv_train += cv_train
    final_cv_pred += cv_pred

    print("cv score:")
    print(Gini(train_label, cv_train))
    print("current score:", Gini(train_label, final_cv_train / (s + 1.)), s+1)
    print(fold_scores)
    print(best_trees, np.mean(best_trees))

    x_score.append(Gini(train_label, cv_train))
    print(x_score)


    pd.DataFrame({'id': test_id, 'target': final_cv_pred / 16.}).to_csv('model/lgbm3_pred_avg_2.csv', index=False)
    pd.DataFrame({'id': train_id, 'target': final_cv_train / 16.}).to_csv('model/lgbm3_cv_avg_2.csv', index=False)

    return (1 - x_score[0])

study = optuna.create_study()
study.optimize(objective, n_trials=150)


パラメータの設定の範囲を抜粋すると以下のようになります。

drop_rate = trial.suggest_uniform('drop_rate', 0, 1.0)
feature_fraction = trial.suggest_uniform('feature_fraction', 0, 1.0)
learning_rate = trial.suggest_uniform('learning_rate', 0, 1.0)
subsample = trial.suggest_uniform('subsample', 0.8, 1.0)
num_leaves = trial.suggest_int('num_leaves', 5, 1000)
verbosity = trial.suggest_int('verbosity', -1, 1)
num_boost_round = trial.suggest_int('num_boost_round', 10, 100000)
min_data_in_leaf = trial.suggest_int('min_data_in_leaf', 10, 100000)
min_child_samples = trial.suggest_int('min_child_samples', 5, 500)
min_child_weight = trial.suggest_int('min_child_weight', 5, 500)

なお、Optuna自体の使用方法は、下記の記事と公式リファレンスを参考させていただきした。

https://qiita.com/ryota717/items/28e2167ea69bee7e250d
https://optuna.readthedocs.io/en/stable/index.html

(18/12/11 19:41追記)
コメントいただけた通り、'verbosity'は、警告レベルの表示を制御するパラメータであり、予測性能の最適化としては意味の無いパラメータでした。ですので、チューニングの対象にはすべきではありませんでした。

以下のように試行回数を定めていますが、

n_trials=150 

時間が足りなくなった関係で、その時点で計算されたパラメータで最適化を中断しております。
20時間ほど回し回しましたが、ハイパーパラメータによって検証の時間は1分から60分程度となり、
100回くらいの試行数だったようです。

そうしてできてパラメータが、以下のように、2位の解法と比較すると以下のようになります。

ハイパーパラメータ 今回のチューニング結果 2位の解法
drop_rate 0.3015600134599976 0.1
feature_fraction 0.46650703511665226 0.6
learning_rate 0.004772377676601769 0.1
subsample 0.8080720420805803 0.9
num_leaves 718 15
verbosity -1 0
num_boost_round 1942 10000
min_data_in_leaf 212 150
min_child_samples 68 10
min_child_weight 151 150

2位コンペとの解法とは、雰囲気が異なるセットとなり、公開解法の再現ということにはならないようです。
K_fold=4 でやっていることも異なる要因になると思います。

算出できたハイパーパラメータでsubmit

最初のpython3のスクリプトからパラメータを入れ替え、予測値を算出しました。
K_fold =4,
また、ランダムシートの数を16から4に減らしております。

結果

スコアは下がってます。

image.png

1176位相当。。ハイパーパラメータ次第でシルバーメダル圏内ということを考えると、微妙な結果です。

image.png

所感

結果としては残念ですが、grid searchだけに頼らない、ハイパーパラメータの最適化方法の導入のきっかけになりました。
また、非常に手軽に使えたというのもあり、今後もチューニングの場面でOptunaを活用してみたいと思います。

反省としては、探索するハイパーパラメータの設定が悪く、計算の効率化が著しく悪くなった恐れがあります。
validationの際に、fold数の全て計算するのではなく、スコアが下がらなそうなら、そのハイパーパラメータの計算をやめるとか、一定時間以上かかってしまったらまた、次に試行に移るとかできれば効率化できたように思えます。
フレームワークはブラックボックスでもある程度は動かすことができますが、やはり中身をある程度理解しないと遠回りしてしまうというのは、当然の結果と言えます。
もっと使いこなせるよう精進しなければと思いました。

公式リファレンスでも、OptunaでLightGBMをチューニングする例が出ており、そちらの例も参考にしながらリベンジしたいと思います。

https://github.com/pfnet/optuna/blob/master/examples/lightgbm_simple.py

最後にですが、この記事が何かの役に経てば幸いです。

tawash
enigmo
株式会社エニグモは、ソーシャルショッピングサイト「BUYMA(バイマ)」を運営する会社です。
https://www.enigmo.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした