Capítulo 3 de 25 10 secciones 17 min

Gobernanza: calidad, sesgos y el umbral de aceptación

Lo que una senior escribe antes de tocar un modelo, y que casi nunca aparece: el perfil de calidad, los sesgos declarados y el número que decide si el proyecto sirve.

Antes de entrenar hay que escribir cuatro cosas: el perfil de calidad de cada columna, qué grupos están sub o sobre representados, qué se hace con los datos sensibles, y el umbral de aceptación. Ese último es el que casi nadie escribe y el que decide todo: aquí una sola columna, el segmento, consigue un AUC de 0,6346, así que un modelo con seis columnas que saque 0,6554 apenas está justificando su existencia.

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:

  1. 📋 El perfil de calidad. Columna por columna, qué tan confiable es lo que hay dentro.
  2. ⚖️ Los sesgos declarados. Quién está de más y quién está de menos en estos datos.
  3. 🔒 La política de lo sensible. Qué columnas no se usan, y por qué.
  4. 🎯 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:

NivelAUCQué significa
Piso0,6346Lo que da mirar solo el segmento. Por debajo, el modelo estorba
Mínimo para seguir0,70Suficiente por encima del piso como para justificar mantenerlo
Objetivo0,75Lo 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() 📋

¿Tienes alguna duda o consulta?