시계열 EDA
시계열 데이터(Time Series Data)는 시간 순서에 따라 관측된 데이터입니다. 트렌드(Trend), 계절성(Seasonality), 잔차(Residual)를 분해하여 데이터의 시간적 패턴을 이해하고, 이동평균으로 노이즈를 제거하여 핵심 추세를 파악합니다.
학습 목표
- 시계열 데이터의 구성 요소(트렌드, 계절성, 잔차)를 이해한다
- 시계열 분해(decomposition)를 수행할 수 있다
- 이동평균과 지수가중이동평균을 계산하고 해석할 수 있다
- 정상성(Stationarity)을 검정할 수 있다
- 시계열 EDA 결과를 인사이트로 정리할 수 있다
왜 중요한가
주가, 매출, 트래픽, 센서 데이터 등 실무 데이터의 상당 부분이 시계열입니다. 시계열의 구조를 이해해야 적절한 예측 모델(ARIMA, Prophet, LSTM)을 선택할 수 있습니다.
Step 1: 시계열 데이터 생성
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
np.random.seed(42)
# 일별 매출 데이터 (2년간)
dates = pd.date_range('2023-01-01', '2024-12-31', freq='D')
n = len(dates)
# 구성 요소 생성
trend = np.linspace(100, 200, n) # 상승 트렌드
seasonality = 30 * np.sin(2 * np.pi * np.arange(n) / 365) # 연간 계절성
weekly = 10 * np.sin(2 * np.pi * np.arange(n) / 7) # 주간 패턴
noise = np.random.normal(0, 8, n) # 랜덤 노이즈
sales = trend + seasonality + weekly + noise
ts = pd.DataFrame({
'date': dates,
'sales': sales
}).set_index('date')
print(f"기간: {ts.index.min()} ~ {ts.index.max()}")
print(f"데이터 포인트: {len(ts)}")
print(ts.describe().round(1))
# 전체 시계열 시각화
fig, ax = plt.subplots(figsize=(14, 5))
ax.plot(ts.index, ts['sales'], linewidth=0.8, color='#4a9eca', alpha=0.7)
ax.set_title('일별 매출 추이 (2023-2024)')
ax.set_xlabel('날짜')
ax.set_ylabel('매출')
plt.tight_layout()
plt.show()
Step 2: 시계열 분해
from statsmodels.tsa.seasonal import seasonal_decompose
# 가법 분해 (additive decomposition)
decomposition = seasonal_decompose(ts['sales'], model='additive', period=365)
fig, axes = plt.subplots(4, 1, figsize=(14, 10), sharex=True)
components = [
('원본 데이터', decomposition.observed, '#4a9eca'),
('트렌드', decomposition.trend, '#4a9e4a'),
('계절성', decomposition.seasonal, '#e6a23c'),
('잔차', decomposition.resid, '#666666')
]
for ax, (title, data, color) in zip(axes, components):
ax.plot(data, color=color, linewidth=0.8)
ax.set_title(title)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
가법 분해(Additive): 원본 = 트렌드 + 계절성 + 잔차. 승법 분해(Multiplicative): 원본 = 트렌드 x 계절성 x 잔차. 계절 변동이 트렌드 크기에 비례하면 승법 분해를 사용합니다.
Step 3: 이동평균
# 이동평균 계산
ts['ma_7'] = ts['sales'].rolling(window=7).mean() # 7일 이동평균 (주간 노이즈 제거)
ts['ma_30'] = ts['sales'].rolling(window=30).mean() # 30일 이동평균 (월간 추세)
ts['ma_90'] = ts['sales'].rolling(window=90).mean() # 90일 이동평균 (분기 추세)
# 지수가중이동평균 (최근 데이터에 가중치)
ts['ewm_7'] = ts['sales'].ewm(span=7).mean()
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(ts.index, ts['sales'], alpha=0.3, color='#4a9eca', label='원본', linewidth=0.8)
ax.plot(ts.index, ts['ma_7'], color='#4a9e4a', label='7일 MA', linewidth=1.5)
ax.plot(ts.index, ts['ma_30'], color='#e6a23c', label='30일 MA', linewidth=2)
ax.plot(ts.index, ts['ma_90'], color='#e91e63', label='90일 MA', linewidth=2)
ax.set_title('이동평균 비교')
ax.legend()
plt.tight_layout()
plt.show()
이동평균 vs 지수가중이동평균
fig, ax = plt.subplots(figsize=(14, 5))
# 최근 90일만 확대
recent = ts.tail(90)
ax.plot(recent.index, recent['sales'], alpha=0.4, color='#4a9eca', label='원본')
ax.plot(recent.index, recent['ma_7'], color='#4a9e4a', label='SMA 7일')
ax.plot(recent.index, recent['ewm_7'], color='#e6a23c', linestyle='--', label='EWM 7일')
ax.set_title('SMA vs EWM 비교 (최근 90일)')
ax.legend()
plt.tight_layout()
plt.show()
| 방법 | 특징 | 적합한 상황 |
|---|
| SMA (단순 이동평균) | 동일 가중치 | 안정적 트렌드, 노이즈 제거 |
| EWM (지수가중이동평균) | 최근에 높은 가중치 | 변동이 크고 최근 트렌드가 중요한 경우 |
Step 4: 계절성 분석
# 월별 패턴
ts['month'] = ts.index.month
monthly_pattern = ts.groupby('month')['sales'].agg(['mean', 'std'])
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 월별 평균 매출
axes[0].bar(monthly_pattern.index, monthly_pattern['mean'], color='#4a9eca', alpha=0.7)
axes[0].errorbar(monthly_pattern.index, monthly_pattern['mean'],
yerr=monthly_pattern['std'], fmt='none', color='black', capsize=3)
axes[0].set_title('월별 평균 매출')
axes[0].set_xlabel('월')
axes[0].set_ylabel('매출')
axes[0].set_xticks(range(1, 13))
# 요일별 패턴
ts['dow'] = ts.index.dayofweek
dow_pattern = ts.groupby('dow')['sales'].mean()
axes[1].bar(range(7), dow_pattern.values, color='#4a9e4a', alpha=0.7)
axes[1].set_title('요일별 평균 매출')
axes[1].set_xticks(range(7))
axes[1].set_xticklabels(['월', '화', '수', '목', '금', '토', '일'])
plt.tight_layout()
plt.show()
# 연도별 비교
ts['year'] = ts.index.year
fig, ax = plt.subplots(figsize=(14, 5))
for year in ts['year'].unique():
yearly = ts[ts['year'] == year]
ax.plot(yearly.index.dayofyear, yearly['sales'].rolling(7).mean(),
label=str(year), linewidth=1.5)
ax.set_title('연도별 매출 패턴 비교')
ax.set_xlabel('일 (1월 1일 = 1)')
ax.legend()
plt.tight_layout()
plt.show()
Step 5: 정상성 검정
from statsmodels.tsa.stattools import adfuller
def test_stationarity(series, name=''):
"""Augmented Dickey-Fuller 검정으로 정상성을 테스트합니다."""
result = adfuller(series.dropna(), autolag='AIC')
print(f"\n{name} ADF 검정:")
print(f" 검정 통계량: {result[0]:.4f}")
print(f" p-value: {result[1]:.4f}")
print(f" 사용된 지연: {result[2]}")
for key, val in result[4].items():
print(f" 임계값 ({key}): {val:.4f}")
if result[1] < 0.05:
print(f" → 정상 시계열 (p < 0.05)")
else:
print(f" → 비정상 시계열 (p >= 0.05) — 차분 필요")
# 원본 데이터 검정
test_stationarity(ts['sales'], '원본 매출')
# 1차 차분 후 검정
ts['sales_diff'] = ts['sales'].diff()
test_stationarity(ts['sales_diff'], '1차 차분')
자기상관 분석
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
fig, axes = plt.subplots(1, 2, figsize=(14, 4))
plot_acf(ts['sales'].dropna(), lags=60, ax=axes[0], color='#4a9eca')
axes[0].set_title('자기상관함수 (ACF)')
plot_pacf(ts['sales'].dropna(), lags=60, ax=axes[1], color='#4a9eca')
axes[1].set_title('편자기상관함수 (PACF)')
plt.tight_layout()
plt.show()
ACF에서 7일 간격으로 스파이크가 보이면 주간 계절성이, 365일 간격이면 연간 계절성이 있는 것입니다. PACF는 ARIMA 모델의 p 파라미터를 결정하는 데 사용합니다.
Step 6: 인사이트 정리
insights = """
## 시계열 EDA 인사이트
### 트렌드
- 2년간 꾸준한 상승 트렌드 (일평균 100 → 200)
- 성장률: 약 100% (연평균 50%)
### 계절성
- 연간: 여름(6-8월)에 매출 최고, 겨울(12-2월)에 최저
- 주간: 주중 대비 주말 매출 10~15% 높음
### 정상성
- 원본 데이터: 비정상 (트렌드 존재)
- 1차 차분 후: 정상 시계열
### ML 확장 제안
- **단기 예측**: ARIMA (차분 후 정상성 확보)
- **계절성 반영**: SARIMA 또는 Prophet
- **복잡한 패턴**: LSTM (딥러닝 시계열 모델)
- **피처 기반**: 시계열 피처(lag, rolling mean) + Random Forest
"""
print(insights)
ML로 확장하기
# 시계열 피처 생성 → ML 모델에 활용
def create_ts_features(df, target_col, lags=[1, 7, 14, 30]):
"""시계열 피처를 생성합니다."""
features = pd.DataFrame(index=df.index)
# Lag 피처
for lag in lags:
features[f'lag_{lag}'] = df[target_col].shift(lag)
# Rolling 통계
for window in [7, 14, 30]:
features[f'rolling_mean_{window}'] = df[target_col].rolling(window).mean()
features[f'rolling_std_{window}'] = df[target_col].rolling(window).std()
# 날짜 피처
features['month'] = df.index.month
features['day_of_week'] = df.index.dayofweek
features['day_of_year'] = df.index.dayofyear
# 순환 피처
features['month_sin'] = np.sin(2 * np.pi * features['month'] / 12)
features['month_cos'] = np.cos(2 * np.pi * features['month'] / 12)
return features
ts_features = create_ts_features(ts, 'sales')
print(f"생성된 피처: {ts_features.columns.tolist()}")
print(f"피처 수: {ts_features.shape[1]}")
# 학습/테스트 분할 (시계열은 시간 순서 유지)
train_size = int(len(ts) * 0.8)
train = ts_features.iloc[:train_size].dropna()
test = ts_features.iloc[train_size:].dropna()
print(f"\n학습 기간: {train.index.min()} ~ {train.index.max()}")
print(f"테스트 기간: {test.index.min()} ~ {test.index.max()}")
시계열 데이터의 학습/테스트 분할은 반드시 시간 순서를 유지해야 합니다. 무작위 분할은 미래 정보가 학습에 포함되어 데이터 누수가 발생합니다.
시계열 분석에서 cross_val_score를 사용해도 되나요?
일반적인 K-Fold 교차검증은 시간 순서를 무시하므로 사용하면 안 됩니다. TimeSeriesSplit을 사용하면 시간 순서를 유지하면서 교차검증을 수행할 수 있습니다.
체크리스트
다음 문서