Herramientas de optimiauzación en Python

Optimiza tu código en Python sin perder ni un bytegote

Herramientas de optimiauzación en Python

Principios generales de la optimización: ¿Qué estamos afinando?

Antes de abrir un profiler o reemplazar tu precioso for por un generador oscuro, conviene detenerse a reflexionar: ¿por qué y para qué quieres optimizar tu código? Optimizar no es simplemente hacerlo más rápido: también puede significar que consuma menos memoria, que sea más eficiente energéticamente, o que escale mejor cuando aumentan los datos o los usuarios. Como todo buen gato sabe, no se trata solo de correr más rápido, sino de moverse con elegancia y precisión.

Tipos de optimización:

  1. Optimización del tiempo de ejecución: reducir el tiempo que tarda el código en completar una tarea. Es el tipo más común y se mide fácilmente con herramientas como timeit, cProfile o incluso perf_counter.
  2. Optimización del uso de memoria: evitar que el programa consuma RAM innecesaria, algo crucial cuando se trabaja con grandes volúmenes de datos o en entornos limitados (por ejemplo, procesamiento de datos en streaming, servidores con contenedores, etc.).
  3. Optimización de recursos de hardware (CPU, disco, red): puede que tu script no sea lento por el código en sí, sino por estar saturando la CPU con cálculos innecesarios o bloqueando la red con lecturas pesadas. Aquí entra en juego el concepto de bound (más abajo lo explicamos).
  4. Optimización algorítmica: implica repensar el enfoque general. A veces, una mejora real no pasa por trucos de bajo nivel, sino por cambiar el algoritmo o la estructura de datos que usas. Si tienes un algoritmo de orden O(n^2) cuando podría ser O(n log n), ningún jit te salvará del desastre.
  5. Optimización de escalabilidad: tu código puede funcionar bien con 1000 elementos, pero colapsar con un millón. En producción, este tipo de optimización es la que separa a los scripts de juguete de los sistemas robustos.

Optimizar sin medir es un error clásico

Una de las mayores trampas para desarrolladores con buenas intenciones es lanzarse a optimizar partes del código que parecen lentas… sin verificar si realmente lo son. Esta práctica, conocida como premature optimization, ha sido denunciada desde los años 70 (Donald Knuth la llamó “the root of all evil”). La solución no es ignorar el rendimiento, sino medir primero y actuar después.

Esto se traduce en:

  • No optimices por intuición: el fragmento que parece lento podría estar ejecutándose una sola vez.
  • Mide el rendimiento real: con herramientas de profiling (ver apartado 2), y no solo con prints o cronómetros mentales.
  • Evalúa el impacto de la optimización: ¿cuánto mejora? ¿vale la pena el coste en legibilidad o mantenimiento?

Boundaries: ¿CPU-bound o I/O-bound?

Entender el tipo de cuello de botella es esencial para saber cómo optimizar. Estas son las dos grandes categorías:

Tipo de cargaCaracterísticasEstrategia típica
CPU-boundEl proceso está limitado por la velocidad del procesadorParalelismo real (multiprocessing, numba)
I/O-boundEl proceso espera lectura/escritura en disco, red, etc.Asincronía (asyncio, threading)

Ejemplo:

  • Si estás procesando imágenes pixel a pixel → CPU-bound.
  • Si estás leyendo archivos grandes o esperando a una API → I/O-bound.

Elegir mal la estrategia puede hacer que “optimices” sin notar mejora real (¡o incluso ralentices el sistema!).

🐈‍⬛ Moraleja gatuna de esta sección:

Un gato no salta sin saber dónde va a caer. Tú tampoco deberías optimizar sin medir.

Created by potrace 1.15, written by Peter Selinger 2001-2017 Michidato curioso

Los gatos tienen dificultades para ver a menos de 30 cm, pero los bigotes compensan esta limitación de la vista captando los diferentes estímulos y siendo capaces de ofrecerles una imagen 3D de lo que tienen delante.

Benchmarking y profiling: mide antes de saltar

En esta sección vamos a medir el rendimiento de tu código de forma precisa, tanto en términos de tiempo de ejecución como de consumo de recursos, para identificar cuellos de botella reales. Esto te permite centrar tus esfuerzos donde realmente importa. 

Benchmarking: medir tiempos de ejecución

Opción 1: timeit

Ideal para comparar fragmentos de código pequeños y ver cuál es más rápido.

import timeit

tiempo = timeit.timeit("sum(range(1000))", number=10000)
print(tiempo)
  • El parámetro number indica cuántas veces se repite el fragmento.
  • Internamente, timeit se ocupa de eliminar interferencias (como carga inicial del intérprete o variabilidad del sistema operativo).

Consejo felino: Usa timeit para comparar soluciones concretas, no para analizar todo un script.

Opción 2: perf_counter

Útil para medir tramos más largos o procesos reales.

from time import perf_counter

inicio = perf_counter()
mi_funcion_pesada()
fin = perf_counter()

print(f"Tardó {fin - inicio:.4f} segundos")

A diferencia de time.time(), perf_counter() tiene mayor resolución y es inmune a cambios del reloj del sistema.

Profiling: encontrar los cuellos de botella

Benchmarking te dice cuánto tarda algo. El profiling te dice dónde está el problema.

Herramientas principales:

HerramientaQué mideIdeal para...
cProfileTiempo por funciónScripts completos
line_profilerTiempo por línea de códigoAnálisis fino de funciones
memory_profilerConsumo de memoria por líneaScripts pesados en RAM
py-spy, scaleneSampling sin modificar códigoAnálisis en producción / live

Ejemplo básico con cProfile:

import cProfile

def calcular():
    total = 0
    for i in range(10000):
        total += i ** 0.5
    return total

cProfile.run("calcular()")

La salida muestra:

  • Tiempo total de ejecución.
  • Número de llamadas a cada función.
  • Tiempo acumulado y por llamada.

👉 Si ves que una función se ejecuta 100.000 veces pero no lo esperabas... ahí tienes un indicio.

Ejemplo más preciso con line_profiler

Instalación:

pip install line_profiler

Marcamos con decorador:

@profile
def calcular():
    total = 0
    for i in range(10000):
        total += i ** 0.5
    return total

Se ejecuta así:

kernprof -l -v mi_script.py

Te muestra línea a línea el tiempo exacto de ejecución, y la proporción sobre el total.

Memory profiling

Cuando el problema es la RAM, memory_profiler te da visibilidad sobre el consumo por línea.

pip install memory_profiler

Decorador y ejecución:

from memory_profiler import profile

@profile
def mi_funcion():
    ...
python -m memory_profiler mi_script.py

Especialmente útil con pandas, numpy, listas grandes o cuando tienes leaks por referencias circulares.

Bonus: profiling sin modificar tu código

Para analizar scripts ya escritos o en producción sin editar, existen herramientas como:

py-spy

pip install py-spy
py-spy top --pid 
  • Muestra funciones más costosas en tiempo real.
  • No necesita modificar el script.
  • Compatible con Docker y producción.

scalene

Otra joya moderna: mide CPU, memoria y tiempo, todo en uno.

pip install scalene
scalene mi_script.py

Con esta biblioteca podrás generar un informe coloreado, detallado y legible.

Complejidad algorítmica y estructuras de datos: Que no se te escape el gato por un O(n²)

Cuando hablamos de optimización, muchas personas piensan inmediatamente en bucles, bibliotecas mágicas o en usar multiprocessing para todo. Pero lo cierto es que la optimización más efectiva empieza en el diseño. Elegir la estructura de datos adecuada y comprender la complejidad algorítmica de tu enfoque es, en muchos casos, el mayor salto de rendimiento que puedes conseguir.

🐾 Como diría cualquier felino sabio: No se trata solo de correr más rápido, sino de elegir el camino más corto al cuenco de comida.

¿Qué es la complejidad algorítmica y por qué deberías prestarle atención?

La complejidad algorítmica describe cómo cambia el tiempo de ejecución o el uso de memoria de un algoritmo a medida que aumenta el tamaño de los datos. Se expresa en notación Big-O, que no indica tiempo real, sino cómo escala el algoritmo:

NotaciónNombreDescripción breveEjemplo común
O(1)ConstanteSiempre tarda lo mismoAcceso a índice de una lista
O(log n)LogarítmicaReduce a la mitad en cada pasoBúsqueda binaria
O(n)LinealRecorre todos los elementosSuma de lista
O(n log n)CuasilinealDivide y conquista (mezclas, árboles)Merge sort
O(n²)CuadráticaComparaciones entre todosBúsqueda en parejas
O(2ⁿ)ExponencialMuy lento con entradas grandesFuerza bruta en combinatoria

Estructuras de datos y su impacto en el rendimiento

No todas las estructuras de datos están diseñadas para lo mismo. Elegir la errónea puede convertir una operación trivial en un desastre de rendimiento. Aquí tienes una tabla con las operaciones más frecuentes:

Operaciónlistsetdictdeque
Acceso por índiceO(1)O(1)O(1)
BúsquedaO(n)O(1)O(1)O(n)
Inserción al finalO(1)O(1)O(1)O(1)
EliminaciónO(n)O(1)O(1)O(1)
Inserción al principioO(n)O(1)O(1)O(1)

🔎 Consejo felino: Si haces muchas búsquedas, no uses listas. Si accedes mucho por clave, dict. Si trabajas por extremos, deque.

Ejemplos comparativos

Caso 1: búsqueda eficiente

# Lento: búsqueda en lista
nombres = ['Vera', 'Salem', 'Nibi', 'Paquísimo']
if 'Vera' in nombres:  # O(n)
    print("¡Está!")

# Rápido: búsqueda en set
nombres = {'Vera', 'Salem', 'Nibi', 'Paquísimo'}
if 'Vera' in nombres:  # O(1)
    print("¡Está!")

Caso 2: agrupación dinámica

# Sin defaultdict
agrupados = {}
for animal, tipo in [('Vera', 'reina gata'), ('Paquísimo', '¿dios gato?')]:
    if tipo not in agrupados:
        agrupados[tipo] = []
    agrupados[tipo].append(animal)

# Con defaultdict
from collections import defaultdict
agrupados = defaultdict(list)
for animal, tipo in [('Vera', 'reina gata'), ('Paquísimo', '¿dios gato?')]:
    agrupados[tipo].append(animal)

Menos líneas, menos errores, más eficiente.

Cuando el algoritmo es el verdadero cuello de botella

Imagina que tienes un script que compara cada elemento de una lista con todos los demás:

# Comparación doble - O(n²)
for i in range(len(data)):
    for j in range(len(data)):
        if i != j and data[i] == data[j]:
            print("Duplicado encontrado")

Esto funciona para 100 elementos. Para 10.000, no. Una forma mejor:

# Comparación eficiente - O(n)
vistos = set()
for item in data:
    if item in vistos:
        print("Duplicado encontrado")
    vistos.add(item)

Este tipo de cambio puede reducir tiempos de ejecución de minutos a milisegundos.

