Open12

競艇AI開発格闘記

ピン留めされたアイテム
西住のぶ西住のぶ

はじめに

競艇初心者の競馬AIerによる競艇AI開発の様子を記していく。
正確でない情報も載ってしまう場合や重要な情報は敢えて載せない場合があるかもしれない。

西住のぶ西住のぶ

競艇基本情報

基本的な情報をここに記す。

まずはBOAT RACE オフィシャルウェブサイトを訪問

以下の情報を得る。

  • 年中レースが開催されている
  • 競艇場は24ある
  • 同じ日にレースがある競艇場はだいたい10以上ある
  • 中央競馬と同じく1開催日で12Rは固定
  • 出場予定レーサーは6人固定
  • 最初のレースが朝、昼、夜、深夜いずれから始まるかバリエーションがある
    • 一番早いレースは8:30くらいで、一番遅いレースは22:00くらいか
  • ルーキー限定や女性限定レースもある

直前情報について

  • 展示タイム 参考
    • レース前に計測する150mを全力で走ったときのタイム
    • モーターの力が分かるかな
  • 調整体重 参考
    • 最低体重に満たない分を調整するためのおもりの重さ
  • チルト 参考
    • ボートにモーターを取り付ける角度
  • プロペラ
    • 持ちペラ制が2012年5月?に廃止 参考
      • 学習、評価データは2012年5月~で問題ないかな
    • 今はプロペラを交換したときにと書かれるだけ? 参考
  • 部品交換
    • 交換した部品が書かれる

モーター・ボートについて 参考

  • モーター・ボートは競艇場の所有物で、節ごとに抽選でレーサーに配られる
    • 新品に交換されていない限り、出走表のモーターNo、ボートNoを使って紐づけできる
  • 競艇場ごとに年1回新品に交換される
    • 今までと同じ番号でも全く別のものになる
    • 交換時期は競艇場でバラバラ
    • 当然だが、交換したばっかはモーター・ボートに関する情報量が0なため、そこをどう扱うかがポイントになるかもしれない

欠場・失格について 参考

最低限以下のことを抑えておけば良さそうか。

  • 欠場=レース前に出場が取り消される 参考
  • 失格=スタート後に失格 参考

そして以下のルールがある。

  • 欠場は舟券が返還され、失格は返還されない
  • フライングと出遅れ(スタート事故)は欠場になる
    • いずれもペナルティがある
  • 失格には選手責任と選手責任外の判定がある
    • 選手責任であればレーサーはペナルティを受ける

私見

  • AI的にはレーサーがペナルティを受けるかどうかは関係ない
    • なので、選手責任か選手責任外は気にしないでOK
    • と思いきや、選手責任は自分から負けにいっているのに対し、選手責任外は何もなければもっと良い順位になっていた可能性もあるので、データ上はなんらかプラスを入れたほうがいいのかも
  • 学習時
    • 目的変数が競走結果(着順やタイム)の場合
      • 欠場データは抜く
      • 失格データは素直に扱うならば全部負けとして入れる(タイムの場合は工夫がいる)
    • 目的変数がスタートタイミングの場合
      • フライングと出遅れのデータも入れる
  • 推論時
    • 推論タイミングで欠場が分かっているデータ以外は入れる

特払い 参考

ある舟券において、的中者がいなかった場合に払戻されるルール。
賭け金100円あたり70円が返還される。
競馬ではほとんどないのであまり気にしないが、競艇では普通に発生する。
払戻計算がめんどくさそう。

不成立 参考

以下の条件を満たしていない舟券は不成立になる。

  • 返還されていない組み合わせの内、はずれの組み合わせが存在する
  • 必要艇数がゴールしている

