👌

SnowflakeのNotebookでXGBoostを用いた順位予測モデルの作成

2024/12/07に公開

この記事はSnowflake Advent Calendar 2024年の8日目です。

はじめに

機械学習の分野では、予測モデルの精度を向上させるためにさまざまなアルゴリズムが使用されています。その中でも、XGBoostは人気のあるアルゴリズムの一つです。
本記事では、Snowflake NotebookでXGBoostを用いて、競艇の順位予測モデルを作成し、実際のレースでの回収率をみていきます。

1. 競艇のルール

まず、競艇の基本的なルールですが、1周600メートルのコースを3周し、スタートからゴールまでの速さを競います。各レースには6艇が出場します。
(競艇は、ネット投票の普及の影響で、ここ数年人気が上昇しているよう)

2. XGBoost

2.1 XGBoostの概要

XGBoostは、勾配ブースティング(Gradient Boosting)アルゴリズムの一種で、特に大規模なデータセットや高次元のデータに対して高い性能を発揮します。XGBoostは以下の特徴が挙げられます。

  1. 高速な学習速度と高い予測精度:XGBoostは並列処理を活用して高速に学習を行い、高い予測精度
  2. 過学習の防止:正則化(Regularization)を導入することで、過学習を防げる
  3. 柔軟性:回帰、分類、ランキングなどさまざまなタスクに対応可能
  4. 欠損値の処理:欠損値を自動的に処理する機能がある

2.2 XGBoostの技術的詳細

XGBoostは、以下の技術的な特徴を持っています
• 並列処理:XGBoostは、データの分割や木の構築などの処理を並列に実行することで、学習速度を大幅に向上させる
• ブロック構造:データをブロック単位で処理することで、メモリ効率を高め、大規模データセットに対してもスケーラブルに対応
• スパースデータの効率的な処理:スパースデータ(欠損値が多いデータ)を効率的に処理するための最適化が施されている
• カスタマイズ可能な目的関数と評価指標:ユーザーが独自の目的関数や評価指標を定義可能

3. Snowflake Notebook

3.1 Snowflake Notebookの概要

Snowflake Notebookは、データサイエンスや機械学習のプロジェクトを効率的に進めるためツールで、以下のようなメリットがあります。

  1. 統合環境:データの取り込み、前処理、モデルの学習、評価、デプロイまで一貫して行うことが可能
  2. スケーラビリティ:Snowflakeのクラウドインフラを活用することで、大規模なデータセットに対してもスケーラブルに処理可能
  3. コラボレーション:チームメンバーとリアルタイムでノートブックを共有し、共同作業が可能
  4. セキュリティ:データのセキュリティとプライバシーを確保

4. 順位予測モデルの構築

4.1 データの準備

それでは、実際にSnowflake Notebookで、XGBoostを用いた順位予測モデルを作成していきます。
公式サイトから過去の成績と、当日の出走表をダウンロードし、Snowflakeにデータを取り込みます。

4.2 データ加工

データの前処理

データの前処理は、以下の記事を参考にさせていただきました。
https://qiita.com/yyyyyy666/items/1a28cc2f84ea24d6ab4a

ライブラリのインポート

import glob
import os
import re
import pandas as pd
import xgboost as xgb
import numpy as np
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split, GridSearchCV, RepeatedKFold
from string import ascii_letters, digits

出走表情報

# ファイル一覧取得
files_race = glob.glob('race/*')
df_all_race = pd.DataFrame()

