特徴量生成、特徴量選択、ハイパーパラメータのチューニングを自動化するライブラリを使ってみた

概要

テーブルデータを手に入れた直後のファーストアクションとして、「特徴量生成」、「特徴量選択」、「ハイパーパラメータチューニング」が自動化できていると、初動が早くなるのではと思い調査&利用してみました。

今回は下記の3つのライブラリを一連の流れで利用する、ということを目的としています。

また、コードはここにあります。

目次

1.やること

今回は、適当なデータセットを用意して「featuretools」、「boruta」、「Optuna」を使った一連の流れを試してみました。
一連の流れを通しての感想は下記の通りです。

  • 「featuretools」はとても便利だけど、どのような特徴量が作成できるか、使うアルゴリズムではどのような特徴量を持たせるべきか、というのを理解した上で使う必要がある
  • 「featuretools」で生成できる特徴量はあくまでも「基本的な特徴量」のみ。恐らくこれで生成できる特徴量だけで上手くいくケースは少ない。なので、従来ながらの特徴量エンジニアリングの重要性は依然として変わらない。
  • 「boruta」は処理時間も長いので、「とりあえず特徴量を大量に作った時」、「行き詰まったり何をしたらいいかわからない時」、に使ってみるくらいがちょうどいいかも。
  • 「Optuna」は「コードの記述が簡単」かつ「高機能」なので、使えるようならハイパーパラメータチューニングをする際のファーストオプションとして使いたい

実際に実行したスクリプトはGitにあげておきますが、要点だけを紹介していこうと思います。

2.特徴量自動生成

まずは、「featuretools」を使って、特徴量の自動生成をしてみました。
「featuretools」をうまく使えば、基本的な特徴量を殆どコードを書かずに作成することができます。

ここでは、「featuretools」に関する下記3点について説明しようと思います。

  • どんなデータを用意したらどんな特徴量が生成できるのか
  • featuretoolsを使う上で知っておきたい概念
  • コードの書き方

2-1.どんなデータを用意したらどんな特徴量が生成できるのか

まずは「用意するデータ」についてですが、「pandasのdataframe形式のデータ」を用意する必要があります。

・DF1

index col1 col2 col3
0 100 a1 2018-10-01
1 123 a2 2018-12-01
2 45 a1 2018-11-01
3 320 a1 2018-10-25

そして、このDataFrameにfeaturetoolsで特徴量を生成すると、例えば、下記のように「日付」に関する特徴量が生成できます。

DF1に特徴量を自動生成(年、月、日)

index col1 col2 col4 col5 col6
0 100 a1 2018 10 1
1 123 a2 2018 12 1
2 45 a1 2018 11 1
3 320 a1 2018 10 25

これだけでなく、他にも複数のDataFrameと紐付けて特徴量を生成することもできます。
例えば、下記のようなDataFrameを用意して、「col2」で紐づけるとすると...

・DF2

index col2 col3
0 a1 100
1 a2 300
2 a1 500
3 a1 2400

下記の一番右の2列のように特徴量を生成できます。

index col1 col2 col4 col5 col6 Mean(DF2.col3) Max(DF2.col3)
0 100 a1 2018 10 1 1000 2400
1 123 a2 2018 12 1 300 300
2 45 a1 2018 11 1 1000 2400
3 320 a1 2018 10 25 1000 2400

生成できる特徴量は大まかに「変換(ex.日付から年、月、日を生成)」、「集計(平均や最大値)」、の2タイプに分類できるのですが、詳細についてはこちらをご参照ください。
作成できる特徴量については、カスタムで処理内容を記述することもできます。

2-2.featuretoolsを使う上での概念

上記のようにとても便利なのですが、利用するためにはまず「featuretools」での言葉遣いを理解しなくてはなりません。
最低限理解しないといけない言葉について、ざっくりと記述すると下記の通りです。

  • EntitySet
    • RDBMSで言うところの「スキーマ」
  • Entity
    • RDBMSで言うところの「テーブル」

とりあえず最低限この2つを理解しておきましょう。
同一の「EntitySet」に複数の「Entity」を入れておき、各「Entity」の結合キーを指定すると、特徴量生成時に各Entityを紐づけて様々な特徴量を生成してくれます。