それぞれのパターンでどの舟券が成立するか。

  • 6艇失格
    • レース不成立、全舟券返還
  • 5艇以上欠場
    • レース不成立、全舟券返還
  • 5艇失格or欠場、内1艇以上失格
    • 単勝のみ成立
      • ゴールした艇と失格艇に関する単勝が2通り以上残り(はずれの組み合わせが存在)、1艇ゴールするため
  • 4艇欠場
    • 単勝、2連単のみ成立
      • ゴールした2艇に関する舟券のみ残り、その内はずれの組み合わせが存在するため
  • 4艇失格or欠場、内1艇以上失格
    • 拡連複、3連複、3連単が不成立
      • ゴールした2艇と失格艇に関する舟券のみ残り、3艇ゴールしないため
  • 3艇欠場
    • 拡連複、3連複が不成立
      • ゴールした3艇に関する舟券のみ残り、はずれの組み合わせがなくなるため
  • その他
    • 全舟券成立
西住のぶ西住のぶ

前づけ 参考

  • 前づけとは、待機行動の際に内側のコースを取りに行く戦法のこと
    • メリット
      • 枠順より内側のコースでスタートでき、第一コーナーのポジションが有利になる
    • デメリット
      • 助走が短くなる
  • 新人レーサーとB2級レーサーは6コースに進入する暗黙のルールがある
  • 前づけを生業とするイン屋と呼ばれるレーサーがいる
  • 最近は枠なりが主流で、前づけするのはほぼベテランレーサーだそう(なぜ?)
西住のぶ西住のぶ

競艇データ情報

競艇データに関する情報をここに記す。

まずはデータソースの確認

競馬におけるnetkeiba、JRA-VAN DataLab、JRDBのようなポジションのサービスはなんだろうか。

  • BOAT RACE オフィシャルウェブサイト
    • 競馬では公式サイトを活用することは考えなかったが、競艇はこれでほぼ良い気がする
    • JRA-VAN DataLabのような公式のデータ配信サービスはないらしい
  • 競艇オッズ保管庫
    • すでに取得できない過去の締切前オッズを閲覧できる
    • 善意で貴重なデータを公開している個人サイトなのでスクレイピング厳禁(BOAT RACE オフィシャルウェブサイトなら自由にスクレイピングしていいという意味ではない)
    • BOAT RACE オフィシャルウェブサイトの「データを調べる」の「オッズ情報・結果」から取ってきているようで(推測)、999.9倍までしか表示されない

BOAT RACE オフィシャルウェブサイトについて

  • 2種類のデータ閲覧方法がある
    1. 「レース情報を見る」
      • 「本日のレース」
        • ウェブページで番組や結果を閲覧できるが、過去は10年しか遡れない(正確には10年前の同月より前は見れなくなる)
        • 各レースページのURLはhttps://www.boatrace.jp/owpc/pc/race/racelist?rno=1&jcd=05&hd=20241021のような感じ
          • rno=1はレース番号、jcd=05は競艇場コード、hd=20241021は日付
          • racelistは出走表のページ、odds3tに変えることで三連単オッズのページに遷移するという感じ
        • 直前情報も含めて(締切前オッズ以外の)公式データが全部手に入るが、大量のレースについて複数ページに渡るスクレイピングが必要で、10年以上前のデータは手に入らない
        • 「直前情報」
          • 気象情報の表示にクセがある
          • 1Rはリアルタイムのデータ(「16:20現在」というような)が表示され、それ以外は前のレースのデータ(「1R時点」というような)が表示される
          • つまり、後からさかのぼってデータを取得する場合、1Rはその日の最後に測定したデータが表示されており、使い物にならない
            • ちゃんとやるなら、1Rのデータだけリアルタイムにスクレイピングする必要があるが、学習データとして貯まるまで時間がかかるし、労力の割に影響が小さいので無視する(欠損データとする)
          • 他は、前のレースのデータのままなので使えるが、「競走成績ダウンロード」でも取れるので、過去データを取得する目的では「直前情報」から取らなくてもよい
            • ただ、気温・水温・競艇場に対する風向は「直前情報」でしか取れない
          • 当日の推論用には「直前情報」から取る必要がある
      • 「月間スケジュール」
        • 文字通り何日にどの競艇場で開催されるか月間スケジュールが分かる
        • なくてもなんとかなるので必須ではない
        • 「本日のレース」と同じで10年以上前のデータは手に入らない
    2. 「データを調べる」
      • 「ダウンロード・他」の「各種ダウンロード」
        • 番組や結果をダウンロードでき、1996年7月からのデータがある
        • 例えば番組表ダウンロードのように、年月と日を選択し「ダウンロード開始」を押せば、その日の全競艇場の全レースの番組データがテキストファイル1つに入ってダウンロードされる
        • かなり古いデータから最新まで、少ないスクレイピング処理で取得できるが、テキストファイルのフォーマットを読み解き情報を構造化しなければいけない上、直前情報など取れないデータが存在する
        • 「オッズ情報・結果」は、999.9倍以上のオッズがつく場合、情報が欠損する(999.9までしか表示されない)

