Capítulo 4. Conceptos básicos de NumPy: Matrices y cálculo vectorial

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

NumPy, abreviatura de Numerical Python, esuno de los paquetes fundacionales más importantes para la computación numérica en Python. Muchos paquetes computacionales que proporcionan funcionalidad científica utilizan los objetos array de NumPy como una de las lingua francas de interfaz estándar para el intercambio de datos. Gran parte de los conocimientos sobre NumPy que cubro son transferibles también a pandas.

Éstas son algunas de las cosas que encontrarás en NumPy:

  • ndarray, una eficiente matriz multidimensional que proporciona rápidas operaciones aritméticas orientadas a matrices y capacidades de difusiónflexibles

  • Funciones matemáticas para operaciones rápidas en matrices enteras de datos sin tener que escribir bucles

  • Herramientas para leer/escribir datos de matrices en disco y trabajar con archivos mapeados en memoria

  • Capacidades de álgebra lineal, generación de números aleatorios y transformada de Fourier

  • Una API en C para conectar NumPy con bibliotecas escritas en C, C++ o FORTRAN

Dado que NumPy proporciona una API de C completa y bien documentada, essencillo pasar datos a bibliotecas externas escritas en un lenguaje de bajo nivel, y que las bibliotecas externas devuelvan datos a Python como matrices NumPy. Esta característica ha convertido a Python en el lenguaje preferido para envolver bases de código C, C++ o FORTRAN heredadas y dotarlas de una interfaz dinámica y accesible.

Aunque NumPy por sí mismo no proporciona funciones de modelado o científicas, conocer las matrices de NumPy y la computación orientada a matrices te ayudará a utilizar herramientas con semántica de computación de matrices, como pandas, de forma mucho más eficaz. Como NumPy es un tema extenso, más adelante trataré con más profundidad muchas funciones avanzadas de NumPy, como la transmisión (véase el Apéndice A). Muchas de estas funciones avanzadas no son necesarias para seguir el resto de este libro, pero pueden ayudarte a medida que profundices en la computación científica en Python.

Para la mayoría de las aplicaciones de análisis de datos, las principales áreas de funcionalidad en las que me centraré son:

  • Operaciones rápidas basadas en matrices para agrupar y limpiar datos, subconjuntar y filtrar, transformar y cualquier otro tipo de cálculo

  • Algoritmos comunes de matrices, como operaciones de ordenación, únicas y conjuntos

  • Estadística descriptiva eficaz y agregación/resumen de datos

  • Alineación de datos y manipulaciones relacionales de datos para fusionar y unir conjuntos de datos heterogéneos

  • Expresar la lógica condicional como expresiones de matriz en lugar de bucles con ramas if-elif-else

  • Manipulaciones de datos en grupo (agregación, transformación y aplicación de funciones)

Aunque NumPy proporciona una base computacional para el procesamiento general de datos numéricos, muchos lectores querrán utilizar pandas como base para la mayoría de tipos de estadísticas o análisis, especialmente sobre datos tabulares. Además, pandas proporciona algunas funcionalidades más específicas del dominio, como la manipulación de series temporales, que no están presentes en NumPy.

Nota

La computación orientada a matrices en Python hunde sus raíces en 1995,cuando Jim Hugunin creó la biblioteca Numeric. Durante los 10 años siguientes, muchas comunidades de programación científica empezaron a programar matrices en Python, pero el ecosistema de bibliotecas se había fragmentado ena principios de la década de 2000. En 2005, Travis Oliphant consiguió forjar el proyecto NumPy a partir de los entonces proyectos Numeric y Numarray para reunir a la comunidad en torno a un único marco de computación de matrices.

Una de las razones por las que NumPy es tan importante para los cálculos numéricos enPython es porque está diseñado para ser eficiente con grandes matrices de datos. Hay varias razones para ello:

  • NumPy almacena internamente los datos en un bloque contiguo de memoria, independiente de otros objetos incorporados de Python. La biblioteca de algoritmos de NumPy, escrita en lenguaje C, puede operar en esta memoria sin comprobación de tipos ni otros gastos generales. Las matrices de NumPy también utilizan mucha menos memoria que las secuencias incorporadas de Python.

  • Las operaciones NumPy realizan cálculos complejos en matrices enteras sin necesidad de bucles Python for, que pueden ser lentos para secuencias grandes. NumPy es más rápido que el código Python normal porque sus algoritmos basados en C evitan la sobrecarga presente en el código Python interpretado normal.

Para que te hagas una idea de la diferencia de rendimiento, considera una matriz NumPy de un millón de enteros, y la lista Python equivalente:

In [7]: import numpy as np

In [8]: my_arr = np.arange(1_000_000)

In [9]: my_list = list(range(1_000_000))

Ahora multipliquemos cada secuencia por 2:

In [10]: %timeit my_arr2 = my_arr * 2
721 us +- 7.49 us per loop (mean +- std. dev. of 7 runs, 1000 loops each)

In [11]: %timeit my_list2 = [x * 2 for x in my_list]
49 ms +- 1.02 ms per loop (mean +- std. dev. of 7 runs, 10 loops each)

Los algoritmos basados en NumPy suelen ser de 10 a 100 veces más rápidos (o más) que sus homólogos en Python puro y utilizan mucha menos memoria.

4.1 La matriz NumPy ndarray: Un objeto matriz multidimensional

Una de las características clave de NumPy es su objeto matriz N-dimensional, o ndarray, que es un contenedor rápido y flexible para grandes conjuntos de datos en Python. Las matrices te permiten realizar operaciones matemáticas en bloques enteros de datos utilizando una sintaxis similar a la de las operaciones equivalentes entre elementos escalares.

Para que te hagas una idea de cómo NumPy permite realizar cálculos por lotes con una sintaxis similar a la de los valores escalares en los objetos incorporados de Python, primero importo NumPy y creo una pequeña matriz:

In [12]: import numpy as np

In [13]: data = np.array([[1.5, -0.1, 3], [0, -3, 6.5]])

In [14]: data
Out[14]: 
array([[ 1.5, -0.1,  3. ],
       [ 0. , -3. ,  6.5]])

A continuación, escribo operaciones matemáticas con data:

In [15]: data * 10
Out[15]: 
array([[ 15.,  -1.,  30.],
       [  0., -30.,  65.]])

In [16]: data + data
Out[16]: 
array([[ 3. , -0.2,  6. ],
       [ 0. , -6. , 13. ]])

En el primer ejemplo, todos los elementos se han multiplicado por 10. En el segundo, los valores correspondientes de cada "celda" de la matriz se han sumado entre sí.

Nota

En este capítulo y en todo el libro, utilizo la convención estándar de NumPy de utilizar siempre import numpy as np. Sería posible poner from numpy import * en tu código para evitar tener que escribir np., pero te desaconsejo que lo hagas por costumbre. El espacio de nombresnumpy es grande y contiene varias funciones cuyos nombres entran en conflicto con funciones incorporadas de Python (como min ymax). Seguir convenciones estándar como éstas es casi siempre una buena idea.

Un ndarray es un contenedor multidimensional genérico para datos homogéneos; es decir, todos los elementos deben ser del mismo tipo. Toda matriz tiene un shape, una tupla que indica el tamaño de cada dimensión, y un dtype, un objeto que describe el tipo de datos de la matriz:

In [17]: data.shape
Out[17]: (2, 3)

In [18]: data.dtype
Out[18]: dtype('float64')

Este capítulo te introducirá en los fundamentos del uso de matrices NumPy, y debería ser suficiente para seguir el resto del libro. Aunque no es necesario tener un conocimiento profundo de NumPy para muchas aplicaciones analíticas de datos, dominar la programación y el pensamiento orientados a matrices es un paso clave en el camino para convertirse en un gurú científico de Python.

Nota

Siempre que veas "array", "NumPy array" o "ndarray" en el texto del libro, en la mayoría de los casos todos se refieren al objeto ndarray.

Crear ndarrays

La forma más sencilla de crear una matriz es utilizar la función array . Ésta acepta cualquier objeto tipo secuencia (incluidas otras matrices) y produce una nueva matriz NumPy que contiene los datos pasados. Por ejemplo, una lista es un buen candidato para la conversión:

In [19]: data1 = [6, 7.5, 8, 0, 1]

In [20]: arr1 = np.array(data1)

In [21]: arr1
Out[21]: array([6. , 7.5, 8. , 0. , 1. ])

Las secuencias anidadas, como una lista de listas de igual longitud, se convertirán en una matriz multidimensional:

In [22]: data2 = [[1, 2, 3, 4], [5, 6, 7, 8]]

In [23]: arr2 = np.array(data2)

In [24]: arr2
Out[24]: 
array([[1, 2, 3, 4],
       [5, 6, 7, 8]])

Puesto que data2 era una lista de listas, la matriz NumPy arr2 tiene dos dimensiones, con forma inferida a partir de los datos. Podemos confirmarlo inspeccionando los atributos ndim y shape:

In [25]: arr2.ndim
Out[25]: 2

In [26]: arr2.shape
Out[26]: (2, 4)

