Réseaux de neurones 3

Ce billet fait partie d’une série qu’il vaut mieux avoir lu avant, mais c’est vous qui voyez.

Nous avons vu que pour jouer avec un neurone, il fallait calculer son potentiel (la somme pondérée des sorties des neurones qui lui sont connectés), puis sa sortie grâce à sa fonction d’activation. Je n’en ai pas encore parlé, mais pour pouvoir modifier les coefficients du réseau, il faut aussi connaître la dérivée de la fonction d’activation du neurone. Idem pour la variable “erreur” dont je parlerai un peu plus tard, pendant la phase d’apprentissage du réseau.

Si vous êtes étudiant et que vous souhaitez travailler sérieusement sur les réseaux de neurones, je vous conseille d’étudier attentivement le code source d’une bibliothèque telle que FANN qu’un lecteur m’a recommandé et qui a l’air très bien. Dans mon cas, je suis partisan d’un travail artisanal qui permet de mieux comprendre les différents mécanismes en jeu. Et puis, j’aime bien le blog de Libon: fabriqué à mains nues, alors…

Pour moi, un neurone, en langage C, c’est donc cela:

Avec cette déclaration, un réseau de neurones peut être le simple tableau suivant: NEUR* neurone[NBMAXNEUR]; où NBMAXNEUR est une constante indiquant le nombre total de neurones (y compris les entrées du réseau).

La création d’un réseau se fera alors de manière dynamique avec un petit programme du type:

Note à moi-même pour plus tard:  ne pas oublier un appel à “free()” pour chaque appel à “malloc()”.

Parmi tous les réseaux de neurones possibles, j’ai choisi de travailler avec un réseau complètement connecté à une seule sortie. Il s’agit du type de réseau possédant le plus de liens possibles entre les neurones.

Il est assez facile à construire:

– le 1er neurone est relié à toutes les entrées du réseau

– le neurone suivant est relié à toutes les entrées du réseau, et à la sortie du premier neurone,

– le Nème neurone est relié à toutes les entrées du réseau, et à la sortie de tous les neurones précédents,

– la sortie du réseau est la sortie du dernier neurone.

Je vous ai fait un petit dessin qui montre ce type de réseau:

Figure 1: Réseau complètement connecté

avec 3 entrées, 3 neurones et une sortie

Dans un réseau de neurones, le cœur du problème, ce qu’il faut rechercher, ce sont les coefficients des liens reliant les neurones entre eux. Le coefficient reliant le neurone j vers le neurone i s’appelle Cij. Par exemple, sur la figure 1, le coefficient reliant 3 à 5 s’appelle C53 (attention au sens).

Pour faire très simple, et suivre la notation utilisée, j’ai choisi une matrice pour stocker les coefficients Cij : double coef[NBMAXNEUR][NBMAXNEUR];

où NBMAXNEUR contient le nombre d’entrées et le nombre de neurones (soit 6 sur la figure ci-dessus). Ainsi, le coefficient C53 est stocké dans coef[5][3]. Ma matrice aura beaucoup de zéros, mais je privilégie la simplicité.

La propagation de l’information au sein du réseau se fait donc de la manière suivante:

La sortie du réseau est donc neurone[NBMAXNEUR-1]->sortie

Les entrées du réseau sont considérées comme des neurones particuliers très simples (pas de liens vers eux, pas de fonction d’activation, potentiel égal à l’entrée).

Le prochain billet sera consacré à l’apprentissage d’un tel réseau de neurones (ie: au calcul des coefficients du réseau). On révisera aussi un peu les fonctions. Il vaut mieux aller doucement.

