【DQN】強化学習でビットコインの価格予想する

May 01, 2019

学生時代にも ML 系について勉強していましたが、久々に 1 から始めてみようと思い立ちました。
しかし勉強したところで業務に利用することもないし、もちろんお金になりません。

だったら、ビットコインの価格予想で儲けることができればモチベも上がるんじゃないかと。 ということで頑張ってみます。

先に結果を記載しますと、 一見すると予想はいい感じだけど、いざ取引してみると爆損です。

一見するといい感じのグラフ

20190430024517

少しでも興味のある方への手助けになれば幸いですが、私自身かなり浅い所にいるのは自覚しているのでご承知おきください。

使用するもの

今回は keraskeras-rl を利用します。なぜなら”私の”学習コストが安いからです。

エージェントにはたくさん学習させますが、私の学習時間は減らしたかったので。社会人は時間がないのです。勉強しながら働ける職場を熱望します。

価格予想には強化学習を利用します。

Keras Keras-rl

Keras は簡単に機械学習できるライブラリです。機械学習についての基礎は、良くも悪くもいらないです。

Keras-rl はそれをさらに簡単にしたライブラリです。「なんでもいいから機械学習させてくれ」って方におすすめです。
keras.io

ビットコイン

ビットコイン FX は 24 時間稼働していますし、ボラティリティが大きいので成果が確認しやすいため、今回の実験対象としました。

私自身は少しだけアカウント取ってみただけでトレード知識はほとんどありません。

環境構築

keras を用意したところで、まだ実験を始めることはできません。

機械学習には学習させるためのデータが必要です。

データ取得

FX 予想に使われるデータとしては、時系列データである OHLC が一般的ですね。

OHLC は過去のデータを簡単に取得できるので、学習データを集めやすいのが大きいメリットです。

しかし、私は下記仮定を立てました。

  1. 価格情報は学習データの中でユニークな値になりやすいのではないか
  2. ユニークな値で学習すると Agent が安易に学習してしまうのではないか

その結果、 OHLC で学習しても使い物にならないのではないか (過学習になりやすいのではないか)と予想を立てました。
※過学習=データの丸暗記みたいなもの。汎用性がない。

対策として OHLC を値そのままで使わず、増減率にするなどのアイデアもありましたが、今回は板情報から”板の厚み”を学習データにすることを考えました。

そこに価格情報は含めず、「midprice から x 円離れたところの厚み」を利用します。

これがいい選択である確信なんてありません。ですが、何事もトライアル&エラーです。

板情報の取得

bitFlyer API で板情報を取得して sqlite で保存します。

保存方法は下記を参考にしました。
https://qiita.com/onhrs/items/b64144cd63cf7484a35a
https://note.mu/akagami/n/nda6159c7b8ad

コード全文は上記有料 note を改変したものですので載せるのは控えます。
累積方法は以下のコードを参考にしてください。

# 欲しい深さによって変更
DEPTH = [10, 20, 30, 50, 80, 130, 210, 340, 550, 890, 1440, 2330, 3770, 6100, 9870]
askvol = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
bidvol = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

for i in range(len(DEPTH)):
    for ask in board['asks']:
        if DEPTH[i] < ask['price'] - board['mid_price']:
            break
        else:
            askvol[i] += ask['size']

    for bid in board['bids']:
        if DEPTH[i] < board['mid_price'] - bid['price']:
            break
        else:
            bidvol[i] += bid['size']

私はだいたい 1 週間を 1 つのファイルに保存していましたが、データベースが大きくなっていくと徐々に処理が重くなっていくので注意が必要です。

計算リソースの確保

効率的に ML を進めるには計算能力のある環境が必要です。

そこで今回は AWS の EC2 インスタンス p2-xlarge と、AMI Deep Learning AMI (Ubuntu) の組み合わせで環境を作ります。

AWS アカウントについての詳細は割愛しますが、インスタンスの作成で下記 AMI を選びましょう。

20190426004237

インスタンスに接続すると「環境を選べ」みたいなメッセージが出ますが、source activate tensorflow_p36を実行しましょう。

