Saltar al contenido

Indice / Visualizacion con Matplotlib / 8.7

Bandas y anomalías: la variabilidad en el gráfico

Una línea de promedios dice dónde está el centro. La banda dice cuánto se aparta lo que hay alrededor, y es lo que convierte un gráfico bonito en uno que sirve para decidir.

Para qué sirve

Un promedio por hora, por semana o por sucursal es una línea limpia y tranquilizadora que esconde exactamente el dato que hace falta para operar: qué tan distinto puede salir el próximo caso. Una entrega que tarda 30 minutos en promedio con un desvío de 2 y otra con un desvío de 20 son dos negocios distintos, y la línea de promedios es idéntica.

Y de la misma medida sale lo segundo: si sabés cuánto se aparta lo normal, podés marcar lo que se aparta demasiado. Esa es la parte de anomalías.

El ejemplo mínimo

EjemploMedia y banda de mas menos un desvio con fill_between
import pandas as pd
import matplotlib.pyplot as plt

pedidos = pd.DataFrame({
    "semana": [1] * 7 + [2] * 7 + [3] * 7 + [4] * 7 + [5] * 7 + [6] * 7,
    "pedidos": [98, 102, 101, 99, 103, 100, 97, 104, 108, 105, 110, 107, 106,
                109, 111, 115, 117, 113, 116, 114, 118, 120, 123, 119, 126,
                124, 122, 127, 128, 133, 130, 136, 129, 138, 132, 139, 145,
                137, 149, 141, 151, 143]})

resumen = pedidos.groupby("semana")["pedidos"].agg(["mean", "std"])

fig, axes = plt.subplots(2, 1, figsize=(7.2, 4.8), sharex=True)

axes[0].plot(resumen.index, resumen["mean"], color="steelblue", marker="o")
axes[0].fill_between(resumen.index,
                     resumen["mean"] - resumen["std"],
                     resumen["mean"] + resumen["std"],
                     color="steelblue", alpha=0.2,
                     label="Media mas menos un desvio")
axes[0].set_title("El nivel sube y la variabilidad tambien")
axes[0].set_ylabel("Pedidos por dia")
axes[0].legend()

axes[1].bar(resumen.index, resumen["std"], color="darkorange")
axes[1].set_xlabel("Semana")
axes[1].set_ylabel("Desvio")

fig.tight_layout()
plt.show()

print(resumen.round(2).to_string())
print()
print("La media crece de", round(resumen["mean"].iloc[0], 1), "a",
      round(resumen["mean"].iloc[-1], 1), "pedidos por dia.")
print("El desvio crece de", round(resumen["std"].iloc[0], 2), "a",
      round(resumen["std"].iloc[-1], 2), ".")
print()
print("La banda dice cuanto se puede esperar que se aparte un dia cualquiera.")
print("Una banda que se ensancha es una operacion cada vez menos previsible,")
print("aunque el promedio mejore.")
la media de pedidos sube de 100 a 141 en seis semanas dentro de una banda sombreada que se ensancha al final, y debajo unas barras muestran ese desvio creciendo de 2 a 5
          mean   std
semana              
1       100.00  2.16
2       107.00  2.16
3       114.86  2.41
4       123.00  2.94
5       132.29  3.68
6       143.57  5.13

La media crece de 100.0 a 143.6 pedidos por dia.
El desvio crece de 2.16 a 5.13 .

La banda dice cuanto se puede esperar que se aparte un dia cualquiera.
Una banda que se ensancha es una operacion cada vez menos previsible,
aunque el promedio mejore.

La media crece de 100 a 143,6 pedidos por día. El desvío crece de 2,16 a 5,13. Las dos cosas son ciertas al mismo tiempo y cuentan historias opuestas: el centro logístico mueve más volumen y se volvió menos previsible. La banda que se ensancha es el hallazgo, y sin ella el gráfico habría dicho solamente «vamos bien».

ELI5Explicado facil

