tutoriales.com

Optimización de Memoria y Rendimiento con Pandas: Estrategias Avanzadas

Este tutorial explora técnicas avanzadas para optimizar el uso de memoria y acelerar las operaciones en Pandas. Aprenderás a seleccionar tipos de datos eficientes, manejar datos categóricos y aplicar trucos de rendimiento para trabajar con grandes volúmenes de datos de manera más efectiva.

Intermedio20 min de lectura13 views19 de marzo de 2026Reportar error

¡Hola, entusiasta de la Ciencia de Datos! 👋

Trabajar con grandes volúmenes de datos es el pan de cada día para muchos de nosotros. Si bien Pandas es una herramienta increíblemente poderosa, su consumo de memoria y rendimiento pueden convertirse en un cuello de botella cuando se manejan DataFrames de gigabytes o incluso terabytes. Pero no te preocupes, ¡hay solución! En este tutorial, desglosaremos estrategias clave para exprimir cada gota de eficiencia de tus DataFrames de Pandas, permitiéndote procesar más datos, más rápido y con menos recursos.

🎯 ¿Por qué es Crucial la Optimización?

La optimización en Pandas no es solo una cuestión de velocidad; es fundamental para:

  • Evitar errores de memoria: Especialmente en entornos con recursos limitados (como entornos de desarrollo local o máquinas virtuales con poca RAM).
  • Reducir tiempos de ejecución: Operaciones más rápidas significan ciclos de desarrollo más cortos y análisis más ágiles.
  • Mejorar la escalabilidad: Permite trabajar con conjuntos de datos que antes eran inmanejables.
  • Reducir costos: Menos tiempo de cómputo en la nube se traduce en menores facturas.

📖 Entendiendo el Consumo de Memoria de Pandas

Antes de optimizar, necesitamos entender dónde se va la memoria. Pandas almacena los datos en arreglos NumPy, y el tipo de dato (dtype) de estas columnas es el factor más importante. Por defecto, Pandas puede ser un poco 'generoso' con los tipos de datos, a menudo eligiendo int64 o float64 incluso cuando tipos más pequeños serían suficientes.

📌 Nota: Los objetos de Python (cadenas, listas, tuplas, etc.) son especialmente "pesados" en memoria porque no se almacenan como arreglos contiguos, sino como punteros a objetos en la memoria.

Para inspeccionar el uso de memoria de un DataFrame, puedes usar el método .info() con el argumento memory_usage='deep'.

import pandas as pd
import numpy as np

# Crear un DataFrame de ejemplo grande
df_grande = pd.DataFrame({
    'col_int': np.random.randint(0, 100000, 1000000),
    'col_float': np.random.rand(1000000) * 100000,
    'col_str': ['cadena_' + str(i) for i in range(1000000)],
    'col_bool': np.random.choice([True, False], 1000000)
})

print("Uso de memoria inicial:")
print(df_grande.info(memory_usage='deep'))

🛠️ Estrategias de Optimización de Memoria

1. 🔍 Elección de Tipos de Datos (dypes) Eficientes

Este es, con diferencia, el paso más efectivo. Reduce el tamaño de los tipos numéricos tanto como sea posible.

Números Enteros (Integers)

  • int8: -128 a 127
  • int16: -32768 a 32767
  • int32: -2147483648 a 2147483647
  • int64: Rango muy amplio

Lo mismo aplica para los tipos uint (sin signo), que pueden almacenar valores positivos el doble de grandes.

Números de Punto Flotante (Floats)

  • float32: Precisión simple, útil para la mayoría de los casos.
  • float64: Precisión doble, por defecto en Pandas, solo necesaria si la precisión es crítica.

Booleans

  • bool: Usa un solo byte por valor. Es eficiente por defecto.

Fechas y Tiempos (Datetimes)

  • datetime64[ns]: Por defecto, almacena fechas y horas con precisión de nanosegundos. Es eficiente.

Cadenas de Texto (Strings) y Datos Categóricos

