Capítulo 5 de 25 9 secciones 16 min

Auditoría de tipos: pandas te miente

Las ocho categorías en que cae cualquier columna, con el detector que las clasifica solo y las dos que este archivo tenía disfrazadas.

El tipo que declara pandas no es el tipo real de la columna: un int64 puede ser un identificador, una categoría o una fecha mal leída. Hay ocho categorías reales y cada una decide qué se hace con la columna. En este archivo, satisfaccion viene como float64 y es ordinal, y cliente_id tiene 617 niveles, o sea que codificarla como una categórica normal añadiría 617 columnas al modelo.

Ya está limpio. Ahora la pregunta que casi nadie hace antes de modelar: ¿qué es realmente cada columna? 🕵️

Y no vale mirar dtypes, porque pandas te dice cómo está guardada la columna, no qué es. Un int64 puede ser un número, un identificador, una categoría o una fecha mal leída, y los cuatro casos se tratan distinto.

Las ocho categorías

Qué esCómo se reconoceQué se hace con ella
IdentificadorTantos valores distintos como filasFuera del modelo
Numérica continuaMuchos decimales distintosEscalar
Numérica discretaEnteros, decenas de valoresEscalar, o tramos
Categórica nominalPocos niveles, sin ordenOne-hot
Categórica ordinalPocos niveles, con ordenCodificar respetando el orden
Alta cardinalidadCientos de nivelesAgrupar, o codificar por target
FechaTipo datetimeSacarle columnas, nunca usarla cruda
ConstanteUn valor domina casi todoFuera: no distingue nada

Fíjate en que ninguna de las ocho se decide mirando dtypes 😌

El detector

import pandas as pd

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'


def carga_limpia(url):
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y',
                                  errors='coerce')
        v[col] = f
    return v


v = carga_limpia(URL)


def audita(df, ordinales=()):
    """Clasifica cada columna en una de las ocho categorias."""
    filas = []
    for col in df.columns:
        s = df[col]
        distintos = s.nunique()
        ratio = distintos / len(df)
        dominante = s.value_counts(normalize=True, dropna=True).iloc[0]

        if pd.api.types.is_datetime64_any_dtype(s):
            tipo = 'fecha'
        elif ratio > 0.98:
            tipo = 'identificador'
        elif dominante > 0.95:
            tipo = 'constante'
        elif col in ordinales:
            tipo = 'ordinal'
        elif distintos <= 15:
            tipo = 'nominal'
        elif not pd.api.types.is_numeric_dtype(s):
            tipo = 'alta cardinalidad'
        elif pd.api.types.is_integer_dtype(s):
            tipo = 'discreta'
        else:
            tipo = 'continua'

        filas.append((col, str(s.dtype), distintos, round(ratio, 4),
                      round(dominante, 4), tipo))

    return pd.DataFrame(filas, columns=['columna', 'dtype', 'distintos',
                                        'ratio', 'dominante', 'tipo'])


print(audita(v, ordinales=('satisfaccion',)).to_string(index=False))
              columna          dtype  distintos  ratio  dominante              tipo
             id_venta          int64       3000 1.0000     0.0003     identificador
                fecha datetime64[us]        537 0.1790     0.0047             fecha
           cliente_id            str        617 0.2057     0.0047 alta cardinalidad
               ciudad            str          6 0.0020     0.1820           nominal
             segmento            str          4 0.0013     0.2670           nominal
                canal            str          4 0.0013     0.2640           nominal
            categoria            str          5 0.0017     0.2133           nominal
             unidades          int64         30 0.0100     0.0693          discreta
                monto        float64       2964 0.9880     0.0010     identificador
            descuento        float64        251 0.0837     0.0083          continua
  fecha_ultima_compra datetime64[us]        399 0.1330     0.0053             fecha
         satisfaccion        float64          5 0.0017     0.2033           ordinal
               compro          int64          2 0.0007     0.5777           nominal
monto_final_facturado        float64       1723 0.5743     0.4223          continua

Ahí está el archivo entero clasificado en una pasada 🙌

Y hay tres filas que merecen que pares 👇

