El otro día, me encontré con una biblioteca de la que nunca había oído hablar antes. Fue llamado Numexpr.
Inmediatamente estaba interesado por algunas afirmaciones hechas sobre la biblioteca. En particular, declaró que para algunos cálculos numéricos complejos, era hasta 15 veces más rápido que Numpy.
Estaba intrigado porque, hasta ahora, Numpy se ha mantenido sin respuesta en su dominio en el espacio de cálculo numérico en Python. En particular con Ciencia de datosNumpy es una piedra angular para el aprendizaje automático, el análisis de datos exploratorios y el entrenamiento de modelos. Cualquier cosa que podamos usar para exprimir hasta el último rendimiento en nuestros sistemas será bienvenido. Entonces, decidí poner las afirmaciones a la prueba yo mismo.
Puede encontrar un enlace al repositorio de NUMEXPR al final de este artículo.
¿Qué es numExpr?
Según su página de GitHub, NUMEXPR es un evaluador de expresión numérica rápida para Numpy. Utilizándolo, las expresiones que funcionan en matrices se aceleran y usan menos memoria que realizar los mismos cálculos en Python con otras bibliotecas numéricas, como Numpy.
Además, como es multiproceso, NUMEXPR puede usar todos sus núcleos de CPU, lo que generalmente da como resultado una escala de rendimiento sustancial en comparación con Numpy.
Configurar un entorno de desarrollo
Antes de comenzar a codificar, establezcamos nuestro entorno de desarrollo. La mejor práctica es crear un Pitón Entorno donde puede instalar cualquier software necesario y experimentar con la codificación, sabiendo que cualquier cosa que haga en este entorno no afectará al resto de su sistema. Utilizo Conda para esto, pero puedes usar cualquier método que mejor conoces que te convenga.
Si desea seguir la ruta de Miniconda y aún no la tiene, primero debe instalar Miniconda. Consíguelo usando este enlace:
https://www.anaconda.com/docs/main
1/ Crear nuestro nuevo entorno de desarrollo e instalar las bibliotecas requeridas
(base) $ conda create -n numexpr_test python=3.12-y
(base) $ conda activate numexpr
(numexpr_test) $ pip install numexpr
(numexpr_test) $ pip install jupyter
2/ Start Jupyter
Ahora escriba jupyter notebook en su símbolo del sistema. Debería ver un cuaderno Jupyter abierto en su navegador. Si eso no sucede automáticamente, es probable que vea una pantalla de información después del jupyter notebook dominio. Cerca de la parte inferior, encontrará una URL que debe copiar y pegar en su navegador para iniciar el cuaderno Jupyter.
Su URL será diferente a la mía, pero debería verse algo así:-
http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69
Comparación de numexpr y rendimiento numpy
Para comparar el rendimiento, ejecutaremos una serie de cálculos numéricos utilizando numpy y numExpr, y tiempo ambos sistemas.
Ejemplo 1 – Un simple cálculo de adición de matriz
En este ejemplo, ejecutamos una adición vectorizada de dos grandes matrices 5000 veces.
import numpy as np
import numexpr as ne
import timeit
a = np.random.rand(1000000)
b = np.random.rand(1000000)
# Using timeit with lambda functions
time_np_expr = timeit.timeit(lambda: 2*a + 3*b, number=5000)
time_ne_expr = timeit.timeit(lambda: ne.evaluate("2*a + 3*b"), number=5000)
print(f"Execution time (NumPy): {time_np_expr} seconds")
print(f"Execution time (NumExpr): {time_ne_expr} seconds")
>>>>>>>>>>>
Execution time (NumPy): 12.03680682599952 seconds
Execution time (NumExpr): 1.8075962659931974 seconds
Tengo que decir que ese es un comienzo bastante impresionante de la biblioteca NUMEXPR. Hago una mejora 6 veces sobre el tiempo de ejecución numpy.
Verifiquemos que ambas operaciones devuelvan el mismo conjunto de resultados.
# Arrays to store the results
result_np = 2*a + 3*b
result_ne = ne.evaluate("2*a + 3*b")
# Ensure the two new arrays are equal
arrays_equal = np.array_equal(result_np, result_ne)
print(f"Arrays equal: {arrays_equal}")
>>>>>>>>>>>>
Arrays equal: True
Ejemplo 2 – Calcule Pi usando una simulación de Monte Carlo
Nuestro segundo ejemplo examinará un caso de uso más complicado con más aplicaciones del mundo real.
Las simulaciones de Monte Carlo implican ejecutar muchas iteraciones de un proceso aleatorio para estimar las propiedades de un sistema, que pueden ser computacionalmente intensivas.
En este caso, usaremos Monte Carlo para calcular el valor de PI. Este es un ejemplo bien conocido en el que tomamos un cuadrado con una longitud lateral de una unidad e inscribimos un cuarto de círculo dentro de él con un radio de una unidad. La proporción del área del cuarto de círculo al área de la plaza es (π/4)/1, y podemos multiplicar esta expresión por cuatro para obtener π por sí solo.
Entonces, si consideramos numerosos puntos aleatorios (x, y) que todos se encuentran dentro o en los límites del cuadrado, ya que el número total de estos puntos tiende al infinito, la relación de puntos que se encuentran dentro o dentro del cuarto de círculo al número total de puntos tienden hacia PI.
Primero, la implementación numpy.
import numpy as np
import timeit
def monte_carlo_pi_numpy(num_samples):
x = np.random.rand(num_samples)
y = np.random.rand(num_samples)
inside_circle = (x**2 + y**2) <= 1.0
pi_estimate = (np.sum(inside_circle) / num_samples) * 4
return pi_estimate
# Benchmark the NumPy version
num_samples = 1000000
time_np_expr = timeit.timeit(lambda: monte_carlo_pi_numpy(num_samples), number=1000)
pi_estimate = monte_carlo_pi_numpy(num_samples)
print(f"Estimated Pi (NumPy): {pi_estimate}")
print(f"Execution Time (NumPy): {time_np_expr} seconds")
>>>>>>>>
Estimated Pi (NumPy): 3.144832
Execution Time (NumPy): 10.642843848007033 seconds
Ahora, usando numExpr.
import numpy as np
import numexpr as ne
import timeit
def monte_carlo_pi_numexpr(num_samples):
x = np.random.rand(num_samples)
y = np.random.rand(num_samples)
inside_circle = ne.evaluate("(x**2 + y**2) <= 1.0")
pi_estimate = (np.sum(inside_circle) / num_samples) * 4 # Use NumPy for summation
return pi_estimate
# Benchmark the NumExpr version
num_samples = 1000000
time_ne_expr = timeit.timeit(lambda: monte_carlo_pi_numexpr(num_samples), number=1000)
pi_estimate = monte_carlo_pi_numexpr(num_samples)
print(f"Estimated Pi (NumExpr): {pi_estimate}")
print(f"Execution Time (NumExpr): {time_ne_expr} seconds")
>>>>>>>>>>>>>>>
Estimated Pi (NumExpr): 3.141684
Execution Time (NumExpr): 8.077501275009126 seconds
Bien, entonces la aceleración no fue tan impresionante ese tiempo, pero una mejora del 20% tampoco es terrible. Parte de la razón es que NUMEXPR no tiene una función de suma optimizada (), por lo que tuvimos que volver a Numpy para esa operación.
Ejemplo 3 – Implementación de un filtro de imagen de Sobel
En este ejemplo, implementaremos un filtro Sobel para imágenes. El filtro Sobel se usa comúnmente en el procesamiento de imágenes para la detección de borde. Calcula el gradiente de intensidad de la imagen en cada píxel, destacando los bordes y las transiciones de intensidad. Nuestra imagen de entrada es del Taj Mahal en India.
Veamos el código Numpy que se ejecuta primero y el tiempo.
import numpy as np
from scipy.ndimage import convolve
from PIL import Image
import timeit
# Sobel kernels
sobel_x = np.array([[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]])
sobel_y = np.array([[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1]])
def sobel_filter_numpy(image):
"""Apply Sobel filter using NumPy."""
img_array = np.array(image.convert('L')) # Convert to grayscale
gradient_x = convolve(img_array, sobel_x)
gradient_y = convolve(img_array, sobel_y)
gradient_magnitude = np.sqrt(gradient_x**2 + gradient_y**2)
gradient_magnitude *= 255.0 / gradient_magnitude.max() # Normalize to 0-255
return Image.fromarray(gradient_magnitude.astype(np.uint8))
# Load an example image
image = Image.open("/mnt/d/test/taj_mahal.png")
# Benchmark the NumPy version
time_np_sobel = timeit.timeit(lambda: sobel_filter_numpy(image), number=100)
sobel_image_np = sobel_filter_numpy(image)
sobel_image_np.save("/mnt/d/test/sobel_taj_mahal_numpy.png")
print(f"Execution Time (NumPy): {time_np_sobel} seconds")
>>>>>>>>>
Execution Time (NumPy): 8.093792188999942 seconds
Y ahora el código NUMEXPR.
import numpy as np
import numexpr as ne
from scipy.ndimage import convolve
from PIL import Image
import timeit
# Sobel kernels
sobel_x = np.array([[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]])
sobel_y = np.array([[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1]])
def sobel_filter_numexpr(image):
"""Apply Sobel filter using NumExpr for gradient magnitude computation."""
img_array = np.array(image.convert('L')) # Convert to grayscale
gradient_x = convolve(img_array, sobel_x)
gradient_y = convolve(img_array, sobel_y)
gradient_magnitude = ne.evaluate("sqrt(gradient_x**2 + gradient_y**2)")
gradient_magnitude *= 255.0 / gradient_magnitude.max() # Normalize to 0-255
return Image.fromarray(gradient_magnitude.astype(np.uint8))
# Load an example image
image = Image.open("/mnt/d/test/taj_mahal.png")
# Benchmark the NumExpr version
time_ne_sobel = timeit.timeit(lambda: sobel_filter_numexpr(image), number=100)
sobel_image_ne = sobel_filter_numexpr(image)
sobel_image_ne.save("/mnt/d/test/sobel_taj_mahal_numexpr.png")
print(f"Execution Time (NumExpr): {time_ne_sobel} seconds")
>>>>>>>>>>>>>
Execution Time (NumExpr): 4.938702256011311 seconds
En esta ocasión, el uso de NUMEXPR condujo a un gran resultado, con una actuación que fue casi el doble de la de Numpy.
Así es como se ve la imagen detectada por el borde.
Ejemplo 4 – Aproximación de la serie de Fourier
Es bien sabido que las funciones periódicas complejas se pueden simular aplicando una serie de ondas sinusoidales superpuestas entre sí. En el extremo, incluso una onda cuadrada se puede modelar fácilmente de esta manera. El método se llama aproximación de la serie Fourier. Aunque es una aproximación, podemos acercarnos a la forma de la onda objetivo como lo permitan la memoria y la capacidad computacional.
Las matemáticas detrás de todo esto no es el enfoque principal. Solo tenga en cuenta que cuando aumentamos el número de iteraciones, el tiempo de ejecución de la solución aumenta notablemente.
import numpy as np
import numexpr as ne
import time
import matplotlib.pyplot as plt
# Define the constant pi explicitly
pi = np.pi
# Generate a time vector and a square wave signal
t = np.linspace(0, 1, 1000000) # Reduced size for better visualization
signal = np.sign(np.sin(2 * np.pi * 5 * t))
# Number of terms in the Fourier series
n_terms = 10000
# Fourier series approximation using NumPy
start_time = time.time()
approx_np = np.zeros_like
for n in range(1, n_terms + 1, 2):
approx_np += (4 / (np.pi * n)) * np.sin(2 * np.pi * n * 5 * t)
numpy_time = time.time() - start_time
# Fourier series approximation using NumExpr
start_time = time.time()
approx_ne = np.zeros_like
for n in range(1, n_terms + 1, 2):
approx_ne = ne.evaluate("approx_ne + (4 / (pi * n)) * sin(2 * pi * n * 5 * t)", local_dict={"pi": pi, "n": n, "approx_ne": approx_ne, "t": t})
numexpr_time = time.time() - start_time
print(f"NumPy Fourier series time: {numpy_time:.6f} seconds")
print(f"NumExpr Fourier series time: {numexpr_time:.6f} seconds")
# Plotting the results
plt.figure(figsize=(10, 6))
plt.plot(t, signal, label='Original Signal (Square Wave)', color='black', linestyle='--')
plt.plot(t, approx_np, label='Fourier Approximation (NumPy)', color='blue')
plt.plot(t, approx_ne, label='Fourier Approximation (NumExpr)', color='red', linestyle='dotted')
plt.title('Fourier Series Approximation of a Square Wave')
plt.xlabel('Time')
plt.ylabel('Amplitude')
plt.legend()
plt.grid(True)
plt.show()
Y la salida?
Ese es otro buen resultado. NUMEXPR muestra una mejora 5 veces sobre Numpy en esta ocasión.
Resumen
Numpy y NumExpr son bibliotecas potentes utilizadas para los cálculos numéricos de Python. Cada uno tiene fortalezas y casos de uso únicos, lo que los hace adecuados para diferentes tipos de tareas. Aquí, comparamos su rendimiento e idoneidad para tareas computacionales específicas, centrándonos en ejemplos como la adición de matriz simple a aplicaciones más complejas, como el uso de un filtro Sobel para la detección de borde de la imagen.
Si bien no vi el aumento de la velocidad de 15x reclamado sobre Numpy en mis pruebas, no hay duda de que NUMEXPR puede ser significativamente más rápido que Numpy en muchos casos.
Si es un usuario pesado de Numpy y necesita extraer todo el rendimiento de su código, le recomiendo probar la biblioteca NUMEXPR. Además del hecho de que no todos los códigos numpy se pueden replicar usando NUMEXPR, prácticamente no hay inconveniente, y la ventaja podría sorprenderte.
Para obtener más detalles sobre la biblioteca NumExPR, consulte la página de GitHub aquí.