Pensá en el tiempo que tardás en llegar a la facultad.

  • Si siempre tardás 30 minutos, salís 35 minutos antes y llegás.
  • Si tardás 30 en promedio, pero a veces 20 y a veces 50, tenés que salir 55 minutos antes. El promedio es el mismo y la vida es otra.

La banda del gráfico es eso: el «a veces». Sin ella, un promedio te hace llegar tarde una de cada cinco veces.

Sobre datos de verdad

Una lectura imposible y la banda se traga el gráfico

EjemploUn 999.9 y la banda se abre tanto que deja de decir nada
import pandas as pd
import matplotlib.pyplot as plt

sensores = pd.read_csv("numpy_sensores.csv")
horas = [c for c in sensores.columns if c.startswith("h")]
lecturas = sensores[horas]
eje = [int(h[1:]) for h in horas]

media = lecturas.mean()
desvio = lecturas.std()

fig, ax = plt.subplots()
ax.plot(eje, media.to_numpy(), color="steelblue", marker="o", markersize=3)
ax.fill_between(eje, (media - desvio).to_numpy(), (media + desvio).to_numpy(),
                color="steelblue", alpha=0.2)
ax.set_title("Temperatura media por hora, sin filtrar")
ax.set_xlabel("Hora del dia")
ax.set_ylabel("Grados Celsius")
fig.tight_layout()
plt.show()

print("hora con el desvio mas alto:", desvio.idxmax(),
      "->", round(desvio.max(), 2), "grados")
print("desvio en el resto de las horas: entre",
      round(desvio.drop(desvio.idxmax()).min(), 2), "y",
      round(desvio.drop(desvio.idxmax()).max(), 2))
print()
print("De donde sale:")
sospechosa = sensores.loc[lecturas[desvio.idxmax()] > 60,
                          ["sensor_id", "ubicacion", desvio.idxmax()]]
print(sospechosa.to_string(index=False))
print()
peor = desvio.idxmax()
print("Una lectura de 999.9 en una sola hora de un solo sensor.")
print("Media de esa hora:", round(media[peor], 2),
      " | media del resto de las horas:",
      round(media.drop(peor).mean(), 2))
print("La banda se come todo el grafico y las otras 23 horas quedan")
print("aplastadas contra una linea.")
la media por hora sube hasta un pico enorme a las once y la banda sombreada se estira de menos trescientos a mas seiscientos grados
hora con el desvio mas alto: h11 -> 309.27 grados
desvio en el resto de las horas: entre 1.84 y 2.4

De donde sale:
sensor_id   ubicacion   h11
      S05 laboratorio 999.9

Una lectura de 999.9 en una sola hora de un solo sensor.
Media de esa hora: 119.72  | media del resto de las horas: 20.72
La banda se come todo el grafico y las otras 23 horas quedan
aplastadas contra una linea.

El desvío de las 11 es 309,27 grados; el de las otras veintitrés horas está entre 1,84 y 2,4. La media de esa hora salta de unos 21 grados a 119,72. Es un único 999.9 en un único sensor, y basta para que la banda cubra de −300 a +600 grados y las veintitrés horas restantes queden aplastadas contra una línea.

Por qué la media y el desvío se rompen tan fácil

Los dos suman todos los valores, así que un valor extremo entra entero. El desvío además eleva al cuadrado la distancia al centro, con lo cual un valor mil veces más grande pesa un millón de veces más.

La mediana y el rango intercuartílico no tienen ese problema: cambian poco aunque el máximo sea absurdo. Son robustos. La contra es que son menos familiares y que no se combinan tan bien con el resto del análisis, así que la solución habitual sigue siendo limpiar primero y usar media y desvío después.

Con la lectura imposible afuera

EjemploLa misma banda con la lectura imposible afuera
import pandas as pd
import matplotlib.pyplot as plt

sensores = pd.read_csv("numpy_sensores.csv")
horas = [c for c in sensores.columns if c.startswith("h")]
eje = [int(h[1:]) for h in horas]

