# AP5 · Apprendre une transformation


```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

Une droite ne suffit pas. Nous allons apprendre une transformation des deux
coordonnées, puis combiner les nouvelles valeurs. Axon décrit ce calcul et
gère les paramètres ainsi que l'entraînement. Quelles briques reconnaissez-vous
de notre descente manuelle, et lesquelles sont nouvelles ?

> **Repères** : [slides 16–25](https://ap-2026.noce.fr/slides/AP5.html#16).
> [Poly, chap. 8 « Des calculs qui apprennent »](https://ap-2026.noce.fr/poly/poly-elixir.pdf).
> AP5 atelier 1 : paramètres, perte, gradient · dans cet atelier : la capture `&(&1.split < 3)`, `elem/2`, la mise à jour de map `%{recette | clé: …}`, `Enum.zip_with/3` · Axon 0.9 : `input`, `dense`, `activation`, `Loop.trainer`, `Loop.run`, `build`.

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)
```


## Des exemples distincts pour apprendre et évaluer

La préparation ci-dessous est fournie pour garder le temps consacré au
modèle. Retrouvez `for`, les maps et `Enum` de l'atelier 1 d'AP4. Une forme
est nouvelle : `&(&1.split < 3)` abrège `fn p -> p.split < 3 end`, `&1`
désignant le premier argument. La grille de 144 points évite les axes, et
chaque point reçoit une clé `split`, un reste modulo 5 calculé à partir de ses
indices : trois restes sur cinq vont à l'*entraînement*, les exemples qui
ajustent les paramètres ; un à la *validation*, les exemples sur lesquels nous
comparerons nos recettes ; un au *test*, réservé à la mesure finale. Cette
répartition déterministe entrelace les points ; ce petit problème contrôlé ne
représente pas la diversité des données du monde réel.

`tensors/1` rend un tuple `{x, y}` : les coordonnées en `{n, 2}` et les
étiquettes en `{n, 1}`, dans le même ordre. Les paramètres ne recevront que
les coordonnées et les étiquettes du train.

```elixir
defmodule Points do
  def grid do
    for i <- 0..11, j <- 0..11 do
      x = (i + 0.5) / 6 - 1
      y = (j + 0.5) / 6 - 1
      %{x: x, y: y, label: if(x * y > 0, do: 1, else: 0),
        split: rem(7 * i + 11 * j, 5)}
    end
  end

  def features(rows), do: Enum.map(rows, fn p -> [p.x, p.y] end)
  def labels(rows), do: Enum.map(rows, fn p -> [p.label] end)

  def tensors(rows) do
    {Nx.tensor(features(rows), type: :f32), Nx.tensor(labels(rows), type: :f32)}
  end

  def partition(rows, :train), do: Enum.filter(rows, &(&1.split < 3))
  def partition(rows, :validation), do: Enum.filter(rows, &(&1.split == 3))
  def partition(rows, :test), do: Enum.filter(rows, &(&1.split == 4))
end
```

Préparez le train ; nous réservons encore validation et test. `elem(tuple, i)`
lit la case `i` d'un tuple, à partir de 0 : `elem(train, 0)` est donc le
tenseur des coordonnées. Prédisez les deux formes avant d'exécuter.

```elixir
rows = Points.grid()
train_rows = Points.partition(rows, :train)
train = Points.tensors(train_rows)
assert length(rows) == 144
assert length(train_rows) == 87
assert Nx.shape(elem(train, 0)) == {87, 2}
assert Nx.shape(elem(train, 1)) == {87, 1}
```

## Une recette explicite, avant de construire le modèle

Nous avions fixé quatre coins pour vérifier le gradient. Nous prenons
maintenant 87 points d'apprentissage et gardons les autres à part. Voici
aussi ce qui change dans le calcul :

| Brique | Atelier 1 | Cet atelier |
|---|---|---|
| Score et sortie | Transformation affine, valeur réelle. | Transformation affine ou réseau caché, puis sigmoid. |
| Perte | MSE, commode pour vérifier les dérivées. | Entropie croisée binaire (BCE), pour les classes 0/1. |
| Mise à jour | Descente avec un taux fixe. | Adam, qui adapte le pas à l'historique des gradients. |
| Ce qui reste | Prédire, mesurer la perte, dériver, mettre à jour. | Les mêmes étapes, orchestrées par Axon. |

