[備忘録] March Machine Learning Mania 2025

# March Machine Learning Mania 2025とは
March Machine Learning Mania 2025は、NCAAというアメリカの大学のバスケットボールトーナメントの勝敗を、過去の試合の詳細(得点やシュート成功値、ブロック成功率など)から予測するコンペで毎年開催されている。リーダーボードが試合の進捗によって上下するため楽しく参加することができる点で特徴的。

# 結果
今回の結果は454位/1727(上位26.2%)だった。

# 本記事の流れ
一昨年のコンペの優秀者の解法をベースラインとした。そのため、次章では大まかな解法の流れを説明し、さらに次の章では自分で考えて取り入れたアプローチに注目して説明する。さらに最後の章では本コンペの振り返りを行う。


# おおまかな解法
## データの加工
提供されている試合データは勝利チーム側のデータのみであったので、敗北チーム側のデータも作成することで、「モデルが負けパターンを認識できるようにする」、「データ量を2倍に増やす」ことに成功した。
``` python
def prepare_data(df):
    # 勝チームと負チームを反転したdfを作成
    df_swap = df[['Season', 'DayNum', 'LTeamID', 'LScore', 'WTeamID', 'WScore', 'WLoc', 'NumOT', 
    'LFGM', 'LFGA', 'LFGM3', 'LFGA3', 'LFTM', 'LFTA', 'LOR', 'LDR', 'LAst', 'LTO', 'LStl', 'LBlk', 'LPF', 
    'WFGM', 'WFGA', 'WFGM3', 'WFGA3', 'WFTM', 'WFTA', 'WOR', 'WDR', 'WAst', 'WTO', 'WStl', 'WBlk', 'WPF']]

    # 反転させてW/Lの区別をなくすからlocationとする
    df_swap.loc[df['WLoc'] == 'H', 'WLoc'] = 'A'
    df_swap.loc[df['WLoc'] == 'A', 'WLoc'] = 'H'
    df.columns.values[6] = 'location'
    df_swap.columns.values[6] = 'location'    

    # 最初のチームをT1,その対戦相手をT2として列名をつけなおす
    df.columns = [x.replace('W','T1_').replace('L','T2_') for x in list(df.columns)]
    df_swap.columns = [x.replace('L','T1_').replace('W','T2_') for x in list(df_swap.columns)]

    # 結合してlocationを数値化する
    output = pd.concat([df, df_swap]).reset_index(drop=True)
    output.loc[output.location=='N','location'] = '0'
    output.loc[output.location=='H','location'] = '1'
    output.loc[output.location=='A','location'] = '-1'
    output.location = output.location.astype(int)
    
    # 点差の列を追加
    output['PointDiff'] = output['T1_Score'] - output['T2_Score']
    
    return output
```

