Uso de pandas category para codificar variables categóricas en modelos de machine learning

Uso de pandas category para codificar variables categóricas en modelos de machine learning

Joaquín Amat Rodrigo
Marzo, 2024

Introducción

Pandas permite almacenar variables categóricas en un formato más eficiente que el formato de texto. Para ello dispone del tipo de datos category. Internamente pandas almacena cada categoría como un entero y mantiene un mapa que relaciona cada entero con la categoría correspondiente. Por ejemplo, para una columna con los valores ['a', 'b', 'c', 'a'], pandas almacena internamente [0, 1, 2, 0] y mantiene un mapa que relaciona 0 con a, 1 con b y 2 con c.

Este tipo de almacenamiento es más eficiente en términos de memoria y también puede mejorar el rendimiento en algunas operaciones. Sin embargo, puede causar problemas cuando se utilizan como equivalentes variables que, a pesar de tener los mismos valores, el mapa interno de las categorías es diferente.

En este documento se muestra cómo evitar este problema y cómo impacta en modelos de machine learning que gestionan automaticamente las variables de tipo category.

Warning

Las librerías LightGBM, XGBoost y HistGradientBoosting de scikit-learn son capaces de seleccionar automáticamente las variables de tipo `category`. Sin embargo, solo LightGBM y HistGradientBoosting de scikit-learn son capaces de identificar cambios en la codificación entre entrenamiento y nuevos datos, y mantener la coherencia de las categorías. XGBoost no gestiona automáticamente las categorías de forma correcta. Si se utilizan variables de tipo category en un modelo de XGBoost, es necesario asegurarse de que el mapa de categorías es el mismo en el conjunto de entrenamiento y en los nuevos datos.

Librerías

In [108]:
# Librarías
# ==============================================================================
import pandas as pd
import numpy as np
from lightgbm import LGBMRegressor
from sklearn.ensemble import HistGradientBoostingRegressor
from xgboost import XGBRegressor
import sklearn
import lightgbm
import xgboost

print(f"Versión de pandas: {pd.__version__}")
print(f"Versión de scikit-learn: {sklearn.__version__}")
print(f"Versión de lightgbm: {lightgbm.__version__}")
print(f"Versión de xgboost: {xgboost.__version__}")
Versión de pandas: 2.0.3
Versión de scikit-learn: 1.4.1.post1
Versión de lightgbm: 4.3.0
Versión de xgboost: 2.0.3

Recodificar una serie de tipo category

Supongase que se tiene una serie con los valores ['a', 'b', 'c', 'a'] y se desea almacenarla como una serie de tipo category. Por defecto, pandas ordena las categorías alfabéticamente, y les asigna un entero en el rango [0, n-1] donde n es el número de categorías. En este caso, el mapa de categorías sería {'a': 0, 'b': 1, 'c': 2}.

In [109]:
variable_1 = pd.Series(["a", "b", "c", "a"], dtype="category")
mapa_variable_1 = dict(enumerate(variable_1.cat.categories))
mapa_variable_1
Out[109]:
{0: 'a', 1: 'b', 2: 'c'}

Se crea ahora otra variable que tiene los mismos valores, pero en un orden diferente: ['b', 'c', 'a', 'b']. Dado que las categorías se ordenan alfabéticamente, el mapa de categorías sería el mismo que en el caso anterior.

In [110]:
variable_2 = pd.Series(['b', 'c', 'a', 'b'], dtype="category")
mapa_variable_2 = dict(enumerate(variable_2.cat.categories))
mapa_variable_2
Out[110]:
{0: 'a', 1: 'b', 2: 'c'}

Sin embargo, si se crea una variable con un subconjunto de las categorías anteriores, el mapa de categorías será diferente ya que al ordenarlas, las posiciones de las categorías cambian. Por ejemplo, si se crea una variable con los valores ['b', 'c', 'b'], el mapa de categorías sería {'b': 0, 'c': 1}.

In [111]:
variable_3 = pd.Series(['b', 'c', 'b'], dtype="category")
mapa_variable_3 = dict(enumerate(variable_3.cat.categories))
mapa_variable_3
Out[111]:
{0: 'b', 1: 'c'}

Para asegurar que el mapa de categorías es el mismo para todas las variables que representan la misma información, se puede especificar el orden de las categorías al crear la serie de tipo category.

In [112]:
categorias = ['a', 'b', 'c']
variable_1 = pd.Series(pd.Categorical(
                ["a", "b", "c", "a"],
                categories=categorias,
                ordered=False
            ))
