2  Preparación de datos

2.1 Manejando tipos de datos

2.1.1 ¿De qué se trata esto?

Una de las primeras cosas que hay que hacer cuando empezamos un proyecto de datos es asignar el tipo de datos correcto para cada variable. Aunque esto parece una tarea sencilla, algunos algoritmos funcionan con ciertos tipos de datos. Aquí trataremos de cubrir estas conversiones mientras explicamos con ejemplos las implicaciones en cada caso.

Espiral de Fibonacci

La sucesión de Fibonacci. Una secuencia de números presente en la naturaleza y en los cuerpos humanos.


¿Qué vamos a repasar en este capítulo?

  • Detección del tipo de datos correcto
  • Cómo convertir de categórico a numérico
  • Cómo convertir de numérico a categórico (métodos de discretización)
  • Aspectos teóricos y prácticos (ejemplos en Python)
  • Cómo observa las variables numéricas un modelo predictivo


2.1.2 El universo de los tipos de datos

Hay dos tipos principales de datos, numérico y categórico. Otros nombres para categóricos son string y nominal.

Un subconjunto de categórico es el ordinal o, como se lo llama en Python, un Categorical ordenado. Este tipo es relevante cuando se grafican categorías en un orden determinado. Un ejemplo en Python:

# Crear un factor ordinal u ordenado
var_factor = pd.Categorical(["3_high", "2_mid", "1_low"],
                            ordered=False)
var_ordered = pd.Categorical(var_factor, ordered=True)
print(var_ordered)
['3_high', '2_mid', '1_low']
Categories (3, object): ['1_low' < '2_mid' < '3_high']

No presten demasiada atención a este tipo de datos, ya que los numéricos y categóricos son los más necesarios.


2.1.2.1 Variable binaria: ¿numérica o categórica?

Este libro sugiere utilizar las variables binarias como numéricas cuando 0 es FALSE y 1 es TRUE. Simplifica el análisis matemático de los datos.


2.1.3 Tipos de datos por algoritmo

Algunos algoritmos funcionan de la siguiente manera:

  • Sólo con datos categóricos
  • Sólo con datos numéricos
  • Con ambos tipos

Además, no todos los modelos predictivos pueden manejar valores faltantes.

El libro vivo de ciencia de datos busca cubrir todas estas situaciones.


2.1.4 Convirtiendo variables categóricas en numéricas

Usar la función pd.get_dummies de pandas es una tarea sencilla que convierte cada variable categórica en una variable flag, también conocida como variable dummy.

Si la variable categórica original tiene treinta valores posibles, entonces resultará en 30 nuevas columnas que contengan el valor 0 o 1, donde 1 representa la presencia de esa categoría en la fila.

Con pandas, para esta conversión sólo se necesitan unas pocas líneas de código:

# Detectar variables categóricas
cat_vars = heart_disease.select_dtypes(
    include=['object', 'category']).columns.tolist()
import textwrap as _tw
_cats_str = "Variables categóricas: " + str(cat_vars)
print(_tw.fill(_cats_str, width=60))

# Convertir categóricas a dummies (0/1 enteros)
heart_disease_2 = pd.get_dummies(
    heart_disease, columns=cat_vars,
    drop_first=False, dtype=int)

# Renombrar: separador _ por . (consistencia con R)
for c in cat_vars:
    heart_disease_2.columns = [
        col.replace(f'{c}_', f'{c}.')
        for col in heart_disease_2.columns]

import textwrap
print("\nColumnas del nuevo dataset:")
cols_str = str(list(heart_disease_2.columns))
print(textwrap.fill(cols_str, width=60))
Variables categóricas: ['chest_pain', 'resting_electro',
'slope', 'thal', 'has_heart_disease']

Columnas del nuevo dataset:
['age', 'sex', 'resting_blood_pressure',
'serum_cholestoral', 'fasting_blood_sugar',
'max_heart_rate', 'exer_angina', 'oldpeak',
'num_vessels_flour', 'has_heart_disease.num',
'chest_pain.1', 'chest_pain.2', 'chest_pain.3',
'chest_pain.4', 'resting_electro.0', 'resting_electro.1',
'resting_electro.2', 'slope.1', 'slope.2', 'slope.3',
'thal.3', 'thal.6', 'thal.7', 'has_heart_disease.no',
'has_heart_disease.yes']

Los datos originales heart_disease han sido convertidos a heart_disease_2 que no tiene variables categóricas, sólo numéricas y dummy. Observe que cada nueva variable tiene un punto seguido por el valor.

Si comprobamos el antes y el después para el séptimo paciente (fila) en la variable chest_pain que puede tomar los valores 1, 2, 3 o 4, entonces

# Antes
print("Antes:")
print(heart_disease.iloc[6]['chest_pain'])

# Después (una fila = un paciente)
print("\nDespués:")
print(heart_disease_2.iloc[[6]][[
    'chest_pain.1', 'chest_pain.2',
    'chest_pain.3', 'chest_pain.4']].to_string())
Antes:
4

Después:
   chest_pain.1  chest_pain.2  chest_pain.3  chest_pain.4
6             0             0             0             1

Habiendo conservado y transformado sólo variables numéricas excluyendo las nominales, los datos heart_disease_2 están listos para ser utilizados.


2.1.5 ¿Es categórica o numérica? Piénsenlo.

Consideren la variable chest_pain, que puede tomar los valores 1, 2, 3, o 4. ¿Es esta variable categórica o numérica?

Si los valores están ordenados, entonces se la puede considerar tan numérica como si exhibiera un orden, es decir, 1 es menos de 2, 2 es menos de 3, y 3 es menos de 4.

Si creamos un modelo de árbol de decisión, entonces podemos encontrar reglas como: “Si el dolor de pecho es > 2.5, entonces…”. ¿Tiene sentido? El algoritmo divide la variable por un valor que no está presente (2.5); sin embargo, la interpretación que hacemos es “si dolor de pecho es igual o superior a 3, entonces…”.


2.1.6 Pensar como un algoritmo

Considere dos variables numéricas de entrada y una variable binaria de destino. El algoritmo verá ambas variables de entrada como puntos en un rectángulo, considerando que hay valores infinitos entre cada número.

Por ejemplo, una Máquina de Soporte Vectorial (SVM) creará varios vectores para separar la clase de la variable de destino. Encontrará regiones basadas en estos vectores. ¿Cómo sería posible encontrar estas regiones basándose en variables categóricas? No es posible y es por eso que el SVM sólo funciona con variables numéricas como en las redes neuronales artificiales.

Máquina de vectores soporte

Image credit: ZackWeinberg

La última imagen muestra tres líneas, que representan tres límites de decisión o regiones diferentes.

Para una rápida introducción a este concepto de SVM, por favor vean este corto video: Demo SVM.

Sin embargo, si el modelo está basado en árboles, como decision trees, random forest o gradient boosting machine, entonces manejan ambos tipos porque su espacio de búsqueda puede ser regiones (igual que SVM) y categorías. Como la regla “si postal_code es AX441AG y tiene más de 55 años, entonces...”.

Volviendo al ejemplo de la enfermedad cardíaca, la variable chest_pain exhibe orden. Debemos aprovechar esto porque si lo convertimos en una variable categórica, entonces estamos perdiendo información y este es un punto importante a la hora de manejar los tipos de datos.


2.1.6.1 ¿Es la solución tratar a todas las variables como categóricas?

No…. Una variable numérica contiene más información que una nominal debido a su orden. En las variables categóricas, los valores no se pueden comparar. Digamos que no es posible hacer una regla como Si el código postal es superior a "AX2004-P".

Los valores de una variable nominal pueden ser comparados si tenemos otra variable para usar como referencia (normalmente un resultado a predecir).

Por ejemplo, el código postal “AX2004-P” es más alto que “MA3942-H” porque hay más personas interesadas en asistir a clases de fotografía.

Además, la alta cardinalidad es un problema en las variables categóricas, por ejemplo, una variable postal code que contiene cientos de valores diferentes. Este libro ha tratado este tema en ambos capítulos: el manejo de variables de alta categorización para estadísticas descriptivas y cuando hacemos modelado predictivo.

De todos modos, pueden hacer la prueba gratis de convertir todas las variables en categóricas y ver qué pasa. Comparen los resultados con las variables numéricas. Recuerden usar alguna buena medida de error para la prueba, como el estadístico Kappa o ROC, y validar los resultados.


2.1.6.2 Tengan cuidado al convertir variables categóricas en numéricas

Imaginemos que tenemos una variable categórica que necesitamos convertir a numérica. Como en el caso anterior, pero intentando una diferente transformación, asignen un número diferente a cada categoría.

Tenemos que tener cuidado al hacer tales transformaciones porque estamos introduciendo orden a la variable.

Considere el siguiente ejemplo de datos con cuatro filas. Las dos primeras variables son visitas y codigo_postal (esto funciona como dos variables de entrada o visitas como entrada y codigo_postal como salida).

El siguiente código mostrará las visitas dependiendo de codigo_postal transformadas según dos criterios:

  • transformacion_1: Asigna un número de secuencia basado en el orden dado.
  • transformacion_2: Asigna un número basado en la cantidad de visitas.
df_pc = pd.DataFrame({
    'visitas': [10, 59, 27, 33],
    'codigo_postal': ['AA1', 'BA5', 'CG3', 'HJ1'],
    'transformacion_1': [1, 2, 3, 4],
    'transformacion_2': [1, 4, 2, 3]
})
print(df_pc.to_string(index=False))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
colors = ['#F8766D', '#00BFC4', '#7CAE00', '#C77CFF']
x_smooth = np.linspace(1, 4, 100)

for ax, tx, deg in [(ax1, 'transformacion_1', 3),
                     (ax2, 'transformacion_2', 1)]:
    for i, row in df_pc.iterrows():
        ax.scatter(row[tx], row['visitas'],
                   c=colors[i], s=100, zorder=5)
        ax.annotate(row['codigo_postal'],
                    (row[tx], row['visitas']),
                    fontsize=9, ha='center', va='bottom',
                    xytext=(0, 8),
                    textcoords='offset points',
                    bbox=dict(boxstyle='round,pad=0.3',
                              fc=colors[i], alpha=0.7),
                    color='white', fontweight='bold')
    p = np.poly1d(np.polyfit(df_pc[tx],
                             df_pc['visitas'], deg))
    ax.plot(x_smooth, p(x_smooth), 'lightblue',
            linestyle='--')
    ax.set(xlabel=tx, ylabel='visitas',
           title=tx.replace('_', ' ').title())

plt.tight_layout()
plt.show()
 visitas codigo_postal  transformacion_1  transformacion_2
      10           AA1                 1                 1
      59           BA5                 2                 4
      27           CG3                 3                 2
      33           HJ1                 4                 3

Comparación entre transformaciones de datos

Para estar seguros, nadie construye un modelo predictivo usando sólo cuatro filas; sin embargo, la intención de este ejemplo es mostrar cómo la relación cambia de no lineal (transformacion_1) a lineal (transformacion_2). Esto hace las cosas más fáciles para el modelo predictivo y explica la relación.

El efecto es el mismo cuando manejamos millones de filas de datos y el número de variables escala a cientos. Aprender de datos pequeños es un enfoque adecuado en estos casos.


2.1.7 Discretizando variables numéricas

Este proceso convierte los datos en una categoría dividiéndolos en segmentos. Para una definición más sofisticada, podemos citar a Wikipedia: La discretización refiere al proceso de transferir funciones, modelos y ecuaciones continuas a contrapartes discretas.

Los segmentos también se conocen como bins o buckets. Continuemos con los ejemplos.

2.1.7.1 Sobre los datos

Los datos contienen información sobre el porcentaje de niños con retraso en el crecimiento. El valor ideal es cero.

El indicador refleja la proporción de niños menores de 5 años que presentan retraso en el crecimiento. Los niños con retraso en el crecimiento tienen mayor riesgo de enfermedad y muerte.

Fuente: ourworldindata.org, hunger and undernourishment.

En primer lugar, tenemos que hacer una rápida preparación de datos. Cada fila representa un par país-año, por lo que tenemos que obtener el indicador más reciente por país.

data_stunting = pd.read_csv("images/share-of-children-"
    "younger-than-5-who-suffer-from-stunting.csv")

# Renombrar la métrica
data_stunting = data_stunting.rename(columns={
    'Share of stunted children under 5':
    'share_stunted_child'})

# Realizar la agrupación previamente mencionada
d_stunt_grp = (data_stunting
    .sort_values('Year')
    .groupby('Entity')
    .last()
    .reset_index()[['Entity', 'share_stunted_child']])

Los criterios de segmentación más comunes son:

  • Igual rango
  • Igual frecuencia
  • Segmentos personalizados

Todos están explicados a continuación.


2.1.7.2 Igual rango

El rango se suele encuentrar en los histogramas que estudian la distribución, pero es altamente susceptible a los valores atípicos. Para crear, por ejemplo, cuatro segmentos, dividimos por 4 los valores mínimos y máximos.

# pd.cut con igual rango (equivalente a cut_interval)
d_stunt_grp['share_stunted_child_eq_range'] = pd.cut(
    d_stunt_grp['share_stunted_child'], bins=4)

# sort_index() ordena las barras por intervalo
vc = d_stunt_grp[
    'share_stunted_child_eq_range'].value_counts(
    ).sort_index()
print(vc)
vc.plot(kind='bar', color='#009E73', figsize=(5, 3.5))
plt.ylabel('Frecuencia')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
share_stunted_child_eq_range
(1.242, 15.8]    62
(15.8, 30.3]     45
(30.3, 44.8]     37
(44.8, 59.3]     10
Name: count, dtype: int64

Discretización por igual rango

El resultado nos dice que hay cuatro categorías en la variable y, entre paréntesis y corchetes, el número total de casos por categoría. Por ejemplo, la categoría (15.8,30.3] contiene todos los casos que tienen share_stunted_child desde 15.8 (no inclusive) hasta 30.3 (inclusive).


2.1.7.3 Igual frecuencia

Esta técnica agrupa la misma cantidad de observaciones utilizando criterios basados en percentiles. Pueden encontrar más información sobre percentiles en el capítulo: Anexo 1: La magia de los percentiles.

La función equal_freq del paquete funpymodeling (equivalente a funModeling::equal_freq) crea segmentos basándonos en este criterio:

d_stunt_grp['stunt_child_ef'] = equal_freq(
    d_stunt_grp['share_stunted_child'], n_bins=4)

vc = d_stunt_grp['stunt_child_ef'].value_counts(
    ).sort_index()
print(vc)
vc.plot(kind='bar', color='#CC79A7', figsize=(5, 3.5))
plt.ylabel('Frecuencia')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
stunt_child_ef
(1.2990000000000002, 9.4]    40
(9.4, 20.7]                  37
(20.7, 32.75]                38
(32.75, 59.3]                39
Name: count, dtype: int64

Ejemplo de igual frecuencia

En este caso, seleccionamos cuatro segmentos, por lo que cada uno contendrá aproximadamente un 25% del total.


2.1.7.4 Segmentos personalizados

Si ya tenemos los puntos de los cuales queremos los segmentos, podemos usar la función pd.cut.

d_stunt_grp['share_stunted_child_custom'] = pd.cut(
    d_stunt_grp['share_stunted_child'],
    bins=[0, 2, 9.4, 29, 100])

vc = d_stunt_grp[
    'share_stunted_child_custom'].value_counts(
    ).sort_index()
print(vc)
vc.plot(kind='bar', color='#0072B2', figsize=(5, 3.5))
plt.ylabel('Frecuencia')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
share_stunted_child_custom
(0.0, 2.0]        5
(2.0, 9.4]       35
(9.4, 29.0]      65
(29.0, 100.0]    49
Name: count, dtype: int64

Discretización manual

Noten que solo es necesario definir el valor máximo de cada segmento.

Por lo general, no sabemos cuál es el valor mínimo o máximo. En esos casos, podemos utilizar los valores -np.inf e np.inf. De lo contrario, si definimos un valor que está fuera del rango, pd.cut le asignará el valor NaN.

Es una buena práctica asignar el valor mínimo y máximo usando una función. En este caso, la variable es un porcentaje, por lo que sabemos de antemano que su escala es de 0 a 100; sin embargo, Atención: ¿qué pasaría si no conociéramos el rango?

La función devolverá NaN para aquellos valores por debajo o por encima de los puntos de corte. Una solución es obtener valores mínimos y máximos variables:

# Obtener el valor mínimo y máximo
min_value = d_stunt_grp['share_stunted_child'].min()
max_value = d_stunt_grp['share_stunted_child'].max()

# Configuren include_lowest=True para incluir el valor
# mínimo, de lo contrario será asignado como NaN.
# precision=2 redondea los límites de los intervalos.
d_stunt_grp['share_stunted_child_custom_2'] = pd.cut(
    d_stunt_grp['share_stunted_child'],
    bins=[min_value, 2, 9.4, 29, max_value],
    include_lowest=True, precision=2)

print(d_stunt_grp[
    'share_stunted_child_custom_2'].value_counts(
    ).sort_index())
