Random forest con valores nulos y variables categoricas

Random forest con valores nulos y variables categóricas

Joaquín Amat Rodrigo
Enero, 2024

Introducción

Los modelos de Random Forest son una poderosa técnica en el campo del aprendizaje automático (machine learning). Se basan en la construcción de múltiples árboles de decisión y combinan sus predicciones para obtener un resultado final más robusto y preciso. Este enfoque de conjunto permite a los Random Forest superar muchas de las limitaciones asociadas con un solo árbol de decisión, como el sobreajuste y la sensibilidad a pequeñas variaciones en los datos de entrenamiento. Gracias a su versatilidad y capacidad para manejar una amplia variedad de problemas de clasificación y regresión, Random Forest se han convertido en una herramienta indispensable para los científicos de datos.

En el ecosistema Python, una de las implementaciones de Random Forest más utilizadas es la disponible en Scikit-learn (RandomForestRegressor, RandomForestClassifier). Aunque esta implementación satisface con éxito la mayoría de los casos de uso, tiene dos limitaciones: no acepta datos ausentes (missing) y carece de la capacidad de manejar de forma nativa variables categóricas. Esto lleva a la necesidad de aplicar estrategias de preprocesamiento (como one hot encoding, target encoding, etc.) para superar estas limitaciones.

XGBoost y LightGBM, conocidos sobre todo por su eficaz aplicación de modelos gradient boosting, también permiten crear modelos Random Forest. Estas implementaciones tienen varias ventajas sobre la de Scikit-learn:

  • Manejo nativo de datos ausentes: tienen la capacidad de manejar eficazmente los valores que ausentes. Evitando así la necesidad de eliminarlos o imputarlos.

  • Manejo nativo de variables categóricas: A diferencia de la implementación de Scikit-learn, pueden manejar variables categóricas de forma nativa.

  • Regularización integrada: incorporan una función de regularización que ayuda a controlar el sobreajuste.

  • Velocidad de entrenamiento: gracias a las optimizaciones del algoritmo, pueden entrenar modelos Random Forest más rápido que la implementación Scikit-learn.

  • Aceleración GPU: XGBoost y LightGBM tienen una versión compatible con GPU que puede acelerar significativamente el entrenamiento y la inferencia del modelo.

Es importante señalar que, a pesar de estas ventajas, la elección entre la implementación dependerá del conjunto de datos específico, los objetivos del proyecto y las limitaciones de recursos computacionales. Además, es fundamental tener en cuenta las posibles diferencias en los resultados debidas a las características específicas de cada implementación.

Note

El principal beneficio de utilizar la librería XGBoost o LightGBM para entrenar modelos random forest frente a la implementación nativa de scikit-learn es la velocidad junto con su capacidad para manejar variables categóricas.
Desde la version 1.4 de Scikit-learn, la implementación de Random Forest ha añadido la capacidad de manejar valores ausentes.

Warning

La implementación de XGBoost tiene algunas diferencias con respecto a la implementación de scikit-learn que pueden llevar a resultados diferentes.
  • XGBoost utiliza una aproximación de segundo orden a la función objetivo. Esto puede llevar a resultados que difieren de una implementación que utiliza el valor exacto de la función objetivo.
  • XGBoost no realiza reemplazos al seleccionar observaciones de entrenamiento. Cada valor de entrenamiento puede aparecer como máximo una vez en cada muestreo.

Librerías

Las librerías utilizadas en este documento son:

In [1]:
# Tratamiento de datos
# ==============================================================================
import numpy as np
import pandas as pd
from sklearn.datasets import fetch_california_housing

# Gráficos
# ==============================================================================
import matplotlib.pyplot as plt

# Modelado
# ==============================================================================
import xgboost
from xgboost import XGBRFRegressor
import lightgbm
from lightgbm import LGBMRegressor
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder
from sklearn.preprocessing import TargetEncoder
from sklearn.compose import ColumnTransformer
import sklearn
import optuna
import time

# Configuración warnings
# ==============================================================================
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("ignore", category=FutureWarning)
optuna.logging.set_verbosity(optuna.logging.WARNING)

print(f"XGBoost version: {xgboost.__version__}")
print(f"LightGBM version: {lightgbm.__version__}")
print(f"Optuna version: {optuna.__version__}")
print(f"Scikit-learn version: {sklearn.__version__}")
XGBoost version: 2.0.3
LightGBM version: 4.3.0
Optuna version: 3.2.0
Scikit-learn version: 1.4.1.post1

Datos

