시간 순서대로 놓인 데이터 블록 사이에서 검증 구간을 살펴보는 확대경 모양의 시계열 교차검증 대표 이미지

시계열 교차검증: 랜덤 분할이 미래 정보를 누수하는 이유와 walk-forward 비교

시계열 교차검증은 과거에 이용할 수 있었던 정보만으로 그다음 구간을 예측하는 평가 절차다. 행을 무작위로 나누면 평가 시점보다 뒤의 관측이 학습 집합에 들어가 실제 배포보다 쉬운 문제가 될 수 있다.

2026년 8월 확인한 scikit-learn 공식 문서의 계약과 Node.js로 실행한 통제 합성 실험을 함께 살펴본다. scikit-learn 자체는 로컬에서 실행하지 않았으며, 동일 데이터에서 분할 정책만 바꿨을 때의 MAE와 fold 범위를 비교했다.

20초 핵심 요약

  • 무엇: 랜덤 분할과 시간 순서를 지킨 walk-forward가 시계열 예측의 평가 조건과 MAE에 미치는 차이를 비교한다.
  • 왜: 미래 관측이 학습에 섞이면 실제 배포 시점보다 쉬운 문제를 풀어 오프라인 점수가 과대평가될 수 있기 때문이다.
  • 어떻게: 동일 합성 데이터에서 fold별 MAE와 시간 범위를 재현하고 horizon·gap·expanding/rolling window를 운영 조건에 맞춘다.

핵심 요약

  • 무엇: 랜덤 분할과 시간 순서를 지킨 walk-forward의 평가 조건을 비교한다.
  • 왜: 미래 구간이 학습에 섞이면 오프라인 오차가 실제 예측보다 낙관적으로 보일 수 있다.
  • 어떻게: 동일 합성 데이터의 fold별 MAE를 재현하고 horizon·gap·window 설계 기준을 확인한다.

랜덤 분할은 예측 시점의 정보 경계를 무너뜨린다

시계열 예측이 답하려는 질문은 보통 “시점 t까지 알려진 정보로 t+h를 얼마나 잘 맞히는가”다. 랜덤 분할은 행을 섞기 때문에 train에 test보다 미래인 관측이 들어갈 수 있다. 미래 target을 feature로 직접 넣지 않았더라도 미래 regime의 수준과 관계를 미리 학습해 과거 test 구간을 보간할 수 있다.

이 조건을 확인하려고 LCG seed 43으로 만든 20개 인덱스를 무작위 75/25로 나눈 뒤 max(train) < min(test)를 검사했다. 다음은 프로젝트 루트에서 실행한 축소 fixture 전체다. 마지막 exit 0은 셸 단계의 중단만 막으며, 관측 대상인 Node 프로세스의 종료 상태는 exit_status=1로 보존한다.

set +e
node <<'JS'
'use strict';
let state = 43;
const random = () => ((state = (1664525 * state + 1013904223) >>> 0) / 2 ** 32);
const indices = Array.from({length:20}, (_,i) => i);
for (let i=indices.length-1; i>0; i--) {
  const j = Math.floor(random() * (i+1));
  [indices[i], indices[j]] = [indices[j], indices[i]];
}
const test = indices.slice(0,5);
const train = indices.slice(5);
const maxTrain = Math.max(...train);
const minTest = Math.min(...test);
console.log(`random_train=${JSON.stringify(train)}`);
console.log(`random_test=${JSON.stringify(test)}`);
console.log(`check=max(train) < min(test): ${maxTrain} < ${minTest}`);
if (!(maxTrain < minTest)) throw new Error(`temporal-order invariant violated: max(train)=${maxTrain} >= min(test)=${minTest}`);
JS
status=$?
echo "exit_status=$status"
exit 0

실제 연속 출력은 다음과 같다.

random_train=[7,19,14,1,18,13,16,15,12,11,0,17,4,3,5]
random_test=[9,10,6,8,2]
check=max(train) < min(test): 19 < 2
[stdin]:16
if (!(maxTrain < minTest)) throw new Error(`temporal-order invariant violated: max(train)=${maxTrain} >= min(test)=${minTest}`);
                           ^

