Paquetes

  • matplotlib
  • numpy

Tipos de dato en Python

Tipos base

  • Números enteros - int
  • Números flotantes - float
  • Números complejos - complex
  • Cadenas - str
  • Booleanos - bool

Contenedores

  • Listas - list
  • Tuplas - tuple
  • Mapas - dict
  • Conjuntos - set

muy importantes pero, no tan interesantes a comparación de...

Los arreglos nuéricos multidimensionales

🗣️ Los arreglos multidimensionales también se conocen como ndarrays, N-dimensional arrays.

Es un tipo de dato que nos sirve para representar colecciones de números, es un tipo no nativo de Python.

Razones

Representación de elementos complejos

Imágenes, videos, canciones, modelos 3d... todos estas cosas se pueden representar como arreglos multidimensionales: una canción es un areglo unidimensional de intensidades de sonido, una imagen es un arreglo bidimensional de valores de brillo, y un vídeo es un areglo tridimensional, dos de las dimensiones son brillos y la otra representa el tiempo, un modelo 3d es in conjunto de puntos en un espacio tridimensional representado como un arreglo multidimensional.

Trabajar con "datos científicos"

Usualmente realizamos experimentos científicos repetidamente, obteniendo de ellos una secuencia de valores que podemos representar fácilmente como un arreglo. Otras veces hacemos un solo experimento sobre múltiples sujetos, obtieniendo de ellos una secuencia de valores. ¡A veces hacemos las dos cosas a la vez!

Elegancia 🧐

Los arreglos multidimensionales nos permiten aplicar y extender las operaciones que podemos aplicar a números individuales para aplicarselos a todos los elementos del arreglo de forma concisa y fácil de leer; la alternativa es escribir ciclos para aplicar operaciones a distintos elementos de una colección (ver ejemplo).

Eficiencia

Los arreglos multidimensionales son relativamente compactos (comparados con una lista, por ejemplo) y almacenan la información de forma muy eficiente, lo cual los hace ideales para lidiar con problemas en los que tenemos que lidiar con grandes cantidades de datos.

Una instrucción, muchos datos

El tema central de esta sesión es la introducción al cómputo vectorizado (ojo, ¡no vectorial!). Nosotros vamos a conocer bajo este término a la práctica de escribir código que se aplique a múltiples elementos de un arreglo de forma simultanea. Esto se también como SIMD (Single Instruction Multiple Data) y representa una forma de implementar cómputo paralelo restringido a operaciones numéricas sobre arreglos, claro.

GPUs

Uno de los ejemplos por excelencia de dónde es que ocurren este tipo de operaciones es en las tarjetas gráficas, hace poco mencioné que podemos representar modelos 3D usando arreglos multidimensionales; para hacer que estos objetos se muevan mediante rotaciones, traslaciones y demás, debemos aplicar sobre ellos operaciones matemáticas y para eso es que se ocupan este tipo de tarjetas.

Estas tarjetas están especialmente diseñadas (y programadas) para lidiar de manera eficiente con arreglos numéricos y múltiples operaciones que podemos usar para manipularlos. Es por eso que son muy importantes en el mundo de los videojuegos; pero no solo ahí, puesto que cualquier operacion que representemos como arreglos puede ser llevada a cabo en una GPU.

Además de GPUs, Google creó algo llamado TPUs (Tensor Processor Unit) que es una versión orientada a la manipulación de arreglos multidimensionales para su framework de Deep Learning Tensorflow.

Las cosas por su nombre

Hasta el momento hemos estado hablando de "arreglos multidimensionales", pero existen nombres específicos para arreglos con 1, 2 y 3 o más dimensiones:

Propiedades de los arreglos

  • Tamaño (siempre "rectangulares")
  • Tipo

Ejemplo 1

 [0 0 0]
 [0 0 0]
 [0 0 0]

Tamaño: (3, 3), tipo: enteros

Ejemplo 2

 [1.3 2.3 3.5 4.1 5.9]

Tamaño: (5,), tipo: float

Ejemplo 3 ❌

 [1.3 2.3 3.5]
 [0.0 3.5]
 [1.3 2.3 3.5 1.1]

Tamaño: (????), tipo: float

Como ya mencioné anteriormente, las listas en Python son listas de objetos; por el contrario, los ndarrays almacenan los números como números y de forma contigua en memoria; la biblioteca que nos permite interactuar con estos objetos en Python nos permite interactuar con los arreglos como valores numéricos en Python, pero en realidad no lo son.