Otros recursos felinos en Python

  • collections.Counter: contar elementos sin necesidad de bucles anidados.
  • heapq: para colas de prioridad sin ordenar listas completas.
  • array.array: para listas de enteros o floats con menor uso de memoria.
  • bisect: búsqueda binaria sobre listas ordenadas.
  • namedtuple y dataclass: estructuras ligeras que mejoran legibilidad y eficiencia.

🐾 Un gato eficiente no persigue la misma mariposa dos veces. Usa una trampa mejor diseñada.

Vectorización total: deja que numpy ronronee por ti

Si Python fuera una pandilla de gatos, los bucles for serían los gatitos callejeros: simpáticos, accesibles… pero algo ruidosos y lentos. En cambio, numpy sería el gato elegante que vive sobre el teclado, rodeado de eficiencia en C: no hace ruido, pero hace magia.

La vectorización consiste en sustituir bucles explícitos por operaciones que se aplican en bloque sobre arrays. En lugar de decirle a Python “haz esto para cada elemento”, le dices “haz esto sobre todo el array”. Y como numpy usa código en C muy optimizado, eso se traduce en muuuucha velocidad.

¿Qué hace a numpy tan rápido?

  1. Implementación en C: las operaciones vectorizadas no pasan por el intérprete de Python.
  2. Procesamiento en bloque: accede a la memoria de forma contigua, evitando saltos de dirección.
  3. Broadcasting: permite operar entre arrays de diferentes formas automáticamente.
  4. Operaciones in-place: reduce copias de memoria y reutiliza buffers.

🧪 Ejemplo comparativo: suma de dos arrays

Veamos el clásico:

# Python puro (loop explícito)
result = []
for i in range(len(a)):
    result.append(a[i] + b[i])

Ahora en modo vectorizado:

# numpy vectorizado
import numpy as np

a = np.arange(10**6)
b = np.arange(10**6)
c = a + b

Benchmark comparativo:

import time
import numpy as np

# Datos
N = 10**6
a = np.arange(N)
b = np.arange(N)

# Loop clásico
start = time.perf_counter()
resultado = [a[i] + b[i] for i in range(N)]
t_loop = time.perf_counter() - start

# Vectorizado
start = time.perf_counter()
resultado_np = a + b
t_vectorizado = time.perf_counter() - start

print(f"Loop: {t_loop:.4f} s")
print(f"Vectorizado: {t_vectorizado:.4f} s")

Visualización (simulada):

MétodoTiempo (s)
Loop en Python0.2200
Vectorizado0.0021

¡Un bucle más de 100 veces más lento que su equivalente en numpy!

Otras operaciones vectorizables comunes

  • Multiplicación de vectores: a * b
  • Potencias: a ** 2
  • Operaciones condicionales: a[a > 0]
  • Transformaciones: np.sqrt(a), np.log1p(a)
  • Comparaciones: (a > b) & (b < 10)

Broadcasting: como un gato que se adapta a cualquier recipiente donde echar una siesta

El broadcasting permite realizar operaciones entre arrays de diferentes formas, ampliando automáticamente dimensiones cuando es posible.

a = np.array([[1], [2], [3]])  # Forma (3, 1)
b = np.array([10, 20, 30])     # Forma (3,)
resultado = a * b  # Se ajustan a (3, 3)

Esto elimina la necesidad de bucles anidados y mejora la legibilidad del código.

Operaciones in-place: menos memoria, más velocidad

Muchas operaciones en numpy permiten evitar crear una copia:

a *= 2  # Doble en el mismo array

Esto no solo es más rápido, sino que reduce el uso de memoria, crucial en datasets grandes.

Comparación extendida con Python puro

Sin vectorizar

def sin_vectorizar():
    a = list(range(10**6))
    b = list(range(10**6))
    return [x + y for x, y in zip(a, b)]

Vectorizado

def vectorizado():
    a = np.arange(10**6)
    b = np.arange(10**6)
    return a + b

⚠️ Aunque el resultado sea el mismo, en términos de recursos utilizados hay una diferencia abismal.

Benchmark extendido con perfplot

Puedes usar perfplot para generar gráficos como este:

import perfplot
import numpy as np

perfplot.show(
    setup=lambda n: (np.arange(n), np.arange(n)),
    kernels=[
        lambda a_b: [x + y for x, y in zip(*a_b)],
        lambda a_b: a_b[0] + a_b[1],
    ],
    labels=["Python loop", "Vectorizado"],
    n_range=[2**k for k in range(10, 22)],
    xlabel="Tamaño del array (n)",
    logx=True,
    logy=True,
    equality_check=np.allclose,
)

📈 El gráfico resultante muestra cómo el tiempo de ejecución del método vectorizado escala mucho mejor en grandes volúmenes de datos.

Cuando se trata de rendimiento numérico, numpy es tu mejor aliado. Es como ese gato que parece tranquilo, pero salta a tres metros cuando le conviene. Vectorizar no solo te da velocidad, sino también limpieza, elegancia y eficiencia en memoria.

  • Si puedes expresarlo sin bucles, hazlo.
  • Si puedes evitar .append(), mejor.
  • Si puedes usar operaciones in-place, gato feliz asegurado.

Concurrencia, paralelismo y asincronía: divide y vencerás… o no

¿Has visto alguna vez a un grupo de gatos cazando juntos? No lo hacen. Cada uno va a lo suyo, pero si lo piensas... todos están sincronizados con el objetivo final: eficiencia máxima sin pisarse las colas.

En Python, optimizar el rendimiento no siempre implica acelerar un bucle, sino organizar las tareas de forma más inteligente. Aquí entran en juego tres estrategias clave: concurrencia, paralelismo y asincronía.

¿En qué se diferencian?