Los object dtype (usados para strings, mezclas de tipos, etc.) son los que más memoria consumen. Si tienes columnas con un número limitado de valores únicos que se repiten (por ejemplo, nombres de países, estados, géneros), conviértelas a tipo category.

# Función para optimizar tipos numéricos
def optimizar_numericos(df):
    for col in df.select_dtypes(include=np.number).columns:
        min_val = df[col].min()
        max_val = df[col].max()

        if pd.api.types.is_integer_dtype(df[col]):
            if min_val >= 0:
                if max_val < 255: # uint8
                    df[col] = df[col].astype(np.uint8)
                elif max_val < 65535: # uint16
                    df[col] = df[col].astype(np.uint16)
                elif max_val < 4294967295: # uint32
                    df[col] = df[col].astype(np.uint32)
            else:
                if min_val > np.iinfo(np.int8).min and max_val < np.iinfo(np.int8).max:
                    df[col] = df[col].astype(np.int8)
                elif min_val > np.iinfo(np.int16).min and max_val < np.iinfo(np.int16).max:
                    df[col] = df[col].astype(np.int16)
                elif min_val > np.iinfo(np.int32).min and max_val < np.iinfo(np.int32).max:
                    df[col] = df[col].astype(np.int32)
        elif pd.api.types.is_float_dtype(df[col]):
            # Aquí se puede ser más agresivo con float32 si la precisión lo permite
            df[col] = df[col].astype(np.float32)
    return df

# Función para optimizar tipos de objeto (cadenas)
def optimizar_objetos_a_categoria(df, umbral_cardinalidad=0.5):
    for col in df.select_dtypes(include='object').columns:
        num_unique_values = len(df[col].unique())
        num_total_values = len(df[col])
        if num_unique_values / num_total_values < umbral_cardinalidad:
            df[col] = df[col].astype('category')
    return df

# Aplicar optimizaciones al DataFrame de ejemplo
df_optimizado = df_grande.copy()
df_optimizado = optimizar_numericos(df_optimizado)
df_optimizado = optimizar_objetos_a_categoria(df_optimizado)

print("\nUso de memoria después de la optimización:")
print(df_optimizado.info(memory_usage='deep'))

# Comparación detallada
mem_inicial = df_grande.memory_usage(deep=True).sum()
mem_optimizada = df_optimizado.memory_usage(deep=True).sum()

print(f"\nMemoria inicial: {mem_inicial / (1024**2):.2f} MB")
print(f"Memoria optimizada: {mem_optimizada / (1024**2):.2f} MB")
print(f"Reducción de memoria: {((mem_inicial - mem_optimizada) / mem_inicial * 100):.2f}%")

2. 🗑️ Cargar Solo las Columnas Necesarias

Si estás leyendo un archivo CSV o Parquet con muchas columnas, pero solo necesitas unas pocas para tu análisis, ¡no cargues todo el archivo!

# Suponiendo que tienes un archivo CSV grande llamado 'datos_grandes.csv'
# df = pd.read_csv('datos_grandes.csv')

# Cargar solo las columnas 'col1', 'col_interes' y 'col_fecha'
# df_subconjunto = pd.read_csv('datos_grandes.csv', usecols=['col1', 'col_interes', 'col_fecha'])

3. 📦 Usar chunksize para Cargar Archivos Muy Grandes

Si tu archivo es tan grande que no cabe en la memoria incluso después de la optimización de tipos, puedes cargarlo en trozos (chunks) y procesarlo iterativamente.

# No ejecutable sin un archivo grande real, pero ilustrativo
# chunks = pd.read_csv('datos_enorme.csv', chunksize=100000)
# for chunk in chunks:
#     # Procesar cada chunk
#     optimizar_numericos(chunk)
#     optimizar_objetos_a_categoria(chunk)
#     # ... realizar operaciones ...
#     pass

4. 💾 Elegir Formatos de Almacenamiento Eficientes