La importancia de que los arreglos tengan un tipo uniforme para todos sus valores tiene que ver con la eficiencia a la hora de almacenarlos; es más fácil almacenar y acceder a ellos si tenemos un tamaño fijo para todos los elementos.

NumPy

triangle with all three sides equal

🚨 Citing NumPy

Un paquete con que permite operar con arreglos. Es la pieza central en SciPy.

Por convención lo verás (casi siempre) como:

import numpy as np
import numpy as np

Creemos un arreglo...

my_first_array = np.array([0, 1, 2, 3, 4])
print(my_first_array)
[0 1 2 3 4]

Recordando las propiedades...

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

Tipo - dtype

Un nombre para data type; recuerda: en un ndarray todos los valores son del mismo tipo.

print(my_second_array.dtype)
int64

Tamaño - shape

print(my_second_array.shape)
(3, 2)

El orden dado es (filas, columnas, profundidad, ???, ...).

$\begin 1 & 2 \ 3 & 4 \ 5 & 6 \ \end$

my_second_array tiene tres filas, dos columnas.

Creación de arreglos

np.array

Una de las formas más básicas para crear arreglos es a partir de información existente, por ejemplo, una lista (o una lista de listas):

my_list = [9.5, 4.5, 6.3]
my_arr = np.array(my_list)
print(my_arr, my_arr.dtype, my_arr.shape)
[9.5 4.5 6.3] float64 (3,)
multi_dimensional = np.array([[1, 2, 3], [4, 5, 6]])
print(multi_dimensional, multi_dimensional.dtype, multi_dimensional.shape)
[[1 2 3]
 [4 5 6]] int64 (2, 3)

⚠️ Sobre copiar arreglos...
Si necesitamos copiar un arreglo es necesario usar nuevamente np.array, o copy puesto que los ndarrays son mutables y podemos obtener efectos no deseados

# Incorrecto
a1 = np.array([0, 1, 2])
a2 = a1
a1[0] = 5000
print(a1)
print(a2)
[5000    1    2]
[5000    1    2]
# Correcto
a1 = np.array([0, 1, 2])
a2 = a1.copy()  # o np.array(a1)
a1[0] = 5000
print(a1)
print(a2)
[5000    1    2]
[0 1 2]

Arreglos vacíos

También es común que querramos crear arreglos "vacíos" o con valores pre-definidos; en lugar de crear un montón de listas llenas de ceros podemos usar np.zeros:

np.zeros

np.zeros((3,))
array([0., 0., 0.])
np.zeros((3, 3))
array([[0., 0., 0.],
       [0., 0., 0.],
       [0., 0., 0.]])
np.zeros((1, 2, 3, 4))
array([[[[0., 0., 0., 0.],
         [0., 0., 0., 0.],
         [0., 0., 0., 0.]],

        [[0., 0., 0., 0.],
         [0., 0., 0., 0.],
         [0., 0., 0., 0.]]]])
np.ones((1, 2, 3, 4))
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.ones

np.ones((3,))
array([1., 1., 1.])

np.full

A veces es útil poder crear un arreglo con un valor específico:

np.full((1, 2, 3), 1.23)
array([[[1.23, 1.23, 1.23],
        [1.23, 1.23, 1.23]]])

np.(...)_like

También podemos crear arreglos del mismo tamaño que otros, ya sean llenos con 0, 1 o cualquier otro valor de preferencia:

base = np.array([[0.0, 1], [2, 3]])
np.zeros_like(base)
array([[0., 0.],
       [0., 0.]])
np.ones_like(base)
array([[1., 1.],
       [1., 1.]])
np.full_like(base, np.pi)
array([[3.14159265, 3.14159265],
       [3.14159265, 3.14159265]])

Arreglos aleatorios

El aprendizaje automático tiene un componente de aleatoridad, es necesario saber cómo es que nosotros podemos usar esa aleatoridad. NumPy nos permite crear arreglos aleatorios:

np.random.randint(low=0, high=10, size=(3, 3))
array([[9, 2, 1],
       [2, 2, 1],
       [4, 1, 5]])
np.random.uniform(low=1, high=2, size=(2, 3))
array([[1.72594716, 1.5775126 , 1.22209363],
       [1.31707247, 1.34222539, 1.25546175]])
