ウマ娘にハマったので35年分のレース結果をPlotlyで散布図にした
- アニメウマ娘にハマったので,35年分の重賞[1]のレース結果をnetkeibaから取得した
- Plotlyでインタラクティブな散布図を描き,馬・タイトル・適性毎に分布を見てニヤニヤした
- 散布図に謎のクラスターが生じたが,
1993年頃までの上りタイムの定義がわからず,原因解明に至らなかった1993年頃まで一部のレースで上りタイムの計測方法が異なっていたことが原因と考えられる - (長い記事なので,YouTubeの字幕をONにしてデモだけでもご覧頂けますと幸甚です)
はじめに
ウマ娘プリティーダービー(以下,ウマ娘)とは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にて公開中です.
-
demos/
:YouTubeのデモ動画の元データを格納 -
figs/scatters/
:作図した散布図を格納-
scatter_all.html
:全データをプロットした結果 -
horses/
:各競走馬に注目した散布図を格納 -
indices/
:各適性がAの競走馬に注目した散布図を格納 -
title/
:各タイトルで賞金を獲得した競走馬に注目した散布図を格納 -
vs/
:2頭の競走馬に着目した散布図を格納
-
-
notebooks/
:処理内容の一部をNotebookとして格納
私はまだ馬券を購入したことがなく,かつウマ娘のゲームもチュートリアルすら終わらせず断念した根性なしですので,本記事には誤った情報が含まれる可能性があります.特に,記事後半で触れる散布図のクラスターに関しては,知識不足のため原因を特定できませんでした.競馬ファンおよびトレーナーの皆様におかれましては,何卒ご了承いただき,可能であればご指摘頂けますと幸いです.
データ取得(レース結果)
取得元
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をご参照ください.
苦労したこと
新衣装等で複数回登場するウマ娘がいた
エアグルーヴとエアグルーヴ(花嫁)等,表中に複数回登場するウマ娘が数人いました[14].念の為検証したところ,衣装によって適性は変わらないことが確認できたため,安心しました.
- エアグルーヴとエアグルーヴ(花嫁)
- エルコンドルパサーとエルコンドルパサー(新衣装)
- グラスワンダーとグラスワンダー(新衣装)
- ゴールドシチーとゴールドシチー(新)
- シンボリルドルフとシンボリルドルフ(新)
- スペシャルウィークとスペシャルウィーク(水着)
- スーパークリークとスーパークリーク(ハロウィン)
- トウカイテイオーとトウカイテイオー(新衣装)
- マチカネフクキタルとマチカネフクキタル(フルアーマー)
- マヤノトップガンとマヤノトップガン(花嫁)
- マルゼンスキーとマルゼンスキー(水着)
- メジロマックイーンとメジロマックイーン(新衣装)
- ライスシャワーとライスシャワー(ハロウィン)
前処理
散布図のプロットにあたり,下記の前処理を施しました:
-
race
テーブルにgrade
カラムとtitle
カラムを追加 - プロット用の軽量なデータフレームの作成
本章ではそれぞれについて概説します.ソースコードは下記のNotebookをご確認ください.実行環境に関しては本記事のAppendixをご参照ください.
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
を抜き出しました.
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
を抜き出す関数を作成しました.
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
として中間出力しました.大まかな処理としては:
-
race
テーブルとresult
テーブルを結合し,grade
およびsteeple
でフィルタ - レース全体・上り[22]3ハロンの平均の速さ(
speed_total
,speed_3f
)を計算して追加 - ウマ娘基準の距離区分を追加
でした.以下それぞれについて解説します.
race
テーブルとresult
テーブルの結合
下記のクエリで任意のグレードの平地競走のレース結果を抽出しました.
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']
)を抽出対象としました.
L
はリステッド競争を表し,重賞に次ぐ重要なレースと位置づけられていることがわかりましたが,G
は定義が見当たらず苦労しました.詳細は後述します.
なお,G
は様々な理由でグレードのつかなかった重賞(詳細は後述します)を表し,L
はリステッド競争(準重賞)[23]を表します.
レース全体および上り3ハロンの平均の速さを追加
散布図の縦軸・横軸として平均の速さ[km/h][24]を用いるため,上記のDataFrameにspeed_total
とspeed_3f
を追加しました.
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以上
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種類に分類できることがわかりました.
- もともとG3だったが,天候等の影響で一時的にグレードが外れた重賞
- G3の新規格付けをもらう前の新設重賞および重賞
- レパードステークス(2009年,2010年)
- アルテミスステークス(2012年,2013年)
- いちょうステークス(2014年,のちのサウジアラビアRC)
- サウジアラビアRC(2015年)
- ターコイズステークス(2015年,2016年)
- 葵ステークス(2018年,2019年,2020年)
-
不明アングロアラブに対する重賞
3については,両者ともにアングロアラブの名馬セイユウとタマツバキを冠した由緒ある重賞であることまではわかったのですが,それが必要十分条件かどうか判断できませんでした.ご存知の方いらっしゃいましたら,ご教示頂けますと幸いです.
pd.DataFrame.to_sql(method='multi')
が動かなかった
race
テーブルの更新にあたっては
-
pd.DataFrame
として読み出し -
title
およびgrade
カラムを追加 -
pd.DataFrame
からto_sql()
でrace
テーブルを上書き保存
というかなり行儀の悪いことをやっていたのですが,最後のto_sql()
が遅すぎて困りました.method='multi'
を指定すると高速化できるらしいのですが,バグのためエラーが発生してしまいました.結局,SQLAlchemy経由で書き込むことにしました.
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」の設定をおすすめします.
まず距離区分に応じて,散布図を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をご参照ください.
大きく2種類の散布図を作成しました.それぞれについて概説します.
- 全データの散布図(例:
scatter_all.html
) - 全データの上に,注目したいデータを重ねた散布図(例:
scatter_トウカイテイオー.html
)
scatter_all.html
)
全データの散布図(例:scatter_all.html
を作成するために,下記のsubplot_scatter_by_distance_class()
を定義しました.
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].
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
)
注目したいデータを重ねた散布図(例:冒頭のデモでお見せしたトウカイテイオーのように,全体の散布図を背景として示しつつ,注目したいデータを重ねて描画した散布図を作成しました.
-
figs/scatters/horses/
:各競走馬に注目した散布図 -
figs/scatters/indices/
:各適性がAの競走馬に注目した散布図 -
figs/scatters/title/
:各タイトルで賞金を獲得した競走馬に注目した散布図
これらは全て,前記のsubplots_scatter_by_distance_class()
を少しだけ変更したsubplots_two_scatters_by_distance_class()
により作図しました.
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()
を呼び出す-
df
を半透明(opacity=0.3
)で,ホバー時に反応しないように(hover=False
)追加 -
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
を使いました.
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
として渡しました.
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程度になってしまいました.少しでも圧縮するため,マーカーの位置が重複する場合は最も獲得賞金が大きいもの(獲得賞金が同じ場合は開催日が新しいもの)以外を削除することにしました.
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]まで小さくなりました.これでも十分巨大ですが….
結果
作図結果を眺めてみます.全て以下のリポジトリに格納してありますので,適宜ダウンロードして御覧ください.
全体像
まず,全ての競走馬のレース結果を描画したscatter_all.html
から簡単に解説します.
距離区分に応じて,散布図を4つ描画しました.これにより各競走馬の距離適性,および距離区分における全体傾向を明らかにすることが狙いです.
各散布図の横軸としてレース全体の平均の速さ,縦軸として上り3ハロンの平均の速さを設定しました.散布図の各マーカーは,各重賞・各競走馬のレース結果と対応しています.直感的には,右側に位置する競走馬ほど速く到着しており,上側に位置する競走馬ほどラストスパートが速かったことを示します.これにより各競走馬の脚質適性を見ることが狙いです.
マーカーの色は獲得賞金を表します.獲得賞金が高いほど明るい黄色になります.どのような距離適性・脚質適性の競走馬が稼いでいるか直感的に眺めることが狙いです.
補助線として,灰色の破線で
明るい黄色のマーカーのほとんどが明るい破線より上側に存在することがわかります.やはり日本競馬においては,上りをいかに速く走るかが重要なテーマのようです.
また,当たり前ですが距離が長くなるほどレース全体の平均の速さが落ち込み,全体として縦に潰れた分布になることがわかります.短距離レースがほぼ補助線
ところで…,
これは何でしょう?
上記のNotebookで簡単に分析したところ,このクラスターに属する351レース[34]は全て1993年頃までに開催されたものであることがわかりました.上りの平均の速さが3/4程度になっていることから,上り3ハロンではなく4ハロンで計測されたタイムなのではないかと想像しています[35]が,裏が取れませんでした.以下に該当するレース一覧を格納しておりますので,ピンと来た方はコメント頂けますと幸いです.
競走馬
競走馬ごとの戦績をもとに,散布図を作成しました.
作図対象としたのは,下記いずれかの条件を満たす競走馬です:
- 神ゲー攻略,ウマ娘攻略Wikiに掲載されている[36]ウマ娘のモデル[37]であること
- 35年間の合計獲得金額の上位50位に入っていること
例えばゴールドシップは
ゴールドシップの戦績
ほとんどの点が補助線
一方でツインターボは,
ツインターボの戦績
ほとんどの点が補助線
他の競走馬についても眺めて頂けると幸いです.私は何気なく開いたアーモンドアイが凄すぎて驚いてしまいました.
競走馬同士の比較
私の独断と偏見で選定した,下記の6ペアについて比較した散布図を作成しました.
ums_vs = [
['トウカイテイオー', 'メジロマックイーン'],
['スペシャルウィーク', 'サイレンススズカ'],
['ウオッカ', 'ダイワスカーレット'],
['キタサンブラック', 'サトノダイヤモンド'],
['ライスシャワー', 'メジロマックイーン'],
['トウカイテイオー', 'ツインターボ'],
]
例えば,トウカイテイオーとメジロマックイーンの散布図は下図のようになります.見やすいよう,中距離と長距離の散布図を拡大していることにご注意ください.
トウカイテイオー(☆)とメジロマックイーン(△)
タイトル
タイトルごとに,入賞した競走馬の散布図を作成しました.
ファイル名はscatter_{グレード}_{タイトル名}.html
というフォーマットに従っています.35年間でグレードが変動したタイトルについては,全てのグレードを降順[38]に記載しました(例:scatter_G1_G2_G3_スプリンターズS.html
).
例えば,有馬記念の散布図は下図のようになります.見やすいよう,長距離の散布図のみ拡大していることにご注意ください.
有馬記念の歴代入賞馬
適性
適性ごとに,それぞれ評価がAの競走馬の散布図を作成しました.
例えば,追い込み適性がAの競走馬の散布図は下図です.殆どのマーカーが補助線
追い込み適性Aの競走馬
今後の課題
上りタイムクラスターの原因特定
なんとなく開催年と開催場所にヒントがありそうですが,現時点では原因を特定できていません.
統計的仮説検定
競馬界で囁かれている定説について,数理統計的な観点での裏付けに挑戦したいです.
- 日本競馬の高速化
- 先行脚質のある馬は外枠で不利
- 「芦毛は走らない」[39]
きちんと調査すればどこかで誰かが検証している気がしますが,統計的仮説検定の練習としてやってみたいです.
ダッシュボード化
そもそもこういったデータはクソデカHTMLとしてGitHubで公開するのではなく,ダッシュボード化して,誰でも任意の切り口で集計・分析できるようにしてあると親切です.それこそPlotly Dashをサーバー上に構築しても良いですし,Google Data Studio等でサーバーレスに実現しても良いでしょう.
ただ,これを突き詰めていくとnetkeiba スーパープレミアムコースと競合しそう[40]で….本家に迷惑をかけるのは本意でないので,対応を検討中です.
おわりに
ジョージア工科大学の合格通知を頂いてからおよそ1週間後に,軽度の頚椎椎間板ヘルニアを診断され,趣味だったジョギングを止めなければならなくなりました.かなり落ち込んでいたのですが,ウマ娘や競走馬の走りを見て,大きく勇気づけられました.少しでもその魅力が伝わるよう,今回は稚拙ながら記事を書かせて頂きました.
分析はデータがないことには始まりません.35年以上前から,良質なデータを取得し続けてくださった競馬関係者の皆様に感謝申し上げます.いつも面白いレースをありがとうございます.また,ウマ娘関係者の皆様,このような素晴らしいコンテンツを発見するきっかけを与えてくださり,ありがとうございます.今後もウマ娘を応援し続けます!
Appendix:実行環境の構築
本記事の処理の一部をNotebookにまとめ,下記で公開しています.
Python等の環境はnotebooks/Dockerfileに記載の通りjupyter/datascience-notebook:2021-11-04
イメージに準拠しています.
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
の
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"
最終行を適宜修正し,必ずパスワードを変更してください.
-
厳密にはnetkeibaから35年分の中央競馬の全結果を取得し,レース名に
[G1, G2, G3, G, L]
のいずれかが付与された平地競走のみフィルタリングした上でプロットしました. ↩︎ -
2021年11月に日経トレンディおよび日経クロストレンドから発表された「2021年ヒット商品30」では2位に選出されました.また,Cygamesはおろか,親会社のサイバーエージェントのゲーム事業の売上高が過去最高を更新したことにも驚きました. ↩︎
-
イラストだけで競走馬を当てる企画が成り立つほど ↩︎
-
ゲーム中,ナイスネイチャとトレーナーの距離が近いのは,モデルとなった競走馬のナイスネイチャが担当厩務員に非常になついていたからだ,という説があります. ↩︎
-
有名な余談ですが,ウマ娘の世界では四本脚で走る「馬」という概念が存在しないため,「馬」の代わりに「バ」あるいは「(馬のれっかを点二つに置き換えた架空の漢字)」が使用されています. ↩︎
-
今回は初期値を利用しました.継承により適性を向上させ,最終的にA以上の「S」に到達することも可能らしいです. ↩︎
-
「牝」(メス)「牡」(オス)の他に「セ」(騸馬,去勢された牡馬)という概念があり,驚きました. ↩︎
-
35年分の中央競馬のデータのうち,何らかの理由で着順に数字以外が入っていたレース数は合計で13143(約11%≒13143/119898)に上りました.結構ありますね,何か処理をミスっているかもしれません. ↩︎
-
2021年11月9日時点. ↩︎
-
平地競走に関する区分です.障害競走の格付けは少し異なり,J.G1,J.G2,J.G3となります.https://www.jra.go.jp/kouza/yougo/w323.html ↩︎
-
1986年から2020年までのレース結果のみから判断しているため,運悪く全盛期がこの期間にかぶらなかった競走馬(例えば2020年にメイクデビューして,2021年以降に重賞に出始めた馬)に関しては不利な集計になっています.そのあたりの境界条件を考慮すると,正確にはもう少し大きな数値になると思いますが,今回は目をつぶりました. ↩︎
-
高知競馬場を中心に活躍したハルウララとか. ↩︎
-
しかも無印の
フェアウェルS
も存在しました.1995年以降はレース名に略西暦がつかなくなったようです. ↩︎ -
オールスターJ第{n}戦に関しては,nに応じて出馬条件が異なるようだったので別タイトルとして集計するか悩みましたが,細かくなりすぎても扱いきれないため,同一タイトルとしました. ↩︎
-
ヤングJSFR中山{n}に関してはnによって出馬条件が異なるようでしたが,オールスターJ第n戦と同様に同一タイトルとしました. ↩︎
-
読みは「あがり」です.ざっと調べたところ「上がり」という送り仮名が正しそうでしたが,netkeibaの列名「上り」を尊重して本記事では「上り」を採用しています(途中で気づいたけど書き直す気力がなかった). ↩︎
-
デモ動画中では何気なく「速度」という表現を使っていましたが,ベクトルではなくスカラーなので「速さ」という表現が正しいです.ごめんなさい,デモを撮り直す気力はありませんでした.ご了承ください. ↩︎
-
S(Sprint: 1000m - 1300m),M(Mile: 1301m - 1899m),I(Intermediate: 1900m - 2100m),L(Long: 2101m - 2700m),E(Extended: 2701m以上) ↩︎
-
厳密には1200m - 1600m未満と定義されていますが,我々の世界には1000mの名レースもたくさん存在するため,便宜的に拡張しました. ↩︎
-
たとえ距離が同じであっても,そのレースによって対抗馬や体調や天候は異なります.原理的にフェアな比較は不可能という前提で見て頂けると幸いです. ↩︎
-
この外れ値なんだっけ?みたいなときにすごく便利です. ↩︎
-
エクセルでも同じことができるのですが,データの規模が大きくなってくると動作がもたついてくること,元データを全て公開する必要があること,またマクロを組まなければ自動化が難しいこと,(そして私のエクセル力が極端に低いこと,)からPlotlyを使うことが多いです. ↩︎
-
subplotの順序を制御するのが難しかったり,subplotを跨いだcolorbarの表示が崩れたりしました. ↩︎
-
最初に
fig
オブジェクトを作って,それにadd_trace
でグラフを追加していくのは,Plotlyによる作図のよくあるパターンの一つです.Plotly Expressはその辺すら簡略化してくれますが. ↩︎ -
タイムの精度が小数点以下1桁で,走行距離のバリエーションも多くないので,もっと圧縮できると思っていましたが…. ↩︎
-
厳密にクラスタリングしたわけではなく,「最も上りタイムが速かった競走馬の平均の速さが50[km/h]未満のレース」という雑な条件で抽出しました.結果を見る限り,そこまで悪くない分離方法だと思います. ↩︎
-
未実装(サポカ等で他のウマ娘のストーリーには登場するが,育成対象ではないウマ娘)を含みます. ↩︎
-
ただし,競走馬のデータを取得できなかったウマ娘が4人います.まずシンボリルドルフ,マルゼンスキー,ミスターシービーについては,活躍した時期が今回の調査期間と重ならなかったため,データを取得できませんでした.そしてハルウララについては,中央競馬での出走記録がなかったため,データを取得できませんでした. ↩︎
-
アルファベット順における降順ではなく,グレードの格付け
[G1, G2, G3, G, L]
における降順. ↩︎ -
思い上がりも甚だしいですが. ↩︎
-
ローカルのJupyterと衝突しないよう9999番にフォワーディングしています.こちらも
docker-compose.yml
から適宜変更してください. ↩︎
Discussion
面白いですね……。例えば、アニメの題材にもなった第105回天皇賞ですが、先頭の通過ラップが計測されており、レースの後半のラップタイムは、残り800m手前で先頭に立っていたメジロマックイーンのラップタイムと一致するはずです。
確認すると、メジロマックイーンの「上り」となっている 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ハロンで間違いないと思います.助かりました!ありがとうございます!
なお,分析詳細は下記に追記しました.
一箇所コメントを忘れていました。
以下に記載がありますが、アングロアラブ系の重賞がこの2つしかなかったからです。
こちらに関してもありがとうございます!(こんなページがあったんですね…!)コメントを受けて本文を修正しました.クラスターの件も時間が取れ次第本文に反映予定です.