ココンの情報をいつでも、どこでも。ココントコ。

エンジニア 2018.11.14

競馬予想AI再び -後編- 〜アンサンブル学習編〜

AI戦略室の坂本です。
あまりに気合いを入れて作成すると、社内での自分の評価が「競馬予想の人」になってしまいそうで躊躇がある競馬予想AIですが、ここまで作った以上最後まで解説をしようと思います。(一応、最新の機械学習アルゴリズムについての調査と評価という意味合いもあると言うことで・・・)
前編ではLambdaRankによる競馬予想の、基本的な考え方と学習の仕組みについて解説しました。後編である今回は、前編で作成したAIを、予想がより当たるように改良するために、より多くのデータを用意することと、アンサンブル学習の手法を使う部分について解説をします。

目次

馬のデータ履歴を作成する

人間が馬券を買う場合、どのような情報を重視するかは、人それぞれ違っていることでしょう。
しかし競馬情報サイトを見る限り、出走する競走馬のこれまでの平均着順だとか、獲得賞金総額だとかの情報が掲載されているので、そうした情報もレースの予想には有用なのでしょう。
また、人間にとっては一度に扱う事が出来ないほど大量のデータも、機械学習であれば容易に扱う事が出来るため、レース結果に影響を及ぼしそうなデータは出来るだけ沢山集めて、学習データの一部とすることで、予想の精度を向上させる効果が期待できます。
レース結果に影響を及ぼしそうなデータとしては、まずは前述した、出走馬の平均着順だとか、獲得賞金総額だとかの情報が挙げられます。
そうしたデータは馬毎に用意しておいて、出走馬のメタデータとして扱えば良いのですが、しかし、実はそこには落とし穴があって、単純に最新の獲得賞金総額などからデータを用意しても、学習データとしては使用してはいけないデータになってしまいます。
例えば馬の獲得賞金総額は着順と相関の強い有望なパラメーターですが、「現在の」獲得賞金総額をその馬のメタパラメータとしてしまうと、「現在の」獲得賞金総額を使って「過去の」レースを学習することになってしまいます。
言い換えるなら、「強いと解った後の馬のパラメーターを使用して、強いかどうか解らなかったときのレースの学習を行う」という感じでしょうか。そうして出来たモデルで「今現在強いかどうか解らない馬の結果」を予測しても、「すでに強いと証明された後の馬」の方が上位に来るので、正しくない結果が出ることになります。
機械学習プログラムを作るときには、そのデータが「学習させて良いデータ」なのか、きちんと考えるようにしないと、かえって悪い結果になるという一例です。特にこれは、過去のレースからK-分割などで抽出したテストデータでは見つからない訳なので、とても厄介なパターンです。

ではどうするのかというと、学習データの一つ一つに対して、「そのレースの時点での」馬の情報、つまり「その時点での」平均着順だとか、獲得賞金総額の情報を入手する必要があります。
それには、その馬の戦歴から、過去に遡って累計を新たに計算し直さなければなりません。
面倒ですが馬の戦歴はウェブからスクレイピング可能なので、データを用意することは出来るでしょう。
ここでは以下のように、一頭の馬に対して、「ある年のある月時点での戦歴」を用意することで、馬のメタデータとして利用できるようにしました。
メタデータは、平均着順や獲得賞金総額などのデータと、(その時点の)直近3レース分の平均、さらにそれらの、競馬場別のデータ、馬場の種類別のデータ、天気別のデータ・・・などを用意し、合計218個の数値データとします。また、その数値の最初の15個は血統の種類で、カテゴリカルなデータとなります。

実際のデータファイルのない方は以下のようになっており、各行に「馬の名前YYMM」の形式で、月時点での戦歴を表す数値データが並んでいます。
月ごとのデータなので完璧な正確さではないですが、競走馬の出走間隔から考えてこの程度の粒度で十分なはずです。

$ cat horse_history.csv
ショウナンアーリー1601,261,5,0,5,0,1,・・・
ショウナンアーリー1602,261,5,0,5,0,1,・・・
・
・
・
ゲマインシャフト1811,1109,1,0,1,0,6,・・・
ゲマインシャフト1812,1109,1,0,1,0,6,・・・

メタデータを取得する関数は以下のようになっていて、馬の名前とレースの日付から、その時点の馬のメタデータを返します。
指定された日付時点のメタデータが見つからない場合は、日付を遡り、最も直近のデータを返すようになっています。

