Capítulo 6 de 25 10 secciones 18 min

Cada columna, mirada de una en una

La ficha de cuatro números que decide qué escalar y qué transformar, y los tres métodos que cuentan 203, 32 o 222 atípicos en la misma columna.

El análisis univariado son cuatro números por columna: media contra mediana para ver si hay cola, coeficiente de variación para comparar columnas con unidades distintas, y asimetría y curtosis para saber cuánto se aleja de una campana. En este archivo precio_unitario sale con asimetría 6,734 y curtosis 60,015, y el escalado estándar no arregla eso. Los atípicos de monto son 203, 32 o 222 según el método, así que no existe cuántos tiene: existe cuántos encuentra el que elegiste. Y monto_final_facturado se delata sin cruzarla con nada, porque su primer cuartil vale 0 y el 42,23% de las filas también.

Ya está limpio y ya sabemos qué es cada columna. Toca mirarlas una por una, sin cruzarlas con nada 🔎

Y esto no es un trámite. Lo que salga aquí decide cosas muy concretas más adelante: qué se escala, qué se transforma, qué se parte en tramos, qué imputar con la media y qué con la mediana. Saltarse este capítulo es tomar todas esas decisiones a ojo.

Tranqui, que son cuatro números por columna y una función que los saca todos 💜

La ficha de una columna

El resumen que yo saco de cada columna numérica antes de decidir nada.

import numpy as np
import pandas as pd
from scipy import stats

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

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

ventas = carga_limpia(URL)
ventas['precio_unitario'] = ventas['monto'] / ventas['unidades']

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion',
             'precio_unitario', 'monto_final_facturado']

def ficha(v, columnas):
    filas = []
    for c in columnas:
        s = v[c].dropna()
        filas.append({
            'columna': c,
            'n': len(s),
            'nulos_%': round(100 * v[c].isna().mean(), 2),
            'media': round(s.mean(), 2),
            'mediana': round(s.median(), 2),
            'ds': round(s.std(), 2),
            'cv': round(s.std() / s.mean(), 3) if s.mean() else np.nan,
            'asimetria': round(stats.skew(s), 3),
            'curtosis': round(stats.kurtosis(s), 3),
            'min': round(s.min(), 2),
            'max': round(s.max(), 2),
        })
    return pd.DataFrame(filas)

print(ficha(ventas, NUMERICAS).to_string(index=False))
              columna    n  nulos_%  media  mediana     ds    cv  asimetria  curtosis      min     max
             unidades 3000     0.00  11.60    12.00   5.77 0.498      0.184    -0.337     1.00   30.00
                monto 3000     0.00 803.76   533.63 751.99 0.936      1.340     1.541 -2497.72 4236.71
            descuento 2405    19.83   0.13     0.13   0.07 0.573     -0.016    -1.160     0.00    0.25
         satisfaccion 2769     7.70   3.01     3.00   1.42 0.472     -0.015    -1.309     1.00    5.00
      precio_unitario 3000     0.00 121.28    53.02 255.15 2.104      6.734    60.015  -599.90 3586.26
monto_final_facturado 3000     0.00 481.26   191.89 678.86 1.411      1.764     2.776     0.00 4171.70

Esa tabla ya cuenta media docena de cosas. Vamos por partes 🧵

Los cuatro números que de verdad se miran

Media contra mediana. Si se parecen, la columna es simétrica. Si la media es mucho mayor, hay cola por la derecha: monto tiene media 803,76 y mediana 533,63, o sea que la mitad de las ventas está por debajo de 534 y unas pocas grandes tiran del promedio.

El coeficiente de variación, que es la desviación dividida entre la media.

CV=sx¯

la desviación dividida entre la media, que sirve para comparar cuánto varían dos columnas medidas en unidades distintas

Sirve para lo que la desviación sola no puede: comparar columnas medidas en unidades distintas. unidades tiene CV 0,498 y precio_unitario tiene 2,104, o sea que el precio por unidad varía cuatro veces más en términos relativos, aunque los dos números crudos no se puedan comparar.

