AI/Technical

AI Sales Forecasting 백테스트 설계: Rolling CV·베이스라인·리포트 (3)

Royzero 2026. 2. 9. 15:14
반응형

TL;DR

  • AI Sales Forecasting에서 백테스트는 "점수 뽑기"가 아니라 운영과 같은 조건으로 성능을 검증하는 절차입니다. FPP3는 진짜 예측 오차는 학습 잔차가 아니라 새 데이터에서의 genuine forecasts로 봐야 한다고 정리합니다.
  • 기본은 rolling forecasting origin(rolling-origin) + expanding/rolling window + 재학습(refit) 여부 명시입니다.
  • 리테일/판매 예측에서는 MAPE만 고집하지 말고 WAPE(볼륨 가중), MASE(스케일드) 같은 지표를 함께 써야 합니다.
  • 베이스라인은 최소 Seasonal Naive + ETS(지수평활 계열) 2개를 두고, 모든 모델은 여기에 "상대 개선"으로 보고하세요.
  • 분위수(확률) 예측을 한다면 pinball(quantile) loss까지 리포트에 넣어야 의사결정(안전재고/서비스레벨)과 연결됩니다.

본문

TOC

    1. 사전 요구사항: 백테스트가 “같은 게임”인지 확인
    1. 단계별 절차: Rolling-origin 백테스트 설계
    1. 베이스라인 2종(필수)과 구현 예시
    1. 평가 지표: WAPE/MASE/sMAPE + 분위수 지표
    1. 검증 방법: 리포트 템플릿(필수 표)과 게이트
    1. 트러블슈팅 3개
    1. 운영 팁: 재학습 주기·대시보드·감사/재현성
    1. FAQ
    1. 다음 글 예고(Part 4)

1) 사전 요구사항: 백테스트가 “같은 게임”인지 확인

AI Sales Forecasting 백테스트 설계의 1순위는 “운영과 같은 조건”입니다. 첫 2문장만 고정해도 사고가 크게 줄어듭니다: 예측 지평(horizon)리드타임/발주 주기가 같아야 합니다(예: 리드타임 7일인데 28일 점수만 좋으면 의미가 약해짐).

1-1. 백테스트 설정 체크(필수)

항목 예시 왜 필요한가
horizon 7/14/28 운영 의사결정과 동일해야 함
step(재계산 주기) 매일/매주 운영 배치와 동일해야 함
window expanding vs rolling 데이터 비정상성/시즌 변화 대응
refit 매 fold마다 재학습? 실제 운영도 재학습한다면 refit가 맞음
cutoff D+1 확정, 타임존 고정 누수 방지(포인트-인-타임 정합)

FPP3는 예측 정확도 평가는 "학습 잔차"가 아니라 새 데이터에서의 genuine forecasts로 해야 한다고 강조합니다.
Why it matters: 백테스트 설정이 운영과 다르면, 점수는 좋아도 운영 성능은 그대로 무너집니다.


2) 단계별 절차: Rolling-origin 백테스트 설계

Step 1) Rolling-origin(rolling forecasting origin)으로 fold 만들기

FPP3는 이 절차를 rolling forecasting origin이라 부르며, 원점(origin)이 시간에 따라 앞으로 "굴러간다"고 설명합니다.
실무에서는 보통 두 가지 창(window)을 씁니다.

  • Expanding window: 학습 구간이 계속 늘어남(장기 패턴/계절성에 유리)
  • Rolling window: 학습 구간 길이를 고정하고 앞으로 이동(분포 변화에 유리) — skforecast는 고정 창을 굴리며 테스트하는 backtesting을 설명합니다.

Step 2) “재학습(refit)” 여부를 명시

  • 운영이 “매일 재학습”이면 백테스트도 fold마다 refit가 맞습니다.
  • 운영이 “주 1회 재학습 + 매일 추론”이면, 백테스트도 그 형태로 맞추는 것이 정직합니다.

sktime는 rolling 환경을 흉내 내는 평가를 위해 evaluate(timeseries CV 기반)를 제공한다고 안내합니다.
Why it matters: refit 여부를 숨기면 모델 비교가 무의미해집니다(특히 드리프트가 큰 판매 데이터).


3) 베이스라인 2종(필수)과 구현 예시

베이스라인은 “최소한의 정직함”입니다. 여기서는 2개만 고정합니다.

  1. Seasonal Naive: “작년/지난주 같은 요일 값”을 그대로 쓰는 기준
  2. ETS(지수평활 계열): 추세/계절성을 포함하는 대표 통계 모델(Statsmodels ETSModel)

3-1. Python 예시(단일 시계열)

