CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Optuna を使って QWK の閾値を最適化してみる

最近、Twitter のタイムラインで QWK (Quadratic Weighted Kappa: 二次の重み付きカッパ係数) の最適化が話題になっていたので個人的に調べていた。 QWK は順序つきの多値分類問題を評価するための指標で、予測を大きく外すほど大きなペナルティが与えられるようになっている。 また、予測値の分布が結果に影響する点も特徴的で、この点が今回取り扱う最適化にも関係してくる。

QWK の最適化については、Kaggle 本と、その著者 @Maxwell_110 さんによる次のブログエントリが詳しい。 ようするに、真のラベルの分布に沿った形で予測しないと最適な結果が得られない、ということ。

Kaggleで勝つデータ分析の技術

Kaggleで勝つデータ分析の技術

note.com

Kaggle 本や、Web をざっと調べた限りでは scipy.optimize.minimize() 関数を使って Nelder Mead 法で最適化するのが一般的なようだった。

docs.scipy.org

今回は、せっかくなので Optuna を使って TPE (Tree-structured Parzen Estimator) で最適化してみた。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G3020
$ python -V                                                                
Python 3.7.6

下準備

はじめに、利用するパッケージをインストールしておく。

$ pip install scikit-learn optuna lightgbm pandas

その上で、Kaggle で開催された次のコンペのデータセットをダウンロードしておく。

www.kaggle.com

これは、前述した Kaggle 本で QWK の最適化が有効に働く例として挙げられているもの。 カレントワーキングディレクトリに、次のファイルがある状態にしておく。

$ ls | grep .zip
sample_submission.csv.zip
test.csv.zip
train.csv.zip

多値分類問題として解いてみる

そもそも、順序つきのラベルを予測するタスクの場合、多値分類問題として解く方法と回帰問題として解く方法がある。 まずは、ベーシックな考え方として多値分類問題として解く方法を試してみる。

早速だけど、サンプルコードは次のとおり。 LightGBM を使って多値分類問題として解いている。 目的関数は Multi LogLoss で、5-Fold CV で QWK について評価している。

#!/usr/bin/env python3

import numbers

import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import KFold
from sklearn.metrics import cohen_kappa_score


