6  Introducción a NumPy

Objetivo

El objetivo de esta clase es introducir a los estudiantes en el uso de Numpy para realizar operaciones matemáticas y estadísticas con arreglos y matrices en Python

NumPy, abreviatura de Numerical Python, se utiliza para analizar datos numéricos con Python. Las matrices (arrays) NumPy se utilizan principalmente para crear matrices homogéneas \(n-\)dimensionales (\(n=1,\dots,n\)). Importemos la biblioteca NumPy para utilizar sus métodos y funciones.

import numpy as np

matriz_1 = np.array([[1,2],[3,4]])
matriz_1
type(matriz_1)

6.1 Matrices NumPy

Las matrices NumPy pueden almacenar datos al igual que otras estructuras de datos, como listas, tuplas y DataFrames (Pandas). Los cálculos realizados con matrices NumPy también se pueden realizar con datos almacenados en otras estructuras de datos. Sin embargo, NumPy es el preferido por su eficiencia, especialmente cuando se trabaja con matrices de datos de gran tamaño.

6.1.1 Eficiencia en uso de memoria

Una matriz NumPy es una colección de tipos de datos homogéneos que se almacenan en ubicaciones de memoria contiguas. Por otro lado, las estructuras de datos como las listas son una colección de tipos de datos heterogéneos que se almacenan en ubicaciones de memoria no contiguas. Los elementos de datos homogéneos permiten que la matriz NumPy se compacte de forma densa, lo que da como resultado un menor consumo de memoria. Revisemos esto con un ejemplo.

tuple_ex = tuple(range(1000))
list_ex = list(range(1000))
numpy_ex = np.array([range(1000)])
numpy_ex_2 = np.arange(1000)
print(f"Espacio <tuple> = {tuple_ex.__sizeof__()} bytes")
print(f"Espacio <list> = {list_ex.__sizeof__()} bytes")
print(f"Espacio <NumPy array> = {numpy_ex.__sizeof__()} bytes")
print(f"Espacio <NumPy array> (arange) = {numpy_ex_2.__sizeof__()} bytes")

Tenga en cuenta que las matrices NumPy son eficientes en el uso de la memoria siempre que sean homogéneas. Perderán la eficiencia de la memoria si se utilizan para almacenar elementos de múltiples tipos de datos.

El siguiente ejemplo compara el tamaño de una matriz NumPy homogénea con el de una matriz NumPy heterogénea similar para ilustrar este punto.

numpy_homogeneo = np.array([[1,2],[3,3]])
print(f"Espacio <numpy array> homogéneo = {numpy_homogeneo.__sizeof__()} bytes")
numpy_heterogeneo = np.array([[1,'2'],[3,3]])
print(f"Espacio <numpy array> heterogéneo = {numpy_heterogeneo.__sizeof__()} bytes")

El tamaño de una matriz NumPy homogénea es mucho menor que el de una matriz con datos heterogéneos. Por lo tanto, las matrices NumPy se utilizan principalmente para almacenar datos homogéneos.

Por otro lado, el tamaño de otras estructuras de datos, como una lista, no depende de si los elementos que contienen son homogéneos o heterogéneos, como se muestra en el siguiente ejemplo.

lista_homogenea = list([1,2,3,4])
print(f"Espacio <list> homogénea = {lista_homogenea.__sizeof__()} bytes")
lista_heterogenea = list([1,'2',3,4])
print(f"Espacio <list> heterogénea = {lista_heterogenea.__sizeof__()} bytes")

Tenga en cuenta que la eficiencia de memoria de las matrices NumPy no entra en juego con una cantidad muy pequeña de datos. Por lo tanto, una lista con cuatro elementos (1, 2, 3 y 4) tiene un tamaño menor que una matriz NumPy con los mismos elementos. Sin embargo, con conjuntos de datos más grandes, como el que se mostró anteriormente (secuencia de números enteros de 0 a 999), se puede ver la eficiencia de memoria de las matrices NumPy.

A diferencia de las estructuras de datos como listas, tuplas y diccionarios, todos los elementos de una matriz NumPy deben ser del mismo tipo para aprovechar la eficiencia de memoria de las matrices NumPy.

