[儲からない競馬予想AI] Section 04-03 : Multi-objective GPによるベット予想

GPでベットをするかしないかを予測する関数を作ります。
前回は選択方法を改善しましたが、うまく利益上げることができませんでした。
今回は多目的進化計算を使って見たいと思います。

今までのGPでは、評価値に一つの値しか取ることができませんでした。
多目的(Multi-objective)では、他のものも評価値として並列に扱えるため、より過学習しにくいと考えられます。

例えば、今回の実験では総利益の他に勝った賭け数や、負けた賭け数を評価値として返します。
多目的最適化についてもこの本では詳しく解説しませんので、自分で調べることを推奨します。

学習過程はipynbファイルで行いました。以下はその記録です。

前処理

前処理は今までと変わりません。
まず、使いそうなライブラリをインポートします。

import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from IPython.display import display, Math

学習データの読み込み

df = pd.read_pickle('/work/formatted_source_data/analysis_data02.pkl')

次に学習データとテストデータ、バリデーションデータの切り分け、および入力データと教師データの切り分けをします。データの正規化もします。

教師データは単勝が当たったかどうか(tansyo_hit)と単勝が当たった時の支払い金額(tansyo_payout)を使います。

df = df.sort_values(['race_date']).reset_index(drop=True)

not_use_columns = [
    'race_date', 'race_id', 'race_grade', 'name', 'jocky_name', 'odds', 'popular', 'rank', 'time', 'prize', 'tansyo_hit', 'tansyo_payout', 'hukusyo_hit', 'hukusyo_payout',
]

not_use_df = df[not_use_columns]
target_df = df.drop(columns=not_use_columns)

train_df = target_df.loc[not_use_df['race_date'] < '2018-01-01', :].reset_index(drop=True)
valid_df = target_df.loc[('2018-01-01' <= not_use_df['race_date']) & (not_use_df['race_date'] < '2022-01-01'), :].reset_index(drop=True)
test_df = target_df.loc['2022-01-01' <= not_use_df['race_date'], :].reset_index(drop=True)

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
train_X =  pd.DataFrame(data=scaler.fit_transform(train_df), columns=train_df.columns)
valid_X =  pd.DataFrame(data=scaler.transform(valid_df), columns=train_df.columns)
test_X  =  pd.DataFrame(data=scaler.transform(test_df), columns=train_df.columns)

train_X = train_X.fillna(0)
valid_X = valid_X.fillna(0)
test_X = test_X.fillna(0)

Y_columns = ['tansyo_hit', 'tansyo_payout']
train_Y = not_use_df.loc[not_use_df['race_date'] < '2018-01-01', Y_columns].reset_index(drop=True)
valid_Y = not_use_df.loc[('2018-01-01' <= not_use_df['race_date']) & (not_use_df['race_date'] < '2022-01-01'), Y_columns].reset_index(drop=True)
test_Y = not_use_df.loc['2022-01-01' <= not_use_df['race_date'], Y_columns].reset_index(drop=True)

train_Y = train_Y.rename(columns={'tansyo_hit':'hit', 'tansyo_payout':'payout'})
valid_Y = valid_Y.rename(columns={'tansyo_hit':'hit', 'tansyo_payout':'payout'})
test_Y = test_Y.rename(columns={'tansyo_hit':'hit', 'tansyo_payout':'payout'})

学習

まず、使うライブラリをインポートします。

import operator
from deap import base
from deap import creator
from deap import tools
from deap import gp
import random
from functools import partial

割り算の演算関数を再定義します。

def protectedDiv(left, right):
    with np.errstate(divide='ignore',invalid='ignore'):
        x = np.divide(left, right)
        if isinstance(x, np.ndarray):
            x[np.isinf(x)] = 1
            x[np.isnan(x)] = 1
        elif np.isinf(x) or np.isnan(x):
            x = 1
    return x

次にGPでつかう関数群を定義します。

pset = gp.PrimitiveSet("MAIN", len(train_X.columns))
pset.addPrimitive(np.add, 2, name="add")
pset.addPrimitive(np.subtract, 2, name="sub")
pset.addPrimitive(np.multiply, 2, name="mul")
pset.addPrimitive(protectedDiv, 2, name='div')
pset.addPrimitive(np.negative, 1, name="neg")
pset.addPrimitive(np.cos, 1, name="cos")
pset.addPrimitive(np.sin, 1, name="sin")
pset.addEphemeralConstant("rand101", partial(random.randint, -1, 1))

pset.renameArguments(**{f'ARG{i}':f'x_{col}' for i, col in enumerate(train_X.columns)})

評価クラスを多目的用に設定します。FittnessMultiとしました。
weightsは複数の評価値の重みです。3つ目の評価値として負けた賭け数を与えるため、それは最小化が目的なので-1.0を設定。それ以外の2つは最大化がもくてきなので1.0を設定。

creator.create("FitnessMulti", base.Fitness, weights=(1.0, 1.0, -1.0))
creator.create("Individual", gp.PrimitiveTree, fitness=creator.FitnessMax)