Error: temporal-order invariant violated: max(train)=19 >= min(test)=2
    at [stdin]:16:34
    at Script.runInThisContext (node:vm:122:12)
    at Object.runInThisContext (node:vm:298:38)
    at node:internal/process/execution:82:21
    at [stdin]-wrapper:6:24
    at runScript (node:internal/process/execution:81:62)
    at evalScript (node:internal/process/execution:103:10)
    at node:internal/main/eval_stdin:32:5
    at Socket.<anonymous> (node:internal/process/execution:204:5)
    at Socket.emit (node:events:529:35)

Node.js v18.19.1
exit_status=1

랜덤 분할 인덱스가 시간 순서 invariant를 위반하고 Node가 exit 1로 끝난 실제 연속 터미널 출력

이 실패는 랜덤 분할이 곧바로 target leakage라는 뜻은 아니다. forecast 배포가 요구하는 시간 방향을 위반해 미래 regime 정보가 모델 학습과 선택에 들어갈 수 있는 검증 누수를 뜻한다. scikit-learn도 데이터 누수를 예측 시점에 이용할 수 없는 정보로 모델을 만드는 문제로 설명한다.scikit-learn Common pitfalls

분할만 고쳐도 모든 누수가 사라지는 것은 아니다. scaler, imputer, PCA, feature selection, target encoding 같은 학습형 변환은 각 fold의 train에만 fit하고 test에는 transform만 해야 한다. lag와 rolling feature도 같은 시점 target, 음수 shift, centered window를 참조하지 않는지 별도로 검사해야 한다. 공식 lag 예제는 rolling 집계 전에 shift(1)을 둔다.scikit-learn lagged features 예제

같은 데이터에서 분할만 바꾸자 오차가 달라졌다

통제 실험은 2026년 8월 6일 Linux와 Node.js v18.19.1에서 수행했다. LCG seed 43과 Box–Muller noise로 720개 합성 수요를 만들고, 기본 추세와 일·주 계절성에 시점 480부터 수준 및 기울기 변화를 추가했다. 모든 분할에 같은 결정론적 5-nearest-neighbor 회귀기와 MAE를 사용했다.

재실행 가능한 원문은 다음과 같다.