mapa_variable_1 = dict(enumerate(variable_1.cat.categories))

variable_2 = pd.Series(pd.Categorical(
                ['b', 'c', 'b'],
                categories=categorias,
                ordered=False
            ))
mapa_variable_2 = dict(enumerate(variable_2.cat.categories))

print(mapa_variable_1)
print(mapa_variable_2)
{0: 'a', 1: 'b', 2: 'c'}
{0: 'a', 1: 'b', 2: 'c'}

Tambien se puede recodificar una serie de tipo category para que tenga el mismo mapa de categorías que otra serie.

In [113]:
variable_1 = pd.Series(["a", "b", "c", "a"], dtype="category")
variable_2 = pd.Series(['b', 'c', 'b'], dtype="category")

variable_2 = pd.Series(pd.Categorical(
                variable_2,
                categories=variable_1.cat.categories,
                ordered=False
            ))

mapa_variable_1 = dict(enumerate(variable_1.cat.categories))
mapa_variable_2 = dict(enumerate(variable_2.cat.categories))
print(mapa_variable_1)
print(mapa_variable_2)
{0: 'a', 1: 'b', 2: 'c'}
{0: 'a', 1: 'b', 2: 'c'}

Codigos de categórias en modelos de machine learning

Los códigos internos utilizados por pandas para las categorías tienen especial relevancia en modelos de machine learning que gestionan automáticamente las variables de tipo category, por ejemplo, LightGBM, XGBoost y HistGradientBoosting. Esto es así porque estos modelos utilizan los códigos internos de las categorías, no las categorías en sí. Por lo tanto, es muy importante que al realizar las predicciones, las categorías tengan el mismo mapa de códigos internos que en el momento de entrenar el modelo.

En los siguinetes ejemplos se comprueba si las implementaciones de LightGBM, XGBoost y HistGradientBoosting gestionan automáticamente las categorías de forma correcta.

In [114]:
# Datos
# ==============================================================================
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', 'postcode', 'ciudad_mas_cercana', 'distancia_ciudad_mas_cercana']
)
print(f"Dimensiones del dataset: {datos.shape}")
datos.head()
Dimensiones del dataset: (20640, 8)
Out[114]:
MedInc HouseAge AveRooms AveBedrms Population AveOccup MedHouseVal county
0 8.3252 41.0 6.984127 1.023810 322.0 2.555556 4.526 Contra Costa County
1 8.3014 21.0 6.238137 0.971880 2401.0 2.109842 3.585 Contra Costa County
2 7.2574 52.0 8.288136 1.073446 496.0 2.802260 3.521 Alameda County
3 5.6431 52.0 5.817352 1.073059 558.0 2.547945 3.413 Alameda County
4 3.8462 52.0 6.281853 1.081081 565.0 2.181467 3.422 Alameda County
In [115]:
# Variables categóricas
# ==============================================================================
datos['county'] = datos['county'].astype('category')
datos.dtypes
Out[115]:
MedInc          float64
HouseAge        float64
AveRooms        float64
AveBedrms       float64
Population      float64
AveOccup        float64
MedHouseVal     float64
county         category
dtype: object
In [116]:
# Mapa de categorías
# ==============================================================================
mapa_county = dict(enumerate(datos['county'].cat.categories))
mapa_county
Out[116]:
{0: 'Alameda County',
 1: 'Alpine County',
 2: 'Amador County',
 3: 'Butte County',
 4: 'Calaveras County',
 5: 'Colusa County',
 6: 'Contra Costa County',
 7: 'Curry County',
 8: 'Del Norte County',
 9: 'Douglas County',
 10: 'El Dorado County',
 11: 'Fresno County',
 12: 'Glenn County',
 13: 'Humboldt County',
 14: 'Imperial County',
 15: 'Inyo County',
 16: 'Kern County',
 17: 'Kings County',
 18: 'La Paz County',
 19: 'Lake County',
 20: 'Lassen County',
 21: 'Los Angeles',
 22: 'Madera County',
 23: 'Marin County',
 24: 'Mariposa County',
 25: 'Mendocino County',
 26: 'Merced County',
 27: 'Modoc County',
 28: 'Mohave County',
 29: 'Mono County',
 30: 'Monterey County',
 31: 'Municipio de Tecate',
 32: 'Municipio de Tijuana',
 33: 'Napa County',
 34: 'Nevada County',
 35: 'Orange County',
 36: 'Placer County',
 37: 'Plumas County',
 38: 'Riverside County',
 39: 'Sacramento County',
 40: 'San Benito County',
 41: 'San Bernardino County',
 42: 'San Diego County',
 43: 'San Joaquin County',
 44: 'San Luis Obispo County',
 45: 'San Mateo County',
 46: 'Santa Barbara County',
 47: 'Santa Clara County',
 48: 'Santa Cruz County',
 49: 'Shasta County',
 50: 'Sierra County',
 51: 'Siskiyou County',
 52: 'Solano County',
 53: 'Sonoma County',
 54: 'Stanislaus County',
 55: 'Sutter County',
 56: 'Tehama County',
 57: 'Trinity County',
 58: 'Tulare County',
 59: 'Tuolumne County',
 60: 'Ventura County',
 61: 'Washoe County',
 62: 'Yolo County',
 63: 'Yuba County',
 64: 'Yuma County'}