share_stunted_child_custom_2
(1.29, 2.0]      5
(2.0, 9.4]      35
(9.4, 29.0]     65
(29.0, 59.3]    49
Name: count, dtype: int64


2.1.8 Discretizacación con nuevos datos

Todas estas transformaciones se realizan con un conjunto de datos de práctica basado en las distribuciones de las variables. Tal es el caso de la discretización de igual frecuencia y de igual rango. Pero, ¿qué pasaría si llegaran nuevos datos?

Si aparece un nuevo valor mínimo o máximo, afectará el rango de ubicaciones en el método igual rango. Si llega algún nuevo valor, entonces moverá los puntos basados en percentiles como vimos en el método igual frecuencia.

Para ver qué pasa, imaginemos que añadimos cuatro casos más en el ejemplo propuesto, con los valores 88, 2, 7 y 3:

# Simular que se agregan cuatro valores nuevos
updated_data = pd.concat([
    d_stunt_grp['share_stunted_child'],
    pd.Series([88, 2, 7, 3])], ignore_index=True)

# Discretizar por igual frecuencia
updated_data_eq_freq = equal_freq(updated_data, 4)

# Resultados en...
print(updated_data_eq_freq.value_counts().sort_index())
(1.2990000000000002, 9.0]    40
(9.0, 20.45]                 39
(20.45, 32.75]               39
(32.75, 88.0]                40
Name: count, dtype: int64

Ahora comparemos con los segmentos que creamos anteriormente:

print(d_stunt_grp['stunt_child_ef'].value_counts(
    ).sort_index())
stunt_child_ef
(1.2990000000000002, 9.4]    40
(9.4, 20.7]                  37
(20.7, 32.75]                38
(32.75, 59.3]                39
Name: count, dtype: int64

¡Todos los segmentos cambiaron! Dado que estas son nuevas categorías, el modelo predictivo fallará a la hora de procesarlas porque son todos valores nuevos.

La solución es conservar los puntos de corte cuando preparamos los datos. Luego, ejecutamos el modelo en producción, utilizamos el segmento de discretización manual y, así, forzamos que cada caso quede en la categoría correspondiente. De esta manera, el modelo predictivo siempre ve lo mismo.

La solución será detallada en la siguiente sección.


2.1.9 Discretización automática de data frames

Las funciones discretize_get_bins y discretize_df del paquete funpymodeling (equivalentes a las de funModeling >= 1.6.6) operan juntas para ayudarnos en la tarea de discretización.

Veamos un ejemplo. Primero, corroboramos los tipos de datos actuales:

st = status(heart_disease)
print(st[['variable', 'type', 'q_nan',
           'p_nan']].sort_values('type').to_string())
                  variable     type  q_nan  p_nan
0                      age  float64      0   0.00
1                      sex  float64      0   0.00
3   resting_blood_pressure  float64      0   0.00
4        serum_cholestoral  float64      0   0.00
5      fasting_blood_sugar  float64      0   0.00
7           max_heart_rate  float64      0   0.00
8              exer_angina  float64      0   0.00
9                  oldpeak  float64      0   0.00
11       num_vessels_flour  float64      4   0.01
13   has_heart_disease_num    int64      0   0.00
2               chest_pain   object      0   0.00
6          resting_electro   object      0   0.00
10                   slope   object      0   0.00
12                    thal   object      2   0.01
14       has_heart_disease   object      0   0.00

Tenemos variables object, enteras, y numéricas: ¡una buena mezcla! La transformación tiene dos pasos. Primero, obtiene los valores de corte o de umbral donde comienza cada segmento. El segundo paso es utilizar el umbral para obtener las variables como categóricas.

Discretizaremos dos variables en el siguiente ejemplo: max_heart_rate y oldpeak. Además, agregaremos algunos valores NA a oldpeak para evaluar cómo opera la función con datos faltantes.

# Crear una copia para conservar los datos originales
heart_disease_2b = heart_disease.copy()

# Introducir algunos valores faltantes en las primeras
# 30 filas de la variable oldpeak
heart_disease_2b.loc[:29, 'oldpeak'] = np.nan

Paso 1) Obtener los umbrales de segmento para cada variable de entrada:

discretize_get_bins devuelve un diccionario que necesitaremos para la función discretize_df, que genera el data frame final procesado.

d_bins = discretize_get_bins(
    data=heart_disease_2b,
    input=['max_heart_rate', 'oldpeak'],
    n_bins=5)

# Verificar el objeto `d_bins`:
for var, bins in d_bins.items():
    print(f"{var}: {[round(b, 2) for b in bins]}")
max_heart_rate: [-inf, 130.0, 146.0, 159.0, 170.0, inf]
oldpeak: [-inf, 0.2, 1.0, 1.86, inf]

Parámetros:

  • data: el data frame que contiene las variables a procesar.
  • input: lista de strings que contienen los nombres de las variables.
  • n_bins: la cantidad de segmentos que tendremos en los datos discretizados.

Podemos ver el punto del umbral (o límite superior) para cada variable.

Nota: En R, discretize_get_bins con n_bins=5 para oldpeak produce los umbrales 0.1|0.3|1.1|2|Inf. En Python, pd.qcut con duplicates='drop' puede generar menos segmentos cuando la variable tiene muchos valores repetidos (como oldpeak, que tiene muchos ceros). Esto es esperable y no un error: el algoritmo fusiona segmentos que serían idénticos.

¡Es hora de continuar con el siguiente paso!

Paso 2) Aplicar los umbrales para cada variable:

# Ahora se puede aplicar en el mismo data frame o
# en uno nuevo (por ejemplo, en un modelo predictivo
# en el que los datos cambian con el tiempo)
heart_disease_discretized = discretize_df(
    data=heart_disease_2b, data_bins=d_bins)

Parámetros:

  • data: data frame que contiene las variables a procesar.
  • data_bins: diccionario resultado de discretize_get_bins.
  • Las variables finales serán categóricas y útiles para graficar.

2.1.9.1 Resultados finales y sus gráficos

Antes y después:

 mhr_antes          mhr_dsp  op_antes       op_dsp
     171.0    [170.00, Inf]       NaN          NA.
     114.0   [-Inf, 130.00)       NaN          NA.
     151.0 [146.00, 159.00)       1.8 [1.00, 1.86)
     160.0 [159.00, 170.00)       1.4 [1.00, 1.86)
     158.0 [146.00, 159.00)       0.0 [-Inf, 0.20)
     161.0 [159.00, 170.00)       0.5 [0.20, 1.00)

Distribución final:

cols = ['max_heart_rate', 'oldpeak']
colors = ['#0072B2', '#CC79A7']

for c in cols:
    print(f"{c}:")
    print(heart_disease_discretized[c].value_counts(
        ).sort_index(), "\n")

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
for ax, c, col in zip(axes, cols, colors):
    heart_disease_discretized[c].value_counts(
        ).sort_index().plot(kind='bar', color=col, ax=ax)
    ax.set_title(c)
    ax.tick_params(axis='x', rotation=45)
plt.tight_layout()
plt.show()
max_heart_rate:
max_heart_rate
[-Inf, 130.00)      63
[130.00, 146.00)    59
[146.00, 159.00)    62
[159.00, 170.00)    62
[170.00, Inf]       57
NA.                  0
Name: count, dtype: int64 

oldpeak:
oldpeak
[-Inf, 0.20)    115
[0.20, 1.00)     54
[1.00, 1.86)     49
[1.86, Inf]      55
NA.              30
Name: count, dtype: int64 

Resultados de la discretización automática

A veces, no es posible obtener la misma cantidad de casos por segmento al computar por igual frecuencia, como en el caso de la variable oldpeak.

2.1.9.2 Manejo de valores NA

Con respecto a los valores NA, la nueva variable oldpeak tiene cinco categorías: cuatro categorías generadas por la discretización (menos que las cinco solicitadas en n_bins=5 debido a valores repetidos) más el valor NA. Noten el punto al final que indica la presencia de valores faltantes.

2.1.9.3 Más información

  • discretize_df nunca devolverá un valor NaN sin transformarlo en el string NA..
  • n_bins configura la cantidad de segmentos para todas las variables.
  • Solo las variables definidas en input serán procesadas, mientras que las restantes no serán modificadas en absoluto.
  • discretize_get_bins devuelve un diccionario que puede ser modificado a mano como sea necesario.

2.1.9.4 Discretización con nuevos datos

En nuestros datos, el valor mínimo para max_heart_rate es 71. La preparación de datos debe ser robusta cuando incorporamos nuevos casos; por ejemplo, si llega un nuevo paciente cuya max_heart_rate es 68, entonces el proceso actual lo asignará a la categoría más baja.

En otras funciones de otros paquetes, esta preparación puede devolver un NaN porque está fuera del segmento.

Como señalamos anteriormente, si los nuevos datos llegan a lo largo del tiempo, es probable que obtengan nuevos valores mínimos/máximos. Esto puede romper nuestro proceso. Para resolver esto, discretize_df siempre tendrá como mínimo/máximo los valores -Inf/Inf; por lo tanto, cualquier nuevo valor que caiga por debajo o por encima del mínimo/máximo se añadirá al segmento más bajo o más alto según corresponda.

El diccionario devuelto por discretize_get_bins debe ser guardado para poder aplicarlo a nuevos datos. Si la discretización no está pensada para funcionar con nuevos datos, entonces no tiene sentido tener dos funciones: puede ser solo una. Además, no habría necesidad de guardar los resultados de discretize_get_bins.

Con este enfoque de dos pasos, podemos manejar ambos casos.

2.1.9.5 Conclusiones sobre la discretización de dos pasos

El uso de discretize_get_bins + discretize_df permite una rápida preparación de datos, con un data frame limpio y listo para usar. Dado que muestra claramente dónde empieza y termina cada segmento, resulta indispensable a la hora de realizar informes estadísticos.

La decisión de no fallar a la hora de manejar un nuevo valor mínimo o máximo cuando incorporamos nuevos datos es solo una decisión. En algunos contextos, fracasar puede ser el comportamiento deseado.

La intervención humana: La manera más fácil de discretizar un data frame es seleccionar la misma cantidad de segmentos para aplicar a cada variable, igual que en el ejemplo que vimos. Sin embargo, si es necesario realizar ajustes, entonces algunas variables pueden necesitar un número diferente de segmentos. Por ejemplo, una variable con menos dispersión puede funcionar bien con pocos segmentos.

Los valores más comunes para el número de segmentos suelen ser 3, 5, 10 ó 20 (pero no más). Esta decisión corre por cuenta del científico de datos.


2.1.9.6 Bonus track: El arte del equilibrio

  • Alta cantidad de segmentos => Más ruido capturado
  • Baja cantidad de segmentos => Demasiada simplificación, menos varianza.

¿Estos términos les suenan parecidos a otros empleados en el ámbito de machine learning?

La respuesta: ¡Sí! Solo por mencionar un ejemplo: buscar el equilibrio a la hora de agregar o quitar variables en un modelo predictivo.

  • Más variables: Alerta de sobreajuste (el modelo predictivo es demasiado detallado).
  • Menos variables: Peligro de subajuste (no hay suficiente información para captar los patrones generales).

Como la filosofía oriental ha señalado durante miles de años, hay un arte en encontrar el equilibrio justo entre un valor y su opuesto.


2.1.10 Reflexiones finales

Como podemos ver, cada decisión tiene su costo en la discretización o preparación de datos. ¿Cómo creen que un sistema automático o inteligente resolverá todas estas situaciones sin la intervención o el análisis humano?

Para estar seguros, podemos delegar algunas tareas a procesos automáticos; sin embargo, los humanos son indispensables en la etapa de preparación de datos, brindando los datos de entrada correctos para procesar.

La asignación de variables como categóricas o numéricas, los dos tipos de datos más utilizados, varía según la naturaleza de los datos y los algoritmos seleccionados, ya que algunos sólo soportan un tipo de datos.

La conversión introduce algún sesgo al análisis. Un caso similar existe cuando se trata de valores faltantes: Manejo e imputación de datos faltantes.

Cuando trabajamos con variables categóricas, podemos cambiar su distribución reorganizando las categorías según una variable objetivo para exponer mejor su relación. Convertir una relación variable no lineal en una lineal.


2.1.11 Bonus track

Volvamos a la sección sobre discretización de variables y grafiquemos todas las transformaciones que hemos visto hasta ahora:

disc_vars = [
    ('share_stunted_child_eq_range', 'Igual rango',
     '#009E73'),
    ('stunt_child_ef', 'Igual frecuencia', '#CC79A7'),
    ('share_stunted_child_custom', 'Personalizado',
     '#0072B2')]

fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for ax, (col, title, color) in zip(axes, disc_vars):
    d_stunt_grp[col].value_counts().sort_index().plot(
        kind='bar', color=color, ax=ax)
    ax.set_title(title)
    ax.tick_params(axis='x', rotation=45)
plt.tight_layout()
plt.show()

Mismos datos, diferentes visualizaciones

Los datos ingresados son siempre los mismos. Sin embargo, todos estos métodos exhiben diferentes perspectivas de la misma cosa.

Algunas perspectivas son más adecuadas que otras para ciertas situaciones, como el uso de igual frecuencia para modelos predictivos.

Aunque este caso solo considera una variable, el razonamiento es el mismo si tenemos más variables a la vez, es decir, un espacio “N-dimensional”.

Cuando construimos modelos predictivos, describimos el mismo grupo de puntos de diferentes maneras, al igual que cuando distintas personas dan su opinión sobre un objeto.





2.2 Var. de alta cardinalidad en estadística descriptiva

2.2.1 ¿De qué se trata esto?

Una variable de alta cardinalidad es aquella que puede tomar muchos valores diferentes. Por ejemplo, la variable país.

Este capítulo cubrirá la reducción de la cardinalidad basada en la regla de Pareto, usando la función freq_tbl que da una visión rápida sobre dónde se concentran la mayoría de los valores y la distribución de la variable.


2.2.2 Alta cardinalidad en estadística descriptiva

El siguiente ejemplo contiene una encuesta de 910 casos, con 3 columnas: person, country y has_flu, que indica haber tenido gripe en el último mes.

# data_country: dataset original del paquete
# funModeling de R, exportado a CSV.
data_country = pd.read_csv("images/data_country.csv")

Los datos de data_country provienen del paquete funModeling de R.

Rápido análisis numérico de data_country (primeras 10 filas)

# Graficar las primeras 10 filas
print(data_country.head(10))
   person      country has_flu
0     478       France      no
1     990       Brazil      no
2     606       France      no
3     575  Philippines      no
4     806       France      no
5     232       France      no
6     422       Poland      no
7     347      Romania      no
8     858      Finland      no
9     704       France      no
# Explorar los datos, visualizando solamente las
# primeras 10 filas
country_freq_full = freq_tbl(
    data_country[['country']])
print(country_freq_full.head(10))

# Graficar las primeras 10 categorías
top10 = country_freq_full.head(10)
top10.plot.bar(x='country', y='frequency',
               legend=False, figsize=(6, 4),
               color='steelblue')
plt.ylabel('Frecuencia')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
          country  frequency  percentage  cumulative_perc
0          France        288      0.3165           0.3165
1          Turkey         67      0.0736           0.3901
2           China         65      0.0714           0.4615
3         Uruguay         63      0.0692           0.5307
4  United Kingdom         45      0.0495           0.5802
5       Australia         41      0.0451           0.6253
6         Germany         30      0.0330           0.6583
7          Canada         19      0.0209           0.6792
8     Netherlands         19      0.0209           0.7001
9           Japan         18      0.0198           0.7199

Análisis de frecuencia por país
# Explorar los datos
flu_freq = freq_tbl(data_country[['has_flu']])
print(flu_freq)

flu_freq.plot.bar(x='has_flu', y='frequency',
                  legend=False, figsize=(4, 3),
                  color='steelblue')
plt.ylabel('Frecuencia')
plt.tight_layout()
plt.show()
  has_flu  frequency  percentage  cumulative_perc
0      no        827      0.9088           0.9088
1     yes         83      0.0912           1.0000

Análisis de frecuencia de casos con gripe


La última tabla muestra que hay sólo 83 filas en las que has_flu="yes", lo que representa cerca del 9% del total de personas (que tuvieron gripe).

Pero muchos de ellos casi no tienen participación en los datos. Esta es la cola larga, por lo que una técnica para reducir la cardinalidad es conservar aquellas categorías que están presentes en un alto porcentaje de los datos, por ejemplo 70, 80 o 90%, el principio de Pareto.

# freq_tbl ya devuelve frecuencia, porcentaje y
# porcentaje acumulado, ordenado por frecuencia.
country_freq = freq_tbl(data_country[['country']])

# Las primeras 10 filas tienen la mayor participación.
print(country_freq.head(10))
          country  frequency  percentage  cumulative_perc
0          France        288      0.3165           0.3165
1          Turkey         67      0.0736           0.3901
2           China         65      0.0714           0.4615
3         Uruguay         63      0.0692           0.5307
4  United Kingdom         45      0.0495           0.5802
5       Australia         41      0.0451           0.6253
6         Germany         30      0.0330           0.6583
7          Canada         19      0.0209           0.6792
8     Netherlands         19      0.0209           0.7001
9           Japan         18      0.0198           0.7199


