🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Cross-Validation for Time Series 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 12. 3. · 16 Views

Cross-Validation for Time Series 완벽 가이드

시계열 데이터에서 일반적인 교차 검증을 사용하면 안 되는 이유와 올바른 검증 방법을 알아봅니다. 미래 데이터 누수 문제를 해결하는 다양한 시계열 교차 검증 기법을 초급자도 이해할 수 있도록 설명합니다.


목차

  1. 시계열_교차검증의_필요성
  2. TimeSeriesSplit_기본_사용법
  3. 갭_설정으로_누수_방지
  4. 슬라이딩_윈도우_방식
  5. 블록_교차검증
  6. 중첩_교차검증
  7. 퍼지_교차검증
  8. sklearn_cross_val_score_활용

1. 시계열 교차검증의 필요성

김개발 씨는 주식 가격 예측 모델을 만들었습니다. K-Fold 교차 검증으로 95%의 정확도를 얻어 의기양양하게 실제 서비스에 적용했습니다.

그런데 실제 예측 결과는 처참했습니다. 대체 무엇이 잘못된 걸까요?

시계열 교차 검증은 시간 순서가 있는 데이터에서 모델의 성능을 올바르게 평가하는 방법입니다. 일반적인 K-Fold 교차 검증은 데이터를 무작위로 섞기 때문에 미래 정보가 과거로 누수되는 치명적인 문제가 발생합니다.

시계열 데이터에서는 반드시 시간 순서를 지켜야 합니다.

다음 코드를 살펴봅시다.

import numpy as np
from sklearn.model_selection import KFold, TimeSeriesSplit

# 시계열 데이터 예시 (날짜 순서)
dates = ['2024-01', '2024-02', '2024-03', '2024-04', '2024-05']
values = [100, 120, 115, 130, 125]

# 잘못된 방법: 일반 K-Fold (시간 순서 무시)
kfold = KFold(n_splits=3, shuffle=True)

# 올바른 방법: TimeSeriesSplit (시간 순서 유지)
tscv = TimeSeriesSplit(n_splits=3)

# TimeSeriesSplit 결과 확인
for train_idx, test_idx in tscv.split(values):
    print(f"Train: {train_idx}, Test: {test_idx}")

김개발 씨는 입사 6개월 차 데이터 분석가입니다. 첫 프로젝트로 매출 예측 모델을 개발하라는 업무를 받았습니다.

열심히 공부한 대로 K-Fold 교차 검증을 적용했고, 모델 성능이 무려 95%나 나왔습니다. "드디어 해냈다!" 김개발 씨는 기쁜 마음으로 실서비스에 모델을 배포했습니다.

하지만 일주일 후, 예측 결과를 확인한 김개발 씨의 얼굴은 하얗게 질렸습니다. 실제 정확도는 고작 60%에 불과했던 것입니다.

선배 박시니어 씨가 코드를 살펴보더니 고개를 저었습니다. "시계열 데이터에 일반 K-Fold를 썼군요.

이게 바로 데이터 누수 문제예요." 데이터 누수란 무엇일까요? 쉽게 비유하자면, 마치 시험 문제를 미리 알고 공부하는 것과 같습니다.

학교에서 시험을 치를 때, 선생님이 실수로 답안지를 미리 보여줬다고 가정해봅시다. 당연히 시험 점수는 높겠지만, 실제로 그 내용을 이해한 것은 아닙니다.

일반 K-Fold 교차 검증은 데이터를 무작위로 섞습니다. 2024년 1월 데이터와 2024년 12월 데이터가 뒤섞여서 학습에 사용됩니다.

문제는 12월 데이터에는 이미 미래의 트렌드, 패턴, 이벤트 영향이 반영되어 있다는 점입니다. 모델은 영리하게도 이 미래 정보를 학습해버립니다.

"아, 12월에 이런 패턴이 나타나는구나. 그러면 1월 예측에 이 정보를 써야지." 학습 단계에서는 완벽하게 맞추지만, 실제로 미래를 예측할 때는 그 정보가 없습니다.

바로 이런 문제를 해결하기 위해 시계열 교차 검증이 등장했습니다. 핵심 원칙은 단순합니다.

과거 데이터로만 학습하고, 미래 데이터로 테스트합니다. 시간의 화살은 항상 한 방향으로만 흘러야 합니다.

위의 코드에서 TimeSeriesSplit을 사용하면 첫 번째 분할에서는 1월 데이터로 학습하고 2월로 테스트합니다. 두 번째 분할에서는 1~2월로 학습하고 3월로 테스트합니다.

시간 순서가 절대로 뒤섞이지 않습니다. 실제 현업에서 이 실수는 놀라울 정도로 흔합니다.