## 特徴量エンジニアリング
特徴量は過去コンペでの優秀者が使用していたものを中心に選択した。
``` python
# 特徴量エンジニアリング
## その年のレギュラーシーズンの全試合のT1, T2, T1の対戦相手, T2の対戦相手の情報の統計値を追加する
boxscore_cols = [
        'T1_FGM', 'T1_FGA', 'T1_FGM3', 'T1_FGA3', 'T1_OR', 'T1_Ast', 'T1_TO', 'T1_Stl', 'T1_PF', 
        'T2_FGM', 'T2_FGA', 'T2_FGM3', 'T2_FGA3', 'T2_OR', 'T2_Ast', 'T2_TO', 'T2_Stl', 'T2_Blk',  
        'PointDiff']
funcs = ['mean']

# 年ごとのチームの試合状況の平均dfを作成(ex:2003年のチーム1102とその対戦相手の平均フリースロー成功回数)
season_statistics = regular_data.groupby(['Season', 'T1_TeamID'])[boxscore_cols].agg(funcs).reset_index()
season_statistics.columns = [''.join(col).strip() for col in season_statistics.columns.values] # 列のマルチインデックスを解消

# mergeするために列名の異なる同じdfを作成
season_statistics_T1 = season_statistics.copy()
season_statistics_T2 = season_statistics.copy()

season_statistics_T1.columns = ['T1_' + x.replace('T1_', '').replace('T2_', 'opponent_') for x in list(season_statistics_T1.columns)]
season_statistics_T2.columns = ['T2_' + x.replace('T1_', '').replace('T2_', 'opponent_') for x in list(season_statistics_T2.columns)]
season_statistics_T1.columns.values[0] = 'Season'
season_statistics_T2.columns.values[0] = 'Season'

# 事後情報は使用できないので使用できる列に絞る(Scoreは得点差を計算するために残しておく)
tourney_data = tourney_data[['Season', 'DayNum', 'T1_TeamID', 'T1_Score', 'T2_TeamID' ,'T2_Score']]

# T1とT2のその年のレギュラーシーズンでの情報(ゴール数などの平均)を追加
# さらにT1の対戦相手,T2の対戦相手のその年のレギュラーシーズンでの情報も追加
# 対戦相手の情報も追加する理由は、「今までの対戦相手より平均的に好成績であれば勝ちやすい」などのパターンをとらえるため
tourney_data = pd.merge(tourney_data, season_statistics_T1, on = ['Season', 'T1_TeamID'], how = 'left')
tourney_data = pd.merge(tourney_data, season_statistics_T2, on = ['Season', 'T2_TeamID'], how = 'left')
## その年のレギュラーシーズンのNCAA前14日間の勝率
# レギュラーシーズンから、その年のそのチームのNCAA前の14日間の勝率を取得
last14days_stats_T1 = regular_data.loc[regular_data.DayNum > CFG.day].reset_index(drop=True)
last14days_stats_T1['win'] = np.where(last14days_stats_T1['PointDiff'] > 0, 1, 0)
last14days_stats_T1 = last14days_stats_T1.groupby(['Season', 'T1_TeamID'])['win'].mean().reset_index(name='T1_WinRatio14d')

# 同じものを作成(T2でもmergeするため)
last14days_stats_T2 = regular_data.loc[regular_data.DayNum > CFG.day].reset_index(drop=True)
last14days_stats_T2['win'] = np.where(last14days_stats_T2['PointDiff'] < 0, 1, 0)
last14days_stats_T2 = last14days_stats_T2.groupby(['Season', 'T2_TeamID'])['win'].mean().reset_index(name='T2_WinRatio14d')

# tourney_dataにレギュラーシーズンでの過去14日間の勝率を追加
tourney_data = pd.merge(tourney_data, last14days_stats_T1, on = ['Season', 'T1_TeamID'], how = 'left')
tourney_data = pd.merge(tourney_data, last14days_stats_T2, on = ['Season', 'T2_TeamID'], how = 'left')
## その年のチームの強さ
# レギュラーシーズンのデータから列を絞る(試合情報は得点差と勝利ラベルのみにする)
regular_season_effects = regular_data[['Season', 'T1_TeamID', 'T2_TeamID', 'PointDiff']].copy()
regular_season_effects['T1_TeamID'] = regular_season_effects['T1_TeamID'].astype(str) # カテゴリ変数にするため
regular_season_effects['T2_TeamID'] = regular_season_effects['T2_TeamID'].astype(str) # カテゴリ変数にするため
regular_season_effects['win'] = np.where(regular_season_effects['PointDiff'] > 0, 1, 0)

# 年ごとのNCAAトーナメント参加チームの総当たり表を作成
march_madness = pd.merge(seeds[['Season', 'TeamID']], seeds[['Season', 'TeamID']], on='Season')
march_madness.columns = ['Season', 'T1_TeamID', 'T2_TeamID']
march_madness['T1_TeamID'] = march_madness['T1_TeamID'].astype(str)
march_madness['T2_TeamID'] = march_madness['T2_TeamID'].astype(str)

# レギュラーシーズンでの実際の試合結果の得点差と勝利ラベルを、NCAA参加チームの全ての組み合わせに限定する
regular_season_effects = pd.merge(regular_season_effects, march_madness, on=['Season', 'T1_TeamID', 'T2_TeamID'], how='inner')
def team_quality2(season):
    # print(season)
    # 必要なカラム（'win', 'T1_TeamID', 'T2_TeamID'）を抽出
    data = regular_season_effects.loc[regular_season_effects['Season'] == season, ['win', 'T1_TeamID', 'T2_TeamID']]
    
    # ダミー変数を作成する
    data_dummies = pd.get_dummies(data, drop_first=True)
    
    # 説明変数 (X) と目的変数 (y) に分ける
    X = data_dummies.drop('win', axis=1)
    y = data_dummies['win']
    
    # ロジスティック回帰モデルを作成
    model = LogisticRegression(solver='saga', penalty='elasticnet', l1_ratio=0.25)
    cv_scores = cross_val_score(model, X, y, cv=5, scoring='accuracy')

    # print(f'cv_scores: {cv_scores}')
    # print(f'cv_scores_mean: {np.mean(cv_scores)}')

    model.fit(X, y)
    
    quality = pd.DataFrame({'TeamID': data_dummies.columns[1:], 'quality': model.coef_.flatten()})
    quality['Season'] = season

    # T1の方を使う。モデルはT1の勝つ確率を予測するため、T2の方よりもT1の方が「強さ」として適している
    quality = quality.loc[quality['TeamID'].str.contains('T1_')].reset_index(drop=True)
    quality['TeamID'] = quality['TeamID'].apply(lambda x: x[10:14]).astype(int)
    return quality
# 2010年から2024年のチームの強さのdfを取得
team_quality_results = []
for year in range(CFG.quality_start_year, CFG.target_year+1):
    if year == 2020:
        continue
    team_quality_results.append(team_quality2(year))
logi_quality = pd.concat(team_quality_results).reset_index(drop=True)

# チームの強さをtourney_dataにマージ(T1とT2の2つの視点でマージする)
glm_quality_T1 = logi_quality.copy()
glm_quality_T2 = logi_quality.copy()
glm_quality_T1.columns = ['T1_TeamID', 'T1_quality', 'Season']
glm_quality_T2.columns = ['T2_TeamID', 'T2_quality', 'Season']
tourney_data = pd.merge(tourney_data, glm_quality_T1, on=['Season', 'T1_TeamID'], how='left')
tourney_data = pd.merge(tourney_data, glm_quality_T2, on=['Season', 'T2_TeamID'], how='left')
## その年のシード順位と対戦順位との差を追加する
# シード情報の数字部分のみを抽出(地区情報を削除)
seeds['seed'] = seeds['Seed'].apply(lambda x: int(x[1:3]))

# tourney_dataにT1とT2のシード情報(シード順位)を追加する
seeds_T1 = seeds[['Season','TeamID','seed']].copy()
seeds_T2 = seeds[['Season','TeamID','seed']].copy()
seeds_T1.columns = ['Season','T1_TeamID','T1_seed']
seeds_T2.columns = ['Season','T2_TeamID','T2_seed']
tourney_data = pd.merge(tourney_data, seeds_T1, on=['Season', 'T1_TeamID'], how='left')
tourney_data = pd.merge(tourney_data, seeds_T2, on=['Season', 'T2_TeamID'], how='left')

# 対戦相手とのシード順位差を追加
tourney_data["SeedDiff"] = tourney_data["T1_seed"] - tourney_data["T2_seed"]
```

