Introducción al Cálculo Científico

En esta clase introduciremos algunos conceptos de computación cientifica en Python, principalmente utilizando la biblioteca NumPy, piedra angular de otras librerías científicas.

SciPy.org

SciPy es un ecosistema de software open-source para matemática, ciencia y engeniería. Las principales bibliotecas son:

  • NumPy: Arrays N-dimensionales. Librería base, integración con C/C++ y Fortran.

  • SciPy library: Computación científica (integración, optimización, estadística, etc.)

  • Matplotlib: Visualización 2D:

  • IPython: Interactividad (Project Jupyter).

  • SimPy: Matemática Simbólica.

  • Pandas: Estructura y análisis de datos.

Numpy

NumPy es el paquete fundamental para la computación científica en Python. Proporciona un objeto de matriz multidimensional, varios objetos derivados (como matrices y arreglos) y una variedad de rutinas para operaciones rápidas en matrices, incluida la manipulación matemática, lógica, de formas, clasificación, selección, I/O, transformadas discretas de Fourier, álgebra lineal básica, operaciones estadísticas básicas, simulación y mucho más. Fuente.

Para comenzar, la forma usual de importar NumPy es utilizando el alias np. Lo verás así en una infinidad de ejmplos, libros, blogs, etc.

import numpy as np

Lo básico

Los objetos principales de Numpy son los comúnmente conocidos como NumPy Arrays (la clase se llama ndarray), corresponden a una tabla de elementos, todos del mismo tipo, indexados por una tupla de enternos no-negativos. En NumPy, las dimensiones son llamadas axes (ejes) y su singular axis (eje), similar a un plano cartesiano generalizado. Esta parte de la clase está basada en el Quickstart tutorial en la página oficial (link).

Instanciar un NumPy Array es simple es utilizando el constructor propio de la biblioteca.

a = np.array(
    [
        [ 0,  1,  2,  3,  4],
        [ 5,  6,  7,  8,  9],
        [10, 11, 12, 13, 14]
    ]
)
type(a)
numpy.ndarray

Los atributos más importantes de un ndarray son:

a.shape  # the dimensions of the array.
(3, 5)
a.ndim  # the number of axes (dimensions) of the array.
2
a.size  # the total number of elements of the array.
15
a.dtype  # an object describing the type of the elements in the array. 
dtype('int64')
a.itemsize  # the size in bytes of each element of the array.
8

Crear Numpy Arrays

Hay varias formas de crear arrays, el constructor básico es el que se utilizó hace unos momentos, np.array. El type del array resultante es inferido de los datos proporcionados.

a_int = np.array([2, 6, 10])
a_float = np.array([2.1, 6.1, 10.1])  

print(f"a_int: {a_int.dtype.name}")
print(f"a_float: {a_float.dtype.name}")
a_int: int64
a_float: float64

También es posible utilizar otras estructuras de Python, como listas o tuplas.

a_list = [1, 1, 2, 3, 5]
np.array(a_list)
array([1, 1, 2, 3, 5])
a_tuple = (1, 1, 1, 3, 5, 9)
np.array(a_tuple)
array([1, 1, 1, 3, 5, 9])

¡Cuidado! Es fácil confundirse con las dimensiones o el tipo de argumento en los contructores de NumPy, por ejemplo, utilizando una lista podríamos crear un arreglo de una o dos dimensiones si no tenemos cuidado.

one_dim_array = np.array(a_list)
two_dim_array = np.array([a_list])
print(f"np.array(a_list) = {one_dim_array} tiene shape: {one_dim_array.shape}, es decir, {one_dim_array.ndim} dimensión(es).")
print(f"np.array([a_list]) = {two_dim_array} tiene shape: {two_dim_array.shape}, es decir, {two_dim_array.ndim} dimensión(es).")
np.array(a_list) = [1 1 2 3 5] tiene shape: (5,), es decir, 1 dimensión(es).
np.array([a_list]) = [[1 1 2 3 5]] tiene shape: (1, 5), es decir, 2 dimensión(es).

Una funcionalidad útil son los constructores especiales a partir de constantes.

np.zeros((3, 4))
array([[0., 0., 0., 0.],
       [0., 0., 0., 0.],
       [0., 0., 0., 0.]])
