Pandas Apply: Transforma DataFrames y Series con funciones personalizadas
Updated on
La transformación de datos está en el corazón de cada flujo de trabajo de análisis de datos. Mientras que pandas proporciona cientos de métodos integrados para operaciones comunes, los datos del mundo real a menudo demandan lógica personalizada que las funciones estándar no pueden manejar. Esto crea un dilema: ¿cómo aplicas eficientemente transformaciones complejas y definidas por el usuario en miles o millones de filas?
El método apply() resuelve este problema al permitirte ejecutar cualquier función de Python en columnas, filas o elementos de Series de DataFrame. Ya sea que necesites limpiar formatos de cadena inconsistentes, implementar lógica de negocio condicional o diseñar características para modelos de aprendizaje automático, apply() proporciona la flexibilidad para manejar operaciones que quedan fuera del conjunto de herramientas integradas de pandas. Sin embargo, este poder viene con compromisos de rendimiento que muchos científicos de datos pasan por alto, lo que lleva a código que se ejecuta de 10 a 100 veces más lento que las alternativas optimizadas.
Esta guía revela cómo usar apply() de manera efectiva, cuándo evitarlo por completo y qué alternativas vectorizadas entregan los mismos resultados en una fracción del tiempo.
Comprender los conceptos básicos de pandas apply()
El método apply() existe en dos formas: DataFrame.apply() para operaciones en columnas o filas completas, y Series.apply() para transformaciones elemento por elemento en una sola columna.
Sintaxis de Series.apply()
Al trabajar con una sola columna (Series), apply() ejecuta una función en cada elemento:
import pandas as pd
import numpy as np
# Crear datos de ejemplo
df = pd.DataFrame({
'price': [29.99, 45.50, 15.75, 89.00],
'quantity': [2, 1, 5, 3],
'product': ['widget', 'gadget', 'tool', 'device']
})
# Aplicar función a Series
def add_tax(price):
return price * 1.08
df['price_with_tax'] = df['price'].apply(add_tax)
print(df)Salida:
price quantity product price_with_tax
0 29.99 2 widget 32.38920
1 45.50 1 gadget 49.14000
2 15.75 5 tool 17.01000
3 89.00 3 device 96.12000DataFrame.apply() con parámetro axis
El parámetro axis controla si apply() procesa columnas o filas:
axis=0(predeterminado): Aplicar función a cada columna (operación vertical)axis=1: Aplicar función a cada fila (operación horizontal)
# axis=0: Procesar cada columna
def get_range(column):
return column.max() - column.min()
ranges = df[['price', 'quantity']].apply(get_range, axis=0)
print(ranges)
# Salida:
# price 73.25
# quantity 4.00# axis=1: Procesar cada fila
def calculate_total(row):
return row['price'] * row['quantity']
df['total'] = df.apply(calculate_total, axis=1)
print(df)Salida:
price quantity product total
0 29.99 2 widget 59.98
1 45.50 1 gadget 45.50
2 15.75 5 tool 78.75
3 89.00 3 device 267.00Funciones Lambda vs. funciones con nombre
Las funciones lambda proporcionan transformaciones en línea concisas, mientras que las funciones con nombre ofrecen mejor legibilidad para lógica compleja.
Funciones Lambda
Perfectas para operaciones simples de una línea:
# Convertir nombres de productos a mayúsculas
df['product_upper'] = df['product'].apply(lambda x: x.upper())
# Calcular precio con descuento
df['discounted'] = df['price'].apply(lambda x: x * 0.9 if x > 30 else x)
# Combinar múltiples columnas
df['description'] = df.apply(
lambda row: f"{row['quantity']}x {row['product']} @ ${row['price']}",
axis=1
)
print(df['description'])Salida:
0 2x widget @ $29.99
1 1x gadget @ $45.5
2 5x tool @ $15.75
3 3x device @ $89.0Funciones con nombre
Esenciales para transformaciones de múltiples pasos y lógica reutilizable:
def categorize_price(price):
"""Categorizar productos por nivel de precio"""
if price < 20:
return 'Budget'
elif price < 50:
return 'Standard'
else:
return 'Premium'
df['tier'] = df['price'].apply(categorize_price)
def validate_order(row):
"""Aplicar reglas de negocio a datos de pedidos"""
if row['quantity'] > 10:
return 'Bulk Order - Review Required'
elif row['price'] * row['quantity'] > 200:
return 'High Value - Priority Shipping'
else:
return 'Standard Processing'
df['order_status'] = df.apply(validate_order, axis=1)
print(df[['product', 'tier', 'order_status']])Comprender el parámetro result_type
El parámetro result_type (solo DataFrame.apply()) controla cómo apply() formatea la salida cuando las funciones devuelven múltiples valores:
| result_type | Comportamiento | Caso de uso |
|---|---|---|
| None (predeterminado) | Infiere formato de salida automáticamente | Propósito general |
| 'expand' | Divide resultados tipo lista en columnas separadas | Múltiples valores de retorno |
| 'reduce' | Intenta devolver Series en lugar de DataFrame | Operaciones de agregación |
| 'broadcast' | Devuelve DataFrame con forma original | Transformaciones elemento por elemento |
def get_stats(column):
"""Devolver múltiples estadísticas"""
return [column.mean(), column.std(), column.max()]
# Comportamiento predeterminado (infiere estructura)
stats = df[['price', 'quantity']].apply(get_stats)
print(stats)
# Expandir en filas separadas
stats_expanded = df[['price', 'quantity']].apply(get_stats, result_type='expand')
print(stats_expanded)Comparación de métodos: apply vs map vs applymap vs transform
Comprender cuándo usar cada método previene cuellos de botella de rendimiento:
| Método | Opera en | La función de entrada recibe | Tipo de salida | Mejor para |
|---|---|---|---|---|
Series.apply() | Columna única | Cada elemento individualmente | Series | Transformaciones elemento por elemento en una columna |
Series.map() | Columna única | Cada elemento (también acepta dict/Series) | Series | Operaciones de sustitución/búsqueda |
DataFrame.apply() | DataFrame completo | Columna completa (axis=0) o fila (axis=1) | Series/DataFrame | Operaciones por columna/fila |
DataFrame.applymap() (obsoleto) | DataFrame completo | Cada elemento individualmente | DataFrame | Elemento por elemento en todas las columnas (use map() en su lugar) |
DataFrame.transform() | DataFrame/GroupBy | Columna/grupo completo | Misma forma que entrada | Operaciones que preservan la forma del DataFrame |
# Series.apply() - función personalizada elemento por elemento
df['price_rounded'] = df['price'].apply(lambda x: round(x, 0))
# Series.map() - mapeo de sustitución
tier_map = {'Budget': 1, 'Standard': 2, 'Premium': 3}
df['tier_code'] = df['tier'].map(tier_map)
# DataFrame.apply() - agregación por columna
totals = df[['price', 'quantity']].apply(sum, axis=0)
# DataFrame.transform() - preservando forma con groupby
df['price_norm'] = df.groupby('tier')['price'].transform(
lambda x: (x - x.mean()) / x.std()
)Rendimiento: Por qué apply() es lento
El método apply() procesa datos en un bucle de Python, evitando las implementaciones optimizadas de C/Cython de pandas. Para conjuntos de datos grandes, esto crea penalizaciones de rendimiento severas.
Comparación de rendimiento
import time
# Crear conjunto de datos grande
large_df = pd.DataFrame({
'values': np.random.randn(100000)
})
# Método 1: apply() con lambda
start = time.time()
result1 = large_df['values'].apply(lambda x: x ** 2)
apply_time = time.time() - start
# Método 2: Operación vectorizada
start = time.time()
result2 = large_df['values'] ** 2
vectorized_time = time.time() - start
print(f"Tiempo apply(): {apply_time:.4f}s")
print(f"Tiempo vectorizado: {vectorized_time:.4f}s")
print(f"Aceleración: {apply_time/vectorized_time:.1f}x")Salida típica:
Tiempo apply(): 0.0847s
Tiempo vectorizado: 0.0012s
Aceleración: 70.6xCuándo evitar apply()
Usa operaciones vectorizadas en su lugar cuando:
# NO HAGAS: Usar apply para aritmética
df['total'] = df.apply(lambda row: row['price'] * row['quantity'], axis=1)
# SÍ HAZLO: Usar multiplicación vectorizada
df['total'] = df['price'] * df['quantity']
# NO HAGAS: Usar apply para métodos de cadena
df['upper'] = df['product'].apply(lambda x: x.upper())
# SÍ HAZLO: Usar accesor de cadena integrado
df['upper'] = df['product'].str.upper()
# NO HAGAS: Usar apply para condiciones
df['expensive'] = df['price'].apply(lambda x: 'Yes' if x > 50 else 'No')
# SÍ HAZLO: Usar np.where o comparación directa
df['expensive'] = np.where(df['price'] > 50, 'Yes', 'No')Alternativas vectorizadas
| Operación | Lento (apply) | Rápido (vectorizado) |
|---|---|---|
| Aritmética | .apply(lambda x: x * 2) | * 2 |
| Condicionales | .apply(lambda x: 'A' if x > 10 else 'B') | np.where(condition, 'A', 'B') |
| Operaciones de cadena | .apply(lambda x: x.lower()) | .str.lower() |
| Operaciones de fecha | .apply(lambda x: x.year) | .dt.year |
| Múltiples condiciones | .apply(complex_if_elif_else) | np.select([cond1, cond2], [val1, val2], default) |
# Condicionales complejos con np.select
conditions = [
df['price'] < 20,
(df['price'] >= 20) & (df['price'] < 50),
df['price'] >= 50
]
choices = ['Budget', 'Standard', 'Premium']
df['tier_fast'] = np.select(conditions, choices, default='Unknown')Casos de uso comunes para pandas apply()
A pesar de las limitaciones de rendimiento, apply() sigue siendo esencial para operaciones que carecen de equivalentes vectorizados.
1. Limpieza de cadenas con lógica compleja
# Datos desordenados de ejemplo
messy_df = pd.DataFrame({
'email': ['John.Doe@COMPANY.com', ' jane_smith@test.co.uk ', 'ADMIN@Site.NET']
})
def clean_email(email):
"""Estandarizar formato de correo electrónico"""
email = email.strip().lower()
# Eliminar puntos adicionales
username, domain = email.split('@')
username = username.replace('..', '.')
return f"{username}@{domain}"
messy_df['email_clean'] = messy_df['email'].apply(clean_email)
print(messy_df)2. Lógica condicional con datos externos
# Ajuste de precio basado en búsqueda externa
discount_rules = {
'widget': 0.10,
'gadget': 0.15,
'device': 0.05
}
def apply_discount(row):
"""Aplicar descuento específico del producto con lógica mínima"""
base_price = row['price']
discount = discount_rules.get(row['product'], 0)
discounted = base_price * (1 - discount)
# Precio mínimo
return max(discounted, 9.99)
df['final_price'] = df.apply(apply_discount, axis=1)3. Ingeniería de características para ML
# Crear características de interacción
def create_features(row):
"""Generar características para modelado predictivo"""
features = {}
features['price_per_unit'] = row['price'] / row['quantity']
features['is_bulk'] = 1 if row['quantity'] > 5 else 0
features['revenue_tier'] = pd.cut(
[row['price'] * row['quantity']],
bins=[0, 50, 150, 300],
labels=['Low', 'Medium', 'High']
)[0]
return pd.Series(features)
feature_df = df.apply(create_features, axis=1)
df = pd.concat([df, feature_df], axis=1)4. Llamadas a API y búsquedas externas
def geocode_address(address):
"""Llamar a API externa para geocodificación"""
# Marcador de posición para llamada API real
# En la práctica: requests.get(f"api.geocode.com?q={address}")
return {'lat': 40.7128, 'lon': -74.0060}
# Aplicar con limitación de tasa
import time
def safe_geocode(address):
time.sleep(0.1) # Limitación de tasa
return geocode_address(address)
# df['coords'] = df['address'].apply(safe_geocode)Técnicas avanzadas
Pasar argumentos adicionales
Las funciones pueden recibir parámetros adicionales a través de args y kwargs:
def apply_markup(price, markup_pct, min_profit):
"""Agregar margen porcentual con ganancia mínima"""
markup_amount = price * (markup_pct / 100)
return price + max(markup_amount, min_profit)
# Pasar argumentos adicionales
df['retail_price'] = df['price'].apply(
apply_markup,
args=(25,), # markup_pct=25
min_profit=5.0 # argumento de palabra clave
)Usar apply() con groupby()
Combinar groupby con apply para agregaciones complejas:
# Transformaciones a nivel de grupo
def normalize_group(group):
"""Normalización Z-score dentro del grupo"""
return (group - group.mean()) / group.std()
df['price_normalized'] = df.groupby('tier')['price'].apply(normalize_group)
# Agregaciones de grupo personalizadas
def group_summary(group):
"""Crear estadísticas resumidas para el grupo"""
return pd.Series({
'total_revenue': (group['price'] * group['quantity']).sum(),
'avg_price': group['price'].mean(),
'item_count': len(group)
})
tier_summary = df.groupby('tier').apply(group_summary)
print(tier_summary)Barras de progreso con tqdm
Monitorear operaciones apply de larga duración:
from tqdm import tqdm
tqdm.pandas()
# Usar progress_apply en lugar de apply
# df['result'] = df['column'].progress_apply(slow_function)Manejar errores con elegancia
def safe_transform(value):
"""Aplicar transformación con manejo de errores"""
try:
return complex_operation(value)
except Exception as e:
return None # o np.nan, o valor predeterminado
df['result'] = df['column'].apply(safe_transform)Errores comunes y depuración
Error 1: Usar axis=1 cuando la vectorización es posible
# INCORRECTO: Operación lenta por filas
df['total'] = df.apply(lambda row: row['price'] * row['quantity'], axis=1)
# CORRECTO: Operación vectorizada rápida
df['total'] = df['price'] * df['quantity']Error 2: Olvidar la declaración return
# INCORRECTO: Sin valor de retorno
def broken_function(x):
x * 2 # ¡Falta return!
# CORRECTO: Return explícito
def working_function(x):
return x * 2Error 3: Modificar DataFrame dentro de apply()
# INCORRECTO: Intentar modificar DataFrame durante la iteración
def bad_function(row):
df.loc[row.name, 'new_col'] = row['price'] * 2 # ¡Inseguro!
return row['price']
# CORRECTO: Devolver valores y asignar después
def good_function(row):
return row['price'] * 2
df['new_col'] = df.apply(good_function, axis=1)Depurar funciones apply()
# Probar función en una sola fila/elemento primero
test_row = df.iloc[0]
result = your_function(test_row)
print(f"Resultado de prueba: {result}")
# Agregar impresiones de depuración dentro de la función
def debug_function(row):
print(f"Procesando fila {row.name}: {row.to_dict()}")
result = complex_logic(row)
print(f"Resultado: {result}")
return result
# Probar en subconjunto pequeño
df.head(3).apply(debug_function, axis=1)Visualizar resultados con PyGWalker
Después de transformar tu DataFrame con apply(), visualizar los resultados ayuda a validar transformaciones y descubrir patrones. PyGWalker convierte tu pandas DataFrame en una interfaz interactiva estilo Tableau directamente en notebooks Jupyter.
import pygwalker as pyg
# Visualizar datos transformados de forma interactiva
walker = pyg.walk(df)PyGWalker permite análisis de arrastrar y soltar de tus transformaciones aplicadas:
- Compara columnas originales vs. transformadas con gráficos lado a lado
- Valida lógica condicional mediante filtrado y agrupación
- Detecta valores atípicos en campos calculados a través de gráficos de distribución
- Exporta visualizaciones para documentación
Explora PyGWalker en github.com/Kanaries/pygwalker (opens in a new tab) para transformar la exploración de datos de gráficos estáticos a análisis interactivos.
FAQ
¿Cómo aplico una función a múltiples columnas a la vez?
Usa DataFrame.apply() con axis=0 para procesar cada columna seleccionada, o usa operaciones vectorizadas en múltiples columnas directamente:
# Aplicar a múltiples columnas
df[['price', 'quantity']] = df[['price', 'quantity']].apply(lambda x: x * 1.1)
# O vectorizado (más rápido)
df[['price', 'quantity']] = df[['price', 'quantity']] * 1.1¿Cuál es la diferencia entre apply() y map()?
apply() ejecuta cualquier función invocable elemento por elemento, mientras que map() está optimizado para sustitución a través de diccionarios, Series o funciones. Usa map() para búsquedas y reemplazos (más rápido), y apply() para transformaciones personalizadas que requieren lógica compleja.
¿Por qué mi función apply() es tan lenta?
apply() se ejecuta en un bucle de Python en lugar de código C compilado, haciéndolo de 10 a 100 veces más lento que las operaciones vectorizadas. Siempre verifica si pandas tiene un método integrado (.str, .dt, operadores aritméticos) o si np.where()/np.select() puede reemplazar tu lógica antes de usar apply().
¿Puedo usar apply() con funciones lambda que acceden a múltiples columnas?
Sí, con axis=1 para procesar filas:
df['result'] = df.apply(lambda row: row['col1'] + row['col2'] * 0.5, axis=1)Sin embargo, esto es lento para DataFrames grandes. Prefiere: df['result'] = df['col1'] + df['col2'] * 0.5
¿Cómo devuelvo múltiples columnas desde una sola llamada apply()?
Devuelve una pd.Series con índices con nombre, y pandas la expandirá automáticamente en columnas:
def multi_output(row):
return pd.Series({
'sum': row['a'] + row['b'],
'product': row['a'] * row['b']
})
df[['sum', 'product']] = df.apply(multi_output, axis=1)Conclusión
El método pandas apply() proporciona flexibilidad esencial para transformaciones personalizadas que quedan fuera de las operaciones integradas de pandas. Si bien su implementación de bucle de Python crea compromisos de rendimiento, comprender cuándo usar apply() versus alternativas vectorizadas separa los flujos de trabajo de datos eficientes de aquellos que se detienen en conjuntos de datos de producción.
Puntos clave: Usa apply() solo cuando los métodos vectorizados, accesores de cadena o funciones de NumPy no puedan lograr tu objetivo. Prueba las funciones en filas individuales antes de aplicarlas a DataFrames completos. Para operaciones por filas en conjuntos de datos grandes, investiga Cython, compilación JIT de Numba o cambiar a polars para ejecución paralela.
Domina tanto el poder como las limitaciones de apply(), y tu conjunto de herramientas de transformación de datos manejará cualquier desafío que pandas te presente.