Indice / pandas: calidad y limpieza / 5.4
Valores ausentes
Un dato que falta no es un cero, no es un texto vacío y no es un error. Es una tercera cosa, y casi todo lo que hagas después depende de que la trates como tal.
Para qué sirve
Ningún archivo real viene completo. Alguien no cargó la edad, el sistema exportó un guion donde no tenía dato, el formulario dejó pasar un campo en blanco. Si no decidís qué hacer con esos huecos, pandas decide por vos: unas funciones los saltean en silencio, otras te devuelven un error, y otras te dan un número que parece razonable y está mal.
Esta lección es sobre las dos preguntas que hay que responder, en este orden: cuántos ausentes hay de verdad y qué hacés con ellos. La primera es más difícil de lo que parece: en el archivo con el que vamos a trabajar, la respuesta obvia se equivoca por más del doble.
ELI5Explicado facil
Imaginate una lista de asistencia donde algunos casilleros quedaron en blanco. No sabés si esa persona vino o no: el casillero vacío no es un «no», es un «no sé».
Cuando abrís un archivo con Python, esos huecos también existen. Esta lección enseña dos cosas: contar cuántos huecos hay de verdad (spoiler: son más de los que parecen) y decidir qué hacer con ellos.
La idea: tres cosas que parecen la misma
Pensá en una planilla de asistencia en papel. Una celda con 0
significa que el alumno vino y sacó cero. Una celda vacía significa que nadie anotó
nada: no sabemos si vino. Son dos hechos distintos del mundo, y si los mezclás, el
promedio de la clase te va a mentir.
| Valor | Qué significa | Cuenta en un promedio |
|---|---|---|
0 | Sabemos que es cero | Sí, y lo baja |
"" (texto vacío) | Hay un dato: es la cadena vacía | No es número; rompe o contamina |
NaN | No sabemos | No: se saltea |
NaN quiere decir Not a Number, y en pandas representa
«acá no hay dato». Tiene una propiedad que sorprende a todo el mundo la primera
vez: NaN no es igual a sí mismo. Es coherente con lo
que significa - si no sé cuánto vale ninguno de los dos, no puedo afirmar que sean
iguales - pero tiene una consecuencia práctica inmediata: buscar los ausentes con
== no encuentra ninguno.
ELI5Explicado facil
Hay tres cosas que parecen iguales y no lo son:
- 0 = «medí y dio cero». Un dato.
- texto vacío = alguien escribió nada. Sigue siendo texto.
- NaN = «no sé». No hay dato.
Y lo más raro: dos «no sé» no son iguales entre sí. Si te
pregunto cuánto miden dos personas que no conocés, no podés decirme que miden lo
mismo. Por eso preguntar «¿este valor es igual a NaN?» siempre da que no, y hay
que usar .isna(), que pregunta «¿acá falta el dato?».
El ejemplo mínimo
import numpy as np
import pandas as pd
notas = pd.Series([7.5, np.nan, 9.0, np.nan], name="nota")
print(notas)
print()
print("NaN == NaN ->", np.nan == np.nan)
print("encontrados con == :", (notas == np.nan).sum())
print("encontrados con .isna() :", notas.isna().sum())
print()
print("La media ignora los ausentes en silencio:")
print(" suma =", notas.sum())
print(" media =", notas.mean(), " (suma / 2, no / 4)")
0 7.5
1 NaN
2 9.0
3 NaN
Name: nota, dtype: float64
NaN == NaN -> False
encontrados con == : 0
encontrados con .isna() : 2
La media ignora los ausentes en silencio:
suma = 16.5
media = 8.25 (suma / 2, no / 4)
.sum() y .mean() ignoran los ausentes sin avisar. La
media dio 8.25, que es 16.5 / 2 y no
16.5 / 4. No está mal: es la media de lo que sabemos. Pero si vos
creías que era la media de las cuatro notas, tenés un número equivocado en las
manos y ninguna señal de que lo esté.
Sobre datos de verdad
Vamos a trabajar con un archivo de clientes que ya viene sucio a propósito:
clientes_crudos.csv . Tiene 120 filas y la columna que nos interesa es
edad.
Primer intento: leer y confiar
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv")
print(clientes[["id", "nombre", "edad"]].head(4))
print()
print("dtype de edad :", clientes["edad"].dtype)
print("ausentes detectados:", clientes["edad"].isna().sum())
sospechosos = sorted({v for v in clientes["edad"].dropna().unique()
if not v.lstrip("-").isdigit()})
print("valores no numericos:", sospechosos)
print()
try:
print(clientes["edad"].mean())
except TypeError as error:
print("clientes['edad'].mean() ->", type(error).__name__)
print(" ", error)
id nombre edad
0 C0001 José Paz 62
1 C0002 Lucía Peña 48
2 C0003 Santiago Herrera 40
3 C0004 José Rodríguez 67
dtype de edad : str
ausentes detectados: 3
valores no numericos: ['-', 'treinta']
clientes['edad'].mean() -> TypeError
Cannot perform reduction 'mean' with string dtype
Dos cosas para mirar acá. La primera: el dtype es str,
no un número. La segunda: isna() encontró 3 ausentes.
Esa cifra es la que vas a repetir en tu informe si no seguís mirando, y es
falsa.
Vas a ver por todos lados que una columna de texto tiene dtype
object. Desde pandas 3.0 el tipo por defecto del texto es
str. Es el mismo concepto y no cambia nada de lo que hacemos acá,
pero conviene que no te desconcierte la diferencia.
Segundo intento: declarar los tokens que faltan
Cuando read_csv lee una celda, la compara contra una lista fija de
cadenas que considera «ausente». En esa lista están NaN,
N/A, null, NA y la celda vacía, entre otras.
El guion no está. Y este archivo usa el guion como marca de dato
faltante.
import pandas as pd
por_defecto = pd.read_csv("clientes_crudos.csv")
declarado = pd.read_csv("clientes_crudos.csv", na_values=["-"])
print("sin na_values :", por_defecto["edad"].isna().sum(), "ausentes")
print("con na_values :", declarado["edad"].isna().sum(), "ausentes")
print(" el guion aparecia",
(por_defecto["edad"] == "-").sum(), "vez y pasaba como dato valido")
print()
print("dtype de edad :", declarado["edad"].dtype)
print("Sigue siendo texto: adentro de la columna todavia queda 'treinta'.")
print("Declarar los ausentes no alcanza para volver numerica una columna.")
sin na_values : 3 ausentes
con na_values : 4 ausentes
el guion aparecia 1 vez y pasaba como dato valido
dtype de edad : str
Sigue siendo texto: adentro de la columna todavia queda 'treinta'.
Declarar los ausentes no alcanza para volver numerica una columna.
Un ausente más. Parece poco; es una fila que hasta recién estaba viajando por
todo tu análisis disfrazada de dato válido. El punto general: los tokens de
ausente son una convención de quien exportó el archivo, no una ley. Antes
de confiar en isna(), mirá qué cadenas raras aparecen en la columna.
ELI5Explicado facil
pandas tiene una lista de palabras que reconoce como «acá no hay nada»:
NaN, N/A, null y el casillero vacío.
Si tu archivo usa otra cosa para decir lo mismo -como un guion- pandas no se da
cuenta y lo trata como si fuera un dato normal.
Es como si en la lista de asistencia alguien hubiera puesto una rayita en vez
de dejar el casillero vacío. Vos entendés que significa «no sé», pero la
computadora no, salvo que se lo digas: eso es na_values=["-"].
Tercer intento: convertir a número
La columna sigue siendo texto porque adentro quedó "treinta". Y
mientras sea texto, no podés hacerle ninguna cuenta. pd.to_numeric con
errors="coerce" convierte lo que puede y manda a NaN lo
que no.
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv", na_values=["-"])
edad = pd.to_numeric(clientes["edad"], errors="coerce")
print("dtype :", edad.dtype)
print("ausentes :", edad.isna().sum(), "de", len(edad))
print("minimo :", edad.min(), " maximo:", edad.max())
print()
validas = edad[edad.between(18, 100)]
print("dentro del dominio 18-100 :", len(validas))
print("fuera de rango :", int(((edad < 18) | (edad > 100)).sum()))
print("mediana de las validas :", validas.median())
print()
print("Los 3 ausentes nuevos son los 'treinta': errors='coerce' los convirtio")
print("en NaN. Sin coerce, to_numeric habria cortado con ValueError.")
dtype : float64
ausentes : 7 de 120
minimo : -5.0 maximo: 150.0
dentro del dominio 18-100 : 95
fuera de rango : 18
mediana de las validas : 46.0
Los 3 ausentes nuevos son los 'treinta': errors='coerce' los convirtio
en NaN. Sin coerce, to_numeric habria cortado con ValueError.
Recién ahora el número es real: 7 ausentes de 120, no 3. La diferencia entre 3 y 7 no es un detalle; es más del doble, y aparece únicamente si convertís antes de contar.
Primero declarás los tokens de ausente (na_values), después
convertís el tipo (to_numeric), y recién ahí contás
(isna()). Contar antes de convertir es el error más común de esta
unidad, y no da ningún síntoma: te devuelve un número, simplemente es el número
equivocado.
ELI5Explicado facil
Si en una columna de edades hay un valor que dice treinta escrito
con letras, la computadora no puede sumar nada: mientras haya una sola palabra
metida ahí, trata a toda la columna como texto.
to_numeric con errors="coerce" significa:
«convertí a número lo que puedas, y lo que no se pueda dejalo como hueco». Después
de eso la columna sí es numérica y recién ahí el conteo de huecos es el de verdad.
El orden es siempre el mismo: avisar cuáles son los huecos → convertir a número → contar. Contar primero da un número que parece bien y está mal.
Lo mismo, paso a paso
Seguí seis celdas reales del archivo mientras pasan por las tres etapas. Cada paso dice qué acaba de cambiar y por qué.
- El archivo es texto. Todas las celdas de la columna edad son cadenas, incluso las que parecen numeros.
clientes = pd.read_csv("clientes_crudos.csv") - read_csv reconoce solos algunos tokens de ausente: NaN, N/A, null, NA y la celda vacia. Esos pasan a NaN sin que le pidas nada.
clientes = pd.read_csv("clientes_crudos.csv") # N/A y null estan en la lista por defecto de pandas - Pero el guion NO esta en esa lista. Queda como un dato valido mas, y nadie avisa nada.
clientes["edad"].isna().sum() # 3, y hay 7 - Con na_values lo declaramos nosotros, y recien ahi el guion se vuelve ausente.
clientes = pd.read_csv("clientes_crudos.csv", na_values=["-"]) - La columna sigue siendo texto: mientras quede una cadena como treinta adentro, pandas no puede hacerla numerica.
clientes["edad"].dtype # str, todavia no se puede promediar - to_numeric convierte lo que puede y, con errors="coerce", manda a NaN lo que no. Sin coerce cortaria con ValueError en treinta.
edad = pd.to_numeric(clientes["edad"], errors="coerce") - Ahora si se puede contar: 4 ausentes de 6 en esta muestra. Sobre el archivo entero son 7 de 120, no los 3 que parecian al principio.
edad.isna().sum() # 7 sobre las 120 filas
Dónde se rompe
Causa: estás sacando el promedio de una columna que pandas todavía ve como texto, casi siempre porque quedó adentro un token raro.
Arreglo: pd.to_numeric(col, errors="coerce") antes de
cualquier cuenta. Si querés saber qué la ensucia, mirá los valores únicos que no
son dígitos.
Causa: to_numeric sin errors="coerce" corta
en el primer valor que no puede convertir.
Arreglo: este error es útil. Te está diciendo exactamente qué
valor y en qué posición. Corré primero sin coerce para enterarte de
qué hay, y recién después poné coerce a propósito, sabiendo qué
estás mandando a NaN.
Causa: astype(int) sobre una columna que tiene ausentes.
El tipo int de NumPy no tiene forma de representar «no sé».
Arreglo: decidí primero qué hacés con los ausentes. Si querés enteros
conservando los huecos, usá astype("Int64") con I mayúscula, que sí
los admite.
Causa: los ausentes no se comparan con ==. Ni con
None, ni con np.nan, ni con "NaN".
Arreglo: df[df.edad.isna()]. Es el único camino, y no
falla nunca. Este es el peor de los cuatro casos porque no se queja: te devuelve
una tabla vacía y te deja creer que no hay ausentes.
Causa: sin subset, dropna() elimina toda fila
que tenga un ausente en cualquier columna. En este archivo eso deja 112
filas; filtrando solo por edad quedan 116.
Arreglo: decí siempre qué columnas te importan:
dropna(subset=["edad"]).
Acá hay que elegir
Ya sabés cuántos ausentes hay. Ahora hay que decidir, y no existe una respuesta correcta universal: existe una decisión que tenés que tomar y dejar por escrito. Son tres.
ELI5Explicado facil
Te falta la edad de siete personas. Tenés tres caminos, y ninguno es «el correcto»:
- Descartar: borrás esas siete fichas. Simple, pero también perdés el nombre, la ciudad y todo lo demás que sí tenían.
- Imputar: escribís un valor inventado, normalmente el del medio. Ojo: estás inventando datos. Anotá cuáles inventaste.
- Conservar: dejás el hueco como está. Python ya sabe saltearlos cuando saca promedios.
Lo importante no es cuál elegís, sino que lo elijas a propósito y lo escribas.
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv", na_values=["-"])
clientes["edad"] = pd.to_numeric(clientes["edad"], errors="coerce")
mediana = clientes.loc[clientes["edad"].between(18, 100), "edad"].median()
descartar = clientes.dropna(subset=["edad"])
imputar = clientes.assign(edad=clientes["edad"].fillna(mediana))
conservar = clientes.assign(edad_ausente=clientes["edad"].isna().astype("int64"))
print(f"{'politica':<11}{'filas':>7}{'media':>9} {'que pasa con el ausente'}")
for nombre, tabla, nota in (
("descartar", descartar, "se pierde la fila entera"),
("imputar", imputar, f"pasa a valer {mediana:.0f} y se vuelve invisible"),
("conservar", conservar, "queda, marcado con una bandera"),
):
print(f"{nombre:<11}{len(tabla):>7}{tabla['edad'].mean():>9.2f} {nota}")
print()
print("Descartar y conservar dan la MISMA media: .mean() ya salteaba los NaN.")
print("Imputar es la unica que la mueve, porque inventa 7 valores.")
print()
print("Ojo: esa media sigue contaminada por las edades imposibles (-5, 150).")
print("Eso no es un problema de ausentes sino de rango, y se trata en 5.6.")
politica filas media que pasa con el ausente
descartar 113 46.50 se pierde la fila entera
imputar 120 46.47 pasa a valer 46 y se vuelve invisible
conservar 120 46.50 queda, marcado con una bandera
Descartar y conservar dan la MISMA media: .mean() ya salteaba los NaN.
Imputar es la unica que la mueve, porque inventa 7 valores.
Ojo: esa media sigue contaminada por las edades imposibles (-5, 150).
Eso no es un problema de ausentes sino de rango, y se trata en 5.6.
- Descartar cuando la fila sin ese dato no sirve para nada de lo que vas a hacer. Es honesto y simple, pero perdés todo lo demás que esa fila sí tenía.
- Imputar cuando necesitás sí o sí un valor en cada fila (por ejemplo, para un modelo que no tolera huecos). Estás inventando datos: dejá siempre una columna que diga cuáles inventaste.
- Conservar cuando estás analizando, que es casi siempre.
pandas ya sabe saltear
NaN; no necesitás rellenarlo para calcular una media.
La regla que salva: si imputás, que se note. Una columna
edad_imputada con 0 y 1 cuesta una línea y evita que dentro de tres
meses alguien lea 46 años y crea que eso lo dijo el cliente.
Si imputás: ¿con qué?
Decidir imputar es solo la mitad de la decisión. Falta elegir el valor, y ahí hay
cuatro candidatos habituales. Tres son variantes de fillna y el cuarto
es otra función.
| Relleno | Qué escribe en el hueco | Cuándo tiene sentido |
|---|---|---|
fillna(mediana) |
El valor del medio de la columna | La opción por defecto: no la mueven los valores extremos |
fillna(media) |
El promedio de la columna | Solo si no hay atípicos, porque el promedio sí se deja arrastrar |
fillna(0) |
Un cero | Cuando el hueco de verdad significa cero, como «ventas: ninguna» |
ffill() |
El último valor no ausente que apareció más arriba | Series de tiempo ordenadas, donde el dato «sigue vigente» |
ffill quiere decir forward fill, «rellenar hacia adelante»
Recorre la columna de arriba abajo y, cada vez que encuentra un hueco, copia el
último valor que vio. Si la columna es [10, NaN, NaN, 40], queda
[10, 10, 10, 40]: el 10 se arrastra hacia abajo hasta que aparece otro
dato. Existe también bfill (backward fill), que hace lo mismo
mirando hacia arriba.
Dos cosas que sorprenden la primera vez: si el primer valor de la columna es un ausente, se queda ausente, porque no hay nada anterior para copiar; y el resultado depende del orden de las filas, así que ordenar la tabla distinto cambia los datos que inventaste.
ffill tiene sentido cuando la fila de arriba y la de abajo están
relacionadas: mediciones de un sensor cada hora, la cotización de una moneda día por
día, el stock de un producto a lo largo del tiempo. En una tabla de clientes
no lo tiene: la edad del cliente C0028 no dice nada sobre la del
C0029. Está en la lista de abajo justamente para que veas que produce un resultado
-no falla, no avisa- y que aun así es la respuesta equivocada.
ELI5Explicado facil
ffill es «copiá el de arriba». Recorre la columna de arriba abajo y,
cada vez que encuentra un hueco, pone el último valor que vio.
Sirve cuando las filas están en orden de tiempo y el dato «sigue valiendo»: si el lunes la temperatura fue 20 grados y el martes no se midió, copiar 20 es razonable.
No sirve en una lista de clientes: copiar la edad del cliente anterior es como decir que si no sabés la edad de tu compañero de banco, le ponés la tuya. Python lo hace igual, sin quejarse. Ese es el peligro.
Probá los cuatro y mirá dos cosas: qué valor termina en cada hueco, y qué le pasa al desvío estándar de la columna entera.
import pandas as pd
try:
RELLENO
except NameError:
RELLENO = "mediana"
clientes = pd.read_csv("clientes_crudos.csv", na_values=["-"])
edad = pd.to_numeric(clientes["edad"], errors="coerce")
faltan = edad.isna()
# El texto tal cual venia en el archivo, para ver que token era cada ausente.
crudo = pd.read_csv("clientes_crudos.csv", dtype=str, keep_default_na=False)
validas = edad[edad.between(18, 100)]
if RELLENO == "mediana":
rellenada = edad.fillna(validas.median())
elif RELLENO == "media":
rellenada = edad.fillna(round(validas.mean(), 1))
elif RELLENO == "cero":
rellenada = edad.fillna(0)
elif RELLENO == "ffill":
rellenada = edad.ffill()
else:
raise ValueError(f"relleno desconocido: {RELLENO}")
print(f"RELLENO = {RELLENO!r} ({faltan.sum()} valores imputados de {len(edad)})")
print()
comparacion = pd.DataFrame({
"id": clientes.loc[faltan, "id"],
"en_el_archivo": crudo.loc[faltan, "edad"].replace("", "(vacio)"),
"edad_imputada": rellenada[faltan],
})
print(comparacion.to_string(index=False))
print()
print(f"media antes : {edad.mean():.2f}")
print(f"media despues: {rellenada.mean():.2f}")
print(f"desvio antes : {edad.std():.2f}")
print(f"desvio despues: {rellenada.std():.2f}")RELLENO = "mediana"
RELLENO = 'mediana' (7 valores imputados de 120)
id en_el_archivo edad_imputada
C0029 N/A 46.0
C0031 treinta 46.0
C0058 (vacio) 46.0
C0062 treinta 46.0
C0087 - 46.0
C0093 treinta 46.0
C0116 null 46.0
media antes : 46.50
media despues: 46.47
desvio antes : 28.23
desvio despues: 27.39
RELLENO = "media"
RELLENO = 'media' (7 valores imputados de 120)
id en_el_archivo edad_imputada
C0029 N/A 46.3
C0031 treinta 46.3
C0058 (vacio) 46.3
C0062 treinta 46.3
C0087 - 46.3
C0093 treinta 46.3
C0116 null 46.3
media antes : 46.50
media despues: 46.48
desvio antes : 28.23
desvio despues: 27.39
RELLENO = "cero"
RELLENO = 'cero' (7 valores imputados de 120)
id en_el_archivo edad_imputada
C0029 N/A 0.0
C0031 treinta 0.0
C0058 (vacio) 0.0
C0062 treinta 0.0
C0087 - 0.0
C0093 treinta 0.0
C0116 null 0.0
media antes : 46.50
media despues: 43.78
desvio antes : 28.23
desvio despues: 29.50
RELLENO = "ffill"
RELLENO = 'ffill' (7 valores imputados de 120)
id en_el_archivo edad_imputada
C0029 N/A 44.0
C0031 treinta 51.0
C0058 (vacio) 128.0
C0062 treinta 65.0
C0087 - 36.0
C0093 treinta -5.0
C0116 null -5.0
media antes : 46.50
media despues: 46.40
desvio antes : 28.23
desvio despues: 29.23
Ninguno de los cuatro es neutro. Con la mediana el desvío baja de 28,2 a 27,4: metiste siete veces el mismo valor justo en el centro y la columna quedó artificialmente más concentrada de lo que era en realidad. La media da casi lo mismo acá solo porque en este archivo el promedio (46,5) y la mediana (46,0) casi coinciden; con una columna torcida por atípicos se separarían mucho. Con cero es peor: además de bajar la media, agregás siete edades imposibles que la lección 5.6 va a marcar como fuera de rango.
ffill
Cada hueco copió la edad del cliente de la fila de arriba, que no tiene ninguna
relación con él. Pero además pasó algo peor: como copia el último valor visto
sin fijarse si es razonable, arrastró un 128 y dos veces
un -5. Es decir que no solo inventó datos: propagó los errores
que la columna ya tenía, y ahora hay tres filas más con edades imposibles
que antes estaban limpias.
Nada falló, nada avisó, y el archivo resultante tiene más basura que el original. Este es el argumento entero de la lección en un solo ejemplo: la función corrió bien y el resultado está mal.
Recetario
| Expresión | Qué devuelve | Cuándo |
|---|---|---|
read_csv(f, na_values=["-"]) | DataFrame | Cuando el archivo usa tokens propios de ausente |
pd.to_numeric(s, errors="coerce") | Series numérica | Siempre, antes de contar o calcular |
s.isna() | Series de bool | Para encontrar los ausentes |
s.isna().sum() | Entero | Para contarlos |
s.notna() | Series de bool | Lo contrario; útil como máscara |
df.isna().sum() | Series por columna | Diagnóstico rápido de toda la tabla |
s.fillna(v) | Series | Imputar con un valor fijo |
s.fillna(s.median()) | Series | Imputar con la mediana |
s.ffill() | Series | forward fill: copia el último valor visto hacia abajo. Solo en datos ordenados en el tiempo |
s.bfill() | Series | Lo mismo pero hacia arriba: copia el siguiente valor |
df.dropna(subset=["c"]) | DataFrame | Descartar por una columna concreta |
s.astype("Int64") | Series entera nullable | Enteros que admiten ausentes |