競馬予想ソフト「馬心君」のディープラーニング版開発日記

##馬心君（まごころくん）とは

競馬予想サイト「馬心君」では、2005年に開発した遺伝的アルゴリズムを活用した勝ち馬予想ソフトの予想を毎週金曜夜と土曜夜に公開しています。
的中率はかなり低め（色々買って10%程度）ながら、穴予想に振っているため、それなりの回収率（年間トータルでは105%前後）となっています。
穴党の自分としては、馬券を買う際の参考としては十分であり、そもそも自分の予想ロジックをそのままプログラミングしているので、予想時間の短縮にも役立ってます。
実際に100万馬券や300万を超えるWIN5なども何度か経験でき、個人的には十分満足しています。
ちなみにサイト公開から15年弱経過した今でも、毎週100名程度のアクセスがあるようです。（かなり少ないｗｗｗ）
http://magokorokun.web.fc2.com

##投資競馬としての馬心君へ

この馬心君のロジックをベースに投資競馬の面でバージョンアップできないかと色々模索した結果、ディープラーニングの活用が良いのではないかと思い、調査を開始しました。
投資としての競馬なので、回収率は低めでも的中率を上げる必要があり、複利運用の観点から的中率50%以上、回収率110%以上を目標としました。
的中率50%の確率収束試行回数は危険率5%の場合380回程度となり、1日20レース、土日で40レース、月160レース程度で考えると、２ヶ月強で複利運用額を見直せることになります。
年5回の運用額見直しで複利110%だとすると、1年で1.6倍、2年で2.6倍、3年で4倍程度となります。
今回の開発言語はPython、フレームワークはChainerとしました。

##馬心君のアルゴリズム

馬心君は、大きく３つのロジックを持っています。
 １）予想対象のレースと出走馬の過去のレースの近似度を計算
 ２）出走馬の過去のレースにおける能力値を計算（タイム指数のようなもの）
 ３）これを過去7走分計算し、対象レースに似ている3レースを選出し、その能力値を平均する（ここに遺伝的アルゴリズムを適用）
このうち、１）と２）のロジックは大きな変更を加えず、３）の部分だけをディープラーニングで学習させることにしました。
ディープラーニングのいい意味での曖昧さによって、馬心君の弱点だった不利の問題、過去の能力値以上が想定できない問題などをついでに解決できれば最高です。

##学習と評価のためのデータ

今回、2014年から2018年までのレース結果と全出馬データを使うことにしました。このうち2014年から2017年までを学習データに、2018年を評価データとします。
1レース分のデータは[18,7,x]の形で18頭分過去7走分のデータｘとしました。
ｘは、能力値（ロジック１）、レース差分情報（ロジック２）、今回レース情報で構成されます。
教師データは、着順をベースとし、オッズを少し混入させて[18]の形式としました。
オッズを足しこむ理由は、着順だけだと本命サイドの学習となり、回収率が期待できないためです。
今回は
 ・着順：(1-着順/頭数)/2　（ただし1着の場合は1.0）
 ・オッズ：log10（オッズ）/着順/2　（ただし3着までのみ）
とし、着順＋オッズの教師データを作成しています。

##学習データの画像化とデータ増加手法

これで試しに全結合3層程度のネットワークで学習させてみましたが、やはり芳しくありません。
そこで、CNN（畳み込み）を使ってみることにしました。そのためには、学習データを画像化する必要があります。
色々とお絵描きしながら、画像化を実現してみました。最終的には、まるでビルの夜景のような感じになりました。
![traindata.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324631/f79a4c28-a232-3199-b68b-799e6f51f233.jpeg)

データ増量についても問題があります。競馬の場合、簡単にデータ複製ができません。（データと結果の関連性が強いため）
今回は、過去7走のデータ位置をランダムに入れ替える方法と、能力値に若干のガウシアンノイズを加える方法をとりました。
以上を踏まえたオリジナルのデータセット関数を作成しました。

