시계열:
예측과 역 군집
서울 지하철 11년치 일별 승차 인원으로 시간순 분할, 계절 naive, 순환망을 같은 저울에 올린다. 딥러닝이 이기는지, 이긴다면 왜 이기는지를 숫자로 확인하고, 455개 역을 이용 패턴으로 묶는다. 결과는 전부 실제 실행값이다.
일요일 저녁의 발표, 월요일 아침의 빈 열차
예측 모형이 한 번도 본 적 없는 세계가 시작됐다. 그런데 그 이전 5년은, 가장 단순한 규칙으로도 꽤 잘 맞았다.
하루 740만 명이 타던 열차
2020년 2월 23일 일요일 저녁, 정부는 감염병 위기경보를 가장 높은 단계인 "심각"으로 올렸다. 그 전해 서울 지하철은 하루 평균 744만 명을 태웠다. 그 해에는 538만 명이었다. 4분의 1 넘게 사라진 승객은 서서히 돌아와, 6년 뒤인 지금도 이전 수준에 조금 못 미친다.
이 자료로 예측 모형을 만든다고 하자. 흔한 출발은 "LSTM 을 돌린다"이다. 그런데 이 회차의 실험이 보여주는 것은 다르다. 7일 전 값을 그대로 쓰는 규칙이 생각보다 강하고, 딥러닝이 그 규칙을 이기는 폭의 3분의 2는 순환 구조가 아니라 달력(요일·공휴일)에서 온다. 어떤 모델을 쓰느냐보다 무엇을 입력에 넣고 어떻게 평가하느냐가 결과를 좌우한다.
그래서 이 회차는 모델 소개가 아니라 절차를 다룬다. 시간순으로만 자르는 분할, 베이스라인과의 비율(MASE), seed 반복, 특징을 하나 빼 보는 절제. 이 절차를 몸에 익히면 자기 데이터가 매출이든 방문자든 환율이든 같은 노트북이 돈다.
시계열 예측 대회의 역사는 "복잡한 모델이 단순한 규칙을 못 이긴다"는 경고의 역사이기도 하다. Makridakis 가 1982년부터 이어 온 M-competition 계열에서 순수 통계 방법이 오랫동안 기계학습 방법을 앞섰고, 2018년 M4 에서야 통계와 신경망을 섞은 하이브리드가 1위를 했다. 2020년대에 들어서는 Zeng 외(2023)가 "Transformer 가 장기 예측에 효과적인가"라는 제목의 논문에서 선형 한 층(DLinear)이 여러 Transformer 계열을 이기는 결과를 보였다. 이 회차가 DLinear 를 세 모델 중 하나로 넣은 이유다.
이 흐름의 교훈은 "딥러닝을 쓰지 말라"가 아니다. 베이스라인을 세우고, 그 위에서 무엇이 개선을 만드는지 하나씩 확인하라는 것이다. 이 회차의 절제 실험(§4)이 정확히 그 확인이다.
생각해 볼 질문
자기 연구 데이터에서 "7일 전 값" 에 해당하는 가장 단순한 규칙은 무엇인가. 월별 매출이면 12개월 전 값, 분기 자료면 4분기 전 값이다. 그 규칙의 오차를 먼저 재 두지 않으면, 모델의 오차가 좋은지 나쁜지 판단할 기준이 없다.
출처 질병관리청, 코로나19 위기경보 단계 격상(2020-02-23) 보도자료 · 서울 열린데이터광장, 지하철 호선별 역별 승하차 인원 정보(OA-12914), data.seoul.go.kr/dataList/OA-12914 · Makridakis, Spiliotis & Assimakopoulos, "The M4 Competition: 100,000 time series and 61 forecasting methods", IJF 36(1), 2020
데이터
일 × 역 단위 250만 행을 일 단위 합계 4,261행으로 줄인다. 결측일은 없고, 역 수가 11년 사이 539개에서 618개로 늘었다는 점만 기억하면 된다.
4,261일, 결측 0, 그러나 역 수는 늘었다
원자료는 하루·노선·역 단위 승하차 인원이다. 이 회차는 하루 총승차로 합쳐 하나의 시계열로 쓰고, §5 에서 역 단위로 돌아간다.
| 항목 | 값 | 비고 |
|---|---|---|
| 기간 | 2015-01-01 ~ 2026-08-31 | 4,261일, 결측일 0 |
| 원자료 행 수 | 2,518,189 | 일 × 노선 × 역. 월·연 파일 52개(cp949, 일부 utf-8) |
| 일평균 총승차 | 676만 명 | 2019년 744만 → 2020년 538만 → 2026년 712만 |
| 역 수 | 539 → 618 | 신설·연장으로 증가. 총합에는 그 증가분이 섞여 있다 |
| 공변량 | 요일 7, 공휴일 1 | holidays 패키지 KR 달력(대체공휴일·임시공휴일 포함) |
| 단위 | 백만 명 | 모든 오차는 백만 명 단위 |
데이터 로드
일 단위 합계 파일은 이 사이트에서 직접 읽는다. 원자료 52개 파일을 합치는 스크립트는 노트북 부록에 있다.
import pandas as pd, numpy as np, holidays
URL = 'https://hufs-ai-lecture.pages.dev/practice/data/subway_daily_total.csv'
df = pd.read_csv(URL, parse_dates=['date']).sort_values('date').reset_index(drop=True)
y = df.board.values / 1e6 # 백만 명 단위
kr = holidays.KR(years=range(2015, 2027))
hol = np.array([d in kr for d in df.date.dt.date]).astype(float)
dow = df.date.dt.dayofweek.values
print(df.shape, df.date.min().date(), df.date.max().date())
# → (4261, 4) 2015-01-01 2026-08-31
조작 안내 — 노트북 ① 셀을 실행하고 df.board.describe() 로 최솟값이 어느 날인지 찾아보라.
관찰 포인트 — 최솟값(158만 명)은 2021년 1월 1일, 코로나 한복판의 공휴일이다. 코로나 기간 안에서도 가장 낮은 날은 공휴일이다. 공휴일 특징이 왜 필요한지가 여기서 이미 보인다.
공공데이터 포털은 대개 두 경로를 준다. 파일 다운로드와 Open API 다. 이 데이터는 파일이 로그인 없이 열리고 API 는 키가 필요하다. 논문용으로는 파일을 받아 날짜와 함께 보관하는 쪽이 낫다. API 는 제공 기관이 스키마를 바꾸면 재현이 깨진다. 실제로 이 자료도 2020년 5월부터 열 하나가 추가돼 파서가 한 번 깨졌다. 노트북 부록의 로더는 그 경우를 처리한다.
역 수가 늘어난 것은 총합 시계열에 작은 상승 추세를 심는다. 예측 문제에서는 추세도 데이터의 일부라 문제가 없지만, 실습 4 에서 "코로나가 없었을 세계"를 예측할 때는 이 추세를 반사실에 넣을지 말지가 설계 판단이 된다. 그때 다시 만난다.
출처 서울특별시 교통정책과, "서울시 지하철호선별 역별 승하차 인원 정보", 갱신 매일, data.seoul.go.kr · python-holidays, github.com/vacanza/holidays
분할과 베이스라인
시간순으로만 자른다. 7일 전 값을 반복하는 규칙이 학습구간 기준 MASE 0.89 를 낸다. 이것이 모든 모델이 넘어야 할 선이다.
미래는 학습에 들어오면 안 된다
평가 구간을 6개월씩 세 번 두고, 각 구간 이전 데이터로만 학습한다. 구간 안에서는 28일 간격으로 origin 을 옮기며 28일씩 예측한다. 무작위 분할은 시계열에서 금지다.
| fold | 학습 | 평가 | origin 수 | 평가 구간의 공휴일 |
|---|---|---|---|---|
| 1 | 2015-01 ~ 2025-02 | 2025-03-01 ~ 08-31 | 6 | 삼일절 대체, 어린이날, 현충일, 6·3 선거일, 광복절 |
| 2 | 2015-01 ~ 2025-08 | 2025-09-01 ~ 2026-02-28 | 6 | 추석 연휴(10-3~10-9), 성탄절, 신정, 설 연휴 |
| 3 | 2015-01 ~ 2026-02 | 2026-03-01 ~ 08-31 | 6 | 삼일절 대체, 어린이날, 석가탄신일 대체, 지방선거, 제헌절(달력 패키지 기준) |
윈도우 만들기
origin o 에서 입력은 직전 56일, 정답은 이후 28일, 공변량은 그 28일의 요일·공휴일이다.
H, L = 28, 56 # 지평 28일, 입력 창 56일 folds = [('2025-03-01','2025-08-31'), ('2025-09-01','2026-02-28'), ('2026-03-01','2026-08-31')] idx = lambda d: int(np.searchsorted(df.date.values, np.datetime64(d))) def cal_feats(t): # 요일 원핫 7 + 공휴일 1 f = np.zeros((len(t), 8)); f[np.arange(len(t)), dow[t]] = 1; f[:, 7] = hol[t]; return f def make_windows(start, end): # 학습용: 모든 origin X, Y, C, O = [], [], [], [] for o in range(max(start, L), end - H + 1): X.append(y[o-L:o]); Y.append(y[o:o+H]); C.append(cal_feats(np.arange(o, o+H)).ravel()); O.append(o) return np.array(X), np.array(Y), np.array(C), np.array(O) def test_set(s, e): # 평가용: 28일 간격 비중첩 origin Ote = np.arange(s, e - H + 1, H) return (np.array([y[o-L:o] for o in Ote]), np.array([y[o:o+H] for o in Ote]), np.array([cal_feats(np.arange(o, o+H)).ravel() for o in Ote]), Ote)
조작 안내 — make_windows(0, idx('2025-03-01')) 의 X 행 수를 세어 보라.
관찰 포인트 — 학습 윈도우는 서로 55일씩 겹친다. 그래서 학습 표본 수(약 3,600)는 독립 표본 수가 아니다. 조기종료용 검증도 무작위가 아니라 학습구간 끝 10% 를 떼야 한다.
rolling-origin 평가는 Tashman(2000)이 정리한 이름이고, 실무에서는 "time series cross-validation" 또는 "backtesting" 으로 불린다. 핵심은 세 가지다. 첫째, 학습 데이터의 마지막 날짜가 평가 origin 보다 앞서야 한다. 둘째, 정규화 통계(평균·표준편차)도 학습 구간에서만 계산한다. 셋째, 하이퍼파라미터 선택도 학습 구간 안에서 끝낸다. 셋 중 하나만 어겨도 결과는 좋아 보이고, 논문 심사에서는 이 세 가지를 가장 먼저 묻는다.
이 회차는 origin 을 28일 간격으로 띄워 평가 구간이 겹치지 않게 했다. 매일 origin 을 두면 오차의 평균은 더 안정되지만 인접 origin 의 오차가 강하게 상관되어 표준편차가 과소평가된다. 보고할 때는 어느 쪽을 썼는지 적는다.
출처 Tashman, "Out-of-sample tests of forecasting accuracy: an analysis and review", IJF 16(4), 2000 · Hyndman & Athanasopoulos, Forecasting: Principles and Practice 3rd ed., §5.10, otexts.com/fpp3/tscv.html
7일 전 값을 그대로 쓰면 MASE 0.89
한 줄 목표: 계절 naive 가 언제 잘 맞고 언제 무너지는지를 origin 을 옮겨 가며 직접 본다. 무너지는 날은 거의 전부 공휴일이다.
시간순 분할·계절 naive 뷰어
파란 띠가 입력 56일, 붉은 띠가 예측 28일이다. 회색 점선은 "7일 전 값 반복" 예측, 붉은 세로 점선은 공휴일.
조작 안내 — ① "공휴일 없음"(2025-10-27)을 눌러 naive MAE 를 보라. ② "추석 주간"(2025-09-29)으로 옮겨라. ③ 슬라이더로 아무 날이나 골라 붉은 점선이 지평 안에 있을 때와 없을 때를 비교하라.
관찰 포인트 — 공휴일 없는 4주는 MAE 0.1 안팎, 추석이 든 4주는 1.1 을 넘는다(10배). naive 가 무너지는 이유는 "패턴을 못 배워서"가 아니라 달력을 모르기 때문이다. 이것이 §4 절제 실험의 예고다.
| 베이스라인 | 규칙 | fold 1 | fold 2 | fold 3 | 평균 |
|---|---|---|---|---|---|
| SeasonalNaive7 | 7일 전 같은 요일 값 | 0.811 | 1.073 | 0.797 | 0.894 |
| YoY364 | 364일 전 값 × 최근 4주 수준 보정 | 0.883 | 1.721 | 0.884 | 1.163 |
값은 MASE. 분모는 각 fold 학습구간의 계절 naive(7일) 평균절대오차(fold 1 기준 0.491 백만 명). fold 2 가 1 을 넘는 것은 추석·설 연휴가 평가 구간에 들어 평가 오차가 학습구간 평균 오차보다 커졌기 때문이다.
MASE(mean absolute scaled error)는 Hyndman & Koehler(2006)가 제안했다. 절대오차를 학습구간에서 계절 naive 가 낸 절대오차의 평균으로 나눈다. 단위가 사라지므로 서로 다른 시계열·논문 사이에서 비교가 되고, 1 이 "naive 와 같다"는 뜻이라 해석이 즉각적이다. MAPE 는 0 근처 값에서 폭발하고, RMSE 는 단위가 남아 다른 데이터와 비교가 안 된다. 논문에는 MAE(단위 있음)와 MASE(단위 없음)를 나란히 쓰는 것이 관례다.
YoY364(1년 전 같은 요일)가 7일 naive 보다 나쁜 것은 의외일 수 있다. 이유는 두 가지다. 명절이 음력이라 364일 전과 날짜가 어긋나고, 코로나 회복 추세 때문에 1년 전 수준 자체가 다르다. "작년 이맘때"는 사람의 직관에는 강하지만 데이터에서는 약한 규칙이다.
출처
Hyndman & Koehler, "Another look at measures of forecast accuracy", IJF 22(4), 2006, 679–688 ·
실행 결과: 실습_work/out/ts_summary.csv
세 가지 모델
lag-MLP, GRU, DLinear 는 모두 "과거 56일 + 앞으로 28일의 달력"을 받아 28일 벡터를 낸다. 셋을 같은 조건에 두어야 구조의 효과만 남는다.
순환·선형·완전연결
GRU 는 시간 순서를 따라 읽고, DLinear 는 추세와 잔차를 선형으로 따로 맞추고, MLP 는 순서를 무시하고 56개 값을 한 번에 본다. 셋 다 direct multi-output 이라 재귀 예측의 오차 누적이 없다.
| 모델 | 구조 | 파라미터 | 가정 |
|---|---|---|---|
| lag-MLP | (56 + 28×8) → 256 → 256 → 28, ReLU, Dropout 0.1 | 약 14만 | 순서 정보 없음. lag 자체가 특징 |
| GRU | 입력 (값 + 달력 8) × 56 스텝 → GRU 2층 64 → 마지막 상태 ‖ 미래 달력 → 28 | 약 5만 | 순서 의존. 게이트로 장기 의존 조절 |
| DLinear | 7일 이동평균 추세 + 잔차 각각 Linear(56→28) + 달력 Linear(224→28) | 약 1만 | 선형. 추세·계절 분해 |
모델 정의
class MLP(nn.Module):
def __init__(s, i=L+H*8, h=256): super().__init__(); s.n = nn.Sequential(
nn.Linear(i,h), nn.ReLU(), nn.Dropout(.1), nn.Linear(h,h), nn.ReLU(), nn.Linear(h,H))
def forward(s, x, c, cin=None): return s.n(torch.cat([x, c], 1))
class GRUm(nn.Module):
def __init__(s, h=64): super().__init__(); s.g = nn.GRU(1+8, h, num_layers=2, batch_first=True, dropout=.1); s.o = nn.Linear(h+H*8, H)
def forward(s, x, c, cin): _, hN = s.g(torch.cat([x.unsqueeze(-1), cin], -1)); return s.o(torch.cat([hN[-1], c], 1))
class DLinear(nn.Module): # 추세(이동평균)와 잔차를 각각 선형으로
def __init__(s, k=7): super().__init__(); s.k = k; s.lt = nn.Linear(L,H); s.ls = nn.Linear(L,H); s.lc = nn.Linear(H*8,H)
def forward(s, x, c, cin=None):
pad = torch.cat([x[:,:1].repeat(1,s.k//2), x, x[:,-1:].repeat(1,s.k//2)], 1)
tr = pad.unfold(1, s.k, 1).mean(-1)
return s.lt(tr) + s.ls(x - tr) + s.lc(c)
조작 안내 — sum(p.numel() for p in m.parameters()) 로 세 모델의 파라미터 수를 세어 표와 맞춰 보라.
관찰 포인트 — 파라미터가 14배 차이 나는 MLP 와 DLinear 의 성능 차이가 §4 에서 seed 표준편차 안에 들어온다. 이 데이터에서는 용량이 병목이 아니다.
세 모델에 달력 특징을 넣는 방식이 다르다. MLP 와 DLinear 는 미래 28일의 달력(224차원)을 입력에 이어 붙인다. GRU 는 과거 56일의 달력을 각 스텝에 넣어 "그날이 공휴일이었다"를 순서대로 읽고, 미래 달력은 출력층에 이어 붙인다. 순환 구조에 미래 정보를 주는 자연스러운 방법이 없다는 것 자체가 GRU 의 약점이고, seq2seq 디코더나 Transformer 계열이 그 약점을 메우려 한 구조다.
학습 설정은 셋 다 같다. 손실 L1(MAE 와 일치), AdamW(lr 2e-3, weight decay 1e-4), 배치 128, 최대 300 epoch, 학습구간 끝 10% 를 검증으로 20 epoch 개선 없으면 중단. 입력 정규화 통계는 학습 윈도우에서만 계산한다. 이 설정을 부록 표로 적는 것이 §6 의 재현성 규격이다.
출처 Cho 외, "Learning Phrase Representations using RNN Encoder–Decoder", 2014, arxiv.org/abs/1406.1078 · Zeng, Chen, Zhang & Xu, "Are Transformers Effective for Time Series Forecasting?", AAAI 2023, arxiv.org/abs/2205.13504
정규화는 학습 창에서, 검증은 끝 10% 에서
한 줄 목표: 누수가 생길 수 있는 세 지점(정규화·검증 분할·조기종료)이 코드 어디에 있는지 짚는다.
fit_predict
def fit_predict(name, Xtr, Ytr, Ctr, Otr, Xte, Cte, Ote, seed, use_cal=True):
torch.manual_seed(seed); np.random.seed(seed)
mu, sd = Xtr.mean(), Xtr.std() # ① 정규화 통계는 학습 창에서만
T = lambda a: torch.tensor(a, dtype=torch.float32, device=dev)
xt, yt, ct = T((Xtr-mu)/sd), T((Ytr-mu)/sd), T(Ctr if use_cal else 0*Ctr)
xe, ce = T((Xte-mu)/sd), T(Cte if use_cal else 0*Cte)
cin_tr = T(np.stack([cal_feats(np.arange(o-L,o)) for o in Otr])*use_cal); cin_te = T(np.stack([cal_feats(np.arange(o-L,o)) for o in Ote])*use_cal)
m = {'MLP':MLP,'GRU':GRUm,'DLinear':DLinear}[name]().to(dev); opt = torch.optim.AdamW(m.parameters(), lr=2e-3, weight_decay=1e-4)
n = len(xt); nv = max(int(n*.1), H); tr, va = np.arange(n-nv), np.arange(n-nv, n) # ② 검증 = 학습구간 끝 10%
best = (1e9, None); wait = 0
for ep in range(300):
m.train(); perm = np.random.permutation(tr)
for i in range(0, len(perm), 128):
b = perm[i:i+128]; loss = nn.functional.l1_loss(m(xt[b], ct[b], cin_tr[b]), yt[b]); opt.zero_grad(); loss.backward(); opt.step()
m.eval()
with torch.no_grad(): vl = nn.functional.l1_loss(m(xt[va], ct[va], cin_tr[va]), yt[va]).item()
if vl < best[0]-1e-4: best = (vl, {k:v.clone() for k,v in m.state_dict().items()}); wait = 0
elif (wait := wait+1) >= 20: break # ③ 조기종료 — 평가 구간은 보지 않는다
m.load_state_dict(best[1]); m.eval()
with torch.no_grad(): return m(xe, ce, cin_te).cpu().numpy()*sd + mu
조작 안내 — ①을 y.mean(), y.std()(전체 시계열)로 바꿔 다시 돌려 보라.
관찰 포인트 — 이 데이터에서는 차이가 작다. 그러나 추세가 강한 데이터(매출·주가)에서는 전체 평균이 미래 수준을 미리 알려 주는 누수가 된다. 차이가 작아도 습관은 옳은 쪽으로 들인다.
결과와 절제
세 모델 모두 naive 를 35% 이상 이긴다. 그러나 달력 특징을 빼면 그 이득의 3분의 2가 사라지고, DLinear 는 naive 와 같아진다. 이긴 것은 구조가 아니라 달력이다.
MASE 0.57 ~ 0.62, 세 모델의 차이는 seed 안에
아래 표가 논문 본문에 들어갈 형태다. 평균 ± 표준편차, seed 수, 베이스라인, 단위를 한 표에 담는다.
| 모델 | fold 1 | fold 2 | fold 3 | MASE 평균 | MAE 평균 (백만 명) |
|---|---|---|---|---|---|
| SeasonalNaive7 | 0.811 | 1.073 | 0.797 | 0.894 | 0.440 |
| YoY364 | 0.883 | 1.721 | 0.884 | 1.163 | 0.572 |
| lag-MLP | 0.548 ± 0.042 | 0.689 ± 0.051 | 0.468 ± 0.049 | 0.569 | 0.280 |
| GRU | 0.638 ± 0.055 | 0.569 ± 0.032 | 0.544 ± 0.049 | 0.584 | 0.287 |
| DLinear | 0.509 ± 0.015 | 0.736 ± 0.009 | 0.627 ± 0.018 | 0.624 | 0.307 |
fold 별 값은 seed 5회(0~4) 평균 ± 표준편차. 평가 origin 은 fold 당 6개(28일 간격). 실행: 2026-09-12, RTX 4090, PyTorch 2.11, 모델당 평균 학습 시간 3~6초(조기종료 평균 94~115 epoch).
"세 모델의 차이가 seed 안에 있다"는 말을 정확히 하자. fold 별 seed 표준편차는 0.01~0.06 이고, 세 모델의 fold 평균 차이는 최대 0.06 이다. fold 를 바꾸면 순위가 뒤집힌다. 이런 상황에서 논문에 "GRU 가 MLP 보다 우수하다"고 쓰려면 fold·seed 를 모두 포함한 대응 비교(paired test)가 필요하고, 그 검정은 여기서 유의하지 않다. 정직한 문장은 "세 구조는 이 데이터에서 통계적으로 구분되지 않았다"이다.
반면 딥러닝 셋과 naive 의 차이(0.89 → 0.57~0.62)는 모든 fold·seed 에서 일관된다. 이 차이는 보고할 가치가 있다. 다음 카드는 그 차이가 어디서 오는지 묻는다.
출처 실행 결과 실습_work/out/ts_results.csv(seed 별 90행), ts_summary.csv
달력을 빼면 DLinear 는 naive 와 같아진다
한 줄 목표: 딥러닝의 이득 중 얼마가 "요일·공휴일 특징" 이고 얼마가 "모델 구조" 인지를 특징을 빼 보는 것으로 분해한다.
| 모델 | 달력 포함 | 달력 제거 (-cal) | naive 대비 이득 중 달력 몫 |
|---|---|---|---|
| lag-MLP | 0.569 | 0.766 ± 0.154 | 61% |
| GRU | 0.584 | 0.774 ± 0.157 | 61% |
| DLinear | 0.624 | 0.887 ± 0.139 | 97% |
| SeasonalNaive7 | 0.894 | — | — |
MASE, 3 fold × seed 5회 평균(± 는 15회 표준편차). "달력 몫" = (달력 제거 − 달력 포함) ÷ (naive − 달력 포함).
"LSTM/GRU 가 계절성을 스스로 배우니 특징 공학은 필요 없다." 주간 계절성은 배운다(달력 제거 GRU 도 naive 보다 낫다). 그러나 불규칙한 공휴일은 56일 창 안에 한두 번밖에 안 나타나므로 배울 표본이 없다. 달력은 모델이 배울 수 없는 정보를 밖에서 넣어 주는 것이고, 이 데이터에서는 그것이 이득의 대부분이다.
절제 실험은 딥러닝 논문의 표준 절차이지만 사회과학 응용 논문에서는 자주 빠진다. 빠지면 심사자는 "결국 어떤 요인이 성능을 만들었는가"를 물을 수밖에 없다. 절제는 비용이 거의 없다. 특징 하나를 0 으로 두고 같은 루프를 다시 돌리면 된다. 이 회차 전체(90회 학습)가 GPU 에서 8분, Colab T4 에서는 seed 3회로 줄여 5분 안팎이다.
절제가 알려 주는 또 하나는 다음에 무엇을 넣을지다. 달력이 이 정도 몫을 차지한다면, 기상(폭우·폭설), 대규모 행사, 파업 같은 외생 정보를 넣는 것이 구조를 바꾸는 것보다 남은 오차를 더 줄일 가능성이 높다. 논문의 "향후 연구" 문단은 이렇게 절제 결과에서 나와야 설득력이 있다.
출처 실행 결과 ts_total.csv. 절제 설정: 달력 입력 벡터를 0 으로 치환(모델 구조·학습 설정 동일)
역 군집·시각화
라벨 없이도 구조는 보인다. 요일 분포·규모·코로나 하락·회복으로 만든 벡터를 군집하면 여가형·통근형·업무형 세 유형이 갈리고, 코로나 타격과 회복이 유형마다 달랐다.
역 하나를 11개 숫자로
군집은 특징 설계가 전부다. 2019년(코로나 전) 요일별 승차 비중 7개, 승차÷하차 비, 규모(log 일평균), 2020/2019 비, 2024/2019 비. 전 기간 자료가 있는 455개 역만 쓴다.
특징 → 표준화 → K-means → UMAP
st = pd.read_parquet('https://hufs-ai-lecture.pages.dev/practice/data/subway_station_daily.parquet')
st['key'] = st.line+'·'+st.station; st['dow'] = st.date.dt.dayofweek; st['year'] = st.date.dt.year
keep = st.groupby('key').date.nunique().pipe(lambda c: c[c >= 4000].index); d = st[st.key.isin(keep)]; y19 = d[d.year==2019]
piv = y19.pivot_table(index='key', columns='dow', values='board', aggfunc='mean')
feat = piv.div(piv.sum(1), axis=0); feat.columns = [f'dow{i}' for i in range(7)] # 요일 분포(합 1)
g = y19.groupby('key'); feat['board_alight'] = g.board.sum()/g.alight.sum(); feat['log_size'] = np.log(g.board.mean())
ym = lambda yr: d[d.year==yr].groupby('key').board.mean()
feat['covid_drop'] = ym(2020)/ym(2019); feat['recover_24'] = ym(2024)/ym(2019); feat = feat.dropna()
X = StandardScaler().fit_transform(feat.values) # 척도 통일
print({k: round(silhouette_score(X, KMeans(k, n_init=10, random_state=0).fit_predict(X)), 3) for k in range(2, 7)})
# → {2: 0.486, 3: 0.279, 4: 0.269, 5: 0.274, 6: 0.275}
km = KMeans(3, n_init=10, random_state=0).fit(X); feat['cluster'] = km.labels_ # 군집은 원 벡터에서
red = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=0).fit_transform(X) # 2차원은 그림에만
조작 안내 — k 를 2 로 바꿔 어느 두 군집이 합쳐지는지 보라.
관찰 포인트 — 실루엣은 k=2 가 가장 높다(0.49). 그런데 k=2 는 "주말형 vs 나머지"로만 갈라 통근형과 업무형의 차이(회복 0.93 vs 1.12)를 지운다. k 선택은 지표와 해석 가능성 사이의 판단이고, 논문에는 지표 표와 선택 이유를 함께 적는다.
세 가지 원칙을 지켰다. 첫째, 군집은 원 공간(11차원 표준화 벡터)에서 하고 UMAP 은 그림에만 쓴다. UMAP 좌표 위에서 군집하면 축소가 만든 인공 구조를 군집으로 착각한다. 둘째, 지도 위의 거리는 해석하지 않는다. UMAP 은 이웃 관계를 보존하지 목적이지 거리·밀도·군집 크기를 보존하지 않는다(McInnes 외 2018). 셋째, seed 를 바꿔 안정성을 잰다. K-means 를 seed 1~5 로 다시 돌린 결과와 seed 0 결과의 ARI 는 0.99, 0.99, 0.70, 1.00, 1.00 이었다. 다섯 중 하나가 다른 국소해로 갔다는 뜻이고, 이것도 그대로 보고한다.
이 특징 벡터에는 시간대 정보가 없다(원자료가 일 단위). 시간대별 승하차 자료(OA-12252)를 쓰면 "아침 승차·저녁 하차 = 주거", "아침 하차·저녁 승차 = 업무"가 직접 잡혀 군집이 훨씬 선명해진다. 확장 과제로 남긴다.
출처
McInnes, Healy & Melville, "UMAP: Uniform Manifold Approximation and Projection", 2018, arxiv.org/abs/1802.03426 ·
Rousseeuw, "Silhouettes", J. Comput. Appl. Math. 20, 1987 ·
실행 결과 out/station_cluster_profile.csv, station_cluster_meta.json
여가형 58, 통근형 288, 업무형 109
한 줄 목표: 군집 라벨을 색으로 보고, 같은 점들을 코로나 하락·회복으로 다시 칠해 "군집이 실제로 다른 운명을 겪었는가"를 확인한다.
역 이용 패턴 UMAP 지도
점 하나가 역 하나다. 마우스를 올리면 역 이름과 수치가 뜬다.
조작 안내 — ① "군집" 상태에서 오른쪽 위 덩어리(청록)에 마우스를 올려 어떤 역들인지 읽어라. ② "2020/2019 하락"으로 바꿔 어느 덩어리가 붉게 물드는지 보라. ③ "2024/2019 회복"에서 붉은 점이 왼쪽 아래(남색 군집)에 몰리는지 보라.
관찰 포인트 — 군집 색을 지우고 하락·회복으로 칠해도 같은 덩어리가 같은 색으로 물들면, 군집이 "요일 분포"만이 아니라 실제 행동의 차이를 잡았다는 증거다. 군집 변수를 회귀에 넣을 근거가 여기서 생긴다.
| 군집 | 역 수 | 주말 비중 | 일평균(천 명) | 2020/2019 | 2024/2019 | 중심 최근접 역 |
|---|---|---|---|---|---|---|
| 0 · 주말형(여가·외곽) | 58 | 0.319 | 10.4 | 0.661 | 0.918 | 온양온천, 가평, 청평, 용문, 신설동, 홍대입구(공항철도) |
| 1 · 통근 주거형 | 288 | 0.218 | 13.9 | 0.730 | 0.927 | 원당, 태릉입구, 부개, 회룡, 한대앞, 중동 |
| 2 · 평일 업무형 | 109 | 0.170 | 11.5 | 0.798 | 1.120 | 증미, 샛강, 강동구청, 디지털미디어시티, 등촌, 경찰병원 |
군집 이름은 프로파일을 보고 붙인 해석이다. 실루엣(k=3) 0.279, seed 5회 ARI 평균 0.94. 지축·구반포·덕계·신내 네 역은 재개발·신도시로 2024/2019 가 2 를 넘거나 0.4 아래라 그림 오른쪽 위·아래에 따로 떨어진다.
군집 결과를 논문에 쓰는 두 가지 길이 있다. 하나는 기술(descriptive)로, "역은 세 유형으로 나뉘고 코로나 타격은 여가형 −34%, 통근형 −27%, 업무형 −20% 였다" 같은 문장이다. 다른 하나는 변수로, 군집 더미를 회귀에 넣어 "여가형 역 주변 상권 매출은 팬데믹에 더 민감했다"를 검정하는 것이다. 후자에서는 군집이 결과변수(매출)와 무관한 정보로만 만들어졌는지 확인해야 순환 논리를 피한다. 여기서는 승차 데이터로 군집하고 승차 회복을 설명하므로 후자로는 쓸 수 없고, 다른 결과변수(상권·부동산)에 붙일 때 의미가 있다.
외딴 점들은 버리지 말고 각주로 남긴다. 지축역(3호선)은 2019년 일평균 1,300명에서 2025년 7,200명이 됐고, 구반포역(9호선)은 재건축 이주로 3,100명에서 990명까지 줄었다 되돌아오는 중이다. 군집이 놓치는 것을 사람이 보충하는 자리가 이런 각주다.
출처 실행 결과 out/station_features.csv(455행), station_widget.json
논문에 쓰기
보고할 것은 다섯 가지다. 분할 방식, 베이스라인, 평균±표준편차, 절제, 그리고 재현에 필요한 설정표. 이 다섯이 있으면 심사자의 첫 질문 다섯 개가 사라진다.
본문 한 표, 부록 한 표
본문에는 §4 의 성능표(모델 × fold, 평균 ± 표준편차, 베이스라인 포함)를 넣고, 부록에는 아래 재현성 표를 넣는다. 노트북 ⑦ 셀이 두 표를 마크다운으로 출력한다.
| 항목 | 이 회차의 값 |
|---|---|
| 데이터 | 서울 열린데이터광장 OA-12914, 2015-01-01 ~ 2026-08-31, 일 단위 합계, 내려받은 날짜 명시 |
| 분할 | rolling-origin 3 fold(평가 6개월), origin 28일 간격 비중첩, 학습 = 평가 시작 이전 전부 |
| 입력·출력 | 입력 56일 + 미래 28일 달력(요일 7·공휴일 1), 출력 28일 direct |
| 정규화 | 학습 윈도우 평균·표준편차(fold 마다 재계산) |
| 모델·최적화 | MLP 256-256 / GRU 2×64 / DLinear k=7 · L1 손실 · AdamW lr 2e-3, wd 1e-4 · 배치 128 · 최대 300 epoch · 조기종료 20(학습 끝 10%) |
| 반복 | seed 0~4 (5회), 보고값은 평균 ± 표준편차 |
| 지표 | MAE(백만 명), MASE(분모 = 학습구간 계절 naive 7일 MAE) |
| 환경 | Python 3.13, PyTorch 2.11, RTX 4090 (Colab T4 재현 시 seed 3회) |
| 코드 | 노트북 링크 + 실행 스크립트 저장소 |
표준편차를 어디서 재는지 밝혀야 한다. 이 회차의 fold 별 ± 는 seed 변동이고, 전체 평균의 ± 는 fold 와 seed 를 합친 변동이다. 둘은 뜻이 다르다. seed 변동은 "같은 데이터에서 다시 학습하면 얼마나 달라지나"이고, fold 변동은 "다른 시기를 평가하면 얼마나 달라지나"다. 후자가 크면(여기서는 fold 2 의 명절 효과) 모델보다 시기가 결과를 좌우한다는 뜻이다.
이 회차의 예측기는 실습 4 에서 반사실 생성기로 다시 쓰인다. 개입 전 데이터로만 학습해 개입 후를 예측하면 "개입이 없었을 세계"가 되고, 관측치와의 격차가 효과가 된다(인과추론 4장). 그때 필요한 것은 점예측이 아니라 예측구간이며, 그 구간을 어떻게 만드는지가 4장 §6 "예측기 교체의 두 규칙"이다.
자기 데이터로 바꾸기: ① 블록만
노트북은 ①~⑧ 블록으로 짜여 있고, ① 의 df 를 date·board 두 열짜리 데이터프레임으로 바꾸면 나머지는 그대로 돈다.
- 결측 날짜 채우기.
df.set_index('date').asfreq('D')로 빈 날을 만들고 보간하거나 0 으로 채운다. 윈도우 코드는 연속 인덱스를 가정한다. - 계절 주기 맞추기. 월별 자료면 naive 의 7 을 12 로,
H·L을 12·36 정도로 줄인다. 공휴일 특징 대신 월 더미를 쓴다. - 평가 구간 정하기. 관심 개입(정책·캠페인)이 있으면 그 시점을 평가 구간이 아니라 실습 4 의 반사실 문제로 넘긴다. 평가 구간은 "보통의 시기"여야 성능이 뜻을 가진다.
- 베이스라인 먼저 돌리기. ③ 셀의 표를 보고 MASE 분모가 상식적인 크기인지 확인한다. 분모가 0 에 가까우면(거의 결정론적 시계열) MASE 는 폭발한다.
- ⑦ 보고표를 그대로 붙이기. 마크다운 출력을 논문 표로 옮기고, 부록 표의 각 행을 자기 설정으로 채운다.
퀴즈와 핵심 정리
먼저 스스로 답한 뒤 펼쳐서 확인하라.
퀴즈
어떤 학생이 4,261일을 무작위로 8:2 로 나눠 GRU 를 학습했더니 MASE 가 0.31 이 나왔다. 이 숫자가 §4 의 0.58 보다 좋은 이유와, 왜 논문에 쓸 수 없는지 설명하라.
fold 2 에서 계절 naive 의 MASE 가 1.07 로 1 을 넘었다. "naive 가 자기 자신보다 나쁘다"는 뜻인가?
UMAP 지도에서 두 점이 멀리 떨어져 있으면 두 역의 이용 패턴이 크게 다르다고 말할 수 있는가? 군집을 UMAP 좌표에서 하면 안 되는 이유는?
min_dist 에 따라 바뀐다. 군집을 UMAP 좌표에서 하면 축소가 만든 인공적인 덩어리를 군집으로 착각하고, 차원 축소의 seed 를 바꾸면 군집 결과가 바뀐다. 군집은 원 특징 공간(표준화한 11차원)에서 하고, 지도는 그 결과를 보여 주는 데만 쓴다.하나. 시계열은 시간순으로만 자른다. 정규화·검증·조기종료까지 학습 구간 안에서 끝내야 예측 성능이 예측 성능이다.
둘. 베이스라인은 저울이다. 세 신경망은 계절 naive 를 35% 이상 이겼지만, 그 이득의 3분의 2는 달력 특징이 만들었고 구조 간 차이는 seed 변동 안이었다. 절제 없이는 이 문장을 쓸 수 없다.
셋. 군집은 원 공간에서, 지도는 그림에만. 군집이 잡은 구조는 다른 변수(코로나 하락·회복)로 다시 칠해 확인하고, 외딴 점은 버리지 말고 각주로 남긴다.
참고문헌
연도는 출간 기준. 실행 결과 파일은 실습 작업 폴더 기준이다.
데이터
- 서울특별시 교통정책과. 서울시 지하철호선별 역별 승하차 인원 정보 (OA-12914). 서울 열린데이터광장. data.seoul.go.kr/dataList/OA-12914 — 2015-01 ~ 2026-08 월·연 파일 52개, 2026-09-12 내려받음.
- vacanza/holidays. python-holidays, KR 달력. github.com/vacanza/holidays
- 질병관리청. 코로나바이러스감염증-19 위기경보 "심각" 격상 (2020-02-23). kdca.go.kr
평가와 방법
- Hyndman, R. J., & Koehler, A. B. (2006). Another look at measures of forecast accuracy. International Journal of Forecasting, 22(4), 679–688.
- Tashman, L. J. (2000). Out-of-sample tests of forecasting accuracy: an analysis and review. International Journal of Forecasting, 16(4), 437–450.
- Hyndman, R. J., & Athanasopoulos, G. (2021). Forecasting: Principles and Practice (3rd ed.). OTexts. otexts.com/fpp3
- Makridakis, S., Spiliotis, E., & Assimakopoulos, V. (2020). The M4 Competition: 100,000 time series and 61 forecasting methods. International Journal of Forecasting, 36(1), 54–74.
- Cho, K., et al. (2014). Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation. EMNLP. arxiv.org/abs/1406.1078
- Zeng, A., Chen, M., Zhang, L., & Xu, Q. (2023). Are Transformers Effective for Time Series Forecasting? AAAI. arxiv.org/abs/2205.13504
- McInnes, L., Healy, J., & Melville, J. (2018). UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction. arxiv.org/abs/1802.03426
- Rousseeuw, P. J. (1987). Silhouettes: A graphical aid to the interpretation and validation of cluster analysis. Journal of Computational and Applied Mathematics, 20, 53–65.
실행 결과
실습_work/10_ts_experiment.py → out/ts_results.csv(90행) · ts_summary.csv · ts_total.csv · fig/ts_*.png. 11_station_cluster.py → out/station_*.csv/json. 실행일 2026-09-12.
이 자료는 단일 HTML 파일로 배포되며, 실습 위젯은 순수 JavaScript 로 구현되어 오프라인에서도 동작한다. 모든 수치는 본문에 적힌 날짜의 실제 실행 결과다.