본문 바로가기
  • plotly로 바로쓰는 동적시각화 in R & 파이썬
실전에서 바로 쓰는 시계열 데이터 처리와 분석 in R

시계열 예측에서 모든 데이터 과학자가 저지르는 10가지 실수와 해결 방법

by 아참형인간 2026. 5. 30.

“역사는 똑같이 반복되지는 않지만, 놀라울 정도로 비슷하다.”— 마크 트웨인

 

시계열 예측은 역사의 흐름을 파악하는 기술입니다. 이 열 가지 실수를 저지르면 역사의 흐름을 파악하지 못하게 될 것입니다.

서론: 시계열 예측이 생각보다 훨씬 어려운 이유

회귀분석을 해봤다. 학습 데이터와 테스트 데이터를 나누는 방법도 알고 있다. 과적합(overfitting)이 무엇인지도 이해하고 있다. 그러다 시계열 데이터를 접하면 이런 생각이 들기 쉽다.

“그렇게까지 다를까?”

결론부터 말하면, 매우 다르다.

시계열 데이터는 일반적인 머신러닝이 전제하는 거의 모든 가정을 깨뜨린다. 관측값들은 서로 독립적이지 않다. 데이터의 순서가 중요하다. 미래의 정보가 과거를 설명하도록 해서는 안 된다. 적어도 모델을 학습시키는 과정에서는 그렇다. 여기에 계절성(seasonality), 추세(trend), 구조적 변화(structural break)까지 더해지면, 일반적인 머신러닝 모델이 예상하지 못하거나 제대로 처리하지 못하는 패턴들이 나타난다.

이 글에서 다루는 실수들은 초보자의 단순한 부주의 때문이 아니다. 오히려 일반적인 머신러닝 사고방식을 시계열 문제에 그대로 적용했을 때 거의 필연적으로 발생하는 결과에 가깝다. 실제로 이러한 실수들은 수많은 데이터 과학자들의 신뢰도를 떨어뜨렸고, 조직과 기업의 의사결정에 잘못된 방향을 제시하기도 했다.

이제부터 시계열 예측에서 가장 흔하게 발생하는 10가지 실수와, 이를 피하는 방법을 살펴보자.

실수 1: 시계열 데이터에 무작위 학습-테스트 분할 적용

무슨 일이 발생하는가: 주간 매출 데이터를 무작위로 나누어 80%는 학습용, 20%는 테스트용으로 사용한다. 이런 경우 모델은 2024년 12월 데이터를 학습한 뒤 2023년 3월 데이터를 예측하게 될 수 있다.

즉, 테스트 데이터에 포함된 일부 관측값이 학습 데이터에 포함된 많은 관측값보다 과거 시점에 위치하게 된다.

 

이것은 시간 누수(temporal leakage) 이다. 모델이 학습 과정에서 암묵적으로 미래 정보를 본 것과 다름없기 때문이다.

시계열 예측에서는 항상 과거 데이터를 사용해 미래를 예측해야 한다. 따라서 데이터 분할 역시 시간 순서를 유지해야 한다.

WRONG:
  X_train, X_test = train_test_split(df, test_size=0.2, random_state=42)
  # Randomly mixes 2020, 2021, 2022, 2023 data across train and test

CORRECT:
  split_date = '2024-01-01'
  train = df[df['date'] < split_date]
  test  = df[df['date'] >= split_date]
  # Test is always strictly in the future

 

교차 검증(cross-validation)을 수행할 때도 마찬가지다. 무작위 분할(random split)은 사용하지 말고, 확장 윈도우(expanding window) 또는 슬라이딩 윈도우(sliding window) 방식을 사용해야 한다.

Fold 1: Train [week 1–100]  → Test [week 101–113]
Fold 2: Train [week 1–113]  → Test [week 114–126]
Fold 3: Train [week 1–126]  → Test [week 127–139]

실수 2: 정상성(Stationarity)을 무시