2-3.コードの書き方

続いて、実際に特徴量生成をしてみましょう。
今回は公式のチュートリアルではなく、自前のCSVデータから読み取る形で実施してみました。

まずは、PandasでDataFrame形式で読み込んで、

# featuretoolsのインストール
!pip install featuretools

# データの読み込み
import pandas as pd

items = pd.read_csv('./data/items.csv')
cats = pd.read_csv('./data/item_categories.csv')
train = pd.read_csv('./data/sales_train.csv.gz', compression='gzip')

続いて、「EntitySet」を定義します。

import featuretools as ft

# Entity Setを作成
es = ft.EntitySet(id='entityset') # RDBで言う所のスキーマ

以降は、この「EntitySet」配下に「Entity」を指定していきます。 Entity名、インデックス等を指定する必要があります。
詳細についてはこちらをご参照ください。

# Entityの追加(RDBで言う所のテーブル)
es = es.entity_from_dataframe(entity_id="items",# テーブル名のようなもの
                              dataframe=items,# 読み込み対象のDF
                              index="item_id",# RDBのテーブルで言うところの主キー(なので、一意にならないとダメ)。
                              variable_types={"item_category_id": ft.variable_types.Id
                                             } # 数字が入っているけど、IDとして扱いたいので指定しておく
                             )

es = es.entity_from_dataframe(entity_id="cats",
                              dataframe=cats,
                              index="item_category_id"
                             )

es = es.entity_from_dataframe(entity_id="train",
                              dataframe=train,
                              index = 'train_index', # 新しく作るインデックスの名前を指定
                              make_index=True, # 新しくキーを作成する。
                              variable_types={"shop_id": ft.variable_types.Id,
                                              "item_id": ft.variable_types.Id
                                             }
                             )

続いて、各Entityを「どのカラムで紐付けるか」といったことを指定します。
各Entityの親子関係に気をつけてください。

# 関係性の定義
relationship_items_train = ft.Relationship(parent_variable= es["items"]["item_id"] # 先に親entityを指定(結合キーが重複しない)。マスターテーブルのようなイメージ。
                                   ,child_variable=es["train"]["item_id"] # 後に子entityを指定(結合キーが重複しても良い)。トランザクションテーブルのようなイメージ。
                                  ) 


relationship_cats_items = ft.Relationship(parent_variable= es["cats"]["item_category_id"]
                                   ,child_variable=es["items"]["item_category_id"]
                                  ) 

# 関係性を認識する
es = es.add_relationship(relationship_items_train)
es = es.add_relationship(relationship_cats_items)

その後に、「生成する特徴量」について指定します。
特に指定しないと、デフォルトでいっぱい特徴量を生成してくれますが、下記のように指定することも可能です。
「agg_primitives」は集計する類の特徴量が、「trans_primitives」では変換する系の特徴量が生成できます。

どういった特徴量が生成できるのかの詳細についてはこちらをご参照ください。

# 生成する特徴量の指定
agg_primitives = ["mean", "sum", "mode"] # Default: [“sum”, “std”, “max”, “skew”, “min”, “mean”, “count”, “percent_true”, “n_unique”, “mode”]
trans_primitives = ["year", "month","day","weekday"] # Default: [“day”, “year”, “month”, “weekday”, “haversine”, “num_words”, “num_characters”]

ここまでできたら、あとはここまで指定した条件に基づいて、特徴量を生成するだけです。

# 特徴量生成
# https://docs.featuretools.com/generated/featuretools.dfs.html
feature_matrix, features_defs = ft.dfs(entityset=es,
                                       target_entity="train",
                                       agg_primitives=agg_primitives, # Default: [“sum”, “std”, “max”, “skew”, “min”, “mean”, “count”, “percent_true”, “n_unique”, “mode”]
                                       trans_primitives=trans_primitives, # Default: [“day”, “year”, “month”, “weekday”, “haversine”, “num_words”, “num_characters”]
                                       max_depth=2,
                                       verbose=True
                                      )

また、ここで出てくる「max_depth」という引数が重要です。
これは端的に言うと「生成する特徴量の粒度」を示しています。
(ex.2に設定すると、集計を2回実施した特徴量を生成できる)