np.random.normal(loc=0, scale=1, size=(2, 3))
array([[ 0.46034061, -0.02050112, -0.40308532],
       [-0.6456416 , -0.2674267 ,  1.75029367]])

Secuencias

Otra de las herramientas muy útiles dentro de NumPy es la generación de secuencias incrementales.

np.arange

Una funcionalidad similar a range en Python, solo que en lugar de regresar un generador, np.arange retorna un ndarray:

np.arange(10)
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
np.arange(-10, 10)
array([-10,  -9,  -8,  -7,  -6,  -5,  -4,  -3,  -2,  -1,   0,   1,   2,
         3,   4,   5,   6,   7,   8,   9])
np.arange(start=1, stop=11, step=2)
array([1, 3, 5, 7, 9])
np.arange(start=11, stop=1, step=-2)
array([11,  9,  7,  5,  3])
np.arange(start=1.1, stop=2.1, step=0.1)
array([1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2. ])

⚠️ Los límites en np.arange son \([start, end)\), es decir, start es inclusivo mientras que end es exclusivo por default.

np.linspace

Si bien np.arange nos permite usar fracciones para determinar el tamaño del incremento; no es la mejor manera para generar sucesiones fraccionarias entre números, para eso podemos usar np.linspace

np.linspace(0, 5, 5)
array([0.  , 1.25, 2.5 , 3.75, 5.  ])
np.linspace(-10, 10, 15)
array([-10.        ,  -8.57142857,  -7.14285714,  -5.71428571,
        -4.28571429,  -2.85714286,  -1.42857143,   0.        ,
         1.42857143,   2.85714286,   4.28571429,   5.71428571,
         7.14285714,   8.57142857,  10.        ])
np.linspace(start=3.5, stop=3.6, num=10)
array([3.5       , 3.51111111, 3.52222222, 3.53333333, 3.54444444,
       3.55555556, 3.56666667, 3.57777778, 3.58888889, 3.6       ])

⚠️ A diferencia de np.arange, en np.linspace los límit4es son \([start, stop]\), es decir, ambos son inclusivos por default.

Indexación

Como cualquier secuencia en Python, los ndarrays también nos permiten acceder a los valores individuales que los forman usando la notación []:

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

print(index_me[0])
[1 2 3]
print(index_me[0][1])
2

Tenemos a nuestra disposición otra sintaxis para realizar indexación múltiple; nos vamos a asistir de la ,:

print(index_me[0, 1])
2

Slicing

🤠 El slicing en Python es una de las caracterísitcas más interesantes, porque además de permitirnos especificar los puntos de inicio y fin del slicing, también nos permite especificar el tamaño del paso a dar.

Los ndarrays también soportan el slicing:

index_me[:, :]
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])
index_me[:, 0]  # Primera columna
array([1, 4, 7])
index_me[:, 1:]  # Últimas dos columnas
array([[2, 3],
       [5, 6],
       [8, 9]])
index_me[1:, 1:]
array([[5, 6],
       [8, 9]])
long_array = np.arange(10, 20)

print(long_array)
[10 11 12 13 14 15 16 17 18 19]
print(long_array[2:6])
[12 13 14 15]
print(long_array[2:-2])
[12 13 14 15 16 17]
print(long_array[::3])
[10 13 16 19]
print(long_array[::-1])
[19 18 17 16 15 14 13 12 11 10]

Asignación de valores

A recordar que los ndarrays son mutables; podemos cambiar los valores de un arreglo usando indexación y slicing

arreglo = np.arange(0, 20)

print(arreglo)
[ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19]
arreglo[0] = -500
print(arreglo)
[-500    1    2    3    4    5    6    7    8    9   10   11   12   13
   14   15   16   17   18   19]
arreglo[::2] = -100
print(arreglo)
[-100    1 -100    3 -100    5 -100    7 -100    9 -100   11 -100   13
 -100   15 -100   17 -100   19]
arreglo[2:5:] = 0
print(arreglo)
[-100    1    0    0    0    5 -100    7 -100    9 -100   11 -100   13
 -100   15 -100   17 -100   19]

Playground

Photo by Filip Gielda on Unsplash

import matplotlib.pyplot as plt
import matplotlib.image as mpimg

chichen_itza = mpimg.imread("assets/02/filip-gielda-VPavA7BBxK0-unsplash.jpg")
print(type(chichen_itza))
print(chichen_itza.dtype)
print(chichen_itza.shape)