## XGBで得点差を予測
XGBoostで得点差を予測するモデルを作成。これは、初めから勝率を分類モデルで予測するのではなく、連続値である得点差を予測して、その得点差を勝率に変換する方が精度向上が期待できると考えたから。
また、損失関数としては外れ値の影響を抑えるためにcauchy-lossを使用した。c=5000は手作業での探索を行った。
``` python
def cauchyobj(preds, dtrain):
    labels = dtrain.get_label()
    c = 5000
    x =  preds - labels
    grad = x / (x**2/c**2 + 1)
    hess = -c**2*(x**2-c**2)/(x**2+c**2)**2
    return grad, hess
```
XGB自体のパラメータはoptunaを使用して探索した。
``` python
M_iteration_counts_list = []
def objective(trial):
    param = {
        'eval_metric': 'mae',
        'booster': 'gbtree',
        'verbosity': 0,
        'max_depth': trial.suggest_int('max_depth', 3, 10),
        'eta': trial.suggest_float('eta', 0.01, 0.5, log=True),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.1, 1.0),
        'subsample': trial.suggest_float('subsample', 0.1, 1.0),
        'min_child_weight': trial.suggest_int('min_child_weight', 1, 100),
        'alpha': trial.suggest_float('alpha', 1e-8, 10, log=True),
        'lambda': trial.suggest_float('lambda', 1e-8, 10, log=True),
        'gamma': trial.suggest_float('gamma', 1e-8, 10, log=True),
        'num_parallel_tree': trial.suggest_int('num_parallel_tree', 1, 15)
    }

    xgb_cv = []

    for i in range(CFG.repeat_cv): 
        # print(f"\nFold repeater {i}")
        xgb_result = xgb.cv(
            params=param,
            dtrain=M_dtrain,
            obj=cauchyobj,
            num_boost_round=3000,
            folds = KFold(n_splits=5, shuffle=True, random_state=i),
            early_stopping_rounds=25,
            verbose_eval=50
            )
        xgb_cv.append(xgb_result)
    
    # 検証データへのmaeが最小となったイテレーション回数とその値
    iteration_counts = [np.argmin(x['test-mae-mean'].values) for x in xgb_cv]
    M_iteration_counts_list.append(iteration_counts)
    val_mae = [np.min(x['test-mae-mean'].values) for x in xgb_cv]
    return np.mean(val_mae)

# Optunaによる最適化
study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=CFG.n_trials)
    
# 結果表示
# print("Best parameters:", study.best_params)
# print("Best accuracy:", study.best_value)

M_best_param = study.best_params
M_best_iteration_counts = M_iteration_counts_list[study.best_trial.number]
M_best_score = study.best_value
```