1. cliente_id: 617 niveles

print('niveles: %d' % v['cliente_id'].nunique())
print('el mas frecuente aparece %d veces' % v['cliente_id'].value_counts().iloc[0])
print('columnas que crearia un one-hot: %d' % v['cliente_id'].nunique())
print('filas por columna nueva: %.2f' % (len(v) / v['cliente_id'].nunique()))
niveles: 617
el mas frecuente aparece 14 veces
columnas que crearia un one-hot: 617
filas por columna nueva: 4.86

Codificar esto con one-hot añadiría 617 columnas al modelo, y tocarían a menos de cinco filas por columna 😵

Eso no es información, es ruido con formato de tabla: el modelo puede aprenderse cada cliente de memoria y no generalizar a ninguno nuevo.

Las salidas de una alta cardinalidad son tres, y las tres son decisiones de negocio:

  • ✂️ Sacarla. Es lo que hace este libro, porque la pregunta es sobre ventas y no sobre clientes.
  • 🧺 Agrupar. Quedarse con los 20 clientes más grandes y meter el resto en "otros".
  • 🎯 Codificar por target. Reemplazar cada cliente por su tasa histórica de compra. Funciona muy bien y es fuga de información servida si se calcula sobre todos los datos, así que va dentro del pipeline y solo con el entrenamiento.

2. satisfaccion: float64 pero ordinal

print(v['satisfaccion'].value_counts().sort_index().to_string())
print()
print('dtype: %s | niveles: %d' % (v['satisfaccion'].dtype,
                                   v['satisfaccion'].nunique()))
satisfaccion
1.0    560
2.0    537
3.0    551
4.0    558
5.0    563

dtype: float64 | niveles: 5

Cinco niveles guardados como decimales 🙃 Y decimales solo porque hay nulos: en pandas una columna de enteros con huecos se vuelve float.

La diferencia práctica entre tratarla como nominal o como ordinal es esta:

  • 🔢 Como ordinal, 1 a 5, el modelo aprende que ir de 4 a 5 es lo mismo que ir de 1 a 2. Una columna.
  • 🧩 Como nominal, one-hot de cinco columnas, el modelo puede aprender que el 3 se porta raro sin tener que respetar el orden. Cinco columnas.

Y esto se decide midiendo, no opinando. Lo hace el ejercicio 3.

3. monto_final_facturado: el 42,23% de las filas valen lo mismo

print('valor mas comun: %.2f' % v['monto_final_facturado'].mode()[0])
print('cuantas filas lo tienen: %d (%.2f%%)'
      % ((v['monto_final_facturado'] == 0).sum(),
         100 * (v['monto_final_facturado'] == 0).mean()))
print()
print(v.groupby('compro')['monto_final_facturado'].agg(['min', 'max', 'count']))
valor mas comun: 0.00
cuantas filas lo tienen: 1267 (42.23%)

         min     max  count
compro                     
0       0.00     0.0   1267
1       2.53  4171.7   1733

Ahí está la trampa del archivo, cazada por el detector sin que nadie le avisara 🎣

El 42,23% de las filas tienen exactamente cero, y esas son justo las que no compraron. La columna no es un monto: es la etiqueta escrita de otra forma.

Un auditor de tipos no sabe que eso es fuga, pero sí levanta la bandera de "aquí hay un valor que domina casi la mitad de la tabla", y esa bandera es la que te hace mirar. La fuga como tal la vemos en el capítulo 12.

El error del capítulo

v['satisfaccion'].astype(int)
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer.Replace or remove non-finite values or cast to an integer typethat supports these values (e.g. 'Int64')

Los 231 nulos 🕳️ Una columna de enteros con huecos no se puede convertir a entero, porque no existe el entero "vacío".

Y aquí hay algo que sí sirve de verdad y casi nadie usa: pandas tiene un entero que admite nulos.

convertida = v['satisfaccion'].astype('Int64')
print('dtype: %s' % convertida.dtype)
print(convertida.head(4).to_string())
print('nulos conservados: %d' % convertida.isna().sum())
dtype: Int64
0    4
1    3
2    1
3    4
nulos conservados: 231