plt.imshow(chichen_itza[80:170, 230:400])
<class 'numpy.ndarray'>
uint8
(427, 640, 3)





<matplotlib.image.AxesImage at 0x1117280a0>

png

ci_copy = chichen_itza[80:170, 230:400].copy()
ci_copy[::2, ::2, :] = 0
plt.imshow(ci_copy)
<matplotlib.image.AxesImage at 0x1117d86d0>

png

ci_copy = chichen_itza[::4, ::4].copy()
plt.imshow(ci_copy)
<matplotlib.image.AxesImage at 0x11184fa90>

png

Operaciones arreglo-escalar

# Suma
test1 = np.arange(10, 15)
print(test1)
print(test1 + 10)
[10 11 12 13 14]
[20 21 22 23 24]
# Multiplicaciones
print(test1 * 10)
print(test1 / 10)
[100 110 120 130 140]
[1.  1.1 1.2 1.3 1.4]
# cos, abs, pow
test2 = np.arange(-5, 5)
print(test2)
print(np.abs(test2))
print(np.cos(test2))
print(np.power(test2, 2))
[-5 -4 -3 -2 -1  0  1  2  3  4]
[5 4 3 2 1 0 1 2 3 4]
[ 0.28366219 -0.65364362 -0.9899925  -0.41614684  0.54030231  1.
  0.54030231 -0.41614684 -0.9899925  -0.65364362]
[25 16  9  4  1  0  1  4  9 16]

Operaciones elemento-elemento

# Suma
a = np.arange(10, 15)
b = np.arange(20, 25)
print(a, b)
print(a + b)
print(a - b)
[10 11 12 13 14] [20 21 22 23 24]
[30 32 34 36 38]
[-10 -10 -10 -10 -10]
# Multiplicación
print(a * b)
print(a / b)
[200 231 264 299 336]
[0.5        0.52380952 0.54545455 0.56521739 0.58333333]

Broadcasting

Las operaciones elemento a elemento funcionan siempre y cuando los arreglos con los que estamos operando sean del mismo tamaño y forma... ¿pero qué sucede cuando no es así?

Aún así podemos realizar operaciones bajo ciertas condiciones en nuestros operandos x e y:

  • Si x es un areglo e y un escalar, el escalar actúa sobre todos los elementos del arreglo.

  • Si x e y tienen las mismas dimensiones y tamaños, la operación sucede elemento a elemento.

  • Si uno de los operandos tiene menos dimensiones que el otro, y las ultimas dimensiones del izquierdo coinciden con la forma del operando derecho, las operaciones suceden repitiendo el elemento derecho tantas veces como sea necesario

    • (2, 2) * (2,)
    • (1, 2, 3) * (2, 3)
    • (10, 20, 20) * (20,)
    • (10, 20, 20) * (10,20)
    • (10,) * (20,)
vector = np.array([1, 2, 3, 4])
unos = np.ones((3, 4))
print(unos.shape, vector.shape)
(3, 4) (4,)
print(unos + vector)
[[2. 3. 4. 5.]
 [2. 3. 4. 5.]
 [2. 3. 4. 5.]]
print(unos / vector)
[[1.         0.5        0.33333333 0.25      ]
 [1.         0.5        0.33333333 0.25      ]
 [1.         0.5        0.33333333 0.25      ]]

Transposición

Una de las operaciones más comunes sobre matrices es la transposición, NumPy ofrece una sintaxis especial: array.T:

matriz = np.array(
    [
        [1, 2, 3, 4],
        [0, 0, 0, 0],
        [1, 2, 3, 4],
    ]
)

print(matriz)
print(matriz.T)
[[1 2 3 4]
 [0 0 0 0]
 [1 2 3 4]]
[[1 0 1]
 [2 0 2]
 [3 0 3]
 [4 0 4]]

### Transformación de arreglos

❓❓❓ ¿hacemos otro live opcional?

Indexación "elegante"

Ya vimos cómo acceder a elementos mediante indexación y slicing, pero NumPy permite otro tipo de indexación, conocida como 💅Fancy Indexing 🎩.

Arreglos como índices

Podemos usar otros arreglos como índices

indexing_array = np.array([3, 2, 5, 1, 4])
original_array = np.arange(10, 20)

