Dans ce chapitre, nous allons explorer la notion de broadcasting avec NumPy, en nous concentrant d'abord sur les opérations element-wise (en fonction des éléments), c'est-à-dire les opérations effectuées élément par élément entre des tableaux.

Nous n'avons pas toujours besoin d'utiliser des opérations matricielles complexes comme le produit scalaire ou matriciel.

Grâce à NumPy, nous pouvons faire des opérations de manière linéaire, c'est-à-dire entre les éléments individuels de deux tableaux, tant qu'ils ont la même forme ou que leurs formes sont compatibles.

import numpy as np

M = np.ones(shape=(3, 3))
print(M)

# Résultat :
# [[1. 1. 1.]
#  [1. 1. 1.]
#  [1. 1. 1.]]

Nous avons ici une matrice M remplie de 1 avec une shape de (3, 3). Créons maintenant une matrice identité :

I = np.eye(3)
print(I)
# Résultat :
# [[1. 0. 0.]
#  [0. 1. 0.]
#  [0. 0. 1.]]

La matrice identité I a des 1 sur sa diagonale et des 0 ailleurs. Comparons maintenant les deux types d'opérations possibles : le produit matriciel (dot product) et l'opération élément par élément.

Commençons par le produit matriciel :

np.dot(M, I)

# Résultat :
# [[1. 1. 1.]
#  [1. 1. 1.]
#  [1. 1. 1.]]

Le résultat de np.dot(M, I) est une nouvelle matrice, mais ici elle est identique à M. C'est logique, car I est la matrice identité, et multiplier une matrice par l'identité donne la matrice d'origine.

Voyons maintenant ce qui se passe si nous faisons une multiplication élément par élément :

M * I

# Résultat :
# [[1. 0. 0.]
#  [0. 1. 0.]
#  [0. 0. 1.]]

Cette fois, chaque élément de M est multiplié par l'élément correspondant de I. Comme M est remplie de 1, le résultat est simplement la matrice identité. Cela illustre bien la différence entre un produit matriciel (dot) et une opération element-wise.

Nous pouvons aussi faire des additions ou des soustractions élément par élément :

M + I

# Résultat :
# [[2. 1. 1.]
#  [1. 2. 1.]
#  [1. 1. 2.]]

Ici, on additionne 1 (de M) avec chaque élément de I. Là où I a des 1, on obtient 2. Ailleurs, la valeur reste 1.

M - I
# Résultat :
# [[0. 1. 1.]
#  [1. 0. 1.]
#  [1. 1. 0.]]

La soustraction fonctionne de la même façon. Là où I a des 1, on obtient 0 (1 - 1). Ailleurs, on garde les 1 de M.

Voyons maintenant ce que cela donne avec une autre matrice :

A = np.arange(9).reshape(3, 3)
print(A)

# Résultat :
# [[0 1 2]
#  [3 4 5]
#  [6 7 8]]

Nous avons ici une matrice A de forme (3, 3) avec des valeurs de 0 à 8. Faisons une addition avec I :

A + I

# Résultat :
# [[1. 1. 2.]
#  [3. 5. 5.]
#  [6. 7. 9.]]

On voit que seuls les éléments sur la diagonale ont été incrémentés de 1. Cela montre bien l'effet de l'addition élément par élément.

Faisons maintenant une soustraction :

A - I
# Résultat :
# [[-1.  1.  2.]
#  [ 3.  3.  5.]
#  [ 6.  7.  7.]]

Les éléments de la diagonale ont été décrémentés de 1. Les autres restent inchangés.

En conclusion, les opérations element-wise permettent de manipuler des matrices de même forme de manière intuitive, en effectuant des opérations arithmétiques sur chaque paire d'éléments correspondants. C'est une façon simple et très puissante de faire des calculs en NumPy, sans avoir besoin de boucles explicites ou d'opérations matricielles complexes.

Broadcasting (diffusion)

Commençons par observer une matrice simple que nous créons avec NumPy. Lorsque nous écrivons :

import numpy as np

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

Si nous faisons maintenant A + 1, le résultat est le suivant :

# array([[1, 2, 3],
#       [4, 5, 6],
#       [7, 8, 9]])

Nous venons d'ajouter un scalaire à toute une matrice. Ce scalaire 1 est automatiquement transformé en 1.0 pour s'aligner au type de la matrice. Comme 1 n'a pas la même forme (shape) que A, NumPy crée virtuellement une matrice remplie de 1, de même forme que A, sans réellement l'allouer en mémoire. C'est cela le broadcasting : une opération implicite qui évite la duplication de données tout en permettant des calculs élément par élément.

Prenons maintenant une matrice plus grande :

A = np.arange(1000 * 1000).reshape(1000, -1)

