3  Structures homogènes

Les structures de données par défaut de Python permettent de gérer des données hétérogènes (par exemple des entiers et des chaînes de caractères). Cette particularité fait que les structures de données Python sont extrêmement flexibles, au détriment de la performance. En effet vu que des données hétérogènes doivent pouvoir être supportées, il n’est pas possible d’allouer une plage de mémoire fixe pour une structure de données, ce qui ralentit son utilisation. Particulièrement en mathématiques, il apparaît très régulièrement des ensembles de données homogènes de tailles fixes (liste d’entiers, vecteurs réels ou complexes, matrices…). Le module NumPy définit le type ndarray qui est optimisé pour de telles structures de données homogènes de tailles fixes. La documentation de NumPy est disponible ici.

Pour charger le module NumPy, il est d’usage de procéder ainsi:

import numpy as np
NoteConcepts abordés
  • tableau de données homogènes
  • slicing
  • opérations vectorielles
  • indexage et sélection

Exercice 3.1 - Introduction à NumPy

Création. La taille et le type des éléments d’un tableau NumPy doivent être connus à l’avance. La première façon de créer un tableau NumPy est de construire un tableau rempli de zéros en spécifiant la taille et le type:

array0 = np.zeros(3, dtype=int) # vecteur de 3 entiers
array1 = np.zeros((2,4), dtype=float) # tableau de flottants de taille 2x4
array2 = np.zeros((2,2), dtype=complex) # matrice carrée complexe de taille 2x2
array3 = np.zeros((5,6,4)) # tableau tridimensionnel de flottants

La seconde façon est de passer directement les données:

array4 = np.array([1,4,5]) # vecteur d'entiers (1,4,5)
array5 = np.array([[1.1,2.2,3.3,4.4],[1,2,3,4]]) # matrice de taille 2x4 de flottants
array6 = np.array([[1+1j,0.4],[3,1.5]]) # matrice complexe de taille 2x2

NumPy va alors déterminer lui-même le type et la taille du tableau. À noter qu’il est possible de forcer le type:

array0 = np.array([1,4,5], dtype=complex) # vecteur de complexes

Le type des éléments du tableau NumPy array1 peut être déterminé par array1.dtype. La taille de ce tableau est donnée par array1.shape. Les commandes suivantes permettent d’accéder aux éléments des tableaux :

array4[1] # retourne 4
array5[1,3] # retourne 4.0
np.float64(4.0)

À noter que les indices commencent à 0 et non pas à 1. Les tableaux NumPy sont mutables dans le sens où les données peuvent être modifiées mais en conservant le même type et la même taille:

array0[1] = 4
array1[1,3] = 3.3
array3[3,4,2] = 3

Slicing. Le slicing permet d’accéder à certaines parties d’un tableau:

array4[2:3] # retourne les éléments d'indices compris entre 2 et 3
array1[0,:] # retourne la première ligne de array1
array1[:,-1] # retourne la dernière colonne de array1
array3[3,3:5,1:4] # retourne la sous-matrice correspondante
array([[0., 0., 0.],
       [0., 3., 0.]])

Itération. Il est possible d’itérer un tableau sur sa première dimension, par exemple pour retourner la somme des lignes:

for i in array5:
    print(np.sum(i))
11.0
10.0

a. Étudier la documentation de la fonction arange et utiliser cette fonction pour générer les vecteurs \((5,6,7,8,9)\) et \((3,5,7,9)\)s.

La documentation de la fonction arange est disponible ici.

b. Étudier la documentation de la fonction linspace et l’utiliser pour générer 10 points équidistribués dans l’intervalle \([2,5]\).

c. Lire la documentation de la fonction reshape et effectuer successivement les transformations suivantes:

\[(1,2,3,4,5,6)\to\begin{pmatrix}1 & 2\\ 3 & 4\\ 5 & 6 \end{pmatrix}\to\begin{pmatrix}1 & 2 & 3\\ 4 & 5 & 6 \end{pmatrix}\to\begin{pmatrix}1 & 4\\ 2 & 5\\ 3 & 6 \end{pmatrix}\]

Exercice 3.2 - Opérations sur les tableaux

Les opérations arithmétiques de base sur les tableaux NumPy sont effectuées élément par élément:

mat1 = np.array([[1,2.5,3],[5,6.1,8],[3,2,5]])
mat2 = np.array([[1,0.5,0],[0,0.9,8],[2,0,0]])
mat1 + mat2 # retourne la somme élément par élément
mat1 * mat2 # retourne le produit élément par élément (pas le produit matriciel)
10*mat1**2 # retourne 10 fois le carré des éléments de mat1
array([[ 10. ,  62.5,  90. ],
       [250. , 372.1, 640. ],
       [ 90. ,  40. , 250. ]])

