머신러닝·신경망

DL / 15번째 글

데이터 분할과 교차 검증: 성능을 정직하게 재는 법

훈련·검증·테스트를 왜 나누는지, 누수는 어디서 새는지, 한 번의 분할로 부족할 때 K-겹 교차 검증과 층화·시계열·그룹 분할기가 무엇을 지키는지 한 편에서 정리한다.

PALDYN Team49 MIN READ

지난 글에서 t-SNE와 UMAP으로 고차원 데이터를 눈에 보이는 평면으로 끌어내렸다. 이번에는 방향을 바꿔, 모델을 만든 다음에 오는 질문을 다룬다. 이 모델이 얼마나 좋은가를 어떻게 재는가. 알고리즘을 아무리 정교하게 골랐어도 재는 방법이 틀리면 숫자가 현실과 따로 논다. 그리고 이 자리에서 나오는 실수는 조용하다 — 코드가 멈추지 않고, 오류도 안 나고, 오히려 성적표가 더 좋아진다.

데이터 분할과 교차 검증을 한 편에서 다루는 이유가 있다. 교차 검증은 분할과 다른 주제가 아니라 같은 분할을 여러 번 하는 것이기 때문이다. 데이터가 부족해 검증 세트 하나를 떼기 아까울 때, 혹은 한 번 떼어 본 점수를 믿기 어려울 때 쓰는 대안이다. 그래서 순서가 「왜 나누는가 → 어떻게 나누는가 → 한 번 나누는 것으로 부족할 때」로 이어진다. 층화·시계열·그룹이라는 세 가지 주의사항은 두 자리 모두에 똑같이 걸리므로, 나눠 놓으면 같은 이야기를 두 번 하게 된다.

일반화와 데이터 분할

암기와 일반화

머신러닝 모델의 목표는 본 적 없는 데이터에서 잘 동작하는 것이다. 이 목표를 「일반화」(Generalization)라 부른다. 그런데 학습에 쓴 데이터로 평가하면 이 목표를 전혀 재지 못한다. 재는 것이 일반화가 아니라 암기이기 때문이다.

극단적인 예가 이해를 빠르게 한다. 훈련 데이터를 통째로 저장해 두었다가 입력이 들어오면 가장 가까운 저장 샘플의 답을 그대로 내놓는 모델을 생각해 보자. 이 모델을 훈련 데이터로 평가하면 각 샘플에서 가장 가까운 것은 자기 자신이므로 정확도가 정확히 100%다. 아무것도 배우지 않았는데 만점이다. 깊이 제한을 풀어 놓은 결정 트리도 사실상 같은 일을 한다 — 잎이 샘플 하나씩을 담을 때까지 쪼개면 훈련 정확도는 100%가 되지만, 새 데이터에서는 그보다 한참 낮은 점수를 받는다. 이 두 모델은 극단적인 예시일 뿐이지만, 정도만 다를 뿐 모든 모델이 같은 방향으로 기운다. 학습이란 손실을 훈련 데이터에서 낮추는 일이므로, 훈련 데이터에서 잰 점수는 구조적으로 낙관적일 수밖에 없다.

훈련 데이터에만 맞아 들어가는 이 현상을 과대적합(Overfitting)이라 한다. 데이터에 있는 규칙이 아니라 그 표본에만 있던 잡음까지 따라 그린 상태다. 학습에 쓰지 않은 별도 데이터로 재야만 이 상태를 알아챌 수 있고, 그래서 데이터를 미리 갈라 두는 일이 평가의 첫 단계가 된다.

훈련·검증·테스트

훈련·검증·테스트 세트 분리 구조

훈련 세트(Training Set)는 모델이 패턴을 배우는 데이터다. 가중치와 계수 같은 모델 파라미터는 전부 이 세트에서 최적화된다. 여기에 하나가 더 붙는데, 자주 잊힌다 — 전처리에 쓰는 통계량도 훈련 세트에서만 계산해야 한다. 표준화의 평균과 표준편차, 결측값을 메울 중앙값, 인코딩할 범주 목록이 전부 여기 해당한다.

검증 세트(Validation Set)는 모델을 고르고 하이퍼파라미터를 조정하는 데 쓴다. 학습률, 정규화 강도, 트리 깊이, 레이어 수를 바꿔 가며 이 세트의 점수를 본다. 훈련 세트와 결정적으로 다른 점은 모델이 이 데이터로 기울기를 계산하지 않는다는 것이고, 그래서 여러 번 들여다봐도 된다.

테스트 세트(Test Set)는 최종 성능을 보고할 때만 여는 봉인 데이터다. 모델 개발이 완전히 끝난 뒤 단 한 번 쓴다. 「한 번」이라는 말이 과장으로 들리겠지만 여기에는 지킬 만한 이유가 있고, 그 이유가 다음 소절이다.

검증 세트 오염

검증 세트를 여러 번 참조해도 된다고 했지만 공짜는 아니다. 참조할 때마다 검증 세트에 대한 정보가 조금씩 우리 손을 거쳐 모델로 흘러든다. 하이퍼파라미터를 바꾸고 검증 점수를 보고 다시 바꾸는 과정 자체가 검증 세트를 목적 함수로 삼은 최적화라서다. 기울기 대신 사람이 손으로 도는 최적화일 뿐이다.

