GBT
한국외국어대학교Global Business & Technology
실습 1 · 시계열 — 예측과 역 군집
목차 강의 홈 ↗
대학원 딥러닝 · 실습 1

시계열:
예측과 역 군집

서울 지하철 11년치 일별 승차 인원으로 시간순 분할, 계절 naive, 순환망을 같은 저울에 올린다. 딥러닝이 이기는지, 이긴다면 왜 이기는지를 숫자로 확인하고, 455개 역을 이용 패턴으로 묶는다. 결과는 전부 실제 실행값이다.

실데이터 · 실행 결과 수록Colab 노트북조작형 실습 2개템플릿 ① 블록 교체형
0
2020년 2월 23일, 서울

일요일 저녁의 발표, 월요일 아침의 빈 열차

예측 모형이 한 번도 본 적 없는 세계가 시작됐다. 그런데 그 이전 5년은, 가장 단순한 규칙으로도 꽤 잘 맞았다.

도입 · 한 장면

하루 740만 명이 타던 열차

2020년 2월 23일 일요일 저녁, 정부는 감염병 위기경보를 가장 높은 단계인 "심각"으로 올렸다. 그 전해 서울 지하철은 하루 평균 744만 명을 태웠다. 그 해에는 538만 명이었다. 4분의 1 넘게 사라진 승객은 서서히 돌아와, 6년 뒤인 지금도 이전 수준에 조금 못 미친다.

이 자료로 예측 모형을 만든다고 하자. 흔한 출발은 "LSTM 을 돌린다"이다. 그런데 이 회차의 실험이 보여주는 것은 다르다. 7일 전 값을 그대로 쓰는 규칙이 생각보다 강하고, 딥러닝이 그 규칙을 이기는 폭의 3분의 2는 순환 구조가 아니라 달력(요일·공휴일)에서 온다. 어떤 모델을 쓰느냐보다 무엇을 입력에 넣고 어떻게 평가하느냐가 결과를 좌우한다.

그래서 이 회차는 모델 소개가 아니라 절차를 다룬다. 시간순으로만 자르는 분할, 베이스라인과의 비율(MASE), seed 반복, 특징을 하나 빼 보는 절제. 이 절차를 몸에 익히면 자기 데이터가 매출이든 방문자든 환율이든 같은 노트북이 돈다.

누수 없이 자르기미래가 학습에 새어 들어오면 어떤 모델도 좋아 보인다. rolling-origin 3 fold 로 잘라 본다
베이스라인을 저울로MASE = 내 모델 오차 ÷ 계절 naive 오차. 1 을 넘으면 딥러닝을 쓸 이유가 없다
라벨 없이 구조 보기455개 역의 이용 패턴을 벡터로 만들어 군집하면 여가·통근·업무 세 유형이 갈린다
더 읽기

시계열 예측 대회의 역사는 "복잡한 모델이 단순한 규칙을 못 이긴다"는 경고의 역사이기도 하다. 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

1
서울 열린데이터광장 OA-12914 · 로그인 없이 내려받기

데이터

일 × 역 단위 250만 행을 일 단위 합계 4,261행으로 줄인다. 결측일은 없고, 역 수가 11년 사이 539개에서 618개로 늘었다는 점만 기억하면 된다.

§1 · 데이터 요약

4,261일, 결측 0, 그러나 역 수는 늘었다

원자료는 하루·노선·역 단위 승하차 인원이다. 이 회차는 하루 총승차로 합쳐 하나의 시계열로 쓰고, §5 에서 역 단위로 돌아간다.

