Skip to main content
Machine Learning advanced Lesson 8 of 11

Ensemble Methods

Combine multiple models to outperform any individual model — voting, bagging, boosting, and stacking ensembles.

Real-World Scenario

A Kaggle competition winner explains their solution: individual models score 0.89 AUC each. Stacking 4 diverse models (gradient boosting, random forest, logistic regression, neural network) with a logistic regression meta-learner scores 0.924 AUC — a 3.4 percentage point improvement. The ensemble works because the models make different types of errors on different parts of the feature space.

Voting Ensemble

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.ensemble import (
    VotingClassifier, RandomForestClassifier, GradientBoostingClassifier
)
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score
import numpy as np

X, y = make_classification(n_samples=5000, n_features=20, n_informative=12,
                            n_redundant=4, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Individual models (pre-scaled for LR and SVM)
lr  = Pipeline([("scaler", StandardScaler()), ("clf", LogisticRegression(C=1.0, max_iter=1000))])
svm = Pipeline([("scaler", StandardScaler()), ("clf", SVC(probability=True, C=1.0))])
rf  = RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)
gb  = GradientBoostingClassifier(n_estimators=200, learning_rate=0.1, max_depth=4, random_state=42)

# Soft voting: averages predicted probabilities (better than hard voting for AUC)
soft_voting = VotingClassifier(
    estimators=[("lr", lr), ("svm", svm), ("rf", rf), ("gb", gb)],
    voting="soft",
    n_jobs=-1,
)

# Compare individual models vs ensemble
models = {"LR": lr, "SVM": svm, "RF": rf, "GB": gb, "Ensemble": soft_voting}
print(f"{'Model':<12} {'CV AUC':>10}")
print("-" * 24)
for name, model in models.items():
    scores = cross_val_score(model, X_train, y_train, cv=5, scoring="roc_auc", n_jobs=-1)
    print(f"{name:<12} {scores.mean():>10.4f} ± {scores.std():.4f}")

# Test set evaluation
soft_voting.fit(X_train, y_train)
test_auc = roc_auc_score(y_test, soft_voting.predict_proba(X_test)[:, 1])
print(f"\nTest AUC (soft voting): {test_auc:.4f}")

Stacking

from sklearn.datasets import make_classification
from sklearn.model_selection import (
    train_test_split, StratifiedKFold, cross_val_predict, cross_val_score
)
from sklearn.ensemble import (
    RandomForestClassifier, GradientBoostingClassifier, StackingClassifier
)
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score
import numpy as np

X, y = make_classification(n_samples=5000, n_features=20, n_informative=12, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Level 0: diverse base models
base_models = [
    ("rf",  RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)),
    ("gb",  GradientBoostingClassifier(n_estimators=200, learning_rate=0.1, random_state=42)),
    ("lr",  Pipeline([
        ("scaler", StandardScaler()),
        ("clf", LogisticRegression(C=1.0, max_iter=1000)),
    ])),
]

# Level 1: meta-learner trained on out-of-fold predictions
meta_learner = LogisticRegression(C=0.1)

stacking = StackingClassifier(
    estimators=base_models,
    final_estimator=meta_learner,
    cv=5,                      # inner CV to generate OOF predictions for meta-learner
    stack_method="predict_proba",
    passthrough=False,         # True: also pass original features to meta-learner
    n_jobs=-1,
)

cv_scores = cross_val_score(stacking, X_train, y_train, cv=5, scoring="roc_auc", n_jobs=1)
print(f"Stacking CV AUC: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}")

stacking.fit(X_train, y_train)
test_auc = roc_auc_score(y_test, stacking.predict_proba(X_test)[:, 1])
print(f"Stacking Test AUC: {test_auc:.4f}")

Manual Stacking (OOF Approach)

import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold, train_test_split, cross_val_score
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
from sklearn.metrics import roc_auc_score

X, y = make_classification(n_samples=5000, n_features=20, n_informative=12, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

BASE_MODELS = {
    "rf":  RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1),
    "gb":  GradientBoostingClassifier(n_estimators=200, learning_rate=0.1, random_state=42),
    "mlp": MLPClassifier(hidden_layer_sizes=(128, 64), max_iter=200, random_state=42),
    "lr":  LogisticRegression(C=1.0, max_iter=1000),
}

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# Step 1: Generate out-of-fold (OOF) predictions for training the meta-learner
oof_preds_train = np.zeros((len(X_train), len(BASE_MODELS)))
test_preds      = np.zeros((len(X_test),  len(BASE_MODELS)))