특히 주가 예측, 수요 예측, 트래픽 예측 같은 시계열 프로젝트에서 자주 발생합니다. 검증 단계에서 좋은 성능을 보이다가 실서비스에서 처참하게 실패하는 대부분의 이유가 바로 이 데이터 누수 문제입니다.

다시 김개발 씨 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 듣고 TimeSeriesSplit으로 다시 검증해보니 실제 정확도는 62%였습니다.

실서비스 성능과 거의 일치했습니다. "이제 진짜 성능이 보이네요!" 시계열 교차 검증을 사용하면 실서비스 성능을 정확하게 예측할 수 있습니다.

화려한 숫자에 속지 않고, 진짜 모델의 실력을 알 수 있습니다.

실전 팁

💡 - 시간 컬럼이 있는 데이터는 무조건 시계열 교차 검증을 고려하세요

  • 검증 성능과 실서비스 성능 차이가 크다면 데이터 누수를 의심하세요

2. TimeSeriesSplit 기본 사용법

박시니어 씨가 말했습니다. "자, 이제 TimeSeriesSplit을 제대로 배워봅시다." 김개발 씨는 노트를 펼치고 열심히 필기할 준비를 했습니다.

이 도구를 마스터하면 시계열 모델 검증의 기초를 확실히 다질 수 있습니다.

TimeSeriesSplit은 scikit-learn에서 제공하는 시계열 전용 교차 검증 도구입니다. 데이터를 시간 순서대로 나누어 점점 커지는 학습 세트와 고정된 크기의 테스트 세트를 만듭니다.

과거 데이터만으로 학습하고 바로 다음 시점을 예측하는 실제 상황을 시뮬레이션합니다.

다음 코드를 살펴봅시다.

import numpy as np
from sklearn.model_selection import TimeSeriesSplit
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error

# 시계열 데이터 생성
X = np.array([[1], [2], [3], [4], [5], [6], [7], [8], [9], [10]])
y = np.array([10, 20, 30, 40, 50, 60, 70, 80, 90, 100])

# TimeSeriesSplit 설정 (5개의 분할)
tscv = TimeSeriesSplit(n_splits=5)

# 교차 검증 수행
scores = []
for fold, (train_idx, test_idx) in enumerate(tscv.split(X)):
    X_train, X_test = X[train_idx], X[test_idx]
    y_train, y_test = y[train_idx], y[test_idx]

    model = LinearRegression()
    model.fit(X_train, y_train)
    score = mean_squared_error(y_test, model.predict(X_test))
    scores.append(score)
    print(f"Fold {fold+1}: MSE = {score:.2f}")

김개발 씨가 물었습니다. "그런데 TimeSeriesSplit이 정확히 어떻게 동작하는 건가요?" 박시니어 씨는 화이트보드에 그림을 그리기 시작했습니다.

비유를 들어보겠습니다. 당신이 일기 예보관이라고 상상해보세요.

오늘의 날씨를 예측하려면 어제까지의 기상 데이터만 사용할 수 있습니다. 내일 데이터를 미리 볼 수는 없으니까요.

TimeSeriesSplit은 바로 이런 상황을 정확히 재현합니다. 10개의 데이터가 있다고 가정해봅시다.

첫 번째 분할에서는 데이터 15로 학습하고 6으로 테스트합니다. 두 번째 분할에서는 16으로 학습하고 7로 테스트합니다.

학습 세트는 점점 커지고, 항상 바로 다음 시점만 테스트합니다. 이 방식을 Expanding Window 또는 Growing Window라고 부릅니다.

마치 눈덩이가 굴러가며 점점 커지는 것처럼, 학습 데이터가 계속 누적됩니다. 왜 이렇게 해야 할까요?

실제 비즈니스 상황을 생각해보세요. 2024년 1월에 서비스를 시작했다면, 그 시점에는 2024년 1월까지의 데이터만 있습니다.

2월이 되면 12월 데이터가 있고, 3월이 되면 13월 데이터가 있습니다. 모델도 마찬가지입니다.

처음에는 적은 데이터로 시작해서 점점 많은 데이터를 학습하게 됩니다. TimeSeriesSplit은 이 과정을 그대로 시뮬레이션합니다.

코드를 살펴보겠습니다. n_splits=5는 5개의 분할을 만들겠다는 의미입니다.

각 분할에서 train_idxtest_idx가 반환되는데, 이를 통해 학습 데이터와 테스트 데이터를 분리합니다. 반복문 안에서 매번 새로운 모델을 학습하고 성능을 측정합니다.

5개의 MSE 점수가 나오고, 이 점수들의 평균을 내면 모델의 전반적인 성능을 알 수 있습니다. 주의할 점이 있습니다.

초반 분할에서는 학습 데이터가 매우 적습니다. 따라서 성능이 불안정할 수 있습니다.

