signate: 初心者が2013年の4大テニストーナメントの試合詳細情報から、試合の結果を予測するモデルを作成
目的
- 前回のタイタニック二値分類に引き続き、二値分類問題をもう一度やることで知識を定着させる。また、精度改善にも取り組む。
- 前回はコードが見にくくなってしまったので、クラスを使ってみる。
コンペ概要
-
今回参加したコンペ
-
データ概要
課題種別:分類
データ種別:多変量
学習データサンプル数:471
説明変数の数:45
欠損値:あり
まずは提出パート
仮説立案
特徴量の説明
No. | カラム名 | データ型 | 説明 |
---|---|---|---|
0 | id | int | インデックスとして使用 |
1 | Tournament | varchar | 大会名(テニス四大大会) |
2 | Sex | varchar | 性別 |
3 | Year | int | 年度(yyyy) |
4 | Player 1 | varchar | プレーヤー1の名前 |
5 | Player 2 | varchar | プレーヤー2の名前 |
6 | Result | int | 試合の結果(Player1勝利=1, Player2勝利=0) |
7 | FSP.1 | int | プレーヤー1のファーストサーブの割合(%) |
8 | FSW.1 | int | プレーヤー1がファーストサーブで勝った割合(%) |
9 | SSP.1 | int | プレーヤー1のセカンドサーブの割合(%) |
10 | SSW.1 | int | プレーヤー1がセカンドサーブで勝った割合(%) |
11 | ACE.1 | int | プレーヤー1のサービスエース数 |
12 | DBF.1 | int | プレーヤー1のダブルフォルト数 |
13 | WNR.1 | int | プレーヤー1が獲得したウィナー数 |
14 | UFE.1 | int | プレーヤー1のアンフォーストエラー数 |
15 | BPC.1 | int | プレーヤー1のブレークポイント創出数 |
16 | BPW.1 | int | プレーヤー1のブレーク成功数 |
17 | NPA.1 | int | プレーヤー1のネットポイント試行数 |
18 | NPW.1 | int | プレーヤー1のネットポイント獲得数 |
19 | TPW.1 | int | プレーヤー1の総獲得ポイント数 |
20 | ST1.1 | int | プレーヤー1の第1セットゲーム数 |
21 | ST2.1 | int | プレーヤー1の第2セットゲーム数 |
22 | ST3.1 | int | プレーヤー1の第3セットゲーム数 |
23 | ST4.1 | int | プレーヤー1の第4セットゲーム数 |
24 | ST5.1 | int | プレーヤー1の第5セットゲーム数 |
25 | FNL.1 | int | プレーヤー1の最終セット数 |
26 | FSP.2 | int | プレーヤー2のファーストサーブの割合(%) |
27 | FSW.2 | int | プレーヤー2がファーストサーブで勝った割合(%) |
28 | SSP.2 | int | プレーヤー2のセカンドサーブの割合(%) |
29 | SSW.2 | int | プレーヤー2がセカンドサーブで勝った割合(%) |
30 | ACE.2 | int | プレーヤー2のサービスエース数 |
31 | DBF.2 | int | プレーヤー2のダブルフォルト数 |
32 | WNR.2 | int | プレーヤー2が獲得したウィナー数 |
33 | UFE.2 | int | プレーヤー2のアンフォーストエラー数 |
34 | BPC.2 | int | プレーヤー2のブレークポイント創出数 |
35 | BPW.2 | int | プレーヤー2のブレーク成功数 |
36 | NPA.2 | int | プレーヤー2のネットポイント試行数 |
37 | NPW.2 | int | プレーヤー2のネットポイント獲得数 |
38 | TPW.2 | int | プレーヤー2の総獲得ポイント数 |
39 | ST1.2 | int | プレーヤー2の第1セットゲーム数 |
40 | ST2.2 | int | プレーヤー2の第2セットゲーム数 |
41 | ST3.2 | int | プレーヤー2の第3セットゲーム数 |
42 | ST4.2 | int | プレーヤー2の第4セットゲーム数 |
43 | ST5.2 | int | プレーヤー2の第5セットゲーム数 |
44 | FNL.2 | int | プレーヤー2の最終セット数 |
45 | Round | int | 試合が行われたトーナメントラウンド |
勝敗に影響しそうな特徴量をリストアップ
-
サーブ成功率(FSP, FSW, SSP, SSW)、エース数(ACE)、ダブルフォルト(DBF)、ウィナー(WNR)、アンフォーストエラー(UFE)、ブレークポイント(BPC, BPW)、ネットポイント(NPA, NPW)、総獲得ポイント(TPW)などは、テニスの勝敗に直結しやすい。
-
セットごとのゲーム数や最終セット数(ST1〜ST5, FNL)は、試合展開を反映するが、試合結果と強く相関するため、リーク(情報漏洩)になる。
→ 予測モデルには「試合前に分かる特徴量」や「試合中の途中経過」だけを使う。
一連の流れを先に確認(初期)
クラスについて
疎結合にしているが、基本的にはメソッドをインスタンス化して順番通りに実行していけば問題ない。
例:
preprocess():データの前処理(欠損値補完など)
split_data():訓練・テストデータに分割
train_model():ロジスティック回帰モデルの学習
evaluate_model():モデル評価
create_submission():提出ファイル作成
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
class TennisMatchData:
def __init__(self, train_file, test_file):
self.train = pd.read_csv(train_file, delimiter='\t')
self.test = pd.read_csv(test_file, delimiter='\t')
self.df = pd.concat([self.train, self.test], axis=0, ignore_index=True)
self.features = [
'FSP.1', 'FSW.1', 'SSP.1', 'SSW.1', "FSP.2", "FSW.2", "SSP.2", "SSW.2",
'ACE.1', 'ACE.2',
'DBF.1', 'DBF.2',
'WNR.1', 'WNR.2',
'UFE.1', 'UFE.2',
'BPC.1', 'BPC.2', 'BPW.1', 'BPW.2',
'NPA.1', 'NPA.2', 'NPW.1', 'NPW.2',
'TPW.1', 'TPW.2'
]
self.target = 'Result'
self.X = None
self.y = None
# 前処理全般
def preprocess(self):
self.df = self.df.drop(columns=['id'])
self.X = self.df[self.features]
# ターゲット変数の設定
self.y = self.df[self.target]
# 欠損値処理
self.X.fillna(self.X.mean(), inplace=True)
self.y.fillna(self.y.mean(), inplace=True)
# 交差項を作成
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=2, interaction_only=True, include_bias=False)
self.X = poly.fit_transform(self.X)
self.X = pd.DataFrame(self.X, columns=poly.get_feature_names_out(self.features))
# スケーリング
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
self.X = scaler.fit_transform(self.X)
self.X = pd.DataFrame(self.X, columns=poly.get_feature_names_out(self.features))
def split_data(self):
X_train = self.X.iloc[:len(self.train), :]
X_test = self.X.iloc[len(self.train):, :]
y_train = self.y.iloc[:len(self.train)]
y_test = self.y.iloc[len(self.train):]
return X_train, X_test, y_train, y_test
# trainデータのみを学習用・検証用に分割
def split_train_valid(self, X_train, y_train):
from sklearn.model_selection import train_test_split
return train_test_split(X_train, y_train, test_size=0.2, random_state=42)
# ロジスティック回帰モデルの学習
def train_model(self, X_train, y_train):
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_train, y_train)
return model
# LightGBMモデルの学習
def train_lightgbm_model(self, X_train, y_train):
import lightgbm as lgb
model = lgb.LGBMClassifier()
model.fit(X_train, y_train)
return model
# アンサンブル
def ensemble_models(self, models, X_valid):
from sklearn.ensemble import VotingClassifier
voting_model = VotingClassifier(estimators=[('lr', models[0]), ('lgbm', models[1])], voting='soft')
voting_model.fit(X_valid, y_valid)
return voting_model
# 評価用データでモデルを評価
def evaluate_model(self, model, X_valid, y_valid):
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
y_pred = model.predict(X_valid)
display("Accuracy:", accuracy_score(y_valid, y_pred))
display("Classification Report:\n", classification_report(y_valid, y_pred))
display("Confusion Matrix:\n", confusion_matrix(y_valid, y_pred))
def create_submission(self, model, X_test, test_ids):
y_test_pred = model.predict(X_test)
submit = pd.DataFrame({
'id': test_ids,
'Result': y_test_pred.astype(int)
})
submit.to_csv('submit.csv', index=False, header=False)
print("Submission file created: submit.csv")
実際に学習してみる
if __name__ == "__main__":
tennis_data = TennisMatchData('train.csv', 'test.csv')
tennis_data.preprocess()
X_train, X_test, y_train, y_test = tennis_data.split_data()
# 学習用・検証用データに分割
X_train, X_valid, y_train, y_valid = tennis_data.split_train_valid(X_train, y_train)
# モデルの学習
model_lr = tennis_data.train_model(X_train, y_train)
model_lgbm = tennis_data.train_lightgbm_model(X_train, y_train)
# アンサンブル
voting_model = tennis_data.ensemble_models([model_lr, model_lgbm], X_valid)
# モデルの評価
tennis_data.evaluate_model(voting_model, X_valid, y_valid)
# 提出ファイルの作成
tennis_data.create_submission(voting_model, X_test, tennis_data.test['id'])
精度改善パート
評価項目で問題発生
- 'Accuracy:'1.0'
- 'Confusion Matrix:'
array([[42, 0],
[ 0, 53]])
と、評価がどの指標を見ても1.0となっており、過学習(過剰適合)が疑える結果となった。
対策
- 「試合前に分かる特徴量」だけを使う
TPW(総獲得ポイント)、BPW(ブレーク成功数)、NPW(ネットポイント獲得数)など「結果に直結する特徴量」は除外 - 使用する特徴量
サーブ成功率やサーブで勝った割合
FSP.1, FSW.1, SSP.1, SSW.1, FSP.2, FSW.2, SSP.2, SSW.2
エース数、ダブルフォルト数、ウィナー数、アンフォーストエラー数
ACE.1, DBF.1, WNR.1, UFE.1, ACE.2, DBF.2, WNR.2, UFE.2
ブレークポイント、ネットポイント、総獲得ポイント
BPC.1, BPW.1, NPA.1, NPW.1, TPW.1, BPC.2, BPW.2, NPA.2, NPW.2, TPW.2
セットごとのゲーム数や最終セット数
ST1.1, ST2.1, ST3.1, ST4.1, ST5.1, FNL.1, ST1.2, ST2.2, ST3.2, ST4.2, ST5.2, FNL.2
その他、Tournament, Sex, Year, Player1, Player2, Round などの基本情報 - 交差項を減らす/使わない
まずは元の特徴量だけにする
# 上記すべての流れをデータクラスを用いて疎結合にするコードを作る
class TennisMatchData:
def __init__(self, train_file, test_file):
self.train = pd.read_csv(train_file, delimiter='\t')
self.test = pd.read_csv(test_file, delimiter='\t')
self.df = pd.concat([self.train, self.test], axis=0, ignore_index=True)
self.features = [
'FSP.1', 'FSW.1', 'SSP.1', 'SSW.1', # サーブ率
'FSP.2', 'FSW.2', 'SSP.2', 'SSW.2',
'ACE.1', 'ACE.2', # エース数
'DBF.1', 'DBF.2', # ダブルフォルト
'WNR.1', 'WNR.2', # ウィナー数
'UFE.1', 'UFE.2', # アンフォーストエラー
'BPC.1', 'BPC.2', # ブレークポイント創出数
'NPA.1', 'NPA.2', # ネットポイント試行数
'NPW.1', 'NPW.2', # ネットポイント獲得数
# ↓以下は除外(リークの可能性が高い)
# 'TPW.1', 'TPW.2', 'BPW.1', 'BPW.2', 'ST1.1'〜'FNL.2'
]
self.target = 'Result'
self.X = None
self.y = None
- 以下にコードを修正
def preprocess(self):
self.df = self.df.drop(columns=['id'])
self.X = self.df[self.features]
# ターゲット変数の設定
self.y = self.df[self.target]
# 数値変数のみ平均値で補完
self.X = self.X.fillna(self.X.mean())
self.y = self.y.fillna(self.y.mean())
# 交差項を作成
#from sklearn.preprocessing import PolynomialFeatures
#poly = PolynomialFeatures(degree=2, interaction_only=True, include_bias=False)
#self.X = poly.fit_transform(self.X)
#self.X = pd.DataFrame(self.X, columns=poly.get_feature_names_out(self.features))
# スケーリング
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
self.X = scaler.fit_transform(self.X)
self.X = pd.DataFrame(self.X, columns=scaler.feature_names_in_)
def split_data(self):
X_train = self.X.iloc[:len(self.train), :]
X_test = self.X.iloc[len(self.train):, :]
y_train = self.y.iloc[:len(self.train)]
y_test = self.y.iloc[len(self.train):]
return X_train, X_test, y_train, y_test
# trainデータのみを学習用・検証用に分割
def split_train_valid(self, X_train, y_train):
from sklearn.model_selection import train_test_split
return train_test_split(X_train, y_train, test_size=0.2, random_state=42)
# ロジスティック回帰モデルの学習
def train_model(self, X_train, y_train):
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_train, y_train)
return model
# LightGBMモデルの学習
def train_lightgbm_model(self, X_train, y_train):
import lightgbm as lgb
model = lgb.LGBMClassifier()
model.fit(X_train, y_train)
return model
# アンサンブル
def ensemble_models(self, models, X_train, y_train):
from sklearn.ensemble import VotingClassifier
voting_model = VotingClassifier(estimators=[('lr', models[0]), ('lgbm', models[1])], voting='soft')
voting_model.fit(X_train, y_train)
return voting_model
def evaluate_model(self, model, X_valid, y_valid):
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
y_pred = model.predict(X_valid)
display("Accuracy:", accuracy_score(y_valid, y_pred))
display("Classification Report:\n", classification_report(y_valid, y_pred))
display("Confusion Matrix:\n", confusion_matrix(y_valid, y_pred))
def create_submission(self, model, X_test, test_ids):
y_test_pred = model.predict(X_test)
submit = pd.DataFrame({
'id': test_ids,
'Result': y_test_pred.astype(int)
})
submit.to_csv('submit.csv', index=False, header=False)
print("Submission file created: submit.csv")
→しかし、これらを試しても一向に治らず。。
結論
- アンサンブル時に渡していたデータが評価用データだったので、ゴリゴリリークを起こしていた。。。
以下のように修正
# アンサンブル
def ensemble_models(self, models, X_train, y_train):
from sklearn.ensemble import VotingClassifier
voting_model = VotingClassifier(estimators=[('lr', models[0]), ('lgbm', models[1])], voting='soft')
voting_model.fit(X_train, y_train)
return voting_model
# アンサンブル
voting_model = tennis_data.ensemble_models([model_lr, model_lgbm], X_train, y_train)
'Accuracy:'0.9052631578947369'
'Confusion Matrix:
([[38, 4],
[ 5, 48]])
ようやく正常の範囲内になりました!リークの恐ろしさをを身をもって体感。
特徴量エンジニアリング
特徴量エンジニアリングは、モデルがより多くの有用な情報を学習できるように新しい特徴量を作成・加工すること
- 差分・比率特徴量の作成
各指標の差分
例:FSP_diff = FSP.1 - FSP.2(ファーストサーブ率の差)
各指標の比率
例:ACE_ratio = ACE.1 / (ACE.2 + 1)(エース数の比率、0除算防止で+1) - 選手ごとの特徴量の集約
例:Player1やPlayer2ごとに平均サーブ率や勝率などを集計し、特徴量として追加
# 選手ごとの平均値を計算
player1_stats = train.groupby('Player 1')[['FSP.1', 'ACE.1', 'WNR.1', 'UFE.1']].mean().add_prefix('P1_avg_')
player2_stats = train.groupby('Player 2')[['FSP.2', 'ACE.2', 'WNR.2', 'UFE.2']].mean().add_prefix('P2_avg_')
# 元データに選手ごとの平均値をマージ
self.df = self.df.merge(player1_stats, left_on='Player 1', right_index=True, how='left')
self.df = self.df.merge(player2_stats, left_on='Player 2', right_index=True, how='left')
アンサンブルの手法を変更
現状はVotingClassifierを採用しているが、tackingClassifierに変更。
- VotingClassifier
複数のモデルの予測結果を「投票」して最終予測を決める手法。
voting='soft'の場合は、各モデルの「予測確率の平均」をとって一番高いクラスを選ぶ。
重み付きアンサンブルも可能で、weights引数で各モデルの重要度を調整できる。 - tackingClassifier
各モデルの予測結果を「新たな特徴量」として、さらに別のモデル(メタモデル)で最終予測を行う手法。
Votingよりも複雑な関係を学習できるため、精度が上がることがある。
# StackingClassifier(スタッキングアンサンブル)
def ensemble_models(self, models, X_train, y_train):
from sklearn.ensemble import StackingClassifier
from sklearn.linear_model import LogisticRegression
# メタモデルとしてロジスティック回帰を使用
meta_model = LogisticRegression(max_iter=1000, random_state=42)
# スタッキングアンサンブルの作成
stacking_model = StackingClassifier(
estimators=[(f'model_{i}', model) for i, model in enumerate(models)],
final_estimator=meta_model,
cv=5
)
結果:Cross-Validation Accuracy (cv=5): 0.9263 ± 0.0421
一番高そう!
ハイパーパラメータチューニング
今回はなし
最終的なコードと感想
class TennisMatchData:
def __init__(self, train_file, test_file):
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
self.train = pd.read_csv(train_file, delimiter='\t')
self.test = pd.read_csv(test_file, delimiter='\t')
self.df = pd.concat([self.train, self.test], axis=0, ignore_index=True)
## 特徴量エンジニアリング
# 選手ごとの平均値を計算し、元データにマージ
# # 1. 選手ごとの平均値を計算
player1_stats = self.train.groupby('Player1')[['FSP.1', 'ACE.1', 'WNR.1', 'UFE.1']].mean().add_prefix('P1_avg_')
player2_stats = self.train.groupby('Player2')[['FSP.2', 'ACE.2', 'WNR.2', 'UFE.2']].mean().add_prefix('P2_avg_')
# 元データに選手ごとの平均値をマージ
self.df = self.df.merge(player1_stats, left_on='Player1', right_index=True, how='left')
self.df = self.df.merge(player2_stats, left_on='Player2', right_index=True, how='left')
# 差分特徴量
self.df['FSP_diff'] = self.df['FSP.1'] - self.df['FSP.2'] # サーブ成功率の差
# 比率特徴量
# エース数の比率
self.df['ACE_ratio'] = self.df['ACE.1'] / (self.df['ACE.2'] + 1) # ゼロ除算を避けるために1を加える
self.features = [
'FSP.1', 'FSW.1', 'SSP.1', 'SSW.1', # サーブ率
'FSP.2', 'FSW.2', 'SSP.2', 'SSW.2',
'ACE.1', 'ACE.2', # エース数
'DBF.1', 'DBF.2', # ダブルフォルト
'WNR.1', 'WNR.2', # ウィナー数
'UFE.1', 'UFE.2', # アンフォーストエラー
'BPC.1', 'BPC.2', # ブレークポイント創出数
'NPA.1', 'NPA.2', # ネットポイント試行数
'NPW.1', 'NPW.2', # ネットポイント獲得数
'P1_avg_FSP.1', 'P1_avg_ACE.1', 'P1_avg_WNR.1', 'P1_avg_UFE.1','P2_avg_FSP.2', 'P2_avg_ACE.2', 'P2_avg_WNR.2', 'P2_avg_UFE.2',# 選手ごとの平均値
'FSP_diff', 'ACE_ratio', # 差分特徴量
# ↓以下は除外(リークの可能性が高い)
# 'TPW.1', 'TPW.2', 'BPW.1', 'BPW.2', 'ST1.1'〜'FNL.2'
]
self.target = 'Result'
self.X = None
self.y = None
def preprocess(self):
self.df = self.df.drop(columns=['id'])
self.X = self.df[self.features]
# ターゲット変数の設定
self.y = self.df[self.target]
# 数値変数のみ平均値で補完
self.X = self.X.fillna(self.X.mean())
self.y = self.y.fillna(self.y.mean())
# 交差項を作成
#from sklearn.preprocessing import PolynomialFeatures
#poly = PolynomialFeatures(degree=2, interaction_only=True, include_bias=False)
#self.X = poly.fit_transform(self.X)
#self.X = pd.DataFrame(self.X, columns=poly.get_feature_names_out(self.features))
# スケーリング
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
self.X = scaler.fit_transform(self.X)
self.X = pd.DataFrame(self.X, columns=scaler.feature_names_in_)
def split_data(self):
X_train = self.X.iloc[:len(self.train), :]
X_test = self.X.iloc[len(self.train):, :]
y_train = self.y.iloc[:len(self.train)]
y_test = self.y.iloc[len(self.train):]
return X_train, X_test, y_train, y_test
# trainデータのみを学習用・検証用に分割
def split_train_valid(self, X_train, y_train):
from sklearn.model_selection import train_test_split
return train_test_split(X_train, y_train, test_size=0.2, random_state=42)
# XGBoostモデルの学習
def train_xgboost_model(self, X_train, y_train):
import xgboost as xgb
model = xgb.XGBClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
return model
# ロジスティック回帰モデルの学習
def train_logistic_regression_model(self, X_train, y_train):
from sklearn.linear_model import LogisticRegression
model = LogisticRegression(max_iter=1000, random_state=42)
model.fit(X_train, y_train)
return model
# ランダムフォレストモデルの学習
def train_random_forest_model(self, X_train, y_train):
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
return model
# LightGBMモデルの学習
def train_lightgbm_model(self, X_train, y_train):
import lightgbm as lgb
model = lgb.LGBMClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
return model
# StackingClassifier(スタッキングアンサンブル)
def ensemble_models(self, models, X_train, y_train):
from sklearn.ensemble import StackingClassifier
from sklearn.linear_model import LogisticRegression
# メタモデルとしてロジスティック回帰を使用
meta_model = LogisticRegression(max_iter=1000, random_state=42)
# スタッキングアンサンブルの作成
stacking_model = StackingClassifier(
estimators=[(f'model_{i}', model) for i, model in enumerate(models)],
final_estimator=meta_model,
cv=5
)
stacking_model.fit(X_train, y_train)
return stacking_model
# クロスバリデーションでモデルの評価
def evaluate_model_cv(self, model, X, y, cv=5):
from sklearn.model_selection import cross_val_score
scores = cross_val_score(model, X, y, cv=cv, scoring='accuracy')
print(f"Cross-Validation Accuracy (cv={cv}): {scores.mean():.4f} ± {scores.std():.4f}")
def create_submission(self, model, X_test, test_ids):
y_test_pred = model.predict(X_test)
submit = pd.DataFrame({
'id': test_ids,
'Result': y_test_pred.astype(int)
})
submit.to_csv('submit.csv', index=False, header=False)
print("Submission file created: submit.csv")
感想
- 今回は様々な手法で、ミニマムな提出から精度改善につなげることを目的にした。そこは達成できたのでよかった。
- 一方で、一番重要な部分である特徴量エンジニアリングへの理解はふわふわしたままなので、書籍などから手法を学びたい。
- データ分析は手段でしかないので、仮説を立ててそれを証明する流れをもっと大切にするべきだった。行き当たりばったりの修正が多くなってしまったように思う。
今後の目標・課題
ここまで二値分類のコンペのみなので、時系列データ予測などの別のコンペに挑戦したい。また、テーブルデータが複数あるコンペもあるときいたのでSQLの勉強もしたい。
参考記事・書籍
Discussion
お話してからの行動力とスピードが圧巻でした。お疲れ様でした☕️
ありがとうございます!zenn記事出したいとはずっと思っていたのですが、もくもく会で後押しされたのが大きかったです。
今後も勉強内容などの記事を継続して出していきたいと思います!