```python:
class MagokoroDataset(chainer.dataset.DatasetMixin):
        def __init__(self, fromdate='20140101', todate='20181231', add_num=4):
            m = Magokoro(fromdate, todate)
            print('dump reading...')
            m.load_dict()
            print('dump read.')
            
            self.values, self.labels, self.result = m.evaluate_all_race(add_num)
            
        def __len__(self):
            return len(self.values)
            
        def make_image(self, x):
            img = np.array([])
            for i in range(3):
                tmp6 = np.array([])
                for j in range(6):
                    tmp7 = np.array([])
                    for k in range(7):
                        tmp1 = np.array([])
                        tmp2 = np.array([])
                        tmp3 = np.array([])
                        
                        tmp1 = np.append(tmp1, np.full(2, np.float32(x[0,i*6*7+j*7+k,24])))
                        tmp1 = np.append(tmp1, np.full(12, np.float32(x[0,i*6*7+j*7+k,0])))
                        tmp1 = np.append(tmp1, np.full(2, np.float32(0.0)))
                        
                        tmp2 = np.append(tmp2, np.full(2, np.float32(x[0,i*6*7+j*7+k,24])))
                        tmp2 = np.append(tmp2, np.float32(x[0,i*6*7+j*7+k,1:13]))
                        tmp2 = np.append(tmp2, np.full(2, np.float32(0.0)))
                        
                        tmp3 = np.append(tmp3, np.full(2, np.float32(x[0,i*6*7+j*7+k,24])))
                        tmp3 = np.append(tmp3, np.full(14, np.float32(0.0)))
                        
                        if k < 1:
                            tmp7 = tmp3
                        else:
                            tmp7 = np.vstack((tmp7, tmp3))
                        tmp7 = np.vstack((tmp7, tmp1))
                        tmp7 = np.vstack((tmp7, tmp2))
                        tmp7 = np.vstack((tmp7, tmp3))
                    if j < 1:
                        tmp6 = tmp7
                    else:
                        tmp6 = np.hstack((tmp6, tmp7))
                if i < 1:
                    img = tmp6
                else:
                    img = np.vstack((img, tmp6))
                if i < 2:
                    img = np.vstack((img, np.full(96, np.float32(0.0))))
                    img = np.vstack((img, np.full(96, np.float32(0.0))))
                    img = np.vstack((img, np.full(96, np.float32(0.0))))
                    img = np.vstack((img, np.full(96, np.float32(0.0))))
                    img = np.vstack((img, np.full(96, np.float32(0.0))))
                    img = np.vstack((img, np.full(96, np.float32(0.0))))
                    
            #img = Image.fromarray(np.uint8(img*255))
            #img.show()
            return np.float32(img)
            
        def get_example(self, i):
            img = self.make_image(self.values[i])
            img = img.reshape((1,96,96))
            return img, self.labels[i]
            
        def get_testdata(self, i):
            img = self.make_image(self.values[i])
            img = img.reshape((1,96,96))
            return img, self.labels[i], self.result[i]
```

##ネットワーク

ネットワークはVGGもどきのものを作成しました。（3,1,1）フィルタによる同サイズ畳み込みとMaxPoolingを重ね、活性化関数はReLUとしてます。BatchNormalizationとDropOutも入れています。
出力は18個のsigmoid値としています。

