NYC 택시 수요 예측 파이프라인 만들기 (5) Baseline vs LightGBM vs PyTorch
학습이 필요 없는 규칙 3종부터 LightGBM, PyTorch MLP까지 동일한 분할과 지표로 비교한 결과를 정리했습니다.
이번 글에서 할 일
4편에서 학습 테이블과 피처를 만들었습니다. 이번에는 1편에서 정한 3단계 비교를 실행합니다.
- Baseline: 학습 없는 예측 규칙 3종
- LightGBM: 테이블 데이터 주력 후보
- PyTorch MLP: 딥러닝은 여기서 얼마나 통하나
규칙은 하나입니다. 모든 모델은 같은 분할, 같은 지표로 채점합니다. 개발 중에는 검증 구간만 보고, 테스트 구간은 마지막에 한 번 엽니다.
Baseline: 이겨야 할 기준선
Baseline 3종은 별도 구현이 없습니다. 4편에서 만든 이력 피처를 그대로 예측값으로 씁니다.
1
2
3
4
BASELINES = ["lag_1h", "lag_24h", "week_mean"]
for name in BASELINES:
demand[f"pred_{name}"] = demand[name]
“직전 시간 수요를 그대로 말하기”, “전날 같은 시간 수요를 그대로 말하기”, “최근 7일 같은 시각대 평균 말하기”입니다. 우스워 보이지만 수요처럼 관성이 강한 시계열에서는 꽤 강력하고, 이걸 못 이기는 ML 모델은 존재 이유가 없습니다.
설계상 중요한 점이 하나 있습니다. Baseline과 ML 모델이 같은 피처 계산 코드를 공유합니다. 이력 피처의 계산 로직은 build_features 모듈이 유일한 출처이고, Baseline은 그 값을 예측으로 쓰고 LightGBM은 입력으로 씁니다. 각자 따로 계산하면 “Baseline의 lag_24h와 모델의 lag_24h가 미묘하게 다른” 불공정 비교가 생길 수 있는데, 출처가 하나면 원천 차단됩니다.
평가 함수도 하나를 같이 씁니다. MAE(기본 오차), RMSE(큰 오차에 민감), RMSLE(지역 간 수요 규모 차이 보정)를 계산하고, RMSLE의 log가 음수에서 깨지지 않도록 예측값을 0에서 잘라줍니다.
LightGBM: early stopping으로 트리 개수는 데이터가 정한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
PARAMS = {
"objective": "regression",
"n_estimators": 2000, # 상한. early stopping이 실제 개수를 정한다
"learning_rate": 0.05,
"num_leaves": 63,
"random_state": 42,
}
model.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
eval_metric="mae",
callbacks=[lgb.early_stopping(stopping_rounds=100)],
)
두 가지 포인트만 짚습니다.
- 지역 ID는 범주로:
PULocationID를category타입으로 바꿔서 넣습니다. 그냥 두면 LightGBM이 “지역 161은 지역 80의 두 배”처럼 숫자 크기로 해석합니다. 범주로 지정하면 트리가 지역 집합을 자유롭게 분할합니다. - 트리 개수는 고정하지 않습니다: 상한 2000개를 주고, 검증 MAE가 100라운드 동안 개선되지 않으면 멈춥니다. “몇 개가 적당한가”를 사람이 튜닝하는 대신 검증 데이터가 정하게 합니다.
PyTorch MLP: 지역 임베딩 + 수치 피처
딥러닝 단계는 지역 임베딩을 쓰는 MLP로 구성했습니다.
1
2
3
4
5
6
7
8
class ZoneMLP(nn.Module):
def __init__(self, n_zones, embedding_dim, hidden):
super().__init__()
self.embedding = nn.Embedding(n_zones, embedding_dim) # 263개 지역 → 16차원
...
def forward(self, zones, feats):
return self.net(torch.cat([self.embedding(zones), feats], dim=1)).squeeze(1)
- 임베딩을 쓰는 이유: 지역 263개를 one-hot으로 넣으면 입력이 희소한 263차원이 됩니다. 임베딩은 각 지역을 16차원 벡터로 학습해서, “수요 패턴이 비슷한 지역”이 가까운 벡터를 갖게 됩니다.
- 표준화 통계는 학습 구간에서만 계산합니다. 검증과 테스트의 평균, 표준편차가 섞이면 그것도 누수입니다.
- LightGBM과 같은 조건으로 검증 MAE 기준 early stopping(patience 5)을 겁니다. 학습은 Mac의 MPS 백엔드로 돌렸습니다.
- 예측이 음수가 나올 수 있어 0에서 잘라줍니다. 승차 건수는 음수가 될 수 없기 때문입니다.
결과: 봉인해둔 테스트 구간 개봉
검증 구간에서 LightGBM이 가장 좋았고, PyTorch MLP는 검증 MAE 3.51로 Baseline은 이겼지만 LightGBM에는 못 미쳤습니다. 최종 후보를 LightGBM으로 정한 뒤, 테스트 구간(마지막 7일)을 열었습니다.
| 모델 | MAE | RMSE | RMSLE |
|---|---|---|---|
| lag_1h (직전 시간) | 4.35 | 14.81 | 0.487 |
| lag_24h (전날 같은 시간) | 5.07 | 20.56 | 0.515 |
| week_mean (7일 평균) | 4.73 | 17.85 | 0.437 |
| LightGBM | 2.89 | 9.07 | 0.428 |
읽어낼 것이 몇 가지 있습니다.
- LightGBM이 전 지표에서 최고 Baseline을 이겼습니다. MAE 기준 34% 개선(4.35 → 2.89)입니다. 이제야 ML을 쓸 이유가 생겼습니다.
- Baseline끼리도 순위가 지표마다 다릅니다. MAE는 lag_1h가 가장 좋은데(수요의 관성), RMSLE는 week_mean이 가장 좋습니다. 평균이라 변동이 작아서 수요가 적은 지역에서 상대 오차가 안정적이기 때문입니다. 지표를 하나만 봤다면 놓쳤을 사실입니다.
- 테이블 데이터에서 GBDT가 신경망보다 강하다는 흔한 결론이 여기서도 재현됐습니다. 피처가 이미 잘 정리된 테이블에서는 트리 계열이 적은 튜닝으로 강력하고, 신경망이 역전하려면 더 많은 데이터나 피처로 못 담는 구조(예: 시퀀스 전체)가 필요합니다. MLP를 만든 것이 낭비는 아니었습니다. 임베딩, early stopping, 표준화 누수 방지 같은 것들을 직접 짜봐야 아는 내용이고, “딥러닝이 항상 이기지는 않는다”를 결과로 확인한 것 자체가 이 프로젝트의 목적에 부합합니다.
실험 기록은 전부 MLflow에
두 학습 스크립트 모두 파라미터, Baseline 포함 비교 지표, 학습된 모델을 MLflow에 남깁니다. 어떤 실험을 언제 어떤 설정으로 돌렸는지 스크립트 출력물을 복사해두는 대신 서버에 쌓입니다. 이 MLflow를 포함한 저장소 인프라(PostgreSQL, MinIO)를 어떻게 붙였는지는 다음 글에서 정리합니다.