LightGBM

In [117]:
# Modelo LightGBM
# ==============================================================================
modelo = LGBMRegressor(verbose=-1, random_state=6987)
modelo.fit(
    X = datos.drop(columns='MedHouseVal'),
    y = datos['MedHouseVal'],
    categorical_feature='auto'
)
modelo
Out[117]:
LGBMRegressor(random_state=6987, verbose=-1)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
In [118]:
# Variables categóricas que se han utilizado en el entrenamiento del modelo
# ==============================================================================
cat_index = modelo.booster_.params.get('categorical_column')
if cat_index is not None:
    features_in_model = modelo.booster_.feature_name()
    cat_features_in_model = [features_in_model[i] for i in cat_index]
cat_features_in_model
Out[118]:
['county']

Se crean dos observaciones de test, ambas iguales pero con una codificación interna distinta de la variable categorica county.

In [119]:
test_1 = datos.iloc[0:1, :].drop(columns='MedHouseVal').copy()
display(test_1)
print(f"Código: {test_1['county'][0]} -- {test_1['county'].cat.codes[0]}")
MedInc HouseAge AveRooms AveBedrms Population AveOccup county
0 8.3252 41.0 6.984127 1.02381 322.0 2.555556 Contra Costa County
Código: Contra Costa County -- 6
In [120]:
test_2 = pd.DataFrame([{
  'MedInc': 8.3252,
  'HouseAge': 41.0,
  'AveRooms': 6.984126984126984,
  'AveBedrms': 1.0238095238095235,
  'Population': 322.0,
  'AveOccup': 2.555555555555556,
  'county': 'Contra Costa County',
}])

test_2['county'] = test_2['county'].astype('category')
display(test_2)
print(f"Código: {test_2['county'][0]} -- {test_2['county'].cat.codes[0]}")
MedInc HouseAge AveRooms AveBedrms Population AveOccup county
0 8.3252 41.0 6.984127 1.02381 322.0 2.555556 Contra Costa County
Código: Contra Costa County -- 0

En test_1 la variable county sigue el mismo mapa de categorías que en el conjunto de entrenamiento, la categoría Contra Costa County se codifica con el valor 6. En test_2 la variable county sigue un mapa de categorías distinto, la categoría Contra Costa County se codifica con el valor 0.

In [121]:
# Predicciones
# ==============================================================================
prediccion_1 = modelo.predict(test_1)
prediccion_2 = modelo.predict(test_2)
print(f"Predicción 1: {prediccion_1}")
print(f"Predicción 2: {prediccion_2}")
Predicción 1: [4.25372435]
Predicción 2: [4.25372435]

LightGBM identifica cambios en el mapa de categorías y, si es distinto en el conjunto de test que en el conjunto de entrenamiento, utiliza el de test. Para más información ver github issue.

HistGradientBoosting

In [122]:
# Modelo HistGradientBoostingRegressor
# ==============================================================================
modelo = HistGradientBoostingRegressor(
            categorical_features = 'from_dtype',
            random_state=6987
         )

modelo.fit(
    X = datos.drop(columns='MedHouseVal'),
    y = datos['MedHouseVal'],
)
modelo
Out[122]:
HistGradientBoostingRegressor(categorical_features='from_dtype',
                              random_state=6987)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
In [123]:
# Variables categóricas que se han utilizado en el entrenamiento del modelo
# ==============================================================================
modelo.feature_names_in_[modelo.is_categorical_]
Out[123]:
array(['county'], dtype=object)
In [124]:
# Predicciones
# ==============================================================================
prediccion_1 = modelo.predict(test_1)
prediccion_2 = modelo.predict(test_2)
print(f"Predicción 1: {prediccion_1}")
print(f"Predicción 2: {prediccion_2}")
Predicción 1: [4.12775401]
Predicción 2: [4.12775401]

