본문 바로가기
Project/Predict Stock price Project

6. 불균형 데이터 해결하기 (주가 예측 프로젝트)

by inhovation97 2022. 3. 16.

학부 연구생으로서 진행한 주가 예측 프로그램의 일련의 과정을 차례로 포스팅합니다. 

위 그림처럼 어떤 종목이든 10일간의 데이터로

다음날 종가 상승 여부를 예측하는 머신러닝 프로젝트입니다.

 

주식 데이터는 여기저기 예제들도 굉장히 많이 있고,

비교적 얻기 쉬운 빅데이터이기 때문에 여러가지 프로젝트를 하시는 분들은 굉장히 많을 거라고 생각합니다. 

 

하지만 어려운 도메인인만큼 유의미한 모델링을 한 사례는 굉장히 적습니다. 

특히 주식을 잘 모르시는 분들에게는 정말 어려운 데이터죠...

제가 얻은 인사이트를 기록합니다.

주식 데이터 수집 - EDA - 전처리 - 모델링 - 성능 개선

이전 포스팅은 주식 데이터의 특성에 맞는 최적의 스케일링을 선정했습니다.

 

현재 전처리한 주식 데이터는 불균형이 심하기 때문에 모델링이 쉽지 않습니다.

이번 포스팅은 데이터 불균형을 해결하기 위한 여러가지 방법을 시도해봅니다.

제가 사용할 모델은 LGBM입니다.

 

1. 데이터 불균형

2. 베이스 모델 (비교 대상) 지정하기

3. 데이터 불균형 처리하기
방법 1 - Under sampling & Over sampling
방법 2 - 모델 파라미터 이용
방법 3 - 앙상블
방법 4 - loss function ( focal loss )

 

 

 

 

 

1. 데이터 불균형

import matplotlib.pyplot as plt

fig = plt.figure(figsize =(5,5), dpi = 100)
labels = ['label 0', 'label 1']
pd.Series(trainY).value_counts().plot.pie(colors = ['steelblue', 'firebrick'],
                                         startangle=90,
                                         autopct='%1.2f%%',
                                         labels=labels,
                                         title ='stock_data')
plt.ylabel("")
plt.show()
print('       train 데이터 shape:', trainX.shape)
print('\n       test 데이터 shape:', testX.shape)

라벨 1과 0의 불균형이 심한 imbalanced data

제 데이터는 여러가지 기준으로 전처리한 주식 데이터 입니다. 

위 데이터는 불균형의 문제를 안고 있습니다. 

이렇게 데이터가 불균형하면, 모델도 양이 많은 label 0쪽으로 과적합이 되기 마련입니다.

심지어는 testset을 전부 0으로 예측해버리면, accuracy는 93%인 겉으로는 학습 잘 돼 보이는 쓰레기 모델이 됩니다. 

 

따라서  첫 번째로 할 수있는 방법은 평가 지표 auc, f1 score, precison(재현율), recall(정밀도)를 모두 따져가면서 데이터 불균형 문제를 처리해봅니다.

 

def plot_roc_curve(trainY, testY, train_pred, test_pred, train_prob, test_prob):
    from sklearn.metrics import roc_curve, roc_auc_score, f1_score, f1_score, accuracy_score, recall_score, precision_score
    
    fpr, tpr, thresholds = roc_curve(testY, test_prob) # output 3개가 나오는데, 각 threshhold 마다의 fpr, tpr값 인듯
    
    train_f1 = f1_score(trainY, train_pred)
    test_f1 = f1_score(testY, test_pred)
    
    train_recall = recall_score(trainY, train_pred)
    test_recall = recall_score(testY, test_pred)
    
    train_pre = precision_score(trainY, train_pred)
    test_pre = precision_score(testY, test_pred)  
    
    train_acc = accuracy_score(trainY, train_pred)
    test_acc = accuracy_score(testY, test_pred)
    
    plt.plot(fpr, tpr, color='red', label='ROC')
    plt.plot([0, 1], [0, 1], color='green', linestyle='--')
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('test ROC : {}'.format(round(roc_auc_score(testY, test_prob),3)),fontsize=16)
    plt.legend()
    plt.show()
    print('train_f1 score: ',train_f1)
    print('test_f1 score: ',test_f1,'\n')
    
    print('train_recall score: ',train_recall)
    print('test_recall score: ',test_recall,'\n')

    print('train_pre score: ',train_pre)
    print('test_pre score: ',test_pre,'\n')
    
    print('train acc score: ',train_acc)
    print('test acc score: ',test_acc, '\n')

 

