구조적 시계열과 칼만 필터로 추세·계절성 추정하기
구조적 시계열 모델의 상태공간 표현과 칼만 필터·RTS 스무더 구현을 바탕으로 추세, 계절성, 결측치, 동적 회귀를 다루는 방법을 정리한다.
2026-08-14 · 최초 발행 2024-04-29
관측값 뒤의 상태를 추정하는 시계열 모델
구조적 시계열(Structural Time Series, STS)은 관측값을 수준(Level), 기울기·추세(Trend), 계절(Seasonal), 외생 회귀(Regression) 성분으로 나누어 상태공간모형으로 표현한다. 각 성분을 확률적 과정으로 두기 때문에 시간에 따라 변하는 구조를 유연하게 추정할 수 있다.
비정상성이나 구조 변화가 있는 데이터, 결측치 또는 불규칙한 간격의 관측을 다룰 때도 사용할 수 있다. 성분별 해석이 가능하므로 예측값뿐 아니라 변화의 원인을 살피는 데도 맞는다.
칼만 필터(Kalman Filter)는 선형-가우시안 상태공간모형에서 잠재 상태를 재귀적으로 구하는 알고리즘이다. 이전 상태로 다음 상태를 예측한 뒤 새 관측값으로 갱신하는 흐름을 반복한다. 이 과정은 온라인 추정, 1-스텝 앞 예측, 잔차 진단, 로그우도 계산을 통한 최대우도추정(MLE)을 지원한다.
상태공간에 담는 성분과 행렬
기본 구성으로는 로컬 레벨 또는 로컬 선형 추세(Local Level/Local Linear Trend)를 사용하고, 필요에 따라 삼각함수형 계절 성분이나 더미 계절 성분을 더한다. 외생변수를 포함하면 동적 회귀모형(Dynamic Regression)으로 확장할 수 있으며, 시간 가변 계수(Time-varying Coefficients)도 구현할 수 있다.
모형은 상태전이 행렬(F), 관측 행렬(H), 공정잡음 공분산(Q), 관측잡음 공분산(R), 초기상태(x0, P0)로 정의한다. 시간에 따라 행렬을 바꾸면 휴일, 프로모션, 레짐 전환도 반영하기 쉽다.
상태 추정에는 칼만 필터와 RTS 스무더(RTS Smoother)를 쓸 수 있다. 모수는 로그우도 기반 MLE 또는 EM 알고리즘으로 구하며, 사전 분포와 MCMC를 쓰는 베이지안 추론은 불확실성 정량화를 강화하는 대신 복잡도와 운영 비용을 높인다.
수치 안정성도 모델 구성의 일부다. 조셉 형태의 공분산 갱신을 사용하고 PSD(양의 반정부호) 보장을 검증해야 한다. 표준화 잔차, Q-Q 플롯, Ljung-Box, 예측 구간 적중률은 모델 상태를 점검하는 기준이 된다.
상태차원 n에 대한 시간복잡도는 O(T·n^3)이다. 희소성 활용, 분해정복, 배치 처리는 확장성을 확보하는 방법이며, 스트리밍 환경에서는 온라인 업데이트도 가능하다. 재학습 주기와 감도 조정에는 민첩성 및 안정성 사이의 트레이드오프가 있다.
| 항목 | STS(Kalman) | ARIMA | Prophet |
|---|---|---|---|
| 성능(불규칙/결측) | 강함 | 보통 | 보통 |
| 확장성(다성분) | 높음 | 제한적 | 중간 |
| 일관성(해석 가능) | 높음 | 중간 | 높음 |
| 안정성(구조변화) | 강함 | 약함 | 중간 |
| 운영 편의 | 중간 | 높음 | 높음 |
예측과 갱신이 이어지는 흐름
결측 관측에서는 갱신 단계를 생략하고 예측 상태를 사용한다. 행렬의 비정규 또는 비PSD를 감지하면 Q, R의 대각선 최소값을 클리핑하고 수치 안정화를 적용한다. 발산을 감지했을 때는 공분산 고정 상한과 파라미터 재초기화를 둘 수 있다.
로컬 선형 추세 필터 구현
구현 환경은 Python 3.10+, numpy 1.25+, scipy 1.11+ (옵션, MLE), matplotlib 3.8+ (옵션)이다. 설치는 pip install numpy scipy matplotlib로 할 수 있다.
로컬 선형 추세(LLT)에서 상태는 x_t = [level_t, trend_t]^T이며, 전이 행렬은 F = [[1,1],[0,1]], 관측 행렬은 H = [1,0]이다. Q = diag([sigma_level^2, sigma_trend^2]), R = [sigma_obs^2]로 둔다.
# LLT Kalman Filter & RTS Smoother (self-contained)
import numpy as np
from dataclasses import dataclass
@dataclass
class KFResult:
x_filt: np.ndarray
P_filt: np.ndarray
x_pred: np.ndarray
P_pred: np.ndarray
x_smooth: np.ndarray
P_smooth: np.ndarray
ll: float # log-likelihood
def kalman_filter_llt(y, sig_level, sig_trend, sig_obs,
x0=None, P0=None):
y = np.asarray(y, dtype=float)
T = len(y)
F = np.array([[1.0, 1.0],
[0.0, 1.0]])
H = np.array([[1.0, 0.0]])
Q = np.diag([sig_level**2, sig_trend**2])
R = np.array([[sig_obs**2]])
I = np.eye(2)
x_pred = np.zeros((T, 2))
P_pred = np.zeros((T, 2, 2))
x_filt = np.zeros((T, 2))
P_filt = np.zeros((T, 2, 2))
if x0 is None:
x_prev = np.zeros(2)
else:
x_prev = np.asarray(x0, dtype=float)
if P0 is None:
P_prev = np.eye(2) * 1e4 # vague prior
else:
P_prev = np.asarray(P0, dtype=float)
ll = 0.0
two_pi = 2.0 * np.pi
for t in range(T):
# Predict
x_pr = F @ x_prev
P_pr = F @ P_prev @ F.T + Q
x_pred[t] = x_pr
P_pred[t] = P_pr
yt = y[t]
if np.isnan(yt):
# Missing observation: skip update
x_fl = x_pr
P_fl = P_pr
else:
v = yt - (H @ x_pr)[0]
S = (H @ P_pr @ H.T + R)[0, 0]
if S <= 0 or not np.isfinite(S):
# Stabilize
S = max(S, 1e-9)
K = (P_pr @ H.T)[:, 0] / S # 2x1 -> vector
x_fl = x_pr + K * v
# Joseph form for numerical stability
KH = np.outer(K, H[0])
P_fl = (I - KH) @ P_pr @ (I - KH).T + np.outer(K, K) * R[0, 0]
# Log-likelihood
ll += -0.5 * (np.log(two_pi) + np.log(S) + (v * v) / S)
x_filt[t] = x_fl
P_filt[t] = P_fl
x_prev, P_prev = x_fl, P_fl
# RTS smoother
x_smooth = np.zeros_like(x_filt)
P_smooth = np.zeros_like(P_filt)
x_smooth[-1] = x_filt[-1]
P_smooth[-1] = P_filt[-1]
for t in range(T - 2, -1, -1):
P_f = P_filt[t]
P_p1 = P_pred[t + 1]
# Smoother gain
C = P_f @ F.T @ np.linalg.pinv(P_p1)
x_smooth[t] = x_filt[t] + C @ (x_smooth[t + 1] - x_pred[t + 1])
P_smooth[t] = P_f + C @ (P_smooth[t + 1] - P_p1) @ C.T
return KFResult(x_filt, P_filt, x_pred, P_pred, x_smooth, P_smooth, ll)
# Example usage with synthetic data
if __name__ == "__main__":
rng = np.random.default_rng(0)
T = 300
true = dict(sig_level=0.2, sig_trend=0.05, sig_obs=0.5)
level = 0.0
trend = 0.05
y = []
for t in range(T):
# state evolution
level = level + trend + rng.normal(0, true["sig_level"])
trend = trend + rng.normal(0, true["sig_trend"])
obs = level + rng.normal(0, true["sig_obs"])
y.append(obs)
y = np.array(y, dtype=float)
# insert missing
y[50:60] = np.nan
# initial guess
res = kalman_filter_llt(y, sig_level=0.3, sig_trend=0.1, sig_obs=0.7)
print(f"log-likelihood: {res.ll:.2f}")
print("last filtered state:", res.x_filt[-1])
# Optional plotting
try:
import matplotlib.pyplot as plt
plt.figure(figsize=(10,4))
plt.plot(y, "k.", alpha=0.5, label="y")
plt.plot(res.x_smooth[:,0], "r-", label="smoothed level")
plt.plot(res.x_filt[:,0], "b--", alpha=0.7, label="filtered level")
plt.legend(); plt.tight_layout(); plt.show()
except Exception as e:
print("Plot skipped:", e)
로그우도를 최대화해 σ_level, σ_trend, σ_obs를 추정할 수 있다. 아래 예시는 SciPy를 사용하며 경계조건과 안정화를 포함한다.
import numpy as np
from scipy.optimize import minimize
def neg_loglike(theta_log, y):
# parameters on log-scale to enforce positivity
sig_level = np.exp(theta_log[0])
sig_trend = np.exp(theta_log[1])
sig_obs = np.exp(theta_log[2])
res = kalman_filter_llt(y, sig_level, sig_trend, sig_obs)
return -res.ll
# initial guess
theta0 = np.log([0.3, 0.1, 0.7])
opt = minimize(neg_loglike, theta0, args=(y,),
method="L-BFGS-B",
bounds=[(-10, 5), (-10, 5), (-10, 5)])
sig_level, sig_trend, sig_obs = np.exp(opt.x)
print("Estimated sigmas:", sig_level, sig_trend, sig_obs)
초기 공분산(P0)은 크게 설정한 뒤 안정화하고, 학습이 진행되면 EM 또는 재추정 주기를 적용한다. 결측과 이상치가 섞인 데이터에서는 이상치 관측의 가중치를 낮추기 위해 일시적으로 R을 키울 수 있다. P의 대각 상한과 Q, R의 최소값 하한은 수치 발산에 대한 가드레일이 된다.
예측, 품질 감시, 효과 추정에 연결하기
수요 예측과 재고 최적화에서는 주별·일별 수요를 수준·추세·계절 성분으로 분해하고, 프로모션과 가격을 외생변수로 결합한다. 판매 시계열, 프로모션 플래그, 가격, 캘린더 변수를 입력으로 받아 STS와 동적 회귀를 구성하고, 칼만 필터 추정·모수 MLE·잔차 진단을 거친다. 결과는 예측값, 성분 시각화, 재고·발주 의사결정 지표로 이어진다.
이상 탐지와 품질 관리에서는 스무딩 상태와 예측 잔차를 이용해 온라인 감지를 수행한다. 센서 시계열과 유지보수 이력을 입력으로 사용하고, 1-스텝 앞 예측 잔차에 적응형 임계값을 적용한다. 이상 알람과 함께 레벨·추세·계절 성분의 기여도를 확인할 수 있다.
마케팅 효과 추정에서는 광고 집행량을 외생변수로 포함해 시간 가변 계수를 추정한다. 매출, 캠페인 지표, 시즌 지표를 바탕으로 회귀 성분을 상태에 포함하고 칼만 필터로 추정하면 서서히 변화하는 효과량과 캠페인 ROI 추세, 예측 개선을 추적할 수 있다.
기대할 수 있는 효과와 모델 선택의 대가
베이스라인인 단순 지수평활 또는 ARIMA와 비교해 MAPE가 520%p 개선될 가능성이 있으며, 이는 데이터와 도메인에 의존한다. 관측 누락률 1030% 환경에서는 예측 안정성을 유지할 수 있다. 상태차원 n≤5를 가정하면 O(n^3) 상태 갱신으로 초당 수천 포인트 처리 가능성이 있다.
해석 가능한 성분 분해는 의사결정 품질을 높이고, 이상치와 구조 변화에 대응하는 민첩성을 높여 운영 리스크를 낮출 수 있다. 다만 Q(공정잡음)를 키우면 적응성은 높아지지만 노이즈 증폭 위험도 함께 커진다. 상태차원을 늘리는 선택 역시 과적합과 수치불안정 리스크를 동반한다.
로컬 선형 추세 같은 최소 구성에서 출발해 필요한 성분을 추가하고, 잔차 진단과 재추정을 자동화하는 편이 안정적이다. 결측·이상치 처리 규칙과 수치 안정화 가드레일도 모델 운영 전에 정해 둘 필요가 있다.