본문 바로가기
논문/논문 리뷰

논문 리뷰 2. Stock Price Movement Prediction Using Sentiment Analysis and CandleStick Chart Representation(1)

by p-jiho 2023. 2. 21.

 

논문 명 : Stock Price Movement Prediction Using Sentiment Analysis and CandleStick Chart Representation. 2021

저자 : Trang-Thi Ho, Yennun Huang

 

분류분석을 이용해 주가예측을 시작한 후 두번째 논문 리뷰이다.

두번째라고 해서 논문을 두 개만 읽은 것은 아니고 읽는 것에 그치지 않고 재현을 해보는 논문이 두번째인 것이다.

Fake 논문, 모델을 친절하게 알려주지 않은 논문들이 많아서 읽은 모든 논문을 재현해보는 것은 불가능했다.

이 논문을 재현해보기 전에 다른 논문을 재현하다가 재현을 하기에는 정보가 너무 부족하다 느껴 잠시 멈추고 이 논문을 재현하게 되었다.

 

교수님께서 컨펌하시기에 R code가 편하기 때문에 요즘은 R을 주로 사용하였지만 이 논문은 Python을 사용하기도 하였고, 모듈부터 세부 파라미터까지 알려주어 Python으로 재현을 해보는 것이 정확하다 생각하여 이번 논문은 Python을 이용하였다.

이 논문은 앞서 재현해본 논문을 인용한 논문이다. 좋은 저널에 게재되어있는 논문이므로 믿고 재현해보도록 하겠다.

 

필요한 package

import pandas as pd
import yfinance as yf
from sklearn.preprocessing import MinMaxScaler

from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier as RF
from sklearn.svm import LinearSVC as SVC
from sklearn.naive_bayes import GaussianNB as NB
from keras.models import Sequential
from keras.layers import Dense, LSTM, Dropout
from keras.layers import Conv1D
from keras.layers import BatchNormalization, Activation, GlobalAveragePooling1D

from sklearn.preprocessing import OneHotEncoder
from sklearn.model_selection import KFold, GridSearchCV
from keras.callbacks import ReduceLROnPlateau
from keras.wrappers.scikit_learn import KerasClassifier
from keras.callbacks import ModelCheckpoint
from keras.optimizers import Adam, SGD, RMSprop

 

먼저, 이 논문은 sentiment analysis와 candlestick chart를 이용해 deep learning을 하였다.

text data는 twitter에서 얻었다. 일단은 재현과정에서 text data는 nbc news data를 사용하였다.

추후에 twitter data를 얻어 다시 시도해볼 것이다.

score = pd.read_csv("./csv_data/price_data_score_10years.csv")
sentiment_score = score.copy()
sentiment_score.Score = sentiment_score.Score.apply(lambda x: 4 if x > 0.5
                           else 3 if 0 < x and x <= 0.5
                           else 2 if x == 0
                           else 1 if -0.5 <= x and x < 0
                           else 0 )

나의 text data는 csv_data 파일에 저장이 되어있어 read_csv를 이용해 불러왔다.

sentiment analysis가 완료된 score data인데 이 논문에서도 동일하게 NLTK를 사용하여 분석을 하였으므로 감성분석 과정이 동일하다. 그래서 그대로 가져왔다.

그리고 이 논문은 compound를 사용하였고 나의 score data도 compound로 구성되어있다.

sentiment analysis는 작년 봄~여름쯤에 코드가 완료되었는데 블로그를 작성하진 않았다. 추후에 이 부분도 작성할 예정이다.

이 논문에서 score 값을 5가지 분류로 나누었다. apply와 lambda를 이용하여 분류를 하였다.

나는 Python 에서는 for문 대신 apply, lambda, map을 이용하여 데이터 처리를 하는 경우가 많다.

이유는 예전에 for문과 비교를 해보았는데 실행 시간이 굉장히 빨라서였다. 데이터 형식에 맞춰서 적절히 사용하면 실행시간을 단축시킬 수 있으므로 유용하게 사용하면 좋다.

 

sentiment_score = sentiment_score.groupby('Date').sum({"Score"})
sentiment_score = sentiment_score.reset_index()

Date에 따라서 Score을 합쳤다. 논문에서 결합하라고 적혀있어 평균으로 결합해야하는 것인지 합으로 결합해야하는 것인지 궁금했다.

sample data를 보면 합으로 결합을 하는 것이 맞다고 판단하여 sum 함수를 사용하였다.

여기서 드는 의문점은 휴일에 나온 news는 어떻게 처리를 하는 것인지 명시해두지 않았다.

합이 적으면 긍정적인 글이 적다는 이야기가 된다.

나는 이 전에 분석을 할 때 1, 2일이 휴일이고 3일이 개장일이면 1,2일에 나온 news를 모두 가지고 3일의 주가에 영향을 반영하였다.

