본문으로 건너뛰기

054 정상성과 차분

키워드: 정상성, stationarity, 차분, differencing

개요

정상성(Stationarity)은 시계열 분석의 핵심 개념입니다. 많은 통계적 모델은 정상 시계열을 가정하며, 비정상 시계열은 차분(Differencing)을 통해 정상화할 수 있습니다.

실습 환경

  • Python 버전: 3.11 권장
  • 필요 패키지: statsmodels, pandas, numpy
pip install statsmodels pandas numpy matplotlib scipy

정상성의 정의

정상 시계열의 조건

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.stattools import adfuller, kpss

# 054 정상 시계열 (White Noise)
np.random.seed(42)
n = 500
stationary = np.random.randn(n)

# 054 비정상 시계열 (랜덤 워크)
non_stationary = np.cumsum(np.random.randn(n))

# 054 트렌드가 있는 시계열
with_trend = np.linspace(0, 10, n) + np.random.randn(n)

# 054 시각화
fig, axes = plt.subplots(3, 2, figsize=(14, 10))

# 054 정상 시계열
axes[0, 0].plot(stationary)
axes[0, 0].set_title('Stationary (White Noise)')
axes[0, 1].hist(stationary, bins=30, edgecolor='black')
axes[0, 1].set_title('Distribution')

# 054 비정상 시계열 (랜덤 워크)
axes[1, 0].plot(non_stationary)
axes[1, 0].set_title('Non-Stationary (Random Walk)')
axes[1, 1].hist(non_stationary, bins=30, edgecolor='black')
axes[1, 1].set_title('Distribution')

# 054 트렌드 있는 시계열
axes[2, 0].plot(with_trend)
axes[2, 0].set_title('Non-Stationary (With Trend)')
axes[2, 1].hist(with_trend, bins=30, edgecolor='black')
axes[2, 1].set_title('Distribution')

plt.tight_layout()
plt.show()

# 054 정상성 조건
conditions = {
'조건': ['상수 평균', '상수 분산', '자기공분산'],
'설명': [
'E[Y_t] = μ (시간에 상관없이 일정)',
'Var(Y_t) = σ² (시간에 상관없이 일정)',
'Cov(Y_t, Y_{t+k}) = γ_k (시간 차이에만 의존)'
]
}

print("정상 시계열 조건:")
print(pd.DataFrame(conditions).to_string(index=False))

이동 평균과 분산 확인

def check_stationarity_visual(series, window=50):
"""이동 평균/분산으로 정상성 시각적 확인"""

rolling_mean = pd.Series(series).rolling(window=window).mean()
rolling_std = pd.Series(series).rolling(window=window).std()

fig, axes = plt.subplots(2, 1, figsize=(14, 8))

# 원본 + 이동 평균
axes[0].plot(series, alpha=0.5, label='Original')
axes[0].plot(rolling_mean, 'r-', linewidth=2, label=f'Rolling Mean ({window})')
axes[0].axhline(y=np.mean(series), color='green', linestyle='--', label='Overall Mean')
axes[0].legend()
axes[0].set_title('Rolling Mean')

# 이동 표준편차
axes[1].plot(rolling_std, 'orange', linewidth=2)
axes[1].axhline(y=np.std(series), color='green', linestyle='--', label='Overall Std')
axes[1].legend()
axes[1].set_title('Rolling Standard Deviation')

plt.tight_layout()
plt.show()

# 054 정상 시계열 확인
print("정상 시계열:")
check_stationarity_visual(stationary)

# 054 비정상 시계열 확인
print("\n비정상 시계열 (랜덤 워크):")
check_stationarity_visual(non_stationary)

정상성 검정

ADF 검정 (Augmented Dickey-Fuller)

def adf_test(series, name=''):
"""ADF 검정 수행"""
result = adfuller(series, autolag='AIC')

print(f"ADF Test - {name}")
print(f" Test Statistic: {result[0]:.4f}")
print(f" p-value: {result[1]:.4f}")
print(f" Critical Values:")
for key, value in result[4].items():
print(f" {key}: {value:.4f}")

if result[1] < 0.05:
print(f" → 결론: 정상 (p < 0.05, 귀무가설 기각)")
else:
print(f" → 결론: 비정상 (p >= 0.05, 귀무가설 채택)")

return result[1] < 0.05 # True if stationary

