Comprendre les convolutions et autograd

$$\gdef \sam #1 {\mathrm{softargmax}(#1)}$$ $$\gdef \vect #1 {\boldsymbol{#1}} $$ $$\gdef \matr #1 {\boldsymbol{#1}} $$ $$\gdef \E {\mathbb{E}} $$ $$\gdef \V {\mathbb{V}} $$ $$\gdef \R {\mathbb{R}} $$ $$\gdef \N {\mathbb{N}} $$ $$\gdef \relu #1 {\texttt{ReLU}(#1)} $$ $$\gdef \D {\,\mathrm{d}} $$ $$\gdef \deriv #1 #2 {\frac{\D #1}{\D #2}}$$ $$\gdef \pd #1 #2 {\frac{\partial #1}{\partial #2}}$$ $$\gdef \set #1 {\left\lbrace #1 \right\rbrace} $$ % My colours $$\gdef \aqua #1 {\textcolor{8dd3c7}{#1}} $$ $$\gdef \yellow #1 {\textcolor{ffffb3}{#1}} $$ $$\gdef \lavender #1 {\textcolor{bebada}{#1}} $$ $$\gdef \red #1 {\textcolor{fb8072}{#1}} $$ $$\gdef \blue #1 {\textcolor{80b1d3}{#1}} $$ $$\gdef \orange #1 {\textcolor{fdb462}{#1}} $$ $$\gdef \green #1 {\textcolor{b3de69}{#1}} $$ $$\gdef \pink #1 {\textcolor{fccde5}{#1}} $$ $$\gdef \vgrey #1 {\textcolor{d9d9d9}{#1}} $$ $$\gdef \violet #1 {\textcolor{bc80bd}{#1}} $$ $$\gdef \unka #1 {\textcolor{ccebc5}{#1}} $$ $$\gdef \unkb #1 {\textcolor{ffed6f}{#1}} $$ % Vectors $$\gdef \vx {\pink{\vect{x }}} $$ $$\gdef \vy {\blue{\vect{y }}} $$ $$\gdef \vb {\vect{b}} $$ $$\gdef \vz {\orange{\vect{z }}} $$ $$\gdef \vtheta {\vect{\theta }} $$ $$\gdef \vh {\green{\vect{h }}} $$ $$\gdef \vq {\aqua{\vect{q }}} $$ $$\gdef \vk {\yellow{\vect{k }}} $$ $$\gdef \vv {\green{\vect{v }}} $$ $$\gdef \vytilde {\violet{\tilde{\vect{y}}}} $$ $$\gdef \vyhat {\red{\hat{\vect{y}}}} $$ $$\gdef \vycheck {\blue{\check{\vect{y}}}} $$ $$\gdef \vzcheck {\blue{\check{\vect{z}}}} $$ $$\gdef \vztilde {\green{\tilde{\vect{z}}}} $$ $$\gdef \vmu {\green{\vect{\mu}}} $$ $$\gdef \vu {\orange{\vect{u}}} $$ % Matrices $$\gdef \mW {\matr{W}} $$ $$\gdef \mA {\matr{A}} $$ $$\gdef \mX {\pink{\matr{X}}} $$ $$\gdef \mY {\blue{\matr{Y}}} $$ $$\gdef \mQ {\aqua{\matr{Q }}} $$ $$\gdef \mK {\yellow{\matr{K }}} $$ $$\gdef \mV {\lavender{\matr{V }}} $$ $$\gdef \mH {\green{\matr{H }}} $$ % Coloured math $$\gdef \cx {\pink{x}} $$ $$\gdef \ctheta {\orange{\theta}} $$ $$\gdef \cz {\orange{z}} $$ $$\gdef \Enc {\lavender{\text{Enc}}} $$ $$\gdef \Dec {\aqua{\text{Dec}}}$$
🎙️ Alfredo Canziani

Comprendre la convolution 1D

