2023有馬記念を予想したかった男の備忘録

# はじめに
2023年の有馬記念は、単勝オッズが一桁台の馬が7頭にも上るなど、近年稀に見る混戦模様であり、予想を行うのが非常に難しいレースとなっていた。
そんなレースの予想は何か「わかりやすい正解」を出してくれそうなものに頼りたい、ということで競馬予想AIを作り、結果を予測してみることとした。
この記事では、用いた手法や、競馬予想AIを作成していく中で見えた課題などについて記述していく。

# 手順
- スクレイピングにより必要なデータを収集する
- 集めたデータを学習できる形に整形する
- 学習を行い予測モデルを作成する
- 実際に予測を行う

# 環境
- ubuntu 20.04 LTS (wsl2)
- python 3.10.9

# 実際にやってみる
## スクレイピングによるデータ収集
スクレイピングは[netkeiba](https://www.netkeiba.com/)から行った。有馬記念の予測が出来ればそれでよかったため、中山競馬場のレース結果データ、馬の過去成績データの取得を行い、その中で芝2500mで行われたレースのデータを利用した。
コードについては[この記事](https://zenn.dev/dijzpeb/books/848d4d8e47001193f3fb/viewer/02_scraping)にあるものをほぼそのまま使用したので記述しない。
スクレイピングはなるべくサーバへの負荷へとならないように、一定の間隔を開けるなどして行う。

## データの整形
データの整形は以下のようなコードで行った。
``` python
#目的変数の設定
def func(x):
    try:
        return int(x)
    except:
        return None
    
#データの整形
def func2(x):
    try:
        return 0.8/float(x)
    except:
        return None
    
def func3(x):
    try:
        weight = int(x.split("(")[0])
    except ValueError:
        weight = None
    return weight

def func4(x):
    try:
        weight_dif = int(x.split("(")[1][:-1])
    except ValueError:
        weight_dif = None
    except:
        weight_dif = None
    return weight_dif

#ラベルエンコーディング
def make_dic(df, key):
    labels = sorted(df[key].unique())
    dic_labels = {}
    for i in range(len(labels)):
        dic_labels[labels[i]]=i
    print(dic_labels)
    return dic_labels

def func5(x):
    try:
        return float(str(x).split('-')[-1])
    except:
        return None
    
def func6(x):
    try:
        return float(str(x).split('-')[-2])
    except:
        return None
    
def func7(x):
    try:
        return float(str(x).split('-')[-3])
    except:
        return None
    
def func8(x):
    try:
        return float(str(x).split('-')[-4])
    except:
        return None

def func9(x):
    try:
        return float(str(x).split('-')[1])
    except:
        return None
    
#hourse_resultを使った特徴量の追加
def average_price(horse_id_list, horse_results, date):
    target_df = horse_results.loc[horse_id_list]
    target_df["日付"] = target_df["日付"].map(lambda x: pd.to_datetime(x))
    target_df["着順_avg"] = target_df["着 順"].map(func)
    target_df["賞金_sum"] = target_df["賞金"]
    target_df = target_df[target_df["日付"] < date].sort_values('日付', ascending=False).groupby(level=0).head(5)
    avg_rnk = target_df[target_df["日付"] < date].groupby(level=0)[['着順_avg']].mean()
    sum_price = target_df[target_df["日付"] < date].groupby(level=0)[['賞金_sum']].sum()
    target_df = avg_rnk.merge(sum_price, left_index=True, right_index=True, how='left')
    return target_df
    
def average_turn(horse_id_list, horse_results, date):
    target_df = horse_results.loc[horse_id_list]
    target_df["日付"] = target_df["日付"].map(lambda x: pd.to_datetime(x))
    target_df = target_df[target_df["日付"] < date].sort_values('日付', ascending=False).groupby(level=0).head(5)
    target_df['通過-1'] = target_df['通過'].map(func8)
    target_df['通過-2'] = target_df['通過'].map(func7)
    target_df['通過-3'] = target_df['通過'].map(func6)
    target_df['通過-4'] = target_df['通過'].map(func5)
    target_df['ペース終い'] = target_df["ペース"].map(func9)
    target_df["上り"] = target_df["上り"].map(func)
    target_df.dropna(subset=["上り", "ペース終い"], inplace=True)
    target_df = target_df[~target_df["距離"].str.startswith('障')]
    target_df['上り比較_avg'] = target_df["ペース終い"] - target_df["上り"]
    target_df = target_df[target_df["日付"] < date].groupby(level=0)[['通過-1', '通過-2', '通過-3', '通過-4', '上り比較_avg']].mean()
    return target_df

def merge(results, horse_results, date):
    df = results[results["date"] == date]
    horse_id_list = df["horse_id"].unique()
    target_df = average_price(horse_id_list, horse_results, date)
    merge_df = df.merge(target_df, left_on='horse_id', right_index=True, how='left')
    target_df = average_turn(horse_id_list, horse_results, date)
    merge_df = merge_df.merge(target_df, left_on='horse_id', right_index=True, how='left')
    return merge_df

def merge_all(results, horse_results):
    date_list = results['date'].unique()
    merge_df = pd.concat([merge(results, horse_results, date) for date in tqdm(date_list)])
    return merge_df

#データのコピー
results_c = results.copy()
horse_results_c = horse_results.copy()

#データ抽出
results_c =results_c[results_c['race_type'] == '芝']
results_c =results_c[results_c['course_len'] == 2500]

#整形
results_c['着順'] = results_c['着順'].map(func)
results_c['target'] = results_c['着順'].map(lambda x: 1 if x <= 3 else 0)
results_c['単勝'] = results_c['単勝'].map(func2)
results_c['体重'] = results_c["馬体重"].map(func3)
results_c['体重変化'] = results_c["馬体重"].map(func4)

results_c['性'] = results_c['性齢'].map(lambda x: x[0])
results_c['齢'] = results_c['性齢'].map(lambda x: int(x[1:]))

results_c["date"] = results_c["date"].map(lambda x: pd.to_datetime(x.replace("年", "-").replace("月", "-").replace("日", "")))
dic_labels = make_dic(results_c, "性")
results_c['性'] = results_c['性'].map(dic_labels) 
dic_labels = make_dic(results_c, "ground_state")
results_c['ground_state'] = results_c['ground_state'].map(dic_labels) 
dic_labels = make_dic(results_c, "weather")
results_c['weather'] = results_c['weather'].map(dic_labels) 
dic_labels = make_dic(results_c, "騎手")
results_c['騎手'] = results_c['騎手'].map(dic_labels) 

merge_results = merge_all(results_c, horse_results_c)

merge_results['賞金_sum'] = merge_results['賞金_sum'].fillna(0)

tmp = merge_results.groupby(level=0)[['通過-1', '通過-2', '通過-3', '通過-4', '上り比較_avg', '賞金_sum', '着順_avg']].mean()
tmp = tmp.rename(columns={'通過-1': '通過-1_raceavg', '通過-2': '通過-2_raceavg', '通過-3': '通過-3_raceavg', 
                          '通過-4': '通過-4_raceavg', '上り比較_avg': '上り比較_raceavg', '賞金_sum': '賞金_racesum', '着順_avg': '着順_raceavg'})
merge_results = merge_results.merge(tmp, left_index=True, right_index=True, how='left')

merge_results['通過-1'] /= merge_results['通過-1_raceavg']
merge_results['通過-2'] /= merge_results['通過-2_raceavg']
merge_results['通過-3'] /= merge_results['通過-3_raceavg']
merge_results['通過-4'] /= merge_results['通過-4_raceavg']
merge_results['賞金_sum'] /= merge_results['賞金_racesum']
merge_results['上り比較_avg'] -= merge_results['上り比較_raceavg']
merge_results['着順_avg'] /= merge_results['着順_raceavg']

#不要列の削除
merge_results.drop(['単勝', '着順', '馬名', '性齢', 'タイム', '着差', '人気',
       '体重', '体重変化', '馬体重', '調教師', 'course_len', 'race_type', 'weather', 'ground_state', 
       'date', 'horse_id', 'jockey_id', '通過-1_raceavg', '通過-2_raceavg', 
       '通過-3_raceavg', '通過-4_raceavg', '賞金_racesum', '上り比較_raceavg', '着順_raceavg'], axis=1, inplace=True)

merge_results.dropna(inplace=True)
```
整形が必要なデータは、まず下記のテーブルの"騎手"の列のように数値で表現できないデータで、今回はこれに"柴田善臣"=0, "戸崎圭太"=1,...などのように値ごとに別の数値を割り振っている（ラベルエンコーディング）。
![スクリーンショット 2024-01-09 000323.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3669896/998d91b7-34f8-4880-ac98-77923f6547fc.png)
また、下のテーブルは馬の過去成績のデータを示したテーブルである。ここにある"通過"や"ペース"などは数値を取り出すために加工が必要である。ハイフンを区切り文字として数値を取り出すなどする。
![スクリーンショット 2024-01-09 002337.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3669896/7e3fac0f-73f6-7cc3-f9af-b7d92ba3feac.png)
他に、馬の過去成績については直近過去5走の賞金合計を算出し、レースに出走する馬の平均値と比較するなど、相対的な数値になるような整形を行った。

## 予測モデルの作成
今回予測モデルの作成には非ディープラーニング系の中で精度が高く、処理が高速であるLightGBMを用いた。
### データの分割
今回は3着以内を1とする二値分類であるため、不均衡データになると思われる。そのため、アンダーサンプリングを行った。
``` python
merge_results_t = merge_results.copy()

# 目的変数のデータを抽出
y = merge_results['target']

merge_results_t.drop(['target'], axis=1, inplace=True)
# 説明変数のデータを抽出
X = merge_results_t

# 列名取得
X_cols = X.columns.to_list()

# 学習データと評価データに分割(7:3)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, stratify=y, random_state=0) 

#アンダーサンプリング
rus = RandomUnderSampler(sampling_strategy=0.5, random_state=0)
X_train, y_train = rus.fit_resample(X_train, y_train)
```

### パラメータの調整
Optunaを用いてLightGBMのハイパーパラメータをチューニングした。
```python
def objective(trial):
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_test = lgb.Dataset(X_test, y_test, reference=lgb_train)
    
    param = {
        'task': 'train',
        'boosting_type': 'gbdt',
        'objective': 'binary',
        'metric': {'binary_logloss'},
        'lambda_l1'         : trial.suggest_float('lambda_l1', 1e-8, 10.0, log=True),
        'lambda_l2'         : trial.suggest_float('lambda_l2', 1e-8, 10.0, log=True),
        'learning_rate': trial.suggest_float('learning_rate', 0.1, 1.0),
        'num_leaves': trial.suggest_int('num_leaves', 2, 256),
        'feature_fraction': trial.suggest_float('feature_fraction', 0.4, 1.0),
        'bagging_fraction': trial.suggest_float('bagging_fraction', 0.4, 1.0),
        'bagging_freq': trial.suggest_int('bagging_freq', 1, 7),
        'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
        'force_col_wise':True,
        'random_state': 0,
        }
    
    model = lgb.train(
                    params=param,
                    train_set=lgb_train,              
                    valid_sets=[lgb_train, lgb_test], 
                    valid_names=['Train', 'Test'],    
                    num_boost_round=100,
                    callbacks=[lgb.early_stopping(stopping_rounds=50, verbose=1),
                               lgb.log_evaluation(10),], 
                    )
    # 推論
    pred = model.predict(X_test, num_iteration=model.best_iteration)
    y_pred = np.where(pred < 0.5, 0, 1)
    # 評価
    score = fbeta_score(y_test, y_pred, average='binary', beta=0.5)
    return score
 
study = optuna.create_study(direction='maximize',sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=200)

print("=======ベストパラメータ========")
print(study.best_params)

lgb_train = lgb.Dataset(X_train, y_train)
lgb_test = lgb.Dataset(X_test, y_test, reference=lgb_train)

param = {
    'task': 'train',
    'boosting_type': 'gbdt',
    'objective': 'binary',
    'metric': {'binary_logloss'},
    'force_col_wise':True,
    'random_state': 0,
    }
param.update(study.best_params)

clf_lgb_best = lgb.train(
                params=param,
                train_set=lgb_train,
                valid_sets=[lgb_train, lgb_test], 
                valid_names=['Train', 'Test'], 
                num_boost_round=100,
                callbacks=[lgb.early_stopping(stopping_rounds=50, verbose=1),
                           lgb.log_evaluation(10),],  
                )
```
ハイパーパラメータをチューニングする前と後で精度については以下のように変化した。
```
precision recall  f1-score
0.462     0.477     0.469
↓
0.536     0.579     0.557
```

## 実際に予測してみる
ある程度形になったので実際に2023有馬記念のデータを用いて予測を行ってみた。予測結果を画像にまとめると以下の表のようになった。予測確率をそのまま3着以内に入る確率と考えて回収率の期待値も出そうとしてみたが、12/16頭が低い方でも1を超えるというおかしな事態となってしまった。馬一頭ごとに3着以内に入れるかを予測しているため、対戦相手との比較が十分に出来ておらず、このようになってしまったと思われる。
![out.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3669896/310a0d75-cf7b-41ef-4d66-deac76b1b2d3.png)
予測が1となった馬3頭の三連複を買ってみたが3着以内に入ったのは一頭のみで
外れ。実際のレース結果は[こちら](https://race.netkeiba.com/race/result.html?race_id=202306050811)。不利と言われていた大外枠に入ったスターズオンアースが3着以内に入るのは正しく予測できているが、1着だったドウデュースは予測確率最下位...。なかなか難しいなぁと感じる結果となってしまった。


# 注意・改善点
実際に予測を行った際に注意、もしくは改善すべきと思った点について記述する。
### ページをそのままの形で保存すること
スクレイピングを行うと、このようなテーブルデータがファイルとして保存されることになるが、後になって、このテーブル存在しないようなデータが欲しいということがあった。
![スクリーンショット 2024-01-08 233149.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3669896/9c2440b5-8318-85f0-216b-c366c0058f8e.png)
今回のように[netkeiba](https://www.netkeiba.com/)から多くのデータを取得する場合、スクレイピングには非常に長い時間がかかる。これを再度行うのはかなり面倒であるため、後からデータの不足などが発生することに備えて、とりあえずはページをそのままの形(htmlなど)で保存し、後から保存したファイルを利用してデータの取得を改めて行うなどすべきである。

### 欠損値・外れ値の対応
今回は時間がなかったのもあってどちらも適切に行えていない。精度を上げるためには対応が必要そう。OCSVMなどを使うことを検討してもいいかもしれない。

### 前処理の方法の検討
"騎手名"のようなカテゴリ変数はラベルエンコーディングで処理したが、カテゴリ変数の処理としてはワンホットエンコーディングなどラベルエンコーディング以外にも手法が考えられるため、どの手法が適切か検討が必要そう。

### 単純なミス
特徴量として"騎手"を採用したが、同じような意味を持つデータとして"jockey_id"があった。端的に言えば特徴量には"騎手"ではなく、"jockey_id"を採用すべきだったという話だ。
学習のためのデータ収集には、netkeibaの"データベース"から情報を取得するが、実際に予測を行う際はnetkeibaの"レース"のページから当日レースに出る馬についての情報を取得する。ここで、[データベース](https://db.netkeiba.com/race/202306050811)と[レース](https://race.netkeiba.com/race/shutuba.html?race_id=202306050811)のページの騎手欄を見比べるとレースのページのみで一部名前が省略されている騎手が見て取れる。
このようなページの違いから、レースページから"騎手"を特徴量として取り出す際に少し面倒な処理を必要とした。
なのでどちらのページでも同じ数値となる"jockey_id"を特徴量として採用すべきだったという話だ。今回は有馬記念のみの予測だったため大事には至らなかったが、これが継続的に予測をしようとなるとかなり面倒になったこと請け合いである。もっと慎重に扱うデータについて考えた上で作業を開始すべきだった。

### 特徴量の吟味
扱う特徴量についてもう少し考えなきゃ駄目だなぁという話。パッと思いつく限りでも持ちタイムなどの特徴量は採用してみたい。また、過去成績のデータについては過去5走の平均値や合計値などを元に特徴量としたが、加工せずにそのままの形で特徴量としたらどうなるか、どの馬に勝利した馬であるかなどの馬同士の力関係を用いた特徴量を考えるなど、試せることはまだあるように思える。

### 学習データの分割
今回使ったデータ数が少なかったこともあり行えなかったが、本来学習の際にはデータは学習用、検証用、テスト用の三つのデータに分けるのが一般的なように見えた。特に今回はパラメータをチューニングしているのもあり、少し不適切な形になってしまったように思える。

### 買い目を考える
これは目的変数をどうするかという話にもなるが、ちゃんとAIとして運用するならば予測をしたとてその後どう買うかまで考えなければならない。同じ馬を本命にしても買い方次第で勝ち負けが大きく分かれるのが競馬なので、ここも力を入れるポイントだろう。

### chatgptくんにもっと頼る
コードを書く上でわからない部分について聞くなど、chatgptくんには大変お世話になった。~~しかし最初から全部chatgptくんに頼った方がもっときれいなコードになった気もする。~~

# まとめ
というわけでかなり雑ではあるが、有馬記念について予測をしてみた記録についてまとめた。時間、知識の不足により不十分なところが多すぎるので、勉強をしてまた挑戦してみたい。

# 参考
下記のサイトは無知な筆者が競馬の予測を行うにあたり大変お世話になりました。

https://zenn.dev/dijzpeb/books/848d4d8e47001193f3fb

https://aissblog.com/category/%e7%ab%b6%e9%a6%acai%e9%96%8b%e7%99%ba

https://nlab-notebook.com/entry/optuna_lgb_v1