Nous avons ici une matrice avec un million d'éléments. Si nous faisons :

A + 1

Il faudrait en théorie créer une matrice de 1 de taille équivalente pour effectuer l'addition, mais NumPy utilise le broadcasting pour éviter cela. Il ne crée pas réellement cette matrice, économisant ainsi de la mémoire et accélérant le calcul.

Voyons concrètement à quel point cela est rapide :

from time import time

ts = time()
n = 1000
for _ in range(n):
    _ = A + 1
te = time()
r = (te - ts) / n
print(r)

Ce code mesure le temps moyen d'une opération A + 1 répétée 1000 fois. Grâce au broadcasting, cette opération est quasi instantanée malgré la taille énorme de A.

Essayons maintenant la même opération mais sans NumPy, sur une matrice plus petite pour éviter des temps d'exécution trop longs :

MATRIX = [[i + j for j in range(100)] for i in range(100)]

ts = time()
n_loop = 10
for _ in range(n_loop):
    for i in range(len(MATRIX)):
        for j in range(len(MATRIX[0])):
            _ = MATRIX[i][j] + 1
tf = time()

chrono = (tf - ts) / n_loop
print(chrono)

Ici, nous effectuons l'opération manuellement en Python pur. Le temps est beaucoup plus long, car l'opération est réalisée boucle par boucle, sans optimisation mémoire.

Notez que l'équivalent explicite de A + 1 serait :

A + np.ones((1000, 1000))

Mais cela consommerait bien plus de ressources, car une matrice entière de 1 serait réellement créée en mémoire. Le broadcasting évite cela.

Manipulons à présent une matrice encore plus grande :

A = np.arange(20000 * 20000).reshape(20000, -1)

Cela revient à faire un reshape(20000, 20000). La matrice contient 400 millions d'éléments. On peut tenter :

A + 1
A + 2
A + 3

Mais à un moment donné, notre notebook va planter : nous saturons la mémoire. Il est donc crucial d'écraser la variable à chaque opération, comme A = A + 1, pour ne pas accumuler plusieurs versions de cette matrice énorme en mémoire.

Voyons un autre exemple de broadcasting :

M = np.arange(9).reshape(3, -1)
print(M)
# [[0 1 2]
#  [3 4 5]
#  [6 7 8]]

Si nous faisons :

M + [10, 20, 30]

# array([[10, 21, 32],
#      [13, 24, 35],
#       [16, 27, 38]])

NumPy a automatiquement broadcasté la liste [10, 20, 30] en :

#[[10, 20, 30],
# [10, 20, 30],
# [10, 20, 30]]

Puis il a additionné chaque élément. Cela marche aussi sur les colonnes :

M + [[10],
     [20],
     [30]]

Cela revient à répliquer la colonne [10, 20, 30] pour chaque colonne de M.

En résumé :

M + n réplique le scalaire sur toute la matrice.

M + ligne réplique la ligne sur chaque ligne de M.

M + colonne réplique la colonne sur chaque colonne de M.

Et cela fonctionne avec tous les opérateurs mathématiques : +, -, *, /, //, %, etc.

Voyons maintenant avec des booléens :

ID = np.eye(3)
print(ID == 1)
# array([[ True, False, False],
#      [False,  True, False],
#       [False, False,  True]])

Chaque comparaison est faite élément par élément.

Prenons :

M = np.arange(9).reshape(3, -1)
print(M == [0, 1, 2])
# array([[ True,  True,  True],
#       [False, False, False],
#       [False, False, False]])

La liste [0, 1, 2] a été broadcastée ligne par ligne, et seule la première ligne est égale.

Maintenant :

print(M == [[4],
            [0],
            [8]])
# array([[False, False, False],
#       [False, False, False],
#       [False, False,  True]])

La matrice [4, 0, 8] est comparée à chaque ligne de M en la broadcastant en colonne. Le résultat est une matrice de booléens, ce qui est extrêmement utile pour faire des filtres ou des masques.

Conclusion

Le broadcasting est un mécanisme clé de NumPy. Il permet de réaliser des opérations élément par élément (elementwise) entre des tableaux de shape différentes, tout en étant rapide et économe en mémoire.

Cela s'applique aussi bien aux opérations arithmétiques (+, -, *, /, //, %, ** etc.) qu'aux comparaisons logiques (==, !=, <, <=, >, >= etc.). Il faut cependant rester vigilant : une mauvaise gestion de la mémoire peut rapidement saturer votre environnement de calcul.

Pour aller plus loin dans les détails du broadcasting, notamment en deep learning, nous vous recommandons de consulter la documentation officielle : https://numpy.org/doc/stable/user/basics.broadcasting.html