たかいとの備忘録

自然言語処理や機械学習のことについて学んだことを忘れないように書いていけたらと思います.

【FDUA】第二回 金融データ活用チャレンジの戦い方を考える

はじめに

第二回 金融データ活用チャレンジに関しては,以下をご参考ください. signate.jp

第一回金融データ活用チャレンジに続き,今回もbaselineとなるようなnotebookを共有することができたらと思います.

今更ながら開会式を視聴させていただきました. 前回同様,コンペ開催の目的など,とてもポジティブなものであり,コンペが盛り上がるといいなと思っております.(記事投稿の理由も少しでもコンペの活性化に貢献することを意識しており,最低限の実装とコンペを進める上で手詰まりを避けるためのアイデアをいくつか書かせていただきました.)

当該コンペはディスカッション(フォーラム)がなく,baselineコードなどの共有が少ないため,コードを共有できたらと思います. また,ルールに書いてあるように,前処理,学習,予測の3つにコードを分け実装が必要となります.

slackの自己紹介チャンネルでも「初めて挑戦します!」という方が多かったので,コンペでの一番の楽しみである,特徴量作成,自身の工夫を加えるというところにぜひ時間を割いてもらい,少しでもデータ分析コンペの楽しさを実感してもらえたら幸いです.(特にコンペ期間も残り3週間を切っており,何もできずに終わってしまったというのを回避してもらえたら幸いです!) なお,本記事はコンペで共有されているチュートリアルを元に作成しております.

まだ始まったばかりで,的外れな意見もあるかと思いますが,何かの参考になれば幸いです.

今回のコンペに取り組むにあたって

今回のコンペは第一回よりもよりシンプルな課題設計となっており,よりトライしやすいコンペだと感じました.

また,分析環境やツールも充実しておりますが,使い慣れていない環境は最初に時間をだいぶ使うと思うので,使うものをいくつか絞るといいのではないかと思います.

ただ,コンペが終わると使えなくなってしまうので,コンペが終わってからも使えるcolab環境のコードを共有することができたらと思います. 今後,データ分析を個人的にするときなどにも,ぜひ活用していただければと思います!

Baseline Notebook

Baseline Notebookを以下で共有します.

colab.research.google.com

「ファイル」から「ドライブにコピーを保存」を選んでもらい,コピーすればほとんどそのまま使えると思います. signateからコンペのデータをダウンロードし,自身のドライブに保存してください. また,データの置く場所によって,データへのパスなどを書き換えてもらえたらと思います.

上記のNotebookを使用することで,特徴量などの工夫をしなくてもリーダーボードで0.682(現在90位)くらいのスコアが出ます.

ここからは,公開したものに関して簡単に紹介します.

作成が必要な関数

ルールの「実装方法」にも書いてある通り,前処理,学習,予測の3つにコードを分け実装が必要となります.

①Preprocessing「前処理」

  提供データを読み込み、前処理を施し、モデルに入力が可能な状態に変換するモジュール。

  get_train_dataやget_test_dataのように、学習用と評価用を分けて、前処理を行う関数を定義。

 ②Learning「学習」

  ①の出力を読み込み、モデルを学習し、学習済みモデルを出力するモジュール。

  学習済みモデルや特徴量、クロスバリデーションの評価結果を出力する関数等を定義。

 ③Predicting「予測」

  ①で作成した評価要データ及び②で作成した学習済みモデルを読み込み、予測結果を出力するモジュール。

まずはこれらに沿った関数を紹介します.

①Preprocessing「前処理」

def Preprocessing(input_df: pd.DataFrame()) -> pd.DataFrame():
    def deal_missing(input_df: pd.DataFrame()) -> pd.DataFrame():
        output_df = input_df.copy()
        for col in ['RevLineCr', 'LowDoc', 'BankState', 'DisbursementDate']:
            output_df[col] = input_df[col].fillna('[UNK]')
        return output_df
    def clean_money(input_df: pd.DataFrame()) -> pd.DataFrame():
        output_df = input_df.copy()
        for col in ['DisbursementGross', 'GrAppv', 'SBA_Appv']:
            output_df[col] = input_df[col].str[1:].str.replace(',', '').str.replace(' ', '').astype(float)
        return output_df
    output_df = deal_missing(input_df)
    output_df = clean_money(output_df)
    output_df['NewExist'] = np.where(input_df['NewExist'] == 1, 1, 0)
    def make_features(input_df: pd.DataFrame()) -> pd.DataFrame():
        output_df = input_df.copy()
        # いろいろ特徴量作成を追加する
        return output_df
    output_df = make_features(output_df)
    return output_df