def confusion_matrix(testY, test_pred):
    TP, FP, TN, FN = 0
    for (y,pred) in zip(testY, test_pred):
        if y == 1 and pred==1:
            TP+=1
        elif y==0 and pred==1:
            FP+=1
        elif y == 0 and pred==0:
            TN+=1
        elif y==1 and pred==0:
            FN+=1
    
    print('     y_true') 
    print('pred',[TP, TN],'\n    ',[FN,FP])

위 코드를 이용하여, 학습한 모델의 여러 지표들을 비교하여 모델 성능을 높여봅니다.

 

 

 

 

 

2. 베이스 모델 (비교 대상) 지정하기

from lightgbm import LGBMClassifier
import os
import time

# pos 8.2 & lr 0.08하면 더 좋음

start_time=time.time()

model = LGBMClassifier(
                       learning_rate=0.1, 
                       num_iterations = 1000, # n_estimator 랑 같은 것 같음
                       max_depth = 4,
                       n_jobs=-1,
                       boost_from_average=False)

trained_model = model.fit( trainX,trainY, 
          eval_set=[(testX,testY)],
          early_stopping_rounds=25, 
          verbose = 5, 
          eval_metric = 'auc')

train_pred = trained_model.predict(trainX)
train_prob = trained_model.predict_proba(trainX)[:, 1]

test_pred = trained_model.predict(testX)
test_prob = trained_model.predict_proba(testX)[:, 1]

plot_roc_curve(trainY, testY, train_pred, test_pred, train_prob, test_prob)
confusion_matrix(testY, test_pred)
print("---%s seconds ---" % (time.time() - start_time))

베이스 모델

현재 선정한 모델은 grid search로 얻은 적당한 모델입니다.

그나마 thresh hold를 변경하며 체크하는 ROC 커브는 나쁘지 않지만, f1 score같은 다른 지표들은 쓰레기입니다.

혼동 행렬을 보면, 양성이라고 예측하는 값도 고작 10개인데, 

양성인 데이터 13,551개 (13,549 + 2)중에 2개 맞췄습니다.  

 

주식 데이터의 특성 상 많은 데이터 중 신호를 찾기 어려운 점도 있지만, 데이터 불균형의 문제도 큰 것 같습니다.

이제 데이터 불균형을 해소해봅시다.

 

 

 

 

 

 

3. 데이터 불균형 처리하기

 

방법1 - under sampling & over sampling

위 방식은 불균형 데이터에 대해서 샘플링을 통해 양적으로 같게 만들어 주는 방식입니다. 

먼저 undersampling부터 해볼 겁니다. (공식 홈페이지)

<언더 샘플링>

# Undersample imbalanced dataset with NearMiss-1
from imblearn.under_sampling import NearMiss
# define dataset
nearmiss = NearMiss(version=1, n_neighbors=3)
trainX_under, trainY_under = nearmiss.fit_resample(trainX,trainY)
print('nearmiss 적용 전 학습용 피처/레이블 데이터 세트: ', trainX.shape, trainY.shape)
print('nearmiss 적용 후 학습용 피처/레이블 데이터 세트: ', trainX_under.shape, trainY_under.shape)


print('\n nearmiss 적용 전 레이블 값 분포: \n', pd.Series(trainY).value_counts())
print('\n nearmiss 적용 후 레이블 값 분포: \n', pd.Series(trainY_under).value_counts())

undersampling은 구현 함수에 있어서 version이 3가지가 있습니다.

version 1 : 서로 가장 붙어있는 n개의 소수 클래스 데이터로부터 minimum avg distance인 다수 클래스로부터 샘플링

version 2 : 서로 가장 멀리있는 n개의 소수 클래스 데이터로부터 minimum avg distance인 다수 클래스로부터 샘플링

version 3 : 소수 클래스의 각 데이터 포인트마다 가장 가까운 다수 클래스 데이터를 샘플링

 

