Este capítulo es el que separa a alguien que entrena modelos de alguien que entrega proyectos 🎓
En el capítulo 2 escribimos el contrato: qué es una fila, qué significa la etiqueta, en qué momento se predice. Ahora falta lo otro, que es igual de aburrido de escribir y salva igual de proyectos:
- 📋 El perfil de calidad. Columna por columna, qué tan confiable es lo que hay dentro.
- ⚖️ Los sesgos declarados. Quién está de más y quién está de menos en estos datos.
- 🔒 La política de lo sensible. Qué columnas no se usan, y por qué.
- 🎯 El umbral de aceptación. A partir de qué número el proyecto sirve. Escrito ANTES de ver el resultado.
El perfil de calidad, en una tabla
import pandas as pd
URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
def carga_limpia(url):
"""La del capítulo 4, resumida."""
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)
perfil = pd.DataFrame({
'tipo': v.dtypes.astype(str),
'nulos': v.isna().sum(),
'pct_nulos': (100 * v.isna().mean()).round(2),
'distintos': v.nunique(),
})
print(perfil.to_string())
tipo nulos pct_nulos distintos id_venta int64 0 0.00 3000 fecha datetime64[us] 0 0.00 537 cliente_id str 0 0.00 617 ciudad str 0 0.00 6 segmento str 0 0.00 4 canal str 0 0.00 4 categoria str 0 0.00 5 unidades int64 0 0.00 30 monto float64 0 0.00 2964 descuento float64 595 19.83 251 fecha_ultima_compra datetime64[us] 544 18.13 399 satisfaccion float64 231 7.70 5 compro int64 0 0.00 2 monto_final_facturado float64 0 0.00 1723
Esa tabla es la primera página del informe, y se lee de una 👀
Tres columnas tienen huecos y ninguna es poca cosa: descuento con el 19,83%, fecha_ultima_compra con el 18,13% y satisfaccion con el 7,70%. Eso son tres decisiones que hay que tomar antes de modelar, no tres detalles.
Y la columna de valores distintos ya delata los tipos, que es el capítulo siguiente: 3.000 valores distintos en 3.000 filas es un identificador, y 4 o 5 valores es una categoría por mucho que esté escrita con números.
Los duplicados, contra la clave de negocio
drop_duplicates() quita filas idénticas. Pero el duplicado que
duele es otro: la misma venta cargada dos veces con distinto
identificador.
print('filas: %d' % len(v))
print('id_venta unicos: %d' % v['id_venta'].nunique())
print('duplicados por cliente + fecha + monto: %d'
% v.duplicated(subset=['cliente_id', 'fecha', 'monto']).sum())
filas: 3000 id_venta unicos: 3000 duplicados por cliente + fecha + monto: 0
Limpio ✅ Pero fíjate en que lo comprobé contra la clave de negocio (quién, cuándo, cuánto) y no contra el identificador, que por definición nunca se repite.
Si esto hubiera salido con 40 duplicados, todo lo que viene después estaría contando cuarenta ventas dos veces, y el modelo creería tener más evidencia de la que tiene.
Los rangos que no pueden ser
for col in ['unidades', 'monto', 'descuento', 'satisfaccion']:
s = v[col]
print('%-13s de %9.2f a %9.2f | negativos %3d | ceros %3d'
% (col, s.min(), s.max(), (s < 0).sum(), (s == 0).sum()))
unidades de 1.00 a 30.00 | negativos 0 | ceros 0 monto de -2497.72 a 4236.71 | negativos 21 | ceros 0 descuento de 0.00 a 0.25 | negativos 0 | ceros 5 satisfaccion de 1.00 a 5.00 | negativos 0 | ceros 0
Ahí están las 21 ventas negativas otra vez 🔴
Lo importante de este paso no es encontrarlas: es escribir qué rango es posible para cada columna antes de mirar. Un descuento va de 0 a 1, una satisfacción de 1 a 5, unas unidades no pueden ser cero. Cuando eso está escrito, el día que llegue un archivo nuevo la comprobación es automática.
Los sesgos, declarados
La pregunta es: ¿estos datos representan al negocio, o representan a una parte?
for col in ['segmento', 'canal', 'ciudad']:
p = v[col].value_counts(normalize=True)
print('%-9s el mas chico %.4f | el mas grande %.4f | desbalance %.2fx'
% (col, p.min(), p.max(), p.max() / p.min()))
segmento el mas chico 0.2357 | el mas grande 0.2670 | desbalance 1.13x canal el mas chico 0.2397 | el mas grande 0.2640 | desbalance 1.10x ciudad el mas chico 0.1570 | el mas grande 0.1820 | desbalance 1.16x
Un desbalance de 1,13 quiere decir que el grupo más representado tiene un 13% más de filas que el menos representado. Eso es equilibradísimo, y me hace levantar una ceja 🤨
En datos reales de una distribuidora es rarísimo que los cuatro segmentos tengan casi las mismas ventas. Lo anoto en el informe como lo que es: una sospecha de que este archivo fue muestreado, no volcado entero. Y si fue muestreado, las proporciones que salgan de aquí no son las del negocio.
Eso no invalida el modelo (el modelo aprende relaciones, no proporciones), pero sí invalida cualquier frase del tipo "el 25% de nuestras ventas son de mayoristas".
Lo sensible
Aquí no hay nombres ni documentos de identidad, pero sí hay una columna que
merece política: ciudad.
print(v.groupby('ciudad')['compro'].agg(['mean', 'count']).round(4).to_string())
mean count ciudad arequipa 0.5751 546 chiclayo 0.5725 510 cusco 0.5746 496 lima 0.5839 471 piura 0.5692 506 trujillo 0.5924 471
Las seis ciudades cierran prácticamente igual, así que usar ciudad no va a discriminar a nadie por dónde vive, sencillamente porque no aporta información.
Pero fíjate en el orden de la frase, porque importa: primero se mide, después se decide. Si Cusco cerrara el 20% y Lima el 80%, meter ciudad en el modelo significaría que a un cliente de Cusco se le llama menos por ser de Cusco, y eso ya no es una decisión técnica 🔒
La política que escribo es esta: ciudad entra al modelo porque no discrimina; si en una versión futura empieza a pesar, se saca y se documenta.
Y ahora el que casi nadie escribe
El umbral de aceptación. ¿A partir de qué número este proyecto vale la pena?
La tentación es decir "un AUC de 0,80 estaría bien". Ese número no sale de ningún sitio. Lo que hay que hacer es medir contra qué se compara:
from sklearn.compose import ColumnTransformer
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
cat = ['segmento', 'canal', 'categoria', 'ciudad']
num = ['unidades', 'monto']
X, y = v[cat + num], v['compro']
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=7,
stratify=y)
tonto = DummyClassifier(strategy='stratified', random_state=7).fit(Xtr, ytr)
print('1. tirar una moneda: %.4f'
% roc_auc_score(yte, tonto.predict_proba(Xte)[:, 1]))
solo_seg = Pipeline([
('pre', ColumnTransformer([('c', OneHotEncoder(handle_unknown='ignore'),
['segmento'])])),
('clf', LogisticRegression(max_iter=1000)),
]).fit(Xtr[['segmento']], ytr)
print('2. solo el segmento: %.4f'
% roc_auc_score(yte, solo_seg.predict_proba(Xte[['segmento']])[:, 1]))
completo = Pipeline([
('pre', ColumnTransformer([
('cat', OneHotEncoder(handle_unknown='ignore'), cat),
('num', StandardScaler(), num)])),
('clf', LogisticRegression(max_iter=1000)),
]).fit(Xtr, ytr)
print('3. las seis columnas: %.4f'
% roc_auc_score(yte, completo.predict_proba(Xte)[:, 1]))
1. tirar una moneda: 0.4866 2. solo el segmento: 0.6346 3. las seis columnas: 0.6554
Léelo despacio, porque esto cambia el proyecto entero 😳
Una sola columna, el segmento, consigue 0,6346. Las seis juntas consiguen 0,6554. Las otras cinco columnas, todas juntas, aportan dos centésimas.
Y eso hay que escribirlo en el informe ANTES de seguir, porque significa una cosa muy concreta: si al final el modelo saca 0,66, no hacía falta ningún modelo. Bastaba con una tabla de cuatro filas que diga la tasa de cierre de cada segmento.
El umbral de aceptación que escribo es este:
| Nivel | AUC | Qué significa |
|---|---|---|
| Piso | 0,6346 | Lo que da mirar solo el segmento. Por debajo, el modelo estorba |
| Mínimo para seguir | 0,70 | Suficiente por encima del piso como para justificar mantenerlo |
| Objetivo | 0,75 | Lo que haría falta para cambiar cómo se reparte el equipo comercial |
Los capítulos 5 y 9 construyen columnas nuevas, y ahí se ve si se llega. Lo que no se puede hacer es fijar el umbral después de ver el resultado, que es exactamente lo que pasa cuando nadie lo escribió antes 📝
El error del capítulo
Copio el chequeo de duplicados de otro proyecto, donde la columna de dinero se llamaba distinto:
v.duplicated(subset=['cliente_id', 'fecha', 'monto_total'])
KeyError: Index(['monto_total'], dtype='str')
Te frena, y eso está bien 👍 Copiar un script de gobernanza de un proyecto a otro es lo normal, y este error es lo que te obliga a mirar si las columnas se llaman igual.
Ahora el que no te frena, que es el que quería enseñarte.
Quiero el perfil de calidad de golpe con describe(), que es lo que
hace todo el mundo:
print(v.describe(include='all').loc['mean'].to_string())
id_venta 11499.5 fecha 2025-09-29 21:17:16.800000 cliente_id NaN ciudad NaN segmento NaN canal NaN categoria NaN unidades 11.600667 monto 803.761277 descuento 0.125202 fecha_ultima_compra 2025-07-17 23:45:20.521172 satisfaccion 3.009751 compro 0.577667 monto_final_facturado 481.256003
Mira lo que devuelve 😳
La media de id_venta es 11.499,50, que es el número de factura
promedio y no existe. La media de fecha es el 29 de septiembre de
2025, que es la fecha promedio de las ventas y tampoco significa nada. Y las
columnas de texto salen como NaN, o sea que en una tabla de catorce
filas hay cinco que son huecos y dos que son disparates.
Y ninguna dio error 😑
La lección: el perfil de calidad no es describe().
describe() te da estadísticos; un perfil te dice cuántos huecos hay, cuántos
valores distintos, qué tipo tiene cada columna y si el rango es posible. Son
cosas distintas y la primera no sustituye a la segunda.
Practica 💪
1. La cobertura temporal, que decide el split
Mira cuánto tiempo cubren los datos y si todos los meses pesan igual. De eso depende cómo hay que partir en entrenamiento y test.
por_mes = v.groupby(v['fecha'].dt.to_period('M')).size()
print('de %s a %s' % (v['fecha'].min().date(), v['fecha'].max().date()))
print('meses: %d' % len(por_mes))
print('ventas por mes: de %d a %d' % (por_mes.min(), por_mes.max()))
print('el ultimo mes: %d' % por_mes.iloc[-1])
de 2025-01-01 a 2026-06-24 meses: 18 ventas por mes: de 122 a 192 el ultimo mes: 122
Dieciocho meses y entre 122 y 192 ventas por mes 📅
Lo importante es el último: tiene menos que el resto porque los datos cortan el 24 de junio. Eso no es una caída del negocio, es un mes al que le faltan seis días, y si alguien entrena con todo y valida con "el último mes" va a validar contra un mes cojo.
De aquí sale una decisión del contrato: si el modelo se va a usar hacia adelante en el tiempo, la validación tiene que ser temporal, no aleatoria. Lo vemos en el capítulo de validación.
2. La tasa base por grupo, que es la línea de fairness
Antes de entrenar, deja escrito qué tasa de compra tiene cada segmento. Es contra eso que se va a comparar el modelo después.
base = v.groupby('segmento')['compro'].agg(['mean', 'count'])
base.columns = ['tasa', 'ventas']
base['vs_global'] = (base['tasa'] - v['compro'].mean()).round(4)
print(base.round(4).to_string())
tasa ventas vs_global segmento Bodega 0.3748 707 -0.2028 Horeca 0.6321 742 0.0544 Mayorista 0.7240 750 0.1463 Minimarket 0.5693 801 -0.0084
Esta tabla es la referencia de todo el capítulo 22 🎯
Bodega cierra 20 puntos por debajo del global y Mayorista 15 por encima. Eso NO es un sesgo del modelo: es el negocio.
El sesgo del modelo sería que, además de esa diferencia real, el modelo acertara peor en un grupo que en otro. Y para poder afirmar eso hace falta tener esta tabla escrita de antemano, o si no cualquier diferencia posterior se puede explicar como "es que ese segmento compra menos" 🛡️
3. Qué pasa si el hueco significa cero
La columna descuento tiene 19,83% de nulos. Mide cuánto cambia la conclusión según qué supongas.
print('media ignorando huecos: %.4f' % v['descuento'].mean())
print('media con huecos = 0: %.4f' % v['descuento'].fillna(0).mean())
print()
print('tasa de compra con descuento: %.4f'
% v.loc[v['descuento'].notna(), 'compro'].mean())
print('tasa de compra sin descuento: %.4f'
% v.loc[v['descuento'].isna(), 'compro'].mean())
media ignorando huecos: 0.1252 media con huecos = 0: 0.1004 tasa de compra con descuento: 0.6017 tasa de compra sin descuento: 0.4807
Mira las dos últimas líneas, que son lo interesante 👀
La tasa de compra es distinta según si el descuento está o no está. O sea que el hueco no es aleatorio: significa algo.
Eso tiene un nombre en el skill de gobernanza y es la pregunta que decide qué hacer: si los huecos aparecen al azar (MCAR) se pueden rellenar sin drama; si aparecen según otra columna (MAR) hay que rellenar con cuidado; y si aparecen según el propio valor que falta (MNAR) rellenarlos inventa datos.
Aquí el hueco predice la compra y por bastante: 60,17% de cierre cuando hay descuento contra 48,07% cuando no lo hay. Doce puntos que salen de una columna vacía 😮
Así que lo correcto es lo del capítulo siguiente: convertir el hueco en columna en vez de taparlo. Rellenarlo con la media borraría esos doce puntos.
4. Una función de perfil que puedas reutilizar
Empaqueta el perfil de calidad para poder correrlo sobre cualquier archivo nuevo.
def perfil_de_calidad(df, rangos=None):
rangos = rangos or {}
filas = []
for col in df.columns:
s = df[col]
aviso = ''
if col in rangos:
bajo, alto = rangos[col]
fuera = ((s < bajo) | (s > alto)).sum()
aviso = f'{fuera} fuera de rango' if fuera else ''
if s.nunique() == len(df):
aviso = (aviso + ' identificador').strip()
filas.append((col, str(s.dtype), s.isna().sum(),
round(100 * s.isna().mean(), 2), s.nunique(), aviso))
return pd.DataFrame(filas, columns=['columna', 'tipo', 'nulos', 'pct',
'distintos', 'aviso'])
print(perfil_de_calidad(v, rangos={
'descuento': (0, 1),
'satisfaccion': (1, 5),
'unidades': (1, 1000),
'monto': (0, 1e9),
}).to_string(index=False))
columna tipo nulos pct distintos aviso
id_venta int64 0 0.00 3000 identificador
fecha datetime64[us] 0 0.00 537
cliente_id str 0 0.00 617
ciudad str 0 0.00 6
segmento str 0 0.00 4
canal str 0 0.00 4
categoria str 0 0.00 5
unidades int64 0 0.00 30
monto float64 0 0.00 2964 21 fuera de rango
descuento float64 595 19.83 251
fecha_ultima_compra datetime64[us] 544 18.13 399
satisfaccion float64 231 7.70 5
compro int64 0 0.00 2
monto_final_facturado float64 0 0.00 1723
Ahí lo tienes en una pasada 🙌
Lo que hace útil a esta función no es contar nulos, que lo hace
isna(). Es la columna de avisos: compara contra lo que tú
declaraste que era posible, y eso es lo que convierte un vistazo en un
control.
El día que llegue el archivo del mes que viene, esta función corre sola y te dice si algo cambió sin avisar 📟
5. El umbral, traducido a plata
Un AUC no le dice nada a nadie. Traduce la diferencia entre 0,6346 y 0,75 a algo que se pueda defender.
ticket = v.loc[v['compro'] == 1, 'monto_final_facturado'].median()
llamadas = 200
for auc, nombre in [(0.6346, 'solo el segmento'), (0.70, 'minimo'),
(0.75, 'objetivo')]:
# Aproximacion de campo: la tasa de acierto entre los mejores 200 sube
# aproximadamente con el AUC por encima del azar.
tasa = v['compro'].mean() + (auc - 0.5) * 0.9
print('%-18s AUC %.4f -> %.1f cierres de %d llamadas -> S/ %.0f'
% (nombre, auc, llamadas * tasa, llamadas, llamadas * tasa * ticket))
solo el segmento AUC 0.6346 -> 139.8 cierres de 200 llamadas -> S/ 79749 minimo AUC 0.7000 -> 151.5 cierres de 200 llamadas -> S/ 86466 objetivo AUC 0.7500 -> 160.5 cierres de 200 llamadas -> S/ 91602
Ahora la conversación es otra 💰
Pasar del piso al objetivo son unos cuantos miles de soles al mes sobre las mismas 200 llamadas. Ese número es el que decide si el proyecto sigue, y es el que hay que poner al lado del costo de mantenerlo.
Y ojo con la línea del comentario: ese 0,9 es una aproximación de campo, no una ley. Sirve para dimensionar antes de tener el modelo. Con el modelo hecho, el número bueno sale de contar los cierres de verdad entre los 200 mejores, que es lo que hace el capítulo del proyecto final.
6. La ficha de gobernanza, entera
Junta todo en el bloque que iría al principio del informe.
print('FICHA DE GOBERNANZA')
print('-' * 46)
print('unidad de analisis: una venta')
print('filas: %d | clientes: %d | periodo: %s a %s'
% (len(v), v['cliente_id'].nunique(),
v['fecha'].min().date(), v['fecha'].max().date()))
print('etiqueta: compro (1 = se cerro), tasa base %.4f' % v['compro'].mean())
print('columnas con huecos: %s'
% ', '.join('%s %.1f%%' % (c, 100 * v[c].isna().mean())
for c in v.columns if v[c].isna().any()))
print('sensibles: ciudad (medida, no discrimina)')
print('umbral: piso 0.6346 | minimo 0.70 | objetivo 0.75')
print('riesgo declarado: los grupos estan demasiado equilibrados,')
print(' probablemente es una muestra y no el volcado entero')
FICHA DE GOBERNANZA ---------------------------------------------- unidad de analisis: una venta filas: 3000 | clientes: 617 | periodo: 2025-01-01 a 2026-06-24 etiqueta: compro (1 = se cerro), tasa base 0.5777 columnas con huecos: descuento 19.8%, fecha_ultima_compra 18.1%, satisfaccion 7.7% sensibles: ciudad (medida, no discrimina) umbral: piso 0.6346 | minimo 0.70 | objetivo 0.75 riesgo declarado: los grupos estan demasiado equilibrados, probablemente es una muestra y no el volcado entero
Esa ficha cabe en media carilla y es lo que hace que un cliente confíe 📄
Fíjate en la última línea, que es la que más cuesta escribir: el riesgo declarado. Decir "sospecho que esto es una muestra" antes de empezar te protege, y sobre todo protege a quien va a decidir con el resultado.
Un informe que solo dice lo que salió bien no es un informe, es una presentación de ventas 🙃
Comprueba que lo tienes
Antes de entrenar mides que una sola columna, el segmento, da un AUC de 0,6346. ¿Para qué sirve ese número?
- Es el piso: por debajo de ahí el modelo estorba en vez de ayudar
- Para saber que el segmento es la variable más importante
- Para descartar las otras cinco columnas
- Para nada, porque el modelo final va a usar todas
Lo que te llevas
- 📋 El perfil de calidad va antes que el modelo. Aquí: tres columnas con huecos, la peor con el 19,83%.
- 🔁 Los duplicados se buscan contra la clave de negocio (quién, cuándo, cuánto), no contra el identificador, que nunca se repite.
- ⚖️ Los grupos están equilibrados a 1,13x, que es sospechosamente parejo para datos reales. Se declara como riesgo.
- 🎯 El umbral de aceptación se escribe antes de ver el resultado. Aquí el piso es 0,6346, que es lo que da mirar una sola columna.
- 🕳️ El hueco de descuento no es aleatorio: predice la compra. Eso se convierte en columna, no se tapa.
- 📄 El riesgo declarado es la línea más difícil de escribir y la que hace que te crean.
Qué viene ahora
Con la ficha escrita, toca mirar los datos de verdad 🕵️
El capítulo 4 limpia lo que está sucio: las filas repetidas, las cuatro formas de escribir Lima, el dinero que se evapora al convertirlo y los dos formatos de fecha metidos en la misma columna.
Y de ahí en adelante el libro sigue el orden que a mí me costó años aprender, que es el contrario del que parece:
- 🔍 Primero se entiende lo que hay. Limpiar, auditar tipos, mirar cada columna por separado y luego contra la etiqueta.
- 🧱 Después se construye. Las columnas que no venían en el archivo y que son las que de verdad mueven la aguja.
- 🤖 Y el modelo llega casi al final, cuando ya está decidido todo lo que importa.
Si el modelado te ocupa más tiempo que entender los datos, lo que estás
escribiendo es un script y no un proyecto. Esa proporción es la que separa a una
senior de alguien que sabe llamar a .fit() 📋