Skip to content
The loss curve

Chapitre 4 · 16 min

Donner du sens aux mots

Transforme les ids de tokens en vecteurs, entraîne de petits embeddings skip-gram et ajoute les premiers poids appris à ton projet local.

Au chapitre 3, tu as entraîné un . Il te rend des entiers : par exemple 17 pour king, 248 pour queen. Le modèle ne sait toujours pas que ces deux entiers ont un rapport.

La façon naïve de donner ces entiers au modèle est le : le 17 devient un vecteur de 50 000 zéros avec un seul 1 à la position 17. Ça marche mécaniquement, mais c’est gaspilleur, et le produit scalaire entre deux vecteurs différents vaut toujours zéro. Pour le modèle, king et queen ne sont pas plus proches que king et bicycle.

On veut que chaque vive dans un espace continu, et que la géométrie de cet espace signifie quelque chose. La technique qui rend ça possible est : pour chaque mot, on pousse son vecteur et ceux de ses voisins à se rapprocher au sens du produit scalaire.

1. Construire les paires (centre, contexte)

s’entraîne sur des paires : pour chaque position du , chaque voisin dans une fenêtre fixe devient un exemple positif.

Code · JavaScript

Cette liste est tout le dataset . Une fenêtre plus large donne plus de contexte à chaque mot.

2. Une étape de SGD

Pour une paire positive (center, context), on veut que le produit scalaire u · v augmente, avec u = E[center] et v = E[context]. On pousse les deux vecteurs l’un vers l’autre.

=σ(uv)1,uuηv,vvηu\nabla = \sigma(u \cdot v) - 1, \quad u \leftarrow u - \eta\,\nabla\,v, \quad v \leftarrow v - \eta\,\nabla\,u

Code · JavaScript

La barre du produit scalaire doit grandir après l’update. C’est tout le signal d’ : prendre une paire que le dit proche, et rapprocher ses vecteurs.

3. Entraîner et visualiser

On chaîne : construire les paires, initialiser des aléatoires, lancer des milliers de pas . À chaque pas, on prend une paire au hasard. Après assez d’itérations, les mots utilisés dans des contextes similaires finissent proches en 2D.

Code · JavaScript

Regarde le scatter. Avec 3000 itérations sur un petit , tu devrais voir des regroupements : man/woman, boy/girl, king/queen. C’est bruité, parce que le est minuscule, mais la forme est la bonne : les mots utilisés dans des contextes similaires ont des vecteurs similaires.

4. Similarité cosinus

Une fois les vecteurs appris, “à quel point ces mots se ressemblent” devient “à quel point leurs vecteurs sont alignés”. La mesure standard est la :

cos(a,b)=abab\cos(a, b) = \frac{a \cdot b}{\|a\|\,\|b\|}

1 signifie même direction, 0 orthogonal, -1 opposé. On ignore souvent la magnitude : c’est la direction qui porte le sens.

Code · JavaScript

Essaie quelques mots. Avec assez d’itérations, tu devrais voir des voisins plausibles. Ne t’attends pas à l’analogie magique king - man + woman ≈ queen sur ce jouet : il faut des milliards de pour ça.

Ce que ça corrige

On a corrigé “tous les mots distincts sont sans rapport”. Le modèle peut maintenant dire : “ce vit dans le même voisinage que ceux-là”.

Mais tout n’est pas réglé :

  • Vecteurs statiques. bank rivière et bank banque partagent le même vecteur.
  • Pas d’ordre des mots. agrège des voisinages.
  • Pas encore de prédiction. On a décoré les avec des vecteurs ; on n’a pas encore construit un modèle qui les utilise pour prédire.

5. Ajouter les embeddings à my-llm/

Crée llm/embeddings.py :

"""Tiny embedding helpers before we switch to PyTorch."""
from __future__ import annotations
 
import math
import random
 
 
Vector = list[float]
Matrix = list[Vector]
 
 
# [1]
def init_embeddings(vocab_size: int, dim: int, scale: float = 0.01) -> Matrix:
    return [
        [random.uniform(-scale, scale) for _ in range(dim)]
        for _ in range(vocab_size)
    ]
 
 
# [2]
def dot(a: Vector, b: Vector) -> float:
    return sum(x * y for x, y in zip(a, b))
 
 
def sigmoid(x: float) -> float:
    return 1.0 / (1.0 + math.exp(-x))
 
 
def skipgram_step(E: Matrix, center: int, context: int, lr: float = 0.1) -> float:
    u = E[center][:]
    v = E[context][:]
    # [3]
    pred = sigmoid(dot(u, v))
    # [4]
    grad = pred - 1.0
 
    # [5]
    for i in range(len(u)):
        E[center][i] -= lr * grad * v[i]
        E[context][i] -= lr * grad * u[i]
 
    return -math.log(max(pred, 1e-9))
 
 
# [6]
def cosine(a: Vector, b: Vector) -> float:
    denom = math.sqrt(dot(a, a)) * math.sqrt(dot(b, b))
    return 0.0 if denom == 0 else dot(a, b) / denom

Lis ce fichier comme le plus petit système apprenant possible :

  • [1] init_embeddings crée une table : une ligne par .
  • [2] dot score l’alignement de deux vecteurs.
  • [3] sigmoid(dot(u, v)) transforme ce score en nombre entre 0 et 1.
  • [4] grad = pred - 1.0 est l’erreur sur une paire positive.
  • [5] rapproche les deux vecteurs.
  • [6] cosine sert à inspecter, pas à .

Plus tard, PyTorch portera la table et les . Pour l’instant, ce fichier fixe le contrat : id de en entrée, vecteur dense en sortie.

Recap

  • Les vecteurs gaspillent de la place et déclarent tous les mots différents comme sans rapport. - Les sont des vecteurs denses par . - les entraîne en rapprochant les paires (centre, contexte). - La mesure l’alignement des . - Ton projet local a maintenant llm/embeddings.py, le premier fichier avec des appris. - Les seuls ne prédisent rien. Les prochains chapitres les branchent dans une fonction qui apprend.

Pour aller plus loin

Prochaine étape : un neurone qui apprend — les nous donnent des entrées. Il faut maintenant une fonction qui transforme ces entrées en sorties et s’améliore.