버전은 nearmiss에서 인자만 바꿔주면 됩니다. 

어떤게 가장 좋을 지 모르니 3가지 전부 해봅니다. 

링크에서 산점도까지 보고 오시면, 이해가 쉬울 겁니다. 

( 51만개 정도 되는 데이터 포인트 전부 거리를 구하여 처리하다 보니까 시간이 생각보다 오래걸립니다. )

 

undersampling 결과

데이터 클래스의 균형이 딱 맞습니다. 

위 데이터를 갖고 베이스 모델을 돌려봅시다.

 

언더 샘플링 결과

사실 under sampling은 데이터 정보 손실이라는 단점이 있어서 큰 기대는 안했습니다. 

안하느니만 못하네요.

version 2는 자꾸 커널이 죽어서 실행이 안되네요.

결과도 아마 비슷할 거 같습니다.

 

<오버 샘플링>

from imblearn.over_sampling import SMOTE
smote = SMOTE(random_state=42)
trainX_over, trainY_over = smote.fit_resample(trainX,trainY)
print('SMOTE 적용 전 학습용 피처/레이블 데이터 세트: ', trainX.shape, trainY.shape)
print('SMOTE 적용 후 학습용 피처/레이블 데이터 세트: ', trainX_over.shape, trainY_over.shape)



print('\n SMOTE 적용 전 레이블 값 분포: \n', pd.Series(trainY).value_counts())
print('\n SMOTE 적용 후 레이블 값 분포: \n', pd.Series(trainY_over).value_counts())

오버 샘플링 SMOTE

유명한 SMOTE를 이용하여 클래스 균형을 맞추었습니다. 

SMOTE 설명 자료

공식 홈페이지 설명까지 하면 글도 난잡해지고 저도 처음해보는 거라 링크로 남깁니다.

 

오버 샘플링 결과

오버 샘플링이 확실히 언더 샘플링의 결과보다는 좋게 나왔습니다. 

ROC curve는 감소했지만, 양성으로 예측하는 개수도 많아졌고 재현율도 높아졌습니다.

f1 score도 0.1 정도로 많이 올랐습니다. 

과적합을 견제하는 람다같은 파라미터도 많이 넣어서 randomized search를 진행하면, 더 좋은 성능을 기대해 볼 수 있을 것 같습니다.

 

 

 

방법2 - 모델 파라미터 이용

from lightgbm import LGBMClassifier
import os
import time

# pos 8.2 & lr 0.08하면 더 좋음

start_time=time.time()

model = LGBMClassifier(scale_pos_weight=8,
                       learning_rate=0.1, 
                       num_iterations = 1000, # n_estimator 랑 같은 것 같음
                       max_depth = 4,
                       n_jobs=-1,
                       boost_from_average=False,
                       objective = 'binary')

trained_model = model.fit( trainX,trainY, 
          eval_set=[(testX,testY)],
          early_stopping_rounds=25, 
          verbose = 5,
          eval_metric = 'auc')

train_pred = trained_model.predict(trainX)
train_prob = trained_model.predict_proba(trainX)[:, 1]

test_pred = trained_model.predict(testX)
test_prob = trained_model.predict_proba(testX)[:, 1]

plot_roc_curve(trainY, testY, train_pred, test_pred, train_prob, test_prob)
confusion_matrix(testY, test_pred)
print("---%s seconds ---" % (time.time() - start_time))

 

LGBM(공식 홈페이지)에는 scale_pos_weight라는 하이퍼 파라미터가 있습니다. 

positive 클래스에 원하는 만큼 가중치를 줄 수 있는 하이퍼 파라미터입니다.

위에서는 samping을 통해 데이터의 양적으로 불균형을 해소 했다면, 이번에는 열등한 클래스에 가중치를 부여하여 질적으로 불균형을 해소해주는 방법입니다.

 

보통은 양성 클래스와 음성 클래스를 완전히 동일하게 두기 위해 초기값을 negative_class/positive_class로 설정하며 조금씩 바꿔갑니다.  이것도 나중에 과적합을 고려하여 randomized나 grid search를 해야하긴 합니다.

 

결과

f1 score가 대략 0.2 정도까지 올라갔습니다!

재현율이 많이 올라갔네요.

