Capítulo 24 de 25 11 secciones 17 min

El proyecto entero, del CSV crudo a la lista de clientes

Todo el libro en un solo archivo, y la pregunta con la que se termina de verdad: a quién llamo el lunes.

Un proyecto de machine learning termina en una decisión, no en una métrica. Aquí el modelo tiene 0,7227 de AUC y el producto es una lista de 200 clientes que captura 147 ventas y S/76.302 de margen. Y la lista, ordenada solo por valor, deja fuera a las 237 bodegas del periodo: repartirla por segmento cuesta S/32.870 y esa decisión no es del modelo.

Catorce capítulos y llegamos a lo único que le importa a quien pagó por esto: a quién llamo el lunes 📞

Este capítulo es el proyecto entero seguido, sin desviarse. Cada decisión lleva al lado el capítulo donde la discutimos, para que puedas volver.

Y termina donde tiene que terminar un proyecto de datos: en una decisión de negocio con su precio medido al lado.

El encargo

El equipo comercial puede visitar 200 clientes al trimestre. Hoy los elige por costumbre. La pregunta es si podemos elegirlos mejor.

Fíjate que el encargo no dice "hazme un modelo". Dice 200. Ese número es el que manda, y es el que va a decidir todo lo que viene 🎯

Antes de tocar nada, tres preguntas al que encarga, que son las del capítulo 2 aplicadas aquí:

  • 🎯 ¿Qué se va a hacer distinto? Se cambia el criterio con el que se arma la ruta de visitas. Si la respuesta hubiera sido "nada, es para tener el dato", no hay proyecto.
  • 📅 ¿Cuándo se decide? Al empezar el trimestre, así que solo vale lo que se sabe antes de la visita. Eso deja fuera media tabla.
  • 💰 ¿Contra qué se compara el éxito? Contra elegir por costumbre. Como no tenemos registro de la costumbre, usamos el azar como listón, que es más exigente de lo que parece.

Y una cuarta que casi nadie hace y ahorra disgustos: ¿qué pasa con los que no entran en la lista? Porque la respuesta por defecto es "no se les visita", y eso tiene consecuencias que vamos a ver en el paso 6 y que no salen en ninguna métrica 👀

Paso 1. Cargar y limpiar

Del capítulo 4, tal cual, sin cambiar nada.

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import GroupShuffleSplit, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
MARGEN = 0.25          # el negocio se queda con 25 centavos de cada sol vendido
CUPO = 200             # visitas que caben en el trimestre

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

crudo = pd.read_csv(URL)
datos = carga_limpia(URL)
print('filas en el CSV:', len(crudo))
print('filas después de quitar duplicadas:', len(datos))
print('montos que se habrían perdido con to_numeric a secas: S/',
      round(pd.to_numeric(crudo['monto'], errors='coerce').isna().sum() and
            datos.loc[pd.to_numeric(crudo['monto'], errors='coerce').reindex(datos.index).isna(),
                      'monto'].sum(), 2))
filas en el CSV: 3037
filas después de quitar duplicadas: 3000
montos que se habrían perdido con to_numeric a secas: S/ 471534.21

Treinta y siete filas duplicadas fuera y casi medio millón de soles rescatados de la coma decimal. Eso ya justifica el capítulo 4 entero 💰

Paso 2. Las variables, y sobre todo las que no

Del capítulo 9 vienen las banderas de nulo, que era el hallazgo bueno. Del capítulo 12 viene la disciplina de no meter nada que no exista el día que predices.

def prepara(v):
    v = v.sort_values(['cliente_id', 'fecha']).copy()
    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)
    v['precio_unitario'] = v['monto'] / v['unidades']
    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']
COLS = NUMERICAS + CATEGORICAS

datos = prepara(datos)
print('columnas que entran:', len(COLS))
print()
print('la bandera que valía la pena:')
print(datos.groupby('sin_compra_previa')['compro'].agg(['size', 'mean']).round(4).to_string())
columnas que entran: 13

la bandera que valía la pena:
                   size    mean
sin_compra_previa              
0                  2456  0.6140
1                   544  0.4136

Veinte puntos de diferencia entre las filas donde falta la fecha de la última compra y las demás. El nulo tenía información y por eso se marca antes de rellenarlo.

