🏇

ウマ娘にハマったので35年分のレース結果をPlotlyで散布図にした

commits40 min read 6
  • 2021-11-14,下記のコメントによりクラスターの謎が解けました!ありがとうございます!(ソースコードに補正式を入れ,散布図を全て修正する予定です)
    https://zenn.dev/link/comments/caaa54ef2c79cd
  • アニメウマ娘にハマったので,35年分の重賞[1]のレース結果をnetkeibaから取得した
  • Plotlyでインタラクティブな散布図を描き,馬・タイトル・適性毎に分布を見てニヤニヤした
  • 散布図に謎のクラスターが生じたが,1993年頃までの上りタイムの定義がわからず,原因解明に至らなかった 1993年頃まで一部のレースで上りタイムの計測方法が異なっていたことが原因と考えられる
  • (長い記事なので,YouTubeの字幕をONにしてデモだけでもご覧頂けますと幸甚です)

https://youtu.be/fBI9aKwAhfM

https://youtu.be/tSBXADm7pv8

https://github.com/kakeami/keiba-eda-public

はじめに

ウマ娘プリティーダービー(以下,ウマ娘)とはCygamesによるスマホ向けゲームを中心とするとメディアミックスコンテンツ[2]です.テレビアニメは2018年4月から6月まで第1期,2021年1月から3月まで第2期が放送されました.私は当時それどころではなかったこともありリアルタイムで視聴できませんでしたが,のちほど全話視聴し,見事にハマってしまいました.

ウマ娘の素晴しい点の一つは,競馬へのただならぬ愛情とリスペクト[3][4]です.競走馬の外観や体格(やときにはジョッキーの勝負服)を反映したキャラクターデザイン[5],競走馬の気性やエピソードを考慮した脚本[6],そして実況含め史実を忠実に再現したレース展開[7],と枚挙にいとまがありません.熱にあてられたプレーヤーや視聴者が興味を持ち始め,競馬ファンの拡大に貢献しました[8].2021年上半期には,ウマ娘をダウンロードした28.7%のユーザが競馬情報アプリnetkeibaをダウンロードしていたそうです[9]

私もそんな視聴者の一人です.アニメを2周ほどしたあと,Wikipediaやレース動画(やニコ動やVTuberによる実況)を一通り眺めたのですが,それだけでは満足できなくなってきました.色々調べたところ,netkeibaで1986年から現在までの中央競馬の結果が無料で公開されていることがわかったので,1986年〜2020年の35年分のデータを分析し,競走馬たちに思いを馳せることにしました.

少し話は変わりますが,ウマ娘には適性という概念が存在します.これはウマ娘がどのような環境で力を発揮しやすいかを表すもので,バ場[10]適性(芝,ダート),距離適性(短距離,マイル,中距離,長距離),脚質適性(逃げ,先行,差し,追込)があり,それぞれアルファベットのGからAで評価されます[11].こちらも合わせて分析すると考察に深みが出ると考えたため,神ゲー攻略,ウマ娘攻略Wikiから適性情報を取得させて頂くことにしました.

データの取得にはRequestsおよびBeautiful Soup,データの管理にはSQLite,データの整形にはPandas,データの可視化にはPlotlyを用いました.可視化にデファクトスタンダードのmatplotlibを使用しなかったのは,前回の記事と同様,インタラクティブにデータの中身を確認したかったためです.

分析結果は下記のGitHubにて公開中です.

https://github.com/kakeami/keiba-eda-public

私はまだ馬券を購入したことがなく,かつウマ娘のゲームもチュートリアルすら終わらせず断念した根性なしですので,本記事には誤った情報が含まれる可能性があります.特に,記事後半で触れる散布図のクラスターに関しては,知識不足のため原因を特定できませんでした.競馬ファンおよびトレーナーの皆様におかれましては,何卒ご了承いただき,可能であればご指摘頂けますと幸いです.

データ取得(レース結果)

取得元


https://db.netkeiba.com/race/199306040411/

netkeibaで無料で公開されている中央競馬のレース結果を,情報解析を目的としてスクレイピングしました.定番のサイトですので,ネット上にスクリプトが溢れていましたが,細かいチューニングをすることを考えて自分で全部書きました.

実装

netkeibaからのスクレイピング方法は優れた解説記事が多く存在します.ただでさえ長くなってしまったので,紙面の都合上,本記事では詳細に触れません.私はオーソドックスにRequestsでHTMLを取得し,Beautiful Soupで解析しました.

スクレイピングにあたっては,くれぐれもサーバに負荷をかけない方法で,適法に取得するようお願いいたします.

データ管理

データの規模がそこそこあるので,SQLiteを使ってデータを管理しました.レースの概要情報を管理するraceテーブルと,着順等の結果を管理するresultテーブルを用意しました.以下でスキーマを示します.

raceテーブル

カラム データ型 補足
race_id TEXT (UNIQUE) URLの末尾
race_name TEXT レース名
date TEXT 開催日.YYYY-MM-DD
place TEXT 開催場所
distance INTEGER 距離[m]
dart TEXT ダートか否か.{'True', 'False'}
dart_cond TEXT ダートの状況.dart = 'False'の場合はNull
turf TEXT 芝か否か.{'True', 'False'}
turf_cond TEXT 芝の状況.turf = 'False'の場合はNull
steeple TEXT 障害競走か否か.{'True', 'False'}
direction TEXT 右回りか左回りか.{'Right', 'Left'}
weather TEXT 天候
start_time TEXT 発走時刻.HH:MM

resultテーブル

