- L’auteur expose ses griefs envers NumPy à l’aide de plusieurs exemples concrets
- Les opérations simples sur les tableaux sont faciles avec NumPy, mais dès que le nombre de dimensions augmente, la complexité et la confusion explosent
- La conception de NumPy, notamment le broadcasting et l’indexation avancée, manque de clarté et d’abstraction
- Écrire du code impose de s’appuyer sur des suppositions et des tâtonnements au lieu de spécifier explicitement les axes
- Il propose des pistes pour un meilleur langage de tableaux et présentera des alternatives concrètes dans un prochain article
Introduction : relation amour-haine avec NumPy
- L’auteur explique qu’il utilise NumPy depuis longtemps, mais qu’il a souvent été déçu par ses limites
- NumPy est une bibliothèque essentielle et très influente pour le calcul sur tableaux en Python
- Des bibliothèques modernes de machine learning comme PyTorch souffrent elles aussi de problèmes similaires à ceux de NumPy
Ce qui est facile et ce qui est difficile avec NumPy
- Les opérations simples, comme la résolution d’équations linéaires de base, peuvent être exprimées avec une syntaxe claire et élégante
- Mais quand les tableaux ont davantage de dimensions ou que les opérations deviennent complexes, il faut traiter le tout en bloc, sans boucles for
- Dans les environnements où les boucles ne sont pas possibles, comme le calcul sur GPU, il faut recourir à une syntaxe de vectorisation étrange ou à des appels de fonctions spécifiques
- Or, la bonne manière de les utiliser reste ambiguë, et la documentation seule ne permet pas toujours de lever le doute
- En pratique, avec des tableaux de grande dimension, il est difficile d’avoir la certitude d’utiliser correctement la fonction
linalg.solve de numpy
Les problèmes de NumPy
- NumPy manque d’une théorie cohérente pour appliquer des opérations à une partie d’un tableau multidimensionnel ou à certains axes précis
- Jusqu’à 2 dimensions, tout reste clair, mais à partir de 3 dimensions, il devient flou de savoir quels axes de chaque tableau sont concernés par l’opération
- Il faut alors recourir à des techniques complexes comme l’usage de None, le broadcasting ou
np.tensordot pour aligner explicitement les dimensions
- Ces approches favorisent les erreurs, réduisent la lisibilité du code et augmentent le risque de bugs
Boucles et clarté
- En réalité, si les boucles sont autorisées, il devient possible d’écrire un code plus concis et plus clair
- Le code avec des boucles peut sembler moins sophistiqué, mais il présente un avantage majeur en matière de clarté
- À l’inverse, dès que la dimension des tableaux change, il faut réfléchir à la main au transpose ou à l’ordre des axes, ce qui augmente la complexité
np.einsum : une fonction exceptionnellement réussie
np.einsum est puissant, car il fournit un langage spécialisé flexible permettant de nommer les axes
einsum rend l’intention de l’opération claire et se généralise très bien, ce qui permet d’implémenter explicitement des opérations complexes sur les axes
- Mais ce type de prise en charge, proche de
einsum, reste limité à certaines opérations et ne s’applique pas, par exemple, à linalg.solve
Les problèmes du broadcasting
- Le broadcasting, astuce centrale de NumPy, aligne automatiquement les dimensions quand elles ne correspondent pas
- Dans les cas simples, c’est pratique, mais en réalité cela rend souvent plus difficile la compréhension explicite des dimensions et provoque de nombreux cas d’erreur
- Comme le broadcasting est implicite, il faut vérifier à chaque lecture du code comment l’opération va réellement se comporter
Le manque de clarté de l’indexation
- L’indexation avancée de NumPy rend la prédiction de la shape d’un tableau particulièrement difficile et peu claire
- Comme la shape du tableau résultant varie selon les combinaisons d’indexation, il est difficile de la prévoir sans expérience pratique directe
- Même la documentation expliquant les règles d’indexation est longue et complexe, ce qui demande beaucoup de temps pour être assimilé
- Même lorsqu’on veut se limiter à une indexation simple, certaines opérations obligent malgré tout à utiliser l’indexation avancée
Les limites de la conception des fonctions NumPy
- Beaucoup de fonctions NumPy sont optimisées uniquement pour certaines shapes de tableaux
- Pour les tableaux de grande dimension, il faut ajouter des arguments axes, utiliser des noms de fonctions différents ou suivre des conventions distinctes, sans cohérence d’ensemble entre les fonctions
- Cette structure va à l’encontre des principes de programmation fondés sur l’abstraction et la réutilisation
- Même lorsqu’une fonction résout un problème précis, il faut souvent réécrire totalement le code pour la réappliquer à d’autres tableaux et à d’autres axes
Exemple concret : implémentation du self-attention
- Lorsqu’on écrit une implémentation de self-attention en NumPy, l’usage de boucles rend le code clair, mais imposer la vectorisation le complique fortement
- Pour des opérations de grande dimension comme le multi-head attention, il faut combiner
einsum et des transformations d’axes, ce qui rend le code difficile à lire
Conclusion et alternative
- L’auteur affirme que NumPy est « le seul choix devenu incontournable sur le marché, malgré de nombreux défauts par rapport à d’autres langages de tableaux »
- Il annonce avoir créé un prototype de langage de tableaux amélioré pour dépasser plusieurs problèmes de NumPy, comme le broadcasting, le manque de clarté de l’indexation ou l’incohérence des fonctions
- Il présentera plus tard, dans un autre article, des pistes d’amélioration concrètes, sous la forme d’une nouvelle API de langage de tableaux
4 commentaires
On dirait une histoire sur les raisons de la création de Julia. Il faut certes se former aux bibliothèques, mais le fait qu’il résolve beaucoup des problèmes de NumPy en fait vraiment une option très séduisante.
Si on ne maîtrise pas bien la vectorisation avec
numpy, les performances s’effondrent. Devoir écrire le code en tenant compte de ça est stressant et difficile.On dirait que pas mal de bibliothèques Python un peu anciennes ont toutes le même genre de problème.
Réactions sur Hacker News
bdans la documentation, c’est difficile à lire, mais comme il y a une explication sur lashaperenvoyée, il faut vérifier si le vecteurbest en fait sous forme matricielle, surtout quandK=1da.sel(x=some_x).isel(t=-1).mean(["y", "z"])est simple, et comme les noms de dimensions sont respectés, le broadcasting est clair ; c’est aussi très solide pour traiter des données géospatiales avec plusieurs CRS ; l’usage avec ArviZ est excellent, ce qui facilite aussi la gestion de dimensions supplémentaires en analyse bayésienne ; on peut regrouper plusieurs tableaux dans un même dataset pour partager des coordonnées communes, ce qui permet d’appliquer facilement quelque chose commeds.isel(t=-1)à tous les tableaux ayant un axe temporelarray[:, :, None], donc ça fait plaisir de voir que je ne suis pas le seulnp.linalg.solveparce que c’est le plus rapide soit la bonne ; il existe aussi plusieurs bonnes raisons d’écrire soi-même des kernels spécialisés pour un problème donné\vmapde JAXsqueeze, etc. ; le problème lui-même est tellement flou que j’ai du mal à le comprendrereshapepoly1dPparz0à droite, on obtient unpoly1d, mais si on fait la multiplication à gauche sous la formez0*P, on ne récupère qu’un tableau, avec une conversion de type silencieuse ; même le coefficient dominant d’un polynôme quadratique peut se récupérer de deux façons,P.coef[0]ouP[2], ce qui prête facilement à confusion ; officiellement,poly1dest une API « ancienne » et il est recommandé d’utiliser la classePolynomialpour le nouveau code, mais en pratique il n’y a même pas d’avertissement de dépréciation ; entre ces conversions de type, les incohérences de types de données et d’autres pièges disséminés dans la bibliothèque, le débogage devient un cauchemarto_numpy(); au final, on passe plus de temps à convertir des formats de données qu’à résoudre le problème ; Julia n’a pas que des avantages, mais l’interopérabilité entre bibliothèques pour les unités, les incertitudes, etc., y fonctionne bien, alors qu’en Python il faut toujours beaucoup de boilerplatenumpysanereste au final une boucle Python, ce n’est pas de la vraie vectorisationeinsumavecoptimize="optimal"afin d’utiliser l’algorithme de multiplication chaînée de matrices pour gagner en performance ; en pratique, c’était bien environ deux fois plus rapide qu’une implémentation vectorisée classique, mais étonnamment une implémentation naïve avec des boucles est encore plus rapide ; ceux que ça intéresse peuvent regarder le code pour comprendre pourquoi ; je suppose qu’il reste des marges d’amélioration sur la cohérence de cache à l’intérieur deeinsum