def lgb_custom_metric_qwk_multiclass(preds, data):
    """LightGBM のカスタムメトリックを計算する関数

    多値分類問題として解いた予測から QWK を計算する"""
    # 正解ラベル
    y_true = data.get_label()
    # ラベルの数
    num_labels = data.params['num_class']
    # 多値分類問題では本来は二次元配列が一次元配列になって提供される
    reshaped_preds = preds.reshape(num_labels, len(preds) // num_labels)
    # 最尤と判断したクラスを選ぶ 
    y_pred = np.argmax(reshaped_preds, axis=0)  # 最尤と判断したクラスを選ぶ
    # QWK を計算する
    return 'qwk', qwk(y_true, y_pred), True


def qwk(y_true, y_pred):
    """QWK (Quadratic Weighted Kappa) を計算する関数"""
    return cohen_kappa_score(y_true, y_pred, weights='quadratic')


def _numerical_only_df(df_):
    """数値のカラムだけ取り出す関数"""
    number_cols = [name for name, dtype in df_.dtypes.items()
                   if issubclass(dtype.type, numbers.Number)]
    numerical_df = df_[number_cols]
    return numerical_df


def main():
    # データセットを読み込む
    train_df = pd.read_csv('train.csv.zip')

    # 説明変数
    x_train = train_df.drop('Response', axis=1)
    # 簡単にするため、とりあえずすぐに使えるものだけ取り出して使う
    x_train = _numerical_only_df(x_train)

    # 目的変数
    y_train = train_df.Response.astype(float)
    # 目的変数はゼロからスタートにする
    y_train -= 1

    # LightGBM のデータセット表現にする
    lgb_train = lgb.Dataset(x_train, y_train)

    # 多値分類問題として解く
    lgbm_params = {
        'objective': 'multiclass',
        'metric': 'multi_logloss',
        'num_class': len(y_train.unique()),
        'first_metric_only': True,
        'verbose': -1,
    }

    # データ分割の方法
    folds = KFold(n_splits=5, shuffle=True, random_state=42)

    # 5-Fold CV で検証する
    result = lgb.cv(lgbm_params,
                    lgb_train,
                    num_boost_round=1000,
                    early_stopping_rounds=100,
                    folds=folds,
                    verbose_eval=10,
                    feval=lgb_custom_metric_qwk_multiclass,
                    seed=42,
                    )

    # early stop したラウンドでの QWK を出力する
    print(f'CV Mean QWK: {result["qwk-mean"][-1]}')


if __name__ == '__main__':
    main()

上記を実行した結果が次のとおり。

$ python qwkmc.py
[10]   cv_agg's multi_logloss: 1.37765 + 0.0076621   cv_agg's qwk: 0.442683 + 0.00532416
[20]   cv_agg's multi_logloss: 1.25563 + 0.0095355   cv_agg's qwk: 0.507798 + 0.00703211
[30]   cv_agg's multi_logloss: 1.20349 + 0.0100597   cv_agg's qwk: 0.519766 + 0.00748977
...(省略)...
[220]  cv_agg's multi_logloss: 1.14153 + 0.0126548   cv_agg's qwk: 0.560446 + 0.00516118
[230]  cv_agg's multi_logloss: 1.14198 + 0.0126144   cv_agg's qwk: 0.56074 + 0.00444787
[240]  cv_agg's multi_logloss: 1.14242 + 0.0130142   cv_agg's qwk: 0.561591 + 0.0046819
CV Mean QWK: 0.5574903141807788

交差検証では QWK の平均として約 0.5574 が得られている。

回帰問題として解いてみる

続いては、同じデータを回帰問題として解くパターンについて。 ラベルが順序つきであれば、回帰問題として解いた上で結果を clip + 四捨五入するような方法もある。

サンプルコードは次のとおり。 やっていることは同じで、解き方が違うだけ。

#!/usr/bin/env python3

import numbers

import numpy as np
import pandas as pd
import lightgbm as lgb
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import cohen_kappa_score


def lgb_custom_metric_qwk_regression(preds, data):
    """LightGBM のカスタムメトリックを計算する関数

    回帰問題として解いた予測から QWK を計算する"""
    # 正解ラベル
    y_true = data.get_label()
    # 予測ラベル
    y_pred = np.clip(preds, 0, 7).round()  # 単純に予測値を clip して四捨五入する
    # QWK を計算する
    return 'qwk', qwk(y_true, y_pred), True


def qwk(y_true, y_pred):
    """QWK (Quadratic Weighted Kappa) を計算する関数"""
    return cohen_kappa_score(y_true, y_pred, weights='quadratic')


def _numerical_only_df(df_):
    """数値のカラムだけ取り出す関数"""
    number_cols = [name for name, dtype in df_.dtypes.items()
                   if issubclass(dtype.type, numbers.Number)]
    numerical_df = df_[number_cols]
    return numerical_df


def main():
    # データセットを読み込む
    train_df = pd.read_csv('train.csv.zip')

    # 説明変数
    x_train = train_df.drop('Response', axis=1)
    # 簡単にするため、とりあえずすぐに使えるものだけ取り出して使う
    x_train = _numerical_only_df(x_train)

    # 目的変数
    y_train = train_df.Response.astype(float)
    # 目的変数はゼロからスタートにする
    y_train -= 1

    # LightGBM のデータセット表現にする
    lgb_train = lgb.Dataset(x_train, y_train)

    # 回帰問題として解く
    lgbm_params = {
        'objective': 'regression',
        'metric': 'rmse',
        'first_metric_only': True,
        'verbose': -1,
    }

    # データ分割の方法
    folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

    # 5-Fold CV で検証する
    result = lgb.cv(lgbm_params,
                    lgb_train,
                    num_boost_round=1000,
                    early_stopping_rounds=100,
                    folds=folds,
                    verbose_eval=10,
                    feval=lgb_custom_metric_qwk_regression,
                    seed=42,
                    )

    # early stop したラウンドでの QWK を出力する
    print(f'CV Mean QWK: {result["qwk-mean"][-1]}')


if __name__ == '__main__':
    main()

上記の実行結果は次のとおり。

$ python qwkreg.py
[10]   cv_agg's rmse: 2.02046 + 0.0085424    cv_agg's qwk: 0.409782 + 0.0042456
[20]   cv_agg's rmse: 1.91647 + 0.0125972    cv_agg's qwk: 0.506518 + 0.00716283
[30]   cv_agg's rmse: 1.87532 + 0.0140473    cv_agg's qwk: 0.553375 + 0.00638559
...(省略)...
[220]  cv_agg's rmse: 1.83657 + 0.0177681    cv_agg's qwk: 0.607652 + 0.00776567
[230]  cv_agg's rmse: 1.83694 + 0.0177138    cv_agg's qwk: 0.608083 + 0.00775286
[240]  cv_agg's rmse: 1.83728 + 0.0176133    cv_agg's qwk: 0.608366 + 0.00780103
CV Mean QWK: 0.6064040204540543

交差検証では QWK の平均として約 0.6064 が得られている。 先ほどよりもスコアが改善している。

回帰問題として解いた上で OOF Prediction を最適化する

続いては、今回の本題である QWK の閾値の最適化について。 OOF で予測した値を、閾値を最適化することでスコアが改善するかどうか確認してみる。

サンプルコードは次のとおり。 OptunaRounder というクラスを導入して、OOF Prediction を最適化している。

#!/usr/bin/env python3

import numbers

import optuna
import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import KFold
from sklearn.metrics import cohen_kappa_score


class OptunaRounder:
    """Optuna を使って QWK の最適な閾値を探索するクラス"""

    def __init__(self, y_true, y_pred):
        # 真のラベル
        self.y_true = y_true
        # 予測したラベル
        self.y_pred = y_pred
        # ラベルの種類
        self.labels = np.unique(y_true)

    def __call__(self, trial):
        """最大化したい目的関数"""
        # 閾値を Define by run で追加していく
        thresholds = []
        # ラベルの数 - 1 が必要な閾値の数になる
        for i in range(len(self.labels) - 1):
            # 閾値の下限 (既存の最大 or ラベルの最小値)
            low = max(thresholds) if i > 0 else min(self.labels)
            # 閾値の上限 (ラベルの最大値)
            high = max(self.labels)
            # 閾値の候補を追加する
            t = trial.suggest_uniform(f't{i}', low, high)
            thresholds.append(t)

        # 閾値の候補を元に QWK を計算する
        opt_y_pred = self.adjust(self.y_pred, thresholds)
        return qwk(self.y_true, opt_y_pred)

    def adjust(self, y_pred, thresholds):
        """閾値にもとづいて予測を補正するメソッド"""
        opt_y_pred = pd.cut(y_pred,
                            [-np.inf] + thresholds + [np.inf],
                            labels=self.labels)
        return opt_y_pred


def lgb_custom_metric_qwk_regression(preds, data):
    """LightGBM のカスタムメトリックを計算する関数

    回帰問題として解いた予測から QWK を計算する"""
    # 正解ラベル
    y_true = data.get_label()
    # 予測ラベル
    y_pred = np.clip(preds, 0, 7).round()  # 単純に clip して四捨五入する
    # QWK を計算する
    return 'qwk', qwk(y_true, y_pred), True


def qwk(y_true, y_pred):
    """QWK (Quadratic Weighted Kappa) を計算する関数"""
    return cohen_kappa_score(y_true, y_pred, weights='quadratic')


class ModelExtractionCallback(object):
    """lightgbm.cv() から学習済みモデルを取り出すためのコールバックに使うクラス"""

    def __init__(self):
        self._model = None

    def __call__(self, env):
        # _CVBooster の参照を保持する
        self._model = env.model

    def _assert_called_cb(self):
        if self._model is None:
            # コールバックが呼ばれていないときは例外にする
            raise RuntimeError('callback has not called yet')

    @property
    def boosters_proxy(self):
        self._assert_called_cb()
        # Booster へのプロキシオブジェクトを返す
        return self._model

    @property
    def raw_boosters(self):
        self._assert_called_cb()
        # Booster のリストを返す
        return self._model.boosters

    @property
    def best_iteration(self):
        self._assert_called_cb()
        # Early stop したときの boosting round を返す
        return self._model.best_iteration


def _numerical_only_df(df_):
    """数値のカラムだけ取り出す関数"""
    number_cols = [name for name, dtype in df_.dtypes.items()
                   if issubclass(dtype.type, numbers.Number)]
    numerical_df = df_[number_cols]
    return numerical_df


def main():
    # データセットを読み込む
    train_df = pd.read_csv('train.csv.zip')

    # 説明変数
    x_train = train_df.drop('Response', axis=1)
    # 簡単にするため、とりあえずすぐに使えるものだけ取り出して使う
    x_train = _numerical_only_df(x_train)
    # Id 列をインデックスに設定する
    x_train = x_train.set_index('Id')

    # 目的変数
    y_train = train_df.Response.astype(float)
    # 目的変数はゼロからスタートにする
    y_train -= 1

    # LightGBM のデータセット表現にする
    lgb_train = lgb.Dataset(x_train, y_train)

    # 回帰問題として解く
    lgbm_params = {
        'objective': 'regression',
        'metric': 'rmse',
        'first_metric_only': True,
        'verbose': -1,
    }

    # データ分割の方法
    folds = KFold(n_splits=5, shuffle=True, random_state=42)

    # 学習済みモデルを取り出すためのコールバックを用意する
    extraction_cb = ModelExtractionCallback()
    callbacks = [
        extraction_cb,
    ]

    # 5-Fold CV で検証する
    result = lgb.cv(lgbm_params,
                    lgb_train,
                    num_boost_round=10000,
                    early_stopping_rounds=100,
                    folds=folds,
                    verbose_eval=10,
                    feval=lgb_custom_metric_qwk_regression,
                    callbacks=callbacks,
                    seed=42,
                    )

    # early stop したラウンドでの QWK を出力する
    print(f'CV Mean QWK: {result["qwk-mean"][-1]}')

    # 学習データを学習済みモデルを使って OOF で予測する
    boosters = extraction_cb.raw_boosters
    best_iteration = extraction_cb.best_iteration
    oof_y_pred = np.zeros_like(y_train)
    for (_, val_index), booster in zip(folds.split(x_train, y_train), boosters):
        x_val = x_train.iloc[val_index]
        oof_y_pred[val_index] = booster.predict(list(x_val.to_numpy()),
                                                num_iteration=best_iteration)

    # ひとまず clipping + 四捨五入する
    oof_y_pred = np.clip(oof_y_pred, 0, 7).round()

    # 最適化していない状態での OOF Prediction の QWK
    raw_oof_qwk = qwk(y_train, oof_y_pred)
    print(f'Raw OOF QWK: {raw_oof_qwk}')

    # Optuna を使って QWK の閾値を最適化する
    objective = OptunaRounder(y_train, oof_y_pred)

    # 探索に 100 秒かける
    study = optuna.create_study(direction='maximize')
    study.optimize(objective, timeout=100)

    # 見つけた閾値
    best_thresholds = sorted(study.best_params.values())
    print(f'Optimized thresholds: {best_thresholds}')

    # 閾値を最適化した場合の QWK
    optimized_oof_y_pred = objective.adjust(oof_y_pred, best_thresholds)
    optimized_oof_qwk = qwk(y_train, optimized_oof_y_pred)
    print(f'Optimized OOF QWK: {optimized_oof_qwk}')

    # テスト用データを読み込む
    test_df = pd.read_csv('test.csv.zip')
    test_df = test_df.set_index('Id')

    # 数値のカラムだけ取り出す
    numerical_test_df = _numerical_only_df(test_df)

    # 学習済みモデルの Averaging で予測する
    boosters_proxy = extraction_cb.boosters_proxy
    test_y_preds = boosters_proxy.predict(numerical_test_df,
                                          num_iteration=best_iteration)
    test_y_pred_avg = np.mean(test_y_preds, axis=0)

    # サンプル提出ファイルを読み込む
    submission_df = pd.read_csv('sample_submission.csv.zip')
    submission_df = submission_df.set_index('Id')

    # 最適化していない予測値 (単純なクリッピングと四捨五入)
    submission_df.Response = np.clip(test_y_pred_avg, y_train.min(), y_train.max() + 1).round().astype(int) + 1
    submission_df.to_csv('unoptimized_submission.csv')

    # 最適化した予測値
    optimized_test_y_pred_avg = objective.adjust(test_y_pred_avg, best_thresholds)
    submission_df.Response = optimized_test_y_pred_avg.astype(int) + 1
    submission_df.to_csv('optimized_submission.csv')


if __name__ == '__main__':
    main()

上記を実行した結果は次のとおり。

$ python qwkregopt.py
[10]   cv_agg's rmse: 2.01959 + 0.0101767    cv_agg's qwk: 0.408957 + 0.00513284
[20]   cv_agg's rmse: 1.9165 + 0.008155  cv_agg's qwk: 0.507252 + 0.00192446
[30]   cv_agg's rmse: 1.87492 + 0.00797763   cv_agg's qwk: 0.553388 + 0.00129979
...(省略)...
[200]  cv_agg's rmse: 1.8374 + 0.00729844    cv_agg's qwk: 0.60726 + 0.00206979
[210]  cv_agg's rmse: 1.83764 + 0.00757892   cv_agg's qwk: 0.607434 + 0.00210639
[220]  cv_agg's rmse: 1.83771 + 0.00759351   cv_agg's qwk: 0.607882 + 0.00205622
CV Mean QWK: 0.6054069736940326
Raw OOF QWK: 0.605407593931218
[I 2020-03-01 12:39:28,349] Finished trial#0 resulted in value: 0.14760909846772208. Current best value is 0.14760909846772208 with parameters: {'t0': 0.6069999513322623, 't1': 6.071871351250824, 't2': 6.421127597260041, 't3': 6.6499083619322334, 't4': 6.825786239070231, 't5': 6.94896643994053, 't6': 6.988042174746154}.
[I 2020-03-01 12:39:28,461] Finished trial#1 resulted in value: 0.3273196067078864. Current best value is 0.3273196067078864 with parameters: {'t0': 1.3022593301640533, 't1': 3.3523582269835885, 't2': 5.775809715879871, 't3': 6.748066158840195, 't4': 6.813600862422318, 't5': 6.952492269479133, 't6': 6.986909664863027}.
[I 2020-03-01 12:39:28,580] Finished trial#2 resulted in value: 0.1845825834695184. Current best value is 0.3273196067078864 with parameters: {'t0': 1.3022593301640533, 't1': 3.3523582269835885, 't2': 5.775809715879871, 't3': 6.748066158840195, 't4': 6.813600862422318, 't5': 6.952492269479133, 't6': 6.986909664863027}.
...(省略)...
[I 2020-03-01 12:41:08,371] Finished trial#660 resulted in value: 0.3366380459371958. Current best value is 0.6461578531751463 with parameters: {'t0': 1.9366086554521658, 't1': 2.765496885821397, 't2': 3.711341767628701, 't3': 3.8616368179727982, 't4': 4.309923993071266, 't5': 5.4467521001848525, 't6': 5.753790447645578}.
Optimized thresholds: [1.9366086554521658, 2.765496885821397, 3.711341767628701, 3.8616368179727982, 4.309923993071266, 5.4467521001848525, 5.753790447645578]
Optimized OOF QWK: 0.6461578531751463

最適化する前の OOF Prediction の QWK が約 0.6054 なのに対して、閾値を最適化すると約 0.6461 と、大きくスコアが向上している。

手元の交差検証だけでは何か間違っている恐れがあるので、テストデータに対するサブミッションまでやってみた。 先ほどのモジュールを実行すると unoptimized_submission.csv という最適化前のファイルと、optimized_submission.csv という最適化後のファイルができる。 すると、最適化する前の Public LB / Private LB が 0.61931 / 0.62190 だったのに対して、最適化した後は 0.65801 / 0.66005 と、ちゃんと効果があることが確認できた。

所感など

QWK の最適化は、真のラベルの分布に依存している。 そのため、学習済みモデルを使って予測する対象が学習時の分布と異なる場合、最適化の効果が得られない。 この点から、Private データの分布が異なる場合には最適化が有効ではないと考えられる。

だとすると、Private データの分布が学習時と同じであるという確証が得られない限りは使うのは賭けになるのでは、という疑問が浮かんだ。 とはいえ、うまくいった場合の効果は絶大なので、ある程度の仮定のもとに使う、という選択肢は現実的なのかもしれない。

いじょう。

参考

Kaggleで勝つデータ分析の技術

Kaggleで勝つデータ分析の技術

www.kaggle.com

blog.amedama.jp

blog.amedama.jp