NYC 택시 수요 예측 파이프라인 만들기 (4) 집계와 피처 생성
원본 운행 기록 296만 건을 지역과 시간별 수요 테이블로 집계하고, 데이터 누수 없는 피처를 만든 과정을 정리했습니다.
이번 글에서 할 일
3편에서 데이터를 확보하고 품질 이슈와 수요 패턴을 확인했습니다. 이번에는 운행 단위의 원본 기록을 모델이 학습할 수 있는 테이블로 바꿉니다.
- 정제와 집계: 운행 기록을
지역 × 1시간수요로 - 학습, 검증, 테스트 분할 규칙 정하기
- 피처 생성: 미래를 보지 않는 피처만
집계: 문제의 단위로 바꾸기
원본은 운행 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으로 채웠습니다. 이유는 두 가지입니다.
- “승차 없음”도 학습해야 하는 정보입니다. 새벽 3시 외곽 지역의 수요가 0이라는 사실을 모델이 배우려면 그 행이 존재해야 합니다.
- 이후의 모든 피처 계산이 이 구조를 전제합니다. 잠시 뒤에 나오는
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_weekend | t 자체에서 파생되어 미래를 보지 않아도 아는 값 |
| 이력 | 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를 동일한 분할과 지표로 비교합니다.