이런 이유로 충분한 데이터가 쌓인 후부터 검증을 시작하는 것이 좋습니다. 김개발 씨가 고개를 끄덕였습니다.

"아, 그래서 초반 Fold의 성능이 더 나빴군요!" 박시니어 씨가 웃으며 말했습니다. "맞아요.

데이터가 적으면 모델도 제대로 학습하기 어려우니까요." TimeSeriesSplit을 제대로 이해하면 시계열 모델 검증의 기초를 확실히 다질 수 있습니다. 이제 다음 단계로 넘어가봅시다.

실전 팁

💡 - n_splits 값은 데이터 크기에 맞게 조절하세요

  • 초반 분할의 불안정한 성능은 자연스러운 현상입니다

3. 갭 설정으로 누수 방지

김개발 씨가 TimeSeriesSplit을 적용했는데, 여전히 실서비스 성능이 검증 성능보다 낮았습니다. 박시니어 씨가 말했습니다.

"혹시 예측 지연 시간을 고려했나요?" 김개발 씨는 무슨 말인지 이해하지 못했습니다.

Gap은 학습 데이터와 테스트 데이터 사이에 일정한 간격을 두는 것입니다. 실무에서는 데이터 수집과 모델 예측 사이에 시간차가 존재하는 경우가 많습니다.

오늘 수집한 데이터로 내일이 아닌 다음 주를 예측해야 한다면, 그 간격만큼 Gap을 설정해야 합니다.

다음 코드를 살펴봅시다.

from sklearn.model_selection import TimeSeriesSplit

# 30일치 데이터가 있다고 가정
data_size = 30

# Gap 없는 경우: 학습 직후 바로 예측
tscv_no_gap = TimeSeriesSplit(n_splits=3, gap=0)

# Gap 있는 경우: 3일 후를 예측 (데이터 처리 지연)
tscv_with_gap = TimeSeriesSplit(n_splits=3, gap=3)

print("Gap=0 (간격 없음):")
for train, test in tscv_no_gap.split(range(data_size)):
    print(f"  Train: {train[0]}-{train[-1]}, Test: {test[0]}-{test[-1]}")

print("\nGap=3 (3일 간격):")
for train, test in tscv_with_gap.split(range(data_size)):
    print(f"  Train: {train[0]}-{train[-1]}, Test: {test[0]}-{test[-1]}")

이번에는 조금 더 현실적인 이야기를 해보겠습니다. 김개발 씨가 만든 모델은 매출 예측 모델이었습니다.

매일 새벽에 전날 매출 데이터가 수집되고, 이를 바탕으로 3일 후 매출을 예측해야 했습니다. 문제는 여기서 발생했습니다.

TimeSeriesSplit의 기본 설정은 Gap=0입니다. 즉, 어제까지의 데이터로 오늘을 바로 예측합니다.

하지만 실제 시스템에서는 어제 데이터로 3일 후를 예측해야 합니다. 비유를 들어보겠습니다.

마치 택배 배송과 같습니다. 오늘 주문하면 내일 바로 도착하는 것이 아니라, 3일 후에 도착합니다.

그 3일 동안 무슨 일이 일어날지 모릅니다. 기상 악화로 배송이 지연될 수도 있고, 물류센터에 문제가 생길 수도 있습니다.

모델도 마찬가지입니다. 3일의 간격 동안 시장 상황이 변할 수 있습니다.

새로운 경쟁사가 등장하거나, 갑자기 이슈가 터질 수 있습니다. 이런 예측 지연을 고려하지 않으면, 검증 성능은 좋지만 실서비스에서는 나쁜 결과가 나옵니다.

gap=3을 설정하면 어떻게 될까요? 110일 데이터로 학습하고, 1416일을 테스트합니다.

11~13일은 건너뜁니다. 이것이 바로 실제 비즈니스 상황을 정확히 반영한 검증입니다.

코드에서 두 가지 결과를 비교해보세요. Gap이 없을 때는 학습 데이터 바로 다음이 테스트 데이터입니다.

Gap이 3일 때는 학습 데이터와 테스트 데이터 사이에 3일의 간격이 있습니다. 실무에서 Gap을 설정해야 하는 상황은 다양합니다.

데이터 파이프라인에 지연이 있는 경우, 의사 결정에 시간이 필요한 경우, 예측 결과를 바탕으로 조치를 취하는 데 시간이 걸리는 경우 등입니다. 주의할 점도 있습니다.

Gap을 너무 크게 설정하면 테스트 데이터가 부족해집니다. 적절한 Gap 크기는 실제 비즈니스 프로세스를 분석해서 결정해야 합니다.

김개발 씨가 Gap을 3으로 설정하고 다시 검증해보니, 검증 성능이 실서비스 성능과 거의 일치했습니다. "와, 이제야 진짜 성능이 보이네요!" Gap 설정은 작은 디테일처럼 보이지만, 실서비스 성능 예측에 큰 영향을 미칩니다.