# 各ファイルを処理
for file in files_race:
    
    # ファイルを読み込み
    with open(file, encoding='shift-jis') as f:
        data = f.readlines()
        
    # スペースや改行を削除し
    data = [s.replace('\u3000', '').replace('\n', '') for s in data]
    
    half = ascii_letters + digits
    table = {c + 65248: c for c in map(ord, half)}
    data = [name.translate(table) for name in data]
    
     # ファイル名から日付を抽出
    date = os.path.basename(file)
    date = re.sub(r'\D', '', date)

    # 数値のみ抽出
    data = [row for row in data if re.match('^[0-9]', row)]
    
    # 必要な値のみを抽出
    pattern_place_re1 = re.compile('\d{2}[B][B]')
    pattern_race_num_re1 = re.compile('\d+[R]') 
    pattern_racer_re1 = re.compile('^[1-6]\s\d{4}')
    
    pattern_place_re2 = re.compile('(\d{2})[B][B]')
    pattern_race_num_re2 = re.compile('(\d+)[R]')
    pattern_racer_re2 = re.compile('^([1-6])\s(\d{4})([^0-9]+)(\d{2})([^0-9]+)(\d{2})([AB]\d{1})\s(\d.\d{2})\s*(\d+.\d{2})\s*(\d+.\d{2})\s*(\d+.\d{2})\s*(\d+)\s*(\d+.\d{2})\s*(\d+)\s*(\d+.\d{2})')
    
    values = []
    place_elm = race_num_elm = None

    for row in data:
       if re.match(pattern_place_re1, row):
           place = re.match(pattern_place_re2, row).groups()
           place_elm = place[0]
       elif re.match(pattern_race_num_re1, row):
           race_num = re.match(pattern_race_num_re2, row).groups()
           race_num_elm = race_num[0].zfill(2)
       elif re.match(pattern_racer_re1, row):
           value = re.match(pattern_racer_re2, row).groups()
           val_li = list(value) + [place_elm, race_num_elm, date]
           values.append(val_li)
    
    # データフレーム作成
    columns = ['艇番', '選手登番', '選手名', '年齢', '支部', '体重', '級別', '全国勝率', '全国2連率', '当地勝率', '当地2連率', 'モーターNO', 'モーター2連率', 'ボートNO', 'ボート2連率', '開催地', 'レース番号', '日付']
    df_race = pd.DataFrame(values, columns=columns)
    df_race['レースID'] = df_race['日付'] + df_race['開催地'] + df_race['レース番号']
    df_all_race = pd.concat([df_all_race, df_race], ignore_index=True)

df_all_race

出力結果(上位3件)

艇番 選手番号 選手名 年齢 支部 体重 級別 全国勝率 全国2連率 当地勝率 当地2連率 モーターNO モーター2連率 ボートNO ボート2連率 開催地 レース番号 日付
1 3290 倉谷和信 61 大阪 55 B1 4.60 22.86 0.00 0.00 25 27.16 38 32.94 24 01 241106
2 4463 三苫晃幸 38 福岡 54 A2 5.79 39.81 5.85 41.51 16 30.12 33 28.74 24 01 241106
3 4346 前田健太 39 福岡 56 B1 4.07 25.00 5.05 29.31 62 44.71 16 36.14 24 01 241106

レース結果データ

# ファイル一覧取得
files_result = glob.glob('result/*')
df_all_result = pd.DataFrame()

# 各ファイルを処理
for file in files_result:
    
    # ファイルを読み込み
    with open(file, encoding='shift-jis') as f:
        data = f.readlines()
    # 余分なスペースや改行を削除
    data = [s.replace('\u3000', '').replace('\n', '') for s in data]
    
    # 全角文字を半角文字に変換
    half = ascii_letters + digits
    table = {c + 65248: c for c in map(ord, half)}
    data = [name.translate(table) for name in data]
    
    # ファイル名から日付を抽出
    date = os.path.basename(file)
    date = re.sub(r'\D', '', date)
    
    # 必要なデータのみを抽出
    data = [row for row in data if re.match('[0-9]', row) or re.match('\s\s[0-9]', row) or re.match('\s\s\s[0-9]', row)]
    
    # データから必要な値を抽出
    pattern_place_re1 = re.compile('\d{2}[K][B]')
    pattern_race_num_re1 = re.compile('\s+\d+[R]')
    pattern_racer_re1 = re.compile('\s+\d+\s+[1-6]\s\d{4}')
    
    pattern_place_re2 = re.compile('(\d{2})[K][B]')
    pattern_race_num_re2 = re.compile('\s+(\d+)[R]')
    pattern_racer_re2 = re.compile('\s+(\d+)\s+[1-6]\s(\d{4})')
    
    values = []
    place_elm = race_num_elm = None
    
    for row in data:
        if re.match(pattern_place_re1, row):
            place = re.match(pattern_place_re2, row).groups()
            place_elm = place[0]
        elif re.match(pattern_race_num_re1, row):
            race_num = re.match(pattern_race_num_re2, row).groups()
            race_num_elm = race_num[0].zfill(2)
        elif re.match(pattern_racer_re1, row):
            value = re.match(pattern_racer_re2, row).groups()
            val_li = list(value) + [place_elm, race_num_elm, date]
            values.append(val_li)
    
    # データフレーム作成
    columns = ['実着順', '選手登番', '開催地', 'レース番号', '日付']
    df_result = pd.DataFrame(values, columns=columns)
    df_result['レースID'] = df_result['日付'] + df_result['開催地'] + df_result['レース番号']
    df_result = df_result[['実着順', '選手登番', 'レースID']]
    
    df_all_result = pd.concat([df_all_result, df_result], ignore_index=True)

df_all_result

出力結果(上位3件)

実着順 選手登板 レースID
01 4760 2412032301
02 3708 2412032301
03 5352 2412032301

出走表データと、レース結果データを結合

