ユニケージで競馬予想AIの特徴量を生成する

趣味で競馬AIを作り始めて、２年ほど経ったので
自分の為の記録の意味も込めて、作成したものの一部を記載しようと思う。

# 1.はじめに
最近、XなどのSNSを見ていても、競馬AIの作成に取り組んでいる方を多く見受けられます。
その方たちの中には、機械学習で利用する膨大なデータの取り扱いに難儀した方も多くいるのでは無いでしょうか。自分もその中の一人で、学習データを５年→１０年に延ばしてみたり、思い付いた特徴量をどんどん追加していくと、個人のPCでは処理速度がみるみるうちに低下していき、終いには１日ではデータの更新が終わらない、、なんて事も。

そこで私は、特徴量の生成を当初は比較的処理速度の遅いPythonで全て行ってましたが、**ユニケージ**で作成する方法に切り替える事にしました。

# 2.ユニケージ開発手法とは
ざっくり言うと、既存のDBMSを利用せず、UNIX系のシェルスクリプとコマンドでその機能を補うという思想で、開発コストが安価で容易に行える事が可能で、**最大の特徴が処理速度の速さ**です。
私はこれを取り入れた事により、特徴量を１０００まで追加しても運用が可能な状態に持っていく事が出来ました。

# 3.前準備(1)：ユニケージコマンドのインストール
ユニケージコマンド(Personal Tukubai)は、下記から購入可能で非商用だと**６ヶ月の利用**で**２０００円**となってます。

https://www.usp-lab.com/opinion.html

料金を支払い終えると、メールでコマンドとインストールの方法などをまとめたメモを送ってもらえるので、手順に沿って一緒に送られて来るバッチを実行するだけで、環境は整います。
（Windowsで開発する際は、UNIX環境を整えるため色々とその他設定が必要になります。）

# 4.前準備(2)：データの収集
データについては、ネット競馬のデータをスクレイピングする形で用意しており、主に３つに分けて抽出、利用しています。因みに私は、過去１０年分を抽出して利用しております。
（スクレイピングの方法は、youtubeなど探せば出て来るので割愛します。）

**1.競走馬の出走データ**

https://db.netkeiba.com/horse/2017101835/

上記のページにある各馬の競争データを下記の形式で抽出します。
※の項目は、有料会員のみ取得可能な情報となっている。
| No. | 項目名 | 補足説明 |
|:-:|:-:|:-:|
| 1 |  horse_id |  各馬を識別するためのID |
| 2 | 日付  |  レースが行われた日付 |
| 3 | 開催  |  第何回目のどの競馬場の何レース目か |
| 4 | 天気  |   |
| 5 | R  |  レース番号 |
| 6 | レース名  |   |
| 7 | ※映像  |   |
| 8 | 頭数  |   |
| 9 | 枠番  |   |
| 10 | 馬番  |   |
| 11 | オッズ  |   |
| 12 | 人気  |   |
| 13 | 着順  |   |
| 14 | 騎手  |   |
| 15 | 斤量  |   |
| 16 | 距離  |   |
| 17 | 馬場  |   |
| 18 | ※馬場指数  |   |
| 19 | タイム  |   |
| 20 | 着差  |   |
| 21 | ※タイム指数   |   |
| 22 | 通過   |   |
| 23 | ペース  |   |
| 24 | 上り  |   |
| 25 | 馬体重  |   |
| 26 | ※厩舎コメント  |   |
| 27 | ※備考  |   |
| 28 | 勝ち馬(2着馬)  |   |
| 29 | 賞金  |   |


**2.レース結果データ**

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

上記のページにある各レース結果のデータを下記の形式で抽出します。

| No. | 項目名 | 補足説明 |
|:-:|:-:|:-:|
| 1 |  race_id |  各レースを識別するためのID |
| 2 | 着順  |   |
| 3 | 枠番 |   |
| 4 | 馬番  |   |
| 5 | 馬名  |   |
| 6 | 性齢  |   |
| 7 | 斤量  |   |
| 8 | 騎手  |   |
| 9 | タイム  |   |
| 10 | 着差  |   |
| 11 | 単勝オッズ  |   |
| 12 | 人気  |  単勝人気 |
| 13 | 馬体重  |   |
| 14 | 調教師  |   |
| 15 | horse_id  | 各馬を識別するためのID  |
| 16 | jockey_id  |  各騎手を識別するためのID |
| 17 | 距離  |   |
| 18 | 天気  |   |
| 19 |  race_type | 芝 or ダート or 障害  |
| 20 | 馬場  |   |
| 21 | 日付 |   |


**3.血統データ**

