Balanced Random Forest
| Task | Classification (imbalanced classes) |
Method key (analysis_type) | brf |
| Button in the tools panel | Balanced RF |
| Prediction on new data | Yes — see Prediction |
| Library | imblearn.ensemble.BalancedRandomForestClassifier |
When to use
The classes are imbalanced and you want a random forest. It also exposes more tree settings than the plain random forest (criterion, max features, leaf sizes).
How it works
A balanced random forest (Chen, Liaw & Breiman, 2004) is a random forest in which every tree is grown on a balanced bootstrap sample:
- For every tree, draw a bootstrap sample and under-sample it so that all classes have the same number of rows (by default, the size of the smallest class).
- Grow the tree as in a random forest: at every split, only a random subset
of the features is considered (
max_features). - Predict by averaging the class probabilities of all trees.
On top of the balanced sampling, class_weight can reweight the classes
inside each tree.
Parameters
Defaults as set by the analysis dialog.
| Parameter | UI label | Default | Notes |
|---|---|---|---|
n_estimators | Number of Trees | 100 | |
criterion | Criterion | gini | gini or entropy |
max_depth | Max Depth | unlimited | |
max_features | Max Features | sqrt | Features considered at each split: √(features) → , log2(features) → , All features → , 10 % / 50 % → that share of |
min_samples_split | Min Samples Split | 2 | |
min_samples_leaf | Min Samples Leaf | 1 | |
sampling_strategy | Sampling Strategy | auto | Which classes are under-sampled: auto (all but the smallest), not minority, not majority, all |
class_weight | Class Weight | balanced_subsample | balanced_subsample: weights inversely proportional to class frequencies in each bootstrap sample; balanced: the same over the whole training set; None: no weights (balancing by under-sampling only) |
bootstrap | Bootstrap | on | |
replacement | Replacement | off | Under-sample with replacement |
random_state | — | 42 | |
feature_selection | Enable Feature Selection | off | See Feature selection. |
average_replicates | Average technical replicates | off | See Averaging replicates. |
The model uses all CPU cores (n_jobs=-1).
The dialog values are converted before they reach the library: All
features becomes max_features=None, 10 % and 50 % become the numbers
0.1 and 0.5, and Class Weight None becomes class_weight=None.
Preprocessing
No scaling: trees do not need it.
Before the model: replicate averaging and feature selection, if they are on. See Order of steps.
Results and metrics
The common classification metrics are described in Metrics. AUC is not computed for this method. For imbalanced data, look at recall, sensitivity and specificity per class rather than at accuracy.
Method-specific fields:
| Field | Meaning |
|---|---|
feature_importances | Mean decrease in impurity per feature (final model), sums to 1 |
parameters | All parameters actually used |
Visualizations
The visualization dialog offers no plots for this method in the current version.
Source code
run_brf_analysis in chrometrica/analysis/analysis.py. The imports, the
parameters, the model in the CV loop and the final model:
def run_brf_analysis(X, y, parameters, cv_method, cv_folds):
"""Run Balanced Random Forest analysis with cross-validation and optional feature selection"""
from imblearn.ensemble import BalancedRandomForestClassifier
from sklearn.model_selection import StratifiedKFold, GroupKFold
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score
import numpy as np
# ...
n_estimators = parameters.get('n_estimators', 100)
criterion = parameters.get('criterion', 'gini')
max_depth = parameters.get('max_depth', None)
min_samples_split = parameters.get('min_samples_split', 2)
min_samples_leaf = parameters.get('min_samples_leaf', 1)
max_features = parameters.get('max_features', 'sqrt')
bootstrap = parameters.get('bootstrap', True)
random_state = parameters.get('random_state', 42)
class_weight = parameters.get('class_weight', 'balanced_subsample')
replacement = parameters.get('replacement', False)
sampling_strategy = parameters.get('sampling_strategy', 'auto')
# ...
for train_idx, test_idx in cv.split(X_processed, y, groups=groups if cv_method == 'group' else None):
X_train, X_test = X_processed[train_idx], X_processed[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# Train and predict
brf_cv = BalancedRandomForestClassifier(
n_estimators=n_estimators,
criterion=criterion,
max_depth=max_depth,
min_samples_split=min_samples_split,
min_samples_leaf=min_samples_leaf,
max_features=max_features,
bootstrap=bootstrap,
random_state=random_state,
class_weight=class_weight,
replacement=replacement,
sampling_strategy=sampling_strategy,
n_jobs=-1 # Use all available cores
)
brf_cv.fit(X_train, y_train)
y_pred = brf_cv.predict(X_test)
# ...
# Fit final model on all data
brf = BalancedRandomForestClassifier(
n_estimators=n_estimators,
criterion=criterion,
max_depth=max_depth,
min_samples_split=min_samples_split,
min_samples_leaf=min_samples_leaf,
max_features=max_features,
bootstrap=bootstrap,
random_state=random_state,
class_weight=class_weight,
replacement=replacement,
sampling_strategy=sampling_strategy,
n_jobs=-1
)
brf.fit(X_processed, y)
y_pred_full = brf.predict(X_processed)
Full source of run_brf_analysis()
def run_brf_analysis(X, y, parameters, cv_method, cv_folds):
"""Run Balanced Random Forest analysis with cross-validation and optional feature selection"""
from imblearn.ensemble import BalancedRandomForestClassifier
from sklearn.model_selection import StratifiedKFold, GroupKFold
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score
import numpy as np
if parameters.get('average_replicates', False):
groups = parameters.get('groups')
if groups is None:
raise ValueError("'groups' must be provided when averaging replicates is enabled.")
X, y, new_groups = average_replicates(X, y, groups, method='mean')
# Update parameters with new groups (now each row is unique)
parameters['groups'] = new_groups
# If you used group CV, you might want to keep it; but with unique groups,
# GroupKFold becomes equivalent to standard KFold. You can leave it as is.
# Get overall classes for consistent confusion matrix dimensions
overall_classes = np.unique(y)
overall_classes_list = overall_classes.tolist()
n_overall_classes = len(overall_classes_list)
# Check for feature selection
feature_selection_params = get_feature_selection_params(parameters)
if feature_selection_params:
X_processed, selected_features, feature_scores = select_features(
X, y, feature_selection_params, 'classification'
)
else:
X_processed = X
selected_features = list(range(X.shape[1]))
feature_scores = None
# Get BRF parameters from analysis parameters
n_estimators = parameters.get('n_estimators', 100)
criterion = parameters.get('criterion', 'gini')
max_depth = parameters.get('max_depth', None)
min_samples_split = parameters.get('min_samples_split', 2)
min_samples_leaf = parameters.get('min_samples_leaf', 1)
max_features = parameters.get('max_features', 'sqrt')
bootstrap = parameters.get('bootstrap', True)
random_state = parameters.get('random_state', 42)
class_weight = parameters.get('class_weight', 'balanced_subsample')
replacement = parameters.get('replacement', False)
sampling_strategy = parameters.get('sampling_strategy', 'auto')
# Dialog values -> values accepted by BalancedRandomForestClassifier
# ("All features" is sent as 'auto', "10%" as '0.1', "None" as 'none')
max_depth = _optional_int(max_depth)
if max_features is None or (isinstance(max_features, str) and max_features.lower() in ('auto', 'all', 'none', '')):
max_features = None # all features
elif isinstance(max_features, str) and max_features not in ('sqrt', 'log2'):
max_features = float(max_features)
if class_weight is None or (isinstance(class_weight, str) and class_weight.lower() in ('none', '')):
class_weight = None
# Enhanced cross-validation with metrics
cv_scores = []
cv_predictions = [] # Store predictions from each fold
cv_true_labels = [] # Store true labels from each fold
cv_class_reports = [] # Store classification reports from each fold
cv_confusion_matrices = [] # Store confusion matrices for each fold (using overall classes)
# Sensitivity and specificity tracking for binary classification
cv_sensitivity_scores = [] # Store sensitivity from each fold (for binary)
cv_specificity_scores = [] # Store specificity from each fold (for binary)
if cv_method == 'stratified':
cv = StratifiedKFold(n_splits=cv_folds, shuffle=True, random_state=random_state)
elif cv_method == 'group':
groups = parameters.get('groups')
if groups is None:
raise ValueError("Groups parameter required for group cross-validation")
cv = GroupKFold(n_splits=cv_folds)
else:
# Simple K-fold
from sklearn.model_selection import KFold
cv = KFold(n_splits=cv_folds, shuffle=True, random_state=random_state)
for train_idx, test_idx in cv.split(X_processed, y, groups=groups if cv_method == 'group' else None):
X_train, X_test = X_processed[train_idx], X_processed[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
# Train and predict
brf_cv = BalancedRandomForestClassifier(
n_estimators=n_estimators,
criterion=criterion,
max_depth=max_depth,
min_samples_split=min_samples_split,
min_samples_leaf=min_samples_leaf,
max_features=max_features,
bootstrap=bootstrap,
random_state=random_state,
class_weight=class_weight,
replacement=replacement,
sampling_strategy=sampling_strategy,
n_jobs=-1 # Use all available cores
)
brf_cv.fit(X_train, y_train)
y_pred = brf_cv.predict(X_test)
# Store fold results
cv_scores.append(accuracy_score(y_test, y_pred))
cv_predictions.extend(y_pred)
cv_true_labels.extend(y_test)
# Store per-fold classification report
fold_report = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
cv_class_reports.append(fold_report)
# Create confusion matrix using overall classes to ensure consistent dimensions
cm = confusion_matrix(y_test, y_pred, labels=overall_classes_list)
cv_confusion_matrices.append(cm)
# Calculate sensitivity and specificity for binary classification using the full 2x2 matrix
if n_overall_classes == 2:
tn, fp, fn, tp = cm.ravel()
sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
cv_sensitivity_scores.append(sensitivity)
cv_specificity_scores.append(specificity)
else:
# For multiclass, we can't calculate single sensitivity/specificity values per fold
cv_sensitivity_scores.append(None)
cv_specificity_scores.append(None)
# Calculate CV-based metrics
cv_precision_scores = []
cv_recall_scores = []
cv_f1_scores = []
# Track if this is binary classification
is_binary = n_overall_classes == 2
for report in cv_class_reports:
# Extract macro averages from each fold
if 'macro avg' in report:
cv_precision_scores.append(report['macro avg']['precision'])
cv_recall_scores.append(report['macro avg']['recall'])
cv_f1_scores.append(report['macro avg']['f1-score'])
# Calculate overall CV confusion matrix (aggregated)
cv_confusion_matrix_aggregated = confusion_matrix(cv_true_labels, cv_predictions,
labels=overall_classes_list).tolist()
# Calculate overall CV sensitivity and specificity from aggregated confusion matrix
if is_binary:
cm_agg = np.array(cv_confusion_matrix_aggregated)
tn, fp, fn, tp = cm_agg.ravel()
cv_sensitivity_aggregated = tp / (tp + fn) if (tp + fn) > 0 else 0
cv_specificity_aggregated = tn / (tn + fp) if (tn + fp) > 0 else 0
else:
cv_sensitivity_aggregated = None
cv_specificity_aggregated = None
# Calculate average confusion matrix using the outer function
if cv_confusion_matrices:
cv_confusion_matrix_avg, avg_matrix_classes = create_average_confusion_matrix(
cv_confusion_matrices, [overall_classes_list] * len(cv_confusion_matrices)
)
# Calculate average sensitivity and specificity from averaged confusion matrix (binary only)
if is_binary:
cm_avg = np.array(cv_confusion_matrix_avg)
tn, fp, fn, tp = cm_avg.ravel()
cv_sensitivity_avg = tp / (tp + fn) if (tp + fn) > 0 else 0
cv_specificity_avg = tn / (tn + fp) if (tn + fp) > 0 else 0
else:
cv_sensitivity_avg = None
cv_specificity_avg = None
else:
cv_confusion_matrix_avg = []
avg_matrix_classes = []
cv_sensitivity_avg = None
cv_specificity_avg = None
# Fit final model on all data
brf = BalancedRandomForestClassifier(
n_estimators=n_estimators,
criterion=criterion,
max_depth=max_depth,
min_samples_split=min_samples_split,
min_samples_leaf=min_samples_leaf,
max_features=max_features,
bootstrap=bootstrap,
random_state=random_state,
class_weight=class_weight,
replacement=replacement,
sampling_strategy=sampling_strategy,
n_jobs=-1
)
brf.fit(X_processed, y)
y_pred_full = brf.predict(X_processed)
# Get feature importances
feature_importances = brf.feature_importances_.tolist() if hasattr(brf, 'feature_importances_') else None
# Get the final model's class order
final_classes = overall_classes_list
# Reorder the average confusion matrix to match final_classes if needed
if cv_confusion_matrix_avg and avg_matrix_classes != final_classes:
# Create mapping from avg_matrix_classes to final_classes
avg_to_final = {cls: idx for idx, cls in enumerate(avg_matrix_classes)}
# Create reordered confusion matrix
n_classes = len(final_classes)
reordered_matrix = np.zeros((n_classes, n_classes))
for i, true_cls in enumerate(final_classes):
for j, pred_cls in enumerate(final_classes):
if true_cls in avg_to_final and pred_cls in avg_to_final:
orig_i = avg_to_final[true_cls]
orig_j = avg_to_final[pred_cls]
if (orig_i < len(cv_confusion_matrix_avg) and
orig_j < len(cv_confusion_matrix_avg[0])):
reordered_matrix[i, j] = cv_confusion_matrix_avg[orig_i][orig_j]
cv_confusion_matrix_avg = reordered_matrix.tolist()
avg_matrix_classes = final_classes
# Calculate sensitivity and specificity for full model (self-test)
full_confusion_matrix = confusion_matrix(y, y_pred_full, labels=final_classes)
if is_binary and full_confusion_matrix.shape == (2, 2):
tn, fp, fn, tp = full_confusion_matrix.ravel()
full_sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0
full_specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
else:
full_sensitivity = None
full_specificity = None
# Validate consistency between metrics
validation_message = None
if is_binary and full_sensitivity is not None and full_specificity is not None:
avg_sens_spec = (full_sensitivity + full_specificity) / 2
accuracy = accuracy_score(y, y_pred_full)
accuracy_diff = abs(accuracy - avg_sens_spec)
if accuracy_diff > 0.01: # Allow small floating point differences
validation_message = f"Note: Accuracy ({accuracy:.3f}) differs from (sensitivity + specificity)/2 ({avg_sens_spec:.3f}) by {accuracy_diff:.3f}"
# Calculate CV sensitivity mean and std (filtering out None values)
cv_sensitivity_scores_filtered = [s for s in cv_sensitivity_scores if s is not None]
if cv_sensitivity_scores_filtered:
cv_sensitivity_mean = np.mean(cv_sensitivity_scores_filtered)
cv_sensitivity_std = np.std(cv_sensitivity_scores_filtered)
else:
cv_sensitivity_mean = None
cv_sensitivity_std = None
# Calculate CV specificity mean and std (filtering out None values)
cv_specificity_scores_filtered = [s for s in cv_specificity_scores if s is not None]
if cv_specificity_scores_filtered:
cv_specificity_mean = np.mean(cv_specificity_scores_filtered)
cv_specificity_std = np.std(cv_specificity_scores_filtered)
else:
cv_specificity_mean = None
cv_specificity_std = None
cv_confusion_matrices_lists = [cm.tolist() for cm in cv_confusion_matrices]
results = {
'method': 'Balanced Random Forest',
'cv_scores': cv_scores,
'cv_mean': np.mean(cv_scores) if cv_scores else 0.0,
'cv_std': np.std(cv_scores) if cv_scores else 0.0,
'cv_precision': np.mean(cv_precision_scores) if cv_precision_scores else 0.0,
'cv_precision_std': np.std(cv_precision_scores) if cv_precision_scores else 0.0,
'cv_recall': np.mean(cv_recall_scores) if cv_recall_scores else 0.0,
'cv_recall_std': np.std(cv_recall_scores) if cv_recall_scores else 0.0,
'cv_f1': np.mean(cv_f1_scores) if cv_f1_scores else 0.0,
'cv_f1_std': np.std(cv_f1_scores) if cv_f1_scores else 0.0,
# Sensitivity metrics
'cv_sensitivity': cv_sensitivity_mean,
'cv_sensitivity_std': cv_sensitivity_std,
'cv_sensitivity_aggregated': cv_sensitivity_aggregated,
'cv_sensitivity_avg': cv_sensitivity_avg,
'cv_sensitivity_scores': cv_sensitivity_scores, # Per-fold sensitivities
# Specificity metrics
'cv_specificity': cv_specificity_mean,
'cv_specificity_std': cv_specificity_std,
'cv_specificity_aggregated': cv_specificity_aggregated,
'cv_specificity_avg': cv_specificity_avg,
'cv_specificity_scores': cv_specificity_scores, # Per-fold specificities
# Validation flag
'is_binary': is_binary,
'validation_message': validation_message,
# Full model metrics
'sensitivity': full_sensitivity,
'specificity': full_specificity,
# Existing metrics
'cv_confusion_matrix': cv_confusion_matrix_avg,
'cv_confusion_matrices': cv_confusion_matrices_lists,
'cv_confusion_matrix_aggregated': cv_confusion_matrix_aggregated,
'cv_class_reports': cv_class_reports,
'classification_report': classification_report(y, y_pred_full, output_dict=True, zero_division=0),
'confusion_matrix': full_confusion_matrix.tolist(),
'feature_importances': feature_importances,
'labels': y.tolist(),
'predictions': y_pred_full.tolist(),
'classes': final_classes,
'parameters': {
'n_estimators': n_estimators,
'criterion': criterion,
'max_depth': max_depth,
'min_samples_split': min_samples_split,
'min_samples_leaf': min_samples_leaf,
'max_features': max_features,
'bootstrap': bootstrap,
'class_weight': class_weight,
'replacement': replacement,
'sampling_strategy': sampling_strategy
}
}
# Add metric consistency check for binary classification
if is_binary and full_sensitivity is not None and full_specificity is not None:
results['metric_consistency_check'] = {
'accuracy': accuracy_score(y, y_pred_full),
'sensitivity': full_sensitivity,
'specificity': full_specificity,
'avg_sens_spec': (full_sensitivity + full_specificity) / 2,
'difference': abs(accuracy_score(y, y_pred_full) - (full_sensitivity + full_specificity) / 2)
}
# Add feature selection info if used
if feature_selection_params:
results['feature_selection'] = {
'selected_features': selected_features,
'feature_scores': feature_scores,
'method': feature_selection_params.get('method', 'anova'),
'k': feature_selection_params.get('k', 'all')
}
# Final fitted model for inference on unknown samples (popped in run_analysis)
return attach_model_bundle(results, brf, selected_features, feature_selection_params)
Limitations and common pitfalls
- Very small minority class. Every tree is grown on twice (or times) the size of the smallest class. With 2–3 rows there, the trees are very noisy; use more trees.
- Training metrics look perfect, as with any random forest: judge the model by the cross-validated metrics.
References
- Chen C., Liaw A., Breiman L. Using random forest to learn imbalanced data. Technical report 666, Department of Statistics, University of California, Berkeley (2004). PDF
- Breiman L. Random forests. Machine Learning, 45, 5–32 (2001). doi:10.1023/A:1010933404324
- Lemaître G., Nogueira F., Aridas C. K. Imbalanced-learn: a Python toolbox to tackle the curse of imbalanced datasets in machine learning. Journal of Machine Learning Research, 18(17), 1–5 (2017). jmlr.org/papers/v18/16-365