앙상블 방법(Ensemble Methods) — 부스팅(Boosting), 배깅(Bagging), 스태킹(Stacking)
약한 학습기(weak learner)를 올바르게 결합하면 강한 학습기(strong learner)가 됩니다. 이것은 비유가 아니라 정리(theorem)입니다.
유형: Build
언어: Python
선수 지식: Phase 2, Lesson 10 (편향-분산 트레이드오프)
소요 시간: 약 120분
학습 목표
- AdaBoost와 그래디언트 부스팅(Gradient Boosting)을 처음부터 구현하고, 부스팅이 어떻게 편향을 순차적으로 줄이는지 설명합니다.
- 배깅 앙상블(Bagging Ensemble)을 만들고, 상관이 낮은 모델을 평균내면 편향을 크게 늘리지 않고 분산을 줄일 수 있음을 보입니다.
- 배깅(Bagging), 부스팅(Boosting), 스태킹(Stacking)이 각각 어떤 오류 성분을 겨냥하는지 비교합니다.
- 앙상블 다양성(Ensemble Diversity)을 평가하고, 독립적인 약한 학습기가 많아질수록 다수결 정확도가 왜 좋아지는지 설명합니다.
문제
단일 결정 트리(Decision Tree)는 빠르게 학습되고 해석하기 쉽지만 과대적합(overfitting)되기 쉽습니다. 단일 선형 모델은 복잡한 경계에서 과소적합(underfitting)되기 쉽습니다. 완벽한 모델 구조를 만들기 위해 며칠을 쓸 수도 있지만, 불완전한 모델 여러 개를 결합해 각각의 모델보다 더 나은 결과를 얻을 수도 있습니다.
앙상블 방법(Ensemble Methods)은 바로 이 일을 합니다. 표 형식(tabular) 데이터의 Kaggle 대회에서 가장 믿을 만한 기법이고, 많은 운영 ML 시스템에서 쓰이며, 편향-분산 트레이드오프가 실제로 어떻게 작동하는지 보여줍니다. 배깅은 분산을 줄입니다. 부스팅은 편향을 줄입니다. 스태킹은 어떤 입력에서 어떤 모델을 신뢰해야 하는지 학습합니다.
개념
앙상블이 작동하는 이유
정확도가 p > 0.5인 독립 분류기 N개가 있다고 해봅니다. 다수결의 정확도는 다음과 같습니다.
P(다수결이 맞을 확률) = k > N/2에 대해 C(N,k) * p^k * (1-p)^(N-k)의 합
각각 정확도 60%인 분류기 21개를 다수결로 묶으면 정확도는 약 74%가 됩니다. 101개로 늘리면 약 84%까지 올라갑니다. 모델들이 서로 다른 실수를 만들 때 오류가 서로 상쇄되기 때문입니다.
핵심 요구사항은 다양성(diversity) 입니다. 모든 모델이 같은 실수를 한다면 결합해도 도움이 되지 않습니다. 앙상블은 다음 방식으로 다양한 모델을 만듭니다.
- 서로 다른 학습 부분집합: 배깅(Bagging)
- 서로 다른 특성 부분집합: 랜덤 포레스트(Random Forest)
- 순차적 오류 수정: 부스팅(Boosting)
- 서로 다른 모델 계열: 스태킹(Stacking)
배깅(Bagging, Bootstrap Aggregating)
배깅은 학습 데이터의 서로 다른 부트스트랩 샘플(Bootstrap Sample)로 각 모델을 학습시켜 다양성을 만듭니다.
flowchart TD
D[학습 데이터] --> B1[부트스트랩 샘플 1]
D --> B2[부트스트랩 샘플 2]
D --> B3[부트스트랩 샘플 3]
D --> BN[부트스트랩 샘플 N]
B1 --> M1[모델 1]
B2 --> M2[모델 2]
B3 --> M3[모델 3]
BN --> MN[모델 N]
M1 --> V[평균 또는 다수결]
M2 --> V
M3 --> V
MN --> V
V --> P[최종 예측]
부트스트랩 샘플은 원본 데이터에서 복원추출로 뽑으며, 크기는 원본과 같습니다. 각 부트스트랩에는 고유 샘플의 약 63.2%가 등장합니다. 남은 36.8%는 OOB(out-of-bag) 샘플이며, 별도 검증 세트처럼 사용할 수 있습니다.
배깅은 편향을 크게 늘리지 않고 분산을 줄입니다. 각 트리는 자기 부트스트랩 샘플에 과대적합되지만, 과대적합의 방향이 트리마다 다르므로 평균을 내면 잡음이 상쇄됩니다.
랜덤 포레스트(Random Forest) 는 배깅에 한 가지를 더합니다. 각 분할(split)에서 전체 특성이 아니라 무작위 특성 부분집합만 고려합니다. 이렇게 하면 트리 사이의 다양성이 더 커집니다. 일반적으로 분류에서는 sqrt(n_features), 회귀에서는 n_features / 3개의 후보 특성을 사용합니다.
부스팅(Boosting, Sequential Error Correction)
부스팅은 모델을 순차적으로 학습합니다. 새 모델은 이전 모델들이 틀린 예시에 더 집중합니다.
flowchart LR
D[가중치가 있는 데이터] --> M1[모델 1]
M1 --> E1[오류 찾기]
E1 --> W1[오류 예시의 가중치 증가]
W1 --> M2[모델 2]
M2 --> E2[오류 찾기]
E2 --> W2[오류 예시의 가중치 증가]
W2 --> M3[모델 3]
M3 --> F[모든 모델의 가중합]
부스팅은 편향을 줄입니다. 새 모델은 지금까지의 앙상블이 가진 체계적 오류를 수정합니다. 최종 예측은 모든 모델의 가중합이며, 성능이 좋은 모델일수록 더 큰 가중치를 받습니다.
단점도 있습니다. 너무 많은 라운드(round)를 돌리면 어려운 예시, 그중 일부는 잡음일 수도 있는 예시에 계속 맞추기 때문에 과대적합될 수 있습니다.
AdaBoost
AdaBoost(Adaptive Boosting)는 처음으로 실용화된 부스팅 알고리즘입니다. 어떤 기본 학습기(base learner)와도 쓸 수 있지만, 보통 결정 그루터기(Decision Stump, 깊이 1 트리)를 사용합니다.
알고리즘은 다음과 같습니다.
1. 샘플 가중치 초기화: 모든 i에 대해 w_i = 1/N
2. t = 1부터 T까지 반복:
a. 가중 데이터로 약한 학습기 h_t 학습
b. 가중 오류 계산:
err_t = sum(w_i * I(h_t(x_i) != y_i)) / sum(w_i)
c. 모델 가중치 계산:
alpha_t = 0.5 * ln((1 - err_t) / err_t)
d. 샘플 가중치 갱신:
w_i = w_i * exp(-alpha_t * y_i * h_t(x_i))
e. 가중치 합이 1이 되도록 정규화
3. 최종 예측: H(x) = sign(sum(alpha_t * h_t(x)))
오류가 낮은 모델은 더 큰 alpha를 받습니다. 잘못 분류된 샘플은 더 높은 가중치를 받아 다음 모델이 그 예시에 집중하게 됩니다.
그래디언트 부스팅(Gradient Boosting)
그래디언트 부스팅은 임의의 손실 함수(loss function)에 부스팅을 일반화합니다. 샘플 가중치를 다시 주는 대신, 현재 앙상블의 잔차(residual), 즉 손실의 음의 그래디언트(negative gradient)에 새 모델을 맞춥니다.
1. 초기화: F_0(x) = argmin_c sum(L(y_i, c))
2. t = 1부터 T까지 반복:
a. 의사 잔차(pseudo-residual) 계산:
r_i = -dL(y_i, F_{t-1}(x_i)) / dF_{t-1}(x_i)
b. 잔차 r_i에 트리 h_t 학습
c. 최적 스텝 크기(step size) 찾기:
gamma_t = argmin_gamma sum(L(y_i, F_{t-1}(x_i) + gamma * h_t(x_i)))
d. 갱신:
F_t(x) = F_{t-1}(x) + learning_rate * gamma_t * h_t(x)
3. 최종 예측: F_T(x)
제곱 오차 손실에서는 의사 잔차가 실제 잔차와 같습니다. r_i = y_i - F_{t-1}(x_i)입니다. 각 트리는 말 그대로 이전 앙상블의 오류를 맞춥니다.
학습률(learning rate, shrinkage)은 각 트리가 얼마나 기여할지 조절합니다. 작은 학습률은 더 많은 트리를 필요로 하지만 일반화가 더 좋아지는 경우가 많습니다. 보통 0.01부터 0.3 사이 값을 씁니다.
XGBoost가 표 형식 데이터를 지배하는 이유
XGBoost(eXtreme Gradient Boosting)는 그래디언트 부스팅에 빠르고 정확하며 과대적합에 강한 엔지니어링 최적화를 더한 것입니다.
- 정규화된 목적 함수(Regularized Objective): 잎 가중치(leaf weight)에 L1, L2 페널티(penalty)를 적용해 개별 트리가 지나치게 확신하지 않도록 합니다.
- 2차 근사(Second-order Approximation): 손실의 1차/2차 도함수를 모두 사용해 더 나은 분할(split) 결정을 합니다.
- 희소성 인식 분할(Sparsity-aware Splits): 결측값을 어느 방향으로 보내는 것이 좋은지 학습해 결측값을 기본 지원합니다.
- 열 부분표본 추출(Column Subsampling): 랜덤 포레스트처럼 분할마다 특성을 샘플링해 다양성을 높입니다.
- 가중 분위수 스케치(Weighted Quantile Sketch): 분산 데이터에서 연속 특성의 분할점(split point)을 효율적으로 찾습니다.
- 캐시 친화적 블록 구조(Cache-aware Block Structure): CPU 캐시 라인(cache line)에 맞춰 메모리 레이아웃을 최적화합니다.
표 형식 데이터에서는 XGBoost와 후속 계열인 LightGBM이 신경망보다 꾸준히 좋은 성능을 내는 경우가 많습니다. 데이터가 행과 열로 표현되는 문제라면 그래디언트 부스팅부터 시작하는 것이 안전합니다.
스태킹은 여러 기본 모델(base model)의 예측값을 메타 학습기(meta-learner)의 입력 특성으로 사용합니다.
flowchart TD
D[학습 데이터] --> M1[모델 1: 랜덤 포레스트]
D --> M2[모델 2: 서포트 벡터 머신]
D --> M3[모델 3: 로지스틱 회귀]
M1 --> P1[예측 1]
M2 --> P2[예측 2]
M3 --> P3[예측 3]
P1 --> META[메타 학습기]
P2 --> META
P3 --> META
META --> F[최종 예측]
메타 학습기는 어떤 입력에서 어떤 기본 모델을 신뢰해야 하는지 학습합니다. 특정 영역에서는 랜덤 포레스트가 더 좋고 다른 영역에서는 서포트 벡터 머신(SVM)이 더 좋다면, 메타 학습기가 그 라우팅을 학습합니다.
데이터 누수(data leakage)를 피하려면 기본 모델의 예측은 반드시 학습 세트에서 교차 검증(cross-validation)으로 생성해야 합니다. 같은 데이터로 기본 모델을 학습하고 곧바로 메타 특성을 만들면 안 됩니다.
투표(Voting)
가장 단순한 앙상블입니다. 예측을 직접 결합합니다.
- 하드 보팅(Hard voting): 클래스 레이블(class label)에 대한 다수결입니다.
- 소프트 보팅(Soft voting): 예측 확률을 평균낸 뒤 평균 확률이 가장 높은 클래스를 선택합니다. 보통 신뢰도(confidence) 정보를 활용하기 때문에 더 좋습니다.
만들어 보기
Step 1: 결정 그루터기(Decision Stump, Base Learner)
code/ensembles.py는 모든 것을 처음부터 구현합니다. 먼저 단일 분할(split)만 가진 트리인 결정 그루터기부터 시작합니다.
class DecisionStump:
def __init__(self):
self.feature_idx = None
self.threshold = None
self.polarity = 1
self.alpha = None
def fit(self, X, y, weights):
n_samples, n_features = X.shape
best_error = float("inf")
for f in range(n_features):
thresholds = np.unique(X[:, f])
for thresh in thresholds:
for polarity in [1, -1]:
pred = np.ones(n_samples)
pred[polarity * X[:, f] < polarity * thresh] = -1
error = np.sum(weights[pred != y])
if error < best_error:
best_error = error
self.feature_idx = f
self.threshold = thresh
self.polarity = polarity
def predict(self, X):
n = X.shape[0]
pred = np.ones(n)
idx = self.polarity * X[:, self.feature_idx] < self.polarity * self.threshold
pred[idx] = -1
return pred
Step 2: AdaBoost 처음부터 구현하기
class AdaBoostScratch:
def __init__(self, n_estimators=50):
self.n_estimators = n_estimators
self.stumps = []
self.alphas = []
def fit(self, X, y):
n = X.shape[0]
weights = np.full(n, 1 / n)
for _ in range(self.n_estimators):
stump = DecisionStump()
stump.fit(X, y, weights)
pred = stump.predict(X)
err = np.sum(weights[pred != y])
err = np.clip(err, 1e-10, 1 - 1e-10)
alpha = 0.5 * np.log((1 - err) / err)
weights *= np.exp(-alpha * y * pred)
weights /= weights.sum()
stump.alpha = alpha
self.stumps.append(stump)
self.alphas.append(alpha)
def predict(self, X):
total = sum(a * s.predict(X) for a, s in zip(self.alphas, self.stumps))
return np.sign(total)
Step 3: 그래디언트 부스팅 처음부터 구현하기
class GradientBoostingScratch:
def __init__(self, n_estimators=100, learning_rate=0.1, max_depth=3):
self.n_estimators = n_estimators
self.lr = learning_rate
self.max_depth = max_depth
self.trees = []
self.initial_pred = None
def fit(self, X, y):
self.initial_pred = np.mean(y)
current_pred = np.full(len(y), self.initial_pred)
for _ in range(self.n_estimators):
residuals = y - current_pred
tree = SimpleRegressionTree(max_depth=self.max_depth)
tree.fit(X, residuals)
update = tree.predict(X)
current_pred += self.lr * update
self.trees.append(tree)
def predict(self, X):
pred = np.full(X.shape[0], self.initial_pred)
for tree in self.trees:
pred += self.lr * tree.predict(X)
return pred
Step 4: sklearn과 비교하기
코드는 처음부터 구현한 결과가 sklearn의 AdaBoostClassifier, GradientBoostingClassifier와 비슷한 정확도를 내는지 확인하고, 모든 방법을 나란히 비교합니다.
사용하기
어떤 방법을 언제 쓸까
| 방법 | 줄이는 항목 | 적합한 경우 | 주의할 점 |
|---|
| 배깅 / 랜덤 포레스트 | 분산 | 잡음이 있는 데이터, 특성이 많은 데이터 | 편향 문제에는 도움이 되지 않음 |
| AdaBoost | 편향 | 깨끗한 데이터, 단순한 기본 학습기 | 이상치와 잡음에 민감함 |
| 그래디언트 부스팅 | 편향 | 표 형식 데이터, 대회 | 학습이 느리고 튜닝 없이 오래 돌리면 과대적합되기 쉬움 |
| XGBoost / LightGBM | 둘 다 | 운영용 표 형식 ML | 하이퍼파라미터가 많음 |
| 스태킹 | 둘 다 | 마지막 1-2% 정확도 개선 | 복잡하고 메타 학습기 과대적합 위험이 있음 |
| 투표 | 분산 | 다양한 모델 2-3개를 빠르게 결합 | 모델들이 다양할 때만 도움됨 |
표 형식 데이터를 위한 운영 스택
대부분의 표 형식 예측 문제에서는 다음 순서로 시도합니다.
- 기본 파라미터의 LightGBM 또는 XGBoost
n_estimators, learning_rate, max_depth, min_child_weight 튜닝
- 마지막 0.5%가 필요하면 서로 다른 모델 3-5개로 스태킹 앙상블 구성
- 전체 과정에서 교차 검증(cross-validation) 사용
표 형식 데이터에서 신경망은 계속 연구되고 있지만, 잘 튜닝한 그래디언트 부스팅보다 약한 경우가 많습니다. TabNet, NODE 같은 구조가 가끔 비슷한 성능을 내지만, 잘 튜닝한 XGBoost를 자주 이기지는 못합니다.
산출물 만들기
이 lesson의 최종 산출물은 outputs/prompt-ensemble-selector.md와 outputs/skill-ensemble-builder.md입니다.
prompt-ensemble-selector.md에는 데이터 크기, 특성 유형, 잡음 수준, 클래스 균형(class balance), 해결하려는 문제를 넣어 적절한 앙상블 방법을 추천받습니다.
skill-ensemble-builder.md에는 앙상블 방법을 고르고 초기 설정을 정할 때 참고할 수 있는 선택 가이드가 들어 있습니다.
- 산출물은 방법 추천, 시작 하이퍼파라미터, 흔한 실수 경고가 서로 일관되는지 검수할 수 있어야 합니다.
연습문제
-
AdaBoost 구현을 수정해 각 라운드(round) 이후의 학습 정확도를 기록해봅니다. 추정기(estimator) 수에 따른 정확도를 그립니다. 언제 수렴하나요?
-
회귀 트리에 무작위 특성 부분추출(random feature subsampling)을 추가해 랜덤 포레스트를 처음부터 구현합니다. max_features=sqrt(n_features)로 트리 100개를 학습하고 예측을 평균냅니다. 단일 트리와 비교해 분산 감소를 확인합니다.
-
그래디언트 부스팅 구현에 조기 종료(early stopping)를 추가합니다. 각 라운드 이후 검증 손실을 추적하고, 10번 연속 개선되지 않으면 멈춥니다. 실제로 몇 개의 트리가 필요했나요?
-
로지스틱 회귀(Logistic Regression), 결정 트리(Decision Tree), K-최근접 이웃(K-Nearest Neighbors) 세 기본 모델과 로지스틱 회귀 메타 학습기로 스태킹 앙상블을 만듭니다. 5-겹 교차 검증(5-fold cross-validation)으로 메타 특성(meta-feature)을 만들고 각 기본 모델 단독 성능과 비교합니다.
-
같은 데이터셋에서 기본 파라미터로 XGBoost를 실행합니다. 처음부터 구현한 그래디언트 부스팅과 정확도, 실행 시간을 비교합니다. 속도 차이는 얼마나 큰가요?
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 배깅(Bagging) | "무작위 부분집합으로 학습" | 부트스트랩 샘플로 모델을 학습하고 예측을 평균내 분산을 줄이는 부트스트랩 집계(bootstrap aggregating) |
| 부스팅(Boosting) | "어려운 예시에 집중" | 모델을 순차적으로 학습해 지금까지의 앙상블 오류를 고치고 편향을 줄이는 방법 |
| AdaBoost | "데이터 가중치 조정" | 잘못 분류된 샘플의 가중치를 높여 다음 학습기가 집중하게 하는 부스팅 |
| 그래디언트 부스팅(Gradient Boosting) | "잔차를 맞춤" | 새 모델을 손실 함수의 음의 그래디언트에 맞추는 부스팅 |
| XGBoost | "Kaggle 무기" | 정규화, 2차 최적화, 시스템 수준 속도 개선을 더한 그래디언트 부스팅 |
| 스태킹(Stacking) | "모델 위의 모델" | 기본 모델의 예측을 메타 학습기의 입력 특성으로 사용하는 방법 |
| 랜덤 포레스트(Random Forest) | "무작위 트리 여러 개" | 결정 트리 배깅에 분할(split)별 무작위 특성 부분추출(random feature subsampling)을 더한 앙상블 |
| 앙상블 다양성(Ensemble Diversity) | "서로 다른 실수 만들기" | 개별 모델보다 앙상블이 좋아지려면 모델 오류의 상관이 낮아야 한다는 조건 |
| OOB 오류(Out-of-bag Error) | "공짜 검증" | 부트스트랩에 뽑히지 않은 샘플 약 36.8%를 검증 세트처럼 쓰는 방식 |
더 읽을거리