En vista de los resultados, HistGradientBoosting también gestiona automáticamente las categorías de forma correcta.

XGBoost


In [125]:
# Modelo XGBoost
# ==============================================================================
modelo = XGBRegressor(
            enable_categorical=True,
            random_state=6987
         )

modelo.fit(
    X = datos.drop(columns='MedHouseVal'),
    y = datos['MedHouseVal'],
)
modelo
Out[125]:
XGBRegressor(base_score=None, booster=None, callbacks=None,
             colsample_bylevel=None, colsample_bynode=None,
             colsample_bytree=None, device=None, early_stopping_rounds=None,
             enable_categorical=True, eval_metric=None, feature_types=None,
             gamma=None, grow_policy=None, importance_type=None,
             interaction_constraints=None, learning_rate=None, max_bin=None,
             max_cat_threshold=None, max_cat_to_onehot=None,
             max_delta_step=None, max_depth=None, max_leaves=None,
             min_child_weight=None, missing=nan, monotone_constraints=None,
             multi_strategy=None, n_estimators=None, n_jobs=None,
             num_parallel_tree=None, random_state=6987, ...)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
In [126]:
# Variables categóricas que se han utilizado en el entrenamiento del modelo
# ==============================================================================
feature_types = np.array(modelo.get_booster().feature_types)
features_in_model = np.array( modelo.get_booster().feature_names)
features_in_model[feature_types == 'c']
Out[126]:
array(['county'], dtype='<U10')
In [127]:
# Datos de test
# ==============================================================================
display(test_1)
print(f"Código: {test_1['county'][0]} -- {test_1['county'].cat.codes[0]}")
display(test_2)
print(f"Código: {test_2['county'][0]} -- {test_2['county'].cat.codes[0]}")
MedInc HouseAge AveRooms AveBedrms Population AveOccup county
0 8.3252 41.0 6.984127 1.02381 322.0 2.555556 Contra Costa County
Código: Contra Costa County -- 6
MedInc HouseAge AveRooms AveBedrms Population AveOccup county
0 8.3252 41.0 6.984127 1.02381 322.0 2.555556 Contra Costa County
Código: Contra Costa County -- 0
In [128]:
# Predicciones
# ==============================================================================
prediccion_1 = modelo.predict(test_1)
prediccion_2 = modelo.predict(test_2)
print(f"Predicción 1: {prediccion_1}")
print(f"Predicción 2: {prediccion_2}")
Predicción 1: [4.369355]
Predicción 2: [3.983941]

Las predicciones son distintas. Esto se debe a que, a pesar de que el valor de county es el mismo en ambos casos, el código interno de la categoría es distinto. XGBoost no gestiona automáticamente las categorías de forma correcta.

Vease lo que ocurre si se recodifica la variable county de test_2 para que tenga el mismo mapa de categorías que test_1.

In [129]:
# Recodificación de variables categóricas
# ==============================================================================
test_2['county'] = pd.Categorical(
    test_2['county'],
    categories=test_1['county'].cat.categories,
    ordered=False
)
display(test_2)
print(f"Código: {test_2['county'][0]} -- {test_2['county'].cat.codes[0]}")
MedInc HouseAge AveRooms AveBedrms Population AveOccup county
0 8.3252 41.0 6.984127 1.02381 322.0 2.555556 Contra Costa County
Código: Contra Costa County -- 6
In [130]:
# Predicciones
# ==============================================================================
prediccion_1 = modelo.predict(test_1)
prediccion_2 = modelo.predict(test_2)
print(f"Predicción 1: {prediccion_1}")
print(f"Predicción 2: {prediccion_2}")
Predicción 1: [4.369355]
Predicción 2: [4.369355]

Las predicciones son ahora iguales.

Warning

XGBoost no gestiona automáticamente las categorías de forma correcta. Si se utilizan variables de tipo `category` en un modelo de XGBoost, es necesario asegurarse de que el mapa de categorías es el mismo en el conjunto de entrenamiento y en los nuevos datos.

Información de sesión

In [131]:
import session_info
session_info.show(html=False)
-----
lightgbm            4.3.0
numpy               1.25.2
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-14 09:18

%%html

Instrucciones para citar

¿Cómo citar este documento?

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

Uso de pandas category para codificar variables categóricas en modelos de machine learning 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/py55-pandas-category-modelos-machine-learning.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.