関数「Preprocessing」の中の「make_features」という関数内に,特徴量作成をいろいろと追加してもらえたらと思います. いろいろな工夫を加えるのがコンペの醍醐味の一つかと思いますので,何も特徴量作成は行わず,最低限のデータ処理のみを行ったものとなります.

カテゴリカル変数のラベルへの変換やカウントエンコーディング(学習データ内で何回出現したかで埋め込みを行う)も本来この中に入れるべきなのですが,学習データ時とテストデータ時で分岐が必要で,煩雑になるため,今回は別枠で実施しております.

②Learning「学習」

baselineとして,kaggleなどのコンペで使用率の高い決定木ベースの手法の実装を共有します.

細かいことは,公式ドキュメントなどを確認してもらえたら幸いです. 初心者の方は,u++さんの「初手LightGBM」をする7つの理由をまずは読むのがオススメです. 自身としては,高速,高い精度が見込める,特徴量のスケーリングが不要(決定木系モデルなので)の三点が特にポイントかと思います.

# ====================================================
# Metric
# ====================================================
# f1_score

# ====================================================
# LightGBM Metric
# ====================================================
def lgb_metric(y_pred, y_true):
    y_true = y_true.get_label()
    return 'f1score', f1_score(y_true, np.where(y_pred >= 0.5, 1, 0), average='macro'), CFG.metric_maximize_flag

# ====================================================
# XGBoost Metric
# ====================================================
def xgb_metric(y_pred, y_true):
    y_true = y_true.get_label()
    return 'f1score', f1_score(y_true, np.where(y_pred >= 0.5, 1, 0), average='macro')


def lightgbm_training(x_train: pd.DataFrame, y_train: pd.DataFrame, x_valid: pd.DataFrame, y_valid: pd.DataFrame, features: list, categorical_features: list):
    lgb_train = lgb.Dataset(x_train, y_train, categorical_feature=categorical_features)
    lgb_valid = lgb.Dataset(x_valid, y_valid, categorical_feature=categorical_features)
    model = lgb.train(
                params = CFG.classification_lgb_params,
                train_set = lgb_train,
                num_boost_round = CFG.num_boost_round,
                valid_sets = [lgb_train, lgb_valid],
                feval = lgb_metric,
                callbacks=[lgb.early_stopping(stopping_rounds=CFG.early_stopping_round, 
                                              verbose=CFG.verbose)]
            )
    # Predict validation
    valid_pred = model.predict(x_valid)
    return model, valid_pred
def xgboost_training(x_train: pd.DataFrame, y_train: pd.DataFrame, x_valid: pd.DataFrame, y_valid: pd.DataFrame, features: list, categorical_features: list):
    xgb_train = xgb.DMatrix(data=x_train, label=y_train)
    xgb_valid = xgb.DMatrix(data=x_valid, label=y_valid)
    model = xgb.train(
                CFG.classification_xgb_params, 
                dtrain = xgb_train, 
                num_boost_round = CFG.num_boost_round, 
                evals = [(xgb_train, 'train'), (xgb_valid, 'eval')], 
                early_stopping_rounds = CFG.early_stopping_round, 
                verbose_eval = CFG.verbose,
                feval = xgb_metric, 
                maximize = CFG.metric_maximize_flag, 
            )
    # Predict validation
    valid_pred = model.predict(xgb.DMatrix(x_valid))
    return model, valid_pred
def catboost_training(x_train: pd.DataFrame, y_train: pd.DataFrame, x_valid: pd.DataFrame, y_valid: pd.DataFrame, features: list, categorical_features: list):
    cat_train = Pool(data=x_train, label=y_train, cat_features=categorical_features)
    cat_valid = Pool(data=x_valid, label=y_valid, cat_features=categorical_features)
    model = CatBoostClassifier(**CFG.classification_cat_params)
    model.fit(cat_train, 
              eval_set = [cat_valid],
              early_stopping_rounds = CFG.early_stopping_round, 
              verbose = CFG.verbose, 
              use_best_model = True)
    # Predict validation
    valid_pred = model.predict_proba(x_valid)[:, 1]
    return model, valid_pred