def get_horsemeta(name, date):
	if not use_history:
		return []
	else:
		if len(date) == 10 and date.replace("/","").isdigit() and date[4] == '/' and date[7] == '/':  # YYYY/MM/DD
			sdate_y = date[2:4]
			sdate_m = date[5:7]
		elif date.isdigit() and len(date) == 8:  # YYYYMMDD
			sdate_y = date[2:4]
			sdate_m = date[4:6]
		elif date.isdigit() and len(date) == 6:  # YYMMDD
			sdate_y = date[0:2]
			sdate_m = date[2:4]
		else:
			print('date format error: %s'%date)
			return []
		colname = name + sdate_y + sdate_m
		if colname in df_history.index:
			return df_history.loc[colname].values.tolist()
		else:
			date_y = int(sdate_y)
			date_m = int(sdate_m)
			for i in reversed(range(1,date_m)):
				colname = name + sdate_y + '%02d'%i
				if colname in df_history.index:
					return df_history.loc[colname].values.tolist()
			for i in reversed(range(16,date_y)):
				for j in reversed(range(1,13)):
					colname = name + '%02d%02d'%(i,j)
					if colname in df_history.index:
						return df_history.loc[colname].values.tolist()
			return np.zeros((len(df_history.columns),)).tolist()

学習データを拡張する

さて、こうして全ての馬に対して、過去の全ての月の戦歴データが用意できました。
用意したデータをそのまま学習させる前に、データ内に何らかの意味合いが含まれていそうかどうかだけでも、簡単に確認しておくことにします。
とは言っても、200次元以上あるデータを人間が全て確認して傾向を見ることは不可能なので、まずは、PCA、TruncatedSVD、GaussianRandomProjection、SparseRandomProjectionの次元削減アルゴリズムを使用して、データの次元数を圧縮します。
それぞれ10次元程度の次元数へとデータを圧縮すれば、10次元なら2次元上の散布図5枚にプロットできるので、ぱっと見の形でデータを確認することが出来ます。
その結果が下の図で、上から順にPCA、TruncatedSVD、GaussianRandomProjection、SparseRandomProjectionのアルゴリズムによる次元削減の結果を、2次元ずつ横に並べて散布図で表しています。


また下の散布図はその中のGaussianRandomProjectionでの結果について色分けしたもので、色の意味は上段から、出走したレース数、平均順位、平均人気、平均オッズとなっています。

こうした、「そこにどのくらいの意味が含まれているか解らない雑なデータ」を扱う際の手法として、こうして次元削減アルゴリズムでデータの傾向を確認するというのは、割と役に立つ場合が多いです。
何も考えずに用意したデータの場合、何の傾向も見いだせない(次元削減すると一点に集中してしまうなど)場合も多く、そうしたデータを使用しても学習精度の向上は望めません。
また、次元削減した後のデータを、元のデータとマージして学習データの次元数を増やすことも出来ます。

ここでは元々の218次元に加えて、次元削減アルゴリズムで作成した合計40次元のデータを合わせて258次元のベクトルデータを、馬のメタデータとして利用することにします。
そのためのコードは以下の部分で、メタデータのファイルがあればそれを読み込み、次元削減アルゴリズムを適用した後、Pandasの「join」関数を使用して元のデータにマージしています。

df_history = pd.DataFrame()
if len(history_file) > 0 and os.path.isfile(history_file):
	X = pd.read_csv(history_file, index_col=0, header=None)
	use_history = True
	pca = PCA(n_components=10)
	pca_X = pd.DataFrame(pca.fit_transform(X), index=X.index, columns=['pca%d'%i for i in range(10)])
	tsvd = TruncatedSVD(n_components=10)
	tsvd_X = pd.DataFrame(tsvd.fit_transform(X), index=X.index, columns=['tsvd%d'%i for i in range(10)])
	grp = GaussianRandomProjection(n_components=10, eps=0.1)
	grp_X = pd.DataFrame(grp.fit_transform(X), index=X.index, columns=['grp%d'%i for i in range(10)])
	srp = SparseRandomProjection(n_components=10, dense_output=True)
	srp_X = pd.DataFrame(srp.fit_transform(X), index=X.index, columns=['srp%d'%i for i in range(10)])
	df_history = X.join([pca_X, tsvd_X, grp_X, srp_X])
	del pca, tsvd, grp, srp, pca_X, tsvd_X, grp_X, srp_X

Query毎の交差検証

作成したメタデータは、それぞれのレースにおける馬のデータとして、学習データに追加されることになります。