Y lo que dejamos fuera a propósito: monto_final_facturado, que en el capítulo 12 daba 0,9994 de AUC y en producción predecía cero de 750 porque no existe todavía. La lista de columnas prohibidas es tan importante como la de columnas usadas 🚫

Paso 3. Qué pregunta estoy respondiendo

Aquí es donde la mayoría de los proyectos se tuercen, y es una sola decisión: cómo partir los datos.

En el capítulo 16 usamos GroupShuffleSplit para que un cliente no estuviera en los dos lados. En el capítulo 23 partimos por fecha. Las dos están bien, porque responden preguntas distintas:

gs = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42)
i, j = next(gs.split(datos[COLS], datos['compro'], groups=datos['cliente_id']))

def arma_modelo():
    return Pipeline([
        ('pre', ColumnTransformer([
            ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                              ('e', StandardScaler())]), NUMERICAS),
            ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                              ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
        ])),
        ('mod', LogisticRegression(max_iter=1000, random_state=42)),
    ])

por_cliente = arma_modelo().fit(datos[COLS].iloc[i], datos['compro'].iloc[i])
print('cliente que nunca vi :', round(roc_auc_score(
    datos['compro'].iloc[j], por_cliente.predict_proba(datos[COLS].iloc[j])[:, 1]), 4))

CORTE = pd.Timestamp('2026-01-01')
pasado = datos[datos['fecha'] < CORTE]
futuro = datos[datos['fecha'] >= CORTE].copy()
modelo = arma_modelo().fit(pasado[COLS], pasado['compro'])
futuro['prob'] = modelo.predict_proba(futuro[COLS])[:, 1]
print('próximo trimestre    :', round(roc_auc_score(futuro['compro'], futuro['prob']), 4))
print('clientes en los dos lados de la partición por fecha:',
      len(set(pasado['cliente_id']) & set(futuro['cliente_id'])))
cliente que nunca vi : 0.6843
próximo trimestre    : 0.7227
clientes en los dos lados de la partición por fecha: 453

0,6843 contra 0,7227. Casi cuatro puntos de diferencia por elegir una partición en vez de la otra.

Y no es que una mienta. La de grupos dice qué tan bien le va con un cliente que nunca vio. La de fecha dice qué tan bien le va el próximo trimestre con la cartera que ya tiene, donde 453 clientes aparecen a los dos lados porque son los mismos de siempre.

El encargo era priorizar la cartera actual. Entonces la partición correcta es la de fecha, y el 0,7227 es el número honesto para este encargo. Si el encargo fuera captar clientes nuevos, el número honesto sería 0,6843 y habría que decirlo así 🧭

Paso 4. El modelo, y contra qué se compara

Del capítulo 13: probamos ocho y ganó la regresión logística. Del capítulo 14: un modelo se compara contra la tontería más simple que exista, no contra el cero.

base = futuro['compro'].mean()
print('si llamo a todos, compra el:', round(base, 4))
print('AUC del modelo            :', round(roc_auc_score(futuro['compro'], futuro['prob']), 4))
print()
orden = futuro.sort_values('prob', ascending=False)
print('de los 200 mejores según el modelo, compran:',
      int(orden.head(CUPO)['compro'].sum()))
print('de 200 al azar, comprarían                 :', round(CUPO * base, 1))
si llamo a todos, compra el: 0.5706
AUC del modelo            : 0.7227

de los 200 mejores según el modelo, compran: 163
de 200 al azar, comprarían                 : 114.1

163 contra 114. Cuarenta y nueve ventas más con el mismo esfuerzo, y eso es lo que hay que decir en la reunión, no el 0,7227 😊

Paso 5. Ordenar por lo que de verdad importa

Aquí hay un error que yo cometí durante años: ordenar por probabilidad.

Una bodega con 90% de probabilidad y S/200 de pedido vale menos que un mayorista con 60% y S/3.000. Lo que se ordena es el valor esperado, que es la probabilidad multiplicada por lo que hay en juego:

futuro['valor_esperado'] = futuro['prob'] * futuro['monto'] * MARGEN

por_prob = futuro.nlargest(CUPO, 'prob')
por_valor = futuro.nlargest(CUPO, 'valor_esperado')

for nombre, lista in [('ordenando por probabilidad', por_prob),
                      ('ordenando por valor esperado', por_valor)]:
    ventas = int(lista['compro'].sum())
    margen = lista.loc[lista['compro'] == 1, 'monto'].sum() * MARGEN
    print(f'{nombre:30} {ventas} ventas   S/{margen:,.2f} de margen')
