Capítulo 9 de 25 10 secciones 12 min

Construir columnas, que es donde se gana

Cinco columnas nuevas: tres que valen puntos enteros, una que no sirve y una que es una trampa con el 30% de los datos.

Cambiar de modelo da décimas; construir una buena columna da puntos enteros. Lo más rentable son las banderas de hueco (aquí valen veinte, doce y cuatro puntos y medio) y los ratios (monto entre unidades separa veinticinco puntos). Y ojo con las columnas de tiempo: el 30% de las fechas de última compra de este archivo son posteriores a la venta, así que la historia se construye con groupby().shift(1) sobre datos ordenados.

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 / unidades separa 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_compra es posterior a la venta. Se arregla con groupby().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! 🌸

¿Tienes alguna duda o consulta?