np.ones((2, 3, 4), dtype=np.int)  # dtype can also be specified
array([[[1, 1, 1, 1],
        [1, 1, 1, 1],
        [1, 1, 1, 1]],

       [[1, 1, 1, 1],
        [1, 1, 1, 1],
        [1, 1, 1, 1]]])
np.identity(4)  # Identity matrix
array([[1., 0., 0., 0.],
       [0., 1., 0., 0.],
       [0., 0., 1., 0.],
       [0., 0., 0., 1.]])

Por otro lado, NumPy proporciona una función análoga a range.

range(10)
range(0, 10)
type(range(10))
range
np.arange(10)
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
type(np.arange(10))
numpy.ndarray
np.arange(3, 10)
array([3, 4, 5, 6, 7, 8, 9])
np.arange(2, 20, 3, dtype=np.float)
array([ 2.,  5.,  8., 11., 14., 17.])
np.arange(9).reshape(3, 3)
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])

Bonus: Utilizar np.arange tiene como “ingredientes” el inicio (start), fin (stop) y el tamaño del espacio entre valores (step) y el largo (len) depende estos argumentos. Sin embargo, existe la función np.linspace que construye un np.array con un inicio y un fin, pero indicando la cantidad de elementos (y por lo tanto, el espaciado depende es este).

np.linspace(0, 100, 5)
array([  0.,  25.,  50.,  75., 100.])

Esto puede causar confusiones en ocasiones, pues recuerda que la indexación de Python (y por lo tanto NumPy) comienza en cero, por lo que si quieres replicar el np.array anterior con np.arange debes tener esto en consideración. Es decir:

np.arange(start=0, stop=100, step=25)  # stop = 100
array([ 0, 25, 50, 75])
np.arange(start=0, stop=101, step=25)  # stop = 101
array([  0,  25,  50,  75, 100])

No podía faltar la instanciación a través de elementos aleatorios

np.random.random(size=3)  # Elementos entre 0 y 1
array([0.96165579, 0.77606847, 0.45565248])
np.random.uniform(low=3, high=7, size=5)  # Desde una distribución uniforme
array([5.43713347, 6.38887844, 3.08124221, 5.89289897, 4.22273772])
np.random.normal(loc=100, scale=10, size=(2, 3))   # Desde una distribución normal indicando media y desviación estándar
array([[104.60216161, 100.60921685, 107.82969819],
       [ 93.05321527, 103.01779242,  98.19886912]])

Acceder a los elementos de un array

Es muy probable que necesites acceder a elementos o porciones de un array, para ello NumPy tiene una sintáxis consistente con Python.

x1 = np.arange(0, 30, 4)
x2 = np.arange(0, 60, 3).reshape(4, 5)
print("x1:")
print(x1)
print("\nx2:")
print(x2)
x1:
[ 0  4  8 12 16 20 24 28]

x2:
[[ 0  3  6  9 12]
 [15 18 21 24 27]
 [30 33 36 39 42]
 [45 48 51 54 57]]
x1[1]  # Un elemento de un array 1D
4
x1[:3]  # Los tres primeros elementos
array([0, 4, 8])
x2[0, 2]  # Un elemento de un array 2D
6
x2[0]  # La primera fila
array([ 0,  3,  6,  9, 12])
x2[:, 1]  # Todas las filas y la segunda columna
array([ 3, 18, 33, 48])
x2[:, 1:3]  # Todas las filas y de la segunda a la tercera columna
array([[ 3,  6],
       [18, 21],
       [33, 36],
       [48, 51]])

Nuevamente, recordar que Python tiene indexación partiendo desde cero. Además, la dimensión del arreglo también depende de la forma en que se haga la selección.

x2[:, 2]
array([ 6, 21, 36, 51])
x2[:, 2:3]  # What?!
array([[ 6],
       [21],
       [36],
       [51]])

En el ejemplo anterior los valores son los mismos, pero las dimensiones no. En el primero se utiliza indexing para acceder a la tercera columna, mientras que en el segundo slicing para acceder desde la tercera columna a la tercera columna.