カラム データ型 補足
race_id TEXT URLの末尾
arrival_order INTEGER 着順.存在しない場合はNull
frame_no INTEGER 枠番
horse_no INTEGER 馬番
horse_name TEXT 馬名
horse_id TEXT 馬のID
horse_sex TEXT 馬の性別[12]
horse_age INTEGER 馬の年齢
horse_weight REAL 馬体重[kg]
horse_weight_change REAL 馬体重の増減[kg]
jockey_name TEXT 騎手名
jockey_weight REAL 斤量[kg]
seconds_total REAL タイム[s]
seconds_3f REAL 上りタイム[s]
odds REAL 単勝オッズ
popularity_order INTEGER 人気順
trainer_name TEXT 調教師名
trainer_affiliation TEXT 調教師の所属.{'East', 'West'}
owner_name TEXT 馬主名
prize REAL 賞金[万円]

苦労したこと

pd.read_html()で抜けないページがあった

resultテーブルに該当する情報をPandasのread_html()で抜けると非常に楽だったのですが,1986年頃の古い結果ページに対しては正しく動作しませんでした.原因を調べるのが面倒でしたし,tableタグ以外は結局解析する必要があったため,諦めて全て書き下しました.

着順に数字以外が入ることがあった

降着・失格競走中止出走取消等が原因で着順がつかず,「着順」に数字以外が入ることがある[13]ことを知りませんでした.


出走取消の例:https://db.netkeiba.com/race/198601010207

獲得賞金が1000万円以上だと,が入っていた

これもありがちですが,獲得賞金が1000万円を超えると,が含まれる(例:4,000)ため,そのままREAL型にキャストしようとするとエラーが出ました.

データ取得(ウマ娘)

取得元

神ゲー攻略,ウマ娘攻略Wikiから適性情報をスクレイピングしました.


https://kamigame.jp/umamusume/page/110667391372886023.html

実装

具体的な処理内容は下記のNotebookをご確認ください.実行環境に関しては本記事のAppendixをご参照ください.

https://github.com/kakeami/keiba-eda-public/blob/master/notebooks/scatter_scrape.ipynb

苦労したこと

新衣装等で複数回登場するウマ娘がいた

エアグルーヴとエアグルーヴ(花嫁)等,表中に複数回登場するウマ娘が数人いました[14].念の為検証したところ,衣装によって適性は変わらないことが確認できたため,安心しました.

  • エアグルーヴとエアグルーヴ(花嫁)
  • エルコンドルパサーとエルコンドルパサー(新衣装)
  • グラスワンダーとグラスワンダー(新衣装)
  • ゴールドシチーとゴールドシチー(新)
  • シンボリルドルフとシンボリルドルフ(新)
  • スペシャルウィークとスペシャルウィーク(水着)
  • スーパークリークとスーパークリーク(ハロウィン)
  • トウカイテイオーとトウカイテイオー(新衣装)
  • マチカネフクキタルとマチカネフクキタル(フルアーマー)
  • マヤノトップガンとマヤノトップガン(花嫁)
  • マルゼンスキーとマルゼンスキー(水着)
  • メジロマックイーンとメジロマックイーン(新衣装)
  • ライスシャワーとライスシャワー(ハロウィン)

前処理

散布図のプロットにあたり,下記の前処理を施しました:

  • raceテーブルにgradeカラムとtitleカラムを追加
  • プロット用の軽量なデータフレームの作成

本章ではそれぞれについて概説します.ソースコードは下記のNotebookをご確認ください.実行環境に関しては本記事のAppendixをご参照ください.

https://github.com/kakeami/keiba-eda-public/blob/master/notebooks/scatter_preprocess.ipynb

raceテーブルの更新

下記のようにrace.race_nameからグレード情報とタイトル情報を抜き出し,それぞれgradeカラムとtitleカラムとして追加しました.

gradeカラムの追加

競馬のレースにはグレードと呼ばれる序列が存在しており,上から順に

  • G1
  • G2
  • G3
  • リステッド
  • オープン特別
  • 3勝クラス
  • 2勝クラス
  • 1勝クラス
  • 新馬・未勝利

に分類されています[15][16].グレードが高くなるほど出馬条件が厳しくなり,G1レースに出馬できる競走馬はほんの一握りです.実際,1986年から2020年までの35年間で,中央競馬の平地競走(障害競走以外のレース)に出場経験のある競走馬は149610頭いましたが,そのうちG3以上のレースに出場したのは:

  • G1:4981頭(3.3293%)
  • G2:6966頭(4.6561%)
  • G3:11356頭(7.5904%)

でした[17].そもそも中央競馬の平地競走に出場できない競走馬も星の数ほど存在します[18]ので,G1ホースともなるとエリート中のエリートであることが再確認できました.

こうした背景から,グレードの考慮は必須と判断しました.下記のような関数でrace_nameからgradeを抜き出しました.

scatter_preprocess.ipynb
def get_grade(race_name):
    """race_nameからグレード情報を抽出"""
    r = re.search('(\((J.G|G)\d+\)|\((L|G)\))', race_name)
    if r:
        grade = r.group().replace('(', '').replace(')', '')
    else:
        grade = None
    return grade

titleカラムの追加

グレードを含め,race_nameには回数や西暦など様々な修飾語がついています.例えば有馬記念等,タイトルを軸に過去と現在の優勝馬を比較してみたかったので,race_nameからtitleを抜き出す関数を作成しました.

scatter_preprocess.ipynb
def get_title(race_name):
    """race_nameからタイトル情報を抽出"""
    # 空白文字を除外
    title = re.sub('\s$', '', race_name)
    # 第n回
    title = re.sub('第\d+回', '', title)
    # グレード:(Gn),(J.Gn)
    title = re.sub('\((J.G|G)\d+\)', '', title)
    # リステッド競争:(L), 重賞:(G)
    title = re.sub('\((L|G)\)', '', title)
    # (n)
    title = re.sub('\(\d+\)', '', title)
    # 第n戦
    title = re.sub('第\d+戦', '', title)
    # 末尾の数字
    title = re.sub('\d$', '', title)
    # 西暦
    title = re.sub('19[8-9][0-9]|20[0-2][0-9]', '', title)
    # 略式の西暦
    title = re.sub('’([8-9][0-9]|[0-2][0-9])', '', title)
    return title