항목비고
기간2015-01-01 ~ 2026-08-314,261일, 결측일 0
원자료 행 수2,518,189일 × 노선 × 역. 월·연 파일 52개(cp949, 일부 utf-8)
일평균 총승차676만 명2019년 744만 → 2020년 538만 → 2026년 712만
역 수539 → 618신설·연장으로 증가. 총합에는 그 증가분이 섞여 있다
공변량요일 7, 공휴일 1holidays 패키지 KR 달력(대체공휴일·임시공휴일 포함)
단위백만 명모든 오차는 백만 명 단위
서울 지하철 일별 총승차 2015~2026
그림 1. 일별 총승차. 굵은 띠처럼 보이는 것은 평일·주말의 주간 진동이다. 2020년 2월의 급락 뒤 회복이 4년 넘게 이어지고, 옅은 음영이 §2 의 세 평가 구간이다. 읽어 낼 것: 2024년 이후에도 2019년 수준의 띠 상단(약 900만)에 닿는 날이 드물다.
LAB · 코드형

데이터 로드

일 단위 합계 파일은 이 사이트에서 직접 읽는다. 원자료 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

2
rolling-origin 3 fold · 지평 28일 · 계절 naive

분할과 베이스라인

시간순으로만 자른다. 7일 전 값을 반복하는 규칙이 학습구간 기준 MASE 0.89 를 낸다. 이것이 모든 모델이 넘어야 할 선이다.

§2 · 분할

미래는 학습에 들어오면 안 된다

평가 구간을 6개월씩 세 번 두고, 각 구간 이전 데이터로만 학습한다. 구간 안에서는 28일 간격으로 origin 을 옮기며 28일씩 예측한다. 무작위 분할은 시계열에서 금지다.

fold학습평가origin 수평가 구간의 공휴일
12015-01 ~ 2025-022025-03-01 ~ 08-316삼일절 대체, 어린이날, 현충일, 6·3 선거일, 광복절
22015-01 ~ 2025-082025-09-01 ~ 2026-02-286추석 연휴(10-3~10-9), 성탄절, 신정, 설 연휴
32015-01 ~ 2026-022026-03-01 ~ 08-316삼일절 대체, 어린이날, 석가탄신일 대체, 지방선거, 제헌절(달력 패키지 기준)
LAB · 코드형

윈도우 만들기

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

§2 · 베이스라인

7일 전 값을 그대로 쓰면 MASE 0.89

한 줄 목표: 계절 naive 가 언제 잘 맞고 언제 무너지는지를 origin 을 옮겨 가며 직접 본다. 무너지는 날은 거의 전부 공휴일이다.

LAB · 조작형

시간순 분할·계절 naive 뷰어

파란 띠가 입력 56일, 붉은 띠가 예측 28일이다. 회색 점선은 "7일 전 값 반복" 예측, 붉은 세로 점선은 공휴일.

관측계절 naive(7일)공휴일

조작 안내 — ① "공휴일 없음"(2025-10-27)을 눌러 naive MAE 를 보라. ② "추석 주간"(2025-09-29)으로 옮겨라. ③ 슬라이더로 아무 날이나 골라 붉은 점선이 지평 안에 있을 때와 없을 때를 비교하라.
관찰 포인트 — 공휴일 없는 4주는 MAE 0.1 안팎, 추석이 든 4주는 1.1 을 넘는다(10배). naive 가 무너지는 이유는 "패턴을 못 배워서"가 아니라 달력을 모르기 때문이다. 이것이 §4 절제 실험의 예고다.

베이스라인규칙fold 1fold 2fold 3평균
SeasonalNaive77일 전 같은 요일 값0.8111.0730.7970.894
YoY364364일 전 값 × 최근 4주 수준 보정0.8831.7210.8841.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

3
같은 입력, 같은 손실, 같은 조기종료 — 구조만 다르다

세 가지 모델

lag-MLP, GRU, DLinear 는 모두 "과거 56일 + 앞으로 28일의 달력"을 받아 28일 벡터를 낸다. 셋을 같은 조건에 두어야 구조의 효과만 남는다.

§3 · 세 구조

순환·선형·완전연결

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만순서 의존. 게이트로 장기 의존 조절
DLinear7일 이동평균 추세 + 잔차 각각 Linear(56→28) + 달력 Linear(224→28)약 1만선형. 추세·계절 분해
LAB · 코드형

