ころがる狸

ころがる狸のデータ解析ブログ

【PyTorch+LSTM】LSTMの仕組みと米国株予測の実装

おはようございます。ゴールデンウイーク最終日です。連休中に時系列データ解析を中心に記事を書き、ARIMAモデル、状態空間モデル、次元圧縮、人口推移の可視化、そして本稿のPyTorchによるLSTMの紹介記事をまとめました。今日このトピックを取り上げた理由としては、機械学習フレームワークとしてTensorFlowとPyTorchが最有力ですが、時系列データ解析のPyTorchでの実装例がウェブを調べても意外と多くないなと感じたからです。また私はこれまでChainerという和製のフレームワークを使っていましたが、開発がストップしてしまったため類似した記法を採用しているPyTorchにシフトする必要があり練習がてら使ってみようと思った次第です。
・・・

・・・

1.LSTMの仕組みの解説

今日のテーマは人工知能界隈でとても有名なモデルであるLSTMです。LSTM (long-short term memory)は時系列データをニューラルネットワーク技術を用いて効率的に処理する方法の1つです。時系列情報を普通のニューラルネットワーク(多層パーセプロトン, MLP)に読み込ませても、ネットワークはそれを連続したデータとして扱うことができず時系列データのトレンドや周期性といったパターンを抽出することができません。一方でLSTMは時系列データをまとまりとして処理し、予測を行うのに重要な情報を記憶したり、捨てたり、追加することができるため、時系列データの解析にはこちらを使うのがベターだと思われます。LSTMは歴史のある技術ですが、非常に複雑で分かりづらいため図を用いながら説明したいと思います(私も使うたびに覚え、そして忘れます)。作図にはこちらの英語サイトを参考にさせて頂きました:
Long Short-Term Memory: From Zero to Hero with PyTorch

f:id:Dajiro:20200506114932p:plain
LSTMの概念図。筆者作成。
LSTMを学ぶ上で、以下の5つの単語は覚える必要がありそうです。

  1. 長期記憶セル:時系列情報のうち重要なものを記憶しておく
  2. 隠れ層:短期的な記憶を伝える
  3. 入力ゲート: 新たに追加したデータのうち重要な情報を抽出し長期記憶に加える
  4. 忘却ゲート:長期記憶のうち不必要なものを忘却する
  5. 出力ゲート:長期記憶から必要なものを取り出し次の隠れ層としたり出力したりする

入力・忘却・出力ゲートから説明しましょう。ゲートという難しい単語が入っていますが、これは0~1の範囲の値をとる調整弁のような役割を担っていると考えてください。値0のゲートを何らかの行列要素にかけると結果はゼロとなりますし、値1のゲートをかけるとその行列要素の値がそのまま取り出されます。このように、掛け合わされる行列要素の必要なもの、不必要なものの数値を調整するのがゲートの役割になります。ここでいうゲートがかけられる行列というのは長期記憶セルと隠れ層テンソルのことを指します。入力ゲートなら入力ベクトルと隠れ層から取り出すべき値を調整し、忘却ゲートは不必要なものを長期記憶セルから消去し、出力ゲートは長期記憶から必要なものを取り出して新しい隠れ層を作ります。名前の通り覚えておけば分かりやすいですね。
数式で書いてみましょう。

入力ゲート I = \sigma (X_{t}W_{x}^{I} + H_{t-1}W_{h}^{I} + B^{I} )
忘却ゲート F = \sigma (X_{t}W_{x}^{F} + H_{t-1}W_{h}^{F} + B^{F} )
出力ゲート O = \sigma (X_{t}W_{x}^{O} + H_{t-1}W_{h}^{O} + B^{I} )

ついでに、入力ゲートと掛け算されるテンソルも書き下しておきましょう。単純な入力と隠れ層の足し算ではなく、活性化関数としてtanhをかますのが一般的です。

入力ゲートがかけられるテンソル:G = {\rm tanh} (X_{t}W_{x}^{G} + H_{t-1}W_{h}^{G} + B^{G} )

