Veintitrés 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 27 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 24 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
revisadel capítulo 27 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 24.
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
Y el número de la última diapositiva
La trampa
El entregable final: la lista de 200 clientes captura 147 ventas y S/76.302 de margen. Está medido sobre datos reales y es el número que va en la última diapositiva.
lista = ordenados.head(200) margen = lista['margen'].sum() print(margen) # 76302 # "el modelo genera S/76.302"
Qué está mal
El modelo no genera esos S/76.302. Los encuentra, que no es lo mismo 🎯 Una parte de esos 200 clientes habría comprado igual sin que nadie los llamara, y ese trozo no es mérito del modelo: ya estaba ahí.
Lo que el modelo aporta de verdad es la diferencia contra lo que habría pasado sin él, y eso no se lee en la lista. Se mide comparando con lo que consigue el criterio que ya usaban, o dejando un grupo sin llamar y viendo qué hace.
Prometer los S/76.302 enteros es la forma más rápida de que el proyecto siguiente no exista, porque a los seis meses alguien va a comparar la facturación real contra esa promesa y no van a cuadrar. Se entrega el número con la frase al lado: esto es lo que captura la lista, y esto otro es lo que habría pasado sin ella.
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 si de todo el capítulo te llevas una sola frase, que sea esta:
El modelo no genera el dinero. Lo encuentra. Y confundir esas dos cosas es lo que hace que no haya proyecto siguiente.
Y si quieres seguir por el camino de la carrera y no solo del modelo, eso está en cómo ser analista de datos 💼
Y hasta aquí el libro. Veinticuatro 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 29 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! 🌸
Y cuando el modelo tiene que salir del cuaderno
Tienes un modelo entrenado, con su umbral elegido por costo y su lista de clientes priorizada. Eso ya es más de lo que mucha gente que pone "machine learning" en el perfil sabe hacer 🎓
Lo que este libro no cubre es lo que pasa después: que ese modelo corra solo cada semana, que alguien se entere cuando empiece a fallar, y que puedas demostrar por qué decidió lo que decidió. Eso ya no es modelar, es ingeniería, y es justo lo que trabajamos en el curso de ingeniería de IA en producción.
Son cuatro semanas y el entregable es un sistema tuyo, sobre un proceso real de tu trabajo, con las mediciones que demuestran que acierta. No un proyecto de ejemplo con datos de juguete.
Si por ahora te quedas en el cuaderno, tranqui, no pasa nada. El capítulo de referencias te deja por dónde seguir sola sin gastar un sol, y si prefieres que te lo explique en video en vez de leerlo, el bloque de machine learning de los cursos de datos grabados es justo esto mismo dado en clase 🌸
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.
Los términos que salen en este capítulo
Están todos en el glosario de IA, con su definición corta.