Asimetría y curtosis, que en una campana perfecta valen cero las dos.

g1=1n(xix¯)3s3,g2=1n(xix¯)4s43

la primera dice hacia qué lado se estira la cola y la segunda cuánto pesan los extremos, y las dos valen cero en una campana perfecta

Y aquí salta la que se lleva el premio: precio_unitario, con asimetría 6,734 y curtosis 60,015. Eso no es una cola larga, eso es un cohete 🚀

Una columna así rompe cualquier modelo lineal si entra tal cual, y encima el escalado estándar no la arregla: restarle la media y dividir por la desviación no cambia la forma, solo la mueve de sitio.

El error que se comete recorriendo columnas

Lo natural cuando quieres la ficha de todo es meter un bucle sobre ventas.columns. No acaba bien.

stats.shapiro(ventas['segmento'])
ValueError: could not convert string to float: 'Horeca'

Por eso la función de arriba recibe una lista de columnas en vez de recorrerlas todas. La lista sale de la auditoría de tipos del capítulo 5, que es exactamente para lo que servía 🗂️

Y ojo con la otra versión del mismo problema, que es peor porque no da error: si le pasas una columna con nulos, shapiro devuelve nan tan tranquilo. De ahí el dropna() dentro de la ficha.

Normalidad: la prueba contesta otra pregunta

Todo el mundo aprende a preguntar si una columna es normal. Preguntémoslo.

for c in NUMERICAS:
    s = ventas[c].dropna()
    w, p = stats.shapiro(s.sample(min(4000, len(s)), random_state=7))
    ad = stats.anderson(s, dist='norm')
    print(f'{c:22s} shapiro W {w:.4f} p {p:.3e}   anderson {ad.statistic:8.2f} '
          f'(critico al 5% {ad.critical_values[2]:.3f})')
unidades               shapiro W 0.9869 p 5.181e-16   anderson     6.57 (critico al 5% 0.752)
monto                  shapiro W 0.8378 p 4.460e-48   anderson   175.97 (critico al 5% 0.752)
descuento              shapiro W 0.9580 p 8.410e-26   anderson    23.61 (critico al 5% 0.752)
satisfaccion           shapiro W 0.8866 p 4.297e-41   anderson   100.52 (critico al 5% 0.752)
precio_unitario        shapiro W 0.4011 p 6.850e-72   anderson   502.35 (critico al 5% 0.752)
monto_final_facturado  shapiro W 0.7424 p 5.966e-56   anderson   274.05 (critico al 5% 0.752)

Las seis rechazadas, y no por poco: la más suave sale con p de 5,18e-16 😅

Aquí es donde casi todo el mundo concluye "es que con muchos datos la prueba rechaza siempre". Yo también lo pensaba, así que lo medí.

rng = np.random.default_rng(7)

print('datos normales de verdad:')
for n in (30, 100, 1000, 3000, 20000):
    p = stats.shapiro(rng.normal(0, 1, n)).pvalue
    print(f'  n {n:6d}  p {p:.4f}  {"pasa" if p > 0.05 else "RECHAZA"}')
datos normales de verdad:
  n     30  p 0.5097  pasa
  n    100  p 0.8481  pasa
  n   1000  p 0.4186  pasa
  n   3000  p 0.4519  pasa
  n  20000  p 0.5194  pasa

Pues no. Los datos normales pasan la prueba con 30 y con 20.000 🤨

Así que la conclusión honesta es la incómoda: estas seis columnas sencillamente no son normales. No es un artefacto del tamaño.

Donde sí manda el tamaño es en las desviaciones chicas. Aquí una mezcla que es 95% normal y 5% de algo con más dispersión, veinte veces por cada tamaño:

print('95% normal y 5% de otra cosa, 20 intentos por tamano:')
for n in (50, 200, 1000, 3000, 10000):
    ps = []
    for semilla in range(20):
        g = np.random.default_rng(semilla)
        mezcla = np.where(g.random(n) < 0.95, g.normal(0, 1, n), g.normal(0, 3, n))
        ps.append(stats.shapiro(mezcla).pvalue)
    ps = np.array(ps)
    print(f'  n {n:6d}  p mediana {np.median(ps):.2e}  rechaza en '
          f'{int((ps < 0.05).sum())}/20')
95% normal y 5% de otra cosa, 20 intentos por tamano:
  n     50  p mediana 7.05e-02  rechaza en 10/20
  n    200  p mediana 4.37e-05  rechaza en 19/20
  n   1000  p mediana 3.36e-14  rechaza en 20/20
  n   3000  p mediana 6.17e-27  rechaza en 20/20
  n  10000  p mediana 1.25e-45  rechaza en 20/20

Ahí está el efecto del tamaño, dicho bien: con 50 filas esa desviación se cuela la mitad de las veces y con 1.000 no se le escapa ninguna.

O sea que la prueba no rechaza de más con muchos datos: rechaza de menos con pocos. Es lo mismo visto al revés y cambia la conclusión entera.

Y aun así yo casi nunca uso la prueba, por una razón práctica: contesta "¿es exactamente normal?" cuando la pregunta útil es "¿cuánto se desvía?". Eso lo contestan la asimetría y la curtosis, que además te dicen hacia dónde 📐

Cuántos atípicos hay, según a quién preguntes

Tres formas de contarlos, las tres estándar.

for c in NUMERICAS:
    s = ventas[c].dropna()
    q1, q3 = s.quantile([0.25, 0.75])
    ri = q3 - q1
    tukey = ((s < q1 - 1.5 * ri) | (s > q3 + 1.5 * ri)).sum()
    z = (np.abs(stats.zscore(s)) > 3).sum()
    mad = stats.median_abs_deviation(s)
    robusto = (np.abs(0.6745 * (s - s.median()) / mad) > 3.5).sum() if mad else 0
    print(f'{c:22s} tukey {tukey:5d}   z>3 {z:5d}   z robusto {robusto:5d}   '
          f'de {len(s)}')
unidades               tukey    10   z>3     7   z robusto     0   de 3000
monto                  tukey   203   z>3    32   z robusto   222   de 3000
descuento              tukey     0   z>3     0   z robusto     0   de 2405
satisfaccion           tukey     0   z>3     0   z robusto     0   de 2769
precio_unitario        tukey   277   z>3    54   z robusto   316   de 3000
monto_final_facturado  tukey   270   z>3    50   z robusto   434   de 3000

Mira monto: 203, 32 y 222. Siete veces más según el método 😬

Y no es que uno esté mal, es que miden cosas distintas. El z clásico usa la media y la desviación, que los propios atípicos inflan, así que se le esconden. El z robusto usa la mediana y la MAD, que no se dejan mover, y por eso encuentra más.

La consecuencia práctica es dura de aceptar: no existe "cuántos atípicos tiene esta columna". Existe cuántos encuentra el método que elegiste, y ese método hay que decirlo cuando se reporta el número.

Fíjate además en las dos que salen en cero por los tres caminos: descuento y satisfaccion. Cuando los tres métodos coinciden, ahí sí se puede afirmar algo 👍

Lo que ninguna ficha resume

for c in ('monto', 'precio_unitario', 'monto_final_facturado'):
    s = ventas[c]
    print(f'{c:22s} negativos {int((s < 0).sum()):4d}   '
          f'ceros {int((s == 0).sum()):5d}')
monto                  negativos   21   ceros     0
precio_unitario        negativos   21   ceros     0
monto_final_facturado  negativos    0   ceros  1267

Veintiún montos negativos. Son las devoluciones del capítulo 4, y aparecen aquí otra vez porque el mínimo de la ficha ya lo cantaba: −2497,72.

Y monto_final_facturado con 1.267 ceros, que es el 42,23% de las ventas. Esa columna se delata sola sin cruzarla con nada:

print(ventas['monto_final_facturado'].describe().round(2).to_string())
print()
print('valen exactamente 0:', int((ventas['monto_final_facturado'] == 0).sum()),
      f"({100 * (ventas['monto_final_facturado'] == 0).mean():.2f}%)")
count    3000.00
mean      481.26
std       678.86
min         0.00
25%         0.00
50%       191.89
75%       668.36
max      4171.70

valen exactamente 0: 1267 (42.23%)

El primer cuartil vale 0 y la mediana 191,89. Una columna de dinero donde el 25% inferior es exactamente cero no es una columna de dinero: es dos cosas metidas en una, y una de ellas es "no pasó nada" 🚩

Todavía no sabemos que es una fuga (eso es el capítulo 12), pero ya sabemos que hay que preguntar por ella. Y eso lo dio el análisis de una sola columna.

Las categóricas, que aquí no dan guerra

for c in ('ciudad', 'segmento', 'canal', 'categoria'):
    cuenta = ventas[c].value_counts(dropna=False)
    print(f'{c:12s} niveles {len(cuenta):3d}   '
          f'el mas comun {cuenta.index[0]!r} {100 * cuenta.iloc[0] / len(ventas):5.2f}%   '
          f'el mas raro {cuenta.index[-1]!r} {100 * cuenta.iloc[-1] / len(ventas):5.2f}%')
ciudad       niveles   6   el mas comun 'arequipa' 18.20%   el mas raro 'lima' 15.70%
segmento     niveles   4   el mas comun 'Minimarket' 26.70%   el mas raro 'Bodega' 23.57%
canal        niveles   4   el mas comun 'Web' 26.40%   el mas raro 'WhatsApp' 23.97%
categoria    niveles   5   el mas comun 'Abarrotes' 21.33%   el mas raro 'Bebidas' 18.83%

Todas repartidas entre el 15,70% y el 26,70%, y ninguna pasa de seis niveles. Aburrido, y lo digo como un elogio 😌

Lo aburrido aquí significa que no hay que hacer nada: sin categorías raras no hay que agrupar niveles, y con seis niveles el one-hot no infla la tabla.

Cuando esta tabla sale fea (una categoría con el 95%, o cuarenta niveles con tres filas cada uno) es cuando empiezan las decisiones difíciles, y esas están en el capítulo 5.

Ejercicios

1. La ficha completa, con recomendación

Amplía la ficha para que además diga qué hacer con cada columna según su forma.

def recomienda(fila):
    if abs(fila['asimetria']) > 2:
        return 'muy sesgada: log o tramos'
    if abs(fila['asimetria']) > 0.8:
        return 'sesgada: probar log'
    if fila['cv'] > 1:
        return 'varia mucho: escalar seguro'
    return 'escalar y listo'

tabla = ficha(ventas, NUMERICAS)
tabla['que_hacer'] = tabla.apply(recomienda, axis=1)
print(tabla[['columna', 'asimetria', 'cv', 'que_hacer']].to_string(index=False))
              columna  asimetria    cv                 que_hacer
             unidades      0.184 0.498           escalar y listo
                monto      1.340 0.936       sesgada: probar log
            descuento     -0.016 0.573           escalar y listo
         satisfaccion     -0.015 0.472           escalar y listo
      precio_unitario      6.734 2.104 muy sesgada: log o tramos
monto_final_facturado      1.764 1.411       sesgada: probar log

Esos cortes en 0,8 y en 2 son convenciones, no leyes de la naturaleza. Sirven para ordenar la conversación, no para decidir solas 📋

Lo valioso de la tabla es lo mismo que en el capítulo 5: cada fila lleva una decisión al lado, y esa decisión queda escrita antes de que nadie la pregunte en una reunión.

2. El logaritmo, a ver si arregla el cohete

La receta clásica para una columna sesgada es el logaritmo. Compruébalo con precio_unitario, que tenía asimetría 6,734.

s = ventas['precio_unitario']
positivos = s[s > 0]

