Selección: .loc, .iloc, boolean indexing

python
pandas
eda
Cómo elegir filas y columnas en pandas sin sufrir. La distinción etiqueta vs posición, boolean indexing al estilo tidyverse y por qué SettingWithCopyWarning aparece y cómo no provocarlo.

El problema: ¿etiqueta o posición?

pandas tiene dos formas de identificar una fila: por etiqueta (lo que está en el Index) o por posición (0, 1, 2…). Esta distinción no existe en R porque data.frame no tiene índice explícito. En pandas hay que elegir consciente:

  • .loc[etiqueta]: selección por etiqueta del índice.
  • .iloc[posición]: selección por posición numérica (siempre 0, 1, 2…).
  • df[...]: atajo ambiguo. Para columnas funciona, para filas suele confundir.

La regla simple: para acceder a filas, usa siempre .loc o .iloc. Para acceder a columnas, df["col"].

import pandas as pd

df = pd.DataFrame({
    "ventas":   [1500, 2100, 1800, 2400],
    "clientes": [120, 180, 145, 200],
}, index=["Norte", "Sur", "Este", "Oeste"])

.loc: por etiqueta

.loc accede por la etiqueta del índice (no por posición numérica):

df.loc["Norte"]            # Series con la fila de Norte
df.loc["Norte", "ventas"]  # 1500 (fila, columna)
df.loc[:, "ventas"]        # toda la columna ventas
df.loc["Norte":"Este"]     # tres filas: Norte, Sur, Este — slice inclusivo

Detalle crucial: el slice con .loc es inclusivo en ambos extremos. ["Norte":"Este"] incluye Norte y Este. Distinto del slicing tradicional de Python.

Selección múltiple:

df.loc[["Norte", "Sur"]]                # dos filas
df.loc[["Norte", "Sur"], ["ventas"]]    # dos filas, una columna (como DataFrame)

.iloc: por posición

.iloc accede por posición entera, igual que listas o arrays de NumPy:

df.iloc[0]            # primera fila — Series
df.iloc[0, 1]         # 120 (fila 0, columna 1)
df.iloc[:, 0]         # primera columna
df.iloc[0:2]          # primeras dos filas — slice exclusivo en el final
df.iloc[[0, 2]]       # filas 0 y 2
df.iloc[-1]           # última fila

Detalle: .iloc slice es exclusivo en el final, como Python normal. [0:2] da las filas 0 y 1, no incluye la 2.

Resumen rápido:

Operación Por etiqueta Por posición
Una fila df.loc["x"] df.iloc[0]
Slice de filas df.loc["a":"c"] (inclusivo) df.iloc[0:3] (exclusivo)
Una celda df.loc["x", "col"] df.iloc[0, 1]
Múltiples df.loc[["a", "b"]] df.iloc[[0, 1]]

Boolean indexing: filtrar filas

El patrón más común en EDA: filas que cumplen una condición.

df.loc[df["ventas"] > 1500]
# o equivalente
df[df["ventas"] > 1500]

El array df["ventas"] > 1500 es una Series booleana. pandas la usa como máscara, selecciona solo las filas donde es True.

Combinaciones lógicas:

df.loc[(df["ventas"] > 1500) & (df["clientes"] > 150)]   # AND
df.loc[(df["ventas"] > 1500) | (df["clientes"] > 150)]   # OR
df.loc[~(df["ventas"] > 1500)]                            # NOT (negación)

Trampa: en pandas los operadores lógicos son &, |, ~, no and, or, not. Y necesitan paréntesis alrededor de cada comparación, porque & tiene precedencia mayor que >.

Más legible: query()

df.query("ventas > 1500 and clientes > 150")
df.query("region in ['Norte', 'Sur']")

query() permite escribir condiciones como strings. Limpio para filtros largos, equivalente a filter() de dplyr. Más lento que el boolean indexing tradicional para datasets pequeños, pero más legible.

Combinar selección y filtro

.loc admite booleanos:

df.loc[df["ventas"] > 1500, "clientes"]
# Series con los clientes de las regiones donde ventas > 1500

Esto es el patrón idiomático para filtrar y elegir columnas a la vez. Equivalente a df %>% filter(...) %>% select(...) en dplyr.

df.loc[df["ventas"] > 1500, ["ventas", "clientes"]]
# DataFrame con dos columnas, filtrado por la condición

SettingWithCopyWarning: la trampa más famosa

Este warning aparece cuando modificas una vista y pandas no puede garantizar si está cambiando el DataFrame original o no. El patrón típico que lo dispara:

df_sub = df[df["ventas"] > 1500]
df_sub["nueva_col"] = 0    # ⚠️ SettingWithCopyWarning

¿Por qué? df[df["ventas"] > 1500] puede devolver una vista o una copia según el caso. Cuando asignas a nueva_col, pandas no sabe si estás modificando df_sub (lo que quieres) o df (efecto colateral inesperado). Te avisa con el warning.

Dos formas de evitarlo:

Opción A: trabajar siempre sobre una copia explícita.

df_sub = df[df["ventas"] > 1500].copy()
df_sub["nueva_col"] = 0    # OK, es una copia

Opción B: usar .loc para la asignación directa.

df.loc[df["ventas"] > 1500, "nueva_col"] = 0
# Modifica df directamente, sin warning

La opción B es la idiomática moderna: una sola operación, sin warnings, claro qué estás haciendo.

A partir de pandas 3.0 (próxima mayor), el modelo de copia cambia (Copy-on-Write) y este warning desaparece. Pero en versiones actuales, conviene conocerlo.

.at y .iat: acceso a una celda

Cuando quieres una sola celda, .at y .iat son más rápidos que .loc/.iloc:

df.at["Norte", "ventas"]   # 1500 — por etiqueta
df.iat[0, 0]               # 1500 — por posición

Solo en bucles tight donde la velocidad importa, para código normal, .loc/.iloc es igual de bueno.

Atajos para casos comunes

df.head(10)              # primeras 10 filas
df.tail(5)               # últimas 5
df.sample(3)             # 3 aleatorias
df.nlargest(5, "ventas") # top 5 por ventas
df.nsmallest(3, "ventas")# bottom 3

nlargest y nsmallest son más eficientes que sort_values().head() cuando solo quieres N elementos, pandas no ordena todo el DataFrame.

Trampas habituales

  • df[0] no es la primera fila. En R, los índices numéricos sirven para filas. En pandas, df[0] busca una columna llamada 0 y suele fallar. Para la primera fila, df.iloc[0].
  • .loc slice inclusivo vs .iloc slice exclusivo. df.loc[0:5] con índice numérico devuelve 6 filas (0 a 5). df.iloc[0:5] devuelve 5 (0 a 4). Es asimétrico por diseño, .loc se centra en etiquetas, .iloc en posiciones Python estándar.
  • Encadenamiento que dispara SettingWithCopyWarning. Patrón típico: df[df.x > 0]["y"] = 1. Es un “chain”: selección, selección, asignación. Usa df.loc[df.x > 0, "y"] = 1.
  • df.col.replace(...) modifica la columna pero el DataFrame depende del caso. Si quieres modificarlo, asigna explícito: df["col"] = df["col"].replace(...).

En la siguiente entrega

Tienes el control de qué filas y columnas elegir. Lo siguiente es operar sobre ellas, crear columnas calculadas, aplicar funciones por fila o columna, usar np.where() para condicionales vectorizados, evitar .apply() cuando hay una forma vectorizada. Lo siguiente.