정상성이란 시계열의 평균(mean), 분산(variance), 자기상관(autocorrelation)이 시간에 따라 일정하게 유지되는 특성을 의미한다.

ARIMA를 비롯한 많은 전통적인 시계열 모델은 데이터가 정상성을 만족한다고 가정한다. 따라서 추세(trend)나 계절성(seasonality)을 포함한 비정상 시계열(non-stationary series)에 이러한 모델을 그대로 적용하면, 모델이 추정한 매개변수(parameter)의 신뢰성이 떨어질 수 있다.

즉, 모델은 과거 데이터의 패턴을 제대로 학습하지 못하고, 결과적으로 예측 성능도 불안정해질 수 있다.

Augmented Dickey-Fuller (ADF) test:
  H₀: series has a unit root (non-stationary)
  H₁: series is stationary

  If p-value < 0.05 → reject H₀ → series is stationary
  If p-value > 0.05 → fail to reject → difference the series
from statsmodels.tsa.stattools import adfuller

result = adfuller(df['sales'])
print(f"ADF Statistic: {result[0]:.4f}")
print(f"p-value: {result[1]:.4f}")

# Fix: first differencing
df['sales_diff'] = df['sales'].diff(1)

# Seasonal differencing for seasonal non-stationarity
df['sales_seasonal_diff'] = df['sales'].diff(52)  # 52-week lag for annual seasonality

실수 3: 시차(Lag) 변수에 미래 정보를 포함

무슨 일이 발생하는가: 머신러닝 기반 시계열 예측 모델을 만들기 위해 시차(lag) 변수를 생성하면서 데이터를 올바르게 이동(shift)하지 않는다. 그 결과, 예측 시점에는 알 수 없었던 미래 데이터가 특성(feature)에 포함된다.

이는 그래디언트 부스팅(Gradient Boosting) 이나 랜덤 포레스트(Random Forest) 와 같은 머신러닝 모델을 시계열 예측에 활용할 때 가장 흔하게 발생하는 실수 중 하나다.

# WRONG — rolling mean uses future data at time t
df['rolling_mean_7'] = df['sales'].rolling(7).mean()  # Includes sales at time t

# CORRECT — shift by 1 before rolling
df['rolling_mean_7'] = df['sales'].shift(1).rolling(7).mean()
# At time t, this only uses sales up to time t-1

# CORRECT lag features
df['lag_1']  = df['sales'].shift(1)   # Last period
df['lag_7']  = df['sales'].shift(7)   # Same day last week
df['lag_52'] = df['sales'].shift(52)  # Same week last year

 

집계 연산을 수행하기 전에 적용하는 .shift(1)은 미래 정보 누수를 방지하는 핵심 장치다. 이 한 줄이 정보 누수가 없는 올바른 특성과 미래 정보에 오염된 특성을 구분해 준다.

실수 4: 가법 계절성과 승법 계절성을 혼동

가법 계절성(additive seasonality)은 계절적 변동의 크기가 추세 수준과 관계없이 일정하게 유지되는 경우를 의미한다. 즉, 데이터의 전체 수준이 높아지거나 낮아져도 계절 효과의 절대적인 크기는 변하지 않는다.

반면 승법 계절성(multiplicative seasonality)은 계절적 변동의 크기가 추세 수준에 비례하여 변하는 경우를 의미한다. 데이터의 수준이 높아질수록 계절적 변동 폭도 함께 커지고, 수준이 낮아질수록 변동 폭도 작아진다.

Additive:       yₜ = Tₜ + Sₜ + Rₜ
  December peak is always +₹50,000 above the trend, regardless of trend level.

Multiplicative: yₜ = Tₜ × Sₜ × Rₜ
  December peak is always +30% above the trend — growing as the business grows.

 

어떻게 구분할 수 있을까? 가장 간단한 방법은 시계열 데이터를 직접 시각화해 보는 것이다.