https://db.netkeiba.com/horse/ped/2017101835/

上記のページにある各馬の血統データを下記の形式で抽出します。
| No. | 項目名 | 補足説明 |
|:-:|:-:|:-:|
| 1 |  horse_id |  各馬を識別するためのID |
| 2 | peds_0  |  父 |
| 3 | peds_1  |  母 |
| 4 | peds_2  |  父父 |
| 5 | peds_3  |  父母 |
| : | :  | : |
| : | :  | : |
| 62 | peds_60  |  母母母母父 |
| 63 | peds_61  |  母母母母母 |

これだけでも、１００以上の項目となるが、ここからユニケージを利用して新たな特徴量を生み出していきます。

# 5.特徴量を生成してみる

予想をする上で、いつ以来の出走か？重視されている方も多くいるかと思うので、まず「レース間隔」についてのデータを生成してみようと思う。
無敗の三冠馬、コントレイルを例に今回作るデータイメージを下記に記載しました。

※は、データのイメージを分かり易くする為に記載しただけで、実際には用意しない項目です。
| 日付 | ※レース名 | horse_id | レース間隔（日数） |
|:-:|:-:|:-:|:-:|
| 2019/09/15 | 新馬戦 | 2017101835 | 0 |
| 2019/11/16 | 東京スポーツ杯2歳S(G3) | 2017101835 | 62 |
| 2019/12/28 | ホープフルステークス(G1) | 2017101835 | 42 |
| 2019/04/19 | 皐月賞(G1) | 2017101835 | 113 |
| 2019/05/31 | 東京優駿(G1) | 2017101835 | 42 |
| : | : | : | : |
| : | : | : | : |
| 2021/11/28 | ジャパンカップ(G1) | 2017101835 | 28 |

以下が実際のコードです。
（「race_results.csv」が、「2.レース結果データ」でスクレイピングしたデータになります。）

```Lace_Spacing.sh
#!/bin/bash

tmp=/tmp/tmp
scrpd=/data/scraping
csvd=/data/csv

today=$(date +%Y%m%d)

#----------------------------------------
# 前準備
#----------------------------------------
cat << FIN | self 1 > $tmp-tyaku_list
1
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
FIN

cat ${scrpd}/race_results.csv           |
fromcsv                                 > $tmp-race_results
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

#----------------------------------------
# 重複行と除外、取り消しがあったデータを取り除く
#----------------------------------------
cat $tmp-race_results                   |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

LANG=C sort -k 2,2                      |
join0 key=2 $tmp-tyaku_list             > $tmp-race_results_uniq

#----------------------------------------
# horse_id_listの作成
#----------------------------------------
cat $tmp-race_results_uniq              |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

lineup 15                               > $tmp-horse_list
# 1:horse_id

#----------------------------------------
# 初戦データ
#----------------------------------------
cat $tmp-race_results_uniq              |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

self 15 21                              |
# 1:horse_id 2:date

LANG=C sort                             |
getfirst 1 1                            |
awk '{ print $1,$2,0 }'                 > $tmp-syosen_d
# 1:horse_id 2:date 3:Lace_Spacing

#----------------------------------------
# ダミーデータの作成
#----------------------------------------
cat $tmp-horse_list                     |
# 1:horse_id

awk '{ print $1,"2000-01-01",0}'        > $tmp-dummy
# 1:horse_id 2:ダミー日付 3:0

#----------------------------------------
# メイン処理
#----------------------------------------
cat $tmp-race_results_uniq              |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

awk '{ print $15,$21,
       '"${today}"',
       substr($21,1,4)substr($21,6,2)substr($21,9,2)
     }'                                 |
# 1:horse_id 2:date 3:処理日 4:date(8桁)

uniq                                    |
mdate -f 3 4                            |
# 1:horse_id 2:date 3:処理日 4:date(8桁) 5:処理日とdateの差

delf 3 4                                |

# ダミーデータの追加（この後の「ychange」で列が崩れないようにする為）
cat - $tmp-dummy                        |
# 1:horse_id 2:date 3:処理日とdateの差

LANG=C sort -r                          |
ychange num=1                           |
# 1:horse_id 2:date 3:処理日とdateの差 4:1レース前のdate 5:1レース前の処理日とdateの差

awk '{ print $1,$2, $5-$3}'             |
# 1:horse_id 2:date 3:Lace_Spacing

LANG=C sort                             |
upl key=1/2 - $tmp-syosen_d             |

# yyyymmddの形式に変換
awk '{ print substr($2,1,4)substr($2,6,2)substr($2,9,2),
             $1,$3 }'                   |
LANG=C sort                             |
tocsv                                   > ${csvd}/Lace_Spacing.csv
# 1:date 2:horse_id 3:Lace_Spacing

```