아래 코드는 “구조 템플릿”입니다. 실제로는 Part 2에서 만든 ds, y 뷰를 그대로 쓰면 됩니다.

import numpy as np
import pandas as pd

from statsmodels.tsa.exponential_smoothing.ets import ETSModel  # ETS family
# ETSModel docs: https://www.statsmodels.org/stable/generated/statsmodels.tsa.exponential_smoothing.ets.ETSModel.html

def seasonal_naive(y: pd.Series, season_length: int, horizon: int) -> np.ndarray:
    # 마지막 시즌 패턴을 반복
    last_season = y.iloc[-season_length:].to_numpy()
    reps = int(np.ceil(horizon / season_length))
    return np.tile(last_season, reps)[:horizon]

def wape(y_true: np.ndarray, y_pred: np.ndarray) -> float:
    denom = np.sum(np.abs(y_true))
    return float(np.sum(np.abs(y_true - y_pred)) / denom) if denom != 0 else np.nan

def rolling_origin_backtest(
    df: pd.DataFrame,              # columns: ds, y
    horizon: int,
    step: int,
    min_train_size: int,
    season_length: int
):
    df = df.sort_values("ds").reset_index(drop=True)
    y = df["y"]

    rows = []
    for cutoff in range(min_train_size, len(df) - horizon + 1, step):
        train = y.iloc[:cutoff]
        test = y.iloc[cutoff:cutoff + horizon].to_numpy()

        # Baseline 1: Seasonal Naive
        pred_naive = seasonal_naive(train, season_length, horizon)

        # Baseline 2: ETS (simple example; tune error/trend/seasonal per your data)
        ets = ETSModel(train, error="add", trend="add", seasonal="add", seasonal_periods=season_length)
        ets_fit = ets.fit(disp=False)
        pred_ets = ets_fit.forecast(horizon).to_numpy()

        rows.append({
            "cutoff_ds": df.loc[cutoff - 1, "ds"],
            "wape_naive": wape(test, pred_naive),
            "wape_ets": wape(test, pred_ets),
        })

    return pd.DataFrame(rows)

Why it matters: ETS 같은 "강한 통계 베이스라인"을 이기지 못하면, AI 모델을 운영에 넣을 이유가 약합니다.


4) 평가 지표: WAPE/MASE/sMAPE + 분위수 지표

4-1. WAPE(볼륨 가중 MAE)

  • Hyndman은 WAPE를 "가중 퍼센트 오차"로 정리하며 공식(가중치 정의 포함)을 제시합니다.
  • AWS Forecast 문서도 WAPE를 지표로 설명합니다.

4-2. MASE(Scaled Error)와 sMAPE

FPP3는 "진짜 예측 오차"를 새 데이터에서 평가해야 하고, 다양한 지표군(스케일 의존/백분율/스케일드)을 목적에 맞게 써야 한다는 맥락을 제공합니다.
AutoGluon TimeSeries는 MAE/MAPE/MASE/RMSE/WAPE 등을 예측 평가 지표로 정리합니다.

4-3. 분위수(확률) 예측이면 pinball loss(quantile loss)

  • pinball loss는 분위수 예측 평가 지표로 널리 알려져 있고(quantile loss), 정의와 의미가 정리돼 있습니다.

Why it matters: 판매 예측은 "작은 SKU가 많고 0도 많아지는" 순간 MAPE가 쉽게 깨집니다. WAPE/MASE를 같이 두면 KPI가 더 안정적입니다.


5) 검증 방법: 리포트 템플릿(필수 표)과 게이트

5-1. 리포트는 “전체 점수 1개”로 끝내면 실패합니다

리테일 예측은 계층(상품/카테고리/전체)구간(프로모션/비프로모션)에 따라 성능이 갈립니다. M5 대회는 리테일 판매 예측에서 계층을 중요하게 다루고, 평가로 WRMSSE를 사용한 것으로 널리 참고됩니다.

(A) 백테스트 요약표(필수)

구분 Baseline(Seasonal Naive) Baseline(ETS) Candidate(AI) Gate
WAPE(전체) ETS 대비 ↓
WAPE(프로모션) 악화 금지
WAPE(상위 20% 매출 SKU) 개선 필수
MASE(전체) ETS 대비 ↓
pinball(p50/p90) 서비스레벨 기준

(B) “누수/정합성” 검증 체크(필수)

  • fold별 cutoff 이후 데이터가 학습에 들어가지 않는지(시간 기준)
  • 테스트 구간이 "동일 길이/동일 간격"인지(특히 TimeSeriesSplit을 쓸 경우)