La I mayúscula es toda la diferencia: Int64 es el entero de pandas que admite huecos, y int64 el de numpy que no.

Con eso la columna deja de fingir que es decimal, y cualquier detector que mire dtypes la va a clasificar mejor 🎯

Practica 💪

1. Las columnas que el modelo NO debe ver

Con la auditoría hecha, arma la lista de lo que se descarta y escribe por qué cada una.

auditoria = audita(v, ordinales=('satisfaccion',))
fuera = auditoria[auditoria['tipo'].isin(['identificador', 'constante',
                                          'alta cardinalidad'])]
print(fuera[['columna', 'tipo', 'distintos']].to_string(index=False))
print()
print('columnas del archivo: %d' % len(v.columns))
print('descartadas por tipo: %d' % len(fuera))
   columna              tipo  distintos
  id_venta     identificador       3000
cliente_id alta cardinalidad        617
     monto     identificador       2964

columnas del archivo: 14
descartadas por tipo: 3

Tres columnas fuera antes de mirar una sola relación... y una de las tres está mal 🚨

id_venta y cliente_id son correctas: la primera es un identificador puro y la segunda tiene 617 niveles.

Pero monto no es un identificador, y mi propio detector dice que sí. El motivo está en el umbral que puse: marco como identificador todo lo que tenga más del 98% de valores distintos, y el monto tiene 2.964 valores distintos en 3.000 filas, o sea un 98,80%.

print('valores distintos: %d de %d' % (v['monto'].nunique(), len(v)))
print('ratio: %.4f' % (v['monto'].nunique() / len(v)))
print('valores que se repiten: %d' % (v['monto'].value_counts() > 1).sum())
valores distintos: 2964 de 3000
ratio: 0.9880
valores que se repiten: 35

Solo 35 montos se repiten, y es lo esperable en una columna de dinero con dos decimales: que dos ventas den exactamente 1.043,25 es casualidad, no un patrón.

Lo dejo publicado tal cual porque es la lección del capítulo entero: el detector propone y tú decides. Un umbral automático no distingue "cada fila tiene su propio código" de "esta magnitud tiene muchos decimales", y las dos cosas dan un ratio de 0,99.

La regla que uso yo, y que ninguna función puede aplicar sola: un identificador no se puede sumar. Se puede sumar el monto de dos ventas y el resultado significa algo; sumar dos números de factura no significa nada 🧠

2. Un identificador que parece número

Comprueba qué pasa si dejas id_venta dentro del modelo.

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split

X = pd.get_dummies(v[['segmento', 'canal', 'unidades', 'monto']],
                   drop_first=True)
y = v['compro']
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=7,
                                      stratify=y)

bosque = RandomForestClassifier(n_estimators=120, random_state=7).fit(Xtr, ytr)
print('sin id: %.4f' % roc_auc_score(yte, bosque.predict_proba(Xte)[:, 1]))

Xtr2, Xte2 = Xtr.copy(), Xte.copy()
Xtr2['id_venta'] = v.loc[Xtr.index, 'id_venta']
Xte2['id_venta'] = v.loc[Xte.index, 'id_venta']
bosque2 = RandomForestClassifier(n_estimators=120, random_state=7).fit(Xtr2, ytr)
print('con id: %.4f' % roc_auc_score(yte, bosque2.predict_proba(Xte2)[:, 1]))
sin id: 0.5901
con id: 0.6184

Y aquí me llevé un susto 😳

Meter el identificador subió el AUC. Yo iba a escribir que el modelo se gasta capacidad en ruido y empeora, y salió lo contrario.

Antes de explicarlo, hay que ver si fue casualidad de esta partición:

import numpy as np

sin_id, con_id = [], []
for semilla in range(10):
    a, b, ya, yb = train_test_split(X, y, test_size=0.25, random_state=semilla,
                                    stratify=y)
    m1 = RandomForestClassifier(n_estimators=120, random_state=7).fit(a, ya)
    sin_id.append(roc_auc_score(yb, m1.predict_proba(b)[:, 1]))

    a2, b2 = a.copy(), b.copy()
    a2['id_venta'] = v.loc[a.index, 'id_venta']
    b2['id_venta'] = v.loc[b.index, 'id_venta']
    m2 = RandomForestClassifier(n_estimators=120, random_state=7).fit(a2, ya)
    con_id.append(roc_auc_score(yb, m2.predict_proba(b2)[:, 1]))

