포스트

머신러닝 기초 (13) - 데이터 누수와 파이프라인

검증 점수를 조용히 부풀리는 데이터 누수의 세 가지 유형과 올바른 fit/transform 순서에서 출발해, Pipeline과 ColumnTransformer로 전처리와 모델을 하나로 묶어 누수를 원천 차단하고 교차검증까지 안전하게 만드는 법을 정리한 시리즈 마지막 편입니다.

머신러닝 기초 (13) - 데이터 누수와 파이프라인

머신러닝 기초 시리즈의 13편입니다. 12편의 “전처리: 스케일링, 인코딩, 결측치”에 이어집니다.

좋은 점수가 거짓일 때

12편까지 오면서 모델을 고르고, 튜닝하고, 전처리하는 도구를 모두 갖췄습니다. 이제 교차검증 점수가 0.95라면 그 모델을 믿고 배포할 수 있을까요. 대개는 그렇지만, 그 점수가 처음부터 잘못 계산된 것이라면 이야기가 다릅니다. 배포하고 나서야 실제 성능이 0.80밖에 안 된다는 걸 알게 됩니다.

이런 일이 벌어지는 가장 흔한 원인이 데이터 누수입니다. 모델의 문제도, 데이터의 문제도 아니고, 평가 절차의 문제입니다. 이 마지막 편은 누수가 무엇이고 어떻게 점수를 부풀리는지, 그리고 scikit-learn의 파이프라인으로 그것을 어떻게 구조적으로 막는지를 봅니다.

데이터 누수란 무엇인가

데이터 누수(data leakage)는 예측 시점에는 알 수 없는 정보가 학습과 모델선택 과정에 새어 들어가는 것입니다. 그 정보 덕분에 검증과 테스트 점수가 실제 일반화 성능보다 높게 나오고, 정작 배포하면 그 점수가 재현되지 않습니다.

누수의 본질은 4편과 5편에서 세운 원칙 하나를 어기는 데 있습니다. 평가에 쓰기로 한 데이터의 어떤 정보도 학습에 미리 반영되어서는 안 된다. test는 “본 적 없는 데이터”여야 하고, 그래야 test 점수가 “본 적 없는 데이터에서의 성능”을 정직하게 추정합니다. 누수는 이 “본 적 없음”을 조용히 깨뜨립니다. 5편에서 다룬 “test로 튜닝하면 누수”도 이 원칙의 한 사례였습니다.

문제는 누수가 에러를 내지 않는다는 점입니다. 코드는 잘 돌고 점수는 오히려 좋아집니다. 그래서 누수는 눈에 띄지 않고, 실전에 나가서야 드러납니다. 유형을 알아두는 것이 유일한 방어입니다.

누수의 세 가지 유형

유형 1: 전처리를 전체 데이터로 fit

가장 흔하고, 가장 놓치기 쉬운 유형입니다. 12편에서 스케일러, 인코더, 결측치 대치기는 모두 데이터에서 무언가를 배운다(fit)고 했습니다. StandardScaler는 평균과 표준편차를, SimpleImputer는 대치값(중앙값 등)을, OneHotEncoder는 카테고리 목록을 배웁니다. 이 “배우기”를 train과 test를 합친 전체 데이터에 하면, test의 통계가 train 데이터의 변환에 스며듭니다.

1
2
3
4
5
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler().fit(X)   # test까지 포함한 전체로 평균과 표준편차를 계산. 누수
X_scaled = scaler.transform(X)
# 이 뒤에 train_test_split을 해도 이미 늦었다: test의 분포가 스케일에 반영됐다

test의 평균과 분산을 미리 엿본 스케일은, 배포 환경에서는 결코 만들 수 없는 스케일입니다. 실제로는 test(미래 데이터)를 보기 전에 스케일을 확정해야 하기 때문입니다.

유형 2: 타깃에서 파생된 특성

특성 중에 타깃 정보가 들어 있는 경우입니다. 예측 시점에는 존재할 수 없는 값이 특성으로 들어가면, 모델은 그걸 지름길로 삼아 사실상 정답을 베낍니다.

