"Numpy-like" en "Built-in"

Intro

Lorsque l'on vient de CPython, on a l'habitude d'utiliser des séries de valeur, typiquement une série de valeurs x et y en vue d'un affichage de courbes x,y, ou encore pour des courbes paramétriques où à partir d'une série t, on a une courbe x(t), y(t).

La librairie que l'on utilise dans ce type de situation est Numpy. Elle permet la manipulation facile de séries de valeurs, y compris de grande dimension, ainsi que de nombreuses fonctions mathématiques. Elle permet également la manipulation de tableaux multidimensionnels, etc.

Sur microcontrôleur, on a une double contrainte :

  • d'une part la librairie Numpy est beaucoup trop grosse pour être implémentée en intégralité, et même si il y a d'ors et déjà une implémentation Numpy sous Micropython, elle n'en reste pas moins gourmande en ressources,

  • d'autre part, le besoin sur microcontrôleur est somme toute limité, à savoir essentiellement gérer des séries de valeurs 1D voire 2D, mais guère plus.

En fait, en y réfléchissant un peu, dans un contexte de microcontrôleur aux ressources limitées, les classes et fonctions builtins reprennent tout leur sens, et on se rend compte au passage, qu'en CPython on peut malgré tout prendre de mauvaises habitudes, à savoir utiliser un peu systématiquement "l'artillerie lourde" là où on pourrait le faire assez simplement avec le langage (Micro)Python lui-même. Finalement, pour celui qui est habitué à coder en CPython, le fait de coder en MicroPython sur microcontrôleur impose de revenir au langage Python lui-même, ce qui est finalement salutaire d'un certain point de vue car c'est paradoxalement une attitude que l'on a tendance à perdre en CPython.

Petite démonstration / exploration du sujet ici, en Micropython : on va (re)-découvrir ici tout le potentiel de la comprehension list.

Note

Un point positif de cette approche est, il me semble, que l'on reste sur la syntaxe de base de Python sans avoir à appréhender la syntaxe d'une nouvelle librairie, ce qui est souvent source de frustration au début de la prise en main d'une librairie telle que numpy. (Re)-découvrir que l'on peut faire les choses simplement est intéressant... justement pour les situations simples.

Les vecteurs de base

Les vecteurs est l'autre nom des "série de valeur", à ne pas confondre avec le vecteur de la géométrie ou de la physique qui est le concept d'une grandeur associé à une direction. Ici, on ne parle que de série de valeur.

Le ones

Avec numpy, il est facile de créer une série de valeurs valant 1 avec le ones. Voici comment faire le ones facilement :

x=[1]*10 # Un list de 10 valeurs 1
print(x)
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

On obtiendrait exactement le même résultat en faisant :

x=[1 for i in range(10)] # Un list de 10 valeurs 1
print(x)
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

Le zeros

De la même façon, avec numpy, il est facile de créer une série de valeurs valant 0 avec le zeros. Voici comment faire le zeros facilement :

x=[0]*10 # Un list de 10 valeurs 1
print(x)
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Et comme pour le ones, on obtiendrait exactement le même résultat en faisant :

x=[0 for i in range(10)] # Un list de 10 valeurs 1
print(x)
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Le arange

Avec Numpy, il est possible de créer un vecteur de valeurs bien plus utile que le ones ou zeros, à savoir le arange est une plage de valeurs décimales définies par une valeur de début, de fin et un pas. C'est comme le range mais avec les nombres à virgules, ce que ne supporte pas le range. Il est cependant assez facile d'obtenir un équivalent du arange sous forme d'un list en posant :

x=[ i*0.1 for i in range(0, 10)] # un arange
print(x)
0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]

On peut généraliser un peu les choses en créant un petite fonction dédiée :

def arange(start, end, step):
    return [start+(el*step) for el in range(0,int((end-start)/step))]

x=arange(0,10,0.1)
print(x)

[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 5.0, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 5.9, 6.0, 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 7.0, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 8.0, 8.1, 8.2, 8.3, 8.400001, 8.5, 8.6, 8.7, 8.8, 8.900001, 9.0, 9.1, 9.2, 9.3, 9.400001, 9.5, 9.6, 9.7, 9.8, 9.900001]

Le linspace

Avec Numpy, le linspace permet d'obtenir n valeurs régulièrement espacées dans une plage donnée (ici, on précise le nombre de valeurs au lieu du step comme on le fait avec la arange). Assez logiquement, le pas du linspace inclus la valeur finale. On l'obtient en posant :

``````python def linspace(start, end, num): step=(end-start)/(num-1) return [start+(el*step) for el in range(0,num)]

x=linspace(0,10,30) print(x) [0.0, 0.3448276, 0.6896552, 1.034483, 1.37931, 1.724138, 2.068965, 2.413793, 2.758621, 3.103448, 3.448276, 3.793103, 4.137931, 4.482759, 4.827586, 5.172414, 5.517241, 5.862069, 6.206897, 6.551724, 6.896552, 7.241379, 7.586207, 7.931035, 8.275862, 8.620689, 8.965517, 9.310345, 9.655172, 10.0]