6.1.2 Rapidez

Con las matrices NumPy, los cálculos matemáticos se pueden realizar más rápido, en comparación con otras estructuras de datos, debido a las siguientes razones: 1. Como la matriz NumPy está densamente llena de datos homogéneos, también ayuda a recuperar los datos más rápido, lo que hace que los cálculos sean más rápido 2.

Con NumPy, los cálculos vectorizados pueden reemplazar cicloucfor for de Python, que son relativamente más costosos. El paquete NumPy divide los cálculos vectorizados en múltiples fragmentos y luego procesa todos los fragmentos en paralelo. Sin embargo, co ciclo for for, los cálculos se realizarán de a uno por 3. z.

El paquete NumPy integra códigos C y C++ en Python. Estos lenguajes de programación tienen muy poco tiempo de ejecución en comparación con Python.

Veremos la mayor velocidad en los cálculos NumPy en el siguiente nidimensional.

import time as tm
start_time = tm.time()
list_ex = list(range(10000000))
a=(list_ex*2)
print(f"Tiempo para una lista = {1000*(tm.time()-start_time):0.2f} ms")

start_time = tm.time()
tuple_ex = tuple(range(10000000))
a=(tuple_ex*2)
print(f"Tiempo para una tupla = {1000*(tm.time()-start_time):0.2f} ms")

start_time = tm.time()
numpy_ex = np.arange(10000000)
a=(numpy_ex*2)
print(f"Tiempo para una matriz NumPy = {1000*(tm.time()-start_time):0.2f} ms")

6.2 Atributos básicos de matrices NumPy

A partir de una matriz Numpy que esté definida

numpy_ej = np.array([[1,2,3],[4,5,6]])
numpy_ej

podemos acceder a sus atributos digitando el nombre de variable, seguido por un punto (.) y presionando la tecla Tab

numpy_ej.

ndim

Muestra el numero de dimensiones (o ejes) de la matriz

numpy_ej.ndim

shape

Se trata de una tupla de números enteros que indica el tamaño de la matriz en cada dimensión. Para una matriz con \(n\) filas y \(m\) columnas, la forma será \((n, m)\). La longitud de esta tupla de forma es, por tanto, el número de dimensiones (ndim).

numpy_ej.shape

size

Este es el número total de elementos de la matriz, que es el producto de los elementos de la forma.

numpy_ej.size

dtype

Este es un objeto que describe el tipo de los elementos de la matriz. Se pueden crear o especificar dtypes utilizando tipos estándar de Python. NumPy ofrece muchos, por ejemplo, bool_, character, int_, int8, int16, int32, int64, float_, float8, float16, float32, float64, complex_, complex64, object_.

numpy_ej.dtype

T

Este atributo se utiliza para transponer la matriz NumPy.

numpy_ej.T

6.3 Operaciones aritméticas

Las matrices de Numpy admiten operadores aritméticos como +, -, *, etc. Podemos realizar una operación aritmética en una matriz ya sea con un solo número (también llamado escalar) o con otra matriz de la misma forma. Sin embargo, no podemos realizar una operación aritmética en una matriz con una matriz de una forma diferente.

A continuación, se muestran algunos ejemplos de operaciones aritméticas en matrices.

arr1 = np.array([[1, 2, 3, 4], 
                 [5, 6, 7, 8], 
                 [9, 1, 2, 3]])
arr2 = np.array([[11, 12, 13, 14], 
                 [15, 16, 17, 18], 
                 [19, 11, 12, 13]])
arr1 + arr2
arr1 - arr2
arr1 * arr2
arr1 / arr2
arr1 % 4

6.4 Difusión

La difusión permite realizar operaciones aritméticas entre dos matrices con diferentes cantidades de dimensiones pero formas compatibles.

La documentación de difusión lo explica sucintamente de la siguiente manera:

El término difusión describe cómo NumPy trata las matrices con diferentes formas durante las operaciones aritméticas. Sujeto a ciertas restricciones, la matriz más pequeña se difunde a través de la matriz más grande para que tengan formas compatibles. La difusión proporciona un medio para vectorizar las operaciones de matriz de modo que el bucle se produzca en C en lugar de Python. Esto se hace sin hacer copias innecesarias de datos y, por lo general, conduce a implementaciones de algoritmos eficientes.

arr1 = np.array([[1, 2, 3, 4], 
                 [5, 6, 7, 8], 
                 [9, 1, 2, 3]])
arr2 = np.array([4, 5, 6, 7])
arr1 + arr2

Cuando se evalúa la expresión arr1 + arr2, arr2 (que tiene la forma (4,)) se replica tres veces para que coincida con la forma (3, 4) de arr1. Numpy realiza la replicación sin crear realmente tres copias de la matriz de dimensión más pequeña, lo que mejora el rendimiento y utiliza menos memoria.

En la adición de matrices anterior, arr2 se difundió a la forma de arr1. Sin embargo, esta difusión solo fue posible porque la dimensión derecha de ambas matrices es 4 y la dimensión izquierda de una de las matrices es 1.

Consulte la documentación de transmisión para comprender las reglas de transmisión:

Al operar en dos matrices, NumPy compara sus formas elemento por elemento. Comienza con las dimensiones finales (es decir, las más a la derecha) y avanza hacia la izquierda. Dos dimensiones son compatibles cuando:

  • son iguales, o
  • una de ellas es 1

Si la dimensión más a la derecha de arr2 es 3, no se producirá la transmisión, ya que no es igual a la dimensión más a la derecha de arr1:

arr2 = np.array([4, 5, 6])
arr1 + arr2

6.5 Comparación

Las matrices Numpy admiten operaciones de comparación como ==, !=, >, etc. El resultado es una matriz de valores booleanos.

arr1 = np.array([[1, 2, 3], [3, 4, 5]])
arr2 = np.array([[2, 2, 3], [1, 2, 5]])
arr1 == arr2
arr1 != arr2
arr1 >= arr2
arr1 >= arr2

La comparación de matrices se utiliza con frecuencia para contar la cantidad de elementos iguales en dos matrices mediante el método de suma. Recuerde que True se evalúa como 1 y False como 0 cuando se utilizan valores booleanos en operaciones aritméticas.

(arr1 == arr2).sum()

6.6 Indexación de arreglos Numpy

Ya estamos familiarizados con la indexación de listas estándar de Python, por lo que la indexación en NumPy nos resultará bastante familiar. En una matriz unidimensional, se puede acceder al \(i\)-ésimo valor (contando desde cero) especificando el índice deseado entre corchetes, al igual que con las listas de Python:

np.random.seed(0)
x1 = np.random.randint(10, size=6)
x2 = np.random.randint(10, size=(3, 4))
x3 = np.random.randint(10, size=(3, 4, 5)) 
print(x1)
print(x1[0])
print(x1[-1])
print(x2)
print(x2[0])
print(x2[-1])
print(x2[0][0])
print(x2[0][-1])
print(x2[-3][-2])
print(x3)
print(x3[2])
print(x3[1][1][3])

Los valores también se pueden modificar utilizando cualquiera de las notaciones de índice anteriores:

print(x2)
x2[0, 0] = 12
print(x2)

Tenga en cuenta que, a diferencia de las listas de Python, las matrices NumPy tienen un tipo fijo. Esto significa, por ejemplo, que si intenta insertar un valor de punto flotante en una matriz de enteros, el valor se truncará silenciosamente. ¡No se deje sorprender por este comportamiento!

x1[0] = 3.14159
print(x1)

x4 = np.array([1., 3, 4])
print(x4.dtype)

6.7 Acceso a subarreglos

Así como podemos usar corchetes para acceder a elementos individuales de la matriz, también podemos usarlos para acceder a subarreglos con la notación de división, marcada por el carácter de dos puntos (:). La sintaxis de corte de NumPy sigue la de la lista estándar de Python x[start:stop:step].

6.7.1 Arreglos unidimensionales

