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.
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.
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:
The bootstrap process performs n independent draws. The probability that the observation is never selected is consequently:
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.
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.
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
- Create a new Python file in Spyder.
- Save it as
oob_random_forest_afap.py. - Copy the complete script below into the file.
- Press F5 or select Run → Run.
- Read the printed metrics in the IPython console.
- Open the generated PNG and CSV files from the same folder as the script.
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.
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
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.
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 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
- Breiman, L. Random Forests. Machine Learning, 45, 5–32, 2001.
- Hastie, T., Tibshirani, R., and Friedman, J. The Elements of Statistical Learning, 2nd edition. Springer, 2009.
- scikit-learn developers. RandomForestClassifier API documentation and OOB Errors for Random Forests example.






No comments:
Post a Comment