print(x2[:, 2].shape)
print(x2[:, 2:3].shape)
(4,)
(4, 1)

Operaciones Básias

Numpy provee operaciones vectorizadas, con tal de mejorar el rendimiento de la ejecución.

Por ejemplo, pensemos en la suma de dos arreglos 2D.

A = np.random.random((5,5))
B = np.random.random((5,5))

Con los conocimientos de la clase pasada, podríamos pensar en iterar a través de dos for, con tal de llenar el arreglo resultando. algo así:

def my_sum(A, B):
    n, m = A.shape
    C = np.empty(shape=(n, m))
    for i in range(n):
        for j in range(m):
            C[i, j] = A[i, j] + B[i, j]
    return C
%timeit my_sum(A, B)
16.2 µs ± 297 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

Pero la suma de ndarrays es simplemente con el signo de suma (+):

%timeit A + B 
537 ns ± 0.869 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

Para dos arrays tan pequeños la diferencia de tiempo es considerable, ¡Imagina con millones de datos!

Los clásicos de clásicos:

x = np.arange(5)
print(f"x      = {x}")
print(f"x + 5  = {x + 5}")
print(f"x - 5  = {x - 5}")
print(f"x * 2  = {x * 2}")
print(f"x / 2  = {x / 2}")
print(f"x // 2 = {x // 2}")
print(f"x ** 2 = {x ** 2}")
print(f"x % 2  = {x % 2}")
x      = [0 1 2 3 4]
x + 5  = [5 6 7 8 9]
x - 5  = [-5 -4 -3 -2 -1]
x * 2  = [0 2 4 6 8]
x / 2  = [0.  0.5 1.  1.5 2. ]
x // 2 = [0 0 1 1 2]
x ** 2 = [ 0  1  4  9 16]
x % 2  = [0 1 0 1 0]

¡Júntalos como quieras!

-(0.5 + x + 3) ** 2
array([-12.25, -20.25, -30.25, -42.25, -56.25])

Al final del día, estos son alias para funciones de Numpy, por ejemplo, la operación suma (+) es un wrapper de la función np.add

np.add(x, 5)
array([5, 6, 7, 8, 9])

Podríamos estar todo el día hablando de operaciones, pero básicamente, si piensas en alguna operación lo suficientemente común, es que la puedes encontrar implementada en Numpy. Por ejemplo:

np.abs(-(0.5 + x + 3) ** 2)
array([12.25, 20.25, 30.25, 42.25, 56.25])
np.log(x + 5)
array([1.60943791, 1.79175947, 1.94591015, 2.07944154, 2.19722458])
np.exp(x)
array([ 1.        ,  2.71828183,  7.3890561 , 20.08553692, 54.59815003])
np.sin(x)
array([ 0.        ,  0.84147098,  0.90929743,  0.14112001, -0.7568025 ])

Para dimensiones mayores la idea es la misma, pero siempre hay que tener cuidado con las dimensiones y shape de los arrays.

print("A + B: \n")
print(A + B)
print("\n" + "-" * 80 + "\n")
print("A - B: \n")
print(A - B)
print("\n" + "-" * 80 + "\n")
print("A * B: \n")
print(A * B)  # Producto elemento a elemento
print("\n" + "-" * 80 + "\n")
print("A / B: \n")
print(A / B)  # División elemento a elemento
print("\n" + "-" * 80 + "\n")
print("A @ B: \n")
print(A @ B)  # Producto matricial
A + B: 

[[0.36641902 1.10265758 0.97080253 1.61427266 1.10848218]
 [0.6980639  1.02215826 0.33492285 1.09431768 1.16570131]
 [0.58299357 1.22340948 0.62221823 0.75374863 0.91516117]
 [1.62053995 0.85742391 1.77298672 1.01488167 0.64104018]
 [1.23877663 1.52619614 1.70190349 1.12218395 1.41613267]]

--------------------------------------------------------------------------------

A - B: 

[[-0.08307907 -0.16907341  0.59270808 -0.13320033  0.15083695]
 [-0.07945091  0.7505947  -0.1826062  -0.316115    0.61827474]
 [ 0.38407445  0.35219947 -0.43638885  0.4595377  -0.73772994]
 [-0.03996857  0.44160434  0.18059724  0.79528976  0.33486536]
 [ 0.0267252   0.08823926  0.12273678  0.72022703  0.56097398]]