そうして作成された学習データは、LightGBMなどの決定木アルゴリズムを使用してRambdaRankによる学習が行われますが、それらのアルゴリズムでは学習データとテスト用データの二種類のデータを必要とします。
そうしたデータは通常、交差検証の手法を使用して元のデータを分割することで作成するのですが、RambdaRankを使用する場合、単純には学習データを分割できません。
というのも、RambdaRankではQueryデータを必要とするので、Queryデータの区切りで分割するように学習データとテスト用データを用意しないと、データの整合性が保たれなくなってしまいます。

そこで、実行速度は遅くなりますが、元々のレース毎のデータを「KFold」で分割した後、Queryデータと学習データを新しく抽出し直すようにしました。
なお、このコードは実行効率という意味では効率が悪く、最初に全データを抽出して適切な区切りで分割する方が、もっと高速に動作するはずです。

for fold_id, (train_index, test_index) in enumerate(KFold(n_splits=10).split(all_races_train)):
	all_races_train_train = all_races_train[train_index]
	all_races_train_valid = all_races_train[test_index]
	all_races_rank_train_train = []
	all_races_query_train_train = []
	all_races_target_train_train = []
	all_races_rank_train_valid = []
	all_races_query_train_valid = []
	all_races_target_train_valid = []
	get_race_gets(all_races_train_train, all_races_rank_train_train, all_races_query_train_train, all_races_target_train_train)
	get_race_gets(all_races_train_valid, all_races_rank_train_valid, all_races_query_train_valid, all_races_target_train_valid)
	all_races_rank_train_train = get_matrix(np.array(all_races_rank_train_train))
	all_races_query_train_train = np.array(all_races_query_train_train)
	all_races_target_train_train = np.array(all_races_target_train_train)
	all_races_rank_train_valid = get_matrix(np.array(all_races_rank_train_valid))
	all_races_query_train_valid = np.array(all_races_query_train_valid)
	all_races_target_train_valid = np.array(all_races_target_train_valid)

アンサンブル学習を行う

学習データの種類を増やすだけではなく、アンサンブル学習の手法も使って予想精度の向上を図ります。
RambdaRankを使用できるアルゴリズムには、LightGBMの他にもXGBoostがあるので、ここではLightGBMとXGBoostの二種類の結果をアンサンブルするようにします。

ただし、XGBoostはLightGBMとはことなり、カテゴリカルなデータを扱うためのオプションが用意されていないので、それらのデータをOne-Hotベクトル化するためのコードは自分で用意する必要があります。
それは以下の関数で実装されています。全てNumpyの行列で計算するので粗行列を使用するのに比べメモリ効率は良くないですが、XGBoostは粗行列をサポートしていないので、どちらにせよ密な行列化する必要が出るのでトータルとしては同じことです。

def get_matrix(mat):
	shape = list(mat.shape)
	shape[1] += int(np.sum(categorical_dim)) - len(categorical_feature)
	matrix = np.zeros(tuple(shape))
	cur_dim = 0
	cur_ind = 0
	while cur_dim < shape[1]:
		if cur_ind in categorical_feature:
			dim = categorical_dim[categorical_feature.index(cur_ind)]
			for z in range(shape[0]):
				matrix[z,cur_dim+int(mat[z,cur_ind])] = 1
			cur_dim += dim
		else:
			matrix[:,cur_dim] = mat[:,cur_ind]
			cur_dim += 1
		cur_ind += 1
	return matrix

XGBoostで学習を行う箇所は以下の部分で、パラメーターに「’objective’: ‘rank:pairwise’」を使用することで、ランキング学習を行うことが出来ます。

xgb_params =  {
	'objective': 'rank:pairwise',
	'eta': 0.1,
	'gamma': 1.0,
	'min_child_weight': 0.1,
	'max_depth': 6
}
xgtrain = DMatrix(all_races_rank_train_train, all_races_target_train_train)
xgtrain.set_group(all_races_query_train_train)
xgvalid = DMatrix(all_races_rank_train_valid, all_races_target_train_valid)
xgvalid.set_group(all_races_query_train_valid)

