見出し画像

ボラティリティが激しいBitcoin価格をAIモデルで予測してみた


1.はじめに

私は7月からプログラミングを3ヶ月間オンラインスクールで学びました。プログラミングやPythonは全くの初心者で、数学や英語も苦手ですが、データの分析やAIを活用できるスキルを身につけたくチャレンジしました。

2.概要

プログラミングの学習を始めてから、米国株や日本株の株価予測をよく目にしますが、値動きの激しい暗号資産ではどうなんだろうか?しっかりと予測はできるのだろうか?と気になりました。

そこで今回、暗号資産の中でもメジャーで今年半減期を迎えたビットコインの価格を、LSTMを用いて予測してみようと思います。

2.1. 分析の流れ

2.2. 実行環境

  • Windows11

  • Google Colaboratory

  • Python3

3.実装

3.1. データの取得

まずデータの読み込みを行っていきます。
今回は「kaggle」というプラットフォームからデータを取得しようと思います。

kaggleとは、企業や政府などの組織とデータ分析のプロであるデータサイエンティストや機械学習エンジニアを繋げるプラットフォームであり、提示された課題を参加者が分析し競い合うコンペティションがあるというのが一番の特徴です。

kaggleからダウンロードしたビットコイン価格のデータをgoogle colaboにアップロードし読み込みます。
データは、2020年から2023年の4年間と2024年の7月末までの計約4年半の分刻みのデータです。

0列目のunixと6列目のcloseだけを取り出し、DataFrameを作成します。

  • unix:「Unixタイムスタンプ」または「エポックタイム」とも呼ばれます。これを使用して、ローカルタイムゾーンに変換します。

  • close:期間の終値です。

なぜunix列を取り出したのかというと、データにはdate列もありましたが、協定世界時という日本時間とは異なる日付けでしたので、今回はunixから日本時間に変換したいと思います。

また2024年のデータのカラム名に大文字が含まれていたので、小文字に統一する処理をしてから全データを結合します。

# 必要なライブラリのインポート
import pandas as pd
import numpy as np
from datetime import datetime as dt
import yfinance as yf
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from sklearn.metrics import mean_squared_error
import math


# データの読み込み
data2020 = pd.read_csv("/content/Binance_BTCUSDT_2020_minute.csv", usecols=[0, 6])
data2021 = pd.read_csv("/content/Binance_BTCUSDT_2021_minute.csv", usecols=[0, 6])
data2022 = pd.read_csv("/content/Binance_BTCUSDT_2022_minute.csv", usecols=[0, 6])
data2023 = pd.read_csv("/content/Binance_BTCUSDT_2023_minute.csv", usecols=[0, 6])
data2024 = pd.read_csv("/content/Binance_BTCUSDT_2024_minute.csv", usecols=[0, 6])


# カラム名を統一
data2024.columns = data2024.columns.str.lower()


# データの結合
data = pd.concat([data2020, data2021, data2022, data2023, data2024], axis=0).reset_index(drop=True)

3.2. データの前処理

1.データの確認

data.head()



>>>出力結果
unix			close
0	1609459140000	28923.63
1	1609459080000	28923.67
2	1609459020000	28975.03
3	1609458960000	28975.06
4	1609458900000	28979.72
data.shape



>>>出力結果
(2374384, 2)

2,374,384行の2列で構成されています。

data.dtypes



>>>出力結果
unix int64
close float64
dtype: object
data.isnull().sum()



>>>出力結果
unix 0
close 0

出力結果を見る限り欠損値はなさそうです。

2.データの加工

データの加工では、まずunixタイムスタンプを使って日本時間に変換し、date列を作成していきます。

pd.to_datetime() を使って、Unixタイムスタンプ(ミリ秒単位)を日時形式に変換します。

引数のunit = “ms” で、ミリ秒単位のUnixタイムスタンプを指定し、utc = True でUTCとして受け取った後、dt.tz_convert(“Asia/Tokyo”) で日本時間(JST, UTC+9)に変換します。

dt.tz_localize(None)で、「+09:00」 のようなタイムゾーン情報を表示しないようにします。

# Unixタイムスタンプをミリ秒から秒に変換して、日時に変換
data["date"] = pd.to_datetime(data["unix"], unit="ms", utc=True).dt.tz_convert("Asia/Tokyo").dt.tz_localize(None)
data.head()



>>>出力結果
unix           close      date
01609459140000 28923.6320 21-01-01 08:59:00
11609459080000 28923.6720 21-01-01 08:58:00
21609459020000 28975.0320 21-01-01 08:57:00
31609458960000 28975.0620 21-01-01 08:56:00
41609458900000 28979.7220 21-01-01 08:55:00