반드시 실제 비즈니스 상황을 고려해서 설정하세요.

실전 팁

💡 - 데이터 파이프라인의 지연 시간을 파악해서 Gap을 설정하세요

  • Gap이 너무 크면 테스트 데이터가 부족해지니 주의하세요

4. 슬라이딩 윈도우 방식

김개발 씨가 TimeSeriesSplit으로 검증하다가 문제를 발견했습니다. 학습 데이터가 점점 커지다 보니, 오래된 데이터까지 전부 사용하게 됩니다.

하지만 3년 전 패턴이 지금도 유효할까요? 박시니어 씨가 새로운 방법을 알려주었습니다.

슬라이딩 윈도우(Sliding Window)는 고정된 크기의 학습 기간을 사용하는 방식입니다. Expanding Window와 달리 오래된 데이터는 버리고 최신 데이터만 유지합니다.

트렌드가 빠르게 변하는 도메인에서 특히 유용합니다. 모델이 최신 패턴에 집중할 수 있게 해줍니다.

다음 코드를 살펴봅시다.

import numpy as np

def sliding_window_split(X, window_size, test_size, step=1):
    """슬라이딩 윈도우 교차 검증 생성기"""
    n_samples = len(X)
    indices = np.arange(n_samples)

    splits = []
    start = 0
    while start + window_size + test_size <= n_samples:
        train_end = start + window_size
        test_end = train_end + test_size

        train_idx = indices[start:train_end]
        test_idx = indices[train_end:test_end]
        splits.append((train_idx, test_idx))

        start += step

    return splits

# 사용 예시: 100일 데이터, 30일 학습, 7일 테스트
data = np.arange(100)
splits = sliding_window_split(data, window_size=30, test_size=7, step=7)

for i, (train, test) in enumerate(splits):
    print(f"Split {i+1}: Train {train[0]}-{train[-1]}, Test {test[0]}-{test[-1]}")

박시니어 씨가 화이트보드에 새로운 그림을 그렸습니다. "TimeSeriesSplit의 Expanding Window 방식과 달리, Sliding Window는 학습 데이터 크기가 고정되어 있어요." 쉽게 비유하자면, Expanding Window는 일기장과 같습니다.

어린 시절부터 지금까지 모든 기록을 다 보관합니다. 반면 Sliding Window는 메모장과 같습니다.

최근 30일치 메모만 남기고, 오래된 것은 버립니다. 왜 오래된 데이터를 버려야 할까요?

세상은 빠르게 변합니다. 코로나 이전의 소비 패턴과 이후의 패턴은 완전히 다릅니다.

3년 전 유행했던 상품이 지금도 인기 있을까요? 대부분 아닙니다.

이런 상황에서 오래된 데이터는 오히려 노이즈가 됩니다. 모델이 더 이상 유효하지 않은 패턴을 학습하게 됩니다.

"예전에는 이랬으니까 지금도 이럴 거야"라고 잘못 예측하게 됩니다. 코드를 살펴보겠습니다.

window_size=30은 항상 30일치 데이터만 학습에 사용한다는 의미입니다. test_size=7은 7일을 테스트한다는 뜻입니다.

step=7은 7일씩 윈도우를 이동한다는 의미입니다. 첫 번째 분할에서는 029일로 학습하고 3036일을 테스트합니다.

두 번째 분할에서는 736일로 학습하고 3743일을 테스트합니다. 윈도우가 슬라이딩하듯 이동합니다.

실무에서 Sliding Window가 적합한 경우는 명확합니다. 패션 트렌드처럼 빠르게 변하는 도메인, 신규 서비스처럼 과거 데이터가 의미 없는 경우, 계절성이 강한 데이터에서 특정 시즌만 분석하는 경우 등입니다.

반대로 Expanding Window가 적합한 경우도 있습니다. 물리 법칙처럼 불변하는 패턴, 장기적인 트렌드를 파악해야 하는 경우, 데이터가 많을수록 성능이 좋아지는 모델 등입니다.

김개발 씨가 물었습니다. "그러면 어떤 방식을 선택해야 하나요?" 박시니어 씨가 답했습니다.

"정답은 없어요. 둘 다 시도해보고 실서비스 성능과 더 가까운 쪽을 선택하면 됩니다." 두 방식을 모두 실험해보는 것이 가장 좋습니다.

검증 성능과 실서비스 성능의 차이가 적은 방식이 해당 도메인에 더 적합한 방식입니다.

실전 팁

💡 - 트렌드가 빠르게 변하는 도메인에서는 Sliding Window를 먼저 시도하세요

  • window_size는 비즈니스 사이클을 고려해서 결정하세요

5. 블록 교차검증