print('original        asimetria', round(stats.skew(positivos), 3),
      ' curtosis', round(stats.kurtosis(positivos), 3))
print('log             asimetria', round(stats.skew(np.log(positivos)), 3),
      ' curtosis', round(stats.kurtosis(np.log(positivos)), 3))
print('raiz cuadrada   asimetria', round(stats.skew(np.sqrt(positivos)), 3),
      ' curtosis', round(stats.kurtosis(np.sqrt(positivos)), 3))
print()
print('filas que el log deja fuera por no ser positivas:', int((s <= 0).sum()))
original        asimetria 6.766  curtosis 60.161
log             asimetria 0.271  curtosis 0.272
raiz cuadrada   asimetria 2.807  curtosis 12.177

filas que el log deja fuera por no ser positivas: 21

El logaritmo hace su trabajo y de sobra. Pero mira la última línea: deja fuera 21 filas, las de los montos negativos.

Ese es el precio que nadie menciona. Si transformas con log tienes que decidir qué haces con los no positivos, y "no hacer nada" significa perderlos 🕳️

3. Cuánto cambia la ficha si quitas los atípicos

Antes de borrar un atípico conviene ver cuánto de la columna se lleva por delante.

s = ventas['monto']
q1, q3 = s.quantile([0.25, 0.75])
ri = q3 - q1
dentro = s[(s >= q1 - 1.5 * ri) & (s <= q3 + 1.5 * ri)]

print(f'{"":12s} {"n":>6s} {"media":>10s} {"mediana":>10s} {"ds":>10s} {"asim":>8s}')
for nombre, datos in (('con todo', s), ('sin atipicos', dentro)):
    print(f'{nombre:12s} {len(datos):6d} {datos.mean():10.2f} '
          f'{datos.median():10.2f} {datos.std():10.2f} {stats.skew(datos):8.3f}')
print()
print('soles que se van:', round(float(s.sum() - dentro.sum()), 2))
print('porcentaje del dinero total:',
      round(100 * (s.sum() - dentro.sum()) / s.sum(), 2), '%')
                  n      media    mediana         ds     asim
con todo       3000     803.76     533.63     751.99    1.340
sin atipicos   2797     670.93     494.47     558.71    1.214

soles que se van: 534679.47
porcentaje del dinero total: 22.17 %

Ahí está el argumento de verdad contra borrar atípicos por sistema 💸

Son 203 filas de 3.000, o sea el 6,77%. Y se llevan 534.679 soles, que es el 22,17% de todo el dinero del archivo.

Borrar el 6,77% de las filas para perder el 22,17% de la facturación deja una columna más bonita y un negocio que ya no es el tuyo. Y fíjate en que la asimetría apenas baja, de 1,340 a 1,214: ni siquiera arregla lo que se supone que venía a arreglar.

4. La misma columna, mirada por trozos

Una ficha global puede esconder dos poblaciones. Saca la ficha de monto dentro de cada segmento.

por_segmento = ventas.groupby('segmento')['monto'].agg(
    n='count', media='mean', mediana='median', ds='std',
    asimetria=lambda s: stats.skew(s))
por_segmento['cv'] = por_segmento['ds'] / por_segmento['media']
print(por_segmento.round(3).to_string())
print()
print('global: media', round(ventas['monto'].mean(), 2),
      'asimetria', round(stats.skew(ventas['monto']), 3))
              n     media   mediana       ds  asimetria     cv
segmento                                                      
Bodega      707   178.698   183.260   69.587     -0.774  0.389
Horeca      742   755.179   742.995  277.276     -0.427  0.367
Mayorista   750  1856.337  1869.255  719.551     -0.455  0.388
Minimarket  801   414.917   413.740  148.680     -0.678  0.358

global: media 803.76 asimetria 1.34

Esto es lo mismo que salió en el libro de estadística con la media y la mediana, y aquí sale todavía más fuerte: la forma global no es la forma de nadie 🎭

