Saltar al contenido

Indice / NumPy / 3.7

Atípicos: z-score e IQR

Un valor extremo no es un error. Detectarlo es aritmética; decidir qué hacer con él es una política, y las tres opciones dan totales que difieren en un 15 %.

Para qué sirve

Es la última pieza del diagnóstico: después de contar ausentes (3.6) y de validar rangos, quedan los valores que están dentro del rango posible y muy lejos del resto. El corpus tiene varios sembrados, y cada uno mueve un promedio de forma espectacular.

Y la lección deja dos herramientas que reaparecen en pandas sin cambios: np.percentile (que es .quantile()) y np.clip (que es .clip()).

ELI5Explicado facil

Si en una foto de un curso hay alguien de tres metros, hay dos posibilidades: la persona existe, o alguien midió mal.

La estadística puede decirte que está lejos del resto. Cuál de las dos posibilidades es, no lo dice ningún número: hay que ir a mirar.

El criterio IQR

EjemploEl criterio del rango intercuartil (IQR)
import csv

import numpy as np

NULOS = {"", "-", "N/A", "NaN", "null"}

with open("dw_hecho_ventas.csv", encoding="utf-8", newline="") as f:
    filas = list(csv.DictReader(f))


def a_numero(texto):
    crudo = texto.strip()
    if crudo in NULOS:
        return np.nan
    try:
        return float(crudo)
    except ValueError:
        return np.nan


costo = np.array([a_numero(x["costo"]) for x in filas])
validos = costo[~np.isnan(costo)]
print("columna costo:", validos.size, "valores validos de", costo.size)

q1, q3 = np.percentile(validos, [25, 75])
iqr = q3 - q1
print()
print("Los tres numeros que hacen falta:")
print(f"   Q1 (percentil 25) : {q1:>10.2f}")
print(f"   Q3 (percentil 75) : {q3:>10.2f}")
print(f"   IQR = Q3 - Q1     : {iqr:>10.2f}")
print("   Entre Q1 y Q3 esta la MITAD central de los datos.")

K = 1.5
bajo = q1 - K * iqr
alto = q3 + K * iqr
print()
print(f"Los limites, con k = {K} (la convencion de Tukey):")
print(f"   limite inferior = Q1 - {K} * IQR = {bajo:>10.2f}")
print(f"   limite superior = Q3 + {K} * IQR = {alto:>10.2f}")

atipicos = validos[(validos < bajo) | (validos > alto)]
print()
print("atipicos:", atipicos.size, "de", validos.size,
      f"({100 * atipicos.size / validos.size:.1f}%)")
print("los cinco mas grandes:", np.round(np.sort(atipicos)[-5:], 2))
print("por debajo del limite inferior:",
      int((validos < bajo).sum()), "(el limite es negativo y el costo no)")

print()
print("Por que 1.5 y no otro numero: es una convencion, no una ley.")
for k in (1.0, 1.5, 3.0):
    b, a = q1 - k * iqr, q3 + k * iqr
    cuantos = int(((validos < b) | (validos > a)).sum())
    print(f"   k = {k:<4} -> limites ({b:>9.2f}, {a:>9.2f}) -> "
          f"{cuantos:>3} atipicos")
print("   k = 1.5 marca 'sospechoso'; k = 3.0 marca 'muy lejos'. El")
print("   numero se declara en el enunciado, no se elige mirando cuantos")
print("   quedan.")

print()
print("La ventaja del IQR sobre la media y el desvio: los cuartiles no se")
print("mueven con los extremos, asi que el criterio no se contamina con")
print("lo mismo que intenta detectar.")
columna costo: 498 valores validos de 500

Los tres numeros que hacen falta:
   Q1 (percentil 25) :     797.62
   Q3 (percentil 75) :    3627.09
   IQR = Q3 - Q1     :    2829.47
   Entre Q1 y Q3 esta la MITAD central de los datos.

Los limites, con k = 1.5 (la convencion de Tukey):
   limite inferior = Q1 - 1.5 * IQR =   -3446.57
   limite superior = Q3 + 1.5 * IQR =    7871.29

atipicos: 23 de 498 (4.6%)
los cinco mas grandes: [ 9561.45  9898.86 10017.95 10134.22 10226.35]
por debajo del limite inferior: 0 (el limite es negativo y el costo no)