A menos que se especifique explícitamente (se trata en "Tipos de datos para ndarrays"), numpy.arrayintenta inferir un buen tipo de datos para el array que crea. El tipo de datos se almacena en un objeto especial de metadatos dtype; por ejemplo, en los dos ejemplos anteriores tenemos:

In [27]: arr1.dtype
Out[27]: dtype('float64')

In [28]: arr2.dtype
Out[28]: dtype('int64')

Además de numpy.array, existen otras funciones para crear nuevas matrices. Como ejemplos, numpy.zeros y numpy.ones crean matrices de 0s o 1s, respectivamente, con una longitud o forma dadas. numpy.empty crea una matriz sin inicializar sus valores a ningún valor concreto. Para crear una matriz de mayor dimensión con estos métodos, pasa una tupla para la forma:

In [29]: np.zeros(10)
Out[29]: array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])

In [30]: np.zeros((3, 6))
Out[30]: 
array([[0., 0., 0., 0., 0., 0.],
       [0., 0., 0., 0., 0., 0.],
       [0., 0., 0., 0., 0., 0.]])

In [31]: np.empty((2, 3, 2))
Out[31]: 
array([[[0., 0.],
        [0., 0.],
        [0., 0.]],
       [[0., 0.],
        [0., 0.],
        [0., 0.]]])
Precaución

No es seguro asumir que numpy.empty devolverá una matriz de todos ceros. Esta función devuelve memoria no inicializada y, por tanto, puede contener valores "basura" distintos de cero. Sólo debes utilizar esta función si pretendes rellenar la nueva matriz con datos.

numpy.arange es una versión con valores de matrizde la función incorporada de Python range:

In [32]: np.arange(15)
Out[32]: array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14])

Consulta la Tabla 4-1 para ver una breve lista de funciones estándar de creación de matrices. Dado que NumPy se centra en la computación numérica, el tipo de datos, si no se especifica, será en muchos casos float64 (coma flotante).

Tabla 4-1. Algunas funciones importantes de NumPy para la creación de matrices
FunciónDescripción
arrayConvierte los datos de entrada (lista, tupla, matriz u otro tipo de secuencia) en una ndarray, ya sea deduciendo un tipo de datos o especificando explícitamente un tipo de datos; copia los datos de entrada por defecto
asarrayConvertir la entrada en ndarray, pero no copiar si la entrada ya es un ndarray
arangeComo el built-in rangepero devuelve un ndarray en lugar de una lista
ones, ones_likeProduce una matriz de todos los 1s con la forma y el tipo de datos dados; ones_like toma otra matriz y produce una matriz ones de la misma forma y tipo de datos
zeros, zeros_likeComo ones y ones_like pero produciendo matrices de 0s en lugar de 0s
empty, empty_likeCrea nuevas matrices asignando nueva memoria, pero no las rellenes con ningún valor como ones y zeros
full, full_likeProduce una matriz de la forma y tipo de datos dados con todos los valores ajustados al "valor de relleno" indicado; full_like toma otra matriz y produce una matriz rellena de la misma forma y tipo de datos
eye, identityCrea una matriz cuadrada N × N de identidad (1s en la diagonal y 0s en el resto)

Tipos de datos para ndarrays

El tipo de datos o dtype es un objeto especial que contiene la información (o metadatos, datos sobre datos) que la ndarray necesita para interpretar un trozo de memoria como un tipo de datos concreto:

In [33]: arr1 = np.array([1, 2, 3], dtype=np.float64)

In [34]: arr2 = np.array([1, 2, 3], dtype=np.int32)

In [35]: arr1.dtype
Out[35]: dtype('float64')

In [36]: arr2.dtype
Out[36]: dtype('int32')

Los tipos de datos son una fuente de la flexibilidad de NumPy para interactuar con datos procedentes de otros sistemas. En la mayoría de los casos, proporcionan un mapeo directo a una representación subyacente en disco o memoria, lo que permite leer y escribir flujos binarios de datos en disco y conectar con código escrito en un lenguaje de bajo nivel como C o FORTRAN. Los tipos de datos numéricos se nombran de la misma manera: un nombre de tipo, como float o int, seguido de un número que indica el número de bits por elemento. Un valor estándar de coma flotante de doble precisión (lo que se utiliza en el objeto float de Python) ocupa 8 bytes o 64 bits. Así, este tipo se conoce en NumPy como float64. Consulta la Tabla 4-2 para ver una lista completa de los tipos de datos soportados por NumPy.

Nota

No te preocupes por memorizar los tipos de datos de NumPy, sobre todo si eres un usuario nuevo. A menudo sólo es necesario preocuparse por el tipo general de datos con los que estás tratando, ya sean de coma flotante, complejos, enteros, booleanos, cadenas u objetos generales de Python. Cuando necesites más control sobre cómo se almacenan los datos en la memoria y en el disco, especialmente los grandes conjuntos de datos, es bueno saber que tienes control sobre el tipo de almacenamiento.

Tabla 4-2. Tipos de datos NumPy
TipoCódigo de tipoDescripción
int8, uint8i1, u1Tipos enteros de 8 bits (1 byte) con signo y sin signo
int16, uint16i2, u2Tipos enteros de 16 bits con signo y sin signo
int32, uint32i4, u4Tipos enteros de 32 bits con signo y sin signo
int64, uint64i8, u8Tipos enteros de 64 bits con signo y sin signo
float16f2Punto flotante de precisión media
float32f4 or fPunto flotante de precisión única estándar; compatible con C float
float64f8 or dPunto flotante de doble precisión estándar; compatible con C double y Python floatobject
float128f16 or gPunto flotante de precisión ampliada
complex64, complex128, complex256c8, c16, c32Números complejos representados por dos flotantes de 32, 64 ó 128, respectivamente
bool?Tipo booleano que almacena los valores True y False
objectOTipo de objeto Python; un valor puede ser cualquier objeto Python
string_STipo de cadena ASCII de longitud fija (1 byte por carácter); por ejemplo, para crear un tipo de datos de cadena con longitud 10, utiliza 'S10'
unicode_UTipo Unicode de longitud fija (número de bytes específico de la plataforma); misma semántica de especificación que string_ (por ejemplo, 'U10')
Nota

Existen tipos de enteros con signo y sin signo, y muchos lectores no estarán familiarizados con esta terminología. Un entero con signo puede representar enteros positivos y negativos, mientras que un entero sin signosólo puede representar enteros distintos de cero. Por ejemplo, int8 (entero de 8 bits con signo) puede representar enteros de -128 a 127 (ambos inclusive), mientras que uint8 (entero de 8 bits sin signo) puede representar de 0 a 255.

Puedes convertir explícitamente o fundir una matriz de un tipo de datos a otro utilizando el método astypede ndarray:

In [37]: arr = np.array([1, 2, 3, 4, 5])

In [38]: arr.dtype
Out[38]: dtype('int64')

In [39]: float_arr = arr.astype(np.float64)

In [40]: float_arr
Out[40]: array([1., 2., 3., 4., 5.])

In [41]: float_arr.dtype
Out[41]: dtype('float64')

En este ejemplo, los números enteros se han convertido a coma flotante. Si convierto algunos números de coma flotante en enteros, la parte decimal se truncará:

In [42]: arr = np.array([3.7, -1.2, -2.6, 0.5, 12.9, 10.1])

In [43]: arr
Out[43]: array([ 3.7, -1.2, -2.6,  0.5, 12.9, 10.1])

In [44]: arr.astype(np.int32)
Out[44]: array([ 3, -1, -2,  0, 12, 10], dtype=int32)

Si tienes una matriz de cadenas que representan números, puedes utilizarastype para convertirlas a forma numérica:

In [45]: numeric_strings = np.array(["1.25", "-9.6", "42"], dtype=np.string_)

In [46]: numeric_strings.astype(float)
Out[46]: array([ 1.25, -9.6 , 42.  ])
Precaución

Ten cuidado cuando utilices el tipo numpy.string_ , ya que los datos de cadena en NumPy son de tamaño fijo y pueden truncar la entrada sin previo aviso. pandas tiene un comportamiento más intuitivo con los datos no numéricos.

Si la conversión fallara por algún motivo (como una cadena que no se puede convertir a float64), se producirá un errorValueError. Antes, me daba un poco de pereza y escribía float en lugar de np.float64; NumPy asigna alias a los tipos de Python a sus propios tipos de datos equivalentes.

También puedes utilizar el atributo dtype de otra matriz:

In [47]: int_array = np.arange(10)

In [48]: calibers = np.array([.22, .270, .357, .380, .44, .50], dtype=np.float64)

In [49]: int_array.astype(calibers.dtype)
Out[49]: array([0., 1., 2., 3., 4., 5., 6., 7., 8., 9.])

Existen cadenas de código de tipo abreviado que también puedes utilizar para referirte a un dtype:

In [50]: zeros_uint32 = np.zeros(8, dtype="u4")

In [51]: zeros_uint32
Out[51]: array([0, 0, 0, 0, 0, 0, 0, 0], dtype=uint32)
Nota

Al llamar a astypesiempre se crea una nueva matriz (una copia de los datos), aunque el nuevo tipo de datos sea el mismo que el antiguo.

Aritmética con matrices NumPy