y1 = np.arange(10)
print(f'y1 = {y1}')
print(f'y1[:5] = {y1[:5]}')
print(f'y1[::-1] = {y1[::-1]}')

y2 = np.arange(0, 20, 2)
print(f'\ny2 = {y2}')
print(f'y2[5:] = {y2[5::2]}')

y3 = np.linspace(0, 1,  9)
print(f'\ny3 = {y3}')
print(f'y3[2:5] = {y3[2:5]}')

6.7.2 Arreglos multidimensionales

Los cortes multidimensionales funcionan de la misma manera, con varios cortes separados por comas. Por ejemplo:

print(f'x2 =\n {x2}')
print(f'\nx2[:2, :3] =\n{x2[:2, :3]}')
print(f'\nx2[:3, ::2] =\n {x2[:3, ::2]}')

Una rutina comúnmente necesaria es acceder a filas o columnas individuales de una matriz. Esto se puede hacer combinando la indexación y el corte, utilizando un segmento vacío marcado con dos puntos (:):

print(f'x2[:, 0] = \n {x2[:, 1]}')  # Primera columna de x2
print(f'x2[0, ;] = \n {x2[1, :]}')  # Primera fila de x2

Una cosa importante, y extremadamente útil, que debe saber sobre los segmentos de matriz es que devuelven vistas en lugar de copias de los datos de la matriz.

print(f'x2 = \n {x2}')
x2_sub = x2[:2, :2]
print(f'\nx2_sub =\n {x2_sub}')

Ahora, si modificamos este subarreglo, ¡veremos que el arreglo original ha cambiado!

x2_sub[0, 0] = 150
print(f'\nx2_sub =\n {x2_sub}')
print(f'\nx2 = \n {x2}')

Este comportamiento predeterminado es bastante útil: significa que cuando trabajamos con grandes conjuntos de datos, podemos acceder y procesar partes de estos conjuntos de datos sin necesidad de copiar el búfer de datos subyacente. Pero si queremos crear una copia independiente, podemos usar el método copy() de los arreglos.

x2_sub_copy = x2[:2, :2].copy()
print(f'x2_sub_copy =\n {x2_sub_copy}')
x2_sub_copy[0, 0] = 42
print(f'\nx2_sub_copy =\n {x2_sub_copy}')
print(f'\nx2 = \n {x2}')

6.7.3 Reestructuración de arreglos

Otro tipo útil de operación es la reestructuración de matrices. Por ejemplo, si desea poner los números del 1 al 9 en una matriz \(3 \times 3\), puede hacer lo siguiente:

grid = np.arange(1, 10).reshape((3, 3))
print(grid)

Tenga en cuenta que para que esto funcione, el tamaño de la matriz inicial debe coincidir con el tamaño de la matriz reestructurada. Siempre que sea posible, el método de reestructuración utilizará una vista sin copia de la matriz inicial, pero con búferes de memoria no contiguos, este no es siempre el caso.

Otro patrón de reestructuració común es la conversión de una matriz unidimensional en una matriz bidimensional de filas o columnas. Esto se puede hacer con el método reshape(), o más fácilmente usando la palabra clave newaxis dentro de una operación de división:

x = np.array([1, 2, 3])
x
yy = x.reshape((3, 1)).copy()
x[:, np.newaxis]

6.8 Concatenación y división de matrices

Todas las rutinas anteriores funcionaron en matrices individuales. También es posible combinar varios arreglos en uno y, a la inversa, dividir un solo arreglo en varios arreglos. Echaremos un vistazo a esas operaciones aquí.

6.8.1 Concatenación

La concatenación, o unión de dos matrices en NumPy, se logra principalmente mediante las rutinas np.concatenate, np.vstack y np.hstack. np.concatenate toma una tupla o lista de matrices como primer argumento, como podemos ver aquí:

x = np.array([1, 2, 3])
y = np.array([3, 2, 1])
yy = x.reshape((3, 1)).copy()
np.concatenate([x, y])
np.concatenate([yy,yy])

También puede concatenar más de dos matrices a la vez y para matrices bidimensionales:

z = [99, 99, 99]
np.concatenate([x, y, z])
grid = np.array([[1, 2, 3],
                 [4, 5, 6]])
np.concatenate([grid, grid])

Para trabajar con arreglos de dimensiones mixtas, puede ser más claro usar las funciones np.vstack (apilado vertical) y np.hstack (apilado horizontal):

x = np.array([1, 2, 3])
grid = np.array([[9, 8, 7],
                 [6, 5, 4]])

np.vstack([x, grid])
y = np.array([[99],
              [99]])
np.hstack([grid, y])

De manera similar, np.dstack apilará matrices a lo largo del tercer eje.

6.8.2 División

Lo opuesto a la concatenación es la división, que se implementa mediante las funciones np.split, np.hsplit y np.vsplit. Para cada uno de estos, podemos pasar una lista de índices que dan los puntos de división:

x = [1, 2, 3, 99, 99, 3, 2, 1]
x1, x2, x3 = np.split(x, [3, 5])
print(x1, x2, x3)
x1[0] = 15
print(x)
print(x1)

Observe que \(N\) puntos de división conducen a \(N + 1\) subarreglos. Las funciones relacionadas np.hsplit y np.vsplit son similares:

grid = np.arange(16).reshape((4, 4))
grid
upper, lower = np.vsplit(grid, [2])
print(upper)
print(lower)
left, right = np.hsplit(grid, [2])
print(left)
print(right)

De manera similar, np.dsplit dividirá matrices a lo largo del tercer eje.

6.9 Funciones universales

Ya hemos discutido algunos de los aspectos básicos de NumPy. Ahora, profundizaremos en las razones por las que NumPy es tan importante en el mundo de la ciencia de datos de Python. Es decir, proporciona una interfaz fácil y flexible para el cálculo optimizado con matrices de datos.

El cálculo en matrices NumPy puede ser muy rápido o muy lento. La clave para hacerlo rápido es usar operaciones vectorizadas, generalmente implementadas a través de las funciones universales de NumPy (UFuncs).

6.9.1 Fundamentos de UFuncs

Para muchos tipos de operaciones, NumPy proporciona una interfaz conveniente para este tipo de rutina compilada y tipificada estáticamente. Esto se conoce como operación vectorizada. Esto se puede lograr simplemente realizando una operación en la matriz, que luego se aplicará a cada elemento. Este enfoque vectorizado está diseñado para insertar el bucle en la capa compilada que subyace a NumPy, lo que lleva a una ejecución mucho más rápida.

np.random.seed(0)

def compute_reciprocals(values):
    output = np.empty(len(values))
    for i in range(len(values)):
        output[i] = 1.0 / values[i]
    return output

big_array = np.random.randint(1, 100, size=1000000)
%timeit compute_reciprocals(big_array)
%timeit (1.0 / big_array)

Las operaciones vectorizadas en NumPy se implementan a través de UFuncs, cuyo objetivo principal es ejecutar rápidamente operaciones repetidas en valores en matrices NumPy. Las UFuncs son extremadamente flexibles: antes vimos una operación entre un escalar y una matriz, pero también podemos operar entre dos matrices:

x = np.arange(5)
print(f'x = {x}')
y = np.arange(1, 6)
print(f'y = {y}')
print(f'x/y = {x/y}')
z = np.arange(9)
print(f'z = \n{z}')
print(f'\n  2 ** z = \n{ 2 ** z}')

La siguiente tabla enumera los operadores aritméticos implementados en NumPy:

Operador UFunc equivalente
+ np.add
- np.subtract
- np.negative
* np.multiply
/ np.divide
// np.floor_divide
** np.power
% np.mod

6.9.2 UFuncs útiles

Valor absoluto, signo y magnitud

x = np.array([-2, -1, 0, 1, 2])
print(np.abs(x))
print(np.sign(x))
x = np.array([3 - 4j, 4 - 3j, 2 + 0j, 0 + 1j])
np.abs(x)

Funciones trigonométricas

Numpy trabaja sobre ángulos en radianes