박시니어 씨가 더 고급 주제를 꺼냈습니다. "시계열 데이터는 자기상관이 있어서, 인접한 데이터끼리 서로 비슷해요.

이게 교차 검증을 왜곡할 수 있습니다." 김개발 씨는 처음 듣는 개념에 귀를 기울였습니다.

블록 교차 검증(Blocked Cross-Validation)은 인접한 데이터 간의 상관관계로 인한 정보 누수를 방지합니다. 시계열 데이터에서 오늘의 값은 어제의 값과 비슷한 경향이 있습니다.

학습 데이터와 테스트 데이터가 너무 가까우면 이 유사성 때문에 성능이 과대평가됩니다.

다음 코드를 살펴봅시다.

import numpy as np
from sklearn.model_selection import TimeSeriesSplit

class BlockedTimeSeriesSplit:
    """블록 교차 검증: 학습/테스트 사이에 버퍼 존 추가"""

    def __init__(self, n_splits=5, buffer_size=5):
        self.n_splits = n_splits
        self.buffer_size = buffer_size
        self.tscv = TimeSeriesSplit(n_splits=n_splits)

    def split(self, X):
        for train_idx, test_idx in self.tscv.split(X):
            # 학습 데이터 끝에서 buffer_size만큼 제거
            if len(train_idx) > self.buffer_size:
                train_idx = train_idx[:-self.buffer_size]
            yield train_idx, test_idx

# 사용 예시
data = np.arange(100)
blocked_cv = BlockedTimeSeriesSplit(n_splits=4, buffer_size=5)

print("블록 교차 검증 (버퍼 5일):")
for i, (train, test) in enumerate(blocked_cv.split(data)):
    print(f"  Split {i+1}: Train {train[-1]}, Buffer, Test {test[0]}")

자기상관(Autocorrelation)이라는 개념부터 이해해봅시다. 오늘 기온이 25도라면, 내일 기온도 비슷하게 24~26도일 가능성이 높습니다.

갑자기 -10도가 되지는 않습니다. 이처럼 시계열 데이터는 인접한 값끼리 서로 비슷한 경향이 있습니다.

문제는 이 유사성이 교차 검증을 속인다는 점입니다. 비유를 들어보겠습니다.

쌍둥이 형제가 같은 시험을 본다고 가정해봅시다. 형이 먼저 시험을 보고, 동생이 바로 다음에 시험을 봅니다.

형의 답을 외워서 동생이 높은 점수를 받는다면, 그것이 동생의 진짜 실력일까요? 시계열 교차 검증에서도 비슷한 일이 일어납니다.

학습 데이터의 마지막 날과 테스트 데이터의 첫 날이 너무 비슷합니다. 모델이 "어제가 100이었으니 오늘도 100 근처일 거야"라고 쉽게 맞춥니다.

하지만 일주일 후를 예측할 때는 이런 힌트가 없습니다. 버퍼 존은 이 문제를 해결합니다.

학습 데이터와 테스트 데이터 사이에 일정 기간의 데이터를 사용하지 않습니다. 마치 쌍둥이 형제 사이에 다른 학생 5명을 끼워넣는 것과 같습니다.

형의 답을 보고 베끼기 어려워집니다. 코드에서 buffer_size=5는 학습 데이터 마지막 5개를 버린다는 의미입니다.

학습이 끝나는 지점과 테스트가 시작하는 지점 사이에 5일의 간격이 생깁니다. 이 간격 덕분에 모델은 "어제가 이랬으니까"라는 쉬운 힌트 없이, 진짜 패턴을 학습해야 합니다.

검증 성능은 조금 낮아지지만, 실서비스 성능과 더 가까워집니다. 버퍼 크기는 어떻게 정해야 할까요?

자기상관 분석을 통해 데이터의 상관관계가 얼마나 오래 지속되는지 파악할 수 있습니다. 일반적으로 자기상관이 유의미하게 떨어지는 시점까지를 버퍼로 설정합니다.

김개발 씨가 실험해보니, 버퍼 없이 95%였던 성능이 버퍼 5일을 추가하니 80%로 떨어졌습니다. 하지만 실서비스 성능도 78%였습니다.

"버퍼 덕분에 현실적인 성능을 알 수 있게 됐네요!" 블록 교차 검증은 시계열 검증의 정확도를 한 단계 높여줍니다. 특히 자기상관이 강한 금융 데이터, 센서 데이터 등에서 효과적입니다.

실전 팁

💡 - 자기상관 분석(ACF)을 통해 적절한 버퍼 크기를 결정하세요

  • 버퍼가 너무 크면 학습 데이터가 부족해지니 균형을 맞추세요

6. 중첩 교차검증