x=linspace(5,10,15) print(x) [5.0, 5.357143, 5.714286, 6.071428, 6.428572, 6.785714, 7.142857, 7.5, 7.857143, 8.214286, 8.571428, 8.928572, 9.285714, 9.642857, 10.0] ```

Note

A titre de comparaison, voici les sorties de la fonctions numpy équivalente en CPython :

``` import numpy as np

np.linspace(0,10,30) Out[19]: array([ 0. , 0.34482759, 0.68965517, 1.03448276, 1.37931034, 1.72413793, 2.06896552, 2.4137931 , 2.75862069, 3.10344828, 3.44827586, 3.79310345, 4.13793103, 4.48275862, 4.82758621, 5.17241379, 5.51724138, 5.86206897, 6.20689655, 6.55172414, 6.89655172, 7.24137931, 7.5862069 , 7.93103448, 8.27586207, 8.62068966, 8.96551724, 9.31034483, 9.65517241, 10. ])

np.linspace(5,10,15) Out[18]: array([ 5. , 5.35714286, 5.71428571, 6.07142857, 6.42857143, 6.78571429, 7.14285714, 7.5 , 7.85714286, 8.21428571, 8.57142857, 8.92857143, 9.28571429, 9.64285714, 10. ]) ```

Comme on peut le voir, à quelques décimales près ( à partir de la 7ème après la virgule...!), le résultat est le même. MicroPython s'en sort très bien.

Un vecteur de valeurs randomisées

Avec Numpy, le empty est un vecteur de valeurs randomisées comprises entre 0 et 1. On peut obtenir la même chose facilement en faisant :

python import random x=[random.random() for i in range(10)] # série de n valeur aléatoire = le empty print(x) [0.6827134, 0.2670938, 0.4636654, 0.9186064, 0.767941, 0.6937876, 0.7738847, 0.517539, 0.5688641, 0.1101506]

On peut également envisager une série de n valeurs aléatoires comprises dans une plage donnée à l'aide de la fonction randrange ce qui donne par exemple :

python import random x=[random.randrange(0,100) for i in range(10)] # série de n valeur aléatoires print(x) [74, 50, 14, 22, 46, 74, 85, 41, 78, 9]

Operations sur les éléments d'un vecteur

Une des grandes force de Numpy est de permettre d'appliquer des fonctions sur l'ensemble des valeurs du vecteur, tout comme on le ferait sur une variable. A partir du moment où nous utilisons des list, cela ne sera pas directement possible, mais un résultat tout à fait comparable est obtenu plutôt simplement en "pure-(Micro)Python".

Avec une comprehension list

Première solution, assez simple, c'est d'utiliser la comprehension list qui est vraiment un "couteau suisse" :

```python import math

x=range(0,360,36) y=[math.sin(math.radians(el)) for el in x] # fonction appliquée sur le vecteur de valeurs y [0.0, 0.5877852, 0.9510566, 0.9510566, 0.5877852, -5.7742e-08, -0.5877851, -0.9510565, -0.9510565, -0.5877853] ```

Avec la fonction built-in map

Une autre possibilité est d'utiliser la fonction built-in map() qui permet d'appliquer une fonction à tous les éléments d'un objet séquence, et donc d'un list

Attention, map renvoie un itérateur, à caster en list pour accéder aux valeurs.

Première possibilité, en utilisant une fonction inline lambda :

```python

x=list(range(0,100, 10)) y=list(map(lambda x: x**2, x)) print(y) [0, 100, 400, 900, 1600, 2500, 3600, 4900, 6400, 8100] ```

Et l'autre possibilité, encore plus souple potentiellement dans ses possiblités, est d'appeler une fonction dédiée :

```python import math

def f(x): out=math.sin(math.radians(x)) return out

x=range(0,360,36) y=list(map(f,x)) print(y)

[0.0, 0.5877852, 0.9510566, 0.9510566, 0.5877852, -5.7742e-08, -0.5877851, -0.9510565, -0.9510565, -0.5877853] ```

C'est moins compact, mais potentiellement sans limite de possibilités !

Opérations itérative sur les éléments d'un vecteur

Nous avons vu précédemment comment effectuer facilement des opérations élément par élément sur un vecteur de valeurs. Nous allons voir ici comment appliquer une opération récursive sur un vecteur qui renvoie un vecteur dont chaque élément est la résultante d'une opération sur l'ensemble des éléments précédents du vecteur d'origine.

Typiquement, un exemple de çà est le calcul de "l'intégrale" d'un vecteur de valeurs :

```python x=list(range(10))

y=[sum(x[:idx])+el for idx,el in enumerate(x)] print(y) [0, 1, 3, 6, 10, 15, 21, 28, 36, 45] ```

C'est l'équivalent du cumsum() en numpy.

On peut tester çà par exemple par l'intégration d'une fonction linéaire qui abouti à une fonction puissance 2 (parabole) :

