はじめに

機械学習の勉強がてら、以前からやってみたかった「競馬予想プログラム」にチャレンジしてみました。

競馬予想ソフト。これまでたくさんありましたし、AI(機械学習)を使ったものもすっかり一般的になりました。ひるがえって、自分が競馬新聞をみる時を思い出してみると、いきなり細かい部分の検討はしないですね。

第一見で全体を見渡し、よさそうな馬を「ぱっと見」で抽出して、その後、各論のデータを検討している。私の脳が実際に紙面のどこを見てるのか分かりませんが、ともかく最初は馬柱全体を一種の「パターン」でとらえてるんじゃないか?このメカニズムを取り入れたら、けっこううまい予想するのではないか?!そんな発想がありました。

これを技術的に落とし込むと

予想データを、数値の集まり(テーブルデータ)でなく、1枚の画像として扱い、それを深層学習(CNN)で学習させると、これまで気づかなかった相関が見えるかもしれない

そんなアイデアを手掛かりに、従来の競馬予想AIとは少し違うアプローチのプログラムを作ってみることにしました。

この記事はその開発過程を記した一種のドキュメンタリーです。

手始めに

とりあえず機械学習も深層学習も聞きかじりの知識しかなかったので、作り方をChatGPT先生に聞いてみました

競馬の予想を行うためのディープラーニングモデルは、多くの要因に基づいて複雑な計算を行う必要があります。
また、適切なデータセットの作成や特徴量の  選択も重要です。そのため、完全なプログラムの提供は困難ですが、基本的なアウトラインを提供します。
1.データの収集:
 競馬の結果や競走馬の情報、天気などのデータを収集します。これらの情報を含むデータセットを作成します。
2.データの前処理:
 データを前処理し、モデルの学習に適した形式に変換します。例えば、カテゴリ変数をエンコーディングし、欠損値を処理します。
3.モデルの構築:
 Kerasなどのライブラリを使用して、ディープラーニングモデルを構築します。モデルのアーキテクチャやハイパーパラメーターの選択が重要です。
4.モデルの学習:
 モデルにデータを与えて学習させます。学習には、過学習を防ぐために正則化やドロップアウトなどのテクニックを使用することができます。
5.モデルの評価:
 モデルの性能を評価するために、テストセットで評価します。評価指標として、正解率、適合率、再現率、F1スコアなどがあります。
6.予測の実行:
 新しいデータを与えて、モデルによる予測を行います。予測は、予測確率の高い順にソートして、最も可能性が高い競走馬を選択することができます。
以上の手順に従って、競馬の予想をディープラーニングで行うプログラムを作成することができます。ただし、モデルの構築やデータの前処理には専門的な知 識が必要であり、正確な予測を行うには膨大なデータと時間が必要となります。

抽象的すぎてちょっとよく分からないです・・。とりあえずデータの収集からいきます。

データの収集

今回は勉強用ということで、他の方々に倣って、netkeiba.com殿からスクレイピングさせて頂くことにしました。ChatGPT先生に聞いたところ、Jsoupという良さげなライブラリがあるようなので、これを使うことにしました。

スクレイピング

面倒だったのでChatGPT先生にサンプルもらうことにしました。

質問: Jsoupでhttps://race.netkeiba.com/top/race_list.html?kaisai_date=20231001 からデータを入手するコードをください

回答:

以下は、Java言語を使用してJsoupライブラリを使ってNetkeibaのウェブページ(https://race.netkeiba.com/top/race_list.html?kaisai_date=20231001)からデータを取得するコードの例です。この例では、ウェブページからタイトルとHTMLコンテンツを取得します。

..長いので以下省略..

試してみると、htmlタグの要素から特定の文字を抜き出すコードが結構面倒。なのでこれも教えてもらいました。

質問: Jsoupで <td class="Barei Txt_C">牝3</td> を切り出すJavaのコードをください

回答:

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
public class JsoupExample {
   public static void main(String[] args) {
       String html = "<table><tr><td class=\"Barei Txt_C\">牝3</td></tr></table>";
       Document doc = Jsoup.parse(html);
       Elements tdElements = doc.select("td.Barei.Txt_C"); // "Barei Txt_C" クラスの td 要素を選択
       for (Element tdElement : tdElements) {
           System.out.println(tdElement.text()); // "牝3" を出力
       }
   }
}

doc.select("td.Barei.Txt_C")
なんてドット連結する記法、直感じゃ思いつかないな~

前処理いろいろ

先生の指示に従って次は前処理。

エンコーディング

調べてみるとどうやら機械学習に与えるデータは数値でないとダメらしい。上がり3Fといったデータはそのまま数値でOKだね、たぶん。牡、牝といった性別はこれに対応する1とか2をアサインするのか。いやちょっとまて。そんなことやってたらキリない。そもそも無数の「馬名」「騎手名」はどうするのか。。

仕方ない。元が数値のデータ以外は、その文字列のハッシュ値を当ててしまおう。

欠損値

今回の開発のそもそもの発端は「競馬新聞の馬柱表を見たときの直観」をデジタルに再現することなので、学習データはいわゆる馬柱表をなるべく忠実に再現したものにしたい。そのため一般的なフォーマットである前5走情報をまずは採用したいと考えます。それでは、5走走ってない馬はどうするか? 

仮想のレースを走ったことにしてみました。

その他、競馬データを眺めてみるといろいろ気づいた点があり:

ちなみに「馬柱」は「うまばしら」と訓読みするの知ってました?某人気漫画みたいですね。自分はこの間まで「バチュー」って読んでました。恥ずかしいです。

完成したテーブルデータ

htmlから抜き出した要素を仕分けして保存するコードをごにょごにょと書き、スレッド化して一晩流しました。かくして東京、中山、阪神、京都、中京競馬場の過去五年間、プラス2023年前半のデータがざっくりと集まりました。

細かくファイルに分かれてる各要素をマージして一つのテーブル形式にすると、こうなりました。

2018年東京競馬場のレースデータ(抜粋) fileracetable_201805.csv

1行が1レース。列数=1803。1列目が勝ち馬の馬番、すなわち分類問題での正解ラベル。トレーニング用は約1万行、検証用が約1200行。

この先すべての基礎になるデータです。

(寄り道)競馬オッズの基礎データ

データが集まったのでちょっと寄り道していろいろ調べてみました。

2023年前半の単勝平均オッズは?

全レース数...1216
単勝オッズ平均...9.51倍

一番人気だけを買い続けた場合の勝率、平均オッズ、収支率は?

的中率...33.7%
平均オッズ...2.3倍
回収率...77.8%

JRA殿が最初から25%控除してるので、普通なら収支75%くらいに収まりそうだなぁと思っていたら、まさにその通りでした。

各人気ごとの勝率と平均オッズは?

人気平均オッズ勝率(%)
12.2933.7
24.019.1
35.9813.0
48.2110.1
511.297.2
614.74.2
723.644.0
823.282.5
934.232.4
1047.371.6
1167.450.6
1274.520.7
13112.360.4
1468.00.2
15157.80.2

回収100%を上回る美味しい払い戻しは、どの人気でも得られなさそう。

モデルの構築(準備体操)

今回の競馬予想プログラムはシンプルに「どの馬が勝つか」=単勝を予想させることにします。上から分かる通り、何も考えず1番人気を買い続けると的中率33%、回収率78%ほどになるので、これを超えることを目標に設定します。

単勝の予想とは、対象レースの直近5走データから、勝ち馬の馬番号(今回では1-16番)を出すことなので、機械学習の概念でいえば「1-16の分類問題」と捉えて解けば良いと理解しました。

いきなり画像の深層学習をやるには敷居が高かったので段階を追ってみることにしました

  1. シンプルな機械学習でcsv形式のテーブルデータを学習、予測させる
  2. 感触を掴んだら、馬柱の画像表現を考えて、これをCNNで学習、予測させる
  3. 結果を比較してみる

ツールの選択

そこでまずは分類問題をチャチャっとできるだけ楽して実行できるツールはないかなと探すと、予想界隈では「勾配ブースティング」なる手法が高性能で話題になっているらしく、ライブラリとしてはXGBoost, LightGBMが人気のようでしたので、これを使ってみることにします。

JavaはないのかJavaはっ!と思って探すと「Deep Learning for Java」(DL4J)という環境があったので、こちらも導入してみました。

結果的にいうとDL4Jが勉強用としては最高でした。

DL4J

セットアップ方法はネットを検索してください。IntelliJ IDEAとJava 11を使います。歴史があってこなれているので環境構築は迷わないと思います。

まず、付属する大量のサンプルプログラムが「深層学習で何ができるか」のカタログになっていて一覧性が高いです。例を挙げるとfeedforward(クラス分類と回帰), convolution(CNN), recurrent(RNN), autoencoder, seq2seq, word2vecなどなど大量の実行可能なサンプル群

あのよく見るMNISTのサンプルデータと共に機械学習のトピックが一通り「実行しながら」体験できるようになっています。中には"alphagozero"ってのもあって「えっ」となりました。

プロジェクトを実行すると、ニューラルネットの学習過程(勾配とか評価値の変化)をモニタできるウェブサーバがローカルで起動します。ブラウザからローカルポートにアクセスすると1 epochごとの変化や収束の様子がリアルタイムで観測できます。ぼーっと眺めるのにうってつけです。

DL4J最高すぎて感動を覚えました。思わずポチったオライリーの「詳説 Deep Learning―実務者のためのアプローチ」と一緒に読むとなお最高でした。

LightGBM (Python)

軽い。速い。簡単。サンプルも解説もいっぱいある。もう全部これでいい感じ。

XGBoost

OracleがJava向け機械学習ライブラリ「Tribuo」をリリースしており、ここにXGBoostも含まれていました。Marvenでダウンロードされる環境ではjarファイルの参照関係が微妙におかしかったりで、マニュアルでjarを入れ替えたり、セットアップに結構苦労しました。

本開発に関する限り、LightGBMに比べて結果精度はさほど変わらず、一方で計算時間はこちらの方がずっと遅かったので、常用するには至りませんでした。大量データを分散計算させるにはこちの方が手軽かもと思いました。

評価方法

とし、テストデータの予測結果を実際の結果と比較して「的中率」と「回収率」を算出しました。

ただし前章でも書いた通り、新馬戦、障害戦、17頭・18頭立てレースは購入も予想もしないとします。

LightGBM

LightGBMパラメータ

params = {
 'objective':'multiclass', 
 'metric':{'multi_error'}, 
 'num_class':17, 
 'num_leaves':30,
 'min_child_samples':10,
 'max_depth':10,
 'seed':1234,
 'boosting_type':'gbdt',
 'subsample_freq':2,
}

正直、これらの値に根拠はないです。試行錯誤の結果です。

評価結果

confusion matrix =
[[23  5  8  6  4  3  9  3  1  1  3  3  0  2  1  0]
[ 5 22  3  5  3  7 13  2  6  6  2  4  3  1  0  0]
[ 5  6 28  4  6  9  1  2  4  1  2  1  1  3  0  0]
[ 9  6  8 35  6  7  9  7  5  8  1  0  2  4  0  0]
[12  7  5  5 24  8 10  8  8  2  0  3  2  1  1  0]
[ 9 14  4 11  5 30  4  9  8  2  4  4  2  3  2  0]
[ 1  6  5 10  7  6 26  7  5  3  2  2  2  1  2  1]
[ 6  7  5  6  8  8  8 32  6  4  2  2  1  1  1  0]
[ 6  4  4  1  3 13  2  7 18  3  4  3  1  2  1  0]
[ 5  4  7  6  7  8  6  8  6 24  0  2  3  3  0  0]
[ 1  5  3  9  7  6  5  5  2  7 15  3  2  2  1  0]
[ 3  9  9  2  4  7  6  3  6  2  2 20  0  3  3  1]
[ 4  0  2  2  6  2  3  5  3  4  4  1 11  2  0  1]
[ 4  5  4  2  3  0  4  4  4  0  2  2  0  7  1  0]
[ 2  1  5  4  5  5  4  6  0  1  1  4  2  1  4  1]
[ 1  3  2  2  2  0  2  2  4  4  4  1  4  2  2  5]]

的中率...26.6%
回収率...76.8%

ニューラルネット

DL4J feedforwardパラメータ

DL4Jのfeedforwardサンプルを元に同じテーブルデータをニューラルネットで学習させて分類させました。

ネットワーク構造:ノード数(numHiddenNodes)=500の3層完全結合。

   MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
               .l2(1e-6)
               .seed(seed)
               .weightInit(WeightInit.XAVIER)
   /*
   参考資料
   SGD (Stochastic Gradient Descent):確率的勾配降下法。標準的な最適化アルゴリズムの1つで、各バッチに対して勾配を計算し、重みを更新します。
   AdaGrad:適応的学習率法。過去の勾配の二乗和のルートを使用して、学習率を調整します。
   AdaMax:AdaGradの一種であり、重みの更新に最大値を使用します。
   AdaDelta:Adaptive Learning Rateの一種であり、勾配の二乗平均のルートを使用して学習率を自動的に調整します。
   Adam:Adaptive Moment Estimationの一種であり、勾配の一次モーメントと二次モーメントの推定値を使用して学習率を自動的に調整します。
   AMSGrad:Adamの改良版で、重みの更新に最大値を使用します。
   Nadam:Nesterov Accelerated GradientとAdamを組み合わせたアルゴリズムで、勾配の推定値にNesterov Accelerated Gradientを使用します。
   Nesterovs:Nesterov Accelerated Gradientを使用します。
   */
               //.updater(new Nesterovs(learningRate, 0.9))
               .updater(new Nesterovs(new MapSchedule(ScheduleType.ITERATION, learningRateSchedule)))
               //.updater(new Adam(1e-3))
               //.updater(new AdaDelta())
               //.updater(new Nadam())
               //.updater(new Adam())

                .list()

               //RELUの方がスコアは下がる
               //Accuracyはたいして変わらない
               .layer(new DenseLayer.Builder().nIn(numInputs).nOut(numHiddenNodes)
                       //.activation(Activation.RELU)
                       .activation(Activation.IDENTITY)
                       .build())
               .layer(new BatchNormalization())

               .layer(new DenseLayer.Builder().nIn(numHiddenNodes).nOut(numHiddenNodes2)
                       //.activation(Activation.RELU)
                       .activation(Activation.IDENTITY)
                       .build())
               .layer(new BatchNormalization())

               .layer(new DenseLayer.Builder().nIn(numHiddenNodes2).nOut(numHiddenNodes3)
                       //.activation(Activation.RELU)
                       .activation(Activation.IDENTITY)
                       .build())
       learningRateSchedule.put(0,    0.001);
       learningRateSchedule.put(1000, 0.0005);
       learningRateSchedule.put(2000, 0.0001);
       learningRateSchedule.put(3000, 0.00001);
       learningRateSchedule.put(3500, 0.000005);      

この書き方Builderパターンって言うんだそうですがメッセージパッシングの連鎖でかっこいいですね。(メッセージパッシングって言うあたりで歳がばれるね)

評価結果

的中率...17%
回収率...69.3%

========================Evaluation Metrics========================

# of classes:    17
Accuracy:        0.1719
Precision:       0.1718	(1 class excluded from average)
Recall:          0.1726	(1 class excluded from average)
F1 Score:        0.1615	(1 class excluded from average)

=========================Confusion Matrix=========================

 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16
----------------------------------------------------
 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0 | 0 = 0
 0  8  3 11  1  4  5 10  4  6  2  3  4  5  4  1  1 | 1 = 1
 0  2  7  5  4  1  9 12  3  3  6  7 10  9  1  2  1 | 2 = 2
 0  1  2 13  3  5 11  7  3  3  1  4  3  9  6  1  1 | 3 = 3
 0  2  3  9 19  5  7 11  7  6  7  7  6  8  6  3  1 | 4 = 4
 0  4  2  6  7 17 10  9 10  7  3  2  4  7  5  2  1 | 5 = 5
 0  4  4  6  2  6 24 17  6  6  3  3  6 14  5  4  1 | 6 = 6
 0  1  5  2  7  5 10 26  3  2  3  3  6  8  4  1  0 | 7 = 7
 0  4  6 10  5  7  8 10 13  4  3  5  6  6  2  4  4 | 8 = 8
 0  1  3  5  2  4 10  8  8  7  6  2  1  9  3  1  2 | 9 = 9
 0  3  7  5  4  7  3  8  4  5 12  2  4 14  6  3  2 | 10 = 10
 0  2  4  2  5  6  6  6  7  1  2  8  4 10  6  4  0 | 11 = 11
 0  1  3  2  2  5  3 10  2  5  3  6 16 13  4  4  1 | 12 = 12
 0  2  2  0  1  2  1  3  2  1  1  4  1 22  3  3  2 | 13 = 13
 0  1  1  1  1  1  3  6  1  5  0  1  4  6  9  1  1 | 14 = 14
 0  0  2  0  0  5  5  3  3  2  2  2  2 10  5  5  0 | 15 = 15
 0  0  1  0  0  3  4  4  1  2  2  6  0  8  4  2  3 | 16 = 16

結果考察

目標値である「的中率33%以上、回収率78%以上」を上回る結果は出ませんでした。結構近いとこまで行ってますが、「近い」では不満です!

GPUを積んでないノートPCでやってる自分が悪いのですが、ニューラルネットは学習にすごぶる時間がかかる。LightGBMは、超速い。精度がよくて超速いならこっち使わない理由ないじゃん、と思いました。

この結果を得る過程で、テーブルデータの列項目に何を入れるかは、かなり試行錯誤しました。「深く考えず全部いれとけばあとはシステムが良きに計らってくれる」と思ってましたが、そうでもないらしいです。

LightGBM では木構造を決めるパラメータが重要だろうと勝手に考え、num_leaves、min_child_samples、max_depth値あたりを探索しました。

ニューラルネットワークでは階層数、ノード数、最適化アルゴリズム、学習率の組み合わせを総当たり的に試しました。結果として階層数はこれ以上増やしても効果なかったです。結合数(ノード数)は多すぎず少なすぎない適度な数が良く、500に落ち着きました。テーブルの列数(numInputs)=1808に対してノード数500と減ってるわけですが、恐らくこの少ないノード間の重みに情報を適度に圧縮させることがキモなのかと理解しました。

個人的にはニューラルネットを使った分類の方が良い結果と期待してましたが、そうでもなかったです。

結果が期待ほどでもなかった理由を振り返って考えてみると

  1. 前処理でのエンコーディング問題の可能性(馬名などの名前をどうやって表現するか)
  2. そもそも前5走データではこの程度の的中率が限界という可能性
  3. スクレイピングがしくじってゴミが大量に紛れ込んでいる可能性

各データを再調査して、イレギュラーな形式(「騙馬」とか「競争除外」とか)をもっと丁寧に前処理し、3の可能性は低減させました。

深層学習への展開(本番)

データの画像化

繰り返しですが本来の目的は「出走表をぱっと見たときの直観」ってどういうんだろう、ってことでした。入力を、数値の集まりでなく、1枚の画像として扱うことで競馬予想がどうなるかに興味があります。この「1枚の画像」の表現方法として3種類思いつきました。

テーブルの画像表現

現在の学習データ列数は1803。これの各項目をピクセル値と考え、8bitに分解してR, G, Bの24bitカラーにしてみました。ただしこのやり方でテーブルデータ1行を変換すると縦1ピクセルの画像になってしまうので、馬18頭を縦にならべて長方形の画像になるよう調整しました。

結果としてcsvの1行が81*18のRGB画像に変換されます。トレーニングデータは約1万行あるので、81*18のRGB画像が1万枚得られることになります。

2018年 中山競馬場 第1回開催 1日目 第1レース

201806_0.png

拡大すると

201806_0.png

実際の馬柱表はこちら: https://race.netkeiba.com/race/shutuba_past.html?race_id=201806010101&rf=shutuba_submenu

このレースは15番コウギョウブライトが勝ちましたので分類ラベルは「15」

1レースがこれだけ小さい画像に圧縮できるって面白いですね。ただし馬名などはハッシュしてるのでここから元のレース情報に戻すことはできません。

疑似馬柱画像

同様に、テーブルデータを、「前5走-馬柱風」に描画して保存してみました。こちらの方が当初の開発のモチベーションにより近いです。

2018年 東京競馬場 第1回開催 1日目 第1レース

201805_0.png
(クリックで拡大)

実際の馬柱表はこちら: https://race.netkeiba.com/race/shutuba_past.html?race_id=201805010101&rf=shutuba_submenu

このレースは6番マサノカバーガールが勝ちましたので分類ラベルは「6」

馬柱そのまま

Selenuimというライブラリを使うと特定のウェブページを、「ブラウザで表示した画面イメージそのまま」で取得できます。ポイントは、ブラウザ(今回使ったのはChrome)のHTML描画エンジンを1回通しているので、JavaScriptなども全部実行された後の画面が得られることです。これの応用で、オッズなどJavaScriptで動的に表示しているページも結果を抽出できます。

サンプルはこちら:

201810_1_1_1.png
(クリックで拡大)

学習と評価

ローカルPC

テーブルデータの学習程度はGPUのないノートPCでもできましたが、1万枚の画像のCNNトレーニングとなるともう全然無理。というわけでクラウドサービスを探すことにしました。

Prediction One

ソニーの機械学習クラウドサービス。

ちょうどいいタイミングでニュースリリースが流れてきたので、無料枠を試してみました。

このサービスのいいところは、事前に定番モデルが沢山用意されていて、コーディングしなくてもインポートですぐ適用できる。 層の追加や中間処理の挿入などもGUI操作で行え、パッドを選択して画面にドロップして各層をマウスで連結していく手軽さ。いわゆるノーコード。 込み入ったことやろうとすると少々面倒だったり、そもそもパッドの意味と用途が分からないものが多いですが、サンプルを参考にすればけっこう勉強できます。 学習データは指定のディレクトリ構造に仕分けしてアップロードするだけで良く、初心者には使い易くてすごくいいなと思いました。

無料の罠

無料枠で一通りお試しはできる一方で、無料期間を過ぎると作業画面にアクセスできなくなります。 新規に学習や予測ができないのは分かりますが、結果を振り返って確認したくでもホーム画面自体にアクセスできないので、成果物は基本的にゼロになります。無料期間がすぎる前に回収しましょう。

自分はこれに気づかず放置していたので、実験結果はなくなってしまいました。けっこう大量に試したんですけどね~

Google Colaboratory

これ使うのも初めてでした。要するにPythonのコマンドプロンプトを仮想環境で貸してくれてるんだと理解。Python系ではPyTorchってのが定番らしく(この辺から分かってない)インストール。Google Driveと連動してドライブ内の画像をローカルファイルのように扱えるため、プログラムが自然にかけます。データの準備もドライブへコピーするだけでとても楽。なるほどうまいねと思いました。

モデル抜粋

一番オーソドックスなモデルのサンプルをネットから拝借しました。とても「深層」とはいえない単なる3層構造。

class Net(nn.Module):
   def __init__(self, input_size, output_size):
       super(Net, self).__init__()
       self.relu = nn.ReLU()
       #self.relu = nn.Identity()
       self.pool = nn.MaxPool2d(2, stride=2)

       self.conv1 = nn.Conv2d(num_channels,8,2)
       self.conv2 = nn.Conv2d(8,16,2)
       self.conv3 = nn.Conv2d(16,32,2)
       #self.conv4 = nn.Conv2d(24,32,2)

       self.fc1 = nn.Linear(256, 32)
       self.fc2 = nn.Linear(32, output_size)

       self.bn1 = nn.BatchNorm2d(8)
       self.bn2 = nn.BatchNorm2d(16)
       self.bn3 = nn.BatchNorm2d(32)
       #self.bn4 = nn.BatchNorm2d(32)

   def forward(self, x):
       x = self.conv1(x)
       x = self.bn1(x)
       x = self.relu(x)
       x = self.pool(x)

       x = self.conv2(x)
       x = self.bn2(x)
       x = self.relu(x)
       x = self.pool(x)

       x = self.conv3(x)
       x = self.bn3(x)
       x = self.relu(x)
       x = self.pool(x)
 
       x = x.view(x.size()[0], -1)
       x = self.fc1(x)
       x = self.relu(x)
       x = self.fc2(x)
       return x

 

問題発覚

データ(画像)は3種類揃いました。これらを順にニューラルネットへつっこみたいわけですがここで大きな問題が発覚。

まず2番目の「疑似馬柱画像」。画像サイズと枚数が多すぎてPrediction OneでもGoogle Colaboratoryでも計算資源のリソースエラーと言われて計算できない。しかたないのでエラーにならない程度に画像をリサイズしていると今度は文字がつぶれてとても見えたものではない。いやニューラルネットならそこは関係ないだろと思ってもみたけれど、そもそもピクセルでつぶれてる情報をいくら読ませても意味がないのでは? 悩んでもラチがあかないので、画像サイズは判別ギリギリの1/2縮小、入力枚数を大幅に減らし、さらにCNNの階層数も減らして、とにかく試すだけ試すことにしました。

次に3番目の「馬柱そのまま」。よく見ると出走頭数によって画像サイズが違う。ニューラルネットへの入力は同じサイズでないといけないらしいので、無理に揃えてみると、今度は文字の縮尺めちゃくちゃ、さらに潰れて読めない。さらに画像サイズ大きすぎてエラー。もういろんな意味でこの方法はかなり無理そう。というわけで「馬柱そのまま」法はひとまず、諦めることにしました。

評価結果

以下はすべてGoogle Colaboratoryで実行。Prediction Oneではいろいろ面白い結果があったのですが回収しなかったのが悔やまれます。

テーブル画像

Loss: 3.924950735909598, Accuracy: 7.795918367346939% (382/4900)

Confusion Matrix:
matrix.png

・・・精度7.8%・・・

今回は対象レースを「16頭立て以下」に絞ったので、サイコロ振って予想しても1/16=6.25%で当たりますよね。

つまりこれサイコロ振ったのとほぼ同じで、全然、学習できてない結果になりました。

疑似馬柱画像

Loss: 2.7520341139573317, Accuracy: 10.843373493975903% (45/415)

あれ、11%もある!と思ったのはぬか喜び。以下のように予測はラベル11のみのでたらめで、偶然ラベル11の正解画像が11%分あっただけでした。 入力画像枚数を大幅に減らした影響が原因の一つとは思うけれど、それ以上にこの方式には根本的な問題がありそう。

matrix2.png

結果考察

サイコロに勝てないという残念な結果に終わりました。

まずテーブル画像法。馬名、騎手名などの扱い。画像ということで24bit化してるが、これがRGBに分解され3チャンネルで処理された時点で、もう名前としての性質は失われてるんじゃないか。バラバラにしたらダメだよねということかもしれない。またCNNの畳み込みウィンドウは一般の解説だと3*3などだが、この画像の趣旨として縦に並んだ馬は同時に比較しないと意味ないのだから、3*18な縦長にするべきかもしれない。

次に疑似馬柱画像法。そもそもCNNが得意とする画像(猫とか犬とか手書き文字とか)と「画像で表された文字、数字情報」は根本的に意味が違う気がする。手書き文字の認識は、認識結果は「1」や「9」といったその画像が指し示すもの(数字)だが、今回欲しいのは、その数字の認識じゃなく、文字・数字を認識した上でのその間の関連性なんだなと考えると、OCRと大して変わらないこの方法には無理があるのかもしれない。

モデル抜粋は簡単な3階層CNNですが、Prediction Oneではもっと様々な構造を試しました。

結果として、構造やパラメータを変えても、それほど顕著な性能向上は見られませんでした。

実戦で試す

せっかくなので実際のレースで試してみましょう。結果が比較的まともだった「LightGBMでテーブルを学習させる」方式を適用してみます。

結果です。

開催的中率回収率
4月4週35%87%
4月5週33%88%
5月1週47%101%
5月2週38%69%
5月3週30%76%
5月4週26%65%
トータル34%81%

競馬予想特有の難しさ

オッズや人気の関係を調べて痛感しました。こんにちの勝ち馬投票にはあらゆる方法の予想がオッズに織り込まれて、すでに相当部分で最適化されてしまってます。収支75%はそう簡単に超えられない。

つまり競馬予想は

という2段構えの発見が必要で、なかなか一筋縄ではいかなさそうです。

何が重要か

LightGBMでは学習で「効いている」パラメータ(特徴量)を表示できます。上位の結果は以下の通りでした

importance.png
(クリックで拡大)

意外にも、オーナー=馬主名が重要視されてました。 レース結果の様々な数値が一番重要と思ってたのですが、着差、上り3F、レースタイムといった要素がようやく中間順位からちらほら見え始める程度です。

この現象、ほんとかよと思って、改めてnetkeibaで馬主検索してみるとその理由が少し納得できました。特定の馬主は、持ち馬の勝率が非常に高いんですね。サイバーエージェントの藤田晋さんなんて、2022年度 勝率17.7% 連対25.8% ですよ。これ馬主だけみて買ってたら結構勝てるのでは?!

つまり「目利きできる馬主がオーナー」=「おめがねにかなった馬」=「優秀」=「勝つ確率高い」の図式が成り立ってるわけで、このメタ的な法則が「馬主名」というたった1個の特徴量に織り込まれているのではないか。

そう考えるとぶちゃけレースデータを一生懸命集めるのは徒労かも?!とさえ思えてきました。

今後の展望

仕事の合間、主に土日を使ってコツコツ開発して、正味2か月くらい。競馬予想でウハウハ儲けるという夢は叶いませんでしたが、非常に良い勉強にはなりました。

この先、データサイズを増やす、階層数を深くする、もっと複雑なネットワーク構造を試すなどいろいろ考えられます。LLMの教訓に従えば、データを増やすとある時点で予測精度が跳ね上がるかもしれない。しかしそうなると計算資源の確保等で投資も若干は必要になり、ギアを1個上げる必要がありそう。

昨今話題のLLM。馬柱表の文字表現をXとして、「Xの勝ち馬はY番でした」という大量の文章を、適当な学習済みモデルに追加で教え込めば、あとは「Z(対象レース馬柱表の文字表現)の勝ち馬教えて」と聞くだけでいいのでは?入力はあくまで自然言語文の体だから、少々欠測や形式不全があっても、一種の崩れた文章表現として、崩れたなりの予測が成り立つのではないか。。そんな感じで、流行りにのかって次はLLMを使った競馬予測プログラムにもチャンレジしてみようかと思います。

余談

今回、半ば無理やりChatGPT使いながら開発を進めて1つ気づいたこと。それは

チャットでの情報収集には「横の広がり」がまったくない。

例えばLightGBMのウェブサイトを読んでる時にふとサイドメニューを見ると、その人が株価予測を試してるページが載ってたりする。すると、へぇそんなこともできるんだ、という予期しない発見がある。ライブラリの使い方の解説を読んでいたら、その人が複数のライブラリ比較のページを作っているのに気づき、そっちを読むともっとよさげなライブラリに行き当たった。

聞かなくてはヒントをくれないチャットに対してウェブ構造は縦横無尽に知識が連鎖していく。なるほど、こと情報収集に関しては、依然としてウェブサイトをじっくり読んで回る方がよほど濃密だと痛感しました。

この事態の元凶である「入力を指示して出力を得る」インターフェイスが、実は生成AIの可能性を大幅に狭めてのではないか。個性をそぎ落として脱色した無色な情報を提示するチャットという方法に、果たしてどれほどの価値があるのか。

かつてインターネットがハイパーリンクの発明で大きく変貌したように、「チャット的ではない何か」が発明されて初めて、生成AIの真価が発揮されるのかもと、いいヒントをもらった気がしました。

余談その2

ニューラルネットではちょっとした処理の違いが全然違う結果を出すことがよくありました。 例えば「バッチノーマライズ」という中間処理をCNNの層間に挟むと全然違う(良くなる)。画像の前処理の方法、正規化あり・なしで結果が全く違う。理由は理解できない。層の数、畳み込みウィンドウのサイズ、他、いわゆるハイパーパラメータってやつ。 そういう意味で本記事は、途中経過や試行錯誤をかなり端折って結果だけ書いてます。

正解があるかもわからない中で、これらを手当たりしだい組み合わせ論的に探すのは、まさしくギャンブル。「当たり方式を探しだす」というこのギャンブル

きっついなー 

というのが正直な感想。暗中模索って言葉がぴったり。精神的にアレです。相当タフじゃないと続かないです。

そこでふと世の中に目を向けてみると、昨今話題のLLM。もし今、世界中の人が、大規模言語モデルなる代物で、よりよい方式を探し続けてるのだとしたら、この世界をあげた「当たり方式を見つける」壮大なギャンブルにベットする価値はあるんだろうか。

いや、実際には世界中のリスクマネーは全力の倍プッシュでギャンブルしてるわけですが。

参考文献

城崎 哲「AI競馬 人工知能は馬券を制することができるか?」(ガイドワークス)

謝辞

全競馬ファンのためスクレイピングを恐らく黙認しているであろうnetkeiba殿に深く感謝いたします。

文責

本文章の文責は(株)スカイリー・ネットワークスにあります。 ご意見、ご要望、ご感想は、www.skyley.comのお問い合わせフォームよりお寄せください。
製品、サービス等の名称にはそれぞれの団体または企業のトレードマークが含まれます。


添付ファイル: file201806_0.png 364件 [詳細] fileracetable_201805.csv 509件 [詳細] filematrix2.png 297件 [詳細] filematrix.png 297件 [詳細] fileimportance.png 495件 [詳細] file201810_1_1_1.png 365件 [詳細] file201805_67.png 172件 [詳細] file201805_0.png 401件 [詳細]

トップ   編集 凍結 差分 履歴 添付 複製 名前変更 リロード   新規 一覧 検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2024-02-02 (金) 10:46:00