def gradient_boosting_model_cv_training(method: str, train_df: pd.DataFrame, features: list, categorical_features: list):
    # Create a numpy array to store out of folds predictions
    oof_predictions = np.zeros(len(train_df))
    oof_fold = np.zeros(len(train_df))
    kfold = KFold(n_splits=CFG.n_folds, shuffle=True, random_state=CFG.seed)
    for fold, (train_index, valid_index) in enumerate(kfold.split(train_df)):
        print('-'*50)
        print(f'{method} training fold {fold+1}')
        
        x_train = train_df[features].iloc[train_index]
        y_train = train_df[CFG.target_col].iloc[train_index]
        x_valid = train_df[features].iloc[valid_index]
        y_valid = train_df[CFG.target_col].iloc[valid_index]
        if method == 'lightgbm':
            model, valid_pred = lightgbm_training(x_train, y_train, x_valid, y_valid, features, categorical_features)
        if method == 'xgboost':
            model, valid_pred = xgboost_training(x_train, y_train, x_valid, y_valid, features, categorical_features)
        if method == 'catboost':
            model, valid_pred = catboost_training(x_train, y_train, x_valid, y_valid, features, categorical_features)
        
        # Save best model
        pickle.dump(model, open(CFG.MODEL_DATA_PATH / f'{method}_fold{fold + 1}_seed{CFG.seed}_ver{CFG.VER}.pkl', 'wb'))
        # Add to out of folds array
        oof_predictions[valid_index] = valid_pred
        oof_fold[valid_index] = fold + 1
        del x_train, x_valid, y_train, y_valid, model, valid_pred
        gc.collect()

    # Compute out of folds metric
    score = f1_score(train_df[CFG.target_col], oof_predictions >= 0.5, average='macro')
    print(f'{method} our out of folds CV f1score is {score}')
    # Create a dataframe to store out of folds predictions
    oof_df = pd.DataFrame({CFG.target_col: train_df[CFG.target_col], f'{method}_prediction': oof_predictions, 'fold': oof_fold})
    oof_df.to_csv(CFG.OOF_DATA_PATH / f'oof_{method}_seed{CFG.seed}_ver{CFG.VER}.csv', index = False)
    
def Learning(input_df: pd.DataFrame, features: list, categorical_features: list):
    for method in CFG.METHOD_LIST:
        gradient_boosting_model_cv_training(method, input_df, features, categorical_features)

モデルの学習は,学習データへの過学習を防ぐためにクロスバリデーションすることが多く,上記のコードも学習データを7分割しております. クロスバリデーションに関しては,交差検証(cross validation/クロスバリデーション)の種類を整理してみたなどが参考になるかと思います. 今回のデータは,学習データとテストデータの分布が非常に似ているため,ランダムに分割を行っておりますが,目的変数のラベル割合を同じように分割する方法などもあるので,いろいろと試してみるといいかと思います.

また,lightgbmとxgboostは,学習の過学習抑制のためのearly stoppingに使用する評価指標をf1 scoreで止めることができるようにしております.

③Predicting「予測」

def lightgbm_inference(x_test: pd.DataFrame):
    test_pred = np.zeros(len(x_test))
    for fold in range(CFG.n_folds):
        model = pickle.load(open(CFG.MODEL_DATA_PATH / f'lightgbm_fold{fold + 1}_seed{CFG.seed}_ver{CFG.VER}.pkl', 'rb'))
        # Predict
        pred = model.predict(x_test)
        test_pred += pred
    return test_pred / CFG.n_folds
def xgboost_inference(x_test: pd.DataFrame):
    test_pred = np.zeros(len(x_test))
    for fold in range(CFG.n_folds):
        model = pickle.load(open(CFG.MODEL_DATA_PATH / f'xgboost_fold{fold + 1}_seed{CFG.seed}_ver{CFG.VER}.pkl', 'rb'))
        # Predict
        pred = model.predict(xgb.DMatrix(x_test))
        test_pred += pred
    return test_pred / CFG.n_folds
    
def catboost_inference(x_test: pd.DataFrame):
    test_pred = np.zeros(len(x_test))
    for fold in range(CFG.n_folds):
        model = pickle.load(open(CFG.MODEL_DATA_PATH / f'catboost_fold{fold + 1}_seed{CFG.seed}_ver{CFG.VER}.pkl', 'rb'))
        # Predict
        pred = model.predict_proba(x_test)[:, 1]
        test_pred += pred
    return test_pred / CFG.n_folds

def gradient_boosting_model_inference(method: str, test_df: pd.DataFrame, features: list, categorical_features: list):
    x_test = test_df[features]
    if method == 'lightgbm':
        test_pred = lightgbm_inference(x_test)
    if method == 'xgboost':
        test_pred = xgboost_inference(x_test)
    if method == 'catboost':
        test_pred = catboost_inference(x_test)
    return test_pred