숫자로 보면 규모가 실감 난다. 진짜 성능이 전부 정확히 0.80인 모델 후보 100개가 있고 검증 세트가 1,000개라고 하자. 여기서 쓸 도구가 표준오차(Standard Error)인데, 같은 실험을 되풀이했을 때 재는 값이 흔들리는 폭을 뜻한다. 지금 맥락에서는 「같은 모델을 다른 검증 세트로 잴 때마다 점수가 얼마나 달라지는가」다. 정확도처럼 맞았다·틀렸다를 세는 지표라면 진짜 성능 pp 와 표본 크기 nn 으로 p(1−p)/n\sqrt{p(1-p)/n} 이 그 폭이 된다. 위 설정에 넣으면 0.8×0.2/1000≈0.0126\sqrt{0.8 \times 0.2 / 1000} \approx 0.0126 이므로 후보들의 검증 점수는 0.80 언저리에 1.26%p 폭으로 흩어진다. 100개 중 가장 높은 점수는 평균보다 대략 2.5 표준오차 위, 즉 0.83쯤이 된다. 우리는 그 후보를 고르고 「0.83짜리 모델을 찾았다」고 말하지만, 실제 성능은 여전히 0.80이다. 검증 점수 0.83은 실력이 아니라 뽑기 운을 최댓값으로 걸러 낸 결과다.

테스트 세트를 봉인해 두는 이유가 이것이다. 개발 중에 테스트 점수를 한 번이라도 보고 그에 따라 무언가를 바꾸면, 테스트 세트도 똑같이 닳아서 배포 후 성능을 낙관적으로 말하게 된다. 실무에서 「검증 0.83, 테스트 0.80」처럼 테스트가 조금 낮게 나오는 것은 사고가 아니라 정상이다 — 그 차이가 바로 검증 세트를 쓰면서 치른 비용이다.

홀드아웃

분할 비율

데이터를 한 번 잘라 세 덩어리를 만드는 가장 단순한 방식을 홀드아웃(Holdout)이라 한다. 비율은 대체로 아래를 따른다.

데이터 크기 훈련 검증 테스트
소규모 (~1만) 60% 20% 20%
중규모 (~10만) 70% 15% 15%
대규모 (100만+) 98% 1% 1%

비율이 데이터 크기에 따라 이렇게 달라지는 이유는 평가에 필요한 것이 비율이 아니라 개수이기 때문이다. 점수의 흔들림은 테스트 세트의 절대 크기가 정한다. 정확도 0.90인 모델을 테스트 세트 10,000개로 재면 표준오차가 0.9×0.1/10000=0.003\sqrt{0.9 \times 0.1 / 10000} = 0.003 으로 0.3%p지만, 1,000개로 재면 0.95%p, 200개로 재면 2.1%p까지 벌어진다. 100만 개 데이터에서 1%면 이미 10,000개라 충분하고, 나머지 99%를 훈련에 쓰는 편이 이득이다. 반대로 5,000개짜리 데이터에서 테스트를 1%로 잡으면 50개뿐이라 정확도가 4%p씩 튄다.

그래서 비율표는 외울 것이 아니라 거꾸로 읽어야 한다. 먼저 「이 성능 차이를 구별하고 싶다」를 정하고, 거기에 필요한 테스트 개수를 잡은 뒤, 남은 것을 훈련에 준다. 0.5%p 차이를 구별하려면 수천 개가 아니라 수만 개가 필요하다.

이중 분할

사이킷런의 train_test_split은 데이터를 둘로만 나눈다. 셋을 만들려면 두 번 부른다 — 먼저 훈련과 나머지로 자르고, 나머지를 다시 검증과 테스트로 자르는 식이다.

from sklearn.model_selection import train_test_split

# 7 : 1.5 : 1.5 — 먼저 30%를 떼고
X_train, X_temp, y_train, y_temp = train_test_split(
    X, y, test_size=0.30, stratify=y, random_state=42)

# 그 30%를 반으로 갈라 검증과 테스트로
X_val, X_test, y_val, y_test = train_test_split(
    X_temp, y_temp, test_size=0.50, stratify=y_temp, random_state=42)

print(f"Train {len(X_train):,} / Val {len(X_val):,} / Test {len(X_test):,}")

두 번째 호출에서 test_size=0.50인 것에 주의한다. 이 값은 전체가 아니라 넘겨받은 X_temp 기준이므로, 30%의 절반씩 나뉘어 결과가 15%와 15%가 된다. 전체 기준으로 착각해 0.15를 넣으면 검증 25.5%, 테스트 4.5%라는 엉뚱한 분할이 나오고, 이런 실수는 출력에 크기를 찍어 보지 않으면 끝까지 모른다. random_state를 고정하는 것도 습관으로 두는 편이 낫다 — 고정하지 않으면 어제 잰 0.87과 오늘 잰 0.84 중 무엇이 모델 변화 때문이고 무엇이 분할 때문인지 가릴 수 없다.

층화 추출

stratify=y는 분할 후에도 각 세트의 클래스 비율이 원본과 같게 유지되도록 강제한다. 층화(Stratification)라 부르는 이 옵션은 클래스가 기울어 있을 때 선택이 아니라 필수다.