データソースの結論

方針として、過不足なく取れるデータは「各種ダウンロード」で取り、どうしようもないデータは「本日のレース」で取る。
締切前オッズはそれでもどうしようもないため、諦めて取得開始以降のデータのみを使う。評価期間分だけあればいいのと、競艇はレース数が多く取得期間が短くても評価レース数が稼げるのでなんとかなるはず。
また、持ちプラ制が廃止されたのが2012年なので、雑に考えて学習データは2013年以降あればいいとする。「本日のレース」では10年前以降しか取れないが、執筆日の2024年から10年さかのぼって2014年なので必要な分はほぼ取れていると思う。

  • 番組表
    • 「各種ダウンロード」で取る、1996年7月~
  • オッズ
    • 締切時
      • 「本日のレース」で取る、10年前~
    • 締切前
      • 「本日のレース」でリアルタイムに取る、取得開始以降~
  • 直前情報
    • 「本日のレース」で取る、10年前~
    • 展示タイム、2R以降の天気、風、波高は「各種ダウンロード」でも取れる、1996年7月~
  • 結果
    • 「各種ダウンロード」で取る、1996年7月~
  • 払戻
    • 「各種ダウンロード」で取る、1996年7月~
  • 開催スケジュール
    • 「月間スケジュール」で取る、10年前~

「各種ダウンロード」のスクレイピングとパース

  • スクレイピングはそんなに難しくないので普通にやるだけ
  • パースの方はかなりめんどくさいがなんとかした
    • まず、文字コード周りがめんどくさい
      • 基本的にはcp932でデコードできるが、ところどころ旧漢字というのかな、人名に含まれる漢字が恐らくcp932に含まれておらずデコードに失敗するので、そういったコードを先に消しておく
      • 他にも謎のコードの文字が入っていたりするので丁寧に消してやる
      • 後は、ファイル内改行コードが\r\nなのか\rなのか\nなのかバラバラだったりするので、これも丁寧に揃えてやる
    • デコードができればようやくパース作業に入るが、これまためんどくさい
      何かのサイトを参考にして改変した気がするが、思い出せないため省略
      • 「番組表ダウンロード」
        • こっちはまだましか
        • 1行のうち何文字目から何文字目までが何のデータかが決まっているので、motorNumber = int(row[40: 43])という具合で拾う
      • 「競走成績ダウンロード」
        • 基本的には「番組表ダウンロード」と同様に、racerNumber = row[8: 12]という具合で拾うのだが色々うまくいかないところがあった気がする(うろ覚え)
          • 例えばレースタイムがないところは、_.__._のはずなんだけど、_.__.になってたり(最後の半角スペースがあるかないか)
            半角スペースをアンダーバーで置き換えている
          • 欠場の場合の、展示からレースタイムまでの文字列における文字の位置がずれていたり(K_.______0__K_0.00_____0.00.0K_._____0___K0.00_____0.00.0の違い)
          • 欠場の場合の、展示タイムの表記が様々だったり(_.______K.__)
        • レースが中止になった場合やまだデータが登録されていない場合の例外処理も必要
        • さらに、払戻部分のパースをしなければならない
          • 基本的には一定のルールに見えるが、特払い、不成立、同着に注意する必要があり普通にめんどくさい
    • めんどうな箇所は古めなデータに多かった記憶があるので、新しめなデータに限ってパースすればましかもしれない
    • 大昔(1996年)から同じフォーマットで配信しているので、このようなテキストファイルになっているのは理解できる
      新しく構造化データで配信するならそれこそJRA-VANのようにサービス化しないと難しいだろうし、無料でそれを配る義理もない
      現状、公式から無料でこれだけのデータ手に入るのだから感謝した方がいいのだろう
