가르치는 형식으로 시계열을 공부한 3주, 내가 틀렸던 것 5가지
지난 3주 동안 시계열 데이터 분석을 공부했습니다. 공부 방법으로 강의자료 형식을 썼습니다. 누굴 가르치려고 만든 게 아니라, 내가 나를 가르치는 형식이 제일 정직한 공부법이라서 그렇게 했습니다.
방법은 이렇습니다. 개념 하나를 골라 활동지와 정답까지 갖춘 모듈로 씁니다. 그리고 제가 그 개념을 처음 보는 사람인 척하고 실습을 직접 처음부터 끝까지 해봅니다. 막히면 막힌 지점을 PROGRESS.md에 기록하고, 기록을 반영해 자료를 고친 다음 다음 모듈로 넘어갑니다. 3주 동안 그 기록이 60건 넘게 쌓였습니다.
난이도를 세 단계로 나눈 것도 같은 이유입니다. 제일 쉬운 층은 구글 시트로 개념을 손으로 만져 보고, 가운데 층은 Colab과 pandas로 표준 파이프라인을 돌리고, 마지막 층에서 ARIMA·ETS·ML을 다룹니다. 맨 위층부터 시작하면 아는 척하고 넘어가기가 너무 쉬워서, 스스로를 못 속이는 맨 아래층부터 다시 밟아 올라갔습니다. 모듈 24개, 파일 120개가 나왔고 parameterfreak/timeseries-academy에 공개해 뒀습니다.
그 60건을 다시 읽어 보니, 제가 예상했던 것과 전혀 다른 것들이 남아 있었습니다. 이 글은 그중 다섯 가지입니다.
1. “안다”와 “설명할 수 있다”는 전혀 다른 상태다
시작할 때 저는 시계열의 기본 개념들을 안다고 생각했습니다. 이동평균, 분해, 자기상관, ARIMA. 그래서 이 작업이 “아는 것을 순서대로 배열하는 일”이 될 거라고 예상했습니다.
틀렸습니다. 60건의 기록 중 대부분은 개념을 몰라서 막힌 게 아니라, 설명하려고 하는 순간에야 내가 그 개념을 얼마나 얕게 붙잡고 있었는지가 드러나서 막힌 것이었습니다.
가장 분명한 사례가 U2의 Loess입니다. STL 분해를 다루는 모듈이고, 저는 슬라이드에 “Loess = 매끈한 곡선을 맞추는 방법”이라고 써 뒀습니다. 그 문장을 쓸 때는 아무 문제가 없었습니다. 그런데 실습을 하다가 제가 저 자신에게 물었습니다. 그래서 Loess가 뭔데?
이동평균과 뭐가 다른지 한 문장으로 말할 수 없었습니다. 알고 있다고 생각한 것은 “Loess라는 이름의 평활 방법이 있다”는 사실이지, 그 작동이 아니었습니다. 결국 teacher 노트에 이렇게 다시 썼습니다.
이동평균은 창 안의 값들을 평균 내서 수평선 하나를 놓는다. Loess는 창 안의 값들에 작은 회귀선 하나를 맞춘다. 그리고 이동평균은 창 안의 모든 점을 똑같이 세지만, Loess는 가까운 점에 더 큰 무게를 준다.
STL이 왜 손으로 한 분해보다 잔차를 절반으로 줄이는지도 여기서 따라 나옵니다. 손 분해의 두 부품을 Loess로 바꾸고 반복시킨 것이 STL이니까요.
“안다”의 기준을 저는 착각하고 있었습니다. 기준은 개념을 들었을 때 고개를 끄덕일 수 있는지가 아니라, 아무것도 모르는 사람 앞에서 그 개념이 하는 일을 문장으로 만들 수 있는지입니다. 후자를 통과하지 못하는 지식은 생각보다 아주 많았습니다.
2. 비유는 설명을 돕지만, 용어 자리에 들어가면 이해를 막는다
자료 전체에 경기 서사를 깔았습니다. 예측법은 선수, 오차 지표는 심판, 넘어야 할 기준선은 벽. 이 장치 자체는 잘 작동했습니다. “naive의 벽을 못 넘는 모형은 쓸 이유가 없다”는 문장은 벤치마크의 의미를 정확히 전달합니다.
문제는 비유가 서사를 넘어 용어 자리까지 침범했을 때 생겼습니다. H6 모듈의 설명 노트에 저는 이렇게 써 뒀습니다.
RMSE는 큰 미스에 가중
실습하다 이 줄에서 멈췄습니다. ‘미스’는 선수 은유에서 온 말인데 여기서는 개념을 지칭하는 자리에 앉아 있고, 압축하는 과정에서 목적어(“가중치를”)까지 빠졌습니다. 결국 이 줄은 아무것도 설명하지 못합니다. 모듈 전체에서 ‘미스’를 ‘오차’로 바꾸고 완전한 문장으로 고쳤습니다.
RMSE는 큰 오차에 가중치를 준다.
같은 유형의 결함이 그 뒤로 반복해서 나왔습니다. H7의 “어디서 벌었는지”(돈 은유가 목적어 없이 첫 등장), U1의 “가장 아픈 요일”, U4의 “죽은 부품”, U6 힌트의 “남은 숙제의 이름은 달력이다”(은유 위에 은유를 얹은 이중 압축). 전부 제가 쓸 때는 자연스러웠고, 처음 보는 사람으로 읽으니 뜻을 추측해야 하는 문장이었습니다.
정리된 원칙은 이렇습니다. 은유는 서사에만 쓰고, 개념을 지칭하는 자리에는 정확한 용어를 쓴다. 압축한 은유를 꼭 써야 한다면 첫 등장에 정식 용어를 병기한다.
그리고 이 원칙에는 제가 미처 몰랐던 함정이 하나 더 있었습니다. 세 층은 서로 독립이라 앞 층을 밟았다고 전제하지 않는데, 저는 가운데 층에서 정착시킨 어휘를 마지막 층에서 무의식적으로 재사용하고 있었습니다. 저에게는 세 번째 등장이지만 자료 위에서는 첫 등장입니다. 나중에는 층 경계뿐 아니라 모듈 경계에서도 물려받은 은유는 짧게 다시 풀어 주는 것으로 규칙을 확장했습니다. U6 하나에서만 8곳이었습니다.
3. 로컬에서 통과한 코드는 Colab을 보증하지 않는다
U2 실습 중에 Colab에서 에러가 났습니다.
trend_hand.min().round(2) # AttributeError
제가 쓴 코드고, 로컬에서 실행 검증까지 마친 코드였습니다. 그런데 Colab에서만 터졌습니다. type()으로 확인해 보니 Colab의 pandas 2.2.3에서 min()은 맨 파이썬 float를 돌려주고 있었습니다. float에는 .round()가 없습니다. 같은 버릇으로 쓴 U1의 mean().round(2)는 왜 통과했느냐 하면, mean() 계열은 .round를 가진 타입을 돌려주기 때문입니다. 우연히 통과한 것이죠.
재현을 시도해 봤지만 로컬에서는 pandas 2.2.3과 3.0.5 어느 조합으로도 이 에러가 나지 않았습니다. Colab 환경 특이로 결론 내리고, 스칼라 반올림은 전부 내장 round(x, n)으로 바꿨습니다(Series의 .round()는 그대로 둡니다).
교훈은 pandas 버전 이야기가 아닙니다. 실제로 실행할 환경에서 검증하지 않은 코드는 검증된 것이 아니다. 실습 환경은 Colab인데 저는 로컬에서 확인하고 통과 도장을 찍고 있었습니다. 이건 타입 하나 틀린 문제가 아니라 검증 절차 자체가 잘못 설계돼 있었던 겁니다.
부수적으로, 이 계열의 함정은 코드 밖에도 있었습니다. 한 문단에 물결표(~)가 두 개 있으면 마크다운이 취소선으로 렌더링해 정답이 깨져 보입니다. **단어(영문)**조사 꼴은 닫는 별표가 괄호 뒤·조사 앞에 끼어 GitHub에서 별표가 그대로 노출됩니다(세 과정 전수 검사에서 12곳 나왔습니다). 한글 문서에서만 밟는 지뢰들입니다.
4. 데이터가 두 개여야 비로소 보이는 것이 있다
고등 과정까지는 서울 일별 기온 하나로 진행했습니다. 대학 과정을 설계하면서 성격이 정반대인 데이터를 하나 더 넣었습니다. 서울 지하철 일별 승하차 인원. 같은 1,096일 구간, 결측 0입니다.
두 데이터의 자기상관을 재보면 이렇습니다.
| 데이터 | 시차 1 | 시차 7 |
|---|---|---|
| 서울 기온 | 0.98 | 0.92 |
| 지하철 승차 | 0.35 | 0.68 |
기온은 어제가 오늘을 거의 다 말해 줍니다(0.98). 관성의 세계입니다. 지하철은 어제(0.35)보다 지난주 같은 요일(0.68)이 오늘을 더 잘 말해 줍니다. 달력의 세계고, 최저값은 설날 연휴(2025-01-29)에 찍힙니다.
여기서 제가 틀렸던 것: 저는 “좋은 모형”과 “나쁜 모형”이 있다고 은연중에 생각하고 있었습니다. 데이터 두 개를 나란히 놓고 나서야 그게 아니라는 게 실습에서 매번 증명됐습니다. 기온에서는 ETS·ARIMA 같은 고전 모형이 강합니다. 값의 과거만 봐도 충분한 데이터니까요. 지하철에서는 같은 모형들이 명절 앞에서 무너집니다. “그날이 빨간 날인가”는 과거 값 어디에도 적혀 있지 않기 때문입니다.
U7에서 이게 가장 선명하게 나왔습니다. 부스팅 트리에 공휴일 열 하나를 추가하니 판이 뒤집혔습니다. 같은 알고리즘, 같은 fit입니다. 훈련 구간에 명절이 33번이나 들어 있었는데도 그 전까지 못 배우고 있었던 건, 명절을 갈라낼 열이 없어서입니다. 모형은 자기가 가진 열에 대해서만 질문할 수 있습니다.
모형의 힘은 알고리즘이 아니라 질문할 수 있는 재료의 목록이 정한다.
이 문장이 3주의 결론이 됐습니다. 데이터가 하나였으면 절대 못 썼을 문장입니다. 모형 비교는 데이터를 하나 더 놓기 전까지는 그냥 취향 이야기입니다.
5. 어렵게 쓰는 건 실력이 아니라 게으름이었다
U6(SARIMA와 예측 구간)을 다 쓰고 실습에 들어갔는데, 제가 대충 읽고 코드만 실행하고 끝냈습니다. 제가 쓴 자료를 제가 그렇게 했습니다. 공부가 목적인데 공부를 건너뛴 셈이라, 이건 자료의 결함이 아니라 그 3주가 헛돌았다는 신호였습니다.
처음에는 1절 도입부가 불친절해서라고 생각하고 그 부분만 고쳤습니다. 그래도 안 됐습니다. 다시 보니 문제가 국소적이지 않았습니다. 한 모듈에 넘어야 할 산이 둘(계절 차수 + 예측 구간), 새 도구가 6개, 게다가 검산 산수까지 들어 있었습니다. 하중이 구조적이었던 겁니다.
고칠 때 제가 계속 저항했던 부분은 “내용을 빼면 수준이 낮아지는 것 아닌가”였습니다. 그런데 낮아질 수준이라는 게 누구 앞에서의 수준인지 따져 보니 답이 없었습니다. 읽는 사람은 저뿐이고, 어렵게 쌓아 둬서 제가 안 읽으면 남는 게 없습니다. 결국 이렇게 덜어냈습니다.
- 후보 모형 대회는 결과만 받아 쓰고, 재현은 뒤쪽 실습으로
params읽기, 검산 산수, 커버리지 코드는 실습·심화로- 문항 9개 → 7개, 새 도구 6개 → 3개
여기서 한 번 더 틀렸습니다. 덜어냈더니 분량은 줄었는데 남은 문장이 더 어려워졌습니다. 앞 모듈에서 물려받은 은유와 약칭(놀람·지도·심판·자물쇠·절벽· 백색잡음)이 리마인드 없이 연쇄로 등장하게 된 겁니다. 밀도가 올라간 거죠. 경량화가 만든 2차 결함이었습니다. 물려받은 어휘 8곳에 짧은 괄호 리마인드를 붙이고 나서야 정리됐습니다.
그리고 경량화하면서 코드 셀을 들어내니 본문에 출처 없는 숫자가 남았습니다. “폭이 8도에서 26도로 벌어진다”는 문장이 단언으로만 남아 있는 식입니다. 원래는 계산하는 셀이 있었으니까요. 결국 직접 재는 한 줄을 되살렸습니다.
(pred_ci.iloc[:, 1] - pred_ci.iloc[:, 0]).round(1)
코드는 내려도 다리는 본문에 남긴다. 이게 경량화의 원칙이 됐습니다.
덧붙여 어휘 이야기 하나. U6 코드 주석에 제가 “첫 이레”라고 써 뒀습니다. 7일의 순우리말입니다. 실습하다 “첫 이레가 뭐야”에서 걸렸습니다. 제가 쓴 주석에서요. 네 곳을 ‘7일’로 바꿨습니다. 멋 부린 어휘가 만든 불필요한 턱이었습니다.
남은 것
3주간 가장 크게 바뀐 생각은 이겁니다. 가르치는 형식으로 공부하는 일의 대부분은 내용을 정하는 일이 아니라, 내 이해의 구멍을 찾는 일이었습니다.
구멍을 찾아 준 건 제 지식이 아니라 절차였습니다. 쓴 사람이 처음 보는 사람인 척 직접 끝까지 해보고, 막힌 지점을 반드시 기록하는 것. 이 절차가 없었으면 저 60건은 전부 제 머릿속에서 “아 이건 당연하지”로 넘어갔을 겁니다. 그러고도 자료는 잘 만들어진 것처럼 보였을 거고요. 공부한 티는 나는데 공부는 안 된 상태, 그게 제일 위험합니다.
혼자 공부하면 어디까지 이해했는지 스스로 채점하기가 어렵습니다. 활동지와 정답을 먼저 쓰고 나중에 제가 그걸 푸는 방식은, 그 채점을 강제로 받게 만드는 장치였습니다. 개념을 읽고 끄덕이는 것과 그 개념을 문장으로 만드는 것 사이의 간격이, 생각보다 훨씬 넓습니다.
전체 자료는 parameterfreak/timeseries-academy에 있습니다. 제 공부 기록이라 남이 쓰기 좋게 다듬은 건 아니지만, 같은 방식으로 공부해 볼 분에게는 형식이 참고가 될 것 같아 그대로 뒀습니다. 첫 층은 구글 시트로, 나머지 두 층은 Colab과 pandas로 돌아가고, 모듈마다 슬라이드·설명 노트·활동지· 실습·정답 5종이 들어 있습니다. 위에서 인용한 60건의 원본은 각 층의 PROGRESS.md에 전부 있습니다.