Más sobre ciencia de datos: cienciadedatos.net
Los modelos Random Forest tienen, entre muchas otras, la ventaja de disponer del Out-of-Bag error, lo que permite obtener una estimación del error de test sin tener que recurrir a procesos validación cruzada, que son computacionalmente muy costosos. Esta característica, combinada con una estrategia de early stopping, puede emplearse para acelerar el proceso de búsqueda de hiperparámetros (grid search, random search,búsqueda bayesiana...)).
En el siguiente documento se muestra cómo adaptar los modelos RandomForestClassifier y RandomForestRegressor de scikit-learn para que tengan parada temprana y utilicen el Out-of-Bag error en la búsqueda de hiperparámetros.
Dada la naturaleza del proceso de bagging en el que se basan los modelos de Random Forest, resulta posible estimar el error de test sin necesidad de recurrir a métodos de validación cruzada (cross-validation). El hecho de que los árboles se ajusten empleando muestras generadas por bootstrapping conlleva que, en promedio, cada ajuste solo use aproximadamente dos tercios de las observaciones originales. Al tercio restante se le llama out-of-bag (OOB).
Si para cada árbol ajustado en el proceso de bagging se registran las observaciones empleadas, se puede predecir la respuesta de la observación i haciendo uso de aquellos árboles en los que esa observación ha sido excluida. Siguiendo este proceso, se pueden obtener las predicciones para las n observaciones y con ellas calcular la métrica de interés. Como cada observación se predice empleando únicamente los árboles en cuyo ajuste no participó dicha observación, el OOB-error sirve como estimación del error de test. De hecho, si el número de árboles es suficientemente alto, el OOB-error es prácticamente equivalente al leave-one-out cross-validation error.
Esta es una ventaja añadida de los métodos de bagging, y por lo tanto de Random Forest, ya que evita tener que recurrir al proceso de validación cruzada (computacionalmente costoso) para la optimización de los hiperparámetros. Aun así, dos limitaciones se han de tener en cuenta a la hora de utilizar el Out-of-Bag Error:
El Out-of-Bag Error no es adecuado cuando las observaciones tienen una relación temporal (series temporales). Como la selección de las observaciones que participan en cada entrenamiento es aleatoria, no se respeta el orden temporal y se estaría introduciendo información a futuro.
El preprocesado de los datos de entrenamiento se hace de forma conjunta, por lo que las observaciones out-of-bag pueden sufrir data leakage). De ser así, las estimaciones del OOB-error son demasiado optimistas. Afortunadamente, los modelos de Random Forest requieren de pocas transformaciones, por ejemplo, no es necesario el escalado o normalización de los predictores.
En un muestreo por bootstrapping, si el tamaño de los datos de entrenamiento es n, cada observación tiene una probabilidad de ser elegida de $\frac{1}{n}$ . Por lo tanto, la probabilidad de no ser elegida en todo el proceso es de $(1−\frac{1}{n})^n$ , lo que converge en $\frac{1}{e}$ , que es aproximadamente un tercio.
Una de las características de los modelos Random Forest es que, alcanzado un número suficiente de árboles, el modelo deja de mejorar. Aunque, a diferencia de otros modelos como Gradient Boosting, un exceso de árboles en modelos Random Forest no causa overfitting, es poco eficiente en términos de tiempo y computación añadir más árboles de los necesarios.
Para evitar este problema, se pueden emplear estrategias que detengan el proceso de entrenamiento a partir del momento en el que el modelo deja de mejorar, por ejemplo, monitorizando una métrica en un conjunto de validación, o con el out-of-bag error. Esta última es la que se muestra en este documento.
Las siguientes 3 funciones permiten realizar la búsqueda de hiperparámetros (Grid Search) de modelos Random Forest aplicando en cada entrenamiento una estrategia de parada temprana y utilizando el out-of-bag error como métrica de comparación.
check_early_stopping()
: dada una secuencia de valores y unas reglas, determina si se activa o no la parada temprana. Es necesario definir el tipo de métrica al que pertenecen los valores para determinar si el modelo es mejor a medida que aumenta el valor o al revés.
fit_RandomForest_early_stopping()
: entrena un modelo RandomForestClassifier
o RandomForestRegressor
hasta que se cumple una condición de parada temprana o se alcanza el máximo de árboles (n_estimators
) definido al crear el modelo.
custom_gridsearch_RandomForestClassifier()
: grid search de un modelo RandomForestClassifier
utilizando una métrica out-of-bag para comparar los modelos y activando la parada temprana en cada entrenamiento.
import pandas as pd
import numpy as np
import typing
from typing import Optional, Union, Tuple
import logging
import tqdm
from sklearn.base import clone
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import ParameterGrid
from sklearn.metrics import roc_auc_score, accuracy_score, f1_score
from sklearn.metrics import mean_absolute_error, mean_squared_error
logging.basicConfig(
format = '%(asctime)-5s %(name)-10s %(levelname)-5s %(message)s',
level = logging.INFO,
)
def check_early_stopping(
scores: Union[list, np.ndarray],
metric: str,
stopping_rounds: int=4,
stopping_tolerance: float=0.01,
max_runtime_sec: int=None,
start_time: pd.Timestamp=None) -> bool:
"""
Check if early stopping condition is met.
Parameters
----------
scores: list, np.ndarray
Scores used to evaluate early stopping conditions.
metric: str
Metric which scores referes to. Used to determine if higher score
means a better model or the opposite.
stopping_rounds: int, default 4
Number of consecutive rounds without improvement needed to stop
the training.
stopping_tolerance: float, default 0.01
Minimum percentage of positive change between two consecutive rounds
needed to consider it as an improvement.
max_runtime_sec: int, default `None`
Maximum allowed runtime in seconds for model training. `None` means unlimited.
start_time: pd.Timestamp, default `None`
Time when training started. Used to determine if `max_runtime_sec` has been
reached.
Returns
------
bool:
`True` if any condition needed for early stopping is met. `False` otherwise.
Notes
-----
Example of early stopping:
Stop after 4 rounds without an improvement of 1% or higher: `stopping_rounds` = 4,
`stopping_tolerance` = 0.01, `max_runtime_sec` = None.
"""
allowed_metrics = ['accuracy', 'auc', 'f1', 'mse', 'mae', 'squared_error',
'absolute_error']
if metric not in allowed_metrics:
raise Exception(
f"`metric` argument must be one of: {allowed_metrics}. "
f"Got {metric}"
)
if isinstance(scores, list):
scores = np.array(scores)
if max_runtime_sec is not None:
if start_time is None:
start_time = pd.Timestamp.now()
runing_time = (pd.Timestamp.now() - start_time).total_seconds()
if runing_time > max_runtime_sec:
logging.debug(
f"Reached maximum time for training ({max_runtime_sec} seconds). "
f"Early stopping activated."
)
return True
if len(scores) < stopping_rounds:
return False
if metric in ['accuracy', 'auc', 'f1']:
# The higher the metric, the better
diff_scores = scores[1:] - scores[:-1]
improvement = diff_scores / scores[:-1]
if metric in ['mse', 'mae', 'squared_error', 'absolute_error']:
# The lower the metric, the better
# scores = -1 * scores
# diff_scores = scores[:-1] - scores[1:]
# improvement = diff_scores / scores[1:]
diff_scores = scores[1:] - scores[:-1]
improvement = diff_scores / scores[:-1]
improvement = -1 * improvement
improvement = np.hstack((np.nan, improvement))
logging.debug(f"Improvement: {improvement}")
if (improvement[-stopping_rounds:] < stopping_tolerance).all():
return True
else:
return False
def fit_RandomForest_early_stopping(
model: Union[RandomForestClassifier, RandomForestRegressor],
X: Union[np.ndarray, pd.core.frame.DataFrame],
y: np.ndarray,
metric: str,
positive_class: int=1,
score_tree_interval: int=None,
stopping_rounds: int=4,
stopping_tolerance: float=0.01,
max_runtime_sec: int=None) -> np.ndarray:
"""
Fit a RandomForest model until an early stopping condition is met or
`n_estimatos` is reached.
Parameters
----------
model: RandomForestClassifier, RandomForestRegressor
Model to be fitted.
X: np.ndarray, pd.core.frame.DataFrame
Training input samples.
y: np.ndarray, pd.core.frame.DataFrame
Target value of the input samples.
scores: list, np.ndarray
Scores used to evaluate early stopping conditions.
metric: str
Metric used to generate the score. Used to determine if higher score
means a better model or the opposite.
score_tree_interval: int, default `None`
Score the model after this many trees. If `None`, the model is scored after
`n_estimators` / 10.
stopping_rounds: int
Number of consecutive rounds without improvement needed to stop the training.
stopping_tolerance: float, default 0.01
Minimum percentage of positive change between two consecutive rounds
needed to consider it as an improvement.
max_runtime_sec: int, default `None`
Maximum allowed runtime in seconds for model training. `None` means unlimited.
Returns
------
oob_scores: np.ndarray
Out of bag score for each scoring point.
"""
if score_tree_interval is None:
score_tree_interval = int(model.n_estimators / 10)
allowed_metrics = ['accuracy', 'auc', 'f1', 'mse', 'mae', 'squared_error',
'absolute_error']
if metric not in allowed_metrics:
raise Exception(
f"`metric` argument must be one of: {allowed_metrics}. "
f"Got {metric}"
)
if not model.oob_score:
model.set_params(oob_score=True)
start_time = pd.Timestamp.now()
oob_scores = []
scoring_points = np.arange(0, model.n_estimators + 1, score_tree_interval)[1:]
scoring_points = np.hstack((1, scoring_points))
metrics = {
'auc' : roc_auc_score,
'accuracy' : accuracy_score,
'f1': f1_score,
'mse': mean_squared_error,
'squared_error': mean_squared_error,
'mae': mean_absolute_error,
'absolute_error': mean_absolute_error,
}
for i, n_estimators in enumerate(scoring_points):
logging.debug(f"Training with n_stimators: {n_estimators}")
model.set_params(n_estimators=n_estimators)
model.fit(X=X, y=y)
if metric == 'auc':
oob_predictions = model.oob_decision_function_[:, positive_class]
# If n_estimators is small it might be possible that a data point
# was never left out during the bootstrap. In this case,
# oob_decision_function_ might contain NaN.
oob_score = metrics[metric](
y_true=y[~np.isnan(oob_predictions)],
y_score=oob_predictions[~np.isnan(oob_predictions)]
)
else:
oob_predictions = model.oob_decision_function_
oob_predictions = np.argmax(oob_predictions, axis=1)
oob_score = metrics[metric](
y_true=y[~np.isnan(oob_predictions)],
y_score=oob_predictions[~np.isnan(oob_predictions)]
)
oob_scores.append(oob_score)
early_stopping = check_early_stopping(
scores = oob_scores,
metric = metric,
stopping_rounds = stopping_rounds,
stopping_tolerance = stopping_tolerance,
max_runtime_sec = max_runtime_sec,
start_time = start_time
)
if early_stopping:
logging.debug(
f"Early stopping activated at round {i + 1}: n_estimators = {n_estimators}"
)
break
logging.debug(f"Out of bag score = {oob_scores[-1]}")
return np.array(oob_scores), scoring_points[:len(oob_scores)]
def custom_gridsearch_RandomForestClassifier(
model: RandomForestClassifier,
X: Union[np.ndarray, pd.core.frame.DataFrame],
y: np.ndarray,
metric: str,
param_grid: dict,
positive_class: int=1,
score_tree_interval: int=None,
stopping_rounds: int=5,
stopping_tolerance: float=0.01,
model_max_runtime_sec: int=None,
max_models: int=None,
max_runtime_sec: int=None,
return_best: bool=True) -> Tuple[pd.DataFrame, pd.DataFrame]:
'''
Grid search for RandomForestClassifier model based on out-of-bag metric and
early stopping for each model fit.
Parameters
----------
model: RandomForestClassifier
Model to search over.
X: np.ndarray, pd.core.frame.DataFrame
The training input samples.
y: np.ndarray, pd.core.frame.DataFrame
The target of input samples.
scores: list, np.ndarray
Scores used to evaluate early stopping conditions.
metric: str
Metric used to generate the score. I is used to determine if higher score
means a better model or the opposite.
score_tree_interval: int, default `None`
Score the model after this many trees. If `None`, the model is scored after
`n_estimators` / 10.
stopping_rounds: int
Number of consecutive rounds without improvement needed to stop the training.
stopping_tolerance: float, default 0.01
Minimum percentage of positive change between two consecutive rounds
needed to consider it as an improvement.
model_max_runtime_sec: int, default `None`
Maximum allowed runtime in seconds for model training. `None` means unlimited.
max_models: int, default `None`
Maximum number of models trained during the search.
max_runtime_sec: int, default `None`
Maximum number of seconds for the search.
return_best : bool
Refit model using the best found parameters on the whole data.
Returns
------
results: pd.DataFrame
'''
results = {'params': [], 'oob_metric': []}
start_time = pd.Timestamp.now()
history_scores = {}
history_scoring_points = np.array([], dtype = int)
param_grid = list(ParameterGrid(param_grid))
if not model.oob_score:
model.set_params(oob_score=True)
if max_models is not None and max_models < len(param_grid):
param_grid = np.random.choice(param_grid, max_models)
for params in tqdm.tqdm(param_grid):
if max_runtime_sec is not None:
runing_time = (pd.Timestamp.now() - start_time).total_seconds()
if runing_time > max_runtime_sec:
logging.info(
f"Reached maximum time for GridSearch ({max_runtime_sec} seconds). "
f"Search stopped."
)
break
model.set_params(**params)
oob_scores, scoring_points = fit_RandomForest_early_stopping(
model = clone(model), # Clone to avoid modification of n_estimators
X = X,
y = y,
metric = metric,
positive_class = positive_class,
score_tree_interval = score_tree_interval,
stopping_rounds = stopping_rounds,
stopping_tolerance = stopping_tolerance,
max_runtime_sec = model_max_runtime_sec
)
history_scoring_points = np.union1d(history_scoring_points, scoring_points)
history_scores[str(params)] = oob_scores
params['n_estimators'] = scoring_points[-1]
results['params'].append(params)
results['oob_metric'].append(oob_scores[-1])
logging.debug(f"Modelo: {params} \u2713")
results = pd.DataFrame(results)
history_scores = pd.DataFrame(
dict([(k, pd.Series(v)) for k,v in history_scores.items()])
)
history_scores['n_estimators'] = history_scoring_points
if metric in ['accuracy', 'auc', 'f1']:
results = results.sort_values('oob_metric', ascending=False)
else:
results = results.sort_values('oob_metric', ascending=True)
results = results.rename(columns = {'oob_metric': f'oob_{metric}'})
if return_best:
best_params = results['params'].iloc[0]
print(
f"Refitting mode using the best found parameters and the whole data set: \n {best_params}"
)
model.set_params(**best_params)
model.fit(X=X, y=y)
results = pd.concat([results, results['params'].apply(pd.Series)], axis=1)
results = results.drop(columns = 'params')
return results, history_scores
# Datos
# ==============================================================================
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
url = ('https://raw.githubusercontent.com/JoaquinAmatRodrigo/'
'Estadistica-machine-learning-python/master/data/adult_custom_python.csv')
datos = pd.read_csv(url, sep=",")
datos.info()
X = datos.drop(columns='salario')
y = datos.salario
X = pd.get_dummies(X, drop_first=True)
# Grid de valores sobre los que buscar
param_grid = {
'max_depth' : [3, 10, 20],
'min_samples_leaf': [0.05, 0.1],
'max_features': ['sqrt', 'log2'],
'ccp_alpha': [0, 0.01]
}
# Modelo
model = RandomForestClassifier(
n_estimators = 1000,
oob_score = True,
n_jobs = -1,
random_state = 123
)
# Búsqueda de mejor modelo basada en métrica out-of-bag
start = pd.Timestamp.now()
resultados, history = custom_gridsearch_RandomForestClassifier(
model = model,
X = X,
y = y,
metric = 'auc',
param_grid = param_grid,
positive_class = 1,
score_tree_interval = 50,
stopping_rounds = 4,
stopping_tolerance = 0.01,
model_max_runtime_sec = None,
max_models = None,
max_runtime_sec = None,
return_best = True
)
end = pd.Timestamp.now()
print(f"Duración búsqueda: {end-start}")
resultados
fig, ax = plt.subplots(1, 1, figsize=(7,5))
history.set_index('n_estimators').plot(legend=False, ax=ax)
ax.set_ylabel('AUC');
ax.set_title('Evolución de la métrica out-of-bag');
from sklearn.model_selection import GridSearchCV
# Grid de valores sobre los que buscar
param_grid = {
'max_depth' : [3, 10, 20],
'min_samples_leaf': [0.05, 0.1],
'max_features': ['sqrt', 'log2'],
'ccp_alpha': [0, 0.01]
}
# Modelo
model = RandomForestClassifier(
n_estimators = 1000,
oob_score = False,
n_jobs = -1,
random_state = 123
)
# Búsqueda por grid search con validación cruzada
start = pd.Timestamp.now()
grid = GridSearchCV(
estimator = model,
param_grid = param_grid,
scoring = 'roc_auc',
cv = 5,
refit = False,
verbose = 0,
return_train_score = True
)
grid.fit(X = X, y = y)
end = pd.Timestamp.now()
print(f"Duración búsqueda: {end-start}")
# Resultados
resultados = pd.DataFrame(grid.cv_results_)
resultados.filter(regex = '(param.*|mean_t|std_t)') \
.drop(columns = 'params') \
.sort_values('mean_test_score', ascending = False)
Los dos métodos obtienen errores de validación similares e identifican como mejor modelo el que tiene la configuración:
La estrategia basada en métrica out-of-bag y parada temprana es aproximadamente 4x más rápida y los modelos resultantes tienen menos árboles.
Profiling del código para identificar qué partes están requiriendo más tiempo de computo.
# Grid de valores sobre los que buscar
param_grid = {
'max_depth' : [3, 10, 20],
'min_samples_leaf': [0.05, 0.1],
'max_features': ['sqrt', 'log2'],
'ccp_alpha': [0, 0.01]
}
# Modelo
model = RandomForestClassifier(
n_estimators = 1000,
oob_score = True,
n_jobs = -1,
random_state = 123
)
from line_profiler import LineProfiler
lp = LineProfiler()
lp_wrapper = lp(custom_gridsearch_RandomForestClassifier)
lp_wrapper(
model = model,
X = X,
y = y,
metric = 'auc',
param_grid = param_grid,
positive_class = 1,
score_tree_interval = 50,
stopping_rounds = 4,
stopping_tolerance = 0.01,
model_max_runtime_sec = None,
max_models = 5,
max_runtime_sec = None
)
lp.print_stats()
lp = LineProfiler()
lp_wrapper = lp(fit_RandomForest_early_stopping)
lp_wrapper(
model = model,
X = X,
y = y,
metric = 'auc',
positive_class = 1,
score_tree_interval = 50,
stopping_rounds = 4,
stopping_tolerance = 0.01,
max_runtime_sec = None
)
lp.print_stats()
from sinfo import sinfo
sinfo()
¿Cómo citar este documento?
Grid search de modelos Random Forest con out-of-bag error y early stopping por Joaquín Amat Rodrigo, disponible con licencia CC BY-NC-SA 4.0 en https://www.cienciadedatos.net/documentos/py36-grid-search-random-forest-out-of-bag-error-early-stopping.html
¿Te ha gustado el artículo? Tu ayuda es importante
Mantener un sitio web tiene unos costes elevados, tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊
Este contenido, creado por Joaquín Amat Rodrigo, tiene licencia Attribution-NonCommercial-ShareAlike 4.0 International.
Se permite:
Compartir: copiar y redistribuir el material en cualquier medio o formato.
Adaptar: remezclar, transformar y crear a partir del material.
Bajo los siguientes términos:
Atribución: Debes otorgar el crédito adecuado, proporcionar un enlace a la licencia e indicar si se realizaron cambios. Puedes hacerlo de cualquier manera razonable, pero no de una forma que sugiera que el licenciante te respalda o respalda tu uso.
NoComercial: No puedes utilizar el material para fines comerciales.
CompartirIgual: Si remezclas, transformas o creas a partir del material, debes distribuir tus contribuciones bajo la misma licencia que el original.