Por que 1.5 y no otro numero: es una convencion, no una ley.
   k = 1.0  -> limites ( -2031.84,   6456.56) ->  52 atipicos
   k = 1.5  -> limites ( -3446.57,   7871.29) ->  23 atipicos
   k = 3.0  -> limites ( -7690.77,  12115.49) ->   0 atipicos
   k = 1.5 marca 'sospechoso'; k = 3.0 marca 'muy lejos'. El
   numero se declara en el enunciado, no se elige mirando cuantos
   quedan.

La ventaja del IQR sobre la media y el desvio: los cuartiles no se
mueven con los extremos, asi que el criterio no se contamina con
lo mismo que intenta detectar.
Sobre la columna costo de dw_hecho_ventas.csv
PasoValor
Q1 (percentil 25)797,62
Q3 (percentil 75)3.627,09
IQR = Q3 − Q12.829,47
Límite inferior = Q1 − 1,5·IQR−3.446,57
Límite superior = Q3 + 1,5·IQR7.871,29
Atípicos23 de 498 (4,6 %)

Entre Q1 y Q3 está la mitad central de los datos. El IQR es el ancho de esa mitad, y los límites se ponen a una distancia proporcional a ese ancho: así el criterio se adapta solo a columnas con escalas distintas.

El 1,5 es una convención, no una ley

Sobre esta columna: k = 1.0 marca 52 atípicos, k = 1.5 marca 23, k = 3.0 marca 0. Es la convención de Tukey, y se lee como «sospechoso» para 1,5 y «muy lejos» para 3.

Lo que no se hace es elegir el k mirando cuántos quedan. El número se declara en el enunciado - en este curso, 1,5 - antes de mirar el resultado.

Los dos criterios no coinciden

Ejemploz-score contra IQR: los dos criterios no coinciden
import csv

import numpy as np

NULOS = {"", "-", "N/A", "NaN", "null"}

with open("dw_hecho_ventas.csv", encoding="utf-8", newline="") as f:
    filas = list(csv.DictReader(f))


def a_numero(texto):
    crudo = texto.strip()
    if crudo in NULOS:
        return np.nan
    try:
        return float(crudo)
    except ValueError:
        return np.nan


costo = np.array([a_numero(x["costo"]) for x in filas])
validos = costo[~np.isnan(costo)]

# --- criterio IQR
q1, q3 = np.percentile(validos, [25, 75])
iqr = q3 - q1
por_iqr = (validos < q1 - 1.5 * iqr) | (validos > q3 + 1.5 * iqr)

# --- criterio z-score
media = validos.mean()
desvio = validos.std()
z = (validos - media) / desvio
por_z = np.abs(z) > 3

print(f"{'criterio':<12} {'marca':>6} {'umbral':<28}")
print("-" * 50)
print(f"{'IQR k=1.5':<12} {int(por_iqr.sum()):>6} "
      f"fuera de ({q1 - 1.5 * iqr:.0f}, {q3 + 1.5 * iqr:.0f})")
print(f"{'|z| > 3':<12} {int(por_z.sum()):>6} "
      f"fuera de ({media - 3 * desvio:.0f}, {media + 3 * desvio:.0f})")

print()
print("Los dos coinciden en:", int((por_iqr & por_z).sum()))
print("Solo el IQR marca  :", int((por_iqr & ~por_z).sum()))
print("Solo el z-score    :", int((~por_iqr & por_z).sum()))

print()
print("Por que difieren tanto: la media y el desvio SE MUEVEN con los")
print("extremos que intentan detectar. Los cuartiles, no.")
print(f"   media  : {media:>10.2f}")
print(f"   mediana: {np.median(validos):>10.2f}")
print(f"   desvio : {desvio:>10.2f}")
print(f"   IQR    : {iqr:>10.2f}")