toolbox = base.Toolbox()
toolbox.register("expr", gp.genHalfAndHalf, pset=pset, min_=1, max_=2)
toolbox.register("individual", tools.initIterate, creator.Individual, toolbox.expr)
toolbox.register("population", tools.initRepeat, list, toolbox.individual)
toolbox.register("compile", gp.compile, pset=pset)

評価関数を定義します。個体(解候補)が与えられたときに、その評価値を返せばよいです。
toolbox.compileによって解候補から合成関数を作り出します。その後、train_inputsを与えて、出力値を得ます。
出力値が0以上のときに賭け、そうでないときは賭けないようにして、利益を算出します。
その総利益を評価値として返します。
また、勝った数や負けた数を数えてそれらも評価値として返します。

train_inputs = {f'x_{col}':train_X.loc[:, col].values for col in train_X.columns}
def eval_individual(individual):
    func = toolbox.compile(expr=individual)
    ret = func(**train_inputs)
    bet = np.zeros_like(ret)
    bet[ret > 0] = 1
    win_num = np.sum(win_money > 0)
    lose_num = np.sum(win_money < 0)
    return win_score, win_num, lose_num

定義した評価関数や、deapに内蔵されている選択、交叉、突然変異の関数をtoolboxに登録します。また、個体の深さ(合成関数の処理規模だと思ってください)に制限を設けます。

選択方法をtools.selNSGA2に設定します。多目的最適化の手法はたくさんありますが、今回は評価値も3つなのでクラシックなNSGA2にしたいと思います。

toolbox.register("evaluate", eval_individual)
toolbox.register("select", tools.selNSGA2)
toolbox.register("mate", gp.cxOnePoint)
toolbox.register("expr_mut", gp.genFull, min_=0, max_=2)
toolbox.register('mutate', gp.mutUniform, expr=toolbox.expr_mut, pset=pset)

toolbox.decorate("mate", gp.staticLimit(key=operator.attrgetter("height"), max_value=12))
toolbox.decorate("mutate", gp.staticLimit(key=operator.attrgetter("height"), max_value=12))

準備が揃ったので、学習を始めます。
個体数500、世代数200で学習します。

crossover_rate = 0.8
mutate_rate = 0.2
max_generation = 200
population_size = 500

random.seed(318)

population = toolbox.population(n=population_size)
halloffame = tools.HallOfFame(1)
stats_fit0 = tools.Statistics(lambda ind: ind.fitness.values[0])
stats_fit1 = tools.Statistics(lambda ind: ind.fitness.values[1])
stats_fit2 = tools.Statistics(lambda ind: ind.fitness.values[2])
mstats = tools.MultiStatistics(win_score=stats_fit0, win_num=stats_fit1, lose_num=stats_fit2)
mstats.register("avg", np.mean)
mstats.register("std", np.std)
mstats.register("min", np.min)
mstats.register("max", np.max)

logbook = tools.Logbook()
logbook.header = ['gen', 'nevals'] + mstats.fields

# Evaluate the individuals with an invalid fitness
invalid_ind = [ind for ind in population if not ind.fitness.valid]
fitnesses = toolbox.map(toolbox.evaluate, invalid_ind)
for ind, fit in zip(invalid_ind, fitnesses):
    ind.fitness.values = fit
pop = toolbox.select(population, len(population))

if halloffame is not None:
    halloffame.update(population)

record = mstats.compile(population)
logbook.record(gen=0, nevals=len(invalid_ind), **record)
print(logbook.stream)

elite = None
# Begin the generational process
for gen in range(1, max_generation + 1):
    # Select the next generation individuals
    offspring = tools.selTournamentDCD(population, len(population))
    offspring = [toolbox.clone(ind) for ind in offspring]

    # Apply crossover and mutation on the offspring
    for i in range(1, len(offspring), 2):
        if random.random() < crossover_rate:
            offspring[i - 1], offspring[i] = toolbox.mate(offspring[i - 1], offspring[i])
            del offspring[i - 1].fitness.values, offspring[i].fitness.values

    for i in range(len(offspring)):
        if random.random() < mutate_rate:
            offspring[i], = toolbox.mutate(offspring[i])
            del offspring[i].fitness.values

    # Evaluate the individuals with an invalid fitness
    invalid_ind = [ind for ind in offspring if not ind.fitness.valid]
    fitnesses = toolbox.map(toolbox.evaluate, invalid_ind)
    for ind, fit in zip(invalid_ind, fitnesses):
        ind.fitness.values = fit

    # Update the hall of fame with the generated individuals
    if halloffame is not None:
        halloffame.update(offspring)

    population = toolbox.select(population + offspring, population_size)

    # Append the current generation statistics to the logbook
    record = mstats.compile(population)
    logbook.record(gen=gen, nevals=len(invalid_ind), **record)
    print(logbook.stream)

実行するとログが流れます。終わるまで待ちます。

                                  lose_num                                         win_num                                            win_score                       
               ----------------------------------------------  ----------------------------------------------- -------------------------------------------------------
