머신러닝 실전 워크플로 (5) - 피처 파이프라인
전처리를 노트북 셀에 흩어두지 않고 sklearn Pipeline과 ColumnTransformer로 하나의 객체에 묶어 재현 가능하게 만들고, 학습에 쓴 변환을 서빙에서 그대로 다시 쓰는 학습-서빙 일관성을 정리한 5편입니다.
머신러닝 실전 워크플로 시리즈의 5편입니다. 4편의 “EDA와 데이터 품질 자동 점검”에 이어집니다.
4편에서 데이터가 믿을 만한지 EDA와 자동 점검으로 확인했습니다. 이제 그 데이터를 모델이 먹을 수 있는 피처로 바꿉니다. 스케일링, 인코딩, 결측치 대치, 파생 피처 생성이 모두 여기에 들어갑니다. 개별 변환이 무엇을 하는지는 ml-basics 12편에서 다뤘습니다. 이 편의 질문은 다릅니다. 그 변환들을 어떻게 재현 가능하게 묶고, 학습 때 쓴 변환을 서빙에서 그대로 다시 쓰게 만드느냐입니다.
전처리가 흩어지면 생기는 일
노트북에서 전처리는 셀마다 흩어집니다. 한 셀에서 스케일링을 하고, 다른 셀에서 인코딩을 하고, 또 어딘가에서 결측치를 채웁니다. 순서가 있고, 스케일러의 평균이나 인코더의 카테고리 목록처럼 데이터에서 배운 값이 각 단계에 들어 있습니다. 이 상태에서 새 데이터가 들어오면 그 셀들을 같은 순서로, 같은 학습값으로 다시 실행해야 모델이 학습 때와 같은 입력을 받습니다.
여기서 두 가지가 어긋납니다.
- 재현이 안 된다. 셀 순서를 바꾸거나 한 단계를 빠뜨리면 결과가 달라집니다. 몇 주 뒤 같은 결과를 다시 만들려 해도 어느 셀을 어떤 순서로 돌렸는지 기억에 의존하게 됩니다.
- 학습과 서빙이 벌어진다. 학습은 노트북에서 하고 서빙은 API 서버에서 하는데, 서빙 쪽에서 전처리를 손으로 다시 구현하면 두 코드가 조금씩 달라집니다. 모델은 학습 때와 미묘하게 다른 입력을 받고, 에러 없이 성능만 조용히 떨어집니다.
해결의 방향은 하나입니다. 전처리를 하나의 정의로 만들고, 그 정의를 학습과 서빙이 함께 씁니다. 이걸 두 도구가 받쳐줍니다. 데이터에서 무언가를 배우는 변환은 scikit-learn의 Pipeline과 ColumnTransformer로 묶고, 학습이 끝난 그 객체를 통째로 파일 하나에 저장해 서빙에서 다시 불러 씁니다.
전처리와 모델을 한 객체로: Pipeline
ml-basics 13편에서 Pipeline과 ColumnTransformer를 데이터 누수 방지의 도구로 봤습니다. 전처리기의 통계는 train에서만 fit되고, 그 통계로 train과 test를 각각 transform한다는 규칙을 파이프라인이 대신 지켜줬습니다. 워크플로 관점에서 같은 도구를 보는 각도는 조금 다릅니다. 전처리와 모델이 하나의 객체이므로, 그 객체 하나를 저장하면 전처리 정의가 모델과 함께 따라간다는 점입니다.
수치 열과 범주 열이 섞인 데이터를 하나의 파이프라인으로 묶습니다. ColumnTransformer가 열 그룹마다 다른 전처리를 라우팅하고, 그 뒤에 모델을 붙입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import numpy as np, pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import HistGradientBoostingRegressor
num_cols = ["hour_sin", "hour_cos", "lag_1h", "temp"] # 수치 피처
cat_cols = ["zone", "weekday"] # 범주 피처
num = Pipeline([
("impute", SimpleImputer(strategy="median")), # 대치값은 train에서 배운다
("scale", StandardScaler()), # 평균과 표준편차도 train에서
])
pre = ColumnTransformer([
("num", num, num_cols),
("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols),
])
model = Pipeline([
("pre", pre),
("reg", HistGradientBoostingRegressor(random_state=42)),
])
model.fit(X_train, y_train) # 전처리 fit과 모델 학습이 한 번에, train에만
model.fit(X_train, y_train) 한 줄 안에서 대치값, 스케일 통계, 카테고리 목록이 모두 train으로만 학습되고 이어서 모델이 학습됩니다. 예측할 때는 model.predict(new_data)만 부르면 학습 때 확정한 그 통계로 변환이 자동 적용됩니다. 전처리 코드를 예측 쪽에 따로 옮겨 적을 일이 없습니다. handle_unknown="ignore"는 서빙 때 학습에 없던 지역이 들어와도 에러 대신 전부 0인 벡터로 처리해, 예측이 멈추지 않게 합니다.
학습이 필요한 변환과 그렇지 않은 변환
모든 피처 작업을 Pipeline에 넣을 수 있는 건 아닙니다. 피처 작업은 두 종류로 갈립니다.
- 데이터에서 통계를 배우는 변환. 스케일링의 평균과 표준편차, 대치의 중앙값, 인코딩의 카테고리 목록이 여기 속합니다. 이들은 반드시 파이프라인 안에 넣습니다. 이유는 두 가지입니다. 하나는 fit이 train에만 닿아야 누수가 없기 때문이고(13편), 다른 하나는 학습된 통계가 모델과 함께 저장되어야 서빙에서 재현되기 때문입니다.
- 학습이 필요 없는 결정적 변환. 타임스탬프에서 요일을 뽑거나, 시각을 사인과 코사인으로 바꾸는 것처럼 입력만 있으면 값이 정해지는 변환입니다. 여기에는 배울 통계가 없습니다. 파이프라인에 넣어도 되고, 그 앞에 순수 함수로 두어도 됩니다.
결정적 변환을 순수 함수로 둘 때 중요한 규칙이 있습니다. 그 함수를 학습과 서빙이 같은 것을 import 해야 합니다. 학습 스크립트에서 한 번 짜고 서빙 API에서 또 짜면, 두 구현이 벌어지는 순간 학습-서빙 편차가 생깁니다. 피처 정의는 한 곳에만 둡니다.
1
2
3
4
5
6
7
8
9
10
# features.py: 학습 스크립트와 서빙 API가 함께 import 하는 단 하나의 정의
import numpy as np, pandas as pd
def build_features(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
hour = out["pickup_hour"].dt.hour
out["hour_sin"] = np.sin(2 * np.pi * hour / 24) # 시각의 주기성을 사인으로
out["hour_cos"] = np.cos(2 * np.pi * hour / 24) # 코사인으로
out["weekday"] = out["pickup_hour"].dt.weekday
return out
학습도 서빙도 이 함수를 거쳐 파이프라인에 넣습니다. 결정적 파생은 build_features가, 학습이 필요한 변환은 Pipeline이 맡아 역할이 나뉩니다.
1
2
3
4
5
6
7
# 학습 쪽
X_train = build_features(train_df)
model.fit(X_train[feature_cols], y_train)
# 서빙 쪽 (같은 함수를 import)
X = build_features(request_df)
model.predict(X[feature_cols])
학습과 서빙에서 같은 변환을 쓴다
학습이 끝난 파이프라인 객체에는 전처리 통계와 모델 가중치가 함께 들어 있습니다. 이 객체를 파일 하나로 저장하고 서빙에서 그대로 불러오면, 서빙은 전처리를 다시 구현할 필요가 없습니다.
1
2
3
4
5
6
7
import joblib
joblib.dump(model, "artifacts/model.joblib") # 전처리 통계 + 모델을 한 파일로
# 서빙 프로세스에서
model = joblib.load("artifacts/model.joblib")
preds = model.predict(build_features(request_df)[feature_cols])
서빙이 하는 일은 build_features로 결정적 피처를 만들고, 저장된 파이프라인에 넣는 것뿐입니다. 스케일이나 인코딩을 손으로 다시 계산하지 않습니다. 학습 때 fit한 그 통계가 파일 안에 그대로 있기 때문입니다. 이것이 학습-서빙 일관성의 핵심입니다. 같은 변환 코드를 두 번 짜지 않고, 학습이 만든 객체를 서빙이 재사용합니다.
학습-서빙 편차의 가장 흔한 원인은 서빙 코드가 전처리를 손으로 다시 구현하는 것이다. 학습은
StandardScaler로 표준화했는데 서빙은 하드코딩한 평균과 분산으로 나눈다면, 둘은 코드가 바뀔 때마다 어긋난다. 학습 때 fit한 파이프라인 객체를 그대로 저장해 서빙에서 불러 쓰면 이 편차가 원천적으로 생기지 않는다. 결정적 피처 함수도 학습과 서빙이 같은 모듈을 import 하게 한다.
저장한 파이프라인은 그 자체로 하나의 아티팩트입니다. 이걸 실험마다 어떻게 기록하고 버전을 붙이는지는 6편의 실험 추적과 8편의 모델 레지스트리에서 이어서 다룹니다.
피처 스토어: 같은 원칙의 확장
피처 스토어(feature store)는 “정의는 하나, 소비자는 둘”이라는 이 원칙을 조직 규모로 키운 것입니다. 여러 모델과 팀이 같은 피처를 공유하고, 같은 값을 오프라인(학습)과 온라인(서빙)에서 일관되게 내놓아야 할 때 씁니다. 학습 시점 기준으로 과거 값만 조회해 시간 누수를 막는 point-in-time 조회와, 온라인 서빙을 위한 저지연 조회가 여기에 포함됩니다. Feast 같은 도구가 대표적입니다. 다만 프로젝트 하나 규모에서는 앞의 공유 함수와 저장된 파이프라인으로 충분합니다. 피처 스토어는 필요해졌을 때 도입하는 확장이지, 시작부터 갖춰야 하는 것은 아닙니다.
NYC 택시에서는
NYC 택시 파이프라인에서는 집계와 피처 생성 단계가 여기에 대응합니다. 운행 296만 건을 지역과 시간별 수요로 집계하고, 직전 시간대 수요(lag)나 이동평균 같은 파생 피처를 만들 때 그 시점 이후의 값을 절대 참조하지 않게 했습니다. 시간이 흐르는 데이터라 무작위 분할 대신 시간 순서로 잘랐고, 피처도 과거 값만으로 계산했습니다. 이 부분의 원리가 ml-basics 13편의 시간 누수입니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 흩어진 전처리의 문제 | 셀마다 나뉜 전처리는 재현이 어렵고 서빙에서 다시 구현하며 벌어진다 |
| Pipeline | 전처리와 모델을 한 객체로 묶어 저장 하나로 전처리 정의가 모델과 함께 따라간다 |
| ColumnTransformer | 수치 열과 범주 열에 다른 변환을 라우팅해 하나로 합친다 |
| 변환의 두 종류 | 통계를 배우는 변환은 파이프라인 안에, 결정적 변환은 순수 함수로 |
| 피처 정의는 한 곳 | 결정적 피처 함수는 학습과 서빙이 같은 모듈을 import 한다 |
| 학습-서빙 일관성 | 학습이 fit한 파이프라인을 저장해 서빙이 재사용, 전처리를 두 번 짜지 않는다 |
| 피처 스토어 | 이 원칙을 여러 모델과 팀 규모로 키운 확장, 필요할 때 도입한다 |
전처리를 재현 가능한 하나의 정의로 묶고 그것을 학습과 서빙이 함께 쓰면, 피처 단계는 실험을 반복해도 흔들리지 않는 토대가 됩니다. 다음은 그 실험들을 무엇을, 왜 기록하는지입니다.