Las matrices son importantes porque te permiten expresar operaciones por lotes sobre datos sin escribir ningún bucle for. Los usuarios de NumPy llaman a esto vectorización. Cualquier operación aritmética entre matrices de igual tamaño aplica la operación elemento a elemento:

In [52]: arr = np.array([[1., 2., 3.], [4., 5., 6.]])

In [53]: arr
Out[53]: 
array([[1., 2., 3.],
       [4., 5., 6.]])

In [54]: arr * arr
Out[54]: 
array([[ 1.,  4.,  9.],
       [16., 25., 36.]])

In [55]: arr - arr
Out[55]: 
array([[0., 0., 0.],
       [0., 0., 0.]])

Las operaciones aritméticas con escalares propagan el argumento escalar a cada elemento de la matriz:

In [56]: 1 / arr
Out[56]: 
array([[1.    , 0.5   , 0.3333],
       [0.25  , 0.2   , 0.1667]])

In [57]: arr ** 2
Out[57]: 
array([[ 1.,  4.,  9.],
       [16., 25., 36.]])

Las comparaciones entre matrices del mismo tamaño producen matrices booleanas:

In [58]: arr2 = np.array([[0., 4., 1.], [7., 2., 12.]])

In [59]: arr2
Out[59]: 
array([[ 0.,  4.,  1.],
       [ 7.,  2., 12.]])

In [60]: arr2 > arr
Out[60]: 
array([[False,  True, False],
       [ True, False,  True]])

La evaluación de operaciones entre matrices de distinto tamaño se denomina difusión y se tratará con más detalle enApéndice A. Para la mayor parte de este libro no es necesario tener un conocimiento profundo de la difusión.

Indexación y segmentación básicas

La indexación de matrices en NumPy es un tema profundo, ya que hay muchas formas en las que puedesquerer seleccionar un subconjunto de tus datos o elementos individuales. Las matrices unidimensionales son sencillas; en apariencia actúan de forma similar a las listas de Python:

In [61]: arr = np.arange(10)

In [62]: arr
Out[62]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

In [63]: arr[5]
Out[63]: 5

In [64]: arr[5:8]
Out[64]: array([5, 6, 7])

In [65]: arr[5:8] = 12

In [66]: arr
Out[66]: array([ 0,  1,  2,  3,  4, 12, 12, 12,  8,  9])

Como puedes ver, si asignas un valor escalar a una rebanada, como en arr[5:8] = 12, el valor se propaga (o difunde en adelante) a toda la selección.

Nota

Una primera distinción importante con respecto a las listas incorporadas de Python es que las rebanadas de matriz son vistas de la matriz original. Esto significa que los datos no se copian, y que cualquier modificación de la vista se reflejará en la matriz de origen.

Para dar un ejemplo de esto, primero creo una rebanada de arr:

In [67]: arr_slice = arr[5:8]

In [68]: arr_slice
Out[68]: array([12, 12, 12])

Ahora, cuando cambio valores en arr_slice, las mutaciones se reflejan en la matriz original arr:

In [69]: arr_slice[1] = 12345

In [70]: arr
Out[70]: 
array([    0,     1,     2,     3,     4,    12, 12345,    12,     8,
           9])

La rebanada "desnuda" [:] asignará a todos los valores de una matriz:

In [71]: arr_slice[:] = 64

In [72]: arr
Out[72]: array([ 0,  1,  2,  3,  4, 64, 64, 64,  8,  9])

Si eres nuevo en NumPy, puede que esto te sorprenda, sobre todo si has utilizado otros lenguajes de programación de matrices que copian los datos con más avidez. Como NumPy se ha diseñado para poder trabajar con matrices muy grandes, podrías imaginar problemas de rendimiento y de memoria si NumPy insistiera en copiar siempre los datos.

Precaución

Si quieres una copia de una porción de un ndarray en lugar de una vista, tendrás que copiar explícitamente el array; por ejemplo, arr[5:8].copy(). Como verás, pandas también funciona así.

Con matrices de mayor dimensión, tienes muchas más opciones. En una matriz bidimensional, los elementos de cada índice ya no son escalares, sino matrices unidimensionales:

In [73]: arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

In [74]: arr2d[2]
Out[74]: array([7, 8, 9])

Así, se puede acceder recursivamente a elementos individuales. Pero eso es demasiado trabajo, así que puedes pasar una lista de índices separados por comas para seleccionar elementos individuales. Así que son equivalentes:

In [75]: arr2d[0][2]
Out[75]: 3

In [76]: arr2d[0, 2]
Out[76]: 3

Consulta la Figura 4-1 para ver una ilustración de la indexación en una matriz bidimensional. Me resulta útil pensar en el eje 0 como las "filas" del array y en el eje 1 como las "columnas".

Figura 4-1. Indexación de elementos en una matriz NumPy

En los arrays multidimensionales, si omites los índices posteriores, el objeto devuelto será un ndarray de dimensión inferior formado por todos los datos de las dimensiones superiores. Así, en la matriz de 2 × 2 × 3 arr3d:

In [77]: arr3d = np.array([[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]])

In [78]: arr3d
Out[78]: 
array([[[ 1,  2,  3],
        [ 4,  5,  6]],
       [[ 7,  8,  9],
        [10, 11, 12]]])

arr3d[0] es una matriz de 2 × 3:

In [79]: arr3d[0]
Out[79]: 
array([[1, 2, 3],
       [4, 5, 6]])

Tanto los valores escalares como las matrices pueden asignarse a arr3d[0]:

In [80]: old_values = arr3d[0].copy()

In [81]: arr3d[0] = 42

In [82]: arr3d
Out[82]: 
array([[[42, 42, 42],
        [42, 42, 42]],
       [[ 7,  8,  9],
        [10, 11, 12]]])

In [83]: arr3d[0] = old_values

In [84]: arr3d
Out[84]: 
array([[[ 1,  2,  3],
        [ 4,  5,  6]],
       [[ 7,  8,  9],
        [10, 11, 12]]])

Del mismo modo, arr3d[1, 0] te da todos los valores cuyos índices empiezan por (1, 0), formando una matriz unidimensional:

In [85]: arr3d[1, 0]
Out[85]: array([7, 8, 9])

Esta expresión es la misma que si hubiéramos indexado en dos pasos:

In [86]: x = arr3d[1]

In [87]: x
Out[87]: 
array([[ 7,  8,  9],
       [10, 11, 12]])

In [88]: x[0]
Out[88]: array([7, 8, 9])

Observa que en todos estos casos en los que se han seleccionado subsecciones de la matriz, las matrices devueltas son vistas.

Precaución

Esta sintaxis de indexación multidimensional para matrices NumPy no funcionará con objetos normales de Python, como listas de listas.

Indexar con rodajas

Al igual que los objetos unidimensionales, como las listas de Python, las ndarrays se pueden cortar con la sintaxis conocida:

In [89]: arr
Out[89]: array([ 0,  1,  2,  3,  4, 64, 64, 64,  8,  9])

In [90]: arr[1:6]
Out[90]: array([ 1,  2,  3,  4, 64])

Considera la matriz bidimensional de antes, arr2d. Cortar esta matriz es un poco diferente:

In [91]: arr2d
Out[91]: 
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

In [92]: arr2d[:2]
Out[92]: 
array([[1, 2, 3],
       [4, 5, 6]])

Como puedes ver, se ha cortado a lo largo del eje 0, el primer eje. Un corte, por tanto, selecciona un rango de elementos a lo largo de un eje. Puede ser útil leer la expresión arr2d[:2] como "selecciona las dos primeras filas de arr2d."

Puedes pasar varios cortes, igual que puedes pasar varios índices:

In [93]: arr2d[:2, 1:]
Out[93]: 
array([[2, 3],
       [5, 6]])

Al rebanar así, siempre obtienes vistas de matriz del mismo número de dimensiones. Mezclando índices enteros y cortes, obtienes cortes de menor dimensión.

Por ejemplo, puedo seleccionar la segunda fila pero sólo las dos primeras columnas, así:

In [94]: lower_dim_slice = arr2d[1, :2]

Aquí, mientras que arr2d es bidimensional, lower_dim_slice es unidimensional, y su forma es una tupla con un tamaño de eje:

In [95]: lower_dim_slice.shape
Out[95]: (2,)

Del mismo modo, puedo seleccionar la tercera columna pero sólo las dos primeras filas, así:

In [96]: arr2d[:2, 2]
Out[96]: array([3, 6])

Mira la Figura 4-2 para ver una ilustración. Ten en cuenta que dos puntos por sí solos significan tomar todo el eje, por lo que sólo puedes cortar ejes de dimensiones superiores haciendo:

In [97]: arr2d[:, :1]
Out[97]: 
array([[1],
       [4],
       [7]])

Por supuesto, al asignar a una expresión de trozo se asigna a toda la selección:

In [98]: arr2d[:2, 1:] = 0

In [99]: arr2d
Out[99]: 
array([[1, 0, 0],
       [4, 0, 0],
       [7, 8, 9]])
Figura 4-2. Corte de matrices bidimensionales

Indexación booleana

Consideremos un ejemplo en el que tenemos unos datos en una matrizy una matriz de nombres con duplicados:

In [100]: names = np.array(["Bob", "Joe", "Will", "Bob", "Will", "Joe", "Joe"])

In [101]: data = np.array([[4, 7], [0, 2], [-5, 6], [0, 0], [1, 2],
   .....:                  [-12, -4], [3, 4]])

In [102]: names
Out[102]: array(['Bob', 'Joe', 'Will', 'Bob', 'Will', 'Joe', 'Joe'], dtype='<U4')

In [103]: data
Out[103]: 
array([[  4,   7],
       [  0,   2],
       [ -5,   6],
       [  0,   0],
       [  1,   2],
       [-12,  -4],
       [  3,   4]])

Supongamos que cada nombre corresponde a una fila de la matriz data y queremos seleccionar todas las filas con el nombre correspondiente "Bob". Al igual que las operaciones aritméticas, las comparaciones (como ==) con matrices también se vectorizan. Así, al comparar names con la cadena "Bob" se obtiene una matriz booleana:

In [104]: names == "Bob"
Out[104]: array([ True, False, False,  True, False, False, False])

Esta matriz booleana se puede pasar al indexar la matriz:

In [105]: data[names == "Bob"]
Out[105]: 
array([[4, 7],
       [0, 0]])

La matriz booleana debe tener la misma longitud que el eje de la matriz que está indexando. Incluso puedes mezclar y combinar matrices booleanas con rebanadas o enteros (o secuencias de enteros; más adelante hablaremos de ello).

En estos ejemplos, selecciono de las filas donde names == "Bob" e indizo también las columnas:

In [106]: data[names == "Bob", 1:]
Out[106]: 
array([[7],
       [0]])

In [107]: data[names == "Bob", 1]
Out[107]: array([7, 0])

Para seleccionar todo menos "Bob" puedes utilizar != o negar la condición utilizando ~:

In [108]: names != "Bob"
Out[108]: array([False,  True,  True, False,  True,  True,  True])

In [109]: ~(names == "Bob")
Out[109]: array([False,  True,  True, False,  True,  True,  True])

In [110]: data[~(names == "Bob")]
Out[110]: 
array([[  0,   2],
       [ -5,   6],
       [  1,   2],
       [-12,  -4],
       [  3,   4]])

El operador ~ puede ser útil cuando quieras invertir una matriz booleana referenciada por una variable:

In [111]: cond = names == "Bob"

In [112]: data[~cond]
Out[112]: 
array([[  0,   2],
       [ -5,   6],
       [  1,   2],
       [-12,  -4],
       [  3,   4]])

Para seleccionar dos de los tres nombres para combinar varias condiciones booleanas , utiliza operadores aritméticos booleanos como & (y) y | (o):

In [113]: mask = (names == "Bob") | (names == "Will")

In [114]: mask
Out[114]: array([ True, False,  True,  True,  True, False, False])

In [115]: data[mask]
Out[115]: 
array([[ 4,  7],
       [-5,  6],
       [ 0,  0],
       [ 1,  2]])

Seleccionar datos de una matriz mediante indexación booleana y asignar el resultado a una nueva variable siemprecrea una copia de los datos, aunque la matriz devuelta no se modifique.

Precaución

Las palabras clave de Python and y or no funcionan con matrices booleanas. Utiliza & (y) y | (o) en su lugar.

La fijación de valores con matrices booleanas funciona sustituyendo el valor o valores de la parte derecha en los lugares donde están los valores de la matriz booleana True. Para poner a 0 todos los valores negativos de data, sólo tenemos que hacer:

In [116]: data[data < 0] = 0

In [117]: data
Out[117]: 
array([[4, 7],
       [0, 2],
       [0, 6],
       [0, 0],
       [1, 2],
       [0, 0],
       [3, 4]])

También puedes establecer filas o columnas enteras utilizando una matriz booleana unidimensional:

In [118]: data[names != "Joe"] = 7

In [119]: data
Out[119]: 
array([[7, 7],
       [0, 2],
       [7, 7],
       [7, 7],
       [7, 7],
       [0, 0],
       [3, 4]])

Como veremos más adelante, este tipo de operaciones sobre datos bidimensionales es conveniente hacerlas con pandas.

Indexación de fantasía

La indexación extravagante es un término adoptado por NumPy para describir la indexación mediante matrices de enteros. Supongamos que tenemos una matriz de 8 × 4:

In [120]: arr = np.zeros((8, 4))

In [121]: for i in range(8):
   .....:     arr[i] = i

In [122]: arr
Out[122]: 
array([[0., 0., 0., 0.],
       [1., 1., 1., 1.],
       [2., 2., 2., 2.],
       [3., 3., 3., 3.],
       [4., 4., 4., 4.],
       [5., 5., 5., 5.],
       [6., 6., 6., 6.],
       [7., 7., 7., 7.]])

Para seleccionar un subconjunto de filas en un orden determinado, sólo tienes que pasar una lista o ndarray de enteros especificando el orden deseado:

In [123]: arr[[4, 3, 0, 6]]
Out[123]: 
array([[4., 4., 4., 4.],
       [3., 3., 3., 3.],
       [0., 0., 0., 0.],
       [6., 6., 6., 6.]])

¡Espero que este código haya hecho lo que esperabas! Utilizar índices negativos selecciona las filas desde el final:

In [124]: arr[[-3, -5, -7]]
Out[124]: 
array([[5., 5., 5., 5.],
       [3., 3., 3., 3.],
       [1., 1., 1., 1.]])

Pasar múltiples matrices de índices hace algo ligeramente distinto;selecciona una matriz unidimensional de elementos correspondientes a cada tupla de índices:

In [125]: arr = np.arange(32).reshape((8, 4))

In [126]: arr
Out[126]: 
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11],
       [12, 13, 14, 15],
       [16, 17, 18, 19],
       [20, 21, 22, 23],
       [24, 25, 26, 27],
       [28, 29, 30, 31]])

In [127]: arr[[1, 5, 7, 2], [0, 3, 1, 2]]
Out[127]: array([ 4, 23, 29, 10])

Para saber más sobre el método reshape, echa un vistazo al Apéndice A.

Aquí se seleccionaron los elementos (1, 0), (5, 3), (7, 1), y (2, 2). El resultado de una indexación fantasiosa con tantas matrices de enteros como ejes haya es siempre unidimensional.

El comportamiento de la indexación de fantasía en este caso es un poco diferente de lo que algunos usuarios podrían haber esperado (yo incluido), que es la región rectangular formada al seleccionar un subconjunto de filas y columnas de la matriz. He aquí una forma de conseguirlo:

In [128]: arr[[1, 5, 7, 2]][:, [0, 3, 1, 2]]
Out[128]: 
array([[ 4,  7,  5,  6],
       [20, 23, 21, 22],
       [28, 31, 29, 30],
       [ 8, 11,  9, 10]])

Ten en cuenta que la indexación extravagante, a diferencia de la segmentación, siempre copia los datos en una nueva matriz al asignar el resultado a una nueva variable. Si asignas valores con la indexación extravagante, los valores indexados se modificarán:

In [129]: arr[[1, 5, 7, 2], [0, 3, 1, 2]]
Out[129]: array([ 4, 23, 29, 10])

In [130]: arr[[1, 5, 7, 2], [0, 3, 1, 2]] = 0

In [131]: arr
Out[131]: 
array([[ 0,  1,  2,  3],
       [ 0,  5,  6,  7],
       [ 8,  9,  0, 11],
       [12, 13, 14, 15],
       [16, 17, 18, 19],
       [20, 21, 22,  0],
       [24, 25, 26, 27],
       [28,  0, 30, 31]])

Transponer matrices e intercambiar ejes

La transposición es una forma especial de remodelación que, de forma similar, devuelve una vistasobre los datos subyacentes sin copiar nada. Las matrices tienen el método transpose y el atributo especial T:

In [132]: arr = np.arange(15).reshape((3, 5))

In [133]: arr
Out[133]: 
array([[ 0,  1,  2,  3,  4],
       [ 5,  6,  7,  8,  9],
       [10, 11, 12, 13, 14]])

In [134]: arr.T
Out[134]: 
array([[ 0,  5, 10],
       [ 1,  6, 11],
       [ 2,  7, 12],
       [ 3,  8, 13],
       [ 4,  9, 14]])

Al realizar cálculos matriciales, puedes hacer esto muy a menudo; por ejemplo, al calcular el producto interno de matrices utilizando numpy.dot:

In [135]: arr = np.array([[0, 1, 0], [1, 2, -2], [6, 3, 2], [-1, 0, -1], [1, 0, 1
]])

In [136]: arr
Out[136]: 
array([[ 0,  1,  0],
       [ 1,  2, -2],
       [ 6,  3,  2],
       [-1,  0, -1],
       [ 1,  0,  1]])

In [137]: np.dot(arr.T, arr)
Out[137]: 
array([[39, 20, 12],
       [20, 14,  2],
       [12,  2, 10]])

El operador infijo @ es otra forma de realizar la multiplicación de matrices :

In [138]: arr.T @ arr
Out[138]: 
array([[39, 20, 12],
       [20, 14,  2],
       [12,  2, 10]])

