포스트

NYC 택시 수요 예측 파이프라인 만들기 (4) 집계와 피처 생성

원본 운행 기록 296만 건을 지역과 시간별 수요 테이블로 집계하고, 데이터 누수 없는 피처를 만든 과정을 정리했습니다.

NYC 택시 수요 예측 파이프라인 만들기 (4) 집계와 피처 생성

이번 글에서 할 일

3편에서 데이터를 확보하고 품질 이슈와 수요 패턴을 확인했습니다. 이번에는 운행 단위의 원본 기록을 모델이 학습할 수 있는 테이블로 바꿉니다.

  1. 정제와 집계: 운행 기록을 지역 × 1시간 수요로
  2. 학습, 검증, 테스트 분할 규칙 정하기
  3. 피처 생성: 미래를 보지 않는 피처만

집계: 문제의 단위로 바꾸기

원본은 운행 1건이 1행입니다(2024년 1월 약 296만 행). 우리가 풀 문제는 “이 지역에서 다음 1시간 동안 몇 건의 승차가 발생할까”이므로, 학습 테이블의 1행은 (pickup_hour, PULocationID)가 되어야 합니다.

집계 전에 EDA에서 확인한 문제 레코드부터 제거합니다. 1월 범위 밖 타임스탬프 18건, Unknown 지역(ID 264와 265 등) 약 1.2만 건입니다.

1
2
3
4
5
6
7
8
9
counts = df.groupby(
    [df["tpep_pickup_datetime"].dt.floor("h"), "PULocationID"]
).size()

hours = pd.date_range(month_start, month_end, freq="h", inclusive="left")
grid = pd.MultiIndex.from_product(
    [hours, sorted(valid_zone_ids)], names=["pickup_hour", "PULocationID"]
)
demand = counts.reindex(grid, fill_value=0).rename("pickup_count").reset_index()

핵심 결정은 마지막 reindex입니다. 단순히 groupby만 하면 승차가 한 건도 없던 (지역, 시간) 조합은 행 자체가 사라집니다. 대신 모든 조합을 포함하는 완전한 그리드를 만들고 빈칸을 0으로 채웠습니다. 이유는 두 가지입니다.

  1. “승차 없음”도 학습해야 하는 정보입니다. 새벽 3시 외곽 지역의 수요가 0이라는 사실을 모델이 배우려면 그 행이 존재해야 합니다.
  2. 이후의 모든 피처 계산이 이 구조를 전제합니다. 잠시 뒤에 나오는 shift 기반 피처는 “1행 전 = 1시간 전”이라는 등식이 성립해야만 올바르게 동작합니다.

결과는 744시간(1월) × 263개 지역 = 195,672행입니다.

함정 하나: pandas의 기본 NaN 변환

유효 지역 목록은 taxi_zone_lookup.csv에서 뽑는데, 여기서 한 번 데였습니다.

1
2
zones = pd.read_csv(lookup_path, keep_default_na=False)
is_real_nyc = ~zones["Borough"].isin(["Unknown", "N/A"])

keep_default_na=False가 없으면 Borough 컬럼의 문자열 "N/A"를 pandas가 NaN으로 바꿔버리고, NaN은 isin(["N/A"])에 걸리지 않아 제외해야 할 지역이 필터를 통과합니다. 파일을 읽는 첫 줄에서 생기는 버그라 모르면 한참 뒤에 발견됩니다.

분할: 날짜가 아니라 규칙으로

시계열이므로 무작위 분할 대신 시간 순서로 자릅니다. 이때 경계 날짜를 하드코딩하지 않고 규칙으로 정의했습니다.

1
2
3
4
5
TEST_DAYS = 7  # 완전한 주간 주기: 모든 요일이 정확히 한 번씩 평가된다
VAL_DAYS = 5

test_start = end - pd.Timedelta(days=TEST_DAYS)
val_start = test_start - pd.Timedelta(days=VAL_DAYS)
  • 테스트가 7일인 이유: 수요는 요일 주기가 강하므로, 모든 요일이 정확히 한 번씩 들어가는 구간이어야 특정 요일에 유리한 평가가 되지 않습니다.
  • 규칙으로 정의한 이유: 데이터가 여러 달로 늘어나도 “마지막 7일 테스트, 그 앞 5일 검증”이 그대로 적용됩니다. 코드 수정 없이 확장됩니다.

1월 데이터 기준으로 학습 1~19일, 검증 20~24일, 테스트 25~31일이 됩니다. 테스트 구간은 최종 비교 전까지 어떤 결정에도 사용하지 않습니다.

피처: 예측 시점 이전의 정보만

피처는 두 부류입니다. 모두 예측 시점 t에 이미 알 수 있는 값입니다.

부류피처의미
달력hour_of_day, day_of_week, is_weekendt 자체에서 파생되어 미래를 보지 않아도 아는 값
이력lag_1h, lag_24h, lag_168h, week_mean과거 수요의 요약

이력 피처는 지역별로 정렬한 뒤 shift로 만듭니다. 완전 그리드 덕분에 “n행 전 = n시간 전”이 보장됩니다.

1
2
3
4
by_zone = demand.groupby("PULocationID")["pickup_count"]
demand["lag_1h"] = by_zone.shift(1)      # 직전 시간
demand["lag_24h"] = by_zone.shift(24)    # 전날 같은 시간
demand["lag_168h"] = by_zone.shift(168)  # 지난주 같은 요일 같은 시간

week_mean(최근 7일 같은 시각대 평균)은 조금 재미있는 트릭을 씁니다.

1
2
3
4
by_zone_hour = demand.groupby(["PULocationID", "hour_of_day"])["pickup_count"]
demand["week_mean"] = by_zone_hour.transform(
    lambda s: s.shift(1).rolling(7, min_periods=7).mean()
)

(지역, 시각대)로 묶으면 그룹 안에서 연속된 행이 곧 연속된 날짜가 됩니다. 그래서 shift(1) + rolling(7)이 정확히 “어제까지 최근 7일, 같은 시각대의 평균”이 됩니다. min_periods=7이라 이력이 7일 미만이면 NaN으로 남고, 그 행은 학습에서 제외됩니다. 어설프게 0이나 부분 평균으로 채우는 것보다, 계산할 수 없는 값은 없다고 정직하게 표시하는 쪽을 택했습니다. 이 때문에 학습 구간 19일 중 앞 7일이 빠져 실제 학습에는 12일이 쓰입니다.

전제는 assert로 못 박기

shift 기반 피처는 그리드가 완전하지 않으면 소리 없이 틀린 값을 만듭니다. 시간에 구멍이 하나 있으면 lag_24h가 사실은 23시간 전 값이 되는 식입니다. 그래서 피처 함수 입구에서 전제를 검증합니다.

1
2
3
assert not demand.duplicated(["PULocationID", "pickup_hour"]).any()
hours_per_zone = demand.groupby("PULocationID")["pickup_hour"].nunique()
assert (hours_per_zone == demand["pickup_hour"].nunique()).all()

집계 코드와 피처 코드는 별개 모듈이라, 나중에 누가 집계를 고쳐도 이 계약이 깨지면 즉시 터집니다. 조용히 틀리는 것보다 시끄럽게 죽는 게 낫습니다.

다음 글

학습 테이블이 준비됐으니, 다음 글에서는 Baseline 3종과 LightGBM, PyTorch MLP를 동일한 분할과 지표로 비교합니다.

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