Saltar al contenido

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:

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

EjemploUna comparacion no devuelve filas: devuelve True y False
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

Paso a pasoDe una comparacion a un filtro: la mascara booleana paso a paso
  1. 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")
  2. 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
  3. 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]
  4. 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"])
  5. 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"])
  6. 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

EjemploCombinar condiciones: &, | y ~, siempre con parentesis
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 &.
Los paréntesis no son estilo

& 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

Ejemploisin y between, que evitan encadenar comparaciones
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

Ejemplostr.contains: la trampa clasica, y por que en pandas 3 se esconde
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.
Un caso donde pandas 3 cambió el comportamiento

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

EjemploEl filtro que pierde filas sin decirlo
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.

ValueError: The truth value of a Series is ambiguous.

Causa: se usó and, or o not entre máscaras. Esos operadores piden un solo booleano.

Arreglo: &, | y ~, cada comparación entre paréntesis.

TypeError: Cannot perform 'rand_' with a dtyped [int64] array and scalar of type [bool]

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.

ValueError: Cannot mask with non-boolean array containing NA / NaN values

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.

(sin error) los dos grupos no suman el total

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.

(sin error) el filtro devuelve 0 filas y deberían ser varias

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

Qué hacer con los ausentes al filtrar

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

Filtrar filas
ExpresiónQué haceCuándo
df["c"] > 8Máscara booleana, una por filaEl primer paso, siempre
df[mascara]Las filas donde la máscara es TrueEl filtro propiamente dicho
df.loc[mascara, "c"]Una columna de esas filasFiltrar y proyectar de una
mascara.sum()Cuántas cumplenContar sin filtrar
(a) & (b) / (a) | (b) / ~aY / O / NO, vectorizadosSiempre con paréntesis
df["c"].isin(lista)Pertenece al conjuntoEvita encadenar |
df["c"].between(a, b)Rango, extremos incluidosDeclarar inclusive= si el borde importa
df["c"].str.contains("x", na=False)Contiene ese textoSiempre con na=False
df["c"].str.startswith("x", na=False)Empieza conCódigos con prefijo
df["c"].isna() / .notna()Ausente / presenteEl tercer grupo
df.query("c > 8 and d < 2")Lo mismo, como textoCuando la condición es larga; acá sí va and

Ponete a prueba

1. ¿Que devuelve df["precio"] > 800?Nivel 1 · Recordar
2. ¿Como se escribe «precio mayor a 500 Y cantidad mayor a 100»?Nivel 1 · Recordar
3. mascara tiene 35 valores, 8 en True. ¿Que da int(mascara.sum())?Nivel 2 · Predecir la salida
4. s.between(4.53, 872.23) da 29 y s.between(4.53, 872.23, inclusive="neither") da 27. ¿Que explica la diferencia?Nivel 2 · Predecir la salida
5. Un archivo tiene 120 clientes. edad >= 18 devuelve 101 filas y edad < 18 devuelve 12. ¿Que pasa con las 7 que faltan?Nivel 3 · Elegir entre dos casi correctas
6. Un informe tiene que decir cuantos mayores, cuantos menores y no perder ninguna fila. ¿Cual es la mejor forma de garantizarlo?Nivel 3 · Elegir entre dos casi correctas
7. Este codigo funcionaba y despues de una actualizacion empezo a tirar ValueError: Cannot mask con non-boolean array containing NA en algunas maquinas y en otras no. ¿Por que?
correos = clientes["correo"]
validos = clientes[correos.str.contains("@")]
Nivel 4 · Encontrar el defecto

Prácticas de esta lección