Tutorial: manejo de datos categóricos faltantes

En este tutorial vamos a ver las principales técnicas para realizar el manejo de datos faltantes cuando los datos que queremos completar son de tipo categórico.

Video

Como siempre, en el canal de YouTube se encuentra el video de este post:

Introducción

Una fase esencial en cualquier proyecto de Ciencia de Datos y Machine Learning es la preparación de los datos.

Y cuando tenemos datos tabulares, muchas veces esa preparación de los datos implica realizar algo que se conoce como el manejo de datos faltantes, es decir datos que están incompletos.

Entonces en este tutorial vamos a ver las principales técnicas para realizar el manejo de datos faltantes cuando los datos que queremos completar son de tipo categórico.

Así que vamos a entender cada una de esas técnicas y vamos a ver cómo usar Python y librerías como Pandas y Scikit-Learn para realizar el manejo de este tipo de datos faltantes.

Al final de este tutorial se encuentra el enlace para descargar el set de datos y el código fuente.

El set de datos

Comencemos cargando nuestro set de datos y viendo sus características.

En primer lugar carguemos la librería Pandas para realizar la lectura del set de datos:

import pandas as pd

Ahora usemos el método read_csv de Pandas para leer el set de datos (archivo dataset_datos_faltantes_categoricos.csv):

datos = pd.read_csv('dataset_datos_faltantes_categoricos.csv')

con lo cual obtenemos este DataFrame de Pandas:

sexo	peso (kg)	altura (cm)
0	femenino	60.0	160.0
1	masculino	69.0	170.0
2	masculino	73.0	167.0
3	NaN	        81.0	170.0
4	masculino	80.0	170.0
...	...	...	...
595	femenino	65.0	164.0
596	femenino	63.0	160.0
597	masculino	70.0	163.0
598	masculino	75.0	169.0
599	femenino	66.0	157.0
600 rows × 3 columns

Como vemos, el set de datos contiene la información del sexo, el peso (en Kg) y la altura (en cm) de un grupo de 600 personas.

En este caso las columnas peso (kg) y altura (cm) son variables numéricas, mientras que la columna sexo es una variable categórica: nos indica las posibles categorías a las que puede pertenecer cada persona en el set de datos (masculino o femenino).

Detección de los datos faltantes

Una vez leído el set de datos, el siguiente paso es determinar si tenemos datos faltantes.

Si, por ejemplo, miramos en detalle la fila 3 del dataset que acabamos de leer veremos lo siguiente:

3	NaN	        81.0	170.0

En este caso vemos que en la columna sexo aparece un valor NaN (Not a Number) lo cual implica que en la tabla original (archivo CSV) esta celda estaba vacía y al momento de leerla Pandas asigna este tipo de dato a dicha celda. En resumen: ¡esto es un dato faltante!

La idea es verificar en total cuántos datos faltantes tendremos de este tipo dentro del DataFrame.

En Pandas existen diferentes formas de lograr esto, pero una de ellas es usando el método info(), que imprime en pantalla la información detallada de cada columna del DataFrame:

datos.info()

