행 단위로 섞인 사용자 신호와 그룹별로 분리된 사용자 신호를 나란히 비교한 그림

GroupKFold 데이터 누수: 같은 사용자가 train과 test에 섞일 때 점수 착시 비교

내일 가입할 사용자의 결과를 예측해야 하는데, 검증 데이터에 어제 학습한 사용자가 다시 등장한다면 그 점수는 무엇을 재고 있을까. GroupKFold 데이터 누수는 행을 잘못 섞었다는 문법 오류가 아니라, 새 사용자 일반화라는 평가 질문과 행 단위 분할이 어긋날 때 생기는 점수 착시다.

2026년 8월 9일, 사용자 120명의 반복 행 2,206개를 만든 합성 이진 분류 실험에서 이 차이를 통제 비교했다. 데이터·모델·5개 fold·임계값 0.5는 고정하고 행 단위 KFold, 그룹 크기 균형 분할, 그룹·클래스 균형 분할만 바꿨다. 합성 데이터는 누수 메커니즘을 확인하기 위한 것이며 실제 서비스 성능을 뜻하지 않는다.

20초 핵심 요약

  • 무엇: 새 사용자를 예측한다면 한 사용자의 모든 행을 같은 fold에 두고 train/test 사용자 교집합을 0으로 만든다.
  • 왜: 같은 사용자를 양쪽에 섞은 실험은 ROC-AUC를 0.665에서 0.712로 높여, 배포 때 쓸 수 없는 사용자 고유 신호를 일반화 성능처럼 보이게 했다.
  • 어떻게: groups를 정의한 뒤 KFold·GroupKFold·StratifiedGroupKFold의 교집합, 양성률, ROC-AUC, average precision, 미탐을 fold별로 비교한다.

분할 전에 “누구를 처음 만나는가”부터 정한다

입력 한 행을 사용자의 세션·측정·행동 하나, label을 그 행에서 발생한 이진 결과라고 하자. 같은 데이터라도 배포 질문은 둘로 갈린다. 이미 본 사용자의 다음 행동을 예측하는가, 아니면 학습에 없던 새 사용자를 예측하는가. 이 글은 후자를 다룬다.

같은 사용자에게서 나온 여러 행은 독립 표본이 아닐 수 있다. 사용자 고유 성향, 기기 특성, 반복 측정 오차가 여러 행에 함께 남기 때문이다. scikit-learn 교차검증 가이드도 환자별 반복 표본처럼 그룹에 의존하는 데이터에서는 i.i.d. 가정이 깨지며, 새 그룹 일반화를 평가하려면 검증 그룹이 대응하는 train fold에 나타나지 않아야 한다고 설명한다.

여기서 중요한 것은 사용자 ID 열의 유무가 아니다. ID를 feature에서 빼도 센서 보정값, 장치, 세션 습관처럼 사용자를 대리하는 특징과 상관된 오차가 남을 수 있다. groups는 feature 목록이 아니라 예측 시점에 처음 만나게 될 독립 단위를 표현한다.

행 단위 KFold와 GroupKFold의 사용자 중복 여부를 보여주는 5-fold 비교도

같은 모델에 분할만 바꿔 점수 착시를 격리했다

실험은 seed 20260808로 사용자 120명, 사용자당 6~30행을 생성했다. 전체 2,206행의 양성률은 0.340이다. label의 logit은 다음 생성식으로 정했다.

-1.0 + 0.85 × numeric_signal + user_effect
user_effect ~ Normal(0, 1.7)

모델에는 절편, 표준화하지 않은 수치 신호 하나, 사용자 one-hot 가중치를 넣었다. L2 로지스틱 회귀를 batch gradient descent 700회로 학습했다. 사용자 one-hot은 KFold가 train에서 본 사용자의 신호를 test에서 다시 쓰는 상황을 선명하게 만드는 장치다. ID를 제외한 반복 측정 데이터에서 착시가 얼마나 남는지는 이번 실험으로 측정하지 않았다.

비교 조건은 분할 외에 모두 같았다. ROC-AUC는 양성 score가 음성보다 위에 놓이는 순위 능력을 보고, 아래 표의 PR-AUC는 scikit-learn의 average precision 방식으로 계산했다. false negative는 임계값 0.5에서 양성을 음성으로 놓친 행의 합계다.

