date mercredi 5 mars 2025
Considérons un réseau neuronal de 2 entrées (inputs), 2 sorties(outputs), une couche d'entrées (inputs layer) , une couche intermédiaire (hidden layer), et une couche de sorties (outputs layer). Les deux premières couches comportent 4 neurones et la dernière compte naturellement 2 neurones. L'affaire se présente ainsi :
couche d'entrées
$y_0 = f(u_0) = f(a_0x_0+a_1x_1+a_2)$
$y_1 = f(u_1) = f(a_3x_0+a_4x_1+a_5)$
$y_2 = f(u_2) = f(a_6x_0+a_7x_1+a_8)$
$y_3 = f(u_3) = f(a_9x_0+a_1x_{10}+a_{11})$
couche intermédiaire
$y_0 = f(u_0) = f(a_{12}x_0+a_{13}x_1+a_{14}x_2+a_{15}x_3+a_{16})$
$y_1 = f(u_1) = f(a_{17}x_0+a_{18}x_1+a_{19}x_2+a_{20}x_3+a_{21})$
$y_2 = f(u_2) = f(a_{22}x_0+a_{23}x_1+a_{24}x_2+a_{25}x_3+a_{26})$
$y_3 = f(u_3) = f(a_{27}x_0+a_{28}x_1+a_{29}x_2+a_{30}x_3+a_{31})$
couche de sorties
$y_0 = f(u_0) = f(a_{32}x_0+a_{33}x_1+a_{34}x_2+a_{35}x_3+a_{36})$
$y_1 = f(u_1) = f(a_{37}x_0+a_{38}x_1+a_{39}x_2+a_{40}x_3+a_{41})$
Dans la couche des entrées les paramètres $x_i$ sont les entrées du réseau.
Dans la couche intermédiaire les paramètres $x_i$ sont les $y_i$ de la couche précédente.
Il en est de même pour la couche des sorties : les $x_i$ sont les $y_i$ de la couche précédente.
Il y a 42 coefficients $a_i$, 4×3 + 4×5 + 2×5.
Pour chaque neurone n d'une couche donnée, $u_n=\sum_{i=0}^{p-1}a\prime_ix_i+a'_p$ et $y_n=f(u_n)$.
La fonction f est la fonction d'activation (sigmoïde, th, ...).
Dans l'exemple ci-dessus on a pour le neurone 0 de la couche intermédiaire,
les coefficients $a'_0$, . . ., $a'_4$ sont à remplacer respectivement par $a_{12}$, . . ., $a_{16}$.
Nous verrons cette substitution se réaliser en C, grâce aux pointeurs.
La fonction d'activation $f$ est vue comme une fonction d'une seule variable $u$. J'écrirai tout bonnement $f'(u)$.
Les dérivées partielles sont extrêmement simples1 par application de la dérivée d'une fonction composée.
$\frac{\partial y_j}{\partial x_i}=a_if'{(u_j)}$, cette dérivée sera très utile pour le calcul du gradient, car hormis pour la couche d'entrée, le $x_i$ n'est autre que le $y_i$ de la couche précédente.
$\frac{\partialy_j}{\partiala_i}=x_if'{(u_j)}$ si $i<p$, et $\frac{\partialy_j}{\partiala_i}=f'{(u_j)}$ sinon.
Le réseau est codé en C comme une liste doublement chainée, une couche d'entrées in
, zéro, une ou plusieurs couches cachées et une couche de sorties out
:
┌────┐ ┌────┐ ┌────┐ ┌────┐
│in │ │hid0│ │hidh│ │out │
│next│→ │next│→ . . . │next│→ │next│→ NULL
NULL ←│back│ ←│back│ ←│back│ ←│back│
└────┘ └────┘ └────┘ └────┘
En effet nous n'aurons pas besoin d'accéder à une couche intermédiaire en particulier, nous aurons à parcourir le réseau depuis la couche in
jusqu'à out
, ou en remontant de la dernière couche à la première.
Cette façon de faire a en plus l'avantage de ménager le caractère mystérieux de ce qui se passe à l'intérieur du réseau.
Remarque : Dans le nom de ma librairie lnn.h, lnn.c
le l
signifie liste (list neural network).
#ifndef _MRD_LNN_H_
#define _MRD_LNN_H_
#include <stdlib.h>
#include <stdio.h>
#define DEV
// layer
struct layer {
int n, p; // number of neurons by layer, number of args
// pointers only
double *a, *x, *y, *dy, *dz;
double (*f)(double), (*df)(double);
struct layer *back, *next;
#ifdef DEV
int num;
#endif
};
// neural network
struct lnn {
// inputs, outputs, hidden layer, neurons by layer, weights, total neurons
int i, o, h, n, A, N;
// double chained list {in} ⟷ ... ⟷ {out}
struct layer *in, *out; // the first and the last layer
double *a, *g; // arrays of weights, gradient (dim = A)
double *y, *dy, *z, *dz; // arrays: outputs, ∂y/∂u, ∂z/∂y (dim = N)
};
// activation
enum act {sgmd, hvsd, relu, lin, th};
void lnn_set_act(struct layer *lp, enum act f);
#endif
C'est la structure du réseau. Elle contient toutes les données.
Les entiers i, o, h, n, A
sont respectivement le nombre d'entrées, le nombre de sorties, le nombre de couches cachées, le nombre de neurones par couche (sauf la dernière) et la dimension des vecteurs a
et g
. Ce dernier est calculé par le programme.
struct layer *in, *out;
sont deux pointeurs sur les couches d'entrées et de sorties, qui sont les deux extrémités de la liste chainée. La struct layer
sera détaillée dans le paragraphe ci-dessous.
double *a, *g;
sont des tableaux de dimension A
des coefficients (weights and bias) et des composantes du gradient.
double *y, *dy, *z, *dz;
sont les tableaux contenant respectivement $y=f(u)$, $∂y/∂u$, les sorties, et $∂z/∂y$. A vrai dire z
est un pointeur sur les sorties.
Bien qu'elle charpente le programme, cette structure ne contient que deux données propres : le nombre de neurones n
et le nombre de paramètres p
. Ce dernier est le nombre des $x_i$ du neurone. Le nombre de coefficients du neurone, les poids plus le biais est alors $p+1$.
Le reste sont des pointeurs.
a
pointe dans le tableau sur le premier coefficient de la couche dans le tableau de même nom dans la structure lnn
.
y, dy, dz
pointent respectivement dans les tableaux de même nom de struct lnn
et fournissent l'adresse du sous tableau pour chaque neurone de la couche.
z
pointe lui sur l'adresse du tableau des sorties dans out->y
. Ça c'est la magie des pointeurs du C et de son dérivé le C++. C'est la raison pour laquelle j'ai abandonné certains langages comme python et que ne code plus qu'enC et très occasionnellement en js.
Elles sont du niveau de la classe de première, pourvu qu'on esquive la notion de fonction de plusieurs variables, en précisant clairement la variable et les constantes de la fonction, vue comme une fonction d'une seule variable.