[備忘録]SIGNATE CUP 2024

# SIGNATE CUP 2024について
本コンペティションは、毎年SIGNATE主催で開催されるコンペティションであり、今回参加したのは「旅行パッケージ成約率予測」である。

# 結果
結果としては374位/1592と上位23.4%であった。

# 目次
1. 自分のソリューション
1. 試したがうまくいかなかったこと
1. 上位者のソリューション
1. 反省・今後の課題

# 自分のソリューション
## 大まかな流れ
1. 準備
1. データ加工
1. モデルの学習
1. 結果の統合と提出

## 準備
まずは必要なモジュールの読み込み
``` python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib
import seaborn as sns
import random
from pathlib import Path
from tqdm.notebook import tqdm
import inspect
from collections import defaultdict
import pickle
import warnings
import glob

import re
import unicodedata
from simpleeval import simple_eval

from sklearn.preprocessing import LabelEncoder
import lightgbm as lgb
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
from sklearn.metrics import roc_auc_score
import optuna

warnings.filterwarnings("ignore", category=UserWarning)
# optuna.logging.set_verbosity(optuna.logging.WARNING)
pd.set_option("display.max_columns", None)
```
次にモデル構築を行う上での設定
``` python
class CFG:
    INPUT_DIR = Path("..", "data")
    OUTPUT_DIR = Path("..", "output", "ver1_10")
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

    n_trials = 100
    n_fold = 5
    seed = 42
    random.seed(seed)
    target = "ProdTaken"
    seed_list = random.sample(range(0, 101), 10)
```
次にログの設定
``` python
def init_logger(log_file=CFG.OUTPUT_DIR / "train.log"):
    from logging import INFO, FileHandler, Formatter, StreamHandler, getLogger

    logger = getLogger(__name__)
    logger.setLevel(INFO)
    # 既存のハンドラをクリア（再実行時の重複防止）
    if logger.hasHandlers():
        logger.handlers.clear()
    handler1 = StreamHandler()
    handler1.setFormatter(Formatter("%(message)s"))
    handler2 = FileHandler(filename=log_file, mode="w")
    handler2.setFormatter(Formatter("%(message)s"))
    logger.addHandler(handler1)
    logger.addHandler(handler2)
    return logger

LOGGER = init_logger()
```