전형적인 예가 있습니다. “고객이 보험금을 청구할지”를 예측하는데 특성에 “청구 금액”이 들어 있습니다. 청구 금액은 청구가 일어난 뒤에야 정해지는 값입니다. 학습 데이터에서는 완벽한 예측력을 보이지만, 실제로 예측해야 하는 순간에는 그 값을 알 수 없습니다. 타깃을 다른 이름으로 바꿔 특성에 넣은 것과 같아서, 검증 점수는 거의 만점이 나오고 배포하면 무너집니다.

검증 점수가 비현실적으로 높으면(예: 정확도 0.99, R² 0.999) 모델을 칭찬하기 전에 타깃 누수부터 의심한다. “이 특성의 값을 예측 시점에 실제로 알 수 있는가”를 특성마다 물어보는 것이 점검법이다.

유형 3: 시간 누수

시간이 흐르는 데이터에서 미래 정보로 과거를 예측하게 되는 경우입니다. 시계열을 무작위로 train/test 분할하면, 미래의 행이 train에 들어가고 과거의 행이 test에 들어가는 일이 생깁니다. 모델은 미래를 본 상태로 과거를 맞히므로 점수가 좋게 나오지만, 실제 예측은 언제나 과거로 미래를 맞히는 방향입니다.

이동평균 같은 파생 특성에서도 같은 문제가 생깁니다. 어떤 시점의 특성을 계산할 때 그 시점 이후의 값을 포함하면 미래가 새어 들어갑니다. 시계열에서는 무작위 분할 대신 시간 순서로 자르고(과거로 학습, 미래로 검증), 파생 특성도 과거 값만으로 계산해야 합니다. 이 시리즈의 예제는 시간 구조가 없는 데이터라 유형 1에 집중하지만, 시간 데이터를 다룰 땐 이 유형이 가장 위험합니다.

올바른 순서: fit은 train에만

세 유형 중 유형 1은 순서를 지키기만 하면 완전히 사라집니다. 규칙은 한 문장입니다. 전처리기의 통계는 train에서만 배우고(fit), 그 통계로 train과 test를 각각 변환한다(transform).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

X, y = load_breast_cancer(return_X_y=True)   # 유방암 진단 이진분류, 569행 30열
X_tr, X_test, y_tr, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

scaler = StandardScaler().fit(X_tr)   # 평균과 표준편차는 train에서만 배운다
X_tr_s = scaler.transform(X_tr)       # train을 그 통계로 변환
X_test_s = scaler.transform(X_test)   # test도 train의 통계로 변환 (fit 아님)

model = LogisticRegression(max_iter=5000, random_state=42).fit(X_tr_s, y_tr)
model.score(X_test_s, y_test)         # 0.982

핵심은 X_testfit이 한 번도 닿지 않는다는 점입니다. test는 train에서 만든 스케일로 변환만 될 뿐이고, 이것이 배포 상황을 정확히 흉내 냅니다. 미래 데이터도 과거에 확정한 스케일로 변환될 테니까요.

이 데이터에서는 전체로 fit하든 train으로만 fit하든 test 점수가 똑같이 0.982로 나옵니다. StandardScaler처럼 완만한 변환에서는 누수의 영향이 작을 수 있기 때문입니다. 그렇다고 순서를 어겨도 된다는 뜻은 아닙니다. 결측치 대치, 특성 선택처럼 데이터를 크게 건드리는 전처리로 갈수록 누수의 영향은 커지고, 뒤의 교차검증 절에서 그 차이가 얼마나 극적일 수 있는지 숫자로 보게 됩니다.

파이프라인: 전처리와 모델을 하나로

올바른 순서를 손으로 지키는 건 가능하지만 불안합니다. 전처리 단계가 서너 개로 늘면 어느 하나에서 실수로 fit_transform을 test에 부르기 쉽고, 새 데이터가 들어올 때 똑같은 변환을 똑같은 순서로 다시 적용하는 것도 번거롭습니다. scikit-learnPipeline은 전처리 단계들과 마지막 모델을 하나의 객체로 묶어 이 문제를 구조적으로 없앱니다.

1
2
3
4
5
6
7
8
from sklearn.pipeline import make_pipeline

