date mercredi 5 mars 2025
Je me suis appuyé sur l'excellent cours Deepmath d'Arnaud Bodin et François Recher . Je ne saurais assez le recommander tant il est clair et pédagogiquement bien construit, avec même des rappels d'analyse pour ceux qui auraient oublié leur cours.
J'emprunterai mon vocabulaire et mes notations plus aux mathématiques qu'à celui des réseaux neuronaux.
La structure de base struct layer
struct layer {
int n, p; // neuron's number, length of each neuron
double **a; // weights
double *y; // output: phi(a_0x_0 + ... + a_{p-1)x_{p-1) + a_p)
double *dy; // derivative φ(u)
enum phi phi; // activation
struct layer * next;// next layer
struct layer * back;// previous layer
};
Remarque : En C un vecteur de dimension n s'écrit $(a_0, ... , a_{n-1})$ : on compte à partir de zéro et non à partir de 1.
n
est le nombre de perceptrons dans la couche.
p
désigne la dimension commune des formes linéaires, plus une pour la constante (biais).
La matrice a
est la matrice des coefficients $a_i^0, ... , a_i^p$ qui sont les poids dans le jargon des réseaux neuronaux (weights) le dernier étant le biais.
y
est le vecteur des résultats du neurone et dy
est le vecteur des dérivées φ'(u)
phi
est un entier enum phi {sigm, th, hvsd, relu, lin};
qui renvoie à une fonction de ℝ dans ℝ qui est la fonction d'activation (sigmoïd, tanh, Heaviside, ReLu, linear ou autre).
Enfin next
et back
pointent respectivement sur la couche précédente et la couche suivante. Car le réseau est une suite de couches. Ainsi mes couches sont chaînées de deux manières, par leur indice lr[i]
et par les pointeurs lr->back
, lr->next
.
Il faut voir ces perceptrons lr[.]->a[.]
comme totalement connectés.
struct layer *
layer_new(int n, int p, enum phi phi)
{
struct layer *lr = malloc(sizeof(struct layer));
lr->n = n; lr->p = p;
lr->a = malloc(sizeof(double *)*n);
for(int i=0; i<n; i++)
lr->a[i] = malloc(sizeof(double)*(p+1));
lr->y = malloc(sizeof(double)*n);
lr->dy = malloc(sizeof(double)*n);
lr->back = lr->next = NULL;
lr->phi = phi;
return lr;
}
#code c
void
layer_free(struct layer *lr)
{
for(int i=0; i<lr->n; i++) free(lr->a[i]);
free(lr->a);
free(lr->y);
free(lr->dy);
free(lr);
}
void
layer_work(struct layer *lr, double *x)
{
if(lr == NULL) return;
int j;
double u;
for(int i=0; i<lr->n; i++) {
u = 0;
for(j = 0; j<lr->p; j++) u += lr->a[i][j]*x[j];
u += lr->a[i][j];
// now u = a[i}[0]*x[0] + ... + a[i][p-]*x[p-1] + a[i][p]
lr->y[i] = phi(lr->phi, u); // apply φ to u_i
lr->dy[i] = dphi(lr->phi, u); // φ'(u_i)
}
}
Le réseau est donc une suite de couches lr[0]
, ... , lr[lc-1]
, où lc
est le nombre de couches
Les coefficients, les sorties et leurs dérivées vont se voir gratifier d'un indice supplémentaire h, celui de la couche : $a_{h,i}^j$, $y_{h,i}$, $dy_{h,i}$.
Les $x_j$ sont les entrées du réseau.
Les $y_j$ sont les sorties des neurones de le couche précédente.
La fonction $\phi$ est la fonction d'activation de la couche.
struct ape
{
int lc, ic, oc; // layer's count {0, ... , lc - 1), number of inputs and outputs
struct layer * * lr; // pointer to array of layers struct layer
double *x, *y, *z;// inputs array, outputs array, labels array
};
Les entiers lc
, ic
et oc
sont respectivement le nombre de couches du réseau, le nombre d'entrées (inputs i.e. la longueur du tableau x
) et le nombre de sorties (outputs).
* lr
est la liste des couches.
x
est un pointeur sur le vecteur des entrées fournies par l'utilisateur.
y
pointe sur le vecteur des sorties de la dernière couche. C'est une commodité pour éviter une écriture aussi lourde que : ape->lr[ape->lc - 1]->y
.
z
pointe lui sur la sortie attendue (label).
Voici une illustration extensive dans le cas de deux entrées, deux couches intermédiaires de quatre neurones chacune et une couche de sorties avec une seule sortie :
$$y_{0,0}=\phi_0(a_{0,0}^0x_0+a_{0,0}^1x_1+a_{0,0}^2)$$
$$y_{0,1}=\phi_0(a_{0,1}^0x_0+a_{0,1}^1x_1+a_{0,1}^2)$$
$$y_{0,2}=\phi_0(a_{0,2}^0x_0+a_{0,2}^1x_1+a_{0,2}^2)$$
$$y_{0,3}=\phi_0(a_{0,3}^0x_0+a_{0,3}^1x_1+a_{0,3}^2)$$
$$y_{1,0}=\phi_1(a_{1,0}^0y_{0,0}+a_{1,0}^1y_{0,1}+a_{1,0}^2y_{0,2}+a_{1,0}^3y_{0,3}+a_{1,0}^4)$$
$$y_{1,1}}=\phi_1(a_{1,1}^0y_{0,0}+a_{1,1}^1y_{0,1}+a_{1,1}^2y_{0,2}+a_{1,1}^3y_{0,3}+a_{1,1}^4)$$
$$y_{1,2}}=\phi_1(a_{1,2}^0y_{0,0}+a_{1,2}^1y_{0,1}+a_{1,2}^2y_{0,2}+a_{1,2}^3y_{0,3}+a_{1,2}^4)$$
$$y_{1,3}}=\phi_1(a_{1,3}^0y_{0,0}+a_{1,3}^1y_{0,1}+a_{1,3}^2y_{0,2}+a_{1,3}^3y_{0,3}+a_{1,3}^4)$$
$$y_{2,0}=\phi_2(a_{2,0}^0y_{1,0}+a_{2,0}^1y_{1,1}+a_{2,0}^2y_{1,2}+a_{2,0}^3y_{1,3}+a_{2,0}^4)$$
Pour les coefficients $a_{h,i}^j$ j'ai mis en indice le numéro de la couche suivi du numéro neurone et en exposant le numéro de la colonne.
La mémoire est allouée dynamiquement et DOIT donc être libérée par l'utilisateur.
struct ape *
ape_new(int lc, int nc, int ic, int oc, enum phi phi)
{
// memory allocations
struct ape *ape = malloc(sizeof(struct ape));
ape->lr = malloc(sizeof(struct layer *)*lc);
// make ape layer
ape->lr[0] = layer_new(nc, ic, phi);
for(int il=1; il<lc - 1; il++) ape->lr[il] = layer_new(nc, nc, phi);
ape->lr[lc - 1] = layer_new(oc, nc, phi);
// data
ape->lc = lc; ape->ic = ic; ape->oc = oc;
// indexes
for(int il=0; il<lc - 1; il++) ape->lr[il]->next = ape->lr[il+1];
for(int il=1; il<lc; il++) ape->lr[il]->back = ape->lr[il-1];
ape->y = ape->lr[ape->lc - 1]->y;
return ape;
}
void
ape_free(struct ape *ape)
{
for(int h=0; h<ape->lc; h++) layer_free(ape->lr[h]);
free(ape->lr);
free(ape);
}
void
ape_ahead(struct ape *ape)
{
struct layer *lr = ape->lr[0];
double *x = ape->x;
while(lr != NULL) {
layer_work(lr, x);
x = lr->y;
lr = lr->next;
}
}
Je ne couperais pas à l'inéluctable ou exclusif XOR
#include "ape.h"
#include "ape.c"
double a[] = {
1, -1, -1,
-1, 1, -1,
1, 1, -1
};
int main(int argc, char *argv[])
{
struct ape* ape = ape_new(2, 2, 2, 1, hvsd);
ape_set(ape, a);
double x[4][2] = { {0, 0}, {0, 1}, {1, 0}, {1, 1} };
for(int n=0; n<4; n++) {
ape->x = x[n];
ape_ahead(ape);
printf("(%2.lf, %2.lf) -> %2.lf\n", x[n][0], x[n][1], ape->y[0]);
}
ape_free(ape);
}
Dans cet exemple on instancie un réseau avec 2 couches, 2 neurones pour la couche intermédiaire (hidden layer), 2 entrées et une sortie.
Ligne 4 à 8 : les coefficients du réseau, c'est à dire les poids et les biais. Dans la pratique c'est au réseau de les calculer en fonction du jeu d'entrées et des sorties souhaitées.
Ligne 12 : nouveau réseau 2 couches, 2 neurones par couche, 2 entrées, 1 sortie.
Ligne 14 : ape_set recopie les coefficients à leurs places. Elle est utile à titre «pédagogique» pour suivre le cours de Bodin et Recher sans se compromettre dans python, numpy, et pire encore tensorflow. Dans la pratique on met des nombres (presque) au hasard.
Lignes 16 à 20 : On fait bosser le singe et on observe ce qu'il produit.
Ligne 22 : Il faut laisser les lieux dans l'état où on les a trouvés en arrivant.
Le plus dur est à venir : la descente de gradient et la rétropropagation.
Dans ce paragraphe je vais appeler ape
le pointeur renvoyé par ape_new
.
Comme tout le monde on prendra une erreur quadratique moyenne (mse) pour évaluer la distance entre l'attendu et le réalisé c'est à dire entre ape->z
et ape->y
.
$e = \dfrac{1}{n}\sum_{i=0}^{n-1}(y_i-z_i)^2$ où $n$ est la dimension des vecteurs $y$ et $z$. (en C ape->o
)
Sa dérivée partielle par rapport à $y_i$ est alors $\frac{\partial e}{\partial y_i}=\frac{2}{n}(y_i-z_i)$.
Nous allons partir à la construction du gradient de e relatif aux poids du réseau, en partant de la dernière couche.
Je note $\bar{y}$ le $y$ de la couche précédente (bar = back).
On a $y_i = \phi({\sum _{j=0}^{n-1} a_i^j\bar{y}_j + a_i^n)$ où n = ape->n
.
donc $\dfrac {\partial y_i}{\partial a_i^j}=\bar{y}_jdy_i$ pour j<n et $dy_i$ sinon.
d'où
$\dfrac{\partial e}{\partial a_i^j}=\dfrac{2}{n}(y_i-z_i)\bar{y}_j dy_i$ si $i<n$ et $\dfrac{2}{n}(y_i-z_i)dy_i$ sinon
Par ailleurs $\frac{\partial y_i}{\partial \bar{y}_j}=a_i^jdy_i$
Remarque : Le $dy_i$ est là pour la composée avec $\phi$. C'est $\phi\prime(u)$.
Nous en disposons déjà calculé dans la couche ape->ma[ape->c - 1]->dy
Que Bodin et Recher me le pardonnent, j'ai retourné verticalement dans gimp leur image page 126. Si le sourire se transforme en tristesse, au moins les termes de la fraction $\dfrac{g\prime _*}{g_*}$ apparaissent à leur place. On pourrait appeler la formule «formule sagan» à cause de Bonjour tristesse ☺
$\dfrac{\partial F}{\partial a}=f_*.\dfrac{g\prime _*}{g_*}.\dfrac{\partial F}{\partial b}$
et s'il y a plusieurs sorties
$\dfrac{\partial F}{\partial a}=\sum_{i=0}^{n-1} f_*.\dfrac{g\prime_*}{g_*}.b_i.\dfrac{\partial F}{\partial b_i}$