김개발 씨가 하이퍼파라미터 튜닝을 하면서 새로운 문제에 직면했습니다. 같은 테스트 세트로 여러 번 모델을 평가하다 보니, 어느새 테스트 세트에 과적합되어 버렸습니다.

박시니어 씨가 중첩 교차 검증이라는 해결책을 제시했습니다.

중첩 교차 검증(Nested Cross-Validation)은 모델 선택과 성능 평가를 분리합니다. 외부 루프에서 최종 성능을 평가하고, 내부 루프에서 하이퍼파라미터를 튜닝합니다.

이렇게 하면 테스트 세트에 대한 과적합을 방지하고, 더 정확한 일반화 성능을 추정할 수 있습니다.

다음 코드를 살펴봅시다.

import numpy as np
from sklearn.model_selection import TimeSeriesSplit, GridSearchCV
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error

# 데이터 준비
np.random.seed(42)
X = np.random.randn(200, 5)
y = np.sum(X, axis=1) + np.random.randn(200) * 0.1

# 외부 루프: 최종 성능 평가
outer_cv = TimeSeriesSplit(n_splits=3)
outer_scores = []

for train_idx, test_idx in outer_cv.split(X):
    X_train, X_test = X[train_idx], X[test_idx]
    y_train, y_test = y[train_idx], y[test_idx]

    # 내부 루프: 하이퍼파라미터 튜닝
    inner_cv = TimeSeriesSplit(n_splits=3)
    param_grid = {'n_estimators': [50, 100], 'max_depth': [3, 5]}

    grid_search = GridSearchCV(
        RandomForestRegressor(random_state=42),
        param_grid, cv=inner_cv, scoring='neg_mean_squared_error'
    )
    grid_search.fit(X_train, y_train)

    # 최적 모델로 외부 테스트 세트 평가
    score = mean_squared_error(y_test, grid_search.predict(X_test))
    outer_scores.append(score)

print(f"중첩 CV 평균 MSE: {np.mean(outer_scores):.4f}")

하이퍼파라미터 튜닝의 함정에 대해 이야기해봅시다. 김개발 씨는 열심히 그리드 서치를 돌렸습니다.

learning_rate, max_depth, n_estimators 등 다양한 조합을 시도했습니다. 결국 테스트 세트에서 가장 좋은 성능을 내는 조합을 찾았습니다.

문제는 이 "최적의 조합"이 테스트 세트에 과적합되었다는 점입니다. 수십, 수백 번의 시도 끝에 우연히 테스트 세트에 잘 맞는 조합을 찾은 것입니다.

새로운 데이터에서는 그만큼의 성능이 나오지 않습니다. 비유를 들어보겠습니다.

마치 수능 시험을 보기 전에 수능 기출문제만 반복해서 푸는 것과 같습니다. 기출문제 점수는 올라가지만, 새로운 문제에 대한 진짜 실력은 알 수 없습니다.

중첩 교차 검증은 이 문제를 해결합니다. 두 겹의 루프를 사용합니다.

외부 루프는 최종 성능을 평가하는 역할입니다. 내부 루프는 하이퍼파라미터를 튜닝하는 역할입니다.

외부 루프에서 데이터를 학습용과 테스트용으로 나눕니다. 그 다음 학습 데이터 안에서만 내부 루프를 돌려 하이퍼파라미터를 찾습니다.

외부 테스트 세트는 내부 루프에서 한 번도 사용되지 않습니다. 이렇게 하면 외부 테스트 세트는 정말로 "처음 보는 데이터"가 됩니다.

하이퍼파라미터 튜닝 과정에서 전혀 노출되지 않았기 때문입니다. 따라서 외부 루프의 성능이 진짜 일반화 성능에 가깝습니다.

코드를 살펴보겠습니다. 외부 루프에서 **TimeSeriesSplit(n_splits=3)**으로 3개의 폴드를 만듭니다.

각 폴드에서 다시 내부 **TimeSeriesSplit(n_splits=3)**을 사용해 그리드 서치를 수행합니다. GridSearchCV가 내부 루프를 담당합니다.

학습 데이터 안에서만 다양한 하이퍼파라미터 조합을 시도하고, 최적의 조합을 찾습니다. 그 최적 모델로 외부 테스트 세트를 평가합니다.

주의할 점이 있습니다. 중첩 교차 검증은 계산 비용이 매우 높습니다.

외부 3폴드 x 내부 3폴드 x 파라미터 조합 수만큼 모델을 학습해야 합니다. 시간이 오래 걸릴 수 있으므로, 적절한 폴드 수와 파라미터 범위를 선택해야 합니다.

김개발 씨가 중첩 교차 검증을 적용해보니, 이전보다 검증 성능이 낮아졌습니다. 하지만 실서비스 성능과 거의 일치했습니다.

"이제야 진짜 실력을 알게 됐네요!"

실전 팁