만약 전체 데이터 수준이 높아질수록 계절적 변동 폭도 함께 커진다면, 즉 상승과 하락의 진폭(amplitude)이 점점 커진다면 승법 계절성(multiplicative seasonality)을 사용하는 것이 적절하다.

반대로 데이터 수준이 변하더라도 계절적 변동 폭이 절대적인 크기 기준으로 거의 일정하게 유지된다면 가법 계절성(additive seasonality)을 사용하는 것이 적절하다.

from statsmodels.tsa.holtwinters import ExponentialSmoothing

# Additive
model_add = ExponentialSmoothing(df['sales'], trend='add', seasonal='add',
                                  seasonal_periods=52).fit()

# Multiplicative (when swings grow proportionally)
model_mul = ExponentialSmoothing(df['sales'], trend='add', seasonal='mul',
                                  seasonal_periods=52).fit()

# Compare AIC — lower is better
print(f"Additive AIC:       {model_add.aic:.2f}")
print(f"Multiplicative AIC: {model_mul.aic:.2f}")

 

실수 5: 불확실성 구간 없이 점 예측만 사용

무슨 일이 발생하는가: "다음 주 매출은 420만 루피가 될 것이다"라고 보고한다. 경영진은 이 숫자를 기준으로 인력 배치와 재고 계획을 수립한다. 그러나 실제 매출은 310만 루피에 그친다. 아무도 예측의 불확실성을 고려하지 않았기 때문이다.

모든 예측은 틀린다. 중요한 것은 얼마나 틀릴 수 있는지, 그리고 어느 방향으로 틀릴 가능성이 있는지를 아는 것이다.

점 예측(point forecast)은 단 하나의 값만 제시한다. 하지만 이 값만으로는 예측에 내재된 불확실성을 알 수 없다. 따라서 의사결정자는 예측 결과를 지나치게 확신하게 되고, 예상과 다른 결과가 발생했을 때 큰 비용을 치를 수 있다.

시계열 예측에서는 점 예측뿐만 아니라 예측 구간(prediction interval)도 함께 제공해야 한다. 예를 들어 "다음 주 매출은 420만 루피로 예상되며, 95% 예측 구간은 350만~490만 루피이다"와 같이 제시하면 예측의 불확실성을 보다 현실적으로 전달할 수 있다.

예측 모델의 목적은 미래를 정확히 맞히는 것이 아니라, 미래에 대한 합리적인 범위를 제시하여 더 나은 의사결정을 돕는 데 있다.

from statsmodels.tsa.statespace.sarimax import SARIMAX

model = SARIMAX(train, order=(1,1,1), seasonal_order=(1,1,1,52)).fit(disp=False)
forecast_obj = model.get_forecast(steps=13)

# Point forecast and 95% prediction interval
forecast_mean = forecast_obj.predicted_mean
conf_int = forecast_obj.conf_int(alpha=0.05)   # 95% CI

print(f"Forecast: {forecast_mean.iloc[-1]:.0f}")
print(f"95% PI:  [{conf_int['lower sales'].iloc[-1]:.0f},
                  {conf_int['upper sales'].iloc[-1]:.0f}]")

 

예측 결과를 보고할 때는 항상 예측 구간을 함께 제시해야 한다.

"420만 루피"라는 숫자만 본 의사결정자와 "420만 루피 ± 110만 루피"라는 정보를 함께 본 의사결정자는 전혀 다른 결정을 내리게 된다. 그리고 후자가 훨씬 더 충분한 정보를 바탕으로 의사결정을 내릴 수 있다.

실수 6: 잘못된 예측 기간(Forecast Horizon)을 선택

무슨 일이 발생하는가: 계산이 쉽다는 이유로 1주 후 예측(1-week-ahead forecast)을 기준으로 모델을 평가한다. 하지만 실제 비즈니스에서는 해당 모델을 12주 후 수요 계획이나 재고 계획에 활용한다. 문제는 1주 예측에서 가장 좋은 성능을 보이는 모델이 반드시 12주 예측에서도 가장 좋은 모델은 아니라는 점이다.

