Focal Lossとは?
facebookが開発した損失関数で、分類問題を解く時にイージーサンプル(予測が容易なサンプル)のロスを小さくすることで、ハードサンプルを集中的に学習させることができる損失関数になります。
分類で一般的に使用されるcross entropy lossと非常に似ている損失曲線をしています。

(参照:Focal Loss for Dense Object Detection)
図でFLがFocal Lossを表しており、γはこちらで与えるパラメータになります。γの値が大きいほど、分類が良くできている対象(容易な対象)に対してロスが0に近いことがわかります。
例えば、学習データに大量のイージーサンプルが含まれている場合、Cross entropy lossではイージーサンプルに対してもロスが発生している(図の青線)ので、それらに対してモデルのパラメータが更新されてしまい偏ったモデルができてしまうことが考えられます。
これに対して、容易なサンプルをダウンサンプリング(除外する)必要がありますが、Focal lossでは、自然にイージーサンプルを除外できることが利点になるかと思います。
LightGBMのCustom objective function
勾配ブースティング系のライブラリはデフォルトで様々な損失関数や評価指標が用意されており、タスクに応じて簡単に切り替えることができます。
また、今回紹介したFocal lossのような、公式ではサポートされていない損失関数や、何かしらのオリジナルの損失関数がある場合でも、損失関数の1,2階微分を計算する関数を与えるだけで最適化してくれます。(中で目的関数をテイラー展開により二次の項までの近似しているため)
最初に標準実装されているBinary Cross EntropyをCustom objective で定義してみます。
データは昔参加したSantanderのコンペのデータを使用します。

普通に二値分類を行う場合
まずは、普通に二値分類を行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import pandas as pd import lightgbm as lgb from sklearn.model_selection import train_test_split import numpy as np random_state = 42 np.random.seed(random_state) train_df =pd.read_csv("../input/train.csv") features = [col for col in train_df.columns if col not in ['target', 'ID_code']] X_train, X_test, y_train, y_test = train_test_split(train_df, train_df["target"], test_size=0.33, random_state=42) trn_data = lgb.Dataset(X_train[features], label=X_train["target"]) val_data = lgb.Dataset(X_test[features], label=X_test["target"]) evals_result = {} lgb_params = { "objective" : "binary", "metric" : "binary_logloss", "verbosity" : -1, "seed": random_state } lgb_clf = lgb.train(lgb_params, trn_data, 100000, valid_sets = [trn_data, val_data], early_stopping_rounds=300, verbose_eval = 100, evals_result=evals_result ) |
こちらをベースラインとして、損失関数を変更していきます。
損失関数とメトリックを自作する場合
上記と同じ結果を期待して、loglossの1階微分(grad)と2階微分(hessian)を計算する関数とメトリックをfobj, fevalに与えます。余談ですが、勾配の計算はこちらのサイトが便利です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
def original_binary_logloss_metric(y_pred, trn_data): y_train = trn_data.get_label() pred = 1/(1+np.exp(-y_pred)) loss = -(y_train * np.log(pred) + (1-y_train)*np.log(1-pred)) return 'original_binary_logloss', np.mean(loss), False def original_binary_logloss_objective(y_pred, trn_data): y_true = trn_data.get_label() pred = 1/(1+np.exp(-y_pred)) grad = pred - y_true hess = pred * (1-pred) return grad, hess lgb_params = { "metric" : "custom", "verbosity" : -1, "seed": random_state } lgb_clf = lgb.train(lgb_params, trn_data, num_boost_round=100000, valid_sets = [trn_data, val_data], early_stopping_rounds=300, verbose_eval = 100, evals_result=evals_result, fobj=original_binary_logloss_objective, feval=original_binary_logloss_metric ) |
勾配ブースティング系のアルゴリズムは前の木の予測値から、誤差を計算して、誤差にフィッティングするように学習が進みますが、学習の最初の木を構築するための初期値の設定が必要になります。
二値分類の場合、標準のライブラリーでは学習データの正例・負例の対数オッズ比で初期値が設定されているので、学習データを作成する際にそのように初期値を与える必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
def original_init_score(y): y = y.mean() return np.log(y/(1-y)) trn_data = lgb.Dataset(X_train[features], label=X_train["target"], init_score=np.full_like(X_train["target"], original_init_score(X_train["target"]), dtype=float)) val_data = lgb.Dataset(X_test[features], label=X_test["target"], init_score=np.full_like(X_test["target"], original_init_score(X_train["target"]), dtype=float)) evals_result = {} lgb_params = { "metric" : "custom", "verbosity" : -1, "seed": random_state } lgb_clf = lgb.train(lgb_params, trn_data, num_boost_round=100000, valid_sets = [trn_data, val_data], early_stopping_rounds=300, verbose_eval = 100, evals_result=evals_result, fobj=original_binary_logloss_objective, feval=original_binary_logloss_metric ) |
最初に計算した値と一致しました。
初期値は与え方が悪いと精度はあまり変わらないですが、収束が遅くなるのは確実なので、複雑なカスタムロスを使用して異常に学習が遅い場合は、初期値の与え方を変えてみると良いかもしれません。
Focal Lossによる学習
続いて、Focal lossを損失関数として学習をさせてみます。Focal Lossは下記の式で表されます。