ordenando por probabilidad     163 ventas   S/56,859.33 de margen
ordenando por valor esperado   147 ventas   S/76,302.41 de margen

Ordenar por valor esperado consigue menos ventas (147 contra 163) y bastante más plata (S/76.302 contra S/56.859).

Dieciséis ventas menos y S/19.443 más. Si tu jefe mide ventas cerradas, entrega la primera lista; si mide margen, la segunda. Pregunta antes de entregar, porque las dos son defendibles y solo una es la que te pidieron 🤔

Paso 6. El problema de la lista

Vamos a mirar quién está en esa lista tan rentable.

print(por_valor.groupby('segmento').agg(
    en_la_lista=('prob', 'size'), compraron=('compro', 'sum')).to_string())
print()
print('bodegas que hubo en el trimestre:', int((futuro['segmento'] == 'Bodega').sum()))
print('de esas, compraron              :', int(futuro.loc[futuro['segmento'] == 'Bodega', 'compro'].sum()))
           en_la_lista  compraron
segmento                         
Horeca               8          4
Mayorista          192        143

bodegas que hubo en el trimestre: 237
de esas, compraron              : 86

192 mayoristas, 8 de horeca y cero bodegas. Ninguna. En todo el trimestre hubo 237 bodegas y 86 de ellas compraron, y el modelo no manda a visitar ni una 😬

Esto no es un fallo técnico. El modelo hace exactamente lo que le pedimos: maximizar plata. Las bodegas compran menos y compran más barato, así que nunca ganan una comparación por valor esperado.

Pero si esa lista se ejecuta cuatro trimestres seguidos, el negocio se queda sin canal de bodegas, y eso no aparece en ninguna métrica del capítulo 14 📉

Paso 7. Cuánto cuesta no abandonar a nadie

La solución del capítulo 22 aplicada aquí es un cupo por segmento: 50 y 50 y 50 y 50 en vez de 200 al mejor postor.

con_cuota = pd.concat([g.nlargest(50, 'valor_esperado')
                       for _, g in futuro.groupby('segmento')])

for nombre, lista in [('sin cuota', por_valor), ('con cuota', con_cuota)]:
    ventas = int(lista['compro'].sum())
    margen = lista.loc[lista['compro'] == 1, 'monto'].sum() * MARGEN
    print(f'{nombre}: {ventas} ventas   S/{margen:,.2f} de margen')
print()
print(con_cuota.groupby('segmento').agg(
    en_la_lista=('prob', 'size'), compraron=('compro', 'sum')).to_string())
sin cuota: 147 ventas   S/76,302.41 de margen
con cuota: 132 ventas   S/43,432.55 de margen

            en_la_lista  compraron
segmento                          
Bodega               50         24
Horeca               50         38
Mayorista            50         41
Minimarket           50         29

La cuota cuesta S/32.870 de margen y 15 ventas en el trimestre. Y a cambio pone 50 bodegas en la lista, de las que 24 compran.

Ese es todo el trabajo, honestamente. No decidir por el negocio: ponerle el precio exacto a las dos opciones y dejar que decida quien responde por ellas 🤝

Y si te preguntan qué harías tú, yo diría la cuota, porque el modelo se entrena con lo que pasó y una lista que nunca visita bodegas deja de generar datos de bodegas. Al año siguiente el modelo va a saber todavía menos de ellas, y esa bola de nieve no se ve venir en ningún tablero.

Paso 8. Lo que se entrega

No se entrega un cuaderno. Se entrega esto:

entrega = con_cuota.sort_values('valor_esperado', ascending=False)[
    ['cliente_id', 'segmento', 'ciudad', 'canal', 'monto', 'prob', 'valor_esperado']].copy()
entrega['prob'] = entrega['prob'].round(4)
entrega['valor_esperado'] = entrega['valor_esperado'].round(2)