gen    nevals  avg     gen max     min nevals  std     avg     gen max     min nevals  std     avg         gen max min     nevals  std   
...
198    428     218343  198 472111  0   428     145112  24080.7 198 35862   0   428     10651.3 -52135.2    198 2629.9  -138531 428     37984.3
199    419     217249  199 472111  0   419     146645  23935.1 199 35862   0   419     10747.9 -52043.2    199 2629.9  -138531 419     38429.6
200    419     218052  200 472111  0   419     146987  23980.7 200 35862   0   419     10752.8 -52206.5    200 2629.9  -138531 419     38486.3

パレート解を見てみます。
多目的最適化では複数の評価基準があるため、一番良い解候補というのをアルゴリズムは選べません。複数の回候補を見て、人が選ぶ必要があります。

fits = np.array([ind.fitness.values for ind in population])
#win_score, bet_num, lose_score
plt.figure(tight_layout=True, figsize=(12, 6))
plt.subplot(131)
plt.scatter(fits[:, 0], fits[:, 1])
plt.title('win_score - win_num')
plt.xlabel('win_score')
plt.ylabel('win_num')

plt.subplot(132)
plt.scatter(fits[:, 1], fits[:, 2])
plt.title('win_num - lose_num')
plt.xlabel('win_num')
plt.ylabel('lose_num')

plt.subplot(133)
plt.scatter(fits[:, 0], fits[:, 2])
plt.title('win_score - lose_num')
plt.xlabel('win_score')
plt.ylabel('lose_num')


カーブが描けているので、アルゴリズムは正常に動作しているようです。
ただ、win_scoreがほとんどの場合で0以下になってしまっているため、ほとんど不要な解候補となっています。

学習データで利益がでたものだけ、ピックアップしてバリデーションとテストデータの利益を見てみます。

def calc_win_money(individual, X, Y):
    inputs = {f'x_{col}':X.loc[:, col].values for col in X.columns}
    func = toolbox.compile(expr=individual)
    ret = func(**inputs)
    bet = np.zeros_like(ret)
    bet[ret > 0] = 1
    win_money = bet*(Y['hit']*Y['payout'] - 1.0)
    return np.sum(win_money)

for i, ind in enumerate(population):
    if ind.fitness.values[0] > 0:
        print(f'--------{i}--------')
        print(ind.fitness.values)
        print(f'train win money: {calc_win_money(ind, train_X, train_Y)}')
        print(f'valid win money: {calc_win_money(ind, valid_X, valid_Y)}')
        print(f'test  win money: {calc_win_money(ind, test_X, test_Y)}')

インデックス1の解候補は学習、バリデーション、テストデータの3つとも利益がでています。しかし、他の解候補はテストデータ利益がでていません。

--------1--------
(2629.9000000000005, 49.0, 588.0)
train win money: 2629.9000000000005
valid win money: 20551.800000000003
test  win money: 29.100000000000023
--------42--------
(1083.1000000000004, 526.0, 5137.0)
train win money: 1083.1000000000004
valid win money: 19569.100000000002
test  win money: -805.2000000000002
--------141--------
(1445.999999999999, 1481.0, 9710.0)
train win money: 1445.999999999999
valid win money: -1170.2000000000003
test  win money: -953.6000000000001
--------184--------
(1226.5999999999997, 1535.0, 9224.0)
train win money: 1226.5999999999997
valid win money: -828.6
test  win money: -971.8
--------247--------
(398.40000000000003, 2349.0, 12860.0)
train win money: 398.40000000000003
valid win money: -1471.4
test  win money: -1213.6999999999998
--------404--------
(832.4000000000007, 2172.0, 12617.0)
train win money: 832.4000000000007
valid win money: -1504.7
test  win money: -1113.3

インデックス1の解候補の利益の推移を見てみます。

def calc_win_money_line(individual, X, Y):
    inputs = {f'x_{col}':X.loc[:, col].values for col in X.columns}
    func = toolbox.compile(expr=individual)
    ret = func(**inputs)
    bet = np.zeros_like(ret)
    bet[ret > 0] = 1
    win_money_line = bet*(Y['hit']*Y['payout'] - 1.0)
    return np.cumsum(win_money_line)

target_ind = population[1]

fits = np.array([ind.fitness.values for ind in population])
#win_score, bet_num, lose_score
plt.figure(tight_layout=True, figsize=(12, 6))
plt.subplot(131)
win_money_line = calc_win_money_line(target_ind, train_X, train_Y)
plt.plot(win_money_line)
plt.title('train')
plt.xlabel('x(date)')
plt.ylabel('win_score')

plt.subplot(132)
win_money_line = calc_win_money_line(target_ind, valid_X, valid_Y)
plt.plot(win_money_line)
plt.title('valid')
plt.xlabel('x(date)')
plt.ylabel('win_score')

plt.subplot(133)
win_money_line = calc_win_money_line(target_ind, test_X, test_Y)
plt.plot(win_money_line)
plt.title('test')
plt.xlabel('x(date)')
plt.ylabel('win_score')

あまりいい感じではないですね。一つの単勝をたまたま当てた結果、利益がでているように見えましたが、他の地点では徐々に降下傾向にあるため、これを本番で使うのかなりリスキーです。

タイトルとURLをコピーしました