다들 쓰는 그 파이프라인, 한 번 의심해 보셨나요?
scikit-learn으로 몇 달만 모델링하다 보면 누구나 비슷한 코드를 짜게 됩니다. 숫자형 컬럼은 SimpleImputer + StandardScaler, 나머지는 SimpleImputer + OneHotEncoder. ColumnTransformer가 dtype 기준으로 알아서 분기해 주니까 컬럼을 일일이 들여다보지 않아도 돌아가죠.
짧고, 재사용 가능하고, 믿음이 갑니다. 그게 문제입니다.
이 글은 "잘 돌아가는 것처럼 보이는" 파이프라인을 일부러 망가뜨려 보는 실험에서 출발합니다. 8,000행짜리 합성 이탈(churn) 데이터를 시드 0~4까지 5개 생성하고(학습 6,000 / 홀드아웃 2,000), dtype 기반 파이프라인이 원리상 볼 수 없는 함정 4개를 심었어요. 결과는 아래 표처럼 나옵니다.
| 변형 | CV AUC | 홀드아웃 AUC |
|---|---|---|
| 순진한 자동 파이프라인 | 0.979 | 0.564 |
| + 지역을 범주형으로 | 0.982 | 0.624 |
| + 결측 지시자 추가 | 0.984 | 0.670 |
| + 날짜 파싱 | 0.980 | 0.585 |
| + ID 컬럼 제거 | 0.640 | 0.632 |
| 4가지 수정 전부 | 0.833 | 0.826 |
아무것도 크래시하지 않았고, 경고도 안 떴고, 모든 숫자가 "강한 모델"처럼 보였습니다. 실무에서 이 표를 처음 보면 등골이 서늘해지는 지점이에요 😅

함정 4개, 코드로 재현하기
심은 함정은 이렇습니다.
- 숫자로 위장한 범주형:
region_code가 1~12 정수인데, 지역별 이탈률이 순서와 무관하게 다릅니다. 파이프라인은 이걸 그냥 측정값처럼 스케일링해 버려요. - 정보가 담긴 결측값:
support_calls는 이탈 고객의 약 45%, 비이탈 고객의 8%에서 결측입니다. 중앙값 대체 한 방에 이 신호가 사라집니다. - 텍스트로 저장된 날짜:
signup_date가YYYY-MM-DD문자열이라 날짜마다 원핫 컬럼이 생깁니다(6,000행에 고유값 1,931개). - 정답을 흘리는 ID: 학습 데이터에서
customer_id가 결과 순서와 대략 상관되게 부여됐습니다. 실제 시스템에서 내보내기 순서나 시각이 결과와 상관되는 상황의 대역이에요. 홀드아웃에서는 ID와 이탈이 무관합니다.
이걸 자동으로 잡아내는 스캐너를 하나 만들어 봅시다. 모델링 전에 테이블을 훑고, 사람이 읽을 수 있는 경고를 찍는 함수입니다.
import pandas as pd
import numpy as np
def scan_table(df: pd.DataFrame, target: str, id_like_suffixes=("id", "_no", "_code")) -> None:
"""모델링 전에 테이블을 스캔하여 데이터 누수 위험을 경고합니다."""
y = df[target]
for col in df.columns:
if col == target:
continue
s = df[col]
# 1) ID 의심 컬럼: 이름이 id로 끝나거나 고유값 비율이 극단적으로 높음
if col.lower().endswith(id_like_suffixes) or s.nunique() / len(s) > 0.9:
corr = pd.to_numeric(s, errors="coerce").corr(y)
if corr is not None and abs(corr) > 0.3:
print(f"[경고] '{col}' 컬럼이 타깃과 상관 {corr:.2f}. ID 누수 의심.")
# 2) 숫자로 위장한 범주형: 정수인데 고유값이 적고 순서 의미가 없음
if pd.api.types.is_integer_dtype(s) and 2 <= s.nunique() <= 30:
print(f"[검토] '{col}'은 정수지만 범주형일 수 있습니다. 코드/우편번호/지역ID 여부 확인.")
# 3) 정보가 담긴 결측값: 결측 여부에 따라 타깃 비율이 크게 다름
if s.isna().any():
rate_missing = y[s.isna()].mean()
rate_present = y[s.notna()].mean()
if abs(rate_missing - rate_present) > 0.1:
print(f"[경고] '{col}' 결측 여부에 따라 타깃 비율 차이 {abs(rate_missing - rate_present):.2f}. 결측 지시자 추가 권장.")
# 4) 고유값 폭발 텍스트 컬럼: 날짜/이름/자유 텍스트
if s.dtype == object and s.nunique() > 100:
print(f"[경고] '{col}' 텍스트 컬럼의 고유값 {s.nunique()}개. 원핫 대신 별도 처리 필요.")
def gap_warning(cv_score: float, holdout_score: float, threshold: float = 0.1) -> None:
"""CV와 홀드아웃 격차가 크면 누수/암기를 의심합니다."""
gap = cv_score - holdout_score
if gap > threshold:
print(f"[경고] CV({cv_score:.2f})가 홀드아웃({holdout_score:.2f})보다 {gap:.2f} 높습니다. 누수 또는 암기 의심.")
# 사용 예
# scan_table(train_df, target="churned")
# gap_warning(0.979, 0.564)
시드 0 기준으로 스캐너는 심어둔 4개 컬럼(customer_id, region_code, support_calls, signup_date)을 매번 정확히 잡아냈고, tenure_months, monthly_spend, plan_type은 건드리지 않았습니다. gap_warning(0.979, 0.564)는 헤드라인 문제를 한 문장으로 짚어 줍니다. "교차검증(0.98)이 홀드아웃(0.56)보다 훨씬 높습니다. 누수 또는 암기를 의심하세요."
임계값이 완벽하냐고요? 아닙니다. 중요한 건 코드를 모르는 사람도 "ID가 새는 게 어떤 모양인지" 몰라도 된다는 점이에요. 도구가 문장으로 알려주니까요.