print(entrega.head(10).to_string(index=False))
print()
print('clientes en la lista :', len(entrega))
print('valor esperado total : S/', round(entrega['valor_esperado'].sum(), 2))
cliente_id  segmento   ciudad       canal   monto   prob  valor_esperado
     C0284 Mayorista trujillo         Web 3481.29 0.9275          807.20
     C0056 Mayorista trujillo    WhatsApp 3440.83 0.9193          790.82
     C0220 Mayorista arequipa    WhatsApp 3551.77 0.8102          719.43
     C0324 Mayorista arequipa    WhatsApp 4175.88 0.6553          684.12
     C0426 Mayorista trujillo      Tienda 3061.39 0.8859          678.05
     C0150 Mayorista     lima Marketplace 3149.87 0.8209          646.46
     C0224 Mayorista    piura      Tienda 3162.85 0.8023          634.39
     C0363 Mayorista chiclayo         Web 2725.01 0.9016          614.22
     C0216 Mayorista     lima Marketplace 3207.42 0.7646          613.06
     C0361 Mayorista     lima      Tienda 2955.42 0.8221          607.40

clientes en la lista : 200
valor esperado total : S/ 43784.95

Un archivo que el equipo comercial abre y usa, con el nombre del cliente, por qué está ahí y cuánto vale la visita.

Y con eso van tres cosas más, que son las que separan un proyecto de un ejercicio:

  • 📄 Una página: qué se predice, con qué datos, qué mide (0,7227), contra qué se compara (114 ventas al azar) y qué no sabe hacer.
  • 🚦 La función revisa del capítulo 23 corriendo antes de cada entrega, para que nadie prediga sobre datos rotos.
  • 📅 Una fecha para volver a mirar, con el rango de bailoteo ya medido para no asustarse en vano.

Ejercicios

1. El cupo manda más que el modelo

Repite la comparación con cupos distintos y mira dónde deja de valer la pena.

for cupo in [50, 100, 200, 400, 800]:
    top = futuro.nlargest(cupo, 'valor_esperado')
    margen = top.loc[top['compro'] == 1, 'monto'].sum() * MARGEN
    azar = cupo / len(futuro) * futuro.loc[futuro['compro'] == 1, 'monto'].sum() * MARGEN
    print(f'cupo={cupo:4d}  margen=S/{margen:10,.2f}  al azar=S/{azar:10,.2f}  '
          f'ganancia=S/{margen - azar:9,.2f}')
cupo=  50  margen=S/ 27,912.45  al azar=S/  6,693.23  ganancia=S/21,219.23
cupo= 100  margen=S/ 47,099.72  al azar=S/ 13,386.46  ganancia=S/33,713.26
cupo= 200  margen=S/ 76,302.41  al azar=S/ 26,772.92  ganancia=S/49,529.49
cupo= 400  margen=S/108,388.92  al azar=S/ 53,545.84  ganancia=S/54,843.09
cupo= 800  margen=S/130,691.38  al azar=S/107,091.67  ganancia=S/23,599.71

La ganancia sube hasta el cupo 400 y después se desinfla, porque cuando visitas a todo el mundo el orden deja de importar.

Esta tabla vale más que cualquier métrica para decidir si contratar más vendedores. El modelo solo sirve cuando no alcanza para todos 📊

2. El margen que no es igual para todos

Hasta aquí usamos 25% parejo. Pon márgenes distintos por categoría y mira si la lista cambia.

MARGENES = {'Abarrotes': 0.18, 'Bebidas': 0.22, 'Cuidado personal': 0.35,
            'Limpieza': 0.28, 'Snacks': 0.30}
futuro['ve2'] = futuro['prob'] * futuro['monto'] * futuro['categoria'].map(MARGENES)
otra = futuro.nlargest(CUPO, 've2')

print('coinciden con la lista anterior:',
      len(set(otra.index) & set(por_valor.index)), 'de', CUPO)
print()
print(otra.groupby('categoria').size().to_string())
coinciden con la lista anterior: 177 de 200

categoria
Abarrotes           32
Bebidas             33
Cuidado personal    48
Limpieza            39
Snacks              48

Coinciden 177 de 200, así que cambiar el margen mueve 23 clientes. Menos de lo que yo esperaba, y es porque el monto pesa mucho más que el margen en la multiplicación.

Y esos márgenes me los inventé yo para el ejercicio. En un proyecto de verdad salen de contabilidad, y conseguirlos suele costar más reuniones que entrenar el modelo entero 📑

3. Cuánto de esto es suerte

Repite la lista con varios cortes de fecha y mira si la ganancia aguanta.