## データ加工
データには表記揺れが多く見られる。例えば50歳と五〇歳、５０際などである。また、特殊なアルファベット文字も使用されている場合がある。そのため、各列について以下のコードにあるように変換して表記揺れに対処した。
``` python
def check_category(elem, cat_dic):
    for cat in cat_dic.keys():
        if all([char in cat for char in elem]):
            return cat_dic[cat]
    return 0
        
def label_encoding(df, col):
    le = LabelEncoder()
    df.loc[df["Data"]=="train", col] = le.fit_transform(df.loc[df["Data"]=="train", col])
    df.loc[df["Data"]=="test", col] = le.transform(df.loc[df["Data"]=="test", col])
    df[col] = df[col].astype(int)
    return df

class DataCleaner:
    def __init__(self, df):
        self.df = df

    def clean_age(self):
        # 頭の"十"は1に変換。次に数字が続かないならば0に変換。それ以外の"十"は削除
        self.df["Age"] = self.df["Age"].str.replace(r"^十", "1", regex=True)
        self.df["Age"] = self.df["Age"].str.replace(r"十[^一二三四五六七八九]", "0", regex=True)
        self.df["Age"] = self.df["Age"].str.replace(r"十", "")
        # 漢数字を数字に変換
        k2s_table = str.maketrans("一二三四五六七八九", "123456789")
        self.df["Age"] = self.df["Age"].str.translate(k2s_table)
        # 全角数字を半角数字に変換
        z2h_table = str.maketrans("１２３４５６７８９０", "1234567890")
        self.df["Age"] = self.df["Age"].str.translate(z2h_table)
        # 文末の漢字が代なら+5する
        self.df["Age"] = self.df["Age"].apply(lambda x: f"{int(x[:-1]) + 5}" if isinstance(x, str) and x.endswith("代") else x)
        # 数字以外(歳など)を削除
        self.df["Age"] = self.df["Age"].str.replace(r"[^\d]+", "", regex=True)
        # 少数型に変換
        self.df["Age"] = self.df["Age"].astype(float)

    def clean_TypeofContact(self):
        # ラベルエンコーディングするだけ
        self.df = label_encoding(df=self.df, col="TypeofContact")

    def clean_DurationOfPitch(self):
        # 単位が分と秒しかなさそうだから秒を60, 分を1に変えて演算
        self.df["DurationOfPitch"] = self.df["DurationOfPitch"].str[:-1].astype(float) / np.where(self.df["DurationOfPitch"].str[-1] == "秒", 60, 1)
        
    def clean_Occupation(self):
        # 順序がありそうだから一応マッピング
        mapping = {"Small Business": 1, "Salaried": 2, "Large Business": 3}
        self.df["Occupation"] = self.df["Occupation"].map(mapping)

    def clean_Gender(self):
        # f,ｆが含まれれば女性、それ以外は男性
        self.df["Gender"] = self.df["Gender"].str.contains("f|ｆ", case=False).astype(int)

    def clean_NumberOfFollowups(self):
        # 100以上は外れ値として100で割る
        self.df["NumberOfFollowups"] = self.df["NumberOfFollowups"].apply(lambda x: x/100 if x>= 100 else x)

    def clean_ProductPitched(self):
        # 特殊文字を削除して残ったローマ字の一致でカテゴリーを判定する
        dic = {"basic": 1, "standard": 2, "deluxe": 3, "superdeluxe": 4, "king": 5}
        self.df["ProductPitched"] = self.df["ProductPitched"].str.lower()
        self.df["ProductPitched"] = self.df["ProductPitched"].str.replace(r"[^a-z]", "", regex=True)
        self.df["ProductPitched"] = self.df["ProductPitched"].apply(lambda x: check_category(elem=x, cat_dic=dic))
        self.df["ProductPitched"] = self.df["ProductPitched"].astype(int)

    def clean_NumberOfTrips(self):
        # 半年と四半期はそれに対応する数値を割り当てて年間になるように演算
        self.df["NumberOfTrips"] = self.df["NumberOfTrips"].fillna("-1")
        self.df["NumberOfTrips"] = self.df["NumberOfTrips"].str.replace("半年", "2").str.replace("四半期", "4").str.replace("年に", "").str.replace("回", "").str.replace("に", "*")
        self.df["NumberOfTrips"] = self.df["NumberOfTrips"].apply(simple_eval)

    def clean_Designation(self):
        # 特殊文字を削除して残ったローマ字の一致でカテゴリーを判定する
        dic = {"manager": 1, "seniormanager": 2, "vp": 4, "avp": 3, "executive": 5} # 順番が大切
        self.df["Designation"] = self.df["Designation"].str.lower()
        self.df["Designation"] = self.df["Designation"].apply(lambda x: "avp" if len(x) == 3 else x)
        self.df["Designation"] = self.df["Designation"].str.replace(r"[^a-z]", "", regex=True)
        self.df["Designation"] = self.df["Designation"].apply(lambda x: check_category(elem=x, cat_dic=dic))
        self.df["Designation"] = self.df["Designation"].astype(int)

    def clean_MonthlyIncome(self):
        # 万円を×10000に変換して演算
        self.df["MonthlyIncome"] = self.df["MonthlyIncome"].fillna("-1")
        self.df["MonthlyIncome"] = self.df["MonthlyIncome"].str.replace("月収", "").str.replace("万円", "*10000")
        self.df["MonthlyIncome"] = self.df["MonthlyIncome"].apply(simple_eval)

    def clean_customer_info(self):
        # まずは3分割
        pattern = r"[\u3000\t ,、/／\n]+"
        infos = self.df["customer_info"].str.split(pattern).to_list()
        new_infos = []
        for info in infos:
            if len(info) == 4:
                new_info = [info[0], info[1], info[2]+info[3]]
                new_infos.append(new_info)
            elif len(info) == 3:
                new_infos.append(info)
            else:
                print(f"適切に分割できていません.{info}")
                new_infos.append(['missing']*3)
        
        # 分割したものをそれぞれ列に割り当てる
        self.df["customer_info"] = new_infos
        self.df["MarryStatus"] = self.df["customer_info"].str[0]
        self.df["HasCar"] = self.df["customer_info"].str[1]
        self.df["ChildStatus"] = self.df["customer_info"].str[2]
        self.df["NumChild"] = self.df["ChildStatus"].str.extract(r"(\d)", expand=False) # 子どもの人数はChildStatusから抽出する
        
        # それぞれの列をエンコーディング
        self.df.loc[self.df["MarryStatus"].str.contains("未|独"), "MarryStatus"] = "0"
        self.df.loc[self.df["MarryStatus"].str.contains("結"), "MarryStatus"] = "1"
        self.df.loc[self.df["MarryStatus"].str.contains("離"), "MarryStatus"] = "2"
        self.df["MarryStatus"] = self.df["MarryStatus"].astype(int)
    
        self.df["HasCar"] = np.where(self.df["HasCar"].str.contains("未|なし|無"), 0, 1)
        
        self.df.loc[self.df["ChildStatus"].str.contains("未|なし|無|ゼロ|非"), "ChildStatus"] = "0"
        self.df.loc[self.df["ChildStatus"].str.contains("不|わからない|missing"), "ChildStatus"] = "-1"
        self.df.loc[~(self.df["ChildStatus"].str.contains(r"^[-\d]+$")), "ChildStatus"] = "1" # -と数字以外が登場すれば1
        self.df["ChildStatus"] = self.df["ChildStatus"].astype(int)
        
        # 子どもの人数が不明のものは-1に、いない場合は0にする
        self.df.loc[self.df["ChildStatus"]==-1, "NumChild"] = -1
        self.df["NumChild"] = self.df["NumChild"].fillna(0).astype(int)

dc = DataCleaner(df=df.copy())
# インスタンスメソッドを全て取得して実行
for name, method in inspect.getmembers(dc, predicate=inspect.ismethod):
    if not name.startswith('_'):  # _ で始まる名前はスキップ
        method()  # 実行
```

