검증 점수는 완벽한데 실제 운영에서 모델이 처참하게 실패한다. 범인은 거의 항상 하나 — 데이터 누수다. 누수는 모델이 학습 시점에 실제로는 가질 수 없었던 정보에 접근해 평가 점수가 인위적으로 부풀려지는 현상이다.
MLS-C01은 누수에 집착한다. "검증은 99%인데 프로덕션은 60%?" 같은 시나리오의 답은 거의 항상 누수다.
유형
정의
결과
타깃 누수(target leakage)
타깃 정보가 직간접적으로 피처에 포함됨
학습·검증이 둘 다 비현실적으로 높다
train-test 오염(contamination)
전처리·분할을 통해 테스트 정보가 학습에 유입됨
검증은 높은데 프로덕션이 낮다
핵심 질문은 언제나 같다. "예측을 내리는 그 시점에, 우리가 이 값을 정말 알 수 있었는가?" 아니라면 누수다.
누수는 본질적으로 시험 부정행위와 같다 — 모델이 이미 답안지를 본 것이다. 통계적으로는 검증셋의 독립성 가정을 위반한다. 검증 점수가 미지의 데이터에 대한 일반화 성능을 추정하려면, 검증셋이 학습 과정과 완전히 독립이어야 한다. 누수가 있으면 검증 점수는 일반화가 아니라 "이미 본 정보를 재현하는 능력"을 재게 되고, 그 위에서 내린 모델 선택·하이퍼파라미터 튜닝 결정이 전부 오염된다.
타깃 누수는 예측하려는 타깃에 관한 정보가 피처에 포함될 때 일어난다. 가장 은밀하고 흔하다.
시나리오
누수 피처
왜 누수인가
질병 진단 예측
투약_여부
진단이 내려진 후에야 투약한다
이탈 예측
해지_사유_코드
이탈이 확정된 뒤 생성된다
대출 부도 예측
연체_횟수(미래 기간 포함)
부도 발생 이후에 누적된다
매출 예측
당월_최종_매출
타깃 그 자체
이탈 예측
고객센터_해지문의_수
해지 의사 표명 이후 발생한 이벤트
타깃 누수의 신호:
단일 피처의 상관·중요도가 비정상적으로 높다(한 변수가 정확도의 95%를 설명).
피처가 타깃과 동시에 또는 그 이후에 만들어진다.
"예측 시점에 이 값을 정말 알 수 있는가?"의 답이 "아니오"다.
# 타깃 누수 의심 점검: 단일 피처가 타깃을 지나치게 설명하는지 확인import pandas as pdcorr_with_target = ( df.corr(numeric_only=True)["churned"] .abs() .sort_values(ascending=False))print(corr_with_target.head(10))# 어떤 피처의 상관이 0.95 이상이면 "예측 시점에 알 수 있는 값인가?"를 먼저 의심한다
⚠️ 함정: 타깃 누수는 train/validation/test를 완벽하게 나눠도 잡히지 않는다. 누수가 피처 자체에 박혀 있기 때문이다. 모든 분할이 똑같이 오염된다. 그래서 분할만으로는 부족하고, 각 피처가 언제 만들어지는지(타임스탬프)와 예측 시점과의 관계를 도메인 지식으로 따져야 한다. "검증도 높고 테스트도 높은데 운영만 낮다?" → 타깃 누수를 1순위로 의심한다.
무작위 분할: 시계열을 섞으면 미래 데이터가 학습에, 과거가 테스트에 들어간다 → 미래로 과거를 예측
미래 포함 윈도 집계: "최근 30일 평균"을 예측 시점 이후 데이터까지 포함해 계산
전역 통계 사용: 전체 평균으로 정규화 → 미래 정보가 과거 행에 스며든다
lag/rolling 경계 오류: shift를 잘못 걸어 t+1 값이 t 시점에 들어간다
[잘못된 무작위 분할] [올바른 시간 순 분할]
시간축 ──────────────────────▶ 시간축 ──────────────────────▶
T T V T V T T V T V T T V T T T T T T T T T | V V V | E E
└─ 학습(T)과 검증(V)이 뒤섞임 학습 검증 평가
→ 모델이 t+1을 보고 t를 "예측" └── 항상 과거 → 미래 방향 ──┘
→ 시간 역행 학습 = 누수 (forward-chaining, 배포 상황과 동일)
import pandas as pddf = df.sort_values("date")# 올바른 방법: 과거만 보는 rolling (shift로 현재 행을 제외)df["sales_ma7"] = df["sales"].shift(1).rolling(7).mean() # t-1 ~ t-7# 시계열은 시간 순 분할 (무작위 분할 금지)cutoff = "2025-01-01"train = df[df["date"] < cutoff]test = df[df["date"] >= cutoff]
시계열에서 무작위 분할이 치명적인 이유는 시간적 의존성 때문이다. 일반 데이터는 행이 서로 독립이라고 가정하지만, 시계열은 인접 시점 간 상관이 강하다. 무작위로 섞으면 모델이 t+1의 패턴을 보고 t를 "예측"하게 되어, 현실에서 불가능한 시간 역행을 학습한다. 그래서 시계열 검증은 항상 "과거로 학습하고 미래로 평가(forward-chaining)" 원칙을 따라야 하며, 이것이 Day 4의 시계열 분할로 이어진다.
[누수가 나는 순서] [Pipeline이 막는 방식]
전체 데이터 전체 데이터
│ │
▼ fit(scaler) ← 테스트 통계 포함 ▼ split
전체 스케일링 학습 fold │ 검증 fold
│ │ │
▼ split fit+transform transform만
학습 │ 테스트 │ │ (학습 fold 통계만 사용)
└─ 이미 오염됨 └─ 모델 학습 ▶ 평가
from sklearn.pipeline import Pipelinefrom sklearn.preprocessing import StandardScalerfrom sklearn.impute import SimpleImputerfrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import cross_val_score# Pipeline: 각 fold의 학습 부분에서만 fit이 일어나 누수를 차단한다pipe = Pipeline([ ("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler()), ("clf", LogisticRegression()),])# 각 fold의 전처리가 학습 부분에만 fit된다scores = cross_val_score(pipe, X, y, cv=5)
⚠️ 함정: "전체 데이터로 스케일링한 뒤 분할하면 안 되나?"는 헷갈리기 쉬운 문제다. 안 된다. StandardScaler가 전체 데이터로 fit되면 테스트 통계가 학습 스케일에 새어 검증 점수가 낙관적으로 부풀려진다. 학습 부분에만 fit, 테스트에는 transform만 적용해야 한다. 교차검증에서는 Pipeline이 이 규칙을 fold마다 자동으로 강제한다.
불균형 데이터에서 accuracy가 높은 것은 누수가 아니라 다수 클래스 편향일 수 있다. 두 원인을 구분하려면 소수 클래스 지표(recall, PR-AUC)를 봐야 한다. accuracy만 보고 "누수다"라고 단정하는 것도, "정상이다"라고 안심하는 것도 둘 다 틀린다.