Saltar al contenido

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

EjemploUn solo NaN contamina la reduccion entera
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.
Sobre la grilla de sensores, con 4 NaN en 240 celdas
ReducciónDevuelveCon nan*
np.sumnannp.nansum → 5879.5
np.meannannp.nanmean → 24.91
np.min / np.maxnannp.nanmin → 14.2 / np.nanmax → 999.9
np.stdnannp.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ónDaQué está contestando
np.nanmean(s03)18,745Temperatura media medida, sobre 22 horas
np.nansum(s03) / 2417,183Media 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

EjemploMedia, mediana y percentiles sobre datos reales
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.
Las dos columnas de notas, con la política de nulos del curso
Medidanota_1nota_3
Válidos4035 - 5 ausentes
Mínimo3,00−1,00
Mediana6,256,00
Máximo12,509,90
Media6,596,02
Desvío2,222,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.

Media contra mediana

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

(sin error) el promedio da 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.

RuntimeWarning: Mean of empty slice

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.

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

(sin error) el máximo es 999.9

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.

(sin error) el desvío da 0 y el z-score da 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

Estadística descriptiva
ExpresiónQué 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

Ponete a prueba

1. Una grilla de 240 lecturas tiene 4 celdas vacías. ¿Qué devuelve np.mean(lecturas)?Nivel 1 · Recordar
2. ¿Por qué if x == np.nan nunca entra?Nivel 1 · Recordar
3. El sensor S03 tiene 2 huecos en 24 horas. np.nanmean(s03) da 18,745 y np.nansum(s03) / 24 da 17,183. ¿Cuál está mal?Nivel 2 · Predecir la salida
4. np.nanmax(nota_1) devuelve 12.5 en una escala de 0 a 10. ¿Qué falló?Nivel 2 · Predecir la salida
5. Te piden «la nota típica del curso» sobre una columna con media 6,59, mediana 6,25 y un máximo de 12,5. ¿Qué hacés?Nivel 3 · Elegir entre dos casi correctas
6. ¿Qué significa que la media de una columna sea 36.711 y su mediana 1.937?Nivel 3 · Elegir entre dos casi correctas
7. Este código calcula el promedio por sensor y devuelve nan para tres de los diez, con un RuntimeWarning: Mean of empty slice para uno más. ¿Qué pasa?
validas = np.where(lecturas > 50, np.nan, lecturas)
promedios = np.mean(validas, axis=1)
Nivel 4 · Encontrar el defecto

Prácticas de esta subunidad