Vemos que 10 representan más del 70% de los casos. Podemos asignar la categoría other a los casos restantes y graficar:

top_10 = country_freq.head(10)['country'].tolist()
data_country['country_2'] = data_country['country'].apply(
    lambda x: x if x in top_10 else 'other')
c2_freq = freq_tbl(data_country[['country_2']])
print(c2_freq)

c2_freq.plot.bar(x='country_2', y='frequency',
                 legend=False, figsize=(6, 4),
                 color='steelblue')
plt.ylabel('Frecuencia')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
         country_2  frequency  percentage  cumulative_perc
0           France        288      0.3165           0.3165
1            other        255      0.2802           0.5967
2           Turkey         67      0.0736           0.6703
3            China         65      0.0714           0.7417
4          Uruguay         63      0.0692           0.8109
5   United Kingdom         45      0.0495           0.8604
6        Australia         41      0.0451           0.9055
7          Germany         30      0.0330           0.9385
8           Canada         19      0.0209           0.9594
9      Netherlands         19      0.0209           0.9803
10           Japan         18      0.0198           1.0000

Variable país modificada - análisis de frecuencia


2.2.3 Comentarios finales

Las categorías poco representativas a veces son errores en los datos, como tener: “Egipto”, “Eggipto.”, y pueden dar alguna evidencia de malos hábitos de recolección de datos y/o posibles errores en la recolección de la fuente.

No existe una regla general para reducir los datos, depende de cada caso particular.


Próximo capítulo recomendado: Variables de alta cardinalidad en modelado predictivo





2.3 Variables de alta cardinalidad en modelado predictivo

2.3.1 ¿De qué se trata esto?

Como hemos visto en el capítulo anterior, Alta cardinalidad en estadística descriptiva, conservamos las categorías con la mayor representatividad, pero ¿qué tal si podemos tener otra variable para predecir con ella? Es decir, predecir has_flu basándonos en country.

Utilizar el último método puede destruir la información de la variable, por lo que pierde poder predictivo. En este capítulo iremos más allá en el método descrito anteriormente, utilizando una función de agrupación automática -auto_grouping- y navegando a través de la estructura de la variable para dar algunas ideas sobre cómo optimizar una variable categórica, pero lo más importante: animar al lector a realizar sus propias optimizaciones.

Otros autores han nombrado este reagrupamiento como reducción de la cardinalidad o encoding.


¿Qué vamos a repasar en este capítulo?

  • Concepto de representatividad de los datos (tamaño de muestra).
  • Tamaño de muestra con una variable objetivo o de resultado.
  • De Python: Presentar un método para ayudar a reducir la cardinalidad y analizar numéricamente variables categóricas.
  • Un ejemplo práctico de antes y después que reduce la cardinalidad y facilita la extracción de ideas.
  • Cómo diferentes modelos, como un random forest o gradient boosting machine (GBM, en inglés), manejan las variables categóricas.


2.3.2 Pero, ¿es necesario reagrupar la variable?

Depende del caso, pero la respuesta más rápida es sí. En este capítulo veremos un caso en el que esta preparación de datos aumenta la precisión general (medida por área debajo de la curva ROC).

Existe un equilibrio entre la representación de los datos (cuántas filas tiene cada categoría) y cómo se relaciona cada categoría con la variable de resultado. Por ejemplo: algunos países son más propensos a los casos de gripe que otros.

Analizamos numéricamente data_country.

Análisis rápido de data_country (primeras 10 filas)

# Graficar las primeras 10 filas
print(data_country.head(10))
   person      country has_flu country_2
0     478       France      no    France
1     990       Brazil      no     other
2     606       France      no    France
3     575  Philippines      no     other
4     806       France      no    France
5     232       France      no    France
6     422       Poland      no     other
7     347      Romania      no     other
8     858      Finland      no     other
9     704       France      no    France
# Explorar los datos, visualizando solamente las
# primeras 10 filas
country_freq_disp = freq_tbl(data_country[['country']])
print(country_freq_disp.head(10))

country_freq_disp.head(10).plot.bar(
    x='country', y='frequency', legend=False,
    figsize=(6, 4), color='steelblue')
plt.ylabel('Frecuencia')
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
plt.show()
          country  frequency  percentage  cumulative_perc
0          France        288      0.3165           0.3165
1          Turkey         67      0.0736           0.3901
2           China         65      0.0714           0.4615
3         Uruguay         63      0.0692           0.5307
4  United Kingdom         45      0.0495           0.5802
5       Australia         41      0.0451           0.6253
6         Germany         30      0.0330           0.6583
7          Canada         19      0.0209           0.6792
8     Netherlands         19      0.0209           0.7001
9           Japan         18      0.0198           0.7199

Primeros 10 países
flu_freq_2 = freq_tbl(data_country[['has_flu']])
print(flu_freq_2)

flu_freq_2.plot.bar(x='has_flu', y='frequency',
                    legend=False, figsize=(4, 3),
                    color='steelblue')
plt.ylabel('Frecuencia')
plt.tight_layout()
plt.show()
  has_flu  frequency  percentage  cumulative_perc
0      no        827      0.9088           0.9088
1     yes         83      0.0912           1.0000

Distribución de la variable has_flu


2.3.3 El caso

El modelo predictivo intentará mapear ciertos valores con ciertos resultados, en nuestro caso la variable objetivo es binaria.

Calcularemos un análisis numérico completo de country con respecto a la variable objetivo has_flu basado en categ_analysis.

Cada fila representa una categoría única de variables input. Y en cada fila podemos encontrar atributos que definen cada categoría en términos de representatividad y probabilidad.

# `categ_analysis` del paquete funpymodeling,
# equivalente a funModeling::categ_analysis en R.
country_profiling = categ_analysis(
    data=data_country, input='country',
    target='has_flu')

# Visualizar las primeras 15 filas (países) de 70.
_ren_cp = {
    'mean_target': 'mean_tgt',
    'sum_target': 'sum_tgt',
    'perc_target': 'pct_tgt',
    'q_rows': 'rows',
    'perc_rows': 'pct_rows'}
print(country_profiling.head(15)
      .rename(columns=_ren_cp)
      .to_string(index=False))
       country  mean_tgt  sum_tgt  pct_tgt  rows  pct_rows
      Malaysia     1.000        1    0.012     1     0.001
        Mexico     0.667        2    0.024     3     0.003
      Portugal     0.200        1    0.012     5     0.005
United Kingdom     0.178        8    0.096    45     0.049
       Uruguay     0.175       11    0.133    63     0.069
        Israel     0.167        1    0.012     6     0.007
   Switzerland     0.167        1    0.012     6     0.007
        Canada     0.158        3    0.036    19     0.021
        France     0.142       41    0.494   288     0.316
     Argentina     0.111        1    0.012     9     0.010
       Germany     0.100        3    0.036    30     0.033
     Australia     0.098        4    0.048    41     0.045
       Romania     0.091        1    0.012    11     0.012
         Spain     0.091        1    0.012    11     0.012
        Sweden     0.083        1    0.012    12     0.013


  • Nota 1: La primera columna ajusta automáticamente su nombre en base a la variable input
  • Nota 2: La variable has_flu tiene valores yes y no, categ_analysis asigna internamente el número 1 a la clase menos representativa, yes en este caso, para calcular el promedio, suma y porcentaje.

Estas son las métricas que devuelve categ_analysis:

  • country: nombre de cada categoría en la variable input.
  • mean_target: sum_target/q_rows, número promedio de has_flu="yes" para una categoría. Esta es la probabilidad.
  • sum_target: cantidad de valores has_flu="yes" en cada categoría.
  • perc_target: lo mismo que sum_target pero expresado como porcentaje, sum_target of each category / total sum_target. Esta columna suma 1.00.
  • q_rows: cantidad de filas que, más allá de la variable has_flu, cayeron en una categoría. Es la distribución de input. Esta columna suma la cantidad total de filas analizadas.
  • perc_rows: relacionado con q_rows, representa la porción o porcentaje de cada categoría. Esta columna suma 1.00.


2.3.3.1 ¿Qué conclusiones podemos extraer de esto?

Leyendo como ejemplo la primera fila de France:

  • 41 personas tienen gripe (sum_target=41). Estas 41 personas representan casi el 50% del total de personas con gripe (perc_target=0.494).
  • La probabilidad de tener gripe en Francia es 14.2% (mean_target=0.142)
  • Total de filas de Francia=288 -de 910-. Esta es la variable q_rows; perc_rows es el mismo número pero en porcentaje.

Sin considerar el filtro por país, tenemos:

  • La columna sum_target suma el total de personas con gripe en los datos actuales.
  • La columna perc_target suma 1.00 -o 100%
  • La columna q_rows suma el total de filas presentes en el data frame data_country.
  • La columna perc_rows suma 1.00 o 100%.



2.3.4 Análisis para el modelado predictivo

Cuando desarrollamos modelos predictivos, puede que nos interesen aquellos valores que aumentan la probabilidad de un determinado evento. En nuestro caso:

¿Cuáles son los países que maximizan la probabilidad de encontrar personas con gripe?

Fácil, tomemos country_profiling en orden descendiente según mean_target:

# Ordenar country_profiling por mean_target y luego
# tomar los primeros 6 países
print(country_profiling.sort_values(
    'mean_target', ascending=False).head(6)
    .rename(columns=_ren_cp)
    .to_string(index=False))
       country  mean_tgt  sum_tgt  pct_tgt  rows  pct_rows
      Malaysia     1.000        1    0.012     1     0.001
        Mexico     0.667        2    0.024     3     0.003
      Portugal     0.200        1    0.012     5     0.005
United Kingdom     0.178        8    0.096    45     0.049
       Uruguay     0.175       11    0.133    63     0.069
        Israel     0.167        1    0.012     6     0.007


¡Genial! Tenemos a Malaysia como el país con mayor probabilidad de tener gripe! El 100% de las personas ahí tienen gripe (mean_target=1.000).

Pero nuestro sentido común nos aconseja que quizás algo anda mal….

¿Cuántas filas tiene Malaysia? Respuesta: 1. -columna: q_rows=1 ¿Cuántos casos positivos tiene Malaysia? Respuesta: 1 -columna: sum_target=1.

Dado que no se puede aumentar la muestra vean que si esta proporción se mantiene alta, contribuirá a un sobreajuste y creará un sesgo en el modelo predictivo.

¿Y qué pasa con Mexico? 2 de cada 3 tienen gripe…. todavía parece baja. Sin embargo, Uruguay tiene un 17,5% de probabilidad -11 de 63 casos- y estos 63 casos representan casi el 7% de la población total (perc_rows=0.069), esta proporción parece más creíble.

A continuación se presentan algunas ideas para tratar esto:


2.3.4.1 Caso 1: Reducción mediante la recategorización de valores menos representativos

Mantengamos todos los casos que tengan al menos un determinado porcentaje de representación en los datos. Supongamos que cambiamos el nombre de los países que tienen menos del 1% de presencia en los datos a others.

country_profiling = categ_analysis(
    data=data_country, input='country',
    target='has_flu')

countries_high_rep = country_profiling[
    country_profiling['perc_rows'] > 0.01][
    'country'].tolist()

# Si no pertenece a countries_high_rep entonces lo
# asignamos a la categoría `other`
data_country['country_new'] = data_country[
    'country'].apply(
    lambda x: x if x in countries_high_rep else 'other')

Volvemos a chequear la probabilidad:

country_profiling_new = categ_analysis(
    data=data_country, input='country_new',
    target='has_flu')
print(country_profiling_new
      .rename(columns=_ren_cp)
      .to_string(index=False))
   country_new  mean_tgt  sum_tgt  pct_tgt  rows  pct_rows
United Kingdom     0.178        8    0.096    45     0.049
       Uruguay     0.175       11    0.133    63     0.069
        Canada     0.158        3    0.036    19     0.021
        France     0.142       41    0.494   288     0.316
       Germany     0.100        3    0.036    30     0.033
     Australia     0.098        4    0.048    41     0.045
       Romania     0.091        1    0.012    11     0.012
         Spain     0.091        1    0.012    11     0.012
        Sweden     0.083        1    0.012    12     0.013
   Netherlands     0.053        1    0.012    19     0.021
         other     0.041        7    0.084   170     0.187
        Turkey     0.030        2    0.024    67     0.074
        Poland     0.000        0    0.000    13     0.014
       Belgium     0.000        0    0.000    15     0.016
         Japan     0.000        0    0.000    18     0.020
         Italy     0.000        0    0.000    10     0.011
         China     0.000        0    0.000    65     0.071
        Brazil     0.000        0    0.000    13     0.014

Hemos reducido drásticamente la cantidad de países -~74% menos- sólo reduciendo la cantidad de países al recategorizar al 1% menos representativo. Quedaron 18 de los 70 países.

La probabilidad de una variable objetivo se ha estabilizado un poco más en la categoría “other”. Ahora cuando el modelo predictivo vea Malaysia no asignará el 100% de la probabilidad, sino el ~4.1% (mean_target del grupo other).

Consejo sobre este último método

Tengan cuidado al aplicar esta técnica a ciegas. A veces, en una predicción objetivo altamente desequilibrada -por ejemplo, detección de anomalías- el comportamiento anormal está presente en menos del 1% de los casos.

# Replicar los datos
d_abnormal = data_country.copy()

# Simular comportamiento anormal en algunos países
d_abnormal['abnormal'] = d_abnormal['country'].apply(
    lambda x: 'yes' if x in ['Brazil', 'Chile']
    else 'no')

# Análisis categórico
ab_analysis = categ_analysis(
    d_abnormal, input='country',
    target='abnormal')

# Visualizar sólo los primeros 6 elementos
print(ab_analysis.head(6)
      .rename(columns=_ren_cp)
      .to_string(index=False))

# Inspeccionar la distribución
print("\nDistribución de 'abnormal':")
print(d_abnormal['abnormal'].value_counts())
    country  mean_tgt  sum_tgt  pct_tgt  rows  pct_rows
     Brazil       1.0       13    0.867    13     0.014
      Chile       1.0        2    0.133     2     0.002
  Argentina       0.0        0    0.000     9     0.010
New Zealand       0.0        0    0.000     4     0.004
     Poland       0.0        0    0.000    13     0.014
Philippines       0.0        0    0.000     7     0.008

Distribución de 'abnormal':
abnormal
no     895
yes     15
Name: count, dtype: int64

¿Cuántos valores anormales hay?

Sólo 15, y representan el 1,65% de los valores totales.

Comprobando la tabla devuelta por categ_analysis, podemos ver que este comportamiento anormal ocurre sólo en categorías con una participación realmente baja: Brazil que está presente en sólo 1,4% de los casos, y Chile con 0,2%.

En este caso, crear una categoría other basada en la distribución no es una buena idea.

Conclusión:

A pesar de que este es un ejemplo preparado, hay algunas técnicas de preparación de datos que pueden ser realmente útiles en términos de precisión, pero necesitan cierta supervisión. Esta supervisión puede ser con ayuda de algoritmos.


2.3.4.2 Caso 2: Reducción mediante agrupación automática

Este procedimiento utiliza la técnica de clustering o agrupamiento kmeans y la tabla devuelta por categ_analysis para crear grupos -clusters- que contienen categorías que muestran un comportamiento similar en términos de:

  • perc_rows
  • perc_target

La combinación de ambos nos llevará a encontrar grupos en base a la probabilidad y la representatividad.

Manos a la obra en Python:

Definimos el parámetro n_groups, es el número de grupos deseados. El número es relativo a los datos y a la cantidad de categorías totales. Pero un número general estaría entre 3 y 10.

La función auto_grouping del paquete funpymodeling (equivalente a funModeling::auto_grouping). Por favor noten que el parámetro target sólo funciona para variables binarias (en la versión de R se menciona que funciona para variables no binarias también).

Note: el parámetro seed es opcional, pero al asignarle un número siempre obtendrá los mismos resultados.

# Reducir la cardinalidad
country_groups = auto_grouping(
    data=data_country, input='country',
    target='has_flu', n_groups=9, seed=999)