실행 환경은 Linux 7.0.0-1009-aws, Python 3.12.3이었다. 기본 Python과 프로젝트 가상환경에 NumPy와 scikit-learn이 없어 외부 설치는 하지 않았다. 따라서 아래 GroupKFold 목적 재현StratifiedGroupKFold 문서 알고리즘 재현은 공식 문서의 목적과 공개된 할당 설명을 표준 라이브러리로 구현한 결과다. scikit-learn 1.9.0 클래스가 반환하는 exact index를 실행한 결과가 아니다.

0.047의 차이보다 사용자 118명의 겹침이 먼저다

분할 ROC-AUC 평균±표준편차 PR-AUC 평균±표준편차 FN 합계 그룹 교집합 최대 test 양성률 범위
행 단위 KFold 0.712±0.031 0.554±0.046 604 118 0.320~0.367
GroupKFold 목적 재현 0.665±0.023 0.501±0.057 627 0 0.283~0.385
StratifiedGroupKFold 문서 알고리즘 재현 0.663±0.023 0.495±0.021 626 0 0.334~0.349

행 단위 KFold는 GroupKFold 목적 재현보다 ROC-AUC가 0.047, average precision이 0.053 높았다. 그러나 각 fold의 train과 test가 사용자 111~118명을 공유했다. 모델이 train에서 학습한 사용자 one-hot 가중치를 검증 행에서도 다시 쓸 수 있었으므로, 높은 점수에는 새 사용자에게 사용할 수 없는 정보가 섞였다.

그룹 분할에서는 사용자 교집합이 모두 0이었다. ROC-AUC가 0.665와 0.663으로 낮아진 것은 모델이 실패했다는 뜻이 아니라 평가 질문이 달라졌다는 뜻이다. 새 사용자 배포를 가정한다면 이 낮은 점수가 더 정직한 추정치다. 다만 이 값 자체도 하나의 합성 생성식과 seed에 한정되며 서비스 성능으로 옮길 수 없다.

FN 합계는 KFold가 604로 가장 작았다. 그렇다고 누수된 분할을 선택할 수는 없다. splitter와 threshold를 한꺼번에 고르면 일반화 평가와 미탐 비용 결정을 섞게 된다. 먼저 grouped out-of-fold score를 만든 뒤 실제 미탐·오탐 비용에 맞춰 threshold를 따로 정해야 한다.

GroupKFold와 StratifiedGroupKFold는 해결하는 불균형이 다르다

KFold는 행 index를 나눌 뿐 class와 group을 고려하지 않는다. shuffle=True도 행 순서만 섞으므로 같은 사용자를 한 fold에 모아주지 않는다. 행이 실제로 독립이거나 이미 본 사용자의 다음 행을 예측하는 질문이라면 KFold가 틀린 것은 아니다. 그 결과를 새 사용자 성능이라고 부르는 순간 문제가 된다.

GroupKFold는 지정한 그룹이 train과 test에 겹치지 않게 하고, 각 그룹을 전체 반복에서 한 번 test에 둔다. 서로 다른 그룹 수는 n_splits 이상이어야 한다. 공식 user guide에 따르면 shuffle=False에서는 fold의 sample 수를 비슷하게 하려 하고, shuffle=True에서는 distinct group 수를 맞추려 하지만 group size는 고려하지 않는다. 큰 사용자와 작은 사용자의 행 수 차이가 크다면 group 수 균형이 sample 수 균형을 보장하지 않는다.

이번 GroupKFold 목적 재현도 교집합은 0이었지만 fold 양성률이 0.283~0.385로 벌어졌다. 그 결과 average precision의 fold 표준편차는 0.057이었다. 클래스가 희소한 데이터에서는 특정 fold의 양성이 너무 적어 지표가 크게 흔들리거나 ROC-AUC를 계산하지 못할 수도 있다.

StratifiedGroupKFold는 그룹 비중복을 지킨 상태에서 class 비율도 가깝게 만들려 한다. 문서 알고리즘 재현에서는 양성률 범위가 0.334~0.349로 좁아졌고 average precision 표준편차도 0.021로 줄었다. 하지만 그룹 제약과 class 비율을 언제나 동시에 완벽하게 맞출 수 있는 것은 아니다. 공식 구현은 class 분포의 fold 간 분산을 줄이는 탐욕적 근사이며, 완벽한 stratification이 가능한 경우에도 불균형한 split을 만들 수 있다. 그룹별 class 분포가 이미 비슷하다면 더 단순한 GroupKFold가 나을 수 있다.