Técnica¿Qué hace?Ideal para...Afectado por el GIL
threadingEjecuta múltiples hilos en un solo núcleo (conmutación rápida)Tareas I/O-bound✅ Sí
multiprocessingEjecuta procesos separados, cada uno con su propio núcleoTareas CPU-bound❌ No
asyncioUsa un único hilo con event loop no bloqueanteI/O intensivo estructurado❌ No (pero un solo hilo)

¿Qué es el GIL y por qué nos importa?

El Global Interpreter Lock (GIL) es un mecanismo de CPython que impide que múltiples hilos ejecuten bytecode Python al mismo tiempo. Esto significa que en operaciones CPU-bound, usar threading no acelera nada… e incluso puede empeorarlo.

Para tareas intensivas de cálculo → multiprocessing
Para tareas que esperan mucho (APIs, ficheros, bases de datos) → threading o asyncio

⚙️ Ejemplo práctico: CPU-bound (cálculo intensivo)

Supón que tienes que elevar al cuadrado un millón de números:

def cuadrado(x): return x * x

Con un bucle clásico:

resultados = [cuadrado(x) for x in range(10**6)]

Con multiprocessing:

from multiprocessing import Pool

with Pool() as pool:
    resultados = pool.map(cuadrado, range(10**6))

📈 Benchmark orientativo:

Núcleos usadosTiempo (s) estimado
1 (bucle)1.10
4 (pool)0.35
8 (pool)0.20

✅ Escala bien con más núcleos (ideal en máquinas multi-core o servidores).

Ejemplo práctico: I/O-bound (esperas de red)

Descargar muchas URLs al mismo tiempo:

import requests

def descargar(url):
    return requests.get(url).text

Con threading (esperas de red en paralelo):

from concurrent.futures import ThreadPoolExecutor

urls = ["https://example.com"] * 10

with ThreadPoolExecutor() as executor:
    resultados = list(executor.map(descargar, urls))

Con asyncio (requiere librerías async como aiohttp):

import aiohttp
import asyncio

async def descargar(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.text()

async def main():
    urls = ["https://example.com"] * 10
    tareas = [descargar(url) for url in urls]
    resultados = await asyncio.gather(*tareas)

asyncio.run(main())

Ojo: asyncio requiere que todo el stack sea asincrónico. No es un simple “copiar y pegar”.

Comparación resumida

EscenarioMejor opciónJustificación
Leer miles de archivos pequeñosthreadingMuchos bloqueos por disco
Calcular transformadas de FouriermultiprocessingCPU-bound, se benefician de núcleos reales
Consultar múltiples APIsasyncioMuchas esperas de red, sin bloqueo real
Web scraping con esperasthreading o asyncioDepende del stack usado
ETL con pandas + procesamientomultiprocessingpandas no es thread-safe

Visualización: escalado de rendimiento

Imagina este gráfico lineal (simulado) donde se ve cómo disminuye el tiempo con más núcleos:

NúcleosTiempo sincrónico (s)Tiempo paralelo (s)
11010
25.2
42.7
81.4

🧪 Visualización sugerida: usa perfplot o matplotlib para representar esta mejora, especialmente útil si estás enseñando optimización en un entorno académico o corporativo.

🐾 Buenas prácticas felinas

  • No mezcles threading y multiprocessing sin saber lo que haces: pueden surgir bloqueos y condiciones de carrera.
  • asyncio brilla cuando tu código espera mucho pero trabaja poco.
  • Para paralelizar código con pandas, usa swifter, modin o dask en lugar de apply a secas.
  • Siempre mide: a veces paralelizar empeora el rendimiento en scripts pequeños.

Concurrencia, paralelismo y asincronía no son lo mismo, pero cada uno tiene su momento. Un desarrollador experto —o un gato muy avispado— sabe cuándo ronronear con calma y cuándo sacar todas las garras del multiprocesamiento.

👉 No necesitas hacer todo más rápido, necesitas hacer cada parte en su terreno ideal.

Memoria: ¿cuánto pesa tu gato?

La eficiencia no siempre va de velocidad. A veces, optimizar significa reducir el consumo de memoria, especialmente en entornos con recursos limitados, servidores compartidos, pipelines de datos pesados o notebooks que colapsan con un MemoryError en la celda 17.

Python es cómodo, flexible… pero no es el lenguaje más ligero en cuanto a consumo de RAM. De hecho, su gestión automática de memoria (con recolección de basura y objetos dinámicos) puede generar copias innecesarias, referencias perdidas y estructuras pesadas si no se controlan.

¿Por qué puede tu código estar usando más memoria de la cuenta?

  • Estás copiando listas y diccionarios en lugar de trabajar con referencias.
  • Usas estructuras dinámicas cuando podrías usar arrays compactos.
  • Mantienes referencias circulares que impiden liberar objetos.
  • Estás leyendo archivos enteros en memoria en lugar de procesarlos en streaming.
  • Usas pandas sin ajustar tipos de datos, lo que puede multiplicar por 4 el tamaño.

🧪 Herramientas para analizar memoria

Herramienta¿Qué hace?Nivel
sys.getsizeof()Tamaño aproximado en bytes de un objetoBásico
memory_profilerMedición por línea de consumo de memoriaIntermedio
tracemallocRastreo detallado de asignacionesAvanzado
gcAcceso al recolector de basuraInterno

Ejemplo con memory_profiler

Instalación:

pip install memory_profiler

Uso:

from memory_profiler import profile

@profile
def cargar_datos():
    data = [x * 2 for x in range(10**6)]
    return data

cargar_datos()

🧠 Resultado: te muestra línea a línea cuántos MB ocupas, y en qué línea sube.

Ejemplo con tracemalloc

import tracemalloc

tracemalloc.start()

# código sospechoso
data = [x**2 for x in range(1000000)]

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:5]:
    print(stat)