print(country_groups['df_equivalence'].to_string(
    index=False))
                  country country_rec
                 Malaysia     group_9
                   Mexico     group_9
                 Portugal     group_9
           United Kingdom     group_3
                  Uruguay     group_8
                   Israel     group_9
              Switzerland     group_9
                   Canada     group_7
                   France     group_2
                Argentina     group_9
                  Germany     group_7
                Australia     group_6
                  Romania     group_9
                    Spain     group_9
                   Sweden     group_9
              Netherlands     group_1
                   Turkey     group_5
                  Morocco     group_4
              New Zealand     group_4
                   Norway     group_4
                     Peru     group_4
               Montenegro     group_4
     Moldova, Republic of     group_4
                 Pakistan     group_4
    Palestinian Territory     group_4
       Russian Federation     group_4
              Philippines     group_1
                   Poland     group_1
             Saudi Arabia     group_4
                  Senegal     group_4
                Singapore     group_1
                 Slovenia     group_4
             South Africa     group_1
                   Taiwan     group_4
                 Thailand     group_4
                  Ukraine     group_4
                    Malta     group_4
                   Latvia     group_4
               Luxembourg     group_4
                Lithuania     group_4
                  Austria     group_4
               Bangladesh     group_4
                  Belgium     group_1
   Bosnia and Herzegovina     group_4
                   Brazil     group_1
                 Bulgaria     group_1
                 Cambodia     group_4
                    Chile     group_4
                    China     group_5
               Costa Rica     group_4
                  Croatia     group_4
                   Cyprus     group_4
           Czech Republic     group_4
                  Denmark     group_4
       Dominican Republic     group_4
                    Egypt     group_4
                  Finland     group_4
                    Ghana     group_4
                   Greece     group_4
                 Honduras     group_4
                Hong Kong     group_1
                Indonesia     group_4
Iran, Islamic Republic of     group_4
                  Ireland     group_4
              Isle of Man     group_4
                    Italy     group_1
                    Japan     group_1
       Korea, Republic of     group_4
      Asia/Pacific Region     group_4
                  Vietnam     group_4

auto_grouping devuelve un diccionario que contiene 3 objetos:

  • df_equivalence: data frame que contiene una tabla para encontrar las equivalencias entre datos viejos y nuevos.
  • fit_cluster: modelo k-means que se utiliza para reducir la cardinalidad (los valores se escalan).
  • recateg_results: data frame que contiene el análisis numérico de cada grupo con respecto a la variable objetivo. La primera columna ajusta su nombre a la variable de entrada. En este caso tenemos: country_rec. Cada grupo corresponde a una o varias categorías de la variable de entrada (como vimos en df_equivalence).

Exploremos cómo se comportan los nuevos grupos, esto es lo que verá el modelo predictivo:

recateg_res = country_groups['recateg_results']
print(recateg_res[['country_rec', 'mean_target',
                   'sum_target']].to_string(index=False))
print()
print(recateg_res[['country_rec', 'q_rows',
                   'perc_rows']].to_string(index=False))
country_rec  mean_target  sum_target
    group_3        0.178           8
    group_8        0.175          11
    group_9        0.156          10
    group_2        0.142          41
    group_7        0.122           6
    group_6        0.098           4
    group_5        0.015           2
    group_1        0.008           1
    group_4        0.000           0

country_rec  q_rows  perc_rows
    group_3      45      0.049
    group_8      63      0.069
    group_9      64      0.070
    group_2     288      0.316
    group_7      49      0.054
    group_6      41      0.045
    group_5     132      0.145
    group_1     129      0.142
    group_4      99      0.109

La última tabla está ordenada por mean_target, por lo que podemos ver rápidamente los grupos según probabilidad máxima o mínima.

Primero debemos agregar la columna de la nueva categoría al conjunto de datos original.

data_country_2 = data_country.merge(
    country_groups['df_equivalence'],
    on='country', how='inner')

Ahora hacemos las transformaciones adicionales. Identificamos los grupos con probabilidad 0 o muy baja y los agrupamos:

# Identificar grupos con mean_target=0 o muy bajo
recateg = country_groups['recateg_results']
zero_groups = recateg[
    recateg['mean_target'] == 0]['country_rec'].tolist()
low_groups = recateg[
    (recateg['mean_target'] > 0) &
    (recateg['sum_target'] <= 2)][
    'country_rec'].tolist()

# Reemplazar
data_country_2['country_rec'] = (
    data_country_2['country_rec'].apply(
    lambda x: 'low_likelihood' if x in zero_groups
    else ('low_target_share' if x in low_groups
          else x)))

Verificando la agrupación final (variable country_rec):

final_profiling = categ_analysis(
    data=data_country_2, input='country_rec',
    target='has_flu')
_ren_fp = {'country_rec': 'ctry_rec', **_ren_cp}
print(final_profiling
      .rename(columns=_ren_fp)
      .to_string(index=False))
        ctry_rec  mean_tgt  sum_tgt  pct_tgt  rows  pct_rows
         group_3     0.178        8    0.096    45     0.049
         group_8     0.175       11    0.133    63     0.069
         group_9     0.156       10    0.120    64     0.070
         group_2     0.142       41    0.494   288     0.316
         group_7     0.122        6    0.072    49     0.054
         group_6     0.098        4    0.048    41     0.045
low_target_share     0.011        3    0.036   261     0.287
  low_likelihood     0.000        0    0.000    99     0.109

Cada grupo parece tener un buen tamaño de muestra con respecto a la distribución de sum_target.

Todos los grupos parecen tener una buena representación. Esto se puede comprobar en la variable perc_rows.

Intentar con un número menor de clusters puede ayudar a reducir un poco esta tarea manual. Esto fue sólo una demostración de cómo optimizar una variable que tiene muchas categorías diferentes.


2.3.5 Manejo de nuevas categorías cuando el modelo predictivo está en producción

Imaginemos que aparece un nuevo país, new_country_hello_world, los modelos predictivos fallarán ya que fueron entrenados con valores fijos. Una técnica es asignar un grupo que tenga mean_target=0.

Es similar al caso del último ejemplo. Pero la diferencia está en ciertos grupos: estas categorías encajarían mejor en un grupo de probabilidad media que en un valor completamente nuevo.

Después de un tiempo deberíamos reconstruir el modelo con todos los nuevos valores, de lo contrario estaríamos penalizando a new_country_hello_world si tiene una buena probabilidad.

En otras palabras:

¿Aparece una nueva categoría? Envíenla al grupo menos significativo. Después de un tiempo, vuelvan a analizar su impacto. ¿Tiene una probabilidad media o alta? Cámbienla al grupo más adecuado.



2.3.6 ¿Los modelos predictivos pueden manejan la alta cardinalidad? Parte 1

Sí, y no. Algunos modelos tratan mejor que otros este asunto de la alta cardinalidad. En algunos escenarios, esta preparación de datos puede no ser necesaria. Este libro trata de exponer este tema que, a veces, puede llevar a un mejor modelo.

Ahora, vamos a atravesar este tema construyendo dos modelos predictivos: Máquina de potenciación del gradiente - bastante robusta para muchas entradas de datos diferentes.

El primer modelo no tiene datos tratados, y el segundo ha sido tratado por las funciones del paquete funpymodeling.

Estamos midiendo la precisión basándonos en el área ROC, que oscila entre 0.5 y 1; cuanto más alto sea el número, mejor será el modelo. Vamos a utilizar la validación cruzada para estar seguros del valor. La importancia de la validación cruzada de los resultados se trata en el capítulo Conociendo el error.

# Construir el primer modelo, sin reducir la
# cardinalidad.
# Preparar los datos: convertir country a dummies
data_model = data_country_2.copy()
data_model['target'] = (
    data_model['has_flu'] == 'yes').astype(int)

# Modelo 1: con variable country original (dummies)
X1 = pd.get_dummies(data_model[['country']],
                     dtype=int)
y = data_model['target']

gbm1 = GradientBoostingClassifier(
    n_estimators=100, random_state=42)
scores1 = cross_val_score(gbm1, X1, y, cv=4,
                          scoring='roc_auc')
roc = round(scores1.mean(), 2)
print(f"ROC (sin reducir cardinalidad): {roc}")
ROC (sin reducir cardinalidad): 0.65

El área debajo de la curva ROC es: el valor roc mostrado arriba.

Ahora hacemos el mismo modelo con los mismos parámetros, pero aplicando la preparación de datos que hicimos antes.


# Construir el segundo modelo, basándonos en la
# variable country_rec
X2 = pd.get_dummies(data_model[['country_rec']],
                     dtype=int)

gbm2 = GradientBoostingClassifier(
    n_estimators=100, random_state=42)
scores2 = cross_val_score(gbm2, X2, y, cv=4,
                          scoring='roc_auc')
new_roc = round(scores2.mean(), 2)
print(f"ROC (con cardinalidad reducida): {new_roc}")
ROC (con cardinalidad reducida): 0.67

Luego calculamos el porcentaje de mejora con respecto al primer valor de ROC:

improvement = round(100 * (new_roc - roc) / roc, 2)
print(f"Mejora: ~ {improvement}%")
Mejora: ~ 3.08%

Nada mal, ¿no?

Un breve comentario sobre la última prueba:

Hemos utilizado uno de los modelos más robustos, máquina de potenciación del gradiente, y hemos comparado el rendimiento. Si probamos otro modelo, por ejemplo regresión logística, que es más sensible a los datos sucios, obtendremos una mayor diferencia entre reducir y no reducir la cardinalidad.

En lecturas adicionales hay un punto de referencia de diferentes tratamientos para variables categóricas y cómo cada una aumenta o disminuye la precisión.


2.3.7 ¿Los modelos predictivos pueden manejan la alta cardinalidad? Parte 2

Revisemos cómo algunos modelos lidian con esto:

Árboles de decisión: Tienden a seleccionar variables con alta cardinalidad en la parte superior, dándoles más importancia que a otras, en función de la ganancia de información. En la práctica, es una prueba de que está sobreajustado. Este modelo es bueno para ver la diferencia entre reducir o no una variable de alta cardinalidad.

Random forest: maneja variables categóricas con muchas categorías, pero puede ser limitado. Es muy probable que esta limitación sea para evitar el sobreajuste. Este punto, en conjunción con la naturaleza del algoritmo -crea muchos árboles-, reduce el efecto de un único árbol de decisión al elegir una variable de alta cardinalidad.

Gradient Boosting Machine y Regresión logística: convierten variables categóricas internas en variables flag o dummy. En el ejemplo que vimos sobre los países, implica la creación -interna- de 70 variables flag.

Comprobemos el modelo que creamos antes:

# Verificar el primer modelo...
print(f"Cantidad de features: {X1.shape[1]}")
print(f"\nImportancia de las variables (top 10):")
gbm1.fit(X1, y)
importances = pd.Series(
    gbm1.feature_importances_,
    index=X1.columns).sort_values(ascending=False)
print(importances.head(10))
Cantidad de features: 70

Importancia de las variables (top 10):
country_France            0.239689
country_Mexico            0.178809
country_Uruguay           0.145322
country_Malaysia          0.136391
country_United Kingdom    0.118960
country_Canada            0.039445
country_China             0.036381
country_Portugal          0.019950
country_Australia         0.019723
country_Germany           0.016789
dtype: float64

Eso es: las variables flag representan a los países, pero muchas fueron reportadas como no relevantes para la predicción.

Esto está relacionado con Ingeniería de variables. Además, está relacionado con Selección de las mejores variables. Es una práctica muy recomendable seleccionar primero las variables que contienen más información y luego crear el modelo predictivo.

Conclusión: la reducción de la cardinalidad reducirá la cantidad de variables en estos modelos.



2.3.8 Variable objetivo numérica o multinomial

Hasta ahora, el libro sólo cubrió casos donde la variable objetivo era una variable binaria. Está previsto que en el futuro abarque también variables objetivo numéricas y multi-valor.

Sin embargo, si leyeron hasta aquí, puede que quieran explorar por su cuenta teniendo en mente la misma idea. En las variables numéricas, por ejemplo la previsión de page visits en un sitio web, habrá ciertas categorías de la variable de entrada que estarán más relacionadas con un valor alto en las visitas, mientras que hay otras que están más correlacionadas con valores bajos.

Lo mismo ocurre con la variable de salida multinomial, habrá algunas categorías más relacionadas con ciertos valores. Por ejemplo, prediciendo el grado de epidemia: high, mid o low según la ciudad. Habrá algunas ciudades que se correlacionarán más con un alto nivel epidémico que otras.


2.3.9 ¿Qué beneficio “extra” obtuvimos con la agrupación?

Saber cómo las categorías fueron asignadas a los grupos nos brinda información que -en algunos casos- es bueno registrar. Las categorías que pertenezcan a un mismo grupo van a tener un comportamiento similar -en términos de representatividad y poder predictivo.

Si Argentina y Chile están en el group_1, entonces son iguales, y así es cómo las verá el modelo.


2.3.10 Representatividad o tamaño de muestra

Este concepto aplica al análisis de cualquier variable categórica, pero es un tema muy común en la ciencia de datos y las estadísticas: tamaño de muestra. ¿Cuántos datos necesitamos para ver el patrón bien desarrollado?

En una variable categórica: ¿Cuántos casos de la categoría “X” necesitamos para confiar en la correlación entre el valor “X” y un valor objetivo? Esto es lo que hemos analizado.

En términos generales: cuanto más difícil sea predecir un evento, más casos vamos a necesitar…

Más adelante en este libro abarcaremos este tema desde otros puntos de vista refiriéndonos de vuelta a esta página.


2.3.11 Reflexiones finales

  • Vimos dos casos para reducir la cardinalidad, al primero no le importa la variable objetivo, lo que puede ser peligroso en un modelo predictivo, mientras que al segundo sí. Crea una nueva variable basada en la afinidad -y representatividad- de cada categoría de entrada con la variable objetivo.

  • Concepto clave: representatividad de cada categoría respecto a sí misma, y respecto al evento que se va a predecir. Un buen punto a explorar es analizarlo basándonos en pruebas estadísticas.

  • Lo que se mencionó al principio con respecto a destruir la información en la variable de entrada implica que la agrupación resultante tiene las mismas proporciones entre grupos (en una variable binaria de entrada).

  • ¿Siempre debemos reducir la cardinalidad? Depende, dos pruebas con un simple dato no son suficientes para extrapolar a todos los casos. Esperamos que sea un buen comienzo para que el lector empiece a hacer sus propias optimizaciones cuando lo considere relevante para el proyecto.


2.3.12 Lecturas adicionales





2.4 Tratamiento de valores atípicos

2.4.1 ¿De qué se trata esto?

El concepto de valores extremos, al igual que otros temas en machine learning, no es un concepto exclusivo de esta área. Lo que hoy es un valor atípico puede que mañana no lo sea. Los límites entre el comportamiento normal y el anormal son difusos; por otro lado, pararse en los extremos es fácil.


Imagen creada por: Guillermo Mesyngier


¿Qué vamos a repasar en este capítulo?

  • ¿Qué es un valor atípico? Enfoques filosóficos y prácticos
  • Valores atípicos por dimensionalidad y tipo de datos (numéricos o categóricos)
  • Cómo detectar valores atípicos en Python (bottom/top X%, Tukey y Hampel)
  • Preparación de valores atípicos para análisis numérico en Python
  • Preparación de valores atípicos para modelado predictivo en Python



2.4.2 La intuición detrás de los valores atípicos

Por ejemplo, consideren la siguiente distribución:

# Crear un conjunto de datos de muestra
np.random.seed(31415)
df_1 = pd.DataFrame({
    'var': np.round(
        10000 * np.random.beta(0.15, 2.5, 1000))
})

# Graficar
fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(df_1['var'], bins=20, color='steelblue',
        edgecolor='white')
ax.set(xlabel='var', ylabel='Frecuencia')
plt.tight_layout()
plt.show()

Distribución de muestra con cola larga

La variable está sesgada hacia la izquierda, mostrando algunos puntos atípicos a la derecha. Queremos lidiar con ellos. Entonces, surge la pregunta: ¿Dónde definimos el umbral de lo extremo? Basándonos en la intuición, puede ser el 1% más alto, o podemos analizar cómo cambia el promedio si quitamos el 1% más alto.

Ambos casos podrían estar bien. De hecho, tomar otro número como el umbral (es decir, 2% o 0,1%), también puede ser correcto. Vamos a visualizarlos:

# Calcular los percentiles del 3% y 1% superior
percentile_var = df_1['var'].quantile(
    [0.98, 0.99, 0.999])
df_p = pd.DataFrame({
    'value': percentile_var.values,
    'percentile': ['a_98th', 'b_99th', 'c_99.9th']
})

# Graficar la misma distribución más los percentiles
fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(df_1['var'], bins=20, color='steelblue',
        edgecolor='white', alpha=0.7)
colors_p = ['green', 'red', 'purple']
for i, row in df_p.iterrows():
    ax.axvline(x=row['value'], linestyle='--',
               color=colors_p[i],
               label=row['percentile'])
ax.legend()
ax.set(xlabel='var', ylabel='Frecuencia')
plt.tight_layout()
plt.show()

Diferentes umbrales para valores atípicos

Para entender los percentiles en mayor profundidad, por favor diríjanse al capítulo Anexo 1: La magia de los percentiles.

Por ahora, seguiremos con el 1% superior (percentil 99) como el umbral para marcar todos los puntos que estén más allá como valores atípicos.

Marcando el 1 porciento superior como atípico

Aquí surge un elemento conceptual interesante: cuando definimos lo anormal (o una anomalía), el concepto de normal emerge como su opuesto.

Este comportamiento “normal” está representado en el área verde:

Mismo umbral, diferente perspectiva

Lo difícil es determinar dónde se separa lo normal de lo anormal. Hay varios enfoques para lidiar con esto. Vamos a repasar algunos de ellos.