print()
print("Se ve mejor agregando un valor absurdo a la columna:")
con_error = np.append(validos, 10_000_000.0)
q1b, q3b = np.percentile(con_error, [25, 75])
print(f"   {'':<10} {'sin el error':>14} {'con 10 millones':>18}")
print(f"   {'media':<10} {media:>14.2f} {con_error.mean():>18.2f}")
print(f"   {'desvio':<10} {desvio:>14.2f} {con_error.std():>18.2f}")
print(f"   {'Q1':<10} {q1:>14.2f} {q1b:>18.2f}")
print(f"   {'Q3':<10} {q3:>14.2f} {q3b:>18.2f}")
print("   La media y el desvio se multiplican; los cuartiles casi no se")
print("   mueven. Ese es todo el argumento a favor del IQR.")

z_error = (con_error - con_error.mean()) / con_error.std()
print()
print("Y el efecto practico sobre el z-score, que es contraintuitivo:")
print("   |z| del valor de 10 millones:", round(float(abs(z_error[-1])), 2))
print("   atipicos por |z| > 3, ahora:", int((np.abs(z_error) > 3).sum()))
print("   El unico valor absurdo ELEVA tanto el desvio que los 23 que")
print("   antes estaban lejos ya no lo parecen. Se llama enmascaramiento,")
print("   y es la razon por la que este curso usa IQR por defecto.")
criterio      marca umbral                      
--------------------------------------------------
IQR k=1.5        23 fuera de (-3447, 7871)
|z| > 3           4 fuera de (-4483, 9786)

Los dos coinciden en: 4
Solo el IQR marca  : 19
Solo el z-score    : 0

Por que difieren tanto: la media y el desvio SE MUEVEN con los
extremos que intentan detectar. Los cuartiles, no.
   media  :    2651.30
   mediana:    1962.62
   desvio :    2378.20
   IQR    :    2829.47

Se ve mejor agregando un valor absurdo a la columna:
                sin el error    con 10 millones
   media             2651.30           22686.07
   desvio            2378.20          447100.44
   Q1                 797.62             798.36
   Q3                3627.09            3641.82
   La media y el desvio se multiplican; los cuartiles casi no se
   mueven. Ese es todo el argumento a favor del IQR.

Y el efecto practico sobre el z-score, que es contraintuitivo:
   |z| del valor de 10 millones: 22.32
   atipicos por |z| > 3, ahora: 1
   El unico valor absurdo ELEVA tanto el desvio que los 23 que
   antes estaban lejos ya no lo parecen. Se llama enmascaramiento,
   y es la razon por la que este curso usa IQR por defecto.
La misma columna, dos criterios
CriterioMarcaUmbral
IQR, k = 1,523Fuera de (−3.447, 7.871)
z-score, |z| > 34Fuera de (−4.483, 9.786)
Coinciden en 4 · Solo IQR: 19 · Solo z-score: 0
El enmascaramiento

La media y el desvío se mueven con los extremos que intentan detectar. Agregando un solo valor de 10.000.000 a esta columna:

Sin el errorCon 10 millones
Media2.651,3022.686,07
Desvío2.378,20447.100,44
Q1797,62798,36
Q33.627,093.641,82

La media y el desvío se multiplican; los cuartiles casi no se mueven. Y el efecto sobre el z-score es contraintuitivo: con el valor absurdo adentro, los atípicos por |z| > 3 pasan de 4 a 1. El error eleva tanto el desvío que los otros ya no parecen lejos.

Por eso este curso usa IQR por defecto. El z-score es útil para comparar escalas distintas (lección 3.5), no para detectar lo que lo corrompe.

Acá hay que elegir

EjemploDescartar, recortar o marcar: los tres finales posibles
import csv

import numpy as np

NULOS = {"", "-", "N/A", "NaN", "null"}

with open("dw_hecho_ventas.csv", encoding="utf-8", newline="") as f:
    filas = list(csv.DictReader(f))


def a_numero(texto):
    crudo = texto.strip()
    if crudo in NULOS:
        return np.nan
    try:
        return float(crudo)
    except ValueError:
        return np.nan


costo = np.array([a_numero(x["costo"]) for x in filas])
validos = costo[~np.isnan(costo)]
q1, q3 = np.percentile(validos, [25, 75])
iqr = q3 - q1
bajo, alto = q1 - 1.5 * iqr, q3 + 1.5 * iqr
es_atipico = (validos < bajo) | (validos > alto)
print("costo:", validos.size, "valores |", int(es_atipico.sum()), "atipicos")
print(f"limites: ({bajo:.2f}, {alto:.2f})")

