Aquí es donde de verdad se gana 🔧
Cambiar de modelo te da décimas. Construir una columna buena te da puntos enteros. Y en el capítulo 4 ya encontramos dos que valen oro sin haberlas escrito todavía: los huecos.
Este capítulo va de eso: convertir lo que hay en cosas que un modelo pueda usar. En inglés se llama feature engineering, que es como lo vas a ver escrito en todas partes, y en español ingeniería de características. Yo le digo construir columnas porque es literalmente lo que se hace. Y también de lo contrario, porque vamos a construir cinco columnas y solo tres van a servir. Enseñar las que no sirven es la mitad del oficio.
El punto de partida
Cada capítulo de este libro arranca solo, así que empezamos por la función de limpieza del capítulo 4.
import pandas as pd
URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
def carga_limpia(url):
"""Lo del capítulo 4, en una función."""
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
ventas = carga_limpia(URL)
print(ventas.shape)
(3000, 14)
Columna 1: el hueco convertido en dato
En el capítulo 4 vimos que sin fecha de última compra se cierra el 41,4% y con fecha el 61,4%. Esa señal está ahí y el modelo no la puede ver, porque un nulo para scikit-learn es un problema, no un dato.
La solución es de una línea y es la técnica más rentable de todo el capítulo:
ventas['sin_compra_previa'] = ventas['fecha_ultima_compra'].isna().astype(int)
ventas['sin_descuento'] = ventas['descuento'].isna().astype(int)
print(ventas.groupby('sin_compra_previa')['compro'].agg(['count', 'mean']).round(4))
count mean sin_compra_previa 0 2456 0.6140 1 544 0.4136
Ahí está, ahora sí en una columna de ceros y unos que cualquier modelo entiende. Veinte puntos de diferencia guardados 💎
La regla: cuando un hueco significa algo, se marca antes de rellenarlo. Primero la bandera, después el relleno. Así el modelo tiene las dos cosas: un valor con el que trabajar y la información de que ese valor era inventado.
Columna 2: el precio por unidad
Ni el monto ni las unidades por separado decían gran cosa. Su cociente sí.
ventas['precio_unitario'] = ventas['monto'] / ventas['unidades']
print(ventas.groupby('compro')['precio_unitario'].median().round(2))
compro 0 39.27 1 61.72 Name: precio_unitario, dtype: float64
Mediana de S/39 en las que no se cerraron y S/62 en las que sí. Y en tramos se ve todavía mejor:
tramo = pd.qcut(ventas['precio_unitario'], 4) print(ventas.groupby(tramo, observed=True)['compro'].agg(['count', 'mean']).round(4))
count mean precio_unitario (-599.901, 23.941] 750 0.4400 (23.941, 53.024] 750 0.5667 (53.024, 121.764] 750 0.6107 (121.764, 3586.26] 750 0.6933
De 44% en el cuarto más barato a 69% en el más caro. Veinticinco puntos, y esa columna no existía hace dos líneas 🌟
Esto se llama ratio y es de las cosas que más funcionan: dividir dos columnas que ya tienes. Piensa siempre en "por unidad", "por día", "porcentaje del total". Un modelo lineal no puede inventarse una división, así que si la necesita hay que dársela hecha.
Columna 3: el monto en tramos
tramo_monto = pd.qcut(ventas['monto'], 4) print(ventas.groupby(tramo_monto, observed=True)['compro'].agg(['count', 'mean']).round(4))
count mean monto (-2497.721, 252.69] 750 0.3960 (252.69, 533.63] 750 0.5680 (533.63, 1068.685] 750 0.6373 (1068.685, 4236.71] 750 0.7093
De 39,6% a 70,9%, y en orden. Eso es señal de verdad y encima monótona: cuanto más grande el monto, más se cierra.
Cuando la relación es así de ordenada, la columna original ya sirve tal cual y no hace falta partirla en tramos. Los tramos son útiles cuando la relación no es ordenada: por ejemplo si se cerrara mucho abajo y arriba y poco en el medio, ahí una columna numérica no lo puede expresar y los tramos sí 📊
Columna 4: la que no sirve
Ahora una que suena obvia y no funciona.
ventas['dia_semana'] = ventas['fecha'].dt.dayofweek
print(ventas.groupby('dia_semana')['compro'].agg(['count', 'mean']).round(4))
count mean dia_semana 0 415 0.5952 1 473 0.5687 2 431 0.5963 3 418 0.5622 4 421 0.5701 5 420 0.5929 6 422 0.5592
Todos entre 0,559 y 0,596. Cuatro puntos entre el mejor y el peor día, con unas 420 filas cada uno. Eso es ruido.
Y aquí va algo importante: esto también es un resultado. Si alguien en la reunión dice "los martes se vende peor", ya tienes la respuesta medida en vez de una opinión.
La columna se puede dejar (un buen modelo la ignora sola) o quitar. Yo la quito, porque cada columna inútil es una oportunidad más de que el modelo memorice ruido 🧹
Columna 5: la trampa
Esta parece la mejor idea del capítulo. Tenemos la fecha de la venta y la de la última compra, así que la antigüedad del cliente sale sola:
ventas['dias_desde_ultima'] = (ventas['fecha'] - ventas['fecha_ultima_compra']).dt.days print(ventas['dias_desde_ultima'].describe().round(1))
count 2456.0 mean 72.8 std 190.3 min -385.0 25% -65.0 50% 69.0 75% 213.2 max 522.0 Name: dias_desde_ultima, dtype: float64
Mira el mínimo: -385 😳
negativos = ventas['dias_desde_ultima'] < 0
print('cuántos:', int(negativos.sum()), f'({negativos.mean():.1%})')
print(ventas.loc[negativos, ['fecha', 'fecha_ultima_compra', 'dias_desde_ultima']].head(3).to_string(index=False))
cuántos: 904 (30.1%)
fecha fecha_ultima_compra dias_desde_ultima
2025-06-05 2025-08-08 -64.0
2025-02-02 2026-01-18 -350.0
2025-06-29 2025-10-10 -103.0
904 filas, el 30% del archivo, donde la "última compra" es posterior a la venta. O sea que esa columna no dice "la última compra antes de esta": dice la última compra del cliente en todo el archivo, futuro incluido.
Un modelo entrenado con eso aprende de compras que en el momento de predecir
todavía no habían pasado. Es la misma fuga del capítulo 12, y esta versión es
mucho peor que la del monto_final_facturado porque no salta a la
vista: la columna tiene un nombre inocente y valores razonables 🚨
La versión bien hecha, y lo que pasa
Se arregla calculando la fecha anterior de cada cliente ordenando por fecha:
ventas = ventas.sort_values(['cliente_id', 'fecha'])
fecha_anterior = ventas.groupby('cliente_id')['fecha'].shift(1)
ventas['dias_reales'] = (ventas['fecha'] - fecha_anterior).dt.days
print('negativos ahora:', int((ventas['dias_reales'] < 0).sum()))
print('nulos (primera venta del cliente):', int(ventas['dias_reales'].isna().sum()))
negativos ahora: 0 nulos (primera venta del cliente): 617
Cero negativos, y 617 nulos que son exactamente las primeras ventas de cada uno de los 617 clientes. Eso ya es correcto: solo mira hacia atrás.
¿Y sirve?
tramo_dias = pd.cut(ventas['dias_reales'], [-1, 30, 90, 180, 10000],
labels=['0-30', '31-90', '91-180', '+180'])
print(ventas.groupby(tramo_dias, observed=True)['compro'].agg(['count', 'mean']).round(4))
count mean dias_reales 0-30 736 0.5774 31-90 853 0.5803 91-180 515 0.5748 +180 279 0.5591
Todos alrededor de 0,57. No sirve de nada 😐
Y esa es la parte honesta del capítulo. Hicimos el trabajo bien, arreglamos la fuga, y la columna correcta no tiene señal. Pasa muchísimo y hay que contarlo, porque si solo te enseñan los casos donde todo sale bonito, el día que te salga esto vas a pensar que lo hiciste mal.
El detalle que quedó abierto
Fíjate en algo raro, porque es una pregunta de verdad y no la voy a tapar.
La bandera sin_compra_previa (que la fecha venga vacía) vale
veinte puntos. Pero "es la primera venta de este cliente en el archivo" no vale
nada:
primera = ventas['dias_reales'].isna()
print('primera venta del cliente:', round(ventas.loc[primera, 'compro'].mean(), 4))
print('el resto: ', round(ventas.loc[~primera, 'compro'].mean(), 4))
primera venta del cliente: 0.5851 el resto: 0.5757
0,5851 contra 0,5757. Nada.
O sea que el hueco en fecha_ultima_compra no significa
"cliente nuevo", significa otra cosa que este archivo no me deja
averiguar. A lo mejor es "el sistema viejo no lo tenía", a lo mejor es "no se
pudo verificar".
Eso no se resuelve con código: se resuelve con la pregunta del capítulo 2, la que va en el contrato de columnas. ¿Qué significa un hueco aquí?
Y mientras no haya respuesta, la bandera se usa (funciona) pero se reporta con la advertencia puesta. Un modelo que se apoya en algo que nadie sabe explicar es un modelo frágil 🤔
Ejercicios
Siete. Intenta antes de abrir 💛
1. El mes del año
¿Hay estacionalidad en la tasa de cierre?
ventas['mes'] = ventas['fecha'].dt.month
print(ventas.groupby('mes')['compro'].mean().round(4))
mes 1 0.5782 2 0.6176 3 0.5710 4 0.5697 5 0.5479 6 0.5856 7 0.5671 8 0.6090 9 0.5531 10 0.5635 11 0.5989 12 0.5849 Name: compro, dtype: float64
De 0,548 en mayo a 0,618 en febrero. Siete puntos, algo más que el día de la semana pero todavía poco, y con unas 250 filas por mes el margen de error se come buena parte de esa diferencia.
Con dos años de datos no se puede afirmar estacionalidad. Con cinco, quizá 📅
2. La bandera de satisfacción vacía
Construye la tercera bandera de hueco y mira si aporta.
ventas['sin_satisfaccion'] = ventas['satisfaccion'].isna().astype(int)
print(ventas.groupby('sin_satisfaccion')['compro'].agg(['count', 'mean']).round(4))
count mean sin_satisfaccion 0 2769 0.5742 1 231 0.6190
0,619 contra 0,574: cuatro puntos y medio con 231 filas. Es la más floja de las tres banderas y aun así yo la dejaría, porque cuesta una línea y no molesta.
Las banderas de hueco son de las pocas columnas que se pueden crear "por si acaso" sin culpa 🚩
3. El descuento, cuando existe
Entre las ventas que sí tuvieron descuento, ¿más descuento significa más cierre?
con = ventas[ventas['descuento'].notna()] tramo = pd.qcut(con['descuento'], 4) print(con.groupby(tramo, observed=True)['compro'].agg(['count', 'mean']).round(4))
count mean descuento (-0.001, 0.063] 608 0.5757 (0.063, 0.127] 601 0.5574 (0.127, 0.185] 602 0.6362 (0.185, 0.25] 594 0.6380
Mira bien, porque no es plano ni es una recta: los dos tramos de abajo están en 0,576 y 0,557, y los dos de arriba en 0,636 y 0,638. Hay un escalón justo en el descuento mediano (12,7%) y nada más.
O sea: por debajo del 12,7% da igual cuánto descuentes, y por encima también da igual. Lo único que mueve la aguja es cruzar ese umbral, y son ocho puntos.
Eso es una recomendación comercial completa y sale de tres líneas: subir un descuento del 5% al 12% no sirve de nada, y subirlo del 18% al 25% tampoco. Todo lo que no sea cruzar el escalón es margen regalado 💰
Y fíjate en la técnica: esto es exactamente lo que los tramos sirven para encontrar. Con la columna numérica cruda, un modelo lineal habría buscado una recta y no la hay; con el tramo, el escalón se ve solo.
4. Cuántas veces ha aparecido este cliente
Un contador acumulado por cliente, mirando solo hacia atrás.
ventas['visita_numero'] = ventas.groupby('cliente_id').cumcount() + 1
tramo = pd.cut(ventas['visita_numero'], [0, 1, 3, 6, 100],
labels=['1ª', '2ª a 3ª', '4ª a 6ª', '7ª o más'])
print(ventas.groupby(tramo, observed=True)['compro'].agg(['count', 'mean']).round(4))
count mean visita_numero 1ª 617 0.5851 2ª a 3ª 1126 0.5906 4ª a 6ª 986 0.5659 7ª o más 271 0.5498
Casi plano, con una bajadita: 0,585 en la primera visita y 0,550 a partir de la séptima. Tres puntos y medio en el extremo, con solo 271 filas ahí. Es poco y es la dirección contraria a la que esperaba.
El cumcount() sobre datos ordenados por fecha es la forma
correcta de contar historia sin mirar el futuro, y en este archivo no aporta
nada.
Guárdate la técnica igual, porque en datos de verdad "cuántas veces ha comprado antes" suele ser de las mejores columnas que existen 🔢
5. El error de construir sobre nulos
Intenta convertir a entero la columna de días, que tiene huecos.
ventas['dias_reales'].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')
Cannot convert non-finite values (NA or inf) to integer. Los nulos no caben en un entero de numpy.
Se arregla con .astype('Int64') (con I mayúscula, que es el
entero de pandas y sí admite nulos) o rellenando primero. Y es de los errores
que más aparecen justo en este paso, porque las columnas construidas casi
siempre heredan los huecos de sus padres 🕳️
6. Todas las columnas nuevas, medidas de una vez
Una tabla con cuánto separa cada columna nueva, para decidir cuáles se quedan.
nuevas = ['sin_compra_previa', 'sin_descuento', 'sin_satisfaccion']
for col in nuevas:
g = ventas.groupby(col)['compro'].mean()
print(f'{col:20} {g[0]:.4f} vs {g[1]:.4f} diferencia: {abs(g[1] - g[0]):.4f}')
sin_compra_previa 0.6140 vs 0.4136 diferencia: 0.2004 sin_descuento 0.6017 vs 0.4807 diferencia: 0.1210 sin_satisfaccion 0.5742 vs 0.6190 diferencia: 0.0448
Veinte puntos, doce puntos y cuatro puntos y medio. Las tres se quedan, y ahora sabes en qué orden confiar en ellas.
Este bucle de cuatro líneas es lo que yo corro cada vez que creo columnas nuevas. Una columna que no separa nada no se defiende con "es que tiene sentido": se mide 📏
7. La función de preparación completa
Junta la limpieza del capítulo 4 y las columnas de este, en algo reutilizable.
def prepara(v):
"""Columnas nuevas, sin ninguna que mire al futuro."""
v = v.sort_values(['cliente_id', 'fecha']).copy()
# Banderas de hueco: primero marcar, después rellenar.
v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
v['sin_descuento'] = v['descuento'].isna().astype(int)
v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
# Ratio.
v['precio_unitario'] = v['monto'] / v['unidades']
# Historia mirando solo hacia atrás.
anterior = v.groupby('cliente_id')['fecha'].shift(1)
v['dias_reales'] = (v['fecha'] - anterior).dt.days
v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
return v
datos = prepara(carga_limpia(URL))
print(datos.shape)
print([c for c in datos.columns if c not in carga_limpia(URL).columns])
(3000, 20) ['sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'precio_unitario', 'dias_reales', 'visita_numero']
Seis columnas nuevas, y fíjate en lo que no está:
dias_desde_ultima, la de los negativos, y
monto_final_facturado, que venía en el archivo. Las dos miran al
futuro y no entran ni de broma 🚫
Esta función es el punto de partida de todos los capítulos que vienen.
Comprueba que lo tienes
Creas una columna nueva y el AUC sube de 0,69 a 0,95. ¿Qué es lo primero que piensas?
- Que probablemente esa columna no existe cuando hay que predecir
- Que encontré la variable clave
- Que hay que crear más columnas parecidas
- Que el modelo por fin está bien configurado
Lo que te llevas
- 🚩 El hueco se marca antes de rellenarlo. Aquí, veinte puntos, doce puntos y cuatro puntos y medio en tres líneas.
- ➗ Los ratios funcionan:
monto / unidadessepara veinticinco puntos y no existía. - 📊 Los tramos sirven cuando la relación no es ordenada. Si es monótona, la columna numérica ya vale.
- 😐 El día de la semana no aporta nada, y eso también se reporta.
- 🚨 Cuidado con las columnas de tiempo: el 30% de
fecha_ultima_compraes posterior a la venta. Se arregla congroupby().shift(1)sobre datos ordenados. - 🤷♀️ Hacerlo bien a veces mata la señal. La versión correcta de los días no aporta nada, y contarlo es parte del trabajo.
- 📏 Cada columna nueva se mide contra el objetivo antes de quedarse. "Tiene sentido" no es un argumento.
En el capítulo 11 metemos todo esto dentro de un pipeline, que es lo que impide que la preparación se te escape a los datos de examen.
Que tengas lindo día! 🌸