2.4.3 ¿Cuál es el límite entre clima cálido y clima frío?

Hagamos esta sección más filosófica. Algunos buenos matemáticos también fueron filósofos, como es el caso de Pitágoras e Isaac Newton.

¿Dónde podemos poner el umbral para indicar que comienza el clima cálido o, a la inversa, que termina el clima frío?

¿Cuál es el punto de corte?

Cerca del Ecuador, una temperatura cerca de los 10ºC (50ºF) probablemente sea un valor extremadamente bajo; sin embargo, en la Antártida, ¡sería un día de playa!

“¡Oh! ¡Pero eso sería tomar un ejemplo extremo con dos locaciones diferentes!”

¡No hay problema! Hagamos zoom a una ciudad, como un fractal, el límite donde una empieza (y otra termina) no tendrá un único valor para determinar lo siguiente: “Ok, el clima cálido empieza en los 25.5ºC (78ºF).”

Es relativo.

Sin embargo, es bastante fácil pararse en los extremos, donde la incertidumbre disminuye a casi cero. Por ejemplo, cuando consideramos una temperatura de 60ºC (140ºF).

“Ok. Pero, ¿cómo se relacionan estos conceptos con machine learning?”

Estamos exponiendo aquí la relatividad que existe al considerar una etiqueta (cálido/frío) como una variable numérica (temperatura). Esto puede ser considerado para cualquier otra variable numérica, como los ingresos económicos y las etiquetas “normal” y “anormal”.

Entender los valores extremos es una de las primeras tareas en análisis exploratorio de datos. Entonces podremos ver cuáles son los valores normales. Esto se trata en el capítulo Análisis numérico, La voz de los números.

Existen varios métodos para marcar valores como valores atípicos. Así como podríamos analizar la temperatura, esta marca es relativa y todos los métodos pueden ser correctos. El método más rápido puede ser tratar el X% superior e inferior como valores atípicos.

Los métodos más robustos consideran las variables de distribución utilizando cuantiles (método de Tukey) o la dispersión de los valores a través de la desviación estándar (método de Hampel).

La definición de estos límites es una de las tareas más comunes en machine learning. ¿Por qué? ¿Cuándo? Señalemos dos ejemplos:

  • Ejemplo 1: Cuando desarrollamos un modelo predictivo que devuelve una probabilidad de llamar o no llamar a un determinado cliente, necesitamos configurar el umbral para asignar la etiqueta final: “¡sí, llamar!”/“no llamar”. Hay más información sobre esto en el capítulo de Scoring de datos.

  • Ejemplo 2: Otro ejemplo se da cuando necesitamos discretizar una variable numérica porque necesitamos que sea categórica. Los límites en cada segmento afectarán al resultado general. Hay más información sobre esto en la sección Discretizando variables numéricas

Volviendo al problema original (¿Dónde termina el clima frío?), no todas las preguntas necesitan una respuesta: algunas solamente nos ayudan a pensar.



2.4.4 El impacto de los valores atípicos

2.4.4.1 Construcción de modelos

Algunos modelos, como el bosque aleatorio y las máquinas de potenciación del gradiente, tienden a lidiar mejor con los valores atípicos; sin embargo, el “ruido” puede afectar los resultados de todos modos. El impacto de los valores atípicos en estos modelos es menor que en otros, como las regresiones lineales, las regresiones logísticas, los kmeans y los árboles de decisión.

Un aspecto que contribuye a la disminución del impacto es que ambos modelos crean muchos sub-modelos. Si cualquiera de los modelos toma un valor atípico como información, entonces otros sub-modelos probablemente no lo harán; por lo tanto, el error se cancela. El equilibrio yace en la pluralidad de voces.

2.4.4.2 Comunicar los resultados

Si debemos informar cuáles fueron las variables utilizadas en el modelo, terminaremos quitando los valores atípicos para no mostrar un histograma con una sola barra y/o un sesgo en el promedio.

Es mejor mostrar un número no sesgado que justificar que el modelo podrá lidiar con valores extremos.

2.4.4.3 Tipos de valores atípicos según el tipo de datos

  • Numéricos: como los que vimos antes:

Variable numérica con valores atípicos
  • Categóricos: Tener una variable en la que la dispersión de la categorías es bastante alta (alta cardinalidad): por ejemplo, código postal. Hay más información sobre cómo lidiar con valores atípicos en variables categóricas en el capítulo Variables de alta cardinalidad en estadística descriptiva.
country frequency percentage cumulative_perc
0 France 288 0.6874 0.6874
1 China 65 0.1551 0.8425
2 Uruguay 63 0.1504 0.9929
3 Peru 2 0.0048 0.9977
4 Vietnam 1 0.0024 1.0000

Variable categórica con valores atípicos

Peru y Vietnam son valores atípicos en este ejemplo dado que su participación en los datos es inferior al 1%.



2.4.4.4 Tipos de valores atípicos según dimensionalidad

Hasta ahora, hemos observado valores atípicos unidimensionales y univariados. También podemos considerar dos o más variables en simultáneo.

Por ejemplo, tenemos el siguiente conjunto de datos, df_hello_world, con dos variables: v1 y v2. Haciendo el mismo análisis que antes:

          v1  frequency  percentage  cumulative_perc
0    Uruguay         80       0.597            0.597
1  Argentina         54       0.403            1.000

----------------------------------------------------------------

      v2  frequency  percentage  cumulative_perc
0  cat_A         83      0.6194           0.6194
1  cat_B         51      0.3806           1.0000

----------------------------------------------------------------
'Variables processed: v1, v2'

Valores atípicos según dimensionalidad

Por ahora no hay valores atípicos, ¿correcto?

Ahora creamos una tabla de contingencia que nos diga la distribución de ambas variables, una contra la otra:

v2         cat_A  cat_B
v1                     
Argentina  39.55   0.75
Uruguay    22.39  37.31

¡Oh! La combinación de Argentina y cat_B es realmente baja (0.75%) en comparación con los otros valores (menos del 1%), mientras que las otras intersecciones están por encima del 22%.


2.4.4.5 Algunas reflexiones…

Los últimos ejemplos muestran el potencial de los valores extremos o atípicos y están presentados como consideraciones que tenemos que tener en cuenta con un nuevo conjunto de datos.

Mencionamos 1% como un posible umbral para marcar un valor como atípico. Este número podría ser 0.5% o 3%, dependiendo del caso.

Además, la presencia de este tipo de valores atípicos podría no traer problemas.



2.4.5 Cómo lidiar con valores atípicos en Python

La función prep_outliers del paquete funpymodeling (equivalente a funModeling::prep_outliers) puede ayudarnos con esta tarea. Puede manejar de una a ‘N’ variables en simultáneo (especificando el parámetro input).

El núcleo es el siguiente:

  • Soporta tres métodos diferentes (parámetro method) para considerar un valor como un outlier: bottom_top, Tukey, y Hampel.
  • Funciona en dos modos (parámetro type) al establecer un valor NaN o al frenar la variable en un valor particular.


2.4.6 Paso 1: Cómo detectar valores atípicos

Los siguientes métodos se implementan en la función prep_outliers. Obtienen diferentes resultados para que el usuario pueda seleccionar los que mejor se ajustan a sus necesidades.

2.4.6.0.1 Método de valores ‘bottom’ y ‘top’

Esto considera valores atípicos tomando los valores del X% inferior y superior, basados en el percentil. Los puntos de corte más utilizados son 0.5%, 1%, 1.5%, 3%, entre otros.

Configurando el parámetro top_percent en 0.01 se tratarán todos los valores del 1% superior.

La misma lógica aplica a los valores más bajos: si se establece el parámetro bottom_percent en 0.01 se marcará como valores atípicos al 1% más bajo de todos los valores.

La función interna utilizada es quantile; si queremos marcar el 1% inferior y el superior, escribimos:

print(heart_disease['age'].quantile([0.01, 0.99]))
0.01    35.0
0.99    71.0
Name: age, dtype: float64

Todos los valores para aquellos casos que tengan menos de 35 años o más de 71 serán considerados atípicos.

Para leer más sobre percentiles, diríjanse al capítulo: Anexo 1: La magia de los percentiles.


2.4.6.0.2 Método de Tukey

Este método marca valores atípicos utilizando los valores cuartiles, Q1, Q2, y Q3, donde Q1 es esquivalente al percentil 25, Q2 al percentil 50 (también conocido como la mediana), y Q3 es el percentil 75.

El rango intercuartil (IQR por sus siglas en inglés) se calcula haciendo Q3 - Q1.

La fórmula:

  • El umbral inferior es: Q1 - 3*IQR. Todos los valores que queden por debajo son considerados atípicos.
  • El umbral superior es: Q3 + 3*IQR. Todos los valores que queden por encima son considerados atípicos.

El valor 3 es para detectar el límite “extremo”. Este método viene del diagrama de caja, donde el multiplicador es 1.5 (no 3). Esto hace que muchos más valores sean marcados como atípicos, lo veremos en la siguiente imagen.

Cómo interpretar un diagrama de caja

Podemos acceder a la función tukey_outlier para calcular el límite de Tukey:

print(tukey_outlier(heart_disease['age']))
{'lower': 9.0, 'upper': 100.0}

Devuelve un diccionario con dos valores; por lo tanto, tenemos el umbral inferior y el superior: todos los valores que estén por debajo de nueve y por encima de 100 serán considerados atípicos.


2.4.6.0.3 Método de Hampel

La fórmula:

  • El umbral inferior es: median_value - 3*mad_value. Todos los valores que queden por debajo son considerados atípicos.
  • El umbral superior es: median_value + 3*mad_value. Todos los valores que queden por encima son considerados atípicos.

Podemos acceder a la función hampel_outlier para calcular el límite de Hampel:

print(hampel_outlier(heart_disease['age']))
{'lower': 29.31, 'upper': 82.69}

Devuelve un diccionario con dos valores; por lo tanto, tenemos el umbral inferior y el superior.

Tiene un parámetro llamado k_mad_value, y su valor por defecto es 3. El valor k_mad_value puede ser modificado.

Cuanto más alto sea el valor k_mad_value, más amplio será el rango de los umbrales (los límites se alejarán más de la mediana).

print(hampel_outlier(heart_disease['age'],
                     k_mad_value=6))
{'lower': 2.63, 'upper': 109.37}



2.4.7 Paso 2: ¿Qué hacemos con los valores atípicos?

Ya detectamos qué puntos son los atípicos. Ahora, la pregunta es ¿Qué hacemos con ellos?

Hay dos escenarios posibles:

  • Escenario 1: Preparar los valores atípicos para el análisis numérico
  • Escenario 2: Preparar los valores atípicos para modelado predictivo

Hay un tercer escenario en el que no hacemos nada con los valores atípicos detectados. Simplemente los dejamos ser.

Proponemos recurrir a la función prep_outliers del paquete funpymodeling que nos dará una mano con esta tarea.

Más allá de la función en sí, lo importante aquí es el concepto subyacente y la posibilidad de desarrollar un método superador.

La función prep_outliers abarca estos dos escenarios con el parámetro type:

  • type = "set_na", para el escenario 1
  • type = "stop", para el escenario 2

2.4.7.1 Escenario 1: Preparar los valores atípicos para el análisis numérico

El análisis inicial:

En este caso, todos los valores atípicos son convertidos a NaN, por lo que, al aplicar la mayoría de las funciones características (máx, mín, promedio, etc.) obtendremos un valor menos sesgado.

Por ejemplo, consideremos la siguiente variable (la que vimos al principio con algunos valores atípicos):

# Para entender todas estas métricas, por favor
# diríjanse al capítulo sobre Análisis numérico
prof_df1 = profiling_num(df_1)
print(prof_df1[['variable', 'mean', 'std_dev',
                'variation_coef']].to_string(index=False))
print()
print(prof_df1[['variable', 'p_01', 'p_05',
                'p_50', 'p_95', 'p_99']].to_string(
                index=False))
print()
print(prof_df1[['variable', 'skewness', 'kurtosis',
                'iqr']].to_string(index=False))
variable    mean   std_dev  variation_coef
     var 594.378 1215.0494          2.0442

variable  p_01  p_05  p_50   p_95    p_99
     var   0.0   0.0  29.0 3490.2 5549.53

variable  skewness  kurtosis   iqr
     var    2.8079    8.0599 550.5

Aquí podemos ver varios indicadores que nos dan algunas pistas. El desvío estándar std_dev es realmente alto comparado con el promedio mean, y eso se refleja en el coeficiente de variación variation_coef. Además, la curtosis es alta y el valor de p_99 es casi el doble que el de p_95.

Esta última tarea de mirar algunos números y visualizar la distribución de la variable es como imaginar una fotografía por lo que otra persona nos dice: convertimos la voz (que es una señal) en una imagen en nuestro cerebro.


2.4.7.1.1 Utilizar prep_outliers para el análisis numérico

Debemos configurar type="set_na". Esto implica que cada punto marcado como un valor atípico será convertido a NaN.

Usaremos los tres métodos: Tukey, Hampel, y bottom/top X%.

Usando el método de Tukey:

df_1['var_tukey'] = prep_outliers(
    df_1['var'], type='set_na', method='tukey')

Ahora verificamos cuántos valores NaN había antes (la variable original) y después de la transformación basada en Tukey.

# Antes
print("Antes:")
print(f"  q_na: {df_1['var'].isna().sum()}, "
      f"p_na: {round(100*df_1['var'].isna().mean(),1)}%")

# Después
print("Después:")
print(f"  q_na: {df_1['var_tukey'].isna().sum()}, "
      f"p_na: {round(100*df_1['var_tukey'].isna().mean(),1)}%")
Antes:
  q_na: 0, p_na: 0.0%
Después:
  q_na: 91, p_na: 9.1%

Antes de la transformación, había 0 valores NaN, mientras que después algunos valores fueron marcados como atípicos de acuerdo a la prueba de Tukey y reemplazados por NaN.

Podemos comparar el antes y el después:

prof = profiling_num(df_1[['var', 'var_tukey']])
print(prof[['variable', 'mean', 'std_dev',
            'variation_coef']].to_string(index=False))

print(prof[['variable', 'p_01', 'p_05', 'p_25',
            'p_50', 'p_75', 'p_95', 'p_99']].to_string(
            index=False))
 variable     mean   std_dev  variation_coef
      var 594.3780 1215.0494          2.0442
var_tukey 260.2948  474.4502          1.8227
 variable  p_01  p_05  p_25  p_50  p_75   p_95    p_99
      var   0.0   0.0   0.0  29.0 550.5 3490.2 5549.53
var_tukey   0.0   0.0   0.0  15.0 296.0 1387.6 2031.88

El promedio disminuyó, y todas las demás métricas también disminuyeron.

Método de Hampel:

Veamos qué pasa con el método de Hampel (method="hampel"):

df_1['var_hampel'] = prep_outliers(
    df_1['var'], type='set_na', method='hampel')

Verificando…

for col in ['var', 'var_tukey', 'var_hampel']:
    q = df_1[col].isna().sum()
    p = round(100 * df_1[col].isna().mean(), 1)
    print(f"{col}: q_na={q}, p_na={p}%")
var: q_na=0, p_na=0.0%
var_tukey: q_na=91, p_na=9.1%
var_hampel: q_na=366, p_na=36.6%

Este último método es mucho más severo al identificar valores atípicos, marcando el 36.6% de los valores como atípicos. Es probable que esto se deba a que la variable está bastante sesgada hacia la izquierda.


Método del ‘bottom’ y ‘top’ X%

Por último, podemos probar el método más fácil: quitar el 2% superior.

df_1['var_top2'] = prep_outliers(
    df_1['var'], type='set_na', method='bottom_top',
    top_percent=0.02)

Por favor noten que el valor de 2% fue asignado arbitrariamente. También pueden intentar con otros valores, como 3% o 0.5%.

¡Es ahora de comparar todos los métodos!


2.4.7.1.2 Uniendo todo lo que vimos

Tomaremos algunos indicadores para realizar la comparación cuantitativa.

for col in df_1.columns:
    q = df_1[col].isna().sum()
    p = round(100 * df_1[col].isna().mean(), 1)
    print(f"{col}: q_na={q}, p_na={p}%")

prof_num = profiling_num(df_1).round(2)
print()
print(prof_num[['variable', 'mean', 'std_dev',
                'variation_coef']].to_string(index=False))
print()
print(prof_num[['variable', 'p_01', 'p_25',
                'p_50', 'p_75', 'p_99']].to_string(
                index=False))
print()
print(prof_num[['variable', 'skewness', 'kurtosis',
                'iqr']].to_string(index=False))
var: q_na=0, p_na=0.0%
var_tukey: q_na=91, p_na=9.1%
var_hampel: q_na=366, p_na=36.6%
var_top2: q_na=20, p_na=2.0%

  variable   mean  std_dev  variation_coef
       var 594.38  1215.05            2.04
var_hampel  19.20    34.93            1.82
  var_top2 486.52   957.34            1.97
 var_tukey 260.29   474.45            1.82

  variable  p_01  p_25  p_50  p_75    p_99
       var   0.0   0.0  29.0 550.5 5549.53