🔎 Ideal para proyectos grandes: puedes ver en qué archivo y línea se disparan las asignaciones.

Técnicas para reducir el consumo de memoria

1. Usa generadores

Evita listas en memoria cuando no necesitas todos los elementos a la vez.

# En lugar de:
resultados = [procesar(x) for x in datos]

# Usa:
def generar_resultados(datos):
    for x in datos:
        yield procesar(x)

Esto permite “ir generando sobre la marcha”, como si el programa comiera grano a grano.

2. Clases ligeras con __slots__

Las clases normales en Python tienen un diccionario interno (__dict__) para almacenar atributos, lo que consume memoria innecesaria si sabes de antemano los campos.

class Gato:
    __slots__ = ['nombre', 'peso']

    def __init__(self, nombre, peso):
        self.nombre = nombre
        self.peso = peso

Esto puede reducir el uso de memoria por instancia en más del 40% si tienes millones de objetos.

3. Evita copias innecesarias

# Mal
datos_filtrados = list(filter(condicion, datos))
datos_copia = datos_filtrados.copy()

# Bien
datos_filtrados = [x for x in datos if condicion(x)]  # Ya es nueva lista

.copy() puede duplicar estructuras de cientos de MB si no se necesita.

4. Usa gc.collect() si gestionas objetos manualmente

Cuando trabajas con estructuras grandes que ya no usas (como grafos o árboles con referencias circulares), puedes forzar la liberación:

import gc

del estructura_pesada
gc.collect()

Especialmente útil si estás procesando por lotes dentro de un for.

5. Ajusta tipos en pandas

Una columna int64 con valores del 1 al 100 cabe perfectamente en int8. Lo mismo para floats o categóricos.

df['columna'] = df['columna'].astype('int8')
df['ciudad'] = df['ciudad'].astype('category')

Esto puede reducir el tamaño de un DataFrame en un 70-90% fácilmente.

Comparación de estructuras (orientativa)

EstructuraTamaño (10⁶ enteros)RAM estimada
list10⁶~45 MB
numpy array10⁶~8 MB
array.array10⁶~4 MB
generator10⁶~0.1 MB

🔧 Usa array.array('i') para enteros planos si no necesitas operaciones matemáticas pesadas.

🐾 Buenas prácticas felinas para ahorrar RAM

  • Procesa archivos línea a línea (with open(...)) y evita read().
  • Divide cargas grandes en lotes (chunking).
  • Usa yield siempre que puedas en transformaciones.
  • Borra estructuras temporales si ya no las necesitas (del).
  • Piensa antes de convertir cosas en list() solo para “verlas”.

Optimizar memoria no es solo para quien trabaja con big data. Es una forma de hacer tu código más elegante, más estable y más profesional. Un buen programa, como un gato bien cepillado, no deja pelos por todas partes.

🔄 Reutiliza, no repitas.

♻️ Transforma, no acumules.

⚖️ Y sobre todo, piensa antes de asumir que “tampoco es para tanto”.

Reutilización y caching inteligente: no le des mil vueltas al mismo ratón

Si hay algo que los gatos no hacen, es repetir esfuerzos innecesarios. Si han encontrado el camino a la comida una vez, lo recuerdan. En Python, podemos hacer lo mismo: evitar repetir cálculos costosos almacenando sus resultados.

Este proceso se llama caching o memoización. Permite ganar tiempo a costa de memoria, guardando el resultado de funciones para no recalcularlo si se vuelven a invocar con los mismos argumentos.

¿Cuándo aplicar caching?

✅ Tareas deterministas: una función que devuelve siempre lo mismo para la misma entrada.

✅ Funciones recursivas o muy repetitivas.

✅ Operaciones costosas que no cambian en el tiempo.

✅ Procesos que acceden a fuentes externas lentas (como APIs o discos).

❌ No lo uses si los datos cambian entre llamadas.

❌ Cuidado con resultados grandes: la caché se guarda en memoria.

Técnicas principales de cacheo

Técnica¿Dónde se guarda?VentajasInconvenientes
functools.lru_cacheMemoria RAMFácil, automáticoNo controlas el espacio usado
cachetools.TTLCacheRAM + caducidadExpira entradas automáticamenteNecesita instalación extra
joblib.MemoryDisco localPersiste entre sesionesMás lento que RAM
Caché manual (dict)RAMFlexibleMás código, más errores

1. Caching con functools.lru_cache

lru_cache (Least Recently Used) es parte de la librería estándar. Memoriza los últimos resultados y los reutiliza si se repite una llamada.

from functools import lru_cache

@lru_cache(maxsize=128)  # o None para tamaño ilimitado
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

🚀 Con @lru_cache, la complejidad de fibonacci(n) pasa de exponencial a lineal. Un cambio brutal.

📊 Benchmark simple:

FunciónTiempo con n=35
Sin caché5.0 s
Con lru_cache0.001 s

2. Caché manual con dict

Si necesitas más control (como invalidar entradas), puedes usar tu propia caché:

cache = {}

def expensive(x):
    if x in cache:
        return cache[x]
    resultado = x * 42  # cálculo costoso simulado
    cache[x] = resultado
    return resultado

🧪 Ventaja: puedes borrar, limpiar o guardar en disco lo que necesites.

  3. Expiración con cachetools.TTLCache

A veces no quieres guardar los resultados para siempre. Para eso, existen caches con Time To Live.