Key principle: always evaluate on the horizon the business actually needs.

Walk-forward validation at the correct horizon:

for each test window:
    train on data up to week t
    forecast weeks t+1 through t+H   (H = your actual horizon)
    measure error at each step in [1, H]

 

수평선별 MAPE:

APE(h) = (100/n) × Σ|( y(t+h) − ŷ(t+h|t) ) / y(t+h)|

Report MAPE separately for each forecast step h = 1, 2, ..., H.
A model with excellent 1-step-ahead MAPE can have terrible 12-step-ahead MAPE.

실수 7: 공휴일과 특별 이벤트를 고려하지 않음

무슨 일이 발생하는가: 모델은 과거 데이터에 대해서는 매우 뛰어난 적합 성능을 보인다. 하지만 매년 12월, 디왈리(Diwali), 대규모 할인 행사 기간만 되면 예측이 크게 빗나간다. 모델이 이러한 시기가 평상시와는 구조적으로 다른 기간이라는 사실을 학습하지 못했기 때문이다.

from prophet import Prophet

model = Prophet(
    seasonality_mode='multiplicative',
    yearly_seasonality=True,
    weekly_seasonality=True
)

# Add country holidays
model.add_country_holidays(country_name='IN')  # Indian public holidays

# Add custom events
custom_events = pd.DataFrame({
    'holiday': ['diwali_sale', 'year_end_sale', 'diwali_sale'],
    'ds': pd.to_datetime(['2022-10-24', '2022-12-26', '2023-11-12']),
    'lower_window': -1,   # Effect starts 1 day before
    'upper_window': 3     # Effect lasts 3 days after
})

model_with_events = Prophet(holidays=custom_events)
model_with_events.fit(train_df)

실수 8: 비즈니스 문제에 맞지 않는 평가 지표 사용

실수는 비즈니스의 비용 구조가 비대칭적인데도 RMSE를 기본 평가 지표로 사용하는 것이다.

예를 들어 재고 부족(understock)으로 인한 비용이 과잉 재고(overstock)보다 다섯 배 더 크거나, 수요 피크를 놓치는 것은 치명적이지만 과대 예측은 어느 정도 감당할 수 있는 상황이 있을 수 있다.

RMSE (Root Mean Squared Error):
  RMSE = √[(1/n) · Σ(yₜ − ŷₜ)²]
  Symmetric — penalises over and under forecasting equally

MASE (Mean Absolute Scaled Error) — scale-independent, preferred for comparison:
  MASE = MAE / (1/(n−1) · Σ|yₜ − yₜ₋₁|)
  A MASE < 1 means the model outperforms the naive forecast

For asymmetric costs:
  Quantile loss (pinball loss) at quantile q:
  L_q(y, ŷ) = q·(y − ŷ)   if y ≥ ŷ
             = (1−q)·(ŷ − y)  if y < ŷ

  Setting q = 0.8 penalises under-forecasting 4× more than over-forecasting

실수 9: 구조적 변화(Structural Break)를 무시

구조적 변화(structural break)란 데이터를 생성하는 근본적인 메커니즘이 특정 시점을 기준으로 바뀌는 현상을 의미한다. 정책 변화, 시장 환경의 급격한 변화, 팬데믹과 같은 사건이 대표적인 예다.

구조적 변화가 발생하면 과거 데이터를 기반으로 학습한 모델은 더 이상 현재의 데이터 패턴을 제대로 설명하지 못하게 된다. 따라서 과거 데이터에 아무리 잘 적합된 모델이라도 구조적 변화 이후에는 예측 성능이 크게 저하될 수 있다.

 

측정

from statsmodels.stats.diagnostic import breaks_cusumolsresid

# CUSUM test for structural breaks
stat, pvalue, crit = breaks_cusumolsresid(model.resid)
print(f"CUSUM p-value: {pvalue:.4f}")
# p < 0.05 → evidence of structural break

# Visual detection: plot the series and look for regime changes
df['sales'].plot()

 