# 054 검정 수행
print("=" * 50)
is_stationary_1 = adf_test(stationary, 'White Noise')
print()
is_stationary_2 = adf_test(non_stationary, 'Random Walk')
print()
is_stationary_3 = adf_test(with_trend, 'With Trend')

KPSS 검정

def kpss_test(series, name=''):
"""KPSS 검정 수행"""
result = kpss(series, regression='c', nlags='auto')

print(f"KPSS Test - {name}")
print(f" Test Statistic: {result[0]:.4f}")
print(f" p-value: {result[1]:.4f}")
print(f" Critical Values:")
for key, value in result[3].items():
print(f" {key}: {value:.4f}")

# KPSS는 귀무가설이 '정상'
if result[1] < 0.05:
print(f" → 결론: 비정상 (p < 0.05, 귀무가설 기각)")
else:
print(f" → 결론: 정상 (p >= 0.05, 귀무가설 채택)")

return result[1] >= 0.05 # True if stationary

print("=" * 50)
kpss_test(stationary, 'White Noise')
print()
kpss_test(non_stationary, 'Random Walk')

종합 정상성 판단

def comprehensive_stationarity_test(series, name=''):
"""ADF + KPSS 종합 검정"""

adf_result = adfuller(series, autolag='AIC')
kpss_result = kpss(series, regression='c', nlags='auto')

adf_stationary = adf_result[1] < 0.05
kpss_stationary = kpss_result[1] >= 0.05

print(f"\n종합 정상성 검정 - {name}")
print(f" ADF p-value: {adf_result[1]:.4f}{'정상' if adf_stationary else '비정상'}")
print(f" KPSS p-value: {kpss_result[1]:.4f}{'정상' if kpss_stationary else '비정상'}")

if adf_stationary and kpss_stationary:
print(" → 최종 결론: 정상")
return 'stationary'
elif not adf_stationary and not kpss_stationary:
print(" → 최종 결론: 비정상")
return 'non-stationary'
else:
print(" → 최종 결론: 추가 분석 필요 (트렌드 정상 가능)")
return 'trend-stationary'

# 054 테스트
comprehensive_stationarity_test(stationary, 'White Noise')
comprehensive_stationarity_test(non_stationary, 'Random Walk')
comprehensive_stationarity_test(with_trend, 'With Trend')

차분 (Differencing)

1차 차분

# 054 차분 함수
def difference(series, periods=1):
"""차분 수행"""
return pd.Series(series).diff(periods).dropna()

# 054 랜덤 워크에 차분 적용
diff_random_walk = difference(non_stationary, 1)

# 054 시각화
fig, axes = plt.subplots(2, 2, figsize=(14, 8))

axes[0, 0].plot(non_stationary)
axes[0, 0].set_title('Original (Random Walk)')

axes[0, 1].plot(diff_random_walk)
axes[0, 1].set_title('After 1st Differencing')

# 054 이동 통계
axes[1, 0].plot(pd.Series(non_stationary).rolling(50).mean())
axes[1, 0].set_title('Rolling Mean - Original')

axes[1, 1].plot(diff_random_walk.rolling(50).mean())
axes[1, 1].set_title('Rolling Mean - After Differencing')

plt.tight_layout()
plt.show()

# 054 차분 후 정상성 검정
print("차분 후 정상성 검정:")
adf_test(diff_random_walk.values, 'Differenced Random Walk')

계절 차분

# 054 계절성이 있는 시계열
np.random.seed(42)
n = 730 # 2년
t = np.arange(n)
seasonal = 50 + 0.05 * t + 10 * np.sin(2 * np.pi * t / 365) + np.random.randn(n) * 3

# 1차 차분
diff1 = difference(seasonal, 1)

# 054 계절 차분 (365일)
diff_seasonal = difference(seasonal, 365)

# 1차 + 계절 차분
diff_both = difference(difference(seasonal, 1), 365)

# 054 시각화
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

axes[0, 0].plot(seasonal)
axes[0, 0].set_title('Original (Trend + Seasonality)')

axes[0, 1].plot(diff1)
axes[0, 1].set_title('After 1st Differencing')

axes[1, 0].plot(diff_seasonal)
axes[1, 0].set_title('After Seasonal Differencing (365)')

axes[1, 1].plot(diff_both)
axes[1, 1].set_title('After 1st + Seasonal Differencing')

plt.tight_layout()
plt.show()