## 学習
損失関数についてはクラス不均衡に強いFocal Lossを使用する。
``` python
def focal_loss_lgb(gamma):
    def focal_loss(y_pred: np.ndarray, dtrain: lgb.Dataset, eps=1e-6):
        t = dtrain.label
        p = 1 / (1 + np.exp(-y_pred))
        p = np.clip(p, eps, 1 - eps)
        pt = np.where(t == 1, p, 1 - p)
        
        # dFL/dpt
        g1 = gamma * (1 - pt)**(gamma - 1) * np.log(pt)
        g2 = ((1 - pt) ** gamma) / pt
        dFL_dpt = g1 - g2
    
        # d2FL/dpt2
        h1 = -gamma * (gamma - 1) * (1 - pt) ** (gamma - 2) * np.log(pt)
        h2 = 2 * gamma * (1 - pt)**(gamma - 1) / pt
        h3 = (1 - pt)**gamma / (pt**2)
        d2FL_dpt2 = h1 + h2 + h3
    
        # dp/dz = p(1 - p)
        dp_dz = p * (1 - p)
        d2p_dz2 = p * (1 - p) * (1 - 2 * p)
    
        # dp_t/dz = ±dp/dz
        dpt_dz = np.where(t == 1, dp_dz, -dp_dz)
        d2pt_dz2 = np.where(t == 1, d2p_dz2, -d2p_dz2)
    
        # grad = dFL/dpt * dpt/dz
        grad = dFL_dpt * dpt_dz
    
        # hess = d2FL/dpt2 * (dpt/dz)^2 + dFL/dpt * d2pt/dz2
        hess = d2FL_dpt2 * (dpt_dz ** 2) + dFL_dpt * d2pt_dz2
    
        return grad, hess
    return focal_loss
```

学習にはLGBMを用いた。
また、ランダム性を排除するために計10個のシード値で各foldの学習を行い、optunaを用いたハイパーパラメータチューニングまで行った。つまり、各foldについて最適パラメータモデルを作成出来るので、10シード×5fold=50出力を平均化する事とする。