La transposición simple con .T es un caso especial dede intercambio de ejes. ndarray tiene el método swapaxes, que toma un par de números de eje e intercambia los ejes indicados para reordenar los datos:

In [139]: arr
Out[139]: 
array([[ 0,  1,  0],
       [ 1,  2, -2],
       [ 6,  3,  2],
       [-1,  0, -1],
       [ 1,  0,  1]])

In [140]: arr.swapaxes(0, 1)
Out[140]: 
array([[ 0,  1,  6, -1,  1],
       [ 1,  2,  3,  0,  0],
       [ 0, -2,  2, -1,  1]])

swapaxes devuelve de forma similar una vista de los datos sin hacer una copia.

4.2 Generación de números pseudoaleatorios

El módulo numpy.random complementa el módulo incorporado de Python random con funciones para generar eficazmente matrices enteras de valores muestrales a partir de muchos tipos de distribuciones de probabilidad. Por ejemplo, puedes obtener una matriz de 4 × 4 muestras de la distribución normal estándar utilizando numpy.random.standard_normal:

In [141]: samples = np.random.standard_normal(size=(4, 4))

In [142]: samples
Out[142]: 
array([[-0.2047,  0.4789, -0.5194, -0.5557],
       [ 1.9658,  1.3934,  0.0929,  0.2817],
       [ 0.769 ,  1.2464,  1.0072, -1.2962],
       [ 0.275 ,  0.2289,  1.3529,  0.8864]])

En cambio, el módulo incorporado de Python random sólo muestrea un valor cada vez. Como puedes ver en esta comparativa, numpy.random es más de un orden de magnitud más rápido para generar muestras muy grandes:

In [143]: from random import normalvariate

In [144]: N = 1_000_000

In [145]: %timeit samples = [normalvariate(0, 1) for _ in range(N)]
1.05 s +- 14.5 ms per loop (mean +- std. dev. of 7 runs, 1 loop each)

In [146]: %timeit np.random.standard_normal(N)
21.8 ms +- 212 us per loop (mean +- std. dev. of 7 runs, 10 loops each)

Estos números aleatorios no son realmente aleatorios (más bien,pseudoaleatorios), sino que se generan mediante un generador de números aleatorios configurable que determina de forma determinista qué valores se crean. Funciones como numpy.random.standard_normal utilizan el generador de números aleatorios por defecto del módulonumpy.random, pero tu código puede configurarse para utilizar un generador explícito:

In [147]: rng = np.random.default_rng(seed=12345)

In [148]: data = rng.standard_normal((2, 3))

El argumento seed es el que determina el estado inicial del generador, y el estado cambia cada vez que se utiliza el objeto rng para generar datos. El objeto generador rngtambién está aislado de otro código que pueda utilizar el módulo numpy.random:

In [149]: type(rng)
Out[149]: numpy.random._generator.Generator

Consulta la Tabla 4-3 para ver una lista parcial de los métodos disponibles en objetos generadores aleatorios como rng. Utilizaré el objeto rng que he creado anteriormente para generar datos aleatorios durante el resto del capítulo.

Tabla 4-3. Métodos del generador de números aleatorios NumPy
MétodoDescripción
permutationDevuelve una permutación aleatoria de una secuencia, o devuelve un rango permutado
shufflePermutar aleatoriamente una secuencia en su lugar
uniformExtraer muestras de una distribución uniforme
integersExtrae números enteros aleatorios de un intervalo dado de menor a mayor
standard_normalExtrae muestras de una distribución normal con media 0 y desviación típica 1
binomialExtraer muestras de una distribución binomial
normalExtrae muestras de una distribución normal (gaussiana)
betaExtraer muestras de una distribución beta
chisquareExtraer muestras de una distribución chi-cuadrado
gammaExtraer muestras de una distribución gamma
uniformExtrae muestras de una distribución uniforme [0, 1].

4.3 Funciones universales: Funciones rápidas de matrices por elementos

Una función universal, o ufunc, es una función que realizaoperaciones elemento a elemento sobre datos en ndarrays. Puedes pensar en ellas como envoltorios vectoriales rápidos para funciones sencillas que toman uno o varios valores escalares y producen uno o varios resultados escalares.

Muchas ufuncs son simples transformaciones de elemento a elemento, como numpy.sqrt o numpy.exp:

In [150]: arr = np.arange(10)

In [151]: arr
Out[151]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

In [152]: np.sqrt(arr)
Out[152]: 
array([0.    , 1.    , 1.4142, 1.7321, 2.    , 2.2361, 2.4495, 2.6458,
       2.8284, 3.    ])

In [153]: np.exp(arr)
Out[153]: 
array([   1.    ,    2.7183,    7.3891,   20.0855,   54.5982,  148.4132,
        403.4288, 1096.6332, 2980.958 , 8103.0839])

Se denominan ufuncs unarios. Otras, como numpy.add o numpy.maximum, toman dos matrices (por tanto, ufuncs binarias ) y devuelven una única matriz como resultado:

In [154]: x = rng.standard_normal(8)

In [155]: y = rng.standard_normal(8)

In [156]: x
Out[156]: 
array([-1.3678,  0.6489,  0.3611, -1.9529,  2.3474,  0.9685, -0.7594,
        0.9022])

In [157]: y
Out[157]: 
array([-0.467 , -0.0607,  0.7888, -1.2567,  0.5759,  1.399 ,  1.3223,
       -0.2997])

In [158]: np.maximum(x, y)
Out[158]: 
array([-0.467 ,  0.6489,  0.7888, -1.2567,  2.3474,  1.399 ,  1.3223,
        0.9022])

En este ejemplo, numpy.maximum calcula el máximo por elemento de los elementos de x y y.

Aunque no es habitual, una ufunc puede devolver varias matrices. numpy.modf es un ejemplo: una versión vectorizada de la incorporada en Pythonmath.modf , devuelve las partes fraccionaria e integral de una matriz de punto flotante:

In [159]: arr = rng.standard_normal(7) * 5

In [160]: arr
Out[160]: array([ 4.5146, -8.1079, -0.7909,  2.2474, -6.718 , -0.4084,  8.6237])

In [161]: remainder, whole_part = np.modf(arr)

In [162]: remainder
Out[162]: array([ 0.5146, -0.1079, -0.7909,  0.2474, -0.718 , -0.4084,  0.6237])

In [163]: whole_part
Out[163]: array([ 4., -8., -0.,  2., -6., -0.,  8.])

Las ufuncs aceptan un argumento opcional out que les permite asignar sus resultados a una matriz existente en lugar de crear una nueva:

In [164]: arr
Out[164]: array([ 4.5146, -8.1079, -0.7909,  2.2474, -6.718 , -0.4084,  8.6237])

In [165]: out = np.zeros_like(arr)

In [166]: np.add(arr, 1)
Out[166]: array([ 5.5146, -7.1079,  0.2091,  3.2474, -5.718 ,  0.5916,  9.6237])

In [167]: np.add(arr, 1, out=out)
Out[167]: array([ 5.5146, -7.1079,  0.2091,  3.2474, -5.718 ,  0.5916,  9.6237])

In [168]: out
Out[168]: array([ 5.5146, -7.1079,  0.2091,  3.2474, -5.718 ,  0.5916,  9.6237])

Consulta las Tablas 4-4 y 4-5 para ver un listado de algunas de las ufuncs de NumPy. Se siguen añadiendo nuevas ufuncs a NumPy, por lo que consultar la documentación online de NumPy es la mejor forma de obtener un listado completo y estar al día.

Tabla 4-4. Algunas funciones universales unarias
FunciónDescripción
abs, fabsCalcula el valor absoluto elemento a elemento para valores enteros, de coma flotante o complejos
sqrtCalcula la raíz cuadrada de cada elemento (equivalente a arr ** 0.5)
squareCalcula el cuadrado de cada elemento (equivalente a arr ** 2)
expCalcula el exponenteex de cada elemento
log, log10, log2, log1pLogaritmo natural (base e), log base 10, log base 2 y log(1 + x), respectivamente
signCalcula el signo de cada elemento: 1 (positivo), 0 (cero) o -1 (negativo)
ceilCalcula el techo de cada elemento (es decir, el menor número entero mayor o igual que ese número)
floorCalcula el suelo de cada elemento (es decir, el mayor entero menor o igual que cada elemento)
rintRedondea los elementos al entero más próximo, conservando el dtype
modfDevuelve las partes fraccionaria e integral de la matriz como matrices separadas
isnanDevuelve una matriz booleana que indica si cada valor es NaN (No es un número)
isfinite, isinfDevuelve una matriz booleana que indica si cada elemento es finito (noinf, noNaN) o infinito, respectivamente
cos, cosh, sin, sinh, tan, tanhFunciones trigonométricas regulares e hiperbólicas
arccos, arccosh, arcsin, arcsinh, arctan, arctanhFunciones trigonométricas inversas
logical_notCalcula el valor de verdad de not x elemento a elemento (equivalente a ~arr)
Tabla 4-5. Algunas funciones universales binarias
FunciónDescripción
addAñadir los elementos correspondientes en matrices
subtractResta los elementos de la segunda matriz de la primera matriz
multiplyMultiplicar elementos de la matriz
divide, floor_divideDividir o dividir por el suelo (truncando el resto)
powerEleva los elementos de la primera matriz a las potencias indicadas en la segunda matriz
maximum, fmaxMáximo por elemento; fmaxignora NaN
minimum, fminMínimo por elemento; fminignora NaN
modMódulo elemental (resto de la división)
copysignCopia el signo de los valores del segundo argumento a los valores del primer argumento
greater, greater_equal, less, less_equal, equal, not_equalRealiza una comparación elemento a elemento, obteniendo una matriz booleana (equivalente a los operadores infijos >, >=, <, <=, ==, !=)
logical_andCalcula el valor verdadero por elementos de la operación lógica AND (&)
logical_orCalcula el valor verdadero por elementos de la operación lógica OR (|)
logical_xorCalcula el valor verdadero de la operación lógica XOR (^) en función de los elementos