한 방 수정으로는 안 됩니다
표를 다시 보세요. "+ ID 컬럼 제거" 행에서 교차검증이 정직해집니다(0.640 vs 신규 데이터 0.632). 검증 숫자로서는 딱 원하는 모습이죠. 그런데 홀드아웃 AUC는 0.632에 그칩니다. 나머지 세 문제가 그대로 남아 있으니까요.
로지스틱 회귀 기준 단일 수정 효과는 이랬습니다.
- 결측 지시자: 0.564 → 0.670 (가장 큰 단일 이득)
- 지역을 범주형으로: 0.564 → 0.624
- 날짜 파싱: 0.564 → 0.585 (미미함)
- ID 제거: CV는 정직해지지만 홀드아웃은 0.632
네 가지를 모두 적용해야 0.826에 도달하고, 그때서야 정직한 CV(0.833)와 홀드아웃이 손을 잡습니다. "하나만 고치면 된다"는 착각이 가장 위험합니다.
그나마 나은 모델이 구해줄까?
부분적으로요. 그래디언트 부스팅은 순진한 파이프라인에서 0.614로 로지스틱(0.564)보다 조금 낫지만 여전히 빈약합니다. ID 제거가 가장 큰 도움이 됐고(0.739), 네 가지 전부 적용 시 0.809까지 올라갑니다. 다만 그래디언트 부스팅에서 지역 수정(0.607)은 노이즈 범위이고, 결측 지시자 변형은 시드 간 편차가 큽니다(std 0.084). 이 두 행은 개별로 읽지 마세요. 일관된 발견은 ID 효과와 전체 수정 결과입니다.
이 접근의 한계
- 합성 데이터 하나: 결과는 제가 작성한 생성기 하나에서 나왔습니다. 효과의 방향은 전이되겠지만 크기는 실제 데이터에서 달라집니다. 잘 아는 실제 테이블에 같은 변형을 돌려 보세요.
- 과장된 ID 누수: 실패가 눈에 보이도록 ID를 일부러 강하게 상관시켰습니다. 실제 누수는 보통 더 약합니다.
- 모델 두 종류만: 다른 모델은 다르게 반응할 수 있습니다. 미래 데이터가 도착하는 방식 그대로 홀드아웃을 만들어 반복하세요.
- 탐지기의 오탐률 미검증: 함정이 강하고 깨끗해서 탐지가 쉬웠습니다. 실제 데이터에서는 사람이 판단해야 할 경고가 뜹니다(플래그된 ID가 무해할 수도, 치우친 범주가 진짜일 수도). 경고는 "자동 수정"이 아니라 "확인하라는 신호"로 다루세요.
국내 SI·사내 데이터 환경에서는 특히 2번(정보가 담긴 결측값)과 4번(내보내기 순서와 상관된 ID)이 자주 나타납니다. 운영 DB에서 덤프 뜬 순서가 라벨과 상관되는 경우가 은근히 많거든요. 모델링 전에
scan_table같은 스캐너를 CI 파이프라인에 넣어 두면 이런 사고를 조기에 잡을 수 있어요. 데이터 파이프라인 설계에 관심 있다면 프로토타입 로그인 신뢰 구축 가이드처럼 "자동화에 검증을 심는" 사고방식이 참고가 됩니다.

