포스트

NYC 택시 수요 예측 파이프라인 만들기 (5) Baseline vs LightGBM vs PyTorch

학습이 필요 없는 규칙 3종부터 LightGBM, PyTorch MLP까지 동일한 분할과 지표로 비교한 결과를 정리했습니다.

NYC 택시 수요 예측 파이프라인 만들기 (5) Baseline vs LightGBM vs PyTorch

이번 글에서 할 일

4편에서 학습 테이블과 피처를 만들었습니다. 이번에는 1편에서 정한 3단계 비교를 실행합니다.

  1. Baseline: 학습 없는 예측 규칙 3종
  2. LightGBM: 테이블 데이터 주력 후보
  3. 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는 범주로: PULocationIDcategory 타입으로 바꿔서 넣습니다. 그냥 두면 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일)을 열었습니다.

모델MAERMSERMSLE
lag_1h (직전 시간)4.3514.810.487
lag_24h (전날 같은 시간)5.0720.560.515
week_mean (7일 평균)4.7317.850.437
LightGBM2.899.070.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)를 어떻게 붙였는지는 다음 글에서 정리합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.