# Politica: este edificio no supera los 60 grados en ninguna sala. Lo que pasa
# de ahi es una lectura rota del sensor, no una temperatura.
crudo = sensores[horas]
lecturas = crudo.mask(crudo > 60)
print("lecturas anuladas por imposibles:", int((crudo > 60).sum().sum()))
print("lecturas ausentes en el archivo:", int(crudo.isna().sum().sum()))
print("lecturas usadas:", int(lecturas.notna().sum().sum()), "de",
      lecturas.size)

media = lecturas.mean()
desvio = lecturas.std()

fig, ax = plt.subplots()
for _, fila in lecturas.iterrows():
    ax.plot(eje, fila.to_numpy(), color="0.8", linewidth=0.8)
ax.fill_between(eje, (media - desvio).to_numpy(), (media + desvio).to_numpy(),
                color="steelblue", alpha=0.25,
                label="Media mas menos un desvio")
ax.plot(eje, media.to_numpy(), color="steelblue", linewidth=2.5,
        label="Media de los diez sensores")
ax.set_title("El edificio se enfria de madrugada y se calienta hasta las 16")
ax.set_xlabel("Hora del dia")
ax.set_ylabel("Grados Celsius")
ax.set_xticks(range(0, 24, 3))
ax.legend()
fig.tight_layout()
plt.show()

print()
print("hora mas fria:", media.idxmin(), round(media.min(), 2))
print("hora mas calida:", media.idxmax(), round(media.max(), 2))
print("desvio, minimo y maximo:", round(desvio.min(), 2), round(desvio.max(), 2))
print()
print("Ahora el desvio esta entre", round(desvio.min(), 1), "y",
      round(desvio.max(), 1), "grados, y la banda dice algo:")
print("a cualquier hora, las salas del edificio difieren entre si en")
print("ese orden de magnitud.")
la temperatura media por hora baja hasta un minimo a las cuatro y sube a un maximo cerca de las dieciseis, con una banda estrecha de mas menos dos grados y las diez curvas individuales por detras
lecturas anuladas por imposibles: 1
lecturas ausentes en el archivo: 4
lecturas usadas: 235 de 240

hora mas fria: h03 17.93
hora mas calida: h15 23.47
desvio, minimo y maximo: 1.84 2.4

Ahora el desvio esta entre 1.8 y 2.4 grados, y la banda dice algo:
a cualquier hora, las salas del edificio difieren entre si en
ese orden de magnitud.

Una lectura anulada sobre 240 y el gráfico empieza a decir algo: el edificio se enfría hasta las 3 de la mañana, se calienta hasta un máximo a las 15 y las salas difieren entre sí en unos dos grados a cualquier hora. Las diez curvas grises detrás de la banda son las que permiten ver que hay una que va sistemáticamente por arriba, cosa que el promedio no muestra.

Marcar lo que se aparta

Ejemploz-scores: el valor imposible tapaba las anomalias de verdad
import pandas as pd
import matplotlib.pyplot as plt

sensores = pd.read_csv("numpy_sensores.csv")
horas = [c for c in sensores.columns if c.startswith("h")]
eje = [int(h[1:]) for h in horas]

crudo = sensores[horas]
lecturas = crudo.mask(crudo > 60)

todas = lecturas.stack()
media, desvio = todas.mean(), todas.std()
limite_alto, limite_bajo = media + 2 * desvio, media - 2 * desvio
z = (lecturas - media) / desvio
anomala = z.abs() > 2

# El mismo criterio, calculado sobre los datos sin filtrar.
sucias = crudo.stack()
z_sucio = (crudo - sucias.mean()) / sucias.std()
print("anomalias con el 999.9 adentro:", int((z_sucio.abs() > 2).sum().sum()))
print("anomalias con el 999.9 afuera: ", int(anomala.sum().sum()))

fig, ax = plt.subplots()
for indice, fila in lecturas.iterrows():
    ax.plot(eje, fila.to_numpy(), color="0.8", linewidth=0.8)
    marcadas = anomala.loc[indice]
    if marcadas.any():
        ax.scatter([int(h[1:]) for h in horas if marcadas[h]],
                   fila[marcadas].to_numpy(), color="red", s=28, zorder=3)