해결 방법: 구조적 변화가 발생한 시점을 기준으로 그 이전의 데이터를 학습 데이터에서 제외하거나, 체제 전환(regime switching)을 고려할 수 있는 모델을 사용해야 한다.

실수 10: 단순 기준 모델(Naive Model)과 비교하지 않기

무슨 일이 발생하는가: 2주 동안 공들여 SARIMA 모델을 구축했다. 평가 결과 MAPE가 12%로 나타났다. 꽤 만족스러운 결과라고 생각한다. 하지만 가장 단순한 기준 모델과는 비교해 보지 않았다.

이것은 시계열 예측에서 생각보다 자주 발생하는 실수다.

복잡한 모델이 반드시 좋은 모델은 아니다. 때로는 단순한 기준 모델이 고급 시계열 모델과 비슷하거나 더 나은 성능을 보이기도 한다. 만약 복잡한 모델이 단순한 기준 모델보다 나은 결과를 보여주지 못한다면, 그 모델을 사용할 이유가 없다.

따라서 시계열 예측을 시작할 때는 항상 가장 단순한 기준 모델부터 구축해야 한다.

Naive forecast:          ŷ(t+1) = y(t)            (last observed value)
Seasonal naive:          ŷ(t+1) = y(t − m)         (same period last year/week)
Drift:                   ŷ(t+h) = y(t) + h·(y(t) − y(1))/(t−1)
Simple moving average:   ŷ(t+1) = (1/k)·Σy(t−k+1...t)


A model that does not beat the seasonal naive forecast has not earned the right
to be called a forecasting model.
# Seasonal naive baseline
y_naive_seasonal = test.shift(52)   # Last year's same week
mape_naive = (abs(test - y_naive_seasonal) / test).mean() * 100

# Your model's MAPE
mape_model = (abs(test - y_model_forecast) / test).mean() * 100

print(f"Seasonal naive MAPE: {mape_naive:.1f}%")
print(f"Model MAPE:          {mape_model:.1f}%")

skill_score = 1 - (mape_model / mape_naive)
print(f"Improvement over naive: {skill_score:.1%}")

 

skill_score가 음수라면, 여러분의 복합 모델은 작년의 값을 추측하는 것보다 성능이 더 나쁩니다. 이런 일은 사람들이 인정하는 것보다 훨씬 더 자주 발생합니다.

결론: 시계열에는 시계열다운 사고방식이 필요하다

이 글에서 살펴본 모든 실수는 결국 표 형식(tabular) 머신러닝의 사고방식을 순차적(sequential) 문제에 그대로 적용하면서 발생한다.

시계열 데이터에는 시간의 흐름에 따른 기억(memory), 구조(structure), 그리고 인과관계(causality)가 내재되어 있다. 따라서 모델링 과정의 모든 단계는 이러한 특성을 존중해야 한다.

다행히도 해결 방법은 복잡하지 않다.

시간 순서를 유지한 데이터 분할, 정상성 검정, 적절하게 이동된 시차 변수, 예측 구간 제공, 기준 모델과의 비교. 이러한 방법들은 대부분 몇 분이면 적용할 수 있지만, 잘못된 모델을 디버깅하며 허비할 수 있는 수주일의 시간을 절약해 준다.

마크 트웨인은 "역사는 반복되지 않지만 놀라울정도로 비슷하다"고 말했다.

시계열 데이터 역시 마찬가지다. 과거는 미래를 그대로 재현하지는 않지만, 비슷한 패턴을 남긴다. 중요한 것은 모델이 그 패턴을 올바르게 읽을 수 있도록 만드는 것이다.

역사는 분명 패턴이 있다. 당신의 모델이 올바른 패턴에 귀를 기울이고 있는지 확인하자.

 

<출처: https://medium.com/@rccareers3004/the-top-10-time-series-forecasting-mistakes-every-data-scientist-makes-and-how-to-fix-them-375a1c3972c4>

댓글