# AP5 · Faire diminuer une erreur


```elixir
Mix.install([
  {:nx, "1.0.0"},
  {:axon, "0.9.0"},
  {:polaris, "0.2.0"},
  {:kino_vega_lite, "0.1.13"}
])
```


## La question de l'atelier

Nous avons choisi une formule et mesuré son erreur. Comment modifier ses
paramètres sans essayer toutes les valeurs possibles ? Nous allons dériver
une perte et écrire une étape de descente. Nous vérifierons d'abord que le
modèle apprend une règle affine, puis nous changerons seulement les réponses
attendues. Si l'apprentissage bloque alors, que pourrons-nous en déduire ?

> **Repères** : [slides 4–14](https://ap-2026.noce.fr/slides/AP5.html#4).
> [Poly, chap. 8 « Des calculs qui apprennent »](https://ap-2026.noce.fr/poly/poly-elixir.pdf).
> AP4 : formes `{n, 2}` et `{n, 1}`, score affine dans un `defn` · dans cet atelier : `grad`, une map rendue par un `defn`, `Enum.reduce/3` sur un intervalle `1..80`, `Nx.all_close`, `Nx.broadcast` · dérivées de fonctions de plusieurs variables.

Travaillez dans Livebook : le bouton d'exécution des pages du site ne charge
pas Nx. La première cellule, ci-dessus, installe les bibliothèques avec
`Mix.install` ; elle est identique dans les six ateliers et demande Internet à
la première ouverture, le temps de télécharger et de compiler les dépendances.
Ensuite, tout reste local : les points sont fabriqués dans le carnet et les
calculs tournent sur le processeur, sans GPU.

La cellule suivante prépare deux choses. `import ExUnit.Assertions` rend
disponible `assert`, qui lève une erreur quand sa condition est fausse et ne
dit rien sinon : c'est ainsi que nous vérifierons chaque brique.
`Nx.default_backend(Nx.BinaryBackend)` choisit le *backend*, c-à-d. la brique
qui stocke les tenseurs et exécute les opérations ; celui-ci calcule en Elixir
pur, ce qui suffit à nos petits lots. Prédisez avant d'exécuter, puis ajoutez
un cas qui met votre réponse à l'épreuve. Les cellules « À écrire » doivent
être complétées avant leurs vérifications.


```elixir
import ExUnit.Assertions
Nx.default_backend(Nx.BinaryBackend)
```


## Une première règle que le modèle peut apprendre

Nous reprenons les quatre coins, de façon symétrique, pour pouvoir vérifier
les calculs à la main. Pour commencer, les réponses attendues sont données
par `2*x₁ + x₂ + 0.5` : ce sont des nombres, pas encore des classes, et notre
score affine peut représenter exactement cette règle. Quels poids et quel
biais devrait-il retrouver ?

Trois objets sont fixés ici. `x`, de forme `{4, 2}`, contient les
coordonnées ; `affine_targets`, de forme `{4, 1}`, contient une réponse
attendue par ligne ; `params` est la map des *paramètres*, les nombres que
l'entraînement a le droit de modifier. Le gradient sera pris par rapport à
ces paramètres, pas par rapport aux coordonnées ni aux réponses attendues.
Les paramètres de départ restent les mêmes pour tous les essais de ce carnet,
afin que seule la variable étudiée change d'un essai à l'autre.

```elixir
x = Nx.tensor([[-1.0, -1.0], [-1.0, 1.0], [1.0, -1.0], [1.0, 1.0]])
affine_targets = Nx.tensor([[-2.5], [-0.5], [1.5], [3.5]])
params = %{weights: Nx.tensor([0.3, -0.2]), bias: Nx.tensor(0.0)}
```

## À écrire : perte et mise à jour

Reprenez votre formule de `Score.predict/2` d'AP4 dans `Linear.predict/2`.
Écrivez ensuite `loss/3`, la *perte* : la moyenne des carrés des écarts entre
les prédictions et `y`, un scalaire qui dit à quel point le modèle se trompe.
Dans un `defn`, `Nx.mean` et `Nx.pow` s'écrivent comme en AP4.

Enfin, dans `step/4`, `grad(params, fn p -> loss(p, x, y) end)` rend le
*gradient* : une map de même structure que `params`, où chaque dérivée dit
dans quel sens, et à quelle vitesse, la perte varie quand ce paramètre
augmente. Rendez une nouvelle map `%{weights: …, bias: …}` où chaque
paramètre vaut l'ancien moins `rate` fois sa dérivée. `rate` est le *taux
d'apprentissage*, la longueur du pas. À quoi sert le signe moins ?

Pour relier cet appel à la dérivation connue en mathématiques, prenons une
seule observation : `prediction = w*x + b`, puis
`erreur = (prediction - cible)²`. La dérivée par rapport à `w` vaut
`2*(prediction - cible)*x` ; par rapport à `b`, elle vaut
`2*(prediction - cible)`. Pour notre lot, la perte moyenne aussi ces dérivées.
Nx applique ces règles au graphe des opérations, et non à des essais au
hasard de nouvelles valeurs. Vous n'avez pas à programmer ces dérivées à la
main, mais vous devez pouvoir expliquer leur signe sur un exemple.

Dans les vérifications, `Nx.all_close(a, b)` rend le tenseur `1` quand `a`
et `b` sont égaux à une tolérance près ; la dernière ligne contrôle que
`params` n'a pas été modifié par le pas, puisqu'un tenseur, comme toute
valeur Elixir, ne change jamais.

```elixir
defmodule Linear do
  import Nx.Defn
  defn predict(_params, _x), do: raise("Reprenez votre score d'AP4")
  defn loss(_params, _x, _y), do: raise("À écrire")
  defn step(_params, _x, _y, _rate), do: raise("À écrire")
end
```

Vérifiez, puis ajoutez un cas à vous.

```elixir
assert Nx.shape(Linear.predict(params, x)) == Nx.shape(affine_targets)
assert_in_delta Nx.to_number(Linear.loss(params, x, affine_targets)), 4.58, 1.0e-5
next = Linear.step(params, x, affine_targets, 0.1)
assert_in_delta Nx.to_number(Linear.loss(next, x, affine_targets)), 2.9312, 1.0e-5
assert Nx.to_number(Nx.all_close(next.weights, Nx.tensor([0.64, 0.04]))) == 1
assert_in_delta Nx.to_number(next.bias), 0.1, 1.0e-6
assert Nx.to_list(params.weights) == Nx.to_list(Nx.tensor([0.3, -0.2]))
```

## Répéter avec un accumulateur

Un pas rapproche les paramètres de la solution ; il en faut plusieurs.
`Enum.reduce(1..80, params, fn _, current -> … end)` parcourt l'intervalle
`1..80` en transportant un accumulateur : il part de `params`, appelle la
fonction avec le numéro du tour, ignoré par `_`, et l'accumulateur courant,
puis garde le résultat pour le tour suivant. L'accumulateur joue ici le rôle
qu'il jouait dans les fonctions d'AP1. Le lot contient tous nos exemples :
chaque *pas* est donc aussi une *époque*, un passage complet sur les données.

La fonction anonyme `train` repart de `params` à chaque appel : aucune
continuation cachée de l'essai précédent. `IO.inspect(valeur, label: …)`
affiche la valeur avec une étiquette et la rend telle quelle. Comparez les
paramètres appris à votre prédiction.

```elixir
train = fn targets ->
  Enum.reduce(1..80, params, fn _, current -> Linear.step(current, x, targets, 0.1) end)
end
learned_affine = train.(affine_targets)
assert Nx.to_number(Linear.loss(learned_affine, x, affine_targets)) < 1.0e-8
assert Nx.to_number(Nx.all_close(learned_affine.weights, Nx.tensor([2.0, 1.0]))) == 1
assert_in_delta Nx.to_number(learned_affine.bias), 0.5, 1.0e-5
IO.inspect({Nx.to_number(Linear.loss(learned_affine, x, affine_targets)), learned_affine},
  label: "règle affine apprise")
```

## L'essai qui part dans le mauvais sens

Gardez les mêmes paramètres de départ et changez seulement le taux : un seul
pas à `1.2` au lieu de `0.1`. Prédisez si la perte descend ou monte. Une
petite étape suit localement la pente ; une grande étape peut franchir le
minimum et éloigner encore davantage les paramètres. Un essai qui finit en
valeurs non finies, `Inf` ou `NaN`, doit être noté comme un échec, pas comme
un résultat.

```elixir
too_far = Linear.step(params, x, affine_targets, 1.2)
assert Nx.to_number(Linear.loss(too_far, x, affine_targets)) >
  Nx.to_number(Linear.loss(params, x, affine_targets))
{Nx.to_number(Linear.loss(params, x, affine_targets)),
 Nx.to_number(Linear.loss(too_far, x, affine_targets))}
```

## Ne changer que les cibles : les classes alternées

Retrouvons maintenant les classes des quadrants d'AP4 : `1, 0, 0, 1` sur les
quatre coins. Les points, le modèle, les paramètres initiaux, la MSE, le taux
et le nombre de pas sont inchangés ; la fonction `train` reçoit uniquement de
nouvelles cibles. Avant de lancer, écrivez ce que cet essai peut apprendre :
les `assert` de la cellule disent déjà où il aboutit, à vous d'expliquer
pourquoi. `Nx.broadcast(0.5, {4, 1})` fabrique un tenseur de forme `{4, 1}`
rempli de `0.5`.

```elixir
xor_targets = Nx.tensor([[1.0], [0.0], [0.0], [1.0]])
learned_xor = train.(xor_targets)
assert_in_delta Nx.to_number(Linear.loss(learned_xor, x, xor_targets)), 0.25, 1.0e-5
assert Nx.to_number(Nx.all_close(Linear.predict(learned_xor, x), Nx.broadcast(0.5, {4, 1}))) == 1
IO.inspect({Nx.to_number(Linear.loss(learned_xor, x, xor_targets)),
  Nx.to_list(Linear.predict(learned_xor, x))}, label: "plateau sur les classes alternées")
```

## Point de passage : expliquer le plateau

La même mise à jour a appris la règle affine, puis plafonné sur les classes
alternées, avec quatre scores égaux à `0.5`. Expliquez en quelques phrases ce
que cette comparaison permet d'écarter, une erreur dans le pas, par ex., et
pourquoi davantage de pas ne suffiront pas ici. Le socle de l'atelier
s'arrête à cette explication ; les travaux ci-dessous sont des prolongements,
selon le rythme annoncé en séance.

## Prolongement : démontrer le minimum

Sur les quatre coins, développez la perte pour montrer qu'elle vaut
`w₁² + w₂² + (b − 0.5)² + 0.25`. Déduisez son minimum et les paramètres
qui l'atteignent. L'échec vient-il du gradient, du taux ou de la famille
de fonctions choisie ? Ces scores sont des valeurs réelles, pas encore
des probabilités garanties entre 0 et 1.

## Prolongement sans recette : construire votre propre contre-exemple

Inventez quatre autres cibles sur ces mêmes coins. Annoncez si notre modèle
pourra les apprendre exactement, puis vérifiez. Quelle relation entre les
quatre réponses vous permettrait de décider sans entraîner ? Gardez votre
raisonnement, même si votre première prédiction était fausse.

## Prolongement : contrôler une dérivée

Pour le biais, comparez le gradient automatique à la différence centrée
`(loss(b+ε) − loss(b−ε)) / (2ε)` avec `ε=0.001`. Utilisez `Nx.add/subtract`
hors `defn` pour fabriquer les deux maps de paramètres décalées. Pourquoi
prendre ε toujours plus petit n'améliore-t-il pas indéfiniment la
vérification en f32 ?

## Avant de reprendre les slides

Gardez votre code, une prédiction comparée au résultat et un souci expliqué
avec vos mots. Sauvegardez dans `AP5/`. Je préciserai en séance les
étapes à terminer et les modalités du rendu ; les prolongements restent
facultatifs tant qu'ils ne sont pas demandés.

Le [poly](https://ap-2026.noce.fr/poly/poly-elixir.pdf) se consulte quand un bout de syntaxe vous manque : la forme
d'une fonction anonyme, d'un pipe ou d'un motif aux chapitres 1 à 4, Nx et
Axon aux chapitres 7 et 8.

Pour toute question : [aurelien.noce@minesparis.psl.eu](mailto:aurelien.noce@minesparis.psl.eu).
