はじめに
KaggleのMarch Machine Learning Mania 2025の解法を読み解きたいと思います。
補足: March madnessとは?
データ読込
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: シード情報
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")
男子データと女子データを縦に結合します。
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年以降のデータを抽出します。
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]
各データフレームのシェイプは以下のようになっているはずです。
print(regular_results.shape)
print(tourney_results.shape)
print(seeds.shape)
(200590, 34)
(2276, 34)
(2896, 3)
前処理
ここで行う処理の概要は以下の通りです。
- 必要な列を抽出する
- 延長戦を行った試合に対して、各スタッツを調整する。延長戦(NumOT)が2だったら、試合時間は50分になるので、スコア等のスタッツを40分に換算する。具体的には、0.8倍(40/50)する。
- 勝利チームと敗北チームを入れ替えて(swap)、縦に結合する
- 点差列を追加する(勝利スコア - 敗北スコア)
- 勝利列を追加する
- 男女列を追加する
個人的に、上記の中で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を使って、各チームの平均得失点を計算できます。
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 |
前置きが長くなりました。実装します。
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文字目が大事です。
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 |
列名を変更します。
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 = 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を使って、男子と女子に分けて、シードごとの得点差の平均と標準偏差を計算します。
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()
男子について、グラフにします。
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()
続いて女子。
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()
グラフはいずれも右肩下がりです。つまり、T1_Seedが大きいほど、得点差は小さくなる(負は敗北)と言えそうです。男女で比べると、男子の方がグラフの傾きは急なので、その傾向が強く見られそうです。女子の方が番狂わせは起きやすそうです。
シードの差と得点の差の関係を確認
今度はシードの差と、得点の差を可視化します(さっきは、横軸はT1のシードの値でした)。
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のコードは同じです。
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()
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()
長くなってきたので、一旦、ここで終わり。