# レースID,選手登番をキーに出走表データとレース結果データを結合
df = df_all_race.merge(df_all_result, how='left', on=['レースID','選手登番'])

# 級別を数値に変換(説明変数として使用したいため)
df['等級'] = df['級別'].apply(lambda x: 1 if x=='A1' else (2 if x=='A2' else(3 if x=='B1' else 4)))

# 着順なしのデータを補正
df['実着順'] = df['実着順'].fillna('06')

# 無効レースを削除
df_del = df.groupby('レースID').count()['実着順']
df_del = df_del[df_del <= 2]
for i in df_del.index:
    df.drop(df[df['レースID'] == i].index, inplace=True)

# データ型を変換
df[['艇番', '選手登番', 'レースID']] = df[['艇番', '選手登番', 'レースID']].astype(int)
df[['全国勝率', '全国2連率', '当地勝率', '当地2連率', 'モーター2連率', 'ボート2連率']] = df[['全国勝率', '全国2連率', '当地勝率', '当地2連率', 'モーター2連率', 'ボート2連率']].astype(float)

# 不要なカラムを削除
df = df.drop(['選手名', '年齢', '支部', '体重', 'モーターNO', 'ボートNO', '開催地', 'レース番号', '日付','級別'], axis=1)

# カラム名を変更
df = df.rename(columns={
    '実着順': '着順', '選手登番': '選手番号', '艇番': '艇番',
})

# # 着順をマッピングする
class_mapping = {'01': 0, '02': 1, '03': 2, '04': 3, '05': 4, '06': 5}
df['着順'] = df['着順'].map(class_mapping)

df

出力結果(上位3件)

艇番 選手番号 全国勝率 全国2連率 当地勝率 当地2連率 モーター2連率 ボート2連率 着順 等級
1 4,760 7.28 52.78 8.71 65.71 40.32 22.56 0 1
2 5,054 4.86 25 5.38 34.48 40 34.15 5 3
3 3,708 5.2 32.82 5.15 30.77 53.06 28.92 1 3

4.3 学習

パラメータ探索

最適なパラメータを見つけるため、グリッドサーチでパラメータ探索

# 説明変数
# 説明変数
df_x = df.drop(['着順'],axis=1)

# 目的変数
df_y = df['着順']

x_train, x_test, y_train, y_test = train_test_split(df_x,df_y,random_state=1)

model = xgb.XGBClassifier()

params = { 'booster':['gbtree']
          ,'max_features':['sqrt', 'log2', 'auto', None]
          ,'random_state':[1]
          ,'objective':['multi_sofmax']
         }

gcv = GridSearchCV(estimator=model, param_grid=params, scoring='f1_micro')
gcv.fit(x_train, y_train)

print('best score: {}'.format(gcv.score(x_test, y_test)))
print('best params: {}'.format(gcv.best_params_))
print('best val score: {}'.format(gcv.best_score_))

出力結果

best score: 0.2909776617348419  
best params: {'booster': 'gbtree', 'max_features': 'sqrt', 'objective': 'multi_sofmax', 'random_state': 1}  
best val score: 0.29068721716415846

モデルの構築

こちらをもとにパラメータを設定し、モデルを構築していきます。

model=xgb.XGBClassifier(
    booster= "gbtree",
    max_features='sqrt',
    objective="multi:softmax",
    fandom_state=1
)
model.fit(x_train, y_train)

モデル評価

y_test_pred = model.predict(x_test)
print(classification_report(y_test,y_test_pred))

出力結果

precision    recall  f1-score   support  
           0       0.52      0.56      0.54      1128  
           1       0.25      0.26      0.25      1152  
           2       0.18      0.18      0.18      1094  
           3       0.22      0.19      0.20      1161  
           4       0.20      0.20      0.20      1095  
           5       0.34      0.35      0.35      1264  
    accuracy                           0.29      6894  
   macro avg       0.28      0.29      0.29      6894  
weighted avg       0.29      0.29      0.29      6894

5. 実践

今回、リアルタイムのレースに投票するのではなく、先日の鳴門G1、12レースでシミュレーションしてみました。

投票数 : 55件
的中 : 2件
回収率 : 67.27%

厳しい結果となりました。(競艇の期待値は75%だったはず、、、)

最後に

残念ながら、今回のモデルは精度は高くありませんでした。データの質、モデルの選定、ハイパーパラメータなど、まだまだ調整する必要がありそうです。
また、今回の構築したモデルでは、艇番の影響がかなり大きかったです。

今後は、リアルタイムで予測を行う機能、Streamlitを活用し、作成したモデルを使ったアプリケーションを開発していこうと思います。
目指せ、回収率100%越え!!

電通総研 データテックブログ

Discussion