모델 정의

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

§3 · 학습 루프

정규화는 학습 창에서, 검증은 끝 10% 에서

한 줄 목표: 누수가 생길 수 있는 세 지점(정규화·검증 분할·조기종료)이 코드 어디에 있는지 짚는다.

LAB · 코드형

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()(전체 시계열)로 바꿔 다시 돌려 보라.
관찰 포인트 — 이 데이터에서는 차이가 작다. 그러나 추세가 강한 데이터(매출·주가)에서는 전체 평균이 미래 수준을 미리 알려 주는 누수가 된다. 차이가 작아도 습관은 옳은 쪽으로 들인다.

4
3 fold × seed 5회 · RTX 4090 · 모델당 2~6초

결과와 절제

세 모델 모두 naive 를 35% 이상 이긴다. 그러나 달력 특징을 빼면 그 이득의 3분의 2가 사라지고, DLinear 는 naive 와 같아진다. 이긴 것은 구조가 아니라 달력이다.

§4 · 성능표

MASE 0.57 ~ 0.62, 세 모델의 차이는 seed 안에

아래 표가 논문 본문에 들어갈 형태다. 평균 ± 표준편차, seed 수, 베이스라인, 단위를 한 표에 담는다.

모델fold 1fold 2fold 3MASE 평균MAE 평균 (백만 명)
SeasonalNaive70.8111.0730.7970.8940.440
YoY3640.8831.7210.8841.1630.572
lag-MLP0.548 ± 0.0420.689 ± 0.0510.468 ± 0.0490.5690.280
GRU0.638 ± 0.0550.569 ± 0.0320.544 ± 0.0490.5840.287
DLinear0.509 ± 0.0150.736 ± 0.0090.627 ± 0.0180.6240.307

fold 별 값은 seed 5회(0~4) 평균 ± 표준편차. 평가 origin 은 fold 당 6개(28일 간격). 실행: 2026-09-12, RTX 4090, PyTorch 2.11, 모델당 평균 학습 시간 3~6초(조기종료 평균 94~115 epoch).

fold별 MASE
그림 2. fold 별 MASE. 점선이 1(naive 와 동급). 읽어 낼 것: 세 딥러닝 모델의 순위가 fold 마다 뒤바뀐다(fold 1 은 DLinear, fold 2 는 GRU, fold 3 은 MLP 가 최고). 한 fold 만 보고 "GRU 가 최고"라고 쓰면 틀린다.
fold 3 첫 origin 예측 예시
그림 3. 2026-03-01 origin 의 28일 예측. 3월 2일(삼일절 대체휴일)에서 naive(회색)는 평일 값을 내고, 달력을 아는 GRU·DLinear 는 내려앉는다. 읽어 낼 것: 나머지 26일은 세 선이 거의 겹친다. 모델 간 차이는 특수일에 집중된다.
더 읽기

"세 모델의 차이가 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

§4 · 절제(ablation)

달력을 빼면 DLinear 는 naive 와 같아진다

한 줄 목표: 딥러닝의 이득 중 얼마가 "요일·공휴일 특징" 이고 얼마가 "모델 구조" 인지를 특징을 빼 보는 것으로 분해한다.

모델달력 포함달력 제거 (-cal)naive 대비 이득 중 달력 몫
lag-MLP0.5690.766 ± 0.15461%
GRU0.5840.774 ± 0.15761%
DLinear0.6240.887 ± 0.13997%
SeasonalNaive70.894

MASE, 3 fold × seed 5회 평균(± 는 15회 표준편차). "달력 몫" = (달력 제거 − 달력 포함) ÷ (naive − 달력 포함).

