# AP5 · Choisir avec des preuves


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

Un modèle a obtenu un bon résultat. Comment choisir entre deux recettes
sans finir par apprendre les réponses du test ? Vous allez annoncer une
hypothèse, comparer des essais contrôlés, c-à-d. qui ne diffèrent que par un
choix, puis commenter une erreur.

> **Repères** : [slides 26–33](https://ap-2026.noce.fr/slides/AP5.html#26).
> [Poly, chap. 8 « Des calculs qui apprennent »](https://ap-2026.noce.fr/poly/poly-elixir.pdf).
> AP5 atelier 2 : `Classifier` et les recettes · dans cet atelier : `not in`, `Enum.all?/2`, un motif `{a, b} = tuple` en tête de fonction, `Enum.max_by/2`, `Map.take/2`, `Enum.zip/2` · train, validation, test · une seule modification par comparaison.

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


## Reprendre votre brique `Points`

Copiez ici votre module `Points` du carnet précédent, tel que vous l'avez écrit. Il n'est pas chargé implicitement : chaque carnet ouvre sa propre session Elixir, qui ne connaît que les cellules exécutées dans ce carnet. Si cette étape vous bloque, nous reprendrons la brique ensemble en séance.

```elixir
# Copiez ici votre module Points.
```

## Reprendre votre brique `Classifier`

Copiez ici votre module `Classifier` du carnet précédent, tel que vous l'avez écrit. Il n'est pas chargé implicitement : chaque carnet ouvre sa propre session Elixir, qui ne connaît que les cellules exécutées dans ce carnet. Si cette étape vous bloque, nous reprendrons la brique ensemble en séance.

```elixir
# Copiez ici votre module Classifier.
```

## Réserver les données avant les essais

Nous n'utilisons que train et validation pour choisir la recette. Les parts
sont disjointes mais proches géométriquement : le test mesurera une
interpolation sur ce domaine, pas une aptitude générale à reconnaître des
images. Le rôle des trois jeux est différent même quand ils viennent de la
même grille.

Les `assert` vérifient cette séparation : `p not in liste` est vrai quand
`p` n'appartient pas à la liste, et `Enum.all?(liste, f)` rend `true` si `f`
est vraie pour tous les éléments. Le motif `{validation_x, validation_y} = …`
décompose le tuple rendu par `tensors/1` en deux noms.

```elixir
rows = Points.grid()
train_rows = Points.partition(rows, :train)
validation_rows = Points.partition(rows, :validation)
test_rows = Points.partition(rows, :test)
assert Enum.all?(train_rows, fn p -> p not in validation_rows and p not in test_rows end)
assert Enum.all?(validation_rows, fn p -> p not in test_rows end)
assert length(train_rows) + length(validation_rows) + length(test_rows) == 144
train = Points.tensors(train_rows)
{validation_x, validation_y} = Points.tensors(validation_rows)
```

## À écrire : une mesure qui garde la recette

Écrivez `Trial.run/3`. `recipe` contient `kind`, `width`, `activation`, `rate`,
`epochs` et `seed`, comme dans le carnet précédent ; `train` et `validation`
sont des tuples `{x, y}`. Construisez un modèle, entraînez-le depuis un état
vide, calculez ses probabilités sur les deux jeux, puis rendez la map
`%{recipe: recipe, model: model, state: state, train_loss: …,
validation_loss: …, train_accuracy: …, validation_accuracy: …}`.
Utilisez `Classifier.loss/2` et `Classifier.accuracy/2` sur les prédictions.
Conserver la recette dans le résultat évite de perdre ce qui a produit chaque
mesure. Ici la perte BCE, l'optimiseur Adam et les partitions restent fixes
pour tous les essais ; notez ces choix en tête de votre tableau.
Réutiliser un modèle déjà entraîné ne comparerait pas les mêmes départs.

Pour lire les tenseurs d'un tuple, vous pouvez vous servir de `elem(train, 0)`
et `elem(train, 1)`, ou décomposer le tuple dans la tête de fonction : le
paramètre `{train_x, train_y} = train` reconnaît un tuple de deux cases, lie
ses deux tenseurs et garde aussi le tuple entier sous le nom `train`.

Les vérifications lancent trois recettes de référence : la recette `tanh`, la
même sans activation cachée, puis la même avec un taux cent fois plus petit.
`Map.take(map, clés)` garde seulement les clés citées, pour un affichage
lisible ; `Enum.each` applique une fonction à chaque élément sans rien
construire. Prédisez laquelle des trois sera la meilleure en validation, et
pour quelle raison, avant d'exécuter.

```elixir
defmodule Trial do
  def run(_recipe, _train, _validation), do: raise("À écrire")
end
```

Vérifiez, puis ajoutez un cas à vous.

```elixir
reference = %{kind: :hidden, width: 8, activation: :tanh,
  rate: 0.03, epochs: 200, seed: 42}
recipes = [reference, %{reference | activation: :none}, %{reference | rate: 0.0001}]
results = Enum.map(recipes, fn recipe -> Trial.run(recipe, train, {validation_x, validation_y}) end)
assert length(results) == length(recipes)
assert Enum.map(results, & &1.recipe) == recipes
Enum.each(results, fn result ->
  IO.inspect(Map.take(result, [:recipe, :train_loss, :validation_loss,
    :train_accuracy, :validation_accuracy]))
end)
```

## Votre expérience, avant de choisir

Choisissez une question avant de relancer : effet du taux, de la largeur,
de l'activation ou du nombre d'époques. La recette accepte par ex. `width: 4`,
`activation: :relu` ou `seed: 7` ; `relu` est une autre activation, qui
remplace les valeurs négatives par 0. Changez un seul facteur, conservez les
mêmes données et le même protocole, puis consignez : hypothèse, recette,
pertes et exactitudes train/validation, observation et limite de votre
conclusion. Avec une autre largeur, les poids ont d'autres dimensions : la
même graine fixe un départ reproductible, pas des paramètres identiques.
Ajoutez vos recettes à `recipes` et réévaluez la comparaison ci-dessus.
N'exécutez pas encore les cellules du test. Aucun score minimal n'est demandé.

## Une question à vous, sans protocole fourni

Cette question peut servir d'expérience ci-dessus ; elle n'ajoute pas un
deuxième travail à votre comparaison.
Proposez une expérience qui permette de distinguer « ce modèle ne peut pas
représenter la règle » de « mon entraînement ne l'a pas encore apprise ».
Annoncez ce que vous fixez, ce que vous changez et l'observation qui soutiendrait
chaque explication. Mettez votre protocole à l'épreuve, puis échangez-le avec
un autre groupe. Deux essais argumentés suffisent ; une grande recherche
automatique ne remplace pas le raisonnement.

## Prolongement : chercher un surapprentissage

Le *surapprentissage* désigne un modèle qui ajuste de mieux en mieux les
particularités du train sans bénéfice sur les exemples réservés. Pour
l'étudier, réduisez le train et inversez quelques étiquettes seulement dans
cette copie du train. Comparez plusieurs durées d'entraînement à largeur
fixée. Une hausse du score train accompagnée d'une baisse de validation
serait un indice ; il n'est pas garanti à chaque essai. Ne modifiez jamais
les étiquettes de validation pour faire apparaître l'effet. Si vous changez
ainsi le problème, conservez ces essais dans une section séparée avec leur
protocole au lieu de les mélanger aux trois références.

## Choisir, puis ouvrir le test une fois

Avant cette cellule, notez la recette choisie et son critère. Ici on retient
la meilleure exactitude de validation parmi les essais annoncés ; en cas
d'égalité on garde le premier, car `Enum.max_by(liste, f)` rend le premier
élément pour lequel `f` est maximale. L'expérience pédagogique ci-dessous est
reproductible, mais votre compte rendu doit distinguer ce choix de vos essais
supplémentaires. Après avoir consulté le test, toute nouvelle modification
ferait de ce test une information de validation.

```elixir
chosen = Enum.max_by(results, fn result -> result.validation_accuracy end)
{test_x, test_y} = Points.tensors(test_rows)
test_probabilities = Classifier.probabilities(chosen.model, chosen.state, test_x)
test_accuracy = Classifier.accuracy(test_probabilities, test_y)
IO.inspect({chosen.recipe, test_accuracy}, label: "recette figée et exactitude test")
assert test_accuracy >= 0.0 and test_accuracy <= 1.0
```

## Examiner une erreur de validation

Une moyenne indique où chercher ; elle ne raconte pas chaque cas.
`Enum.zip(a, b)` associe les éléments de deux listes en couples `{point,
probabilité}` ; le filtre garde ceux dont la classe décidée diffère de
l'étiquette. Localisez les erreurs et regardez leur distance aux axes, là où
la classe change. Une probabilité proche de 0.5 n'est pas une mesure
universelle de confiance ; c'est ici la sortie du modèle, à interpréter dans
son contexte.

```elixir
validation_probabilities = Classifier.probabilities(chosen.model, chosen.state, validation_x)
errors = Enum.zip(validation_rows, Nx.to_flat_list(validation_probabilities))
|> Enum.filter(fn {p, probability} -> (if probability >= 0.5, do: 1, else: 0) != p.label end)
IO.inspect(errors, label: "erreurs de validation")
```

## Dessiner la frontière estimée

Sur une grille plus fine de 31 × 31 points, calculez les réponses sans
réentraîner : c'est de l'*inférence*, des prédictions avec des paramètres
figés. Ce dessin échantillonne la *frontière de décision*, la ligne où la
sortie passe par 0.5 ; il ne mesure pas une nouvelle exactitude. Comparez les
modèles déjà entraînés sans changer le choix final.

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

Coloriez les classes prédites. `Points.features/1` accepte ces maps sans
`label`, puisqu'il ne lit que `x` et `y`.

```elixir
grid = for i <- 0..30, j <- 0..30, do: %{x: i / 15 - 1, y: j / 15 - 1}
grid_x = Nx.tensor(Points.features(grid), type: :f32)
grid_probabilities = Classifier.probabilities(chosen.model, chosen.state, grid_x) |> Nx.to_flat_list()
boundary = Enum.zip_with(grid, grid_probabilities, fn p, probability ->
  Map.put(p, :prediction, if(probability >= 0.5, do: 1, else: 0))
end)
Plot.points(boundary, "prediction")
```

## Votre conclusion

Reliez votre observation à votre hypothèse, puis expliquez ce que cette
expérience ne permet pas de conclure. Le test déjà ouvert ne sert plus à
choisir. Sur cette petite grille, un test sans erreur est possible : ce n'est
ni une preuve de perfection ni une raison de masquer les erreurs de validation.

## Prolongements pour aller plus loin

Ajoutez une *matrice de confusion* 2 × 2, c-à-d. le compte des couples
`{vraie classe, classe prédite}`, et vérifiez que sa somme égale le nombre
d'exemples. Recommencez avec plusieurs graines pour distinguer une tendance
d'un départ favorable. Enfin, le carnet `ap5-04-fashion.livemd` transpose les
mêmes questions à des images de vêtements : dimensions, classes, perte et
erreurs deviennent plus riches, mais le protocole reste le même.

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