set +e
node <<'JS'
'use strict';
let state = 43;
const random = () => ((state = (1664525 * state + 1013904223) >>> 0) / 2 ** 32);
const normal = () => Math.sqrt(-2 * Math.log(Math.max(random(), 1e-12))) * Math.cos(2 * Math.PI * random());
const n = 720;
const X = [];
const y = [];
for (let t = 0; t < n; t++) {
  X.push([t, Math.sin(2*Math.PI*t/24), Math.cos(2*Math.PI*t/24), Math.sin(2*Math.PI*t/168), Math.cos(2*Math.PI*t/168)]);
  y.push(50 + 0.03*t + 10*Math.sin(2*Math.PI*t/24) + 5*Math.cos(2*Math.PI*t/168) + (t >= 480 ? 33 + 0.10*(t-480) : 0) + 3*normal());
}
const shuffled = Array.from({length:n}, (_,i) => i);
for (let i=n-1; i>0; i--) {
  const j = Math.floor(random() * (i+1));
  [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const randomTest = shuffled.slice(0, 180);
const randomTrain = shuffled.slice(180);
const predict = (train, i) => train.map(j => [X[i].reduce((s,v,k) => s + (v-X[j][k])**2, 0), y[j]])
  .sort((a,b) => a[0]-b[0]).slice(0,5).reduce((s,z) => s+z[1], 0) / 5;
const mae = (train, test) => test.reduce((s,i) => s + Math.abs(y[i]-predict(train,i)), 0) / test.length;
const run = (name, gap, maxTrain) => {
  const scores=[];
  for (let fold=0; fold<4; fold++) {
    const testStart=432+72*fold;
    const trainEnd=testStart-gap;
    const trainStart=maxTrain === null ? 0 : Math.max(0, trainEnd-maxTrain);
    const train=Array.from({length:trainEnd-trainStart}, (_,i)=>trainStart+i);
    const test=Array.from({length:72}, (_,i)=>testStart+i);
    const score=mae(train,test); scores.push(score);
    console.log(`${name} fold=${fold} train=${trainStart}..${trainEnd-1} test=${testStart}..${testStart+71} MAE=${score.toFixed(3)}`);
  }
  console.log(`${name} mean_MAE=${(scores.reduce((a,b)=>a+b,0)/scores.length).toFixed(3)} worst_MAE=${Math.max(...scores).toFixed(3)}`);
  return scores;
};
const randomMAE=mae(randomTrain,randomTest);
console.log(`node=${process.version} platform=${process.platform}`);
console.log(`random_split train=[mixed ${Math.min(...randomTrain)}..${Math.max(...randomTrain)}] test=[mixed ${Math.min(...randomTest)}..${Math.max(...randomTest)}] MAE=${randomMAE.toFixed(3)}`);
const expanding=run('expanding',0,null);
const gap=run('walk_forward_gap24',24,null);
run('rolling_gap24',24,288);
if (!(randomMAE < 0.5 * (gap.reduce((a,b)=>a+b,0)/gap.length))) throw new Error('optimism assertion failed');
console.log('assertion=random split is at least 50% more optimistic: PASS');
JS
status=$?
echo "exit_status=$status"
exit "$status"

명령과 같은 실행에서 이어진 출력 및 종료 상태다.

node=v18.19.1 platform=linux
random_split train=[mixed 0..719] test=[mixed 12..717] MAE=2.993
expanding fold=0 train=0..431 test=432..503 MAE=25.329
expanding fold=1 train=0..503 test=504..575 MAE=8.323
expanding fold=2 train=0..575 test=576..647 MAE=14.541
expanding fold=3 train=0..647 test=648..719 MAE=12.225
expanding mean_MAE=15.104 worst_MAE=25.329
walk_forward_gap24 fold=0 train=0..407 test=432..503 MAE=24.540
walk_forward_gap24 fold=1 train=0..479 test=504..575 MAE=46.735
walk_forward_gap24 fold=2 train=0..551 test=576..647 MAE=13.441
walk_forward_gap24 fold=3 train=0..623 test=648..719 MAE=21.544
walk_forward_gap24 mean_MAE=26.565 worst_MAE=46.735
rolling_gap24 fold=0 train=120..407 test=432..503 MAE=24.540
rolling_gap24 fold=1 train=192..479 test=504..575 MAE=46.735
rolling_gap24 fold=2 train=264..551 test=576..647 MAE=13.441
rolling_gap24 fold=3 train=336..623 test=648..719 MAE=21.544
rolling_gap24 mean_MAE=26.565 worst_MAE=46.735
assertion=random split is at least 50% more optimistic: PASS
exit_status=0

동일 합성 데이터의 랜덤·expanding·gap·rolling 분할별 fold 범위와 MAE 및 exit 0 실제 연속 터미널 출력

분할 정책 train/test 시간 관계 평균 MAE 최악 MAE
random 75/25 두 집합의 시간 범위가 혼합됨 2.993 측정하지 않음
expanding train 뒤에 test 배치 15.104 25.329
expanding, gap=24 train과 test 사이 24개 제외 26.565 46.735
rolling 288, gap=24 최근 288개 train 뒤에 24개 제외 26.565 46.735

random과 gap=24 walk-forward에서 바뀐 것은 분할 정책뿐이다. random의 MAE 2.993은 미래 구간의 이웃을 학습에 섞어 보간할 수 있었던 이 합성 조건의 관측값이다. 변화 직전까지만 학습하고 변화 뒤를 평가한 gap=24의 fold 1은 MAE 46.735로 무너졌고, 평균만 봤다면 이 regime의 위험을 축소해 보였을 것이다.

이 차이를 보편적인 배율로 일반화할 수는 없다. 단일 합성 시계열, 단일 seed, 직접 구현한 간단한 k-NN 결과이며 실제 데이터나 다른 모델을 시험하지 않았다. rolling과 expanding의 수치가 같은 것도 이 estimator와 feature scaling에서 최근 이웃이 선택된 결과이지 두 방법이 일반적으로 동등하다는 뜻이 아니다.

walk-forward는 운영의 예측 사건부터 설계한다

walk-forward validation의 출발점은 splitter 인자가 아니라 실제 예측 사건이다. 다음 네 조건을 먼저 고정해야 한다.

  1. Origin: 예측을 생성하는 시점
  2. Forecast horizon: origin에서 몇 step 앞까지 예측하는지
  3. 재학습 주기: 매시간·매일·매주 중 언제 다시 fit하는지
  4. 데이터 가용성: label과 외생 변수가 언제 확정되는지

운영에서 한 번에 24-step을 예측한다면 검증도 각 origin의 1~24 step 오류를 모으거나 test window를 같은 운용 단위로 잡아야 한다. 1-step 성능만으로 24-step 배포를 판단할 수 없다. rolling forecasting origin은 multi-step error로 확장할 수 있다.Forecasting: Principles and Practice의 time series cross-validation

TimeSeriesSplit 인자를 운영 조건으로 번역하는 법

2026년 8월 확인 기준 scikit-learn stable 문서는 1.9.0으로 표시됐다. TimeSeriesSplit은 입력 행 순서를 기준으로 앞쪽을 train, 뒤쪽을 test로 나누며 후속 train은 이전 train의 상위집합이 되는 expanding 방식이다.

  • n_splits는 재현할 역사적 배포 구간 수다. 늘리면 더 많은 regime를 보지만 초기 train 크기와 계산비용도 확인해야 한다.
  • test_size는 각 fold가 대표할 평가 기간이다. 운영의 forecast block과 맞추고, fold 지표가 같은 시간 폭을 뜻하도록 등간격 샘플을 사용한다.
  • gap은 train 끝과 test 시작 사이에서 제외할 샘플 수다. label 확정, 데이터 가용성, feature 생성과 배포 지연으로부터 정해야 한다.
  • max_train_size=None은 모든 과거를 누적한다. 값 k를 주면 최근 k개만 쓰는 rolling window를 근사한다.

gap은 임의의 안전 여백도, 모든 누수를 막는 스위치도 아니다. label이 24시간 뒤 확정된다면 그 지연을 최소 후보로 검토하지만, lag가 168이라는 이유만으로 gap도 168이 되는 것은 아니다. 예측 시점에 해당 lag feature를 실제 사용할 수 있는지와 label 구간이 겹치는지를 따져야 한다.

입력도 먼저 timestamp로 정렬하고 중복과 누락을 확인해야 한다. TimeSeriesSplit은 timestamp의 의미를 해석하지 않고 행 순서로 인덱스를 만들며, 공식 문서는 fold별 지표를 같은 시간 폭으로 비교하려면 샘플이 등간격이어야 한다고 명시한다.

expanding과 rolling은 분포 변화에 대한 가정이 다르다

expanding window는 모든 과거를 보존해 데이터 효율을 높이지만 오래된 regime가 현재 관계를 희석할 수 있다. rolling window는 최근 구간만 사용해 drift 대응 가능성을 시험하는 대신 계절 주기와 희귀 사건을 잃을 수 있다. max_train_size를 지정했다고 concept drift가 자동으로 해결되지는 않는다.

두 방식은 같은 fold, horizon, gap과 지표에서 비교해야 한다. window 길이는 최소 계절 주기와 필요한 표본량을 보존하는지 확인하고, 최근 구간만 쓸 때 최악 fold가 실제로 개선되는지를 기준으로 선택한다.

전처리와 모델 선택도 fold 안에 가둔다

scikit-learn으로 옮길 때의 개념 골격은 다음과 같다. 이 코드는 공식 API 계약에 맞춘 예시이며 현재 로컬 환경에서 직접 실행한 코드는 아니다.

from sklearn.model_selection import TimeSeriesSplit, cross_validate

cv = TimeSeriesSplit(
    n_splits=5,
    test_size=forecast_block,
    gap=availability_delay,
    max_train_size=lookback_window,
)

scores = cross_validate(
    pipeline,
    X_sorted,
    y_sorted,
    cv=cv,
    scoring={"mae": "neg_mean_absolute_error"},
    return_estimator=False,
)

imputer, scaler, feature selection과 model은 pipeline에 넣어 fold의 train에만 fit한다. lag와 rolling feature는 point-in-time correctness를 따로 검사한다. hyperparameter나 threshold를 전체 기간의 결과로 고르면 outer test가 모델 선택에 사용되므로, 시간 순서를 지키는 inner CV와 마지막 untouched holdout을 분리해야 한다.

공식 문서의 “train은 앞, test는 뒤”, 누적 train, gap 제외 계약은 직접 생성한 fold 범위와 일치했다. 다만 로컬 Python 환경에 NumPy와 scikit-learn이 없어 estimator와 API 자체의 버전별 동작까지 관측한 것은 아니다. 최초 Python 전달의 f-string 구문 오류와 이어진 ModuleNotFoundError 뒤에 외부 패키지를 설치하지 않고 Node 표준 기능으로 비교를 대체했다.

평균 MAE보다 최악의 배포 구간까지 본다

시간 순서를 보존해도 분포 변화가 사라지지는 않는다. walk-forward의 역할은 오히려 어느 test 기간에서 모델이 무너지는지 드러내는 데 있다. 174개 실제 시계열과 3개 합성 시계열을 비교한 2020년 연구는 stationary series에서 blocked CV가 적용 가능할 수 있다고 보았지만, non-stationary 조건에서는 여러 시간대의 out-of-sample 평가가 더 정확한 성능 추정치를 냈다고 보고했다.Cerqueira et al.

따라서 모든 시계열에 일반 K-fold가 절대 무효라고 단정할 수는 없다. 다만 구조 변화 가능성이 있고 미래 forecasting 배포를 재현해야 한다면 시간 순서 보존을 기본값으로 두는 편이 검증 질문에 맞다.

지표는 오류 비용에 맞춰 선택한다.

  • MAE: 원 단위로 해석하기 쉽고 큰 오류를 제곱해 벌하지 않는다.
  • RMSE: 피크 누락처럼 큰 오류가 특히 비쌀 때 보조 지표로 본다.
  • MAPE: 실제값이 0 또는 0에 가까우면 불안정하고 낮은 수요 구간을 과도하게 가중할 수 있다.
  • MASE/RMSSE: 규모가 다른 시계열을 naive benchmark 대비 비교할 때 사용한다. 분모의 scale도 train fold 정보만으로 계산해야 한다.FPP3의 예측 분포 정확도
  • Pinball loss: 과소 예측과 과대 예측 비용이 비대칭일 때 quantile별로 평가한다.

최종 보고에는 적어도 fold 기간, train/test 범위, horizon, MAE와 최악 또는 상위 분위 오류를 함께 둔다. 평균과 표준편차만으로 구조 변화 구간을 숨기지 않는 것이 배포 판단에 더 직접적이다. 점수 이후의 운영상 오류 비용과 의사결정 기준은 분류 임계값과 오류 비용에서 이어서 볼 수 있다.

시간성이 있는 이상 탐지 데이터에도 같은 정보 경계가 필요하다. 평가 시점보다 미래인 관측을 학습에 섞지 않는 확장 사례는 Isolation Forest 물 사용량 이상 탐지를 후속 읽을거리로 참고할 수 있다.

배포 판단은 점수보다 정보 집합에서 시작한다

시계열 검증에서 먼저 확인할 것은 가장 낮은 평균 점수가 아니라 각 fold가 실제 예측 시점의 정보 집합을 재현하는지다. horizon과 test window를 운영 단위에 맞추고, label·데이터·배포 지연보다 gap이 짧다면 검증을 보류해야 한다. 최악 fold가 사업상 허용 MAE를 넘을 때도 배포를 멈추고 해당 regime와 rolling window를 다시 검토해야 한다.

지금 쓰는 모델의 랜덤 분할 점수를 forecast 배포 근거로 채택하지 말고, 같은 데이터와 지표로 시간 분할의 fold별 MAE를 나란히 비교해 보라.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다