Saltar al contenido

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».

Tres cosas que se confunden
ClaseEjemplo¿Puede ser cierto?Qué se suele hacer
Fuera de rangoedad = −5NuncaDescartar e informar
Fuera del dominiotipo = «devolución» cuando solo hay ingreso y gastoSí, pero no acáDescartar e informar
Atípicouna compra de $9.999.999Sí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

EjemploTres preguntas distintas: rango, dominio y atipico
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

EjemploFuera de rango en un archivo real, y de que tipo es cada uno
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

EjemploDominio cerrado: lo que no esta en la lista, no vale
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.
Normalizar antes de validar, siempre

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

EjemploAtipicos por IQR: el metodo que no supone nada sobre los datos
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:

EjemploDescartar, recortar o marcar: tres salidas distintas
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

(sin error) el detector de atípicos marca el 12 % de las filas

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.

(sin error) isin() no reconoce un valor que sí está

Causa: compara exacto. "INGRESO", " ingreso" y "Ingreso" son tres valores distintos.

Arreglo: str.strip().str.lower() antes de isin.

(sin error) between() deja pasar los ausentes… o no

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().

(sin error) se descartaron los datos más importantes

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 umbral es una decisión, no un descubrimiento

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

Rango, dominio y atípicos
ExpresiónQué haceCuándo
s.between(a, b)Máscara, extremos incluidosRango numérico o de fechas
s.between(a, b, inclusive="left")Incluye solo el inicioCuando el borde importa
s.isin(lista)Máscara de pertenenciaDominio cerrado de valores
~s.isin(lista)Lo que no pertenecePara informar lo desconocido
s.quantile([0.25, 0.75])Q1 y Q3Base del IQR
q3 - q1IQRDispersión robusta a extremos
q1 - 1.5*iqr, q3 + 1.5*iqrLímitesUmbral habitual; el 1.5 se declara
s.clip(lower=a, upper=b)Recorta a los extremosSolo si el recorte es parte del modelo
s.describe()min, max, cuartilesPrimera mirada a los extremos
(~mascara).astype("int64")Columna bandera 0/1Marcar sin descartar

Ponete a prueba

1. ¿Los extremos de s.between(18, 100) están incluidos?Nivel 1 · Recordar
2. ¿Qué diferencia hay entre un valor fuera de rango y un atípico?Nivel 1 · Recordar
3. tipos = pd.Series(["ingreso", "gasto", "INGRESO"])
¿Qué da (~tipos.isin(["ingreso", "gasto"])).sum()?Nivel 2 · Predecir la salida
4. Con el método IQR y k = 1.5, los límites dan -5343 y 12340. Un ingreso de 9999999.99, ¿queda marcado como atípico?Nivel 2 · Predecir la salida
5. La columna tipo trae ingreso, gasto, INGRESO y devolucion. ¿Cuál es el orden correcto de operaciones?Nivel 3 · Elegir entre dos casi correctas
6. Tenés edades de 150 y de −5 en un padrón. ¿Qué conviene?Nivel 3 · Elegir entre dos casi correctas
7. Este código busca atípicos en los ingresos y marca 60 de 500. Al mirarlos, casi todos son ventas normales. ¿Qué pasó?
hechos = pd.read_csv("dw_hecho_ventas.csv")
ing = pd.to_numeric(hechos["ingreso"], errors="coerce")
q1, q3 = ing.quantile([0.25, 0.75])
iqr = q3 - q1
print(ing[(ing < q1 - 1.5*iqr) | (ing > q3 + 1.5*iqr)].count())
Nivel 4 · Encontrar el defecto

Prácticas de esta lección