Indice / NumPy / 3.6
Estadística y NaN
Cuatro celdas vacías en 240 alcanzan para que
np.mean devuelva nan. La familia nan* lo
arregla - y al hacerlo toma una decisión que conviene mirar antes de aceptarla.
Para qué sirve
Es la lección que convierte «tengo un array» en «puedo decir algo sobre estos
datos». Y trae la trampa que más veces aparece en el corpus: una reducción que devuelve
nan, o peor, una que devuelve un número usando datos que no debería
usar.
ELI5Explicado facil
NaN se comporta como una mancha de tinta: cualquier
cosa que lo toque queda manchada. Sumale 1 y da nan; multiplicalo por 0 y
da nan.
Por eso una sola celda vacía tiñe el promedio de las 240. Las funciones
nan* son las que saben mirar alrededor de la mancha.
El contagio
import numpy as np
lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
print("lecturas:", lecturas.shape, "| NaN:", int(np.isnan(lecturas).sum()),
"de", lecturas.size)
print()
print("Las reducciones normales devuelven nan si tocan UN solo ausente:")
for nombre in ("sum", "mean", "min", "max", "std"):
valor = getattr(np, nombre)(lecturas)
print(f" np.{nombre:<5}(lecturas) -> {valor}")
print()
print("Y la familia nan* los ignora:")
for nombre in ("nansum", "nanmean", "nanmin", "nanmax", "nanstd"):
valor = getattr(np, nombre)(lecturas)
print(f" np.{nombre:<8}(lecturas) -> {round(float(valor), 2)}")
print()
print("Por fila se ve mejor cuales estan afectadas:")
normal = np.mean(lecturas, axis=1)
ignorando = np.nanmean(lecturas, axis=1)
print(f" {'sensor':<8} {'np.mean':>10} {'np.nanmean':>12}")
for i in range(10):
print(f" S{i + 1:02d} {normal[i]:>10.2f} {ignorando[i]:>12.2f}")
print(" Tres filas en nan, que son las tres con un hueco: S03, S06, S09.")
print()
print("El motivo: nan se propaga por cualquier operacion aritmetica.")
print(" 1 + np.nan ->", 1 + np.nan)
print(" 0 * np.nan ->", 0 * np.nan)
print(" np.nan ** 0 ->", np.nan ** 0, " <- la unica excepcion")
print(" max(1, np.nan) -> depende de la implementacion; por eso nanmax")
print()
print("Y el detalle que rompe las comparaciones ingenuas:")
print(" np.nan == np.nan ->", np.nan == np.nan)
print(" Un nan NO es igual a si mismo. Para detectarlo hay dos formas:")
print(" np.isnan(x) ->", np.isnan(np.nan))
print(" x != x ->", np.nan != np.nan, " <- funciona en Python puro")
print()
print("Lo que hay que decidir, y no lo decide nan*:")
print(" nanmean IGNORA el hueco, no lo rellena. El denominador cambia.")
s03 = lecturas[2]
print(" S03 tiene", int(np.isnan(s03).sum()), "huecos:")
print(" np.nanmean(s03) ->", round(float(np.nanmean(s03)), 3),
"(sobre", int((~np.isnan(s03)).sum()), "valores)")
print(" np.nansum(s03) / 24 ->", round(float(np.nansum(s03) / 24), 3),
"(tratando el hueco como 0)")
print(" Los dos numeros son correctos y contestan preguntas distintas.")
print(" 'temperatura media medida' contra 'temperatura media del dia,")
print(" contando las horas sin medicion como cero'. La segunda casi")
print(" nunca es lo que se quiere, pero hay que decirlo.")
lecturas: (10, 24) | NaN: 4 de 240
Las reducciones normales devuelven nan si tocan UN solo ausente:
np.sum (lecturas) -> nan
np.mean (lecturas) -> nan
np.min (lecturas) -> nan
np.max (lecturas) -> nan
np.std (lecturas) -> nan
Y la familia nan* los ignora:
np.nansum (lecturas) -> 5879.5
np.nanmean (lecturas) -> 24.91
np.nanmin (lecturas) -> 14.2
np.nanmax (lecturas) -> 999.9
np.nanstd (lecturas) -> 63.65
Por fila se ve mejor cuales estan afectadas:
sensor np.mean np.nanmean
S01 21.73 21.73
S02 19.62 19.62
S03 nan 18.75
S04 21.63 21.63
S05 60.65 60.65
S06 nan 21.08
S07 24.55 24.55
S08 20.04 20.04
S09 nan 22.78
S10 17.53 17.53
Tres filas en nan, que son las tres con un hueco: S03, S06, S09.
El motivo: nan se propaga por cualquier operacion aritmetica.
1 + np.nan -> nan
0 * np.nan -> nan
np.nan ** 0 -> 1.0 <- la unica excepcion
max(1, np.nan) -> depende de la implementacion; por eso nanmax
Y el detalle que rompe las comparaciones ingenuas:
np.nan == np.nan -> False
Un nan NO es igual a si mismo. Para detectarlo hay dos formas:
np.isnan(x) -> True
x != x -> True <- funciona en Python puro
Lo que hay que decidir, y no lo decide nan*:
nanmean IGNORA el hueco, no lo rellena. El denominador cambia.
S03 tiene 2 huecos:
np.nanmean(s03) -> 18.745 (sobre 22 valores)
np.nansum(s03) / 24 -> 17.183 (tratando el hueco como 0)
Los dos numeros son correctos y contestan preguntas distintas.
'temperatura media medida' contra 'temperatura media del dia,
contando las horas sin medicion como cero'. La segunda casi
nunca es lo que se quiere, pero hay que decirlo.
| Reducción | Devuelve | Con nan* |
|---|---|---|
np.sum | nan | np.nansum → 5879.5 |
np.mean | nan | np.nanmean → 24.91 |
np.min / np.max | nan | np.nanmin → 14.2 / np.nanmax → 999.9 |
np.std | nan | np.nanstd → 63.65 |
Con axis=1 se ve mejor: np.mean devuelve nan
para tres de los diez sensores - S03, S06 y S09, que son
exactamente los que tienen un hueco. Los otros siete están perfectos.
nan no es igual a sí mismo
np.nan == np.nan es False. Por eso
if x == np.nan nunca funciona, y por eso existen dos formas de
detectarlo: np.isnan(x), o el modismo x != x, que también
sirve en Python puro.
La única operación aritmética que no propaga es np.nan ** 0,
que da 1.0. Es una curiosidad del estándar, no algo en lo que apoyarse.
Lo que nanmean decide por vos
nanmean ignora el hueco: no lo rellena. Y eso cambia
el denominador, que es una decisión con nombre.
El sensor S03 tiene 2 huecos en sus 24 horas:
| Expresión | Da | Qué está contestando |
|---|---|---|
np.nanmean(s03) | 18,745 | Temperatura media medida, sobre 22 horas |
np.nansum(s03) / 24 | 17,183 | Media del día, contando las horas sin medición como cero |
Los dos números son correctos y responden preguntas distintas. La segunda casi nunca es la que se quiere - una hora sin sensor no es una hora a 0 °C - pero hay que decir cuál se usó. Es la misma decisión de la lección 1.5: descartar, imputar o informar, ahora con la aritmética a la vista.
Media, mediana y percentiles
import csv
import numpy as np
NULOS = {"", "-", "N/A", "NaN", "null"}
# Se lee a mano para poder convertir con la politica del curso: los cinco
# tokens de ausente van a NaN, y lo que no es numero, tambien.
with open("estudiantes_notas.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
nota_1 = np.array([a_numero(x["nota_1"]) for x in filas])
nota_3 = np.array([a_numero(x["nota_3"]) for x in filas])
print("estudiantes:", nota_1.size)
print("nota_1 ausentes:", int(np.isnan(nota_1).sum()))
print("nota_3 ausentes:", int(np.isnan(nota_3).sum()),
" <- los 4 tokens raros mas el 'NaN'")
print()
print(f"{'medida':<22} {'nota_1':>9} {'nota_3':>9}")
print("-" * 42)
for nombre, funcion in (("n validos", lambda a: (~np.isnan(a)).sum()),
("minimo", np.nanmin),
("percentil 25", lambda a: np.nanpercentile(a, 25)),
("mediana", np.nanmedian),
("percentil 75", lambda a: np.nanpercentile(a, 75)),
("maximo", np.nanmax),
("media", np.nanmean),
("desvio", np.nanstd)):
print(f"{nombre:<22} {float(funcion(nota_1)):>9.2f} "
f"{float(funcion(nota_3)):>9.2f}")
print()
print("Mirando los extremos aparecen los defectos: el maximo de nota_1 es")
print("12.5 y el minimo de nota_3 es -1. Ninguna de las dos es una nota")
print("posible. nanmin y nanmax NO las filtran: solo ignoran los ausentes.")
fuera_1 = nota_1[(nota_1 < 0) | (nota_1 > 10)]
fuera_3 = nota_3[(nota_3 < 0) | (nota_3 > 10)]
print(" nota_1 fuera de rango:", fuera_1)
print(" nota_3 fuera de rango:", fuera_3)
print()
print("Con esas fuera, los numeros cambian:")
validas_1 = nota_1[(nota_1 >= 0) & (nota_1 <= 10)]
print(" media de nota_1, todo :", round(float(np.nanmean(nota_1)), 3))
print(" media de nota_1, en rango:", round(float(validas_1.mean()), 3))
print(" diferencia :",
round(float(np.nanmean(nota_1) - validas_1.mean()), 3))
print()
print("Media contra mediana, y cuando usar cual:")
print(" La media usa TODOS los valores: un extremo la mueve.")
print(" La mediana usa el del medio: un extremo no la mueve.")
print(" nota_1 media", round(float(np.nanmean(nota_1)), 2),
"| mediana", round(float(np.nanmedian(nota_1)), 2))
print(" Cuando las dos se parecen, la distribucion es mas o menos")
print(" simetrica. Cuando difieren mucho, hay cola o hay atipico, y")
print(" conviene mirar antes de publicar la media.")
print()
print("Los percentiles dicen la forma sin necesidad de graficar:")
for p in (0, 10, 25, 50, 75, 90, 100):
print(f" p{p:<3} {float(np.nanpercentile(nota_1, p)):>6.2f}")
print(" p25 a p75 es el rango intercuartil, y es la base del criterio")
print(" de atipicos de la leccion 3.7.")
estudiantes: 40
nota_1 ausentes: 0
nota_3 ausentes: 5 <- los 4 tokens raros mas el 'NaN'
medida nota_1 nota_3
------------------------------------------
n validos 40.00 35.00
minimo 3.00 -1.00
percentil 25 4.70 4.05
mediana 6.25 6.00
percentil 75 8.10 7.80
maximo 12.50 9.90
media 6.59 6.02
desvio 2.22 2.44
Mirando los extremos aparecen los defectos: el maximo de nota_1 es
12.5 y el minimo de nota_3 es -1. Ninguna de las dos es una nota
posible. nanmin y nanmax NO las filtran: solo ignoran los ausentes.
nota_1 fuera de rango: [12.5 10.5]
nota_3 fuera de rango: [-1.]
Con esas fuera, los numeros cambian:
media de nota_1, todo : 6.588
media de nota_1, en rango: 6.329
diferencia : 0.259
Media contra mediana, y cuando usar cual:
La media usa TODOS los valores: un extremo la mueve.
La mediana usa el del medio: un extremo no la mueve.
nota_1 media 6.59 | mediana 6.25
Cuando las dos se parecen, la distribucion es mas o menos
simetrica. Cuando difieren mucho, hay cola o hay atipico, y
conviene mirar antes de publicar la media.
Los percentiles dicen la forma sin necesidad de graficar:
p0 3.00
p10 3.79
p25 4.70
p50 6.25
p75 8.10
p90 9.52
p100 12.50
p25 a p75 es el rango intercuartil, y es la base del criterio
de atipicos de la leccion 3.7.
| Medida | nota_1 | nota_3 |
|---|---|---|
| Válidos | 40 | 35 - 5 ausentes |
| Mínimo | 3,00 | −1,00 |
| Mediana | 6,25 | 6,00 |
| Máximo | 12,50 | 9,90 |
| Media | 6,59 | 6,02 |
| Desvío | 2,22 | 2,44 |
nanmin y nanmax ignoran los ausentes, no los imposibles
Un máximo de 12,50 y un mínimo de −1 en una escala de 0 a 10. Ninguna de las dos funciones tiene forma de saber que eso no es una nota: el rango lo fija el reglamento de la materia, no los datos.
Mirar el mínimo y el máximo es el diagnóstico más barato que existe, y detecta esta
clase de defecto antes de que llegue al promedio. Acá cuesta 0,26 puntos en la media de
nota_1.
La media usa todos los valores: un extremo la mueve. La mediana usa el del medio: un extremo no la mueve.
Cuando las dos se parecen (6,59 y 6,25), la distribución es más o menos simétrica.
Cuando difieren mucho - como en transacciones_categorias, con media
36.711 y mediana 1.937 - hay cola o hay atípico, y conviene mirar antes de
publicar la media.
Los percentiles describen la forma sin necesidad de graficar: el
p25 y el p75 de nota_1 son 4,70 y 8,10, así que
la mitad central del curso está entre esas dos notas. Esa distancia es el
rango intercuartil, y es la base del criterio de atípicos de la lección 3.7.
Dónde se rompe
nan
Causa: hay al menos un NaN en el eje que se está
reduciendo.
Arreglo: np.nanmean. Y antes,
np.isnan(a).sum() para saber cuántos son: si son muchos, ignorarlos
no es una decisión menor.
Causa: nanmean sobre una fila que es toda
NaN: no queda nada que promediar.
Arreglo: contar los válidos por fila primero
((~np.isnan(a)).sum(axis=1)) y decidir qué hacer con las que dan 0.
if x == np.nan nunca entra
Causa: nan no es igual a nada, ni siquiera a sí mismo.
Arreglo: np.isnan(x), o x != x.
Causa: nanmax ignora ausentes, no imposibles.
Arreglo: filtrar por rango antes:
a[(a >= min) & (a <= max)], o
np.where(a > max, np.nan, a) para marcarlo sin perder la
posición.
inf
Causa: todos los valores de esa fila son iguales.
Arreglo: es un caso real (un sensor trabado). Detectarlo con
desvios == 0 antes de dividir, y decidir qué informar.
Recetario
| Expresión | Qué devuelve |
|---|---|
np.nanmean(a) / np.nanmedian(a) | Centro, ignorando ausentes |
np.nanstd(a) | Dispersión |
np.nanmin(a) / np.nanmax(a) | Extremos - el diagnóstico más barato |
np.nanpercentile(a, 25) | El valor que deja el 25 % por debajo |
np.nanpercentile(a, [25, 50, 75]) | Los tres de una vez |
np.isnan(a).sum() | Cuántos ausentes |
(~np.isnan(a)).sum(axis=1) | Cuántos válidos por fila |
np.nanmean(a, axis=1) | Un promedio por fila |
np.nan_to_num(a, nan=0.0) | Reemplaza los NaN - decisión explícita |
np.count_nonzero(~np.isnan(a)) | Lo mismo que la suma de la máscara |
a[~np.isnan(a)] | Solo los válidos, aplanados |