Partie IV - Calcul scientifique · Chapitre 17

Bibliothèque Numpy

3 sections de cours · fiche d'exercices corrigee

1Le tableau NumPy : creation, types, indexation

Les listes Python sont souples mais lentes pour le calcul numerique : chaque element est un objet independant, et chaque operation passe par l'interpreteur. NumPy fournit un type ndarray homogene, stocke de facon contigue, sur lequel les operations sont executees en C : le gain est d'un facteur 10 a 100.

Definition — ndarray

Un tableau NumPy est une grille multidimensionnelle d'elements tous de meme type (le dtype), stockes dans un bloc memoire contigu. Sa forme (shape) est le tuple de ses dimensions.

import numpy as np

a = np.array([1, 2, 3])              # tableau 1D depuis une liste
b = np.array([[1, 2], [3, 4]])       # 2D
a.shape      # (3,)          forme
b.shape      # (2, 2)
b.ndim       # 2             nombre de dimensions
b.size       # 4             nombre total d'elements
b.dtype      # dtype('int64')  type des elements

Creation de tableaux

np.zeros(5)                    # [0. 0. 0. 0. 0.]        flottants
np.zeros((2, 3))               # matrice 2x3 de zeros
np.ones((2, 2))
np.full((2, 2), 7)             # rempli de 7
np.eye(3)                      # matrice identite 3x3
np.arange(0, 10, 2)            # [0 2 4 6 8]   comme range, mais tableau
np.linspace(0, 1, 5)           # [0. 0.25 0.5 0.75 1.]  n points, bornes INCLUSES
np.random.random((2, 2))       # uniformes dans [0,1[
np.random.randn(3)             # loi normale centree reduite
np.array([1, 2], dtype=float)  # type impose
arange ou linspace ?
np.arange(0, 1, 0.1)      # 10 valeurs, borne 1 EXCLUE, pas fixe
np.linspace(0, 1, 11)     # 11 valeurs, borne 1 INCLUSE, nombre de points fixe

Pour un pas flottant, toujours preferer linspace : arange avec un pas non entier accumule les erreurs d'arrondi et peut produire un element de trop ou de moins. np.arange(0, 0.3, 0.1) donne parfois 3, parfois 4 valeurs selon la plateforme.

Le type des elements

np.array([1, 2, 3]).dtype           # int64
np.array([1.0, 2, 3]).dtype         # float64  : un seul flottant suffit
np.array([1, "a"]).dtype            # <U21     : tout devient chaine !
np.zeros(3, dtype=int)              # [0 0 0]  entiers

t = np.array([1, 2, 3])
t[0] = 3.7                          # 3 : TRONQUE, car le dtype est entier
print(t)                            # [3 2 3]
Debordement silencieux
t = np.array([2**62, 2**62], dtype=np.int64)
print(t.sum())      # -4611686018427387904 : DEBORDEMENT, aucun avertissement

n = 2**62 + 2**62   # 9223372036854775808 : correct, les int Python sont exacts

C'est le prix de la performance : NumPy utilise des entiers machine de taille fixe, comme le langage C. Les entiers Python natifs, eux, sont de precision illimitee. Sur un calcul entier susceptible de deborder, verifier le dtype ou utiliser dtype=object.

Indexation et tranches

A = np.arange(12).reshape(3, 4)
# [[ 0  1  2  3]
#  [ 4  5  6  7]
#  [ 8  9 10 11]]

A[1, 2]        # 6         ligne 1, colonne 2 (et non A[1][2], moins efficace)
A[0]           # [0 1 2 3]     premiere ligne
A[:, 1]        # [1 5 9]       deuxieme COLONNE : impossible aussi simplement en liste
A[0:2, 1:3]    # [[1 2], [5 6]]  sous-bloc
A[::2, ::2]    # une ligne sur deux, une colonne sur deux
A[-1, -1]      # 11
A.T            # transposee (sans copie !)
Les tranches sont des VUES, pas des copies
A = np.arange(5)
B = A[1:4]           # VUE sur la memoire de A
B[0] = 99
print(A)             # [ 0 99  2  3  4]  : A est modifiee !

C = A[1:4].copy()    # copie explicite
C[0] = -1
print(A)             # inchangee

C'est l'inverse du comportement des listes Python, ou t[1:4] cree une copie. La vue evite des allocations couteuses, mais surprend : en cas de doute, utiliser .copy().

Indexation booleenne et avancee

t = np.array([3, -1, 4, -5, 9])

