"모든 이상치가 오류는 아니다. 하지만 어떤 이상치는 분명히 문제가 있다."
서론: 모델을 망가뜨린 단 하나의 데이터 포인트
당신의 시계열 예측 모델은 완벽하게 작동하고 있었다. 12개월 동안의 깔끔한 주간 판매 데이터, 일관된 패턴, 그리고 합리적인 예측 결과.
그런데 어느 주에 판매량이 평소의 10배까지 급증했다. 데이터에는 기록되지 않은 일회성 플래시 세일이 있었던 것이다.
모델은 이 급증을 실제 추세로 받아들였고, 기준 수준(baseline)을 상향 조정했다. 그 결과 이후 6개월 동안의 모든 예측이 틀어졌다.
이것이 이상치(outlier)가 시계열 모델에 미치는 영향이다. 이상치는 단순한 잡음(noise)이 아니다. 모델이 잘못 해석하고, 잘못 학습하며, 그 결과를 미래로 계속 전파하는 신호(signal)다. 10주 차의 이상치 하나가 70주 차의 예측에도 영향을 미칠 수 있다.
하지만 여기에는 훌륭한 실무자와 탁월한 실무자를 구분하는 중요한 차이가 있다. 모든 이상치를 제거해야 하는 것은 아니다. 데이터 입력 오류로 발생한 급증이라면 수정해야 한다. 실제로 발생한 일회성 이벤트로 인한 급증이라면 문서화하고 모델에 반영해야 한다. 예상하지 못했더라도 실제로 일어난 현상이라면 그대로 유지해야 한다. 이상치를 어떻게 처리할지는 전적으로 그것이 왜 발생했는지에 달려 있다.
이 가이드는 이상치 처리의 전 과정을 다룬다. 이상치를 탐지하는 방법, 원인을 진단하기 위해 던져야 할 질문들, 그리고 상황에 따라 선택할 수 있는 다양한 처리 방법까지 모두 살펴본다.
Part 1: 시계열에서 이상치가 특별한 이유
일반적인 테이블 형태의 데이터에서 이상치는 단순히 하나의 비정상적인 값이다. 이를 식별한 뒤 필요하면 제거하고 넘어가면 된다. 다른 관측값들은 영향을 받지 않는다.
하지만 시계열 데이터에서 이상치는 훨씬 더 위험하다. 그 이유는 크게 세 가지다.
이동 통계량(Moving Statistics)의 오염
이동평균(Rolling Mean), 이동 표준편차(Rolling Standard Deviation), 지수평활법(Exponential Smoothing)과 같은 기법들은 이상치의 영향을 미래 시점까지 전달한다. 단 한 번의 급격한 스파이크도 해당 값이 포함된 모든 이동 윈도우에 영향을 미친다.
학습된 모수(Parameter)의 왜곡
ARIMA와 같은 모델은 오차가 정규분포를 따른다는 가정하에 자기회귀(AR) 및 이동평균(MA) 계수를 추정한다. 큰 이상치가 존재하면 잔차 분산 추정치가 과도하게 증가하고, 결과적으로 모든 계수 추정에 편향이 발생할 수 있다.
계절성 패턴의 교란
이상치가 특정 요일이나 특정 주차에 발생하면 모델은 이를 실제 계절성 패턴으로 오해할 수 있다. 그 결과 존재하지 않는 계절성을 학습하게 되어 예측 성능이 저하될 수 있다.
시계열 이상치의 유형 (표준 분류 체계)
Additive Outlier (AO): A sudden spike or dip at a single point.
Returns to normal immediately.
Cause: data entry error, short promotion, system glitch.
Level Shift (LS): A permanent step change upward or downward.
Returns to a new baseline, not the old one.
Cause: pricing change, new market entry, operational shift.
Temporary Change (TC): A spike that decays exponentially back to baseline.
Cause: holiday effect, product launch, weather event.
Innovative Outlier (IO): A shock that propagates through the ARIMA dynamics.
Affects all subsequent forecasts through the model structure.
Most dangerous for forecast accuracy.
증상의 유형을 파악하는 것이 중요한데, 유형에 따라 치료 방법이 다르기 때문이다.
Part 2: 이상치 탐지 방법
방법 1: Z-점수(Z-Score)와 수정 Z-점수(Modified Z-Score)
가장 단순한 접근법은 평균으로부터 k배의 표준편차 이상 떨어진 값을 이상치로 표시하는 것이다.
Standard z-score:
z(t) = (y(t) − μ) / σ
Flag as outlier if |z(t)| > k (typically k = 3)
Problem: mean and standard deviation are themselves distorted by outliers.
수정 Z-점수 (더 견고함 — 중앙값과 MAD를 사용함):
MAD = median(|y(t) − median(y)|)
Modified z-score:
M(t) = 0.6745 × (y(t) − median(y)) / MAD
Flag as outlier if |M(t)| > 3.5
The constant 0.6745 = Φ⁻¹(0.75) makes MAD consistent with σ for normal data.
import numpy as np
import pandas as pd
def modified_zscore(series):
median = series.median()
mad = (series - median).abs().median()
return 0.6745 * (series - median) / mad
scores = modified_zscore(df['sales'])
outliers = df[scores.abs() > 3.5]
print(f"Flagged {len(outliers)} potential outliers")
방법 2: IQR 기반 탐지
Q1 = 25th percentile of y
Q3 = 75th percentile of y
IQR = Q3 − Q1
Lower fence = Q1 − 1.5 × IQR
Upper fence = Q3 + 1.5 × IQR
Flag as outlier if y(t) < Lower fence OR y(t) > Upper fence
For more extreme threshold:
Lower fence = Q1 − 3.0 × IQR
Upper fence = Q3 + 3.0 × IQR
Q1 = df['sales'].quantile(0.25)
Q3 = df['sales'].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
outliers_iqr = df[(df['sales'] < lower) | (df['sales'] > upper)]
방법 3: 계절성 분해(Seasonal Decomposition) 잔차 분석
시계열을 추세(Trend), 계절성(Seasonality), 잔차(Residual) 구성 요소로 분해한 후 이상치를 탐지하는 방법이다.
기본 아이디어는 간단하다. 데이터의 추세와 계절성 패턴을 먼저 설명한 뒤, 이들로 설명되지 않는 부분을 잔차로 남긴다. 이상치는 일반적으로 이러한 잔차에서 매우 큰 값으로 나타난다.
from statsmodels.tsa.seasonal import STL
# STL decomposition (robust to outliers)
stl = STL(df['sales'], period=52, robust=True)
result = stl.fit()
residuals = result.resid
residual_scores = modified_zscore(pd.Series(residuals))
outlier_dates = df.index[residual_scores.abs() > 3.5]
print(f"Outlier dates: {outlier_dates.tolist()}")
특히 STL의 robust=True 옵션은 이상치 탐지에 매우 유용하다. 이 옵션은 반복적 가중치 재추정(Iteratively Reweighted Fitting) 기법을 사용하여 각 반복 단계에서 큰 잔차를 갖는 관측값의 영향력을 점진적으로 낮춘다.
방법 4: ARIMA 잔차 분석
ARIMA 모델을 적합한 후 잔차를 살펴본다. 큰 표준화 잔차(Standardised Residuals)를 가진 관측값은 모델이 설명할 수 없는 이상치이다.
from statsmodels.tsa.statespace.sarimax import SARIMAX
model = SARIMAX(df['sales'], order=(1,1,1), seasonal_order=(1,1,1,52)).fit(disp=False)
residuals = model.resid
# Standardised residuals
std_residuals = (residuals - residuals.mean()) / residuals.std()
outlier_idx = std_residuals[std_residuals.abs() > 3].index
Part 3: 이상치가 발생한 원인 진단하기
이상치 탐지(Detection)는 이상치 후보 목록을 제공한다. 하지만 진단(Diagnosis)은 그 이상치를 어떻게 처리해야 하는지를 알려준다.
이상치로 표시된 모든 데이터 포인트에 대해 다음 질문들을 던져보자.
Question 1: Is this a data error?
→ Check source systems, ask data owners, look for implausible values
(negative sales, revenue in wrong currency, unit mismatch)
→ If yes: correct or remove
Question 2: Is there a documented real-world explanation?
→ Check event logs, marketing calendars, operational notes, external events
→ Spike on 15 August → national holiday, not anomaly
→ If yes: model it explicitly (event/holiday feature), do not remove
Question 3: Is this a genuine but undocumented event?
→ Could this level have occurred legitimately, just unusually?
→ If yes: retain the value, consider robust models
Question 4: Is this a level shift or additive outlier?
→ Look at the subsequent periods. Does the series return to baseline (AO)?
Or does it stay at the new level (LS)?
→ Different treatments apply
이 진단 과정은 표시된 지점당 5분이 소요되며, 잘못된 치료 방법을 선택하는 실수를 방지한다.
Part 4: 이상치 처리 방법
처리 방법 1: 대체(Imputation) (가법적 이상치(Additive Outliers) 또는 데이터 오류의 경우)
이상치를 주변 데이터의 패턴과 일관된 값으로 대체한다.
# Linear interpolation
df['sales_clean'] = df['sales'].copy()
df.loc[outlier_dates, 'sales_clean'] = np.nan
df['sales_clean'] = df['sales_clean'].interpolate(method='linear')
# Seasonal interpolation — use the same period from adjacent years
def seasonal_impute(series, date, period=52):
prev = series.get(date - pd.DateOffset(weeks=period), np.nan)
next_ = series.get(date + pd.DateOffset(weeks=period), np.nan)
return np.nanmean([prev, next_])
# Rolling median replacement
window = 7
df['rolling_median'] = df['sales'].rolling(window, center=True, min_periods=1).median()
df.loc[outlier_dates, 'sales_clean'] = df.loc[outlier_dates, 'rolling_median']
처리 방법 2: 윈저화(Winsorisation) / 클리핑(Clipping)
극단적인 값을 완전히 제거하는 대신, 미리 정한 임계값(threshold)으로 제한한다.
Winsorised value:
y_win(t) = max(lower_fence, min(y(t), upper_fence))
경계값(fence)은 일반적으로 사분위 범위(IQR) 또는 백분위수(percentile) 기준으로 설정한다.
lower = df['sales'].quantile(0.01)
upper = df['sales'].quantile(0.99)
df['sales_winsorised'] = df['sales'].clip(lower=lower, upper=upper)
처리 방법 3: 강건한(Robust) 모델 사용
데이터를 정제하는 대신, 본질적으로 이상치에 강한 모델을 사용한다.
STL + 강건 평활화(Robust Smoothing)
(이상치 탐지에서 이미 살펴본 방법으로, 탐지에 도움이 되는 동일한 강건성이 모델 적합에도 도움이 된다.)
머신러닝 기반 예측 모델을 위한 Huber 손실(Huber Loss)
Huber Loss:
L_δ(r) = r² / 2 if |r| ≤ δ
= δ·(|r| − δ/2) if |r| > δ
For small errors: behaves like squared loss (sensitive, learns from clean data)
For large errors: behaves like absolute loss (robust, does not overfit to outliers)
from sklearn.linear_model import HuberRegressor
# HuberRegressor with lag features — robust to outlier contamination
model = HuberRegressor(epsilon=1.35, max_iter=200)
model.fit(X_train_lags, y_train)
처리 방법 4: 알려진 이벤트를 위한 더미 변수(Dummy Variables)
알려진 일회성 이벤트를 처리하는 가장 원칙적인 방법은 모델에 이진 지시 변수(binary indicator)를 추가하는 것이다.
from statsmodels.tsa.statespace.sarimax import SARIMAX
# Create exogenous variable for the flash sale event
df['flash_sale'] = 0
df.loc['2023-11-20', 'flash_sale'] = 1 # Known one-time event
model = SARIMAX(
df['sales'],
exog=df['flash_sale'],
order=(1,1,1),
seasonal_order=(1,1,1,52)
).fit(disp=False)
이 모델은 플래시 세일의 영향을 별도로 추정하며, 이를 기준 모델이나 계절적 패턴에 반영하지 않는다.
Part 5: 평가(Evaluation) — 이상치 처리가 실제로 도움이 되었는가?
이상치를 처리한 후에는 해당 처리가 실제로 표본 외(out-of-sample) 예측 성능을 개선했는지 검증해야 한다.
Before treatment MAPE:
MAPE = (100/n) × Σ|(y(t) − ŷ(t)) / y(t)|
After treatment MAPE (on hold-out test set):
Lower MAPE → treatment improved the forecast
Residual autocorrelation (Ljung-Box test):
H₀: residuals are white noise (no autocorrelation)
If p > 0.05 after treatment → model is correctly specified
Residual distribution check:
Plot histogram of residuals — should be approximately symmetric
Large tails suggest remaining outliers
from statsmodels.stats.diagnostic import acorr_ljungbox
lb_test = acorr_ljungbox(model.resid, lags=20, return_df=True)
print(lb_test[lb_test['lb_pvalue'] < 0.05]) # Lags with significant autocorrelation
결론: 본능이 아니라 의도를 가지고 처리하라
이상치 처리는 시계열 분석에서 건너뛸 수 있는 단계도 아니고, 버튼 하나로 해결되는 작업도 아니다. 이는 모델이 학습하는 기준선(baseline), 포착하는 계절성 패턴, 그리고 앞으로 수개월 동안 생성할 예측 결과에 영향을 미치는 중요한 의사결정이다.
기본적인 프레임워크는 항상 동일하다. 먼저 강건한 방법으로 이상치를 탐지하고, 그 원인을 진단한 뒤, 발생 이유에 맞는 처리 방법을 선택한다. 데이터 입력 오류라면 대체(imputation)를 수행한다. 알려진 이벤트라면 더미 변수를 추가한다. 실제로 발생한 극단값이라면 강건한 모델을 사용하거나 윈저화(winsorisation)를 적용한다. 수준 변화(level shift)라면 변화 시점을 탐지하고 모델에 반영한다.
12월에 당신의 모델을 망가뜨린 이상치는 어쩌면 데이터에서 가장 중요한 관측값일 수도 있다. 중요한 것은 그것을 무조건 제거하는 것이 아니라, 그 값이 무엇을 말하고 있는지 이해하기 위해 시간을 들이는 것이다.
'실전에서 바로 쓰는 시계열 데이터 처리와 분석 in R' 카테고리의 다른 글
| 시계열의 테이블화: ML 기반 예측의 기초 (0) | 2026.06.18 |
|---|---|
| 인과추론 적용 v3: 베이지안 구조적 시계열(BSTS) 소개 (0) | 2026.06.12 |
| 시계열 예측에서 모든 데이터 과학자가 저지르는 10가지 실수와 해결 방법 (0) | 2026.05.30 |
| 세종 우수도서 선정! (0) | 2023.08.27 |
| 데이터홀릭 도서 증정 이벤트 (0) | 2021.10.29 |
댓글