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

エンジニア 2018.11.14

競馬予想AI再び -前編- 〜LambdaRank編〜

AI戦略室の坂本です。
元はといえば忘年会の余興から始まった競馬予想AIですが、ブログ記事のアクセス数も多く、予想以上に注目されている感じです。
実は以前のモノは、所詮余興というノリで作ったAIなので、色々と不完全な部分もあったのですが、方々からリクエストがあったので、もう少しだけきちんと考えて予想AIを作り直すことにしました(本当は次の忘年会までネタとして取っておきたかったのですが・・・)。
なお、ここで紹介する競馬予想AIのソースコード全体は、例によってGitHub(https://github.com/cocon-ai-group/turf-tipster2/)で公開しています。記事中で紹介するのはコードの断片のみなので、全体を見ながら記事を読む方が、より理解が容易となるでしょう。

目次

LambdaRankとは

前回では、独自に定義した損失関数を使ってニューラルネットワークを学習させることで、「馬の名前から強さベクトルを作成する」ニューラルネットワークを作成しました。
実はこのような、教師データから序列を求める機械学習はランキング学習と呼ばれ、様々なアルゴリズムが存在しています。
代表的にはRankNet・NNRank、LambdaRankなどがあり、いずれも損失関数の形を工夫することで、「強さベクトル」に相当する数値を出力させることが出来ます。
中でもLambdaRank(https://dl.acm.org/citation.cfm?doid=1571941.1572021)は、マイクロソフトの研究者が考案して公開したアルゴリズムで、(おそらく)検索エンジンBingのランキングシステムのベースとなっているであろう、優れたアルゴリズムです。
このLambdaRankを、こちらでも紹介した決定木を使用したアルゴリズムに対して適用させたモノを、LambdaMART(https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/MSR-TR-2010-82.pdf)と呼び、ニューラルネットワークを使用した機械学習よりも軽量で、大きなデータに対して適用することが容易になっています。
さて、このように色々と優れたアルゴリズムがある以上、それを使用して競走馬の序列を学習させれば、前回と同じ発想による競馬予想AIが出来るだろう、ということで、今回はLambdaRank/LambdaMARTによる競馬予想AIの作成について解説します。
ちなみにランキング学習のアルゴリズムを使用する以上、ここでは前回と同様に、「強い馬は上の順位に位置することが多いだろう」という前提に立って競馬予想AIを作成します。
つまり、全て結果となる順位のみから学習することになるので、追い込み馬や先行馬などの脚質、馬同士の相性などについては考慮されません。また前回指摘したように、「勝つときは大勝ちするが負けるときは大負け」のような馬については前提の対象外になります。そのため実際の競馬レースを本当に正しくモデル化出来るのか、という疑問もありますが、大量のデータを学習させることでどの程度の予測が可能か、まずは試してみたいと思います。

LambdaRankによる学習の基本

まずは競馬予想AIに必要な、機械学習の部分について解説しようと思います。
手始めに、前回同様の小さなデータ(馬と騎手の名前、競馬場の名前、馬場の種類、天気、距離)のみを学習させる例について考えてみましょう。
RambdaRankを使う場合、学習データの単位はランキングされる一つ一つの要素です。つまりここでは、レース一つではなく、レース内に出走する馬の一頭一頭が、学習データの一単位となります。
そして、学習ターゲットはランキングの数値で、ここではレース結果の着順が学習ターゲットに当たります。まくって勝っただとか先行逃げ切りレースだったとかは学習しません(というかデータが無いです)。
通常の回帰とは異なっている箇所として、RambdaRankを使用する場合に必要な「Queryデータ」というデータが存在する点が挙げられます。このQueryデータは、ランキング学習におけるランキングの単位(学習データの単位ではなく)を表すデータで、そのランキングに含まれている学習データの数を表すデータとなります。

つまり上の図では、2レースで7頭の馬が学習データとなるため、学習データの数は7個で、それぞれ6次元で7×6サイズの配列が、学習データのサイズとなります。
そしてQueryデータは2レース分なので2個のデータを持つ一次元配列で、それぞれの値は4と3になります。Queryデータの合計は学習データの数と一致しなければなりません。

ここでは馬と騎手の名前の他に、4つのパラメーターのみをレースのメタデータとして追加していますが、きちんとスクレイピングさえしてやれば、他にも気温や湿度や風向きや前日の株価や太陽黒点の数など、色々なデータを追加することも出来ます。
そうしたデータの選別が今回の手法でのAI作成では重要で、AIの個性をもたらず原因となる訳なので、このプログラムを元に独自の競馬予想AIを作成したいという方は、色々とデータの取り扱いについて考えてみると面白いでしょう。

学習データの用意

それでは実際のアルゴリズムとプログラムコードについて解説します。
競馬レースのデータについては前回と同様、JRAのデータサービスであるJRA-VAN(https://jra-van.jp/)等から入手します。今回も、前回用意したデータと共通のフォーマットを使用します。
レース情報には、レースのメターデータと、出走馬情報のリストが含まれています。

レース情報:

レース名|競馬場|馬場|距離|天気|オッズ|日付

出走馬情報:

着順|馬名|騎手名

csvデータ:

レース情報,出走馬情報,出走馬情報,出走馬情報・・・

例:

$ head -n1 race_train.csv
新潟日報賞|新潟|芝|1400|曇|600:230_160_980:410:1210:490_6730_3340:2700:22900:102190|20170812,1|アポロノシンザン|津村明秀,2|ビップライブリー|大野拓弥,3|ディアマイダーリン|田中勝春,4|ラプソディーア|吉田豊,5|ルグランパントル|柴田善臣,6|メイショウメイゲツ|江田照男,7|ネオスターダム|内田博幸,8|ピンストライプ|M.デム,9|ナイトフォックス|丸田恭介,10|テンテマリ|丸山元気,11|エリーティアラ|北村宏司,12|クリノタカラチャン|野中悠太,13|スズカシャーマン|伊藤工真,14|スズカアーサー|武士沢友,15|オヒア|小崎綾也,16|マリオーロ|吉田隼人,17|フレンドスイート|柴田大知,18|ディープジュエリー|石橋脩

このCSVファイルを読み込むのは、以下の関数です。
内容は簡単なモノで、CSVの各カラムから全てのデータを取り出して引数のリストへ追加するのと、全ての馬の名前、騎手の名前、競馬場の名前、馬場の種類、天気の種類をラベル化するためにリストアップしておくものになります。

def read_file(file, all_races):
	with open(file, 'r') as csvfile:
		# データを取り出して追加
		csvreader = csv.reader(csvfile)
		# ラベル用にリストアップしておく
		for race in csvreader:
			all_races.append(race)
			race_meta = race[0].split('|')
			if len(race_meta) > 5:
				all_where_str.append(race_meta[1])
				all_baba_str.append(race_meta[2])
				all_tenki_str.append(race_meta[4])
			for e in range(1, len(race)):
				entry = race[e]
				result = entry.split('|')
				if len(result) >= 3:
					all_horse_name.append(result[1])
					all_jockey_name.append(result[2])

上記関数でデータを読み込んだら、全ての馬の名前、騎手の名前、競馬場の名前、馬場の種類、天気の種類について以下のようにLabelEncoderを作成しておきます。

horses_i = LabelEncoder().fit(horse_names)
jockeys_i = LabelEncoder().fit(jockey_names)
where_i = LabelEncoder().fit(all_where_str)
baba_i = LabelEncoder().fit(all_baba_str)
tenki_i = LabelEncoder().fit(all_tenki_str)

そうして、各レースのデータを分解して数値データ化するために、以下の関数を作成しました。
この関数は、レース情報のリストを引数に取り、同じく引数で与えられるリストに、分解したデータを追加してゆきます。
学習データのターゲットは、レースの着順となりますが、ここではmax_positionで最大値を指定することで、「ある一定以下の下位馬は全部同じ扱い」にしています。
重要なのが、ソースコード中にある、「ターゲットの順序をシャッフルする」という行です。
なぜならば、レース結果から作成されるCSVデータは着順に並んでいるのですが、そのまま学習させてしまうと、LightGBMは「枠順の上の馬ほどランクが高い」という風に学習をしてしまうのです!(実際作ってみて、このような挙動をするとは予想外でした。RambdaRankの特性なのかLightGBMの特性なのかは解りませんが、前回のようにニューラルネットワークでバッチサイズ方向にデータを並べる場合は起こりえない嵌まりポイントです)
そこでレース毎に枠順をランダムに並び替えることで、枠順は順位とは関係ないという事を学習させています。(本来は出走時の枠順で並べるべきでしょうね。そのデータは入力ファイル内に存在しないのでやりませんでしたが・・・そうそう、このAIは枠順毎の有利不利も無視します)

def get_race_gets(all_races, all_races_rank, all_races_query, all_races_target):
	for race in all_races:
		# レースのデータを読み込む
		race_meta = race[0].split('|')
		where_num = where_i.transform([race_meta[1]])[0]
		baba_num = baba_i.transform([race_meta[2]])[0]
		tenki_num = tenki_i.transform([race_meta[4]])[0]
		len_num = int(race_meta[3])
		# ターゲットのバッファ
		target = []
		# レース内の出走馬をターゲットに追加
		for e in range(1, len(race)):
			entry = race[e]
			result = entry.split('|')
			horse_num = get_horse_i(result[1])
			jockey_num = get_jockey_i(result[2])
			target.append(([horse_num, jockey_num, where_num, baba_num, tenki_num, len_num], min(e, max_position)))
		# ターゲットの順序をシャッフルする
		random.shuffle(target)
		# Queryデータを作る
		all_races_query.append(len(target))
		# 学習データとターゲットを追加
		for tgt in target:
			all_races_rank.append(tgt[0])
			all_races_target.append(tgt[1])

ソースコードの細々とした箇所の解説は省きますが、こうしてレースのデータをRambdaRankで学習可能なデータ形式で読み込めば、後は機械学習を行うだけとなります。

LightGBMによる学習

学習データとなる、馬と騎手の名前、競馬場の名前、馬場の種類、天気、距離のうち、距離を除くデータはカテゴリカルなデータ、つまり連続量ではなくどれか一つを選択するタイプのデータです。
そういった場合、機械学習アルゴリズムではOne-Hotベクトルとして、種類の数だけのサイズで対象の次元のみが1となるベクトルデータを用意することで精度の向上が図れます。
つまり、馬の名前について言えば、馬に相当する数値(1とか2とか)は一つの値として表現できますが、学習させるときには馬の数と同じ次元数を持つベクトル(馬が全7頭の場合:1は[1,0,0,0,0,0,0]に、2は[0,1,0,0,0,0,0]になる)で馬を表現する事になります。
そのようなベクトルは手動で作成しても良いのですが、幸いにLightGBMでは「categorical_feature」オプションを使用することで、「そのデータはカテゴリカルなデータだ」と教えてやることが出来ます。「categorical_feature」オプションで指定されたデータは、内部でOne-Hotベクトルとして扱われることになります。
「categorical_feature」オプションを指定して、RambdaRankでLightGBMを学習させるコードは、以下の部分になります。

lgbm_params =  {
	'task': 'train',
	'boosting_type': 'gbdt',
	'objective': 'lambdarank',
	'metric': 'ndcg',   # for lambdarank
	'ndcg_eval_at': [1,2,3],  # for lambdarank
	'max_position': max_position,  # for lambdarank
	'learning_rate': 1e-8,
	'min_data': 1,
	'min_data_in_bin': 1,
}
lgtrain = lgb.Dataset(all_races_rank_train_train, all_races_target_train_train, categorical_feature=[0,1,2,3,4,7], group=all_races_query_train_train)
lgvalid = lgb.Dataset(all_races_rank_train_valid, all_races_target_train_valid, categorical_feature=[0,1,2,3,4,7], group=all_races_query_train_valid)
lgb_clf = lgb.train(
	lgbm_params,
	lgtrain,
	categorical_feature=[0,1,2,3,4],
	num_boost_round=10,
	valid_sets=[lgtrain, lgvalid],
	valid_names=['train','valid'],
	early_stopping_rounds=2,
	verbose_eval=1
)


パラメーターの指定で「’task’: ‘train’」「’boosting_type’: ‘gbdt’」とするのは回帰と同じですが、「’objective’: ‘lambdarank’」としてRambdaRankを使用することを指定し、「’metric’: ‘ndcg’」「’ndcg_eval_at’: [1,2,3]」「’max_position’: max_position」は学習の際の評価の方法を指定します。「metric」オプションは評価の損失関数で「ndcg_eval_at」は評価する対象を(ここでは1着から3着までを評価の対象に)指定します。また「max_position」はランキングに含まれる最大の値を指定します(最大のQueryデータ値とは異なります)。
そして、結果は回帰と同じく「predict」を使用して求めます。predictを呼び出す際にもQueryデータは必要となります。下のコードでは、1レースのみの予想を求めているので、長さ1の配列に出走馬の数を入れてpredict時のQueryデータとしています。

dst = norm_racedata(xgb_clf.predict(DMatrix(predict_races_target_x)), [len(predict_races_target_x)])
for dst_ind in range(len(dst)):
	predict_validation_regression[dst_ind][fold_offset+fold_id] = dst[dst_ind


結果に対して呼び出している「norm_racedata」は、求められたスコアを正規化するものですが、特に無くても構わないです。
ここでは後で異なる(LightGBM以外の)アルゴリズムとアンサンブルするために全ての結果を正規化するようにしていますが、正規化を行わなくてもそれほど違いは出ないようです。

def norm_racedata(data, query):
	cur_pos = 0
	for q in query:
		data[cur_pos:cur_pos+q] = data[cur_pos:cur_pos+q] - np.min(data[cur_pos:cur_pos+q])
		data[cur_pos:cur_pos+q] = data[cur_pos:cur_pos+q] / np.sum(data[cur_pos:cur_pos+q])
		cur_pos += q
	return data

オッズ計算

さて、RambdaRankによる予想は、あくまで「レース結果の予想」であって、「馬券の予想」ではないことに注意してください。
馬券を買う場合はオッズを見ながら買うことになるので、オッズで重み付けをおこない、Q学習などの手法を採るべきなのでしょうが、ここではそのような学習は行っていません。
しかしそれでも、実際に馬券を買っていくら帰ってくるのかは気になるところなので、過去のレースのオッズ情報を元に、どの馬券でどのくらいのリターンが期待できるか、計算してみます。
まずはレース情報からオッズ情報を読み込む関数です。

def get_race_odds(all_races):
	race_odds = []
	for race in all_races:
		race_meta = race[0].split('|')
		if len(race_meta) > 6:
			odds = race_meta[5].split(':')
			race_odds.append([
				int(odds[0]), # 単勝
				int(odds[1].split('_')[0]), # 複勝
				int(odds[1].split('_')[1]), # 複勝
				int(odds[1].split('_')[2]), # 複勝
				int(odds[2]), # 枠連
				int(odds[3]), # 馬連
				int(odds[4].split('_')[0]), # ワイド
				int(odds[4].split('_')[1]), # ワイド
				int(odds[4].split('_')[2]), # ワイド
				int(odds[5]), # 馬単
				int(odds[6]), # 三連複
				int(odds[7]) # 三連単
			])
	return race_odds

この関数はほぼ問題ないでしょう。レース情報を取得するプログラムで作成した並び順が正しく再現されていればそれで良いです。
次に実際のリターンを計算する部分です。

race_odds = get_race_odds(all_races_test)
ret_score = [0,0,0,0,0,0,0,0,0]
ret_hitnum = [0,0,0,0,0,0,0,0,0]
num_retrace = 0
cur_pos = 0

test_validation_result = test_validation_regression.mean(axis=1)
for i, o in zip(all_races_query_test, race_odds):
	order = np.argsort(test_validation_result[cur_pos:cur_pos+i])
	order_t = np.argsort(all_races_target_test[cur_pos:cur_pos+i])
	if order[0] == order_t[0]:  # 単勝あたり
		ret_score[0] += o[0]
		ret_hitnum[0] += 1
	if order[0] == order_t[0] or order[1] == order_t[0] or order[2] == order_t[0]:  # 複勝あたり
		ret_score[1] += o[1]
		ret_hitnum[1] += 1
	if order[0] == order_t[1] or order[1] == order_t[1] or order[2] == order_t[1]:  # 複勝あたり
		ret_score[1] += o[2]
		ret_hitnum[1] += 1
	if order[0] == order_t[2] or order[1] == order_t[2] or order[2] == order_t[2]:  # 複勝あたり
		ret_score[1] += o[3]
		ret_hitnum[1] += 1
	if order[0] == order_t[0]:  # 複勝あたり
		ret_score[2] += o[1]
		ret_hitnum[2] += 1
	elif order[0] == order_t[1]:  # 複勝あたり
		ret_score[2] += o[2]
		ret_hitnum[2] += 1
	elif order[0] == order_t[2]:  # 複勝あたり
		ret_score[2] += o[3]
		ret_hitnum[2] += 1
	if (order[0] == order_t[0] and order[1] == order_t[1]) or (order[0] == order_t[1] and order[1] == order_t[0]):  # 馬連あたり
		ret_score[3] += o[5]
		ret_hitnum[3] += 1
	if (order[0] == order_t[0] or order[1] == order_t[0] or order[2] == order_t[0]) and (order[0] == order_t[1] or order[1] == order_t[1] or order[2] == order_t[1]):  # ワイドあたり
		ret_score[4] += o[6]
		ret_hitnum[4] += 1
	if (order[0] == order_t[0] or order[1] == order_t[0] or order[2] == order_t[0]) and (order[0] == order_t[2] or order[1] == order_t[2] or order[2] == order_t[2]):  # ワイドあたり
		ret_score[4] += o[7]
		ret_hitnum[4] += 1
	if (order[0] == order_t[1] or order[1] == order_t[1] or order[2] == order_t[1]) and (order[0] == order_t[2] or order[1] == order_t[2] or order[2] == order_t[2]):  # ワイドあたり
		ret_score[4] += o[8]
		ret_hitnum[4] += 1
	if (order[0] == order_t[0] and order[1] == order_t[1]) or (order[0] == order_t[1] and order[1] == order_t[0]):  # ワイドあたり
		ret_score[5] += o[6]
		ret_hitnum[5] += 1
	elif (order[0] == order_t[0] and order[1] == order_t[2]) or (order[0] == order_t[2] and order[1] == order_t[0]):  # ワイドあたり
		ret_score[5] += o[7]
		ret_hitnum[5] += 1
	elif (order[0] == order_t[1] and order[1] == order_t[2]) or (order[0] == order_t[2] and order[1] == order_t[1]):  # ワイドあたり
		ret_score[5] += o[8]
		ret_hitnum[5] += 1
	if order[0] == order_t[0] and order[1] == order_t[1]:  # 馬単あたり
		ret_score[6] += o[9]
		ret_hitnum[6] += 1
	if (order[0] == order_t[0] or order[0] == order_t[1] or order[0] == order_t[2]) and (order[1] == order_t[0] or order[1] == order_t[1] or order[1] == order_t[2]) and (order[2] == order_t[0] or order[2] == order_t[2] or order[1] == order_t[2]):  # 三連複あたり
		ret_score[7] += o[10]
		ret_hitnum[7] += 1
	if order[0] == order_t[0] and order[1] == order_t[1] and order[2] == order_t[2]:  # 三連単あたり
		ret_score[8] += o[11]
		ret_hitnum[8] += 1
	num_retrace = num_retrace+1
	cur_pos = cur_pos+i

ret_score_r = [ret_score[r] / num_retrace for r in range(9)]
ret_score_r[1] = ret_score[1] / (num_retrace*3)
ret_score_r[4] = ret_score[4] / (num_retrace*3)

df_outfile.write('払い戻し予想: [レース数%d]\n'%len(all_races_query_test))
df_outfile.write('\t単勝\t複勝\t複勝(1枚)\t馬連\tワイド\tワイド(1枚)\t馬単\t三連複\t三連単\n')
df_outfile.write('オッズ:'+'\t'.join(list(map(str,ret_score_r)))+'\n')
df_outfile.write('当り数:'+'\t'.join(list(map(str,ret_hitnum)))+'\n')

リターンの計算は上のように、テスト用データに対する予想を行い、結果とオッズ情報から平均オッズとあたり数を計算しています。
学習データとテスト用データを、2000レースと500レース分用意して、AIの予想精度を見てみましょう。

$ tail -n 2500 race_mearged.csv | head -n 2000 > race_train.csv
$ tail -n 500 race_mearged.csv > race_test.csv
$ python3 tipster-ensemble.py -a lgbm -t race_train.csv -e race_test.csv
払い戻し予想: [レース数500]
払い戻し予想: [レース数500]
    単勝    複勝    複勝(1枚)    馬連    ワイド    ワイド(1枚)    馬単    三連複    三連単
オッズ:80.48    73.26    71.86    45.14    63.38    55.32    40.98    59.32    27.76
当り数:85    526    207    24    155    62    13    11    2

残念ながら馬券で儲けを期待できるほどの精度は出ていませんが、単勝馬券の控除率が80%であることと、予想に使うデータの種類が少ない(馬と騎手の名前、競馬場の名前、馬場の種類、天気、距離しか見ていない!)ことを考えれば、それなりではないでしょうか。また、データの量を増やすことで、さらに予想精度を改善することが出来ます。
データの種類を増やして、予想精度を向上させる部分については、後編で解説をします。

イベントインタビュー広報 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、特にディープラーニング系のホットな技術を実際に動かすと…