sin_id, con_id = np.array(sin_id), np.array(con_id)
print('sin id: %.4f' % sin_id.mean())
print('con id: %.4f' % con_id.mean())
print('veces que el id ayuda: %d de 10' % (con_id > sin_id).sum())
sin id: 0.5803
con id: 0.6143
veces que el id ayuda: 10 de 10

Diez de diez. No es casualidad 🤨

Y sin embargo el identificador no tiene ninguna relación con la etiqueta:

from scipy import stats

r, p = stats.pearsonr(v['id_venta'], v['compro'])
print('correlacion id_venta con compro: r = %.4f | p = %.4f' % (r, p))
correlacion id_venta con compro: r = 0.0022 | p = 0.9034

r = 0,0022 con p = 0,9034, o sea nada de nada. Entonces, ¿de dónde sale la mejora?

El control lo resuelve. En vez del identificador, le meto una columna de ruido puro:

generador = np.random.default_rng(7)
v['ruido'] = generador.normal(size=len(v))

con_ruido = []
for semilla in range(10):
    a, b, ya, yb = train_test_split(X, y, test_size=0.25, random_state=semilla,
                                    stratify=y)
    a3, b3 = a.copy(), b.copy()
    a3['ruido'] = v.loc[a.index, 'ruido']
    b3['ruido'] = v.loc[b.index, 'ruido']
    m3 = RandomForestClassifier(n_estimators=120, random_state=7).fit(a3, ya)
    con_ruido.append(roc_auc_score(yb, m3.predict_proba(b3)[:, 1]))

print('sin nada:       %.4f' % sin_id.mean())
print('con id_venta:   %.4f' % con_id.mean())
print('con ruido puro: %.4f' % np.mean(con_ruido))
sin nada:       0.5803
con id_venta:   0.6143
con ruido puro: 0.6006

Ahí está 🎯 Una columna de números al azar también sube el AUC.

O sea que la mejora no viene del identificador: viene de darle al bosque una columna continua más por donde partir. Con solo cuatro columnas el bosque estaba corto de sitios donde cortar, y cualquier variable con muchos valores le da particiones más finas que, en este caso, promedian mejor.

Lo que esto NO significa es que meter identificadores sea buena idea. Significa tres cosas y las tres importan:

  • 🧪 Un experimento sin control no prueba nada. Si me quedo en "el id sube el AUC", habría publicado una conclusión falsa con diez particiones de respaldo.
  • 🌲 Este bosque estaba mal dimensionado para cuatro columnas, y eso se arregla en el capítulo de validación, no metiéndole basura.
  • 🚨 En un archivo donde los identificadores se asignen por orden (por fecha, por sucursal, por lote), esto deja de ser una curiosidad y se vuelve fuga de información pura.
3. ¿Ordinal o nominal? Que lo diga el AUC

Decide el tipo de satisfaccion midiendo, no opinando.

from sklearn.linear_model import LogisticRegression

con_dato = v.dropna(subset=['satisfaccion'])
y2 = con_dato['compro']

como_numero = con_dato[['satisfaccion']]
como_categoria = pd.get_dummies(con_dato['satisfaccion'].astype(int),
                                prefix='sat')

for nombre, Xs in [('ordinal (1 columna)', como_numero),
                   ('nominal (5 columnas)', como_categoria)]:
    a, b, ya, yb = train_test_split(Xs, y2, test_size=0.25, random_state=7,
                                    stratify=y2)
    m = LogisticRegression(max_iter=1000).fit(a, ya)
    print('%-22s AUC %.4f' % (nombre, roc_auc_score(yb, m.predict_proba(b)[:, 1])))
ordinal (1 columna)    AUC 0.6150
nominal (5 columnas)   AUC 0.6150

