こんにちは @THERE2 です。
現在自然言語処理においては、BERTというディープラーニングのモデルの評価が高いようです。
そこで、BERTを使ってロイターの経済ニュース(英語)を解析し、そのニュースによってFX(USD/JPY)が上がるか下がるかという予測をするモデルを実装してみました。
実装にあたっては、先日購入した「Pytorchによる発展ディープラーニング」を参考にしました。
この本はある程度ディープラーニングの知識、経験がある人にとっては非常にうまくまとまっていてかなりの良書です。ゼロから作るシリーズを読んだ後に取り組むのがオススメです。
BERTについて
BERTは事前学習済みのモデルを転用できるのが大きなメリットのようです。自然言語や画像のようなデータを一から学習させていくのは大変ですが、大きなデータセットですでに学習済みのモデルをベースにできれば、学習時間を短縮して最初から高い精度を見込めます。
また、BERTは文書解析のコアのモデルの後にレイヤを追加することで文書生成や単語の予測、各単語に対するクラス分類、文書全体に対するクラス分類と様々なタスクを行えます。
今回のモデルでは、コアのモデルでニュース文章を解析し、ニュース毎にFXが上がるか下がるかを予測させるという BertForSequenceClassification
を利用します。
実装にあたって以下のソースコードも参考にしました。
[Transformers: State-of-the-art Natural Language Processing for TensorFlow 2.0 and PyTorch. ] (https://github.com/huggingface/transformers)
[BERT Fine-Tuning Tutorial with PyTorch] (https://mccormickml.com/2019/07/22/BERT-fine-tuning/)
利用データについて
BERTでFX予測をするにあたり、次のようなモデルとしました。
英語版のロイターの経済ニュースのタイトルを利用します。本当は本文を利用したかったのですが、本文を使うとデータの前処理でもBERTのトレーニングでも時間がかかりすぎるので諦めました。
ただ、タイトルだけでも最大255文字あり、主要なキーワードが含まれているので、FXの予測に使うだけであればタイトルだけでも十分かと考えています。
おそらくFXで売買する人達もニュースの本文は目を通さずヘッドラインだけで反応している人が多いと思いますので、それもタイトルだけで十分かと思った理由です。
また、ロイターニュースは英語にしていますが、USD/JPYの為替の予測であれば英語のニュースが必要な情報の大半をカバーしているのと、自然言語処理のモデルは英語での解析が一番進んでいるためです。
学習用のニュースデータは、kaggleで公開されている以下のデータを使いました。
2018年1月から5月までのBloomberg.com, CNBC.com, reuters.com, wsj.com, fortune.comの英語でのfinancial data(経済データ)となります。
この中から、reuters.comのデータを抜出して利用します。
News APIというサービスを使えば、リアルタイムに近い(フリー版は15分のディレイ有り)ニュースデータを取得する事ができます。
しかし、このサービスは残念ながら過去1ヶ月分のデータしか取得できず、学習用としては不十分です。
そのため、今回学習用としてはkaggleのデータを利用する事にしました。
※449ドル払って商用利用にすれば、過去1年分のデータをディレイ無しで利用できるようです。流石に約5万円の出費は厳しいですね。
FXの価格データ
FXの価格データは、上記のニュースと同期間(2018年1月から5月)の時間足データとしました。
次の時間のClose価格を利用して予測をしていきます。
- ニュースが公開された時間の次の時間足のclose価格
- その時間足の6時間後(6足後)の時間足のclose価格
2の価格が1の価格より高ければ価格アップ(ラベル:1)、2の価格が1の価格より低ければ価格ダウン(ラベル:0)としてラベルデータを用意しました。
これを各ニュース毎にラベルとして付与してニュースタイトルと一緒にBERTに学習させる事とします。
では実装していきましょう。
PyTorchによるBERTの実装
ソースコードの全体は以下のgistに格納しておきました。
https://gist.github.com/THERE2/4518239e7c099e95b3a78432a01eeab9
追加パッケージのインストール
PyTorchではBERTの実装済みのモデルはインストールして利用できます。
「Pytorchによる発展ディープラーニング」では一から実装して説明していますが、ここではパッケージでインストールしたものをそのまま使わせていただきます。
インストールはpip
から次のコマンドで可能です。BERTのモデルとBERT用のtokenizer
が入ってます。
このインストール自体はすぐに終わります。
ただ、最初にBERTのモデルをロードする時に学習済みのモデルをダウンロードするのですが、そのダウンロードがかなり時間かかります。1時間以上かかったでしょうか。根気強く待ちましょう。
一度ダウンロードしておけば次からのモデルのロードはすぐに終わります。
公式ドキュメントへのリンクを貼っておきます。
https://huggingface.co/pytorch-transformers/index.html
pip install pytorch-transformers
また、テキストデータを変換したりミニバッチで取り出したりするのに、torchtext
というライブラリを利用しますので、これもインストールしておきます。
同じくpip
でインストールします。
pip install torchtext
その他pytorch
、pandas
、numpy
等必要になります。
ニュースデータの取得
kaggleから取得したデータをdataフォルダに展開しました。月ごとのフォルダに1記事毎に一つのJSONファイルとして格納されていますので、これらをまとめてpandas
のDataFrame
に格納します。
- data以下にあるファイルは、blogとnewsの両方があるのですが、今回はnewsしか使わないので、ファイル名をnews_*.jsonで指定して取り込みました。
- JSONファイルを一つづつ開いて
DataFrame
に格納していきます。日付型はISO形式からpandas
のdatetime
型に変換しました。タイムゾーンは価格データと合わせるためUTCとしています。 - テキストは本文は数千文字と長すぎるのでタイトルのみを使う事にしました。タイトルだけでも最大255文字有り、重要な情報がコンパクトにまとまっているため、タイトルだけでも十分効果的だと思ったためです。
-
DataFrame
は後から取り出しやすいようにpickle
形式で保存しました。 - ニュース数は全部で30万件弱となります。
import json
import glob
import pandas as pd
# blogは対象外にするので、newsから始まるjsonファイルのみを取り出す。
# 必要な項目のみlistに格納していく。
json_news_files = glob.glob('./data/*/news_*.json')
data = []
for json_file in json_news_files:
json_data = json.load(open(json_file, 'r'))
data.append([
json_data['uuid'],
pd.to_datetime(json_data['published'], utc=True), #datetime型に変換してutc時間に設定。
json_data['language'],
json_data['thread']['country'],
json_data['thread']['site'],
json_data['title'],
json_data['text'],
])
# pandasのデータフレームに変換して、カラム名を設定。uuidをindexとする。
df = pd.DataFrame(data)
df.columns = ['uuid', 'published', 'language', 'country', 'site', 'title', 'text']
df = df.set_index('uuid')
df.to_pickle('./pickle/all_news_df.pkl')
FXの価格データの取得
OANDA APIから価格データを取得する方法については、以前に投稿した記事を参照ください。
機械学習でFX:Oanda APIを使ってPythonから自動売買する
今回は、ニュースデータの期間と合わせて、2017年12月〜2018年5月末までの時間足データを取得してPandas
のDataFrame
に格納しpickle
で塩漬けしました。
from oandapyV20 import API
from oandapyV20.exceptions import V20Error
from oandapyV20.endpoints.pricing import PricingStream
import oandapyV20.endpoints.orders as orders
import oandapyV20.endpoints.instruments as instruments
import json
import datetime
import pandas as pd
# accountID, access_tokenは各自のコードで書き換えてください。
accountID = my_accountID
access_token = my_access_token
api = API(access_token=access_token, environment="practice")
# Oandaからcandleデータを取得する。
def getCandleDataFromOanda(instrument, api, date_from, date_to, granularity):
params = {
"from": date_from.isoformat(),
"to": date_to.isoformat(),
"granularity": granularity,
}
r = instruments.InstrumentsCandles(instrument=instrument, params=params)
return api.request(r)
# Oandaからのresponse(JSON形式)をpython list形式に変換する。
def oandaJsonToPythonList(JSONRes):
data = []
for res in JSONRes['candles']:
data.append( [
datetime.datetime.fromisoformat(res['time'][:19]),
res['volume'],
res['mid']['o'],
res['mid']['h'],
res['mid']['l'],
res['mid']['c'],
])
return data
all_data = []
date_from = datetime.datetime(2017, 12, 1)
date_to = datetime.datetime(2018, 6, 1)
ret = getCandleDataFromOanda("USD_JPY", api, date_from, date_to, "H1")
all_data = oandaJsonToPythonList(ret)
# pandas DataFrameへ変換
df = pd.DataFrame(all_data)
df.columns = ['Datetime', 'Volume', 'Open', 'High', 'Low', 'Close']
df = df.set_index('Datetime')
# pickleファイルへの出力
df.to_pickle('./pickle/USD_JPY_201712_201805.pkl')
前処理の実施
続いて取得したニュースデータと価格データを結合してラベルを設定します。
- ラベルとしては、各ニュースデータ毎に次の時間足での価格と、その6時間後の価格を比較して、上がっていれば1、下がっていれば0をセットするようにしました。
- データ件数が多いとBERTのトレーニングに時間がかかるので、対象データをreuters.comに限定し、さらにそのうち30%のみをサンプリングする事にしました。BERTは事前トレーニング済みのモデルのため、データ総数自体はそこまで多くなくてもいいのではないかと思っています。ただ、各月によってニュースの内容も異なるでしょうから、月ごとのバリエーションは必要だと思います。
- 最後に
DataFrame
をトレーニング用、バリデーション用、テスト用に60%, 20%, 20%の割合で分割してtsvファイル形式で保存しました。この後使うtorchtext
がテキストファイル読み込みを想定しており、DataFrame
のままだと面倒そうだったので、いったんテキストファイルで保存しておくことにしました。
import re
import torchtext
import pandas as pd
import numpy as np
# read text data
news_df = pd.read_pickle('./pickle/all_news_df.pkl')
# read candle data
candle_df = pd.read_pickle('./pickle/USD_JPY_201712_201805.pkl')
################## labelの設定 ###################
# labelとして6時間後の価格が上がっているかどうかとするため、6時間後のclose値とのdiffを取る。
# 上がっていればプラス、下がって入ればマイナス値となる。
candle_df['diff_6hours'] = - candle_df['Close'].astype('float64').diff(-6)
candle_df['label'] = 0
# labelに6時間後の価格が上がっているか下がっているかをセット
candle_df.loc[candle_df['diff_6hours']>0, 'label'] = 1
# newsのtimestampから次の時間足のラベルを取得する。
# 例) 2017-12-04 19:35:51のタイムスタンプのニュースであれば、2017-12-04 20:00:00の時間足のclose値に対して6時間後の時間足の価格が上がっているかどうかがラベルとなる。
def get_label_from_candle(x):
tmp_idx = candle_df.loc[candle_df.index > x.tz_localize(None)].index.min()
return candle_df.loc[tmp_idx, 'label']
# 各ニュースへのラベル設定。件数が多いので数分かかる。
news_df['label'] = news_df['published'].map(get_label_from_candle)
# BERTでトレーニングするにはボリュームが有りすぎるので、ロイターニュースの30%に絞る
news_df = news_df[news_df.site == 'reuters.com']
news_df = news_df.sample(frac=0.3)
# 学習用、バリデーション用、テスト用の配分
train_size = 0.6
validation_size = 0.2
test_size = 1 - train_size - validation_size
total_num = news_df.shape[0]
train_df = news_df.iloc[:int(total_num*(1-validation_size-test_size))][['title', 'label']]
val_df = news_df.iloc[int(total_num*train_size):int(total_num*(train_size+validation_size))][['title', 'label']]
test_df = news_df.iloc[int(total_num*(train_size+validation_size)):][['title', 'label']]
# torchtextのdatasetとして取り込むのに、csvファイル形式に保存。
# ※他にいい方法があると思うが、参考にしている本がCSVファイル形式での記載だったため。
train_df.to_csv('data/dataset_for_torchtext_train.tsv', index=False, sep='\t')
val_df.to_csv('data/dataset_for_torchtext_val.tsv', index=False, sep='\t')
test_df.to_csv('data/dataset_for_torchtext_test.tsv', index=False, sep='\t')
DataLoaderの実装
続いてテキストデータをトークンに分割してミニバッチ毎に取り出すDataLoader
を実装します。ここはtorchtext
を利用しています。torchtext
の使い方は発展ディープラーニング本に詳しく書いてありました。
- テキストをトークンに分割するために、インストールした
BertTokenizer
を利用します。 - BERTの事前学習済みモデルとして、
bert-base-uncased
を利用します。 -
BertTokenizer
にテキストの前処理として、改行コードの削除、記号のスペースへの変換、小文字への変換を加えたものをtokenizer_with_preprocessing
として実装しました。 -
torchtext
のDataLoader
で、tokenizer
を実装したものを指定すると共に、init_token
、eos_token
、pad_token
、unk_token
を指定しています。これにより、torchtext
がテキストを読み込む時に文書にそれぞれ適切なtokenを追加してくれます。 -
torchtext
のTabularDataset.split
でファイルを読み込んでDataSet
を生成します。 - TEXTオブジェクトにwordを数値に変換する単語リストを登録します。
BertTokenizer
のvocab属性がその単語のOrderedDict
となっていますのでそれを設定してあげます。ただ、いきなりTEXTオブジェクトに指定するとエラーになってしまうので一旦回避策としてダミーのボキャブラリを作成TEXT.build_vocab(train_ds, min_freq=1)
して上書きしています。 - 最後に
torchtext
のIterater
でDataSet
からDataLoader
を生成します。この時、バッチサイズを指定します。
import pandas as pd
import torchtext
import pickle
import string
import re
from torchtext.vocab import Vectors
from pytorch_transformers import BertTokenizer
pre_trained_weights = 'bert-base-uncased'
tokenizer_bert = BertTokenizer.from_pretrained(pre_trained_weights)
def tokenizer_with_preprocessing(text, tokenizer=tokenizer_bert.tokenize):
#改行の削除
text = re.sub('\r', '', text)
text = re.sub('\n', '', text)
#数字文字の一律0化
text = re.sub(r'[0-9]', '0', text)
#カンマ、ピリオド以外の記号をスペースに置換
for p in string.punctuation:
if (p == '.') or (p == ","):
continue
else:
text = text.replace(p, " ")
#ピリオド等の前後にはスペースを入れておく
text = text.replace("."," . ")
text = text.replace(","," , ")
#トークンに分割して返す
return tokenizer(text.lower())
def get_DataLoaders_and_TEXT(max_length, batch_size):
#テキストの前処理
TEXT = torchtext.data.Field(sequential=True,
tokenize=tokenizer_with_preprocessing,
use_vocab=True,
include_lengths=True,
batch_first=True,
fix_length=max_length,
init_token='[CLS]',
eos_token='[SEP]',
pad_token='[PAD]',
unk_token='[UNK]',
)
LABEL = torchtext.data.Field(sequential=False, use_vocab=False)
#data setの取得
train_ds, val_ds, test_ds = torchtext.data.TabularDataset.splits(
path='./data/',
train='dataset_for_torchtext_train.tsv',
validation='dataset_for_torchtext_val.tsv',
test='dataset_for_torchtext_test.tsv',
format='tsv',
skip_header=True,
fields=[('title', TEXT), ('label', LABEL)]
)
# ボキャブラリーの作成
# エラー回避のため一旦仮で作成し、bertのvocabで上書き
TEXT.build_vocab(train_ds, min_freq=1)
TEXT.vocab.stoi = tokenizer_bert.vocab
# Data loaderの作成
train_dl = torchtext.data.Iterator(train_ds, batch_size=batch_size, train=True)
val_dl = torchtext.data.Iterator(val_ds, batch_size=batch_size, train=False, sort=False)
test_dl = torchtext.data.Iterator(test_ds, batch_size=batch_size, train=False, sort=False)
return train_dl, val_dl, test_dl, TEXT
トレーニング用コードの実装
ここまでで準備が整ったので、トレーニング用のコードを実装していきます。
ちょっと長いのでソースコードのコメントの番号に沿って簡単に解説します。
#1. パッケージのインポート、定数定義
- BERTのテキストから分類問題を解くための専用のクラスがありますので、それを利用します。これはBERTのモデルの後に
Dropout
層とLinear
層を付けて、CrossEntropy
でloss
を計算してそのloss
とlogits
を返してくれます。コンストラクタの引数で分類クラス数も指定できます。2値分類であればクラス数を2にしてlogits
に対してmaxのindexを取れば推定できます。 - random seedは42で固定しています。42が望ましい理由があるようなのですが、調べてもよくわかりませんでした。
- batch sizeは私のGTX1050だと64ではメモリ不足となってしまうので、32としました。
#2. DataLoaderの取得
- 事前に定義したメソッド
get_DataLoaders_and_TEXT
を使ってDataLoader
を取得します。 - 取得した
DataLoader
をdataloaders_dict
にまとめておきます。
#3. Bertモデルの読み込み
- BERTの事前学習済みモデルを読み込みます。分類クラス数を2で指定しています。
- BERTは
Encoder
層が12層あるのですが、全部再学習すると時間がかかりすぎるので、1〜11層までは固定param.requires_grad = False
で学習対象外としています。
※デフォルトは全ての層が学習対象です。 - いったん全て学習対象外
param.requires_grad = False
とした上で、Encoder
層の12層目とLinear
のClassification
層を学習対象param.requires_grad = True
で更新するようにしました。
#4. Optimizerの設定
- Optimizerは
Adam
にしています。インストールしたBERTのパッケージにBertAdam
というのが含まれているようなのですが、よく分からなかったので使っていません。 - 重み減衰(weight decay)は入れた方が良いようなのですが、実装がややこしくなるので入れていません。
- lossファンクションを定義していませんが、これは読み込んでいる
BertForSequenceClassification
の中で定義しているためです。モデルの中ではCrossEntropyLoss
が利用されています。
# 5. BERTモデルでの予測とlossの計算、backpropの実行
-
BertForSequenceClassification
のforwardには、inputデータとlabelデータの両方を渡します。labelデータを渡す事で、BertForSequenceClassification
がCrossEntropyLoss
でlossを計算して返してくれます。 -
token_type_ids
は、1文が複数のセンテンスに分かれているわけではなければ不要なのでNone
です。 -
attention_mask
も、学習済みモデルを利用し、1〜11層まで固定しているため特に必要無いと重いNone
としています。 - 戻り値はリストとなっており、1つ目が
loss
、2つ目がlogits
です。後は設定に応じてhidden stateやattentionsなどが帰ってきます。 -
_, preds = torch.max(logits, 1)
でlogits
の最大値とそのインデックスが取得できます。ここではindexのみが必要なので、そのindexをpreds
に格納しています。 - あとは
loss
とpreds
を使ってbackpropしてAccuracyを計算してログ出力しています。
# 6. testデータでの検証
- メインループではepoch毎にトレーニングデータとバリデーションデータを交互に処理して学習させていきました。
- 最後学習が終わった後に、学習済みモデルを使ってテストデータで精度を確認します。
# 7. torchモデルを保存しておく
- 学習済みモデルを再利用できるように、保存しておきます。
torch.save(net_trained.state_dict(), 'weights/bert_net_trainded.model')
# 1. パッケージのインポート、定数定義
import random
import math
import numpy as np
import json
import torch
import torch.nn as nn
import torch.optim as optim
import torch.utils.data as data
import torch.nn.functional as F
from dataloader import get_DataLoaders_and_TEXT
from pytorch_transformers import BertForSequenceClassification
torch.manual_seed(42)
np.random.seed(42)
random.seed(42)
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
max_length=256
batch_size=32
pre_trained_weights = 'bert-base-uncased'
# 2. data loaderの取得
train_dl, val_dl, test_dl, TEXT = get_DataLoaders_and_TEXT(
max_length=max_length,
batch_size=batch_size
)
dataloaders_dict = {"train":train_dl, "val": val_dl}
# 3. Bertモデルの読み込み
net = BertForSequenceClassification.from_pretrained(pre_trained_weights, num_labels=2)
net.to(device)
# Bertの1〜11段目は更新せず、12段目とSequenceClassificationのLayerのみトレーニングする。
# 一旦全部のパラメータのrequires_gradをFalseで更新
for name, param in net.named_parameters():
param.requires_grad = False
# Bert encoderの最終レイヤのrequires_gradをTrueで更新
for name, param in net.bert.encoder.layer[-1].named_parameters():
param.requires_grad = True
# 最後のclassificationレイヤのrequires_gradをTrueで更新
for name, param in net.classifier.named_parameters():
param.requires_grad = True
# 4. Optimizerの設定
optimizer = optim.Adam([
{'params': net.bert.encoder.layer[-1].parameters(), 'lr': 5e-5},
{'params': net.classifier.parameters(), 'lr': 5e-5}], betas=(0.9, 0.999))
def train_model(net, dataloaders_dict, optimizer, num_epochs):
net.to(device)
torch.backends.cudnn.benchmark = True
for epoch in range(num_epochs):
for phase in ['train', 'val']:
if phase == 'train':
net.train()
else:
net.eval()
epoch_loss = 0.0
epoch_corrects = 0
batch_processed_num = 0
# データローダーからミニバッチを取り出す
for batch in (dataloaders_dict[phase]):
inputs = batch.title[0].to(device)
labels = batch.label.to(device)
# optimizerの初期化
optimizer.zero_grad()
with torch.set_grad_enabled(phase=='train'):
# 5. BERTモデルでの予測とlossの計算、backpropの実行
outputs = net(inputs, token_type_ids=None, attention_mask=None, labels=labels)
# loss and accuracy
loss, logits = outputs[:2]
_, preds = torch.max(logits, 1)
if phase =='train':
loss.backward()
optimizer.step()
curr_loss = loss.item() * inputs.size(0)
epoch_loss += curr_loss
curr_corrects = (torch.sum(preds==labels.data)).to('cpu').numpy() / inputs.size(0)
epoch_corrects += torch.sum(preds==labels.data)
batch_processed_num += 1
if batch_processed_num % 10 == 0 and batch_processed_num != 0:
print('Processed : ', batch_processed_num * batch_size, ' Loss : ', curr_loss, ' Accuracy : ', curr_corrects)
# loss and corrects per epoch
epoch_loss = epoch_loss / len(dataloaders_dict[phase].dataset)
epoch_acc = epoch_corrects.double() / len(dataloaders_dict[phase].dataset)
print('Epoch {}/{} | {:^5} | Loss:{:.4f} Acc:{:.4f}'.format(epoch+1, num_epochs, phase, epoch_loss, epoch_acc))
return net
# trainingの実施
num_epochs = 3
net_trained = train_model(net, dataloaders_dict, optimizer, num_epochs=num_epochs)
# 6. testデータでの検証
net_trained.eval()
net_trained.to(device)
epoch_corrects = 0
for batch in (test_dl):
inputs = batch.title[0].to(device)
labels = batch.label.to(device)
with torch.set_grad_enabled(False):
# input to BertForSequenceClassifier
outputs = net_trained(inputs, token_type_ids=None, attention_mask=None, labels=labels)
# loss and accuracy
loss, logits = outputs[:2]
_, preds = torch.max(logits, 1)
epoch_corrects += torch.sum(preds == labels.data)
epoch_acc = epoch_corrects.double() / len(test_dl.dataset)
print('Correct rate {} records : {:.4f}'.format(len(test_dl.dataset), epoch_acc))
# 7. torchモデルを保存しておく
torch.save(net_trained.state_dict(), 'weights/bert_net_trainded.model')
実行結果の確認
かなり時間がかかりますが、3 epoch回してみました。
Validation結果では、Lossがわずかに下がっていますが、Accuracyはいったん下がった後に上がっています。51.4%のAccuracyは悪くないですが、このままトレーニングを続けていくと精度があがっていくでしょうか。
※validationの結果のみ抜粋
Epoch 1/3 | val | Loss:0.6932 Acc:0.4959
Epoch 2/3 | val | Loss:0.6939 Acc:0.4859
Epoch 3/3 | val | Loss:0.6928 Acc:0.5141
テストデータでの検証結果は以下のようになりました。12万件弱で50.46%。完全にランダムよりは若干良さそうですが誤差の範囲内かもしれません。
Correct rate 11779 records : 0.5046
もっと時間をかけて学習させてみたり、学習用データを増やしたり、予測を6時間後から前後させてみたりと工夫する事で精度があがっていく可能性はあると思います。
また、このニュース解析だけではなく、通常の価格データやテクニカル指標と組み合わせる事で精度を上げていくことが出来るかも知れませんね。
次の記事では機械学習の最強モデルという評判のLightGBMでFX予測に取り組んでみたいと思います。