pip install cachetools
from cachetools import TTLCache

cache = TTLCache(maxsize=100, ttl=300)  # 5 minutos

def get_precio(producto):
    if producto in cache:
        return cache[producto]
    precio = calcular_precio(producto)
    cache[producto] = precio
    return precio

Esto es útil para datos que cambian a lo largo del día pero no cada segundo (como el clima, cotizaciones, etc.).

4. Caching persistente con joblib.Memory

Ideal para scripts largos o notebooks donde quieres evitar repetir cálculos costosos entre ejecuciones (incluso tras cerrar el programa).

from joblib import Memory

memoria = Memory("./.cachedir", verbose=0)

@memoria.cache
def cargar_dataset_grande():
    # Simular carga pesada
    import time
    time.sleep(2)
    return [i**2 for i in range(1000000)]

🧠 Al segundo intento, la función se devuelve al instante, incluso tras reiniciar el kernel.

🐾Al usar caché...

  • Limpieza: si usas mucha caché, controla el tamaño para no agotar la RAM.
  • Evita caché para funciones con side effects (como escritura en disco).
  • No caches funciones que acceden a estados cambiantes, como datetime.now(), input() o random.random() (salvo que quieras resultados congelados).
  • No abuses del almacenamiento en disco: puedes acabar con miles de archivos si cacheas con argumentos variables (como objetos complejos o rutas).

Recuerda: una caché mal gestionada puede convertirse en un problema. No todos los recuerdos merecen guardarse… ni siquiera los de un gato.

Pandas a tope: velocidad y memoria en big data

Pandas es como ese gato que parece tranquilo en el sofá, pero puede transformarse en una máquina salvaje si lo agobias demasiado. Es una librería poderosa para el análisis de datos, pero su rendimiento puede degradarse drásticamente si no se usa con cabeza.

Created by potrace 1.15, written by Peter Selinger 2001-2017 Michidato curioso

El primer commit de pandas pesaba solo 213 líneas Fue creado por Wes McKinney en 2008 mientras trabajaba en una empresa de trading. Hoy es una de las bibliotecas más influyentes en ciencia de datos, y su repo supera los 2.000 archivos y 1 millón de líneas entre código, docs y tests.

¿Qué hace lento a pandas?

  • Uso excesivo de .apply() y funciones lambda.
  • Iteración fila a fila con iterrows() (⚠️ nunca lo uses en producción).
  • No ajustar los tipos de datos (int64, object, float64 por defecto).
  • Uniones (merge, join) mal planteadas o sin índices.
  • Copias innecesarias de columnas o DataFrames.

❌ Evita estas prácticas

1. .apply() con lambda

# Lento y peligroso
df['nuevo'] = df.apply(lambda row: row['col1'] + row['col2'], axis=1)

🐢 Esto fuerza a pandas a entrar en modo “fila a fila”, perdiendo toda la eficiencia de los arrays de numpy.

Mejor: vectorizar la operación

df['nuevo'] = df['col1'] + df['col2']

2. iterrows() o for row in df.iterrows()

# Este patrón es equivalente a usar Excel... con palillos
for _, row in df.iterrows():
    procesar(row['valor'])

Internamente, convierte cada fila en una serie separada (con conversión de tipos), lo que es lentísimo.

Alternativas:

  • Operaciones vectorizadas.
  • itertuples() (más rápido que iterrows() si necesitas iterar).
  • Procesamiento externo con numpy o polars si la carga es masiva.

Vectoriza siempre que puedas

Ejemplo: calcular ratio

# Malo
df['ratio'] = df.apply(lambda row: row['ventas'] / row['coste'], axis=1)

# Bueno
df['ratio'] = df['ventas'] / df['coste']

🧪 Benchmark (simulado):

MétodoTiempo (s) con 1M filas
apply2.7
Vectorizado0.03

¡Una mejora de 90x solo por pensar como numpy!

Uniones eficientes: merge, join, concat

Reglas básicas:

  • Asegúrate de que las claves de unión estén indexadas.
  • Usa join() si solo quieres añadir columnas por índice.
  • Usa merge() si quieres controlar más condiciones.

Ejemplo:

df_ventas = df_ventas.merge(df_clientes, on='cliente_id', how='left')

Evita:

for i, fila in df.iterrows():
    df.at[i, 'nuevo'] = buscar_info_externa(fila['id'])  # 😿

📉 Reducción de memoria

1. Cambia los tipos de datos

df['edad'] = df['edad'].astype('int8')
df['provincia'] = df['provincia'].astype('category')

📉 Esto puede reducir un DataFrame de 500 MB a 100 MB.

2. Usa pd.to_numeric(..., downcast='integer')

df['valor'] = pd.to_numeric(df['valor'], downcast='float')

Medir uso de memoria

df.info(memory_usage='deep')

Y para columnas individuales:

df['columna'].memory_usage(deep=True)

También puedes usar:

import sys
sys.getsizeof(df['columna'])

Procesamiento por bloques (chunking)

Ideal para archivos CSV grandes que no caben en memoria:

for chunk in pd.read_csv('grande.csv', chunksize=100000):
    procesar(chunk)

Esto te permite trabajar con volúmenes de datos arbitrariamente grandes, sin colapsar el sistema.

Alternativas más rápidas a pandas

LibreríaVentaja principalIdeal para...
polarsBasado en Rust, muy rápido, lazy evalETL, cargas grandes, series temporales
modinDrop-in de pandas + multiprocessingAprovechar núcleos sin cambiar código
daskParalelización distribuidaProcesamiento por lotes / clúster
cuDFGPU (NVIDIA) con sintaxis pandasDataframes masivos en GPU