여기까지하면, oversampling 데이터로 pos weight 인자를 추가한 모델도 궁금해집니다. 

oversampling 데이터는 이미 양적으로 개수가 같기 때문에 Scale_pos_weight = 1.05로 아주 작게하는 것이 그냥 over sampling의 모델보다 더 나은 결과를 얻었습니다. ( 크게하면 과적합 위험이 너무 큼 )

 

Scale pos weight > Over sampling & Scale pos weight (작게) > over sampling >>> base 모델

 

 

사실 제 데이터가 주식 데이터라 신호와 잡음을 분리하기 힘든 케이스입니다.

아마 True positive로 나온 4497개의 데이터도 cv를 여러개 만들어 검증을 해봐야할 것 같습니다. 

종속 변수와 독립 변수들이 강한 일반적인 데이터들은 이 정도 과정으로도 제 결과보다는 훨씬 나을 것 같습니다. 

 

 

방법3 - 앙상블

위에서 유의미하게 나온 scale_pos_weight 인자를 넣은 모델과 oversample 데이터와 scale_pos_weight 인자를 넣은 두 모델은 각각 다른 패턴을 캐치하여 학습했을 수도 있습니다. 

따라서 위에 잘 나온 두 모델을 각각 앙상블을 하는데, 

 

1. 두 모델에서 testset을 prediction하여 나온 두 확률값을 평균하는 앙상블

2. 두 모델에서 testset을 prediction하여 나온 두 확률값중 max값으로 정하는 앙상블

 

마지막으로 이 두 앙상블을 비교해보겠습니다. 

제 데이터셋에 한해서는 재현율과 정밀도가 너무 작기때문에 max값 앙상블도 괜찮을 것이라 생각했습니다. 

 

앙상블 결과

 

max의 결과가 더 좋게나왔지만 그냥 Scale_pos_weight인자만 이용했던 모델이 가장 좋은 결과를 얻어냈네요.

그래도 재현율에 한해서는 가장 높게 나왔기 때문에 어떻게 좀 더 머리를 굴려볼 수 있을 것 같습니다.

다른 데이터들에서는 어떻게 나올지 모르니 일단은 포스팅하고 인사이트를 공유해봤습니다!

 

최종적으로는 Scale pos weight인자만 설정한 모델이 가장 좋게나왔습니다.

 

 

방법4 - loss function ( focal loss )

마지막 방법은 사실 제일 많이 서칭해보고 기대했던 야심작이었는데 결과가 정말 안좋았어서 아~ 이런 방법도 있구나 하고 번외로 보시면 될 것 같습니다. 

 

GBM모델은 1차 Tree모델에서 오분류했던 데이터들의 잔차를 구해 개선해 나가면서 업데이트하는 방식입니다. 따라서 loss function이 꽤나 중요할 것입니다. 

 

https://techblog-history-younghunjo1.tistory.com/191

컴퓨터 비전 객체 검출 모델에서는 이미지처럼 negative 클래스가 정말 많이 생기는 문제로 발명된게 focal loss입니다.

Cross entropy에 알파, 감마값을 조정하면서 잘 맞추는 class에는 점수를 조금 주고, 잘 틀리는 class에는 높은 페널티를 부여하는 것으로 공부했던 것 같은데... 

저도 논문을 제대로 읽은 것은 아니라서 관심있으시면 링크로 가시면됩니다. 

 

어쨌든 focal loss가 딥러닝에서는 imbalanced data에 아주 많이 쓰이는 loss function인데, 쓰일 수 있을 것 같아 찾아보니 LGBM이나 XGboost에도 쓰이더라구요! 

결과는 좋지 않았지만, 코드를 공유합니다. 

(코드를 참고했던 자료)

※ focal loss를 이용하려면, 커스텀해야하므로 lgbm.train 함수를 이용해야함 (lgbm.train 공식 홈페이지, lgbm.dataset 공식 홈페이지)

 

############### focal loss custom 함수

import numpy as np
from scipy import optimize
from scipy import special