--------------------------------------------------------------------------------

A * B: 

[[0.03184019 0.29681698 0.14778867 0.64703348 0.30149524]
 [0.12024519 0.12035377 0.01970707 0.27440062 0.24414898]
 [0.04809208 0.34317157 0.04918007 0.08924052 0.07331863]
 [0.65613806 0.13504034 0.77771664 0.09937475 0.07469943]
 [0.38346333 0.58037212 0.72035279 0.18514246 0.42268498]]

--------------------------------------------------------------------------------

A / B: 

[[0.6303474  0.73410507 4.13523812 0.84755092 1.31501635]
 [0.79562857 6.52794872 0.29431517 0.55174748 3.25884078]
 [4.86161407 1.80852946 0.17554141 4.12386558 0.10734599]
 [0.95185984 3.12401907 1.22682547 8.24334324 3.18741275]
 [1.04409911 1.12272865 1.155445   4.5836031  2.31197632]]

--------------------------------------------------------------------------------

A @ B: 

[[1.28748531 1.10067779 1.64813712 0.77582799 1.22427637]
 [1.28529716 1.07242418 1.34224653 1.12853499 0.89461885]
 [0.9816119  0.64481785 0.89750116 1.07615905 0.6547509 ]
 [1.57443388 1.55520675 1.94039586 1.48968361 1.71064846]
 [1.9106845  1.8116373  2.32539761 1.55615051 1.84161453]]

Operaciones Booleanas

print(f"x      = {x}")
print(f"x > 2  = {x > 2}")
print(f"x == 2 = {x == 2}")
print(f"x == 2 = {x == 2}")
x      = [0 1 2 3 4]
x > 2  = [False False False  True  True]
x == 2 = [False False  True False False]
x == 2 = [False False  True False False]
aux1 = np.array([[1, 2, 3], [2, 3, 5], [1, 9, 6]])
aux2 = np.array([[1, 2, 3], [3, 5, 5], [0, 8, 5]])

B1 = aux1 == aux2
B2 = aux1 > aux2

print("B1: \n")
print(B1)
print("\n" + "-" * 80 + "\n")
print("B2: \n")
print(B2)
print("\n" + "-" * 80 + "\n")
print("~B1: \n")
print(~B1)  # También puede ser np.logical_not(B1)
print("\n" + "-" * 80 + "\n")
print("B1 | B2 : \n")
print(B1 | B2)
print("\n" + "-" * 80 + "\n")
print("B1 & B2 : \n")
print(B1 & B2)
B1: 

[[ True  True  True]
 [False False  True]
 [False False False]]

--------------------------------------------------------------------------------

B2: 

[[False False False]
 [False False False]
 [ True  True  True]]

--------------------------------------------------------------------------------

~B1: 

[[False False False]
 [ True  True False]
 [ True  True  True]]

--------------------------------------------------------------------------------

B1 | B2 : 

[[ True  True  True]
 [False False  True]
 [ True  True  True]]

--------------------------------------------------------------------------------

B1 & B2 : 

[[False False False]
 [False False False]
 [False False False]]

Broadcasting

¿Qué pasa si las dimensiones no coinciden? Observemos lo siguiente:

a = np.array([0, 1, 2])
b = np.array([5, 5, 5])
a + b
array([5, 6, 7])

Todo bien, dos arrays 1D de 3 elementos, la suma retorna un array de 3 elementos.

a + 3
array([3, 4, 5])

Sigue pareciendo normal, un array 1D de 3 elementos, se suma con un int, lo que retorna un array 1D de tres elementos.

M = np.ones((3, 3))
M
array([[1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]])
M + a
array([[1., 2., 3.],
       [1., 2., 3.],
       [1., 2., 3.]])

Magia! Esto es broadcasting. Una pequeña infografía para digerirlo:

Resumen: A lo menos los dos arrays deben coincidir en una dimensión. Luego, el array de dimensión menor se extiende con tal de ajustarse a las dimensiones del otro.

La documentación oficial de estas reglas la puedes encontrar aquí.