숫자를 넣어 보면 왜인지 보인다. 1,000개 중 양성이 50개(5%)인 데이터에서 20%인 200개를 테스트로 떼면, 기대되는 양성 개수는 10개다. 그런데 무작위로 뽑으면 이 개수의 표준편차가 200×0.05×0.95≈3.1\sqrt{200 \times 0.05 \times 0.95} \approx 3.1 이라, 흔하게 4개에서 16개 사이를 오간다. 여기서 문제가 되는 지표가 재현율(Recall), 즉 실제 양성 중 모델이 몇 개를 잡아냈는가를 재는 값이다. 양성이 4개뿐인 테스트 세트에서 재현율은 0/4, 1/4, 2/4처럼 0.25 단위로만 움직이므로 소수점 아래를 논하는 것 자체가 무의미하고, 양성이 16개 들어간 세트에서는 같은 모델이 전혀 다른 점수를 받는다. 더 기운 데이터, 이를테면 양성이 1%인 경우에는 테스트 세트에 양성이 아예 한 개도 안 들어가는 일까지 생긴다. 분모가 0이 되어 재현율은 계산조차 되지 않는다.

층화는 이 뽑기를 없앤다. 클래스별로 따로 잘라 정해진 비율만큼 각 세트에 넣으므로, 200개 테스트에는 언제나 양성이 10개 들어간다. 대신 회귀 문제에는 그대로 쓸 수 없다 — 타겟이 연속값이라 나눌 클래스가 없기 때문이다. 이때는 타겟을 사분위 같은 구간으로 잘라 그 구간 라벨을 stratify에 넘기면 같은 효과를 낸다.

데이터 누수

전처리 누수

데이터 누수(Data Leakage)는 검증·테스트 세트의 정보가 학습 과정에 흘러들어 성능이 실제보다 좋게 측정되는 현상이다. 머신러닝 실수 중 가장 흔하고 가장 비싸다. 코드는 잘 돌고 점수는 오르므로 발견되는 자리가 대개 배포 이후이기 때문이다.

가장 흔한 형태는 전처리를 분할보다 먼저 하는 것이다. 전체 데이터에 StandardScaler().fit_transform(X)를 적용한 다음 나누면, 스케일링에 쓰인 평균과 표준편차에 테스트 세트의 값이 섞여 있다. 표준화 하나만 놓고 보면 영향이 작아서 이 실수가 오래 살아남는다.

문제는 같은 실수를 특성 선택에서 저지를 때다. 완전한 잡음으로 실험해 보면 규모가 드러난다. 샘플 200개에 서로 무관한 무작위 특성 10,000개를 만들고 라벨도 동전 던지기로 붙인다. 신호는 하나도 없으므로 어떤 모델이든 정확도 50%가 정답이다. 그런데 전체 데이터에서 라벨과 상관이 높은 특성 20개를 먼저 고르고 그 20개로만 모델을 세우면, 데이터를 여러 번 나눠 점수를 평균 내는 방식(뒤에 다룰 교차 검증)으로 조심스럽게 재도 정확도가 우연 수준인 50%를 한참 웃돈다. 특성이 10,000개나 되니 이 200개 표본에서 우연히 라벨과 잘 맞는 것이 반드시 몇 개는 있고, 선택 단계가 그것들을 정확히 집어낸 것이다. 고르는 행위 자체가 이미 라벨을 본 것이라, 그 뒤에 아무리 정직하게 나눠도 소용이 없다.

이 실험이 무서운 이유는 특성 개수가 늘수록 부풀림도 커진다는 데 있다. 유전체나 텍스트처럼 샘플보다 특성이 많은 데이터에서는 우연히 잘 맞는 특성을 고를 기회도 그만큼 많아진다. 그래서 「특성 선택은 전처리니까 미리 해 두면 편하다」는 판단이 가장 비싼 실수가 된다. 선택은 전처리가 아니라 라벨을 쓰는 학습 단계이고, 학습 단계는 훈련 데이터 안에서만 일어나야 한다.

파이프라인

데이터 누수 방지 파이프라인

이 실수를 사람이 매번 조심해서 막는 것은 오래 못 간다. 전처리 단계가 다섯 개쯤 되고 그중 셋이 학습이 필요한 변환이면, 어느 하나에서 순서를 놓치기 쉽다. 사이킷런의 Pipeline은 그 순서를 구조로 못 박는다.

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.svm import SVC

pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('select', SelectKBest(f_classif, k=20)),   # 특성 선택도 안에 넣는다
    ('clf',    SVC()),
])

pipe.fit(X_train, y_train)                       # fit은 훈련 데이터에만
print(f"Val {pipe.score(X_val, y_val):.4f} | Test {pipe.score(X_test, y_test):.4f}")

fit()을 부르면 각 단계가 훈련 데이터로만 학습하고, score()나 predict()에서는 저장해 둔 파라미터로 변환만 수행한다. 위 코드에서 SelectKBest가 파이프라인 안에 들어 있는 것이 핵심이다 — 이 한 줄이 앞 소절의 잡음 실험을 제자리로 돌려놓는다. 특성 선택이 평가 대상 안으로 들어왔으므로 매번 그 시점의 훈련 데이터에서만 다시 일어나고, 신호가 없는 데이터에서는 점수도 우연 수준으로 내려온다. 학습이 필요한 모든 전처리는 파이프라인 안에 넣는다가 기억할 규칙이고, 여기에는 결측값 대치, 인코딩, 차원 축소, 오버샘플링이 전부 포함된다.