これがゲートと言われるゆえんは活性化関数としてシグモイド関数σを使っていることです。シグモイド関数は入力値を0~1の範囲の値に変換する性質があるため、ゲートとしての役割を担うことができます。またゲートの構築には、時刻tの入力Xt、1時刻前の隠れ層Ht-1にそれぞれ異なる重みをかけ、バイアスを足した値が使われます。
これらI, F, O, そしてGテンソルを使うと、隠れ層と長期記憶セルはこのように書くことができます。

隠れ層H_{t}=O\otimes {\rm tanh}C_{t}
長期記憶セルC_{t}=G\otimes I + F\otimes C_{t-1}

\otimesアダマール積で行列の要素ごとの積を取りますが、これがまさにゲートをかける、という演算に相当しています。これでLSTMの1つの単位が出来上がりました。これを時系列データの数の分だけ横に連結することで、LSTMを用いた時系列データの処理が完成します。

2.解析対象データ

今日も米国株の時系列データを用いて解析したいと思います。S&P500という経済指標に採用されている企業の株価を用いました。問題設定としては、数週間分の時系列データから次の週の株価を予測するモデルを構築しました。また、訓練データとテストデータは企業ごとに7:3で分割し、同じ企業の時系列データが訓練とテストに混在しないようにしました。混在しているとそっくりなデータが訓練とテストに共存することになり、予測精度が誤って高く見えてしまうのでここは注意が必要です。

f:id:Dajiro:20200506133907p:plain
入力と出力データの概念図。

3.PyTorchによる実装

こちらがPyTorchによる実装です。PyTorchは機械学習モデルを構築できる便利なフレームワークです。機械学習フレームワークの2台巨頭はGoogleが開発を主導しているTesorFlowとFacebookが開発を主導するPyTorchです。TensorFlowのLSTMは書いたことがあったのですが、PyTrochは未経験だったのでこちらを使ってみることにしました。

データの読み込み

以下はデータの読み込みですが、データごとに読み込み方法は違うと思うのであまり重要ではないかも。注意点としては、numpy形式のデータをPyTorchで処理するにはPytorch用のテンソル形式に変換する必要があることと、入力の外れ値が大きかったので正規分布を使った標準化ではなくminmax標準化をしたことです。

import os
import pickle
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split

import torch
import torch.nn as nn
from torch.utils.data import DataLoader

torch.manual_seed(1)

#学習データが存在するディレクトリ
path = '../data/update191102_SP500/'

#学習データの取り出し
df_dic = {}
inp_dim = 0
for i in os.listdir(path):
    with open(path + i, 'rb') as f:
        df = pickle.load(f)
        #入力ベクトルのサイズを取得
        if len(df) > inp_dim:
            inp_dim = len(df)
    df_dic[i] = df

#入力ベクトルの次元を持つデータの抽出
key = []
for k, v in df_dic.items():
    if len(v) == inp_dim:
        key.append(k)

#訓練データとテストデータに分割
train_k, test_k = train_test_split(key, test_size = 0.3, shuffle=True)
train = torch.zeros((len(train_k), inp_dim))
test = torch.zeros((len(test_k), inp_dim))
for n, k in enumerate(train_k):
    nd_cast = df_dic[k].調整後終値.values.astype(np.float32)
    train[n] =  torch.from_numpy(nd_cast).clone()
for n, k in enumerate(test_k):
    nd_cast = df_dic[k].調整後終値.values.astype(np.float32)
    test[n] = torch.from_numpy(nd_cast).clone()

#minmax標準化(正規分布で標準化すると外れ値に引きずられるため)
X_max, X_min = max(train.flatten()), min(train.flatten())
train_norm = (train - X_min) / (X_max - X_min)
test_norm = (test - X_min) / (X_max - X_min)
適当な時系列データへ変換

ここはLSTMを使った計算で重要な部分です。何週のデータを使って予測をするかを、windowというパラメータで制御することにしました。それらをリスト形式で追加していきます。

#時系列データに分割する。
window = 6
X_train, y_train, X_test, y_test= [], [], [], []
for n in range(len(train)):
    for i in range(inp_dim - window):
        X_train.append(train_norm[n][i:i+window])
        y_train.append(train_norm[n][i+window])
        
