Zenn
🏥

Kaggle CIBMTRコンペ1位解法まとめ〜感想を添えて〜

に公開

はじめに

この記事では、CIBMTRコンペ1位解法を日本語でまとめます。
私もこのコンペには参戦しており、色々な感想を抱きました。そこで、自分の感想は解法と区別して添える形で掲載しました。
私自身はKaggleを始めて9ヶ月のエンジニア(非データサイエンティスト)です。間違っている点などあればぜひコメント等でご指摘ください。

1位解法

ソロで1位(LBもPBも両方1位。すごい!)を取ったminerppdyさんのsolutionを紹介。

概要

分類モデルと回帰モデルをそれぞれ独立に学習させて独自の関数でマージ

分類モデル

  • 患者が efs == 0 か efs == 1 かどうかで二値分類

回帰モデル

  • efs == 1の患者の生存時間(efs_time)の順位を予測

流れ

分類モデルで患者が efs == 0 である確率を予測

  • efs == 0 である確率が高い場合は、高いスコアを与える
  • efs == 1である確率が高い場合は、回帰モデルで得たefs_timeの順位を考慮してスコアを決定

感想

公開Notebookでは、efsとefs_timeを組み合わせた新しい値を目的変数として回帰タスクとして取り組むのが主流に見えた(自分もそうしていた)が、上位解法では分類と回帰を組み合わせているようでした。
2位の解法でも、分類モデルと回帰モデルが組み合わせられていました。

特徴量エンジニアリング

  • データセットに含まれる特徴量の利用
  • すべてのカテゴリ変数についてワンホットエンコーディング(ワンホットエンコーディング後も、元のカテゴリ変数自体は残しておく)
  • 連続値の特徴量は(ビン分割などを用いて)カテゴリ変数に変換(元の連続値の特徴量自体は残しておく)

感想

元のカテゴリ変数自体も、元の連続値の特徴量自体も、残しておくのはより多様なパターンを学習できるようにするという意図なのかもしれません。
→ 変換しても元の特徴量を残しておくという考え方は覚えておきたいです。

CV

分割方法によってCV(交差検証)の結果が大きく変動してしまう可能性があるため、すべての分類モデルと回帰モデルの学習で、同じランダムシード(ここでは888)を使って10分割(10-fold)のStratifiedKFoldを採用
→結果の再現性と比較のしやすさが向上

StratifiedKFoldではefsとrace_groupのバランスを保つように設定
→モデルの評価を安定させるという目的

y_combine = data['efs'].astype('str')+'|'+data['X']['race_group'].astype('str')
skf = StratifiedKFold(n_splits=10, shuffle=True, random_state=888)
skf.split(X,y_combine)

Early Stoppingを使わない

・Fold数が多い時に、各Foldのデータにオーバーフィッティングしてしまうのを防ぐために、early stoppingを使わないことでより安定してモデルを評価できるようにした。

感想

Fold数が多い時に、各Foldのデータにオーバーフィッティングしてしまうのを防ぐために、early stoppingを使わないという考え方は知りませんでした。10-fold ということは、各foldに割り当てられるバリデーションデータ量が小さく、そこにオーバーフィットしてearly stoppingが止まりすぎたり、誤差が大きくブレたりするリスクがあるからということだと解釈しました。

分類

分類に使ったモデル:

  • XGBoost, LightGBM, CatBoost
  • NN, TabM
  • GNN(グラフニューラルネットワーク)
  • NN/TabNet/GNN with pairwise-rank-loss

GNN手法:

  • KNNを使用して最も近い25個のノードを探し(ユークリッド距離を使用)、エッジを作成した後、GraphSAGEを使ってターゲットにフィッティング
  • rank lossを使用する場合、異なるfold間で予測がずれてしまうことがあり、このずれが原因で、全体の評価指標を計算する際にCVスコアが低くなる可能性があるため、各foldで学習したモデルの平均または中央値の予測値が同じになるように、これらのずれを補正
  • GNNでもloglossを使用している場合に同じ問題が発生(その理由は不明)