``` python
d = defaultdict(list)
oof_df = df_train.copy()
if (CFG.OUTPUT_DIR / "inference_df.csv").exists():
    inference_df = pd.read_csv(CFG.OUTPUT_DIR / "inference_df.csv")
else:
    inference_df = df_test.copy()
start = len(glob.glob(str(CFG.OUTPUT_DIR / "oof_df*.csv")))

for i, seed in tqdm(enumerate(CFG.seed_list), total=len(CFG.seed_list)-start):
    if i < start:
        continue
    LOGGER.info(f"========== seed: {seed} training ==========")
    skf = StratifiedKFold(n_splits=CFG.n_fold, shuffle=True, random_state=seed)
    
    for fold, (train_idx, valid_idx) in enumerate(skf.split(X, y)):
        LOGGER.info(f"===== fold: {fold} training =====")
        X_train, X_valid = X.iloc[train_idx], X.iloc[valid_idx]
        y_train, y_valid = y.iloc[train_idx], y.iloc[valid_idx]
        dtrain = lgb.Dataset(X_train, label=y_train)
        dvalid = lgb.Dataset(X_valid, label=y_valid)
        dtest = lgb.Dataset(X_test)
        oof_df.loc[valid_idx, f"seed{seed}_fold"] = fold
        n_estimators_list = []
    
        def objective(trial):
            gamma = trial.suggest_float("gamma", 1e-3, 5.0, log=True)
            obj_func = focal_loss_lgb(gamma)
            param = {
                    'objective': obj_func,
                    'metric': 'auc',
                    'boosting_type': 'gbdt',
                    'seed': seed,
                    'verbosity': -1,
                    'feature_pre_filter': False,
                    'max_depth': trial.suggest_int('max_depth', 3, 30),
                    'num_leaves': trial.suggest_int('num_leaves', 10, 300),
                    'learning_rate': trial.suggest_float('learning_rate', 0.005, 0.3, log=True),
                    'feature_fraction': trial.suggest_float('feature_fraction', 0.1, 1.0),
                    'colsample_bytree': trial.suggest_float('colsample_bytree', 0.1, 1.0),
                    'subsample': trial.suggest_float('subsample', 0.1, 1.0),
                    'subsample_freq': trial.suggest_int('subsample_freq', 0, 10),
                    'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
                    'reg_alpha': trial.suggest_float('reg_alpha', 1e-4, 10, log=True),
                    'reg_lambda': trial.suggest_float('reg_lambda', 1e-4, 10, log=True)
                    }
    
            model = lgb.train(
                param,
                dtrain,
                valid_sets=[dvalid],
                num_boost_round=1000,
                callbacks=[lgb.early_stopping(250)]
            )

            n_iter = model.best_iteration if model.best_iteration is not None else param["n_estimators"]
            n_estimators_list.append(n_iter)

            return model.best_score["valid_0"]["auc"]
    
        sampler = optuna.samplers.TPESampler(seed=seed)
        study = optuna.create_study(direction="maximize", sampler=sampler)
        study.optimize(objective, n_trials=CFG.n_trials)
    
        # 最良パラメータで学習したモデルを保持
        best_param = study.best_params
        best_score = study.best_value
        best_iter = n_estimators_list[study.best_trial.number]
        gamma = best_param["gamma"]
        obj_func = focal_loss_lgb(gamma)

        # 最良パラメータとスコアを記録
        LOGGER.info(f"best_param: {best_param}")
        LOGGER.info(f"best_score: {best_score}")

        del best_param["gamma"]
        best_param['objective'] = obj_func
        best_param['metric'] = 'auc'
        best_param['boosting_type'] = 'gbdt'
        best_param['seed'] = seed
        best_param['verbosity'] = -1
        best_param['n_estimators'] = best_iter

    
        model = lgb.train(
            best_param,
            dtrain,
            valid_sets=[dvalid],
            num_boost_round=1000,
            callbacks=[lgb.early_stopping(250)]
        )
        preds = model.predict(X_valid)
        d[seed].append(model)
        
        if not np.isclose(roc_auc_score(y_valid, preds), best_score, atol=1e-5):
            LOGGER.info("スコア不一致")
            break

        oof_df.loc[valid_idx, f"seed{seed}_pred"] = preds
        # 推論
        inference_df[f"seed{seed}_fold{fold}_pred"] = model.predict(X_test)

    cv_score = roc_auc_score(oof_df["ProdTaken"], oof_df[f"seed{seed}_pred"])
    oof_df.to_csv(CFG.OUTPUT_DIR / f"oof_df_seed{seed}.csv", index=False)
    inference_df.to_csv(CFG.OUTPUT_DIR / "inference_df.csv", index=False)
    LOGGER.info(f"===== CV =====")
    LOGGER.info(f"cv result: {cv_score}\n")

# モデル達のアンサンブルスコア
df_results = []
result_files = glob.glob(str(CFG.OUTPUT_DIR / "oof_df*.csv"))
for result_file in result_files:
    df_results.append(pd.read_csv(result_file))
df_result = pd.concat(df_results, axis=1)
df_result = df_result.T.drop_duplicates().T
df_result = df_result.loc[:, df_result.columns.str.contains(f"pred|{CFG.target}")].astype(float)
df_result[CFG.target] = df_result[CFG.target].astype(int)
df_result["Pred"] = df_result.drop(CFG.target, axis=1).mean(axis=1).astype(float)
ensemble_score = roc_auc_score(df_result[CFG.target], df_result["Pred"])
LOGGER.info("========== ensemble ==========")
LOGGER.info(f"ensemble score: {ensemble_score}")

# モデルの保存
with open(CFG.OUTPUT_DIR / "models.pickle", "wb") as f:
    pickle.dump(d, f)
LOGGER.info(f"========== save best models ==========")
```