Au lieu d’utiliser la matrice $A$ de la semaine précédente, nous allons changer la largeur de la matrice pour la taille du noyau $k$. Par conséquent, chaque ligne de la matrice est un noyau. Nous pouvons utiliser les noyaux en les empilant et en les déplaçant (voir la figure 1). Nous pouvons alors avoir $m$ couches de hauteur $n-k+1$.

1
Figure 1 : Illustration de la convolution 1D

La sortie est $m$ vecteurs de taille $n-k+1$.

2
Figure 2 : Résultat de la convolution 1D

De plus, un seul vecteur d’entrée peut être considéré comme un signal monophonique.

3
Figure 3 : Signal monophonique

Maintenant, l’entrée $ x:\Omega\rightarrow\mathbb{R}^{c}$ avec $\Omega = \lbrace 1, 2, 3, \cdots \rbrace \subset \mathbb{N}^1$ (puisque c’est un signal de dimension $1$ / il a un domaine de dimension $1$) et dans ce cas le numéro de canal $c$ est $1$. Lorsque $c = 2$, cela devient un signal stéréophonique.

Pour la convolution 1D, nous pouvons simplement calculer le produit scalaire, noyau par noyau (voir figure 4).

4
Figure 4 : Produit scalaire couche par couche de la convolution 1D

Dimension des noyaux et largeur des sorties dans PyTorch

Astuce : on peut utiliser le question mark dans IPython pour avoir accès aux documents de fonctions. Par exemple,

Init signature :
nn.Conv1d(
	in_channels, # nombre de canaux dans l'image d'entrée
	out_channels, # nombre de canaux produits par la convolution
	kernel_size, # taille du noyau en convolution
	stride=1, # pas de la convolution
	padding=0, # rembourage de 0 ajouté aux deux côtés de l'entrée
	dilatation=1, # espacement entre les éléments du noyau
	groups=1, # nombre de connexions bloquées de l'entrée à la sortie
	bias=True, # si `True`, ajoute un biais appris à la sortie
	padding_mode='zeros', # valeurs acceptées `zeros` et `circular`.
)

Convolution 1D

Nous avons une convolution de dimension $1$ allant de $2$ canaux (signal stéréophonique) à $16$ canaux ($16$ noyaux) avec une taille de noyau de $3$ et un pas de $1$. Nous avons ensuite $16$ noyaux avec une épaisseur de $2$ et une longueur de $3$. Supposons que le signal d’entrée ait un batch de taille $1$ (un signal), de $2$ canaux et $64$ échantillons. La couche de sortie résultante a $1$ signal, des $16$ canaux et la longueur du signal est de $62$ ($=64-3+1$). De plus, si nous affichons la taille du biais, nous constaterons qu’elle de $16$, puisque nous avons un biais par poids.

conv = nn.Conv1d(2, 16, 3) # 2 canaux (signal stéréo), 16 noyaux de taille 3
conv.weight.size() # sortie : torch.Size([16, 2, 3])
conv.bias.size() # sortie : torch.Size([16])

x = torche.rand(1, 2, 64) # batch de taille 1, 2 canaux, 64 échantillons
conv(x).size() # sortie : torch.Size([1, 16, 62])

conv = nn.Conv1d(2, 16, 5) # 2 canaux, 16 noyaux de taille 5
conv(x).size() # sortie : torch.Size([1, 16, 60])

Convolution 2D

Nous définissons d’abord les données d’entrée comme un $1$ échantillon, $20$ canaux (disons que nous utilisons une image hyperspectrale) avec une hauteur de $64$ et une largeur de $128$. La convolution 2D a $20$ canaux en entrée et $16$ noyaux de taille de $3\times5$. Après la convolution, la donnée en sortie a un $1$ échantillon, $16$ canaux avec une hauteur de $62$ ($=64-3+1$) et une largeur de $124$ ($=128-5+1$).