詳細についてはこちらをご参照ください。

3.特徴量選択

続いて、「必要な特徴量のみ」にデータを絞り込む「特徴量選択」を半自動化してくれる「boruta」を使ってみました。
アルゴリズムの概要についてはこちらがわかりやすいです。
本エントリーでは、「boruta」の使い方について紹介しようと思います。

まずは、「boruta」をインストールして、

!pip install --upgrade pip
!pip install boruta

先ほどfeaturetoolsで作成したマトリックスからデータを分割して、

import pandas as pd

X_train = feature_matrix[feature_matrix.date_num < 29].drop(['target_column'], axis=1)
Y_train = feature_matrix[feature_matrix.date_num < 29]['target_column']
X_valid = feature_matrix[feature_matrix.date_num == 30].drop(['target_column'], axis=1)
Y_valid = feature_matrix[feature_matrix.date_num == 30]['target_column']

モデルオブジェクトを生成し、Borutaの試行回数等を指定した上で、モデルオブジェクトを渡します。
今回は「ランダムフォレスト回帰」モデルを利用しています。

from boruta import BorutaPy
from sklearn.ensemble import RandomForestRegressor

# model
model = RandomForestRegressor(
    n_estimators=50
    , criterion='mse'
    , max_depth = 7
    , max_features = 'sqrt' 
    , n_jobs=-1
    , verbose=True
    )

# define Boruta feature selection method
# https://github.com/scikit-learn-contrib/boruta_py/blob/master/boruta/boruta_py.py
feat_selector = BorutaPy(model, 
                         n_estimators='auto',  # 特徴量の数に比例して、木の本数を増やす
                         verbose=2, # 0: no output,1: displays iteration number,2: which features have been selected already
                         alpha=0.05, # 有意水準
                         max_iter=50, # 試行回数
                         random_state=1
                        )

# 実行!!
feat_selector.fit(X_train.values, Y_train.values)

実行中は下記のような結果が出力されます。
これは「試行回数」、「重要と見做した特徴量の数」、「判断に悩んでいる特徴量の数」、「重要でないと判断した特徴量の数」をそれぞれ意味します。
(この時は合計17個の特徴量を元にスタートしています)

Iteration:  33 / 50
Confirmed:  5
Tentative:  3
Rejected:   9

処理が終わったら、Borutaで選択された特徴量のみの配列を用意すればOKです。

# 選ばれた特徴量のみの配列を作成

X_train_selected = X_train.iloc[:,feat_selector.support_]
X_valid_selected = X_valid.iloc[:,feat_selector.support_]

なお、単純に「Boruta」で特徴量を選択するだけなら上記で問題ないのですが、有用性を検証する場合は、「特徴量選択の前後」で同一アルゴリズムでの精度を比較するするといいかと思います。

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

最後に、「Optuna」を使ってハイパーパラメータをチューニングします。
「Optuna」の特徴はこちらにも書いてある通りですが、下記の4点です。

使いやすく、無駄な試行を早期に止めてくれて、処理も並列分散化できて、結果も可視化しやすい、という素晴らしいツールですね。
「ダッシュボードによる可視化」機能のリリースが待ち遠しいです。

4-1.基本的な使い方

# インストール
!pip install --upgrade pip
!pip install optuna

Optunaでは、下記の3点を記述した関数を利用します。

  • 探索するハイパーパラメータとその範囲
  • モデルの定義と学習の実行
  • 評価指標(執筆時点では評価指標の最小化しかできませんが、いずれは最大化も対応する予定のようです)
import optuna
from sklearn.ensemble import RandomForestRegressor

def objective(trial):
    '''
    trial:set of hyperparameter    
    '''
    # hypyer param
    max_depth = trial.suggest_int('max_depth', 3, 10) # 深すぎると過学習になるかも...
    n_estimators = trial.suggest_int('n_estimators', 50, 100) # しっかりやるなら100以上
    max_features = trial.suggest_categorical('max_features', ['sqrt', 'auto', 'log2'])

    # model
    model = RandomForestRegressor(max_depth=max_depth,
                                  n_estimators=n_estimators,
                                  max_features=max_features,
                                  n_jobs=-1,
                                  verbose=1)

    # fit
    model.fit(X_train_selected.values, Y_train.values)

    # eval
    score = -1 * model.score(X_valid_selected.values, Y_valid.values) 
    return score

