- El autor explica sus problemas con NumPy y señala varios inconvenientes con ejemplos
- Las operaciones simples con arreglos son fáciles en NumPy, pero cuando aumentan las dimensiones, la complejidad y la confusión crecen rápidamente
- El diseño de NumPy, incluyendo el broadcasting y la indexación avanzada, carece de claridad y abstracción
- Es casi obligatorio escribir código dependiendo de suposiciones y prueba y error, en lugar de especificar los ejes de forma explícita
- Propone ideas para un lenguaje de arreglos mejorado y presentará alternativas concretas en un próximo texto
Introducción: una relación de amor y odio con NumPy
- El autor cuenta que ha usado NumPy durante mucho tiempo, pero también ha quedado muy decepcionado por sus limitaciones
- NumPy es una librería esencial e influyente para operaciones con arreglos en Python
- Librerías modernas de machine learning como PyTorch también tienen problemas parecidos a los de NumPy
Lo fácil y lo difícil de NumPy
- Las operaciones simples, como resolver ecuaciones lineales básicas, pueden hacerse con una sintaxis clara y elegante
- Sin embargo, cuando la dimensionalidad del arreglo aumenta o la operación se vuelve más compleja, se vuelve necesario procesar todo en bloque sin usar for loops
- En entornos donde no se pueden usar loops libremente, como en operaciones sobre GPU, se necesitan sintaxis de vectorización extrañas o formas especiales de llamar funciones
- Pero la forma correcta de usar esas funciones es ambigua, y ni siquiera la documentación permite entenderlo con claridad
- De hecho, en el caso de arreglos de alta dimensión, es difícil que alguien tenga plena certeza de cómo usar correctamente la función
linalg.solve de numpy
Los problemas de NumPy
- A NumPy le falta una teoría consistente para aplicar operaciones a partes de un arreglo multidimensional o a ejes específicos
- Cuando un arreglo tiene 2 dimensiones o menos, todo resulta claro, pero con 3 dimensiones o más ya no queda claro qué ejes son el objetivo de la operación en cada arreglo
- Obliga a usar métodos complejos para alinear dimensiones, como None, broadcasting o
np.tensordot
- Estas formas hacen más probables los errores, reducen la legibilidad del código y aumentan la posibilidad de bugs
Bucles y claridad
- En la práctica, si se permitieran bucles, sería posible escribir código mucho más conciso y claro
- El código con bucles puede parecer menos elegante, pero tiene grandes ventajas en términos de claridad
- En cambio, cuando cambia la dimensionalidad del arreglo, hay que pensar manualmente en transpose o en el orden de los ejes, y la complejidad aumenta
np.einsum: una función excepcionalmente buena
np.einsum es poderosa porque ofrece un lenguaje específico de dominio flexible que permite nombrar ejes
einsum deja clara la intención de la operación y generaliza muy bien, por lo que permite implementar explícitamente operaciones complejas sobre ejes
- Sin embargo, el soporte para este tipo de operaciones al estilo de
einsum está limitado a solo algunas operaciones; por ejemplo, no puede usarse con linalg.solve
Los problemas del broadcasting
- El broadcasting, uno de los trucos centrales de NumPy, ajusta automáticamente dimensiones cuando no coinciden
- En los casos simples es conveniente, pero en la práctica hace más difícil entender con claridad cuáles son las dimensiones y genera muchos casos propensos a error
- Como el broadcasting es implícito, cada vez que se lee el código hay que verificar de nuevo cómo funciona realmente la operación
La falta de claridad en la indexación
- La indexación avanzada de NumPy hace que sea muy difícil y poco claro predecir la shape de un arreglo
- Como la shape del arreglo resultante cambia según la combinación de índices usada, es difícil anticiparla sin experiencia práctica
- La documentación que explica las reglas de indexación también es larga y compleja, lo que implica una gran inversión de tiempo para dominarla
- Incluso si uno quiere usar solo indexación simple, en ciertas operaciones termina viéndose obligado a usar indexación avanzada
Los límites del diseño de funciones de NumPy
- Muchas funciones de NumPy están optimizadas solo para ciertas shapes de arreglos
- Para arreglos de alta dimensión hay que usar argumentos adicionales como axes, nombres de función distintos o convenciones separadas, y no existe consistencia entre funciones
- Es una estructura que va en contra de los principios de programación basados en abstracción y reutilización
- Incluso si se usa una función que resuelve un problema específico, para reaplicarla a distintos arreglos y ejes a menudo hay que reescribir el código como si fuera algo distinto
Ejemplo real: implementación de self-attention
- Al implementar self-attention en NumPy, usar bucles da claridad, pero forzar la vectorización vuelve el código complejo
- Cuando se necesitan operaciones de alta dimensión, como en multi-head attention, hay que combinar
einsum y transformaciones de ejes, y el código se vuelve difícil de entender
Conclusión y alternativa
- El autor afirma que NumPy es "la única opción que se volvió importante en el mercado, aunque tiene muchos defectos frente a otros lenguajes de arreglos"
- Adelanta que creó un prototipo de un lenguaje de arreglos mejorado para superar varios problemas de NumPy, como el broadcasting, la falta de claridad en la indexación y la inconsistencia de las funciones
- Planea presentar más adelante una propuesta concreta de mejora, es decir, una nueva API de lenguaje de arreglos, en un texto aparte
4 comentarios
Parece la historia de por qué nació Julia. Hay que estudiar las bibliotecas, pero me parece una opción realmente atractiva considerando que resuelve muchos de los problemas de NumPy.
Si no sabes usar bien la vectorización de
numpy, el rendimiento se va al piso. Tener que escribirlo teniendo eso en cuenta es estresante y difícil.Creo que muchas bibliotecas de Python algo antiguas tienen un problema parecido.
Opiniones en Hacker News
by vas a la documentación, es difícil de leer, pero como sí hay una explicación sobre elshapeque se devuelve, hay que confirmar si el vectorbrealmente tiene forma de matriz, especialmente cuandoK=1transpose, la mayoría de estos problemas se resuelven así. Xarray es más débil que NumPy en álgebra lineal, pero es fácil volver a NumPy y basta con hacer funciones auxiliares. Con Xarray, la productividad sube mucho al trabajar con datos de 3 dimensiones o másda.sel(x=some_x).isel(t=-1).mean(["y", "z"]), el broadcasting es claro porque se respetan los nombres de dimensión, y tiene fortalezas para procesar datos geoespaciales con varios CRS. También funciona excelente con Arviz, así que manejar dimensiones adicionales en análisis bayesiano es fácil. Además, se pueden agrupar varios arreglos en un solo dataset y compartir coordenadas comunes, por lo que es sencillo aplicar algo comods.isel(t=-1)a todos los arreglos que tengan eje temporalarray[:, :, None], así que me alegra ver a alguien que piensa lo mismonp.linalg.solvees lo máximo en velocidad y por eso hay que adaptarse a la fuerza; también hay varias razones por las que escribir kernels específicos para el problema puede ser mejor\transpose/reshapey demás no es consistente, así que todo se vuelve ambiguovmapde jaxsqueezey similares. El problema en sí me parece tan ambiguo que ni lo entiendo bienreshapepoly1dPporz0desde la derecha, sale unpoly1d, pero si haces la multiplicación desde la izquierda en formaz0*P, solo devuelve un arreglo y la conversión de tipo ocurre silenciosamente. Incluso el coeficiente líder de una cuadrática puede obtenerse tanto conP.coef[0]como conP[2], así que es fácil confundirse. Oficialmentepoly1des una API “antigua” y para código nuevo recomiendan la clasePolynomial, pero en la práctica ni siquiera hay advertencia de deprecated. Como estas conversiones de tipo y las inconsistencias de datatype están regadas por toda la librería, depurarla es una pesadillato_numpy(). Al final se gasta más tiempo convirtiendo formatos de datos que resolviendo el problema. Julia tampoco tiene puras ventajas, pero la interoperabilidad entre librerías para cosas como unidades e incertidumbre funciona bien, mientras que en Python siempre hace falta mucho código boilerplateeinsumy usé el algoritmo de multiplicación en cadena de matrices conoptimize="optimal"para mejorar el rendimiento. En efecto, llegó a ser unas 2 veces más rápido que una implementación vectorizada general, pero sorprendentemente una implementación ingenua con loops fue aún más rápida. Quien tenga curiosidad por la razón puede revisar el código. Sospecho que todavía hay margen para mejorar la cache coherency dentro deeinsum