t > 0                     # [True False True False True]  : tableau de booleens
t[t > 0]                  # [3 4 9]        selection
t[t < 0] = 0              # remplacement conditionnel
np.where(t > 2, t, 0)     # [3 0 4 0 9]    "si ... alors ... sinon"
np.nonzero(t)             # indices des elements non nuls

t[[0, 2, 4]]              # [3 4 9]  indexation par liste d'indices
t[np.array([4, 0])]       # [9 3]    dans l'ordre demande

Ces formes remplacent des boucles entieres : t[t > 0] tient en une expression la ou une liste demanderait une comprehension, et s'execute cent fois plus vite.

2Vectorisation, agregation et algebre lineaire

Vectorisation : le coeur de NumPy

Definition

Vectoriser, c'est remplacer une boucle Python par une operation portant sur le tableau entier. L'operation est alors executee par une boucle compilee en C, sans passer par l'interpreteur a chaque element.

t = np.array([1, 2, 3, 4])

t + 10          # [11 12 13 14]   element par element
t * 2           # [2 4 6 8]
t ** 2          # [1 4 9 16]
t + t           # [2 4 6 8]
t * t           # [1 4 9 16]      produit TERME A TERME, pas matriciel
np.sqrt(t)      # [1. 1.414 1.732 2.]
np.exp(t)       # exponentielle de chaque element
np.sin(t)
Le gain de performance
import numpy as np, time

n = 10**7
liste = list(range(n))
tableau = np.arange(n)

t0 = time.perf_counter()
r1 = [x*x for x in liste]
t1 = time.perf_counter()
r2 = tableau ** 2
t2 = time.perf_counter()

print(f"liste  : {t1-t0:.3f} s")     # ~ 1.2 s
print(f"numpy  : {t2-t1:.3f} s")     # ~ 0.02 s
print(f"gain   : {(t1-t0)/(t2-t1):.0f}x")

Facteur 50 a 100. La raison : dans la liste, chaque x*x declenche une recherche de methode, une allocation d'objet et un increment de compteur de references. Dans le tableau, une seule boucle C parcourt un bloc memoire contigu, en profitant du cache processeur et des instructions vectorielles du CPU.

Methode — vectoriser une boucle
# AVANT : boucle Python
resultat = []
for x in donnees:
    if x > 0:
        resultat.append(sqrt(x))

# APRES : vectorise
donnees = np.array(donnees)
resultat = np.sqrt(donnees[donnees > 0])

Regle : si une boucle ne fait qu'appliquer la meme operation a chaque element, elle doit disparaitre. Les seules boucles legitimes sur un tableau NumPy sont celles dont chaque tour depend du precedent (recurrences, methodes iteratives).

Fonctions d'agregation

A = np.array([[1, 2, 3], [4, 5, 6]])

A.sum()              # 21       tout le tableau
A.sum(axis=0)        # [5 7 9]        somme des COLONNES (on ecrase les lignes)
A.sum(axis=1)        # [6 15]         somme des LIGNES
A.mean(), A.std()    # moyenne, ecart-type
A.min(), A.max()     # extremums
A.argmin(), A.argmax()   # INDICE de l'extremum (dans le tableau aplati)
A.cumsum()           # [1 3 6 10 15 21]   sommes cumulees
np.median(A)         # 3.5
(A > 3).sum()        # 3   nombre d'elements > 3 (True vaut 1)
(A > 3).any(), (A > 3).all()      # (True, False)
Retenir l'axe

axis=0 parcourt les lignes, donc agrege verticalement : le resultat a une valeur par colonne. axis=1 agrege horizontalement : une valeur par ligne. Moyen mnemotechnique : l'axe indique la dimension qui disparait.

Algebre lineaire

A = np.array([[1, 2], [3, 4]])
B = np.array([[0, 1], [1, 0]])
v = np.array([1, 1])

A * B            # produit TERME A TERME : [[0 2], [3 0]]
A @ B            # produit MATRICIEL     : [[2 1], [4 3]]
A.dot(B)         # equivalent
A @ v            # produit matrice-vecteur : [3 7]

np.linalg.det(A)          # -2.0000000000000004
np.linalg.inv(A)          # matrice inverse
np.linalg.solve(A, v)     # resout A x = v  (JAMAIS via inv : moins precis et plus lent)
np.linalg.eig(A)          # valeurs et vecteurs propres
np.linalg.matrix_rank(A)  # rang
np.linalg.norm(v)         # norme euclidienne
A.T                       # transposee
np.trace(A)               # trace
Ne jamais inverser pour resoudre
x = np.linalg.inv(A) @ b       # MAUVAIS : plus lent et moins precis
x = np.linalg.solve(A, b)      # BON : factorisation LU avec pivot