절제 실험
그림 4. 달력 특징을 뺀 세 모델(회색)은 naive(붉은 점선)에 바짝 붙는다. 읽어 낼 것: MLP·GRU 는 과거 56일 값만으로도 naive 보다 조금 낫다(0.77 vs 0.89). 그 몫이 "구조가 배운 것"이고, 나머지는 달력이 준 것이다.
지평별 MAE
그림 5. 지평(1~28일)별 MAE. naive 의 봉우리(6일, 11일, 27일)는 각 fold 평가 구간에 든 공휴일 위치의 평균이다. 읽어 낼 것: 딥러닝 모델의 오차는 지평이 늘어도 거의 평평하다. direct multi-output 이라 오차가 누적되지 않기 때문이다.
흔한 오해

"LSTM/GRU 가 계절성을 스스로 배우니 특징 공학은 필요 없다." 주간 계절성은 배운다(달력 제거 GRU 도 naive 보다 낫다). 그러나 불규칙한 공휴일은 56일 창 안에 한두 번밖에 안 나타나므로 배울 표본이 없다. 달력은 모델이 배울 수 없는 정보를 밖에서 넣어 주는 것이고, 이 데이터에서는 그것이 이득의 대부분이다.

더 읽기

절제 실험은 딥러닝 논문의 표준 절차이지만 사회과학 응용 논문에서는 자주 빠진다. 빠지면 심사자는 "결국 어떤 요인이 성능을 만들었는가"를 물을 수밖에 없다. 절제는 비용이 거의 없다. 특징 하나를 0 으로 두고 같은 루프를 다시 돌리면 된다. 이 회차 전체(90회 학습)가 GPU 에서 8분, Colab T4 에서는 seed 3회로 줄여 5분 안팎이다.

절제가 알려 주는 또 하나는 다음에 무엇을 넣을지다. 달력이 이 정도 몫을 차지한다면, 기상(폭우·폭설), 대규모 행사, 파업 같은 외생 정보를 넣는 것이 구조를 바꾸는 것보다 남은 오차를 더 줄일 가능성이 높다. 논문의 "향후 연구" 문단은 이렇게 절제 결과에서 나와야 설득력이 있다.

출처 실행 결과 ts_total.csv. 절제 설정: 달력 입력 벡터를 0 으로 치환(모델 구조·학습 설정 동일)

5
455개 역 × 11개 특징 → K-means → UMAP

역 군집·시각화

라벨 없이도 구조는 보인다. 요일 분포·규모·코로나 하락·회복으로 만든 벡터를 군집하면 여가형·통근형·업무형 세 유형이 갈리고, 코로나 타격과 회복이 유형마다 달랐다.

§5 · 특징 벡터

역 하나를 11개 숫자로

군집은 특징 설계가 전부다. 2019년(코로나 전) 요일별 승차 비중 7개, 승차÷하차 비, 규모(log 일평균), 2020/2019 비, 2024/2019 비. 전 기간 자료가 있는 455개 역만 쓴다.

LAB · 코드형

특징 → 표준화 → 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

§5 · 지도

여가형 58, 통근형 288, 업무형 109

한 줄 목표: 군집 라벨을 색으로 보고, 같은 점들을 코로나 하락·회복으로 다시 칠해 "군집이 실제로 다른 운명을 겪었는가"를 확인한다.

LAB · 조작형

역 이용 패턴 UMAP 지도

점 하나가 역 하나다. 마우스를 올리면 역 이름과 수치가 뜬다.

0 주말형1 통근 주거형2 평일 업무형

조작 안내 — ① "군집" 상태에서 오른쪽 위 덩어리(청록)에 마우스를 올려 어떤 역들인지 읽어라. ② "2020/2019 하락"으로 바꿔 어느 덩어리가 붉게 물드는지 보라. ③ "2024/2019 회복"에서 붉은 점이 왼쪽 아래(남색 군집)에 몰리는지 보라.
관찰 포인트 — 군집 색을 지우고 하락·회복으로 칠해도 같은 덩어리가 같은 색으로 물들면, 군집이 "요일 분포"만이 아니라 실제 행동의 차이를 잡았다는 증거다. 군집 변수를 회귀에 넣을 근거가 여기서 생긴다.