La asimetría global es +1,340 y dentro de los cuatro segmentos es negativa en los cuatro: −0,774, −0,427, −0,455 y −0,678. No es que se reduzca, es que cambia de signo.

Traducido: no tienes una columna con cola a la derecha. Tienes cuatro poblaciones muy distintas (178 soles de media en bodega, 1.856 en mayorista) y la cola de la derecha son los mayoristas enteros.

Eso no se arregla con un logaritmo. Se arregla con la columna que las distingue, que ya la tienes y se llama segmento 🏷️

5. Los valores repetidos, que delatan cosas

Cuando un valor concreto se repite mucho en una columna continua, casi siempre es un relleno o un tope.

for c in NUMERICAS:
    cuenta = ventas[c].value_counts(dropna=True)
    if cuenta.empty:
        continue
    valor, veces = cuenta.index[0], cuenta.iloc[0]
    print(f'{c:22s} el valor mas repetido es {valor:10.2f} y sale {veces:5d} veces '
          f'({100 * veces / ventas[c].notna().sum():5.2f}%)')
unidades               el valor mas repetido es      13.00 y sale   208 veces ( 6.93%)
monto                  el valor mas repetido es     204.47 y sale     3 veces ( 0.10%)
descuento              el valor mas repetido es       0.05 y sale    20 veces ( 0.83%)
satisfaccion           el valor mas repetido es       5.00 y sale   563 veces (20.33%)
precio_unitario        el valor mas repetido es     185.03 y sale     2 veces ( 0.07%)
monto_final_facturado  el valor mas repetido es       0.00 y sale  1267 veces (42.23%)

Una columna continua de verdad casi no repite valores. Cuando uno se lleva un porcentaje gordo, la pregunta es de dónde sale ese número 🔍

Aquí lo canta monto_final_facturado con su cero. Y ojo con unidades y satisfaccion, que repiten mucho por otra razón perfectamente sana: son discretas y tienen pocos valores posibles.

6. Cuánto pesa lo que falta

Los nulos ya salieron en el capítulo 4. Aquí la pregunta es otra: ¿faltan juntos?

banderas = ventas[['descuento', 'satisfaccion']].isna()
banderas.columns = ['falta_descuento', 'falta_satisfaccion']

print(pd.crosstab(banderas['falta_descuento'],
                  banderas['falta_satisfaccion']).to_string())
print()
esperado = banderas['falta_descuento'].mean() * banderas['falta_satisfaccion'].mean()
juntos = (banderas['falta_descuento'] & banderas['falta_satisfaccion']).mean()
print('si faltaran independientes, coincidirian el', round(100 * esperado, 2), '%')
print('coinciden de verdad el                     ', round(100 * juntos, 2), '%')
falta_satisfaccion  False  True 
falta_descuento                 
False                2209    196
True                  560     35

si faltaran independientes, coincidirian el 1.53 %
coinciden de verdad el                      1.17 %

Aquí la respuesta es que no: si faltaran independientes coincidirían el 1,53% de las filas y coinciden el 1,17%. Prácticamente lo mismo, y si acaso un poco menos 🤷‍♀️

O sea que son dos huecos con dos causas distintas, y hay que tratarlos por separado. Aburrido otra vez, y otra vez es la respuesta que quieres.

La comprobación importa por el caso contrario. Si dos columnas faltaran juntas mucho más de lo que toca por azar, no faltarían por descuido: faltarían por una razón, normalmente un proceso del negocio que no se ejecutó, y entonces son un solo problema disfrazado de dos 🕳️

7. La ficha como control de calidad automático

Convierte todo esto en una función que devuelve avisos, no números. Es lo que se corre cada vez que llega un archivo nuevo.