print()
print("1. DESCARTAR: se van del calculo.")
sin_ellos = validos[~es_atipico]
print(f"   n      : {sin_ellos.size}")
print(f"   total  : {sin_ellos.sum():>12.2f}")
print(f"   media  : {sin_ellos.mean():>12.2f}")

print()
print("2. RECORTAR (clip): se los lleva al limite.")
recortados = np.clip(validos, bajo, alto)
print(f"   n      : {recortados.size}   <- no se pierde ninguno")
print(f"   total  : {recortados.sum():>12.2f}")
print(f"   media  : {recortados.mean():>12.2f}")
print("   Los 23 que estaban por encima ahora valen exactamente",
      round(float(alto), 2))
print("   valores distintos en el tope:",
      int((recortados == alto).sum()), " <- una barra artificial")

print()
print("3. CONSERVAR y marcar: quedan, y se informa cuales son.")
print(f"   n      : {validos.size}")
print(f"   total  : {validos.sum():>12.2f}")
print(f"   media  : {validos.mean():>12.2f}")
print("   mas una columna 'es_atipico' con", int(es_atipico.sum()), "True")

print()
print("Los tres totales, uno al lado del otro:")
print(f"   descartar : {sin_ellos.sum():>12.2f}")
print(f"   recortar  : {recortados.sum():>12.2f}")
print(f"   conservar : {validos.sum():>12.2f}")
diferencia = validos.sum() - sin_ellos.sum()
print(f"   La diferencia entre el primero y el ultimo es {diferencia:.2f},")
print(f"   o sea el {100 * diferencia / validos.sum():.1f}% del total.")

print()
print("Cual corresponde depende de POR QUE existe el valor extremo:")
print("   error de carga  -> descartar (o corregir, si se puede)")
print("   valor real raro -> conservar y marcar")
print("   no se sabe      -> conservar y marcar, y decirlo")
print()
print("Recortar es el que menos se usa y el que mas hay que justificar:")
print("inventa 23 valores identicos que no estaban en los datos. Sirve")
print("cuando un modelo no tolera extremos, no para informar un total.")

print()
print("Y el detalle que define la politica del curso: descartar SIN")
print("informar cuantos fueron es la unica de las tres opciones que")
print("esta mal, porque el resultado no se distingue de uno completo.")
costo: 498 valores | 23 atipicos
limites: (-3446.57, 7871.29)

1. DESCARTAR: se van del calculo.
   n      : 475
   total  :   1118113.12
   media  :      2353.92

2. RECORTAR (clip): se los lleva al limite.
   n      : 498   <- no se pierde ninguno
   total  :   1299152.73
   media  :      2608.74
   Los 23 que estaban por encima ahora valen exactamente 7871.29
   valores distintos en el tope: 23  <- una barra artificial

3. CONSERVAR y marcar: quedan, y se informa cuales son.
   n      : 498
   total  :   1320349.50
   media  :      2651.30
   mas una columna 'es_atipico' con 23 True

Los tres totales, uno al lado del otro:
   descartar :   1118113.12
   recortar  :   1299152.73
   conservar :   1320349.50
   La diferencia entre el primero y el ultimo es 202236.38,
   o sea el 15.3% del total.

Cual corresponde depende de POR QUE existe el valor extremo:
   error de carga  -> descartar (o corregir, si se puede)
   valor real raro -> conservar y marcar
   no se sabe      -> conservar y marcar, y decirlo

Recortar es el que menos se usa y el que mas hay que justificar:
inventa 23 valores identicos que no estaban en los datos. Sirve
cuando un modelo no tolera extremos, no para informar un total.

Y el detalle que define la politica del curso: descartar SIN
informar cuantos fueron es la unica de las tres opciones que
esta mal, porque el resultado no se distingue de uno completo.

Los 23 atípicos de costo, con tres finales posibles:

PolíticanTotalQué cuesta
Descartar4751.118.113 Se pierde el 15,3 % del total. Correcto si son errores de carga
Recortar (clip)4981.299.153 Inventa 23 valores idénticos que no estaban en los datos
Conservar y marcar4981.320.350 Un archivo o una columna más. Es lo que el curso pide por defecto