「학습이 필요한가」를 가르는 기준은 단순하다. 그 변환이 데이터를 보고 무언가를 기억하면 학습이다. 표준화는 평균과 표준편차를, 최소최대 스케일링은 최솟값과 최댓값을, 원핫 인코딩은 범주 목록을, 주성분 분석은 축의 방향을 기억한다. 반대로 로그를 씌우거나 두 컬럼을 나누는 것처럼 각 행 안에서 끝나는 계산은 기억할 것이 없으니 어디서 해도 안전하다. 애매하면 파이프라인에 넣는 편이 낫다 — 넣어서 손해 보는 것은 코드 몇 줄이지만, 빼놓아서 잃는 것은 평가 전체의 신뢰다.

시간과 그룹의 누수

전처리를 다 고쳐 놓아도 남는 누수가 있다. 데이터 자체에 구조가 있는데 무작위로 잘라서 생기는 종류다.

시간적 누수는 미래 정보가 과거 예측에 쓰이는 것이다. 주가나 수요를 예측하면서 데이터를 무작위로 나누면, 2025년 1월 데이터로 학습한 모델이 2024년 12월을 맞히는 상황이 태연하게 벌어진다. 배포하면 절대 가질 수 없는 정보를 평가에서만 쥐고 있는 셈이라, 시계열은 반드시 시간 순서로 잘라야 한다. 코드로는 인덱스를 자르는 것이 전부다.

n = len(X)
train_end, val_end = int(n * 0.70), int(n * 0.85)

X_train, X_val, X_test = X[:train_end], X[train_end:val_end], X[val_end:]
y_train, y_val, y_test = y[:train_end], y[train_end:val_end], y[val_end:]

그룹 누수는 서로 관련된 샘플이 훈련과 테스트로 갈릴 때 생긴다. 같은 환자의 여러 검사 기록, 같은 사용자의 여러 거래, 같은 문서에서 잘라 낸 여러 문장이 그렇다. 환자 A의 기록 열 건 중 일곱 건이 훈련에, 세 건이 테스트에 들어가면 모델은 「이 환자의 특징」을 외워서 테스트를 맞힌다. 새 환자에게는 전혀 통하지 않을 능력인데 점수는 높게 나온다. 같은 그룹의 샘플은 통째로 한쪽에 몰아야 하고, 이때 쓰는 것이 아래에서 볼 GroupKFold다.

타겟 누수

가장 알아채기 어려운 것은 타겟 누수다. 예측하려는 결과가 이미 특성에 들어 있는 경우인데, 특성 이름만 보면 멀쩡해 보인다는 점이 고약하다.

신용 부도 예측 모델에 「부도 후 처리된 채권 여부」라는 특성이 섞여 있으면 모델은 타겟을 특성으로 읽고 만점을 받는다. 병원 데이터에서 「퇴원 병동 코드」가 사실은 진단이 확정된 뒤에 붙는 값이거나, 이탈 예측에서 「마지막 로그인 이후 경과일」이 이미 이탈한 사람에게만 크게 잡히는 것도 같은 종류다. 공통점은 그 값이 예측 시점에는 아직 존재하지 않는다는 것이다.

찾아내는 실용적인 방법이 둘 있다. 하나는 특성 중요도를 보고 한 특성이 압도적으로 높으면 그 특성이 언제 만들어지는 값인지 데이터 담당자에게 확인하는 것이다. 둘째는 그 특성 하나만으로 모델을 세워 보는 것이다. 여기서 쓰는 지표가 AUC로, 양성과 음성을 얼마나 잘 갈라 세우는지를 0에서 1 사이 값으로 재며 무작위 추측이 0.5, 완벽한 분류가 1이다. 특성 하나짜리 모델이 AUC 0.99를 내면 예측이 아니라 정답을 읽고 있을 가능성이 높다. 어느 쪽이든 답은 코드가 아니라 그 컬럼이 실제 업무에서 언제 채워지는지에 있다.

교차 검증

홀드아웃의 한계

홀드아웃은 간단하지만 약점이 하나 있다. 점수가 어떻게 잘랐느냐에 따라 흔들린다는 것이다. 앞에서 테스트 세트가 작으면 표준오차가 커진다고 했는데, 그 흔들림이 그대로 성능 수치에 얹힌다.

1,000개 데이터에서 200개를 테스트로 떼고, 진짜 정확도가 0.85인 모델을 잰다고 하자. 표준오차는 0.85×0.15/200≈0.025\sqrt{0.85 \times 0.15 / 200} \approx 0.025 로 2.5%p다. random_state만 바꿔 가며 스무 번 나눠 재면 점수가 대략 0.80에서 0.90 사이에 퍼진다. 이 상태에서 하이퍼파라미터를 바꿔 0.86이 나왔다면, 그것이 개선인지 분할 운인지 구별할 방법이 없다. 데이터가 적을수록, 그리고 재려는 차이가 작을수록 이 문제가 심해진다.

그리고 낭비도 있다. 1,000개 중 300개를 검증과 테스트로 떼면 학습에 쓰는 것은 700개뿐이다. 데이터가 귀할 때 30%를 평가에만 쓰는 것은 아까운 선택이다.

K-겹 교차 검증