pipe = make_pipeline(
    StandardScaler(),
    LogisticRegression(max_iter=5000, random_state=42))

pipe.fit(X_tr, y_tr)          # 스케일러는 X_tr로 fit, 그 결과로 모델까지 학습
pipe.score(X_test, y_test)    # 0.982, 손으로 짠 것과 동일

pipe.fit(X_tr, y_tr) 한 줄이 하는 일을 풀어보면 이렇습니다. 스케일러를 X_tr로 fit하고, 변환한 X_tr로 모델을 fit합니다. pipe.score(X_test, ...)는 test를 train에서 배운 스케일로 transform만 한 뒤 모델에 넣습니다. 순서와 fit/transform 구분을 파이프라인이 대신 지켜주므로, test에 fit이 닿는 실수가 원천적으로 불가능해집니다.

파이프라인의 이득은 누수 방지만이 아닙니다.

  • 재현성: 전처리부터 예측까지가 하나의 객체라, pipe.predict(new_data)만 부르면 학습 때와 완전히 똑같은 변환이 자동으로 적용됩니다. 전처리 코드를 배포 환경에 따로 옮겨 적을 필요가 없습니다.
  • 튜닝과의 결합: 5편의 GridSearchCV에 파이프라인을 통째로 넘기면 전처리 하이퍼파라미터(예: 대치 전략)와 모델 하이퍼파라미터를 함께 탐색할 수 있습니다. 이름은 스텝이름__파라미터(밑줄 두 개) 형식으로 지정합니다.

열마다 다른 전처리: ColumnTransformer

실제 데이터는 수치 열과 범주 열이 섞여 있습니다. 수치 열에는 스케일링을, 범주 열에는 원-핫 인코딩을(12편) 해야 하는데, 하나의 스케일러를 전체에 적용할 수는 없습니다. 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.linear_model import LogisticRegression

df = pd.DataFrame({
    "age":  [25, 42, 51, 33, np.nan, 29],   # 수치, 결측 하나
    "city": ["Seoul", "Busan", "Seoul", "Daegu", "Busan", "Seoul"],  # 범주
})
y = np.array([0, 1, 1, 0, 1, 0])

# 수치 열: 중앙값 대치 → 표준화 / 범주 열: 원-핫 인코딩
pre = ColumnTransformer([
    ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                      ("scale",  StandardScaler())]), ["age"]),
    ("cat", OneHotEncoder(handle_unknown="ignore"), ["city"]),
])

clf = Pipeline([("pre", pre),
                ("model", LogisticRegression(max_iter=1000, random_state=42))])
clf.fit(df, y)
clf.named_steps["pre"].transform(df).shape   # (6, 4), age 1열 + city 원-핫 3열

파이프라인 안에 파이프라인이 들어가는 구조입니다. ColumnTransformerage에는 대치와 표준화 파이프라인을, city에는 인코더를 각각 fit과 transform하고 결과를 옆으로 붙여 4개 열을 만듭니다. handle_unknown="ignore"는 배포 때 학습에 없던 도시가 들어와도 에러 대신 전부 0인 벡터로 처리해, 예측이 멈추지 않게 합니다. 이 ColumnTransformer 역시 전체가 fit에서 train으로만 학습되므로, 대치값과 카테고리 목록에 test가 새어 들지 않습니다.

교차검증 안에 파이프라인을 통째로

파이프라인의 진짜 값어치는 교차검증(5편)과 만날 때 드러납니다. 교차검증은 데이터를 여러 폴드로 나눠 매 라운드마다 학습과 검증을 반복합니다. 이때 전처리 fit도 폴드마다 새로 해야 각 라운드의 검증 폴드가 그 라운드의 “본 적 없는 데이터”로 남습니다. 전처리를 교차검증 밖에서 전체 데이터에 미리 해두면, 모든 폴드가 서로의 통계를 엿본 상태가 되어 누수가 발생합니다.