잘못된 stratification은 정상처럼 보이는 숫자로도 드러났다

처음에는 공식 scikit-learn API로 비교하려 했지만 NumPy import 단계에서 종료 코드 1이 발생했다.

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ModuleNotFoundError: No module named 'numpy'

의존성을 설치하는 대신 표준 라이브러리 재현으로 전환했다. 이 과정에서 처음 만든 StratifiedGroupKFold 유사 구현은 목적함수를 잘못 잡아 fold 크기가 72~1,164, 양성률이 0.265~0.833으로 붕괴했다. 단순히 “stratified”라는 이름을 붙이는 것으로는 그룹·클래스 균형을 보장할 수 없다는 실패였다.

공식 user guide의 클래스별 fold 분산을 줄이는 탐욕 할당 설명과 대조해 수정하자 fold 크기는 430~449, 양성률은 0.334~0.349가 됐다. 이 수정 결과도 공식 클래스의 exact index와 같다고 주장할 수는 없다. 여기서 얻은 운영 기준은 splitter 이름을 신뢰하는 대신 fold별 n, 그룹 수, 양성률과 교집합을 실제로 출력해야 한다는 것이다.

구현에서는 splitter 뒤에 assertion과 Pipeline을 붙인다

먼저 배포 문장을 쓴다. “새 사용자”, “새 환자”, “새 기기” 가운데 무엇이 학습에 없던 단위인지 정하고 그 값을 groups 배열로 만든다. 그룹 결측과 고유 그룹 수를 확인한 뒤, 모든 fold에서 교집합이 비었는지 assertion으로 막는다.

다음 코드는 공식 API 형태를 정리한 골격이다. 이번 환경에서는 scikit-learn이 설치되지 않아 실행하지 않았다.

from sklearn.model_selection import GroupKFold, StratifiedGroupKFold

cv = GroupKFold(n_splits=5)
# GroupKFold의 class 불균형이 크면 다음 후보와 fold 통계를 비교한다.
# cv = StratifiedGroupKFold(
#     n_splits=5, shuffle=True, random_state=20260808
# )

for fold, (train_idx, test_idx) in enumerate(cv.split(X, y, groups)):
    overlap = set(groups[train_idx]) & set(groups[test_idx])
    assert not overlap, (fold, overlap)

그룹 분할은 엔터티 누수만 막는다. scaler, imputer, feature selector를 전체 데이터에 먼저 fit하면 별도의 전처리 누수가 생긴다. 이 단계들은 fold의 train 데이터에서만 학습되도록 Pipeline 안에 넣어야 한다. 여러 지표를 계산할 때는 fold별 표본 수·그룹 수·양성률도 함께 저장한다. scikit-learn 1.4 이후 metadata routing을 켠 환경에서는 cross_validategroups 전달 방식이 params={'groups': groups}로 달라지므로 설치 버전도 확인해야 한다.

점수가 낮아져도 grouped split을 버리면 안 되는 조건

새 사용자 일반화가 목표이고 일반 KFold에서 그룹 교집합이 하나라도 발견된다면 높은 점수와 관계없이 grouped split을 채택한다. GroupKFold에서 class가 지나치게 적은 fold나 큰 지표 분산이 생기면 StratifiedGroupKFold를 비교하되, class 균형을 위해 그룹 비중복을 양보하지 않는다.

그룹 자체가 잘못 정의된 경우도 있다. 사용자로 묶었지만 실제 배포가 병원이나 기기 단위로 갈린다면 더 강한 공유 원인을 group으로 삼아야 한다. 그룹 수가 매우 적거나 소수의 큰 그룹이 한 class를 독점하면 5-fold 평균만으로 안정성을 주장할 수 없다. 이번 비교도 사용자별 동일 가중 평가, confidence interval, nested CV, calibration, threshold 최적화와 외부 test set을 다루지 않았다.

시간과 그룹 의존성이 함께 있다면 GroupKFold만으로 미래 정보 누수를 막을 수 없다. 반대로 그룹 검증이 끝난 뒤 임계값 0.5를 그대로 운영 기준으로 삼아서도 안 된다. 시간 의존성은 walk-forward 검증 글에서, grouped out-of-fold 예측 이후의 미탐·오탐 판단은 분류 임계값 비용 글에서 이어서 확인할 수 있다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다