교차 검증(Cross-Validation)은 이 둘을 한꺼번에 줄인다. 아이디어는 단순하다. 데이터를 K개의 같은 크기 덩어리(Fold)로 나눈 뒤, K번 반복하면서 매번 다른 덩어리 하나를 검증에 쓰고 나머지 K-1개로 학습한다. K번의 점수를 평균한 값이 최종 추정치다.

5-겹 교차 검증 구조

K=5라면 각 반복에서 20%가 검증에 쓰이고, 다섯 번을 마치면 모든 샘플이 정확히 한 번씩 검증에 참여한다. 이것이 핵심이다 — 추정에 기여한 검증 샘플의 총량이 200개가 아니라 1,000개가 된다. 앞의 예에서 홀드아웃의 표준오차가 2.5%p였다면 5-겹 평균은 1.1%p 쪽에 가까워진다. 다만 폴드마다 훈련 세트가 80%씩 겹쳐 다섯 점수가 서로 완전히 독립은 아니므로, 실제 개선은 이론값보다 조금 덜하다. 동시에 매 반복에서 훈련에 80%를 쓰므로 데이터 낭비도 줄어든다.

from sklearn.model_selection import cross_validate
from sklearn.ensemble import RandomForestClassifier

model = RandomForestClassifier(n_estimators=100, random_state=42)

res = cross_validate(model, X, y, cv=5,
                     scoring=['accuracy', 'f1', 'roc_auc'],
                     return_train_score=True)

print(f"각 폴드 {res['test_accuracy']}")               # 평균 이전의 다섯 숫자
print(f"Train  {res['train_accuracy'].mean():.4f}")
print(f"Val    {res['test_accuracy'].mean():.4f} ± {res['test_accuracy'].std():.4f}")
print(f"F1     {res['test_f1'].mean():.4f} / ROC {res['test_roc_auc'].mean():.4f}")

점수 하나만 필요하면 cross_val_score가 더 짧지만, cross_validate는 여러 지표를 한 번에 계산하고 return_train_score=True로 훈련 점수까지 돌려준다. 볼 것은 평균만이 아니다. 그래서 첫 줄에서 평균을 내기 전의 배열 자체를 찍어 둔다. 표준편차가 크면 모델이 데이터에 따라 불안정하다는 뜻이고, 훈련 점수와 검증 점수의 격차가 크면 과대적합을 의심할 자리다. 평균 0.85 하나만 적어 두면 이 두 신호를 통째로 버리게 된다.

폴드별 점수를 눈으로 보는 습관은 표준편차 하나로 대신할 수 없다. [0.84, 0.85, 0.86, 0.85, 0.84]와 [0.92, 0.85, 0.79, 0.86, 0.82]는 평균이 0.848로 똑같고, 표준편차는 뒤쪽이 더 크다고 알려 줄 뿐이다. 배열을 그대로 펼쳐 놓아야 뒤쪽에서 폴드 1과 폴드 3의 차이가 13%p라는 사실이 눈에 들어온다. 그 정도 폭이면 「모델 A가 모델 B보다 1%p 높다」는 비교가 성립하지 않는다. 한 폴드만 유독 낮은 경우도 흔한데, 그럴 때는 모델이 아니라 그 폴드에 몰린 데이터를 의심해야 한다 — 정렬된 순서로 저장된 데이터를 섞지 않고 잘랐거나, 특정 클래스나 기간이 한 폴드에 통째로 들어간 경우다.

K 값 선택

K 편향 분산 계산 비용 쓰는 자리
3 높음 낮음 낮음 데이터가 많고 학습이 오래 걸릴 때
5 중간 중간 중간 기본값
10 낮음 높음 높음 데이터가 적을 때
n 최소 최대 매우 높음 100개 미만

표에서 편향은 「추정치가 체계적으로 낮게 나오는 정도」다. K=3이면 매 반복에서 데이터의 67%로만 학습하므로, 전체를 다 쓴 모델보다 성능이 낮게 나오고 그만큼 추정이 비관적으로 기운다. K를 키우면 훈련량이 전체에 가까워져 이 편향이 줄지만 학습 횟수가 늘고, 검증 덩어리가 작아져 개별 점수가 흔들린다.

K=n은 LOOCV(Leave-One-Out Cross-Validation)로, 한 번에 샘플 하나씩을 검증에 쓴다. 편향은 가장 작지만 대가가 둘이다. 하나는 n번 학습이라는 비용이고, 다른 하나는 덜 알려져 있다 — n개의 훈련 세트가 n-1개를 공유해 거의 같은 모델이 나오므로 점수들이 강하게 상관되어 평균의 분산이 기대만큼 줄지 않는다. 게다가 분류에서 각 폴드의 점수는 0 아니면 1이라 표준편차를 읽는 재미도 없다. 그래서 실무의 기본은 5이고, 데이터가 몇백 개 수준으로 적을 때 10으로 올린다.

층화·시계열·그룹 분할기

앞에서 층화·시간·그룹이라는 세 가지 주의사항을 봤다. 그 셋은 교차 검증에도 똑같이 걸리고, 사이킷런은 각각에 대응하는 분할기를 따로 두고 있다. cv=5처럼 숫자를 넘기는 대신 이 객체를 넘기면 된다. 아래 그림이 기본 KFold와 함께 층화·시계열 두 분할기를 코드로 나란히 놓았고, 그룹을 맡는 세 번째 분할기는 이 절의 마지막 소절에서 따로 본다.