## スプラインモデルによって予測得点差を勝率に変換する
XGBが出力した得点差を実データから勝率に変換するモデル(スプラインモデル)を構築する。
``` python
# 各予測器について予測得点差と勝利ラベルを学習データとして、予測得点差から勝率を予測するモデルを作成
M_spline_model = []
M_val_cv = []
for i in range(CFG.repeat_cv):
    M_dat = list(zip(M_oof_preds[i],np.where(M_y > 0, 1, 0)))
    M_dat = sorted(M_dat, key=lambda x: x[0])

    M_datdict = {}
    for k in range(len(M_dat)):
        M_datdict[M_dat[k][0]]= M_dat[k][1]

    M_spline_model.append(UnivariateSpline(list(M_datdict.keys()), list(M_datdict.values()), k=5))
    M_spline_fit = M_spline_model[i](M_oof_preds[i])
    M_spline_fit = np.clip(M_spline_fit, 0, 1) # 補正

    M_val_cv.append(pd.DataFrame({"y":np.where(M_y > 0, 1, 0), "pred":M_spline_fit, "season":M_tourney_data['Season']}))
    plot_df = pd.DataFrame({"pred": M_oof_preds[i], "label": np.where(M_y > 0, 1, 0), "spline": M_spline_fit})
    plot_df["pred_int"] = plot_df["pred"].astype(int)
    plot_df = plot_df.groupby('pred_int')[['spline', 'label']].mean().reset_index()

    # print(f"adjusted logloss of cvsplit {i}: {log_loss(np.where(M_y > 0, 1, 0), M_spline_fit)}") 
```

# 自分で考えたアプローチ
本コンペでは以下の4つのアプローチを実装した。
1. 男女別の学習
1. 「得点差予測の際の学習データの使用開始年」と「チームの強さを指標化する際の学習データの使用開始年」の最適化
1. チームの直近勝率を取得する際の「直近」の最適化
1. アンサンブル

## 男女別の学習
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3922938/5c077418-155f-45d4-8ae4-86480b832f2a.png)


以上のグラフはシード差によるアップセット率(シードの低いチームが高いチームに勝った割合)を示したものである。このグラフから、男子の方がアップセット率が高い傾向にあることから、データ自体に差があることが分かる。そのため、男女別に学習することでモデルが各データセットにフィットして特徴をとらえられると考えた。

## 「得点差予測の際の学習データの使用開始年」と「チームの強さを指標化する際の学習データの使用開始年」の最適化
私は、以下の2つの観点から最適化を行った。
1. 何年からのデータを使用するべきか(モデルがパターンを適切にとらえるために重要な年があるかもしれないから)
1. 何年前からのデータを使用するべきか(モデルがパターンを適切にとらえるためのデータ数がある程度決まっているかもしれないから)