var_hampel   0.0   0.0   1.0  22.0  145.01
  var_top2   0.0   0.0  26.0 476.5 4428.23
 var_tukey   0.0   0.0  15.0 296.0 2031.88

  variable  skewness  kurtosis   iqr
       var      2.81      8.06 550.5
var_hampel      2.16      3.93  22.0
  var_top2      2.61      6.71 476.5
 var_tukey      2.19      4.19 296.0

Graficar

df_1_melted = df_1.melt(var_name='variable',
                         value_name='value')
fig, ax = plt.subplots(figsize=(10, 5))
sns.boxplot(data=df_1_melted, x='variable', y='value',
            ax=ax)
ax.set_xticklabels(ax.get_xticklabels(), rotation=30,
                    ha='right')
plt.tight_layout()
plt.show()

Comparación de métodos para identificar valores atípicos


Al seleccionar el bottom/top X%, siempre tendremos algunos valores que cumplan con esa condición, mientras que en los otros dos métodos puede que esto no suceda.

2.4.7.1.3 Conclusiones sobre el manejo de valores atípicos en el análisis numérico

La idea es modificar los valores atípicos lo menos posible (por ejemplo, si estamos interesados solamente en describir el comportamiento general).

Para lograr eso -a la hora de crear un informe ad hoc, por ejemplo- podemos usar el promedio. Podríamos elegir el método del 2% superior porque solo afecta al 2% de todos los valores y provoca una disminución drástica en el promedio.

“Modificar o no modificar el conjunto de datos, esa es la cuestión.” William Shakespeare como científico de datos.

El método de Hampel modificó demasiado el promedio. Eso fue tomando el valor estándar de este método, que es 3-MAD (un desvío estándar un tanto robusto).

Por favor tengan en cuenta que esta demostración no significa que Hampel o Tukey sean una mala elección. De hecho, son métodos más robustos porque el umbral puede ser más alto que el valor actual; de hecho, ningun valor es tratado como atípico.

En el otro extremo, podemos considerar, por ejemplo, la variable age de los datos heart_disease. Analicemos sus valores atípicos:

# Obtener el umbral de valores atípicos
print("Tukey:", tukey_outlier(heart_disease['age']))

# Obtener los valores mínimos y máximos
print(f"Min: {heart_disease['age'].min()}")
print(f"Max: {heart_disease['age'].max()}")
Tukey: {'lower': 9.0, 'upper': 100.0}
Min: 29.0
Max: 77.0
  • El umbral inferior es 9, y el valor mínimo es 29.
  • El umbral superior es 100, y el valor máximo es 77.

Ergo: la variable age no tiene valores atípicos.

Si hubiéramos utilizado el método bottom/top X%, entonces los datos dentro de esos porcentajes hubieran sido detectados como valores atípicos.

Todos los ejemplos que vimos hasta ahora tomaron una sola variable a la vez; no obstante, prep_outliers puede manejar varias en simultáneo usando el parámetro input como veremos en la siguiente sección. Todo lo que vimos hasta aquí será equivalente, excepto lo que hacemos una vez que detectamos los valores atípicos, es decir, el método de imputación.


2.4.7.2 Escenario 2: Preparar los valores atípicos para modelado predictivo

El caso anterior da como resultado que los valores atípicos observados se convierten a valores NaN. Esto es un gran problema si estamos construyendo un modelo de machine learning, ya que muchos de ellos no funcionan con valores NaN. Hay más información sobre el manejo de datos faltantes en el capítulo Datos faltantes.

Para lidiar con valores atípicos y poder usar un modelo predictivo, una buena idea es configurar el parámetro type='stop', para que todos los valores marcados como atípicos sean convertidos al valor del umbral.

Algunas cosas a tener en cuenta:

Traten de pensar en el tratamiento (y creación) de las variables como si se lo estuvieran explicando al modelo. Al frenar las variables en un determinado valor, 1% por ejemplo, le estamos diciendo al modelo: Hey, modelo, por favor considera todos los valores extremos como si estuvieran en el percentil 99, dado que este valor ya es lo suficientemente alto. Gracias.

Algunos modelos predictivos son más tolerantes al ruido que otros. Podemos ayudarlos tratando algunos de los valores atípicos. En la práctica, pre-procesar datos tratando los atípicos tiende a producir resultados más precisos cuando estamos en presencia de datos nunca vistos.


2.4.7.3 Imputar valores atípicos para modelado predictivo

Primero, creamos un conjunto de datos con algunos valores atípicos. Ahora el ejemplo tiene dos variables.

# Crear data frame con valores atípicos
pd.set_option('display.float_format',
              lambda x: '%.6f' % x)
np.random.seed(10)
df_2 = pd.DataFrame({
    'var1': np.random.chisquare(df=1, size=1000),
    'var2': np.random.normal(size=1000)
})
# Forzar los valores atípicos
outliers = pd.DataFrame({
    'var1': [135] + [400]*30 + [245, 300, 303, 200],
    'var2': [135] + [400]*30 + [245, 300, 303, 200]
})
df_2 = pd.concat([df_2, outliers],
                  ignore_index=True)

Lidiar con los valores atípicos en ambas variables (var1 y var2) usando el método de Tukey:

df_2_tukey = prep_outliers(
    data=df_2, input=['var1', 'var2'],
    type='stop', method='tukey')

Verificar algunas métricas antes y después de la imputación:

prof_before = profiling_num(df_2)
print("Antes:")
print(prof_before[['variable', 'mean', 'std_dev',
    'variation_coef']].to_string(index=False))

prof_after = profiling_num(df_2_tukey)
print("\nDespués:")
print(prof_after[['variable', 'mean', 'std_dev',
    'variation_coef']].to_string(index=False))
Antes:
variable      mean   std_dev  variation_coef
    var1 13.681800 68.903200        5.036100
    var2 12.694700 69.079800        5.441600

Después:
variable     mean  std_dev  variation_coef
    var1 1.118000 1.486200        1.329300
    var2 0.122100 1.328400       10.879600

Tukey funcionó perfectamente esta vez, exponiendo un promedio más preciso para ambas variables: cercano a 1 para var1 y cercano a 0 para var2.

Observen que esta vez no hay ni un valor NaN. Lo que hizo la función esta vez fue frenar la variable en los valores umbral. Ahora, los valores mínimos y máximos serán los mismos que informó el método de Tukey.

Verificar el umbral para var1:

print(tukey_outlier(df_2['var1']))
{'lower': -4.15, 'upper': 5.79}

Ahora verificamos los valores min/max antes de la transformación:

# Antes:
print(f"Min antes: {df_2['var1'].min()}")
print(f"Max antes: {df_2['var1'].max()}")
Min antes: 6.1927299409483014e-06
Max antes: 400.0

y después de la transformación…

# Después
print(f"Min después: {df_2_tukey['var1'].min()}")
print(f"Max después: {df_2_tukey['var1'].max()}")
Min después: 6.1927299409483014e-06
Max después: 5.79

El mínimo sigue siendo el mismo, pero el máximo fue ajustado al valor de Tukey.

Los cinco valores más altos antes de la preparación eran:

# Antes
print(df_2['var1'].nlargest(5).values)
[400. 400. 400. 400. 400.]

pero después…

# Después:
print(df_2_tukey['var1'].nlargest(5).values)
[5.79 5.79 5.79 5.79 5.79]

Y verificamos que no haya ningún NaN:

for col in df_2_tukey.columns:
    print(f"{col}: q_na={df_2_tukey[col].isna().sum()}")
var1: q_na=0
var2: q_na=0

Bastante claro, ¿no?


Ahora repliquemos el ejemplo que vimos en la última sección con una sola variable para comparar los tres métodos.

df_2['tukey_var2'] = prep_outliers(
    df_2['var2'], type='stop', method='tukey')
df_2['hampel_var2'] = prep_outliers(
    df_2['var2'], type='stop', method='hampel')
df_2['bot_top_var2'] = prep_outliers(
    df_2['var2'], type='stop', method='bottom_top',
    bottom_percent=0.01, top_percent=0.01)


2.4.7.3.1 Uniendo todo lo que vimos
# Excluir var1
df_2_b = df_2.drop(columns=['var1'])

# Análisis numérico
prof = profiling_num(df_2_b)
print(prof[['variable', 'mean', 'std_dev',
            'variation_coef']].to_string(index=False))
print()
print(prof[['variable', 'p_01', 'p_25',
            'p_50', 'p_75', 'p_99']].to_string(
            index=False))
    variable      mean   std_dev  variation_coef
bot_top_var2 12.697400 69.079200        5.440400
 hampel_var2  0.062800  1.140800       18.165600
  tukey_var2  0.122100  1.328400       10.879600
        var2 12.694700 69.079800        5.441600

    variable      p_01      p_25     p_50     p_75       p_99
bot_top_var2 -2.376700 -0.685400 0.014600 0.703500 400.000000
 hampel_var2 -2.380900 -0.685400 0.014600 0.703500   3.130000
  tukey_var2 -2.380900 -0.685400 0.014600 0.703500   4.870000
        var2 -2.380900 -0.685400 0.014600 0.703500 400.000000

Los tres métodos muestran resultados muy similares con estos datos.

Graficar

df_2_b_filtered = df_2_b[df_2_b.max(axis=1) < 100]
df_2_melted = df_2_b_filtered.melt(
    var_name='variable', value_name='value')
fig, ax = plt.subplots(figsize=(10, 5))
sns.boxplot(data=df_2_melted, x='variable', y='value',
            ax=ax)
ax.set_xticklabels(ax.get_xticklabels(), rotation=30,
                    ha='right')
plt.tight_layout()
plt.show()

Comparación de métodos para identificar valores atípicos (modelado predictivo)

Importante: Los puntos que están por encima del valor 100 fueron excluidos, de lo contrario, hubiera sido imposible notar la diferencia entre los métodos.



2.4.8 Reflexiones finales

Hemos abordado el tema de los valores atípicos tanto desde una perspectiva filosófica como técnica, invitando al lector a mejorar sus habilidades de pensamiento crítico a la hora de definir los límites (umbrales). Es fácil pararse en los extremos, pero encontrar el equilibrio es una tarea difícil.

En términos técnicos, cubrimos tres métodos con diferentes bases para identificar valores atípicos:

  • Bottom/Top X%: Este método siempre marcará valores como atípicos dado que en todas las variables hay un X% inferior y superior.
  • Tukey: Se basa en el clásico boxplot, que usa cuartiles.
  • Hampel: Es bastante restrictivo si no modificamos el parámetro por defecto. Se basa en la mediana y el valor del MAD (similar al desvío estándar pero menos sensible a los valores atípicos).

Una vez que identificamos los valores atípicos, el siguiente paso es decidir qué hacer con ellos. En algunos casos no es necesario hacer ningún tratamiento. En conjuntos de datos muy pequeños, podemos identificarlos a simple vista.

La regla de: “Sólo modificar lo que es necesario” (que también puede aplicar a la relación entre el ser humano y la naturaleza), nos dice que no tratemos o excluyamos ciegamente todos los valores atípicos extremos. Con cada acción que hicimos, introducimos algún sesgo. Por eso es tan importante saber cuáles son las implicaciones de cada método. Si es una buena decisión o no depende de la naturaleza de los datos que estamos analizando.

En modelado predictivo, aquellos que tienen algún tipo de técnica de remuestreo interno, o crean varios modelos pequeños para llegar a una predicción final, son más estables con los valores extremos. Hay más información sobre remuestreo y error en el capítulo Conociendo el error.

En algunos casos, cuando el modelo predictivo está ejecutándose en producción, es recomendable reportar o considerar la preparación de cualquier valor extremo nuevo, es decir, un valor que no estaba presente durante la construcción del modelo. Hay más información sobre este tema, pero con una variable categórica, en Variables de alta cardinalidad en modelado predictivo, sección: Manejo de nuevas categorías cuando el modelo predictivo está en producción.

Una buena prueba para que haga el lector es tomar un conjunto de datos, tratar los valores atípicos, y luego comparar algunas métricas de desempeño como Kappa, ROC, Precisión (Accuracy), etc.; ¿La preparación de los datos mejoró alguna de ellas? O, en los reportes, ver cuánto cambia el promedio. Incluso graficando, ¿ahora el gráfico nos dice algo? De esta manera, el lector creará nuevo conocimiento basándose en su experiencia.





2.5 Datos faltantes: Análisis, manejo e imputación

2.5.1 ¿De qué se trata esto?

El análisis de los valores faltantes es la estimación del vacío mismo. Los valores faltantes presentan un obstáculo a la hora de crear modelos predictivos, análisis de clusters, reportes, etc.

En este capítulo, ahondaremos en el concepto y tratamiento de valores nulos. Realizaremos análisis utilizando diferentes enfoques e interpretaremos los distintos resultados.

Si todo sale bien, después de estudiar el capítulo entero, el lector podrá entender conceptos clave del manejo de valores faltantes y podrá tomar mejores abordajes que los que proponemos aquí.


¿Qué vamos a repasar en este capítulo?

  • ¿Qué es un valor nulo, conceptualmente?
  • Cuándo excluir filas o columnas.
  • Análisis numérico de valores faltantes.
  • Transformación e imputación de variables numéricas y categóricas.
  • Imputar valores: desde enfoques simples a algunos más complejos.

Ejemplificaremos estos temas con un enfoque práctico en Python. Este código busca ser lo suficientemente genérico para que lo puedan aplicar en sus proyectos.



2.5.2 Cuando el valor nulo representa información

Los valores vacíos también aparecen como “NULL” en bases de datos, NaN en Python (o NA en R), o simplemente el string “empty” en programas de hojas de cálculo. También pueden estar representados con algún número, como: 0, -1 o -999.

Por ejemplo, imaginen una agencia de viajes que une dos tablas, una de personas y otra de países. El resultado muestra la cantidad de viajes por persona:

 person  South_Africa    Brazil  Costa_Rica
 Fotero      1.000000  5.000000    5.000000
  Herno           NaN       NaN         NaN
Mamarul     34.000000 40.000000         NaN

En este resultado, Mamarul viajó a South Africa 34 veces.

¿Qué representa el valor NaN (o NULL)?

En este caso, NaN debería ser reemplazado por 0, indicando cero viajes en esa intersección persona-país. Después de la conversión, la tabla está lista para usar.

Ejemplo: Reemplazar todos los valores NaN por 0

# Hacer una copia
df_travel_2 = df_travel.copy()
  
# Reemplazar todos los valores NaN con 0
df_travel_2 = df_travel_2.fillna(0)
print(df_travel_2.to_string(index=False))
 person  South_Africa    Brazil  Costa_Rica
 Fotero      1.000000  5.000000    5.000000
  Herno      0.000000  0.000000    0.000000
Mamarul     34.000000 40.000000    0.000000

El último ejemplo transforma todos los valores NaN en 0. No obstante, en otros escenarios, esta transformación podría no aplicar para todas las columnas.

Ejemplo: Reemplazar los valores NaN por 0 sólo en ciertas columnas

Probablemente el escenario más común sea reemplazar NaN por algún valor -cero en este caso- sólo en algunas columnas. Definimos una lista que contiene todas las variables a reemplazar y luego aplicamos fillna.

# Reemplazar valores NaN con 0 solo en las columnas
# seleccionadas
vars_to_replace = ['Brazil', 'Costa_Rica']

df_travel_3 = df_travel.copy()
df_travel_3[vars_to_replace] = (
    df_travel_3[vars_to_replace].fillna(0))

print(df_travel_3.to_string(index=False))
 person  South_Africa    Brazil  Costa_Rica
 Fotero      1.000000  5.000000    5.000000
  Herno           NaN  0.000000    0.000000
Mamarul     34.000000 40.000000    0.000000

Tengan a mano la última función ya que es muy común enfrentarnos a la situación de aplicar una función especificada a un subconjunto de variables y volver a incorporar las variables transformadas y no transformadas al mismo conjunto de datos.

Vamos a un ejemplo más complejo.


2.5.3 Cuando el valor nulo es un valor nulo

En otras ocasiones, tener un valor nulo es correcto, está expresando la ausencia de algo. Debemos tratarlos para poder usar la tabla. Muchos modelos predictivos no pueden manejar tablas de entrada con valores faltantes.

En algunos casos, una variable es medida después de un período de tiempo, por lo que tenemos datos a partir de este momento y NaN en las instancias previas.

A veces hay casos aleatorios, como una máquina que falla al recoletar datos o un usuario que se olvidó de completar algún campo en un formulario, entre otros.

Aquí aparece una pregunta importante: ¿Qué hacemos?

Las siguientes recomendaciones son simplemente eso, recomendaciones. Pueden probar diferentes enfoques para descubrir cuál es la mejor estrategia para los datos que están analizando. No existe un “talle único y universal” en esto.



2.5.4 Excluir la fila entera

Si al menos una columna tiene un valor NaN, excluyan la fila.

