Funciones universales#
Hasta ahora hemos hablado de la necesidad de tener un tipo de datos específico para arreglos numéricos, pero no hemos especificado cómo utilizar numpy para acceder a operaciones numéricas eficientes.
La clave para la eficiencia es utilizar operaciones vectorizadas, las cuales numpy implementa mediante un mecanismo denominado funciones universales, o ufuncs.
La velocidad de los bucles#
Ya comentamos al hablar de los tipos de Python, que el carácter dinámico del lenguaje hacer que incluso los tipos más básicos se implementen con punteros a estructuras complejas (en la implementación por defecto CPython, escrita en C).
La lentitud relativa de este acceso se manifiesta cuando se repiten muchas operaciones numéricas sencillas, por ejemplo, cuando se realizan muchas operaciones en un array grande de datos.
Veamos un ejemplo:
import numpy as np
def calcular_inv_cuad(arr):
res = np.empty_like(arr, dtype='float')
for idx in range(len(arr)):
res[idx] = 1.0 / arr[idx]**2
return res
val = np.random.randint(1, 10, size=5)
calcular_inv_cuad(val)
array([1. , 0.02040816, 0.015625 , 0.0625 , 1. ])
Este código es similar a lo que se haría, por ejemplo, en C.
Podemos utilizar el comando de celda %timeit
para medir
la velocidad de ejecución en un array más grande. Por ejemplo:
val2 = np.random.randint(1, 100, size=10000)
%timeit calcular_inv_cuad(val2)
15.5 ms ± 183 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
En este caso, vemos que se tarda en promedio unos segundos en realizar esta operación sencilla sobre diez mil enteros. Pensando que esto no es más que una imagen de 100x100, parece extremadamente lento.
Si analizáramos las llamadas internas en C, veríamos que la mayor parte del tiempo se pierde en el acceso a la estructura interna y comprobaciones de tipos.
En un lenguaje compilado, estas comprobaciones se antes de la ejecución del código, durante la compilación, y el resultado se calcularía de manera más eficiente.
Ufuncs#
Para muchas operaciones numéricas, numpy ya proporciona rutinas precompiladas para los diferentes tipos numéricos. Esto es lo que se conoce como operación vectorizada.
Normalmente se accede a las operaciones vectorizas operando directamente sobre los arrays en lugar de sus elementos.
print(calcular_inv_cuad(val))
print(1 / val**2)
[1. 0.02040816 0.015625 0.0625 1. ]
[1. 0.02040816 0.015625 0.0625 1. ]
Y la segunda operación se ejecuta órdenes de magnitud más raṕido:
%timeit 1 / val**2
1.66 μs ± 0.981 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
Internamente, numpy tiene construidas subrutinas para las diferentes operaciones, tanto unarias como binarias. Cuando realizamos operaciones entre arrays o entre arrays y números, numpy se encarga de llamar a la rutina adecuada.
En general, numpy tiene versiones ufunc de todas las funciones matemáticas
en el módulo math
, además de todos los operadores aritméticos.
Puede encontrarse la lista completa en la documentación de numpy.
En general, los operadores aritméticos pueden llamerse de dos maneras:
la obvia (+ para la suma, * para el producto) y mediante una función.
Por ejemplo, para sumar dos arrays, numpy llama a la función np.add
.
De manera que a + b
es equivalente a np.add(a, b)
, siempre
que a
o b
sean un array o un tipo numérico de Python.
Aparte de las funciones en numpy, el paquete scipy
también proporciona
funciones especiales como ufuncs en el módulo scipy.special
.
Por ejemplo, valores de la función de Bessel de primera especie y orden cero.
import scipy.special
xx = [0.3, 1.1, 6.8]
print(scipy.special.j0(xx))
[0.97762625 0.71962202 0.2930956 ]
Siempre conviene comprobar la definición exacta de la función que queremos usar en la documentación de numpy o scipy, ya que pueden existir distintas convenciones.
Uso avanzado#
Los ufuncs de numpy tienen un interfaz uniforme y no solo son funciones, también proporcionan métodos especializados.
Array de salida#
Todos los ufunc tienen un argumento out
que sirve para definir
el array de salida. Esto puede ser conveniente si se está trabajando con
arrays muy grandes, ya que esta operación ahorra la creación de un array
temporal y una copia.
arr = np.linspace(-1, 1, 10)
res = np.empty_like(arr)
np.multiply(arr, 5, out=res)
print(res)
[-5. -3.88888889 -2.77777778 -1.66666667 -0.55555556 0.55555556
1.66666667 2.77777778 3.88888889 5. ]
Mientras que la operación equivalente
res = arr * 5
print(res)
[-5. -3.88888889 -2.77777778 -1.66666667 -0.55555556 0.55555556
1.66666667 2.77777778 3.88888889 5. ]
crea un array temporal para contener el resultado de arr * 5
y a continuación lo copia en res
.
Para arrays pequeños la diferencia es insignificante, pero el ahorro en memoria puede ser significativo para arrays grandes.
Agregación#
Los ufuncs binarios proporcionan funciones de agregación que pueden ser de
utilidad en algunos casos. El método reduce
convierte un array
en un número aplicando la función repetidamente sobre sus miembros, mientras
que el método accumulate
guarda los resultados intermedios.
xx = np.arange(10)
np.add.reduce(xx)
np.int64(45)
xx = np.arange(10)
np.add.accumulate(xx)
array([ 0, 1, 3, 6, 10, 15, 21, 28, 36, 45])
Para el caso de la suma y el producto, ya existen funciones específicas
para reducción y acumulación: np.sum
, np.prod
, np.cumsum
, np.cumprod
Hay que tener cuidado y asegurarse de que el tipo del array es capaz
de contener los resultados de la acumulación. Si no es así, hay que
añadir un argumento dtype
para definir el tipo de salida.
xx = 200 * np.ones(2, dtype='uint8')
print(np.add.accumulate(xx))
print(np.add.accumulate(xx, dtype='uint16'))
[200 400]
[200 400]
Producto externo#
Con un ufunc también se puede calcular una operación para todas las
posibles parejas de datos usando outer
xx = np.linspace(0, 1, 3)
yy = np.linspace(0, 2, 3)
np.arctan2.outer(xx, yy)
array([[0. , 0. , 0. ],
[1.57079633, 0.46364761, 0.24497866],
[1.57079633, 0.78539816, 0.46364761]])
Otros métodos de ufunc#
Los objetos ufunc tiene otros atributos y métodos como reduceat
y at
.
Además, la función ufunc puede tener otros argumentos además de out
,
como por ejemplo where
, axis
, order
o keepdims
.
El soporte de cada argumento depende de la versión de numpy y es
conveniente comprobar la documentación.