次にPCの性能や実行時間の関係上データ数が多すぎるので、毎日の終値だけを抽出します。他のデータは削減し分刻みのデータを日毎のデータにしていきます。

(ちなみにデータ数は多いほうが予測結果の精度は上がります。なので今回もデータを削除しないほうが精度は高かったと思います。)

data.loc[:, "date"] = data["date"].apply(lambda x: re.split(" ", x)[0]) の apply()メソッドは、各要素に対して指定した関数を一つ一つ適用し、その結果を新しいSeriesとして返します。

lambda で無名関数(名前を持たない関数)を作成し、re.split(“ “, x)[0] で文字列 “x” をスペースで分割すると  [“oooo-oo-oo”, “oo:oo:oo”] というリストになるので、[0]でリストの最初の要素を取得するインデックスを指定しています。

# dateのフォーマットを変更
data["date"] = data["date"].dt.strftime("%Y-%m-%d %H:%M:%S")


# 時間が "23:59:00" の行だけを残す
data = data[data["date"].str.endswith("23:59:00")]


# dateから日付部分だけを抽出して新しい列に設定
data.loc[:, "date"] = data["date"].apply(lambda x: re.split(" ", x)[0])


# date列を日付形式に変換
data.loc[:, "date"] = pd.to_datetime(data["date"], format="%Y-%m-%d")


# unix列を削除
data = data.drop("unix", axis=1)


# date列をインデックスに設定
data = data.set_index("date")


# 日付順に並び替え
data = data.sort_values(["date"])
data



>>>出力結果
           close
date
2020-01-01 7216.80
2020-01-02 7131.00
2020-01-03 7252.70
2020-01-04 7312.96
2020-01-05 7429.85
... ...
2024-07-25 64835.00
2024-07-26 67328.01
2024-07-27 68981.69
2024-07-28 67746.00
2024-07-30 65810.00
1649 rows × 1 columns

ここで行数を確認してみると、1649 rowsとなっています。データの確認で欠損値がないことを確認しましたが、約4年半で1649日はすこし少ない気がするので、一応確認してみましょう。

pd.date_range(start=data.index.min(), end=data.index.max(), freq="D") で、データの最小日付から最大日付までの全ての日付を生成します。

missing_dates = full_range.difference(data.index) は、その生成された全日付から実際のデータのインデックス(日付)を引いて、欠けている日付けをリストとして返します。

# 日付の範囲を確認
full_range = pd.date_range(start=data.index.min(), end=data.index.max(), freq="D")


# 欠けている日付を特定
missing_dates = full_range.difference(data.index)
missing_dates



>>>出力結果
DatetimeIndex(['2020-12-21', '2024-01-11', '2024-01-12', '2024-01-13',
               '2024-01-14', '2024-05-14', '2024-05-15', '2024-05-16',
               '2024-05-17', '2024-05-18', '2024-05-19', '2024-05-20',
               '2024-05-21', '2024-05-22', '2024-05-23', '2024-05-24',
               '2024-05-25', '2024-05-26', '2024-05-27', '2024-05-28',
               '2024-06-29', '2024-07-08', '2024-07-21', '2024-07-29'],
               dtype='datetime64[ns]', freq=None)

出力結果を見てみると、24日間分のデータがありませんでした。なのでまず2024-05-14から2024-05-28の約2週間のデータは期間が長いので、実際の価格で埋めていきます。

# 欠損期間のデータフレームを作成
missing_df = pd.DataFrame({
    "close": [61682.87, 66142.66, 65436.00, 66794.64, 66935.00, 66418.00, 69617.00, 70220.00, 69091.00, 67646.00, 68783.00, 69122.00, 68531.00, 69530.96, 68552.00]
}, index=pd.date_range("2024-05-14", "2024-05-28", freq="D"))


# 既存のデータフレームに欠損データを追加
data = data.combine_first(missing_df)

残りの欠損期間は、 interpolate(method="linear") で前後のデータを使って線形に補完していきます。

# 欠損している日付の範囲を確認するために、新しいデータフレームを作成
full_date_range = pd.date_range(start="2020-01-01", end="2024-07-30", freq="D")


# データフレームのインデックスを新しい日付に設定(元データに存在しない日付をNaNで埋める)
data = data.reindex(full_date_range)


# 線形補完
data["close"] = data["close"].interpolate(method="linear")
data.shape



>>>出力結果
(1673, 1)

再度確認しておきましょう。

# 日付の範囲を確認
full_range = pd.date_range(start=data.index.min(), end=data.index.max(), freq="D")


# 欠けている日付を特定
missing_dates = full_range.difference(data.index)
missing_dates



>>>出力結果
DatetimeIndex([], dtype='datetime64[ns]', freq='D')

しっかりと埋められていることが確認できました。

