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é es | Cómo se reconoce | Qué se hace con ella |
|---|---|---|
| Identificador | Tantos valores distintos como filas | Fuera del modelo |
| Numérica continua | Muchos decimales distintos | Escalar |
| Numérica discreta | Enteros, decenas de valores | Escalar, o tramos |
| Categórica nominal | Pocos niveles, sin orden | One-hot |
| Categórica ordinal | Pocos niveles, con orden | Codificar respetando el orden |
| Alta cardinalidad | Cientos de niveles | Agrupar, o codificar por target |
| Fecha | Tipo datetime | Sacarle columnas, nunca usarla cruda |
| Constante | Un valor domina casi todo | Fuera: 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
- 🎭
dtypesdice cómo está guardada la columna, no qué es. Unint64puede 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. - 🔍
satisfaccionviene comofloat64solo porque tiene nulos.Int64con I mayúscula es el entero de pandas que admite huecos. - 🎣 El detector levantó la bandera de
monto_final_facturadosin 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.