Monday, July 27, 2026

Out-of-Bag Error in Random Forests: How OOB Validation Works

Out-of-Bag Error in Random Forests: How OOB Validation Works

Random Forests contain a useful validation mechanism that is created automatically during bootstrap training. It is called out-of-bag validation, or simply OOB validation. In this AFAP tutorial, we will explain the complete idea, calculate the OOB error, inspect sample-level OOB probabilities, compare OOB performance with an untouched test set, and generate two figures in Python.

Out-of-bag validation in a Random Forest. Each tree is trained on a bootstrap sample, while the observations excluded from that sample are used for an internal validation of the tree.
AFAP summary: each tree is trained on a bootstrap sample of the training data. The observations that were not selected for that tree form its OOB subset. Every training observation is predicted only by trees that did not use it for training. The aggregated correctness of those predictions gives the OOB score, while 1 - OOB score gives the OOB error.

What Is Out-of-Bag Error?

A Random Forest is an ensemble of decision trees. In the standard bootstrap version of the algorithm, every tree is not trained on the original training dataset directly. Instead, a new training sample is constructed by randomly drawing observations from the original training set with replacement. This procedure is called bootstrap sampling.

Because sampling is performed with replacement, the same observation can be selected more than once, while some observations are not selected at all. For a particular tree, the observations that were not selected are called its out-of-bag observations.

These excluded observations can be passed through that tree after it has been trained. Since the tree did not see them during fitting, its predictions for those observations behave like internal validation predictions. The procedure is repeated over all trees, and every training observation is evaluated by the subset of trees for which it was out of bag.

OOB error = 1 − OOB accuracy

For classification, the default OOB score in scikit-learn is classification accuracy. Therefore, an OOB score of 0.9624 corresponds to an OOB error of approximately 0.0376, or 3.76%.

Why Does a Bootstrap Sample Leave About 36.8% of the Data Out?

Suppose that the training dataset contains n observations and that one bootstrap sample also contains n draws. During one draw, the probability that a particular observation is not selected is:

P(not selected in one draw) = 1 − 1/n

The bootstrap process performs n independent draws. The probability that the observation is never selected is consequently:

P(OOB) = (1 − 1/n)n ≈ e−1 ≈ 0.368

Therefore, approximately 36.8% of the observations are out of bag for an individual tree, while approximately 63.2% appear at least once in its bootstrap sample. The exact percentage varies from tree to tree because the sampling process is random.

A bootstrap sample has the same number of draws as the training dataset, but duplicate selections cause some original observations to remain outside the sample.

How OOB Validation Works Step by Step

Step 1: Construct a bootstrap sample for each tree

Assume that the training dataset contains 1,000 observations. To build the first tree, the algorithm performs 1,000 random draws with replacement. Some observations appear several times, while roughly 368 observations are not selected.

Step 2: Train the tree only on its bootstrap sample

The decision tree learns its splitting rules from the selected bootstrap observations. The corresponding OOB observations do not participate in the construction of that tree.

Step 3: Predict the OOB observations

After training, the excluded observations are passed through the tree. These are valid internal validation predictions for that particular tree because those observations were not used to fit it.

Step 4: Repeat the procedure for all trees

Every tree receives a different bootstrap sample and therefore has a different OOB subset. An observation excluded from one tree may be included in another tree. With a sufficiently large number of trees, every observation normally receives many OOB predictions.

Step 5: Aggregate only the valid OOB votes

For a given training observation, the forest ignores trees that used that observation during training. Classification probabilities and the predicted class are calculated only from trees for which the observation was out of bag.

Step 6: Compare aggregated predictions with true targets

The aggregated OOB predictions are compared with the original target values. Their average correctness produces the OOB score. The corresponding error is calculated by subtracting the score from one.

An OOB prediction for one observation is produced only from trees that excluded that observation from their bootstrap samples.
Important distinction: there is no single universal OOB dataset. Every tree has its own OOB subset, and every observation is validated by a different subset of trees.

OOB Validation in scikit-learn

The RandomForestClassifier performs OOB estimation when the following two conditions are satisfied:

bootstrap=True
oob_score=True

After fitting, the main OOB attributes are:

Attribute Meaning
forest.oob_score_ The overall OOB score. For a classifier, the default score is accuracy.
forest.oob_decision_function_ OOB class probabilities for each training observation.
forest.estimators_samples_ The training-sample indices drawn for each individual tree.

The matrix oob_decision_function_ has one row for every training observation and one column for every class. In a binary classification problem, a row such as [0.15, 0.85] means that the OOB trees assigned an estimated probability of 0.15 to class 0 and 0.85 to class 1.

Complete Python Example

The example uses the Breast Cancer Wisconsin dataset included with scikit-learn. No external CSV file is required. The script creates an independent 25% test set, calculates the OOB error trajectory for different forest sizes, trains a final forest with 300 trees, calculates additional OOB metrics, compares them with test-set metrics, and saves all results in the same folder as the Python script.

Install the required packages

Open Anaconda Prompt or a terminal and run:

pip install numpy pandas matplotlib scikit-learn

How to run the example in Spyder

  1. Create a new Python file in Spyder.
  2. Save it as oob_random_forest_afap.py.
  3. Copy the complete script below into the file.
  4. Press F5 or select Run → Run.
  5. Read the printed metrics in the IPython console.
  6. Open the generated PNG and CSV files from the same folder as the script.
The script uses Path(__file__).resolve().parent, so the output files are saved beside the Python script even when Spyder uses a different working directory.
"""
Out-of-Bag Error in Random Forests — AFAP example

The script:
1. loads the Breast Cancer Wisconsin dataset;
2. creates an external train/test split;
3. tracks OOB error for different forest sizes;
4. trains a final RandomForestClassifier with OOB estimation;
5. computes OOB and test metrics;
6. saves two publication-ready figures.
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (
    ConfusionMatrixDisplay,
    accuracy_score,
    balanced_accuracy_score,
    classification_report,
    f1_score,
    roc_auc_score,
)
from sklearn.model_selection import train_test_split


# -----------------------------------------------------------------------------
# 1. Reproducibility and figure settings
# -----------------------------------------------------------------------------
RANDOM_STATE = 42
OUTPUT_DIRECTORY = (
    Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
)

plt.rcParams["font.family"] = "Times New Roman"
plt.rcParams["font.size"] = 12
plt.rcParams["axes.titlesize"] = 15
plt.rcParams["axes.labelsize"] = 13
plt.rcParams["legend.fontsize"] = 10


# -----------------------------------------------------------------------------
# 2. Load the dataset
# -----------------------------------------------------------------------------
dataset = load_breast_cancer(as_frame=True)
X = dataset.data
Y = dataset.target

print("=" * 79)
print("DATASET")
print("=" * 79)
print(f"Samples:  {X.shape[0]}")
print(f"Features: {X.shape[1]}")
print("Classes:")
print(Y.value_counts().sort_index())


# -----------------------------------------------------------------------------
# 3. Create an external test set
# -----------------------------------------------------------------------------
# OOB estimation is computed only from X_train and y_train. The test set remains
# completely untouched and is used later for a final independent comparison.
X_train, X_test, y_train, y_test = train_test_split(
    X,
    Y,
    test_size=0.25,
    stratify=Y,
    random_state=RANDOM_STATE,
)

print("\n" + "=" * 79)
print("DATA SPLIT")
print("=" * 79)
print(f"Training samples: {X_train.shape[0]}")
print(f"Test samples:     {X_test.shape[0]}")


# -----------------------------------------------------------------------------
# 4. Measure how OOB error changes with the number of trees
# -----------------------------------------------------------------------------
number_of_trees = [25, 50, 75, 100, 150, 200, 300]
oob_errors = []

for n_estimators in number_of_trees:
    temporary_forest = RandomForestClassifier(
        n_estimators=n_estimators,
        bootstrap=True,
        oob_score=True,
        max_features="sqrt",
        random_state=RANDOM_STATE,
        n_jobs=-1,
    )

    temporary_forest.fit(X_train, y_train)
    current_oob_error = 1.0 - temporary_forest.oob_score_
    oob_errors.append(current_oob_error)

    print(
        f"Trees: {n_estimators:3d} | "
        f"OOB accuracy: {temporary_forest.oob_score_:.6f} | "
        f"OOB error: {current_oob_error:.6f}"
    )

# Store the trajectory in a CSV file for later reuse.
oob_results = pd.DataFrame(
    {
        "n_estimators": number_of_trees,
        "oob_accuracy": [1.0 - value for value in oob_errors],
        "oob_error": oob_errors,
    }
)
oob_results.to_csv(OUTPUT_DIRECTORY / "oob_error_results.csv", index=False)

# Plot and save the OOB error curve.
plt.figure(figsize=(10, 6))
plt.plot(number_of_trees, oob_errors, marker="o", linewidth=1.5)
plt.xlabel("Number of trees")
plt.ylabel("OOB error")
plt.title("Out-of-Bag Error as the Random Forest Grows")
plt.grid(True, linestyle="--", linewidth=0.5, alpha=0.7)
plt.tight_layout()
plt.savefig(
    OUTPUT_DIRECTORY / "oob_error_curve.png",
    dpi=300,
    bbox_inches="tight",
)
plt.show()


# -----------------------------------------------------------------------------
# 5. Train the final random forest
# -----------------------------------------------------------------------------
forest = RandomForestClassifier(
    n_estimators=300,
    bootstrap=True,
    oob_score=True,
    max_features="sqrt",
    random_state=RANDOM_STATE,
    n_jobs=-1,
)
forest.fit(X_train, y_train)


# -----------------------------------------------------------------------------
# 6. Obtain sample-level OOB probabilities and OOB predictions
# -----------------------------------------------------------------------------
oob_probabilities = forest.oob_decision_function_

# With a sufficiently large forest, every training sample normally receives OOB
# votes. The validity mask also makes the script safe for unusually small forests.
valid_oob_mask = (
    np.isfinite(oob_probabilities).all(axis=1)
    & (oob_probabilities.sum(axis=1) > 0.0)
)

if not valid_oob_mask.any():
    raise RuntimeError(
        "No valid OOB predictions were produced. Increase n_estimators."
    )

y_train_array = y_train.to_numpy()
y_oob_true = y_train_array[valid_oob_mask]
oob_probabilities_valid = oob_probabilities[valid_oob_mask]
y_oob_pred = forest.classes_[
    np.argmax(oob_probabilities_valid, axis=1)
]

# The Breast Cancer dataset uses class 1 as the positive class.
positive_class_positions = np.where(forest.classes_ == 1)[0]
if positive_class_positions.size != 1:
    raise RuntimeError("The positive class 1 was not found in forest.classes_.")
positive_class_index = int(positive_class_positions[0])
y_oob_positive_probability = oob_probabilities_valid[:, positive_class_index]


# -----------------------------------------------------------------------------
# 7. Calculate OOB metrics
# -----------------------------------------------------------------------------
oob_accuracy = accuracy_score(y_oob_true, y_oob_pred)
oob_balanced_accuracy = balanced_accuracy_score(y_oob_true, y_oob_pred)
oob_f1 = f1_score(y_oob_true, y_oob_pred)
oob_roc_auc = roc_auc_score(y_oob_true, y_oob_positive_probability)
oob_error = 1.0 - oob_accuracy

print("\n" + "=" * 79)
print("OUT-OF-BAG RESULTS")
print("=" * 79)
print(f"Valid OOB samples:     {valid_oob_mask.sum()} / {len(valid_oob_mask)}")
print(f"forest.oob_score_:     {forest.oob_score_:.6f}")
print(f"OOB accuracy:          {oob_accuracy:.6f}")
print(f"OOB error:             {oob_error:.6f}")
print(f"OOB balanced accuracy: {oob_balanced_accuracy:.6f}")
print(f"OOB F1-score:          {oob_f1:.6f}")
print(f"OOB ROC-AUC:           {oob_roc_auc:.6f}")
print("\nOOB classification report:")
print(classification_report(y_oob_true, y_oob_pred, digits=4))


# -----------------------------------------------------------------------------
# 8. Plot the OOB confusion matrix
# -----------------------------------------------------------------------------
display = ConfusionMatrixDisplay.from_predictions(
    y_oob_true,
    y_oob_pred,
    display_labels=dataset.target_names,
    cmap="Greys",
    colorbar=False,
    values_format="d",
)
display.ax_.set_title("Confusion Matrix Based on OOB Predictions")
plt.tight_layout()
plt.savefig(
    OUTPUT_DIRECTORY / "oob_confusion_matrix.png",
    dpi=300,
    bbox_inches="tight",
)
plt.show()


# -----------------------------------------------------------------------------
# 9. Evaluate the same final model on the untouched test set
# -----------------------------------------------------------------------------
y_test_pred = forest.predict(X_test)
y_test_probabilities = forest.predict_proba(X_test)[:, positive_class_index]

test_accuracy = accuracy_score(y_test, y_test_pred)
test_balanced_accuracy = balanced_accuracy_score(y_test, y_test_pred)
test_f1 = f1_score(y_test, y_test_pred)
test_roc_auc = roc_auc_score(y_test, y_test_probabilities)

print("\n" + "=" * 79)
print("INDEPENDENT TEST RESULTS")
print("=" * 79)
print(f"Test accuracy:          {test_accuracy:.6f}")
print(f"Test error:             {1.0 - test_accuracy:.6f}")
print(f"Test balanced accuracy: {test_balanced_accuracy:.6f}")
print(f"Test F1-score:          {test_f1:.6f}")
print(f"Test ROC-AUC:           {test_roc_auc:.6f}")


# -----------------------------------------------------------------------------
# 10. Save the main results
# -----------------------------------------------------------------------------
summary = pd.DataFrame(
    [
        {
            "evaluation": "OOB",
            "accuracy": oob_accuracy,
            "error": oob_error,
            "balanced_accuracy": oob_balanced_accuracy,
            "f1_score": oob_f1,
            "roc_auc": oob_roc_auc,
        },
        {
            "evaluation": "Independent test",
            "accuracy": test_accuracy,
            "error": 1.0 - test_accuracy,
            "balanced_accuracy": test_balanced_accuracy,
            "f1_score": test_f1,
            "roc_auc": test_roc_auc,
        },
    ]
)
summary.to_csv(OUTPUT_DIRECTORY / "oob_and_test_metrics.csv", index=False)

print("\n" + "=" * 79)
print("FILES CREATED")
print("=" * 79)
for filename in (
    "oob_error_curve.png",
    "oob_confusion_matrix.png",
    "oob_error_results.csv",
    "oob_and_test_metrics.csv",
):
    print(OUTPUT_DIRECTORY / filename)

Step-by-Step Explanation of the Python Code

1. Define reproducibility and output settings

RANDOM_STATE = 42 ensures that the train/test split and bootstrap samples are reproducible. The output directory is derived from the location of the script so that the figures and CSV files are easy to find.

2. Load the dataset

dataset = load_breast_cancer(as_frame=True)
X = dataset.data
Y = dataset.target

The dataset contains 569 samples and 30 numerical input features. The target has two classes. The as_frame=True argument returns pandas objects, which makes the data easier to inspect.

3. Create an external test set

X_train, X_test, y_train, y_test = train_test_split(
    X,
    Y,
    test_size=0.25,
    stratify=Y,
    random_state=RANDOM_STATE,
)

OOB validation can estimate model performance without creating a validation subset from the training data. Nevertheless, a separate test set remains useful for a final independent evaluation. The stratify=Y argument preserves the class proportions in both subsets.

4. Track OOB error as the forest grows

current_oob_error = 1.0 - temporary_forest.oob_score_

Separate forests are trained with 25, 50, 75, 100, 150, 200, and 300 trees. The resulting trajectory shows whether the OOB error is still changing or has reached a stable region. This is one practical way to determine whether adding more trees is likely to produce a meaningful improvement.

OOB error as a function of the number of trees. A plateau indicates that additional trees are no longer changing the OOB estimate substantially.

5. Train the final forest

forest = RandomForestClassifier(
    n_estimators=300,
    bootstrap=True,
    oob_score=True,
    max_features="sqrt",
    random_state=RANDOM_STATE,
    n_jobs=-1,
)

bootstrap=True activates bootstrap sampling, while oob_score=True requests OOB estimation. The max_features="sqrt" setting allows each split to consider the square root of the total number of features. Finally, n_jobs=-1 allows scikit-learn to use all available processor cores during fitting.

6. Extract OOB probabilities

oob_probabilities = forest.oob_decision_function_

This matrix contains the aggregated class probabilities obtained only from trees for which each observation was out of bag. It is more useful than the single oob_score_ value because it allows us to calculate balanced accuracy, F1-score, ROC-AUC, a confusion matrix, and other metrics.

7. Filter invalid OOB rows

With very few trees, it is possible that an observation is never left out and therefore receives no OOB prediction. The script uses a validity mask to remove non-finite or empty probability rows. With 300 trees in this example, all 426 training observations receive valid OOB probabilities.

8. Convert OOB probabilities into classes

y_oob_pred = forest.classes_[
    np.argmax(oob_probabilities_valid, axis=1)
]

np.argmax selects the class with the largest OOB probability for each observation. The corresponding class labels are recovered from forest.classes_.

9. Calculate additional OOB metrics

The script calculates OOB accuracy, error, balanced accuracy, F1-score, and ROC-AUC. This is important because accuracy alone can be misleading when the target classes are strongly imbalanced.

10. Plot the OOB confusion matrix

Confusion matrix calculated from sample-level OOB predictions, not from predictions made on the training observations by the complete fitted forest.

11. Compare OOB and independent test performance

The same final forest is evaluated on the untouched test set. Similar OOB and test results indicate that the OOB estimate is behaving reasonably for this example. A large gap would suggest that the OOB estimate may not represent the external test conditions sufficiently well.

Expected Results and Their Interpretation

Using scikit-learn 1.8.0 and the specified random state, the example produced the following values. Minor numerical differences can occur with another library version or execution environment.

Metric OOB estimate Independent test
Accuracy 0.9624 0.9580
Error 0.0376 0.0420
Balanced accuracy 0.9586 0.9512
F1-score 0.9701 0.9670
ROC-AUC 0.9849 0.9949

The final OOB accuracy is approximately 96.24%, which corresponds to an OOB error of approximately 3.76%. The independent test accuracy is approximately 95.80%. The small difference between these two accuracy values suggests that OOB validation provides a useful internal estimate for this particular dataset and model configuration.

The OOB error decreases from approximately 4.93% with 25 trees to approximately 3.76% with 100 trees. It then remains stable through 300 trees. This indicates that, for this example, increasing the forest beyond approximately 100–150 trees does not substantially change the OOB error estimate.

These values should not be interpreted as universal Random Forest performance. They belong only to this dataset, split, random state, and hyperparameter configuration.

OOB Validation Versus a Test Split and Cross-Validation

Method Main advantage Main limitation
OOB validation Uses training observations efficiently and is obtained during bootstrap forest fitting. Available only for bootstrap-based estimators and may be noisy with too few trees.
Hold-out validation Simple and computationally inexpensive. Performance depends on one particular split, and part of the data is removed from training.
k-fold cross-validation Provides performance estimates over several data partitions. Requires fitting the complete forest several times.
Independent test set Provides the final evaluation on completely untouched data. Must not be repeatedly used for hyperparameter selection.
OOB validation is generated from bootstrap exclusions, whereas hold-out and k-fold validation explicitly partition the available observations.

OOB validation is particularly attractive when the dataset is not large enough to sacrifice a substantial validation subset. However, it should not automatically replace an independent test set. A strong experimental design can use OOB estimates for efficient model development and preserve a completely untouched test set for the final report.

Limitations and Common Mistakes

OOB estimation requires bootstrap sampling

The OOB mechanism depends on observations being excluded from bootstrap samples. Therefore, oob_score=True must be used together with bootstrap=True.

Too few trees can produce unstable OOB estimates

A small forest may provide only a few OOB votes per observation. In extreme cases, some observations may receive no OOB predictions. Increasing n_estimators usually stabilizes the estimate.

OOB accuracy is not enough for imbalanced data

When one class dominates, a high OOB accuracy may hide poor minority-class detection. Use oob_decision_function_ to calculate balanced accuracy, sensitivity, specificity, F1-score, MCC, PR-AUC, or other task-appropriate metrics.

OOB validation does not automatically prevent every form of leakage

OOB sampling is performed at the observation level. If several rows belong to the same patient, device, customer, machine, or time period, related rows may appear in both a tree's bootstrap sample and its OOB subset. Grouped or temporal evaluation may therefore be more appropriate for structured data.

Do not repeatedly tune against one untouched test set

The test set in this tutorial is used only to demonstrate the relationship between OOB and external performance. In a real project, preserve the test data until model development and hyperparameter selection are complete.

OOB validation is not identical to k-fold cross-validation

OOB subsets overlap and have variable sizes. k-fold cross-validation uses explicit, non-overlapping validation folds within each repetition. The two methods may therefore produce different estimates, particularly on small or heterogeneous datasets.

OOB Estimation for Random Forest Regression

The same principle applies to RandomForestRegressor. Every numerical target is predicted by trees that did not use the corresponding observation during training. In scikit-learn, the default OOB score for the regressor is the coefficient of determination, R2, rather than accuracy.

from sklearn.ensemble import RandomForestRegressor

regressor = RandomForestRegressor(
    n_estimators=300,
    bootstrap=True,
    oob_score=True,
    random_state=42,
    n_jobs=-1,
)

regressor.fit(X_train, y_train)
print(regressor.oob_score_)
print(regressor.oob_prediction_)

The oob_prediction_ attribute contains one OOB regression prediction for each training observation. These values can be used to calculate MAE, MSE, RMSE, R2, or another regression metric.

Frequently Asked Questions

Is OOB error the same as training error?

No. Training error is often calculated using trees that were fitted with the same observations being predicted. OOB error excludes those trees and uses only trees that did not train on a particular observation.

Does every tree have the same OOB observations?

No. Every tree receives a different random bootstrap sample and therefore has a different OOB subset.

Can I use OOB validation for hyperparameter tuning?

Yes, it can provide an efficient internal criterion for comparing Random Forest configurations. However, repeatedly optimizing many configurations against the same OOB mechanism can still introduce selection bias. Final performance should be confirmed on untouched data or through a carefully designed external validation procedure.

How many trees are enough?

There is no universal number. Plot the OOB error against n_estimators and identify the region where the curve stabilizes. The required number depends on dataset size, noise, class structure, tree settings, and random variation.

Should I scale the features?

Decision-tree splits are generally not sensitive to monotonic feature scaling. Therefore, scaling is usually not required for Random Forests, although preprocessing may still be necessary for missing values, categorical variables, leakage prevention, and pipeline consistency.

Conclusion

Out-of-bag validation is one of the most useful properties of bootstrap-based Random Forests. Each tree creates its own internal validation subset simply by excluding observations during bootstrap sampling. The forest then aggregates predictions only from trees that did not use a particular observation during training.

In scikit-learn, the procedure is activated by setting bootstrap=True and oob_score=True. The overall result is available through oob_score_, while oob_decision_function_ provides sample-level class probabilities for more detailed analysis. These probabilities make it possible to calculate balanced accuracy, F1-score, ROC-AUC, confusion matrices, and other metrics without creating an additional validation split.

OOB validation is efficient, practical, and easy to calculate, but it is not a universal replacement for a well-designed test set, grouped validation, temporal validation, or cross-validation. It should be treated as an internal performance estimate whose usefulness depends on the structure of the data and the purpose of the experiment.

Files Created by the Example

  • oob_error_curve.png — OOB error versus the number of trees.
  • oob_confusion_matrix.png — confusion matrix from OOB predictions.
  • oob_error_results.csv — OOB trajectory for all tested forest sizes.
  • oob_and_test_metrics.csv — OOB and independent test metrics.

References

  1. Breiman, L. Random Forests. Machine Learning, 45, 5–32, 2001.
  2. Hastie, T., Tibshirani, R., and Friedman, J. The Elements of Statistical Learning, 2nd edition. Springer, 2009.
  3. scikit-learn developers. RandomForestClassifier API documentation and OOB Errors for Random Forests example.

No comments:

Post a Comment