Indice / NumPy / 3.4
Máscaras booleanas
La lección bisagra del curso. df[df.nota_1 >= 7] de la
unidad 4 es esto, con otro nombre para las filas: una comparación que produce
un array de True y False, y ese array usado como índice.
Para qué sirve
Filtrar es la operación más frecuente de todo el análisis de datos, y en NumPy y en
pandas se hace de la misma manera. Los tres errores que produce -
and en vez de &, los paréntesis que faltan, y el
NaN que se descarta solo - también son los mismos.
ELI5Explicado facil
Es un sello de «pasa» o «no pasa» puesto en cada fila, y después la orden de dejar entrar solo a las selladas.
Lo importante es que son dos cosas separadas: primero se sella todo, después se filtra. El sello se puede mirar, contar y guardar sin filtrar nada - y eso es justamente lo que lo hace tan útil.
Los tres pasos
import numpy as np
lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
s01 = lecturas[0]
print("las 24 lecturas de S01:")
print(np.round(s01, 1))
print()
print("PASO 1 - la comparacion devuelve un array de booleanos del MISMO largo:")
calido = s01 > 22
print(" s01 > 22 ->")
print(calido)
print(" dtype:", calido.dtype, "| shape:", calido.shape)
print()
print("PASO 2 - ese array booleano se usa como indice:")
print(" s01[s01 > 22] ->", s01[calido])
print(" Se quedan solo las posiciones donde la mascara dice True.")
print(" shape:", s01[calido].shape, " <- ya NO son 24")
print()
print("PASO 3 - y sumarla cuenta, porque True vale 1:")
print(" calido.sum() ->", calido.sum())
print(" np.count_nonzero() ->", np.count_nonzero(calido))
print(" calido.mean() ->", round(float(calido.mean()), 3),
" <- la PROPORCION")
print(" Ese ultimo es el truco mas util: el promedio de una mascara es")
print(" el porcentaje de filas que cumplen.")
print()
print("Sobre la grilla entera funciona igual, y la mascara es 2D:")
mascara = lecturas > 22
print(" (lecturas > 22).shape ->", mascara.shape)
print(" total de lecturas > 22:", mascara.sum())
print(" por sensor (axis=1) :", mascara.sum(axis=1))
print(" por hora (axis=0) :", mascara.sum(axis=0))
print()
print("Y el filtrado sobre una grilla APLANA el resultado:")
print(" lecturas[lecturas > 22].shape ->", lecturas[mascara].shape)
print(" Tiene sentido: las que cumplen no forman un rectangulo.")
print()
print("Un detalle que salva horas: NaN no cumple NINGUNA comparacion.")
print(" np.nan > 0 ->", np.nan > 0)
print(" np.nan < 0 ->", np.nan < 0)
print(" np.nan == np.nan ->", np.nan == np.nan)
print(" Asi que una mascara descarta los ausentes sin que lo pidas...")
print(" ...y eso puede ser lo que queres, o puede ser un dato perdido")
print(" en silencio. Para preguntarlo explicitamente: np.isnan(a).")
print(" NaN en la grilla:", int(np.isnan(lecturas).sum()))
las 24 lecturas de S01:
[20.8 19.8 19.2 19. 19.6 20.3 20. 21. 21.1 21.7 21.9 22.9 22.6 23.7
23.9 24.3 24.4 23.3 23.2 22.3 22.4 21.9 21.2 21.1]
PASO 1 - la comparacion devuelve un array de booleanos del MISMO largo:
s01 > 22 ->
[False False False False False False False False False False False True
True True True True True True True True True False False False]
dtype: bool | shape: (24,)
PASO 2 - ese array booleano se usa como indice:
s01[s01 > 22] -> [22.9 22.6 23.7 23.9 24.3 24.4 23.3 23.2 22.3 22.4]
Se quedan solo las posiciones donde la mascara dice True.
shape: (10,) <- ya NO son 24
PASO 3 - y sumarla cuenta, porque True vale 1:
calido.sum() -> 10
np.count_nonzero() -> 10
calido.mean() -> 0.417 <- la PROPORCION
Ese ultimo es el truco mas util: el promedio de una mascara es
el porcentaje de filas que cumplen.
Sobre la grilla entera funciona igual, y la mascara es 2D:
(lecturas > 22).shape -> (10, 24)
total de lecturas > 22: 66
por sensor (axis=1) : [10 3 0 8 1 7 20 3 14 0]
por hora (axis=0) : [1 1 0 0 0 0 1 1 2 2 2 6 5 5 6 7 6 6 5 3 3 1 2 1]
Y el filtrado sobre una grilla APLANA el resultado:
lecturas[lecturas > 22].shape -> (66,)
Tiene sentido: las que cumplen no forman un rectangulo.
Un detalle que salva horas: NaN no cumple NINGUNA comparacion.
np.nan > 0 -> False
np.nan < 0 -> False
np.nan == np.nan -> False
Asi que una mascara descarta los ausentes sin que lo pidas...
...y eso puede ser lo que queres, o puede ser un dato perdido
en silencio. Para preguntarlo explicitamente: np.isnan(a).
NaN en la grilla: 4
| Paso | Expresión | Devuelve |
|---|---|---|
| 1. Comparar | s01 > 22 | Un array de bool, del mismo largo |
| 2. Filtrar | s01[s01 > 22] | Solo los que cumplen. Ya no son 24 |
| 3a. Contar | (s01 > 22).sum() | Cuántos cumplen, porque True vale 1 |
| 3b. Proporción | (s01 > 22).mean() | Qué fracción cumple: el truco más útil de todos |
Sobre una grilla la máscara es 2D y se puede reducir por eje:
(lecturas > 22).sum(axis=1) da cuántas horas cálidas tuvo cada sensor.
El filtrado, en cambio, aplana - y tiene sentido, porque las celdas que
cumplen no forman un rectángulo.
NaN no cumple ninguna comparación
np.nan > 0, np.nan < 0 y hasta
np.nan == np.nan son los tres False. Así que una máscara
descarta los ausentes sin que se lo pidas.
A veces eso es exactamente lo que querías. A veces es un dato que se pierde en
silencio y nadie cuenta. La diferencia entre las dos situaciones es si lo escribiste:
np.isnan(a) pregunta explícitamente.
Combinar condiciones
import numpy as np
lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
s01 = lecturas[0]
print("Los tres operadores de mascaras son simbolos, no palabras:")
print(" & Y | O ~ NO")
templado = (s01 > 20) & (s01 < 22)
print()
print("templado = (s01 > 20) & (s01 < 22)")
print(" cuantas:", templado.sum(), "de", s01.size)
print(" valores:", np.round(s01[templado], 1))
extremo = (s01 <= 20) | (s01 >= 22)
print()
print("extremo = (s01 <= 20) | (s01 >= 22)")
print(" cuantas:", extremo.sum())
print()
print("~ invierte la mascara:")
print(" (~templado).sum() ->", (~templado).sum(), "= 24 -", templado.sum())
print(" ¿~templado es igual a extremo?",
np.array_equal(~templado, extremo))
print(" Si, y no es casualidad: negar un Y da un O con las dos")
print(" condiciones negadas. NO(a>20 Y a<22) = (a<=20) O (a>=22).")
print(" Se llaman leyes de De Morgan, y sirven para no escribir")
print(" condiciones dobles cuando alcanza con negar una.")
print()
print("=" * 62)
print("LOS DOS ERRORES QUE TODO EL MUNDO COMETE")
print("=" * 62)
print()
print("1. Usar 'and' en vez de '&':")
try:
s01 > 20 and s01 < 22
except ValueError as error:
print(" s01 > 20 and s01 < 22 ->", type(error).__name__)
print(" ", error)
print(" 'and' necesita un solo True o False para decidir, y le estas")
print(" dando 24. NumPy se niega a adivinar cual queres.")
print()
print("2. Olvidar los parentesis:")
try:
s01 > 20 & s01 < 22
except (ValueError, TypeError) as error:
print(" s01 > 20 & s01 < 22 ->", type(error).__name__)
print(" ", str(error)[:70])
print(" '&' tiene MAS precedencia que '>' y '<', asi que Python lee")
print(" s01 > (20 & s01) < 22. Los parentesis no son estilo: son")
print(" obligatorios.")
print()
print("La forma correcta, siempre:")
print(" (condicion_a) & (condicion_b)")
print(" (condicion_a) | (condicion_b)")
print(" ~(condicion_a)")
print()
print("Y la variante que evita el problema cuando hay muchas condiciones:")
condiciones = [s01 > 20, s01 < 22, ~np.isnan(s01)]
todas = np.logical_and.reduce(condiciones)
print(" np.logical_and.reduce([a, b, c]) ->", todas.sum(), "cumplen las 3")
print(" np.logical_and(a, b) es lo mismo que a & b")
print(" np.logical_or(a, b) es lo mismo que a | b")
print(" np.logical_not(a) es lo mismo que ~a")
print()
print("Sobre la grilla, combinar es igual de barato:")
usable = ~np.isnan(lecturas) & (lecturas <= 50)
print(" usable = ~np.isnan(lecturas) & (lecturas <= 50)")
print(" lecturas usables:", usable.sum(), "de", lecturas.size)
print(" descartadas :", lecturas.size - usable.sum(),
"= 4 ausentes + 1 imposible")
Los tres operadores de mascaras son simbolos, no palabras:
& Y | O ~ NO
templado = (s01 > 20) & (s01 < 22)
cuantas: 9 de 24
valores: [20.8 20.3 21. 21.1 21.7 21.9 21.9 21.2 21.1]
extremo = (s01 <= 20) | (s01 >= 22)
cuantas: 15
~ invierte la mascara:
(~templado).sum() -> 15 = 24 - 9
¿~templado es igual a extremo? True
Si, y no es casualidad: negar un Y da un O con las dos
condiciones negadas. NO(a>20 Y a<22) = (a<=20) O (a>=22).
Se llaman leyes de De Morgan, y sirven para no escribir
condiciones dobles cuando alcanza con negar una.
==============================================================
LOS DOS ERRORES QUE TODO EL MUNDO COMETE
==============================================================
1. Usar 'and' en vez de '&':
s01 > 20 and s01 < 22 -> ValueError
The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
'and' necesita un solo True o False para decidir, y le estas
dando 24. NumPy se niega a adivinar cual queres.
2. Olvidar los parentesis:
s01 > 20 & s01 < 22 -> TypeError
ufunc 'bitwise_and' not supported for the input types, and the inputs
'&' tiene MAS precedencia que '>' y '<', asi que Python lee
s01 > (20 & s01) < 22. Los parentesis no son estilo: son
obligatorios.
La forma correcta, siempre:
(condicion_a) & (condicion_b)
(condicion_a) | (condicion_b)
~(condicion_a)
Y la variante que evita el problema cuando hay muchas condiciones:
np.logical_and.reduce([a, b, c]) -> 9 cumplen las 3
np.logical_and(a, b) es lo mismo que a & b
np.logical_or(a, b) es lo mismo que a | b
np.logical_not(a) es lo mismo que ~a
Sobre la grilla, combinar es igual de barato:
usable = ~np.isnan(lecturas) & (lecturas <= 50)
lecturas usables: 235 de 240
descartadas : 5 = 4 ausentes + 1 imposible
1. and en vez de &.
ValueError: The truth value of an array with more than one element is
ambiguous. and necesita un solo True o
False para decidir, y le estás dando 24: NumPy se niega a adivinar.
2. Los paréntesis. & tiene más precedencia
que > y <, así que a > 20 & a < 22
se lee a > (20 & a) < 22 y da TypeError. No son
estilo: son obligatorios.
| Se escribe | Significa | Alternativa |
|---|---|---|
(a) & (b) | Y | np.logical_and(a, b) |
(a) | (b) | O | np.logical_or(a, b) |
~(a) | NO | np.logical_not(a) |
np.logical_and.reduce([a, b, c]) para muchas condiciones a la vez | ||
Negar un Y da un O con las dos condiciones negadas:
~((a>20) & (a<22)) es lo mismo que
(a<=20) | (a>=22).
Sirve sobre todo para lo contrario: cuando una condición doble se pone difícil de leer, muchas veces alcanza con negar la simple.
Decidir sin filtrar
import numpy as np
lecturas = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
s01 = lecturas[0]
print("np.where(condicion, si, no) elige valor por valor:")
etiquetas = np.where(s01 > 22, "calido", "templado")
print(" np.where(s01 > 22, 'calido', 'templado'), primeros 8:")
print(" ", etiquetas[:8])
print(" Devuelve un array del MISMO largo: no filtra, reemplaza.")
print()
print("Y es la forma de limpiar sin perder filas:")
limpias = np.where(lecturas > 50, np.nan, lecturas)
print(" np.where(lecturas > 50, np.nan, lecturas)")
print(" shape:", limpias.shape, " <- las 240 siguen ahi")
print(" NaN antes :", int(np.isnan(lecturas).sum()))
print(" NaN despues:", int(np.isnan(limpias).sum()),
" <- el 999.9 paso a ser un ausente declarado")
print()
print("Se pueden encadenar, aunque anidados se leen mal:")
tres = np.where(s01 < 20, "frio", np.where(s01 < 22, "templado", "calido"))
print(" primeros 8:", tres[:8])
print()
print("=" * 62)
print("any y all: contestan SI o NO sobre todo el array")
print("=" * 62)
print(" np.isnan(lecturas).any() ->", np.isnan(lecturas).any(),
" ¿hay ALGUN ausente?")
print(" np.isnan(lecturas).all() ->", np.isnan(lecturas).all(),
" ¿son TODOS ausentes?")
print(" (lecturas > 0).all() ->", (lecturas > 0).all(),
" ¿son TODAS positivas? (los NaN dicen que no)")
print(" (~np.isnan(lecturas)).all() ->", (~np.isnan(lecturas)).all())
print()
print("Con axis contestan por fila o por columna:")
print(" np.isnan(lecturas).any(axis=1) ->", np.isnan(lecturas).any(axis=1))
print(" ...los sensores con al menos un hueco: S03, S06 y S09")
con_hueco = np.nonzero(np.isnan(lecturas).any(axis=1))[0]
print(" posiciones:", con_hueco, "-> S03, S06, S09")
print()
print("Son la herramienta de los assert de invariantes:")
print(" assert (salida['edad'] >= 18).all()")
print(" assert not salida.isna().any().any()")
print(" Un any/all que falla dice que una regla se rompio, y eso es")
print(" mucho mejor que descubrirlo en el informe.")
print()
print("Y las tres que dicen DONDE:")
print(" np.nonzero(s01 > 23) ->", np.nonzero(s01 > 23)[0],
" (las posiciones)")
print(" np.argmax(s01) ->", np.argmax(s01),
" (la posicion del maximo)")
print(" np.nanargmax(lecturas) ->", np.nanargmax(lecturas),
" (en el array aplanado)")
print(" np.unravel_index(np.nanargmax(lecturas), lecturas.shape) ->",
np.unravel_index(np.nanargmax(lecturas), lecturas.shape),
" <- fila 4, hora 11: el 999.9")
np.where(condicion, si, no) elige valor por valor:
np.where(s01 > 22, 'calido', 'templado'), primeros 8:
['templado' 'templado' 'templado' 'templado' 'templado' 'templado'
'templado' 'templado']
Devuelve un array del MISMO largo: no filtra, reemplaza.
Y es la forma de limpiar sin perder filas:
np.where(lecturas > 50, np.nan, lecturas)
shape: (10, 24) <- las 240 siguen ahi
NaN antes : 4
NaN despues: 5 <- el 999.9 paso a ser un ausente declarado
Se pueden encadenar, aunque anidados se leen mal:
primeros 8: ['templado' 'frio' 'frio' 'frio' 'frio' 'templado' 'templado' 'templado']
==============================================================
any y all: contestan SI o NO sobre todo el array
==============================================================
np.isnan(lecturas).any() -> True ¿hay ALGUN ausente?
np.isnan(lecturas).all() -> False ¿son TODOS ausentes?
(lecturas > 0).all() -> False ¿son TODAS positivas? (los NaN dicen que no)
(~np.isnan(lecturas)).all() -> False
Con axis contestan por fila o por columna:
np.isnan(lecturas).any(axis=1) -> [False False True False False True False False True False]
...los sensores con al menos un hueco: S03, S06 y S09
posiciones: [2 5 8] -> S03, S06, S09
Son la herramienta de los assert de invariantes:
assert (salida['edad'] >= 18).all()
assert not salida.isna().any().any()
Un any/all que falla dice que una regla se rompio, y eso es
mucho mejor que descubrirlo en el informe.
Y las tres que dicen DONDE:
np.nonzero(s01 > 23) -> [13 14 15 16 17 18] (las posiciones)
np.argmax(s01) -> 16 (la posicion del maximo)
np.nanargmax(lecturas) -> 107 (en el array aplanado)
np.unravel_index(np.nanargmax(lecturas), lecturas.shape) -> (np.int64(4), np.int64(11)) <- fila 4, hora 11: el 999.9
| Expresión | Devuelve | Para qué |
|---|---|---|
np.where(cond, si, no) | Un array del mismo largo | Reemplazar sin perder filas |
mask.any() | Un solo bool | ¿Hay alguno? |
mask.all() | Un solo bool | ¿Cumplen todos? - los assert de invariantes |
mask.any(axis=1) | Un bool por fila | Qué filas tienen algún problema |
np.nonzero(mask) | Las posiciones | Saber dónde |
np.unravel_index(np.nanargmax(a), a.shape) | La coordenada del máximo | Encontrar el atípico: (4, 11) |
np.where es la herramienta para limpiar sin descartar:
np.where(lecturas > 50, np.nan, lecturas) convierte la lectura
imposible en un ausente declarado y deja las 240 celdas en su lugar. De 4 NaN
pasa a 5, y ahora el defecto está marcado en vez de escondido.
Y esto ya es pandas
import numpy as np
import pandas as pd
# TAREA: las lecturas usables (no ausentes, no imposibles) que superan
# los 22 grados, contadas por sensor.
VALIDO_MAX = 50.0
UMBRAL = 22.0
# ---------------------------------------------------------------- NumPy
grilla = np.genfromtxt("numpy_sensores.csv", delimiter=",",
skip_header=1, usecols=range(2, 26))
usable_np = ~np.isnan(grilla) & (grilla <= VALIDO_MAX)
calidas_np = ((grilla > UMBRAL) & usable_np).sum(axis=1)
# --------------------------------------------------------------- pandas
tabla = pd.read_csv("numpy_sensores.csv")
horas = [c for c in tabla.columns if c.startswith("h")]
valores = tabla[horas]
usable_pd = valores.notna() & (valores <= VALIDO_MAX)
calidas_pd = ((valores > UMBRAL) & usable_pd).sum(axis=1)
print("NumPy :", calidas_np)
print("pandas:", calidas_pd.to_numpy())
print("¿iguales?", np.array_equal(calidas_np, calidas_pd.to_numpy()))
print()
print("Las dos lineas que importan, una al lado de la otra:")
print(" NumPy : ~np.isnan(g) & (g <= 50)")
print(" pandas: valores.notna() & (valores <= 50)")
print()
print(" NumPy : ((g > 22) & usable).sum(axis=1)")
print(" pandas: ((valores > 22) & usable).sum(axis=1)")
print()
print("Es la MISMA idea con otro nombre para 'no es ausente'. Todo lo")
print("demas (el &, los parentesis, sumar booleanos, el axis) es igual,")
print("porque pandas usa NumPy por debajo.")
print()
print("Y la version con nombres, que es lo que agrega pandas:")
resumen = pd.DataFrame({
"sensor": tabla["sensor_id"],
"ubicacion": tabla["ubicacion"],
"horas_calidas": calidas_pd,
"horas_usables": usable_pd.sum(axis=1),
})
print(resumen.to_string(index=False))
print()
print("Las tres filas con menos horas usables son S03 (2 huecos), S06 y")
print("S09 (uno cada uno), mas S05, que pierde su lectura imposible.")
print()
print("El paso de NumPy a pandas no es aprender otra cosa: es que las")
print("filas y columnas pasan a tener NOMBRE. La mascara es la misma.")
NumPy : [10 3 0 8 0 7 20 3 14 0]
pandas: [10 3 0 8 0 7 20 3 14 0]
¿iguales? True
Las dos lineas que importan, una al lado de la otra:
NumPy : ~np.isnan(g) & (g <= 50)
pandas: valores.notna() & (valores <= 50)
NumPy : ((g > 22) & usable).sum(axis=1)
pandas: ((valores > 22) & usable).sum(axis=1)
Es la MISMA idea con otro nombre para 'no es ausente'. Todo lo
demas (el &, los parentesis, sumar booleanos, el axis) es igual,
porque pandas usa NumPy por debajo.
Y la version con nombres, que es lo que agrega pandas:
sensor ubicacion horas_calidas horas_usables
S01 deposito 10 24
S02 sala_servidores 3 24
S03 oficina 0 22
S04 planta_baja 8 24
S05 laboratorio 0 23
S06 deposito 7 23
S07 sala_servidores 20 24
S08 oficina 3 24
S09 planta_baja 14 23
S10 laboratorio 0 24
Las tres filas con menos horas usables son S03 (2 huecos), S06 y
S09 (uno cada uno), mas S05, que pierde su lectura imposible.
El paso de NumPy a pandas no es aprender otra cosa: es que las
filas y columnas pasan a tener NOMBRE. La mascara es la misma.
| NumPy | pandas | |
|---|---|---|
| No es ausente | ~np.isnan(g) | valores.notna() |
| Combinar | &, con paréntesis | Igual |
| Contar por fila | .sum(axis=1) | Igual |
| Filtrar | a[mask] | df[mask] |
| Lo que agrega | - | Que las filas y columnas tengan nombre |
El paso de NumPy a pandas no es aprender otra cosa. La máscara es la misma, porque
pandas usa NumPy por debajo: lo que cambia es que lecturas[4, 11] pasa a
llamarse tabla.loc["S05", "h11"].
Dónde se rompe
Causa: usaste and, or, not, o
pusiste una máscara en un if.
Arreglo: &, |, ~ para combinar
máscaras; .any() o .all() si lo que querías era una sola
respuesta.
Causa: faltan los paréntesis y & se aplicó a los números
en vez de a las máscaras.
Arreglo: (a > 20) & (a < 22).
Causa: la máscara tiene otro largo que el array - casi siempre porque se construyó antes de un filtrado.
Arreglo: las máscaras se construyen sobre el array que se va a indexar, y no sobreviven a un filtrado anterior.
Causa: había NaN, y NaN no cumple ninguna
comparación.
Arreglo: contá primero: np.isnan(a).sum(). Después decidí si
los ausentes se descartan o se cuentan aparte.
Causa: a[mask] devuelve una copia.
Arreglo: a[mask] = valor en una sola expresión, no
sub = a[mask] y después sub[:] = valor.
Recetario
| Expresión | Qué hace |
|---|---|
a > 22 | La máscara, del mismo largo que a |
a[a > 22] | Los valores que cumplen (una copia) |
a[a > 22] = 0 | Los reemplaza en el original |
(a > 22).sum() | Cuántos cumplen |
(a > 22).mean() | Qué proporción cumple |
(a > 22).sum(axis=1) | Cuántos por fila |
(x > 1) & (x < 5) | Y - con paréntesis |
~mask | La máscara invertida |
np.isnan(a) | Dónde falta el dato |
np.where(cond, si, no) | Reemplaza sin filtrar |
mask.any() / mask.all() | ¿Alguno? / ¿Todos? |
np.nonzero(mask) | Las posiciones que cumplen |
np.isin(a, [1, 2, 3]) | ¿Está en esta lista? - el isin de pandas |