p2.xlarge のコスパはマシンのスペックから考えるといいんですが、消し忘れるととても痛いです。

20190427174032

価格予想で一攫千金を狙うより、素直に節約した方がお金は貯まると思います。

もちろん、モデルが複雑でない限りはハイスペックはいらないです。

私は途中からモデルをシンプルにしたので、t2.midium の CPU で計算させていました。お手元の mac book でもいけますが、他の作業ができなくなるのでやめておきましょう。

実装と実験

とにかく改変 & 改変で作ったので、色々と非効率だったりすると思います。

整備できてなくてお恥ずかしいですが、GitHub ならこちら。
github.com

DQNBot

  • Agent には 3 つの選択肢 “BUY/SELL/STAY(何もしない)” を与える
  • 与える状況は “一定間隔の板の厚さ”
  • BUY -> (STAY) -> SELL もしくはその逆の順番で action が選択された時、その差額を報酬として与える

実装 1

import sys
from time import sleep
import random
from datetime import datetime
from sklearn import preprocessing
import gym
import gym.spaces
import numpy as np
from keras.models import Sequential
from keras.layers import Dense, Activation, Flatten, LSTM
from keras.optimizers import Adam
from keras.models import model_from_json
from rl.agents.dqn import DQNAgent
from rl.policy import EpsGreedyQPolicy
from rl.memory import SequentialMemory
from keras.initializers import TruncatedNormal
import rl.callbacks
import matplotlib.pyplot as plt
import pandas as pd
import pandas.io.sql as psql
import sqlite3
class DQNBot(gym.core.Env):
def __init__(self):
# Agentにさせる行動はBUY/SELL/STAY(何もしない)
self.BUY = 0
self.SELL = 1
self.STAY = 2
self.action_space = gym.spaces.Discrete(3)
self.con = sqlite3.connect(sys.argv[1])
# ask/bidの観測範囲
# 0番目は BUY SELL STAYで0-2
# それ以外は0-1で正規化する
low_list = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
high_list = [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
low = np.array(low_list)
high = np.array(high_list)
# DBからask/bidのpandasデータフレームを取得
board_df = psql.read_sql('SELECT ask_price_100, ask_price_200, ask_price_300, ask_price_500, ask_price_800, ask_price_1300, ask_price_2100, ask_price_3400, ask_price_5500, ask_price_8900, \
bid_price_100, bid_price_200, bid_price_300, bid_price_500, bid_price_800, bid_price_1300, bid_price_2100, bid_price_3400, bid_price_5500, bid_price_8900 FROM boards;', self.con) # DBからPandasデータフレーム取得
# DBからmidpriceのpandasデータフレームを取得
board_mid_df = psql.read_sql('SELECT mid_price FROM boards;', self.con)
# 観測範囲を定義
self.observation_space = gym.spaces.Box(low=low, high=high)
# 0-1で正規化して配列取得
self.board_array = preprocessing.minmax_scale(board_df.values, axis=1)
# DBの長さを取得
self.board_array_rows = len(self.board_array)
# midpriceの配列を用意
self.board_mid = board_mid_df.values
self.step_count = 0
# 指定stepのask/bidを取得
def get_state(self, count):
return self.board_array[count].flatten()
# 指定stepのmidpriceを取得
def get_midprice(self, count):
return self.board_mid[count].flatten()[0]
# 視覚化の時にだけ使用 本来は多分いらない
def get_midprice_list(self):
return self.board_mid.flatten()
# 各stepごとに呼ばれる
def step(self, action):
# stepのカウントアップ
self.step_count += 1
# 学習データが終わりそうならdoneをTrueにしてstepを0に戻す
done = self.board_array_rows - 20 < self.step_count
if done:
self.step_count = 0
reward = 0
if action == self.BUY:
if self.pos[0] == self.STAY:
self.pos = [self.BUY, self.get_midprice(self.step_count)]
elif self.pos[0] == self.SELL:
reward = self.pos[1] - self.get_midprice(self.step_count)
self.pos = [self.STAY, 0]
# 学習時は売買が成立した時点で区切る(=結果を学習させる)
if sys.argv[2] == 'train':
done = True
elif action == self.SELL:
if self.pos[0] == self.STAY:
self.pos = [self.SELL, self.get_midprice(self.step_count)]
elif self.pos[0] == self.BUY:
reward = self.get_midprice(self.step_count) - self.pos[1]
self.pos = [self.STAY, 0]
if sys.argv[2] == 'train':
done = True
# 次のstate、reward、終了したかどうか、追加情報の順に返す
# ask/bidの情報 + ポジション情報を渡す
# 追加情報は特にないので空dict
return np.insert(self.get_state(self.step_count), 0, self.pos[0]), reward, done, {}
# 各episodeの開始時に呼ばれる
# 初期情報を渡す
def reset(self):
self.pos = [self.STAY, 0]
self.profit = 0
if sys.argv[2] == 'train':
pass
# randomにしてもいいかもしれないですね
# self.step_count = random.randint(0, self.board_array_rows - 2000)
else:
self.step_count = 0
return np.insert(self.get_state(self.step_count), 0, self.pos[0])
# 学習状況の保存
class EpisodeLogger(rl.callbacks.Callback):
def __init__(self):
self.observations = {}
self.rewards = {}
self.actions = {}
def on_episode_begin(self, episode, logs):
self.observations[episode] = []
self.rewards[episode] = []
self.actions[episode] = []
def on_step_end(self, step, logs):
episode = logs['episode']
self.observations[episode].append(logs['observation'])
self.rewards[episode].append(logs['reward'])
self.actions[episode].append(logs['action'])
if __name__ == "__main__":
env = DQNBot()
nb_actions = env.action_space.n
if sys.argv[2] == 'train':
input_shape = (1,) + env.observation_space.shape
# DQNのネットワーク定義
# とりあえずオプションはデフォルト
model = Sequential()
model.add(Flatten(input_shape=(1,) + env.observation_space.shape))
model.add(Dense(512))
model.add(Dense(512))
model.add(Dense(nb_actions))
print(model.summary())
# experience replay用のmemory
# 各ステップごと順番に学習させるわけではく、一度メモリに保存してからランダムに抽出と学習するとか
# 正直、完全には理解できていません
memory = SequentialMemory(limit=40000, window_length=1)
# 行動方策はオーソドックスなepsilon-greedyです。
policy = EpsGreedyQPolicy(eps=0.1)
# warmup = 文字通り準備運動のイメージ いきなり学習させずにある程度メモリに貯めると思ってる
# update = 学習率 小さくすると時間がかかるし、高くすると過学習しやすくなる
dqn = DQNAgent(model=model, nb_actions=nb_actions, memory=memory, nb_steps_warmup=100,
target_model_update=1e-2, policy=policy)
dqn.compile(Adam(lr=0.001))
# nb_steps = 何ステップ学習させるか 数値をめちゃくちゃ大きくして、一晩経ったらCtrl+Cで止めるとかでも別にいい
# max_episode_steps = 1エピソードの最大ステップ
history = dqn.fit(env, nb_steps=400000, visualize=False, verbose=2, nb_max_episode_steps=1440)
# modelとweightの保存
now = datetime.now().strftime("%Y%m%d%H%M%S")
dqn.save_weights('weight_' + str(now) + '.h5')
model_json = model.to_json()
with open('model_' + str(now) + '.json', "w") as json_file:
json_file.write(model_json)
elif sys.argv[2] == 'test':
# modelのロード
json_file = open(sys.argv[3], 'r')
loaded_model_json = json_file.read()
json_file.close()
model = model_from_json(loaded_model_json)
print(model.summary())
# 学習後のテストをしたいだけなのに以下宣言が必要なのかは不明 一応同じようにdqnを設定していく
memory = SequentialMemory(limit=2000000, window_length=1)
policy = EpsGreedyQPolicy(eps=0.1)
dqn = DQNAgent(model=model, nb_actions=nb_actions, memory=memory, nb_steps_warmup=100,
target_model_update=1e-2, policy=policy)
dqn.compile(Adam(lr=0.001))
# weighのロード
dqn.load_weights(sys.argv[4])
cb_ep = EpisodeLogger()
# テストを実行
# データベースで一通り売買してもらう
# 時間がかかるので、consoleに状況を出すようにstepメソッド内で実装してもいいかも
dqn.test(env, nb_episodes=1, visualize=False, callbacks=[cb_ep])
# 結果の視覚化
print("COUNT BUY : " + str(list(cb_ep.actions.values())[0].count(0)))
print("COUNT SELL : " + str(list(cb_ep.actions.values())[0].count(1)))
print("COUNT STAY : " + str(list(cb_ep.actions.values())[0].count(2)))
plt.subplot(211)
plt.plot(env.get_midprice_list(), linewidth=0.1)
plt.subplot(212)
rw_list = []
reward = 0
for ep_reward in list(cb_ep.rewards.values())[0]:
reward += ep_reward
rw_list.append(reward)
print("\rReward: " + str(len(rw_list)), end='')
print("")
plt.plot(rw_list)
plt.xlabel("step")
plt.ylabel("price")
# dpiが低いと荒すぎる
plt.savefig("figure.png",format = 'png', dpi=1200)
view raw DQNBot.py hosted with ❤ by GitHub

結果 1

「Agent はノーポジとガチホを覚えた!!!」

何十万ステップもトレーニングさせると、「これ取引するより何もしないほうがいいよね」「最初に買ってガチホが一番だよね」と考えるようになってしまいました。

逆に学習を少なくしても、ひたすらに負け続けていました。

実装 2 LSTM を導入する

モデルを変えて LSTM を導入してみます。

「実はこの板の厚みの変化って長期的に見る必要があるんじゃないか」と仮説を立て、LSTM を選定しました。

ネットワーク定義を以下に変えてみます。

# DQNのネットワーク定義
model = Sequential()
model.add(LSTM(units=512, return_sequences=True, input_shape=input_shape))
model.add(Dropout(dropout))
model.add(LSTM(units=512, return_sequences=False))
model.add(Dropout(dropout))
model.add(Dense(units=nb_actions))

結果 2

「Agent はノーポジとガチホを覚えた!!!」

変わらないですね。

反省と仮説

  • 1step(1sec)毎に 3 択を与えるって Agent にとってとても難しいことではないのか?
    -> ゲームを攻略できる DQN があるくらいだからいけると思いますが…

  • シンプルにパラメータや考え方が悪いんじゃないか?

  • 一定期間後に上か下か予想させる方が効率よく学習できるんじゃないか?
    -> やってみよう

DQNBot_Label

  • Agent には 3 つの選択肢 “BUY/SELL/STAY(何もしない)” を与える
  • 与える状況は “一定間隔の板の厚さ”
  • BUY or SELL が選択された時、一定期間後の midprice との差額を報酬として与える
  • Label って名前に特に意味はない

報酬の与え方も少し工夫します。
報酬をシンプルにすることで、汎用性を高めることが目的です。

  • 一定額以上のプラス差額 “報酬 1”
  • 一定額未満のプラス差額 “報酬 0”
  • マイナス差額 “報酬-1”

実装

import sys
from time import sleep
import random
from datetime import datetime
from sklearn import preprocessing
import gym
import gym.spaces
import pickle
import numpy as np
from keras.models import Sequential
from keras.layers import Dense, Activation, Flatten, LSTM, Dropout
from keras.optimizers import Adam
from keras.models import model_from_json
from keras.callbacks import TensorBoard
from keras.initializers import TruncatedNormal
from rl.agents.dqn import DQNAgent
from rl.policy import EpsGreedyQPolicy
from rl.policy import BoltzmannQPolicy
from rl.policy import GreedyQPolicy
from rl.policy import BoltzmannGumbelQPolicy
from rl.memory import SequentialMemory
import rl.callbacks
import matplotlib.pyplot as plt
import pandas as pd
import pandas.io.sql as psql
import sqlite3
class DQNBot(gym.core.Env):
def __init__(self):
self.term = 20
self.margin = 10
self.BUY = 0
self.SELL = 1
self.STAY = 2
self.action_space = gym.spaces.Discrete(3)
self.con = sqlite3.connect(sys.argv[2])
low_list = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
high_list = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
low = np.array(low_list)
high = np.array(high_list)
board_df = psql.read_sql('SELECT ask_price_100, ask_price_200, ask_price_300, ask_price_500, ask_price_800, ask_price_1300, ask_price_2100, ask_price_3400, ask_price_5500, ask_price_8900, \
bid_price_100, bid_price_200, bid_price_300, bid_price_500, bid_price_800, bid_price_1300, bid_price_2100, bid_price_3400, bid_price_5500, bid_price_8900 FROM boards;', self.con) # DBからPandasデータフレーム取得
board_mid_df = psql.read_sql('SELECT mid_price FROM boards;', self.con)
self.observation_space = gym.spaces.Box(low=low, high=high)
self.board_array = preprocessing.minmax_scale(board_df.values, axis=1)
self.board_array_rows = len(self.board_array)
self.board_mid = board_mid_df.values
self.step_count = 0
def get_state(self, count):
return self.board_array[count].flatten()
def get_midprice_list(self):
return self.board_mid.flatten()
def get_midprice(self, count):
return self.board_mid[count].flatten()[0]
# 各stepごとに呼ばれる
def step(self, action):
self.step_count += 1
done = self.board_array_rows - 400 < self.step_count
reward = 0
if action == self.BUY:
if sys.argv[1] == 'train':
pl = self.get_midprice(self.step_count + self.term) - self.get_midprice(self.step_count)
if self.margin < pl:
reward = 1
elif pl < 0:
reward = -1
done = True
else:
reward = self.get_midprice(self.step_count + self.term) - self.get_midprice(self.step_count)
# self.step_count += 60
elif action == self.SELL:
if sys.argv[1] == 'train':
pl = self.get_midprice(self.step_count) - self.get_midprice(self.step_count + self.term)
if self.margin < pl:
reward = 1
elif pl < 0:
reward = -1
done = True
else:
reward = self.get_midprice(self.step_count) - self.get_midprice(self.step_count + self.term)
# self.step_count += 60
if sys.argv[1] == 'test':
print('\r' + "step: " + str(self.step_count), end='')
if done:
print("")
return self.get_state(self.step_count), reward, done, {}
def reset(self):
self.pos = [self.STAY, 0]
self.profit = 0
if sys.argv[1] == 'train':
self.step_count = random.randint(0, self.board_array_rows - 2000)
else:
self.step_count = 0
return self.get_state(self.step_count)
class EpisodeLogger(rl.callbacks.Callback):
def __init__(self):
self.observations = {}
self.rewards = {}
self.actions = {}
def on_episode_begin(self, episode, logs):
self.observations[episode] = []
self.rewards[episode] = []
self.actions[episode] = []
def on_step_end(self, step, logs):
episode = logs['episode']
self.observations[episode].append(logs['observation'])
self.rewards[episode].append(logs['reward'])
self.actions[episode].append(logs['action'])
if __name__ == "__main__":
env = DQNBot()
nb_actions = env.action_space.n
memory = SequentialMemory(limit=200000, window_length=1)
warmup = 1000
model_update = 1e-2
policy = EpsGreedyQPolicy(eps=0.1)
if sys.argv[1] == 'train':
input_shape = (1,) + env.observation_space.shape
dropout = 0.5
# DQNのネットワーク定義
model = Sequential()
model.add(LSTM(units=512, return_sequences=True, input_shape=input_shape))
model.add(Dropout(dropout))
model.add(LSTM(units=512, return_sequences=False))
model.add(Dropout(dropout))
model.add(Dense(units=nb_actions))
print(model.summary())
dqn = DQNAgent(model=model, nb_actions=nb_actions, memory=memory, nb_steps_warmup=warmup,
target_model_update=model_update, policy=policy)
dqn.compile(Adam(lr=0.001))
tbcb = TensorBoard(log_dir='./graph', histogram_freq=0, write_grads=True)
history = dqn.fit(env, nb_steps=5000000, verbose=2, nb_max_episode_steps=60, callbacks=[tbcb])
now = datetime.now().strftime("%Y%m%d%H%M%S")
dqn.save_weights('weight1_' + str(now) + '.h5')
model_json = model.to_json()
with open('model1_' + str(now) + '.json', "w") as json_file:
json_file.write(model_json)
with open("history.pickle", mode='wb') as f:
pickle.dump(history.history, f)
elif sys.argv[1] == 'test':
json_file = open(sys.argv[3], 'r')
loaded_model_json = json_file.read()
json_file.close()
model = model_from_json(loaded_model_json)
print(model.summary())
dqn = DQNAgent(model=model, nb_actions=nb_actions, memory=memory, nb_steps_warmup=warmup,
target_model_update=model_update, policy=policy)
dqn.compile(Adam(lr=0.002))
dqn.load_weights(sys.argv[4])
cb_ep = EpisodeLogger()
dqn.test(env, nb_episodes=1, visualize=False, callbacks=[cb_ep])
print("COUNT BUY : " + str(list(cb_ep.actions.values())[0].count(0)))
print("COUNT SELL : " + str(list(cb_ep.actions.values())[0].count(1)))
print("COUNT STAY : " + str(list(cb_ep.actions.values())[0].count(2)))
plt.subplot(211)
plt.plot(env.get_midprice_list(), linewidth=0.1)
plt.subplot(212)
rw_list = []
reward = 0
for ep_reward in list(cb_ep.rewards.values())[0]:
reward += ep_reward
rw_list.append(reward)
print("\rStep: " + str(len(rw_list)), end='')
print("")
plt.plot(rw_list)
plt.xlabel("step")
plt.ylabel("price")
plt.savefig("figure.png",format = 'png', dpi=1200)
view raw DQNBot_Label.py hosted with ❤ by GitHub

結果

train データそのままを test してたところ、かなり儲けているようです。(パラメータや学習回数は記録できていません。。)

青が SELL 赤が BUY

20190430023358

しかし他のデータセットで test したところ、惨敗です。

20190430023538

このモデルで bitFlyer の Bot も作ってみましたが、取引の回数が少なく、十分なデータを集められませんでした。(STAY ばかり選択される)

反省

  • 汎用性がない どうやら過学習になっているように見受けられる
  • 急騰/急落時にリバを期待して逆張りしていくっぽい
  • もっとガンガン取引してスキャって欲しい

対策

以下の案で考えています。まだ検証は進められていません。

  • 汎用性がない
    -> モデルをシンプルにする dropout を高めに設定する 学習データをもっと増やす

  • 急騰/急落時にリバを期待して逆張りしていくようだ
    -> モデルをシンプルにする

  • もっとガンガン取引して欲しい
    -> モデルをシンプルにする action を BUY/SELL の 2 択にする

まとめ

「DQNBot で儲けられたのか」については、

「今の所、儲けられていません」 が回答になります。

バックテストでこの状況では、確実に実環境では大損します。実環境ではスプレッドなども考慮しなければならず、それを賄えるほどの報酬を安定して出さなければ行けません。

過学習を乗り越えた先に何かがあると信じて、暇を見つけて進めたいと思います。上手くいった日には、続編として書くかもしれません。

最初のグラフ

最初に載せたグラフは action を BUY/SELL の 2 択にして 1 秒毎 1 分先の価格予想した結果です。

めちゃくちゃいい感じに伸びていますが、これも過学習でした。

20190430024517

参考にさせていただいたサイト

DQN で機械学習した人工知能が Bitcoin をシストレして月 700 万円儲けるまでの話(失敗) - Qiita
Deep Q-Learning で FX してみた
機械学習やディープラーニングで FX 予測をする際に超参考になる記事まとめ
https://ai-kenkyujo.com/2018/10/16/kagakushu/
[Python] Keras-RL で簡単に強化学習(DQN)を試す - Qiita


Profile picture

Twitter GitHub