Skip to content
The loss curve

Chapitre 7 · 15 min

La descente de gradient en direct

Ouvre les optimiseurs, compare SGD, momentum et Adam, puis ajoute l’état de l’optimiseur à ton projet local.

Au chapitre 5, tu as soustrait lr * gradient de chaque et appelé ça . Ça marchait parce que la était gentille et convexe. Au chapitre 6, la même idée entraînait un , mais avec plateaux, oscillations ou divergences.

La raison : la est une façon particulière de naviguer dans un paysage de . Ce n’est pas toujours la meilleure. Il existe toute une famille d’optimiseurs. , l’optimiseur le plus courant en deep learning moderne, est surtout “ avec de la comptabilité en plus”. Cette comptabilité compte.

On utilise une fonction volontairement pénible pour : f(x, y) = ½(x² + 10y²), un bol étiré. La courbure est 10× plus forte en y qu’en x, donc oscille verticalement et avance lentement horizontalement.

1. Le gradient

Pour f(x, y) = ½(x² + 10y²) :

fx=x,fy=10y\frac{\partial f}{\partial x} = x, \qquad \frac{\partial f}{\partial y} = 10y

Écris la fonction. Le chapitre la vérifie sur trois points.

Code · JavaScript

Si tout correspond, on peut descendre.

2. Descente de gradient vanilla

Le pas le plus simple : soustraire lr * <Lexikey term="gradient">gradient</Lexikey> à la position courante.

Code · JavaScript

Regarde la trajectoire. Le minimum est (0, 0). L’optimiseur essaie d’y aller, mais la direction y, trop raide, le fait dépasser, revenir, redépasser. La direction x avance à peine.

C’est la raison classique pour laquelle un seul est difficile à régler.

3. Momentum

La correction : garder une vitesse courante. Chaque pas ajoute une version amortie de la vitesse précédente. Les mouvements qui vont dans le même sens se renforcent ; les oscillations se compensent.

vμvηf,xx+vv \leftarrow \mu v - \eta\,\nabla f, \quad x \leftarrow x + v

Code · JavaScript

Avec à 0.9, la trajectoire doit être plus lisse. À 0.99, elle peut dépasser le minimum et rebondir : trop de vitesse. À 0, tu retrouves vanilla.

4. Adam

lisse la trajectoire, mais garde un même pas pour chaque dimension. maintient une estimation de la moyenne du et de sa variance, dimension par dimension, puis divise le pas par la racine de cette variance.

mβ1m+(1β1)fvβ2v+(1β2)(f)2m^=m/(1β1t),v^=v/(1β2t)xxηm^v^+ϵ\begin{aligned} m &\leftarrow \beta_1 m + (1 - \beta_1)\,\nabla f \\ v &\leftarrow \beta_2 v + (1 - \beta_2)\,(\nabla f)^2 \\ \hat{m} &= m / (1 - \beta_1^t), \quad \hat{v} = v / (1 - \beta_2^t) \\ x &\leftarrow x - \eta \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon} \end{aligned}

Defaults : β₁ = 0.9, β₂ = 0.999, ε = 1e-8. Tape la formule.

Code · JavaScript

doit produire la trajectoire la plus directe vers le minimum. C’est pour ça qu’il est le choix par défaut de beaucoup de modèles modernes : il s’adapte aux bizarreries de surface de .

Pourquoi c’est important

Chaque chapitre à partir d’ici utilise un optimiseur. Le du chapitre 6 a été entraîné en vanilla ; si tu as vu la courbe de hachée, c’est pour ça. Les modernes utilisent (ou AdamW) presque exclusivement. Ce n’est pas un dogme — c’est l’observation empirique que la plupart des paysages de profonds ont la pathologie illustrée par notre bol jouet : des directions de courbure très différentes, dans le même réseau, à la même itération.

5. Ajouter l’état d’optimiseur localement

Crée llm/optim.py :

"""Tiny optimizer updates before we delegate tensors to PyTorch."""
from __future__ import annotations
 
import math
 
 
Vector = list[float]
 
 
# [1]
def sgd(params: Vector, grads: Vector, lr: float) -> Vector:
    return [p - lr * g for p, g in zip(params, grads)]
 
 
def momentum(
    params: Vector,
    grads: Vector,
    velocity: Vector,
    lr: float,
    beta: float = 0.9,
) -> tuple[Vector, Vector]:
    # [2]
    next_velocity = [beta * v - lr * g for v, g in zip(velocity, grads)]
    return [p + v for p, v in zip(params, next_velocity)], next_velocity
 
 
# [3]
def adam(
    params: Vector,
    grads: Vector,
    m: Vector,
    v: Vector,
    step: int,
    lr: float = 3e-4,
    beta1: float = 0.9,
    beta2: float = 0.999,
    eps: float = 1e-8,
) -> tuple[Vector, Vector, Vector]:
    next_m = [beta1 * mi + (1 - beta1) * g for mi, g in zip(m, grads)]
    next_v = [beta2 * vi + (1 - beta2) * g * g for vi, g in zip(v, grads)]
    # [4]
    m_hat = [mi / (1 - beta1**step) for mi in next_m]
    v_hat = [vi / (1 - beta2**step) for vi in next_v]
    # [5]
    next_params = [
        p - lr * mh / (math.sqrt(vh) + eps)
        for p, mh, vh in zip(params, m_hat, v_hat)
    ]
    return next_params, next_m, next_v
  • [1] sgd n’a aucune mémoire.
  • [2] momentum transporte une vitesse.
  • [3] adam transporte deux mémoires : direction moyenne m, taille quadratique moyenne v.
  • [4] corrige le biais des premiers pas.
  • [5] divise par sqrt(v_hat) pour réduire les pas dans les dimensions aux énormes.

PyTorch gérera bientôt cet état. L’écrire une fois rend la boîte noire moins noire.

Recap

  • vanilla est l’optimiseur le plus simple. Il galère quand les dimensions ont des courbures très différentes. - lisse la trajectoire en accumulant une vitesse. Même formule de pas plus un terme de ; un de plus. - divise le pas de chaque dimension par la racine de la variance de son . adaptatif par dimension. La correction de biais compte au démarrage. - Pourquoi gagne par défaut : dans les vrais réseaux, les dimensions de ont des magnitudes de très différentes. Un lr global est un compromis ; supprime le compromis. - Ton projet local a maintenant llm/optim.py, le même contrat d’optimiseur que celui utilisé partout ensuite.

Pour aller plus loin

Prochaine étape : fin de la partie II. La partie III commence avec une tête d’ — le mécanisme qui permet à un de réellement regarder les autres , au lieu d’être limité à un de taille fixe.