군집역 수주말 비중일평균(천 명)2020/20192024/2019중심 최근접 역
0 · 주말형(여가·외곽)580.31910.40.6610.918온양온천, 가평, 청평, 용문, 신설동, 홍대입구(공항철도)
1 · 통근 주거형2880.21813.90.7300.927원당, 태릉입구, 부개, 회룡, 한대앞, 중동
2 · 평일 업무형1090.17011.50.7981.120증미, 샛강, 강동구청, 디지털미디어시티, 등촌, 경찰병원

군집 이름은 프로파일을 보고 붙인 해석이다. 실루엣(k=3) 0.279, seed 5회 ARI 평균 0.94. 지축·구반포·덕계·신내 네 역은 재개발·신도시로 2024/2019 가 2 를 넘거나 0.4 아래라 그림 오른쪽 위·아래에 따로 떨어진다.

군집 프로파일
그림 6. 왼쪽 UMAP 지도(색=군집), 가운데 군집별 요일 비중, 오른쪽 코로나 하락(가로) 대 회복(세로). 읽어 낼 것: 주말형(청록)은 토·일이 평일보다 높고 코로나 타격이 가장 컸다. 업무형(남색)은 일요일이 평일의 40% 이고 2024년에 2019년을 넘어섰다. 오른쪽 위 외딴 점(지축역)은 군집이 아니라 개별 사연(신도시 입주)이다.
더 읽기

군집 결과를 논문에 쓰는 두 가지 길이 있다. 하나는 기술(descriptive)로, "역은 세 유형으로 나뉘고 코로나 타격은 여가형 −34%, 통근형 −27%, 업무형 −20% 였다" 같은 문장이다. 다른 하나는 변수로, 군집 더미를 회귀에 넣어 "여가형 역 주변 상권 매출은 팬데믹에 더 민감했다"를 검정하는 것이다. 후자에서는 군집이 결과변수(매출)와 무관한 정보로만 만들어졌는지 확인해야 순환 논리를 피한다. 여기서는 승차 데이터로 군집하고 승차 회복을 설명하므로 후자로는 쓸 수 없고, 다른 결과변수(상권·부동산)에 붙일 때 의미가 있다.

외딴 점들은 버리지 말고 각주로 남긴다. 지축역(3호선)은 2019년 일평균 1,300명에서 2025년 7,200명이 됐고, 구반포역(9호선)은 재건축 이주로 3,100명에서 990명까지 줄었다 되돌아오는 중이다. 군집이 놓치는 것을 사람이 보충하는 자리가 이런 각주다.

출처 실행 결과 out/station_features.csv(455행), station_widget.json

6
본문 표 · 부록 재현성 표 · 템플릿 ① 블록 교체

논문에 쓰기

보고할 것은 다섯 가지다. 분할 방식, 베이스라인, 평균±표준편차, 절제, 그리고 재현에 필요한 설정표. 이 다섯이 있으면 심사자의 첫 질문 다섯 개가 사라진다.

§6 · 보고 규격

본문 한 표, 부록 한 표

본문에는 §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회)
코드노트북 링크 + 실행 스크립트 저장소
결과 문장의 예"세 신경망 모델은 계절 naive 대비 MASE 를 0.89 에서 0.57~0.62 로 낮췄다(3 fold × 5 seed). 세 모델 간 차이는 seed 변동 범위 안이었다. 달력 특징을 제거하면 이득의 61~97% 가 사라져, 개선의 주된 원천은 공휴일 정보였다."
쓰지 않을 문장"GRU 가 가장 우수한 성능을 보였다"(fold 2 만 그렇다) · "딥러닝이 전통 방법을 크게 능가했다"(YoY 는 이겼지만 절제가 이유를 말한다) · "MAE 0.28"(단위·베이스라인 없이 숫자만)
더 읽기