def Predicting(input_df: pd.DataFrame, features: list, categorical_features: list):
    output_df = input_df.copy()
    output_df['pred_prob'] = 0
    for method in CFG.METHOD_LIST:
        output_df[f'{method}_pred_prob'] = gradient_boosting_model_inference(method, input_df, features, categorical_features)
        output_df['pred_prob'] += CFG.model_weight_dict[method] * output_df[f'{method}_pred_prob'] 
    return output_df

その他の補足

④Postprocessing「後処理」

今回の評価指標は,後処理が非常に重要となります. 詳しくは,記事の後半で記載しますが,ここではモデル学習時に切り分けた評価データの予測値を用いて,最適な閾値を計算し,その閾値を用いて,0, 1の割り当てを行うコードを共有します.

重要特徴量の確認

model = pickle.load(open(CFG.MODEL_DATA_PATH / f'lightgbm_fold1_seed42_ver1.pkl', 'rb'))
importance_df = pd.DataFrame(model.feature_importance(), index=features, columns=['importance'])
importance_df['importance'] = importance_df['importance'] / np.sum(importance_df['importance'])
importance_df.sort_values('importance', ascending=False)

lightgbmのモデルにおける特徴量の重要度を確認することができます.

第二回 金融データ活用チャレンジの戦い方(工夫点)の案

ここからは,どのような工夫点が考えられるかをいくつか書いておきたいと思います.

特徴量作成

特徴量作成が非常に重要になってきます. 時間に関する特徴量などは,今回使えていないので,まずはこのあたりから始めてみてはいかがでしょうか.

その他にも,いろいろな特徴量が考えられるかと思いますので,ぜひいろいろな仮説を立てて,オリジナルの特徴量を考えてスコアを伸ばしてもらえたらと思います.

また,共有した特徴量の重要度を確認しながら,どの特徴量に着目して,特徴量作成を行ったりしていくと効率がいいかもしれません.

モデルの検討

今回のベースラインでは決定木ベースのモデルとして,lightgbm,xgbboost,catboostを紹介しました.

前回と異なり,今回は環境の制約がありませんので,GPU環境でNN系のモデルを試してみる価値があるかもしれません. また,GPU環境がなくとも,データサイズが小さいので,シンプルなNNのモデルであれば,CPU環境でも学習が可能かと思います. なお,NN系のモデルは,決定木ベースのモデルと異なり,特徴量のスケーリングが必要であるため,前処理の追加が必要となることに注意が必要です.

それ以外にもロジスティック回帰やSVMなど,いろいろモデルを試してみる価値があるかもしれません. 複数のモデルの結果は,スコアを上げる方法の1つであるアンサンブルで,強力な武器となる可能性があるので,時間の許す範囲でいろいろ試してみるべきかと思います.(PyCaretとか便利かと思います.)

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

自身はあまり重視していませんが,やる価値はもちろんあります. ただ,他の工夫を優先した方が,大幅なスコア上昇が見込めるかと思いますので,時間が余ったら程度にいつもしております.

後処理

後処理は,いろいろなコンペでスコアの底上げに貢献しています.

第一回の評価指標はAUCでしたが,ここが前回との大きな違いになるかと思います.

今回の評価指標のF1-scoreは,予測を0, 1のいずれかに割り当てる必要があります.

今回の評価指標に関しては,y-Carbonさんの書かれた記事である「なぜ分類問題のLBでLogLossではなくF1を使うか」が大変勉強になるかと思います.

モデルによっては,0, 1を返すモデルもありますが,多くの出力は0から1の連続値を取ります.

したがって,0, 1を決めるために閾値を決める必要があります.

今回共有したnotebookでは,学習時のOut-Of-Foldの予測した値を元に,ベストな閾値を推定するようにしております.(この後処理の有無でLBのスコアが0.01ほど変わりました.)

後処理もいろいろと工夫の余地があるかと思います!

おわりに

コンペ開催期間中の質問はslackの記事のリンクを貼り付けたスレッドにしていただければ,返信できるかと思います.

追記(20240218)

コンペも終わり,共有したコードの順位を確認したところ,313位に入れるようでした.

また,ご丁寧にSNSなどでリプライやDMなどでお礼の連絡をいただき,少しでも参考になったようで大変嬉しく思います.

この界隈はスキルの共有に関して,とてもオープンなところがいいことだと思っておりますので,今後も続けていけたらと思います.

本題ですが,他の参加者のSolutionなどを踏まえた上で,3点追記することができたらと思います.