기본 K-겹과 층화·시계열 분할기

StratifiedKFold

기본 KFold는 데이터를 순서대로 K등분할 뿐 클래스가 어디에 얼마나 들어가는지는 보지 않는다. 그래서 홀드아웃에서 본 그 뽑기가 폴드마다 되풀이된다 — 한 번이 아니라 다섯 번이다.

되풀이되기만 하는 것이 아니라 성질이 하나 더 나빠진다. 홀드아웃에서는 어긋난 비율이 테스트 세트 하나에만 있었지만, K-겹에서는 폴드끼리도 서로 어긋난다. 양성이 넉넉히 들어간 폴드와 거의 없는 폴드가 섞인 채로 다섯 점수를 내면 그 점수들이 크게 흩어지는데, 앞 소절에서 「표준편차가 크면 모델이 불안정하다」고 읽기로 한 그 신호가 여기서 오염된다. 모델이 흔들린 것인지 분할이 흔들린 것인지 구별할 수 없게 되는 것이다. StratifiedKFold는 클래스별로 따로 K등분한 뒤 조각을 모아 폴드를 짜므로 원본 비율이 폴드마다 유지되고, 그 결과 폴드끼리도 같아진다.

from sklearn.model_selection import StratifiedKFold, cross_val_score

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=skf, scoring='f1')

알아 둘 것이 하나 있다. 분류 문제에서 cross_val_score(model, X, y, cv=5)는 이미 StratifiedKFold를 쓴다. 정수를 넘기면 사이킷런이 타겟을 보고 분류면 층화 분할을, 회귀면 일반 KFold를 고른다. 그러니 객체를 직접 만드는 것은 보통 shuffle=True를 켜고 싶을 때다.

셔플의 기본값이 함수마다 다르다는 점은 따로 외워 둘 만하다. train_test_split은 shuffle=True가 기본이라 부르기만 하면 순서가 섞이지만, KFold와 StratifiedKFold는 shuffle=False가 기본이라 저장된 순서 그대로 앞에서부터 자른다. 데이터가 클래스별로 모여 저장돼 있거나 수집 시각순으로 쌓여 있으면 이 기본값이 그대로 폴드 간 편향이 된다. 반대로 시계열처럼 순서가 곧 정보인 데이터에서는 섞는 것이 사고이므로, 켜고 끄는 판단을 데이터의 성격에 맞춰 한 번은 명시적으로 해야 한다.

TimeSeriesSplit

TimeSeriesSplit은 훈련 구간이 점점 커지는 방식으로 폴드를 만든다. 5-겹이면 첫 반복은 앞쪽 일부로 학습하고 그 바로 뒤 구간을 검증하며, 다음 반복은 그 검증 구간까지 훈련에 포함하고 그 뒤를 검증한다. 검증 구간은 언제나 훈련 구간보다 미래이므로 시간적 누수가 구조적으로 막힌다.

일반 K-겹과 다른 점이 둘 있다. 첫째, 폴드마다 훈련 데이터 양이 다르다. 첫 반복은 데이터의 6분의 1로 학습하고 마지막 반복은 6분의 5로 학습하므로, 폴드 1의 점수가 낮은 것은 모델 탓이 아니라 데이터가 적어서일 수 있다. 둘째, 모든 샘플이 검증에 들어가지 않는다 — 맨 앞 구간은 늘 훈련에만 쓰인다.

그래서 시계열에서는 평균 한 숫자가 특히 덜 쓸모 있고, 폴드를 직접 돌며 훈련 크기와 점수를 나란히 찍어 보는 편이 낫다.

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5, gap=0)

for i, (tr, va) in enumerate(tscv.split(X), start=1):
    model.fit(X[tr], y[tr])
    print(f"폴드 {i}: 훈련 {len(tr):>5} / 검증 {len(va):>5}"
          f" → {model.score(X[va], y[va]):.4f}")

이 출력을 읽는 법은 정해져 있다. 훈련 크기가 늘면서 점수도 함께 오르면 정상이다 — 데이터를 더 주면 좋아지는 모델이라는 뜻이고, 전체로 학습해 배포하면 마지막 폴드보다 조금 더 나을 것이라 기대할 수 있다. 반대로 훈련 크기는 계속 느는데 점수가 뒤로 갈수록 떨어지면 데이터를 더 준 것이 문제가 아니라 데이터의 성질이 시간에 따라 변한 것을 의심한다. 과거 구간에서 통하던 규칙이 최근 구간에서 통하지 않는 상태이고, 이 경우 오래된 구간을 훈련에서 빼는 편이 나을 때가 많다. 어느 쪽이든 평균 하나만 보고 있었다면 구별할 수 없는 상황이다.

gap 인자는 훈련 끝과 검증 시작 사이에 구간을 비운다. 예측 대상이 「7일 뒤 수요」처럼 미래를 향해 있으면, 훈련 마지막 날 바로 다음 날을 검증하는 것은 현실에서 불가능한 상황이다. 그 시차만큼 gap을 주면 평가가 실제 운영 조건에 맞는다.

GroupKFold