西住のぶ西住のぶ

開催節のユニークID作成

同じ競艇場で連続で4~6日くらい開催があり、それをまとめて節と言う。 参考
節を区別できるユニークな文字列が番組表データに見当たらないので、自動で作成する。

前の節と次の節の間に必ず休みがある(ホント?)ことを利用して、1日以上挟んだ開催日同士は別の節であると認識し、ユニークなID(初日の日付と競艇場コードの結合)を与える。

# dfは['yearMonthDay', 'raceFieldCode']を列として持つ。
df = (
    df.with_columns(  # yearMonthDayは'20241023'のような文字列、それをpl.Date型にする
        pl.col('yearMonthDay').str.strptime(pl.Date, '%Y%m%d').alias('date'),
    )
    .with_columns(  # 競艇場ごとに(raceFieldCode)前のレコードとの日付の差を計算する
        pl.col('date').diff().over('raceFieldCode').alias('date_diff'),
    )
    .with_columns(  # 日付の差が1日より大きかったら初日
        (pl.col('date_diff')>datetime.timedelta(days=1)).fill_null(True).alias('is_firstDay'),
    )
    .with_columns(  # 初日の場合ユニークなID(初日の日付と競艇場コードの結合)を与え、fill_nullによって初日以降もそのIDで埋める
        pl.when(pl.col('is_firstDay')).then(pl.col('yearMonthDay') + pl.col('raceFieldCode').cast(pl.Utf8).str.zfill(2))
        .fill_null(strategy='forward').over('raceFieldCode').alias('hold')
    )
)
西住のぶ西住のぶ

モーター・ボートのユニークIDの作成

モーターNoとボートNoはあるが、1年に一回の新品交換後も同じNoが使われてしまうため、ユニークではない。
モーター毎の成績を集計したい場合にユニークなIDが必要になる。

新品のモーターは初陣にて番組表に表示される2連率等の成績が0になる。しかし、初陣から負け続けた場合も2連率等の成績が0になるので、これだけでは初陣の判定はできない。
「現在の2連率等の成績が0」かつ「同じNoのモーターの前走にて番組表に表示される2連率等の成績が0でない」場合、もしくは「同じNoのモーターの前走が存在しない」場合、そのモーターは新品である。
1年通して負け続けたモーターは存在しないと仮定している。
3連率を使った方がより安全なのに以下のコードでは2連率を使っている理由は、「番組表ダウンロード」で取ったデータには2連率しか含まれていないからである。
初陣が分かったら、開催節IDとモーターNoの結合をユニークなIDとして与える。