x = torch.rand(1, 20, 64, 128) # 1 échantillon, 20 canaux, hauteur 64, et largeur 128
conv = nn.Conv2d(20, 16, (3, 5))  # 20 canaux, 16 noyaux, la taille des noyaux est de 3 x 5
conv.weight.size() # sortie : torch.Size([16, 20, 3, 5])
conv(x).size() # sortie : torch.Size([1, 16, 62, 124])

Si nous voulons atteindre la même dimensionnalité, nous pouvons rembourrer. En continuant le code ci-dessus, nous pouvons ajouter de nouveaux paramètres à la fonction de convolution : stride=1 et padding=(1, 2), ce qui signifie $1$ dans la direction $y$ ($1$ en haut et $1$ en bas) et $2$ dans la direction $x$. Le signal de sortie est alors de la même taille que le signal d’entrée. Le nombre de dimensions nécessaires pour stocker la collection de noyaux lorsque vous effectuez une convolution 2D est de $4$.

# 20 canaux, 16 noyaux de taille 3 x 5, pas de 1, rembourrage 1 et 2
conv = nn.Conv2d(20, 16, (3, 5), 1, (1, 2))
conv(x).size() # sortie : torch.Size([1, 16, 64, 128])

Comment fonctionne le gradient automatique ?

Dans cette section, nous allons demander à Pytorch de vérifier tous les calculs sur les tenseurs afin que nous puissions effectuer le calcul des dérivés partielles.

  • Créer un tenseur $2\times2$ $\boldsymbol{x}$ avec des capacités d’accumulation de gradients
  • Enlever $2$ à tous les éléments de $\boldsymbol{x}$ et obtenir $\boldsymbol{y}$
    Si nous affichons y.grad_fn, nous obtenons l’objet <SubBackward0 à 0x12904b290>, ce qui signifie que y est généré par le module de soustraction $\boldsymbol{x}-2$. On peut aussi utiliser y.grad_fn.next_functions[0][0].variable pour dériver le tenseur original.
  • Faire d’autres opérations : $\boldsymbol{z} = 3\boldsymbol{y}^2$
  • Calculer la moyenne de $\boldsymbol{z}$
5
Figure 5 : Exemple d’organigramme d'auto-gradient

La rétropropagation est utilisée pour calculer les gradients. Dans cet exemple, le processus de rétropropagation peut être considéré comme le calcul du gradient $\frac{d\boldsymbol{a}}{d\boldsymbol{x}}$. Après avoir calculé manuellement $\frac{d\boldsymbol{a}}{d\boldsymbol{x}}$ comme validation, nous pouvons constater que l’exécution de a.backward() nous donne la même valeur que x.grad.

Voici le processus de calcul de la rétropropagation à la main :

\[\begin{aligned} a &= \frac{1}{4} (z_1 + z_2 + z_3 + z_4) \\ z_i &= 3y_i^2 = 3(x_i-2)^2 \\ \frac{da}{dx_i} &= \frac{1}{4}\times3\times2(x_i-2) = \frac{3}{2}x_i-3 \\ x &= \begin{pmatrix} 1&2\\3&4\end{pmatrix} \\ \left(\frac{da}{dx_i}\right)^\top &= \begin{pmatrix} 1.5-3&3-3\\[2mm]4.5-3&6-3\end{pmatrix}=\begin{pmatrix} -1.5&0\\[2mm]1.5&3\end{pmatrix} \end{aligned}\]

Chaque fois que nous utilisons une dérivée partielle dans PyTorch, nous obtenons la même forme que les données originales. Mais la bonne jacobienne devrait être la transposée.

Vers plus de folie

Maintenant que nous avons un vecteur $1\times3$ $x$, assignons $2x$ à $y$ et doublons $y$ jusqu’à ce que sa norme soit inférieure à $1000$. En raison du caractère aléatoire que nous avons pour $x$, nous ne pouvons pas connaître directement le nombre d’itérations lorsque la procédure se termine.