theta = np.linspace(0, np.pi, 3)
print("theta      = ", theta)
print("sin(theta) = ", np.sin(theta))
print("cos(theta) = ", np.cos(theta))
print("tan(theta) = ", np.tan(theta))

Para usar las funciones trigonométricas con ángulos en grados, se debe usar la función deg2rad() o crear funciones lambda.

np.sin(np.deg2rad(90))
sind = lambda degrees: np.sin(np.deg2rad(degrees))
cosd = lambda degrees: np.cos(np.deg2rad(degrees))
print(sind(90))

Exponenciales y logaritmos

x = [1, 2, 3]
print("x     =", x)
print("e^x   =", np.exp(x))
print("2^x   =", np.exp2(x))
print("3^x   =", np.power(3, x))
x = [1, 2, 4, 10]
print("x        =", x)
print("ln(x)    =", np.log(x))
print("log2(x)  =", np.log2(x))
print("log10(x) =", np.log10(x))

Funciones especiales

NumPy tiene muchas más UFuncs disponibles, incluidas funciones trigonométricas hiperbólicas, aritmética bit a bit, operadores de comparación, conversiones de radianes a grados, redondeo y resto, y mucho más. Un vistazo a la documentación de NumPy revela muchas funcionalidades interesantes.

Otra fuente excelente para UFuncs más especializadas es el submódulo scipy.special.

from scipy import special
x = [1, 5, 10]
print("gamma(x)     =", special.gamma(x))
print("ln|gamma(x)| =", special.gammaln(x))
print("beta(x, 2)   =", special.beta(x, 2))
x = np.array([0, 0.3, 0.7, 1.0])
print("erf(x)  =", special.erf(x))
print("erfc(x) =", special.erfc(x))
print("erfinv(x) =", special.erfinv(x))

6.9.3 Agregados

Para UFuncs binarias, hay algunos agregados interesantes que se pueden calcular directamente desde el objeto. Por ejemplo, si nos gustaría reducir una matriz con una operación en particular, podemos usar el método reduce de cualquier UFunc. Una reducción aplica repetidamente una operación determinada a los elementos de una matriz hasta que solo queda un único resultado.

x = np.arange(1, 6)
np.add.reduce(x)
np.multiply.reduce(x)

Si quisiéramos almacenar todos los resultados intermedios del cómputo, podemos usar accumulate en su lugar.

np.add.accumulate(x)
np.multiply.accumulate(x)

Tenga en cuenta que para estos casos particulares, existen funciones NumPy dedicadas para calcular los resultados (np.sum, np.prod, np.cumsum, np.cumprod).

6.10 Ejercicios prácticos

  1. Cree un nuevo Notebook.
  2. Guarde el archivo como Ejercicios_practicos_clase_6.ipynb.
  3. Asigne un título H1 con su nombre.

6.10.1 Ejercicio práctico 1

  1. Use NumPy para crear dos matrices \(\mathbf{A}\) y \(\mathbf{B}\) de \(3\times 3\) de números enteros aleatorios entre 1 y 10.
  2. Calcule la matriz suma \((\mathbf{A} + \mathbf{B})\) y la matriz producto \((\mathbf{A}\mathbf{B})\).
  3. Encuentre la matriz transpuesta de \(\mathbf{A}\) \((\mathbf{A}^\top)\).
  4. Calculae el determinante de la matriz \(\mathbf{A}\) \((\mathrm{det}(\mathbf{A}))\).
  5. Encuentre, si existe, la inversa de la matriz \(\mathbf{B}\) \((\mathbf{B}^{-1})\).

Busque la documentación del módulo random de NumPy para la creación de las matrices aleatorias.

6.10.2 Ejercicio práctico 2

  1. Creea un arreglo unidimensional con 100 valores aleatorios entre 0 y 1 utilizando la función numpy.random.rand.
  2. Calcule la media, la mediana, la varianza y la desviación estándar del arreglo.
  3. Encuentre el valor máximo y el valor mínimo en el arreglo.
  4. Ordene el arreglo de menor a mayor.
  5. Seleccione los primeros 10 valores del arreglo ordenado y calcule la media de estos valores.