# dfは['raceId', 'hold', 'raceFieldCode', 'bracketNumber', 'motorNumber', 'motorShowPercentage', 'boatNumber', 'boatShowPercentage']を列として持つ。
motor_df = (
    df.with_columns(  # そのモーターの前走開始時点の2連率
        pl.col('motorShowPercentage').shift(1).over(['raceFieldCode', 'motorNumber']).alias('motorShowPercentage_shift1'),
    )
    .unique(['hold', 'raceFieldCode', 'motorNumber'], keep='first', maintain_order=True)  # 開催初日のデータだけ残す
    .filter(  # 現在の2連率が0 & (前走開始時点の2連率が0でない | 前走開始時点の2連率が存在しない) という条件のデータのみ残す、これが新品のモーター
        (pl.col('motorShowPercentage')==0) &
        ((pl.col('motorShowPercentage_shift1')!=0) | pl.col('motorShowPercentage_shift1').is_null())
    )
    .with_columns(  # ユニークなID(開催節IDとモーターNoの結合)を与える
        (pl.col('hold') + pl.col('motorNumber').cast(pl.Utf8).str.zfill(3)).alias('motorNumberUnique'),
    )
)
boat_df = (  # モーターと同様
    df.with_columns([
        pl.col('boatShowPercentage').shift(1).over(['raceFieldCode', 'boatNumber']).alias('boatShowPercentage_shift1'),
    ])
    .unique(['hold', 'raceFieldCode', 'boatNumber'], keep='first', maintain_order=True)
    .filter(
        (pl.col('boatShowPercentage')==0) &
        ((pl.col('boatShowPercentage_shift1')!=0) | pl.col('boatShowPercentage_shift1').is_null())
    )
    .with_columns([
        (pl.col('hold') + pl.col('boatNumber').cast(pl.Utf8).str.zfill(3)).alias('boatNumberUnique'),
    ])
)
df = (  # 新品のモーター・ボートのユニークIDを初陣の行に結合し、fill_nullによって初陣以降もそのIDで埋める
    df.join(motor_df.select(['raceId', 'bracketNumber', 'motorNumberUnique']), on=['raceId', 'bracketNumber'], how='left')
    .join(boat_df.select(['raceId', 'bracketNumber', 'boatNumberUnique']), on=['raceId', 'bracketNumber'], how='left')
    .with_columns([
        pl.col('motorNumberUnique').fill_null(strategy='forward').over(['raceFieldCode', 'motorNumber']).alias('motorNumberUnique'),
        pl.col('boatNumberUnique').fill_null(strategy='forward').over(['raceFieldCode', 'boatNumber']).alias('boatNumberUnique'),
    ])
)
西住のぶ西住のぶ

「直前情報」の更新時間

自分の目で確認してみた更新時間の目安。あくまでそのときそのレースはその時間に更新されていたというだけで、その時間までに必ず更新されるわけではない。

  • 部品交換
    • 40分前には更新済
  • チルト
    • 25分前には更新済
  • 展示タイム、スタート展示
    • 15分前には更新済
西住のぶ西住のぶ

レーサーの体重と調整重量 参考

  • デイレースの場合は、当日の朝に体重測定を行う
    • 遅い時間のレースになるとどんどん減っていくだろう
  • 最低体重に満たない分を重りで調整する

データソースごとの体重の違い

  • 「番組表ダウンロード」
    • 前日以前の情報だし、小数点以下が丸められているので、基本的に使わなくてよい
  • 「本日のレース」
    • 「出走表」
      • これはどうなんだろう
      • 最新の計測結果がすぐに反映されているのだろうか
      • 軽く見た範囲では「直前情報」とずれはなかったが
    • 「直前情報」
      • もちろん最新の情報である
      • これを使っておけばよい
西住のぶ西住のぶ

競艇考察

思いつきで考えたことを記す。

西住のぶ西住のぶ

スタート展示と本番の関係

  • 202410252101
    • スタート展示は123564で、本番も123564
    • スタート展示で取ったコースを本番でも取るパターン
      • 普通に考えたら、スタート展示でインコースを取りに来たら、本番警戒されるのでは?
        • なぜ同じコースが取れるのか?
          • アウトコースにいったボートのモーターが貧弱説
          • アウトコースにいったレーサーのピットアウトがうまくない説
          • インコースを取ったレーサーのピットアウトがうまい説
          • 敢えてアウトコースにいって助走距離を稼ぐ説
          • 養成所を卒業してデビューしたてのレーサーは、枠番に関係なく6コースに回るのが暗黙のルールだそう(このレースはこれか?) 参考
        • インコースを取ろうとしているレーサーはなぜスタート展示でもインコースを取るのか?他のレーサーにばれるのでは?
          • ばれるのを承知で練習している説
          • 有名すぎて隠す必要がない説
  • 202410250105
    • スタート展示は124653で、本番は123456
    • スタート展示で取ったコースは関係なく、枠なりで進入するパターン
      • スタート展示を踏まえて警戒され、枠なりになってしまった説
      • スタート展示はブラフで、普通に枠なりに進入する予定だった説
  • 202410252104
    • 進入固定というのもあるらしい