Cuál corresponde depende de por qué existe el valor extremo: si es un error de carga, se descarta o se corrige; si es real, conservarlo y marcarlo. Cuando no se sabe - que es lo habitual - se conserva, se marca y se dice.

La única opción que está mal es descartar sin informar cuántos fueron, porque el resultado no se distingue de uno completo.

Sobre clip

Recortar deja 23 valores exactamente iguales al límite: una barra artificial que no existía. Sirve cuando un modelo no tolera extremos; no sirve para informar un total, porque el número que produce no es el de nadie.

Dónde se rompe

(sin error) el criterio no marca el atípico obvio

Causa: enmascaramiento - usaste z-score y el propio atípico infló el desvío.

Arreglo: IQR, que se apoya en cuartiles. O calcular la media y el desvío sobre los datos ya limpios.

(sin error) el límite inferior es negativo en una columna que no puede serlo

Causa: es normal: Q1 − 1,5·IQR puede caer por debajo de cero.

Arreglo: no hace falta corregirlo, simplemente ningún valor cae ahí. Si querés el límite real del dominio, es otra validación: fuera_de_rango, no atipico.

(sin error) el resultado cambia si corro el criterio dos veces

Causa: calculaste los límites después de descartar la primera tanda: los cuartiles se movieron y ahora hay atípicos nuevos.

Arreglo: los límites se calculan una vez, sobre los datos de entrada, y se declaran. Iterar hasta que no queden es un criterio distinto y hay que decirlo.

RuntimeWarning: invalid value encountered in divide

Causa: el z-score con desvío 0, porque todos los valores del grupo son iguales.

Arreglo: detectarlo antes de dividir. Un grupo sin variación no tiene atípicos por definición.

(sin error) np.percentile devuelve nan

Causa: hay ausentes en la columna.

Arreglo: np.nanpercentile, o filtrar antes. Y decidir si los ausentes se cuentan en el denominador del porcentaje de atípicos.

Recetario

Atípicos
ExpresiónQué hace
q1, q3 = np.nanpercentile(a, [25, 75])Los cuartiles, de una vez
iqr = q3 - q1El ancho de la mitad central
bajo, alto = q1 - 1.5*iqr, q3 + 1.5*iqrLos límites de Tukey
(a < bajo) | (a > alto)La máscara de atípicos
z = (a - a.mean()) / a.std()El z-score
np.abs(z) > 3Atípicos por z-score
np.clip(a, bajo, alto)Recorta a los límites
a[~mask]Descarta
np.where(mask, np.nan, a)Marca sin perder la posición
np.nanmedian(a)El centro que no se mueve

Ponete a prueba

1. ¿Qué es el IQR de una columna?Nivel 1 · Recordar
2. Con Q1 = 797,62 y Q3 = 3.627,09, ¿cuál es el límite superior de Tukey?Nivel 1 · Recordar
3. Sobre la misma columna, el IQR marca 23 atípicos y el z-score marca 4. ¿Por qué difieren tanto?Nivel 2 · Predecir la salida
4. Agregar un solo valor de 10 millones a la columna hace que los atípicos por |z| > 3 pasen de 4 a 1. ¿Cómo se llama eso?Nivel 2 · Predecir la salida
5. Los 23 atípicos de la columna costo. Te piden el total facturado y no sabés si son errores o ventas grandes reales. ¿Qué hacés?Nivel 3 · Elegir entre dos casi correctas
6. ¿Por qué el enunciado tiene que declarar k = 1.5 antes de mirar los resultados?Nivel 3 · Elegir entre dos casi correctas
7. Este código aplica el criterio IQR dos veces sobre la misma columna y la segunda vez vuelve a encontrar atípicos, aunque ya los había sacado. ¿Por qué?
q1, q3 = np.percentile(a, [25, 75])
limpio = a[(a >= q1 - 1.5*(q3-q1)) & (a <= q3 + 1.5*(q3-q1))]

q1, q3 = np.percentile(limpio, [25, 75])   # recalcula
otra_vez = limpio[(limpio < q1 - 1.5*(q3-q1))]
Nivel 4 · Encontrar el defecto

Prácticas de esta subunidad