El conjunto de datos utilizado en este documento contiene información sobre la vivienda en California. Es accesible a través de la librería sklearn y se puede cargar con la función fetch_california_housing. El conjunto de datos original (20.640 observaciones y 9 variables) ha sido modificado para incorporar nuevas variables:

  • Dirección de la vivienda (county y postcode): esta información se ha extraído utilizando el paquete geopy y la API de Nomatim de OpenStreetMap.

  • Ciudad más cercana y distancia a la misma: los datos tienen licencia CC0: Public Domain y se pueden obtener en kaggle.

In [2]:
# Descarga de datos california housing extended
# ==============================================================================
datos = pd.read_csv(
    'https://raw.githubusercontent.com/JoaquinAmatRodrigo/'
    'Estadistica-machine-learning-python/master/data/california_housing_extended.csv'
)
datos = datos.drop(columns=['Latitude', 'Longitude'])
datos['postcode'] = "postcode_" + datos['postcode'].astype(str)
datos['postcode'] = datos['postcode'].replace('postcode_nan', np.nan)
print("Dimensiones de los datos:", datos.shape)
datos.head()
Dimensiones de los datos: (20640, 11)
Out[2]:
MedInc HouseAge AveRooms AveBedrms Population AveOccup MedHouseVal county postcode ciudad_mas_cercana distancia_ciudad_mas_cercana
0 8.3252 41.0 6.984127 1.023810 322.0 2.555556 4.526 Contra Costa County postcode_94563.0 Berkeley 3.866682
1 8.3014 21.0 6.238137 0.971880 2401.0 2.109842 3.585 Contra Costa County postcode_94563.0 Orinda 4.019485
2 7.2574 52.0 8.288136 1.073446 496.0 2.802260 3.521 Alameda County postcode_94613.0 Piedmont 2.942843
3 5.6431 52.0 5.817352 1.073059 558.0 2.547945 3.413 Alameda County postcode_94613.0 Berkeley 3.122850
4 3.8462 52.0 6.281853 1.081081 565.0 2.181467 3.422 Alameda County postcode_94613.0 Berkeley 3.122850
In [3]:
# Porcentaje de valores nuelos por columna
# ==============================================================================
datos.isnull().mean()
Out[3]:
MedInc                          0.000000
HouseAge                        0.000000
AveRooms                        0.000000
AveBedrms                       0.000000
Population                      0.000000
AveOccup                        0.000000
MedHouseVal                     0.000000
county                          0.305281
postcode                        0.102859
ciudad_mas_cercana              0.000000
distancia_ciudad_mas_cercana    0.000000
dtype: float64
In [4]:
# Categorias que aparecen menos de 10 veces se agrupan en una categoria "otro"
# ==============================================================================
limit = 10
for col in ['county', 'ciudad_mas_cercana']:
    counts = datos[col].value_counts()
    under_limit = counts[counts < limit]
    index_under_limit = under_limit.index.tolist()
    datos[col] = datos[col].replace(index_under_limit, 'otro')
In [5]:
# Numero de categorias por variable
# ==============================================================================
print("Numero de categorias por variable")
print("---------------------------------")
print("county:", datos['county'].nunique())
print("postcode:", datos['postcode'].nunique())
print("ciudad_mas_cercana:", datos['ciudad_mas_cercana'].nunique())
Numero de categorias por variable
---------------------------------
county: 55
postcode: 701
ciudad_mas_cercana: 411
In [6]:
# Las variables de tipo categorico se almacenan como dtype category
# ==============================================================================
datos['county'] = datos['county'].astype('category')
datos['postcode'] = datos['postcode'].astype('category')
datos['ciudad_mas_cercana'] = datos['ciudad_mas_cercana'].astype('category')
datos.dtypes
Out[6]:
MedInc                           float64
HouseAge                         float64
AveRooms                         float64
AveBedrms                        float64
Population                       float64
AveOccup                         float64
MedHouseVal                      float64
county                          category
postcode                        category
ciudad_mas_cercana              category
distancia_ciudad_mas_cercana     float64
dtype: object
In [7]:
# División de los datos en train, validation y test
# ==============================================================================
target = 'MedHouseVal'

X_train, X_test, y_train, y_test = train_test_split(
    datos.drop(columns=target),
    datos[target],
    train_size   = 0.8,
    random_state = 1234,
    shuffle      = True
)

X_train, X_val, y_train, y_val = train_test_split(
    X_train,
    y_train,
    train_size   = 0.8,
    random_state = 1234,
    shuffle      = True
)