print(original_array[indexing_array])
[13 12 15 11 14]
indexing_array = np.array([0, 0, 0, 0, 1, 2, 2])
print(original_array[indexing_array])
[10 10 10 10 11 12 12]
indexing_array = np.array([0])
print(original_array[indexing_array])
[10]

Máscaras - booleanos como índices

También podemos usar arreglos booleanos como índices 🤔

booleans = [True, False, True, False, False]
original_array = np.arange(5)

print(original_array)
print(original_array[booleans])
[0 1 2 3 4]
[0 2]
original_array = np.arange(22)
even_numbers = original_array % 2 == 0

print(original_array)
print(original_array[even_numbers])
print(original_array[original_array > 10])
[ 0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21]
[ 0  2  4  6  8 10 12 14 16 18 20]
[11 12 13 14 15 16 17 18 19 20 21]

Agregaciones

Siempre es una buena idea echarle un ojo a algunas caracrerísticas de nuestros datos a través de información agregada o sintetizada.

bitcoin_prices = np.array(
    [
        59_295.50,
        45_164.00,
        33_108.10,
        28_949.40,
        19_698.10,
        13_797.30,
        10_776.10,
        11_644.20,
        11_333.40,
        9_135.40,
        9_454.80,
        8_629.00,
        6_412.50,
        8_543.70,
        9_349.10,
        7_196.40,
        7_546.60,
        9_152.60,
        8_284.30,
        9_594.40,
        10_082.00,
        10_818.60,
        8_558.30,
        5_320.80,
        4_102.30,
    ]
)

Estadística - mean, median, desviación estándar

print(np.mean(bitcoin_prices))
print(np.median(bitcoin_prices))
print(np.std(bitcoin_prices))
14637.875999999998
9454.8
13009.079842334122

Mínimos y máximos

print(np.max(bitcoin_prices))
print(np.min(bitcoin_prices))
59295.5
4102.3

Agregaciones en N-dimensiones

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

ndimensional.shape
(2, 3, 3)
np.mean(ndimensional)
3.5
axis_0 = np.mean(ndimensional, axis=0)

# [[ (1 + 1) / 2, (2 + 2) / 2, (3 + 3) / 2 ]
#  [ (4 + 1) / 2, (5 + 2) / 2, (6 + 3) / 2 ]
#  [ (7 + 1) / 2, (8 + 2) / 2, (9 + 3) / 2 ]]
print(axis_0.shape)
print(axis_0)
(3, 3)
[[1.  2.  3. ]
 [2.5 3.5 4.5]
 [4.  5.  6. ]]
axis_1 = np.mean(ndimensional, axis=1)

# [[ (1 + 4 + 7) / 3, (2 + 5 + 8) / 3, (3 + 6 + 9) / 3 ]
#  [ (1 + 1 + 1) / 3, (2 + 2 + 2) / 3, (3 + 3 + 4) / 3 ]]
print(axis_1.shape)
print(axis_1)
(2, 3)
[[4. 5. 6.]
 [1. 2. 3.]]
axis_2 = np.mean(ndimensional, axis=2)

# [[ (1 + 2 + 3) / 3, (4 + 5 + 6) / 3, (7 + 8 + 9) / 3 ]
#  [ (1 + 2 + 3) / 3, (1 + 2 + 3) / 3, (1 + 2 + 3) / 3 ]]
print(axis_2.shape)
print(axis_2)
(2, 3)
[[2. 5. 8.]
 [2. 2. 2.]]

Persistencia

En algún momento vamos a querer almacenar nuestros arreglos, NumPy nos deja hacerlo en forma de archivos de texto (1 y 2 dimensiones):

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

np.savetxt("multidimensional.txt", to_save)

loaded = np.loadtxt("multidimensional.txt")

print(loaded)
[[1. 2. 3.]
 [4. 5. 6.]
 [7. 8. 9.]]

Extra - velocidades

a_list = list(range(100_000))
b_list = list(range(100_000))

a_array = np.array(a_list)
b_array = np.array(b_list)


def add_list(a, b):
    for i in range(len(a)):
        a[i] += b[i]


def add_array(a, b):
    a += b
%timeit add_list(a_list,b_list)
11 ms ± 305 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit add_array(a_list,b_list)
737 µs ± 143 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Referencias

Libros

  • Elegant SciPy: The Art of Scientific Python: México · España · US
  • High Performance Python: Practical Performant Programming for Humans: México · España · US

Sitios web