【Kaggle】March Machine Learning Mania 2025の解法を読み解く【その1】

# はじめに
Kaggleの[March Machine Learning Mania 2025](https://www.kaggle.com/competitions/march-machine-learning-mania-2025)の[解法](https://www.kaggle.com/competitions/march-machine-learning-mania-2025/discussion/572717)を読み解きたいと思います。

## 補足: March madnessとは？
https://ja.wikipedia.org/wiki/NCAA%E7%94%B7%E5%AD%90%E3%83%90%E3%82%B9%E3%82%B1%E3%83%83%E3%83%88%E3%83%9C%E3%83%BC%E3%83%AB%E3%83%88%E3%83%BC%E3%83%8A%E3%83%A1%E3%83%B3%E3%83%88

# データ読込
```Python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn
import warnings

# warnings
warnings.filterwarnings("ignore")

# pandas
pd.set_option("display.max_column", 999)
```

データを読み込みます。
- **RegularSeasonDetailedResults.csv**: レギュラーシーズンのスタッツ 
- **MNCAATourneyDetailedResults.csv**: トーナメントのスタッツ
- **MNCAATourney.csv**: シード情報

```python
data_dir = "../input/march-machine-learning-mania-2025
M_regular_results = pd.read_csv(f"{data_dir}/MRegularSeasonDetailedResults.csv")
M_tourney_results = pd.read_csv(f"{data_dir}/MNCAATourneyDetailedResults.csv")
M_seeds = pd.read_csv(f"{data_dir}/MNCAATourneySeeds.csv")

W_regular_results = pd.read_csv(f"{data_dir}/WRegularSeasonDetailedResults.csv")
W_tourney_results = pd.read_csv(f"{data_dir}/WNCAATourneyDetailedResults.csv")
W_seeds = pd.read_csv(f"{data_dir}/WNCAATourneySeeds.csv")
```

男子データと女子データを縦に結合します。
```python
regular_results = pd.concat([M_regular_results, W_regular_results])
tourney_results = pd.concat([M_tourney_results, W_tourney_results])
seeds = pd.concat([M_seeds, W_seeds])
```

2003年以降のデータを抽出します。
```python
season = 2003  # もし、別の年数で抽出したければ、変更して　change if you want different cutoff year for your models
regular_results = regular_results.loc[regular_results["Season"] >= season]
tourney_results = tourney_results.loc[tourney_results["Season"] >= season]
seeds = seeds.loc[seeds["Season"] >= season]
```

各データフレームのシェイプは以下のようになっているはずです。
```python
print(regular_results.shape)
print(tourney_results.shape)
print(seeds.shape)
```
```:出力
(200590, 34)
(2276, 34)
(2896, 3)
```

# 前処理
ここで行う処理の概要は以下の通りです。
1. 必要な列を抽出する
2. 延長戦を行った試合に対して、各スタッツを調整する。延長戦(NumOT)が2だったら、試合時間は50分になるので、スコア等のスタッツを40分に換算する。具体的には、0.8倍(40/50)する。
3. 勝利チームと敗北チームを入れ替えて（swap）、縦に結合する
4. 点差列を追加する（勝利スコア - 敗北スコア)
5. 勝利列を追加する
6. 男女列を追加する

個人的に、上記の中で3番の意味が飲み込みづらかったのですが、後でgroupbyを使って集計する際に、この処理が必要になります。例えば、A, B, Cの3チームがレギュラーシーズンで総当たり戦を行い、以下のような結果になった場合を考えます。

| Season | T1_TeamID | T1_Score | T2_TeamID | T2_Score |
| --- | --- | --- | --- | --- |
| 2024 | A | 100 | B | 90 |
| 2024 | B | 90 | C | 80 |
| 2024 | A | 80 | C | 70|

groupbyを使って各チームの得点と失点を集計する場合、上記のままではうまく計算できません。そこで、3番の処理の出番です。勝利チームと敗北チームを入れ替えたデータを作成し、縦に結合します。まずは、入れ替えです。

| Season | T2_TeamID | T2_Score | T1_TeamID | T1_Score |
| --- | --- | --- | --- | --- |
| 2024 | A | 100 | B | 90 |
| 2024 | B | 90 | C | 80 |
| 2024 | A | 80 | C | 70|