4.4 Programación orientada a matrices con matrices

Utilizar matrices NumPy te permite expresar muchos tipos de tareas de procesamiento de datos como expresiones concisas de matrices que, de otro modo, requerirían escribir bucles. Algunas personas denominan vectorización a esta práctica de sustituir los bucles explícitos por expresiones de matrices. En general, las operaciones con matrices vectorizadas suelen ser mucho más rápidas que sus equivalentes en Python puro, con un mayor impacto en cualquier tipo de cálculo numérico. Más adelante, en el Apéndice A, explico la difusión, un potente método para vectorizar los cálculos.

Como ejemplo sencillo, supongamos que deseamos evaluar la función sqrt(x^2 + y^2) en una rejilla regular de valores. La función numpy.meshgrid toma dos matrices unidimensionales y produce dos matrices bidimensionales correspondientes a todos los pares de (x, y) en las dos matrices:

In [169]: points = np.arange(-5, 5, 0.01) # 100 equally spaced points

In [170]: xs, ys = np.meshgrid(points, points)

In [171]: ys
Out[171]: 
array([[-5.  , -5.  , -5.  , ..., -5.  , -5.  , -5.  ],
       [-4.99, -4.99, -4.99, ..., -4.99, -4.99, -4.99],
       [-4.98, -4.98, -4.98, ..., -4.98, -4.98, -4.98],
       ...,
       [ 4.97,  4.97,  4.97, ...,  4.97,  4.97,  4.97],
       [ 4.98,  4.98,  4.98, ...,  4.98,  4.98,  4.98],
       [ 4.99,  4.99,  4.99, ...,  4.99,  4.99,  4.99]])

Ahora, evaluar la función es cuestión de escribir la misma expresión que escribirías con dos puntos:

In [172]: z = np.sqrt(xs ** 2 + ys ** 2)

In [173]: z
Out[173]: 
array([[7.0711, 7.064 , 7.0569, ..., 7.0499, 7.0569, 7.064 ],
       [7.064 , 7.0569, 7.0499, ..., 7.0428, 7.0499, 7.0569],
       [7.0569, 7.0499, 7.0428, ..., 7.0357, 7.0428, 7.0499],
       ...,
       [7.0499, 7.0428, 7.0357, ..., 7.0286, 7.0357, 7.0428],
       [7.0569, 7.0499, 7.0428, ..., 7.0357, 7.0428, 7.0499],
       [7.064 , 7.0569, 7.0499, ..., 7.0428, 7.0499, 7.0569]])

Como avance del Capítulo 9, utilizo matplotlib para crear visualizaciones de esta matriz bidimensional:

In [174]: import matplotlib.pyplot as plt

In [175]: plt.imshow(z, cmap=plt.cm.gray, extent=[-5, 5, -5, 5])
Out[175]: <matplotlib.image.AxesImage at 0x7f7132db3ac0>

In [176]: plt.colorbar()
Out[176]: <matplotlib.colorbar.Colorbar at 0x7f713a5833a0>

In [177]: plt.title("Image plot of $\sqrt{x^2 + y^2}$ for a grid of values")
Out[177]: Text(0.5, 1.0, 'Image plot of $\\sqrt{x^2 + y^2}$ for a grid of values'
)

En la Figura 4-3, he utilizado la función matplotlib imshow para crear un gráfico de imagen a partir de una matriz bidimensional de valores de función.

Figura 4-3. Gráfico de la función evaluada en una cuadrícula

Si trabajas en IPython, puedes cerrar todas las ventanas de trama abiertas ejecutando plt.close("all"):

In [179]: plt.close("all")
Nota

El término vectorización se utiliza para describir algunos otros conceptos de informática, pero en este libro lo utilizo para describir operaciones sobre matrices enteras de datos a la vez, en lugar de ir valor a valor utilizando un bucle de Python for.

Expresar la lógica condicional como operaciones de matriz

La función numpy.where es una versión vectorizada de la expresión ternaria x if condition else y. Supongamos que tenemos una matriz booleana y dos matrices de valores:

In [180]: xarr = np.array([1.1, 1.2, 1.3, 1.4, 1.5])

In [181]: yarr = np.array([2.1, 2.2, 2.3, 2.4, 2.5])

In [182]: cond = np.array([True, False, True, True, False])

Supongamos que queremos tomar un valor de xarr siempre que el valor correspondiente en cond sea True, y en caso contrario tomar el valor de yarr. Una comprensión de lista para hacer esto podría tener el siguiente aspecto:

In [183]: result = [(x if c else y)
   .....:           for x, y, c in zip(xarr, yarr, cond)]

In [184]: result
Out[184]: [1.1, 2.2, 1.3, 1.4, 2.5]

Esto tiene múltiples problemas. En primer lugar, no será muy rápido con matrices grandes (porque todo el trabajo se realiza en código Python interpretado). En segundo lugar, no funcionará con matrices multidimensionales. Con numpy.where puedes hacerlo con una sola llamada a una función:

In [185]: result = np.where(cond, xarr, yarr)

In [186]: result
Out[186]: array([1.1, 2.2, 1.3, 1.4, 2.5])

El segundo y tercer argumento de numpy.where no tienen por qué ser matrices; uno o ambos pueden ser escalares. Un uso típico de where en el análisis de datos es producir una nueva matriz de valores basada en otra matriz. Supón que tienes una matriz de datos generados aleatoriamente y quieres sustituir todos los valores positivos por 2 y todos los negativos por -2. Esto es posible hacerlo con numpy.where:

In [187]: arr = rng.standard_normal((4, 4))

In [188]: arr
Out[188]: 
array([[ 2.6182,  0.7774,  0.8286, -0.959 ],
       [-1.2094, -1.4123,  0.5415,  0.7519],
       [-0.6588, -1.2287,  0.2576,  0.3129],
       [-0.1308,  1.27  , -0.093 , -0.0662]])

In [189]: arr > 0
Out[189]: 
array([[ True,  True,  True, False],
       [False, False,  True,  True],
       [False, False,  True,  True],
       [False,  True, False, False]])

In [190]: np.where(arr > 0, 2, -2)
Out[190]: 
array([[ 2,  2,  2, -2],
       [-2, -2,  2,  2],
       [-2, -2,  2,  2],
       [-2,  2, -2, -2]])

Puedes combinar escalares y matrices cuando utilices numpy.where. Por ejemplo, puedo sustituir todos los valores positivos de arr por la constante 2, así:

In [191]: np.where(arr > 0, 2, arr) # set only positive values to 2
Out[191]: 
array([[ 2.    ,  2.    ,  2.    , -0.959 ],
       [-1.2094, -1.4123,  2.    ,  2.    ],
       [-0.6588, -1.2287,  2.    ,  2.    ],
       [-0.1308,  2.    , -0.093 , -0.0662]])

Métodos matemáticos y estadísticos

Un conjunto de funciones matemáticas que calculan estadísticas sobre toda una matriz o sobre los datos a lo largo de un eje son accesibles como métodos de la clase matriz. Puedes utilizar agregaciones (a veces llamadas reducciones) como sum, mean, y std (desviación estándar) llamando al método de instancia del array o utilizando la función NumPy de nivel superior. Cuando utilices la función NumPy, como numpy.sum, tienes que pasar el array que quieres agregar como primer argumento.

Aquí genero algunos datos aleatorios distribuidos normalmente y calculo algunas estadísticas agregadas:

In [192]: arr = rng.standard_normal((5, 4))

In [193]: arr
Out[193]: 
array([[-1.1082,  0.136 ,  1.3471,  0.0611],
       [ 0.0709,  0.4337,  0.2775,  0.5303],
       [ 0.5367,  0.6184, -0.795 ,  0.3   ],
       [-1.6027,  0.2668, -1.2616, -0.0713],
       [ 0.474 , -0.4149,  0.0977, -1.6404]])

In [194]: arr.mean()
Out[194]: -0.08719744457434529

In [195]: np.mean(arr)
Out[195]: -0.08719744457434529

In [196]: arr.sum()
Out[196]: -1.743948891486906

Las funciones como mean y sum toman un argumento opcional axis que calcula la estadística sobre el eje dado, lo que da como resultado una matriz con una dimensión menos:

In [197]: arr.mean(axis=1)
Out[197]: array([ 0.109 ,  0.3281,  0.165 , -0.6672, -0.3709])

In [198]: arr.sum(axis=0)
Out[198]: array([-1.6292,  1.0399, -0.3344, -0.8203])