まず1つ目の観点で分析する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3922938/4472bb2d-b016-4d02-b121-71f6c2d4b4b0.png)

以上のように「得点差予測の際の学習データの使用開始年」と「チームの強さを指標化する際の学習データの使用開始年」をペアとしてどの組み合わせが最も精度が高いのかを検証した。この結果、2021-2024年までの平均精度が最も高くなるのは2007, 2011であることが分かった。(上グラフは変化を見やすくするためにすべての年のグラフの平均が2021年の平均と重なるように調整している。)

同様に2つ目の観点で分析する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3922938/f2f73129-8677-4582-96bd-4b43d197c7b8.png)
以上のグラフから、得点差予測には17年前, チームの強さの指標には10年前からのデータを使用することが最適であると分かった。

## チームの直近勝率を取得する際の「直近」の最適化
チームの直近勝率を計算する際に何日前からの試合の勝率を使用するかを決定するために、上記で決定された(得点差予測に使用するデータ開始年, チームの強さ指標に使用するデータ開始年)=(2007, 2011)and(予測する年-17, 予測する年-10)の2つについてそれぞれ最適な日数を調べた。調べ方としては、1から120までを7ずつ探索するような方法で大まかにあたりを付けてからより細かく探索する方法を取った。

まずはあたりを付けた後の(2007, 2011)のデータに対する最適な日数を調べる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3922938/2ce2e941-c37f-467f-b8d1-06363a064c85.png)
以上より、104を最適とした。

次にあたりを付けた後の(予測する年-17, 予測する年-10)のデータに対する最適な日数を調べる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3922938/e8081cd9-02ef-40e8-baf5-74c39051c3d6.png)
以上より、56を最適とした。

## アンサンブル
以上のように決定した2つ(得点差予測に使用するデータ開始年, チームの強さ指標に使用するデータ開始年, 何日前からの勝率を使用するか)=(2007, 2011, 104)and(予測する年-17, 予測する年-10, 56)のデータセットに対してモデルを構築し、それぞれの予測出力の平均を最終出力とした。

# 振り返り
## 試したがうまくいかなかったこと
* 特徴量エンジニアリング -> 率への変換や対戦相手との差、他の特徴量の使用をやってみたが精度は向上しなかった
* 別モデル(LGBM)の使用 -> XGBの方が精度が高かった
* 損失関数の変更 -> brier scoreやmaeも試したがcauchy lossを使う場合の方が精度が高かった

## コンペ参加中に学んだこと
* 「勝敗」という2値分類を確率として出力する方法が1番に思い浮かんだが、「得点差」という連続値でかつ勝敗に直結している指標を目的変数とする方法があるということ
* 損失関数が非常に精度向上に重要であるということ

## 上位者の解法から学んだこと
* 私は男女別に学習をしたのに対して1位の人は男女を区別する特徴量を作成していた。男女を区別する特徴量を作成することで、勝敗を捉えるためのデータ数を落とすことなく男女のデータの違いにも対応したと考えられる
* 4位の方はスプラインモデルによるリークを避けるために得点差予測のXGBoostのリーフノードを特徴量として勝率のロジスティック回帰を行った。スプラインモデルのリークには気づいていたがXGBoostのリーフノードを特徴量とする方法は思いつかなかった

## 反省点と今後での改善
* データの開始年などを最適化したが、あれはあくまで2021年から2024年までの精度の最適化であるため、過適合していたのではないかと感じた。上位者の解法を見ると全ての年のデータからチームの強さの指標を作っていた。しかし、個人的には最適化するべきパラメータだと思うので、4年分ではなくもっと広い期間で精度を見て最適化するべきだったと考える
* 本コンペは過去コンペからEDAを行った結果などが閲覧できたが、自分でも全く新しい観点から考えるためにもデータの可視化には力を入れていきたい
* ランダム性の排除。本コンペでは1つのシード値で固定していたり、cvでのデータ数と提出物を作るときのデータ数が異なっていたりした(検証データを作らなければならない都合)ので、今後は各シード値でのモデルのアンサンブルを意識的に取り入れたい
* 特徴量エンジニアリングがうまくいかなかったので、特徴量重要度から再帰的に選択する方法やoptunaで最適化する方法のような体系だったものを身につけたい