Stacking 앙상블 직접 구현하기
경진대회에서 Stacking 앙상블을 직접 구현하면서 배운 내용을 정리한다. Stacking은 여러 모델의 장점을 모아 더 강한 모델을 만드는 앙상블 기법이다. 직접 해보며 가장 크게 깨달은 점은, Stacking을 KFold와 반드시 함께 써야 한다는 것이었다.
Stacking에 KFold가 필요한 이유#
Stacking은 여러 base 모델의 예측 결과를 메타모델의 입력 feature로 사용하는 앙상블이다. 메타모델은 base 모델들의 예측을 보고 최종 판단을 내리도록 학습된다.
여기서 문제가 생긴다. 메타모델을 학습시키려면 train 데이터에 대한 base 모델의 예측이 필요하다. 그런데 base 모델이 바로 그 train 데이터로 학습한 상태라면, 같은 데이터에 대한 예측은 이미 답을 본 예측이나 다름없다. 메타모델은 비정상적으로 좋은 입력을 받게 되는데, 이것이 곧 데이터 누수다.
이를 막는 장치가 OOF(Out-of-Fold) 예측이다. 그리고 OOF를 만들려면 KFold가 필요하다. Stacking이 KFold와 한 몸인 이유가 여기에 있다.
OOF 예측과 Test 예측 만들기#
데이터가 클래스 불균형 상태였기 때문에 일반 KFold 대신 StratifiedKFold를 사용했다. 라벨 분포 비율을 유지한 채 fold를 나눠주기 때문이다.
skf = StratifiedKFold(n_splits=FOLDS, shuffle=True, random_state=RANDOM_STATE)
이제 fold를 돌면서 두 종류의 예측 행렬을 채운다.
model_oof_preds: 메타모델 학습용. train 데이터에 대한 예측인데, 각 샘플은 그 샘플을 학습에 쓰지 않은 fold의 모델로만 예측한다.model_test_preds: 메타모델 추론용. 전체 test 데이터에 대한 예측인데, fold마다 만들어진 모델의 예측을 평균낸다.
model_oof_preds = np.zeros((len(train_data), NUM_CLASSES))
model_test_preds = np.zeros((len(test_data), NUM_CLASSES))
for fold, (train_idx, val_idx) in enumerate(skf.split(train_data, train_data['label'])):
# train_idx로 학습. val_idx는 이 fold가 학습하지 않은 검증 슬라이스
trainer.train()
# OOF: 이 fold가 학습하지 않은 val_idx 부분만 예측해서 채운다
model_oof_preds[val_idx] = trainer.predict(val_dataset).predictions
# Test: 매 fold마다 전체 test를 예측하고 FOLDS로 나눠 누적 = 평균
model_test_preds += trainer.predict(test_dataset).predictions / FOLDS
앞에서 말한 누수 방지가 코드로 실현되는 지점이 여기다. model_oof_preds[val_idx]를 채울 때, 그 예측은 val_idx를 학습에 쓰지 않은 모델이 만든 것이다. 모든 fold를 한 바퀴 돌면 전체 train 크기만큼의 예측 행렬이 누수 없이 완성되고, 이게 메타모델의 학습 입력이 된다.
Test 쪽은 발상이 다르다. 매 fold의 모델이 전체 test를 예측하되, 그 예측값을 FOLDS로 나눠 1/FOLDS만큼씩만 누적한다. FOLDS번 돌고 나면 fold별 예측이 전부 더해져 평균이 된다.
여기서 trainer.predict의 반환값을 알아야 한다. Trainer.predict는 NamedTuple을 반환한다.
# Returns: NamedTuple
# - predictions (np.ndarray): test_dataset에 대한 예측 logits
# - label_ids (np.ndarray, optional): 데이터에 라벨이 있으면 그 라벨
# - metrics (dict[str, float], optional): 라벨이 있으면 계산된 지표
그래서 .predictions로 logits(numpy array)를 꺼내고, 거기에 / FOLDS를 적용한다. / 연산자는 numpy array에 broadcasting되므로 행렬 전체에 나눗셈이 적용된다.
메타모델 학습#
OOF 행렬이 준비되면 메타모델은 단순하다. base 모델의 OOF 예측을 입력으로, 실제 train 라벨을 정답으로 학습시킨 뒤, model_test_preds로 최종 예측을 만든다.
meta_model = LogisticRegression()
meta_model.fit(model_oof_preds, train_labels)
final_preds = meta_model.predict(model_test_preds)
KFold와 Epoch의 관계#
구현하다 한 번 멈칫한 지점이 있다. KFold를 쓰면 Epoch은 무슨 의미일까? 5 Folds에 5 Epochs면 학습은 몇 번 일어나는가?
답은 5 Folds × 5 Epochs = 25번이다. Fold와 Epoch은 서로 다른 축이기 때문이다.
- Fold(K=5): 전체 학습 데이터를 몇 개의 덩어리로 나눠 총 몇 번의 개별 학습을 실행할지를 정한다. 1번 Fold를 검증용으로 쓸 때 2·3·4·5번 Fold로 학습(Run 1), 2번 Fold를 검증용으로 쓸 때 1·3·4·5번 Fold로 학습(Run 2), … 이렇게 5번의 개별 학습/검증 세션이 실행된다.
- Epoch(=5): 위에서 말한 한 번의 개별 학습이 얼마나 오래 지속되는지를 의미한다. Run 1에서 모델은 2·3·4·5번 Fold 데이터를 총 5번 훑으며 학습하고, Run 2에서도 1·3·4·5번 Fold 데이터를 총 5번 훑는다.
즉 Fold는 독립적인 학습을 몇 번 실행할지, Epoch은 각 학습이 자기 train fold를 몇 번 반복해 훑을지를 정한다. 둘을 곱한 25가 전체 epoch 수가 된다.
StratifiedKFold.split()이 X를 쓰는 방식#
구현하면서 skf.split(X, y)가 X와 y를 각각 어떻게 쓰는지 헷갈려서 따로 짚어둔다. 다음처럼 fold 크기를 출력해보면 동작을 확인할 수 있다.
for fold, (train_idx, val_idx) in enumerate(skf.split(train_data, train_data['label'])):
print(f"{fold}th fold : train fold size: {len(train_idx)}, valid fold size: {len(val_idx)}")
split의 타입 힌트는 다음과 같다.
def split(
X: MatrixLike | list[str],
y: ArrayLike,
groups: Any = None,
) -> Iterator[Any]
핵심은 split이 X와 y를 받지만 X에서는 오직 총 샘플 개수만 가져간다는 점이다. X에 레이블이 포함되어 있든, 텍스트만 있든, 심지어 완전히 다른 값이 들어 있든 상관없이 길이만 파악한다.
분할의 기준은 y다. y의 label 분포를 분석해 그 비율에 맞게 fold를 자르는 게 Stratified의 역할이다. 그래서 split(train_data, train_data['label'])처럼 X에 full dataset을 통째로 넣어도 문제없이 동작한다. X는 길이만, y는 분포를 결정한다.
참고로 타입 힌트의 ArrayLike와 MatrixLike는 다음을 가리킨다.
ArrayLike: 1차원 또는 다차원의 배열과 유사한 구조.numpy.ndarray,pandas.Series,pandas.DataFrame등.MatrixLike: ArrayLike 중에서도 2차원(행과 열) 구조를 명확히 가진 객체.numpy.ndarray,pandas.DataFrame,torch.Tensor등.
내가 했던 실수#
처음에는 전체 학습 데이터를 train_test_split으로 한 번 자르고, 그 잘린 데이터에 다시 KFold를 적용했다. 이렇게 하면 KFold가 쓸 수 있는 학습 데이터가 불필요하게 줄어든다. Stacking에서 OOF는 이미 전체 train을 fold로 나눠 검증까지 커버하므로 앞단의 train_test_split은 필요 없다. 결국 train_test_split을 제거하고 전체 데이터로 KFold stacking을 수행하도록 고쳤다.
여담이지만 대회는 꽤 나쁘지 않은 성적으로 마감했다...!

댓글