La plupart des fonctions mathématiques définies par NumPy (voir https://numpy.org/doc/stable/reference/routines.math.html) sont également effectuées élément par élément:

np.cos(mat1) # retourne le cosinus élément par élément de mat1
np.exp(mat1) # retourne l'exponentielle élément par élément de mat1
array([[2.71828183e+00, 1.21824940e+01, 2.00855369e+01],
       [1.48413159e+02, 4.45857770e+02, 2.98095799e+03],
       [2.00855369e+01, 7.38905610e+00, 1.48413159e+02]])

Le produit matriciel peut être effectué d’une des trois façons suivantes:

np.dot(mat1,mat2)
mat1.dot(mat2)
mat1 @ mat2
array([[ 7.  ,  2.75, 20.  ],
       [21.  ,  7.99, 48.8 ],
       [13.  ,  3.3 , 16.  ]])

a. Donné un vecteur \((v_0,v_1,\dots,v_{n-1})\in\mathbb{R}^n\) la dérivée discrète de ce vecteur est définie par le vecteur \((d_0,d_1,\dots,d_{n-2})\in\mathbb{R}^{n-1}\) donné par \(d_i = v_{i+1}-v_{i}\) pour \(i=0,1,\dots,n-2\).

Écrire une fonction diff_list qui calcule la dérivée discrète d’une liste et une fonction diff_np qui fait la même opération mais sur des vecteurs NumPy en utilisant le slicing.

b. Soit a_list et a_np respectivement une liste et un tableau de 1 000 éléments tirés au hasard dans l’intervalle \([0,1]\):

a_list = [np.random.random() for _ in range(1000)]
a_np = np.random.random(1000)

Comparer le temps d’exécution de diff_list(a_list) et de diff_np(a_np).

Dans Jupyter Lab, il est très facile de déterminer le temps pris par une cellule pour s’évaluer, il suffit de commencer la cellule par %%time, par exemple:

%%time
result = diff_list(a_list)
CPU times: user 51 μs, sys: 2 μs, total: 53 μs
Wall time: 57 μs

Pour évaluer la cellule à de multiples reprises et faire une moyenne sur le temps d’exécution afin d’obtenir un résultat plus précis, remplacer %%time par %%timeit. La documentation est disponible ici.

Exercice 3.3 - Matrice de Vandermonde

Pour \(p, n\in \mathbb{N}^*\) et \(\boldsymbol{x}= (x_1, \ldots, x_p)\) un vecteur de taille \(p\), la matrice de Vandermonde correspondante est définie par:

\[V(\boldsymbol{x},n)=\begin{pmatrix} 1 & x_1 & x_1^2 & \cdots & x_1^{n-1} & x_1^n \\ 1 & x_2 & x_2^2 & \cdots & x_2^{n-1} & x_2^n \\ \vdots & \vdots& \vdots & \ddots & \vdots & \vdots\\ 1 & x_{p-1} & x_{p-1}^2 & \cdots & x_{p-1}^{n-1} & x_{p-1}^n \\ 1 & x_p & x_p^2 & \cdots & x_p^{n-1} & x_p^n \end{pmatrix}.\]

a. Écrire une fonction qui construit la matrice \(V(\boldsymbol{x},n)\) élément par élément à l’aide d’une double boucle.

b. Après avoir établi une relation permettant d’écrire la \(k\)-ième colonne de \(V(\boldsymbol{x},n)\) uniquement en fonction de \(\boldsymbol{x}\) et de \(k\), écrire une deuxième fonction qui construit la matrice \(V(\boldsymbol{x},n)\) colonne par colonne à l’aide de cette relation.

c. Après avoir établi une relation entre la \(k\)-ième colonne de \(V(\boldsymbol{x},n)\), sa \((k-1)\)-ième colonne et le vecteur \(\boldsymbol{x}\), écrire une troisième fonction qui construit la matrice \(V(\boldsymbol{x},n)\) colonne par colonne à l’aide de cette relation.

d. Comparer les temps d’exécution de ces trois fonctions pour \(n=150\), \(p=100\) et \(\boldsymbol{x}\) généré aléatoirement.

Exercice 3.4 - Indexage de tableaux !

Le slicing permet de sélectionner des blocs dans un tableau, mais il est également possible de sélectionner des éléments disparates en utilisant un tableau comme indexage:

a = np.arange(12)**2 # tableau des carrés parfaits
i = np.array([1,3,8,5]) # tableau d'indices
a[i] # tableau des éléments de a aux places i
array([ 1,  9, 64, 25])

À noter qu’il est également possible d’indexer par un tableau de dimension supérieure. Le résultat est alors un tableau de la même forme que l’index:

j = np.array([[3,4],[9,7]]) # tableau bidimensionnel d'indices
a[j] # sélectionne les éléments de a avec les indices j
array([[ 9, 16],
       [81, 49]])

Pour un tableau à plusieurs dimensions:

b = np.array([[0,1,2,3],[4,5,6,7],[8,9,10,11]])
i = np.array([0,1,2,2]) # tableau des premiers indices
j = np.array([1,0,3,1]) # tableau des seconds indices
b[i,j] # sélectionne les éléments d'indices ij
array([ 1,  4, 11,  9])

Enfin il est possible d’indexer un tableau par un tableau de booléens:

c = np.array([[0,1,2,3],[4,5,6,7],[8,9,10,11]])
cond = (c >= 5) # tableau de booléens valant True si >= 5 et False sinon
c[cond] = 5 # assigne la valeur 5 à toutes les entrées de c plus grandes que 5

Pour la suite, on considère les nombres:

[0.9602, -0.99, 0.2837, 0.9602, 0.7539, -0.1455, -0.99, -0.9111, 0.9602, -0.1455, -0.99, 0.5403, -0.99, 0.9602, 0.2837, -0.99, 0.2837, 0.9602]
[0.9602,
 -0.99,
 0.2837,
 0.9602,
 0.7539,
 -0.1455,
 -0.99,
 -0.9111,
 0.9602,
 -0.1455,
 -0.99,
 0.5403,
 -0.99,
 0.9602,
 0.2837,
 -0.99,
 0.2837,
 0.9602]

comme étant les résultats d’une mesure effectuée toutes les 0.1 seconde aux temps compris entre 2 et 3.7 secondes.

a. Les mesures étant censées être positives, modifier les données pour mettre 0 lorsque les valeurs sont négatives.

b. Calculer les temps pour lesquels les mesures précédentes sont maximales.

c. Pour chaque mesure maximale, retourner la mesure précédente, la mesure maximale et la mesure suivante. Si la mesure précédente ou la suivante n’existent pas, les remplacer par np.nan.