🐾 Buenas prácticas en pandas

  • Piensa en arrays, no en filas.
  • Filtra antes de transformar.
  • Usa astype() para afinar los tipos.
  • Carga solo lo necesario: usecols, nrows, chunksize.
  • Usa .values o .to_numpy() si necesitas interoperar con numpy.

Pandas puede parecer inofensivo, pero si lo fuerzas con mal código, se convierte en un monstruo tragamemoria. 

📈 Un DataFrame que tarda segundos en lugar de minutos es como una siesta de 20 minutos que te rinde como 3 horas.

Cython, Numba y extensiones nativas: para gatos ninja

A veces, incluso el mejor código en Python llega a su límite. Has vectorizado, paralelizado, reducido memoria… y aún así, necesitas más velocidad. Es entonces cuando entras en el territorio de los gatos ninja: herramientas como Cython, Numba, o incluso extensiones en C o Rust que llevan tu código al nivel de un lenguaje compilado, sin renunciar a la flexibilidad de Python.

¿Por qué Python es lento y cómo evitarlo?

Python es interpretado y dinámico. Cada vez que ejecutas una línea, el intérprete tiene que:

  • Comprobar tipos.
  • Acceder a objetos en memoria dinámica.
  • Gestionar referencias.
  • Delegar operaciones a otras librerías.

Esto lo hace cómodo pero mucho más lento que C o Rust, que son lenguajes compilados y tipados.

🔧 Solución: convertir partes críticas de tu código a un entorno más próximo al metal.

Comparativa técnica

HerramientaTipoVelocidadDificultadIntegración con Python
NumbaJIT compiladorMuy altaBajaExcelente
CythonC transpiladorMáximaMedia-altaMuy buena
C / RustExtensión externaExtrema (raw)AltaManual (bindings)

1. Numba: Compila sobre la marcha

Numba convierte funciones de Python en código máquina usando LLVM. Ideal para cálculos numéricos puros.

Instalación:

pip install numba

Uso:

from numba import jit

@jit(nopython=True)
def calcular():
    total = 0
    for i in range(1000000):
        total += i ** 0.5
    return total

🐾 nopython=True obliga a que se compile sin depender del intérprete Python. Es lo que da la mayor ganancia.

📊 Benchmark real (n=1.000.000):

VersiónTiempo (s)
Python puro1.32
Con Numba0.01

🧠 Ideal para: bucles pesados, cálculos numéricos, recursiones controladas.

Created by potrace 1.15, written by Peter Selinger 2001-2017 Michidato curioso

Numba usa LLVM para compilar funciones de Python directamente a instrucciones de máquina. Esto permite que un for clásico, si está bien escrito, se ejecute tan rápido como en C. ¡Sin salir del archivo .py!

2. Cython: Python + C = turbo

Cython permite escribir código Python que se compila a C, con tipado opcional para ganar eficiencia.

Instalación:

pip install cython

Usas archivos .pyx y compilas con setup.py, o bien desde Jupyter con %%cython.

Ejemplo sencillo:

# archivo: calcular.pyx
def calcular():
    cdef int i
    cdef double total = 0
    for i in range(1000000):
        total += i ** 0.5
    return total

Para compilar:

# setup.py
from setuptools import setup
from Cython.Build import cythonize

setup(ext_modules=cythonize("calcular.pyx"))

Luego:

python setup.py build_ext --inplace

🧪 Con el tipado correcto (cdef int, cdef double), Cython puede ser tan rápido como código en C.

¿Cuándo elegir Cython sobre Numba?

  • Si necesitas más control sobre tipos, estructuras, buffers.
  • Si vas a construir módulos que se distribuyen como binarios.
  • Si integras con C/C++ ya existentes.

3. Extensiones en C o Rust: si lo haces tú, hazlo bien

Cuando necesitas máximo control y rendimiento, puedes escribir módulos en C o Rust y exponerlos a Python con:

  • ctypes
  • cffi
  • pybind11 (para C++)
  • maturin (para Rust)

Ejemplo en Rust:

#[no_mangle]
pub extern fn doble(x: i32) -> i32 {
    x * 2
}

Luego compilas y enlazas como librería compartida (.so, .dll, .dylib) y lo llamas desde Python con ctypes.

Casos donde merece la pena:

EscenarioHerramienta sugerida
Bucle numérico intensivoNumba
Algoritmo con estructuras complejasCython
Lógica a bajo nivel o uso en producciónC / Rust

🧪 Benchmark general estimado:

CódigoTiempo (ms)Mejora
Python puro1200
Numba (@jit)12~100x
Cython tipado8~150x
Rust vía FFI5~200x

🐾 Buenas prácticas felinas

  • Usa Numba para soluciones rápidas con poco esfuerzo.
  • Usa Cython si vas a distribuir código optimizado a otros usuarios.
  • No uses extensiones nativas si no tienes experiencia con C o Rust, o no necesitas ese rendimiento.
  • Siempre mide antes de decidir compilar: a veces cambiar un algoritmo es más barato y eficaz.

Cuando ya lo has intentado todo y tu código sigue siendo lento, hay herramientas para convertir partes de Python en auténticos misiles de rendimiento. 

💡 No todo el código tiene que estar compilado. Solo lo que lo necesita.

💡Optimiza el núcleo crítico y deja el resto en Python legible.

Métricas, tests y control de calidad

Después de todo el esfuerzo invertido en vectorizar, paralelizar, tipar, y compilar, ¿cómo sabes que realmente has mejorado algo? ¿Y cómo evitas romper cosas en futuras versiones? Aquí entra en juego la medición continua, el benchmarking automatizado y los tests de rendimiento.