scikit-learn의 TimeSeriesSplit은 시간 순서를 보존해 "미래로 학습"하는 실수를 피하기 위한 splitter라고 설명합니다.

Why it matters: 리포트는 "모델 자랑"이 아니라 배포 승인 문서입니다. 구간별/계층별로 깨지는 곳을 먼저 드러내야 운영 사고가 줄어듭니다.


6) 트러블슈팅(증상→원인→해결) 3개

  1. 증상: 오프라인 점수만 좋아지고 운영에서 붕괴
  • 원인 후보: 백테스트가 rolling-origin이 아니라 “한 번만 자른 홀드아웃”이거나, cutoff 규칙이 불명확
  • 해결: rolling forecasting origin으로 fold를 만들고, refit/step/horizon을 운영과 일치
  1. 증상: TimeSeriesSplit 사용 시 fold 점수가 들쭉날쭉/데이터가 깨짐
  • 원인 후보: 샘플이 "고정 간격"이 아니거나(날짜 누락), series가 섞여 있음
  • 해결: 날짜 연속성 확보 + 단일 시계열에만 적용하거나, 패널이면 series_id별로 분리 backtest(또는 sktime의 평가 워크플로 사용)
  1. 증상: ETS는 이기는데 프로모션 구간만 악화
  • 원인 후보: 프로모션 피처가 미래에 “계획값”으로 제공되지 않거나 라벨 품질이 낮음
  • 해결: 프로모션은 “미래에 알 수 있는 계획 테이블”만 추론에 사용하고, 프로모션 구간을 별도 KPI로 게이트화

Why it matters: 백테스트가 제대로면, 실패 원인이 "모델"인지 "데이터/운영 조건"인지 바로 갈라집니다.


7) 운영 팁: 재학습 주기·대시보드·감사/재현성

  • 재현성(필수): fold 설정(horizon/step/window/refit)과 데이터 컷오프를 설정 파일로 고정하고, 백테스트 결과(요약표/세그먼트별 점수)를 아티팩트로 저장하세요.
  • 대시보드(필수): 전체 WAPE만 보지 말고, “프로모션/상위 매출 SKU/신규 SKU” 3개를 최소로 고정하세요.
  • 지표 정의(필수): WAPE는 Hyndman 정의(가중치 포함)처럼 수식/분모 처리(0일 때)를 문서로 고정하세요.

Why it matters: 운영에서 진짜 필요한 건 “점수”가 아니라 점수가 깨졌을 때 원인을 역추적할 수 있는 구조입니다.


8) FAQ

  1. Q. 베이스라인은 왜 2개나 필요합니까?
    A. Seasonal Naive는 "아무것도 안 한 기준", ETS는 "강한 통계 기준"이라서 둘 다 넘어야 모델 투입 명분이 생깁니다.

  2. Q. WAPE는 왜 판매 예측에서 많이 씁니까?
    A. Hyndman이 WAPE를 볼륨 가중 퍼센트 오차로 정리했고, AWS Forecast도 WAPE를 지표로 제공합니다.

  3. Q. scikit-learn TimeSeriesSplit로 충분합니까?
    A. 단일 시계열/고정 간격 조건에서는 유용하지만, 리테일처럼 "다중 series + 누락 날짜"가 흔하면 series별 backtest 또는 시계열 평가 워크플로(sktime evaluate)가 더 안전합니다.

  4. Q. 확률예측은 언제부터 넣어야 합니까?
    A. 안전재고/서비스레벨처럼 "비대칭 비용"이 명확할 때부터입니다. 그때는 pinball(quantile) loss를 같이 평가해야 합니다.

  5. Q. M5의 WRMSSE 같은 지표를 그대로 써야 합니까?
    A. 그대로 쓸 필요는 없지만, 리테일 예측이 "계층/가중"을 중요하게 본다는 점을 참고해 내부 KPI(상위 매출 SKU 가중 등)를 설계하는 데 도움이 됩니다.


9) 다음 글 예고(Part 4)

Part 4에서는 피처 기반 ML(회귀/트리/부스팅)로 넘어가서, Part 2의 데이터 모델을 바탕으로 누수 없는 피처 카탈로그 + 학습/추론 파이프라인을 만듭니다.


결론 (요약 정리)

  • AI Sales Forecasting 백테스트는 rolling-origin으로 "운영과 같은 조건"을 재현해야 합니다.
  • 베이스라인은 Seasonal Naive + ETS 2개를 고정하고, 모든 비교는 여기서 시작해야 합니다.
  • 판매 예측 KPI는 WAPE/MASE를 기본으로 두고, 확률예측이면 pinball loss를 포함하세요.

References

반응형