次のステップに行く前に、ビットコイン価格の変動量の大きさを見ておこうと思います。

# 日経平均株価のデータの取得
ticker = "^N225"
nikkei = yf.download(ticker, "2020-01-01", "2024-07-30", progress=False)
nikkei = nikkei[["Close"]]


plt.figure(figsize=(15, 6))

# 左のグラフ
plt.subplot(1, 2, 1)
plt.title("Bitcoin Diff")
plt.xlabel("Date")
plt.ylabel("Price Diff")
data_diff = data["close"].diff()
plt.plot(data_diff)

# 左のグラフのy軸の範囲を取得
y_min, y_max = plt.gca().get_ylim()

# 右のグラフ
plt.subplot(1, 2, 2)
plt.ylim(y_min, y_max) # 左のグラフのy軸の範囲を設定
plt.title("Nikkei 225 diff")
plt.xlabel("Date")
nikkei_diff = nikkei.diff()
plt.plot(nikkei_diff)

plt.tight_layout()
plt.show()



>>>出力結果

グラフを見て分かる通り、日経平均株価と比べると変動量の大きさが明らかに違います。

3.データのスケーリングと分割

まず、訓練データと検証データを作成していきます。

# indexやcolumnsを除いてデータのみを取り出す
vdata = data.values


# データを単精度浮動小数点型(float型)に変換
vdata = vdata.astype("float32")


# 訓練データにするデータ件数を算出
train_size = int(len(data) * 0.67)


# 訓練データ、検証データに分割
train, test = data.iloc[:train_size, :], data.iloc[train_size:, :]

次にスケーリングを行います。データのスケーリングとは、データの「大きさ」を揃えることです。10キロとか180センチとか違う種類の数字を同じ基準に揃えて、比べやすくします。

今回は、正規化(MinMaxScaler)して最低値が0、最高値が1になるように元の値を変換します。

# データのスケーリング(正規化)
# 最小値が0, 最大値が1となるようにスケーリング方法を定義
scaler = MinMaxScaler(feature_range=(0, 1))


# trainのデータを基準にスケーリングするようパラメータを定義
scaler_train = scaler.fit(train)


# パラメータを用いてtrainデータをスケーリング
train = scaler_train.transform(train)


# パラメータを用いてtestデータをスケーリング
test = scaler_train.transform(test)

最後に入力データと正解ラベルに分割し、LSTMで分析できるように入力データを整形していきます。

データを作成する関数 create_dataset(dataset, look_back) の引数になる look_back=n の n がいくつ前のデータまで使用するかを表します。

また for文を用いて基準となる時点からいくつか前のデータを取る処理が繰り返し行われます。

# 入力データ・正解ラベルを作成する関数を定義
def create_dataset(dataset, look_back):
    # data_Xが入力データでn日分のデータを1セットとし、data_Yが正解ラベルでXの翌日を正解とします
    data_X, data_Y = [], []
    for i in range(look_back, len(dataset)):
        data_X.append(dataset[i-look_back:i, 0])
        data_Y.append(dataset[i, 0])
    return np.array(data_X), np.array(data_Y)


# 入力データが何日分のデータを取るかを指定
look_back = 5


# 作成した関数create_datasetを用いて、入力データ・正解ラベルを作成
train_X, train_Y = create_dataset(train, look_back)
test_X, test_Y = create_dataset(test, look_back)


# 3次元のnumpy.ndarrayに変換
train_X = train_X.reshape(train_X.shape[0], train_X.shape[1], 1)
test_X = test_X.reshape(test_X.shape[0], test_X.shape[1], 1)

3.3. モデルの作成と学習

既存のニューラルネットワークでは「時系列データ」をうまく扱うことができないのが欠点でしたが、RNN(Recurrent Neural Network:再帰型ネットワーク)によって時系列データを扱えるようになりました。

RNNは、データをループさせることで過去の情報を記憶しながら絶えず最新のデータを持ち続けられます。しかしこのRNNも長期的な記憶を保持できないことが問題点でした。

それを解決するのがLSTM(Long-Short Term Memory:長短期記憶ユニット)です。LSTMでは、RNNの中間層のユニットをLSTMブロックに置き換えることで長期間の時系列を保持することができます。

# LSTMネットワークの構築
model = keras.Sequential()
model.add(layers.Input(shape=(look_back, 1)))
model.add(layers.LSTM(64, return_sequences=True))
model.add(layers.LSTM(32))
model.add(layers.Dense(1))
model.compile(loss="mean_squared_error", optimizer="adam")


# LSTMネットワークの学習
model.fit(train_X, train_Y, epochs=100, batch_size=5, verbose=2)