Es un método fácil y rápido, ¿no? Lo recomendamos cuando la cantidad de filas con valores faltantes sea baja. Pero, ¿cuán baja es baja? Eso depende de ustedes. Diez casos en 1,000 filas pueden no tener un gran impacto, a menos que esos 10 casos estén vinculados con la predicción de una anomalía; en esta instancia, representan información. Señalamos este tema en Caso 1: Reducción mediante la recategorización de valores menos representativos.


Ejemplo en Python:

Inspeccionemos el conjunto de datos heart_disease con la función status, dado que uno de sus objetivos principales es ayudarnos con este tipo de decisiones.

st = status(heart_disease)
print(st[['variable', 'q_nan', 'p_nan']].round(2).sort_values(
    'q_nan', ascending=False).to_string(index=False))
              variable  q_nan    p_nan
     num_vessels_flour      4 0.010000
                  thal      2 0.010000
                   age      0 0.000000
                   sex      0 0.000000
            chest_pain      0 0.000000
resting_blood_pressure      0 0.000000
     serum_cholestoral      0 0.000000
   fasting_blood_sugar      0 0.000000
       resting_electro      0 0.000000
        max_heart_rate      0 0.000000
           exer_angina      0 0.000000
               oldpeak      0 0.000000
                 slope      0 0.000000
 has_heart_disease_num      0 0.000000
     has_heart_disease      0 0.000000

q_nan indica la cantidad de valores NaN y p_nan es el porcentaje. Pueden encontrar toda la información sobre la función status en el capítulo Análisis numérico, La voz de los números.

Dos variables tienen filas con valores NaN, entonces excluimos estas filas:

# dropna devuelve el mismo data frame habiendo excluido
# todas las filas que contenían al menos un valor NaN
heart_disease_clean = heart_disease.dropna()

# número de filas antes de la exclusión:
print(f"Filas antes: {len(heart_disease)}")

# número de filas después de la exclusión:
print(f"Filas después: {len(heart_disease_clean)}")
Filas antes: 303
Filas después: 297

Después de la exclusión, unas pocas filas fueron eliminadas. Este enfoque parece adecuado para este conjunto de datos.

Sin embargo, existen otros escenarios en los que casi todos los casos son valores vacíos, por lo que ¡esta exclusión eliminaría todo el conjunto de datos!



2.5.5 Excluir la columna

En una operación similar al último caso, excluimos la columna. Si aplicamos el mismo razonamiento y la eliminación es sólo de unas pocas columnas y las restantes proveen un resultado final confiable, entonces es aceptable.

Ejemplo en Python:

Estas exclusiones se pueden manejar fácilmente con la función status. El siguiente código va a conservar todos los nombres de las variables cuyo porcentaje de valores NaN es mayor que 0.

# Obtener nombres de variables que contienen valores NA
st = status(heart_disease)
vars_to_exclude = st[st['p_nan'] > 0][
    'variable'].tolist()

# Verificar las variables a excluir
print("Variables a excluir:", vars_to_exclude)

# Excluir las variables del conjunto de datos original
heart_disease_clean_2 = heart_disease.drop(
    columns=vars_to_exclude)
Variables a excluir: ['num_vessels_flour', 'thal']



2.5.6 Tratamiento de valores vacíos en variables categóricas

Abarcaremos diferentes perspectivas tanto para convertir como para tratar valores vacíos en variables nominales.

Los datos del siguiente ejemplo fueron derivados de web_navigation_data, que contiene información estándar sobre cómo llegan los usuarios a un determinado sitio web. Contiene source_page (la página desde la que proviene el usuario), landing_page (primera página visitada en el sitio), y country.

# Cargar los datos de navegación web
web_navigation_data = pd.read_csv(
    "images/web_navigation_data.txt",
    sep="\t", na_values="")

2.5.6.1 Análisis numérico de los datos

stat_nav_data = status(web_navigation_data)
print(stat_nav_data[['variable', 'type',
    'q_nan', 'p_nan']].round(2).to_string(index=False))
    variable   type  q_nan    p_nan
 source_page object     50 0.520000
landing_page object      5 0.050000
     country object      3 0.030000

Las tres variables tienen valores vacíos (NaN). Falta casi la mitad de los valores en source_page, mientras que las otras dos variables tienen un porcentaje menor de valores NaN.

2.5.6.2 Caso A: Convertir el valor nulo en un string

En variables categóricas o nominales, el tratamiento más rápido es convertir el valor nulo en el string unknown. Así, el modelo de machine learning va a tomar los valores “vacíos” como otra categoría. Piénsenlo como una regla: “Si variable_X = unknown, entonces el resultado = sí”.

A continuación, proponemos dos métodos para cubrir los escenarios típicos:

Ejemplo en Python:

# Método 1: Convertir una sola variable
web_navigation_data_1 = web_navigation_data.copy()
web_navigation_data_1['source_page'] = (
    web_navigation_data_1['source_page'].fillna(
    'unknown_source'))

# Método 2: Es una situación típica la de aplicar una
# función sólo a variables específicas y luego
# reingresarlas al data frame original

# Imaginen que queremos convertir todas las variables
# que tengan menos de 6% de valores NA:
stat_nav = status(web_navigation_data)
vars_to_process = stat_nav[
    stat_nav['p_nan'] < 0.06]['variable'].tolist()

print("Variables a procesar:", vars_to_process)

# Crear un nuevo data frame con las variables
# transformadas
web_navigation_data_2 = web_navigation_data.copy()
for v in vars_to_process:
    web_navigation_data_2[v] = (
        web_navigation_data_2[v].fillna('other'))
Variables a procesar: ['landing_page', 'country']

Verificar los resultados:

print("Dataset 1 (source_page reemplazado):")
print(status(web_navigation_data_1)[
    ['variable', 'q_nan', 'p_nan']].round(2).to_string(
    index=False))
print("\nDataset 2 (variables < 6% NA reemplazadas):")
print(status(web_navigation_data_2)[
    ['variable', 'q_nan', 'p_nan']].round(2).to_string(
    index=False))
Dataset 1 (source_page reemplazado):
    variable  q_nan    p_nan
 source_page      0 0.000000
landing_page      5 0.050000
     country      3 0.030000

Dataset 2 (variables < 6% NA reemplazadas):
    variable  q_nan    p_nan
 source_page     50 0.520000
landing_page      0 0.000000
     country      0 0.000000


2.5.6.3 Caso B: Asignar la categoría más frecuente

La intuición detrás de este método es agregar más de lo mismo para no afectar la variable. Sin embargo, a veces la afecta. No tendrá el mismo impacto si el valor más común aparece el 90% de las ocasiones que si aparece el 10%; es decir, depende de la distribución.

Hay otros escenarios en los que podemos incorporar nuevos valores faltantes basándonos en modelos predictivos como k-NN. Este enfoque es más apropiado que reemplazar por el valor más frecuente. No obstante, la técnica recomendada es la que vimos en Caso A: Convertir el valor nulo en un string.


2.5.6.4 Caso C: Excluir algunas columnas y transformar otras

El caso sencillo sería que la columna contenga, digamos, 50% de casos NaN, dado que sería altamente probable que los datos no sean confiables.

En el caso que vimos antes, source_page tiene más de la mitad de los valores vacíos. Podríamos excluir esta variable y transformar -como lo hicimos- las otras dos.

El ejemplo está preparado como genérico:

# Configurar el umbral
threshold_to_exclude = 0.50  # Representa 50%
stat_nav = status(web_navigation_data)
vars_to_exclude = stat_nav[
    stat_nav['p_nan'] >= threshold_to_exclude][
    'variable'].tolist()
vars_to_keep = stat_nav[
    stat_nav['p_nan'] < threshold_to_exclude][
    'variable'].tolist()

# Finalmente...
print("Variables a excluir:", vars_to_exclude)
print("Variables a conservar:", vars_to_keep)

# La próxima línea excluirá las variables que queden
# por encima del umbral y transformará las demás
web_navigation_data_3 = web_navigation_data.drop(
    columns=vars_to_exclude).copy()
for v in vars_to_keep:
    web_navigation_data_3[v] = (
        web_navigation_data_3[v].fillna('unknown'))

# Verificar que no haya valores NaN y que la variable
# que estaba por encima del umbral haya desaparecido
print("\nResultado final:")
print(status(web_navigation_data_3)[
    ['variable', 'q_nan', 'p_nan']].round(2).to_string(
    index=False))
Variables a excluir: ['source_page']
Variables a conservar: ['landing_page', 'country']

Resultado final:
    variable  q_nan    p_nan
landing_page      0 0.000000
     country      0 0.000000


2.5.6.5 Resumiendo

¿Qué pasa si los datos tienen 40% de valores NaN? Depende del objetivo del análisis y de la naturaleza de los datos.

Lo importante aquí es “salvar” la variable para poder usarla. Es común encontrar muchas variables con valores faltantes. Puede que esas variables incompletas contengan información útil para la predicción cuando tienen un valor, por lo tanto, debemos tratarlas y luego construir un modelo predictivo.

De todas maneras, debemos minimizar el sesgo que estamos introduciendo porque el valor faltante es un valor que “no está ahí”.

  • Cuando estamos haciendo un informe, la sugerencia es reemplazar NaN con el string empty,
  • Cuando estamos haciendo un modelo predictivo que se está corriendo en el momento, la recomendación es asignar la categoría que más se repite.



2.5.7 ¿Hay algún patrón en los valores faltantes?

Primero, carguemos datos de ejemplo y hagamos un análisis rápido.

# Crear un dataset similar a HollywoodMovies2011 para
# demostrar patrones en valores faltantes
np.random.seed(31415)
n_movies = 136
HollywoodMovies2011 = pd.DataFrame({
    'Movie': [f'Movie_{i}' for i in range(n_movies)],
    'LeadStudio': np.random.choice(
        ['Warner Bros', 'Fox', 'Paramount', 'Disney',
         'Universal', 'Sony'], n_movies),
    'Story': np.random.choice(
        ['Original', 'Sequel', 'Remake', 'Based on Book',
         'Adaptation'], n_movies),
    'Genre': np.random.choice(
        ['Action', 'Comedy', 'Drama', 'Horror',
         'Animation', 'Thriller'], n_movies),
    'TheatersOpenWeek': np.random.normal(
        2800, 800, n_movies).clip(100, 4100).round(0),
    'BOAvgOpenWeek': np.random.normal(
        8000, 3000, n_movies).clip(500, 20000).round(0),
    'DomesticGross': np.random.normal(
        80, 60, n_movies).clip(1, 400).round(1),
    'ForeignGross': np.random.normal(
        90, 70, n_movies).clip(0, 500).round(1),
    'WorldGross': np.random.normal(
        170, 100, n_movies).clip(1, 800).round(1),
    'Budget': np.random.normal(
        80, 50, n_movies).clip(5, 300).round(1),
    'Profitability': np.random.normal(
        2, 1.5, n_movies).clip(0, 10).round(2),
    'OpeningWeekend': np.random.normal(
        25, 15, n_movies).clip(0.1, 100).round(1)
})
# Introducir NaN con patrón
na_idx_1 = np.random.choice(n_movies, 2, replace=False)
na_idx_2 = np.random.choice(n_movies, 16, replace=False)
for col in ['DomesticGross', 'ForeignGross',
            'WorldGross', 'Profitability']:
    HollywoodMovies2011.loc[na_idx_1, col] = np.nan
for col in ['BOAvgOpenWeek', 'TheatersOpenWeek',
            'Budget', 'OpeningWeekend']:
    HollywoodMovies2011.loc[na_idx_2, col] = np.nan

# Análisis numérico
print(status(HollywoodMovies2011)[
    ['variable', 'q_nan', 'p_nan']].round(2).to_string(
    index=False))
        variable  q_nan    p_nan
           Movie      0 0.000000
      LeadStudio      0 0.000000
           Story      0 0.000000
           Genre      0 0.000000
TheatersOpenWeek     16 0.120000
   BOAvgOpenWeek     16 0.120000
   DomesticGross      2 0.010000
    ForeignGross      2 0.010000
      WorldGross      2 0.010000
          Budget     16 0.120000
   Profitability      2 0.010000
  OpeningWeekend     16 0.120000

Observemos los valores presentes en la columna p_nan. Hay un patrón en los valores faltantes: algunas variables tienen el mismo porcentaje de valores NA. En este caso, no podemos chequear la fuente de los datos; sin embargo, es una buena idea verificar si esos casos tienen un problema en común.



2.5.8 Tratar valores faltantes en variables numéricas

Nuestro primer acercamiento a este punto al principio del capítulo fue convertir todos los valores de NaN a 0.

Una solución es reemplazar los valores vacíos por la media, la mediana u otros criterios. Sin embargo, tenemos que ser conscientes del cambio que esto genera en la distribución.

Si vemos que la variable parece estar correlacionada cuando no está vacía (igual que la categórica), entonces un método alternativo es crear segmentos, convirtiéndola así en categórica.

2.5.8.1 Método 1: Convertir la variable a categórica

La función equal_freq divide la variable en los segmentos deseados. Toma una variable numérica (TheatersOpenWeek) y devuelve una categórica (TheatersOpenWeek_cat), basándose en el criterio de igual frecuencia.

TheatersOpenWeek_cat frequency percentage cumulative_perc
0 (1053.999, 2323.0] 24 0.176500 0.176500
1 (2323.0, 2629.0] 24 0.176500 0.353000
2 (2629.0, 3079.6] 24 0.176500 0.529500
3 (3079.6, 3677.4] 24 0.176500 0.706000
4 (3677.4, 4100.0] 24 0.176500 0.882500
5 NA 16 0.117600 1.000000

Valores faltantes en datos categóricos

Como podemos ver, TheatersOpenWeek_cat contiene cinco segmentos, cada uno representa aproximadamente el ~18-20% de los casos totales. Pero, los valores NaN siguen ahí.

Por último, debemos convertir los NaN en el string empty.

TheatersOpenWeek_cat_fill frequency percentage cumulative_perc
0 (3079.6, 3677.4] 24 0.176500 0.176500
1 (3677.4, 4100.0] 24 0.176500 0.353000
2 (2323.0, 2629.0] 24 0.176500 0.529500
3 (2629.0, 3079.6] 24 0.176500 0.706000
4 (1053.999, 2323.0] 24 0.176500 0.882500
5 empty 16 0.117600 1.000000

Y eso es todo: la variable está lista para usar.

Cortes personalizados:

Si queremos usar tamaños personalizados de segmentos en lugar de los que vienen dados por la igual frecuencia, podemos usar la función pd.cut. En este caso toma la variable numérica TheatersOpenWeek y devuelve TheatersOpenWeek_cat_cust.

# Crear segmentos personalizados, con límites en 1,000,
# 2,300, y un máx de 4,100. Los valores por encima de
# 4,100 serán asignados a NaN.
HollywoodMovies2011['TheatersOpenWeek_cat_cust'] = (
    pd.cut(HollywoodMovies2011['TheatersOpenWeek'],
           bins=[0, 1000, 2300, 4100],
           include_lowest=True))

print(HollywoodMovies2011[
    'TheatersOpenWeek_cat_cust'].value_counts(
    ).sort_index())
TheatersOpenWeek_cat_cust
(-0.001, 1000.0]     0
(1000.0, 2300.0]    24
(2300.0, 4100.0]    96
Name: count, dtype: int64

Debemos destacar que la segmentación por igual frecuencia tiende a ser más robusta que la igual distancia que divide la variable, que se basa en tomar el mínimo y el máximo, y la distancia entre cada segmento, sin considerar cuántos casos caen en cada segmento.

La igual frecuencia ubica a los valores atípicos en el primer o último segmento según corresponda. Los valores normales pueden ir desde 3 hasta 20 segmentos. Un alto número de segmentos suele significar más ruido. Para leer más, diríjanse al capítulo de cross_plot.


2.5.8.2 Método 2: Completar el NaN con algún valor

Al igual que con las variables categóricas, podemos reemplazar los valores con un número como el promedio o la mediana.

En este caso, reemplazaremos los valores NaN por el promedio y graficar los resultados del antes y el después lado a lado.

# Completar todos los valores NaN con el promedio de
# la variable
mean_val = HollywoodMovies2011[
    'TheatersOpenWeek'].mean()
HollywoodMovies2011['TheatersOpenWeek_mean'] = (
    HollywoodMovies2011['TheatersOpenWeek'].fillna(
    mean_val))

# Graficar la variable original y la transformada
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))

ax1.hist(HollywoodMovies2011['TheatersOpenWeek'].dropna(),
         bins=15, color='white', edgecolor='black')
ax1.set(title='TheatersOpenWeek (original)', ylim=(0, 30))

ax2.hist(HollywoodMovies2011['TheatersOpenWeek_mean'],
         bins=15, color='white', edgecolor='black')
ax2.set(title='TheatersOpenWeek_mean (imputado)',
        ylim=(0, 30))

plt.tight_layout()
plt.show()

Completando los NA con el valor promedio

Podemos ver un pico alrededor del promedio, que es producto de la transformación. Se introduce un sesgo alrededor de este punto. Si estamos prediciendo algún evento, entonces sería más seguro no tener ningún evento especial cerca de este punto.