Aquí, arr.mean(axis=1) significa "calcular la media de las columnas", mientras que arr.sum(axis=0)significa "calcular la suma de las filas".

Otros métodos como cumsum y cumprod no agregan, sino que producen una matriz con los resultados intermedios:

In [199]: arr = np.array([0, 1, 2, 3, 4, 5, 6, 7])

In [200]: arr.cumsum()
Out[200]: array([ 0,  1,  3,  6, 10, 15, 21, 28])

En matrices multidimensionales, las funciones de acumulación como cumsum devuelven una matriz del mismo tamaño pero con los agregados parciales calculados a lo largo del eje indicado según cada corte dimensional inferior:

In [201]: arr = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])

In [202]: arr
Out[202]: 
array([[0, 1, 2],
       [3, 4, 5],
       [6, 7, 8]])

La expresión arr.cumsum(axis=0) calcula la suma acumulada a lo largo de las filas, mientras que arr.cumsum(axis=1) calcula las sumas a lo largo de las columnas:

In [203]: arr.cumsum(axis=0)
Out[203]: 
array([[ 0,  1,  2],
       [ 3,  5,  7],
       [ 9, 12, 15]])

In [204]: arr.cumsum(axis=1)
Out[204]: 
array([[ 0,  1,  3],
       [ 3,  7, 12],
       [ 6, 13, 21]])

Consulta la Tabla 4-6 para ver una lista completa. Veremos muchos ejemplos de estos métodos en acción en capítulos posteriores.

Tabla 4-6. Métodos estadísticos básicos de array
MétodoDescripción
sumSuma de todos los elementos de la matriz o a lo largo de un eje; las matrices de longitud cero tienen suma 0
meanMedia aritmética; no válida (devuelve NaN) en matrices de longitud cero
std, varDesviación típica y varianza, respectivamente
min, maxMínimo y máximo
argmin, argmaxÍndices de los elementos mínimo y máximo, respectivamente
cumsumSuma acumulada de elementos a partir de 0
cumprodProducto acumulativo de elementos empezando por 1

Métodos para matrices booleanas

Los valores booleanos se coercionan a 1 (True) y 0 (False) en los métodos anteriores. Así, sum se utiliza a menudo como medio para contar valores True en una matriz booleana:

In [205]: arr = rng.standard_normal(100)

In [206]: (arr > 0).sum() # Number of positive values
Out[206]: 48

In [207]: (arr <= 0).sum() # Number of non-positive values
Out[207]: 52

Los paréntesis aquí en la expresión (arr > 0).sum() son necesarios para poder llamar a sum() sobre el resultado temporal de arr > 0.

Dos métodos adicionales, any yall, son útiles especialmente para matrices booleanas. any comprueba si uno o más valores de una matriz son True, mientras que all comprueba si cada valor esTrue:

In [208]: bools = np.array([False, False, True, False])

In [209]: bools.any()
Out[209]: True

In [210]: bools.all()
Out[210]: False

Estos métodos también funcionan con matrices no booleanas, en las que los elementos distintos de cero se tratan como True.

Clasificación

Al igual que el tipo de lista incorporado en Python, las matrices de NumPy se pueden ordenar en su lugar con el método sort:

In [211]: arr = rng.standard_normal(6)

In [212]: arr
Out[212]: array([ 0.0773, -0.6839, -0.7208,  1.1206, -0.0548, -0.0824])

In [213]: arr.sort()

In [214]: arr
Out[214]: array([-0.7208, -0.6839, -0.0824, -0.0548,  0.0773,  1.1206])

Puedes ordenar cada sección unidimensional de valores de una matriz multidimensional en su lugar a lo largo de un eje pasando el número de eje a sort. En los datos de este ejemplo:

In [215]: arr = rng.standard_normal((5, 3))

In [216]: arr
Out[216]: 
array([[ 0.936 ,  1.2385,  1.2728],
       [ 0.4059, -0.0503,  0.2893],
       [ 0.1793,  1.3975,  0.292 ],
       [ 0.6384, -0.0279,  1.3711],
       [-2.0528,  0.3805,  0.7554]])

arr.sort(axis=0) ordena los valores dentro de cada columna, mientras que arr.sort(axis=1) ordena a través de cada fila:

In [217]: arr.sort(axis=0)

In [218]: arr
Out[218]: 
array([[-2.0528, -0.0503,  0.2893],
       [ 0.1793, -0.0279,  0.292 ],
       [ 0.4059,  0.3805,  0.7554],
       [ 0.6384,  1.2385,  1.2728],
       [ 0.936 ,  1.3975,  1.3711]])

In [219]: arr.sort(axis=1)

In [220]: arr
Out[220]: 
array([[-2.0528, -0.0503,  0.2893],
       [-0.0279,  0.1793,  0.292 ],
       [ 0.3805,  0.4059,  0.7554],
       [ 0.6384,  1.2385,  1.2728],
       [ 0.936 ,  1.3711,  1.3975]])

El método de nivel superior numpy.sort devuelve una copia ordenada de una matriz (como la función incorporada de Python sorted) en lugar de modificar la matriz in situ. Por ejemplo:

In [221]: arr2 = np.array([5, -10, 7, 1, 0, -3])

In [222]: sorted_arr2 = np.sort(arr2)

In [223]: sorted_arr2
Out[223]: array([-10,  -3,   0,   1,   5,   7])

Para más detalles sobre el uso de los métodos de ordenación de NumPy, y técnicas más avanzadas como la ordenación indirecta, consulta el Apéndice A. También se pueden encontrar en pandas otros tipos de manipulaciones de datos relacionadas con la ordenación (por ejemplo, ordenar una tabla de datos por una o más columnas).

Lógica de conjuntos únicos y otros

NumPy dispone de algunas operaciones de conjunto básicas para ndarrays unidimensionales. Una muy utilizada en es numpy.unique, que devuelve los valores únicos ordenados de una matriz:

In [224]: names = np.array(["Bob", "Will", "Joe", "Bob", "Will", "Joe", "Joe"])

In [225]: np.unique(names)
Out[225]: array(['Bob', 'Joe', 'Will'], dtype='<U4')

In [226]: ints = np.array([3, 3, 3, 2, 2, 1, 1, 4, 4])

In [227]: np.unique(ints)
Out[227]: array([1, 2, 3, 4])

Contrasta numpy.unique con la alternativa de Python puro:

In [228]: sorted(set(names))
Out[228]: ['Bob', 'Joe', 'Will']

En muchos casos, la versión NumPy es más rápida y devuelve una matriz NumPy en lugar de una lista Python.

Otra función, numpy.in1d, comprueba la pertenencia de los valores de una matriz a otra, devolviendo una matriz booleana:

In [229]: values = np.array([6, 0, 0, 3, 2, 5, 6])

In [230]: np.in1d(values, [2, 3, 6])
Out[230]: array([ True, False, False,  True,  True, False,  True])

Consulta la Tabla 4-7 para ver un listado de las operaciones de conjunto de matrices en NumPy.

Tabla 4-7. Operaciones de conjunto de matrices
MétodoDescripción
unique(x)Calcula los elementos ordenados y únicos de x
intersect1d(x, y)Calcula los elementos comunes ordenados en x y y
union1d(x, y)Calcula la unión ordenada de los elementos
in1d(x, y)Calcula una matriz booleana que indique si cada elemento de x está contenido en y
setdiff1d(x, y)Diferencia de conjunto, elementos en xque no están en y
setxor1d(x, y)Establece diferencias simétricas; elementos que están en cualquiera de las matrices, pero no en ambas

4.5 Entrada y salida de archivos con matrices

NumPy puede guardar y cargar datos en y desde el disco en algunos formatos binarios de texto o . En esta sección sólo hablo del formato binario incorporado de NumPy, ya que la mayoría de los usuarios preferirán pandas y otras herramientas para cargar datos de texto o tabulares (para más información, consulta el Capítulo 6 ).

numpy.save y numpy.load son las dos funciones más eficaces para guardar y cargar datos de matrices en el disco. Las matrices se guardan por defecto en un formato binario sin comprimir con la extensión de archivo .npy:

In [231]: arr = np.arange(10)

In [232]: np.save("some_array", arr)

Si la ruta del archivo no termina ya en .npy, se añadirá la extensión. La matriz en disco se puede cargar entonces con numpy.load:

In [233]: np.load("some_array.npy")
Out[233]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Puedes guardar varias matrices en un archivo descomprimido utilizandonumpy.savez y pasando las matrices como argumentos de palabra clave:

In [234]: np.savez("array_archive.npz", a=arr, b=arr)

Al cargar un archivo .npz, obtienes de vuelta un objeto similar a un diccionario que carga las matrices individuales perezosamente:

In [235]: arch = np.load("array_archive.npz")

In [236]: arch["b"]
Out[236]: array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Si tus datos se comprimen bien, puedes utilizar numpy.savez_compresseden su lugar:

In [237]: np.savez_compressed("arrays_compressed.npz", a=arr, b=arr)

4.6 Álgebra lineal