Le calcul de l'inverse coute environ trois fois plus cher qu'une resolution directe, et amplifie les erreurs d'arrondi. On n'inverse une matrice que si l'on a vraiment besoin de l'inverse elle-meme, ce qui est rare.

Diffusion (broadcasting)

Regle

Deux tableaux de formes differentes peuvent etre combines si, en comparant leurs dimensions de droite a gauche, chaque paire est soit egale, soit contient un 1. La dimension 1 est alors « etiree » sans copie memoire.

A = np.ones((3, 4))
A + 5                       # (3,4) + scalaire   -> (3,4)
A + np.array([1, 2, 3, 4])  # (3,4) + (4,)       -> ajoute a chaque LIGNE
A + np.array([[1], [2], [3]])   # (3,4) + (3,1)  -> ajoute a chaque COLONNE

x = np.linspace(0, 1, 3)          # (3,)
y = np.linspace(0, 2, 4)          # (4,)
D = x[:, None] - y[None, :]       # (3,1) - (1,4) -> (3,4) : toutes les differences
# application : matrice des distances entre n points, sans aucune boucle
P = np.random.random((5, 2))
diff = P[:, None, :] - P[None, :, :]        # (5,5,2)
D = np.sqrt((diff**2).sum(axis=2))          # (5,5) : toutes les distances
assert np.allclose(D, D.T) and np.allclose(np.diag(D), 0)

Le broadcasting evite des boucles imbriquees et l'allocation de tableaux intermediaires : c'est l'outil qui rend NumPy reellement expressif.

3Visualisation, comparaison et applications

Vectorisation : ce que fait NumPy element par element
Choisir une operation puis derouler.

Liste ou tableau NumPy ?

listnp.ndarray
Typesheterogeneshomogenes (un seul dtype)
Tailledynamique (append)fixe a la creation
Memoirepointeurs dispersesbloc contigu, compact
t + uconcatenationaddition terme a terme
t * 2repetitionmultiplication terme a terme
Tranchecopievue
Dimensions1 (imbriquer pour plus)quelconques
Vitessereference10 a 100 fois plus rapide
Quand utiliser NumPy
  • Oui : calcul numerique sur de grands volumes, matrices, algebre lineaire, traitement d'images, discretisation d'equations, statistiques.
  • Non : structures de taille variable, donnees heterogenes, algorithmique pure (piles, arbres, graphes), petits volumes ou l'on convertit plus qu'on ne calcule.

Convertir une liste en tableau coute \(\Theta(n)\) : si l'on ne fait qu'une operation, le gain est nul.

Application — resolution d'un systeme lineaire

import numpy as np

A = np.array([[2, 1, -1],
              [-3, -1, 2],
              [-2, 1, 2]], dtype=float)
b = np.array([8, -11, -3], dtype=float)

x = np.linalg.solve(A, b)
print(x)                                # [ 2.  3. -1.]
print(np.allclose(A @ x, b))            # True : verification systematique
print(f"conditionnement : {np.linalg.cond(A):.2f}")
Le conditionnement

Le nombre \(\kappa(A) = \|A\|\,\|A^{-1}\|\) mesure la sensibilite de la solution aux erreurs sur les donnees : une perturbation relative \(\varepsilon\) sur \(b\) provoque une perturbation relative jusqu'a \(\kappa(A)\varepsilon\) sur \(x\). Un systeme avec \(\kappa \approx 10^{16}\) est numeriquement insoluble en double precision, meme si la matrice est theoriquement inversible. La matrice de Hilbert en est l'exemple classique.

H = np.array([[1/(i+j+1) for j in range(10)] for i in range(10)])
print(f"{np.linalg.cond(H):.2e}")     # 1.60e+13 : tres mal conditionnee

L'essentiel du chapitre

  • Un ndarray est homogene, de taille fixe, stocke de facon contigue.
  • Les operations sont vectorisees : plus de boucle Python, gain de 10 a 100.
  • * est le produit terme a terme, @ le produit matriciel.
  • Une tranche NumPy est une vue, pas une copie : utiliser .copy() si besoin.
  • axis=0 agrege les lignes (resultat par colonne), axis=1 l'inverse.
  • Preferer linspace a arange pour un pas flottant.
  • np.linalg.solve et jamais inv(A) @ b.
  • Attention au debordement silencieux des entiers de taille fixe.
Fiche d'exercices — chapitre 17Enonces gradues, du calcul d'application au niveau concours, avec corriges detailles.
Ouvrir la fiche →