x = torch.randn(3, requires_grad=True)

y = x * 2
i = 0
while y.data.norm() < 1000:
    y = y * 2
    i += 1

Cependant, nous pouvons facilement le déduire en connaissant les gradients dont nous disposons.

gradients = torch.FloatTensor([0.1, 1.0, 0.0001])
y.backward(gradients)

print(x.grad)
tensor([1.0240e+02, 1.0240e+03, 1.0240e-01])
print(i)
9

Quant à l’inférence, nous pouvons utiliser requires_grad=True pour indiquer que nous voulons suivre l’accumulation du gradient comme indiqué ci-dessous. Si nous omettons requires_grad=True dans la déclaration de $x$ ou $w$ et appelons backward() sur $z$, il y aura une erreur d’exécution due au fait que nous n’avons pas d’accumulation de gradient sur $x$ ou $w$.

# x et w qui permettent l'accumulation de gradients
x = torch.arange(1., n + 1, requires_grad=True)
w = torch.ones(n, requires_grad=True)
z = w @ x
z.backward()
print(x.grad, w.grad, sep='\n')

Nous pouvons avoir with torch.no_grad() pour omettre l’accumulation de gradient.

x = torch.arange(1., n + 1)
w = torch.ones(n, requires_grad=True)

# Tous les tenseurs n'auront pas l'accumulation de gradient
with torch.no_grad():
    z = w @ x

try:
    z.backward()# PyTorch va retourner une erreur ici, puisque z n'a pas de grad accum.
except RuntimeError as e:
    print('RuntimeError!!! >:[')
    print(e)

Plus de choses : des gradients personnalisés

Au lieu d’opérations numériques de base, nous pouvons générer nos propres modules / fonctions qui peuvent être branchés sur le graphe neural. Le notebook Jupyter se trouve ici.
Pour ce faire, nous devons partir de la fonction torch.autograd.Function et remplacer les fonctions forward() et backward(). Par exemple, si nous voulons entraîner des réseaux, nous devons obtenir la propagation en avant dans le réseau et connaître les dérivées partielles de l’entrée par rapport à la sortie, de sorte que nous puissions utiliser ce module en tout point du code. Ensuite, en utilisant la rétropropagation (règle de la chaîne), nous pouvons insérer la chose n’importe où dans la chaîne d’opérations, à condition de connaître les dérivées partielles de l’entrée par rapport à la sortie.

Dans ce cas, il y a trois exemples de modules personnalisés dans le notebook, les modules add, split, et max. Par exemple, le module d’ajout personnalisé :

# Module personnalisé supplémentaire
class MyAdd(torch.autograd.Function):

    @staticmethod
    def forward(ctx, x1, x2):
        # ctx est un contexte où nous pouvons sauvegarder les calculs pour la rétropropagation
        ctx.save_for_backward(x1, x2)
        return x1 + x2

    @staticmethod
    def backward(ctx, grad_output):
        x1, x2 = ctx.saved_tensors
        grad_x1 = grad_output * torch.ones_like(x1)
        grad_x2 = grad_output * torch.ones_like(x2)
        # on a besoin de retourner les gradients pour la phase avant
        return grad_x1, grad_x2

Si nous avons l’addition de deux choses et que nous obtenons un résultat, nous devons écraser la fonction forward comme ceci. Et lorsque nous rétropropagation, les gradients sont copiés sur les deux côtés. Nous écrasons donc la fonction backward en copiant.

Pour les fonctions split et max, consultez le code pour la façon dont nous écrasons les fonctions forward et backward dans le notebook. Pour argmax, cela sélectionne l’indice de la chose la plus élevée. Ainsi l’indice de la plus élevée devrait être de $1$ et $0$ pour les autres.


📝 Leyi Zhu, Siqi Wang, Tao Wang, Anqi Zhang
Loïck Bourdois
25 Feb 2020