```python:
class MagokoroCNN(chainer.Chain):
    def __init__(self,nc=1):
        self.f = True
        w = chainer.initializers.HeNormal()
        super(MagokoroCNN, self).__init__()
        with self.init_scope():
            self.c1 = L.Convolution2D(None, 16*nc, 3,1,1, nobias=True, initialW=w)
            self.bn1 = L.BatchNormalization(16*nc)
            self.c2 = L.Convolution2D(None, 16*nc, 3,1,1, nobias=True, initialW=w)
            self.bn2 = L.BatchNormalization(16*nc)
            self.c3 = L.Convolution2D(None, 32*nc, 3,1,1, nobias=True, initialW=w)
            self.bn3 = L.BatchNormalization(32*nc)
            self.c4 = L.Convolution2D(None, 32*nc, 3,1,1, nobias=True, initialW=w)
            self.bn4 = L.BatchNormalization(32*nc)
            self.c5 = L.Convolution2D(None, 64*nc, 3,1,1, nobias=True, initialW=w)
            self.bn5 = L.BatchNormalization(64*nc)
            self.c6 = L.Convolution2D(None, 64*nc, 3,1,1, nobias=True, initialW=w)
            self.bn6 = L.BatchNormalization(64*nc)
            self.c7 = L.Convolution2D(None, 128*nc, 3,1,1, nobias=True, initialW=w)
            self.bn7 = L.BatchNormalization(128*nc)
            self.c8 = L.Convolution2D(None, 128*nc, 3,1,1, nobias=True, initialW=w)
            self.bn8 = L.BatchNormalization(128*nc)
            self.c9 = L.Convolution2D(None, 128*nc, 3,1,1, nobias=True, initialW=w)
            self.bn9 = L.BatchNormalization(128*nc)
            self.c10 = L.Convolution2D(None, 128*nc, 3,1,1, nobias=True, initialW=w)
            self.bn10 = L.BatchNormalization(128*nc)
            self.c11 = L.Convolution2D(None, 128*nc, 3,1,1, nobias=True, initialW=w)
            self.bn11 = L.BatchNormalization(128*nc)
            self.c12 = L.Convolution2D(None, 128*nc, 3,1,1, nobias=True, initialW=w)
            self.bn12 = L.BatchNormalization(128*nc)
            self.l1 = L.Linear(None, 256*nc)
            self.l2 = L.Linear(None, 256*nc)
            self.l3 = L.Linear(None, 18)
            
    def __call__(self, h): # 96*96
        h = F.relu(self.bn1(self.c1(h))) # 96*96
        h = F.relu(self.bn2(self.c2(h))) # 96*96
        h = F.max_pooling_2d(h, 2, 2, 0) # 48*48
        #h = F.dropout(h, 0.3)
        h = F.relu(self.bn3(self.c3(h))) # 48*48
        h = F.relu(self.bn4(self.c4(h))) # 48*48
        h = F.max_pooling_2d(h, 2, 2, 0) # 24*24
        #h = F.dropout(h, 0.3)
        h = F.relu(self.bn5(self.c5(h))) # 24*24
        h = F.relu(self.bn6(self.c6(h))) # 24*24
        h = F.max_pooling_2d(h, 2, 2, 0) # 12*12
        #h = F.dropout(h, 0.3)
        h = F.relu(self.bn7(self.c7(h))) # 12*12
        h = F.relu(self.bn8(self.c8(h))) # 12*12
        h = F.max_pooling_2d(h, 2, 2, 0) # 6*6
        h = F.dropout(h, 0.3)
        h = F.relu(self.bn9(self.c9(h))) # 6*6
        h = F.relu(self.bn10(self.c10(h))) # 6*6
        h = F.max_pooling_2d(h, 2, 2, 0) # 3*3
        h = F.dropout(h, 0.5)
        h = F.relu(self.bn11(self.c11(h))) # 3*3
        h = F.relu(self.bn12(self.c12(h))) # 3*3
        h = F.relu(self.l1(h)) # 256*nc
        h = F.dropout(h, 0.5)
        h = F.relu(self.l2(h)) # 256*nc
        h = F.dropout(h, 0.5)
        h = F.sigmoid(self.l3(h)) # 18
        
        return h
```

##学習ループ

オリジナルのデータセットから学習用と評価用データを取得します。
最適化関数はAdamを選択しました。
損失関数は教師データにオッズの重みをつけて学習させることから、単純なMSE（平均二乗誤差）としました。
（本当は、出馬数が18頭に満たない場合は、その頭数だけでMSEを実施したいのですが、クロスエントロピー関数にあるようなignore_labelが使えそうなMSEが無いようで･･･）
（2019/5/12追記）sigmoid_cross_entropyのcheck_type_forwardでtの型をfloat32に変更したら、何となくうまくいってる感じです…。本当にこれで良いのかな？ｗ

```python:
class SigmoidCrossEntropy2(function_node.FunctionNode):
                   :
                   :
    def check_type_forward(self, in_types):
        type_check.expect(in_types.size() == 2)
        x_type, t_type = in_types
        type_check.expect(
            x_type.dtype == np.float32,
            t_type.dtype == np.float32,
            x_type.shape == t_type.shape
        )
               :
               :
loss = sigmoid_cross_entropy2(y, t)
```

評価関数は、ひとまず通常のAccuracyによる１着の的中率とし、評価データのlossに更新があった際に、オリジナルの評価関数によっていくつかの指標を表示することにしました。また同タイミングでモデルを保存します。
 ・１着予想の単勝の的中率と回収率
 ・１着予想の複勝の的中率と回収率
 ・１着予想から2-5着予想までの馬連4点流しの的中率と回収率