ax.axhline(limite_alto, color="crimson", linestyle="--",
           label=f"Media mas menos 2 desvios ({limite_bajo:.1f} a {limite_alto:.1f})")
ax.axhline(limite_bajo, color="crimson", linestyle="--")
ax.set_title("Catorce de las dieciseis anomalias vienen de dos sensores")
ax.set_xlabel("Hora del dia")
ax.set_ylabel("Grados Celsius")
ax.set_xticks(range(0, 24, 3))
ax.legend(loc="lower right")
fig.tight_layout()
plt.show()

print()
print("media:", round(media, 2), " desvio:", round(desvio, 2))
print("limites:", round(limite_bajo, 2), "a", round(limite_alto, 2))
print()
print("anomalias por sensor:")
por_sensor = anomala.sum(axis=1)
print(pd.DataFrame({"sensor": sensores["sensor_id"],
                    "ubicacion": sensores["ubicacion"],
                    "anomalas": por_sensor.to_numpy()})
      .query("anomalas > 0").to_string(index=False))
print()
print("Con el 999.9 adentro el desvio era", round(sucias.std(), 2),
      "y el corte alto estaba en",
      round(sucias.mean() + 2 * sucias.std(), 2), "grados:")
print("no marcaba a nadie. El unico dato imposible estaba escondiendo las")
print("lecturas raras de verdad, que es el modo mas caro de fallar: no se")
print("ve nada y todo parece en orden.")
las diez curvas horarias en gris con dieciseis lecturas marcadas en rojo por encima o por debajo de las dos lineas de corte, catorce de ellas de solo dos sensores
anomalias con el 999.9 adentro: 1
anomalias con el 999.9 afuera:  16

media: 20.76  desvio: 2.6
limites: 15.57 a 25.95

anomalias por sensor:
sensor       ubicacion  anomalas
   S03         oficina         1
   S07 sala_servidores         8
   S09     planta_baja         1
   S10     laboratorio         6

Con el 999.9 adentro el desvio era 63.79 y el corte alto estaba en 152.49 grados:
no marcaba a nadie. El unico dato imposible estaba escondiendo las
lecturas raras de verdad, que es el modo mas caro de fallar: no se
ve nada y todo parece en orden.
El dato imposible no solo deforma: esconde

Con el 999.9 adentro, el desvío global es 63,79 y el corte de dos desvíos queda en 152,49 grados: no marca absolutamente nada. Sin él, el corte queda en 25,95 y aparecen dieciséis lecturas fuera de rango, catorce de ellas en apenas dos sensores.

Es el modo más caro de fallar. Un gráfico roto se nota; un detector de anomalías que no marca nada parece un sistema sano.

Y notá qué dicen las dieciséis: no están repartidas al azar. Ocho son de S07, en la sala de servidores, y seis de S10, en el laboratorio. Eso ya no es una anomalía estadística: es un patrón, y la lectura correcta probablemente sea que esas dos salas simplemente funcionan a otra temperatura y no pertenecen a la misma población que las demás.

Acá hay que elegir

Contra qué se compara cada punto

El umbral de dos desvíos es una convención, igual que el 1,5 del IQR. Lo que casi nunca se discute y decide mucho más es respecto de qué media se calcula:

  • La global, como en el ejemplo: marca los sensores que corren calientes o fríos respecto del edificio. Contesta «¿qué sala es distinta?».
  • La de cada hora: marca el momento en que un sensor se apartó de lo que hacía el resto en ese momento. Contesta «¿cuándo pasó algo?».
  • La de cada sensor: marca cuándo un sensor se apartó de sí mismo. Contesta «¿qué equipo cambió de comportamiento?».

Los tres dan conjuntos distintos de anomalías sobre los mismos datos, y ninguno es el correcto en abstracto. Lo correcto es elegir el que responde tu pregunta y escribir cuál elegiste.

Dónde se rompe

(sin error) el detector no marca ninguna anomalía

Causa: hay un valor extremo adentro del cálculo. Infla el desvío y corre el umbral tan lejos que nada lo alcanza.

