Conversando con Numpy en la intimidad (o por qué Numpy es eficiente)

Vamos a conversar un rato con numpy para que me explique una serie de cosas y así lo pueda entender todo un poco mejor.

Kiko: Sin más preámbulos. ¿Para qué te necesitamos en el mundo Python? Tengo entendido que tu principal aportación es la de introducir un nuevo contenedor de datos y funcionalidad para trabajar de forma eficiente con ese contenedor de datos.

Numpy: En Python existen muchos contenedores de datos, listas, tuplas, diccionarios, conjuntos,… Todos ellos son muy flexibles y útiles pero tienen ciertas carencias en determinadas situaciones. Lo que yo propongo es un nuevo contenedor de datos, los ndarrays. Además, proporciono funcionalidad especializada para poder manipular y hacer operaciones científicas con esos ndarrays de forma eficiente.

Kiko: Hablar de manipulación de datos en Python es sinónimo de Numpy y, prácticamente, todo el ecosistema científico de Python está construido sobre Numpy. Digamos que Numpy es el ladrillo que ha permitido levantar edificios tan sólidos como Pandas, Matplotlib, Scipy, scikit-learn,… ¿A qué se debe tu éxito?

Numpy: En Python disponemos, de partida, de diversos contenedores de datos: listas, tuplas, diccionarios, conjuntos,…, cómo antes hemos comentado, ¿por qué añadir uno más?

¡Por conveniencia! A pesar de la pérdida de flexibilidad. Es una solución de compromiso.

Por un uso de memoria más eficiente: Por ejemplo, una lista puede contener distintos tipos de objetos. Por ejemplo, imagina una lista que contenga un entero, un float, un diccionario,… Esto provoca que Python deba guardar información del tipo de cada elemento contenido en la lista. Por otra parte, un ndarray contiene tipos homogéneos, es decir, todos los elementos son del mismo tipo, por lo que la información del tipo solo debe guardarse una vez independientemente del número de elementos que tenga el ndarray. Además, los datos de un ndarray se almacenan de forma contigua por lo que es más fácil recorrerlos que si esos mismos datos estuvieran ‘esparcidos’ por distintas partes de la memoria. En la imagen inferior tienes un esquema que intenta explicar todo lo que he comentado de forma un poco más gráfica.

arrays_vs_listas

(imagen por Jake VanderPlas y extraída de GitHub).

Por velocidad: Por ejemplo, en una lista que consta de elementos con diferentes tipos, Python debe realizar trabajos extra para saber si los tipos son compatibles con las operaciones que estamos realizando. Es decir, en una lista puedo tener un elemento que sea una cadena y un elemento que sea un entero, como he comentado anteriormente, Python debe comprobar el tipo y ver si, por ejemplo, la operación + que quiero hacer es compatible o no con esos tipos. Todas esas operaciones de cosas extra ralentizan las operaciones. Cuando trabajamos con un ndarray ya podemos saber eso de partida y podemos tener operaciones más eficientes (además de que mucha funcionalidad está programada en C, C++, Cython, Fortran).

Por las operaciones vectorizadas: No voy a entrar mucho en detalle sobre lo que esto significa pero permite aumentar el rendimiento ya que se aprovechan mejor los últimos avances en microprocesadores y cómo se comunica la memoria con el procesador. Además, como segunda derivada, tenemos que el código queda más sintético y esto hace que el código resultante sea más parecido a leer operaciones matemáticas que a leer código.

Por funcionalidad extra: Dispongo de muchas operaciones de álgebra lineal, transformadas rápidas de Fourier, estadística básica, histogramas,… Todo tipo de funcionalidad científica optimizada para trabajar con los ndarrays.

Por el acceso a los elementos de una forma mucho más conveniente: Indexación más avanzada que con los tipos normales de Python

Y voy a parar aquí que me emociono y no paro. Espero que alguien se haya convencido de la conveniencia.

Como resumen muestro la siguiente imagen e indico que un ndarray contiene los datos en bruto de forma secuencial en memoria y unos metadatos que nos ayudan a localizar cada elemento dentro de los datos y a cómo interpretar ese elemento de los datos.

(imagen extraída de las scipy-lecture-notes).

Kiko: Has comentado que, por ejemplo, los datos de los tipos los almacenas una sola vez. Por la imagen anterior que muestras entiendo que un ndarray dispone de unos metadatos para poder entender la información. ¿Nos puedes hablar un poco sobre esto y la magia que existe para transformar unos datos secuenciales en memoria a lo que usamos en ciencia, vectores, matrices, arrays de varias dimensiones,…?