LGB と XGB の最適な最大深度は 2 (CatBoost の場合は 6 ):

  • これは、相互作用情報が少なく、タスクが簡単に適合することを意味しており、NN のようなモデルがこの表形式のデータでもうまく機能するのもこれが理由かもしれません。

感想

fold間のずれは全然思いつかなかったですが、全体のoof予測をするときは気をつけたいポイントだと思いました。
depthが2というのは、自分の環境ではそうではなかったですが、xgbよりもcatboostの方がdepthが大きくなるのは自分の環境でも同じでした。

回帰

Target

Grouped and normalized rank by efs

efs_time_norm[efs == 1] = efs_time[efs == 1].rank() / sum(efs == 1)
efs_time_norm[efs == 0] = efs_time[efs == 0].rank() / sum(efs == 0)

感想

efs==0とefs==1でrankを分けるという考え方は思いつかなかったです。

利用したモデル

XGBoost lightgbm catboost( NN 系のモデルはうまくいかなかった)

トリック

  • トレーニング時に efs を特徴量として追加し、efs == 1 のサンプルでの性能に注目
  • リーダーボード(LB)データに対して推論を行うときには、efs = 1 と設定
  • サンプルウェイトを efs == 1 と efs == 0 でそれぞれ 0.6 : 0.4 に設定

なぜこのトリックを使うのか

efs == 0 の場合は打ち切りデータが含まれることもあり、efs_time が本来は意味をなさないはずにもかかわらず、
efs == 1 のサンプルだけを使って回帰器を訓練して、そのモデルを efs == 0 のサンプルに適用して推論したところ、予測値と実測値の間に相関がみられた。(おそらく SurvalGAN アルゴリズムが影響)
さらに調べてみると、efs == 0 と efs == 1 のサンプルには似たようなパターンがあることもわかり、efs == 0 のサンプルも追加して学習させることで、efs == 1 に対する回帰器の性能が大幅に向上した。

マージ関数

分類と回帰の結果をマージ

# efs_time(0~1 にスケール)
def model_merge(Y_HAT_REG, Y_HAT_CLS, a=2.96, b=1.77, c=0.52):
    '''
    Y_HAT_REG and Y_HAT_CLS need be scaled to 0~1
    a,b,c need to be tuned with optuna
    '''
    y_fun = (Y_HAT_REG>0)*c*(np.abs(Y_HAT_REG))**(b)
    x_fun = (Y_HAT_CLS>0)*(np.abs(Y_HAT_CLS))**(a)
    res = (1-y_fun)*x_fun + y_fun
    res = pd.Series(res).rank()/len(res)
    return res

感想

(1-y_fun)*x_fun + y_fun のような非線形変換によるマージは思いつかなかったです。
経験が蓄積されている人は、こういう引き出しを持っておけるのかもしれないです。

アンサンブル

分類器と回帰器を組み合わせて、それぞれのペアを作成

5fold-CVを用いてマージ関数のパラメータ (a,b,c)(a, b, c)(a,b,c) を最適化し、それによって得られた予測をマージして最終的な結果を得る

マージ後の予測については、さらにOptunaと5fold-CVを使って加重平均の重みを調整し、アンサンブル

分類器9種類 × 回帰器3種類 = 27通りの組み合わせがあり、過学習のリスクがあるため、重みのOptuna探索範囲は0〜1ではなく0.1〜1に設定(一種の正則化とも言える)

一部の人種グループにノイズを加えることで、交差検証の性能は大きく向上したが、パブリックLBやプライベートLBでは効果がなかった。

感想

Optuna探索範囲は0〜1ではなく0.1〜1に設定は今後も使うケースがありそうです。

最後に

1位解法には色々な工夫があり、その裏側には、経験による多様な蓄積と粘り強い検証があったのだと思います。自分もこれからもコンペに参加して経験値を増やしていきたいです。まずは、GNNの実装をしたことがないので、それを試してみたいと思います。
参戦した方々はお疲れ様でした!

Discussion

ログインするとコメントできます