評価指標と閾値に関しての補足

今回の評価指標のF1-scoreは,予測を0, 1のいずれかに割り当てる必要があると述べた通り,多くの参加者が閾値をどう決めるを一度は考えていたかと思います.

ここで自身は,oof(Out-Of-Fold)の出力を利用して最適な閾値を決める方法を共有しました. 時間がなかったことから,いろいろと説明を省いてしまったことで,他の参加者の共有に関しても前提などの言及がないまま,記事にまとめているものがほとんどでしたので,この点をまず補足します.

まず,この方法を利用するにあたって前提となるのが,学習データとテスト(評価)データの分布がある程度一致している必要があります.

コンペのルール的には,テスト(評価)データの分布と学習データの分布を可視化することは禁止されていないため,多くの参加者と同様に自身も分布がほぼ一致していることを確認したことで,上記の方法を採用しておりました.

したがって,必ずしも提案している方法がベストではなく,学習データとテストデータの分布が一致していないデータ分析コンペティションや実務での状況は多数存在し,その場合にはまた異なるアプローチを考える必要があります.

特に,実務においては将来のデータを実際に除くことができないため,データの分布が変化しないかどうかを検証するところから始める必要があり,実際に変化する場合には様々な工夫が必要となります.

こういった前提を考慮できないと,実務で役に立たないと言われかねないので,補足させていただきました.

次に,こちらは他の方のsolutionやslackでのディスカッションにもありましたが,foldごとにベストな閾値を算出しておき,各foldのテストデータの予測値を平均するのではなく,各foldごとに0,1の判定を行い,多数決で最終的な0,1を決める方法もあります.(こちらは他の人のsolutionなどを参考にしてもらえればと思います.)

最後に,今回の評価指標を上げるためには,閾値付近のデータの分離が非常に重要となります.

一般的にloglossなどは,より1は1側に,0は0側に近づけることを重視しますが,今回のようにある閾値で0と1に分離することを考えると,1を0.9999...に近づけることはそこまで重要ではなく,仮に0.95くらいでも十分です.

極端な話をすれば,仮に予測値が0.4と0.6の値しかとらない場合,0,1をしっかり分離できるのであれば,F1-scoreは向上します.(loglossはあまり良くなくても)

同様にAUCなども,本来1であるデータの予測値が0.85で本来0であるデータの予測値が0.95の場合,少し悪化しますが0.6~0.7あたりで閾値を決める場合,F1-scoreには何の影響も与えません.

このような評価指標の特徴を考慮することも,非常に重要だと思っております.

評価指標と今回のデータの特徴を踏まえて行った工夫

上記の評価指標の特徴を踏まえた上で,一つ試してみる価値がありそうなのは,複数seedにおいて,oofの予測が0になったり1になったりするデータをきちんと分類できることが重要であり,これらのデータの重みを学習時に大きくすることにより,CV, LBが改善する可能性があります.

また,逆に今回のデータは生成データであり,一定の割合でノイズとなる予測不可能なデータが含まれています.

これは何かしらの方法で,確率を計算し,乱数生成によりラベルを割り当てていると考えると,0.95の可能性で1だけど0が割り当てられているデータや,その逆もまた考えられます.

したがって,逆に複数seedで必ず正解できないようなデータは,学習を阻害している(ノイズとなっている)可能性があるため,weightを小さくすることで,CV, LBが改善する可能性がありました.

詳細な分析はLate Subができてないのでわかりませんが, CV: 0.6801, LB: 0.6897342, Private: 0.6817465 CV: 0.6874, LB: 0.6903327, Private: 0.6837645 と自身の比較では,すべて向上しておりました.

LBとPrivateの関係

今回,最終日の夕方からサブと最終サブの選択ができなかったことで,ボードが荒れてしまったこともありますが,最終評価とそのサブの暫定評価の結果は上位350位のものをサンプリングすると以下のようになっておりました.

FDUA2 散布図

この結果を考慮すると,上位数チームを除いて300位くらいまでの順位はかなり運要素の強いものになっている可能性があるため,順位に一喜一憂する必要はまったくないように思います.(そもそも最終サブを数時間前から選べない時点で順位に意味がないという意見もあるかと思いますが.)

ぜひ,順位にとらわれることなく,解法を簡単にでもいいので共有してもらえると嬉しく思います. 追記の最後まで読んでいただきありがとうございました!

この記事の内容を通して何か発想が生まれ,スコアの改善に繋がったり,学びにつながるものがあれば幸いです.