Numpy: Por supuesto pero me tenéis que permitir algunas licencias en las explicaciones. Espero que me excusen los puristas. Un ndarray de los que yo propongo no es más que un “churro” contiguo de datos en memoria (un buffer de datos o data buffer). Esto de que sean contiguos es importante. Si yo quiero obtener la suma de los 10 elementos que almacena un ndarray de 10 elementos lo único que tengo que hacer es recorrerlos en línea. Es como si un cartero se prepara la ruta y sigue los números de una calle de forma que vayan del más bajo al más alto. Ese cartero no deberá volver atrás en ningún momento y, en general, acabará antes de repartir el correo. En cambio, si tenemos a un cartero sin experiencia que no está bien preparado para hacer ese trabajo de forma eficiente nos podemos encontrar con la situación de que primero reparte una carta en el número 20 de la calle, luego tiene que volver al número 3 de la calle, luego deja un paquete en el número 57, deja una postal en el número 10,… Este segundo cartero va a hacer mucho más trabajo que el primero y va a tardar mucho más. Numpy nos prepara para trabajar como el primer cartero, en cambio el segundo cartero estaría trabajando como lo hace una lista en Python, es más flexible pero más lento. Si queremos velocidad quizá nos interese hacer del primer cartero, si queremos la flexibilidad de, por ejemplo, ir al número 20 a primera hora porque sabemos que el dueño de la casa siempre está a primera hora o si vamos primero a los pares porque a la hora a la que pasamos hay sombra y hace mucho calor o si saltamos unas casas a una hora porque hay perros furiosos y esperamos a pasar cuando los dueños sacan a pasear a los perros,…, podemos usar una lista ya que un ndarray quizá no nos aporta esa flexibilidad.

Repito y machaco el concepto: un ndarray es un bloque de memoria con información extra sobre como interpretar su contenido. La memoria dinámica (RAM, por Random Access Memory en inglés) se puede considerar como un ‘churro’ lineal (un buffer de datos o data buffer) y es por ello que necesitamos esa información extra para saber como formar ese ndarray. Necesitamos, sobre todo, la información de shape y strides.

Y yendo a tu pregunta, los ndarrays son arrays de N dimensiones, si todo se guarda en memoria como si fuera de una dimensión, ¿cómo puedo crear arrays de más dimensiones? En realidad usamos metadatos para saber cómo interpretar ese ndarray pero en memoria sigue siendo de una dimensión.

Mira la imagen siguiente para hacerte una idea de lo que es el shape y los strides. Arriba, en la primera parte de la imagen, aparece el data buffer, según los metadatos que considere tendré un array de una dimensión, la parte de en medio de la figura, o un array de dos dimensiones, la parte de abajo de la figura, pero los datos brutos siguen siendo los mismos. Lo único que estoy cambiando son algunos metadatos. Eso proporciona mucha versatilidad:

(imagen extraída de GitHub).

En la imagen anterior tenemos los mismos datos en memoria pero están representados de dos formas diferentes. En la primera es un array de una dimension mientras que en la segunda es un array de dos dimensiones. Esa información nos la da shape (y ndim). Por otro lado, lo que nos dice strides es cuantos ‘saltitos’ tenemos que dar para llegar al siguiente elemento en cada una de las dimensiones, de forma un poco más precisa sería el número de bytes que tengo que saltar para encontrar el siguiente elemento en cada dimensión. En el segundo caso, si quiero hacer una operación en vertical, por ejemplo sumar los elementos de las columnas, los strides me están diciendo que tengo que dar cuatro saltos para pasar del 0 al 4, cuatro saltos para pasar del 1 al 5, etcétera. Esos saltitos son número de bytes y en el ejemplo anterior estoy considerando que los datos ocupan un byte cada uno (por ejemplo, un entero de 8 bits). Si los datos fueran de dos bytes (por ejemplo, un entero de 16 bits), el valor para los strides sería (8, 2) en el segundo ejemplo en la imagen anterior (la parte de abajo de la imagen).

Podemos saber el número de elementos de un ndarray usando el atributo size (esto es similar a multiplicar los valores que nos devuelve shape). Podemos saber el tipo de los datos contenidos en el ndarray usando dtype. Podemos conocer el tamaño en bytes de cada elemento contenido en el ndarray usando itemsize. Podemos saber el número total de bytes contenido en el ndarray usando nbytes (esto es similar a multiplicar el valor de size por el valor de itemsize). Con data podemos saber el lugar que ocupa el primer elemento del array en memoria… Todo ese tipo de metadatos nos ayudan a interpretar el “churro” lineal de datos brutos que tenemos en memoria.

Además, podemos conocer otras cosas más avanzadas de los ndarrays accediendo a flags. En este objeto se indican cosas como cual es el orden de los datos a la hora de crear las dimensiones. Puede estar ordenado en estilo C o en estilo Fortran y la diferencia es cómo se accede a las dimensiones. Esto también se conoce como row-major order o column-major order, respectivamente, o que están ordenados por filas apiladas o por columnas apiladas (ver imagen siguiente). Lo que uso por defecto es el estilo C como se puede ver en la imagen anterior.

(imagen extraída de ipython-books.github.io).

Dependiendo de la operación que quieras realizar con el ndarray tendrá sentido ordenar los datos de una forma u otra.

En estos flags también se define si se puede escribir en el ndarray, si el ndarray es el dueño de los datos o es una vista de otro ndarray,…

Todos estos metadatos juntos hacen que el ndarray sea algo muy sencillo pero muy eficiente a la vez porque determinadas operaciones solo conllevan sobreescribir algún metadato, no necesitan copiado de datos,… Mucha gente muy inteligente ha echado muchas horas de su vida en hacer que yo, Numpy, sea tan útil y, por tanto, omnipresente en el ecosistema científico.

Kiko: Muchas gracias, Numpy. Por hoy creo que lo vamos a dejar aquí. Si te parece, continuaremos esta entrevista en otro momento pero hablando sobre lo que has comentado hoy de forma más práctica con ejemplos de código para que todo esto sea menos abstracto.

Continuará…

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

+ six = 14