표준편차를 어디서 재는지 밝혀야 한다. 이 회차의 fold 별 ± 는 seed 변동이고, 전체 평균의 ± 는 fold 와 seed 를 합친 변동이다. 둘은 뜻이 다르다. seed 변동은 "같은 데이터에서 다시 학습하면 얼마나 달라지나"이고, fold 변동은 "다른 시기를 평가하면 얼마나 달라지나"다. 후자가 크면(여기서는 fold 2 의 명절 효과) 모델보다 시기가 결과를 좌우한다는 뜻이다.

이 회차의 예측기는 실습 4 에서 반사실 생성기로 다시 쓰인다. 개입 전 데이터로만 학습해 개입 후를 예측하면 "개입이 없었을 세계"가 되고, 관측치와의 격차가 효과가 된다(인과추론 4장). 그때 필요한 것은 점예측이 아니라 예측구간이며, 그 구간을 어떻게 만드는지가 4장 §6 "예측기 교체의 두 규칙"이다.

§6 · 템플릿 적용

자기 데이터로 바꾸기: ① 블록만

노트북은 ①~⑧ 블록으로 짜여 있고, ① 의 dfdate·board 두 열짜리 데이터프레임으로 바꾸면 나머지는 그대로 돈다.

  1. 결측 날짜 채우기. df.set_index('date').asfreq('D') 로 빈 날을 만들고 보간하거나 0 으로 채운다. 윈도우 코드는 연속 인덱스를 가정한다.
  2. 계절 주기 맞추기. 월별 자료면 naive 의 7 을 12 로, H·L 을 12·36 정도로 줄인다. 공휴일 특징 대신 월 더미를 쓴다.
  3. 평가 구간 정하기. 관심 개입(정책·캠페인)이 있으면 그 시점을 평가 구간이 아니라 실습 4 의 반사실 문제로 넘긴다. 평가 구간은 "보통의 시기"여야 성능이 뜻을 가진다.
  4. 베이스라인 먼저 돌리기. ③ 셀의 표를 보고 MASE 분모가 상식적인 크기인지 확인한다. 분모가 0 에 가까우면(거의 결정론적 시계열) MASE 는 폭발한다.
  5. ⑦ 보고표를 그대로 붙이기. 마크다운 출력을 논문 표로 옮기고, 부록 표의 각 행을 자기 설정으로 채운다.
노트북01_timeseries.ipynb 를 내려받아 Google Colab 에서 파일 → 노트 업로드. 런타임 유형은 T4 GPU. 전체 실행 약 10분.
데이터subway_daily_total.csv(일 단위 합계, 150KB) · subway_station_daily.parquet(역 단위, 13MB). 원자료 52개 파일 로더는 노트북 부록.
7
이해 점검

퀴즈와 핵심 정리

먼저 스스로 답한 뒤 펼쳐서 확인하라.

이해 점검

퀴즈

어떤 학생이 4,261일을 무작위로 8:2 로 나눠 GRU 를 학습했더니 MASE 가 0.31 이 나왔다. 이 숫자가 §4 의 0.58 보다 좋은 이유와, 왜 논문에 쓸 수 없는지 설명하라.
무작위 분할에서는 평가일의 전날·다음날이 학습에 들어 있다. 입력 창 56일도 대부분 학습에서 본 값이므로 모델은 "보간"을 하고 있고, 실제 예측 상황(미래를 전혀 모름)과 다르다. 이 숫자는 예측 성능이 아니라 기억 성능이다. 시계열은 시간순 분할만 허용되며, 논문에서는 분할 방식을 반드시 명시해야 한다.
fold 2 에서 계절 naive 의 MASE 가 1.07 로 1 을 넘었다. "naive 가 자기 자신보다 나쁘다"는 뜻인가?
아니다. MASE 의 분모는 학습구간에서 naive 가 낸 평균 오차(보통의 4주)이고, 분자는 평가구간(추석·설 연휴가 든 6개월)의 오차다. 평가 시기가 학습 시기보다 예측하기 어려우면 naive 자신도 1 을 넘는다. 그래서 MASE 는 모델 간 비교에 쓰고, 절대값 1 의 해석은 "같은 시기의 naive 와 같다"가 아니라 "학습구간 naive 의 평균 난이도와 같다"로 읽어야 한다. 같은 fold 안의 모델 비교(0.57 vs 1.07)는 그대로 유효하다.
UMAP 지도에서 두 점이 멀리 떨어져 있으면 두 역의 이용 패턴이 크게 다르다고 말할 수 있는가? 군집을 UMAP 좌표에서 하면 안 되는 이유는?
말할 수 없다. UMAP 은 국소 이웃 관계를 보존하도록 최적화되며 전역 거리는 보존하지 않는다. 멀리 떨어진 두 덩어리 사이의 거리는 초기화와 min_dist 에 따라 바뀐다. 군집을 UMAP 좌표에서 하면 축소가 만든 인공적인 덩어리를 군집으로 착각하고, 차원 축소의 seed 를 바꾸면 군집 결과가 바뀐다. 군집은 원 특징 공간(표준화한 11차원)에서 하고, 지도는 그 결과를 보여 주는 데만 쓴다.
핵심 정리

