著者:柳川 陸(金融NEXT企画部 開発企画課)
はじめに
CTCが2022年12月から特別会員として関わっている、金融データ活用推進協会(FDUA)が主催するデータ分析コンペに参加しました。
今回は、その経験と私が取り組んだ特別なアプローチについてお話しします。
概要
FDUA主催のデータ分析コンペとして第2回目となる今回のテーマは「企業向けローンの返済可否予測」でした。
データセットは米国小企業庁(SBA)のローン保証情報に基づいています。これにはSBAのローン保証を受けたスタートアップや小企業に関する情報が含まれています。
このデータを基に、LGBM(LightGBM)という勾配ブースティングモデルを使って企業が返済不能になるかどうかの予測モデルを構築しました。
データ収集と特徴量エンジニアリング
モデルが上手く機能するためには、良質なデータと巧みな特徴量の工夫が不可欠です。
まずは元のデータセットから考えられる特徴量を生成しました。日付の形式変換を実施したり、新規ビジネスだったりフランチャイズだったりというビジネスの特徴を学習しやすい形に修正しました。
# 日付変換
X["DisbursementDate_Year"] = pd.to_datetime(X["DisbursementDate"]).dt.year
X["DisbursementDate_Month"] = pd.to_datetime(X["DisbursementDate"]).dt.month
X["DisbursementDate_Day"] = pd.to_datetime(X["DisbursementDate"]).dt.day
X["ApprovalDate_Year"] = pd.to_datetime(X["ApprovalDate"]).dt.year
X["ApprovalDate_Month"] = pd.to_datetime(X["ApprovalDate"]).dt.month
X["ApprovalDate_Day"] = pd.to_datetime(X["ApprovalDate"]).dt.day
# 承認日の年と会計年度が同じかどうか(米国の会計年度は10月1日から9月30日らしい)
X["ApprovalDate_Year_equals_ApprovalFY"] = (X["ApprovalDate_Year"] == X["ApprovalFY"]).astype(int)
# ローン額をそれぞれ数値化
X[["GrAppv_Quantify", "SBA_Appv_Quantify", "DisbursementGross_Quantify"]] = X[["GrAppv", "SBA_Appv", "DisbursementGross"]].applymap(lambda x: float(x.strip().replace('$', '').replace(',', '')))
# 新規ビジネスかどうか
X["NewExist"] = X["NewExist"].astype(int) - 1
# フランチャイズかどうか
X["IsFranchise"] = (X["FranchiseCode"] > 1).astype(int)
# UrbanRuralをOneHotEncoding
X[["UrbanRural_0", "UrbanRural_1", "UrbanRural_2"]] = pd.get_dummies(X["UrbanRural"]).rename(columns={0: "UrbanRural_0", 1: "UrbanRural_1", 2: "UrbanRural_2"})
# RevLineCrをOneHotEncoding
X[["RevLineCr_0", "RevLineCr_N", "RevLineCr_T", "RevLineCr_Y"]] = pd.get_dummies(X["RevLineCr"]).rename(columns={"0": "RevLineCr_0", "N": "RevLineCr_N", "T": "RevLineCr_T", "Y": "RevLineCr_Y"})
# LowDocをOneHotEncoding
X[["LowDoc_0", "LowDoc_A", "LowDoc_C", "LowDoc_N", "LowDoc_S", "LowDoc_Y"]] = pd.get_dummies(X["LowDoc"]).rename(columns={"0": "LowDoc_0", "A": "LowDoc_A", "C": "LowDoc_C", "N": "LowDoc_N", "S": "LowDoc_S", "Y": "LowDoc_Y"})
また、日付データについては、年周期の円に変換し、パターンやトレンドが予測に反映されやすいようにしてみました。ローン審査業務は、例えば年度末や月末など、特定のタイミングで件数が増える等のパターンがあると考えられ、申請する側(ダメ元でやってみるとか?)も承認する側(件数が多くて詳細に調査できないとか?)も査定精度に影響を与える行動を取りそうだと考えたからです。実際、私の経験でも、過去にチャレンジしたデータ分析で、同様の変換で良い成果が得られたことがありました。
# 1月1日からの経過日数
X["DisbursementDate_DaysElapsed"] = pd.to_datetime(X["DisbursementDate"]).apply(lambda x: (x - pd.Timestamp(x.year, 1, 1)).days if pd.notnull(x) else None)
X["ApprovalDate_DaysElapsed"] = pd.to_datetime(X["ApprovalDate"]).apply(lambda x: (x - pd.Timestamp(x.year, 1, 1)).days if pd.notnull(x) else None)
# DisbursementDateを年周期の円に変換
X["DisbursementDate_x"] = np.cos(2 * np.pi * X["DisbursementDate_DaysElapsed"] / 365)
X["DisbursementDate_y"] = np.sin(2 * np.pi * X["DisbursementDate_DaysElapsed"] / 365)
# ApprovalDateを年周期の円に変換
X["ApprovalDate_x"] = np.cos(2 * np.pi * X["ApprovalDate_DaysElapsed"] / 365)
X["ApprovalDate_y"] = np.sin(2 * np.pi * X["ApprovalDate_DaysElapsed"] / 365)
# DisbursementDateとApprovalDateの座標の差を取得
X["DisbursementDate_diff_ApprovalDate_x"] = X["DisbursementDate_x"] - X["ApprovalDate_x"]
X["DisbursementDate_diff_ApprovalDate_y"] = X["DisbursementDate_y"] - X["ApprovalDate_y"]
# DisbursementDateとApprovalDateの座標上の距離を取得
X["DisbursementDate_ApprovalDate_Distance"] = np.sqrt(X["DisbursementDate_diff_ApprovalDate_x"] ** 2 + X["DisbursementDate_diff_ApprovalDate_y"] ** 2)
その他にも、デフォルト時に貸し手が被る損失額の割合や借り手と貸し手の州が同一か、雇用の活気を評価するために新規雇用と既存雇用の差を算出してみたり、自分なりに思いつく特徴量も追加してみました。
# デフォルト時に貸し手が被る損失額の割合(貸付額 - SBAの代位弁済額)/ 貸付額
X["BankLossRate"] = (X["GrAppv_Quantify"] - X["SBA_Appv_Quantify"]) / X["GrAppv_Quantify"]
# 借り手と貸し手の州が同一か
X["SameState"] = (X["State"] == X["BankState"]).astype(int)
# トータルの雇用の数
X["TotalJob"] = X["CreateJob"] + X["RetainedJob"]
# 借り入れる資金によって維持する従業員が一人分だけか?(本人だけ)
X["ZeroRetainedJob"] = (X["RetainedJob"] == 0).astype(int)
X["OneRetainedJob"] = (X["RetainedJob"] == 1).astype(int)
# 借り入れる資金による新規雇用が一人分だけか?(本人だけ)
X["ZeroCreateJob"] = (X["CreateJob"] == 0).astype(int)
X["OneCreateJob"] = (X["CreateJob"] == 1).astype(int)
# 雇用の活気のようなもの?
X["JobPositivity"] = X["CreateJob"] - X["RetainedJob"]
上記に加えて、データセットは訓練用と評価用を合わせて80,000件以上あるのですが、今回は特に30件以上のレコードを持つ都市を対象に、人口統計データサイトから平均世帯年収や住宅価値などのデータを収集しました。
対象を絞ったのは全体だと3500種類以上ある都市名のうち、400種類弱の都市だけで全レコードの約85%をカバーできたことが理由です。
実際のビジネスシーンでも、よくローンを利用する都市を中心に情報を集めますので、このアプローチは合理的と考えました。
収集したデータを使って、都市の平均世帯年収に元データの従業員数を掛け合わせ、企業の人件費の規模を推定しました。
また、この人件費を貸付金額で割ることで、事業の規模に対する貸付の比率を推測しました。
# 追加データ(population.csv)の読込およびデータセットへの結合
# 追加データは件数上位400弱の都市名ごとの人口、世帯収入、住宅価値
X = pd.merge(X, pd.read_csv("population.csv"), on=["State", "City"], how="left")
for idx in X[X["Population"].isna()].index:
X.at[idx, "Population"] = X[X["State"] == X.at[idx, "State"]]["Population"].mean()
for idx in X[X["Income"].isna()].index:
X.at[idx, "Income"] = X[X["State"] == X.at[idx, "State"]]["Income"].mean()
for idx in X[X["Value"].isna()].index:
X.at[idx, "Value"] = X[X["State"] == X.at[idx, "State"]]["Value"].mean()
# 年間の人件費
X["PersonnelExpenses"] = X["NoEmp"] * X["Income"]
# 貸付額と年間人権費の比率
X["Portion"] = X["DisbursementGross_Quantify"] / X["PersonnelExpenses"]
モデル構築と評価
LightGBMは、速度と効率性が優れているため選択しました。
データのクラスの分布を保持しながら、データ分割するためStratifiedKFoldを使用して3分割の層化クロスバリデーションを実施しています。
この実装では、LightGBMの分類器(LGBMClassifier)をトレーニングし、バギング(Bagging)手法を用いてアンサンブルモデルとして構築しています。
また、各クロスバリデーションの反復ごとに、テストセットに対する予測確率からF1スコアを最大化する最適なcutoffを決定しています。
F1スコアは、"0.68121242661932"になりました。
# F1スコアが最大となる01の閾値(cutoff)を求める関数
def decide_cutoff(y_true, y_pred_proba):
mean_f1_list = []
fpr, tpr, thresholds = roc_curve(y_true, y_pred_proba)
for threshold in thresholds:
preds_y = np.where(y_pred_proba > threshold, 1, 0)
mean_f1_list.append(f1_score(y_true, preds_y, average='macro'))
return np.max(mean_f1_list), thresholds[np.argmax(mean_f1_list)]
# 交差検証を行い、最も優れたモデルを特定
f1_optimized_list = []
threshold_optimized_list = []
cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
for train_idx, test_idx in cv.split(X, y):
X_train, X_test, y_train, y_test = X.iloc[train_idx], X.iloc[test_idx], y.iloc[train_idx], y.iloc[test_idx]
clf = lgb.LGBMClassifier(objective="binary", force_col_wise=True)
clf = BaggingClassifier(clf, n_estimators=1000)
clf.fit(X_train, y_train)
f1_optimized, threshold_optimized = decide_cutoff(y_test, clf.predict_proba(X_test)[:, 1])
f1_optimized_list.append(f1_optimized)
threshold_optimized_list.append(threshold_optimized)
# 最適なcutoffを計算(ここでは中央値を用いる)
f1_optimized = np.median(f1_optimized_list)
threshold_optimized = np.median(threshold_optimized_list)
# F1スコアの確認(0.68121242661932)
print(f"Macro F1 Score: {f1_optimized}")
結果として、元のデータセットでも重要度の高いTerm等に加えて、今回追加した人件費規模に関連する特徴量がモデルの予測や精度改善において高い重要度を持つことがわかりました。
予測データを投稿した結果、評価スコア(F1スコア)は0.6870295でした。
予測精度での入賞はちょっと難しいかもしれませんが、十分に高い精度が得られました。
多様性の活用
この成果には重要な立役者がいます。CTCグループの特例子会社CTCひなりです。
CTCひなりでは、豊かな未来に向けて障がい者の雇用と働きがい創出をおこなっており、AIやデータ分析案件で必須となるデータプリパレーション(DP)を支援する体制も提供しています。
彼らが人口統計サイトからの地道なデータ収集作業を担ってくれました。
対象を絞ったとはいえ、400件近い都市の統計データを集めてくるのは、骨の折れる仕事です。
彼らの真摯な取組により、数日程度の短期間で有意義なデータを収集できました。
さまざまなバックグラウンドを持つメンバーが、それぞれの強みを発揮し、ひとつの目標に向けて貢献するユースケースとして、とても意義深かったです。
まとめ
本データ分析コンペでの経験で、技術的な解決策を探求する過程で、人々の創造性や多様性が大きな力になることを実感しました。
今回のようなコンペに限らず、データ分析やAI活用のプロジェクトにおいて、様々な形で直面するであろう挑戦と可能性を象徴しているのではないでしょうか。
最後になりましたが、このようなコンペの企画および実施をいただいたFDUAや関係者のみなさまに感謝申し上げます!
参加させていただき大変楽しかったですし、リーダーボードを見ながら一緒に金融データの活用を盛り上げてくれている同志がたくさんいることに大変感動しました!
ぜひ金融の未来を共に盛り上げていきましょう!
Comentarios