7 réflexions sur « Réseaux de neurones 3 »

  1. Petit détail, mais qui a son importance : NEUR* neurone[NBMAXNEUR]; ne déclare pas un tableau de neurones, mais un tableau de pointeurs sur des neurones. En quoi ça importe ? Et bien, accéder aux neurones eux-même va demander de déréférencer un pointeur, mais surtout, les neurones seront répartis un peu partout dans la mémoire, et on ne bénéficiera pas de toutes les optimisations matérielles qui consistent à supposer que l'on veut travailler de manière contiguë en mémoire.

    Sinon, comme j'avais dit qu'un C++ de base permettrait de faire la même chose plus simplement, voici un exemple, sur la partie création du réseau (après, promis, j'arrête, sauf si on me relance) :
    // Une bonne fois pour toutes en haut du fichier
    #include
    using namespace std;

    // Pour créer un tableau de NBMAXNEUR neurones :
    vector monTableau(NBMAXNEUR);

    // Le reste du code est globalement inchangé

    (tout s'initialise automatiquement, pas besoin de faire le moindre free à aucun moment).

    • Je préfère travailler avec des pointeurs pour justement essayer d'éviter d'être limité par les tailles très grandes de données que je manipule, même au prix de la fragmentation de la mémoire. C'est pour cela que je préfère un tableau de pointeurs de structures plutôt qu'un tableau de structures.

      Par ailleurs, il est plus performant de passer des pointeurs comme arguments de fonctions, plutôt que les structures elles-mêmes.

      Mais je reconnais qu'il est temps que je me mette au langage C++…

    • Pour l'aspect taille, je ne suis pas certain d'avoir bien compris. Le code proposé initialement gère un tableau de pointeurs sur la pile (limitée à assez peu de mémoire, genre 8Mo pour l'ensemble d'un programme). Donc il possède un limitation de taille plus importante que le vector (qui alloue sur le tas et a accès à la totalité de la mémoire de la machine). En gros, un vector correspondrait à l'écriture C suivante (mais mieux encapsulée) :
      neur *neurones = malloc (N * sizeof(neur));

      La contrainte principale pour le vector est que cette mémoire doit être contiguë (mais c'est aussi un avantage énorme en perfs).

      D'un autre côté, avec un tableau de pointeurs (alloué sur le tas) pour gérer des objets de 8*5 octets (je suppose qu'on est en 64 bits), on utilise 20% de mémoire en plus (pour stocker les pointeurs), et on divise la taille de mémoire contiguë requise par 5, au prix d'un coût de perfs énorme (et en plus on fragmente bien plus la mémoire avec toutes ces allocations, donc je ne suis pas certain qu'on gagne tant).
      Si on veut vraiment faire ça en C++, le plus simple est de remplacer vector par deque (d’ailleurs, on peut tester avec les deux aisément, et voir ce que ça donne, il suffit de changer un seul typedef)

      Pour le passage d'arguments, je suis d'accord, mais c'est un autre débat que la structure des données (et d'ailleurs, le C++ fournit d'autres outils, mais j'essaye d'évangéliser progressivement 😉 ).

    • Pour l'instant, je travaille avec un nombre relativement restreint de neurones, et je rencontre des problèmes de mémoire dès que je passe une valeur limite. Je vais me pencher sur les problèmes de gestion de la mémoire dès que j'aurai un peu plus de temps…

    • Pour compléter ma réponse, je voudrais préciser que j'ai pris certaines habitudes de programmation en langage C:
      J'utilise très peu la mémoire globale du segment data, j'utilise la mémoire locale (la pile) pour les paramètres des fonctions en limitant leur taille, et j'utilise beaucoup la mémoire globale du tas pour pouvoir exploiter au mieux la taille mémoire disponible sur le serveur de calcul que j'utilise. C'est pour cela que je préfère en général créer des tableaux dynamiquement, plutôt que de manière statique. Les différents petits tests que j'ai effectués ne montrent pas de dégradations conséquentes sur les temps d'accès.

  2. Je plussoie sur les commentaires de Loic Joly: l'utilisation des conteneurs génériques du C++ apporte à la fois sécurité et confort d'utilisation (plus besoin de se préoccuper de la gestion mémoire). Les passages d'argument par référence permettent d'éviter les pointeurs. On peut bien sur tout faire en C, mais au final on passe plus de temps à débogguer les problèmes de gestion mémoire que les algos eux-même !

  3. Bonsoir

    merci pour cette approche très pédagogue. est-il prévu une suite ? notamment pour l'apprentissage?
    merci

Les commentaires sont fermés.