```python:
def train(max_epoch = 50, batchsize = 128, gpu_id=-1):
    train = MagokoroDataset(fromdate='20140101', todate='20171231', add_num=1)
    test = MagokoroDataset(fromdate='20180101', todate='20181231', add_num=0)
    
    train_count = len(train)
    test_count = len(test)
    print('train=',train_count)
    print('test=',test_count)
    
    train_iter = iterators.SerialIterator(train, batchsize)
    test_iter = iterators.SerialIterator(test, batchsize, repeat=False, shuffle=False)
    
    net = MagokoroCNN()
        
    if gpu_id >= 0:
        net.to_gpu(gpu_id)
        
    optimizer = chainer.optimizers.Adam()
    optimizer.setup(net)
    
    train_accuracies = []
    best_loss = 9999.9
    while train_iter.epoch < max_epoch:
        sum_loss = 0
        sum_rec_loss = 0
        
        train_batch = train_iter.next()
        x, t = chainer.dataset.concat_examples(train_batch, gpu_id)
        y = net(x)
        #loss = F.mean_squared_error(F.sigmoid(y), t)
        loss = sigmoid_cross_entropy2(y, t)
        net.cleargrads()
        loss.backward()
        optimizer.update()
        
        train_accuracy = F.accuracy(y, F.argmax(t, axis=1))
        train_accuracy.to_cpu()
        train_accuracies.append(train_accuracy.array)
                
        if train_iter.is_new_epoch:
            print('epoch:{:02d} train_loss:{:.04f} train_accuracy:{:.04f}'.format(
                train_iter.epoch, float(to_cpu(loss.data)),np.mean(train_accuracies)), end='')
            train_accuracies = []
            test_accuracies = []
            test_loss = []
            while True:
                test_batch = test_iter.next()
                x_test, t_test = chainer.dataset.concat_examples(test_batch, gpu_id)
                with chainer.using_config('train', False), \
                        chainer.using_config('enable_backprop', False):
                    y_test = net(x_test)
                    #tloss = F.mean_squared_error(F.sigmoid(y_test), t_test)
                    tloss = sigmoid_cross_entropy2(y_test, t_test)
                    tloss.to_cpu()
                    test_loss.append(tloss.array)
                    accuracy = F.accuracy(y_test, F.argmax(t_test, axis=1))
                    accuracy.to_cpu()
                    test_accuracies.append(accuracy.array)
                
                if test_iter.is_new_epoch:
                    test_iter.reset()
                    break
            print(' test_loss:{:.04f} test_accuracy:{:.04f}'.format(np.mean(test_loss), np.mean(test_accuracies)))
            
            if best_loss > np.mean(test_loss):
                print('update!')
                best_loss = np.mean(test_loss)
                serializers.save_npz('magokoro_cnn_best.model', net)
                report(test, net, gpu_id)
                
    serializers.save_npz('magokoro_cnn_last.model', net)
```  

レポート用の関数は下記の通りです。

```python:
def report(test, net, gpu_id):
    pay_tan, pay_fuk, pay_umaren, cnt_tan, cnt_fuk, cnt_umaren = 0, 0, 0, 0, 0, 0
    with chainer.using_config('train', False), chainer.using_config('enable_backprop', False):
        for i in range(len(test)):
            x, t, result = test.get_testdata(i)
            x = x[None, ...]
            x = net.xp.asarray(x)
            y = net(x)
            y = F.softmax(y)
            y = y.array
            y = to_cpu(y)
            y = y.reshape(18)
            so = np.argsort(y)[::-1]
            if so[0] < len(result[0]['horse']):
                h = result[0]['horse'][so[0]]
                if h['kakuteijyuni'] <= 2 or (h['kakuteijyuni'] == 3 and result[0].get('payfukusyopay3')):
                    pay_fuk += result[0]['payfukusyopay{}'.format(h['kakuteijyuni'])]
                    cnt_fuk += 1
                    if h['kakuteijyuni'] <= 2:
                        if h['kakuteijyuni'] == 1:
                            pay_tan += result[0]['paytansyopay']
                            cnt_tan += 1
                        for k in range(4):
                            if so[k+1] < len(result[0]['horse']):
                                h = result[0]['horse'][so[k+1]]
                                if h['kakuteijyuni'] <= 2:
                                    pay_umaren += result[0]['payumarenpay']
                                    cnt_umaren += 1
                                    break
                                    
    print('tan   ：hit={:>5.1f}% return={:>6.1f}%'.format(cnt_tan*100.0/len(test), pay_tan / len(test)))
    print('fuk   ：hit={:>5.1f}% return={:>6.1f}%'.format(cnt_fuk*100.0/len(test), pay_fuk / len(test)))
    print('umaren：hit={:>5.1f}% return={:>6.1f}%'.format(cnt_umaren*100.0/len(test), pay_umaren / 4.0 / len(test)))
```  