print("Observaciones en train:", X_train.shape)
print("Observaciones en validation:", X_val.shape)
print("Observaciones en test:", X_test.shape)
Observaciones en train: (13209, 10)
Observaciones en validation: (3303, 10)
Observaciones en test: (4128, 10)

Comparación de modelos

A continuación, se compararán los modelos Random Forest de las librerías scikit-learn, XGBoost y LightGBM en términos de velocidad de entrenamiento, velocidad de inferencia y capacidad predictiva.

Para todos los modelos, se realiza una búsqueda de hiperparámetros utilizado la librería Optuna. La selección de hiperparámetros se realiza con los datos de validación. En el caso de scikit-learn, dado que no tiene una implementación nativa para manejar variables categóricas, se utiliza un preprocesamiento con OneHotEncoder para codificar las variables categóricas.

Random Forest con scikit-learn

RandomForestRegressor de scikit-learn no acepta variables categóricas. Para poder utilizar este modelo, es necesario codificar las variables categóricas en formato numérico.

Note

En este caso se realiza una codificación de las variables categóricas con TargetEncoder para evitar aumentar la dimensionalidad de los datos. Pero también existen alternativas para codificar variables categóricas en scikit-learn como OneHotEncoder o OrdinalEncoder.
In [8]:
# Target encoding de las variables categóricas
# ==============================================================================
col_categoricas = datos.select_dtypes(exclude=np.number).columns.to_list()
encoder = ColumnTransformer(
    transformers=[
        ('target', TargetEncoder(target_type="continuous"), col_categoricas)
    ],
    remainder='passthrough'
).set_output(transform='pandas')

encoder.fit(X_train, y_train)
X_train_encoded = encoder.transform(X_train)
X_val_encoded   = encoder.transform(X_val)
X_test_encoded  = encoder.transform(X_test)

print("Observaciones en train encoded:", X_train_encoded.shape)
print("Observaciones en validation encoded:", X_val_encoded.shape)
print("Observaciones en test encoded:", X_test_encoded.shape)
Observaciones en train encoded: (13209, 10)
Observaciones en validation encoded: (3303, 10)
Observaciones en test encoded: (4128, 10)
In [9]:
# Búsqueda bayesiana de hiperparámetros con optuna
# ==============================================================================
def objective(trial):
    params = {
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000, step=100),
        'max_depth': trial.suggest_int('max_depth', 3, 30),
        'min_samples_split': trial.suggest_int('min_samples_split', 2, 100),
        'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 100),
        'max_features': trial.suggest_float('max_features', 0.3, 1),
        'ccp_alpha': trial.suggest_float('ccp_alpha', 0.0, 1),
        'min_impurity_decrease': trial.suggest_float('min_impurity_decrease', 0.0, 1),        
    }

    model = RandomForestRegressor(
                n_jobs       = -1,
                random_state = 4576688,
                criterion    = 'squared_error',
                **params
            )
    model.fit(X_train_encoded, y_train)
    predictions = model.predict(X_val_encoded)
    score = mean_squared_error(y_val, predictions, squared=False)
    return score

study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=25, show_progress_bar=True, timeout=60*5)

print('Mejores hiperparámetros:', study.best_params)
print('Mejor score:', study.best_value)
Mejores hiperparámetros: {'n_estimators': 800, 'max_depth': 17, 'min_samples_split': 69, 'min_samples_leaf': 4, 'max_features': 0.46194796254324455, 'ccp_alpha': 0.023038477521006434, 'min_impurity_decrease': 0.013826157516659318}
Mejor score: 0.5767107118731251
In [10]:
# Random Forest scikit-learn con los mejores hiperparámetros encontrados
# ==============================================================================
rf_sklearn = RandomForestRegressor(
                n_jobs       = -1,
                random_state = 4576688,
                criterion    = 'squared_error',
                **study.best_params
            )

# Entrenamiento del modelo
start = time.time()
rf_sklearn.fit(
        X = pd.concat([X_train_encoded, X_val_encoded]),
        y = pd.concat([y_train, y_val])
)
end = time.time()
tiempo_entrenamiento_sklearn = end - start

# Predicciones test
start = time.time()
predicciones = rf_sklearn.predict(X=X_test_encoded)
end = time.time()
tiempo_prediccion_sklearn = end - start

# Error de test del modelo
rmse_rf_sklearn = mean_squared_error(
        y_true  = y_test,
        y_pred  = predicciones,
        squared = False
       )

