はじめに
SIGNATEの「第2回 金融データ活用チャレンジ」に参加しました。LB0.6908の28位ぐらいで最終63位でした。(みなさん同じだと思いますが最終subを選択したかった・・・)解法自体はたいしたことないので最後に少し書いておきます。主にコンペ中に考えたことをまとめておきたいと思います。
自己紹介
データサイエンスの仕事初めて約1年。本職はアナログ回路設計者です。プログラミングも初心者レベルです。
EDA
NaN数も少なく優しいデータセットなのかなと最初は思いました。しかしちゃんとデータを見ていくと、"Term"の中に0がある、"NoEmp"にも0があるというデータでした。返済期間が0ヶ月はありえないし、NoEmpが0って誰も働いていないのか(社長除いてるかも)と思いました。"ApprovalDate"より前にある"DisbursementDate"・・・などなど。この時点で生成AIが作成したデータだからデータ自体にそこまで意味がないのでは?と嫌な予感がしてました。ちなみにtestデータに関しては一切中身を見ておりません。signate恒例のtestデータの取扱で揉める行事がありますので。。。
初手 LightGBM
とりあえずtrainデータをそのままにLightGBMに突っ込みました。LBは0.627ぐらいだったと思います。(ちゃんと履歴は残しておかないといけないですね)
特徴量を作れば作るほど下がるスコア
"City"を除いてみると0.673まで上がりました。さあこっからっす!主に試した特徴量を書いていきます。
Date関連
# 返済期限
df['LimitDate'] = df.apply(lambda row: pd.to_datetime(row['DisbursementDate']) + pd.DateOffset(months=row['Term']), axis=1)
df["LimitYear"] = df["LimitDate"].dt.year
# リーマンショック関係あるのかな?2008年の数字は色々いじりました
df['Lehman'] = df['LimitYear'].apply(lambda x: 0 if x <= 2008 else 1)
# 承認日と支給日のずれ
df["FY_Diff"] = df["ApprovalFY"] - df["DisbursementYear"]
お金,従業員関連
df['SBA_Portion'] = df['SBA_Appv_num'] / df['GrAppv_num']
df["DisbursementGrossRatio"] = df["DisbursementGross_num"] / df["GrAppv_num"]
df["MonthlyRepayment"] = df["DisbursementGross_num"] / df["Term"]
df["Create/NoEmp"] = df["CreateJob"] / df["NoEmp"]
df["Reatain/NoEmp"] = df["RetainedJob"] / df["NoEmp"]
df["Reatain-NoEmp"] = df["NoEmp"] - df["RetainedJob"]
df["DisbursementGross/NoEmp"] = df["DisbursementGross_num"] / df["NoEmp"]
df["DisbursementGross/Reatain-NoEmp"] = df["DisbursementGross_num"] / df["Reatain-NoEmp"]
LB0.68を超えることなく時間が過ぎていきました。
TargetEncoding
ターゲットエンコーディングをやってみましたが、もう全然だめでした。0.62 ~ 0.66でした。なぜ駄目か全然わかってません。リークしてる?過学習?
不均衡データ対策
'MIS_Status'が1 : 37767、0 : 4540です。データが不均衡なのは気になっていたので下記方法を調べました。LightGBMのsample_weightを設定したり、アンダーサンプリングを試してみたり。それでも0.68を超えることはありませんでした。
ベースとなるLightGBM + StratifiedKfold
for seed_no in range(5):
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=seed_no)
for fold, (trn_idx, val_idx) in enumerate(cv.split(X, y), start=1):
trn_x = X.iloc[trn_idx, :]
trn_y = y[trn_idx]
val_x = X.iloc[val_idx, :]
val_y = y[val_idx]
model_lgb = lgb.LGBMClassifier(**params_lgb)
model_lgb.fit(
trn_x, trn_y,
eval_set=(val_x, val_y),
callbacks=[lgb.early_stopping(300, verbose=True)],
# categorical_feature=X.select_dtypes(include='object').columns.tolist(),
)
list_models.append(model_lgb)
LightGBMにsample_weightを設定 + StratifiedKfold
for seed_no in range(5):
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=seed_no)
for fold, (trn_idx, val_idx) in enumerate(cv.split(X, y), start=1):
trn_x = X.iloc[trn_idx, :]
trn_y = y[trn_idx]
val_x = X.iloc[val_idx, :]
val_y = y[val_idx]
# サンプルの重みを計算
weights = compute_sample_weight(class_weight='balanced', y=trn_y)
model_lgb = lgb.LGBMClassifier(**params_lgb)
model_lgb.fit(
trn_x, trn_y,
eval_set=(val_x, val_y),
sample_weight=weights,
callbacks=[lgb.early_stopping(300, verbose=True)],
# categorical_feature=X.select_dtypes(include='object').columns.tolist(),
)
list_models.append(model_lgb)
アンダーサンプリング + LightGBM + StratifiedKfold
for seed_no in range(5):
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=seed_no)
for fold, (trn_idx, val_idx) in enumerate(cv.split(X, y), start=1):
trn_x = X.iloc[trn_idx, :]
trn_y = y[trn_idx]
val_x = X.iloc[val_idx, :]
val_y = y[val_idx]
# アンダーサンプリング
rus = RandomUnderSampler(random_state=0, replacement=True)
trn_x, trn_y = rus.fit_resample(trn_x, trn_y)
model_lgb = lgb.LGBMClassifier(**params_lgb)
model_lgb.fit(
trn_x, trn_y,
eval_set=(val_x, val_y),
callbacks=[lgb.early_stopping(100, verbose=True)],
# categorical_feature=X.select_dtypes(include='object').columns.tolist(),
)
list_models.append(model_lgb)
GroupKfold
生成AIが作ったデータだから何かしらのグループになってるんじゃないのか!と思い、kNNを使ってGroupKfoldをしてみました。n_clusters=4が一番バランスよさそうでしたがそれでもグループの数のばらつきは大きかったです。もちろん0.68超えていません。
km = KMeans(n_clusters=4, random_state=202402)
km.fit(X1.dropna(axis=1),y)
X['Cluster'] = km.predict(X1.dropna(axis=1))
GroupKfoldをそのまま使うとkfoldのshuffleが使えないので違う方法でやりました。
user_id = X['Cluster']
unique_user_ids = user_id.unique()
for seed_no in range(5):
cv = KFold(n_splits=4, shuffle=True, random_state=seed_no)
for fold, (tr_group_idx, va_group_idx) in enumerate(cv.split(unique_user_ids)):
tr_groups, va_groups = unique_user_ids[tr_group_idx], unique_user_ids[va_group_idx]
is_tr = user_id.isin(tr_groups)
is_va = user_id.isin(va_groups)
X1 = X.iloc[:, 0:-1]
trn_x = X1[is_tr]
trn_y = y[is_tr]
val_x = X1[is_va]
val_y = y[is_va]
model_lgb = lgb.LGBMClassifier(**params_lgb)
model_lgb.fit(
trn_x, trn_y,
eval_set=(val_x, val_y),
callbacks=[lgb.early_stopping(100, verbose=True)],
# categorical_feature=X.select_dtypes(include='object').columns.tolist(),
)
GridSearch
コードは省力しますがハイパーパラメータを最適にすれば!?ということでGridSearch使いましたがスコアは大幅に下がりました。
ついに超えた0.68
ここまで試したことから以下のことを推察しました。
・説明変数が本来の意味を持っていない(AIが適当に生成した数字)
・なのでNaNを埋めたりするのは今回は意味がないどころか悪化しそう
・本来意味ありそうな特徴量は作っても意味ない
・trainデータに合わせれば合わせるほどtestデータには合わない
そこでシンプルにカテゴリデータを使ってNumericalデータの特徴量作成を行いました。
def preprocessing_group_category_numeric(df, df_test):
agg_cols = ['min', 'max', 'mean', 'std']
for col in categorical_columns:
for num_col in numeric_columns:
grp_df = df.groupby(col)[num_col].agg(agg_cols)
grp_df.columns = [f'{col}_{num_col}' + c for c in grp_df.columns]
df = df.merge(grp_df, on=col, how='left')
df_test = df_test.merge(grp_df, on=col, how='left')
return df, df_test
これをするだけでLB0.6861までスコアは上がりました。(最終0.6821)
Catboostが上回った
LB0.6861のコードをcatboostに変えただけで0.6879にまでスコアは上昇しました。アンサンブル試しましたが私の場合はcatboost単体がLBでも一番スコアがよかったです。
あとはハイパーパラメータやNumericalデータの特徴量作成の調整
catboostのハイパーパラメータを変えたり、特徴量作成の際に2つのカテゴリ変数の組み合わせを用いて、ベストはLB0.6908(最終0.6847)でした。
catboostのパラメータ
params_cb = {
'iterations': 1000, # ツリーの数
'learning_rate': 0.09, # 学習率
'depth': 7, # ツリーの深さ
'l2_leaf_reg': 3.0, # L2正則化項の強さ
'loss_function': 'Logloss', # 損失関数
'eval_metric': 'AUC', # 評価指標(AUCなど)
# 'random_seed': seed_no, # ランダムシード(seed_noはループ内で変化する値)
}
2つのカテゴリ変数の組み合わせからNumericalデータの特徴量作成
def preprocessing_group_category_combination(df, df_test):
agg_cols = ['min', 'max', 'mean', 'std']
for col1,col2 in combinations(["ApprovalDate", "State", "Sector"],2):
for num_col in numeric_columns:
new_feature_name = f'{col1}_{col2}'
# groupbyして計算
grouped = df.groupby([col1, col2])[num_col].agg(agg_cols)
# 列名を変更
grouped.columns = [f'{new_feature_name}_{num_col}_{agg}' for agg in grouped.columns]
# 元データフレームに結果をマージするためにインデックスで結合(修正)
df = df.merge(grouped.reset_index(), on=[col1,col2], how='left')
df_test = df_test.merge(grouped.reset_index(), on=[col1,col2], how='left')
return df, df_test
解法まとめ
色々試した割には最後は基本的なことしかせずに終わりました。
・categorical_columns = ['FranchiseCode', 'NewExist', 'RevLineCr', 'LowDoc', 'DisbursementDate', 'Sector', 'ApprovalDate', 'City', 'State', 'BankState', 'UrbanRural']
・numeric_columns = ['Term', 'NoEmp', 'CreateJob', 'RetainedJob', 'ApprovalFY', 'DisbursementGross', 'GrAppv', 'SBA_Appv']
・categorical_columnsごとにnumeric_columnsの各['min', 'max', 'mean', 'std']を特徴量とした
・NaNは"-1"としてからラベルエンコーディングをcategorical_columnsで実施した、'RevLineCr'などにあった関係ない文字はそのまま扱った
・初期説明変数から"City", "State", "BankState", "GrAppv", "SBA_Appv"を削除
・catboostでモデル作成
コンペを終えて
・できたこと
・データの傾向や特徴に気づけた?
・不均衡データの扱いを試せた
・ChatGPTのおかげで試したいことをすぐに試せるようになった
・できなかったこと
・外部データの利用(外部データOKと気づいたのが締め切り2日前ぐらいでした・・・)
・Slackの活用(全然見ずに終わった)
・EDAを効率よくできなかった
・CVのモデルごとにしきい値を決めて予測すればもっとスコアよかったのでは!?(しきい値の中央値で一括処理してました)
・CVスコアとLBスコアが0.004ぐらいズレており、修正できなかった
・今後やりたいこと
・学習環境の構築、今回catboostを1回回すのに2時間ぐらいかかったので手持ちのPCだと今後は厳しそうです
2日前にdropしていたと思ったCityが残っていることに気づき、それで数値特徴量を作っていたので急いでCityなしで詰めていきました。最終結果はCityなしモデルのほうがよかったので最終日にサブが出来ていれば・・・サブを選択出来ていれば・・・という思いはあります(多くの人も同じ感想だと思いますが)。生成AIが作成したデータということで通常のコンペとは違ったのかなと思いますが、データ数も少なく日本語なので色々試しやすく初学者にはいいコンペだったと思います。また今回のコンペでは解法や情報を共有してくれる人が多い点も非常によかったです。コンペの運営や参加された方々、お疲れ様でした!そしてありがとうございました!