Prácticamente lo mismo, y eso ya es la respuesta 🎯

Cuando las dos formas dan igual, se elige la de una columna: menos parámetros, más fácil de explicar y menos oportunidades de sobreajustar.

La versión nominal solo se justifica si al medirla saliera claramente mejor, que sería la señal de que algún nivel se porta distinto de lo que su posición sugiere. Aquí no pasa.

4. La alta cardinalidad, agrupada

En vez de tirar cliente_id, prueba a quedarte con los grandes y agrupar el resto.

frecuencia = v['cliente_id'].value_counts()
grandes = frecuencia.head(20).index

agrupado = v['cliente_id'].where(v['cliente_id'].isin(grandes), 'otros')
print('niveles antes:   %d' % v['cliente_id'].nunique())
print('niveles despues: %d' % agrupado.nunique())
print('filas en "otros": %d (%.1f%%)'
      % ((agrupado == 'otros').sum(), 100 * (agrupado == 'otros').mean()))
print()
print(v.groupby(agrupado)['compro'].mean().sort_values().tail(4).round(4).to_string())
niveles antes:   617
niveles despues: 21
filas en "otros": 2791 (93.0%)

cliente_id
C0433    0.7000
C0515    0.7273
C0594    0.7778
C0108    0.8182

De 617 niveles a 21 🧺

Pero mira el porcentaje que cae en "otros": los veinte clientes más grandes son una parte chica del archivo, así que la columna nueva es casi toda "otros" y aporta poquísimo.

Y hay un problema peor escondido: los clientes de arriba tienen entre diez y catorce ventas cada uno. Estimar una tasa de compra con catorce ventas es estimarla con un margen de error enorme, y el modelo se la va a creer como si fuera exacta.

Por eso aquí la decisión es sacarla. Agrupar sirve cuando los niveles grandes concentran de verdad, no cuando la cola es todo 📉

5. La fecha, que no es una columna sino diez

Saca de fecha todo lo que se puede sacar, y mira cuál sirve.

f = v['fecha']
derivadas = pd.DataFrame({
    'mes': f.dt.month,
    'dia_semana': f.dt.dayofweek,
    'dia_mes': f.dt.day,
    'trimestre': f.dt.quarter,
    'fin_de_mes': (f.dt.day >= 25).astype(int),
    'fin_de_semana': (f.dt.dayofweek >= 5).astype(int),
})

for col in derivadas.columns:
    tasas = v.groupby(derivadas[col])['compro'].mean()
    print('%-14s de %.4f a %.4f  (brecha %.4f)'
          % (col, tasas.min(), tasas.max(), tasas.max() - tasas.min()))
mes            de 0.5479 a 0.6176  (brecha 0.0697)
dia_semana     de 0.5592 a 0.5963  (brecha 0.0370)
dia_mes        de 0.4865 a 0.6602  (brecha 0.1737)
trimestre      de 0.5663 a 0.5876  (brecha 0.0213)
fin_de_mes     de 0.5617 a 0.5817  (brecha 0.0200)
fin_de_semana  de 0.5760 a 0.5783  (brecha 0.0023)

Seis columnas de una sola fecha, y ahora se ve cuáles valen 📅

La brecha es cuánto separa al mejor valor del peor. Cuidado con leerla sola: dia_mes tiene 31 valores y por puro azar alguno va a destacar, así que su brecha siempre parece grande. Con fin_de_semana, que solo tiene dos, no hay dónde esconderse.

Esa comparación entre columnas con distinto número de niveles es justo lo que el capítulo de EDA bivariado hace bien, con un test por medio 🔬

6. El auditor, con recomendación incluida

Amplía el detector para que además diga qué hacer con cada columna.

QUE_HACER = {
    'identificador': 'fuera del modelo',
    'constante': 'fuera: no distingue nada',
    'alta cardinalidad': 'agrupar o codificar por target, dentro del pipeline',
    'nominal': 'one-hot',
    'ordinal': 'una columna respetando el orden',
    'discreta': 'escalar, o partir en tramos',
    'continua': 'escalar',
    'fecha': 'derivar columnas, nunca usarla cruda',
}