La *sigmoid* écrase un score réel entre 0 et 1, ce qui permet de le lire
comme une probabilité de classe 1. La BCE pénalise une prédiction d'autant
plus qu'elle est confiante et fausse : annoncer `0.9` pour une vraie classe 0
coûte bien plus qu'annoncer `0.6`. Adam est un *optimiseur* : une règle de
mise à jour qui garde un historique des gradients pour adapter le pas, à la
place de notre `rate` fixe. Axon permettrait aussi une sortie affine, la MSE
et la descente simple ; les changements ci-dessus sont nos choix, pas des
obligations de l'outil. Les valeurs d'une MSE et d'une BCE ne se comparent
pas directement. Pour isoler l'effet de la non-linéarité, nous garderons
ensuite sigmoid, BCE et Adam fixes.

Les poids et biais sont les *paramètres*, ajustés par l'entraînement. La
largeur de la couche cachée, l'activation, le taux et le nombre d'époques sont
des *hyperparamètres* : nous les choisissons avant d'entraîner. La *graine*
fixe le tirage aléatoire des paramètres initiaux, pour repartir du même point
à chaque essai.

La recette est une map qui consigne ces choix et la famille du modèle.
`%{recipe | kind: :linear, width: nil, activation: nil}` est la syntaxe de
mise à jour d'une map : elle copie `recipe` en remplaçant les clés citées,
qui doivent déjà exister. Pour `:linear`, largeur et activation cachées
valent `nil`, puisqu'il n'y a pas de couche cachée ; `:none` gardera plus loin
une couche cachée affine sans activation, un contre-exemple utile.

```elixir
recipe = %{kind: :hidden, width: 8, activation: :tanh,
  rate: 0.03, epochs: 200, seed: 42}
linear_recipe = %{recipe | kind: :linear, width: nil, activation: nil}
```

## À écrire : le modèle et son entraînement

Axon décrit un réseau par un pipe de couches. Une entrée se déclare avec
`Axon.input("points", shape: {nil, 2})` ; `nil` laisse varier le nombre
d'exemples. `Axon.dense(layer, k)` ajoute une *couche dense* de `k` unités :
chaque unité calcule une combinaison affine de ses entrées, comme notre score
d'AP4, avec ses propres poids et son propre biais. L'option
`activation: :sigmoid` applique la sigmoid à la sortie de la couche ;
`Axon.activation(layer, activation)` l'applique comme une couche à part.
`tanh` produit des valeurs entre −1 et 1 de façon non linéaire, et c'est cette
non-linéarité, entre les deux couches, qui enrichit la famille de fonctions.
Nous prédirons la classe 1 lorsque la sortie sigmoid atteint 0.5, la classe 0
sinon.

Écrivez deux clauses de `model/1`, reconnues par motif sur la recette : la
recette `:linear` produit une sortie à une unité avec sigmoid ; la recette
`:hidden` ajoute `width` unités avant cette sortie, avec l'activation
demandée. Pour `:none`, gardez simplement la couche cachée telle quelle.
Avec la recette de référence, comptez les paramètres, biais compris, avant
de lancer la vérification.

Dans `fit/3`, utilisez `Axon.Loop.trainer(model, :binary_cross_entropy, optimizer,
seed: recipe.seed, log: 0)`, où `optimizer` est
`Polaris.Optimizers.adam(learning_rate: recipe.rate)` : cet appel assemble
modèle, perte et optimiseur en une *boucle* d'entraînement, `log: 0`
réduisant l'affichage. Lancez-la avec
`Axon.Loop.run(loop, [data], Axon.ModelState.empty(), epochs: recipe.epochs)`,
où `data` est le tuple `{x, y}` du train. `Axon.ModelState.empty()` est un
état vide : chaque appel doit repartir de nouveaux paramètres, tirés avec la
graine, et non de la fin de l'essai précédent. La liste contient un seul lot,
donc une époque correspond ici à une mise à jour ; dans un grand jeu,
plusieurs lots feraient plusieurs pas par époque.

Les fonctions de prédiction et de mesure sont fournies : lisez-les.
`Axon.build(model)` rend deux fonctions, l'initialisation et la prédiction ;
les paramètres appris se trouvent dans `state.step_state.model_state`.
`accuracy/2` compare les classes décidées au seuil 0.5 aux étiquettes et en
fait la moyenne : `Nx.greater_equal` et `Nx.equal` rendent des tenseurs de
0 et de 1, dont la moyenne est la proportion de bonnes réponses, l'*exactitude*.
`loss/2` calcule la BCE moyenne. La perte aide à suivre l'apprentissage ;
l'exactitude compte les classes correctes. Elles répondent à deux questions
différentes.

Dans les vérifications, `Axon.get_op_counts/1` compte les couches par type ;
`Nx.template({1, 2}, :f32)` décrit une entrée par sa forme seule, ce qui
suffit à initialiser les paramètres ; `initial.data` est une map de couches,
chacune une map de tenseurs, et `Nx.size/1` compte les éléments d'un tenseur.