##学習の途中経過

やはり回収率が100%を超えてきません。しかも過学習となっています。

~~~
dump reading...
dump read.
dump reading...
dump read.
train= 52030
test= 2565
epoch:01 train_loss:0.1460 train_accuracy:0.1478 test_loss:0.1379 test_accuracy:0.2318
update!
tan   ：hit= 22.4% return=  81.3%
fuk   ：hit= 51.6% return=  85.2%
umaren：hit= 22.3% return=  90.5%
epoch:02 train_loss:0.1516 train_accuracy:0.2094 test_loss:0.1368 test_accuracy:0.2484
update!
tan   ：hit= 23.2% return=  81.3%
fuk   ：hit= 53.3% return=  83.9%
umaren：hit= 23.2% return=  77.2%
epoch:03 train_loss:0.1416 train_accuracy:0.2315 test_loss:0.1348 test_accuracy:0.2612
update!
tan   ：hit= 25.5% return=  85.1%
fuk   ：hit= 54.2% return=  84.4%
umaren：hit= 25.8% return=  89.4%
epoch:04 train_loss:0.1433 train_accuracy:0.2525 test_loss:0.1336 test_accuracy:0.2899
update!
tan   ：hit= 28.5% return=  82.2%
fuk   ：hit= 60.0% return=  86.1%
umaren：hit= 29.3% return=  79.3%
epoch:05 train_loss:0.1370 train_accuracy:0.2659 test_loss:0.1336 test_accuracy:0.2610
epoch:06 train_loss:0.1349 train_accuracy:0.2770 test_loss:0.1327 test_accuracy:0.2935
update!
tan   ：hit= 28.0% return=  84.4%
fuk   ：hit= 58.8% return=  84.7%
umaren：hit= 30.5% return=  84.4%
epoch:07 train_loss:0.1397 train_accuracy:0.2833 test_loss:0.1321 test_accuracy:0.2865
update!
tan   ：hit= 28.2% return=  85.0%
fuk   ：hit= 58.5% return=  84.6%
umaren：hit= 31.2% return=  85.3%
epoch:08 train_loss:0.1340 train_accuracy:0.2953 test_loss:0.1319 test_accuracy:0.2724
update!
tan   ：hit= 26.7% return=  84.9%
fuk   ：hit= 58.0% return=  86.5%
umaren：hit= 30.8% return=  85.2%
epoch:09 train_loss:0.1327 train_accuracy:0.3102 test_loss:0.1318 test_accuracy:0.2906
update!
tan   ：hit= 28.6% return=  82.8%
fuk   ：hit= 59.3% return=  84.1%
umaren：hit= 32.1% return=  84.8%
epoch:10 train_loss:0.1363 train_accuracy:0.3245 test_loss:0.1320 test_accuracy:0.2783
epoch:11 train_loss:0.1377 train_accuracy:0.3424 test_loss:0.1328 test_accuracy:0.2754
epoch:12 train_loss:0.1316 train_accuracy:0.3653 test_loss:0.1332 test_accuracy:0.2620
epoch:13 train_loss:0.1299 train_accuracy:0.3912 test_loss:0.1348 test_accuracy:0.2694
epoch:14 train_loss:0.1275 train_accuracy:0.4213 test_loss:0.1358 test_accuracy:0.2504
epoch:15 train_loss:0.1352 train_accuracy:0.4548 test_loss:0.1365 test_accuracy:0.2504
epoch:16 train_loss:0.1161 train_accuracy:0.4838 test_loss:0.1399 test_accuracy:0.2426
epoch:17 train_loss:0.1240 train_accuracy:0.5183 test_loss:0.1403 test_accuracy:0.2441
epoch:18 train_loss:0.1152 train_accuracy:0.5490 test_loss:0.1421 test_accuracy:0.2286
epoch:19 train_loss:0.1236 train_accuracy:0.5772 test_loss:0.1440 test_accuracy:0.2206
epoch:20 train_loss:0.1145 train_accuracy:0.6032 test_loss:0.1448 test_accuracy:0.2305
epoch:21 train_loss:0.1042 train_accuracy:0.6271 test_loss:0.1465 test_accuracy:0.2180
epoch:22 train_loss:0.1035 train_accuracy:0.6478 test_loss:0.1484 test_accuracy:0.2091
epoch:23 train_loss:0.1066 train_accuracy:0.6694 test_loss:0.1490 test_accuracy:0.2301
epoch:24 train_loss:0.1094 train_accuracy:0.6835 test_loss:0.1508 test_accuracy:0.2054
epoch:25 train_loss:0.1149 train_accuracy:0.6978 test_loss:0.1501 test_accuracy:0.2158
epoch:26 train_loss:0.1016 train_accuracy:0.7145 test_loss:0.1515 test_accuracy:0.2279
epoch:27 train_loss:0.1047 train_accuracy:0.7246 test_loss:0.1506 test_accuracy:0.2242
epoch:28 train_loss:0.1109 train_accuracy:0.7355 test_loss:0.1524 test_accuracy:0.2083
epoch:29 train_loss:0.1118 train_accuracy:0.7444 test_loss:0.1525 test_accuracy:0.2169
epoch:30 train_loss:0.1012 train_accuracy:0.7541 test_loss:0.1522 test_accuracy:0.2150
epoch:31 train_loss:0.1041 train_accuracy:0.7637 test_loss:0.1534 test_accuracy:0.2153
epoch:32 train_loss:0.1004 train_accuracy:0.7704 test_loss:0.1533 test_accuracy:0.2216
epoch:33 train_loss:0.1067 train_accuracy:0.7773 test_loss:0.1536 test_accuracy:0.2160
epoch:34 train_loss:0.1039 train_accuracy:0.7849 test_loss:0.1532 test_accuracy:0.2057
epoch:35 train_loss:0.1027 train_accuracy:0.7892 test_loss:0.1546 test_accuracy:0.2035
epoch:36 train_loss:0.1018 train_accuracy:0.8003 test_loss:0.1555 test_accuracy:0.2132
epoch:37 train_loss:0.1000 train_accuracy:0.8026 test_loss:0.1556 test_accuracy:0.2080
epoch:38 train_loss:0.1014 train_accuracy:0.8049 test_loss:0.1564 test_accuracy:0.2013
epoch:39 train_loss:0.1067 train_accuracy:0.8089 test_loss:0.1560 test_accuracy:0.2113
epoch:40 train_loss:0.0988 train_accuracy:0.8163 test_loss:0.1568 test_accuracy:0.1975
epoch:41 train_loss:0.0947 train_accuracy:0.8170 test_loss:0.1560 test_accuracy:0.2108
epoch:42 train_loss:0.1018 train_accuracy:0.8193 test_loss:0.1564 test_accuracy:0.2046
epoch:43 train_loss:0.1017 train_accuracy:0.8249 test_loss:0.1567 test_accuracy:0.2082
epoch:44 train_loss:0.0971 train_accuracy:0.8271 test_loss:0.1573 test_accuracy:0.2130
epoch:45 train_loss:0.0927 train_accuracy:0.8340 test_loss:0.1576 test_accuracy:0.2039
epoch:46 train_loss:0.0909 train_accuracy:0.8376 test_loss:0.1565 test_accuracy:0.2164
epoch:47 train_loss:0.0972 train_accuracy:0.8395 test_loss:0.1570 test_accuracy:0.2048
epoch:48 train_loss:0.0949 train_accuracy:0.8420 test_loss:0.1572 test_accuracy:0.2086
~~~

色々とハイパーパラメーターを変更しながら、しばらく試行錯誤してみます。