auditoria = audita(v, ordinales=('satisfaccion',))
auditoria['que_hacer'] = auditoria['tipo'].map(QUE_HACER)
print(auditoria[['columna', 'tipo', 'que_hacer']].to_string(index=False))
              columna              tipo                                           que_hacer
             id_venta     identificador                                    fuera del modelo
                fecha             fecha                derivar columnas, nunca usarla cruda
           cliente_id alta cardinalidad agrupar o codificar por target, dentro del pipeline
               ciudad           nominal                                             one-hot
             segmento           nominal                                             one-hot
                canal           nominal                                             one-hot
            categoria           nominal                                             one-hot
             unidades          discreta                         escalar, o partir en tramos
                monto     identificador                                    fuera del modelo
            descuento          continua                                             escalar
  fecha_ultima_compra             fecha                derivar columnas, nunca usarla cruda
         satisfaccion           ordinal                     una columna respetando el orden
               compro           nominal                                             one-hot
monto_final_facturado          continua                                             escalar
                ruido     identificador                                    fuera del modelo

Esa tabla es el puente entre el EDA y el pipeline, y es lo que se pega en el informe 📋

Lo que la hace útil no es la clasificación: es que cada fila lleva una decisión al lado. Cuando alguien pregunte por qué el modelo no usa cliente_id, la respuesta está escrita desde el principio y no se improvisa en la reunión.

Y ojo con una cosa: el detector propone, no decide. satisfaccion salió ordinal porque yo se lo dije; sin esa lista habría salido nominal. La máquina puede contar valores distintos, pero saber si hay orden es cosa tuya 🧠

Comprueba que lo tienes

Tu detector marca la columna monto como identificador porque el 98,80% de sus valores son distintos. ¿Qué haces?

  • Lo corrijo a mano: un identificador no se puede sumar y un monto sí
  • Subo el umbral del detector hasta que monto deje de salir
  • Saco monto del modelo, porque el detector lo dijo
  • Nada, el detector sabe más que yo

Lo que te llevas

  • 🎭 dtypes dice cómo está guardada la columna, no qué es. Un int64 puede ser número, identificador, categoría o fecha mal leída.
  • 🔢 Ocho categorías, y ninguna se decide mirando el dtype: se deciden contando valores distintos, mirando cuánto domina el más frecuente y sabiendo qué significa la columna.
  • 🚪 617 niveles en cliente_id: un one-hot añadiría 617 columnas a menos de cinco filas cada una.
  • 🔍 satisfaccion viene como float64 solo porque tiene nulos. Int64 con I mayúscula es el entero de pandas que admite huecos.
  • 🎣 El detector levantó la bandera de monto_final_facturado sin saber nada de fuga: el 42,23% de las filas valen exactamente lo mismo.
  • ⚠️ Y se equivocó con monto, que marcó como identificador porque el 98,80% de sus valores son distintos. El detector propone; tú decides.
  • 🧪 Meter el identificador subió el AUC en 10 de 10 particiones, y el control con una columna de ruido puro demostró que no era el identificador: era que el bosque estaba corto de columnas por donde partir.
  • 📋 La auditoría termina en una tabla con una decisión por columna, y esa tabla es la que se pega en el informe.

Qué viene ahora

Con cada columna clasificada, toca construir las que no vienen en el archivo 🧱

Porque hasta aquí solo hemos ordenado lo que había. Y lo que de verdad mueve la aguja en un proyecto de ML casi nunca es el algoritmo: son las columnas que alguien se inventó porque conoce el negocio.

El capítulo 9 saca cinco de las que ya hay:

  • 🕳️ El hueco del descuento convertido en dato, que es lo que quedó pendiente en el capítulo de gobernanza.
  • 💵 El precio por unidad, que en el libro de estadística resultó ir de 26,59 a 279,61 soles según el segmento.
  • 📊 El monto partido en tramos.
  • 🗑️ Una que no sirve, para que veas cómo se descarta.
  • 🪤 Y una trampa que sube el AUC treinta puntos y no vale nada.
¿Tienes alguna duda o consulta?