Arreglo: descartar lo imposible antes de medir la dispersión. Es el mismo orden de la lección 5.6, y acá el costo de equivocarse es invisible.

(sin error) el detector marca el 15 % de los datos

Causa: la distribución no es acampanada. Con una cola larga, dos desvíos dejan afuera muchísimo más que el 5 % que uno esperaría.

Arreglo: mirá el histograma primero. Con colas largas conviene el IQR o trabajar sobre el logaritmo, y declararlo.

ValueError: operands could not be broadcast together

Causa: fill_between recibió un eje x de un largo y límites de otro. Suele pasar tras un dropna aplicado a uno solo de los tres.

Arreglo: calcular media y desvío sobre el mismo objeto y usar su índice como eje: ax.fill_between(media.index, media - sd, media + sd).

(sin error) la banda inferior baja de cero en una cantidad

Causa: media menos un desvío da negativo, y una cantidad de pedidos no puede serlo.

Arreglo: recortar con .clip(lower=0) solo para dibujar, y decirlo en el pie. La banda es una referencia, no una predicción, y dibujar un imposible distrae.

(sin error) todas las anomalías son del mismo grupo

Causa: ese grupo no pertenece a la misma población. La sala de servidores está más caliente siempre, y no es un evento.

Arreglo: calcular el umbral dentro de cada grupo, o excluir el grupo y tratarlo aparte. Que las anomalías se concentren es una señal de que el modelo de «normal» está mal armado, no de que pasaron muchas cosas.

Recetario

Bandas, umbrales y marcado
ExpresiónQué haceCuándo
df.groupby(k)["v"].agg(["mean", "std"])Centro y dispersión por grupoBase de la banda
ax.fill_between(x, bajo, alto, alpha=0.2)Área sombreadaBanda de referencia
(bajo).clip(lower=0)Impide que la banda baje de ceroCantidades; se aclara
ax.axhline(limite, linestyle="--")Línea de corteUmbral fijo
(v - v.mean()) / v.std()z-scoreDistancia al centro en desvíos
z.abs() > 2Máscara de anomalíasUmbral convencional; se declara
df.mask(df > 60)Anula lo imposible sin borrar la filaAntes de medir dispersión
ax.scatter(x[m], y[m], color="red", zorder=3)Destaca los marcadoszorder los pone encima
v.quantile([0.25, 0.75])Base del IQRAlternativa robusta a media y desvío
df.stack()Todas las celdas en una serieEstadística sobre la tabla entera
plt.subplots(2, 1, sharex=True)Dos ejes apiladosSerie arriba, dispersión abajo

Ponete a prueba

1. ¿Qué agrega una banda de media más menos un desvío a una línea de promedios?Nivel 1 · Recordar
2. En seis semanas la media de pedidos sube de 100 a 143,6 y el desvío de 2,16 a 5,13. ¿Qué se informa?Nivel 1 · Recordar
3. Un 999.9 en una lectura de temperatura lleva el desvío de esa hora a 309, cuando el de las demás está entre 1,8 y 2,4. ¿Por qué pesa tanto?Nivel 2 · Predecir la salida
4. Con el 999.9 adentro, el detector de |z| > 2 marca 1 lectura; sin él marca 16. ¿Qué explica la diferencia?Nivel 2 · Predecir la salida
5. De las 16 anomalías, 8 son del sensor de la sala de servidores y 6 del laboratorio. ¿Cómo se lee eso?Nivel 3 · Elegir entre dos casi correctas
6. Se quiere detectar el momento en que un sensor se apartó de lo que hacía el resto en ese mismo momento. ¿Contra qué media se calcula el z-score?Nivel 3 · Elegir entre dos casi correctas
7. Este código corre y la banda inferior baja de cero en un gráfico de cantidad de pedidos. ¿Cuál es el defecto de fondo?
r = df.groupby("semana")["pedidos"].agg(["mean", "std"])
ax.fill_between(r.index, r["mean"] - 2 * r["std"], r["mean"] + 2 * r["std"])
ax.plot(r.index, r["mean"])
Nivel 4 · Encontrar el defecto

Prácticas de esta lección