泥臭い正規表現を見ていただければわかるように,この作業が一番地味で一番辛かったです.

  • 西暦が入っているもの(例:2001ファイナルS
  • 略西暦が入っているもの(例:’88フェアウェルS[19]
  • ({回数})が入っているもの(例:ドイツ騎手招待(1)
  • 第n戦が入っているもの(例:オールスターJ第1戦[20]
  • 末尾に回数が入っているもの(例:ヤングJSFR中山1[21]

は一通りカバーしました.

可視化用のDataFrameの準備

本当は全てのレースを散布図にしたかったのですが,処理が重すぎて端末がフリーズしてしまったので,重賞レースのみに絞ることにしました.取り扱いやすいよう,raceテーブルとジョインし,all_res.csvとして中間出力しました.大まかな処理としては:

  1. raceテーブルとresultテーブルを結合し,gradeおよびsteepleでフィルタ
  2. レース全体・上り[22]3ハロンの平均の速さ(speed_totalspeed_3f)を計算して追加
  3. ウマ娘基準の距離区分を追加

でした.以下それぞれについて解説します.

raceテーブルとresultテーブルの結合

下記のクエリで任意のグレードの平地競走のレース結果を抽出しました.

scatter_preprocess.ipynb
def query_results_and_races_by_grade(grade):
    """グレードでレース結果を集計するクエリ"""
    q = f'''
        SELECT *
        FROM (
            SELECT * FROM race 
            WHERE grade = '{grade}'
            AND steeple = 'False'
        ) AS race_g
        INNER JOIN result
        ON race_g.race_id = result.race_id;
    '''
    return q

race_nameから取得可能な平地競走のグレード全て(['G1', 'G2', 'G3', 'G', 'L'])を抽出対象としました.

2021-11-14,下記のコメントを頂いたため,Gに関する記述を修正しました.
https://zenn.dev/link/comments/8b33b536c2288b

Lはリステッド競争を表し,重賞に次ぐ重要なレースと位置づけられていることがわかりましたが,Gは定義が見当たらず苦労しました.詳細は後述します.
なお,Gは様々な理由でグレードのつかなかった重賞(詳細は後述します)を表し,Lはリステッド競争(準重賞)[23]を表します.

レース全体および上り3ハロンの平均の速さを追加

散布図の縦軸・横軸として平均の速さ[km/h][24]を用いるため,上記のDataFrameにspeed_totalspeed_3fを追加しました.

scatter_preprocess.ipynb
def add_average_speed_to_df(df):
    """平均の速さを計算して追加"""
    df_new = df.copy()
    df_new = df_new[~df_new['seconds_total'].isna()].\
        reset_index(drop=True)
    df_new = df_new[~df_new['seconds_3f'].isna()].\
        reset_index(drop=True)
    df_new['speed_total'] = \
        df_new['distance'] / df_new['seconds_total'] * 60 * 60 / 1000
    df_new['speed_3f'] = \
        600 / df_new['seconds_3f'] * 60 * 60 / 1000
    return df_new

speed_totalはレース全体の平均の速さを表します.race.distance[m]をresult.seconds_total[s]で割り,単位変換することで時速を算出しました.speed_totalが大きいほど,レース全体として速く走ったことを表します.

speed_3fは上り3ハロンの平均の速さを表します.上り3ハロンとは,レース終盤の残り600mを指し,日本競馬では特に重要な区間と見なされています[25].600[m]をresult.seconds_3f[s]で割り,単位変換することで時速を算出しました.speed_3fが大きいほど,ラストスパートが速かったことを表します.

横軸をseed_total,縦軸をspeed_3fとしてプロットすることで,競走馬の脚質適性が分布に現れることを期待していました.

ウマ娘基準の距離区分を追加

距離区分ごとに散布図を作図するため,上記のDataFrameにdistance_classを追加しました.国際基準であるSMILE[26]を用いるか悩みましたが,ウマ娘の距離適性と比較分析するため,下記のウマ娘における距離区分を採用しました.

  • 短距離: 1600m未満[27]
  • マイル: 1600m - 2000m未満
  • 中距離: 2000m - 2500m未満
  • 長距離: 2500m以上
scatter_preprocess.ipynb
def get_distance_class(distance):
    """ウマ娘における距離区分を返す
    https://altema.jp/umamusume/kyoritekisei
    """
    if distance < 1600:
        return 'short'
    elif distance < 2000:
        return 'mile'
    elif distance < 2500:
        return 'intermediate'
    elif distance >= 2500:
        return 'long'
    else:
        return None
	

def add_distance_class_to_df(df):
    """距離区分をdfに追加"""
    df_new = df.copy()
    df_new['distance_class'] = \
        df_new['distance'].apply(
            lambda x: get_distance_class(x))
    return df_new

苦労したこと

グレードGが何を表すかわからなかった

調査した結果,末尾に(G)を冠するrace_nameは下記3種類に分類できることがわかりました.

2021-11-14,下記のコメントを頂いたため,3に関する記述を修正しました.
https://zenn.dev/link/comments/8b33b536c2288b

  1. もともとG3だったが,天候等の影響で一時的にグレードが外れた重賞
  2. G3の新規格付けをもらう前の新設重賞および重賞
  3. 不明 アングロアラブに対する重賞

3については,両者ともにアングロアラブの名馬セイユウとタマツバキを冠した由緒ある重賞であることまではわかったのですが,それが必要十分条件かどうか判断できませんでした.ご存知の方いらっしゃいましたら,ご教示頂けますと幸いです.

pd.DataFrame.to_sql(method='multi')が動かなかった

raceテーブルの更新にあたっては

  1. pd.DataFrameとして読み出し
  2. titleおよびgradeカラムを追加
  3. pd.DataFrameからto_sql()raceテーブルを上書き保存

というかなり行儀の悪いことをやっていたのですが,最後のto_sql()が遅すぎて困りました.method='multi'を指定すると高速化できるらしいのですが,バグのためエラーが発生してしまいました.結局,SQLAlchemy経由で書き込むことにしました.

scatter_preprocess.ipynb
import sqlalchemy as sa

engine = sa.create_engine(
    f'sqlite:///{path_db}', echo=False)
df_race.to_sql(
    TN_RACE, engine, if_exists='replace',
    method='multi', chunksize=5000)

Plotlyによる可視化

作ったもの

前章で作成した可視化用データを元に,次のような散布図を作成しました.まずはデモをご覧ください.字幕で解説を入れておりますので,「字幕ON」の設定をおすすめします.

https://youtu.be/fBI9aKwAhfM

https://youtu.be/tSBXADm7pv8

まず距離区分に応じて,散布図を4つ描画しました.これにより各競走馬の距離適性,および距離区分における全体傾向を明らかにすることが狙いです.

各散布図の横軸としてレース全体の平均の速さ,縦軸として上り3ハロンの平均の速さを設定しました.散布図の各マーカーは,各重賞・各競走馬のレース結果と対応しています.直感的には,右側に位置する競走馬ほど速く到着しており,上側に位置する競走馬ほどラストスパートが速かったことを示します.これにより各競走馬の脚質適性を見ることが狙いです.

マーカーの色は獲得賞金を表します.獲得賞金が高いほど明るい黄色になります.どのような距離適性・脚質適性の競走馬が稼いでいるか直感的に眺めることが狙いでした.もちろん,日本競馬は高速化しているため,35年前のタイムと現在のタイムを一概に比較することはできません.また,同じ距離区分内でもレースごとに距離が異なるため,各サンプル同士をフェアに比較することはできません[28].あくまでも全体傾向をぼんやりと眺める用途で作図しました.

Plotlyとは

Plotlyとは,インタラクティブなグラフや地図を描画するためのライブラリ群です.Python,R,Julia,Javascript,F#,MATLAB等様々な言語へのインターフェースが提供されています.

Pythonのデファクトであるmatplotlibとの大きな違いは,手軽にインタラクティブなグラフを作成できる点です.私は仕事柄,入出力データについて社内外で議論することが多いのですが,分布を俯瞰して見ながら,特定のサンプルに焦点をあてる[29]ことが可能なPlotlyは非常に便利です[30]

公式ドキュメントで実装例が豊富なのでやりたいことはたいてい見つかりますし,近年Plotly Expressという高レベルインターフェースが追加されたため,導入のハードルは低くなっています.

Python用Plotlyは下記のコマンドでインストールできます.

pip install plotly

実装

下記のNotebookをご参照ください.実行環境に関しては本記事のAppendixをご参照ください.

https://github.com/kakeami/keiba-eda-public/blob/master/notebooks/scatter_plot.ipynb

大きく2種類の散布図を作成しました.それぞれについて概説します.

全データの散布図(例:scatter_all.html

scatter_all.htmlを作成するために,下記のsubplot_scatter_by_distance_class()を定義しました.

scatter_plot.ipynb
import plotly.express as px
from plotly.subplots import make_subplots
import plotly.graph_objects as go


def subplots_scatter_by_distance_class(
        df, color_col='prize', color_title='獲得賞金', asc=True):
    """距離区分ごとにsubplotでscatterを描画"""
    fig = make_subplots(
        rows=2, cols=2, subplot_titles=SUBPLOT_TITLES)
    x_min, x_max = get_min_and_max_of_col(df, 'speed_total')
    y_min, y_max = get_min_and_max_of_col(df, 'speed_3f')
    for i, dc in enumerate(DISTANCE_CLASSES):
        df_tmp = make_df_for_plot(df, dc, color_col, asc)
        add_line_y_equal_x_to_fig(fig, i)
        add_scatter_trace_to_fig(
            fig, x=df_tmp['speed_total'], y=df_tmp['speed_3f'],
            color=df_tmp[color_col], text=df_tmp['hover_text'],
            name=dc, i=i)
    update_colorbar_of_fig(fig, color_title)
    update_axis_ranges_of_fig(
        fig, x_min=x_min, x_max=x_max, 
        y_min=y_min, y_max=y_max)
    update_axis_titles_of_fig(fig)
    return fig

Plotlyには,subplot(一枚のプロットに複数のグラフを描くこと)を実現する方法がいくつかあります.おそらく一番手軽なのはPlotly Expressのfacetオプションを使う方法だと思いますが,細かい制御ができなかった[31]ため諦めました.代わりに,今回はmake_subplotsメソッドを利用しました.引数としてrowsで行数,colsで列数,subplot_titlesで各サブプロット名を定義することができます.

上記で作成したfigオブジェクトに,独自に作成したadd_scatter_trace_to_fig()を使って,合計4つのDISTANCE_CLASSESごとにtraceオブジェクトを追加しました[32]

scatter_plot.ipynb
def add_scatter_trace_to_fig(
        fig, x, y, color, text, name, i,
        opacity=1., symbol='circle', size=10,
        hover=True, linecolor='White'):
    """figに対しscatterを追加"""
    fig.add_trace(
        go.Scatter(
            x=x,
            y=y,
            mode='markers',
            marker_symbol=symbol,
            marker_size=size,
            opacity=opacity,
            hoverinfo='text' if hover else 'skip',
            marker={
                'color': color,
                'coloraxis':'coloraxis',
                'line':{
                    'color': linecolor,
                    'width': 1},
            },
            text=text,
            hovertemplate='%{text}' if hover else None,
            name=name,
        ),
    i//2+1, i%2+1)

fig.add_trace()内では,所望のグラフオブジェクトを指定してfigに追加できます.ここではgo.Scatter()で散布図のオブジェクトを指定しました.

marker_symbol(マーカーの形),marker_size(マーカーの大きさ),opacity(マーカーの透明度),hover(ホバー時にテキスト等を表示するかどうか)をオプション引数としたのは,次節で使用するためです.

ホバー時に表示するテキストはhovertemplateで制御できます.詳細は後述します.

ちなみに,最後のi//2+1, i%2+1は散布図の行番号と列番号を表します.

注目したいデータを重ねた散布図(例:scatter_トウカイテイオー.html

冒頭のデモでお見せしたトウカイテイオーのように,全体の散布図を背景として示しつつ,注目したいデータを重ねて描画した散布図を作成しました.

これらは全て,前記のsubplots_scatter_by_distance_class()を少しだけ変更したsubplots_two_scatters_by_distance_class()により作図しました.

scatter_plot.ipynb
def subplots_two_scatters_by_distance_class(
        df, df_star, color_col='prize', color_title='獲得賞金', asc=True):
    """距離区分ごとにsubplotでscatterを描画
    ただしdf_starの結果は☆でプロット"""
    fig = make_subplots(
        rows=2, cols=2, subplot_titles=SUBPLOT_TITLES)
    x_min, x_max = get_min_and_max_of_col(df, 'speed_total')
    y_min, y_max = get_min_and_max_of_col(df, 'speed_3f')
    for i, dc in enumerate(DISTANCE_CLASSES):
        df_tmp = make_df_for_plot(df, dc, color_col, asc)
        df_star_tmp = make_df_for_plot(df_star, dc, color_col, asc)
        add_line_y_equal_x_to_fig(fig, i)
        # 背景表示用
        add_scatter_trace_to_fig(
            fig, x=df_tmp['speed_total'], y=df_tmp['speed_3f'],
            color=df_tmp[color_col], text=df_tmp['hover_text'],
            name=dc, i=i, opacity=0.3, hover=False)
        # 注目したいデータ用
        add_scatter_trace_to_fig(
            fig, x=df_star_tmp['speed_total'], y=df_star_tmp['speed_3f'],
            color=df_star_tmp[color_col], text=df_star_tmp['hover_text'],
            name=dc, i=i, symbol='star', size=25)
    update_colorbar_of_fig(fig, color_title)
    update_axis_ranges_of_fig(
        fig, x_min=x_min, x_max=x_max, 
        y_min=y_min, y_max=y_max)
    update_axis_titles_of_fig(fig)
    return fig

主な違いは下記です:

  • dfに加え,重ねて表示したいデータを格納したdf_starを渡す
  • DISTANCE_CLASSES毎に2回ずつadd_scatter_trace_to_fig()を呼び出す
    1. dfを半透明(opacity=0.3)で,ホバー時に反応しないように(hover=False)追加
    2. df_starを星型マーカー(symbol='star')で,大きめに(size=25)で追加

このdf_starを競走馬ごと,タイトルごと,適性毎に作成することで,様々なパターンの散布図を作成しました.

ちなみにfigs/scatters/vs/では2頭の競走馬に注目した散布図(一頭目を星型マーカー,二頭目を三角型マーカーで表示)を作成していますが,基本的な考え方は同じです.

苦労したこと

見栄えを整えるために獲得賞金でソートした

高額賞金を獲得した競走馬は一握りでしたので,何も考えずに散布図を描画すると画面が真っ黒になってしまいました.そこで,prizeで昇順に並び替えてからプロットすることで,明るい黄色のマーカーが前面に描画されるよう工夫しました.

hovertemplateにHTMLを直打ちした

Plotlyでは,マーカー上にカーソルをホバーしたときに表示する情報を制御できます.Plotly Expressはhover_data, hover_name等のオプションで手軽に指定できる機能を提供しているのですが,今回はPlotly Expressを採用しなかったため,より低レベルなインターフェースであるhovertemplateを使いました.

scatter_plot.ipynb
def add_scatter_trace_to_fig(
        fig, x, y, color, text, name, i,
        opacity=1., symbol='circle', size=10,
        hover=True, linecolor='White'):
    """figに対しscatterを追加(一部省略)"""
    fig.add_trace(
        go.Scatter(
            # 略
            text=text,
            hovertemplate='%{text}' if hover else None,
            # 略
        ),
    i//2+1, i%2+1)

hovertemplateではグラフオブジェクト内の変数を自由に展開できます.今回はtextをそのまま展開していますが,x(X軸に対応する値),y(Y軸に対応する値),color(カラースケールに対応する値)等ももちろん呼び出せます.HTMLタグも有効です.

今回の実装では,下記のようなhover_textカラムをDataFrameに追加しておき,add_scatter_trace_to_fig()textとして渡しました.

scatter_plot.ipynb
def make_hover_text(
        horse_name, horse_age,
        race_name, turf, dart, date, distance,
        seconds_total, seconds_3f,
        speed_total, speed_3f,
        arrival_order, prize):

    if turf:
        race_type = '芝'
    elif dart:
        race_type = 'ダート'
    else:
        race_type = ''

    text = f'''<b>{horse_name}</b> ({horse_age}歳) <br><br>
    レース:{race_name} ({date}, {race_type}, {distance}m)<br>
    タイム: {seconds_total} 秒 (上り: {seconds_3f} 秒) <br>
    平均の速さ: {speed_total:.4} km/h (上り: {speed_3f:.4} km/h) <br>
    着順:{arrival_order}<br>
    賞金:{prize}万円
    '''
    return text


def add_hover_text_to_df(df):
    """hovertemplate用のhover_textカラムを追加"""
    df_new = df.copy()
    df_new['hover_text'] = \
        df_new[
            ['horse_name', 'horse_age', 
             'race_name', 'turf', 'dart', 'date', 'distance', 
             'seconds_total', 'seconds_3f',
             'speed_total', 'speed_3f',
             'arrival_order', 'prize']].apply(
        lambda x: make_hover_text(*x), axis=1)
    return df_new

サイズを圧縮するために不要なサンプルを削除した

何も考えずに出力すると,各散布図のファイルサイズは31MB程度になってしまいました.少しでも圧縮するため,マーカーの位置が重複する場合は最も獲得賞金が大きいもの(獲得賞金が同じ場合は開催日が新しいもの)以外を削除することにしました.

scatter_plot.ipynb
def drop_dup_for_plot(
        df, cols_dup=['speed_total', 'speed_3f'],
        cols_sort=['prize', 'date'], asc=False):
    """cols_dupが重複したデータを削除
    その際, cols_sortでasc順にソートしたあとで重複を削除されることに注意.
    デフォルト設定ではprizeおよびyearが大きいものが残る形になる.
    """
    df_new = df.copy()
    df_new = df_new.sort_values(cols_sort, ascending=asc)
    df_new = df_new.drop_duplicates(subset=cols_dup)
    return df_new

結果的にファイルサイズは15MB程度[33]まで小さくなりました.これでも十分巨大ですが….

結果

作図結果を眺めてみます.全て以下のリポジトリに格納してありますので,適宜ダウンロードして御覧ください.

https://github.com/kakeami/keiba-eda-public/tree/master/figs/scatters

全体像

まず,全ての競走馬のレース結果を描画したscatter_all.htmlから簡単に解説します.

距離区分に応じて,散布図を4つ描画しました.これにより各競走馬の距離適性,および距離区分における全体傾向を明らかにすることが狙いです.

各散布図の横軸としてレース全体の平均の速さ,縦軸として上り3ハロンの平均の速さを設定しました.散布図の各マーカーは,各重賞・各競走馬のレース結果と対応しています.直感的には,右側に位置する競走馬ほど速く到着しており,上側に位置する競走馬ほどラストスパートが速かったことを示します.これにより各競走馬の脚質適性を見ることが狙いです.

マーカーの色は獲得賞金を表します.獲得賞金が高いほど明るい黄色になります.どのような距離適性・脚質適性の競走馬が稼いでいるか直感的に眺めることが狙いです.

補助線として,灰色の破線でy=xをプロットしました.ざっくり言うと,この破線より上側に位置する競走馬は上り3ハロンが他の区間より速く下側に位置する競走馬はその逆を表します.脚質適性などと比較すると面白そうですね.

明るい黄色のマーカーのほとんどが明るい破線より上側に存在することがわかります.やはり日本競馬においては,上りをいかに速く走るかが重要なテーマのようです.

また,当たり前ですが距離が長くなるほどレース全体の平均の速さが落ち込み,全体として縦に潰れた分布になることがわかります.短距離レースがほぼ補助線y=x上に乗って伸びているのも面白いですね.最初から最後まで全力疾走している様子が伺えます.

ところで…,

これは何でしょう?

https://github.com/kakeami/keiba-eda-public/blob/master/notebooks/scatter_discussion.ipynb

上記のNotebookで簡単に分析したところ,このクラスターに属する351レース[34]は全て1993年頃までに開催されたものであることがわかりました.上りの平均の速さが3/4程度になっていることから,上り3ハロンではなく4ハロンで計測されたタイムなのではないかと想像しています[35]が,裏が取れませんでした.以下に該当するレース一覧を格納しておりますので,ピンと来た方はコメント頂けますと幸いです.

https://github.com/kakeami/keiba-eda-public/blob/master/figs/scatters/race_slow_3f.csv

競走馬

競走馬ごとの戦績をもとに,散布図を作成しました.

https://github.com/kakeami/keiba-eda-public/tree/master/figs/scatters/horses

作図対象としたのは,下記いずれかの条件を満たす競走馬です:

例えばゴールドシップ


ゴールドシップの戦績

ほとんどの点が補助線y=xより上側に位置し,優秀な追い込み馬だったことがわかります.明るいマーカーも多く,賞金を荒稼ぎしている様子も伺えます.

一方でツインターボは,


ツインターボの戦績

ほとんどの点が補助線y=xより下側に位置し,逃げ馬らしい走り方をしていたことがわかります.

他の競走馬についても眺めて頂けると幸いです.私は何気なく開いたアーモンドアイが凄すぎて驚いてしまいました.

競走馬同士の比較

私の独断と偏見で選定した,下記の6ペアについて比較した散布図を作成しました.

scatter_plot.ipynb
ums_vs = [
    ['トウカイテイオー', 'メジロマックイーン'],
    ['スペシャルウィーク', 'サイレンススズカ'],
    ['ウオッカ', 'ダイワスカーレット'],
    ['キタサンブラック', 'サトノダイヤモンド'],
    ['ライスシャワー', 'メジロマックイーン'],
    ['トウカイテイオー', 'ツインターボ'],
]

https://github.com/kakeami/keiba-eda-public/tree/master/figs/scatters/vs

例えば,トウカイテイオーとメジロマックイーンの散布図は下図のようになります.見やすいよう,中距離と長距離の散布図を拡大していることにご注意ください.


トウカイテイオー(☆)とメジロマックイーン(△)

タイトル

タイトルごとに,入賞した競走馬の散布図を作成しました.

https://github.com/kakeami/keiba-eda-public/tree/master/figs/scatters/titles

ファイル名はscatter_{グレード}_{タイトル名}.htmlというフォーマットに従っています.35年間でグレードが変動したタイトルについては,全てのグレードを降順[38]に記載しました(例:scatter_G1_G2_G3_スプリンターズS.html).

例えば,有馬記念の散布図は下図のようになります.見やすいよう,長距離の散布図のみ拡大していることにご注意ください.


有馬記念の歴代入賞馬

適性

適性ごとに,それぞれ評価がAの競走馬の散布図を作成しました.

https://github.com/kakeami/keiba-eda-public/tree/master/figs/scatters/indices

例えば,追い込み適性がAの競走馬の散布図は下図です.殆どのマーカーが補助線y=xより上部に位置しており,ラストスパートが速かったことが伺えます.


追い込み適性Aの競走馬

今後の課題

上りタイムクラスターの原因特定

なんとなく開催年と開催場所にヒントがありそうですが,現時点では原因を特定できていません.

統計的仮説検定

競馬界で囁かれている定説について,数理統計的な観点での裏付けに挑戦したいです.

  • 日本競馬の高速化
  • 先行脚質のある馬は外枠で不利
  • 「芦毛は走らない」[39]

きちんと調査すればどこかで誰かが検証している気がしますが,統計的仮説検定の練習としてやってみたいです.

ダッシュボード化

そもそもこういったデータはクソデカHTMLとしてGitHubで公開するのではなく,ダッシュボード化して,誰でも任意の切り口で集計・分析できるようにしてあると親切です.それこそPlotly Dashをサーバー上に構築しても良いですし,Google Data Studio等でサーバーレスに実現しても良いでしょう.

ただ,これを突き詰めていくとnetkeiba スーパープレミアムコースと競合しそう[40]で….本家に迷惑をかけるのは本意でないので,対応を検討中です.

おわりに

ジョージア工科大学の合格通知を頂いてからおよそ1週間後に,軽度の頚椎椎間板ヘルニアを診断され,趣味だったジョギングを止めなければならなくなりました.かなり落ち込んでいたのですが,ウマ娘や競走馬の走りを見て,大きく勇気づけられました.少しでもその魅力が伝わるよう,今回は稚拙ながら記事を書かせて頂きました.

分析はデータがないことには始まりません.35年以上前から,良質なデータを取得し続けてくださった競馬関係者の皆様に感謝申し上げます.いつも面白いレースをありがとうございます.また,ウマ娘関係者の皆様,このような素晴らしいコンテンツを発見するきっかけを与えてくださり,ありがとうございます.今後もウマ娘を応援し続けます!

Appendix:実行環境の構築

本記事の処理の一部をNotebookにまとめ,下記で公開しています.

https://github.com/kakeami/keiba-eda-public/tree/master/notebooks

Python等の環境はnotebooks/Dockerfileに記載の通りjupyter/datascience-notebook:2021-11-04イメージに準拠しています.

Dockerfile
FROM jupyter/datascience-notebook:2021-11-04
MAINTAINER Kakeami

RUN pip install jupyterlab && \
    jupyter serverextension enable --py jupyterlab

Jupyter labの環境を立ち上げる際は

sudo docker-compose up -d

した上でlocalhost:9999[41]に接続し,パスワードtwinturboを入力してください.ただし,docker-compose.yml

docker-compose.yml
version: '3'
services:
  jupyterlab:
    build:
      context: ./notebooks/
    volumes:
      - "./:/home/jovyan/work"
    user: root
    ports:
      - "9999:8888"
    environment:
      NB_UID: 1000
      NB_GID: 1000
      GRANT_SUDO: "yes"
    command: start.sh jupyter lab --NotebookApp.password="sha1:d382f585c867:09d7af4613fb433cbebf2606a5ede52c705f5d45"

最終行を適宜修正し,必ずパスワードを変更してください.

脚注
  1. 厳密にはnetkeibaから35年分の中央競馬の全結果を取得し,レース名に[G1, G2, G3, G, L]のいずれかが付与された平地競走のみフィルタリングした上でプロットしました. ↩︎

  2. 2021年11月に日経トレンディおよび日経クロストレンドから発表された「2021年ヒット商品30」では2位に選出されました.また,Cygamesはおろか,親会社のサイバーエージェントのゲーム事業の売上高が過去最高を更新したことにも驚きました. ↩︎

  3. https://rocketnews24.com/2021/03/19/1470546/ ↩︎

  4. https://note.com/cmr2tf/n/n8f3e4e7574e3 ↩︎

  5. イラストだけで競走馬を当てる企画が成り立つほど ↩︎

  6. ゲーム中,ナイスネイチャとトレーナーの距離が近いのは,モデルとなった競走馬のナイスネイチャが担当厩務員に非常になついていたからだ,という説があります. ↩︎

  7. https://dic.nicovideo.jp/a/ウマ娘プロ実況リンク ↩︎

  8. https://news.netkeiba.com/?pid=column_view&cid=49766 ↩︎

  9. https://www.4gamer.net/games/522/G052249/20210901047/ ↩︎

  10. 有名な余談ですが,ウマ娘の世界では四本脚で走る「馬」という概念が存在しないため,「馬」の代わりに「バ」あるいは「(馬のれっかを点二つに置き換えた架空の漢字)」が使用されています. ↩︎

  11. 今回は初期値を利用しました.継承により適性を向上させ,最終的にA以上の「S」に到達することも可能らしいです. ↩︎

  12. 「牝」(メス)「牡」(オス)の他に「セ」(騸馬,去勢された牡馬)という概念があり,驚きました. ↩︎

  13. 35年分の中央競馬のデータのうち,何らかの理由で着順に数字以外が入っていたレース数は合計で13143(約11%≒13143/119898)に上りました.結構ありますね,何か処理をミスっているかもしれません. ↩︎

  14. 2021年11月9日時点. ↩︎

  15. https://www.jra.go.jp/keiba/rules/class.html ↩︎

  16. 平地競走に関する区分です.障害競走の格付けは少し異なり,J.G1,J.G2,J.G3となります.https://www.jra.go.jp/kouza/yougo/w323.html ↩︎

  17. 1986年から2020年までのレース結果のみから判断しているため,運悪く全盛期がこの期間にかぶらなかった競走馬(例えば2020年にメイクデビューして,2021年以降に重賞に出始めた馬)に関しては不利な集計になっています.そのあたりの境界条件を考慮すると,正確にはもう少し大きな数値になると思いますが,今回は目をつぶりました. ↩︎

  18. 高知競馬場を中心に活躍したハルウララとか. ↩︎

  19. しかも無印のフェアウェルSも存在しました.1995年以降はレース名に略西暦がつかなくなったようです. ↩︎

  20. オールスターJ第{n}戦に関しては,nに応じて出馬条件が異なるようだったので別タイトルとして集計するか悩みましたが,細かくなりすぎても扱いきれないため,同一タイトルとしました. ↩︎

  21. ヤングJSFR中山{n}に関してはnによって出馬条件が異なるようでしたが,オールスターJ第n戦と同様に同一タイトルとしました. ↩︎

  22. 読みは「あがり」です.ざっと調べたところ「上がり」という送り仮名が正しそうでしたが,netkeibaの列名「上り」を尊重して本記事では「上り」を採用しています(途中で気づいたけど書き直す気力がなかった). ↩︎

  23. https://jra.jp/kouza/yougo/w573.html ↩︎

  24. デモ動画中では何気なく「速度」という表現を使っていましたが,ベクトルではなくスカラーなので「速さ」という表現が正しいです.ごめんなさい,デモを撮り直す気力はありませんでした.ご了承ください. ↩︎

  25. Wikipediaによると海外では計測しない国が多いとのことなので,日本に生まれてよかったです. ↩︎

  26. S(Sprint: 1000m - 1300m),M(Mile: 1301m - 1899m),I(Intermediate: 1900m - 2100m),L(Long: 2101m - 2700m),E(Extended: 2701m以上) ↩︎

  27. 厳密には1200m - 1600m未満と定義されていますが,我々の世界には1000mの名レースもたくさん存在するため,便宜的に拡張しました. ↩︎

  28. たとえ距離が同じであっても,そのレースによって対抗馬や体調や天候は異なります.原理的にフェアな比較は不可能という前提で見て頂けると幸いです. ↩︎

  29. この外れ値なんだっけ?みたいなときにすごく便利です. ↩︎

  30. エクセルでも同じことができるのですが,データの規模が大きくなってくると動作がもたついてくること,元データを全て公開する必要があること,またマクロを組まなければ自動化が難しいこと,(そして私のエクセル力が極端に低いこと,)からPlotlyを使うことが多いです. ↩︎

  31. subplotの順序を制御するのが難しかったり,subplotを跨いだcolorbarの表示が崩れたりしました. ↩︎

  32. 最初にfigオブジェクトを作って,それにadd_traceでグラフを追加していくのは,Plotlyによる作図のよくあるパターンの一つです.Plotly Expressはその辺すら簡略化してくれますが. ↩︎

  33. タイムの精度が小数点以下1桁で,走行距離のバリエーションも多くないので,もっと圧縮できると思っていましたが…. ↩︎

  34. 厳密にクラスタリングしたわけではなく,「最も上りタイムが速かった競走馬の平均の速さが50[km/h]未満のレース」という雑な条件で抽出しました.結果を見る限り,そこまで悪くない分離方法だと思います. ↩︎

  35. 例えば第56回までの東京優駿と,第57回以降の東京優駿では上りタイムの水準が明らかに異なっています. ↩︎

  36. 未実装(サポカ等で他のウマ娘のストーリーには登場するが,育成対象ではないウマ娘)を含みます. ↩︎

  37. ただし,競走馬のデータを取得できなかったウマ娘が4人います.まずシンボリルドルフ,マルゼンスキー,ミスターシービーについては,活躍した時期が今回の調査期間と重ならなかったため,データを取得できませんでした.そしてハルウララについては,中央競馬での出走記録がなかったため,データを取得できませんでした. ↩︎

  38. アルファベット順における降順ではなく,グレードの格付け[G1, G2, G3, G, L]における降順. ↩︎

  39. タマモクロスやオグリキャップが登場する前は,「芦毛の馬は走らない」が定説だったそうです.シンデレラグレイ最高! ↩︎

  40. 思い上がりも甚だしいですが. ↩︎

  41. ローカルのJupyterと衝突しないよう9999番にフォワーディングしています.こちらもdocker-compose.ymlから適宜変更してください. ↩︎

GitHubで編集を提案

Discussion

上り3ハロンではなく4ハロンで計測されたタイムなのではないかと想像しています

面白いですね……。例えば、アニメの題材にもなった第105回天皇賞ですが、先頭の通過ラップが計測されており、レースの後半のラップタイムは、残り800m手前で先頭に立っていたメジロマックイーンのラップタイムと一致するはずです。

https://db.netkeiba.com/race/199208030210/
確認すると、メジロマックイーンの「上り」となっている 48.2 = 11.9 + 12.2 + 11.9 + 12.2 で、ご想像の通り4Fのタイムになっています。

ただ、この頃の競馬をリアルタイムで観戦していないので、私には経緯が分かりません。
race_slow_3f.csv を見る限り、競馬場ごとに4F -> 3Fに変わる時期が異なっているようですが、JRAが各馬の推定上がりタイムも発表している都合と関係があったかもしれません(競馬場ごとに対応が必要そうなので)。

うおー,ありがとうございます!なるほど先頭のラップタイムで検証する方法は思いつきませんでした.確認してみます.(ただ,第105回天皇賞(春)のように「上り」で1位が入れ替わらないレースでないと裏を取れないのが悩ましいですね.ある程度割り切りが必要かもしれません…)

351レースの上り4ハロンのラップタイムをスクレイピングし(走行距離が200mの倍数でないレースもありましたが,なんとかなりました),当該レースの最速の「上り」タイムと比較したところ,誤差が~5%くらいでした.前述の理由から完全一致はしないものと思われるため,100%の確証は得られませんが,4ハロンで間違いないと思います.助かりました!ありがとうございます!

なお,分析詳細は下記に追記しました.

https://github.com/kakeami/keiba-eda-public/blob/master/notebooks/scatter_discussion.ipynb

一箇所コメントを忘れていました。

3については,両者ともにアングロアラブの名馬セイユウとタマツバキを冠した由緒ある重賞であることまではわかったのですが,それが必要十分条件かどうか判断できませんでした.

以下に記載がありますが、アングロアラブ系の重賞がこの2つしかなかったからです。

https://ja.wikipedia.org/wiki/競馬の競走格付け#サラブレッド系種の平地競走以外の格付け
ログインするとコメントできます