xgb_clf = xgb.train(
	xgb_params,
	xgtrain,
	num_boost_round=10,
	evals=[(xgvalid, 'validation')]

結果

最後に、LightGBMとXGBoost、それにアンサンブル学習で使用するアルゴリズムを分けて、結果を表示する部分のコードです。
どのアルゴリズムを使用するかは、コマンドラインの「-a」オプションで指定できるようにしました。

if __name__ == '__main__':
	if use_algorizm=='xgb':
		 main_xgb(0)
	elif use_algorizm=='lgbm':
		 main_lgbm(0)
	elif use_algorizm=='ensemble':
		 main_xgb(0)
		 main_lgbm(10)
	main_emsemble()

アルゴリズムにアンサンブル学習を指定して、2000レースと500レース分の学習データとテスト用データを用意すると、予想結果のシミュレートは以下のようになります。
なお、ここでの学習には乱数が使われるため、同じデータを学習させても同じ結果になるとは限りません。当たり数、リターン率共に、ある程度の揺らぎが発生します。

$ python3 tipster-ensemble.py -a ensemble -i horse_history.csv -t race_train.csv -e race_test.csv
払い戻し予想: [レース数500]
	単勝	複勝	複勝(1枚)	馬連	ワイド	ワイド(1枚)	馬単	三連複	三連単
オッズ:62.88	74.48666666666666	79.54	76.68	60.1	59.08	94.6	107.32	107.84
当り数:90	591	232	45	202	82	28	23	7

結果は、前編の最後で紹介した結果よりも、当たり数が増えている=予想精度が向上していることが解ります。
オッズから計算したリターン率も100を超えている馬券がありますが、リターン率についてはあくまで参考値程度に捉えるのが良いでしょう。それよりも当たり枚数を見る方が実態に近く、例えば三連複馬券で500レースあたり23枚当たりということは、平均して一度の週末に2〜3枚程度の三連複馬券が当たるという感覚になります。

$ python3 tipster-ensemble.py -a ensemble -t race_train.csv -i horse_history.csv -m "中京|芝|晴|2000|20180114" -r "ハナソメイ|藤岡康太,ナムラムツゴロー|松若風馬,ナポリタン|川島信二,トウカイシーカー|藤田菜,サトノディード|菱田裕二,エポドスミノル|国分恭介,メイショウロセツ|岩崎翼,サイモンサーマル|富田暁,サーム|木幡巧,ジョーカー|川須栄彦,ダノンパーフェクト|松岡正海,ジュピターカリスト|荻野極,プレビアス|小崎綾,サミットプッシュ|丸山元気,エコロレインボー|横山武,サトノグリッター|松山弘平,シーアフェアリー|森裕太,プラードデラルス|中谷雄太"
1着予想:サトノグリッター	0.0114991590497
2着予想:ハナソメイ	0.0118661173314
3着予想:サミットプッシュ	0.0130087168887
4着予想:プラードデラルス	0.0138790182769
5着予想:ジュピターカリスト	0.0253272172995
・・・

また、特定のレースの予想を行うには、上のようにします。

まとめ

機械学習アルゴリズムを使用しているとは言え、このような予想プログラムを「AIである」と言い切って良いものなのかは解りません。しかし、「こんなことが出来る」という実例として、競馬の予想というものは非常にウケが良く、鉄板ネタとして便利なので、なんだかんだ言いながら続編を作ることになりました。
今回は前編後編の2回にわたり、RambdaRankを使用したアンサンブル学習による競馬予想AIを作成しましたが、前回のニューラルネットワークと基本的な発想は同じながら、全く異なるアルゴリズムをベースに、予想精度としてもだいぶ異なる結果が出てくることになりました。
そのことからも解るように、機械学習によるAIの作成では、一つの手法のみが正解と言うことはなくて、いくつもの手法を選択しうるということ、そして最新の手法やアルゴリズムについての引き出しをどれだけ持っているかなど、そうしたことが、出来上がるAIの品質に決定的な影響を与えることになる訳です。
今回の内容も、ランキング学習の使い方や、利用するデータの考え方などについて、応用範囲が広いので、ぜひ参考にしてください。
なお、今回紹介したAIを使用したとしても、馬券で勝つことが出来るとは、坂本もココングループも全く保証しません。馬券の購入はあくまで自己責任の上、ギャンブルであることを自覚して適度に楽しみましょう。

イベントインタビュー広報 2020.01.31

「OpenID Summit Tokyo 2020」参加レポート

広報の馬場です。4年に一度のOpenIDの祭典「OpenID Summit Tokyo 2020」が渋谷ストリーム・ホールにて1月2…

イベントインタビュー広報 2019.11.11

世界のハッカーが競う「DEF CON CTF Finals 2019」参加者インタビュー

広報の馬場です。 DEF CONの人気コンテンツの一つである「DEF CON CTF」は、熾烈な予選を勝ち抜いたチーム…

エンジニア 2019.08.23

危険すぎると話題の文章生成モデル「GPT-2」で会社紹介文を生成してみました

ココングループのAI TOKYO LABの細川です。AI、特にディープラーニング系のホットな技術を実際に動かすと…