richmanbtcさんのチュートリアルをじっくり読んでみた
Cover Photo by Paul Hanaoka
注!
この記事はrichmanさんが2021年の9月頃に行ったアップデート以前のコードを参考としています。コードのアップデートと共にご本人が解説を加えてくださっていますので、最新の情報は後述のリンクを辿ってレポジトリよりご確認ください。
最近すっかり機械学習にはまってしまいました。まだ触り始めて一ヶ月(2021年8月時点)ほどですが、真っ白な状態だったので統計や数学の本を何冊か読み、いくつかの動画コースを飛ばし読みスタイルで進めるなどしています。
少しだけ知識がついてきたので、richmanbtcさんが作成してくださったこちらの素晴らしいチュートリアルを読み解いてみることにしました。
https://note.com/btcml/n/nc5c63a9f5aa2
機械学習の教材自体は英語のものまで含めると無料でもたくさんありますが、richmanbtcさんのチュートリアルはボットにどう適用できるのか、という部分に踏み込んで具体的なサンプルを提示してくださっています。
以下の項目は全て、とても参考になりました。ここまで具体的に実践例としてまとめてくださっているものは今のところこのチュートリアル以外で見たことがありません。
特徴量の検定方法
目的変数yの作成例
バックテスト
ロバストな学習結果の検定
次章から、私が気になった部分について適宜調査したメモをコード(ライセンスがCC0だなんて…!)と一緒に掲載させて頂いています。わからないところも全部残しています。また、私の理解が間違っている部分もあるかもしれませんがいざ。
ライブラリ
まずは簡単にライブラリを確認します。
from collections import defaultdict
import json
import math
import os
import re
import time
import ccxt
from crypto_data_fetcher.ftx import FtxFetcher
import lightgbm as lgb
import matplotlib.pyplot as plt
import numba
import numpy as np
import pandas as pd
import scipy
from scipy.stats import norm, pearsonr, spearmanr, ttest_1samp, ttest_ind
import seaborn as sns
from statsmodels.tsa.stattools import adfuller
import talib
from sklearn.linear_model import Ridge, RidgeCV, LassoCV, LogisticRegression
from sklearn.preprocessing import QuantileTransformer
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.model_selection import cross_val_score, KFold, TimeSeriesSplit
from sklearn.ensemble import BaggingRegressor
pd.set_option('display.max_rows', 500)
pd.set_option('display.max_columns', 500)
pd.set_option('display.width', 1000)
pd.options.mode.chained_assignment = None
今回はFTXからOHLCをフェッチしてGBDT(Gradient Boosting Decision Tree)でモデルを作っておられます。lightgbmはGBDTライブラリの中でも新しく、計算が速いようで、私が触っていた深層学習系のライブラリと比べても圧倒的に速いです。
talibは色々な指標を計算してくれる便利なライブラリです。anacondaに仮想環境を作ってJupyterを動かしているのですが、インストールには以下のコマンドが必要でした。
conda install -c conda-forge ta-lib
データの取得
FTXからOHLCVを取得します。
ftx = ccxt.ftx()
fetcher = FtxFetcher(ccxt_client=ftx)
df = fetcher.fetch_ohlcv(
market='BTC-PERP',
interval_sec=5 * 60,
)
display(df)
df.to_pickle('df_ohlcv.pkl')
richmanbtcさんは独自のライブラリを使われていますが、そちらも公開してくださっています。
https://github.com/richmanbtc/crypto_data_fetcher
あるいは、自分でOHLCを取ってきてpandas使用時に列名だけ気をつければ問題ありません。また、pickleを使って一時データを保存していますね。
特徴量の作成
talibライブラリを利用してOHLCVから一般的な指標を作ります。
# 特徴量作成
# 例としてtalibで特徴量をいくつか生成
def calc_features(df):
open = df['op']
high = df['hi']
low = df['lo']
close = df['cl']
volume = df['volume']
orig_columns = df.columns
print('calc talib overlap')
hilo = (df['hi'] + df['lo']) / 2
df['BBANDS_upperband'], df['BBANDS_middleband'], df['BBANDS_lowerband'] = talib.BBANDS(close, timeperiod=5, nbdevup=2, nbdevdn=2, matype=0)
df['BBANDS_upperband'] -= hilo
df['BBANDS_middleband'] -= hilo
df['BBANDS_lowerband'] -= hilo
df['DEMA'] = talib.DEMA(close, timeperiod=30) - hilo
df['EMA'] = talib.EMA(close, timeperiod=30) - hilo
df['HT_TRENDLINE'] = talib.HT_TRENDLINE(close) - hilo
df['KAMA'] = talib.KAMA(close, timeperiod=30) - hilo
df['MA'] = talib.MA(close, timeperiod=30, matype=0) - hilo
df['MIDPOINT'] = talib.MIDPOINT(close, timeperiod=14) - hilo
df['SMA'] = talib.SMA(close, timeperiod=30) - hilo
df['T3'] = talib.T3(close, timeperiod=5, vfactor=0) - hilo
# ...黒枝により中略
return df
df = pd.read_pickle('df_ohlcv.pkl')
df = df.dropna()
df = df.reset_index()
df = df[df['timestamp'] < pd.to_datetime('2021-01-01 00:00:00Z')] # テスト期間を残せるように少し前で設定
df = calc_features(df)
df = df.set_index('timestamp')
display(df)
df.to_pickle('df_features.pkl')
timeperiodは一般的な数値を入れている状態だと思うので、いずれかの指標を自分で使う際にはハイパーパラメータとして良い値を探る作業も検討することになりそうです。
私が貼り付けたコードからは省略されている部分になりますが、ATR(Average True Range)という指標が取得されています。この値は大まかに言うと、ある一定期間のローソクの値動きをそれぞれで調べて平均した値です。特徴量には含めず、後に約定シミュレーションで利用することになります。
# テスト期間を残せるように少し前で設定
df = df[df['timestamp'] < pd.to_datetime('2021-01-01 00:00:00Z')]
ここはもしかしたらこのチュートリアルのロジックとは直接関係しない部分かもしれません。このあと交差検証(Cross Validation。以下CVと表記します)するのですが、仮にCVではなくホールドアウト(学習とテストデータをすっぱり分ける方法)で検証するなら、ここで期間を区切っておきましょう、ということでしょうか。大きな影響を与える部分ではなさそうなので、次に進みます。
特徴量エンジニアリング
先ほど作成した特徴量を検定していきます。
df = pd.read_pickle('df_features.pkl')
print('dfは特徴量が入ったDataFrame')
print('featuresは使う特徴量カラム名配列')
print('重要度表示。重要度が高いものは汎化性能に悪影響を与える可能性がある')
model = lgb.LGBMRegressor(n_jobs=-1, random_state=1)
model.fit(df[features], np.arange(df.shape[0]))
lgbm_adv_importance = model.feature_importances_
feature_imp = pd.DataFrame(sorted(zip(model.feature_importances_, features)), columns=['Value','Feature'])
plt.figure(figsize=(20, 40))
sns.barplot(x="Value", y="Feature", data=feature_imp.sort_values(by="Value", ascending=False))
plt.title('LightGBM Features adv val (avg over folds)')
plt.tight_layout()
plt.show()
print('スコア計算。スコアが高いと汎化性能が悪い可能性ある (目安は0.3以下)')
cv = KFold(n_splits=2, shuffle=True, random_state=0)
scores = cross_val_score(model, df[features], np.arange(df.shape[0]), scoring='r2', cv=cv)
print('scores', scores)
print('score mean, std', np.mean(scores), np.std(scores))
lgbmのモデルから取得できるfeature_importances_という値を利用して、どの特徴量が強く働いているのかを調査されています。具体的にはいったん学習(fit)してからfeature_importances_を取り出しています。
余談ですがlgbmはtrainというメソッドを用意しており、私が他で見つけたコードではそちらを使った例のほうが多かった気がします。このチュートリアルでは一貫してfitを使っておられますが、文法がsklearnと同じらしいので馴染み深い方も多いのかもしれません。
model.fit(df[features], np.arange(df.shape[0]))
ここでfitしていますが、行数の配列を目的変数としています(例えば[0,1,2...24999]みたいな値が入る)。実践では次章で作成する、実際に学習で使う目的変数を入れて実施するものなのだと思います(誤りです!以下追記)。
8/19日追記
誤りでした!実際の目的変数を入れる必要はない模様です。richmanさんがチュートリアルを更新して説明してくださっていますので、詳しくはrichman non-stationarity scoreについて書いてくださっているあたりをご参照ください。
このfitとimportanceを使った部分は連番を目的変数とした場合の指標の重要度を表示しています。この予測で重要度が高い指標は単調増加する目的変数に反応する指標であり、汎化性能に悪影響を与えることが予想されます。個別で表示していたものをまとめて定量化したものがスコアです。
スコアの算出は学習データのばらつきを吸収することが主な目的であるという理解です。学習データ自体に過剰反応し、汎化性能を落とす指標を、予め篩にかける工程を設けているということかと思います。
続けて、CV(Cross Validation)のためにKFoldクラスが使われています。
cv = KFold(n_splits=2, shuffle=True, random_state=0)
scores = cross_val_score(model, df[features], np.arange(df.shape[0]), scoring='r2', cv=cv)
CVとはデータを分割して学習用と検証用をローテーションすることでテストの回数を増やし、汎化性能を上げることが目的だそうです。要するにたくさん試してどこでも有効なものを探しましょう、みたいな感じですね。ここでも先ほどと同じ連番のみが格納された配列を渡しています。
目的変数の作成
いよいよ目的変数yを作っていきます。
私は機械学習だけでなくボッターとしても経験が浅いため、個人的にとても参考になった部分です。
このあたりから何をなさっているのかよくわからず、暗闇でじっとコードを眺めている時間が多くなりました。
df = pd.read_pickle('df_features.pkl')
# FTX BTC-PERPだとpipsが時期で変化するので(0.25〜1くらい)、
# 約定シミュレーションは小さい単位でやって、指値計算は大きい単位でやる
min_pips = 0.001
max_pips = 1
# limit_price_dist = max_pips
limit_price_dist = df['ATR'] * 0.5
limit_price_dist = np.maximum(1, (limit_price_dist / max_pips).round().fillna(1)) * max_pips
df['buy_price'] = df['cl'] - limit_price_dist
df['sell_price'] = df['cl'] + limit_price_dist
ここでまず、talibで求めておいたATRを呼び出しています。
この値は一定期間のだいたいの値幅を表現する指標でした。そこで、この値を使って上下の指値を設定していきます。終値からこれだけ離れていたら買い、あるいは売りたいです、ということですね。それに0.5をかけることで片側への振れ幅として加工します。
以下、ロング狙いで進むパターンだけ見ていきます。
# calc_force_entry_priceは入力をマイナスにすれば売りに使える
df['sell_fep'] = -calc_force_entry_price(
entry_price=-df['sell_price'].values,
lo=-df['high'].values, # 売りのときは高値
pips=min_pips,
)
ロングポジションを持っている際の決済ロジックです。買いに対する決済なので売りです。ここではその決済価格を取得するために、次の関数を実行しています。ちなみにfep=Force Entry Priceの略だそうです。
@numba.njit
def calc_force_entry_price(entry_price=None, lo=None, pips=None):
y = entry_price.copy()
y[:] = np.nan
for i in range(entry_price.size):
for j in range(i + 1, entry_price.size):
if round(lo[j] / pips) < round(entry_price[j - 1] / pips):
y[i] = entry_price[j - 1]
break
return y
具体的にどう動いているのかというと、とりあえず先ほど設定した売りたい値段と、高値が入った列を渡します。例えば25000個くらいの値が入ったpandasの列を2個渡してやる感じです。
そして、各時点から次の売りの指値がヒットするまで時計を進めてみて(変数jをどんどん進めていきます)、一体いくらでヒットしたのかを記録していきます。
つまり仮に買い指値がヒットしていた場合、次の売り指値での決済がヒットするところまでポジションを保持して、そこで決済したパターンを再現しています。
horizon = 1
fee = 0.0
df['y_buy'] = np.where(
(df['buy_price'] / min_pips).round() > (df['lo'].shift(-1) / min_pips).round(),
df['sell_fep'].shift(-horizon) / df['buy_price'] - 1 - 2 * fee,
0
)
それから実際にそのパターンが起こったのかどうかをこのコードで判定しています。numpyのwhereを使って条件分岐させていますね。pipsが絡んで少しややこしく見えますが、買い指値がヒット(安値がbuy_priceを下回る)した場合、そこから売り指値がヒットする価格(sell_fep)を確認し、どれくらいの割合変化したかを記録しています。
9月4日修正
指値の部分で「買い」と「売り」の単語が逆になっていました。ご指摘を頂き修正しました!
horizonを変更すると、いくらかの時間待ってから決済指値を探りにいくことになります。たとえば短いサイクルでテストしており、決済ロジックは15秒待ってから動かしたい、みたいなときにこの値を変えると良さそうです。
feeに2をかけるのは往復分の手数料を見込んでいるからで、たとえばmaker rebateがある場合はここにマイナスの手数料を加えてシミュレーションすることになるはずです。
指標を取り除いて、この章で計算された部分を加えたdfを掲載しておきます。
また、costという名称で以下の値を求めています。
# バックテストで利用
df['buy_cost'] = np.where(
(df['buy_price'] / min_pips).round() > (df['lo'].shift(-1) / min_pips).round(),
df['buy_price'] / df['cl'] - 1 + fee,
0
)
条件はロングポジションを持つ場合と同じですので、買いで入ったときのコストということになります。今回のシミュレーションでは全て指値のロジックでfeeを0として扱っているため、この値は基本的にネガティブな値になります。buy_priceは理想の値で常にclose価格を下回りますから、そこから1を引いて割合にすると常にネガティブな値です。
たとえば
20000 / 30000 - 1 = -0.3333.... = 終値から33%下落
ということになります。この値についてはバックテストのときにもう一度確認します。なぜここでfeeを足しておられるのかはわかっていないのですが、このfeeを別の値、たとえばcostのような変数に変えて正の値をいれることでスリッページなどのシミュレーションが出来るのかもしれません。
学習
学習と予測を行います。ここもCVで進めておられます。
# 学習 + CV
df = pd.read_pickle('df_y.pkl')
df = df.dropna()
# model = RidgeCV(alphas=np.logspace(-7, 7, num=20))
model = lgb.LGBMRegressor(n_jobs=-1, random_state=1)
# model = BaggingRegressor(model, random_state=1, n_jobs=1)
# 厳密にはどちらもリーク(yに未来の区間のデータが含まれている)しているので注意
# 「ファイナンス機械学習」にCVについて詳しく書かれている
# 通常のCV
cv_indicies = KFold().split(df)
# ウォークフォワード法
# cv_indicies = TimeSeriesSplit().split(df)
for train_idx2, val_idx2 in cv_indicies:
train_idx = df.index[train_idx2]
val_idx = df.index[val_idx2]
model.fit(df.loc[train_idx, features], df.loc[train_idx, 'y_buy'])
df.loc[val_idx, 'y_pred_buy'] = model.predict(df.loc[val_idx, features])
model.fit(df.loc[train_idx, features], df.loc[train_idx, 'y_sell'])
df.loc[val_idx, 'y_pred_sell'] = model.predict(df.loc[val_idx, features])
df = df.dropna()
df[df['y_pred_buy'] > 0]['y_buy'].cumsum().plot()
df[df['y_pred_sell'] > 0]['y_sell'].cumsum().plot()
plt.show()
df.to_pickle('df_fit.pkl')
先述のKFoldを使ってCVを行っています。デフォルトではデータを5分割する仕組みで、それをループして、trainデータで学習(fit)し、validateデータで予測(predict)しています。
計算した予測値はy_pred_buyとy_pred_sellとしてdfに格納しておきます。
あとは予測した利益が正のときに、予め計算してあるy_buyとy_sellの合計を求めています。最終的に何枚BTCが増えるのか、みたいな感じでテスト結果が出るのだと思います。
バックテストと検定
バックテストとrichmanbtcさんが独自に考案された検定を行っている部分になります。以下のようにバックテストの関数が呼び出されています。
df['cum_ret'], df['poss'] = backtest(
cl=df['cl'].values,
buy_entry=df['y_pred_buy'].values > 0,
sell_entry=df['y_pred_sell'].values > 0,
buy_cost=df['buy_cost'].values,
sell_cost=df['sell_cost'].values,
)
終値を全て含んだ列と、上下のエントリが入ったかどうか、そしてコストを含んだ列を渡しています。
buy_entry=df['y_pred_buy'].values > 0
引数のこの部分ですが、これは収支が正であることを予測した行を教えてあげるためのもので、値は[True, False, False, .....False, True, False]のようにBool値を含んだ配列になります。
バックテストのメイン部分を見ていきます。
def backtest(cl=None, hi=None, lo=None, pips=None,
buy_entry=None, sell_entry=None,
buy_cost=None, sell_cost=None
):
n = cl.size
y = cl.copy() * 0.0
poss = cl.copy() * 0.0
ret = 0.0
pos = 0.0
for i in range(n):
prev_pos = pos
# exit
if buy_cost[i]:
vol = np.maximum(0, -prev_pos)
ret -= buy_cost[i] * vol
pos += vol
if sell_cost[i]:
vol = np.maximum(0, prev_pos)
ret -= sell_cost[i] * vol
pos -= vol
# entry
if buy_entry[i] and buy_cost[i]:
vol = np.minimum(1.0, 1 - prev_pos) * buy_entry[i]
ret -= buy_cost[i] * vol
pos += vol
if sell_entry[i] and sell_cost[i]:
vol = np.minimum(1.0, prev_pos + 1) * sell_entry[i]
ret -= sell_cost[i] * vol
pos -= vol
if i + 1 < n:
ret += pos * (cl[i + 1] / cl[i] - 1)
y[i] = ret
poss[i] = pos
return y, poss
ロングのエントリ部分を確認します。
if buy_entry[i] and buy_cost[i]:
vol = np.minimum(1.0, 1 - prev_pos) * buy_entry[i]
ret -= buy_cost[i] * vol
pos += vol
エントリがTrueでコストの部分に値が入っている場合に買いで入ったと判定します。条件内一行目のminimumですが、ポジションを持っている場合
1.0 - 1 = 0
ですので、今回のロジックでは単純に既にエントリ済みならエントリしない、ということになります。分割や積み増しなどカスタマイズ出来そうですね。
また、最後にbuy_entry[i]をかけていますが、これはTrue/Falseをかけているだけですので(もしFalseがかかると0になる)、直前に既にifで判定していることから今回は何もしていないと思います。
次が少しわかりづらいですが、ここでコストを使います。
ret -= buy_cost[i] * vol
前述の通り、基本的にbuy_costはネガティブな値です。ですから、これはコストと名付けられていますが、実はreturnが改善されることになります。これは最後に仕掛けがあります。
先に決済を確認します
if sell_cost[i]:
vol = np.maximum(0, prev_pos)
ret -= sell_cost[i] * vol
pos -= vol
ここではentry条件は見ないで、売りのコスト部分に値が入っていたらifがTrueになります。そして条件内の一行目でポジションがあるのか判定しています。
dfを再掲します。赤く囲ったところは、ロングポジションを持ったパターンです。buy_priceが57550で、次の行のlowは57492とそれを下回っています。
そこから次の売りが起こる(=highがsell_priceを上回る)パターンを探し、その値段をsell_fepに入力していましたね。この赤いボックスのパターンだと次のローソクで既に決済しています。
その実際に決済が起こったであろう行ではsell_costに値が入力されています。この値を使ってreturnに調整を施し、ポジションをリセットしています。
最後にcostによるreturnの調整部分を確認します。
if i + 1 < n:
ret += pos * (cl[i + 1] / cl[i] - 1)
ポジションがある場合、終値の差分を割合に変換して、ポジションサイズ(今回は常に1)と掛け合わせています。
ここで注意するべきポイントは、実際の指値は終値からいくらかずらした値だったということです。ATRを使って終値からずらした指値を出していましたね。
つまり、実際は終値よりもちょっとだけ良い値段でエントリしたり決済したはずですから、その分を先にcostとして計算しておき、後にまとめて調整する仕組みになっています。costと呼んでいますが、実際には終値からの調整幅を記録した値、と考えるとすっきりしそうです。
ここではポジションがある限り終値の差分を計算していくので、含み益、あるいは含み損を持ち越しています。また、決済の指値は機械学習の判定は関係なく常に出している、ということになります。
仮に決済時点で逆方向のエントリを出しても良い、とモデルが言っていたなら、ドテンするということですね。
p平均法
最後に検定部分を確認します。統計に関しては私は勉強不足なのですが、コードの流れを分解していきたいと思います。
def calc_p_mean(x, n):
ps = []
for i in range(n):
x2 = x[i * x.size // n:(i + 1) * x.size // n]
if np.std(x2) == 0:
ps.append(1)
else:
t, p = ttest_1samp(x2, 0)
if t > 0:
ps.append(p)
else:
ps.append(1)
return np.mean(ps)
def calc_p_mean_type1_error_rate(p_mean, n):
return (p_mean * n) ** n / math.factorial(n)
x = df['cum_ret'].diff(1).dropna()
p_mean_n = 5
p_mean = calc_p_mean(x, p_mean_n)
print('p mean {}'.format(p_mean))
print('error rate {}'.format(calc_p_mean_type1_error_rate(p_mean, p_mean_n)))
print('error rateが十分小さくないと有意ではない(何度も試行錯誤することを考えると1e-5以下くらい)')
まず、累積リターンをdiffで差分として扱い、実際に値動きがあったところだけ残しているのだと思います。それを引数として、richmanbtcさんが提唱されているp平均を計算します。
//は割り算に小数点を含まない値を返す演算子で、配列のインデックスを計算するときなどに便利です。
以下はサンプルをn個(今回は5個)に分割しているコードです。
x2 = x[i * x.size // n:(i + 1) * x.size // n]
if節のstd(x2)が0になる(標準偏差が0になる)のがどういう時なのかよくわからないのですが、elseの中でt検定します。
t, p = ttest_1samp(x2, 0)
分割された列の母平均が0から離れているかどうかを検定しています。今回は得られたtが0以上なら母平均は0ではない、としています。
仮に収支がマイナスでも平均が0ではないということになってしまいますが、そこは単純に利益が出ていないロジックは諦めるだけなので合わせて問題ないのかな、と思います。
統計的な表現では、このpは「0ではないという期待が間違っている確率」だそうです。2周くらい回って分かりづらい表現に感じますが、本当は0ではないことを望んでいますから、pは小さい方が良いということになります。この値は5%以下であることが望ましいとのこと。
次のコードは検定の値を利用している部分です。tの値が0以下だと「母平均は0ではないとは言えないことが濃厚である」と考えて、1、つまり100%として記録します。
t, p = ttest_1samp(x2, 0)
if t > 0:
ps.append(p)
else:
ps.append(1)
正確な言い方ではないですが、平均は0なので予測は正しく機能していないだろう、ということですね。つまりpたちの平均が大きくなる仕組みです。
全てのグループでpが得られたら、それらの平均をcalc_p_mean_type1_error_rateという独自の関数に渡してエラー値を算出しておられます。このエラー値の数式がなぜそうなるのかは理解できていないので、ここはrichmanbtcさんの記事をそのまま参考記事とさせていただきます…
ちなみにp値が5%以下なら良いというのは、いわゆる正規分布において2σの外(だいたい95%)なので有意そうだ、みたいな話なのだろうと思っています。ボリンジャーバンドで反発する確率は95%、抜けた場合はトレンドだろう、と期待するのと同じ話かと思います。
大枠として大事だと思ったこと
自分の理解を深めるために一つ一つロジックを確認してきましたが、細かいロジック以上に大枠として機械学習で何をしているのか、ボットとしてどのように機能するのかを理解しておくことが大事だろうと思いました。
このチュートリアルで採用されているロジックと機械学習の関係をまとめてみます。
売買ロジック:ATRを利用して指値を出し、上下の価格変動を狙う
機械学習の特徴量:各種の指標
機械学習の目的変数:売買ロジックで生じる収支
そして、これらをベースにボットとしての狙いは以下となります。
各種の指標を機械学習にかけて、ATRベースで指値を出した場合の収支を予測し、利益が出ると見込んだパターンのみでエントリの指値を出す。
今回の機械学習はいわゆる回帰問題を解くものですが、機械学習のボットでの利用にはこのように売買ロジックも含めてどういった問題を解きたいのかという設問がとても重要なのだろうと思います。
他にはどういった設問方法があるでしょうか。
上下のみを当てにいき、その方向のポジションを取るという方法も考えられます。たとえば4時間足などの長めの足を採用して、十分な値幅が取れるなら検討できそうです。
一定時間後の終値の動きを%で求めるといった方向性もあるのではないかと思います。この場合は、ある一定以上動く場合には売買する、といったロジックを検討するかもしれません。
また、当然のことですが指値を出すロジックは全然別のものでも構わないので、このあたりは相性の良いエントリ+エグジットのペアといったものを探っていくことも必要になりそうです。機械学習を利用したゲームの攻略課題は絶えません…面白いですね!
思った以上に長くなりましたが、それ以上に勉強になりました。今後もとりあえずやってみるの精神で少しずつ幅を広げていきたいと思います!
この記事が気に入ったらサポートをしてみませんか?