はじめに
SIGNATEのMUFG Data Science Champion Ship 2023 コンペティションが先日終了しました。このコンペティションでは、クレジットカードの顧客登録情報や決済手段・利用場所といった定量及び定性データを元に分析モデルを構築して、[カード不正利用]の予測を目指して多くの参加者が競い合いました。データ分析の学習の一環として初めてのコンペティションとしてこのコンペティションに参加し、多くの学びと経験を得ることができました。
評価指標:f1score
この記事では、コンペティションの過程で行ったデータ分析、モデルのトレーニング、および結果について詳しく共有します。また、振り返りとして自分が取り組んだ方法の長所と短所、今後の改善点や学びを述べます。
ちなみに、筆者は 38位/参加者303人中 でした。
順位としては手放しで喜べるほどの順位ではなかったものの、初めて挑んだコンペティションとしてはブロンズメダルも受賞でき、まぁまぁの成績なんじゃないでしょうか。
探索的データ分析(EDA)
行ったEDAを全て列挙するとかなりの量になってしまうため、一部とさせて頂きます
データのimportとmerge
#データのimport
import requests
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
train_data= pd.read_csv("data/train.csv")
test_data= pd.read_csv("data/test.csv")
user_data= pd.read_csv("data/user.csv")
card_data= pd.read_csv("data/card.csv")
train_data_merged = train_data.merge(user_data, on="user_id", how="left")
test_data_merged = test_data.merge(user_data, on="user_id", how="left")
欠損値の確認
missing_values = train_data_merged.isnull().sum()
missing_values
index 0
user_id 0
card_id 0
amount 0
errors? 0
is_fraud? 0
merchant_id 0
merchant_city 0
merchant_state 70688
zip 79220
mcc 0
use_chip 0
current_age 0
retirement_age 0
birth_year 0
birth_month 0
gender 0
address 0
city 0
state 0
zipcode 0
latitude 0
longitude 0
per_capita_income_zipcode 0
yearly_income_person 0
total_debt 0
fico_score 0
num_credit_cards 0
これらの欠損値は入力ミスや受信エラーなどの欠損値ではなく、
決済がONLINEのための 意味のある欠損値のため,
中央値、最頻値での置き換えや、削除ではなく何か他の値で補完する必要があります。
train_data_merged['zip'].fillna('0', inplace=True)
train_data_merged['merchant_state'].fillna('UNKNOWN', inplace=True)
test_data_merged['zip'].fillna('0', inplace=True)
test_data_merged['merchant_state'].fillna('UNKNOWN', inplace=True)
#ついでに'$'がついている特徴量を数値型(float)に変換します
dollar_columns = ['amount', 'per_capita_income_zipcode', 'yearly_income_person', 'total_debt']
for col in dollar_columns:
train_data_merged[col] = train_data_merged[col].str.replace(',', '').str.replace('$', '').astype(float)
test_data_merged[col] = test_data_merged[col].str.replace(',', '').str.replace('$', '').astype(float)
特徴量エンジニアリング
私が行った特徴量エンジニアリングはかなり力任せなところがあるためご容赦ください。
# ユーザーの都市/州での取引フラグの作成
train_data_merged['trans_in_user_city'] = (train_data_merged['merchant_city'] == train_data_merged['city']).astype(float)
test_data_merged['trans_in_user_city'] = (test_data_merged['merchant_city'] == test_data_merged['city']).astype(float)
train_data_merged['trans_in_user_state'] = (train_data_merged['merchant_state'] == train_data_merged['state']).astype(float)
test_data_merged['trans_in_user_state'] = (test_data_merged['merchant_state'] == test_data_merged['state']).astype(float)
# ユーザーの年齢と取引額、およびユーザーの年収と取引額の相互作用特徴
train_data_merged['age_amount_interaction'] = train_data_merged['current_age'] * train_data_merged['amount']
train_data_merged['income_amount_interaction'] = train_data_merged['yearly_income_person'] * train_data_merged['amount']
test_data_merged['age_amount_interaction'] = test_data_merged['current_age'] * test_data_merged['amount']
test_data_merged['income_amount_interaction'] = test_data_merged['yearly_income_person'] * test_data_merged['amount']
# yearly_income_personとamountの比率を計算
train_data_merged['income_amount_ratio'] = train_data_merged['amount'] / train_data_merged['yearly_income_person']
test_data_merged['income_amount_ratio'] = test_data_merged['amount'] / test_data_merged['yearly_income_person']
# ユーザーが退職するまでの年数
train_data_merged['years_to_retirement'] = train_data_merged['retirement_age'] - train_data_merged['current_age']
test_data_merged['years_to_retirement'] = test_data_merged['retirement_age'] - test_data_merged['current_age']
#、取引額と年収の比率
train_data_merged['spend_income_ratio'] = train_data_merged['amount'] / train_data_merged['yearly_income_person']
test_data_merged['spend_income_ratio'] = test_data_merged['amount'] / test_data_merged['yearly_income_person']
#、総債務と年収の比率
train_data_merged['debt_income_ratio'] = train_data_merged['total_debt'] / train_data_merged['yearly_income_person']
test_data_merged['debt_income_ratio'] = test_data_merged['total_debt'] / test_data_merged['yearly_income_person']
# 取引額とFICOスコア
train_data_merged['amount_fico_interaction'] = train_data_merged['amount'] * train_data_merged['fico_score']
test_data_merged['amount_fico_interaction'] = test_data_merged['amount'] * test_data_merged['fico_score']
#、年収とクレジットカードの数
train_data_merged['income_credit_cards_interaction'] = train_data_merged['yearly_income_person'] * train_data_merged['num_credit_cards']
test_data_merged['income_credit_cards_interaction'] = test_data_merged['yearly_income_person'] * test_data_merged['num_credit_cards']
#、年齢と年収などの変数間の相互作用
train_data_merged['age_income_interaction'] = train_data_merged['current_age'] * train_data_merged['yearly_income_person']
test_data_merged['age_income_interaction'] = test_data_merged['current_age'] * test_data_merged['yearly_income_person']
# 各クレジットカードごとの平均取引額
train_data_merged['avg_transaction_per_card'] = train_data_merged['amount'] / train_data_merged['num_credit_cards']
test_data_merged['avg_transaction_per_card'] = test_data_merged['amount'] / test_data_merged['num_credit_cards']
# 性別とmcc
train_data_merged['gender_mcc_interaction'] = train_data_merged['gender'].astype(str) + "_" + train_data_merged['mcc'].astype(str)
test_data_merged['gender_mcc_interaction'] = test_data_merged['gender'].astype(str) + "_" + test_data_merged['mcc'].astype(str)
# 、住所と商人の都市/州、住所、エラートランザクションなどのカテゴリカル変数間の相互作用
train_data_merged['address_merchant_city_interaction'] = train_data_merged['address'].astype(str) + "_" + train_data_merged['merchant_city'].astype(str)
test_data_merged['address_merchant_city_interaction'] = test_data_merged['address'].astype(str) + "_" + test_data_merged['merchant_city'].astype(str)
train_data_merged['address_merchant_state_interaction'] = train_data_merged['address'].astype(str) + "_" + train_data_merged['merchant_state'].astype(str)
test_data_merged['address_merchant_state_interaction'] = test_data_merged['address'].astype(str) + "_" + test_data_merged['merchant_state'].astype(str)
train_data_merged['address_error_interaction'] = train_data_merged['address'].astype(str) + "_" + train_data_merged['errors?'].astype(str)
test_data_merged['address_error_interaction'] = test_data_merged['address'].astype(str) + "_" + test_data_merged['errors?'].astype(str)
train_data_merged['address_merchant_id_interaction'] = train_data_merged['address'].astype(str) + "_" + train_data_merged['merchant_id'].astype(str)
test_data_merged['address_merchant_id_interaction'] = test_data_merged['address'].astype(str) + "_" + test_data_merged['merchant_id'].astype(str)
# 取引店舗のIDと取引額の相互作用
train_data_merged['merchant_id_amount_interaction'] = train_data_merged['merchant_id'].astype(str) + "_" + train_data_merged['amount'].astype(str)
test_data_merged['merchant_id_amount_interaction'] = test_data_merged['merchant_id'].astype(str) + "_" + test_data_merged['amount'].astype(str)
#FICOスコアとクレジットカードの数の相互作用
train_data_merged['fico_credit_interaction'] = train_data_merged['fico_score'] * train_data_merged['num_credit_cards']
test_data_merged['fico_credit_interaction'] = test_data_merged['fico_score'] * test_data_merged['num_credit_cards']
# FICOスコアと年間収入の相互作用
train_data_merged['fico_income_interaction'] = train_data_merged['fico_score'] * train_data_merged['yearly_income_person']
test_data_merged['fico_income_interaction'] = test_data_merged['fico_score'] * test_data_merged['yearly_income_person']
# カードIDとFICOスコアの相互作用
train_data_merged['card_fico_interaction'] = train_data_merged['card_id'] * train_data_merged['fico_score']
test_data_merged['card_fico_interaction'] = test_data_merged['card_id'] * test_data_merged['fico_score']
# カードIDと年齢の相互作用
train_data_merged['card_age_interaction'] = train_data_merged['card_id'] * train_data_merged['current_age']
test_data_merged['card_age_interaction'] = test_data_merged['card_id'] * test_data_merged['current_age']
# カードIDとクレジットカードの保有数の相互作用
train_data_merged['card_credit_interaction'] = train_data_merged['card_id'] * train_data_merged['num_credit_cards']
test_data_merged['card_credit_interaction'] = test_data_merged['card_id'] * test_data_merged['num_credit_cards']
多重共線性
多重共線性とは、データの特徴量の中に、相関係数が高い組み合わせが存在することであり、モデル内の一部の説明変数が他の説明変数と相関している場合に起こる状態
VIF(Variance Inflation Factor、分散膨張因子)は、多重共線性を検出するための指標。
VIF (Variance Inflation Factor) は次のように定義されます。
VIF_j = \frac{1}{1 - R^2_j}
ここで、$\ R^2_j $ は、変数 $ j $ を従属変数として、他のすべての予測変数を独立変数としたモデルの決定係数を指す。
VIFの解釈
- $\ VIF = 1 $ : 完全な独立
- $\ 1 < VIF < 5 $ : 通常、許容される範囲の多重共線性
- $\ VIF \geq 5 $ : 強い多重共線性が存在すると疑われる
注意
VIFの閾値は一般的なガイドラインに過ぎず、状況によって適切値が異なる場合があります。
import pandas as pd
from statsmodels.stats.outliers_influence import variance_inflation_factor
features = train_data_merged.select_dtypes.drop(columns=['is_fraud?'], errors='ignore')
# VIFを計算
vif_data = pd.DataFrame()
vif_data["Feature"] = features.columns
vif_data["VIF"] = [variance_inflation_factor(features.values, i) for i in range(features.shape[1])]
vif_data
VIFを元にしたデータのdropと分割
train_y = train_data_merged['is_fraud?']
train_y =pd.DataFrame(train_y)
train_x = train_data_merged.drop(['is_fraud?','index','amount','zip','current_age','city','state','spend_income_ratio','mcc','errors?','merchant_state','merchant_id','yearly_income_person','num_credit_cards','card_id','gender'],axis = 1)
test = test_data_merged
index = pd.DataFrame(test['index'])
test = test_data_merged.drop(['index','state','city','amount','zip','current_age','city','state','spend_income_ratio','mcc','errors?','merchant_state','merchant_id','yearly_income_person','num_credit_cards','card_id','gender'],axis=1)
モデル構築
# デフォ値
import lightgbm as lgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, recall_score, precision_score, f1_score
X_train, X_val, y_train, y_val = train_test_split(train_x, train_y, test_size=0.2, random_state=42)
params = {
'objective': 'binary', # 二値分類タスクの場合
'verbosity': 1,
'metric': 'binary_error',
'force_col_wise':True,
'num_leaves': 31,
'max_depth': -1,
'learning_rate': 0.1,
'n_estimators': 100,
'subsample': 1.0,
'colsample_bytree': 1.0,
'reg_alpha': 0.0,
'reg_lambda': 0.0,
'min_child_samples': 20
}
# LightGBMモデルを構築
model_lgbm = lgb.LGBMClassifier(**params)
# モデルをトレーニングデータで学習
model_lgbm.fit(X_train, y_train)
# テストデータで予測
Y_pred = model_lgbm.predict(X_val)
# 正解率(Accuracy)の計算
accuracy = round(accuracy_score(y_val, Y_pred) * 100, 2)
# 再現率(Recall)の計算
recall = round(recall_score(y_val, Y_pred, average='macro') * 100, 2)
# 精度(Precision)の計算
precision = round(precision_score(y_val, Y_pred, average='macro') * 100, 2)
# F1スコアの計算
f1 = round(f1_score(y_val, Y_pred, average='macro') * 100, 2)
# 結果の表示
print("Accuracy:", accuracy)
print("Recall:", recall)
print("Precision:", precision)
print("F1 Score:", f1)
#特徴量重要度を算出し、次の特徴量エンジニアリングの糧とする
lgb.plot_importance(model_lgbm)
[LightGBM] [Info] Number of positive: 26085, number of negative: 350941
[LightGBM] [Info] Total Bins 20167
[LightGBM] [Info] Number of data points in the train set: 377026, number of used features: 35
[LightGBM] [Info] [binary:BoostFromScore]: pavg=0.069186 -> initscore=-2.599258
[LightGBM] [Info] Start training from score -2.599258
Accuracy: 95.69
Recall: 73.43
Precision: 89.64
F1 Score: 79.11
次にベイズ最適化を行い、ハイパーパラメーターの最適化を行う
import optuna
import lightgbm as lgb
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split
X_train, X_val, y_train, y_val = train_test_split(train_x, train_y, test_size=0.2, random_state=42)
def objective(trial):
# ハイパーパラメータの探索範囲を定義
params = {
'objective': 'binary',
'verbosity': 1,
'metric': 'binary_error',
'max_depth': trial.suggest_int('max_depth', 7, 9),
'learning_rate': trial.suggest_float('learning_rate', 0.06, 0.08),
'n_estimators': trial.suggest_int('n_estimators', 700, 1000),
'num_leaves': trial.suggest_int('num_leaves', 110, 1024),
'min_child_samples': trial.suggest_int('min_child_samples', 90, 120),
'force_col_wise':True,
'min_split_gain': trial.suggest_float('min_split_gain', 0.5, 0.7),
'subsample': trial.suggest_float('subsample', 0.5, 0.8),
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 0.8)
}
# LightGBMモデルを構築
model_lgbm = lgb.LGBMClassifier(**params)
# モデルをトレーニングデータで学習
model_lgbm.fit(X_train, y_train)
# バリデーションデータで予測
Y_pred = model_lgbm.predict(X_val)
# F1スコアの計算
f1 = f1_score(y_val, Y_pred, average='macro')
return f1
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=50) # ハイパーパラメータ調整の試行回数を指定
# 最適なハイパーパラメータの表示
print('Best trial:')
trial = study.best_trial
print('Value: {}'.format(trial.value))
print('Params: ')
for key, value in trial.params.items():
print('{}: {}'.format(key, value))
'max_depth': 8,
'learning_rate': 0.07552180735270847,
'n_estimators': 958,
'num_leaves': 112,
'min_child_samples': 103,
'min_split_gain': 0.668048741323063,
'subsample': 0.551004063247535,
'colsample_bytree': 0.7022195945858946
ベイズ最適化を通して得られたハイパーパラメーターを使用し、再度モデルの構築
import lightgbm as lgb
X_train, X_val, y_train, y_val = train_test_split(train_x, train_y, test_size=0.2, random_state=42)
params = {
'objective': 'binary', # 二値分類タスクの場合
'verbosity': 1,
'metric': 'binary_error',
'force_col_wise':True,
'max_depth': 8,
'learning_rate': 0.07552180735270847,
'n_estimators': 958,
'num_leaves': 112,
'min_child_samples': 103,
'min_split_gain': 0.668048741323063,
'subsample': 0.551004063247535,
'colsample_bytree': 0.7022195945858946
}
# LightGBMモデルを構築
model_lgbm_best = lgb.LGBMClassifier(**params)
# モデルをトレーニングデータで学習
model_lgbm_best.fit(X_train, y_train)
# テストデータで予測
Y_pred = model_lgbm_best.predict(X_val)
# 正解率(Accuracy)の計算
accuracy = round(accuracy_score(y_val, Y_pred) * 100, 2)
# 再現率(Recall)の計算
recall = round(recall_score(y_val, Y_pred, average='macro') * 100, 2)
# 精度(Precision)の計算
precision = round(precision_score(y_val, Y_pred, average='macro') * 100, 2)
# F1スコアの計算
f1 = round(f1_score(y_val, Y_pred, average='macro') * 100, 2)
# 結果の表示
print("Accuracy:", accuracy)
print("Recall:", recall)
print("Precision:", precision)
print("F1 Score:", f1)
[LightGBM] [Info] Number of positive: 26085, number of negative: 350941
[LightGBM] [Info] Total Bins 20167
[LightGBM] [Info] Number of data points in the train set: 377026, number of used features: 35
[LightGBM] [Info] [binary:BoostFromScore]: pavg=0.069186 -> initscore=-2.599258
[LightGBM] [Info] Start training from score -2.599258
Accuracy: 95.87
Recall: 75.5
Precision: 89.33
F1 Score: 80.69
考察
モデリング時のF1scoreを上げても、提出後のスコアが同じように向上するとはいかなかった。過剰適合を起こしているのかと思われる。
そしてベイズ最適化のハイパーパラメーターの範囲を追求していく、試行回数をあげ、より良いハイパーパラメーターを導出してもここでのF1scoreは80.7以上にならなかった。筆者自身に足りなかったのは、特徴量エンジニアリングとアンサンブル学習だと思う。おそらく単一のモデルで出すことのできるF1scoreは80.7前後であり、上位者はみなアンサンブル学習をしてより良い成果を出したのではないか。
もし、単一のモデルを使用してシルバーメダル以上を獲得した上位者がいるのでしたら今回行った手法をお教えいただけると幸いです
今後の展望
今回はSVMやランダムフォレスト、ディープラーニングを試したが、適切なエンコーディング、スケーリングを選択することができず、LightGBMよりスコアが下がってしまった。そのため、カテゴリカル変数をそのまま使用することのできるLightGBMを選択せざるを得なかった。
次のコンペに向けてエンコーディング、スケーリング手法を学び、複数のモデルからアンサンブル学習をして、最終モデルスコアを上げられるようにしたい。
QOLの向上
データ分析をする上でベイズ最適化やディープラーニングを行うと、処理に時間がかかってしまう。そのため、処理が完了するまでパソコンの前に張り付いていなければいけない。だが、LINEやSlackのAPIを取得し、処理終了時に自分のアカウントへ通知を行うようにすれば、時間がかかる処理の終了時までパソコンの前に張り付く必要がなくなる。
今回私はLINE Notifyを使用して時間のかかりそうな処理の終了時にLINEへ通知を送った。
初期設定はこちらの記事をご覧ください
import requests
def line(message):
#APIのURLとトークン
url = "https://notify-api.line.me/api/notify"
access_token = "ここに発行したトークンを貼り付ける"
headers = {"Authorization": "Bearer " + access_token}
send_data = {"message": message}
result = requests.post(url, headers=headers, data=send_data)
#LINEで通知したいときにこのコードを記述することで設定したアカウントへメッセージが転送される
line('ここに送信したいメッセージ内容を記述')
#ちなみに、
line(f'F1score:{f1}')
#このように記述すれば、ログをLINE上に残してスコアを比較することができる。