for corte in ['2025-10-01', '2026-01-01', '2026-03-01']:
    c = pd.Timestamp(corte)
    a, b = datos[datos['fecha'] < c], datos[datos['fecha'] >= c].copy()
    m = arma_modelo().fit(a[COLS], a['compro'])
    b['prob'] = m.predict_proba(b[COLS])[:, 1]
    b['ve'] = b['prob'] * b['monto'] * MARGEN
    top = b.nlargest(CUPO, 've')
    margen = top.loc[top['compro'] == 1, 'monto'].sum() * MARGEN
    azar = CUPO / len(b) * b.loc[b['compro'] == 1, 'monto'].sum() * MARGEN
    print(f'{corte}  futuro={len(b):4d}  ganancia=S/{margen - azar:9,.2f}')
2025-10-01  futuro=1509  ganancia=S/61,836.00
2026-01-01  futuro= 992  ganancia=S/49,529.49
2026-03-01  futuro= 667  ganancia=S/37,016.97

La ganancia sale positiva en los tres cortes y de tamaño parecido. No era la suerte de una fecha 👍

4. La lista sin el segmento

Si el segmento es la columna que ordena casi todo, mira qué lista sale sin él.

SIN = [c for c in COLS if c != 'segmento']
sin_seg = Pipeline([
    ('pre', ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), NUMERICAS),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]),
         ['ciudad', 'canal', 'categoria']),
    ])),
    ('mod', LogisticRegression(max_iter=1000, random_state=42)),
]).fit(pasado[SIN], pasado['compro'])

futuro['ve3'] = (sin_seg.predict_proba(futuro[SIN])[:, 1] * futuro['monto'] * MARGEN)
otra = futuro.nlargest(CUPO, 've3')
print(otra.groupby('segmento').size().to_string())
print()
print('ventas:', int(otra['compro'].sum()),
      ' margen: S/', round(otra.loc[otra['compro'] == 1, 'monto'].sum() * MARGEN, 2))
segmento
Horeca         9
Mayorista    191

ventas: 145  margen: S/ 75651.94

Sigue sin haber bodegas. Quitar la columna no arregla nada, porque el monto y el resto de columnas ya cuentan quién es mayorista.

Esto es importante y se malentiende mucho: borrar la variable no borra el sesgo. Si quieres que la lista incluya bodegas, la cuota es explícita y funciona; quitar la columna es un gesto que se siente bien y no hace nada 🎭

5. Poner la incertidumbre en la entrega

Marca en la lista los clientes donde el modelo no está seguro, con la predicción conforme del capítulo 22.

X_ent, X_cal, y_ent, y_cal = train_test_split(pasado[COLS], pasado['compro'],
                                              test_size=0.3, random_state=42,
                                              stratify=pasado['compro'])
m_conf = arma_modelo().fit(X_ent, y_ent)
p_cal = m_conf.predict_proba(X_cal)
fallo = 1 - p_cal[np.arange(len(y_cal)), y_cal.values]
k = int(np.ceil((len(fallo) + 1) * 0.80))
liston = 1 - np.sort(fallo)[k - 1]

conj = m_conf.predict_proba(futuro[COLS]) >= liston
futuro['seguro'] = conj.sum(axis=1) == 1

lista = futuro.nlargest(CUPO, 'valor_esperado')
print('en la lista, el modelo está seguro de:', int(lista['seguro'].sum()), 'de', CUPO)
print('aciertan cuando está seguro :',
      round(lista.loc[lista['seguro'], 'compro'].mean(), 4))
print('aciertan cuando no lo está  :',
      round(lista.loc[~lista['seguro'], 'compro'].mean(), 4))
en la lista, el modelo está seguro de: 163 de 200
aciertan cuando está seguro : 0.773
aciertan cuando no lo está  : 0.5676

De los 200 de la lista, el modelo está seguro de 163. Y en esos acierta el 77,30%, contra el 56,76% de los 37 donde duda.

Una columna más en el archivo y el vendedor sabe a cuáles llamar primero.

Es de las cosas que más agradecen y menos cuesta poner. Una probabilidad sola invita a creerle; una probabilidad con un "de este no estoy segura" al lado invita a pensar 🧠

6. El error de entregar la lista con el índice de pandas

Intenta guardar la entrega y mira qué sale.

import os
import tempfile