💡 - 중첩 CV는 계산 비용이 높으니, 최종 모델 평가 단계에서만 사용하세요

  • 내부와 외부 모두 TimeSeriesSplit을 사용해야 시계열 특성을 유지합니다

7. 퍼지 교차검증

박시니어 씨가 마지막으로 특별한 방법을 알려주었습니다. "데이터가 적을 때는 어떻게 해야 할까요?

몇 개 안 되는 데이터를 학습과 테스트로 나누면 둘 다 부족해집니다." 김개발 씨는 이 딜레마에 깊이 공감했습니다.

퍼지 교차 검증(Purged Cross-Validation)은 학습과 테스트 데이터 사이의 경계에서 겹치는 영향을 제거합니다. 특히 금융 데이터처럼 라벨 계산에 미래 정보가 포함되는 경우에 필수적입니다.

라벨 계산 기간에 해당하는 데이터를 학습에서 제외하여 정보 누수를 완벽하게 차단합니다.

다음 코드를 살펴봅시다.

import numpy as np

class PurgedTimeSeriesSplit:
    """퍼지 교차 검증: 라벨 계산 기간을 고려한 검증"""

    def __init__(self, n_splits=5, purge_length=5, embargo_length=3):
        self.n_splits = n_splits
        self.purge_length = purge_length  # 라벨 계산에 사용된 미래 기간
        self.embargo_length = embargo_length  # 추가 안전 기간

    def split(self, X):
        n_samples = len(X)
        fold_size = n_samples // (self.n_splits + 1)

        for i in range(self.n_splits):
            test_start = (i + 1) * fold_size
            test_end = test_start + fold_size

            # 학습 데이터에서 테스트와 겹치는 부분 제거 (purge)
            train_end = test_start - self.purge_length
            # 테스트 후 embargo 기간은 다음 학습에서 제외

            if train_end > 0:
                train_idx = np.arange(0, train_end)
                test_idx = np.arange(test_start, min(test_end, n_samples))
                yield train_idx, test_idx

# 사용 예시
data = np.arange(100)
purged_cv = PurgedTimeSeriesSplit(n_splits=3, purge_length=5, embargo_length=3)

print("퍼지 교차 검증:")
for i, (train, test) in enumerate(purged_cv.split(data)):
    print(f"  Split {i+1}: Train 0-{train[-1]}, Purge, Test {test[0]}-{test[-1]}")

조금 복잡한 이야기를 해보겠습니다. 주식 수익률을 예측하는 모델을 만든다고 가정해봅시다.

오늘의 특성(feature)으로 5일 후 수익률을 예측합니다. 여기서 문제가 발생합니다.

오늘의 라벨(5일 후 수익률)을 계산하려면 미래 5일간의 가격이 필요합니다. 만약 학습 데이터가 오늘까지이고, 테스트 데이터가 내일부터라면 어떨까요?

오늘의 라벨 안에 이미 내일~5일 후의 정보가 들어있습니다. 이것이 바로 라벨 누수(Label Leakage)입니다.

라벨을 계산하는 데 사용된 미래 정보가 학습 데이터에 포함되어 있습니다. 모델이 이 미래 정보를 간접적으로 학습하게 됩니다.

비유를 들어보겠습니다. 마치 책의 결말을 알고 있는 상태에서 추리 게임을 하는 것과 같습니다.

"범인은 집사였다"라는 결말을 알면, 앞부분의 복선을 쉽게 찾아냅니다. 하지만 결말을 모르는 새로운 책에서는 그런 통찰이 불가능합니다.

퍼지(Purge)는 이 문제를 해결합니다. 라벨 계산에 사용된 기간만큼 학습 데이터에서 제외합니다.

5일 후 수익률을 예측한다면, 테스트 시작일 기준 5일 전까지의 데이터만 학습에 사용합니다. 엠바고(Embargo)는 추가적인 안전장치입니다.

퍼지 이후에도 혹시 모를 정보 누수를 방지하기 위해 추가 기간을 더 제외합니다. 보수적으로 검증하고 싶을 때 사용합니다.

코드에서 purge_length=5는 라벨 계산에 5일이 사용되었다는 의미입니다. 테스트 시작 5일 전까지만 학습 데이터로 사용합니다.

embargo_length=3은 추가로 3일을 더 제외합니다. 이 기법은 금융 업계에서 필수적으로 사용됩니다.

Marcos Lopez de Prado의 "Advances in Financial Machine Learning"이라는 책에서 자세히 다루고 있습니다. 금융 데이터를 다룬다면 꼭 읽어보시길 권합니다.

김개발 씨는 처음에는 이 개념이 어렵게 느껴졌습니다. 하지만 박시니어 씨의 설명을 듣고 나니 이해가 됐습니다.