def avisos(v, columnas):
    fuera = []
    for c in columnas:
        s = v[c].dropna()
        if v[c].isna().mean() > 0.15:
            fuera.append(f'{c}: falta el {100 * v[c].isna().mean():.1f}%')
        if abs(stats.skew(s)) > 2:
            fuera.append(f'{c}: asimetria {stats.skew(s):.2f}, muy sesgada')
        if (s < 0).any():
            fuera.append(f'{c}: {int((s < 0).sum())} valores negativos')
        repetido = s.value_counts()
        if not repetido.empty and repetido.iloc[0] / len(s) > 0.3:
            fuera.append(f'{c}: el valor {repetido.index[0]} se lleva el '
                         f'{100 * repetido.iloc[0] / len(s):.1f}%')
    return fuera

for aviso in avisos(ventas, NUMERICAS):
    print(' ', aviso)
  monto: 21 valores negativos
  descuento: falta el 19.8%
  precio_unitario: asimetria 6.73, muy sesgada
  precio_unitario: 21 valores negativos
  monto_final_facturado: el valor 0.0 se lleva el 42.2%

Seis avisos y ninguno es un error: son seis preguntas que hay que hacerle a quien manda los datos antes de modelar 📋

Esta función es la hermana pequeña de la revisa del capítulo 23. Aquella vigila que el archivo de mañana se parezca al de hoy; esta vigila que el de hoy tenga sentido.

Comprueba que lo tienes

La prueba de normalidad rechaza tus seis columnas numéricas con p por debajo de 1e-15. ¿Qué haces?

  • Mirar la asimetría y la curtosis, que dicen cuánto se desvía
  • Transformar las seis con logaritmo para normalizarlas
  • Nada: casi ningún modelo del libro pide normalidad
  • Quitar los atípicos hasta que la prueba pase

Lo que te llevas

  • 📋 Cuatro números por columna: media contra mediana, coeficiente de variación, asimetría y curtosis. Con eso ya se decide qué escalar y qué transformar.
  • 🚀 precio_unitario tiene asimetría 6,734 y curtosis 60,015. El escalado estándar no arregla eso: mueve la columna de sitio pero no le cambia la forma.
  • 📏 El CV compara columnas con unidades distintas: 0,498 en unidades contra 2,104 en precio_unitario.
  • 🧪 Las seis columnas fallan la prueba de normalidad, y no es cosa del tamaño: los datos normales de verdad la pasan con 30 filas y con 20.000.
  • 🔬 Donde sí manda el tamaño es al revés: una desviación chica se cuela la mitad de las veces con 50 filas y no se le escapa ninguna con 1.000. La prueba no rechaza de más con muchos datos, rechaza de menos con pocos.
  • 😬 Los atípicos de monto son 203, 32 o 222 según el método. No existe "cuántos atípicos tiene", existe cuántos encuentra el que elegiste.
  • 🚩 monto_final_facturado se delata sin cruzarla con nada: su primer cuartil vale 0 y el 42,23% de las filas también.
  • 💸 Borrar los atípicos de monto quita el 6,77% de las filas y mucho más del dinero, porque son justo las ventas grandes.
  • 😌 Las cuatro categóricas están repartidas entre el 15,70% y el 26,70%, sin niveles raros. Aburrido, y aquí eso es una buena noticia.

Qué viene ahora

Cada columna por separado ya no tiene secretos. Falta la pregunta que de verdad importa: ¿cuál de ellas tiene que ver con lo que queremos predecir? 🎯

El capítulo 7 cruza cada columna con el objetivo, y no a ojo: con la prueba que corresponde a cada tipo y con el tamaño del efecto al lado, que es lo que separa "hay diferencia" de "la diferencia importa".

Lo que verás allí:

  • 🧮 Qué prueba toca según el tipo de columna, en una tabla que se puede pegar en la pared.
  • 📐 Por qué el valor p solo no sirve, y qué se pone al lado.
  • 🎣 Las columnas que parecen decir algo y no dicen nada, con el ajuste por comparaciones múltiples.
  • 🪤 Y una columna con doce puntos de diferencia que al modelo no le va a aportar nada, que es la trampa del capítulo 19 vista desde el otro lado.
¿Tienes alguna duda o consulta?