하나. 시계열은 시간순으로만 자른다. 정규화·검증·조기종료까지 학습 구간 안에서 끝내야 예측 성능이 예측 성능이다.

둘. 베이스라인은 저울이다. 세 신경망은 계절 naive 를 35% 이상 이겼지만, 그 이득의 3분의 2는 달력 특징이 만들었고 구조 간 차이는 seed 변동 안이었다. 절제 없이는 이 문장을 쓸 수 없다.

셋. 군집은 원 공간에서, 지도는 그림에만. 군집이 잡은 구조는 다른 변수(코로나 하락·회복)로 다시 칠해 확인하고, 외딴 점은 버리지 말고 각주로 남긴다.

8
데이터 · 방법 · 실행 결과

참고문헌

연도는 출간 기준. 실행 결과 파일은 실습 작업 폴더 기준이다.

데이터

  1. 서울특별시 교통정책과. 서울시 지하철호선별 역별 승하차 인원 정보 (OA-12914). 서울 열린데이터광장. data.seoul.go.kr/dataList/OA-12914 — 2015-01 ~ 2026-08 월·연 파일 52개, 2026-09-12 내려받음.
  2. vacanza/holidays. python-holidays, KR 달력. github.com/vacanza/holidays
  3. 질병관리청. 코로나바이러스감염증-19 위기경보 "심각" 격상 (2020-02-23). kdca.go.kr

평가와 방법

  1. Hyndman, R. J., & Koehler, A. B. (2006). Another look at measures of forecast accuracy. International Journal of Forecasting, 22(4), 679–688.
  2. Tashman, L. J. (2000). Out-of-sample tests of forecasting accuracy: an analysis and review. International Journal of Forecasting, 16(4), 437–450.
  3. Hyndman, R. J., & Athanasopoulos, G. (2021). Forecasting: Principles and Practice (3rd ed.). OTexts. otexts.com/fpp3
  4. 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.
  5. Cho, K., et al. (2014). Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation. EMNLP. arxiv.org/abs/1406.1078
  6. Zeng, A., Chen, M., Zhang, L., & Xu, Q. (2023). Are Transformers Effective for Time Series Forecasting? AAAI. arxiv.org/abs/2205.13504
  7. McInnes, L., Healy, J., & Melville, J. (2018). UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction. arxiv.org/abs/1802.03426
  8. 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.pyout/ts_results.csv(90행) · ts_summary.csv · ts_total.csv · fig/ts_*.png. 11_station_cluster.pyout/station_*.csv/json. 실행일 2026-09-12.

한국외국어대학교 Global Business & Technology · 대학원 딥러닝 세미나 · 실습 1.
이 자료는 단일 HTML 파일로 배포되며, 실습 위젯은 순수 JavaScript 로 구현되어 오프라인에서도 동작한다. 모든 수치는 본문에 적힌 날짜의 실제 실행 결과다.