"라벨을 만들 때 미래 정보를 쓰면, 그만큼 학습에서 빼야 하는 거군요!" 퍼지 교차 검증은 시계열 검증의 가장 엄격한 형태입니다. 정보 누수를 완벽하게 차단하고 싶다면 이 방법을 사용하세요.

실전 팁

💡 - 라벨 계산에 미래 몇 일이 사용되는지 정확히 파악하세요

  • 금융 데이터에서는 퍼지 교차 검증이 거의 필수입니다

8. sklearn cross val score 활용

김개발 씨가 지금까지 배운 내용을 정리하며 물었습니다. "매번 이렇게 직접 루프를 작성해야 하나요?

더 간단한 방법은 없을까요?" 박시니어 씨가 웃으며 sklearn의 편리한 함수를 알려주었습니다.

cross_val_score는 scikit-learn에서 제공하는 교차 검증 편의 함수입니다. 직접 루프를 작성하지 않아도 됩니다.

TimeSeriesSplit을 cv 파라미터로 전달하면 시계열 교차 검증을 한 줄로 수행할 수 있습니다. 코드가 간결해지고 실수할 가능성도 줄어듭니다.

다음 코드를 살펴봅시다.

import numpy as np
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

# 데이터 준비
np.random.seed(42)
X = np.random.randn(200, 5)
y = np.sum(X, axis=1) + np.random.randn(200) * 0.1

# 파이프라인 구성 (전처리 + 모델)
pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', RandomForestRegressor(n_estimators=100, random_state=42))
])

# 시계열 교차 검증
tscv = TimeSeriesSplit(n_splits=5, gap=2)

# 한 줄로 교차 검증 수행
scores = cross_val_score(
    pipeline, X, y,
    cv=tscv,
    scoring='neg_mean_squared_error'
)

print(f"각 폴드 MSE: {-scores}")
print(f"평균 MSE: {-scores.mean():.4f} (+/- {scores.std():.4f})")

지금까지 직접 루프를 작성해서 교차 검증을 수행했습니다. 매번 train_idx, test_idx를 받아서 데이터를 나누고, 모델을 학습하고, 성능을 측정하는 코드를 반복했습니다.

코드가 길어지고, 실수하기도 쉬웠습니다. scikit-learn의 cross_val_score는 이 모든 과정을 한 줄로 처리합니다.

마치 자동세차장과 같습니다. 차를 넣기만 하면 알아서 세차하고 건조까지 해줍니다.

직접 호스 들고 씻을 필요가 없습니다. 사용법은 간단합니다.

모델(또는 파이프라인), 특성 데이터 X, 라벨 y, 교차 검증 객체 cv, 평가 지표 scoring을 넘겨주면 됩니다. 결과로 각 폴드의 점수 배열이 반환됩니다.

Pipeline을 사용하면 전처리와 모델을 함께 묶을 수 있습니다. 이것은 매우 중요합니다.

전처리(예: 스케일링)도 학습 데이터만 보고 수행해야 합니다. 테스트 데이터의 통계를 미리 보면 안 됩니다.

Pipeline 안에서 StandardScaler가 각 폴드의 학습 데이터만으로 평균과 표준편차를 계산합니다. 테스트 데이터는 학습 데이터의 통계로 변환됩니다.

이렇게 해야 정보 누수가 없습니다. 코드에서 **scoring='neg_mean_squared_error'**를 사용했습니다.

scikit-learn은 높을수록 좋은 점수를 기대하기 때문에 MSE에 음수를 붙입니다. 결과를 볼 때 다시 음수를 붙여 양수로 변환합니다.

gap=2를 설정해서 학습과 테스트 사이에 2개의 데이터 포인트 간격을 두었습니다. 앞서 배운 Gap 개념이 cross_val_score에서도 적용됩니다.

결과로 5개 폴드의 MSE가 출력됩니다. 평균과 표준편차도 계산합니다.

표준편차가 크면 모델 성능이 불안정하다는 의미입니다. 데이터 특성에 따라 성능 변동이 크다는 뜻입니다.

김개발 씨가 감탄했습니다. "와, 코드가 훨씬 깔끔해졌네요!

앞으로는 이렇게 쓰면 되겠어요." 박시니어 씨가 덧붙였습니다. "하지만 처음에는 직접 루프를 작성해보는 것이 좋아요.

동작 원리를 이해해야 응용할 수 있으니까요." cross_val_score는 빠르고 편리합니다. 하지만 커스텀 로직이 필요하면 직접 루프를 작성해야 합니다.

상황에 맞게 선택하세요.

실전 팁

💡 - 전처리는 반드시 Pipeline 안에 포함시켜 정보 누수를 방지하세요

  • 표준편차가 크면 모델 안정성을 점검해보세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Python#TimeSeries#CrossValidation#MachineLearning#DataScience#Data Science

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.