二つのデータを縦結合すると、以下のようなデータになります。
| Season | T1_TeamID | T1_Score | T2_TeamID | T2_Score |
| --- | --- | --- | --- | --- |
| 2024 | A | 100 | B | 90 |
| 2024 | B | 90 | C | 80 |
| 2024 | A | 80 | C | 70|
| 2024 | B | 90 | A | 100 |
| 2024 | C | 80 | B | 90 |
| 2024 | C | 70 | A | 80|

こうなるとgroupbyを使って、各チームの平均得失点を計算できます。

```python
df.groupby(['Season', 'T1_TeamID'])['T1_Score', 'T2_Score'].ag([np.mean]).reset_index()
```
| Season | T1_TeamID | T1_Score_mean | T2_Score_mean |
| --- | --- | --- | --- |
| 2024 | A | 90.0 | 80.0 |
| 2024 | B | 90.0 | 90.0 |
| 2024 | C | 75.0 | 85.0 |

前置きが長くなりました。実装します。

```python
def prepare_data(df):
    # 1.必要な列を抽出
    df = df[["Season", "DayNum", "LTeamID", "LScore", "WTeamID", "WScore", "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"]]
    

    #  延長戦を行った試合に対して、各スタッツを調整する。延長1回なら試合時間が45分になるので、40分に換算する。
    adjot = (40 + 5 * df["NumOT"]) / 40 # NumOTが1のとき、40+5=45分
    adjcols = ["LScore", "WScore", 
               "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"]
    for col in adjcols:
        df[col] = df[col] / adjot # OTの回数に応じてスタッツを調整する。OTが1回なら、試合時間が40分→45分になるので、各スタッツを45/40で割る。各スタッツを40分（＝通常の試合時間）あたりのスタッツに換算するため

    # 3.列名を入れ替えて、縦結合する
    dfswap = df.copy()
    df.columns = [x.replace("W", "T1_").replace("L", "T2_") for x in list(df.columns)] # 列名を変更W→T1＿、L→T2
    dfswap.columns = [x.replace("L", "T1_").replace("W", "T2_") for x in list(dfswap.columns)] # swap版は、L→T1＿、W→T2に変更
    output = pd.concat([df, dfswap]).reset_index(drop=True) # 結合

    # 4~6. 列追加
    output["PointDiff"] = output["T1_Score"] - output["T2_Score"] # 得点差列を追加
    output["win"] = (output["PointDiff"] > 0) * 1 # 勝敗を1/0で表す
    # output["T1_TeamID"] = output["T1_TeamID"].astype(str) # チームIDを文字列に変換
    # output["T2_TeamID"] = output["T2_TeamID"].astype(str) # チームIDを文字列に変換
    output["men_women"] = (output["T1_TeamID"].apply(lambda t: str(t).startswith("1"))) * 1  # 0: women, 1: men
    return output

regular_data = prepare_data(regular_results)
tourney_data = prepare_data(tourney_results)
```

# 特徴量エンジニアリング（難易度：EASY）
シード情報を付与します。シードとは、NCAAトーナメントに出場したチームへ割り振られる番号です。レギュラーシーズンの結果がよかったチームほど、小さい番号が割り振られます。つまり、シード番号が小さいチームは強いはずなので、勝敗予測に対して有効な特徴量になってくれそうです。

| | Season | Seed | TeamID |
| --- | --- | --- | --- |
| 1154 | 2003 | W01 | 1328 |
| 1155 | 2003 | W02 | 1448 |
| 1156 | 2003 | W03 | 1393 |

シードデータは、W16aのように3桁もくしは4桁の文字列で表されています。一文字目は地域を表し、W, X, Y, Z の4種類です。2～3文字目がシード番号です。4文字目はファースト4（下位8チームのプレーオフ）につけられる記号で、aかbが付きます。要するに2、3文字目が大事です。

```python
seeds["seed"] = seeds["Seed"].apply(lambda x: int(x[1:3])) # 2～3文字目を抽出
```
| | Season | Seed | TeamID | Seed |
| --- | --- | --- | --- | --- |
| 1154 | 2003 | W01 | 1328 | 1 |
| 1155 | 2003 | W02 | 1448 | 2 |
| 1156 | 2003 | W03 | 1393 | 3 |

列名を変更します。
```python
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"]
```