Tras hacer esto obtenemos este resultado:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 600 entries, 0 to 599
Data columns (total 3 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   sexo         570 non-null    object 
 1   peso (kg)    600 non-null    float64
 2   altura (cm)  600 non-null    float64
dtypes: float64(2), object(1)
memory usage: 14.2+ KB

Vemos que el set de datos contiene 600 columnas (RangeIndex: 600 entries). Al analizar cada columna, observamos lo siguiente:

Lo anterior lo podemos verificar también combinando los métodos isna() (que permite determinar qué filas del dataset contienen datos faltantes, almacenados en formato NaN: Not a Number) junto con el método sum() (que calcula la suma total de esos datos):

datos.isna().sum()

con lo que obtenemos este resultado:

sexo           30
peso (kg)       0
altura (cm)     0
dtype: int64

Vemos que efectivamente, la columna sexo contiene 30 datos faltantes.

Otra forma de ver esto de manera un poco más detallada es accediendo a la columna sexo y aplicando el método value_counts(), que nos permite realizar un conteo de las diferentes categorías presentes en esta columna:

datos['sexo'].value_counts()

lo cual nos arroja este resultado:

masculino    288
femenino     282
Name: sexo, dtype: int64

Vemos que en total tenemos 288 datos en la categoría 'masculino' y 282 en la categoría 'femenino', que en total suman 570.

Muy bien, con los métodos vistos anteriormente hemos confirmado que la columna sexo (que contiene datos categóricos) contiene un total de 30 datos faltantes.

Así que, en lo que resta de este tutorial, veremos de forma práctica qué son y como implementar las diferentes técnicas para realizar el manejo de estos datos faltantes.

Manejo de datos categóricos faltantes

En esencia podemos usar uno de estos cuatro enfoques para manejar los datos faltantes en los casos en los cuales la variable “problemática” es de tipo categórico:

  1. Eliminar las filas con los registros faltantes
  2. Eliminar la columna “problemática”
  3. Imputar con la categoría más frecuente
  4. Imputar usando técnicas de Machine Learning

Veamos en detalle cada una de estas técnicas junto con sus ventajas y desventajas y su implementación práctica usando Python y las librerías Pandas y Scikit Learn.

Eliminar las filas con los registros faltantes

Esta técnica consiste simplemente en quitar la fila completa para cada registro faltante.

Por ejemplo, si tenemos una fila con estos tres registros de sexo, peso y altura:

NaN	        81.0	170.0

lo que haríamos sería eliminar la fila por completo, independientemente de que los registros peso (81.0) y altura (170.0) estén completos.

La ventaja de este método es que es muy fácil de implementar (como lo veremos en un momento).

Sin embargo, este enfoque tiene la desventaja de que si el dataset es “pequeño” (si tiene relativamente pocos datos) la eliminación de filas incompletas puede reducir significativamente su tamaño.

Y esta reducción del número de datos puede dificultar tareas posteriores como, por ejemplo, el uso de modelos de Machine Learning para generar predicciones o incluso el Análisis Exploratorio de Datos.

Para implementar este método podemos usar el método dropna() de Pandas, que permite eliminar los registros que contienen datos NaN:

df_filas = datos.dropna(axis=0)
df_filas.info()

En el código anterior hemos usado el argumento axis=0 para especificar que la eliminación se hará por filas, y el DataFrame resultante ha sido almacenado en la variable df_filas.

El método info() usado en el código anterior nos permite ver las características de la tabla resultante:

<class 'pandas.core.frame.DataFrame'>
Int64Index: 570 entries, 0 to 599
Data columns (total 3 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   sexo         570 non-null    object 
 1   peso (kg)    570 non-null    float64
 2   altura (cm)  570 non-null    float64
dtypes: float64(2), object(1)
memory usage: 17.8+ KB

Y con esto podemos ver que ahora en lugar de 600 registros tendremos una tabla con sólo 570, lo cual quiere decir que las 30 filas que contenían datos faltantes han sido eliminadas.

Eliminar la columna “problemática”

Este método es similar al anterior, pero en lugar de eliminar las filas (incluso si estas contienen algunos registros completos) lo que haremos será eliminar únicamente la columna que contiene los datos faltantes (en este caso la columna sexo).

La ventaja de este método es que su implementación es sencilla y que preservaremos las columnas restantes así como el número total de registros (es decir que en este caso seguiremos teniendo 600 filas).

Sin embargo, la desventaja es que al eliminar la columna “problemática” estaríamos quitando por completo una variable que podría contener información relevante para el problema que estemos resolviendo. De hecho, podríamos preguntarnos si realmente vale la pena eliminar toda una columna cuando tan sólo faltan unos cuantos datos.

La manera de implementar este método haciendo uso de Pandas es muy similar al caso anterior, es decir con el método dropna():

df_cols = datos.dropna(axis=1)
df_cols.info()

Sin embargo, en este caso lo único que tenemos que modificar es el argumento usado (axis=1) con lo cual le indicamos a Pandas que debe eliminar la columnas que contengan datos tipo NaN.

Al ejecutar el método info() obtenemos este DataFrame resultante:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 600 entries, 0 to 599
Data columns (total 2 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   peso (kg)    600 non-null    float64
 1   altura (cm)  600 non-null    float64
dtypes: float64(2)
memory usage: 9.5 KB

Y vemos que hemos preservado los 600 registros pero que ahora tenemos sólo dos columnas.

Imputar con la categoría más frecuente

Una tercera alternativa para el manejo de datos categóricos faltantes es la imputación con la categoría más frecuente.

Con el término “imputación” nos referimos a que reemplazaremos los datos faltantes con un valor específico, que en este caso podría ser la categoría masculino o femenino.

En particular, con este método lo que haremos será imputar los valores faltantes con la categoría más frecuente (la que más se repite en el set de datos).

La ventaja de este método es que no eliminaremos ni filas ni columnas del dataset original, es decir que en principio no tendremos pérdida de la cantidad de datos.

Sin embargo, la desventaja es que este método puede generar sesgos en el set de datos resultante: si reemplazamos los datos faltantes con la categoría más frecuente, como resultado tendremos ahora más datos de esa categoría que de la categoría minoritaria.

Y este sesgo podría afectar por ejemplo el análisis que realicemos posteriormente, o podría afectar el entrenamiento de un modelo de Machine Learning (que generalmente requiere que las categorías estén correctamente balanceadas).

Para implementar esta técnica en Pandas debemos primero determinar cuál es la categoría más frecuente. Para ello extraemos la columna sexo del set de datos original y luego usamos el método value_count() para obtener el conteo de cada categoría:

datos['sexo'].value_counts()

lo que nos arroja como resultado:

masculino    288
femenino     282
Name: sexo, dtype: int64

Es decir que, para este caso particular, la categoría mayoritaria es masculino y por tanto usaremos esta categoría para realizar la imputación de los datos faltantes.

Para realizar esta imputación podemos primero crear una copia del DataFrame original, de manera tal que al realizar la imputación no modifiquemos dicha tabla:

df_frec = datos.copy()

Y luego podemos usar el método fillna() para reemplazar los valores NaN en la columna sexo con el valor especificado (masculino):

df_frec['sexo'] = df_frec['sexo'].fillna('masculino')

¡Y listo, ya tenemos la imputación usando la categoría más frecuente!

Verifiquemos que ya no tenemos datos faltantes:

df_frec['sexo'].isna().sum()

lo que nos arroja un valor, impreso en pantalla, exactamente igual a cero.

Imputar usando técnicas de Machine Learning

Este es el método más robusto y consiste en construir un modelo de Machine Learning que tome las variables que están completas (por ejemplo peso (kg) y altura (cm)) y aprenda a predecir la variable faltante (en este caso sexo).

La ventaja de este método es que permite preservar la cantidad de datos (filas y columnas con respecto al DataFrame original) y que, generalmente, no generará sesgos en el dataset resultante (siempre y cuando el modelo pueda ser entrenado correctamente).

Sin embargo tiene algunas desventajas:

  1. La primera es que se requieren suficientes datos para entrenar el modelo. Es decir, si hay demasiados datos faltantes no tendremos una cantidad adecuada de datos para entrenar el modelo.
  2. Y la segunda desventaja es que dependiendo de las características propias de nuestros datos, en ocasiones no resulta sencillo construir un modelo que genere las predicciones adecuadas.

El primer paso en la implementación de este método es crear el set de entrenamiento, que será usado para que el modelo aprenda a reconocer los patrones en los datos y a generar las predicciones esperadas.

En este caso particular el set de entrenamiento corresponderá a las 570 filas que no contienen datos faltantes en el DataFrame original.

Para extraer esta porción del dataset podemos usar el método dropna() de Pandas, que permite eliminar las filas incompletas:

XY = datos.dropna().to_numpy()

En la línea de código anterior hemos usado adicionalmente el método to_numpy() para generar como resultado un arreglo de NumPy, que es el formato requerido por el modelo que construiremos en un momento.

Como resultado de lo anterior tendremos el arreglo XY que contendrá 570 filas y 3 columnas, correspondientes a la información sobre el sexo, el peso y la edad.

Pero, adicionalmente debemos dividir este arreglo en dos partes: las columnas 2 y 3 (que contienen el peso y la altura) serán usadas como entrada al modelo que queremos construir, mientras que la columna 1 (que contiene la información sobre el sexo) será usada como salida de dicho modelo.

De esta forma, tras el entrenamiento, el modelo aprenderá a tomar datos de peso y altura a la entrada y a predecir la categoría (masculino o femenino).

Para crear estos dos arreglos (entrada y salida) podemos usar las técnicas básicas de indexación de arreglos de NumPy:

x_train = XY[:,1:3]
y_train = XY[:,0]

Como resultado de lo anterior tendremos:

El siguiente paso es crear el set de prueba: el set de datos que usaremos para generar las predicciones con el modelo ya entrenado.

Es decir que en este caso nuestro set de prueba provendrá de los datos faltantes (30 filas). La idea en este caso es que:

Para crear este set de prueba comencemos extrayendo la posición de las filas con datos faltantes dentro del DataFrame original:

filas = datos[~datos['sexo'].notna()].index

En este caso hemos buscado las filas completas en la columna sexo usando el método notna() (datos['sexo'].notna()) y luego hemos usado la negación lógica de esta operación (~) lo que nos arroja como resultado las filas incompletas. Luego hemos usado el atributo index para extraer la ubicación de dichas filas dentro del DataFrame original.

Ahora ya podemos crear el set de entrenamiento haciendo uso de esta variable filas que acabamos de obtener:

x_test = datos[['peso (kg)', 'altura (cm)']].iloc[filas].to_numpy()

donde:

El arreglo x_test resultante será entonces un arreglo de NumPy que contendrá únicamente valores numéricos correspondientes al peso (columna 1) y la altura (columna 2) de los 30 sujetos para los cuales desconocemos el sexo:

array([[ 81., 170.],
       [ 68., 166.],
       [ 62., 164.],
       [ 63., 153.],
       [ 57., 155.],
       [ 63., 163.],
       [ 73., 176.],
       [ 64., 167.],
       [ 71., 168.],
       [ 80., 163.],
       [ 69., 157.],
       [ 58., 167.],
       [ 72., 173.],
       [ 62., 159.],
       [ 66., 155.],
       [ 66., 162.],
       [ 58., 161.],
       [ 87., 171.],
       [ 68., 169.],
       [ 67., 161.],
       [ 58., 160.],
       [ 62., 155.],
       [ 63., 153.],
       [ 70., 166.],
       [ 60., 162.],
       [ 65., 159.],
       [ 71., 169.],
       [ 70., 172.],
       [ 63., 165.],
       [ 63., 164.]])

Muy bien, habiendo creado los sets de entrenamiento y prueba podemos enfocarnos en el tercer paso que consiste en escoger y entrenar el modelo de Machine Learning.

Para este sencillo set de datos podemos usar simplemente un modelo de Regresión Logística.

Pero antes de hacer esto debemos pre-procesar los datos, pues las categorías a predecir (masculino, femenino) no pueden estar en formato de texto sino que deben estar en formato numérico (0 ó 1), pues realmente todos los modelos de Machine Learning procesan los datos internamente en formato numérico.

Para hacer esta conversión podemos usar el módulo LabelEncoder de la librería Scikit Learn, que permite tomar cada categoría y representarla numéricamente.

Primero importamos la librería y generamos una instancia de LabelEncoder:

from sklearn.preprocessing import LabelEncoder

le = LabelEncoder()

Ahora usamos el método fit_transform() del LabelEncoder para que determine las categorías presentes en y_train y genere la codificación correspondiente:

le.fit(y_train)

Finalmente, usamos el LabelEncoder y el método transform() para generar la codificación final. En este caso sobre-escribiremos la variable y_train:

y_train = le.transform(y_train)

Y listo, ahora y_train será un arreglo de 570 elementos con valores de 0 y 1 en lugar de las categorías masculino y femenino.

Si por ejemplo queremos obtener la equivalencia entre categorías numéricas (0 ó 1) y las categorías originales (masculino ó femenino) podemos usar el método inverse_transform():

le.inverse_transform([0,1,1,0])

En el caso anterior hicimos la transformación inversa de las categorías 0, 1, 1 y 0, obteniendo:

array(['femenino', 'masculino', 'masculino', 'femenino'], dtype=object)

¡Perfecto! En este punto ya tenemos los sets de entrenamiento (x_train, y_train), el set de prueba (x_test), el LabelEncoder (le) y hemos codificado numéricamente las categorías presentes en y_train. Ahora sí estamos listos para entrenar el modelo.

Para construir el modelo de regresión logística primero importamos y creamos una instancia del módulo LogisticRegression de Scikit-Learn:

from sklearn.linear_model import LogisticRegression

lr = LogisticRegression()

Y ahora entrenamos el modelo (lr) usando el método fit() y el set de entrenamiento:

lr.fit(x_train,y_train)

Muy bien, la variable lr ahora contiene el modelo entrenado.

El siguiente paso es generar las predicciones sobre el set de prueba. Recordemos que el objetivo de esta fase es tomar los datos conocidos de peso y altura (30 registros) y predecir la variable sexo (es decir los datos faltantes).

Para hacer esta predicción simplemente debemos tomar el modelo entrenado (lr) y generar las predicciones sobre el set de prueba (x_test) usando el método predict():

preds = lr.predict(x_test)
print(preds)

Al imprimir estas predicciones (variable preds) obtenemos este resultado:

[1 1 0 0 0 0 1 1 1 1 0 0 1 0 0 0 0 1 1 0 0 0 0 1 0 0 1 1 0 0]

Este arreglo con 30 datos es en esencia el conjunto de categorías predichas para los datos faltantes, el cual debemos transformar a su representación en formato de string (masculino o femenino). Para esto usamos el método inverse_transform() del LabelEncoder, visto anteriormente:

cats = le.inverse_transform(preds)
print(cats)

La variable cats contiene las categorías predichas para los datos faltantes. Al imprimirla en pantalla obtenemos este resultado:

['masculino' 'masculino' 'femenino' 'femenino' 'femenino' 'femenino'
 'masculino' 'masculino' 'masculino' 'masculino' 'femenino' 'femenino'
 'masculino' 'femenino' 'femenino' 'femenino' 'femenino' 'masculino'
 'masculino' 'femenino' 'femenino' 'femenino' 'femenino' 'masculino'
 'femenino' 'femenino' 'masculino' 'masculino' 'femenino' 'femenino']

¡¡¡Perfecto!!! Lo único que queda es tomar estas categorías predichas y realizar la imputación, es decir regresar al set de datos y reemplazar las celdas NaN de la columna sexo con estas predicciones.

Para lograr esto, generaremos primero una copia del DataFrame original y luego usaremos el método iloc para hacer los reemplazos en las filas correspondientes de la tabla resultante:

df_ml = datos.copy()
df_ml.iloc[filas,0]= cats # "sexo" es la columna 0

¡Y en este punto ya hemos realizado la imputación de datos faltantes usando técnicas de Machine Learning!

Antes de finalizar podemos verificar que el DataFrame resultante no contiene datos faltantes:

df_ml['sexo'].isna().sum()

¡¡¡obteniendo como resultado un valor igual a 0!!!

Enlace de descarga

En este enlace se encuentran el notebook y el set de datos usados en este tutorial.

Conclusión

Muy bien, acabamos de entender las principales técnicas para el manejo de datos faltantes para el caso de datos categóricos y vimos además cómo realizar este manejo usando Python y las librerías Pandas y Scikit-Learn.

En últimas vimos que la eliminación de filas o columnas es el método más sencillo pero no es el ideal pues implica que perderemos información, mientras que la imputación con la categoría más frecuente es una alternativa pero que puede generar sesgo en nuestros datos.

Y vimos además que la técnica más robusta consiste en entrenar un modelo de Machine Learning que aprenda a predecir esas categorías faltantes con base en los datos conocidos.