¿Qué queremos medir?

  • Tiempo de ejecución (latencia individual).
  • Consumo de memoria (espacial).
  • Velocidad relativa entre versiones.
  • Estabilidad bajo carga o datos reales.

🐾 No se trata de microoptimizar cada línea, sino de tener visibilidad sobre el impacto real de tus mejoras.

Herramientas clave

Herramienta¿Para qué sirve?Nivel de integración
pytest-benchmarkAñadir tests de rendimiento a pytestAlta
asv (airspeed velocity)Comparar rendimiento entre commitsMuy alta (histórica)
perfplotComparar algoritmos en distintos tamañosAlta (visual)
timeitBenchmarks básicos en scriptsBásica

1. pytest-benchmark: testea si tu código es lento sin darte cuenta

Instalación:

pip install pytest-benchmark

Ejemplo de test:

# test_rendimiento.py
def calcular():
    return sum(i * i for i in range(1000))

def test_rendimiento(benchmark):
    resultado = benchmark(calcular)
    assert resultado > 0

Ejecutas:

pytest test_rendimiento.py --benchmark-only

Te da métricas como:

  • Tiempo mínimo, medio y máximo.
  • Desviación estándar.
  • Iteraciones por segundo.

✅ También puedes comparar con una versión anterior y detectar regresiones automáticas.

2. asv: benchmarking histórico por commit

asv te permite comparar el rendimiento entre versiones de tu proyecto, ideal si estás trabajando en una librería o código de producción con repositorio Git.

Instalación:

pip install asv

Configuración básica:

asv quickstart
asv run
asv publish
asv preview

Permite generar una web interactiva de benchmarks, con gráficos por commit y pruebas como:

  • Tiempo de ejecución por función.
  • Uso de memoria.
  • Cambios de rendimiento entre ramas.

🐾 Un must para cualquier desarrollador serio que mantenga código abierto o librerías de uso general.

3. perfplot: visualización comparativa de algoritmos

Perfecta para notebooks o análisis de alternativas:

import perfplot
import numpy as np

perfplot.show(
    setup=lambda n: np.random.rand(n),
    kernels=[
        lambda a: sorted(a),
        lambda a: np.sort(a),
    ],
    labels=["sorted", "np.sort"],
    n_range=[2**k for k in range(10, 20)],
    xlabel="Tamaño del array",
    logx=True,
    logy=True,
    equality_check=np.allclose
)

👁️ Visualiza a simple vista cuándo una solución empieza a ser más eficiente que otra.

4. Pruebas de regresión de rendimiento

Al igual que testeas lógica, puedes testear performance.

Ejemplo:

def test_algo_es_igual_o_mas_rapido(benchmark):
    resultado = benchmark(calcular)
    assert resultado < 0.5  # segundos

O incluso mejor:

def test_optimizado_vs_antiguo(benchmark):
    resultado = benchmark(calcular_opt)
    resultado_ref = calcular_antiguo()
    assert resultado < resultado_ref * 0.8

🔐 Esto evita que futuras optimizaciones sean involutivas.

🐈 Buenas prácticas felinas para benchmarking

  • No midas solo una vez: usa herramientas que repitan muchas veces para compensar ruido del sistema.
  • Evita código que cambia entre ejecuciones, como random, a menos que lo controles con semilla.
  • Aísla el benchmark: si otras tareas están corriendo en paralelo (indexación, antivirus…), tus números no serán fiables.
  • Borra cachés si usas caching (functools.lru_cache.clear_cache()), o separa benchmarks fríos y calientes.

Un código optimizado pero sin métricas es como un gato invisible: puede estar ahí, puede funcionar… pero también puede estar arañando tus recursos sin que lo veas.

Recuerda:



📏 Medir es parte esencial del trabajo profesional. Si no puedes probar que tu código es más rápido, tal vez no lo sea.

🧪 Los tests de rendimiento son tu seguro frente a regresiones.

📊 Las visualizaciones comparativas te ayudan a tomar decisiones reales, no suposiciones.

Created by potrace 1.15, written by Peter Selinger 2001-2017 Michidato curioso

Python guarda metainformación en cada objeto: por eso pesa tanto Un simple int en C puede ocupar 4 bytes. En Python, un entero puede pesar entre 28 y 32 bytes, porque incluye punteros, tipo, contador de referencias, etc. Esto explica por qué listas de enteros en Python pueden consumir 5-10 veces más RAM que un array.array.

Cosas que recordar:

La optimización de código en Python es tanto una disciplina técnica como una toma de decisiones informadas.

Hemos explorado estrategias que abarcan desde la selección de estructuras de datos adecuadas y el uso de operaciones vectorizadas, hasta la paralelización, la gestión eficiente de memoria y la integración con herramientas de compilación. Sin embargo, el objetivo no es simplemente acelerar el código, sino hacerlo más robusto, escalable y sostenible.

Medir antes de actuar, validar después de optimizar, y mantener la legibilidad y calidad del código son principios clave para cualquier profesional que aspire a escribir software eficiente. Una optimización bien planteada no solo mejora el rendimiento, sino que también refleja un mayor nivel de comprensión y dominio sobre el lenguaje y sus recursos.

Created by potrace 1.15, written by Peter Selinger 2001-2017

Otros artículos

Gaza: el genocidio asistido por IA

Gaza: el genocidio asistido por IA

Iniciación al reversing

Iniciación al reversing

Miaunipulación digital

Miaunipulación digital

El patonejo: entre picos y orejas

El patonejo: entre picos y orejas

Tipos de datos en Python 

Tipos de datos en Python