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
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.
| Paso | Valor |
|---|---|
| Q1 (percentil 25) | 797,62 |
| Q3 (percentil 75) | 3.627,09 |
| IQR = Q3 − Q1 | 2.829,47 |
| Límite inferior = Q1 − 1,5·IQR | −3.446,57 |
| Límite superior = Q3 + 1,5·IQR | 7.871,29 |
| Atípicos | 23 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.
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
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.
| Criterio | Marca | Umbral |
|---|---|---|
| IQR, k = 1,5 | 23 | Fuera de (−3.447, 7.871) |
| z-score, |z| > 3 | 4 | Fuera de (−4.483, 9.786) |
| Coinciden en 4 · Solo IQR: 19 · Solo z-score: 0 | ||
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 error | Con 10 millones | |
|---|---|---|
| Media | 2.651,30 | 22.686,07 |
| Desvío | 2.378,20 | 447.100,44 |
| Q1 | 797,62 | 798,36 |
| Q3 | 3.627,09 | 3.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
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ítica | n | Total | Qué cuesta |
|---|---|---|---|
| Descartar | 475 | 1.118.113 | Se pierde el 15,3 % del total. Correcto si son errores de carga |
Recortar (clip) | 498 | 1.299.153 | Inventa 23 valores idénticos que no estaban en los datos |
| Conservar y marcar | 498 | 1.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.
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
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.
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.
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.
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.
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
| Expresión | Qué hace |
|---|---|
q1, q3 = np.nanpercentile(a, [25, 75]) | Los cuartiles, de una vez |
iqr = q3 - q1 | El ancho de la mitad central |
bajo, alto = q1 - 1.5*iqr, q3 + 1.5*iqr | Los 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) > 3 | Atí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 |