Skip to main content

시계열 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()}")
시계열 데이터의 학습/테스트 분할은 반드시 시간 순서를 유지해야 합니다. 무작위 분할은 미래 정보가 학습에 포함되어 데이터 누수가 발생합니다.
일반적인 K-Fold 교차검증은 시간 순서를 무시하므로 사용하면 안 됩니다. TimeSeriesSplit을 사용하면 시간 순서를 유지하면서 교차검증을 수행할 수 있습니다.

체크리스트

  • 시계열 데이터의 트렌드, 계절성, 잔차를 분해할 수 있다
  • 이동평균과 지수가중이동평균을 계산하고 비교할 수 있다
  • 월별, 요일별 계절 패턴을 분석할 수 있다
  • ADF 검정으로 정상성을 테스트할 수 있다
  • ACF/PACF 플롯을 해석할 수 있다
  • 시계열 피처를 생성하여 ML 모델에 활용할 수 있다

다음 문서