print(f"Tiempo entrenamiento: {tiempo_entrenamiento_sklearn:.2f} segundos")
print(f"Tiempo predicción:    {tiempo_prediccion_sklearn:.2f} segundos")
print(f"RMSE:                 {rmse_rf_sklearn:.2f}")
Tiempo entrenamiento: 3.05 segundos
Tiempo predicción:    0.23 segundos
RMSE:                 0.61

Random Forest con XGBoost


In [11]:
# Búsqueda bayesiana de hiperparámetros con optuna
# ==============================================================================
def objective(trial):
    params = {
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000, step=100),
        'max_depth': trial.suggest_int('max_depth', 3, 30),
        'reg_lambda': trial.suggest_float('reg_lambda', 1e-5, 1e+3, log=True),
        'reg_alpha': trial.suggest_float('reg_alpha', 1e-5, 1e+3, log=True),
        'gamma': trial.suggest_float('gamma', 1e-5, 1e+3, log=True),
        'subsample': trial.suggest_float('subsample', 0.5, 1),
        'colsample_bynode': trial.suggest_float('colsample_bynode', 0.5, 1),
    }

    model = XGBRFRegressor(
                    tree_method        = 'hist',
                    grow_policy        = 'depthwise',
                    learning_rate      = 1.0,
                    n_jobs             = -1,
                    random_state       = 4576,
                    enable_categorical = True,
                    **params
            )
    model.fit(X_train, y_train)
    predictions = model.predict(X_val)
    score = mean_squared_error(y_val, predictions, squared=False)
    return score

study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=25, show_progress_bar=True, timeout=60*10)

print('Mejores hiperparámetros:', study.best_params)
print('Mejor score:', study.best_value)
Mejores hiperparámetros: {'n_estimators': 1000, 'max_depth': 28, 'reg_lambda': 4.019385478115608e-05, 'reg_alpha': 0.017967239941045324, 'gamma': 0.07529217069252889, 'subsample': 0.8072275371971737, 'colsample_bynode': 0.7604468036009921}
Mejor score: 0.43820288664297014
In [12]:
# Random Forest XGBoost con los mejores hiperparámetros encontrados
# ==============================================================================
rf_xgboost = XGBRFRegressor(
                tree_method        = 'hist',
                grow_policy        = 'depthwise',
                learning_rate      = 1.0,
                n_jobs             = -1,
                random_state       = 4576,
                enable_categorical = True,
                **study.best_params
             )

# Entrenamiento del modelo
start = time.time()
rf_xgboost.fit(
        X = pd.concat([X_train, X_val]),
        y = pd.concat([y_train, y_val])
)
end = time.time()
tiempo_entrenamiento_xgboost = end - start

# Predicciones test
start = time.time()
predicciones = rf_xgboost.predict(X=X_test)
end = time.time()
tiempo_prediccion_xgboost = end - start

# Error de test del modelo
rmse_rf_xgboost = mean_squared_error(
                      y_true  = y_test,
                      y_pred  = predicciones,
                      squared = False
                  )
                 
print(f"Tiempo entrenamiento: {tiempo_entrenamiento_xgboost:.2f} segundos")
print(f"Tiempo predicción:    {tiempo_prediccion_xgboost:.2f} segundos")
print(f"RMSE:                 {rmse_rf_xgboost:.2f}")
Tiempo entrenamiento: 123.19 segundos
Tiempo predicción:    0.49 segundos
RMSE:                 0.46

Random Forest con LightGBM


In [13]:
# Búsqueda bayesiana de hiperparámetros con optuna
# ==============================================================================
def objective(trial):
    params = {
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000, step=100),
        'num_leaves': trial.suggest_int('num_leaves', 5, 256),
        'reg_lambda': trial.suggest_float('reg_lambda', 1e-5, 1e+3, log=True),
        'reg_alpha': trial.suggest_float('reg_alpha', 1e-5, 1e+3, log=True),
        'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 100),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.3, 1),
        'colsample_bynode': trial.suggest_float('colsample_bynode', 0.3, 1),
        'subsample': trial.suggest_float('subsample', 0.4, 1),
    }

    model = LGBMRegressor(
                boosting_type  = 'rf',
                learning_rate  = 1.0,
                subsample_freq = 1,
                n_jobs         = -1,
                random_state   = 4576688,
                verbose        = -1,
                **params
            )
    model.fit(X_train, y_train, categorical_feature='auto')
    predictions = model.predict(X_val)
    score = mean_squared_error(y_val, predictions, squared=False)
    return score

study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=25, show_progress_bar=True, timeout=60*5)