# 054 정상성 검정
print("\n차분별 정상성:")
for name, data in [('Original', seasonal), ('1st Diff', diff1),
('Seasonal Diff', diff_seasonal), ('Both', diff_both)]:
result = adfuller(data.dropna() if hasattr(data, 'dropna') else data)
print(f" {name}: ADF p-value = {result[1]:.4f}")

최적 차분 차수 찾기

from statsmodels.tsa.stattools import adfuller

def find_optimal_d(series, max_d=3, significance=0.05):
"""최적 차분 차수 자동 결정"""

current_series = pd.Series(series).copy()

for d in range(max_d + 1):
if d > 0:
current_series = current_series.diff().dropna()

# ADF 검정
result = adfuller(current_series, autolag='AIC')
p_value = result[1]

print(f"d={d}: ADF p-value = {p_value:.4f}", end='')

if p_value < significance:
print(f" → 정상화됨")
return d
else:
print(f" → 비정상")

print(f"경고: {max_d}차 차분까지도 정상화되지 않음")
return max_d

print("최적 차분 차수 찾기:")
print("\nRandom Walk:")
optimal_d = find_optimal_d(non_stationary)
print(f"→ 최적 d = {optimal_d}")

print("\nSeasonal + Trend:")
optimal_d = find_optimal_d(seasonal)
print(f"→ 최적 d = {optimal_d}")

로그 변환과 차분

# 054 승법적 계절성이 있는 데이터
np.random.seed(42)
multiplicative = 100 * np.exp(0.001 * t) * (1 + 0.3 * np.sin(2 * np.pi * t / 365))
multiplicative = multiplicative * (1 + np.random.randn(n) * 0.05)

# 054 로그 변환 후 차분
log_series = np.log(multiplicative)
log_diff = difference(log_series, 1)

# 054 시각화
fig, axes = plt.subplots(2, 2, figsize=(14, 8))

axes[0, 0].plot(multiplicative)
axes[0, 0].set_title('Original (Multiplicative)')

axes[0, 1].plot(log_series)
axes[0, 1].set_title('After Log Transform')

axes[1, 0].plot(difference(multiplicative, 1))
axes[1, 0].set_title('Direct Differencing')

axes[1, 1].plot(log_diff)
axes[1, 1].set_title('Log + Differencing')

plt.tight_layout()
plt.show()

# 054 정상성 비교
print("\n로그 변환 효과:")
result1 = adfuller(difference(multiplicative, 1).dropna())
result2 = adfuller(log_diff.dropna())
print(f" 직접 차분 ADF p-value: {result1[1]:.4f}")
print(f" 로그 + 차분 ADF p-value: {result2[1]:.4f}")

차분과 FLAML

class StationaryTransformer:
"""정상화 변환기"""

def __init__(self, max_d=2, use_log=False):
self.max_d = max_d
self.use_log = use_log
self.d = 0
self.initial_values = []

def fit_transform(self, series):
series = pd.Series(series).copy()

# 로그 변환
if self.use_log:
series = np.log(series)

# 최적 차분 찾기
for d in range(self.max_d + 1):
result = adfuller(series.dropna())
if result[1] < 0.05:
self.d = d
break

self.initial_values.append(series.iloc[0])
series = series.diff().dropna()

return series

def inverse_transform(self, series):
series = pd.Series(series).copy()

# 역차분
for initial in reversed(self.initial_values):
series = series.cumsum() + initial

# 역로그
if self.use_log:
series = np.exp(series)

return series

# 054 사용 예
transformer = StationaryTransformer(max_d=2, use_log=False)
stationary_series = transformer.fit_transform(non_stationary)

print(f"필요한 차분 횟수: {transformer.d}")
print(f"변환 후 길이: {len(stationary_series)}")

정리

  • 정상성: 평균, 분산, 자기공분산이 시간에 상관없이 일정
  • ADF 검정: 귀무가설 = 비정상 (p < 0.05면 정상)
  • KPSS 검정: 귀무가설 = 정상 (p >= 0.05면 정상)
  • 차분: 비정상 시계열을 정상화하는 방법
  • 계절 차분: 계절성 제거를 위한 주기적 차분
  • FLAML 사용 시 Lag 특성차분 특성 활용

다음 글 예고

다음 글에서는 ARIMA 기초에 대해 알아보겠습니다. 시계열 예측의 고전적 방법인 ARIMA 모델을 소개합니다.


FLAML AutoML 마스터 시리즈 #054