실패는 성공으로 가는 과정일 뿐이다. 중요한 건 실패 후에 어떻게 일어서느냐이다.
-젠슨 황(NVIDIA CEO)-
젠슨 황은 자신의 장점 중 하나로 낮은 기대치를 꼽습니다. 낮은 기대치만큼 회복 탄력성이 상승하기 때문입니다. 반대로 기대치가 높은 사람은 회복 탄력성도 낮은 편입니다. AI혁신의 중심에 선 그가 이처럼 회복 탄력성을 중요시하는 이유는 무엇일까요? 그는 “진정한 혁신은 위험을 감수하고 기꺼이 실패하는 것”이라고 말합니다. 실패하더라도 포기하지 않고, 높은 회복탄력성으로 다시 질문에 답을 찾으라고 말입니다.
본 리포트에서는 구조화된 데이터에서 현재 가장 뛰어난 성능을 발휘하는 XGBoost 모델을 사용하여 KOSPI200 예측 모델을 구축하고 검증합니다. KOSPI200과 유의미한 관계를 가지는 다양한 특징(Features)들을 선정하고 시계열 데이터를 적절하게 가공할 것입니다.(혹시 상관성이 떨어지더라도 XGBoost가 자동으로 제외시켜 줄 겁니다!) 총 27개의 범주에서 95개의 특징을 생성할 것이고 이를 통해 익월 KOSPI 수익률의 수준을 예측(Bull/Bear)하는 것을 목적으로 합니다. 젠슨 황의 조언대로 모델의 성능이 형편 없을 거지만 또 이것이 다음 스테이지를 위한 초석이 될 것입니다. 먼저 특징 공학(Feature Engineering)부터 시작하겠습니다.
library(purrr)
package <- c("tidyverse", "quantmod", "readxl", "fastDummies", "httr", "jsonlite",
"PerformanceAnalytics", "caret", "xgboost", "doParallel", "irlba")
pack_map <- map(package, ~ library(.x, character.only = TRUE))
options(scipen = 999)quantmod::getSymbols() 및
한국은행 OpenAPI를 통해 수집불가능한 데이터들은
불가피하게 별도의 엑셀파일(Feature_Engineering.xlsx)
안에 수기 입력했습니다.## KOSPI200 EMA 함수 정의
Func_KS200_EMA <- function(EMA_n = 20, OHLC = "Ad") {
if (OHLC == "Ad") {xts_data = Ad(KS200)}
else if (OHLC == "Vo") {xts_data = Vo(KS200)}
else stop("OHLC must be 'Ad' or 'Vo'")
EMA <- EMA(xts_data, n = EMA_n) |>
apply.monthly(last) |>
na.locf(fromLast = TRUE)
EMA_tbl <- tibble(
YEAR = year(index(EMA)),
MONTH = month(index(EMA)),
EMA = as.vector(coredata(EMA))
)
return(EMA_tbl)
}
## RSI_EMA 함수 정의
Func_RSI_EMA <- function(xts_data, RSI_n = 20, EMA_n = 20, U = 70, L = 30) {
RSI_EMA <- RSI(xts_data, n = RSI_n) |>
EMA(n = EMA_n) |>
apply.monthly(last) |>
na.locf(fromLast = TRUE)
RSI_EMA_F <- case_when(RSI_EMA > 70 ~ 2, # OverBought
RSI_EMA < 30 ~ 1, # OverSold
is.na(RSI_EMA) ~ NA,
.default = 0) # Neutral
RSI_EMA_tbl <- tibble(
YEAR = year(index(RSI_EMA)),
MONTH = month(index(RSI_EMA)),
EMA = as.vector(coredata(RSI_EMA)),
EMA_F = as.factor(as.vector(coredata(RSI_EMA_F)))
)
return(RSI_EMA_tbl)
}
## 2.2.1 ~ 4 KS200_R_EMA_20 ~ 252
KS200 <- getSymbols("^KS200", auto.assign = FALSE, to = "2025-06-30") |>
na.omit()
EMA_period <- c(20, 60, 126, 252)
KS200_R_EMA_list <- lapply(EMA_period, Func_KS200_EMA)
KS200_R_EMA <- purrr::reduce(KS200_R_EMA_list, .f = left_join, by = c("YEAR", "MONTH")) |>
`colnames<-`(c("YEAR", "MONTH", "KS200_R_EMA_20", "KS200_R_EMA_60", "KS200_R_EMA_126", "KS200_R_EMA_252"))
## 2.2.5 KS200_VOL_EMA_20
KS200_VOL_EMA_20 <- Func_KS200_EMA(OHLC = "Vo") |>
`colnames<-`(c("YEAR", "MONTH", "KS200_VOL_EMA_20"))
## 2.2.6 ~ 7 KS200_RSI_20_EMA_20
KS200_RSI_20_EMA_20 <- Func_RSI_EMA(Ad(KS200)) |>
`colnames<-`(c("YEAR", "MONTH", "KS200_RSI_20_EMA_20", "KS200_RSI_20_EMA_20_F"))
## 2.2.8 KS200_MACD_HIST_EMA_20_F
MACD <- MACD(Ad(KS200))
MACD_hist <- EMA(MACD$signal - MACD$macd, n = 20) |>
apply.monthly(last) |>
na.locf(fromLast = TRUE)
MACD_HIST_F <- as.factor(if_else(MACD_hist > 0, 1, 0))
KS200_MACD_HIST_EMA_20_F <- tibble(
YEAR = year(index(MACD_hist)),
MONTH = month(index(MACD_hist)),
KS200_MACD_HIST_EMA_20_F = MACD_HIST_F
)CBOE VIX처럼 공포지수로 코스피200 옵션 가격을 기반으로 향후 30일간의 증시 변동성에 대한 투자자들의 기대를 측정합니다.
## EMA 임베딩 함수 정의의
Func_EMA_Embed <- function(tbl_data, EMA_n = 20, n_lag = 3) {
tbl_data_xts <- as.xts(tbl_data[[2]], order.by = tbl_data[[1]])
EMA_xts <- EMA(tbl_data_xts, n = EMA_n) |>
apply.monthly(last) |>
na.locf(fromLast = TRUE)
EMA_tbl <- as_tibble(EMA_xts)
Embed_tbl1 <- tibble(
YEAR = year(index(EMA_xts)[-c(1:n_lag-1)]),
MONTH = month(index(EMA_xts)[-c(1:n_lag-1)])
)
Embed_tbl2 <- as_tibble(embed(EMA_tbl$EMA, dimension = n_lag))
Embed_tbl3 <- bind_cols(Embed_tbl1, Embed_tbl2)
return(Embed_tbl3)
}
VKOSPI <- readxl::read_excel(path = "Feature_Engineering.xlsx", sheet = "KRX_V_KOSPI")
## 2.3.1 ~ 2 KS200_MACD_HIST_EMA_20
V_KOSPI_EMA_20 <- Func_EMA_Embed(VKOSPI) |>
`colnames<-`(c("YEAR", "MONTH", "VKOSPI_EMA_20_Lag_0", "VKOSPI_EMA_20_Lag_1", "VKOSPI_EMA_20_Lag_2"))PHLX Semiconductor Sector는 미국에 상장된 주요 반도체 기업 30곳의 시가총액 가중지수로 글로벌 반도체 산업의 경기 사이클을 확인할 수 있습니다.
WTI 선물로 글로벌 원유 시장의 대표적인 벤치마크입니다.
주요 6개 통화(EUR, JPY, GBP, CAD, CHF, SEK)의 가중 평균 환율로 USD의 상대적인 가치를 나타내는 지표입니다.
대표적인 안전자산 통화로 위험선호 및 회피 심리를 판단할 수 있습니다.
한국 수출국 1위인 중국의 경제 및 정책 방향, 외환시장 안정성 등을 반영합니다.
CDS(Credit Default Swap) Premium을 통해 한국 신용위험 수준을 파악할 수 있습니다.
미결제약정(Open Interest)은 특정 시점에 청산되지 않고 남아있는 계약 수로 증가하면 새로운 자금 유입 가능성을 의미하며 추세 지속 신호를 나타냅니다. 반대로 감소하면 추세 악화 및 전환 가능성을 의미합니다.
OI <- readxl::read_excel(path = "Feature_Engineering.xlsx", sheet = "KRX_OI")
## 2.10.1 ~ 6 OPTION_OI_EMA_20/FUTURE_OI_EMA_20
OPTION_OI_EMA_20 <- Func_EMA_Embed(OI[-2]) |>
`colnames<-`(c("YEAR", "MONTH", "OPTION_OI_EMA_20_Lag_0", "OPTION_OI_EMA_20_Lag_1", "OPTION_OI_EMA_20_Lag_2"))
FUTURE_OI_EMA_20 <- Func_EMA_Embed(OI[-3]) |>
`colnames<-`(c("YEAR", "MONTH", "FUTURE_OI_EMA_20_Lag_0", "FUTURE_OI_EMA_20_Lag_1", "FUTURE_OI_EMA_20_Lag_2"))ETF <- readxl::read_excel(path = "Feature_Engineering.xlsx", sheet = "KRX_ETF_Amt")
## 2.11.1 ~ 3 ETF_TotAmt_EMA_20_log
ETF_TotAmt_EMA_20_Log <- ETF |>
mutate(ETF_Amt = log(ETF_Amt)) |>
Func_EMA_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"ETF_TotAmt_EMA_20_Log_Lag_0",
"ETF_TotAmt_EMA_20_Log_Lag_1",
"ETF_TotAmt_EMA_20_Log_Lag_2"))# 기본 임베딩 함수 정의
Func_Embed <- function(tbl_data, n_dim = 3, n_lag = 0) {
Embed_tbl1 <- tibble(
YEAR = year(tbl_data$Date + months(n_lag)),
MONTH = month(tbl_data$Date + months(n_lag))
) |>
filter(YEAR > 2006)
Embed_tbl2 <- as_tibble(embed(tbl_data[[2]], dimension = n_dim))
Embed_tbl3 <- bind_cols(Embed_tbl1, Embed_tbl2)
return(Embed_tbl3)
}
## 2.12.1 ~ 3 FOREIGN_RATIO
FOREIGN_RATIO <- readxl::read_excel(path = "Feature_Engineering.xlsx", sheet = "KRX_Monthly_Data") |>
select(Date, FOREIGN_RATIO) |>
Func_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"FOREIGN_RATIO_Lag_0",
"FOREIGN_RATIO_Lag_1",
"FOREIGN_RATIO_Lag_2"))## BOK API KEY
BOK_api <- "N3UTV277ZVHGVL02Z352"
## BOK API 함수 정의
Func_BOK_API <- function(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, BOK_code3 = "?", TRANS = "N") {
# BOK_code <- "403Y001"
# BOK_freq <- "M"
# BOK_start <- "200510"
# BOK_end <- "202505"
# BOK_code2 <- "*AA"
# BOK_code3 = "?"
# TRANS <- "YOY"
BOK_interval <- time_length(interval(ym(BOK_start), ym(BOK_end)), unit = "months") + 1
BOK_url <- paste("https://ecos.bok.or.kr/api/StatisticSearch",
BOK_api,
"json/kr/1",
BOK_interval,
BOK_code,
BOK_freq,
BOK_start,
BOK_end,
BOK_code2,
BOK_code3,
sep = "/") |>
GET() |>
content(as = "text", encoding = "UTF-8") |>
fromJSON()
BOK_res <- BOK_url$StatisticSearch$row |>
mutate(
DATA_VALUE = as.numeric(DATA_VALUE)
) |>
mutate(
DATA_VALUE = case_when(
TRANS == "YOY" ~ ((DATA_VALUE - lag(DATA_VALUE, n = 12, default = 0)) /
lag(DATA_VALUE, n = 12, default = 0)),
TRANS == "MOM" ~ ((DATA_VALUE - lag(DATA_VALUE, n = 1, default = 0)) /
lag(DATA_VALUE, n = 1, default = 0)),
.default = DATA_VALUE
)
) |>
select(TIME, DATA_VALUE) |>
filter(DATA_VALUE != Inf)
return(BOK_res)
}
## BOK 임베딩 함수 정의
Func_BOK_Embed <- function(tbl_data, n_dim = 3, n_lag = 0) {
Embed_tbl1 <- tibble(
YEAR = year(ym(tbl_data$TIME) + months(n_lag)),
MONTH = month(ym(tbl_data$TIME) + months(n_lag))
) |>
filter(YEAR > 2006)
Embed_tbl2 <- as_tibble(embed(tbl_data[[2]], dimension = n_dim))
Embed_tbl3 <- bind_cols(Embed_tbl1, Embed_tbl2)
return(Embed_tbl3)
}
## BOK API 설정
BOK_code <- "901Y062"
BOK_freq <- "M"
BOK_start <- "200611"
BOK_end <- "202506"
BOK_code2 <- "P63AD"
## 2.15.1 ~ 3 KB_HouseSalePrice
KB_HouseSalePrice <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2) |>
Func_BOK_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"KB_HouseSalePrice_Lag_0",
"KB_HouseSalePrice_Lag_1",
"KB_HouseSalePrice_Lag_2"))## BOK API 설정
BOK_code <- "901Y067"
BOK_freq <- "M"
BOK_start <- "200610"
BOK_end <- "202505"
BOK_code2 <- "I16E"
## 2.16.1 ~ 3 LCI
LCI <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2) |>
Func_BOK_Embed(n_lag = 1) |>
`colnames<-`(c("YEAR",
"MONTH",
"LCI_Lag_1",
"LCI_Lag_2",
"LCI_Lag_3"))## BOK API 설정
BOK_code <- "403Y001"
BOK_freq <- "M"
BOK_start <- "200510"
BOK_end <- "202505"
BOK_code2 <- "*AA"
## 2.17.1 ~ 3 ExAmtIdx_YOY
ExAmtIdx_YOY <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, TRANS = "YOY") |>
Func_BOK_Embed(n_lag = 1) |>
`colnames<-`(c("YEAR",
"MONTH",
"ExAmtIdx_YOY_Lag_1",
"ExAmtIdx_YOY_Lag_2",
"ExAmtIdx_YOY_Lag_3"))## BOK API 설정
BOK_code <- "901Y009"
BOK_freq <- "M"
BOK_start <- "200511"
BOK_end <- "202506"
BOK_code2 <- "0"
## 2.18.1 ~ 3 CPI_YOY
CPI_YOY <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, TRANS = "YOY") |>
Func_BOK_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"CPI_YOY_Lag_0",
"CPI_YOY_Lag_1",
"CPI_YOY_Lag_2"))## 2.19.1 ~ 3 SemiAmt_YOY
SEMI_ExAmt <- readxl::read_excel(path = "Feature_Engineering.xlsx", sheet = "CUSTOMS_SEMI_ExAmt") |>
select(Date, SEMI_ExAmt) |>
mutate(
YOY = ((SEMI_ExAmt - lag(SEMI_ExAmt, n = 12, default = 0)) / lag(SEMI_ExAmt, n = 12, default = 0))) |>
select(Date, YOY) |>
filter(YOY != Inf) |>
Func_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"SemiAmt_YOY_Lag_0",
"SemiAmt_YOY_Lag_1",
"SemiAmt_YOY_Lag_2"))## BOK API 설정
BOK_code <- "901Y056"
BOK_freq <- "M"
BOK_start <- "200610"
BOK_end <- "202506"
BOK_code2 <- "S23A"
## 2.20.1 ~ 3 DEPOSIT_MOM
DEPOSIT_MOM <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, TRANS = "MOM") |>
Func_BOK_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"DEPOSIT_MOM_Lag_0",
"DEPOSIT_MOM_Lag_1",
"DEPOSIT_MOM_Lag_2"))## BOK API 설정
BOK_code <- "901Y056"
BOK_freq <- "M"
BOK_start <- "200610"
BOK_end <- "202506"
BOK_code2 <- "S23C"
## 2.21.1 ~ 3 RP_MOM
RP_MOM <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, TRANS = "MOM") |>
Func_BOK_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"RP_MOM_Lag_0",
"RP_MOM_Lag_1",
"RP_MOM_Lag_2"))## BOK API 설정
BOK_code <- "101Y004"
BOK_freq <- "M"
BOK_start <- "200509"
BOK_end <- "202504"
BOK_code2 <- "BBHA00"
## 2.22.1 ~ 3 M2_YOY
M2_YOY <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, TRANS = "YOY") |>
Func_BOK_Embed(n_lag = 2) |>
`colnames<-`(c("YEAR",
"MONTH",
"M2_YOY_Lag_2",
"M2_YOY_Lag_3",
"M2_YOY_Lag_4"))## BOK API 설정
BOK_code <- "901Y027"
BOK_freq <- "M"
BOK_start <- "200510"
BOK_end <- "202505"
BOK_code2 <- "I61E"
BOK_code3 <- "I28A"
## 2.23.1 ~ 3 EmpRatio_YOY
EmpRatio_YOY <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, BOK_code3, TRANS = "YOY") |>
Func_BOK_Embed(n_lag = 1) |>
`colnames<-`(c("YEAR",
"MONTH",
"EmpRatio_YOY_Lag_1",
"EmpRatio_YOY_Lag_2",
"EmpRatio_YOY_Lag_3"))## BOK API 설정
BOK_code <- "901Y027"
BOK_freq <- "M"
BOK_start <- "200510"
BOK_end <- "202505"
BOK_code2 <- "I61BC"
BOK_code3 <- "I28A"
## 2.24.1 ~ 3 UnEmpRatio_YOY
UnEmpRatio_YOY <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2, BOK_code3, TRANS = "YOY") |>
Func_BOK_Embed(n_lag = 1) |>
`colnames<-`(c("YEAR",
"MONTH",
"UnEmpRatio_YOY_Lag_1",
"UnEmpRatio_YOY_Lag_2",
"UnEmpRatio_YOY_Lag_3"))## BOK API 설정
BOK_code <- "722Y001"
BOK_freq <- "M"
BOK_start <- "200611"
BOK_end <- "202506"
BOK_code2 <- "0101000"
## 2.25.1 ~ 3 BaseRate
BaseRate <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2) |>
Func_BOK_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"BaseRate_Lag_0",
"BaseRate_Lag_1",
"BaseRate_Lag_2"))## BOK API 설정
## 국채10년
BOK_code <- "721Y001"
BOK_freq <- "M"
BOK_start <- "200611"
BOK_end <- "202506"
BOK_code2 <- "5050000"
T_10 <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2)
## 국채3년
BOK_code2 <- "5020000"
T_3 <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2)
## 2.26.1 ~ 3 TB_Sp
TB_Sp <- T_10 |>
mutate(SPREAD = DATA_VALUE - T_3$DATA_VALUE) |>
select(TIME, SPREAD) |>
Func_BOK_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"TB_Sp_Lag_0",
"TB_Sp_Lag_1",
"TB_Sp_Lag_2"))## BOK API 설정
BOK_code <- "721Y001"
BOK_freq <- "M"
BOK_start <- "200611"
BOK_end <- "202506"
BOK_code2 <- "7030000"
CORP_BBB_3 <- Func_BOK_API(BOK_code, BOK_freq, BOK_start, BOK_end, BOK_code2)
## 2.27.1 ~ 3 CORP_Sp
CORP_Sp <- CORP_BBB_3 |>
mutate(SPREAD = DATA_VALUE - T_3$DATA_VALUE) |>
select(TIME, SPREAD) |>
Func_BOK_Embed() |>
`colnames<-`(c("YEAR",
"MONTH",
"CORP_Sp_Lag_0",
"CORP_Sp_Lag_1",
"CORP_Sp_Lag_2"))27개의 특징 범주를 정의했습니다. 이제 특징 테이블을 통합할 수 있습니다!
Feature_Table <-
list(
Feature_Table,
KS200_R_EMA,
KS200_VOL_EMA_20,
KS200_RSI_20_EMA_20,
KS200_MACD_HIST_EMA_20_F,
V_KOSPI_EMA_20,
SOX_RSI_20_EMA_20,
OIL_RSI_20_EMA_20,
UXD_RSI_20_EMA_20,
YEN_RSI_20_EMA_20,
YUAN_RSI_20_EMA_20,
CDS_5Y_EMA_20,
OPTION_OI_EMA_20,
FUTURE_OI_EMA_20,
ETF_TotAmt_EMA_20_Log,
FOREIGN_RATIO,
US_PMI,
CN_PMI,
KB_HouseSalePrice,
LCI,
ExAmtIdx_YOY,
CPI_YOY,
SEMI_ExAmt,
DEPOSIT_MOM,
RP_MOM,
M2_YOY,
EmpRatio_YOY,
UnEmpRatio_YOY,
BaseRate,
TB_Sp,
CORP_Sp
) |>
purrr::reduce(left_join, by = c("YEAR", "MONTH")) |>
na.locf(fromLast = TRUE)샘플 222개, 특징 95개인 테이블이 완성됐습니다.
XGBoost는 단순히 데이터 프레임 형식이 아닌 행렬
형식으로 사용해야 하며 이를 위해
Matrix::sparse.model.matrix()함수를 사용합니다.(하지만
caret::train()를 사용할 경우 내부에서 자동으로 처리되므로
별도의 전처리가 필요하지 않습니다.)
KS200 <- getSymbols("^KS200", auto.assign = FALSE, from = "2007-02-01", to = "2025-07-20") |>
Ad() |>
na.omit() |>
apply.monthly(monthlyReturn, type = "log")
KS200_TARGET <- tibble(
YEAR = year(add_with_rollback(index(KS200), -months(1))),
MONTH = month(add_with_rollback(index(KS200), -months(1))),
TARGET_LABLE = as.factor(if_else(as.vector(coredata(KS200)) > 0, 1, 0))
)
KS200_TARGET_Lable <- KS200_TARGET$TARGET_LABLExgboost::xgboost()를 통해 알고리즘 구현이
가능합니다.KOSPI200_ModelData): 2007.01 ~ 2025.06
caret패키지의 train(),
trainControl()함수는 뛰어난 튜닝 기능을 제공합니다.initialWindow(훈련 윈도우): 60 -> 60기간 데이터를
훈련에 사용horizon(예측 기간): 3 -> 3기간 예측fixedWindow: 훈련 윈도우가 증가하지 않고 매번
60기간으로 고정doParallel::registerDoParallel()함수는 아주 간단하게
모델 훈련의 병렬 처리를 가능하게 해줍니다.(대신 컴퓨터의 코어 개수를
알아야 합니다!)vcd::Kappa()## 튜닝 그리드 생성
grid_xgb <- expand.grid(
max_depth = c(1, 2, 3),
eta = c(0.05, 0.1, 0.3),
gamma = c(0, 1),
colsample_bytree = c(0.6, 0.8),
min_child_weight = 1,
subsample = c(0.5, 0.75, 1),
nrounds = c(50, 100, 150)
)
## 훈련 제어 객체 생성
ctrl <- trainControl(
method = "timeslice",
initialWindow = 60,
horizon = 3,
fixedWindow = T
)
## 랜덤 시드 설정
set.seed(1995)
## 병렬처리를 위한 코어 설정
registerDoParallel(cores = 12)
## 모델 튜닝
m_xgb <- caret::train(TARGET_LABLE ~ .,
data = KOSPI200_Train,
method = "xgbTree",
trControl = ctrl,
tuneGrid = grid_xgb,
metric = "Kappa",
verbosity = 0)## 혼동 행렬
pred <- predict(m_xgb, newdata = KOSPI200_Test)
actual <- KOSPI200_Test$TARGET_LABLE
confusionMatrix(pred, actual)## Confusion Matrix and Statistics
##
## Reference
## Prediction 0 1
## 0 11 15
## 1 17 11
##
## Accuracy : 0.4074
## 95% CI : (0.2757, 0.5497)
## No Information Rate : 0.5185
## P-Value [Acc > NIR] : 0.9618
##
## Kappa : -0.1836
##
## Mcnemar's Test P-Value : 0.8597
##
## Sensitivity : 0.3929
## Specificity : 0.4231
## Pos Pred Value : 0.4231
## Neg Pred Value : 0.3929
## Prevalence : 0.5185
## Detection Rate : 0.2037
## Detection Prevalence : 0.4815
## Balanced Accuracy : 0.4080
##
## 'Positive' Class : 0
##
\(\text{Kappa}: -0.1836\)으로 모델은 굉장히 형편없습니다. kappa통계량이 음수라는 것은 그냥 무작위 보다 못하다는 것으로 그냥 다 0으로 예측한게 더 뛰어나다는 겁니다. 그럼 이렇게 형편없는 모델이 나온 이유는 무엇이고 어떻게 하면 성능이 향상될 수 있는지 알아보도록 하겠습니다.
데이터셋은 27개의 범주에서 95개의 예측변수로 구성되어 있습니다. 머신러닝 세계에서 95개의 예측 변수가 많다고 한다면… 아닙니다! 특히 XGBoost는 이보다 훨씬 많은 예측 변수가 있다고 하더라도 간단히 처리할 수 있으며 관련이 떨어지는 변수들은 자동으로 제외하고 모델링을 실시합니다.
데이터셋의 기간은 2007.01 ~ 2025.06으로 월간 데이터 기준으로 총 222개의 샘플로 구성되어 있습니다. 만약 이를 일일 기준으로 처리한다고 해도 2,664개 데이터 포인트에 불과합니다. 이는 일반적인 머신러닝 데이터셋에 비해 턱없이 부족한 수준입니다. 오버샘플링, SMOTE 등의 기법을 통해 인위적으로 샘플의 수를 증가시킬 수 있지만 각각은 치명적인 단점들이 존재하고 무엇보다 자기상관성이 강한 금융 시계열과는 적합하지 않은 방식입니다.
XGBoost의 주요 하이퍼 파라미터의 그리드인 grid_xgb는 총
7개의 하이퍼 파라미터의 324개의 경우의 수로 구성되어 있습니다. 이보다 더
많은 수를 고려해야 했을까요? 1000개? 2000개? 아쉽지만 그리드
수를 증가시켜도 성능 개선의 여지가 제한적입니다. 간단한 324개의
경우의 수로도 Kappa 통계량이 음수가 나왔기 때문에 빛 좋은 개살구가 될
뿐입니다.
데이터 자체의 문제일 수도 있습니다. 우선 금융 시계열 데이터는 유의미한 신호 대비 노이즈가 너무 많습니다. 주가 수익률 움직임은 대부분 무작위이며 예측 가능한 패턴을 찾기란 너무 힘듭니다. 노이즈를 제거한 stationary 데이터를 사용한다고 해도 예측은 제한적일 것입니다. 어제의 패턴이 오늘과 다르고 내일은 이전과는 완전히 다른 새로운 패턴이 등장할 수 있기 때문입니다. 저명한 퀀트 트레이더들과 금융공학자들이 대부분 실패한 이유가 있을 것입니다.
마지막 지푸라기라도 잡는 심정으로 95개의 예측변수를 20개의
PCA(Principal Components Analysis) 변수로 바꿔서 다시 모델링을
시도해보겠습니다. irlba::prcomp_irlba()함수를
사용합니다.
## PCA 훈련 및 테스트 데이터 생성
ks200_pca <- KOSPI200_Train |>
select(-any_of(starts_with(c("YEAR", "MONTH", "TARGET_LABLE")))) |>
mutate(across(everything(), as.numeric)) |>
prcomp_irlba(n = 20, center = T, scale. = T)
KOSPI200_Train_pca <- bind_cols(KOSPI200_Train[ , c(1:14, 96)], ks200_pca$x)
KOSPI200_Test_num <- KOSPI200_Test |>
select(-any_of(starts_with(c("YEAR", "MONTH", "TARGET_LABLE")))) |>
mutate(across(everything(), as.numeric))
KOSPI200_Test_pca <- bind_cols(KOSPI200_Test[ , c(1:14, 96)], predict(ks200_pca, KOSPI200_Test_num))
## 모델 훈련
m_xgb_pca <- caret::train(TARGET_LABLE ~ .,
data = KOSPI200_Train_pca,
method = "xgbTree",
trControl = ctrl,
tuneGrid = grid_xgb,
metric = "Kappa",
verbosity = 0)## 혼동 행렬
pred <- predict(m_xgb_pca, newdata = KOSPI200_Test_pca)
actual <- KOSPI200_Test$TARGET_LABLE
confusionMatrix(pred, actual)## Confusion Matrix and Statistics
##
## Reference
## Prediction 0 1
## 0 3 1
## 1 25 25
##
## Accuracy : 0.5185
## 95% CI : (0.3784, 0.6566)
## No Information Rate : 0.5185
## P-Value [Acc > NIR] : 0.5547
##
## Kappa : 0.0665
##
## Mcnemar's Test P-Value : 0.000006462
##
## Sensitivity : 0.10714
## Specificity : 0.96154
## Pos Pred Value : 0.75000
## Neg Pred Value : 0.50000
## Prevalence : 0.51852
## Detection Rate : 0.05556
## Detection Prevalence : 0.07407
## Balanced Accuracy : 0.53434
##
## 'Positive' Class : 0
##
\(\text{Kappa}: 0.0665\)으로 모델은 이전 -0.1836보다 훨씬 개선됐습니다!(무려 양수로 전환) 하지만 0.0665라는 수치는 여전히 매우 작은 수치입니다.
이번 분석을 통해 XGBoost를 활용한 예측 모델은 랜덤 모델보다 낮은 성능을 보인다는 점을 확인했습니다. 필자의 머신러닝에 대한 이해와 특징 공학의 전문성 부족이 주요 원인일 수 있지만 동시에 저명한 퀀트 트레이더들이 실패했던 것과 같은 이유일 수도 있습니다.
그렇다면 머신러닝으로는 복잡한 금융 시장의 향방을 예측할 수 없을까요? 필자가 최근 머신러닝과 관련된 공부를 처음 시작하면서 느꼈던 것은 가능성은 충분히 있다는 점입니다. 머신러닝은 아직도 진화 중이며, 특히 시계열 데이터에 특화된 딥러닝 모델(LSTM, Transformer)은 여전히 많은 가능성을 가지고 있습니다. 기꺼이 실패하라는 젠슨 황의 조언대로 실패하더라도 밀고 나아간다면 결국 답을 찾을 수 있을 것입니다.