西住のぶ西住のぶ

競艇AIのインフラ

競艇AIを運用する際のインフラについて記す。
あくまで自分がやるもしくはやる予定の方法について書いているだけであって、他にも考え方はたくさんある。

オンプレかクラウドか

もし全く初心者だったら、ランニングコストなし(PCは既にあって、電気代も無視するとする)のオンプレでやっていたと思う。
ランニングコストがかかる環境で、目的を達成できるかどうか分からないものを動かし続けるのは精神的にキツいから。

自分の場合は以下の理由で競艇AIはクラウドで運用することにした。

  • 競馬AIでの経験から、開発から運用へ移行する際のギャップを0にするノウハウが貯まっており、ゴミ戦略を運用してしまう心配がない
    • 良いバックテストの戦略ができるまでオンプレで開発し、その戦略を実運用に移行しても同性能が出せる
  • 競艇は毎日開催されており、オンプレPCが休まらない
    • ゲーム等の趣味PCでもあるので、毎日8時から21時まで使えないのはキツい
  • 既に金土日で中央競馬用のジョブが走っている
    • 同時に競艇用のジョブを走らせる場合、リソースの配分がめんどうだし、どちらかの失敗がもう一方にも影響を与えるリスクをはらむ

どのクラウドベンダを選択するか

個人的にAWS一択である。
AWS、GCP、Azureを徹底的に比較した上での選択という訳ではなく、AWSの知識がちょびっとあるため、他のクラウドを触る気力がないだけである。
大規模なシステムを組む訳ではないので、クラウドベンダ間の多少のコストの差は気にしないことにする。
AWSの範囲内でできるだけ効率の良い方法を探す。

西住のぶ西住のぶ

コンピューティングサービス

どの種類のジョブを実行するかによって、どのサービスを利用するのが適切かということは変化する。
競艇AIシステムの中にどんなジョブがあるかは人それぞれだろうが、自分のところでは、大きな区分として以下の2つがある。

  • 前日ジョブ
    • 開催前日に実行され、前日時点データのダウンロードや前日予測を行う
  • 当日ジョブ
    • リアルタイムのデータダウンロード、いつ予測していつ購入するかを管理するジョブスケジューリング、各レースの予測、各レースの購入

両者の特徴を考えてみると、使うべきサービスは以下のようになるだろう。

  • 前日ジョブ
    • 実行時間的に余裕がある
      • Dockerコンテナを立ち上げる余裕がある
      • データを転送する余裕がある
    • 失敗してもいい
      • ジョブが途中でこけても修正して再実行すればよい(監視は必要)
    • サーバーレスでインフラ管理が必要なく、コストが安いコンピューティングサービスが適しているだろう
    • 【結論】Fargate
  • 当日ジョブ
    • 予測や購入ジョブなど、すぐに処理が始まる必要がある
      • Dockerコンテナは常に立ち上がってなければならない
      • データはすぐにアクセス可能になってなければならない
    • 失敗してはいけない
      • インスタンスに何らかの問題が発生したら、できれば他のインスタンスに引き継ぎたい
    • 常にサーバーとその中にDockerコンテナが立っていて、データもすぐにアクセスできるコンピューティングサービスが適しているだろう
    • 【結論1】普通にEC2
    • 【結論2】EC2 Auto Scaling
      • インスタンスの異常を検知し、インスタンスを削除して、代わりに新しいインスタンスを起動できるらしい