Broadcasting¶
Le broadcasting est la façon qu'à pytorch de traîter les tenseurs lors d'opérations arithmétiques non évidentes.
Par exemple, il est évident qu'on ne va pas pouvoir ajouter une matrice $3 \times 3$ à une matrice $4 \times 2$ et ce cas-là nous causera une erreur dans pytorch. Par contre, si on veut ajouter un scalaire à une matrice $3 \times 3$ ou un vecteur de taille $3$ à une matrice $3 \times 3$, il est possible de trouver des opérations logique même si la façon de procéder n'est pas forcément évidente.
Le broadcasting de pytorch est basé sur des règles simples qu'il faut garder en tête lorsque l'on manipule des tenseurs.
Règle de broadcasting¶
Pour que deux tenseurs soient broadcastable, ils doivent satisfaire les règles suivantes :
- Chaque tenseur a au moins une dimension
- Lors de l'itération sur les tailles des dimensions, en commençant par la dimension de fin, les tailles des dimensions doivent soit être égales, soit l'une d'entre elles doit être 1, soit l'une d'entre elles ne doit pas exister.
Utilisons des examples pour que ça soit plus clair :
import torch
# Deux tenseurs de la même taille sont toujours broadcastables
x=torch.zeros(5,7,3)
y=torch.zeros(5,7,3)
# Les deux tenseurs suivants ne sont pas broadcastables car x n'a pas au moins une dimension
x=torch.zeros((0,))
y=torch.zeros(2,2)
# On aligne les dimensions visuellement pour voir si les tenseurs sont broadcastables
# En partant de la droite,
# 1. x et y ont la même taille et sont de taille 1
# 2. y est de taille 1
# 3. x et y ont la même taille
# 4. la dimension de y n'existe pas
# Les deux tenseurs sont donc broadcastables
x=torch.zeros(5,3,4,1)
y=torch.zeros( 3,1,1)
# A l'inverse, ces deux tenseurs ne sont pas broadcastables car 3. x et y n'ont pas la même taille
x=torch.zeros(5,2,4,1)
y=torch.zeros( 3,1,1)
Maintenant que l'on sait comment reconnaître deux tenseurs broadcastables, on doit définir les règles qui s'applique lors de l'opération entre les deux.
Les règles sont les suivantes :
- Règle 1 : Si le nombre de dimensions de x et y n'est pas égal, ajoutez 1 au début des dimensions du tenseur ayant le moins de dimensions pour les rendre de même longueur.
- Règle 2 : Ensuite, pour chaque taille de dimension, la taille de la dimension résultante est le maximum des tailles de x et y le long de cette dimension.
Le tenseur dont la taille est modifié va être dupliqué le nombre de fois nécessaire pour faire coincider les tailles.
Note : Si deux tenseurs ne sont pas broadcastables et qu'on tente de les ajouter, il y aura une erreur. Par contre, dans de nombreux cas, l'opération de broadcasting va fonctionner mais ne va pas faire l'opération que l'on souhaite à cause des règles de broadcating. C'est pour ce cas qu'il est important de maîtriser ces règles.
Reprenons, dans un premier temps, nos deux exemples :
Ajouter un scalaire à une matrice $3 \times 3$ :
x=torch.randn(3,3)
y=torch.tensor(1)
print("x : " ,x)
print("y : " ,y)
print("x+y : " ,x+y)
print("x+y shape : ",(x+y).shape)
# Le tenseur y est broadcasté pour avoir la même taille que x, il se transforme en tenseur de 1 de taille 3x3
x : tensor([[ 0.6092, -0.6887, 0.3060], [ 1.3496, 1.7739, -0.4011], [-0.8876, 0.7196, -0.3810]]) y : tensor(1) x+y : tensor([[1.6092, 0.3113, 1.3060], [2.3496, 2.7739, 0.5989], [0.1124, 1.7196, 0.6190]]) x+y shape : torch.Size([3, 3])
Ajouter un vecteur de taille $3$ à une matrice $3 \times 3$ :
x=torch.randn(3,3)
y=torch.tensor([1,2,3]) # tenseur de taille 3
print("x : " ,x)
print("y : " ,y)
print("x+y : " ,x+y)
print("x+y shape : ",(x+y).shape)
# Le tenseur y est broadcasté pour avoir la même taille que x, il se transforme en tenseur de 1 de taille 3x3
x : tensor([[ 0.9929, -0.1435, 1.5740], [ 1.2143, 1.3366, 0.6415], [-0.2718, 0.3497, -0.2650]]) y : tensor([1, 2, 3]) x+y : tensor([[1.9929, 1.8565, 4.5740], [2.2143, 3.3366, 3.6415], [0.7282, 2.3497, 2.7350]]) x+y shape : torch.Size([3, 3])
Considérons maintenant d'autres exemples plus compliqués :
x=torch.zeros(5,3,4,1)
y=torch.zeros( 3,1,1)
print("x+y shape : ",(x+y).shape)
# Le tenseur y a été étendu en taille 1x3x1x1 (règle 1) puis dupliqué en taille 5x3x4x1 (règle 2)
x+y shape : torch.Size([5, 3, 4, 1])
x=torch.empty(1)
y=torch.empty(3,1,7)
print("x+y shape : ",(x+y).shape)
# Le tenseur y a été étendu en taille 1x1x1 (règle 1) puis dupliqué en taille 3x1x7 (règle 2)
x+y shape : torch.Size([3, 1, 7])
x=torch.empty(5,2,4,1)
y=torch.empty(3,1,1)
print("x+y shape : ",(x+y).shape)
# L'opération n'est pas possible car les tenseurs ne sont pas broadcastables (dimension 3 en partant de la fin ne correspond pas)
--------------------------------------------------------------------------- RuntimeError Traceback (most recent call last) Cell In[18], line 3 1 x=torch.empty(5,2,4,1) 2 y=torch.empty(3,1,1) ----> 3 print("x+y shape : ",(x+y).shape) 4 # L'opération n'est pas possible car les tenseurs ne sont pas broadcastables (dimension 3 en partant de la fin ne correspond pas) RuntimeError: The size of tensor a (2) must match the size of tensor b (3) at non-singleton dimension 1
Autres points à considérer¶
Comparaison à des scalaires¶
On n'y pense pas forcément mais cela va nous permettre de faire des opérations de comparaisons de manière simple.
a = torch.tensor([10., 0, -4])
print(a > 0)
print(a==0)
tensor([ True, False, False]) tensor([False, True, False])
On peut aussi comparer deux tenseurs entre eux :
a=torch.tensor([1,2,3])
b=torch.tensor([4,2,6])
# Comparaison élément par élément
print(a==b)
# Comparaison élément par élément et égalité pour tous les éléments
print((a==b).all())
# Comparaison élément par élément et égalité pour au moins un élément
print((a==b).any())
# Comparaison avec supérieur ou égal
print(a>=b)
tensor([False, True, False]) tensor(False) tensor(True) tensor([False, True, False])
Cela peut être très utile pour créer des masques à partir d'un seuil par exemple ou vérifier que deux opérations sont équivalentes.
Utilisation de unsqueeze()¶
On va vu précedemment qu'il est possible de broadcast un tenseur de taille $3$ à une matrice de taille $3 \times 3$. Le broadcasting de pytorch va automatiquement le transformer en taille $1 \times 3$ pour réaliser l'opération. Cependant, on peut vouloir réaliser l'opération dans l'autre sens, c'est-à-dire, ajouter un tenseur $3 \times 1$ à une matrice de taille $3 \times 3$.
Dans ce cas là, on va devoir remplacer la règle 1 manuellement à l'aide de la fonction unsqueeze() qui permet de rajouter une dimension.
x=torch.tensor([1,2,3])
y=torch.randn(3,3)
print("y : ",y )
print("x+y : ",x+y)
x=x.unsqueeze(1)
print("x shape : ",x.shape)
print("x+y : ",x+y)
y : tensor([[ 1.3517, 1.1880, 0.4483], [ 0.5137, -0.5406, -0.1412], [-0.0108, 1.3757, 0.6112]]) x+y : tensor([[2.3517, 3.1880, 3.4483], [1.5137, 1.4594, 2.8588], [0.9892, 3.3757, 3.6112]]) x shape : torch.Size([3, 1]) x+y : tensor([[2.3517, 2.1880, 1.4483], [2.5137, 1.4594, 1.8588], [2.9892, 4.3757, 3.6112]])
Comme vous le voyez, on a pu contourner les règles de pytorch pour obtenir le résultat souhaité.
Note :
- La règle 1 de pytorch correspond à faire x.unsqueeze(0) jusqu'à ce que le nombre de dimensions soit le même
- C'est possible de remplacer unsqueeze() avec None de la manière suivante :
x=torch.tensor([1,2,3])
# La première opération est l'équivalent de unsqueeze(0) et la seconde de unsqueeze(1)
x[None].shape,x[...,None].shape
(torch.Size([1, 3]), torch.Size([3, 1]))
Utilisation de keepdim¶
Les fonctions de pytorch qui réduisent la taille d'un tenseur selon une dimension (torch.sum pour sommer selon une dimension, torch.mean pour calculer la moyenne et bien d'autres) ont un paramètre intéressant à utiliser dans certains cas.
Ces opérations vont changer la dimension du tenseur et automatiquement supprimer la dimension sur laquelle on a réalisé l'opération.
x=torch.randn(3,4,5)
print(x.shape)
x=x.sum(dim=1) # somme sur la dimension 1
print(x.shape)
torch.Size([3, 4, 5]) torch.Size([3, 5])
Si vous souhaitez conserver la dimension sur laquelle on fait la somme, vous pouvez utiliser l'argument keepdim=True.
x=torch.randn(3,4,5)
print(x.shape)
x=x.sum(dim=1,keepdim=True) # somme sur la dimension 1
print(x.shape)
torch.Size([3, 4, 5]) torch.Size([3, 1, 5])
C'est parfois très utile pour ne pas faire n'importe quoi avec les dimensions. Regardons un cas où cela va impacter le broadcasting.
x=torch.randn(3,4,5)
y=torch.randn(1,1,1)
x_sum=x.sum(dim=1)
x_sum_keepdim=x.sum(dim=1,keepdim=True)
print("Les deux opérations sont elles équivalentes ? :",(x_sum+y==x_sum_keepdim+y).all().item())
Les deux opérations sont elles équivalentes ? : False
Ce qu'il s'est passé :
- Dans le premier cas, on obtient x_sum de taille $3 \times 5$, la règle 1 le transforme en taille $1 \times 3 \times 5$ et la règle 2 transforme y en $1 \times 3 \times 5$.
- Dans le second cas, on obtient x_sum_keepdim de taille $3 \times 1 \times 5$ et la règle 2 transforme y en $1 \times 3 \times 5$.
Einstein Summation¶
Cette partie n'est pas directement en rapport avec le broadcasting mais il s'agit d'une information importante à connaître.
Pour multiplier les matrices dans pytorch, nous avons utilisé l'opérateur @ (ou torch.matmul) jusqu'ici. Il existe une autre manière de faire des multiplications matricielles avec la Einstein Summation (torch.einsum).
Il s'agit d'une représentation compacte pour écrire les produits et les sommes par exemple :
ik,kj -> ij
Le côté gauche représente les dimensions des entrées, séparées par des virgules. Ici, nous avons deux tenseurs qui ont chacun deux dimensions (i,k et k,j). Le côté droit représente les dimensions du résultat, donc ici nous avons un tenseur avec deux dimensions i,j.
Les règles de la notation de sommation d'Einstein sont les suivantes :
- Les indices répétés sur le côté gauche sont implicitement sommés s'ils ne se trouvent pas sur le côté droit.
- Chaque indice peut apparaître au maximum deux fois sur le côté gauche.
- Les indices non répétés sur le côté gauche doivent apparaître sur le côté droit.
On peut l'utiliser pour plusieurs choses :
torch.einsum('ij->ji', a)
renvoie la transposée de la matrice a.
Alors que
torch.einsum('bi,ij,bj->b', a, b, c)
renverra un vecteur de taille b où la k-ième coordonnée est la somme de $a[k,i]⋅b[i,j]⋅c[k,j]$. Cette notation est particulièrement pratique lorsque vous avez plus de dimensions lors de la manipulation de batchs.
Par exemple, si vous avez deux lots de matrices et que vous voulez calculer le produit matriciel par batch, vous pourriez utiliser ceci :
torch.einsum('bik,bkj->bij', a, b)
C'est une façon pratique d'effectuer des multiplications matricielles dans pytorch. De plus, c'est très rapide et c'est souvent la manière la plus rapide d'effectuer des opérations customs dans pytorch.