CSV es popular, pero no es el más eficiente en espacio o rendimiento para lectura/escritura. Considera:

  • Parquet: Formato columnar, excelente para compresión y lectura de subconjuntos de columnas. Es el estándar de facto para big data.
  • Feather: Muy rápido para lectura/escritura en Python, ideal para DataFrames que se usarán dentro del ecosistema Pandas/Arrow.
  • HDF5: Bueno para almacenar DataFrames muy grandes, con soporte para lectura en partes.
💡 Consejo: Guarda tus DataFrames optimizados en formato Parquet. No solo reduce el tamaño del archivo, sino que la lectura posterior será significativamente más rápida.
# Guardar en Parquet
df_optimizado.to_parquet('df_optimizado.parquet', index=False)

# Cargar desde Parquet
df_cargado = pd.read_parquet('df_optimizado.parquet')

print("\nUso de memoria del DataFrame cargado desde Parquet:")
print(df_cargado.info(memory_usage='deep'))

⚡ Estrategias de Optimización de Rendimiento

La velocidad de tus operaciones es tan importante como el uso de memoria. Evitar for loops y aprovechar las operaciones vectorizadas de NumPy/Pandas es clave.

1. 🏎️ Vectorización sobre Iteración

¡La regla de oro de Pandas! Evita a toda costa los bucles for para aplicar operaciones fila por fila. Usa métodos vectorizados siempre que sea posible.

⚠️ Advertencia: `df.apply()` con una función lambda puede ser más lento que las operaciones vectorizadas directas, especialmente para DataFrames grandes. Úsalo con precaución y solo si no hay una alternativa vectorizada.

Mal (iteración):

# NO HAGAS ESTO para DataFrames grandes
def calcular_doble(valor):
    return valor * 2

# df_grande['col_int_doble'] = [calcular_doble(x) for x in df_grande['col_int']]

Bien (vectorización):

df_grande['col_int_doble_vec'] = df_grande['col_int'] * 2

2. ⚙️ apply() con numba o cython

Si realmente necesitas usar apply y la función es computacionalmente intensiva, puedes acelerarla con librerías como numba o cython.

# Ejemplo con Numba (requiere `pip install numba`)
from numba import jit

@jit(nopython=True)
def heavy_computation(a, b):
    return a**2 + b**2 + 2*a*b

# Crear un DataFrame de prueba
df_test = pd.DataFrame({
    'A': np.random.rand(1000000),
    'B': np.random.rand(1000000)
})

# Usando apply sin numba (lento)
# %timeit df_test.apply(lambda row: row['A']**2 + row['B']**2 + 2*row['A']*row['B'], axis=1)

# Usando apply con numba (mucho más rápido)
# %timeit df_test.apply(lambda row: heavy_computation(row['A'], row['B']), axis=1)

3. 📊 Operaciones en Múltiples Columnas

Cuando realizas operaciones que involucran varias columnas, es más eficiente operar directamente en las Series de Pandas en lugar de iterar.

# Sumar dos columnas
df_grande['suma_cols'] = df_grande['col_int'] + df_grande['col_float']

# Múltiples condiciones
df_grande['condicion'] = (df_grande['col_int'] > 50000) & (df_grande['col_float'] < 50000)

4. ⏩ Uso de df.eval() y df.query()

Para operaciones complejas que involucran varias columnas o expresiones booleanas, eval() y query() pueden ser más rápidos que las operaciones directas porque utilizan el motor numexpr para optimizar los cálculos.

# Ejemplo con query
df_filtrado_query = df_grande.query('col_int > 50000 and col_float < 50000')

# Equivalente sin query (generalmente un poco más lento para complejos)
df_filtrado_normal = df_grande[(df_grande['col_int'] > 50000) & (df_grande['col_float'] < 50000)]

# Ejemplo con eval
df_grande['col_eval'] = df_grande.eval('col_int * col_float / 100')

5. 📉 Resumen de Rendimiento de Operaciones

Aquí tienes una tabla comparativa de la eficiencia de diferentes métodos para aplicar funciones:

MétodoEficienciaCuándo usar
Operaciones VectorizadasMuy altaSiempre que sea posible para operaciones aritméticas, lógicas, etc.
NumPy UfuncsMuy altaPara funciones matemáticas y lógicas que operan elemento a elemento.
df.eval() / df.query()AltaPara expresiones complejas que involucran múltiples columnas.
df.apply() (axis=0)MediaPara funciones que operan en columnas completas.
df.apply() (axis=1)BajaCuando la operación es compleja y necesita acceso a toda la fila; considera numba.
df.iterrows() / for loopMuy bajaEvitar siempre. Para debugging o tareas muy pequeñas.
🔥 Importante: La vectorización es tu mejor amiga. Siempre busca una forma de realizar operaciones en Series o DataFrames completos en lugar de elemento por elemento.

✨ Casos de Uso Avanzados y Consideraciones

Manejo de Valores Faltantes (NaN)

Los valores NaN en columnas enteras (int) obligan a Pandas a usar tipos flotantes (float64) para acomodar np.nan. Si tienes muchos NaN en columnas de enteros y la precisión no es un problema, considera usar los tipos Int64 (con I mayúscula) introducidos en Pandas 0.24, que soportan NaN en enteros sin convertirlos a float.

# Crear una columna con NaNs en int
df_nan_int = pd.DataFrame({
    'id': [1, 2, 3, 4, 5],
    'valor': [10, 20, np.nan, 40, 50]
})

print("DataFrame con NaN en entero (tipo float por defecto):")
print(df_nan_int.info())

# Convertir a tipo Int64 (requiere Pandas >= 0.24)
df_nan_int['valor'] = df_nan_int['valor'].astype('Int64')

print("\nDataFrame con NaN en entero (tipo Int64):")
print(df_nan_int.info())

Bloques de Memoria Contigua

Pandas intenta almacenar columnas del mismo dtype en bloques de memoria contigua. Esto es más eficiente que tener cada columna como un bloque separado. Reordenar tus columnas para agrupar las del mismo tipo puede, en teoría, mejorar ligeramente el rendimiento, aunque Pandas ya maneja esto de forma inteligente.

DataFrame Estructura de Datos lógica int8 col1 col2 float32 col3 col4 category col5 col6 Bloques de Memoria Contiguos (Block Manager)

Uso de Dask para DataFrames Más Grandes

Cuando tus DataFrames exceden la memoria RAM de una sola máquina, Pandas por sí solo no es suficiente. Aquí es donde entran herramientas como Dask DataFrames, que emulan la API de Pandas pero operan en conjuntos de datos distribuidos o que no caben en memoria.

90% de Datos - Pandas
10% de Datos - Dask

Este gráfico representa que, aunque Pandas es excelente para la mayoría de los casos, para un 10% (o más) de datos muy grandes, Dask es la herramienta adecuada.


💡 Ejercicio Práctico: Optimización Completa de un DataFrame

Vamos a aplicar todas las técnicas aprendidas a un DataFrame simulado, pero más grande.

# Generar un DataFrame simulado de 5 millones de filas
num_filas = 5_000_000
df_ejercicio = pd.DataFrame({
    'user_id': np.random.randint(1, 100000, num_filas), # IDs de usuario
    'transaction_amount': np.random.rand(num_filas) * 10000, # Monto de transacción
    'item_category': np.random.choice(['Electronics', 'Books', 'Clothing', 'Food', 'Home'], num_filas), # Categoría del artículo
    'timestamp': pd.to_datetime(pd.date_range('2020-01-01', periods=num_filas, freq='min') + pd.to_timedelta(np.random.randint(0, 60, num_filas), unit='s')),
    'is_returned': np.random.choice([True, False, np.nan], num_filas, p=[0.1, 0.8, 0.1]) # Si fue devuelto, con NaNs
})

print("\n--- DataFrame Inicial ---")
print(df_ejercicio.info(memory_usage='deep'))