Por ejemplo, si estamos prediciendo un evento binario y el evento menos representativo está correlacionado con tener un valor cercano al promedio en TheatersOpenWeek, entonces las probabilidades de tener una Tasa de falsos positivos pueden ser más altas. De nuevo, esto se relaciona con el capítulo de Variables de alta cardinalidad en estadística descriptiva.

Como un comentario extra con respecto a la ultima visualización, fue importante configurar el máximo del eje-y en 30 para que los dos gráficos fueran comparables entre sí.

Como pueden ver, existe una interrelación entre todos los conceptos.


2.5.8.3 Eligiendo el valor adecuado para completar

En el último ejemplo remplazamos los valores NaN con el promedio, ¿pero qué pasa si usamos otros valores? Depende de la distribución de la variable.

La variable que usamos (TheatersOpenWeek) parece tener una distribución normal, que es la razón por la cual utilizamos el promedio. No obstante, si la variable está más sesgada, entonces otra métrica sería más adecuada; por ejemplo, la mediana es menos sensible a los valores atípicos.



2.5.9 Métodos avanzados de imputación

Ahora vamos a hacer un repaso rápido de métodos de imputación más sofisticados en los que creamos un modelo predictivo, con todo lo que eso implica.


2.5.9.1 Método 1: Usando IterativeImputer (equivalente a missForest)

La funcionalidad del IterativeImputer de scikit-learn (similar al paquete missForest en R) se basa en modelos iterativos para completar cada valor faltante, manejando variables numéricas simultáneamente.

from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer, SimpleImputer

# Copiar los datos (solo columnas numéricas)
df_holly = HollywoodMovies2011.select_dtypes(
    include=[np.number]).copy()

# Imputación con IterativeImputer (similar a missForest)
iter_imp = IterativeImputer(
    max_iter=10, random_state=31415)
df_imputed_arr = iter_imp.fit_transform(df_holly)
df_imputed = pd.DataFrame(
    df_imputed_arr, columns=df_holly.columns)

# Imputación simple con mediana (similar a na.roughfix)
simple_imp = SimpleImputer(strategy='median')
df_rough_arr = simple_imp.fit_transform(df_holly)
df_rough = pd.DataFrame(
    df_rough_arr, columns=df_holly.columns)

Ahora es momento de comparar las distribuciones de la variable TheatersOpenWeek.

fig, ax = plt.subplots(figsize=(8, 4))

df_holly['TheatersOpenWeek'].dropna().plot.kde(
    ax=ax, label='original', color='green')
df_imputed['TheatersOpenWeek'].plot.kde(
    ax=ax, label='IterativeImputer', color='orange')
df_rough['TheatersOpenWeek'].plot.kde(
    ax=ax, label='Mediana (SimpleImputer)',
    color='steelblue')

ax.legend()
ax.set(title='Comparación de distribuciones')
plt.tight_layout()
plt.show()

Comparación de métodos de imputación (variable numérica)

Análisis:

  • La curva naranja muestra la distribución después de la imputación basada en IterativeImputer.
  • La azul muestra el método de imputación que reemplaza todos los valores NaN por la mediana usando SimpleImputer.
  • La verde muestra la distribución sin ninguna imputación (por supuesto, los valores NaN no están expuestos).

Reemplazar los valores NaN por la mediana tiende a concentrar, como era de esperarse, todos los valores cerca de un solo punto. Por otro lado, la imputación realizada por IterativeImputer provee una distribución más natural porque no concentra los valores cerca de un solo valor.

¡La curva naranja y la verde son bastante parecidas!

Si deseamos tomar un punto de vista analítico, podemos realizar una prueba estadística para comparar, por ejemplo, los promedios o las varianzas.


2.5.9.2 Método 2: Usar el enfoque MICE

Consejo: Como primer abordaje en la imputación de los valores faltantes, este método es sumamente complejo.

MICE significa “Imputación Multivariada por ecuaciones encadenadas” (“Multivariate Imputation by Chained Equations” en inglés), también se la conoce como “Especificación totalmente condicional” (“Fully Conditional Specification”). Este libro cubre este tema debido a su popularidad.

MICE implica un marco completo para analizar y lidiar con los valores faltantes. Considera las interacciones entre todas las variables al mismo tiempo (multivariado y no una sola) y basa su funcionalidad en un proceso iterativo que utiliza diferentes modelos predictivos para completar cada variable.

Internamente, completa la variable A, basada en B y C. Luego, llena B basada en A y C (A se predice previamente) y la iteración continúa. El nombre “ecuaciones encadenadas” viene del hecho de que podemos especificar el algoritmo por variable para imputar los casos.

En Python, el IterativeImputer de scikit-learn implementa este enfoque MICE. El ejemplo que vimos anteriormente ya utiliza esta técnica internamente.

# Ejemplo con datos pequeños similares a nhanes
np.random.seed(42)
nhanes = pd.DataFrame({
    'age': np.random.choice([1, 2, 3], 25),
    'bmi': np.where(
        np.random.random(25) < 0.36, np.nan,
        np.random.normal(27, 4, 25).round(1)),
    'hyp': np.where(
        np.random.random(25) < 0.32, np.nan,
        np.random.choice([1, 2], 25).astype(float)),
    'chl': np.where(
        np.random.random(25) < 0.40, np.nan,
        np.random.normal(190, 40, 25).round(0))
})
print("Datos originales (nhanes):")
print(status(nhanes)[
    ['variable', 'q_nan', 'p_nan']].round(2).to_string(
    index=False))
Datos originales (nhanes):
variable  q_nan    p_nan
     age      0 0.000000
     bmi     11 0.440000
     hyp      8 0.320000
     chl     14 0.560000

Tres variables tienen valores faltantes. Vamos a completarlos:

# Imputación con IterativeImputer (enfoque MICE)
mice_imp = IterativeImputer(
    max_iter=10, random_state=42, sample_posterior=False)
nhanes_imputed = pd.DataFrame(
    mice_imp.fit_transform(nhanes),
    columns=nhanes.columns)

print("\nDatos imputados:")
print(status(nhanes_imputed)[
    ['variable', 'q_nan', 'p_nan']].round(2).to_string(
    index=False))

Datos imputados:
variable  q_nan    p_nan
     age      0 0.000000
     bmi      0 0.000000
     hyp      0 0.000000
     chl      0 0.000000

La idea detrás de esto es que si las distribuciones antes y después se ven similares, entonces la imputación sigue la distribución original.

El inconveniente es que se trata de un proceso lento que puede requerir ciertos ajustes finos para funcionar.

Más información sobre MICE:



2.5.10 Conclusiones

Habiendo cubierto todas las opciones, podríamos preguntar: ¿cuál es la mejor estrategia? Bueno, depende de cuánto queramos intervenir para manejar los valores faltantes.

Un pequeño repaso de las estrategias posibles:

  1. Excluir las filas y columnas con valores faltantes. Sólo es aplicable si hay pocas filas (o columnas) con valores faltantes, y los datos restantes son suficientes para alcanzar la meta del proyecto. No obstante, cuando excluimos filas con valores faltantes y creamos un modelo predictivo que va a ejecutarse en producción, si llega un caso nuevo que contiene valores faltantes, debemos asignar un valor para procesarlos.

  2. Las estrategias de convertir variables numéricas a categóricas y luego crear el valor “empty” (también aplicable a las variables categóricas), es la opción más rápida -y recomendada- para lidiar con valores nulos. De esta manera incorporamos los valores faltantes al modelo para que pueda manejar la incertidumbre.

  3. Los métodos de imputación como los que cubrimos con IterativeImputer (MICE) son considerablemente más complejos. Con estos métodos, introducimos un sesgo controlado para no tener que excluir filas o columnas.

Es un arte encontrar el equilibrio correcto entre profundizar en estas transformaciones y mantenerlo simple. El tiempo invertido puede no reflejarse en la precisión global.

Independientemente del método, es muy importante analizar el impacto de cada decisión. Hay mucho de prueba y error, así como análisis exploratorio de datos, que conduce al descubrimiento del método más adecuado para sus datos y proyecto.





2.6 Consideraciones que involucran al tiempo

2.6.1 ¿De qué se trata esto?

Todo cambia y nada permanece. - Heráclito, (535 - 475 AC), filósofo griego presocrático.

Lo mismo ocurre con las variables.

Con el paso del tiempo, las variables pueden cambiar sus valores, haciendo que el análisis del tiempo sea crucial a la hora de crear un modelo predictivo. Así evitamos tomar los efectos como causas.


¿Qué vamos a repasar en este capítulo?

  • Conceptos de filtrado de información antes del evento a predecir.
  • Cómo analizar y preparar las variables que aumentan -o disminuyen- su valor hasta el infinito (y más allá).


2.6.1.1 No utilicen información del futuro

Imagen de la película: “Volver al futuro” (1985). Robert Zemeckis (Director).

Usar una variable que contiene información después del evento que se está prediciendo es un error común cuando se comienza un nuevo proyecto de modelo predictivo, como jugar a la lotería hoy usando el periódico de mañana.

Imaginemos que necesitamos construir un modelo predictivo para saber qué usuarios es probable que adquieran una suscripción completa en una aplicación web, y este software tiene una funcionalidad ficticia llamada feature_A:

 user_id feature_A full_subscription
       1       yes               yes
       2       yes               yes
       3       yes               yes
       4        no                no
       5       yes               yes
       6        no                no
       7        no                no
       8        no                no
       9        no                no
      10        no                no

Creamos el modelo predictivo, obtuvimos una precisión perfecta, y una inspección arroja el siguiente mensaje: “El 100% de los usuarios que tienen una suscripción completa utiliza la característica Feature A”. Algunos algoritmos predictivos reportan la importancia de las variables; por lo que feature_A estará por encima del resto.

El problema es: feature_A solo está disponible después de que el usuario adquiere la suscripción completa. Por lo tanto, no puede ser utilizada.

El mensaje clave es: No confíen en variables perfectas, ni modelos perfectos.


2.6.1.2 Sean justos con los datos, dejen que desarrollen su comportamiento

Como en la naturaleza, las cosas tienen un tiempo mínimo y máximo para empezar a mostrar cierto comportamiento. Este tiempo oscila de 0 a infinito. En la práctica se recomienda estudiar cuál es el mejor período para analizar, es decir, podemos excluir todo el comportamiento antes y después de este período de observación. Establecer rangos en las variables no es sencillo, ya que puede ser un poco subjetivo.

Imaginen que tenemos una variable numérica que aumenta a medida que pasa el tiempo. Es posible que necesitemos definir una ventana de tiempo de observación para filtrar los datos y alimentar el modelo predictivo.

  • Configurar el tiempo mínimo: ¿Cuánto tiempo es necesario para empezar a ver el comportamiento?
  • Configurar el tiempo máximo: ¿Cuánto tiempo es necesario para ver el final del comportamiento?

La solución más fácil es: configurar el mínimo en el principio y el máximo en toda la historia.

Estudio de caso:

Dos personas, Ouro y Borus, son usuarios de una aplicación web que tiene una determinada funcionalidad llamada feature_A, y necesitamos crear un modelo predictivo que pronostique basándose en el uso de feature_A usage -medido en clicks- si una persona va a adquirir full_subscription.

Los datos actuales dicen: Borus tiene full_subscription, mientras que Ouro no.

d4 = pd.DataFrame({
    'user': (['Ouro']*5 + ['Borus']*5 +
             ['Ouro', 'Ouro']),
    'days_since_signup': [1,2,3,4,5,1,2,3,4,5,6,7],
    'feature_A': [2,3,3,5,12,0,0,1,6,15,20,24]
})

fig, ax = plt.subplots(figsize=(6, 3.5))
for user, grp in d4.groupby('user'):
    ax.plot(grp['days_since_signup'],
            grp['feature_A'], marker='o', label=user)
ax.set(xlabel='days_since_signup', ylabel='feature_A',
       xticks=range(1, 8))
ax.legend()
plt.tight_layout()
plt.show()

Tengan cuidado con las consideraciones que involucran el tiempo

El usuario Borus comienza a usar feature_A a partir del día 3, y después de 5 días ella le da más uso -15 clicks vs. 12- a esta función que Ouro, que comenzó a usarla desde el día 0.

Si Borus adquiere full subscription y Ouro no, ¿qué aprenderá el modelo?

Cuando modelamos con la historia completa -days_since_signup = all-, cuanto más alto sea days_since_signup mayor será probabilidad, dado que Borus tiene el número más alto.

Sin embargo, si sólo tomamos la historia de los usuarios de los primeros 5 días desde la suscripción, la conclusión será la opuesta.

¿Por qué conservar los primeros 5 días de la historia?

El comportamiento durante este período inicial (kick-off) puede ser más relevante -con respecto a la precisión de la predicción- que analizar toda la historia. Como dijimos antes, depende de cada caso.


2.6.1.3 Luchar contra el infinito

El número de ejemplos sobre este tema es muy amplio. Mantengamos la esencia de este capítulo en cómo cambian los datos a lo largo del tiempo. A veces es sencillo, como una variable que alcanza su mínimo (o máximo) después de un tiempo fijo. Este caso es fácilmente alcanzable.

Por otro lado, requiere que el ser humano luche contra el infinito.

Consideren el siguiente ejemplo. ¿Cuántas horas se necesitan para alcanzar el valor 0?

¿Qué tal 100 horas?

100 horas
Min value after 100 hours: 0.22

Está cerca de cero, pero ¿qué pasa si esperamos 1000 horas?

1,000 horas
Min value after 1,000 hours: 0.14

¡Hurra! ¡Nos estamos acercando! Pero ¿qué pasa si esperamos 10 veces más? (10,000 horas)

10,000 horas
Min value after 10,000 hours: 0.11

¡Seguimos sin llegar a cero! ¡¿Cuánto tiempo necesitamos?!

Como habrán notado, es probable que lleguemos a cero en el infinito… Estamos ante una Asíntota.

¿Qué debemos hacer? Es momento de pasar a la siguiente sección.


2.6.1.4 Amigarnos con el infinito

En el último ejemplo vimos el análisis de la antigüedad de un consumidor en una compañía. Este valor puede ser infinito.

Por ejemplo, si el objetivo del proyecto es predecir un resultado binario, como buy/don't buy, un análisis útil es calcular la tasa de buy de acuerdo a la antigüedad del usuario. Llegaremos a conclusiones como: En promedio, un consumidor necesita cerca de 6 meses para comprar este producto.

Esta respuesta puede alcanzarse gracias al trabajo conjunto del científico de datos y un experto en la materia.

En este caso, un cero puede considerarse al igual que el valor que tenga el 95% de la población. En términos estadísticos, es el percentil 0.95. Este libro abarca extensamente este tema en Anexo 1: La magia de los percentiles. Es un tema clave en el análisis exploratorio de datos.

Un caso relacionado es el de lidiar con valores atípicos, cuando aplicamos percentiles como criterio de corte, como vimos en el capítulo Tratamiento de valores atípicos.


2.6.1.5 Ejemplos en otras áreas

Es muy común encontrar este tipo de variables en muchos conjuntos o proyectos de datos.

En medicina, en los proyectos de análisis de supervivencia, los médicos suelen definir un umbral de, por ejemplo, 3 años para considerar que un paciente sobrevive al tratamiento.

En proyectos de marketing, si un usuario disminuye su actividad dentro de un cierto umbral, digamos:

  • 10-clicks en el sitio web de la compañía en el último mes
  • No abrir un email después de 1 semana
  • Él o ella no compra por 30 días

Se lo puede definir como la pérdida de un cliente u oportunidad.

En atención al cliente, un problema puede marcarse como resuelto una vez que la persona pasó 1 semana sin reportar nuevas quejas.

En el análisis de señales cerebrales, si estas señales provienen de la corteza visual en un proyecto en el que, por ejemplo, necesitamos predecir qué tipo de imagen está mirando el paciente, entonces los primeros 40ms de valores no sirven porque es el tiempo que el cerebro necesita para empezar a procesar la señal.

Pero esto también ocurre en “la vida real”, como cuando escribimos un libro de análisis de datos para todas las edades, ¿cuánto tiempo necesitamos para terminarlo? ¿Una cantidad infinita? Probablemente no.


2.6.1.6 Reflexiones finales

Definir un periodo de tiempo para crear un conjunto de entrenamiento y validación, implica un sesgo importante en nuestros datos. Al igual que decidir cómo manejar las variables que cambian con el tiempo. Es por eso que el Análisis exploratorio de datos es importante para entrar en contacto con los datos que estamos analizando.

Los temas están interconectados. Ahora es momento de mencionar la relación de este capítulo con la Validación out-of-time. Cuando predecimos eventos en el futuro, debemos analizar cuánto tiempo es necesario para que la variable objetivo cambie.

El concepto clave aquí es: cómo manejar el tiempo en modelado predictivo. Es una buena oportunidad para preguntar: ¿Cómo sería posible abordar estos problemas del tiempo con sistemas automáticos?

El conocimiento humano es crucial en estos contextos para definir umbrales basándonos en la experiencia, intuición y algunos cálculos.