for n in range(len(test)):
    for i in range(inp_dim - window):
        X_test.append(test_norm[n][i:i+window])
        y_test.append(test_norm[n][i+window])
データセットの定義

PyTorchではミニバッチ学習を行うためにデータセットクラスを定義する必要があります。2通りの流儀があり、1つは以下のように辞書形式で元のデータにアクセスできるようにするもの、もう一つは__iter__という特殊関数を使ってイテレータとして元のデータを生成できるようにするものです。辞書形式の流儀にここでは従います。__len__と__getiter__を使ったクラスを定義します。詳細な説明はPyTorchの公式に記載されています。
pytorch.org

#Dataloaderに引数として渡すためのデータセット作成
class MyDataset(torch.utils.data.Dataset):

    def __init__(self, X, y):
        self.data = X
        self.teacher = y

    def __len__(self):
        return len(self.teacher)

    def __getitem__(self, idx):
        out_data = self.data[idx]
        out_label =  self.teacher[idx]

        return out_data, out_label
モデル構築

さて、次はLSTMのモデルを構築しましょう。LSTMの入力・出力ベクトルの大きさや、隠れ層、記憶セルのサイズをここで定義しておく必要があります。nn.LSTMが最も重要で、ここにベクトルのサイズを定義したり入力データを入れたりします。細かい話になるので、公式ドキュメントで各引数には何が入るのかご確認頂ければと思います。
pytorch.org

class LSTM(nn.Module):
    def __init__(self, input_size=1, hidden_layer_size=100,
                 output_size=1, batch_size = 32):
        super().__init__()
        self.hidden_layer_size = hidden_layer_size
        self.batch_size = batch_size
        self.lstm = nn.LSTM(input_size, hidden_layer_size)

        self.linear = nn.Linear(hidden_layer_size, output_size)

        self.hidden_cell = (torch.zeros(1, self.batch_size, self.hidden_layer_size),
                            torch.zeros(1, self.batch_size, self.hidden_layer_size))

    def forward(self, input_seq):
        batch_size, seq_len = input_seq.shape[0], input_seq.shape[1]
        lstm_out, self.hidden_cell = self.lstm(input_seq.view(seq_len, batch_size, 1),
                                               self.hidden_cell) #lstmのデフォルトの入力サイズは(シーケンスサイズ、バッチサイズ、特徴量次元数)
        predictions = self.linear(self.hidden_cell[0].view(batch_size, -1))
        return predictions[:, 0]
学習ループの定義

そして、ここが学習の実行パートです。エポックとバッチでループを回します。バッチが分かりづらいですが、上で作成したデータセットをDataLoaderに渡すと、ここから学習データのミニバッチが返されるようになっています。

epochs = 50
batch_size = 32

dataset = MyDataset(X_train, y_train)
dataloader = DataLoader(dataset, batch_size=batch_size)

model = LSTM()
loss_function = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

sample_len = len(X_train)
for i in range(epochs):
    for b, tup in enumerate(dataloader):
        X, y = tup
        optimizer.zero_grad()
        model.hidden_cell = (torch.zeros(1, len(X), model.hidden_layer_size),
                             torch.zeros(1, len(X), model.hidden_layer_size))

        y_pred = model(X)

        single_loss = loss_function(y_pred, y)
        single_loss.backward()
        optimizer.step()

    print(f'epoch: {i:3} loss: {single_loss.item():10.8f}')

4.学習結果

では学習結果を見てみましょう。株価が低い領域では一致しているように見えますが、予測値は正解よりもやや低い値をとる傾向があるようです。真の株価が高い領域ではその傾向は顕著で、予測値が実際よりもかなり過小に評価しています。

f:id:Dajiro:20200506182704p:plain
真値と予測値の相関関係。真の株価が高いところでは予測値は過小評価している。
最後に、実際の企業の株価推移をどう予測しているのか見てみましょう。これはテスト用に使ったアカマイという企業の株価ですが、予測値は真値よりも総じて低くい様子が分かります。一方で株価の山や谷の様子は再現できているようです。株の運用には使えなそうですが、時系列データの推移の傾向は学習できているのでは、という感覚が得られました。
f:id:Dajiro:20200506231756p:plain
アカマイの株価推移の真値と予測値。