그렇다면 어떤 개장일 전에 휴일이 많았다면 그 개장일은 합이 클 수 밖에 없다.

아무런 언급이 없기에 일단 하루치 뉴스만 사용을 하였지만 추후에 합이 아닌 평균으로 데이터를 구성해 결과를 볼 수 있었으면 좋겠다.

그리고 reset_index를 한 이유는 groupby를 하면 Date는 index가 되므로 Date를 하나의 열로 만들어주기 위해서이다.

 

price_data = yf.download("^DJI",start = '2011-12-31', end = '2022-05-01')
price_data = price_data[['Open', 'High', 'Low', 'Close', 'Volume']]
price_data = price_data.reset_index()
price_data.Date = price_data.Date.apply(lambda x : x.strftime('%Y-%m-%d'))

10년치 다우존스 데이터를 가져왔다.

yfinance를 사용하면 yahoo finance의 주가 데이터를 쉽게 긁어올 수 있다.

나는 5가지의 데이터만 사용을 하고 reset_index를 이용해 Date를 하나의 열로 만들어주었다.

그리고 apply와 lambda를 이용해 각 행마다 strftime을 적용을 하였다. time data의 형식을 변경해준 것이다.

 

input_feature = pd.merge(price_data, sentiment_score, how = "left", on = "Date")
n = 6
input_feature["Future_trend"] = input_feature.Close - input_feature.Close.shift(n)
input_feature.Future_trend[0:(input_feature.shape[0]-n)] = input_feature.Future_trend[n:(input_feature.shape[0])]
input_feature = input_feature.iloc[0:(input_feature.shape[0]-n)]
input_feature.Future_trend = input_feature.Future_trend.apply(lambda x: 1 if x>0 else 0)
input_feature = input_feature[['Open', 'High', 'Low', 'Close', 'Volume', 'Score', 'Future_trend']]

input data로 merge 함수를 이용하여 주가 데이터와 score 데이터를 결합한다.

how를 left로 하면 1번째 자리에 있는 데이터를 기준으로 합치는 것이다. 즉, price_data에 있는 Date가 기준이고 기준에 맞지 않는 그 외의 데이터는 제거가된다. on 파라미터를 이용해 기준을 정해준다.

그리고 n의 의미는 이 논문에서 next n days의 trend를 예측을 하는 것이다.

즉, 오늘의 데이터를 가지고 n day 뒤에 오늘보다 주가가 올랐는지 내렸는지를 예측하는 것이다.

그러므로 shift를 이용해 차이를 구해준 후, 오늘의 데이터, n day 후의 trend가 하나의 행으로 구성될 수 있도록 데이터를 만들어준다.

shift를 이용함으로써 필요없어진 데이터는 삭제를 해준다.

그리고 증가했으면 1, 감소했으면 0으로 Future_trend를 구성해준다.

Date 열을 제외하고 필요한 열을 뽑아준다.

 

columns = input_feature.columns
scaler = MinMaxScaler()
input_feature = scaler.fit_transform(input_feature)
input_feature = pd.DataFrame(input_feature)
input_feature.columns = columns

분석을 할 때 정규화는 필수라고 생각한다. 그래서 나는 MinMaxScaler를 이용해 정규화를 해주었다.

fit_transform을 이용해 정규화가 가능하다. 정규화 후에는 형식이 변경이 되고 열이름이 삭제가된다. 그러므로 data frame으로 형식 변경 후 미리 받아두었던 열 이름을 다시 넣어준다.

 

train, test = train_test_split(input_feature, test_size=0.2, shuffle=False)
# train, test = train_test_split(input_feature, test_size=0.2, random_state=123) XXXXX
X_train = train[['Open', 'High', 'Low', 'Close', 'Volume', 'Score']]
Y_train = train[['Future_trend']]
X_test = test[['Open', 'High', 'Low', 'Close', 'Volume', 'Score']]
Y_test = test[['Future_trend']]

이전 논문 리뷰에서 train_test_split에 대해서 이야기했었다. 

직접 손으로 분류할 수도 있지만 만들어진 함수가 있으니 사용을 하는 것이 코드가 깔끔해보인다.

여기서 내가 실수를 해서 코드를 다시 전부 돌렸던 적이 있다.

바로 random_state를 지정해주었던 것이다. 기본값이 shuffle=Ture이고 이것은 무작위로 데이터를 섞어버린다.

시계열 데이터에서는 이는 굉장히 좋지 못하다.

그러므로 shuffle=False로 지정해주어 순서대로 8:2로 분류할 수 있도록 코드를 구성해준다.

그리고 Y data를 Future_trend로 지정하여 X, Y data를 구성한다.

 

데이터 구성은 끝났고, 이제는 총 5가지의 모델을 적용할 것이다.

