Indice / pandas: fundamentos / 4.5
Filtrar filas
Filtrar en pandas no es buscar. Es evaluar una condición en
todas las filas a la vez, obtener un vector de True y
False, y usar ese vector como índice. Entender ese rodeo es entender la
mitad de la biblioteca.
Para qué sirve
Es la operación más usada de todo el curso: los productos de una categoría, los clientes mayores de edad, las ventas de marzo. Y es donde se concentran tres errores que no dan error:
- Escribir
anden vez de&- ese sí avisa. - Olvidar los paréntesis - ese también, pero con un mensaje que no ayuda.
- Perder filas porque la comparación contra un ausente da
False- ese no avisa nada, y es el caro.
ELI5Explicado facil
Imaginate que pasás lista en un aula y a cada alumno le pedís que levante la mano si tiene más de 18. Primero levantan la mano todos los que corresponde, y recién después mirás quiénes la levantaron.
La mano levantada es la máscara: una respuesta por alumno, no una lista de alumnos. Y el que está dormido no levanta la mano - pero tampoco dice que no tiene 18. Simplemente queda afuera.
El ejemplo mínimo
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv")
mascara = inventario["precio_unitario"] > 800
print("tipo de la mascara:", type(mascara).__name__)
print("dtype :", mascara.dtype)
print("largo :", len(mascara), "= una respuesta por fila")
print()
print(mascara.head(5).to_string())
print()
print("cuantas cumplen:", int(mascara.sum()), " (True vale 1 al sumar)")
print()
print("Y recien cuando la mascara va adentro del corchete, se filtra:")
print(inventario[mascara][["codigo", "precio_unitario"]].head(3).to_string(index=False))
tipo de la mascara: Series
dtype : bool
largo : 35 = una respuesta por fila
0 False
1 False
2 True
3 False
4 False
cuantas cumplen: 8 (True vale 1 al sumar)
Y recien cuando la mascara va adentro del corchete, se filtra:
codigo precio_unitario
P003 872.23
P008 810.40
P009 848.60
Prestale atención al dtype: bool y al largo: 35 respuestas
para 35 filas. Todavía no se filtró nada. Y como True vale 1 al
sumar, mascara.sum() cuenta cuántas cumplen - el truco más usado
de todo pandas.
Lo mismo, paso a paso
- Punto de partidaSeis clientes, sin ningun filtro todavia. La ultima columna esta vacia a proposito: ahi va a aparecer el resultado de la condicion, fila por fila.
clientes = pd.read_csv("clientes_crudos.csv") - La condicionUna comparacion sobre una columna NO devuelve las filas que cumplen: devuelve un True o un False por cada fila. Esto es una mascara, y todavia no filtro nada.
clientes["edad"] >= 18 - Usar la mascaraRecien cuando la mascara va adentro del corchete se filtran filas. Las que dan False no desaparecieron del archivo: quedaron fuera de esta seleccion.
clientes[clientes["edad"] >= 18] - Otra condicionUna segunda mascara, independiente de la anterior. isin sirve para preguntar por varios valores sin encadenar comparaciones con or.
clientes["ciudad"].isin(["Rosario", "Mendoza"]) - CombinarLas dos juntas con & (y con parentesis, que son obligatorios). El resultado es otra mascara, no una tabla: True solo donde las dos daban True.
mascara = (clientes["edad"] >= 18) & clientes["ciudad"].isin(["Rosario", "Mendoza"]) - La trampaMira C0087: vive en Mendoza, asi que cumple la segunda condicion, y sin embargo quedo afuera. Su edad es un guion, que no es un numero: la comparacion contra un ausente da False, no error. La fila se cae en silencio.
clientes[mascara]
Combinar condiciones
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv")
caro = inventario["precio_unitario"] > 500
mucho = inventario["cantidad"] > 100
print("caros :", int(caro.sum()))
print("con mucho stock :", int(mucho.sum()))
print("caros Y con stock:", int((caro & mucho).sum()))
print("caros O con stock:", int((caro | mucho).sum()))
print("NO caros :", int((~caro).sum()))
print()
# Los parentesis no son estilo: & tiene mas precedencia que >.
seleccion = inventario[(inventario["precio_unitario"] > 500)
& (inventario["cantidad"] > 100)]
print(seleccion[["codigo", "cantidad", "precio_unitario"]].to_string(index=False))
print()
try:
caro and mucho
except ValueError as error:
print("caro and mucho -> ValueError:", error)
print("and pide UN booleano y le estas dando 35. Para vectores va &.")
caros : 17
con mucho stock : 8
caros Y con stock: 5
caros O con stock: 20
NO caros : 18
codigo cantidad precio_unitario
P003 108.0 872.23
P008 114.0 810.40
P014 103.0 606.24
P028 117.0 907.42
P032 103.0 673.95
caro and mucho -> ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
and pide UN booleano y le estas dando 35. Para vectores va &.
& tiene más precedencia que >,
así que df["a"] > 1 & df["b"] < 2 se lee como
df["a"] > (1 & df["b"]) < 2, que no significa nada.
Envolvé cada comparación: (df["a"] > 1) & (df["b"] < 2).
Y nunca and, or ni not: esos esperan
un valor de verdad y acá hay 35. El mensaje
The truth value of a Series is ambiguous siempre significa esto.
Sobre datos de verdad
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv")
# isin: pertenece a un conjunto. Reemplaza a (c == "a") | (c == "b") | ...
grupos = ["Perifericos", "Pantallas", "Audio"]
print(inventario[inventario["categoria"].isin(grupos)]
[["codigo", "categoria", "precio_unitario"]].to_string(index=False))
print()
# between: rango, con los DOS extremos incluidos por defecto. Para que se vea,
# los limites son precios que EXISTEN en el archivo: 4.53 y 872.23.
precio = inventario["precio_unitario"]
print("between(4.53, 872.23) ->",
int(precio.between(4.53, 872.23).sum()))
print("between(4.53, 872.23, inclusive='neither') ->",
int(precio.between(4.53, 872.23, inclusive="neither").sum()))
print("between(4.53, 872.23, inclusive='left') ->",
int(precio.between(4.53, 872.23, inclusive="left").sum()))
print()
print("Dos filas de diferencia entre el primero y el segundo: son P001 (4.53)")
print("y P003 (872.23), que caen justo en los extremos.")
codigo categoria precio_unitario
P001 Perifericos 4.53
P002 Perifericos 781.78
P003 Pantallas 872.23
P004 Pantallas 432.95
P005 Perifericos 713.42
P006 Audio 542.80
P007 Audio 301.36
between(4.53, 872.23) -> 29
between(4.53, 872.23, inclusive='neither') -> 27
between(4.53, 872.23, inclusive='left') -> 28
Dos filas de diferencia entre el primero y el segundo: son P001 (4.53)
y P003 (872.23), que caen justo en los extremos.
between incluye los dos extremos
Por defecto, inclusive="both". Se puede pedir "left",
"right" o "neither". Cuando el límite es un valor que
existe en los datos - una nota de 10, un precio tope - la
diferencia son filas de verdad, y conviene que el enunciado la fije.
Texto
import pandas as pd
print("pandas", pd.__version__)
# Dos columnas con el MISMO contenido y distinto dtype. La primera es como
# lee pandas 3; la segunda, como leia pandas 2 y como queda si alguien
# escribe astype(object) en el medio.
nueva = pd.Series(["ana@example.com", None, "sin arroba"], dtype="str")
vieja = pd.Series(["ana@example.com", None, "sin arroba"], dtype="object")
for nombre, columna in [("str (pandas 3)", nueva), ("object (pandas 2)", vieja)]:
mascara = columna.str.contains("@")
print()
print(nombre, "-> dtype de la mascara:", mascara.dtype)
print(" valores:", list(mascara))
try:
print(" filtrar:", len(columna[mascara]), "fila(s)")
except ValueError as error:
print(" filtrar -> ValueError:", error)
print()
print("Con el dtype str el ausente ya cuenta como False y el filtro anda.")
print("Con object la mascara lleva None adentro y pandas se niega a indexar.")
print()
print("na=False resuelve los dos casos y no molesta en ninguno:")
print(" columna.str.contains('@', na=False)")
print("Escribilo siempre. Es una palabra y te salva del dia que la columna")
print("llegue como object sin que vos lo hayas pedido.")
pandas 3.0.5
str (pandas 3) -> dtype de la mascara: bool
valores: [True, False, False]
filtrar: 1 fila(s)
object (pandas 2) -> dtype de la mascara: object
valores: [True, None, False]
filtrar -> ValueError: Cannot mask with non-boolean array containing NA / NaN values
Con el dtype str el ausente ya cuenta como False y el filtro anda.
Con object la mascara lleva None adentro y pandas se niega a indexar.
na=False resuelve los dos casos y no molesta en ninguno:
columna.str.contains('@', na=False)
Escribilo siempre. Es una palabra y te salva del dia que la columna
llegue como object sin que vos lo hayas pedido.
En pandas 2, str.contains sobre una columna con ausentes devolvía una
máscara con None adentro, y filtrar con ella tiraba
ValueError: Cannot mask with non-boolean array containing NA / NaN
values. Por eso todos los tutoriales dicen que hay que escribir
na=False.
En pandas 3, con el dtype str nuevo, la máscara ya sale
bool y el ausente cuenta como False: el error no aparece.
Pero vuelve en cuanto la columna es object, que pasa si
alguien la construyó a mano o le aplicó astype(object). Escribí
na=False igual: es una palabra y funciona en los dos mundos.
Dónde se rompe
import pandas as pd
clientes = pd.read_csv("clientes_crudos.csv", na_values=["-"])
edad = pd.to_numeric(clientes["edad"], errors="coerce")
print("clientes en total :", len(clientes))
print("con edad ausente :", int(edad.isna().sum()))
mayores = clientes[edad >= 18]
menores = clientes[edad < 18]
print()
print("edad >= 18 :", len(mayores))
print("edad < 18 :", len(menores))
print("suma :", len(mayores) + len(menores), "de", len(clientes))
print()
print("Faltan", len(clientes) - len(mayores) - len(menores), "filas.")
print("No estan en ninguno de los dos lados: la comparacion contra un")
print("ausente da False, asi que no cumplen ni una condicion ni la contraria.")
print()
print("Si el informe dice 'mayores' y 'menores' y las dos cifras no suman")
print("el total, ese es el motivo. Los ausentes se cuentan aparte:")
print("ausentes :", int(edad.isna().sum()))
clientes en total : 120
con edad ausente : 7
edad >= 18 : 101
edad < 18 : 12
suma : 113 de 120
Faltan 7 filas.
No estan en ninguno de los dos lados: la comparacion contra un
ausente da False, asi que no cumplen ni una condicion ni la contraria.
Si el informe dice 'mayores' y 'menores' y las dos cifras no suman
el total, ese es el motivo. Los ausentes se cuentan aparte:
ausentes : 7
101 mayores + 12 menores = 113, sobre 120 clientes. Faltan 7, y
no están en ninguno de los dos lados: son los que tienen la edad ausente. Una
comparación contra un ausente da False, así que no cumplen ni la
condición ni la contraria.
Este es el error que más plata cuesta de la unidad, porque el informe se ve perfecto: dos categorías, dos números, ningún error. Solo que no suman.
Causa: se usó and, or o not entre
máscaras. Esos operadores piden un solo booleano.
Arreglo: &, | y ~, cada
comparación entre paréntesis.
Causa: faltaron los paréntesis. & se aplicó antes que
la comparación.
Arreglo: (a > 1) & (b < 2). El mensaje es feo;
la causa es siempre esta.
Causa: la máscara tiene ausentes. Con el dtype object,
str.contains los deja pasar.
Arreglo: na=False en el método de texto, o
.fillna(False) sobre la máscara.
Causa: las filas con el valor ausente quedan fuera de los dos, porque
NaN >= 18 y NaN < 18 son las dos
False.
Arreglo: contarlas aparte y decirlo:
len(a) + len(b) + int(col.isna().sum()) == len(df). Como
assert, atrapa el error solo.
Causa: la comparación es contra texto y el valor tiene espacios o
mayúsculas distintas: " Rosario" no es "Rosario".
Arreglo: normalizar antes de comparar
(.str.strip().str.lower()), que es la lección 5.2.
Acá hay que elegir
La comparación los manda al lado del False. Casi nunca es lo que
querés, y hay tres salidas:
- Descartarlos explícitamente antes de filtrar
(
dropna(subset=["edad"])) y decir cuántos eran. Es lo más honesto cuando la fila sin ese dato no sirve. - Contarlos como un tercer grupo: mayores, menores y sin dato. Es lo correcto en un informe.
- Meterlos en uno de los dos a propósito, con
fillna, y dejar escrito por qué. Sirve cuando el ausente significa algo - «sin fecha de baja» es «sigue activo».
Lo que no vale es dejar que el operador decida por vos y que el informe muestre dos números que no suman.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
df["c"] > 8 | Máscara booleana, una por fila | El primer paso, siempre |
df[mascara] | Las filas donde la máscara es True | El filtro propiamente dicho |
df.loc[mascara, "c"] | Una columna de esas filas | Filtrar y proyectar de una |
mascara.sum() | Cuántas cumplen | Contar sin filtrar |
(a) & (b) / (a) | (b) / ~a | Y / O / NO, vectorizados | Siempre con paréntesis |
df["c"].isin(lista) | Pertenece al conjunto | Evita encadenar | |
df["c"].between(a, b) | Rango, extremos incluidos | Declarar inclusive= si el borde importa |
df["c"].str.contains("x", na=False) | Contiene ese texto | Siempre con na=False |
df["c"].str.startswith("x", na=False) | Empieza con | Códigos con prefijo |
df["c"].isna() / .notna() | Ausente / presente | El tercer grupo |
df.query("c > 8 and d < 2") | Lo mismo, como texto | Cuando la condición es larga; acá sí va and |