*Ce guide a été écrit notamment grâce à la lecture des livres [Quand la machine apprend: La révolution des neurones artificiels et de l'apprentissage profond](https://amzn.to/3yUkWCM) et surtout [What Is ChatGPT Doing... And Why Does It Work?](https://amzn.to/3xkZAhg).* ## Introduction Comme cela a été expliqué à de nombreuses reprises ce que ChatGPT fait, c’est d’essayer de produire un texte qui soit une suite de mots raisonnablement cohérents - Cohérent signifiant ici que le texte produit par le modèle est similaire à ce que l’on pourrait attendre d’un humain qui aurait lu des millions de pages web. Le LLM que vous utilisez a "lu" des millions de pages web. D’un point de vue très simplifié, quand il commence la phrase “*Le chat*” et veut trouver le mot suivant, il va regarder le mot qui vient le plus souvent après "*Le chat*" dans tout ce qu’il a lu et va le proposer. Cela va donner le mot “*noir*” par exemple. Fondamentalement, pour chaque nouveau mot qu'il doit écrire, le système se repose la question “*étant donné le texte écrit jusqu’à présent, quel devrait être statistiquement le prochain mot ?*”. L’idée est donc d’avoir un modèle qui permette d’estimer les probabilités avec lesquelles des séquences de mots devraient se produire, même si ces séquences n’ont jamais été explicitement vu dans le corpus de texte que nous avons utilisé. ## Qu’est-ce qu’un modèle ? Imaginons, comme Galilée, que vous vouliez savoir combien de temps met un boulet de canon lâché d’un étage particulier de la tour de Pise pour atteindre le sol. La solution la plus simple est de mesurer le temps que cela prend depuis chacun des étages. L’autre façon de faire, c’est de modéliser le problème afin de trouver une fonction mathématique qui, à partir de paramètres (ici l’étage de départ), va vous donner un résultat (ici le temps de chute du boulet). Nous allons donc commencer par réaliser quelques mesures et les représenter sur un graphique : ![[modelisation_etape_1.png]] À partir des mesures reportées sur le graphique ci-dessus, comment trouver une fonction qui va nous donner la valeur pour les étages où nous n’avons pas réalisé de mesures ? On peut commencer par tracer une droite qui passe au plus près de tous les points que nous avons mesurés : ![[modelisation_etape_2.png]] La ligne droite donne un résultat qui est assez proche de la réalité, mais il y a des points qui sont un peu loin de la droite. En essayant une formule mathématique plus compliquée (`a + b x + c x2`), on arrive à quelque chose de mieux, c’est-à-dire plus proche de la réalité : ![[modelisation_etape_3.png]] On peut voir que tout modèle que vous utilisez possède une structure sous-jacente particulière, puis un ensemble de paramètres que vous pouvez définir pour qu’ils collent le plus possible à vos attentes. Dans le cas de ChatGPT, il y a 175 milliards de paramètres. ## Modéliser des tâches humaines Pour modéliser des tâches humaines comme la reconnaissance d'image, c’est le même principe sauf qu’il n’existe pas de formule mathématique simple pour cette modélisation. Imaginons, par exemple, que nous souhaitions faire de la reconnaissance d’images sur des chiffres comme ceux-ci : ![[modelisation_image_1.png]] De la même façon que pour le boulet de canon, nous allons commencer par collecter différents exemples de chiffres écrits à fin de bâtir notre modèle, par exemple, pour le chiffre 4 : ![[modelisation_image_2.png]] Rien qu’avec l’exemple ci-dessus, nous voyons qu’une comparaison pixel à pixel n’est pas la bonne solution pour reconnaître des chiffres. Qui n’a jamais écrit un chiffre “*7*” qui ressemble au chiffre “*1*” ! De plus, nous souhaitons modéliser une activité humaine, et les humains, pour reconnaître des choses, ne font pas de comparaison pixel par pixel. Alors comment faire ? Nous allons essayer de trouver une fonction qui, à partir des pixels de l’image, va nous donner la probabilité que l’image représente un chiffre particulier. Nous allons donc considérer la valeur du niveau de gris de chaque pixel comme une variable *X* et nous allons trouver une fonction qui, une fois évaluée avec toutes les valeurs de *X* de chaque pixel, nous indique de quel chiffre se trouve l’image… De la même façon que nous avons trouvé une fonction qui détermine, pour chaque étage, le temps que mettait le boulet de canon à tomber. On aura donc une fonction mathématique, avec, en entrée, les valeurs de niveau de gris des différents pixels de l’image. On pourra prendre une image, sur celle-ci, nous exécuterons notre fonction (*qui devrait opérer environ un demi million d’opérations mathématiques*) et cette fonction nous donnera en sortie le chiffre correspondant à l’image. ## Réseaux de neurones Essayons maintenant de comprendre comment les modèles fonctionnent. Pour cela, nous allons nous intéresser à un modèle particulier : le réseau de neurones. Dans le cerveau, il existe environ 100 milliards de neurones. Chaque neurone est connecté à approximativement 10 000 autres neurones. Chacun d’entre eux peut produire un signal électrique qui est transmis à d’autres neurones. Chaque signal dépend des signaux reçus par les neurones connectés. Quand on voit une image comme notre “*4*” ci-dessus, les photons de la lumière frappent la rétine de nos yeux et génèrent un signal électrique qui est transmis aux cellules nerveuses. Le signal va ensuite se propager à travers plusieurs couches de neurones. C’est ce processus qui va former une pensée dans notre cerveau et qui va nous permettre de dire : “*C’est un 4*”. ### Les attracteurs Un concept à comprendre avec les réseaux de neurones est celui d’attracteur. Un attracteur peut être vu comme un “*point de stabilité*” ou une “*zone de stabilité*” dans l’espace des états d’un système dynamique. Ce sont des états vers lesquels un système tend à évoluer, indépendamment de sa situation initiale. ![[attracteur_image_1.png]] Si l’on veut se les représenter, regardez le dessin ci-dessus. Les points oranges sont les attracteurs, si vous lâchez quelque chose dans l’une des régions proche d’un attracteur, cette chose va tendre à se rapprocher de ce point. Si l’on reprend notre exemple de reconnaissance de chiffres, on peut imaginer que les attracteurs sont les différents chiffres que votre modèle peut reconnaître. Quand vous présentez une nouvelle image à votre réseau de neurones, il va “positionner” l’image dans une région et ensuite la faire tendre vers l’attracteur le plus proche, pour vous indiquer quel chiffre est sur votre image. Dans le schéma ci-dessus, on voit que les attracteurs sont des points dans un espace à 2 dimensions. Dans notre exemple de reconnaissance de chiffre, nous avons une image de 28 par 28 pixels, nous allons donc avoir un espace à 784 dimensions, notre image représentant un point dans cet espace, le chiffre deviné par le réseau de neurones étant l'attracteur le plus proche. ### Cas concret avec un réseau de neurones Prenons un cas très simple : ![[reseau_neurones_1.png]] L’idée est la suivante : si l’on nous donne un point quelconque avec des coordonnées _{x, y}_, notre modèle doit trouver de quel point orange il est le plus proche. Le résultat doit correspondre au graphique ci-dessous: ![[reseau_neurones_2.png]] Revenons au réseau de neurones. Un réseau de neurones, ça ressemble au schéma ci-dessous. On y voit des neurones, organisés en couches. Chaque neurone est connecté à tous les neurones de la couche précédente et de la couche suivante. ![[reseau_neurones_3.png]] Chaque neurone va calculer une valeur avec une fonction mathématique. Pour notre problème, dans le réseau de neurones que nous allons construire, nous allons simplement entrer nos coordonnées _{x, y}_ dans les deux premiers neurones du réseau et laisser chaque neurone faire son calcul et passer le résultat à la couche suivante. Le résultat final sera donné par le dernier neurone. Bien sûr, mettre en place le réseau de neurones au hasard n’est pas suffisant, les résultats seront aléatoires. Il va falloir le “calibrer” pour qu’il donne les résultats que nous attendons, c’est ce que l’on appelle l’**entrainement**. ![[reseau_neurones_4.png]] Comme nous pouvons le voir sur le schéma ci-dessus, chaque neurone reçoit des valeurs de plusieurs neurones de la couche précédente et chaque connexion a un “poids” qui va influencer le résultat en sortie - ceci permet de modifier “l’importance” de chacune des entrées. Le “calibrage” du réseau de neurones consiste à trouver les bons poids pour chaque connexion afin que le résultat final soit bon dans la majeure partie des cas d’apprentissage présentés. La valeur d’un neurone donné est calculée en multipliant les valeurs des « neurones précédents » par leurs poids correspondants et en les additionnant. On se retrouve donc à multiplier des matrices. Enfin, à “l’*intérieur*” du neurone, on applique une fonction de "*seuil*" (ou "*d’activation*") qui, comme vous pouvez le voir dans les exemples ci-dessous, va transformer une valeur obtenue en entrée en une valeur de sortie. C’est cette fonction qui va donner la valeur finale du neurone. ![[reseau_neurones_5.png]] Les poids des connexions entre les neurones sont les paramètres du modèle, ils vont êtres calibrés en utilisant les résultats que nous souhaitons obtenir. Si l’on reprend notre exemple de reconnaissance de chiffres, nous allons devoir : - Entrer une image dans le réseau de neurones. - Laisser le réseau de neurones faire ses calculs. - Comparer le chiffre trouvé par le réseau de neurone avec le chiffre que nous attendions. - Modifier les poids des connexions pour que le résultat obtenu soit plus proche de ce que nous attendions. - Recommencer avec une autre image. Revenons à notre exemple, avec un seul neurone, voici ce que nous pouvons générer comme résultat : ![[reseau_neurones_6.png]] Comme vous le voyez, nous n’arrivons pas à générer un résultat qui ressemble à ce que nous attendons. Par contre, si nous rajoutons des neurones et refaisons l’entrainement, nous arrivons à cela, c’est mieux : ![[reseau_neurones_7.png]] Généralement, plus le réseau est gros, plus il est capable de faire une approximation précise de la fonction que nous recherchons comme on peut le voir ci-dessous. ![[reseau_neurones_8.png]] ## L’entrainement des réseaux de neurones L’avantage des réseaux de neurones est que l’on ne les programme pas, on ne va pas essayer de coder une fonction qui va chercher la barre du 7, la boucle du 8, etc. Au lieu de cela, nous allons lui montrer plein d’exemples des résultats que l’on souhaite obtenir et il va “généraliser” les exemples qu’on lui fournit (on revient à notre idée de trouver une formule mathématique qui modélise la réalité). L’entrainement, concrètement, consiste à trouver les valeurs des poids des connexions entre les neurones qui vont permettre de reproduire les exemples qu’on lui a donnés pendant l'entrainement. Prenons un exemple très simple, essayons d’apprendre à un réseau de neurones à reproduire la fonction ci-dessous : ![[entrainement_1.png]] Voici le réseau de neurones utilisé : ![[entrainement_2.png]] Si l'on met des poids au hasard, le réseau de neurones va calculer différentes fonctions qui ne sont pas du tout ce que l’on souhaite : ![[entrainement_3.png]] Pour l’entraîner, l’idée est de fournir beaucoup d’exemples de ce que l’on souhaite obtenir et essayer de trouver les poids qui vont permettre de reproduire ces exemples. ![[entrainement_4.png]] On peut voir qu’à chaque étape de l’entrainement, le réseau de neurones se rapproche de la fonction que nous souhaitons, mais comment sont ajustés les poids ? L’idée ici est de se demander à chaque étape “*à quel point je suis éloigné du résultat que je souhaite ?*”. C’est là qu’intervient la fonction de perte ou de coût. Elle est la quantification de l’écart entre les prévisions du modèle et les observations réelles du jeu de donnée utilisé pendant l’entraînement. Elle nous donne la distance entre les valeurs que nous avons et les valeurs que nous voulons. Imaginons un instant que l’on ait deux poids *w1* et *w2* et que l’on ait une fonction de perte qui ressemble à cela : ![[entrainement_5.png]] Nous allons essayer de trouver les valeurs de *w1* et *w2* qui vont minimiser la fonction de perte. Pour cela, nous allons suivre le chemin de la descente la plus raide à partir des w1 et w2 précédents que nous avons eus: ![[entrainement_6.png]] Au début de notre entrainement, nous avons des poids choisis au hasard, nous allons donc avoir une fonction de perte avec une valeur certainement très élevée. On peut voir cette fonction de coût comme une sorte de paysage montagneux : un endroit particulier correspond à un ensemble de valeurs pour les poids, et la hauteur de cet endroit correspond à la perte que nous avons pour ces valeurs de poids. Entraîner un réseau de neurones, c’est essayer de trouver le point le plus bas de ce paysage, c’est-à-dire les valeurs de poids dans les réseaux de neurones qui vont minimiser la fonction de perte. C’est ce que l’on appelle la **descente de gradient** : On va suivre le chemin le plus raide pour descendre le plus vite possible. Si l’on reprend la métaphore de la montagne, nous faisons, à partir du point où nous sommes, un pas dans une direction pour savoir si le pas que l'on vient de faire nous fait monter ou descendre… et on avance comme cela jusqu’à ce que l’on ne puisse plus descendre plus bas. ## L’entrainement de réseaux de neurones en pratique Tout d’abord, nous ne sommes pas sur une science exacte, on est en mode essais-erreurs-corrections avec un ensemble de concepts et d’astuces qui ont été développées au fil du temps. La première question à se poser, quelle taille doit avoir mon réseau de neurones pour une tâche donnée ? En fait, c’est assez difficile à estimer même si plus une tâche est complexe, plus le réseau de neurones semble devoir être grand. Dans l’exemple ci-dessous, il est facile de voir que si le réseau est trop petit, il est impossible de reproduire la fonction que nous cherchons. Mais attention, si le réseau est trop grand, il va reproduire la fonction que nous cherchons, mais aussi les “*trucs bizarres*” que nous avons dans nos données d’entrainement. ![[entrainement_pratique_1.png]] Maintenant que nous avons choisi une architecture pour notre réseau de neurones, il va falloir récupérer des données pour l'entrainement. Il va falloir les nettoyer, les préparer, les transformer, les augmenter, etc. L’autre question qui se présente alors est “*de combien de données ai-je besoin pour entraîner mon réseau de neurones ?*”. Comme pour la taille du réseau, il est difficile de répondre à cette question. Même s’il existe des techniques pour récupérer des connaissances déjà acquises (comme l'apprentissage par transfert), il va globalement falloir beaucoup de données avec, si possible, beaucoup de données répétitives, un peu comme pour les humains qui ont besoin de voir souvent la même information pour qu’elle s’inscrive dans sa mémoire. Et pour ChatGPT ? pour l’entraîner, il "suffit" de prendre beaucoup de textes et de masquer la fin des phrases, ceci lui servira d’entrées à partir de laquelle s’entraîner, la fin des phrases étant les sorties attendues. Comme nous l’avons vu, l’objectif de l’entrainement est de trouver les poids (aussi appelés paramètres) qui vont permettre d’obtenir les bonnes sorties pour les bonnes entrées avec la fameuse fonction de perte. Généralement, celle-ci va baisser puis stagner. Si elle stagne et que l’on est à un point assez bas, c’est que l’on a atteint un minimum local et que l’on a fini l’entrainement. Si elle stagne et que l’on est à un point assez haut, c’est qu’il y a quelque chose qui ne va pas. ## Les embeddings Nous l’avons vu, les réseaux de neurones sont basés sur des nombres, nous avons donc besoin de représenter les mots par des nombres. On pourrait choisir un nombre au hasard pour chaque mot, mais l’idée, fondamentale chez ChatGPT, est de représenter l’essence d’un mot comme “*pomme*” par un tableau de nombres - Et si ce mot est proche, en terme de sens, d’un autre mot comme “banane”, les valeurs du tableau de nombres du mot “*banane*” seront proches des valeurs du tableau de nombres de “*pomme*”. C’est ce que l’on appelle un embedding. On peut voir cela comme une tentative de représenter un “*espace de signification*” dans lequel les mots sont placés sur une carte les uns à proximités des autres en fonction de leur signification. Si on essayait de faire une carte en seulement deux dimensions, cela donnerait ça : ![[embedding_1.png]] Comme vous pouvez le constater, les animaux sont dans un coin, les fruits dans un autre, le chien et le chat sont proches comme l’alligator et le crocodile. Ceci est généré assez simplement en lisant d’énormes quantités de texte et en regardant à quel point des mots apparaissent dans des contextes similaires. Par exemple, comme “*alligator*” et “*crocodile*” vont apparaître souvent de manière presque interchangeable dans des phrases par ailleurs semblables, on va les mettre “à côté”. Pour comprendre comment sont calculés ces embeddings, nous allons reprendre l’exemple de la reconnaissance de chiffres et nous allons nous voir comment trouver les “mots” qui sont similaires. Ce que l’on a fait au tout début de cet article, si l'on simplifie à l’extrême, c’est créer un réseau de neurones qui va ranger les images qu’on lui présente dans une des dix boites représentants les chiffres de 0 à 9. Nous avons aussi vu que les réseaux de neurones sont organisés en couches, mais que se passe-t-il si on regarde les valeurs des neurones des couches avant d’arriver à la couche finale (celle qui décide de quel chiffre il s'agit) ? Ces valeurs non finales devraient conceptuellement représenter quelque chose qui signifie “*Plutôt un 4, mais ressemble un peu à un 2*” - Et bien, ce sont ces valeurs que l’on va utiliser pour calculer notre embedding. Cette façon de faire permet de ne pas avoir à définir ce qu’est la proximité entre deux chiffres ou deux mots, on va laisser le réseau de neurones faire son travail et utiliser les couches précédentes pour voir ce qu’il décide lui classer comme similaire. Si l’on veut se représenter le résultat, cela donnerait ça, dans un espace à trois dimensions : ![[embedding_2.png]] Si l’on veut faire la même chose pour les mots, on va faire de la prédiction de mot. Si l’on part sur la phrase “*le _ chat*”, on va essayer, en utilisant beaucoup de textes existants, de calculer la probabilité que différents mots puissent remplir le blanc que l’on a laissé dans notre phrase. On va donc affecter un nombre à chacun des mots du dictionnaire, ce qui donnera par exemple, pour notre phrase “*le = 405*” et “*chat = 9782*”. Nous allons ensuite calculer pour chacun des autres mots du dictionnaire, la probabilité de le trouver au milieu de notre phrase et nous allons intercepter l’intérieur des couches du réseau de neurones, juste avant qu’il n’arrive à sa conclusion. Les probabilités qu’il a trouvés pour chaque mot vont être récupérés à cet instant. ## Les transformers ChatGPT est un réseau de neurones avec 175 milliards de poids (paramètres) mais il est spécialisé pour traiter le langage grâce à quelque chose particulier : les **transformers**. Les transformers introduisent la notion d’attention. L’attention est une opération qui permet de donner plus ou moins de poids à chaque mot de la phrase en fonction des autres mots de la phrase. Comment cela va fonctionner : - À partir d’une phrase donnée (“*le chat est _*”), on va trouver un embedding (ensemble de chiffres) qui représente les mots de la phrase. - Nous allons utiliser ces chiffres pour produire un nouvel embedding. - À partir de ce nouvel embedding, on va calculer la probabilité de trouver chaque mot du dictionnaire à la fin de notre phrase. L’attention va permettre de “regarder de plus près” certains mots de la phrase leur donner plus de poids produire le nouvel embedding. Dans ChatGPT, Chaque mot est effectivement représenté par un tableau de nombres que nous pouvons considérer comme les coordonnées d’un point dans une sorte "*d’espace linguistique*". Et lorsque ChatGPT complète une phrase, cela correspond à tracer une trajectoire dans cet espace. ![[transformer_1.png]] Prenons deux phrases : - “*La souris est dans le jardin*” - “*La souris est à côté de l’ordinateur*” Les transformers sont capables de distinguer les deux usages du mot “*souris*” en se basant sur le contexte fourni par les autres mots de la phrase, et ainsi d’assigner à chaque occurrence du mot “*souris*” un embedding différent qui capture son sens spécifique dans la phrase. De cette façon, le mot “*souris*” dans la phrase “*La souris est dans le jardin*” sera rapproché du mot “*jardin*”. De manière équivalente, dans la phrase “*La souris est à côté de l’ordinateur*”, le mot “*souris*” sera rapproché du mot “*ordinateur*”. De cette manière, le mot modifié “*souris*” dans chacune des deux phrases contiendra certaines informations des mots voisins, y ajoutant ainsi du contexte. Le schéma ci-dessous montre le fonctionnement des transformers avec deux phrases “the bank of the river” et “money in the bank”. On peut voir que le mot “*bank*” est déplacé grâce aux autres mots de la phrase. ![[transformer_2.png]] Et voilà, ceci n'est bien sûr qu'une introduction à tous ces concepts !