Skip to main content
시계열 데이터(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 플롯을 해석할 수 있다
  • 시계열 피처를 생성하여 머신러닝 모델에 활용할 수 있다

다음 문서

데이터 분석 시작하기

전체 학습 로드맵으로 돌아갑니다

이커머스 분석

이커머스 분석 프로젝트를 복습합니다