```elixir
defmodule Classifier do
  def model(%{kind: :linear, width: nil, activation: nil}),
    do: raise("À écrire : une couche de sortie")
  def model(%{kind: :hidden, width: _width, activation: _activation}),
    do: raise("À écrire : couche cachée configurable, puis sortie")
  def fit(_model, _data, _recipe), do: raise("À écrire : trainer puis run")

  def probabilities(model, state, x) do
    {_, predict} = Axon.build(model)
    predict.(state.step_state.model_state, x)
  end

  def accuracy(probabilities, labels) do
    probabilities
    |> Nx.greater_equal(0.5)
    |> Nx.equal(labels)
    |> Nx.mean()
    |> Nx.to_number()
  end

  def loss(probabilities, labels) do
    labels
    |> Axon.Losses.binary_cross_entropy(probabilities, reduction: :mean)
    |> Nx.to_number()
  end
end
```

Vérifiez, puis ajoutez un cas à vous.

```elixir
linear_model = Classifier.model(linear_recipe)
model = Classifier.model(recipe)
assert Axon.get_op_counts(linear_model) == %{input: 1, dense: 1, sigmoid: 1}
assert Axon.get_op_counts(model) == %{input: 1, dense: 2, tanh: 1, sigmoid: 1}
{init, _} = Axon.build(model, seed: recipe.seed)
initial = init.(Nx.template({1, 2}, :f32), Axon.ModelState.empty())
count = initial.data |> Map.values() |> Enum.flat_map(&Map.values/1) |> Enum.map(&Nx.size/1) |> Enum.sum()
assert count == 33
state = Classifier.fit(model, train, recipe)
probabilities = Classifier.probabilities(model, state, elem(train, 0))
assert Nx.shape(probabilities) == {87, 1}
assert Classifier.accuracy(probabilities, elem(train, 1)) > 0.85
IO.inspect(Classifier.accuracy(probabilities, elem(train, 1)), label: "exactitude train")
```

## Voir les réponses du modèle

Nous affichons les prédictions sur les points d'entraînement, avec la brique
`Plot` d'AP4. Ce dessin sert à comprendre ce que le modèle a appris, pas à
prouver qu'il généralise : ces points ont servi à l'ajuster.

```elixir
defmodule Plot do
  def points(rows, color) do
    VegaLite.new(width: 360, height: 300)
    |> VegaLite.data_from_values(rows)
    |> VegaLite.mark(:point, filled: true, size: 45)
    |> VegaLite.encode_field(:x, "x", type: :quantitative, scale: [domain: [-1, 1]])
    |> VegaLite.encode_field(:y, "y", type: :quantitative, scale: [domain: [-1, 1]])
    |> VegaLite.encode_field(:color, color, type: :nominal)
  end
end
```

`Nx.to_flat_list/1` aplatit le tenseur `{87, 1}` en une liste de 87
nombres ; `Enum.zip_with(a, b, f)` parcourt deux listes en parallèle et
applique `f` à chaque paire ; `Map.put(p, :prediction, …)` rend une copie de
la map avec une clé de plus. Comparez ce dessin à la règle de départ.

```elixir
predicted_rows = Enum.zip_with(train_rows, Nx.to_flat_list(probabilities), fn p, probability ->
  Map.put(p, :prediction, if(probability >= 0.5, do: 1, else: 0))
end)
Plot.points(predicted_rows, "prediction")
```

## Ce qui ne fonctionne pas en empilant seulement des matrices

Gardez la même recette et remplacez seulement `activation: :tanh` par `:none`.
Avant de lancer, composez deux transformations affines sur le papier :
`W₂ (W₁ x + b₁) + b₂` est-il encore affine en `x` ? La sigmoid finale
suffit-elle alors à fabriquer une frontière non linéaire ?
L'atelier suivant mesure cet essai avec les mêmes points, largeur, graine,
budget, perte et optimiseur. Nous pourrons ainsi attribuer la différence
observée à l'activation dans ce protocole, sans confondre MSE, BCE et Adam.

## Le graphe et ses valeurs sont deux objets différents

`model` décrit les opérations ; il ne contient aucun nombre appris. `state`
contient les paramètres, mais aussi le suivi de boucle et l'état de
l'optimiseur. Avec Axon 0.9, les paramètres à passer à `predict` se trouvent
dans `state.step_state.model_state`. Un exemple ancien qui passe directement
le résultat de `Loop.run` à `predict` mélange ces deux niveaux.

## Prolongement

Retrouvez les dimensions de chaque matrice et dérivez le nombre de paramètres
pour une largeur cachée `h`. Comparez l'architecture apprise à la troisième
coordonnée `x₁*x₂` suggérée en AP4. Quelle connaissance humaine remplace-t-on ?

## 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).