(参照:Focal Loss for Dense Object Detection)
αはCross entropyでも使用される、正例・負例のロスの重みパラメータとなります。(論文ではαを付与させた方が精度が良かったようです)
上記の式をloglossの形に変換すると下記のように書けます。

これを目的関数として、1階微分と2階微分を計算します。微分の計算はscipyのderivativeメソッドを使用します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
from scipy.misc import derivative class focal_loss: def __init__(self, alpha, gamma, balance=False): self.alpha = alpha self.gamma = gamma self.balance = balance def focal_loss_objective(self, y_pred, trn_data): y_true = trn_data.get_label() def fl(x,t): p = 1/(1+np.exp(-x)) if self.balance: return -(t*(1-p)**self.gamma * np.log(p) * self.alpha + p ** self.gamma * (1-t) * np.log(1-p) * (1-self.alpha)) else: return -(t*(1-p)**self.gamma * np.log(p) + p ** self.gamma * (1-t) * np.log(1-p)) partial_fl = lambda x: fl(x, y_true) grad = derivative(partial_fl, y_pred, n=1, dx=1e-6) hess = derivative(partial_fl, y_pred, n=2, dx=1e-6) return grad, hess def original_binary_logloss_metric(self, y_pred, trn_data): y_train = trn_data.get_label() pred = 1/(1+np.exp(-y_pred)) loss = -(y_train * np.log(pred) + (1-y_train)*np.log(1-pred)) return 'original_binary_logloss', np.mean(loss), False focal = focal_loss(alpha=1, gamma=0, balance=False) trn_data = lgb.Dataset(X_train[features], label=X_train["target"], init_score=np.full_like(X_train["target"], original_init_score(X_train["target"]), dtype=float)) val_data = lgb.Dataset(X_test[features], label=X_test["target"], init_score=np.full_like(X_test["target"], original_init_score(X_train["target"]), dtype=float)) evals_result = {} lgb_params = { "metric" : "custom", "verbosity" : -1, "seed": random_state } lgb_clf = lgb.train(lgb_params, trn_data, num_boost_round=100000, valid_sets = [trn_data, val_data], early_stopping_rounds=300, verbose_eval = 100, evals_result=evals_result, fobj=focal.focal_loss_objective, feval=focal.original_binary_logloss_metric ) |
微分の値の違いで結果が微妙に変わってしまっていますが、γ=0としてαを使用しなければ、普通のloglossを損失関数とした時と同じとなります。
これをベースラインとしてγを0(Focal loss無し)からγ=0.5まで値を変えた時のロスの変化を確認しました。(今回はαは使用していません)

γが0.1時にベースラインより精度が良くなっており、微妙ですがFocal Lossにより精度が向上していることがわかります。
まとめ
LightGBMでFocal lossを使用してみました。特に初期値を与えないと収束が遅くなることは知らなかったですし、微分を計算するscipyのメソッドも勉強になりました。
今回のケースでは、劇的な精度向上はしませんでしたが、例えばどちらかのクラスのデータが冗長すぎる場合などに適用してみる価値がありそうです。
最後になりましたが、下記ブログで勉強させていただきました。感謝です。m(__)m