cross_val_score에 파이프라인을 넘기면 이게 자동으로 지켜집니다. 매 폴드에서 파이프라인 전체를 그 폴드의 train으로만 fit하기 때문입니다. 누수가 점수를 얼마나 부풀리는지, 극단적인 예로 확인해 봅니다. 타깃과 아무 관계없는 순수 잡음 5000개 열을 만들고, 그중 타깃과 상관이 높은 20개를 특성 선택으로 고릅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from sklearn.model_selection import cross_val_score
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.pipeline import make_pipeline

rng = np.random.RandomState(42)
noise = rng.randn(569, 5000)   # 타깃과 무관한 순수 잡음. 진짜 신호는 0

# 누수: 전체 데이터(타깃 포함)로 20개 열을 먼저 고른 뒤 교차검증
sel = SelectKBest(f_classif, k=20).fit(noise, y)
noise_sel = sel.transform(noise)
cross_val_score(LogisticRegression(max_iter=5000, random_state=42),
                noise_sel, y, cv=5).mean()        # 0.699

# 올바름: 특성 선택을 파이프라인에 넣어 폴드마다 train으로만 고름
pipe = make_pipeline(SelectKBest(f_classif, k=20),
                     LogisticRegression(max_iter=5000, random_state=42))
cross_val_score(pipe, noise, y, cv=5).mean()      # 0.55

특성에는 진짜 신호가 하나도 없습니다. 그런데 누수된 평가는 정확도 0.699를 보고합니다. 마치 이 잡음 열들이 진단을 예측하는 것처럼요. 이유는 특성 선택을 전체 데이터로 했기 때문입니다. 5000개 잡음 중 우연히 타깃과 상관이 높아 보이는 20개를 검증 폴드의 타깃까지 본 채로 골랐고, 그래서 어떤 폴드로 검증하든 그 폴드에 이미 맞춰진 특성이 쓰입니다. 파이프라인 안에서 폴드마다 다시 고르면 그 상관이 우연이었음이 드러나 점수는 0.55, 사실상 찍기 수준으로 내려앉습니다(이 데이터의 다수 클래스 비율은 약 0.63). 0.699는 순전히 누수가 만든 허깨비입니다.

전처리(스케일링, 대치, 인코딩, 특성 선택)는 교차검증 안에서 폴드마다 fit되어야 한다. 전체 데이터에 전처리를 미리 한 번 해두고 교차검증을 돌리면 모든 폴드가 오염된다. 파이프라인을 만들어 cross_val_scoreGridSearchCV에 통째로 넘기면 이 규칙이 저절로 지켜진다.

시리즈를 마치며

여기까지가 머신러닝 기초의 지도학습 파트입니다. 지도학습의 뼈대(1~3편), 잘 배우게 만들기(4~6편), 평가(7~8편), 대표 모델(9~11편), 전처리와 누수 방지(12~13편)를 개념부터 정리했습니다.

지도학습은 정답이 붙은 데이터를 전제로 합니다. 다음 세 편은 정답 없이 데이터의 구조를 찾는 비지도학습입니다. 군집화(14편), 차원축소(15편), 이상치 탐지(16편)를 차례로 다룹니다.

정리

개념한 줄 요약
데이터 누수예측 시점에 알 수 없는 정보가 학습에 새어 들어 검증 점수가 부풀려지는 것
전처리 누수스케일러, 대치기, 인코더를 전체 데이터로 fit. test 통계가 변환에 스며든다
타깃 누수타깃에서 파생돼 예측 시점엔 알 수 없는 특성. 검증 점수가 비현실적으로 높다
시간 누수미래 정보로 과거를 예측. 시계열은 무작위 분할과 미래 포함 파생을 피한다
올바른 순서fit은 train에만, transform은 train과 test 각각. test에 fit이 닿으면 안 된다
Pipeline전처리와 모델을 한 객체로. 누수를 구조적으로 막고 재현성을 준다
ColumnTransformer열 그룹마다 다른 전처리를 라우팅해 합친다. 수치와 범주 혼합 데이터에
CV 안의 파이프라인전처리를 폴드마다 fit해야 누수가 없다. 파이프라인을 통째로 CV에 넘긴다

지도학습 파트는 여기서 마칩니다. 다음 편부터는 레이블 없는 데이터를 다루는 비지도학습입니다.

다음 글: 머신러닝 기초 (14) - 군집화: K-means와 계층군집

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