長さ look_back の1次元の配列を入力層とし、2つのLSTMレイヤーをそれぞれ64個と32個のLSTMブロックで追加し、最後に1つのノードを持つ出力層を追加します。

また、損失関数(loss)と最適化アルゴリズム(optimizer)を定義し、モデルをコンパイルします。損失関数(loss)には平均二乗誤差(mean_squared_error)を使用し、最適化アルゴリズム(optimizer)には adam を使用します。

3.4. データの予測と精度の確認

学習が完了したらデータの予測と精度評価を行います。

ここで注意しなくてはならないのが、予測したあと出力されたデータの予測結果を正しく評価するために、スケーリングしたデータを元に戻す必要があることです。

スケーリングしたデータを元に戻したあとは、精度評価を行います。

精度評価には、RMSE(平均二乗誤差)を用います。このRMSEは、予測値が正解からどの程度乖離しているかを示すので、値が大きいほど予測精度は悪く、0に近いほどそのモデルは優れているということがわかります。

math.sqrt(mean_squared_error(実際の値, 予測値)) です。

# 予測データの作成
train_predict = model.predict(train_X)
test_predict = model.predict(test_X)


# スケールしたデータを元に戻す
train_predict = scaler_train.inverse_transform(train_predict)
train_Y = scaler_train.inverse_transform([train_Y])
test_predict = scaler_train.inverse_transform(test_predict)
test_Y = scaler_train.inverse_transform([test_Y])


# 訓練データの精度評価
train_score = math.sqrt(mean_squared_error(train_Y[0], train_predict[:, 0]))
print(f"Train Score: {train_score:.2f} RMSE")

# 検証データの精度評価
test_score = math.sqrt(mean_squared_error(test_Y[0], test_predict[:, 0]))
print(f"Test Score: {test_score:.2f} RMSE")



>>>出力結果
Train Score: 1299.84 RMSE
Test Score: 1149.24 RMSE

3.5. 予測結果の可視化

最後に予測結果の可視化を行います。
その準備としてデータを整形しておきます。

np.empty_like(vdata) で、予測データを埋め込むためにvdataと同じ形状の空の配列を作成します。

train_predict_plot[:, :] = np.nan で、作成した空の配列train_predict_plot 全体をNaNで埋めます。

train_predict_plot[look_back:len(train_predict)+look_back, :] = train_predict で、訓練データの予測値に対応する適切な位置に train_predict の値を配置しています。

検証データの方も同じような流れで行います。

そして整形した予測データを使ってプロットします。

# プロットのためのデータ整形
# 訓練データから予測したデータの整形
train_predict_plot = np.empty_like(vdata)
train_predict_plot[:, :] = np.nan
train_predict_plot[look_back:len(train_predict)+look_back, :] = train_predict

# 検証データから予測したデータの整形
test_predict_plot = np.empty_like(vdata)
test_predict_plot[:, :] = np.nan
test_predict_plot[len(train_predict)+(look_back*2):len(vdata), :] = test_predict


# データのプロット
plt.figure(figsize=(15, 6))
plt.title("Bitcoin Closing Price")
plt.xlabel("Date")
plt.ylabel("USD ($)")
plt.plot(data.index, vdata, label="Actual")
plt.plot(data.index, train_predict_plot, label="Train Predict")
plt.plot(data.index, test_predict_plot, label="Test Predict")
plt.legend(loc="upper left")
plt.show()



>>>出力結果
Epoch 1/100
223/223 - 5s - 21ms/step - loss: 0.0096

(中略)

Epoch 99/100
223/223 - 1s - 5ms/step - loss: 4.5832e-04
Epoch 100/100
223/223 - 1s - 6ms/step - loss: 4.6348e-04
35/35 ━━━━━━━━━━━━━━━━━━━━ 1s 12ms/step
18/18 ━━━━━━━━━━━━━━━━━━━━ 0s 3ms/step 
Train Score: 1299.84 RMSE
Test Score: 1149.24 RMSE

4.おわりに

データを削減せずに全データを使用していたら精度はもっと良かったと思います。しかしこの値動きの激しいビットコインでもしっかりと価格を予測できることがわかったので勉強になりました。

また分刻みのデータを日毎のデータに変換したり、欠けている日付を見つけて埋めていく過程はとても苦労しましたが、わからないコードを調べたりしていくことで新たに知識を身につけられたり、講座で学んだことを復習しアウトプットすることでより理解を深めることができたので良かったです。

まだまだ初心者レベルで学ぶことは多いですが、今後はスクレイピングの方法や分析のスキルを上げていき、ネットニュースやSNS、業績などから感情分析を行い、売上予測や株価予測などができるようにプログラミングの学習を続けていき、ステップアップしていきたいです。

ありがとうございました。


この記事が気に入ったらサポートをしてみませんか?