RF_model = RF(min_samples_leaf = 1, max_depth = 2, random_state = 0, criterion = "gini")
RF_model.fit(X_train, Y_train)
sum(RF_model.predict(X_test) == Y_test.Future_trend)/Y_test.shape[0]

첫 번째는 Random Forest이다

파라미터는 논문에 나온 그대로 사용하였다.

min_samples_leaf : internal node를 분류하는데 필요한 최소 샘플 수이다. 즉, 2개만 있어도 분류하는 것이다. default는 2이다.

max_depth : tree의 최대 깊이이다. 깊이가 2로 깊진 않다.

random_state : 부트스트랩 과정에서 무작위성을 제어한다.

criterion : tree의 impurity가 낮은 방향으로 tree를 구축해나가는데 이 impurity를 측정할 지표이다. default는 gini이다.

마지막 줄은 accuracy를 측정한 것이다. 함수가 만들어져있지만 직접 손으로 구현을 하였다.

 

SVC_model = SVC(random_state = 42, class_weight = "balanced")
SVC_model.fit(X_train, Y_train)
sum(SVC_model.predict(X_test) == Y_test.Future_trend)/Y_test.shape[0]

두 번째는 LinearSVC이다.

class_weight : 가중치를 지정해주는 것이다. balanced로 함으로써 데이터를 고려해 균형잡히게 가중치를 부여한다.

 

NB_model = NB().fit(X_train, Y_train)
sum(NB_model.predict(X_test) == Y_test.Future_trend)/Y_test.shape[0]

세 번째는 GaussianNB이다.

연속형 변수에 대한 나이브 베이즈 분류기이다.

 

train, validation = train_test_split(train, test_size=0.2,shuffle=False)

X_train = train[['Open', 'High', 'Low', 'Close', 'Volume', 'Score']]
Y_train = train[['Future_trend']]

X_validation = validation[['Open', 'High', 'Low', 'Close', 'Volume', 'Score']]
Y_validation = validation[['Future_trend']]
X_test = test[['Open', 'High', 'Low', 'Close', 'Volume', 'Score']]
Y_test = test[['Future_trend']]

ohe = OneHotEncoder(sparse=False)
ohe.fit(Y_train)
Y_train = ohe.transform(Y_train)
Y_validation = ohe.transform(Y_validation)
Y_test = ohe.transform(Y_test)

X_train = X_train.to_numpy().reshape(X_train.shape[0],1,X_train.shape[1])
X_validation = X_validation.to_numpy().reshape(X_validation.shape[0],1,X_validation.shape[1])
X_test = X_test.to_numpy().reshape(X_test.shape[0],1,X_test.shape[1])

남은 두 모델은 딥러닝 모형이다. 그래서 validation을 나누고 데이터 구조를 변경하였다.

OneHotEncoder을 이용해 Y data를 만들어주고 reshape을 이용해 데이터 구조도 변경해준다.

 

lr = 0.001
param_grid={'batch_size' :[8, 16,24],
            'optimizer' : [Adam(lr=lr), SGD(lr=lr), RMSprop(lr=lr)]
           }

def create_lstm(optimizer):
    lstm_model = Sequential()
    lstm_model.add(LSTM(30, input_shape = (1,X_train.shape[2]),return_sequences=True))
    lstm_model.add(Dropout(0.5))
    lstm_model.add(LSTM(256))
    lstm_model.add(Dense(2, activation = "softmax"))
    lstm_model.compile(loss='binary_crossentropy', optimizer=optimizer, metrics=['accuracy'])
    print("{}".format(optimizer))
    return(lstm_model)

lstm_model = KerasClassifier(build_fn=create_lstm)

GridLSTM = GridSearchCV(estimator=lstm_model,
                     param_grid=param_grid,
                     cv=3)
filename ='copy_2_data/lstm_model_{}_Dow_Jones.h5'.format(n)
checkpoint = ModelCheckpoint(filename,             # file명을 지정합니다
                             monitor='val_loss',   # val_loss 값이 개선되었을때 호출됩니다
                             verbose=1,            # 로그를 출력합니다
                             save_best_only=True,  # 가장 best 값만 저장합니다
                             mode='auto'           # auto는 알아서 best를 찾습니다. min/max
                            )
reduce_lr = ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=20, min_lr=0.0001)
GridLSTM.fit(X_train, Y_train, epochs=100, callbacks=[reduce_lr, checkpoint], validation_data=(X_validation,Y_validation), verbose=0)

네 번째는 딥러닝 모형 중 LSTM이다.

create_lstm이란 함수를 생성하고 KerasClassifier 함수를 이용해 LSTM 모델을 만들어준다. 

그리고 GridSearchCV를 이용해 최적의 파라미터를 찾는다. estimator에 model을 넣고 param_grid에 여러 파라미터들을 지정해준다. 여기서 지정해준 파라미터 중 최적의 결과를 내는 파라미터를 찾아준다. 그리고 cv 파라미터를 이용해 교차검증까지 해준다.