## 結果の統合と提出
``` python
# アンサンブル評価
df_results = [pd.read_csv(f) for f in glob.glob(str(CFG.OUTPUT_DIR / "oof_df*.csv"))]
df_result  = pd.concat(df_results, axis=1).T.drop_duplicates().T
pred_cols  = [c for c in df_result.columns if 'pred' in c]
df_result[pred_cols] = df_result[pred_cols].astype(float)
df_result[CFG.target] = df_result[CFG.target].astype(int)
# LOGGER.info("===== シグモイドなし =====")
df_result['Pred'] = df_result[pred_cols].mean(axis=1)
ensemble_score = roc_auc_score(df_result[CFG.target], df_result["Pred"])
print(ensemble_score)

# LOGGER.info("===== シグモイドあり =====")
df_result[pred_cols] = 1 / (1 + np.exp(-df_result[pred_cols]))
df_result['Pred'] = df_result[pred_cols].mean(axis=1)
ensemble_score = roc_auc_score(df_result[CFG.target], df_result["Pred"])
# LOGGER.info(f"===== emsemble_score: {ensemble_score} =====")
print(ensemble_score)

inference_df = pd.read_csv(CFG.OUTPUT_DIR / "inference_df.csv")
inference_df = pd.concat([inference_df["id"], inference_df.loc[:, inference_df.columns.str.contains("pred")]], axis=1)
inference_df["Pred"] = inference_df.loc[:, inference_df.columns.str.contains("pred")].mean(axis=1)
df_sub = inference_df[["id"]].copy()
df_sub["Pred"] = inference_df["Pred"]
df_sub.to_csv(CFG.OUTPUT_DIR / "ver1_10.csv", index=False, header=None)
df_sub.describe()
```

# 試したがうまくいかなかったこと
* 他のGBDTモデル(XGB, CatBoost)の使用。どれもLGBMの精度を上回らなかった
* weighted loglossやcauchy loss損失関数の使用。Focal Lossの方が精度が高かった
* 特徴量エンジニアリングで変数の組み合わせやビン化を試したが精度は悪化
``` python
dc.df["HasPartner"] = (dc.df["MarryStatus"] == 1).astype(int)
dc.df["NumFamily"] = np.where(dc.df["NumChild"]==-1,
                                 dc.df["HasPartner"],
                                 dc.df["HasPartner"] + dc.df["NumChild"])
dc.df["AgeInc"] = dc.df["MonthlyIncome"] * dc.df["Age"]
dc.df["PassBasic"] = (dc.df["Passport"] == 1) & (dc.df["ProductPitched"] == 1)
dc.df["BasicExe"] = (dc.df["ProductPitched"] == 1) & (dc.df["Designation"] == 5)
dc.df["AloneExe"] = (dc.df["MarryStatus"] == 0) & (dc.df["Designation"] == 5)
dc.df["MaleExecutive"] = (dc.df["Gender"] == 0) & (dc.df["Designation"] == 5)
dc.df["IncAlone"] = dc.df["MonthlyIncome"] * (dc.df["MarryStatus"] == 0).astype(int)
dc.df["IncFamily"] = dc.df["MonthlyIncome"] / (dc.df["NumFamily"]+1)
dc.df["DurationProduct"] = dc.df["DurationOfPitch"] * dc.df["ProductPitched"]
dc.df["MarryPass"] = (dc.df["MarryStatus"] == 0) & (dc.df["Passport"] == 1)
dc.df["BasicMale"] = (dc.df["ProductPitched"] == 1) & (dc.df["Gender"] == 0)
dc.df["AloneMale"] = (dc.df["MarryStatus"] == 0) & (dc.df["Gender"] == 0)

dc.df = dc.df.astype({col: int for col in dc.df.select_dtypes(bool).columns})

use_cols = dc.df.drop(["id", "customer_info", "ProdTaken"], axis=1).columns

for col in use_cols:
    if dc.df[col].nunique() >= 15:
        dc.df[f"{col}_bin"] = pd.qcut(dc.df[col], 7, duplicates="drop", labels=False)
```

# 上位者のソリューション
* 前処理の方法を3パターンに分けて、それぞれについてCatboostで学習してアンサンブル
* オーバーサンプリングでクラス不均衡対策
* pycraftを用いた簡単な分析方針決定
* AND演算子を用いて、片側の成約率が高くなるような特徴量の作成

# 反省・今後の課題
* 今回のコンペのようにデータの前処理方法が複数思いつく場合は、複数のパターンでモデル構築することも検討するべき
* pycraftのような方針を決めるために有効な機能の勉強をする
* オーバーサンプリングは過学習の懸念があるが、本コンペはそもそも合成データなので検討するべきだった
* AND演算子は十分なサンプルを保った上で探す必要があると思うので、時間がある場合には試すべきアプローチだと感じた