コードは以上。
どういった処理が行われているか、解説していきます。

　
```Lace_Spacing.sh
#!/bin/bash

tmp=/tmp/tmp
scrpd=/data/scraping
csvd=/data/csv

today=$(date +%Y%m%d)
```

こちらはただの変数定義なので、サラっと
以下の内容を設定しています。

tmp ： 中間ファイルの出力先
scrpd ： スクレイピングしたデータの保管場所
csvd ： ユニケージで作成したデータの保管場所

　
```Lace_Spacing.sh
cat << FIN | self 1 > $tmp-tyaku_list
1
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
FIN
```

race_results（レース結果データ）に出走除外があった馬の情報なども含まれているので、それを取り除くための、前準備です。
tmp-tyaku_list に１〜１８までの数値が入ってます。

　
```Lace_Spacing.sh
cat ${scrpd}/race_results.csv           |
fromcsv                                 > $tmp-race_results
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date
```
ここで、スクレイピングしてきてcsv で保管していたレース結果データを、「fromcsv」というコマンドで、ユニケージで取り扱うための形式に変換します。
具体的にはカンマ区切りで保管されているデータを、スペース区切りに変換します。
コメントで、# 1:race_id 2:着順 〜　と記載しているのは、データのレイアウトです。
SQLの様に、カラム名を指定して記述を行わないので、開発を進めると現在のレイアウトの状況が分かりづらくなるため、この様にコメントで記載します。

　
```Lace_Spacing.sh
cat $tmp-race_results                   |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

LANG=C sort -k 2,2                      |
join0 key=2 $tmp-tyaku_list             > $tmp-race_results_uniq
```

この処理で着順に１〜１８までの数値が設定されたレコードのみに絞り、除外などがあった馬の情報を取り除いています。
１点ポイントですが、join を利用する際は、トラン（tmp-race_results）とマスタ（tmp-tyaku_list）の情報がソートされている必要が有ります。
 tmp-tyaku_list の数値の順番が、少し不思議な順になっていたのはそのためです。

　
```Lace_Spacing.sh
cat $tmp-race_results_uniq              |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

lineup 15                               > $tmp-horse_list
# 1:horse_id
```

「lineup」のコマンドで、指定した１５番目の項目（horse_id）がユニークされた状態で全ての種類 tmp-horse_list に保管します。
使い道については、後ほど。
　

```Lace_Spacing.sh
cat $tmp-race_results_uniq              |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

self 15 21                              |
# 1:horse_id 2:date

LANG=C sort                             |
getfirst 1 1                            |
awk '{ print $1,$2,0 }'                 > $tmp-syosen_d
# 1:horse_id 2:date 3:Lace_Spacing
```

「self」のコマンドで、指定した番号の項目を取り出します。（「horse_id」と「date」）
その後、LANG=C sort でデータをソートした後、「getfirst」で同一キーの最初の行を出力します。
データのイメージとしては、こんな感じ。
　
getfirst前
| horse_id | 日付 |
|:-:|:-:|
| 2017101835 | 2019/09/15
| 2017101835 | 2019/11/16
| 2017101835 | 2019/12/28 
| 2017101835 | 2019/04/19
| 2017101835 | 2019/05/31
| : | : |
| : | : |
| 2017101835 | 2021/11/28

getfirst後
| horse_id | 日付 |
|:-:|:-:|
| 2017101835 | 2019/09/15

 これらの処理を経た結果、「tmp-syosen_d」に各馬の初戦データだけを保管してます。
 　

```Lace_Spacing.sh
cat $tmp-race_results_uniq              |
#  1:race_id 2:着順 3:枠番 4:馬番 5:馬名 6:性齢 7:斤量 8:騎手 9:タイム 10:着差 11:単勝 12:人気 13:馬体重 14:調教師
# 15:horse_id 16:jockey_id 17:cource_len 18:weather 19:race_type 20:ground_state 21:date

awk '{ print $15,$21,
       '"${today}"',
       substr($21,1,4)substr($21,6,2)substr($21,9,2)
     }'                                 |
# 1:horse_id 2:date 3:処理日 4:date(8桁)

uniq                                    |
mdate -f 3 4                            |
# 1:horse_id 2:date 3:処理日 4:date(8桁) 5:処理日とdateの差

delf 3 4                                |
```