이후 fit 함수를 이용하는데 여기서 콜백함수로 ModelCheckpoint와 ReduceLROnPlateau을 사용하였다.

ModelCheckpoint는 좋은 결과값을 가지는 순간 filename으로 모델을 저장해준다.

monitor : 좋은 결과값을 가진다고 판단하는 기준이다.

verbose : 1이면 결과를 출력, 0이면 출력하지 않는다. 이 때 결과는 best model을 찾아서 저장했는지 못찾았는지 이다.

save_best_only : True일 경우 가장 좋은 값을 가질 때 저장을 한다. False이면 매번 에폭마다 저장을 한다.

mode : 때론 값이 max일 때 좋고, 때론 값이 min일 때 좋은데 그 판단을 auto로 지정하면 자동으로 해준다.

그리고 학습률을 변환해 모델을 개선시키는 ReduceLROnPlateau도 콜백함수로 사용한다.

monitor : 결과값의 기준이다.

factor : 결과가 안좋을 때 학습률을 개선하는 정도이다. 0.5로 지정했으니 학습률은 0.5 배율로 줄어든다.

patience : 결과가 안좋다고 판단하는 기준이다. 20으로 지정했으니 20번 에폭을 진행했음에도 결과의 개선이 없다면 학습률을 조정한다.

min_lr : 계속 결과가 안좋으면 계속 학습률을 조정하고 factor가 1보다 작으니 학습률은 계속 낮아진다. 이때 학습률의 최소를 지정해주는 것이다.

 

lr = 0.001
param_grid={'batch_size' :[8, 16,24],
            'optimizer' : [Adam(lr=lr), SGD(lr=lr), RMSprop(lr=lr)]
           }

def create_cnn(optimizer):
    cnn_model = Sequential()
    cnn_model.add(Conv1D(filters = 64, kernel_size = 3, input_shape = (1,X_train.shape[2]), padding="same"))
    cnn_model.add(BatchNormalization())
    cnn_model.add(Activation('relu'))
    cnn_model.add(Conv1D(filters = 64, kernel_size = 3, padding="same"))
    cnn_model.add(BatchNormalization())
    cnn_model.add(Activation('relu'))
    cnn_model.add(Conv1D(filters = 64, kernel_size = 3, padding="same"))
    cnn_model.add(BatchNormalization())
    cnn_model.add(Activation('relu'))
    cnn_model.add(GlobalAveragePooling1D())
    cnn_model.add(Dense(32))
    cnn_model.add(Activation('relu'))
    cnn_model.add(Dropout(0.5))
    cnn_model.add(Dense(2, activation = "softmax"))
    cnn_model.compile(loss='binary_crossentropy', optimizer=optimizer, metrics=['accuracy'])
    return(cnn_model)

cnn_model = KerasClassifier(build_fn=create_cnn)

Gridcnn = GridSearchCV(estimator=cnn_model,
                     param_grid=param_grid,
                     cv=3)
filename ='copy_2_data/cnn_model_{}_Dow_Jones.h5'.format(n)
checkpoint = ModelCheckpoint(filename,             # file명을 지정합니다
                             monitor='val_loss',   # val_loss 값이 개선되었을때 호출됩니다
                             verbose=1,            # 로그를 출력합니다
                             save_best_only=True,  # 가장 best 값만 저장합니다
                             mode='auto'           # auto는 알아서 best를 찾습니다. min/max
                            )
reduce_lr = ReduceLROnPlateau(monitor='val_accuracy', factor=0.5, patience=20, min_lr=0.0001)
Gridcnn.fit(X_train, Y_train, epochs=100, callbacks=[reduce_lr, checkpoint], validation_data=(X_validation,Y_validation), verbose=0)

다섯 번째는 CNN-1D 모델이다.

이 논문에서 가장 좋다고 주장하는 모델이다.

CNN 모델을 지정하는 함수만 다르고 LSTM을 적용했던 코드와 동일하다. CNN 모델의 파라미터는 논문에 나온 그대로 사용했으므로 따로 설명하지는 않겠다.

 

이렇게  NBC 뉴스를 가지고 모델을 적용해본 결과 논문과는 달리 딱히 눈에 띄게 좋은 결과를 얻지는 못했다.

이 점을 교수님과 상담해보았는데 교수님께서는 NBC 뉴스가 아닌 논문에서 나온 것과 같이 트위터를 사용해보라고 조언을 해주셔서 위 코드들을 사용하여 트위터 데이터로 다시 결과를 내볼 생각이다.

그리고 이 논문에서는 Candlestick chart도 같이 이용해 데이터를 분석하였는데 이 부분은 2편에서 다룰 것이다.

 

위 코드들의 전체 코드는 추후 github에 게시할 예정이다.