print('Mejores hiperparámetros:', study.best_params)
print('Mejor score:', study.best_value)
Mejores hiperparámetros: {'n_estimators': 900, 'num_leaves': 182, 'reg_lambda': 0.0006407955794893048, 'reg_alpha': 0.07336325542256919, 'min_samples_leaf': 11, 'colsample_bytree': 0.8180313370435927, 'colsample_bynode': 0.6470365376300803, 'subsample': 0.7676612983421611}
Mejor score: 0.47846907209163364
In [14]:
# Random Forest LightGBM con los mejores hiperparámetros encontrados
# ==============================================================================
rf_lgbm = LGBMRegressor(
                boosting_type  = 'rf',
                learning_rate  = 1.0,
                subsample_freq = 1,
                n_jobs         = -1,
                random_state   = 4576688,
                verbose        = -1,
                **study.best_params
          )

# Entrenamiento del modelo
start = time.time()
rf_lgbm.fit(X_train, y_train, categorical_feature='auto')
end = time.time()
tiempo_entrenamiento_lgbm = end - start

# Predicciones test
start = time.time()
predicciones = rf_lgbm.predict(X=X_test)
end = time.time()
tiempo_prediccion_lgbm = end - start

# Error de test del modelo
rmse_rf_lgbm = mean_squared_error(
                y_true  = y_test,
                y_pred  = predicciones,
                squared = False
               )

print(f"Tiempo entrenamiento: {tiempo_entrenamiento_lgbm:.2f} segundos")
print(f"Tiempo predicción:    {tiempo_prediccion_lgbm:.2f} segundos")
print(f"RMSE:                 {rmse_rf_lgbm:.2f}")
Tiempo entrenamiento: 4.82 segundos
Tiempo predicción:    0.13 segundos
RMSE:                 0.51

Resultados


In [15]:
# Comparativa de modelos
# ==============================================================================
resultados = pd.DataFrame({
               'rmse': [rmse_rf_sklearn, rmse_rf_xgboost, rmse_rf_lgbm],
               'tiempo_entrenamiento': [
                  tiempo_entrenamiento_sklearn,
                  tiempo_entrenamiento_xgboost,
                  tiempo_entrenamiento_lgbm
                ],
               'tiempo_prediccion': [
                  tiempo_prediccion_sklearn,
                  tiempo_prediccion_xgboost,
                  tiempo_prediccion_lgbm
                ]
               },
               index = [
                  'Random Forest sklearn',
                  'Random Forest XGBoost',
                  'Random Forest LightGBM'
                ]
             )
resultados.style.highlight_min(axis=0, color='green').format(precision=2)
Out[15]:
  rmse tiempo_entrenamiento tiempo_prediccion
Random Forest sklearn 0.61 3.05 0.23
Random Forest XGBoost 0.46 123.19 0.49
Random Forest LightGBM 0.51 4.82 0.13

Note

Esta tabla muestra los resultados en cuento a capacidad predictiva, velocidad de entrenamiento y velocidad de inferencia de los modelos Random Forest de las librerías scikit-learn, XGBoost y LightGBM, cada una con la mejor configuración de hiperparámetros encontrada con Optuna. Por lo tanto, los tiempos de entrenamiento y predicción no son comparables entre sí. Para comparar los tiempos de entrenamiento se debería utilizar los mismos hiparparámetros para cada modelo.

Información de sesión


In [16]:
import session_info
session_info.show(html=False)
-----
lightgbm            4.3.0
matplotlib          3.7.2
numpy               1.25.2
optuna              3.2.0
pandas              2.0.3
session_info        1.0.0
sklearn             1.4.1.post1
xgboost             2.0.3
-----
IPython             8.14.0
jupyter_client      8.3.0
jupyter_core        5.3.1
jupyterlab          4.0.5
notebook            6.5.4
-----
Python 3.11.4 (main, Jul  5 2023, 13:45:01) [GCC 11.2.0]
Linux-5.15.0-1055-aws-x86_64-with-glibc2.31
-----
Session information updated at 2024-03-12 21:48

Bibliografía

Instrucciones para citar

¿Cómo citar este documento?

Si utilizas este documento o alguna parte de él, te agradecemos que lo cites. ¡Muchas gracias!

Random forest con valores nulos y variables categoricas por Joaquín Amat Rodrigo, disponible bajo una licencia Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) en https://www.cienciadedatos.net/documentos/py54-random-forest-valores-nulos-variable-categoricas.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! 😊


Creative Commons Licence
Este documento creado por Joaquín Amat Rodrigo tiene licencia Attribution-NonCommercial-ShareAlike 4.0 International.