トーナメントデータにマージし、シード番号の差列を追加します。
```python
tourney_data = tourney_data[["Season", "T1_TeamID", "T2_TeamID", "PointDiff", "win", "men_women"]]
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["Seed_diff"] = tourney_data["T2_seed"] - tourney_data["T1_seed"] # シード差を追加
```
| | Season | T1_TeamID | T2_TeamID | PointDiff | win | men_women | T1_seed | T2_seed | Seed_diff |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 0 | 2003 | 1421 | 1411 | 7.111 | 1 | 1 | 16 | 16 | 0 |
| 1 | 2003 | 1112 | 1436 | 29.111 | 1 | 1 | 1 | 16 | 15 |
| 1 | 2003 | 1113 | 1272 | 13.000 | 1 | 1 | 10 | 7 | -3 |
| : | : | : | : | : | : | : | : | : | : |
| 4551 | 2024 | 3234 | 3376 | -12.000 | 0 | 0 | 1 | 1 | 0 |

※得点差が少数になっているのは、延長戦を考慮した調整（スコアを40分換算している）が入っているから。

## 補足: トーナメントについて
- 68チームが選出される。
- 下位8チームが試合を行い、勝利チーム（4チーム）が本大会出場できる。ファースト4というらしい。
- 64チームがノックアウト形式でトーナメントを戦う。
- なので試合数は、4+32+16+8+4+2+1=67になる。
- 1回戦はシード番号が小さいチームと、大きいチームがあたる。
- 地域は4つある。W, X, Y, Z。
- 末尾にa, bがついているのは、ファースト4。
- 

# シードと得点差を確認
シードと得点差の関係を可視化します。まずは、pivot_tableを使って、男子と女子に分けて、シードごとの得点差の平均と標準偏差を計算します。

```python
tmpmean = tourney_data.pivot_table(columns="men_women", index="T1_seed", values="PointDiff", aggfunc="mean").ffill()
tmpstd = tourney_data.pivot_table(columns="men_women", index="T1_seed", values="PointDiff", aggfunc="std").ffill()
```

男子について、グラフにします。
```python
plt.plot(tmpmean.index, tmpmean[0], 'b-')
plt.fill_between(tmpmean.index, tmpmean[0] - tmpstd[0], tmpmean[0] + tmpstd[0], color="b", alpha=0.1)
plt.xlabel("T1_seed")
plt.ylabel("PointDiff_MEAN")
plt.title('Men')
plt.show()
```

![men_T1_seed_PointDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/0f4f8fe9-1d77-456d-b7a1-d5ba76f59b1b.png)

続いて女子。
```python
plt.plot(tmpmean.index, tmpmean[1], 'r-')
plt.fill_between(tmpmean.index, tmpmean[1] - tmpstd[1], tmpmean[1] + tmpstd[1], color="r", alpha=0.1)
plt.xlabel("T1_seed")
plt.ylabel("PointDiff_MEAN")
plt.title('Women')
plt.show()
```
![women_T1seed_PointDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/b9241026-af86-4a36-acd0-9adeda503f79.png)

グラフはいずれも右肩下がりです。つまり、T1_Seedが大きいほど、得点差は小さくなる（負は敗北）と言えそうです。男女で比べると、男子の方がグラフの傾きは急なので、その傾向が強く見られそうです。女子の方が番狂わせは起きやすそうです。

# シードの差と得点の差の関係を確認
今度はシードの差と、得点の差を可視化します（さっきは、横軸はT1のシードの値でした）。

```python
tmpmean = tourney_data.pivot_table(columns="men_women", index="Seed_diff", values="PointDiff", aggfunc="mean").ffill()
tmpstd = tourney_data.pivot_table(columns="men_women", index="Seed_diff", values="PointDiff", aggfunc="std").ffill()
```

plotのコードは同じです。
```python
plt.plot(tmpmean.index, tmpmean[0], 'b--')
plt.fill_between(tmpmean.index, tmpmean[0] - tmpstd[0], tmpmean[0] + tmpstd[0], color="b", alpha=0.1)
plt.xlabel("Seed_diff")
plt.ylabel("PointDiff_MEAN")
plt.title('Men')
plt.show()
```
```python
plt.plot(tmpmean.index, tmpmean[1], 'r--')
plt.fill_between(tmpmean.index, tmpmean[1] - tmpstd[1], tmpmean[1] + tmpstd[1], color="r", alpha=0.1)
plt.xlabel("Seed_diff")
plt.ylabel("PointDiff_MEAN")
plt.title('Women')
plt.show()
```
![men_Seeddiff_Pointdiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/34956275-cdcb-4677-b8e4-b7fe1d4b0820.png)
![women_seeddiff_pointdiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/689015/e1a5682b-4ef9-4816-8b77-2199d1a4fa2f.png)

長くなってきたので、一旦、ここで終わり。