# Función de optimización que combina las anteriores y añade manejo de NaNs en enteros
def optimizar_df(df):
    df_opt = df.copy()

    # 1. Optimizar numéricos
    for col in df_opt.select_dtypes(include=np.number).columns:
        if pd.api.types.is_integer_dtype(df_opt[col]):
            # Si hay NaN, intentar convertir a tipo entero nullable
            if df_opt[col].isnull().any():
                df_opt[col] = df_opt[col].astype('Int64') # Notar la 'I' mayúscula
            else:
                min_val = df_opt[col].min()
                max_val = df_opt[col].max()
                if min_val >= 0:
                    if max_val < 255: df_opt[col] = df_opt[col].astype(np.uint8)
                    elif max_val < 65535: df_opt[col] = df_opt[col].astype(np.uint16)
                    elif max_val < 4294967295: df_opt[col] = df_opt[col].astype(np.uint32)
                else:
                    if min_val > np.iinfo(np.int8).min and max_val < np.iinfo(np.int8).max: df_opt[col] = df_opt[col].astype(np.int8)
                    elif min_val > np.iinfo(np.int16).min and max_val < np.iinfo(np.int16).max: df_opt[col] = df_opt[col].astype(np.int16)
                    elif min_val > np.iinfo(np.int32).min and max_val < np.iinfo(np.int32).max: df_opt[col] = df_opt[col].astype(np.int32)
        elif pd.api.types.is_float_dtype(df_opt[col]):
            df_opt[col] = df_opt[col].astype(np.float32) # Generalmente suficiente

    # 2. Optimizar objetos a categoría
    for col in df_opt.select_dtypes(include='object').columns:
        num_unique_values = len(df_opt[col].unique())
        num_total_values = len(df_opt[col])
        if num_unique_values / num_total_values < 0.5: # Umbral razonable
            df_opt[col] = df_opt[col].astype('category')

    # 3. Optimizar booleans con NaNs (si aplica)
    for col in df_opt.select_dtypes(include='object').columns:
        if df_opt[col].isin([True, False, np.nan]).all():
            df_opt[col] = df_opt[col].astype('boolean') # Pandas nullable boolean type

    return df_opt

# Ejecutar la optimización
df_ejercicio_optimizado = optimizar_df(df_ejercicio)

print("\n--- DataFrame Optimizado ---")
print(df_ejercicio_optimizado.info(memory_usage='deep'))

# Comparación final
mem_inicial_ej = df_ejercicio.memory_usage(deep=True).sum()
mem_optimizada_ej = df_ejercicio_optimizado.memory_usage(deep=True).sum()

print(f"\nMemoria inicial: {mem_inicial_ej / (1024**2):.2f} MB")
print(f"Memoria optimizada: {mem_optimizada_ej / (1024**2):.2f} MB")
print(f"Reducción de memoria: {((mem_inicial_ej - mem_optimizada_ej) / mem_inicial_ej * 100):.2f}%")

# Guardar y cargar para verificar
df_ejercicio_optimizado.to_parquet('df_ejercicio_optimizado.parquet', index=False)
print("\nDataFrame guardado en 'df_ejercicio_optimizado.parquet'")

Este ejercicio demuestra una reducción significativa en el uso de memoria, haciendo que el DataFrame sea mucho más manejable y las operaciones posteriores más rápidas.


📝 Resumen y Mejores Prácticas

La optimización de DataFrames en Pandas es un arte y una ciencia. Aquí te dejo un resumen de las mejores prácticas:

  • Tipos de Datos: Siempre reduce los tipos numéricos y convierte cadenas repetitivas a category.
  • Carga Selectiva: Usa usecols para cargar solo las columnas que necesitas.
  • Formato de Archivo: Prefiere Parquet o Feather sobre CSV para almacenamiento.
  • Vectorización: ¡Evita los bucles for! Abraza las operaciones vectorizadas.
  • eval() y query(): Para expresiones complejas, pueden ofrecer mejoras de rendimiento.
  • Considera Dask: Cuando los datos superan tu RAM, Dask es el siguiente paso.

Con estas estrategias, estarás bien equipado para manejar incluso los conjuntos de datos más desafiantes en Pandas, transformando problemas de rendimiento en análisis fluidos y eficientes. ¡Feliz análisis de datos! 🚀

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!