class FocalLoss:

    def __init__(self, gamma, alpha=None):
        self.alpha = alpha
        self.gamma = gamma

    def at(self, y):
        if self.alpha is None:
            return np.ones_like(y)
        return np.where(y, self.alpha, 1 - self.alpha)

    def pt(self, y, p):
        p = np.clip(p, 1e-15, 1 - 1e-15)
        return np.where(y, p, 1 - p)

    def __call__(self, y_true, y_pred):
        at = self.at(y_true)
        pt = self.pt(y_true, y_pred)
        return -at * (1 - pt) ** self.gamma * np.log(pt)

    def grad(self, y_true, y_pred):
        y = 2 * y_true - 1  # {0, 1} -> {-1, 1}
        at = self.at(y_true)
        pt = self.pt(y_true, y_pred)
        g = self.gamma
        return at * y * (1 - pt) ** g * (g * pt * np.log(pt) + pt - 1)

    def hess(self, y_true, y_pred):
        y = 2 * y_true - 1  # {0, 1} -> {-1, 1}
        at = self.at(y_true)
        pt = self.pt(y_true, y_pred)
        g = self.gamma

        u = at * y * (1 - pt) ** g
        du = -at * y * g * (1 - pt) ** (g - 1)
        v = g * pt * np.log(pt) + pt - 1
        dv = g * np.log(pt) + g + 1

        return (du * v + u * dv) * y * (pt * (1 - pt))

    def init_score(self, y_true):
        res = optimize.minimize_scalar(
            lambda p: self(y_true, p).sum(),
            bounds=(0, 1),
            method='bounded'
        )
        p = res.x
        log_odds = np.log(p / (1 - p))
        return log_odds

    def lgb_obj(self, preds, train_data):
        y = train_data.get_label()
        p = special.expit(preds)
        return self.grad(y, p), self.hess(y, p)

    def lgb_eval(self, preds, train_data):
        y = train_data.get_label()
        p = special.expit(preds)
        is_higher_better = False
        return 'focal_loss', self(y, p).mean(), is_higher_better

 

#https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.train.html # <-LGBM.train 공식 홈페이지
#https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.Dataset.html # <- LGBM.Dataset 공식 홈페이지

####################### focal loss로 학습하기

import lightgbm
import numpy as np
import pandas as pd
from scipy import optimize
from scipy import special
from sklearn import metrics
from sklearn import model_selection
import time

start_time=time.time()
fl = FocalLoss(alpha=None, gamma=2)

fit = lightgbm.Dataset(
    trainX, trainY,
    init_score=np.full_like(trainY, fl.init_score(trainY), dtype=float)
)

val = lightgbm.Dataset(
    testX, testY,
    init_score=np.full_like(testY, fl.init_score(trainY), dtype=float),
    reference=fit # trainset 의미
)

model = lightgbm.train(
    params={
            'objective' : 'binary',
            # 'scale_pos_weight': 11,
            'learning_rate': 0.1, 
            'num_iterations' : 1000,
            'max_depth' : 4,
            'n_jobs':-1,
            'boost_from_average':False,
            'metric' : 'auc'},

    train_set=fit,
    valid_sets=(fit, val),
    valid_names=('fit', 'val'),
    early_stopping_rounds=25,
    verbose_eval = 5,
    fobj=fl.lgb_obj,
    feval=fl.lgb_eval
)


train_prob = special.expit(fl.init_score(trainY) + model.predict(trainX))
train_pred = (train_prob >= 0.5).astype('int')

test_prob = special.expit(fl.init_score(trainY) + model.predict(testX))
test_pred = (test_prob >= 0.5).astype('int')

plot_roc_curve(trainY, testY, train_pred, test_pred, train_prob, test_prob)
confusion_matrix(testY, test_pred)
print("---%s seconds ---" % (time.time() - start_time))

focal loss 결과

focal loss를 이용한 모델은 scale pos weight 인자도 먹히지 않았습니다. 

focal loss를 활용한 XGboost의 논문에서는 focal loss의 gamma = 2.0에서도 효과가 가장 좋았다고 합니다.

참고로 focalloss로 학습한 모델의 predict는 확률값이 아니라서 위 코드에 train_prob, test_prob 선언하는 부분을 잘 보셔야합니다.

 

혹시 focal loss때문에 이 글을 보셨거나 이용해보셨으면 공유 부탁드립니다. 

focal loss를 활용한 GBM모델은 거의 외국 자료더라구요.

 

댓글