Indice / pandas: calidad y limpieza / 5.6
Rango, dominio y atípicos
Hay valores que son imposibles, valores que están prohibidos y valores que son raros. Los tres se ven parecido en una columna y los tres piden un tratamiento distinto.
Para qué sirve
Hasta acá el criterio de «dato malo» era formal: falta, o no es del tipo que debería. Este es el último filtro y el único que pandas no puede aplicar solo, porque depende de qué significan tus datos. Una edad de 150 tiene el tipo correcto, no está ausente, y es basura. Nadie más que vos lo sabe.
Son tres preguntas distintas, y confundirlas es caro en las dos direcciones: descartar datos buenos porque son grandes, o dejar pasar imposibles porque «son números».
| Clase | Ejemplo | ¿Puede ser cierto? | Qué se suele hacer |
|---|---|---|---|
| Fuera de rango | edad = −5 | Nunca | Descartar e informar |
| Fuera del dominio | tipo = «devolución» cuando solo hay ingreso y gasto | Sí, pero no acá | Descartar e informar |
| Atípico | una compra de $9.999.999 | Sí | Marcar y mirar |
ELI5Explicado facil
Imaginate que revisás las planillas de una farmacia:
- Alguien anotó −3 cajas vendidas. Eso no puede pasar: es un error de tipeo seguro.
- Alguien anotó la forma de pago como «trueque». Existir existe, pero esta farmacia solo acepta efectivo o tarjeta: no es un error de tipeo, es algo que no debería estar.
- Alguien vendió 500 cajas de una vez. Es rarísimo… y puede ser cierto: capaz fue un pedido de un hospital.
Los dos primeros se sacan. El tercero no se toca: se marca y se pregunta. Tirarlo sería borrar justamente la venta más interesante del mes.
El ejemplo mínimo
import pandas as pd
edades = pd.Series([34, 150, 22, -5, 17, 61])
print("edades:", edades.to_list())
print()
print("between(18, 100) es inclusivo en los dos extremos:")
print(edades.between(18, 100).to_list())
print("fuera de rango:", edades[~edades.between(18, 100)].to_list())
tipos = pd.Series(["ingreso", "gasto", "INGRESO", "devolucion"])
print()
print("isin declara el dominio permitido:")
print(tipos.isin(["ingreso", "gasto"]).to_list())
print("fuera del dominio:", tipos[~tipos.isin(["ingreso", "gasto"])].to_list())
edades: [34, 150, 22, -5, 17, 61]
between(18, 100) es inclusivo en los dos extremos:
[True, False, True, False, False, True]
fuera de rango: [150, -5, 17]
isin declara el dominio permitido:
[True, True, False, False]
fuera del dominio: ['INGRESO', 'devolucion']
Sobre datos de verdad
Rango: lo que no puede ser
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv", na_values=["-"])
edad = pd.to_numeric(clientes["edad"], errors="coerce")
fuera = edad[~edad.between(18, 100) & edad.notna()]
print("edades fuera de 18-100:", len(fuera))
print(fuera.value_counts().sort_index().to_string())
print()
print("No todas son iguales:")
print(" -5 imposible: no existe una edad negativa")
print(" 15, 16, 17 posible, pero este negocio no admite menores")
print(" 101, 128, 150 posible una, absurdas las otras dos")
print()
print("La primera es un error de carga. La segunda es una regla del negocio.")
print("La tercera hay que mirarla de cerca: 101 puede ser cierto.")
edades fuera de 18-100: 18
edad
-5.0 5
15.0 1
16.0 3
17.0 3
101.0 1
128.0 4
150.0 1
No todas son iguales:
-5 imposible: no existe una edad negativa
15, 16, 17 posible, pero este negocio no admite menores
101, 128, 150 posible una, absurdas las otras dos
La primera es un error de carga. La segunda es una regla del negocio.
La tercera hay que mirarla de cerca: 101 puede ser cierto.
Dieciocho valores fuera del rango, y no son todos la misma cosa. El
-5 es imposible. Los 15, 16 y 17
son edades reales de personas que este negocio no admite. El 101 podría
ser cierto perfectamente. Un solo between los mete a todos en la
misma bolsa, y eso está bien mientras vos sepas que adentro hay tres cosas
distintas y lo digas en el informe.
Dominio: lo que no está en la lista
import pandas as pd
tx = pd.read_csv("transacciones_categorias.csv")
permitidos = ["ingreso", "gasto"]
print("valores que trae la columna tipo:")
print(tx["tipo"].value_counts(dropna=False).to_string())
print()
fuera = ~tx["tipo"].isin(permitidos)
print("filas fuera del dominio:", int(fuera.sum()))
print(tx.loc[fuera, ["id", "tipo"]].to_string(index=False))
print()
normalizado = tx["tipo"].str.lower()
fuera2 = ~normalizado.isin(permitidos)
print("despues de pasar a minusculas, quedan:", int(fuera2.sum()))
print("Normalizar primero y validar despues: INGRESO no era un valor nuevo,")
print("era el mismo escrito distinto. 'devolucion' si es otra cosa.")
valores que trae la columna tipo:
tipo
gasto 82
ingreso 66
INGRESO 1
devolucion 1
filas fuera del dominio: 2
id tipo
OP0096 INGRESO
OP0118 devolucion
despues de pasar a minusculas, quedan: 1
Normalizar primero y validar despues: INGRESO no era un valor nuevo,
era el mismo escrito distinto. 'devolucion' si es otra cosa.
De los dos valores que quedaban afuera, uno era INGRESO: el mismo
valor escrito distinto. Validar antes de normalizar habría reportado dos problemas
donde hay uno, y habría descartado una fila buena. El orden correcto es
str.lower() primero, isin() después.
Atípicos: lo que es raro pero puede ser
import pandas as pd
hechos = pd.read_csv("dw_hecho_ventas.csv")
ingreso = pd.to_numeric(hechos["ingreso"], errors="coerce")
# Los negativos son imposibles: se sacan ANTES de medir la dispersion,
# si no contaminan los cuartiles.
validos = ingreso[ingreso >= 0]
q1, q3 = validos.quantile([0.25, 0.75])
iqr = q3 - q1
bajo, alto = q1 - 1.5 * iqr, q3 + 1.5 * iqr
print(f"Q1={q1:.2f} Q3={q3:.2f} IQR={iqr:.2f}")
print(f"limites: {bajo:.2f} a {alto:.2f}")
atipicos = validos[(validos < bajo) | (validos > alto)]
print()
print("atipicos:", len(atipicos), "de", len(validos))
print(atipicos.sort_values(ascending=False).head(5).to_list())
print()
print("Un atipico NO es un error: es un valor que merece que lo mires.")
Q1=1288.35 Q3=5709.25 IQR=4420.90
limites: -5343.00 a 12340.60
atipicos: 15 de 499
[9999999.99, 14091.99, 14043.84, 13839.96, 13758.66]
Un atipico NO es un error: es un valor que merece que lo mires.
Fijate en dos cosas. Primero: los negativos se sacan antes de calcular los cuartiles, porque si no deforman la medida de dispersión con la que vas a juzgar todo lo demás. Segundo: de los 15 marcados, uno es evidentemente basura (siete dígitos) y los otros catorce rondan los 14.000, que probablemente sean ventas reales y grandes.
Eso es exactamente lo que hace el método: marca candidatos, no culpables. La decisión sigue siendo tuya.
Lo mismo, comparado
Las tres salidas posibles, con lo que produce cada una sobre los mismos datos:
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv", na_values=["-"])
edad = pd.to_numeric(clientes["edad"], errors="coerce")
dentro = edad.between(18, 100)
descartar = edad[dentro]
recortar = edad.clip(lower=18, upper=100)
marcar = pd.DataFrame({"edad": edad, "fuera_de_rango": (~dentro & edad.notna()).astype("int64")})
print(f"{'politica':<12}{'filas':>7}{'media':>9} que pasa con el valor raro")
print(f"{'descartar':<12}{len(descartar):>7}{descartar.mean():>9.2f} desaparece la fila")
print(f"{'recortar':<12}{len(recortar.dropna()):>7}{recortar.mean():>9.2f} 150 pasa a valer 100")
print(f"{'marcar':<12}{len(marcar):>7}{marcar['edad'].mean():>9.2f} queda, con una bandera")
print()
print("clip() es tentador y peligroso: convierte un dato imposible en uno")
print("plausible. Despues nadie puede distinguir un 100 real de un 150 recortado.")
politica filas media que pasa con el valor raro
descartar 95 46.34 desaparece la fila
recortar 113 46.18 150 pasa a valer 100
marcar 120 46.50 queda, con una bandera
clip() es tentador y peligroso: convierte un dato imposible en uno
plausible. Despues nadie puede distinguir un 100 real de un 150 recortado.
clip() es la trampa de esta lección
Es cómodo y parece prolijo: todo queda dentro del rango, no se pierde ninguna fila, los gráficos salen lindos. El problema es que convierte un dato imposible en uno plausible. Una edad de 150 recortada a 100 es indistinguible de una edad de 100 real, para siempre. Usalo solo cuando el recorte sea parte del modelo (por ejemplo, topear un porcentaje en 100) y nunca para tapar errores de carga.
Dónde se rompe
Causa: calculaste los cuartiles sobre datos que todavía tenían valores imposibles. Un ingreso de siete dígitos estira el rango y arrastra a decenas de filas normales.
Arreglo: el orden es descartar lo imposible → medir la dispersión → buscar atípicos. Medir sobre datos rotos da límites rotos.
Causa: compara exacto. "INGRESO", " ingreso" y
"Ingreso" son tres valores distintos.
Arreglo: str.strip().str.lower() antes de
isin.
Causa: NaN.between(...) da False, así que un
ausente cae del lado de «fuera de rango» si no lo separaste antes.
Arreglo: tratá los ausentes primero (lección 5.4) y validá el rango solo
sobre los que tienen valor: ~edad.between(18,100) & edad.notna().
Causa: se trató a los atípicos como errores. El cliente que compra diez veces más que el resto no es un error de carga: es el mejor cliente.
Arreglo: los atípicos se marcan con una columna bandera y se conservan. Descartar un atípico exige una razón concreta, no la costumbre.
Acá hay que elegir
El 1.5 del método IQR es una convención, no una ley de la naturaleza.
Con 3.0 marcás muchos menos; con 1.0, muchos más. El
rango 18-100 de las edades tampoco viene de los datos: viene de una regla del
negocio.
Los dos números tienen que estar escritos en algún lado junto con su motivo. «Se descartaron 18 filas por edad fuera del rango 18-100, que es el rango admitido para titulares de cuenta» es una frase que se puede defender. «Se limpiaron los outliers» no.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
s.between(a, b) | Máscara, extremos incluidos | Rango numérico o de fechas |
s.between(a, b, inclusive="left") | Incluye solo el inicio | Cuando el borde importa |
s.isin(lista) | Máscara de pertenencia | Dominio cerrado de valores |
~s.isin(lista) | Lo que no pertenece | Para informar lo desconocido |
s.quantile([0.25, 0.75]) | Q1 y Q3 | Base del IQR |
q3 - q1 | IQR | Dispersión robusta a extremos |
q1 - 1.5*iqr, q3 + 1.5*iqr | Límites | Umbral habitual; el 1.5 se declara |
s.clip(lower=a, upper=b) | Recorta a los extremos | Solo si el recorte es parte del modelo |
s.describe() | min, max, cuartiles | Primera mirada a los extremos |
(~mascara).astype("int64") | Columna bandera 0/1 | Marcar sin descartar |