Las operaciones de álgebra lineal, como la multiplicación de matrices , las descomposiciones, los determinantes y otras matemáticas de matrices cuadradas, son una parte importante de muchas bibliotecas de matrices. Multiplicar dos matrices bidimensionales con * es un producto elemento a elemento, mientras que las multiplicaciones matriciales requieren utilizar una función. Por tanto, existe una función dot, tanto un método de matriz como una función en el espacio de nombres numpy, para la multiplicación de matrices:

In [241]: x = np.array([[1., 2., 3.], [4., 5., 6.]])

In [242]: y = np.array([[6., 23.], [-1, 7], [8, 9]])

In [243]: x
Out[243]: 
array([[1., 2., 3.],
       [4., 5., 6.]])

In [244]: y
Out[244]: 
array([[ 6., 23.],
       [-1.,  7.],
       [ 8.,  9.]])

In [245]: x.dot(y)
Out[245]: 
array([[ 28.,  64.],
       [ 67., 181.]])

x.dot(y) es equivalente a np.dot(x, y):

In [246]: np.dot(x, y)
Out[246]: 
array([[ 28.,  64.],
       [ 67., 181.]])

Un producto matricial entre una matriz bidimensional y una matriz unidimensional del tamaño adecuado da como resultado una matriz unidimensional:

In [247]: x @ np.ones(3)
Out[247]: array([ 6., 15.])

numpy.linalg tiene un conjunto estándar de descomposiciones de matrices y cosas como la inversa y el determinante:

In [248]: from numpy.linalg import inv, qr

In [249]: X = rng.standard_normal((5, 5))

In [250]: mat = X.T @ X

In [251]: inv(mat)
Out[251]: 
array([[  3.4993,   2.8444,   3.5956, -16.5538,   4.4733],
       [  2.8444,   2.5667,   2.9002, -13.5774,   3.7678],
       [  3.5956,   2.9002,   4.4823, -18.3453,   4.7066],
       [-16.5538, -13.5774, -18.3453,  84.0102, -22.0484],
       [  4.4733,   3.7678,   4.7066, -22.0484,   6.0525]])

In [252]: mat @ inv(mat)
Out[252]: 
array([[ 1.,  0., -0.,  0., -0.],
       [ 0.,  1.,  0.,  0., -0.],
       [ 0., -0.,  1., -0., -0.],
       [ 0., -0.,  0.,  1., -0.],
       [ 0., -0.,  0., -0.,  1.]])

La expresión X.T.dot(X) calcula el producto punto de X con su transpuesto X.T.

Consulta la Tabla 4-8 para ver una lista de algunas de las funciones de álgebra lineal más utilizadas.

Tabla 4-8. Funciones de uso común de numpy.linalg
FunciónDescripción
diagDevuelve los elementos diagonales (u off-diagonales) de una matriz cuadrada como una matriz 1D, o convierte una matriz 1D en una matriz cuadrada con ceros en la off-diagonal
dotMultiplicación de matrices
traceCalcula la suma de los elementos diagonales
detCalcula el determinante de la matriz
eigCalcula los valores y vectores propios de una matriz cuadrada
invCalcula la inversa de una matriz cuadrada
pinvCalcula el pseudoinverso de Moore-Penrose de una matriz
qrCalcula la descomposición QR
svdCalcula la descomposición en valores singulares (SVD)
solveResuelve el sistema lineal Ax = b para x, donde A es una matriz cuadrada
lstsqCalcula la solución por mínimos cuadrados de Ax = b

4.7 Ejemplo: Paseos aleatorios

La simulación de los recorridos aleatorios proporciona una aplicación ilustrativa de la utilización de las operaciones con matrices. Consideremos primero un recorrido aleatorio simple que comienza en 0 con pasos de 1 y -1 que se producen con igual probabilidad.

Aquí tienes una forma pura en Python de implementar un único paseo aleatorio con 1.000 pasos utilizando el módulo incorporado random:

#! blockstart
import random
position = 0
walk = [position]
nsteps = 1000
for _ in range(nsteps):
    step = 1 if random.randint(0, 1) else -1
    position += step
    walk.append(position)
#! blockend

Consulta la Figura 4-4 para ver un ejemplo de gráfico de los 100 primeros valores de uno de estos paseos aleatorios:

In [255]: plt.plot(walk[:100])
Figura 4-4. Un simple paseo aleatorio

Podrías hacer la observación de que walk es la suma acumulada de los pasos aleatorios y podría evaluarse como una expresión de matriz. Así, utilizo el módulo numpy.random para extraer 1.000 lanzamientos de moneda a la vez, ponerlos a 1 y -1, y calcular la suma acumulativa:

In [256]: nsteps = 1000

In [257]: rng = np.random.default_rng(seed=12345)  # fresh random generator

In [258]: draws = rng.integers(0, 2, size=nsteps)

In [259]: steps = np.where(draws == 0, 1, -1)

In [260]: walk = steps.cumsum()

A partir de ahí podemos empezar a extraer estadísticas como el valor mínimo y máximo a lo largo de la trayectoria del paseo:

In [261]: walk.min()
Out[261]: -8

In [262]: walk.max()
Out[262]: 50

Un estadístico más complicado es el tiempo del primer cruce, el paso en el que el paseo aleatorio alcanza un valor determinado. Aquí podríamos querer saber cuánto tardó el paseo aleatorio en alejarse al menos 10 pasos del origen 0 en cualquier dirección. np.abs(walk) >= 10 nos da una matriz booleana que indica dónde ha alcanzado o superado 10 el paseo, pero queremos el índice del primer 10 o -10. Resulta que podemos calcularlo utilizando argmax, que devuelve el primer índice del valor máximo de la matriz booleana (True es el valor máximo):

In [263]: (np.abs(walk) >= 10).argmax()
Out[263]: 155

Ten en cuenta que utilizar argmax aquí no siempre es eficiente, porque siempre hace un barrido completo de la matriz. En este caso especial, una vez que se observa un True sabemos que es el valor máximo.

Simular muchos paseos aleatorios a la vez

Si tu objetivo fuera simular muchos paseos aleatorios, digamos cinco mil de ellos, puedes generar todos los paseos aleatorios con pequeñas modificaciones del código anterior. Si se les pasa una 2-tupla, las funciones numpy.random generarán una matriz bidimensional de extracciones, y podemos calcular la suma acumulada de cada fila para calcular los cinco mil paseos aleatorios de una sola vez:

In [264]: nwalks = 5000

In [265]: nsteps = 1000

In [266]: draws = rng.integers(0, 2, size=(nwalks, nsteps)) # 0 or 1

In [267]: steps = np.where(draws > 0, 1, -1)

In [268]: walks = steps.cumsum(axis=1)

In [269]: walks
Out[269]: 
array([[  1,   2,   3, ...,  22,  23,  22],
       [  1,   0,  -1, ..., -50, -49, -48],
       [  1,   2,   3, ...,  50,  49,  48],
       ...,
       [ -1,  -2,  -1, ..., -10,  -9, -10],
       [ -1,  -2,  -3, ...,   8,   9,   8],
       [ -1,   0,   1, ...,  -4,  -3,  -2]])

Ahora podemos calcular los valores máximo y mínimo obtenidos en todos los recorridos:

In [270]: walks.max()
Out[270]: 114

In [271]: walks.min()
Out[271]: -120

De estos paseos, calculemos el tiempo mínimo de paso a 30 o -30. Esto es un poco complicado porque no todos los 5.000 llegan a 30. Podemos comprobarlo utilizando el método any:

In [272]: hits30 = (np.abs(walks) >= 30).any(axis=1)

In [273]: hits30
Out[273]: array([False,  True,  True, ...,  True, False,  True])

In [274]: hits30.sum() # Number that hit 30 or -30
Out[274]: 3395

Podemos utilizar esta matriz booleana para seleccionar las filas de walks que realmente cruzan el nivel 30 absoluto, y llamar a argmax a través del eje 1 para obtener los tiempos de cruce:

In [275]: crossing_times = (np.abs(walks[hits30]) >= 30).argmax(axis=1)

In [276]: crossing_times
Out[276]: array([201, 491, 283, ..., 219, 259, 541])

Por último, calculamos el tiempo medio de paso mínimo:

In [277]: crossing_times.mean()
Out[277]: 500.5699558173785

Siéntete libre de experimentar con otras distribuciones para los pasos que no sean lanzamientos de monedas de igual tamaño. Sólo tienes que utilizar un método generador aleatorio diferente, como standard_normal para generar pasos distribuidos normalmente con cierta media y desviación típica:

In [278]: draws = 0.25 * rng.standard_normal((nwalks, nsteps))
Nota

Ten en cuenta que este enfoque vectorizado requiere crear una matriz con nwalks * nsteps elementos, lo que puede utilizar una gran cantidad de memoria para grandes simulaciones. Si la memoria es más limitada, será necesario un enfoque diferente.

4.8 Conclusión

Aunque gran parte del resto del libro se centrará en desarrollar habilidades de manejo de datos con pandas, seguiremos trabajando con un estilo similar basado en matrices. En el Apéndice A, profundizaremos en las funciones de NumPy para ayudarte a desarrollar aún más tus habilidades de cálculo de matrices.

Get Python para el Análisis de Datos, 3ª Edición now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.