1.本投稿の背景
本投稿・プログラミング作成の背景は、
「現在学習しているデータ分析の知識をアウトプットし、自分の後学・キャリアに繋げること」 です。
今年2024年4月より、Pythonの使い方から機械学習のモデル・ディープラーニングの手法など、データサイエンスの基本的な知見を学んでいます。
学習を始めた理由を一言で言うと、
「データを高度にかつ正しく扱うことで、世の課題を解決できる人材になりたいと考えたから」です。
現職でも市場開発などのデータ分析、そしてそれらデータの現場活用までの重要性を実感していますが、使用しているのはExcelの相関分析や社内BIツールがほとんどです。
今後のキャリアで自分の強みがより通用するように、さらにデータ分析・活用の専門性を学び得たいと考えています。
今回は学んだPythonや分析モデルを使い、みんな大好きOLC社のバイナリー予測を行ってみました。
少し長い文章になっていますので、目次を活用しながらご覧いただきますと幸いです。
*以下、開発環境
- 開発環境
- Mac M1
- Google Colaboratory
- 使用言語
- python
- モデル
- LightGBM
2. 今回の分析の概要
2-1.予測対象
OLC社(4661)の株価の上下動を予測して、買うか買わないか判断するモデルを作成します。
以下を予測します。
① 20日後、最大株価が5%以上上昇するか
② 20日後、最小株価が5%以上下落しないか
③ 20日後、 平均株価が0%以上上昇するか
上記をすべて満たす場合、購入すると決断します。
2-2.用いる説明変数
① 日経平均株価
② ダウ平均株価
③ NASDAQ総合指数
④ S&P500種指数
⑤ 各国の為替
⑥ 失業率
⑦ インフレ率
⑧ 消費者物価指数
⑨ ウォルトディズニー社株価
⑩ ユニバーサルエンターテインメント(USJ)株価
⑪ トヨタ株価
実際にはデータを処理していく中で、いくつかの説明変数を削除しています。
2-3.期間
訓練データ:2016年1月1日~2018年12月31日
試験データ:2019年1月1日~2020年1月1日
コロナによる暴落は予測しようがないと思ったので、分析期間から除外しました。
2-4.用いたモデル
モデルは「LightGBM」を使用します。
LightGBM(Light Gradient Boosting Machine)は,Microsoft Researchを中心としたチームから発表された機械学習アルゴリズムで、XGBoostと同じ決定木と勾配ブースティングを組み合わせた手法ですが、XGBoostに比べ高速であることが特徴です。
こちらのサイトの解説がわかりやすかったです。参考にさせていただきました。
今回、LightGBMを使用した理由は、「バイナリー予測をやってみたいから」「巷で最強の学習モデルと言われていたから」の2点です。
実は今回の前にSARIMAモデルやLSTMモデルで将来の株価予測をしてみたのですが、
今の私の知識・技術では、”価格を予測する”というテーマそのものに難点がありました。
そのため今回は、価格が上がるか下がるかの”バイナリー予測”に変更しています。
バイナリー予想の場合、単純に思いつくのが「決定木が使えそうか…?」という何となくの思い…。
そこで決定木の1つで、様々な機械学習コンペの上位を占めていると言われるLightGBMに着目しました。
2-5.結果・考察
今回のモデルに従って株を買うと、
「31/(31+35)=46%の確率で当てられる」ことになりました。
大変悔しいのですが、モデルとしてはあまり機能していないということになります。
考察欄に原因や今後の課題も記しております。
以下から、全体の流れ・具体的なコードを説明します。
3. ライブラリのインポートとデータ取得
まず必要なライブラリやデータを取得しておきます。
今回予測するOLC社の株価や説明要因として用意する各種指数は、pandas-datareaderというライブラリを用いて取得します。
3-1.ライブラリのインポート
本コードで使うライブラリはすべてここでまとめてインポートしています。
# ライブラリのインポート
import pandas as pd
pd.set_option('display.max_rows', 500)
import numpy as np
import pandas_datareader.data as web
from sklearn import metrics
from sklearn.metrics import confusion_matrix,mean_squared_error
from sklearn.model_selection import KFold
import lightgbm as lgb
from sklearn.model_selection import train_test_split
取得期間について、2年間の分析→1年間の予測とします。
コロナによる暴落は予測しようがないと思ったので、分析期間から除外しています。
# 欠損値補完のためにStartは少し長めに取得。
st = '2015/01/01'
ed = '2021/01/01'
# 実際の分析期間は2016~2018
st_train = '2016/01/01'
ed_train = '2018/12/31'
# 分析期間は2019
st_test = '2019/01/01'
ed_test = '2020/01/01'
3-2.データの取得
株価・各種指数の取得先は、StooqとFREDというサイトです。
OLC社の他、動きが関係しそうなウォルトディズニー社、USJ社、トヨタ社の株価も説明変数として取得しています。
# STOOQから株価価格等の取得
lst1=['4661.JP', #オリエンタルランド
'DIS.US', #ウォルトディズニー社
'6425.JP', #ユニバーサルエンターテインメント(USJ)
'7203.JP', #トヨタ
]
#DataReaderで株価を取得、第2引数に取得先を定義
stooq = web.DataReader(lst1,'stooq',start=st,end=ed)
# FREDから各種指数の取得
lst2=['NIKKEI225', #日経平均
'T10YIE', #10-Year Breakeven Inflation Rate
'T5YIE', #5-Year Breakeven Inflation Rate
'IRLTLT01USM156N', #Long-Term Government Bond Yields: 10-year: Main (Including Benchmark) for the United States
'IRLTLT01EZM156N', #Long-Term Government Bond Yields: 10-year: Main (Including Benchmark) for the Euro Area
'IRLTLT01JPM156N', #Long-Term Government Bond Yields: 10-year: Main (Including Benchmark) for Japan
'IRLTLT01CAM156N', #Long-Term Government Bond Yields: 10-year: Main (Including Benchmark) for Canada
'JPNCPIALLMINMEI', #Consumer Price Index of All Items in Japan
'PWHEAMTUSDM', #Global price of Wheat
'IPUTIL', #Industrial Production: Utilities: Electric and Gas Utilities (NAICS = 2211,2)
'DJIA', #Dow Jones Industrial Average
'SP500', #S&P 500
'NASDAQCOM', #NASDAQ Composite Index
'DEXJPUS', #Japan / U.S. Foreign Exchange Rate
'DEXCHUS', #China / U.S. Foreign Exchange Rate
'DEXUSEU', #U.S. / Euro Foreign Exchange Rate
'DEXINUS', #India / U.S. Foreign Exchange Rate
'UNRATE', #Unemployment Rate
'LRUN64TTJPM156S', #Unemployment Rate: Aged 15-64: All Persons for Japan
]
#DataReaderで各種指数を取得、第2引数に取得先を定義
fred = web.DataReader(lst2,'fred',start=st,end=ed)
# StooqとFredのデータを、インデックスをキーに結合する
stock = pd.merge(stooq['Close'], fred, how='left', left_index=True, right_index=True)
コード上ではstockを出力し、データの形やレコード数を確認していますが、ここでは省略します。
4. データの前処理
4-1.欠損値の削除・補完
まず欠損値がどれくらいあるかを確認します。
stock.isnull().sum()
>>>出力結果
4661.JP 96
DIS.US 48
6425.JP 96
7203.JP 96
NIKKEI225 94
T10YIE 58
T5YIE 58
IRLTLT01USM156N 1513
IRLTLT01EZM156N 1513
IRLTLT01JPM156N 1513
IRLTLT01CAM156N 1513
JPNCPIALLMINMEI 1513
PWHEAMTUSDM 1513
IPUTIL 1513
DJIA 48
SP500 48
NASDAQCOM 47
DEXJPUS 60
DEXCHUS 60
DEXUSEU 60
DEXINUS 60
UNRATE 1513
LRUN64TTJPM156S 1513
dtype: int64
ほとんど欠損値のカラムが存在していますね。
なんらかのエラーでデータが上手く取り込めていないようです。
1513個もエラーが出ているカラムは削除しておきます。
stock.drop(['IRLTLT01USM156N','IRLTLT01EZM156N','IRLTLT01JPM156N','JPNCPIALLMINMEI','PWHEAMTUSDM','IPUTIL','UNRATE','LRUN64TTJPM156S'], axis=1)
欠損値が100以下のカラムは、そのまま残し、欠損値自体を直前のデータで補完するようにします。
欠損値が多い要素をカラムごと取り除いたり説明変数を変更したりと、いろいろ試行錯誤したのですが、一番精度が高くなるのはこの方法でした。
# StooqとFredから取得したデータをインデックスをキーに結合する
stock = pd.merge(stooq['Close'], fred, how='left', left_index=True, right_index=True)
# 欠損値を直前のデータで補完
stock = stock.fillna(method='ffill')
# 2016-01-01以降のデータを抽出後、データを確認
stock = stock[st:ed]
stock[:5]
>>>出力結果
4661.JP DIS.US 6425.JP 7203.JP NIKKEI225 T10YIE T5YIE IRLTLT01USM156N IRLTLT01EZM156N IRLTLT01JPM156N ... IPUTIL DJIA SP500 NASDAQCOM DEXJPUS DEXCHUS DEXUSEU DEXINUS UNRATE LRUN64TTJPM156S
Date
2015-01-02 NaN 87.377 NaN NaN NaN 1.71 1.30 NaN NaN NaN ... NaN 17832.99 2058.20 4726.81 120.20 6.2046 1.2015 63.27 NaN NaN
2015-01-05 1358.80 86.100 1779.78 1340.94 17408.71 1.64 1.25 NaN NaN NaN ... NaN 17501.65 2020.58 4652.57 119.64 6.2201 1.1918 63.34 NaN NaN
2015-01-06 1340.67 85.644 1940.35 1303.97 16883.19 1.56 1.16 NaN NaN NaN ... NaN 17371.64 2002.61 4592.74 118.26 6.2125 1.1936 63.57 NaN NaN
2015-01-07 1340.92 86.520 1911.32 1323.08 16885.33 1.57 1.16 NaN NaN NaN ... NaN 17584.52 2025.90 4650.47 119.52 6.2127 1.1820 63.27 NaN NaN
2015-01-08 1368.35 87.414 1914.23 1349.34 17167.10 1.62 1.21 NaN NaN NaN ... NaN 17907.87 2062.14 4736.19 119.51 6.2143 1.1811 62.67 NaN NaN
5 rows × 23 columns
4-2.目的変数の作成
ここでOLC社の株を目的変数として、指定しています。
特定の期間におけるデータの集計はrolling関数を使用することで簡単のようです。
rolling(5).max()とすると「基準日を含めた5日分のデータ」から最大値が抽出されます。
ただ、今回は未来の集計を行うので、shift関数を組み合わる必要があります。
翌日から5日間の最大値を抽出したい場合、rolling(5).max().shift(-5)となるわけですね。
# 20営業日先の最大株価
stock['target.future.max'] = stock['4661.JP'].rolling(20).max().shift(-20)
stock['target.ratio.max'] = stock['target.future.max']/stock['4661.JP']
stock['target.binary.max'] = np.where(stock['target.ratio.max'] > 1.05, 1, 0)
# 20営業日先の最小株価
stock['target.future.min'] = stock['4661.JP'].rolling(20).min().shift(-20)
stock['target.ratio.min'] = stock['target.future.min']/stock['4661.JP']
stock['target.binary.min'] = np.where(stock['target.ratio.min'] < 0.95, 0, 1)
# 20営業日先の最小株価
stock['target.future.mean'] = stock['4661.JP'].rolling(20).mean().shift(-20)
stock['target.ratio.mean'] = stock['target.future.mean']/stock['4661.JP']
stock['target.binary.mean'] = np.where(stock['target.ratio.mean'] > 1.00, 1, 0)
# 不要カラムの削除
stock = stock.drop(columns=['target.future.max','target.future.min','target.future.mean'])
stock = stock.drop(columns=['target.ratio.max','target.ratio.min','target.ratio.mean'])
4-3.説明変数の作成
StooqとFREDから取得したデータをそのまま使ってもいいのですが、
各データの時系列的な変化が表現されないので、
各データの移動平均を算出し、現在値との乖離率を算出しました。
移動平均の採用期間は5日、10日、・・・、55日、60日としました。
# 各指数の移動平均との乖離率を算出する
for stock_name in lst1:
for i in [5,10,15,20,25,30,35,40,45,50,55,60]:
exec('stock["{}_{}days_diverge"] = \
(stock["{}"].rolling({}).mean() - stock["{}"])/stock["{}"].rolling({}).mean()' \
.format(stock_name,i,stock_name,i,stock_name,stock_name,i)
)
for stock_name in lst2:
for i in [5,10,15,20,25,30,35,40,45,50,55,60]:
exec('stock["{}_{}days_diverge"] = \
(stock["{}"].rolling({}).mean() - stock["{}"])/stock["{}"].rolling({}).mean()' \
.format(stock_name,i,stock_name,i,stock_name,stock_name,i)
)
# 各指数の列を削除する
stock = stock.drop(columns=lst1)
stock = stock.drop(columns=lst2)
4-4.訓練データとテストデータの作成
いよいよモデル実装まえの最後の仕上げに入っていきます。
# データ期間の定義
st_train = '2016/01/01'
ed_train = '2018/12/31'
st_test = '2019/01/01'
ed_test = '2020/01/01'
# 訓練データ(2016~~2018年)とテストデータ(2019~2020年)に分割
df_train = stock[st_train : ed_train]
df_test = stock[st_test : ed_test]
df_train = df_train.reset_index(drop=True)
df_test = df_test.reset_index(drop=True)
print('訓練データ', df_train.shape)
print('テストデータ', df_test.shape)
>>> 出力結果
訓練データ (778, 279)
テストデータ (260, 279)
# numpyに変換する関数を定義
def toNumpy(x_df, t_max_df, t_min_df, t_mean_df):
X = x_df.values
y_max = t_max_df.values
y_max = y_max.reshape(-1)
y_min = t_min_df.values
y_min = y_min.reshape(-1)
y_mean = t_mean_df.values
y_mean = y_mean.reshape(-1)
return X, y_max, y_min, y_mean
# 目的変数(OLC社の株価)と説明変数(各種指数)に分ける関数を定義
def split(inputData):
## 訓練データの分割
x_df = inputData.drop(columns=['target.binary.min','target.binary.max','target.binary.mean'])
t_max_df = inputData['target.binary.max']
t_min_df = inputData['target.binary.min']
t_mean_df = inputData['target.binary.mean']
## Numpyに変換
X, y_max, y_min, y_mean = toNumpy(x_df, t_max_df, t_min_df, t_mean_df)
return X, y_max, y_min, y_mean
## 訓練データ
X_train, y_trainmax, y_trainmin, y_trainmean = split(df_train)
## テストデータ
X_test, y_testmax, y_testmin, y_testmean = split(df_test)
5. LightGBMモデルの実装と学習
ここからLghtGBMを実装していきます。
5-1.LightGBMモデルの実装
交差検証はKFoldを使用して5分割にしました。
データリーク(学習用データと評価用データのデータ期間が被ること)を防ぐために、
Shuffleはしないようにしています。
以下を実行することでbests_max、bests_min、bests_meanというリストが作成されます。bests_maxには最大株価、bests_minには最小株価、bests_meanには平均株価の変動を
予測したモデルがそれぞれ5つ格納されます。
# LightGBM訓練用関数を定義
def traingbm(X_train, y_train):
bests = []
kf = KFold(n_splits=5, shuffle=False)
for learn_index, valid_index in kf.split(X_train, y_train):
X_learn, X_valid = X_train[learn_index], X_train[valid_index]
y_learn, y_valid = y_train[learn_index], y_train[valid_index]
#LGB用のデータに変形
lgb_learn = lgb.Dataset(X_learn, y_learn)
lgb_valid = lgb.Dataset(X_valid, y_valid)
param = {
'objective': 'binary',
'metric': 'auc',
'boosting_type': 'gbdt',
'random_seed':SEED,
}
# 訓練実施
best = lgb.train(param,
lgb_learn,
valid_sets=[lgb_learn,lgb_valid],
)
bests.append(best)
return bests
5-2.LightGBMモデルの学習
株を買うか買わないかを判断する3つの条件、
最大株価・最小株価・平均株価に対して、モデルを適用します。
# 勾配ブースティングによる学習実施(最大株価予測)
bests_max = traingbm(X_train, y_trainmax)
# 勾配ブースティングによる学習実施(最小株価予測)
bests_min = traingbm(X_train, y_trainmin)
# 勾配ブースティングによる学習実施(平均株価予測)
bests_mean = traingbm(X_train, y_trainmean)
6. テストデータによる予測
作成したモデルを使用してテストデータの予測を実施します。実装は以下の通り。
# テストデータを予測する(最大株価予測)
y_preds = []
for i in range(5):
y_pred = bests_max[i].predict(X_test, num_iteration=bests_max[i].best_iteration)
y_preds.append(y_pred)
y_predmax = (y_preds[0] + y_preds[1] + y_preds[2] + y_preds[3] + y_preds[4])/5
# テストデータを予測する(最小株価予測)
y_preds = []
for i in range(5):
y_pred = bests_min[i].predict(X_test, num_iteration=bests_min[i].best_iteration)
y_preds.append(y_pred)
y_predmin = (y_preds[0] + y_preds[1] + y_preds[2] + y_preds[3] + y_preds[4])/5
# テストデータを予測する(平均株価予測)
y_preds = []
for i in range(5):
y_pred = bests_mean[i].predict(X_test, num_iteration=bests_mean[i].best_iteration)
y_preds.append(y_pred)
y_predmean = (y_preds[0] + y_preds[1] + y_preds[2] + y_preds[3] + y_preds[4])/5
7. 予測結果の表示
df_resultのままだと、予測値が確率表現となってしまうので、
閾値を設けて、1か0に分類します。
今回は四捨五入で分類します。
最後に、予測の結果を混合行列で表示します。
最大・最小・平均の3つの予測から、買うか買わないかの判断も結果に表示するようにします。
# 予測値と実値との比較
result = np.c_[y_predmax,y_testmax,
y_predmin,y_testmin,
y_predmean,y_testmean
]
df_result = pd.DataFrame(data=result,
columns=['pred_max','true_max',
'pred_min','true_min',
'pred_mean','true_mean'
])
# 閾値0.5として1,0に分類
df_result['pred_max'] = np.round(df_result['pred_max'])
df_result['pred_min'] = np.round(df_result['pred_min'])
df_result['pred_mean'] = np.round(df_result['pred_mean'])
df_result['buy_flg'] = df_result['pred_max'] * df_result['pred_min'] * df_result['pred_mean']
df_result['buy_flg_true'] = df_result['true_max'] * df_result['true_min'] * df_result['true_mean']
display(df_result)
>>> 出力結果 (1は価格が上がった、0は下がったを意味します)
◆ 混合行列( 最大株価 )◆
結果\予測 | 1 | 0 |
---|---|---|
1 | 34 | 94 |
0 | 34 | 98 |
34+98=132件はAIが正しく予測できた件数で、34+94=128件はAIが外した件数です。
◆ 混合行列( 最小株価 )◆
結果\予測 | 1 | 0 |
---|---|---|
1 | 190 | 47 |
0 | 22 | 1 |
◆ 混合行列( 平均株価 )◆
結果\予測 | 1 | 0 |
---|---|---|
1 | 81 | 111 |
0 | 44 | 24 |
◆ 混合行列( 買うか買わないか )◆
結果\予測 | 1 | 0 |
---|---|---|
1 | 31 | 96 |
0 | 35 | 98 |
◆混合行列の見方◆
1:モデルの”上昇”予測 | 0:モデルの"下降"予測 | |
---|---|---|
1:実際の”上昇”結果 | True Positive (真陽性:TP) | False Negative (偽陰性:FN) |
0:実際の”下降”結果 | False Positive (偽陽性:FP) | True Negative (真陰性:TN) |
株価予測では上記の内、TPとFPが重要になります。
モデルの言う通りに株を購入する場合、TP+FPが実際に購入する件数になるからです。
また、TPは実際に株価が上昇する件数になりますので、
TP/(TP+FP)が、モデルの言う通りに株を購入した場合の勝率となります。
ここで、4つ目の「買うか買わないか」の結果について着目。
モデルの言う通りに買うと、31/(31+35)=46%の確率で勝てることになることがわかります。
勝率やモデルの正答率が50%前後と、モデルとしてあまり機能していません。
8.考察・残された課題
概要、また上記のコード解説末尾の通り、今回の実装では高い精度が出ませんでした。
より良い精度を出すための工夫として、
「説明変数の追加・修正する」「モデルのパラメータの変更する」等があります。
・欠損値が多い説明変数の削除
・各種コモディティ指数(金や麦等)などの説明変数を追加
・パラメータの種類をboosting,objectiveなどに変更(以下のサイトを参考)
以上、色々と試行錯誤し精度が上がらないかを試したのですが、
精度はどれも変わらない、
もしくは精度が高くなってもほとんどが「買わない」になるなど、どれもイマイチな結果でした。
ちなみにOLC社ではなく、ユニバーサルスタジオ社の株や米Disney社の株でも実装してみましたが、こちらもやはり精度は上がらず。
いろいろなPythonエンジニア・スクールの講師などに相談したのですが。
やはり株価の予測自体、「説明変数が複雑かつ大量のため」難しいとのこと。
当たり前ですが、株価は「これとこれとこれで決まる!」といった単純なものではありませんし、そんなに簡単に予測できるものではありません。もしもそんな簡単に予測ができていたら、世の中投資家だらけになってしまいます。
大事なのは、「株価変動の説明要因をピックアップすること」だと考えます。
例えばOLC社の株価は、新商品の発売やアトラクションエリアの追加発表などさまざまなプレスニュースやファンの動き方で変動します。
モデルの精度向上も1つの課題ですが、
ニュースやSNSなどをスクレイピングをし、ネガポジ分析などと組み合わせることでもっと精度の良い予測ができうるかもしれません。
また「株価の変動を予測し、株の売買で儲ける!」ということをゴールにするのであれば、
「機械学習の予測と人間の予測を組み合わせること」がよりベターと言えるかもしれません。
どれだけハイレベルなモデルを作成しても、コロナ禍のような人類未曾有の自体に陥ってしまえば、機械学習の予測も役に立ちません。機械学習モデルはあくまで機械学習モデル、取り扱う側の活用方法が重要であると考えます。
9.感想
今回はAidemyで学んだことや自学自習で拾ってきたインプットを、データセットから前処理・LightGBMの実装までの作成に落とし込むことができました。
またそこからの精度を上げるための試行錯誤をする経験も良い機会になりました。
今も現在進行形で、平日仕事に向かう前の1~2時間と就寝前の30分をルーティーンとして機械学習やPythonの学習に取り組んでいますが、本当に楽しい時間を過ごせています。
やはり新しい分野の知識が身につくということ、努力した結果が何かしらの見える形になって返ってくることは自分の性分にあっていると思います。
引き続きインプットとアウトプットを繰り返し、さらに機械学習やデータ分析の知識をつけていきたいです。
まだ具体では考えることができていませんが、データ分析の専門性をもっと磨き、自分のキャリアにも組み込んでいきたいと考えています。
そのため、知識・技術だけではなく、分析した結果からの考察や意思決定、Next Actionも重要になってくるので、それらの練習もしていきたいです。
まずは、精度の向上のための複数モデルでの比較や組み合わせ、また株以外の価格予測や画像認識にLightGBMを実践など、楽しんでみたいと考えています。
*このブログはAidemyの一環で、修了条件を満たす目的でも、一般公開しています。