続いて、「study」オブジェクトを生成し、ハイパーパラメータ最適化を実行します。

%%time

study = optuna.create_study()

# https://optuna.readthedocs.io/en/stable/reference/study.html#optuna.study.Study.optimize
study.optimize(func=objective, # 実行する関数
               n_trials=30, # 試行回数
               timeout=None, # 与えられた秒数後に学習を中止します。default=None
               n_jobs=-1 # 並列実行するjob数
              )

最適化の処理が終わったら、下記のように処理結果を確認できます。

#最適化したハイパーパラメータの確認
print('check!!!')
print('best_param:{}'.format(study.best_params))
print('====================')

#最適化後の目的関数値
print('best_value:{}'.format(study.best_value))
print('====================')

#最適な試行
print('best_trial:{}'.format(study.best_trial))
print('====================')

# トライアルごとの結果を確認
for i in study.trials:
    print('param:{0}, eval_value:{1}'.format(i[5], i[2]))
print('====================')

こんな形で結果が確認できます。
必要であれば、この「best_param」で確認したハイパーパラメータでモデルを再学習しましょう。  

check!!!
best_param:{'max_depth': 8, 'n_estimators': 34, 'max_features': 'log2'}
====================
best_value:-0.058732562386513365
====================
best_trial:FrozenTrial(trial_id=26, state=<TrialState.COMPLETE: 1>, value=-0.058732562386513365, datetime_start=datetime.datetime(2019, 1, 11, 10, 35, 22, 627022), datetime_complete=datetime.datetime(2019, 1, 11, 10, 41, 13, 711231), params={'max_depth': 8, 'n_estimators': 34, 'max_features': 'log2'}, user_attrs={}, system_attrs={}, intermediate_values={}, params_in_internal_repr={'max_depth': 8, 'n_estimators': 34, 'max_features': 2})
====================
param:{'max_depth': 6, 'n_estimators': 38, 'max_features': 'auto'}, eval_value:-0.04143774876575668
param:{'max_depth': 7, 'n_estimators': 24, 'max_features': 'auto'}, eval_value:-0.04951360218209144
param:{'max_depth': 9, 'n_estimators': 40, 'max_features': 'log2'}, eval_value:-0.05673757564422378
param:{'max_depth': 9, 'n_estimators': 13, 'max_features': 'auto'}, eval_value:-0.040143784430618834
param:{'max_depth': 3, 'n_estimators': 11, 'max_features': 'log2'}, eval_value:-0.001981779103447656
param:{'max_depth': 7, 'n_estimators': 50, 'max_features': 'log2'}, eval_value:-0.052213705261835996
param:{'max_depth': 8, 'n_estimators': 23, 'max_features': 'auto'}, eval_value:-0.03864020490234121
param:{'max_depth': 4, 'n_estimators': 37, 'max_features': 'auto'}, eval_value:-0.006517389616976454
param:{'max_depth': 8, 'n_estimators': 47, 'max_features': 'log2'}, eval_value:-0.049478868142555976
.
.
.
.

また、学習結果をCSV出力するのも簡単です。

study.trials_dataframe().to_csv("study_history.csv")

下記のように学習結果の履歴を出力することができます。

上記は単純に、「モデル」、「探索するハイパーパラメータの範囲」、「試行回数」を渡してチューニングしただけです。続いて、「前回の学習結果を引き継いで利用」「学習曲線を用いた試行の枝刈り」をしてみます。

4-2.前回の学習結果を引き継いで利用

「study」オブジェクトに「名前」と「保存場所」を指定して学習を開始すれば、チューニング探索の履歴を残せます。  

study_name = 'rfr_20190116_3'  # Unique identifier of the study.
study = optuna.create_study(study_name=study_name, storage='sqlite:///rfr_20190116-3.db')

# 学習の実行
# https://optuna.readthedocs.io/en/stable/reference/study.html#optuna.study.Study.optimize
study.optimize(func=objective, # 実行する関数
               n_trials=3, # HPO試行回数
               timeout=None, # 与えられた秒数後に学習を中止します。default=None
               n_jobs=-1 # 並列実行するjob数
              )