```python t=list(range(10)) y1=[2el for el in t] # 2t

y2=[sum(y1[:idx])+el for idx,el in enumerate(y1)] # cumsum print(y2) [0, 2, 6, 12, 20, 30, 42, 56, 72, 90]

y3=[(el**2)+el for el in t] # integrale print(y3) [0, 2, 6, 12, 20, 30, 42, 56, 72, 90]

```

Sur la récursion , voir cet excellent post : https://stackoverflow.com/questions/30214531/basics-of-recursion-in-python

Opérations sur l'ensemble des éléments d'un vecteur

Nous allons voir ici comment appliquer une opération qui utilise l'ensemble des éléments d'un vecteur de valeurs.

Somme des éléments

python import math x=list(range(0,10)) y=sum(x) 45

Produit des éléments

De la même façon, avec la fonction prod du module standard math (Python 3.8) (non disponible en MicroPython) avec une fonction :

Pour mémoire, le produit d'une suite se note "grand Pi" : https://fr.wikipedia.org/wiki/Notation_(math%C3%A9matiques)#Produit

```python import math

def prod(x):

result=1

for i in x:
    result=result*i

return result

x=list(range(1,10)) y=prod(x) print(y)

362880 ```

A noter que la factorielle n'est qu'un cas particulier.

Voir : https://computersciencehub.io/python/python-multiplying-all-numbers-in-a-list-with-each-other/

Avec les fonctions built-in existantes appliquées à l'ensemble d'un tableau

Certaines fonctions built-in existantes peuvent être appliquées sur les objets list

max()

python import random x=[random.randrange(0,100) for el in range(10)] # serie de 10 nombres aleatoires print(x) [88, 43, 44, 23, 37, 41, 17, 17, 8, 43] print(max(x)) 88

min()

print(max(x)) 8

sorted()

print(sorted(x)) [8, 17, 17, 23, 37, 41, 43, 43, 44, 88]

Avec les fonctions de la classe list

Rappel : Avec les fonctions de l'objet list, l'objet list lui-même est modifié alors que dans le cas des fonctions précédentes, l'objet list source n'est pas modifié

```

x.reverse() x [43, 8, 17, 17, 41, 37, 23, 44, 43, 88] x.reverse() x [88, 43, 44, 23, 37, 41, 17, 17, 8, 43] x.sort() x [8, 17, 17, 23, 37, 41, 43, 43, 44, 88] ```

Opérations entre vecteurs

Une des opérations "magique" les plus intéressante que permet numpy concerne les opérations "terme à terme" entre 2 vecteurs de valeurs. En utilisant les possibilités built-in, les choses ne sont pas si compliquées que çà :

```python

x=range(0,100,10) # 2 list identiques y=range(0,100,10) z=[elx-ely for elx,ely in zip(x,y)] print(z) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

z=[elx-ely for elx,ely in zip(x,y)] # soustraction print(z) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

z=[elx+ely for elx,ely in zip(x,y)] # addition print(z) [0, 20, 40, 60, 80, 100, 120, 140, 160, 180]

z=[elx*ely for elx,ely in zip(x,y)] # multiplication print(z) [0, 100, 400, 900, 1600, 2500, 3600, 4900, 6400, 8100]

z=[elx**ely for elx,ely in zip(x,y)] #puissance print(z) [1, 10000000000, 104857600000000000000000000, 205891132094649000000000000000000000000000000, 12089258196146291747061760000000000000000000000000000000000000000, 8881784197001252323389053344726562500000000000000000000000000000000000000000000000000, 48873677980689257489322752273774603865660850176000000000000000000000000000000000000000000000000000000000000, 1435036016098684342856030763566710717400773837392460666392490000000000000000000000000000000000000000000000000000000000000000000000, 176684706477838432958329750074291851582748389687561895812160620129261977600000000000000000000000000000000000000000000000000000000000000000000000000000000, 76177348045866392339289727720615561750424801402395196724001565744957137343033038019601000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]

```

Remarquer au passage la dimension des entiers supportée par MicroPython.... !

A titre indicatif, la même chose en CPython donne :

[1, 10000000000, 104857600000000000000000000, 205891132094649000000000000000000000000000000, 12089258196146291747061760000000000000000000000000000000000000000, 8881784197001252323389053344726562500000000000000000000000000000000000000000000000000, 48873677980689257489322752273774603865660850176000000000000000000000000000000000000000000000000000000000000, 1435036016098684342856030763566710717400773837392460666392490000000000000000000000000000000000000000000000000000000000000000000000, 176684706477838432958329750074291851582748389687561895812160620129261977600000000000000000000000000000000000000000000000000000000000000000000000000000000, 76177348045866392339289727720615561750424801402395196724001565744957137343033038019601000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]

Le résultat est strictement idem. MicroPython est bluffant ! Et clairement, comparativement à d'autres langages, notamment Arduino, on ne joue pas dans la même catégorie avec MicroPython !

Conclusion

Le langage Python lui-même s'avère plutôt efficace pour réaliser des opérations "à la numpy" sur des vecteurs de valeur de taille limitée, et ces principes seront bien utiles pour l'utilisation d'afficheurs graphiques.