자동화는 검증이 아닙니다
8줄짜리 파이프라인은 좋은 출발점이고 형편없는 종착점입니다. 이 실험에서 그 파이프라인은 0.98을 뽑아냈지만 신규 데이터에서 0.56으로 떨어졌고, 정직한 0.83에 도달하려면 한 가지가 아니라 네 가지 수정이 필요했습니다. 자동화는 시간을 아껴 줬지만 검증은 해 주지 않았습니다. 그래서 저는 검증을 자동화 안에 넣었습니다.
이런 문제를 스스로 못 잡는 사람을 위한 도구를 만든다면, 바로 이 부분을 도구에 넣어야 합니다.
신뢰하기 전에 던질 5가지 질문
- 미래 데이터처럼 생긴 홀드아웃이 있는가? 학습 데이터 안의 누수는 학습 데이터 위의 교차검증으로 못 잡습니다. 시간 기준이나 별도 익스포트로 홀드아웃을 만드세요.
- 모델이 가장 의지하는 컬럼은 무엇인가? 상위 계수나 순열 중요도를 보세요. ID, 행 번호, 타임스탬프가 1등이면 멈추세요.
- 숫자 컬럼이 사실 범주형은 아닌가? 코드, 우편번호, 지역 ID는 수량이 아닌 숫자입니다.
- 결측이 무작위인가? 결측일 때와 존재할 때의 타깃 비율을 비교하세요. 다르면 결측 지시자를 추가하세요.
- 고유값이 수천 개인 텍스트 컬럼이 있는가? 날짜·이름·자유 텍스트는 원핫이 아니라 별도 처리가 필요합니다.
다음 단계 학습 방향
sklearn.pipeline.Pipeline에 커스텀FunctionTransformer로 결측 지시자와 날짜 파서를 붙여 보세요.TimeSeriesSplit으로 시간 기반 홀드아웃을 만들어 위 실험을 재현해 보세요.permutation_importance로 컬럼별 의존도를 측정하고, ID류 컬럼을 자동 제거하는 전처리 단계를 설계해 보세요.- 안드로이드 쪽에서 AI 에이전트를 만들고 있다면 ADK for Kotlin으로 안드로이드 AI 에이전트 만들기도 같은 "자동화에 검증을 심는" 관점에서 읽어 볼 만합니다.
근거자료: My Machine Learning Pipeline Cheated, and Cross-Validation Never Noticed