GroupKFold는 groups 배열을 받아 같은 그룹의 샘플이 훈련과 검증에 동시에 들어가지 않도록 폴드를 짠다. 환자 ID, 사용자 ID, 문서 ID처럼 「이 값이 같으면 사실상 같은 개체」인 컬럼을 넘기면 된다.

from sklearn.model_selection import GroupKFold

groups = patient_ids                       # 샘플마다 소속 그룹 ID
gkf = GroupKFold(n_splits=5)

for train_idx, val_idx in gkf.split(X, y, groups=groups):
    assert not (set(groups[train_idx]) & set(groups[val_idx]))   # 겹치는 그룹 없음

이 분할기는 폴드 크기를 정확히 맞추지 못한다. 옮기는 단위가 샘플이 아니라 그룹이라, 기록이 100건인 환자 하나가 들어간 폴드는 그것만으로 다른 폴드보다 커진다. 사이킷런은 큰 그룹부터 가장 작은 폴드에 넣는 식으로 편차를 줄이지만 같게 만들지는 못하므로, 폴드별 샘플 수가 수십 개씩 차이 나는 것은 정상으로 본다.

무작위성은 뒤늦게 생겼다. 사이킷런 1.6에서 GroupKFold에 shuffle과 random_state가 붙어, 켜면 그룹을 섞은 뒤 폴드에 배치한다. 그 이전 버전에서는 같은 groups 배열에 대해 언제나 같은 분할이 나왔다. 그래서 쓰는 버전을 한 번 확인해 두는 편이 좋다 — 오래된 환경에서 shuffle=True를 넘기면 조용히 무시되는 것이 아니라 오류가 난다.

이름이 비슷한 GroupShuffleSplit은 대체재가 아니라 다른 물건이다. K-겹이 모든 그룹을 정확히 한 번씩 검증에 넣는 데 반해, 이쪽은 매 반복에서 그룹을 무작위로 골라 훈련·검증으로 가르는 반복 분할이라 어떤 그룹은 여러 번 검증에 들어가고 어떤 그룹은 한 번도 안 들어간다. 모든 그룹을 빠짐없이 한 번씩 재고 싶으면 GroupKFold, 분할 운의 폭을 눈으로 보고 싶으면 GroupShuffleSplit이다.

클래스도 기울어 있고 그룹도 있는 경우, 이를테면 환자별 여러 영상에서 희귀 질환을 찾는 문제에는 두 조건을 함께 거는 StratifiedGroupKFold가 있다. 다만 두 조건이 서로 부딪히면 그룹 쪽이 먼저다 — 그룹을 쪼개면 누수지만 비율이 조금 어긋나는 것은 잡음일 뿐이라, 이 분할기도 그룹을 지킨 채 비율을 최대한 맞추는 방식으로 동작한다. 어느 쪽이든 판단은 같은 질문 하나로 끝난다 — 배포된 모델이 만날 데이터는 새 그룹인가, 이미 본 그룹의 새 샘플인가. 새 환자를 상대할 모델이면 그룹으로 나눠야 한다.

튜닝과 최종 평가

격자 탐색

교차 검증의 가장 큰 쓸모는 하이퍼파라미터 탐색이다. GridSearchCV는 지정한 후보 조합을 전부 K-겹으로 평가하고 가장 좋은 조합을 고른 뒤, 그 조합으로 훈련 데이터 전체를 다시 학습해 둔다.

from sklearn.model_selection import GridSearchCV

param_grid = {
    'clf__C':      [0.1, 1, 10, 100],
    'clf__gamma':  ['scale', 0.01, 0.001],
    'clf__kernel': ['rbf', 'linear'],
}

grid = GridSearchCV(pipe, param_grid, cv=5, scoring='f1', n_jobs=-1)
grid.fit(X_train, y_train)

print(f"최적 조합 {grid.best_params_}")
print(f"CV 점수  {grid.best_score_:.4f}")
print(f"테스트   {grid.score(X_test, y_test):.4f}")   # 여기서 딱 한 번

비용을 미리 세어 두는 것이 좋다. 위 격자는 4 × 3 × 2 = 24조합이고 각 조합을 5-겹으로 재므로 학습이 120번, 마지막 재학습까지 121번 일어난다. 한 번 학습에 1분이 걸리면 두 시간이다. 조합을 곱셈으로 늘리는 것이 얼마나 빨리 커지는지 감이 없으면 격자 탐색은 금세 감당이 안 되고, 후보가 많을 때는 무작위로 뽑아 보는 RandomizedSearchCV 쪽이 같은 예산에서 더 넓은 범위를 훑는다.

모델에 넘기는 것이 파이프라인이라는 점도 중요하다. 여기서 pipe를 넘겼으므로 각 폴드마다 스케일러와 특성 선택이 그 폴드의 훈련 부분에서만 다시 학습된다. 파이프라인 밖에서 미리 전처리한 데이터를 넘기면 앞에서 본 누수가 폴드마다 되살아난다. 그리고 GridSearchCV는 내부에서 교차 검증을 하므로 별도의 검증 세트가 필요 없다 — 데이터를 훈련과 테스트 둘로만 나누고, 검증은 GridSearchCV에 맡기는 구성이 흔하다.

중첩 교차 검증

grid.best_score_를 성능으로 보고하고 싶은 유혹이 있는데, 그러면 안 된다. 그 값은 24개 후보의 CV 점수 중 최댓값이고, 앞에서 봤듯 최댓값은 실력에 뽑기 운이 얹힌 값이다. 검증 세트가 닳는 이야기가 여기서 그대로 되풀이된다.