ここからがメインの処理。
ポイントは「mdate -f 3 4 」で、3項目目の処理日と4項目目のdata（レース日）との日数差を5項目目に出力しています。
データイメージはこんな感じ
| horse_id | data | 処理日 | data(8桁) | 処理日とdateの差 |
|:-:|:-:|:-:|:-:|:-:|
| 2017101835 | 2019/09/15 | 20231224 | 20190915 | 1561
| : | : | : |: |
| : | : | : |: |
| 2017101835 | 2021/11/28 | 20231224 | 20211128 | 756

そして「delf」のコマンドですが、こちらで指定した項目（3番目と4番目）を除去しています。
　

```Lace_Spacing.sh
# ダミーデータの追加（この後の「ychange」で列が崩れないようにする為）
cat - $tmp-dummy                        |
# 1:horse_id 2:date 3:処理日とdateの差

LANG=C sort -r                          |
ychange num=1                           |
# 1:horse_id 2:date 3:処理日とdateの差 4:1レース前のdate 5:1レース前の処理日とdateの差

awk '{ print $1,$2, $5-$3}'             |
# 1:horse_id 2:date 3:Lace_Spacing
```

ここのポイントは「ychange num=1」で、このコマンドで以下の様に変化しております。

 　
ychange前
| horse_id | 日付 | 処理日とdateの差 |
|:-:|:-:|:-:|
| 2017101835 | 2019/09/15 | 1561
| 2017101835 | 2019/11/16 | 1499
| 2017101835 | 2019/12/28 | 1457
| : | : | : |
 
ychange後
| horse_id | 日付 | 処理日とdateの差 | 1レース前のdate | 1レース前の処理日とdateの差
|:-:|:-:|:-:|:-:|:-:|
| 2017101835 | 2019/09/15 | 1561 |2000/01/01 | 0
| 2017101835 | 2019/11/16 | 1499 |2019/09/15 | 1561
| 2017101835 | 2019/12/28 | 1457 |2019/11/16 | 1499
| : | : | : | : | : |

「cat - $tmp-dummy」でダミーデータを投入しているのは、１戦しか走っていない馬のデータがこの処理を行った際、レイアウトが崩れてしまうので、それを防ぐために行っております。
そして、3番目から5番目の日数を引く事で、レース間隔を出しております。

```Lace_Spacing.sh
LANG=C sort                             |
upl key=1/2 - $tmp-syosen_d             |

# yyyymmddの形式に変換
awk '{ print substr($2,1,4)substr($2,6,2)substr($2,9,2),
             $1,$3 }'                   |
LANG=C sort                             |
tocsv                                   > ${csvd}/Lace_Spacing.csv
```

初出走のレコードに対しては、レース間隔を「０」に設定する為、「upl key=1/2 - $tmp-syosen_d 」の処理を行ってます。
「upl」コマンドは、同一キーでマージを行い、キーフィールドの値が同一の最終行を抽出します。
データは以下の様に変化しております。
　
 　
「tmp-syosen_d」の中身
| horse_id | 日付 | レース間隔 |
|:-:|:-:|:-:|
| 2017101835 | 2019/09/15 | 0 |
| : | : | : |

　
「upl key=1/2 - $tmp-syosen_d」の処理
| horse_id | 日付 | レース間隔 |
|:-:|:-:|:-:|
| 2017101835 | 2019/09/15 | 62 |
| 2017101835 | 2019/09/15 | 0 |
| 2017101835 | 2019/11/16 | 42 |
| : | : | : |　

　
「upl key=1/2 - $tmp-syosen_d」の処理後
| horse_id | 日付 | レース間隔 |
|:-:|:-:|:-:|
| 2017101835 | 2019/09/15 | 0 |
| 2017101835 | 2019/09/15 | 42 |
| : | : | : |　

そしてawkでデータを整理して、「tocsv」コマンドで、スペース区切りのデータを、カンマ区切りのcsvに変換して完成です。

# 6.最後に

最初の方に触れていた処理速度についてですが、過去１０年以上のデータを扱い、この処理で70万件、16M程のファイルサイズの出力を行ってますが、**１５秒**で処理が完了しています。
使用しているマシンのスペックについては、こんな感じ。

スペック
MacBook Pro（13-inch,2018,Four Thunderbolt 3 Prots）
プロセッサ：2.3GHz クアッドコア Intel Core i5
メモリ：8G 2133 MHz LPDDR3

最近、既存のDBMSをあまり利用してないのですが、比較的これはかなり速い、、はず。

余裕があれば作っている他の特徴量についても、記載したいと思います。

　
ネット競馬様主催の**AI競馬予想マスターズ**にも「mag」という名前で出場しておりました。
結果は、なんとか予選を潜り抜け、最終戦まで進出しました。

https://yoso.sp.netkeiba.com/masters/ai2023/profile.html?id=17218452