前回の学習結果を再利用する際は、下記のように先ほど指定した「学習名」、「保存場所」を指定すればOKです。
とっても簡単です。

study = optuna.Study(study_name='rfr_20190116_3', storage='sqlite:///rfr_20190116-3.db') # 再利用する学習の「名前」、「ストレージ」を指定。
# https://optuna.readthedocs.io/en/stable/reference/study.html#optuna.study.Study.optimize
study.optimize(func=objective, # 実行する関数
               n_trials=5, # HPO試行回数
               timeout=None, # 与えられた秒数後に学習を中止します。default=None
               n_jobs=-1 # 並列実行するjob数
              )

4-3.学習曲線を用いた試行の枝刈り

XGBoostでの記述例です。 目的の関数の中を少し書き換える必要があります。
こちらを参考にしました。

本コード中に不要な処理が含まれていたため、2019年1月23日に修正しました

# 目的の関数を作成
import numpy as np
import sklearn.metrics
from xgboost import XGBRegressor
import optuna

def objective(trial):
    # hypyer param
    max_depth = trial.suggest_int('max_depth', 3, 5) # 深すぎると過学習になるかも...
    n_estimators = trial.suggest_int('n_estimators', 10, 20) # しっかりやるなら100以上がいい

    # callback
    pruning_callback = optuna.integration.XGBoostPruningCallback(trial, 'validation-error')

    # model
    model = XGBRegressor(
        max_depth=max_depth,
        n_estimators=n_estimators,
        min_child_weight=300, 
        colsample_bytree=0.8, 
        subsample=0.8, 
        eta=0.3, 
        n_jobs=-1,
        seed=42,
        callback=[pruning_callback]
    )

    # fit
    model.fit(X_train_selected, 
              Y_train, 
              eval_metric="rmse", 
              eval_set=[(X_train_selected, Y_train), (X_valid_selected, Y_valid)], 
              verbose=True, 
              early_stopping_rounds = 10)

    # eval
    score = model.evals_result()['validation_1']['rmse'][-1]
    return score

あとは学習を実行するだけですが、こちらは今までと変更ありません。

# 学習結果を保存して、後から再利用できるようにしておく
study_name = 'xgbr_20190116'  # Unique identifier of the study.
study = optuna.create_study(pruner=optuna.pruners.MedianPruner(n_warmup_steps=5),
                            study_name=study_name, 
                            storage='sqlite:///xgbr_20190116.db')

# 学習の実行
# https://optuna.readthedocs.io/en/stable/reference/study.html#optuna.study.Study.optimize
study.optimize(func=objective, # 実行する関数
               n_trials=5, # HPO試行回数
               timeout=None, # 与えられた秒数後に学習を中止します。default=None
               n_jobs=-1 # 並列実行するjob数
              )

# 結果の表示
print(study.trials)

5.まとめ

テーブルデータを渡されて「とりあえず何かやってみて」と言われた際の初動をよくするために、「特徴量生成」、「特徴量選択」、「ハイパーパラメータチューニング」をサポートしてくれるライブラリを一連の流れに基づいて使ってみました。

使ってみた感想は下記の4点です。(「1.やること」と同じことを記述しています)

  • 「featuretools」はとても便利だけど、どのような特徴量が作成できるか、使うアルゴリズムではどのような特徴量を持たせるべきか、というのを理解した上で使う必要がある
  • 「featuretools」で生成できる特徴量はあくまでも「基本的な特徴量」のみ。恐らくこれで生成できる特徴量だけで上手くいくケースは少ない。なので、従来ながらの特徴量エンジニアリングの重要性は依然として変わらない。
  • 「boruta」は処理時間も長いので、「とりあえず特徴量を大量に作った時」、「行き詰まったり何をしたらいいかわからない時」、に使ってみるくらいがちょうどいいかも。
  • 「Optuna」は「コードの記述が簡単」かつ「高機能」なので、使えるようならハイパーパラメータチューニングをする際のファーストオプションとして使いたい

どなたかの参考になれば幸いです。