ruta = os.path.join(tempfile.gettempdir(), 'entrega.csv')
entrega.to_csv(ruta)
vuelta = pd.read_csv(ruta)
print(vuelta.columns.tolist())
['Unnamed: 0', 'cliente_id', 'segmento', 'ciudad', 'canal', 'monto', 'prob', 'valor_esperado']

Ahí está el regalito: Unnamed: 0. El to_csv guardó el índice de pandas como una columna sin nombre, y el archivo que abre el equipo comercial empieza con números que nadie sabe qué son.

Y ahora el error de verdad, que es lo que pasa cuando alguien asume que la primera columna es el cliente:

vuelta.iloc[:, 0].str.startswith('C').all()
AttributeError: Can only use .str accessor with string values, not integer

Se arregla con entrega.to_csv(ruta, index=False), y es el último paso del proyecto, el que nadie revisa, y el que hace que tu trabajo llegue bien o llegue raro 📤

7. La página que va con el modelo

Genera el resumen que acompaña la entrega, con números y no con adjetivos.

resumen = {
    'entrenado con': f"{pasado['fecha'].min().date()} a {pasado['fecha'].max().date()}",
    'filas de entrenamiento': len(pasado),
    'columnas': len(COLS),
    'AUC próximo trimestre': round(roc_auc_score(futuro['compro'], futuro['prob']), 4),
    'ventas de la lista': int(por_valor['compro'].sum()),
    'ventas al azar': round(CUPO * futuro['compro'].mean(), 1),
    'margen de la lista': round(por_valor.loc[por_valor['compro'] == 1, 'monto'].sum() * MARGEN, 2),
    'precio de la cuota por segmento': round(
        por_valor.loc[por_valor['compro'] == 1, 'monto'].sum() * MARGEN
        - con_cuota.loc[con_cuota['compro'] == 1, 'monto'].sum() * MARGEN, 2),
    'no sabe hacer': 'clientes nuevos (ahí baja a 0.6843)',
}
for k, v in resumen.items():
    print(f'{k:32} {v}')
entrenado con                    2025-01-01 a 2025-12-31
filas de entrenamiento           2008
columnas                         13
AUC próximo trimestre            0.7227
ventas de la lista               147
ventas al azar                   114.1
margen de la lista               76302.41
precio de la cuota por segmento  32869.86
no sabe hacer                    clientes nuevos (ahí baja a 0.6843)

Nueve líneas. Eso es el proyecto entero contado para alguien que no va a leer tu código nunca.

Si solo te llevas una cosa del libro, que sea esta: el trabajo termina cuando alguien puede tomar una decisión con lo que le diste, no cuando el modelo entrena 💛

Comprueba que lo tienes

El proyecto termina con una lista de 200 clientes. ¿Qué lleva cada fila además del nombre?

  • La probabilidad y el valor esperado, para poder ordenar por lo que importa
  • Solo la probabilidad, que es lo que el modelo predice
  • El segmento y la ciudad
  • Nada más: la lista ya está ordenada por el modelo

Lo que te llevas

  • 🧭 La partición se elige por la pregunta: 0,7227 para la cartera actual y 0,6843 para clientes nuevos. Las dos son ciertas.
  • 📢 A la reunión no se lleva el AUC, se lleva "163 ventas contra 114".
  • 💰 Se ordena por valor esperado, no por probabilidad: 16 ventas menos y S/19.443 más de margen.
  • 😬 La lista óptima dejó fuera a las 237 bodegas del trimestre, y 86 de ellas compraron.
  • 🤝 La cuota por segmento cuesta S/32.870 y 15 ventas. Tu trabajo es poner ese precio sobre la mesa, no decidir.
  • 🎭 Quitar la columna del segmento no quita el sesgo: la lista sigue sin bodegas.
  • 📤 Se entrega un archivo con nombres, probabilidad y valor, más una página de resumen y una fecha para volver a mirar.

Y hasta aquí el libro. Quince capítulos desde "qué es un modelo" hasta una lista de 200 clientes con su precio al lado 🎓

Si llegaste hasta acá haciendo los ejercicios, ya sabes más de machine learning aplicado que mucha gente que lo pone en el perfil. En serio.

En el capítulo 25 te dejo dónde seguir: la documentación que sí vale la pena, los libros que yo leí y las cosas que este libro no cubre.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?