중첩 교차 검증(Nested CV)은 튜닝과 평가를 서로 다른 데이터로 갈라 이 낙관을 없앤다. 바깥 루프가 데이터를 폴드로 나누고, 각 폴드의 훈련 부분 안에서 안쪽 루프가 다시 교차 검증으로 하이퍼파라미터를 고른다. 바깥 폴드의 검증 부분은 튜닝에 한 번도 쓰이지 않았으므로 그 점수는 정직하다.

중첩 교차 검증의 바깥 루프와 안쪽 루프

from sklearn.model_selection import cross_val_score, KFold

inner = GridSearchCV(pipe, param_grid, cv=KFold(3), n_jobs=-1)
nested = cross_val_score(inner, X, y, cv=KFold(5), scoring='f1')

print(f"Nested CV {nested.mean():.4f} ± {nested.std():.4f}")

비용은 곱해진다. 바깥 5 × (안쪽 3 × 24조합 + 재학습 1) = 365번 학습이다. 그래서 중첩 CV는 모든 상황에서 쓰는 것이 아니라, 데이터가 적어 테스트 세트를 따로 떼기 아까운데 성능 수치를 정직하게 말해야 할 때 쓴다. 논문이나 보고서에 「이 방법이 저 방법보다 낫다」를 적을 때가 대표적이다. 데이터가 충분하면 테스트 세트를 봉인해 두는 쪽이 훨씬 싸고 이해하기도 쉽다.

그리고 중첩 CV가 돌려주는 것은 모델이 아니라 성능 추정치다. 바깥 폴드마다 다른 하이퍼파라미터가 뽑혔을 수 있으므로 「최적 조합」이라는 것이 하나로 나오지 않는다. 배포할 모델은 추정을 마친 뒤 전체 데이터로 GridSearchCV를 한 번 더 돌려 만든다.

비용과 반복 홀드아웃

교차 검증은 학습 비용을 K배로 만든다. 한 번 학습에 여섯 시간이 걸리는 딥러닝 모델에서 5-겹은 서른 시간이고, 여기에 하이퍼파라미터 후보까지 곱하면 계산이 성립하지 않는다. 그래서 대형 모델의 세계에서는 교차 검증을 거의 쓰지 않고 홀드아웃 하나로 간다. 다행히 이 영역은 데이터가 많아 검증 세트 하나만으로도 표준오차가 충분히 작다 — 교차 검증이 필요한 이유가 애초에 데이터 부족이었다는 점을 떠올리면 앞뒤가 맞는다.

중간 지대에는 반복 홀드아웃(Repeated Holdout)이 있다. random_state만 바꿔 가며 서너 번 나누고 그 점수들의 평균과 폭을 보는 방식이다. K-겹처럼 모든 샘플이 정확히 한 번씩 검증에 들어가는 성질은 없지만 분할 운의 폭은 눈으로 확인할 수 있고, 비용은 원하는 만큼만 쓴다.

마지막으로, 지금까지 나온 것을 자를 때마다 훑을 순서로 묶어 둔다.

확인 무엇을 보나
순서 전처리는 분할 뒤에, 학습이 필요한 변환은 파이프라인 안에
층화 분류면 stratify 또는 StratifiedKFold
섞기 시계열이 아니면 train_test_split의 기본값(shuffle=True)을 그대로 두고, KFold 계열은 직접 켠다
시간 시계열이면 순서 분할이나 TimeSeriesSplit, 필요하면 gap
그룹 같은 개체의 샘플이 여럿이면 GroupKFold
타겟 예측 시점에 존재하지 않는 컬럼이 특성에 없는지
크기 테스트 개수가 재려는 차이를 구별할 만큼 되는지

분할과 교차 검증이 정직한 숫자를 만들어 주면, 다음 질문은 그 숫자를 어떻게 읽는가로 넘어간다. 훈련 점수와 검증 점수의 격차가 벌어졌을 때 그것이 모델이 너무 단순해서인지 너무 복잡해서인지, 그리고 각각의 처방이 왜 정반대인지를 다음 글에서 본다.


읽어주셔서 감사합니다. 😊

LATEST

머신러닝·신경망의 최신 글

머신러닝·신경망2026.05.08

문맥적 임베딩: ELMo부터 BERT까지

정적 임베딩의 다의어 문제를 해결하는 문맥적 임베딩의 원리, ELMo의 양방향 LSTM 레이어 표현, BERT의 트랜스포머 기반 서브워드 임베딩 추출법을 수식과 코드로 완전히 해설한다.

12 MIN
머신러닝·신경망2026.05.08

FastText: 부분 단어로 OOV를 정복하다

FastText가 문자 n-gram 기반의 부분 단어 모델로 OOV 문제를 해결하는 방법, 한국어 형태론에서의 강점, 실전 학습과 추론 코드를 완전히 해설한다.

11 MIN
머신러닝·신경망2026.05.08

GloVe: 전역 공기 통계로 단어 벡터를 만들다

GloVe가 공기 행렬의 전역 통계와 국소 문맥 창의 장점을 결합하는 방법, 목적 함수의 수학적 의미, 사전 학습 벡터 활용법을 깊이 있게 다룬다.

11 MIN