for i, (name, model) in enumerate(BASE_MODELS.items()):
    oof_fold = np.zeros(len(X_train))
    fold_test_preds = np.zeros((len(X_test), 5))

    for fold, (tr_idx, val_idx) in enumerate(cv.split(X_train, y_train)):
        X_tr, X_val = X_train[tr_idx], X_train[val_idx]
        y_tr, y_val = y_train[tr_idx], y_train[val_idx]

        model.fit(X_tr, y_tr)
        oof_fold[val_idx]        = model.predict_proba(X_val)[:, 1]
        fold_test_preds[:, fold] = model.predict_proba(X_test)[:, 1]

    oof_preds_train[:, i] = oof_fold
    test_preds[:, i]      = fold_test_preds.mean(axis=1)  # average across folds
    oof_auc = roc_auc_score(y_train, oof_fold)
    print(f"{name:5s}: OOF AUC = {oof_auc:.4f}")

# Step 2: Train meta-learner on OOF predictions
meta = LogisticRegression(C=0.1, max_iter=1000)
meta.fit(oof_preds_train, y_train)

# Step 3: Predict on test set using averaged base model test predictions
meta_test_probs = meta.predict_proba(test_preds)[:, 1]
test_auc = roc_auc_score(y_test, meta_test_probs)
print(f"\nMeta-learner Test AUC: {test_auc:.4f}")
print(f"Meta weights: {dict(zip(BASE_MODELS.keys(), meta.coef_[0].round(3)))}")

Blending (Weighted Average)

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from scipy.optimize import minimize

X, y = make_classification(n_samples=5000, n_features=20, random_state=42)
X_train, X_val, X_test = np.split(X, [3000, 4000])
y_train, y_val, y_test = np.split(y, [3000, 4000])

# Train base models
models = {
    "rf": RandomForestClassifier(200, random_state=42).fit(X_train, y_train),
    "gb": GradientBoostingClassifier(200, random_state=42).fit(X_train, y_train),
    "lr": LogisticRegression(max_iter=1000).fit(X_train, y_train),
}

# Get predictions on validation set to find optimal weights
val_preds = np.column_stack([m.predict_proba(X_val)[:, 1] for m in models.values()])
test_preds = np.column_stack([m.predict_proba(X_test)[:, 1] for m in models.values()])

def neg_auc(weights: np.ndarray) -> float:
    weights = np.abs(weights) / np.abs(weights).sum()  # normalize
    blended = val_preds @ weights
    return -roc_auc_score(y_val, blended)

# Optimize blend weights
result = minimize(neg_auc, x0=np.ones(len(models)) / len(models), method="Nelder-Mead")
optimal_weights = np.abs(result.x) / np.abs(result.x).sum()

print("Optimal blend weights:")
for name, w in zip(models.keys(), optimal_weights):
    print(f"  {name}: {w:.3f}")

# Evaluate on test set
blended_test = test_preds @ optimal_weights
test_auc = roc_auc_score(y_test, blended_test)
print(f"\nBlended Test AUC: {test_auc:.4f}")

# Compare with equal-weight blend
equal_blend_auc = roc_auc_score(y_test, test_preds.mean(axis=1))
print(f"Equal-weight AUC: {equal_blend_auc:.4f}")

Frequently Asked Questions

What is the difference between bagging, boosting, and stacking?
Bagging (bootstrap aggregating) trains models independently on random subsets of data and averages predictions — reduces variance. Boosting trains models sequentially, each correcting the errors of the previous — reduces bias. Stacking trains a meta-model to learn how to combine base model predictions — can combine both variance and bias reduction by mixing diverse model types.
When does an ensemble actually help?
Ensembles help most when base models make different errors — they're diverse. Diversity comes from different algorithms, different hyperparameters, or different training subsets. Stacking 5 identical gradient boosting models with the same features rarely beats a single well-tuned one. A random forest (tree+bagging) ensemble works because each tree sees different features and data.