# AP5 · Prolongement — Reconnaître un vêtement


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

Nx.default_backend(Nx.BinaryBackend)
import ExUnit.Assertions
alias VegaLite, as: Vl
```


## Des points aux images

Peut-on reconnaître une chaussure ou un manteau à partir de 784 intensités de
gris ? Nous reprenons le protocole d’AP5 sur Fashion-MNIST : dix classes de
petites images de 28 × 28 pixels. Le chargement est fourni ; la préparation
numérique, l’architecture et les mesures sont votre travail. Ce prolongement
remplace, au choix annoncé en séance, l’atelier d’expériences sur les points.
Il peut aussi servir de mini-projet après le socle.

**Repères** : [AP5, 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) ;
AP3 pour les en-têtes binaires ; AP4 pour formes et axes ; AP5 pour paramètres,
perte, gradient et validation. Formes attendues : `{n, 784}` pour les images,
`{n}` pour les étiquettes et `{n, 10}` pour les logits.

La première cellule installe les mêmes bibliothèques que les six ateliers du
socle, avec des versions figées par `==`, plus Req, un client HTTP qui servira
au téléchargement. `alias VegaLite, as: Vl` permet d’écrire `Vl.new` au lieu
de `VegaLite.new`. La première exécution télécharge environ 30 Mo depuis le
dépôt officiel de Zalando. Le cache se trouve dans le répertoire temporaire du
système, sous `ap-fashion-mnist` ; la variable d’environnement
`AP_FASHION_CACHE` permet de choisir un dossier persistant. Aucune donnée
n’est publiée avec votre rendu. Préchargez les archives avant une séance sans
réseau. Les six carnets du socle restent indépendants de ce téléchargement.

## 1. Lire les données et réserver les lots

Le format IDX commence par des entiers sur 32 bits, en ordre *big-endian*,
l’octet de poids fort en premier : c’est le pattern matching binaire d’AP3.
Lisez la clause `images!/2` : le motif `<<2051::32, count::32, 28::32, 28::32, pixels::binary>>`
exige l’identifiant du format, puis lit le nombre d’images, les lignes, les
colonnes et enfin tous les pixels ; la garde `when byte_size(pixels) == count * 784`
refuse un fichier tronqué. `Nx.from_binary(pixels, :u8)` fabrique un tenseur
d’octets sans recopier chaque valeur, et `Nx.reshape` lui donne une ligne par
image. Les étiquettes sont converties en entiers signés `:s64`, le type que la
perte attendra. Quel contrôle empêche de lire un fichier tronqué ?

Le reste du module tire des indices au hasard, de façon reproductible :
`:rand.seed_s` crée un générateur à partir d’une graine, `Enum.map_reduce`
transporte son état d’un tirage au suivant, et `Nx.take` sélectionne les
lignes voulues. Nous utilisons les 60 000 images d’apprentissage comme
réserve, mais n’en entraînons que 800 pour ce premier essai CPU. Deux cents
autres images servent à la validation ; le test prend 200 images du fichier
officiel séparé de 10 000 images. `MapSet.disjoint?` vérifie qu’aucun indice
n’est commun aux deux premiers lots. Ces petits sous-ensembles ne constituent
pas un benchmark.

```elixir
defmodule FashionData do
  @base "https://raw.githubusercontent.com/zalandoresearch/fashion-mnist/master/data/fashion/"
  @files [
    {"train-images-idx3-ubyte.gz", "8d4fb7e6c68d591d4c3dfef9ec88bf0d"},
    {"train-labels-idx1-ubyte.gz", "25c81989df183df01b3e8a0aad5dffbe"},
    {"t10k-images-idx3-ubyte.gz", "bef4ecab320f06d8554ea6380940ec79"},
    {"t10k-labels-idx1-ubyte.gz", "bb300cfdad3c16e7a12a480ee83cd310"}
  ]

  def load do
    cache = System.get_env("AP_FASHION_CACHE") || Path.join(System.tmp_dir!(), "ap-fashion-mnist")
    File.mkdir_p!(cache)
    [train_images, train_labels, test_images, test_labels] =
      Enum.map(@files, fn {name, expected} ->
        path = Path.join(cache, name)
        if not File.exists?(path) do
          IO.puts("Téléchargement de #{name}")
          response = Req.get!(@base <> name, decode_body: false, receive_timeout: 120_000)
          if response.status != 200, do: raise("Téléchargement refusé : #{response.status}")
          verify!(response.body, expected)
          File.write!(path, response.body)
        end
        bytes = File.read!(path)
        verify!(bytes, expected)
        :zlib.gunzip(bytes)
      end)
    {images!(train_images, 60_000), labels!(train_labels, 60_000),
     images!(test_images, 10_000), labels!(test_labels, 10_000)}
  end

  defp verify!(bytes, expected) do
    actual = :crypto.hash(:md5, bytes) |> Base.encode16(case: :lower)
    if actual != expected, do: raise("Archive Fashion-MNIST incorrecte ; vérifiez le cache")
  end

  def images!(<<2051::32, count::32, 28::32, 28::32, pixels::binary>>, count)
      when byte_size(pixels) == count * 784 do
    pixels |> Nx.from_binary(:u8) |> Nx.reshape({count, 784})
  end
  def images!(_, _), do: raise("En-tête ou longueur IDX images incorrects")

  def labels!(<<2049::32, count::32, labels::binary>>, count)
      when byte_size(labels) == count do
    labels |> Nx.from_binary(:u8) |> Nx.as_type(:s64)
  end
  def labels!(_, _), do: raise("En-tête ou longueur IDX étiquettes incorrects")

  def indices(count, seed) do
    state = :rand.seed_s(:exsss, {seed, seed + 1, seed + 2})
    {keys, _state} = Enum.map_reduce(0..(count - 1), state, fn index, state ->
      {key, next} = :rand.uniform_s(state)
      {{key, index}, next}
    end)
    keys |> Enum.sort() |> Enum.map(&elem(&1, 1))
  end

  def subset(images, labels, indices) do
    indices = Nx.tensor(indices, type: :s64)
    {Nx.take(images, indices), Nx.take(labels, indices)}
  end
end

{all_images, all_labels, official_test_images, official_test_labels} = FashionData.load()
assert Nx.shape(all_images) == {60_000, 784}
assert Nx.shape(official_test_images) == {10_000, 784}
assert_raise RuntimeError, fn -> FashionData.images!(<<2051::32, 1::32, 28::32, 28::32>>, 1) end

indices = FashionData.indices(60_000, 2026)
train_ids = Enum.take(indices, 800)
validation_ids = indices |> Enum.drop(800) |> Enum.take(200)
test_ids = FashionData.indices(10_000, 2027) |> Enum.take(200)
assert MapSet.disjoint?(MapSet.new(train_ids), MapSet.new(validation_ids))
{train_pixels, train_labels} = FashionData.subset(all_images, all_labels, train_ids)
{validation_pixels, validation_labels} = FashionData.subset(all_images, all_labels, validation_ids)
{test_pixels, test_labels} = FashionData.subset(official_test_images, official_test_labels, test_ids)

IO.inspect(Enum.frequencies(Nx.to_flat_list(train_labels)), label: "Effectifs d’apprentissage")
IO.inspect(Enum.frequencies(Nx.to_flat_list(validation_labels)), label: "Effectifs de validation")
```


Le mélange est déterministe et indépendant des étiquettes.
`Enum.frequencies/1` compte les occurrences de chaque étiquette : vérifiez
les effectifs par classe dans l’apprentissage et la validation. Quelle
fluctuation attendez-vous sur seulement 200 images ? Les images de test sont
chargées pour simplifier la manipulation, mais leurs prédictions ne seront
consultées qu’après le choix final. La disponibilité d’un tenseur ne donne
pas le droit de l’utiliser pour régler le modèle.

## 2. Préparer et regarder une image

Les pixels sont des octets entre 0 et 255. Écrivez `FashionInput.normalize/1` :
conservez la forme, convertissez les pixels en flottants 32 bits avec
`Nx.as_type/2`, puis ramenez leurs valeurs dans `[0, 1]` avec `Nx.divide/2`.
Pourquoi diviser par 255 plutôt que par le maximum de chaque image ? Prédisez
le résultat sur une image noire avant d’essayer une normalisation par son
maximum. Dans les vérifications, `Nx.reduce_min` et `Nx.reduce_max` rendent
le plus petit et le plus grand élément du tenseur entier.

La brique d’affichage qui suit est fournie : `Nx.slice_along_axis(pixels, index, 1, axis: 0)`
extrait une ligne, c-à-d. une image, et `Enum.with_index` numérote ses 784
valeurs pour retrouver la ligne (`div(offset, 28)`) et la colonne
(`rem(offset, 28)`) de chaque pixel. Changez l’indice pour regarder d’autres
images, avec leur étiquette.

```elixir
defmodule FashionInput do
  def normalize(_pixels) do
    raise "À vous : passer en f32, puis diviser les intensités par 255"
  end
end
```

```elixir
train_x = FashionInput.normalize(train_pixels)
validation_x = FashionInput.normalize(validation_pixels)
test_x = FashionInput.normalize(test_pixels)
assert Nx.shape(train_x) == {800, 784}
assert Nx.type(train_x) == {:f, 32}
assert Nx.to_number(Nx.reduce_min(train_x)) >= 0.0
assert Nx.to_number(Nx.reduce_max(train_x)) <= 1.0
assert Nx.to_flat_list(FashionInput.normalize(Nx.tensor([0, 255], type: :u8))) == [0.0, 1.0]
```

```elixir
defmodule FashionView do
  @names ["T-shirt", "Pantalon", "Pull", "Robe", "Manteau", "Sandale", "Chemise", "Basket", "Sac", "Bottine"]
  def name(index), do: Enum.at(@names, index)

  def image(pixels, index, title) do
    values = pixels |> Nx.slice_along_axis(index, 1, axis: 0) |> Nx.to_flat_list()
    rows = Enum.with_index(values, fn value, offset ->
      %{row: div(offset, 28), column: rem(offset, 28), intensity: value}
    end)
    VegaLite.new(width: 196, height: 196, title: title)
    |> VegaLite.data_from_values(rows)
    |> VegaLite.mark(:rect)
    |> VegaLite.encode_field(:x, "column", type: :ordinal, axis: nil)
    |> VegaLite.encode_field(:y, "row", type: :ordinal, axis: nil)
    |> VegaLite.encode_field(:color, "intensity", type: :quantitative,
      scale: [domain: [0, 255], range: ["black", "white"]], legend: nil)
  end
end

FashionView.image(train_pixels, 0, FashionView.name(Nx.to_flat_list(train_labels) |> hd()))
```


## 3. Choisir l’architecture et compter ses paramètres

Écrivez `FashionModel.build/1` avec une entrée nommée `"pixels"`, une couche
dense de la largeur demandée avec ReLU, puis une couche dense à dix sorties
sans activation finale. ReLU est une activation qui applique `max(0, valeur)`
à chaque valeur intermédiaire ; comme `tanh` en AP5, elle apporte la
non-linéarité entre les deux couches. Les dix sorties sont des *logits*, des
scores non normalisés, un par classe. La perte applique la transformation
numérique adaptée ; ne lui ajoutez pas un softmax tout en conservant
`from_logits: true`. Le softmax convertirait les dix scores en valeurs
positives de somme 1 ; la perte réalise déjà ce calcul sous une forme
numériquement stable. Pour choisir la classe, l’indice du plus grand logit
suffit.

Avant de lancer le modèle, comptez les paramètres des deux couches : pour une
couche de `a` entrées et `b` sorties, `a * b` poids et `b` biais. Comparez
aussi au modèle linéaire direct, de 784 entrées à 10 sorties. Pourquoi
aplatir une image en 784 valeurs fait-il perdre à l’architecture la notion de
voisinage des pixels, sans perdre les valeurs ?

```elixir
defmodule FashionModel do
  def build(_width) do
    raise "À vous : entrée {nil, 784}, couche dense avec ReLU, puis dix logits"
  end
end
```


## 4. Mesurer autre chose que la perte

Écrivez `FashionMetrics.report/2`. `Nx.argmax(logits, axis: 1)` rend, pour
chaque ligne, l’indice du plus grand logit, c-à-d. la classe prédite ;
`Enum.zip` associe chaque vraie classe à sa prédiction, puis
`Enum.frequencies` compte les couples `{vraie classe, prédiction}` et
`Enum.count` les couples corrects. Le *rappel* d’une classe est la proportion
de ses images reconnues : dans la map des couples, `Map.get(counts, {class, class}, 0)`
donne le nombre de bonnes réponses pour cette classe, `0` s’il n’y en a
aucune. La *balanced accuracy* est ici la moyenne des dix rappels : chaque
classe pèse le même poids, même si les petits lots ne sont pas équilibrés.
Refusez de calculer ce score si une classe est absente au lieu de la
considérer correcte.

Dans les vérifications, `Nx.iota({10})` vaut `[0, 1, …, 9]` et `Nx.eye(10)`
est la matrice identité : dix logits parfaits, un par classe. Ajoutez votre
propre cas minuscule de vérification, avec une erreur volontaire de
classement. Est-ce que permuter les lignes de la matrice de confusion sans
changer les légendes ferait encore raconter la même histoire ?

```elixir
defmodule FashionMetrics do
  def report(_labels, _logits) do
    # Retour attendu : %{accuracy: flottant, balanced_accuracy: flottant,
    # confusion: %{{vraie_classe, classe_predite} => effectif},
    # recalls: liste_de_dix_rappels, predictions: liste_des_classes_predites}
    raise "À vous : argmax sur l’axe des classes, comparaison puis comptage"
  end
end
```

```elixir
perfect = FashionMetrics.report(Nx.iota({10}), Nx.eye(10))
assert perfect.accuracy == 1.0
assert perfect.balanced_accuracy == 1.0
assert perfect.confusion[{3, 3}] == 1
assert_raise RuntimeError, fn -> FashionMetrics.report(Nx.tensor([0]), Nx.eye(10) |> Nx.slice_along_axis(0, 1, axis: 0)) end
```


## 5. Entraîner une référence, puis décider d’un essai

La boucle ci-dessous fournit la mécanique de l’entraînement. Retrouvez la
perte, les lots, l’optimiseur, la graine et les paramètres entraînés.
Dans la perte, l’*entropie croisée catégorielle* généralise la BCE d’AP5 à
dix classes ; `sparse: true` indique que chaque cible est un numéro de classe
entre 0 et 9, et `from_logits: true` que le modèle rend les dix scores bruts.
`Nx.to_batched(train_x, 32)` découpe les 800 images en lots de 32, et
`Stream.zip` apparie chaque lot d’images au lot d’étiquettes correspondant :
une époque parcourt les 25 lots, soit 25 mises à jour, alors qu’en AP5 le lot
unique donnait une seule mise à jour par époque. `:timer.tc` mesure la durée
de l’entraînement en microsecondes. Le mélange initial reste fixe d’une époque
à l’autre dans cette référence. Changer ce protocole est une expérience
supplémentaire, à documenter.

Le contrôle `accuracy > 0.2` détecte un apprentissage manifestement défectueux
sur notre référence ; avec dix classes, répondre au hasard donnerait environ
10 %. Il ne remplace pas l’analyse des classes ni une garantie de performance
sur une autre configuration. Le coût CPU varie selon le poste.

```elixir
width = 16
epochs = 2
model = FashionModel.build(width)
parameter_count = (784 + 1) * width + (width + 1) * 10
# Pour width = 16, le résultat attendu est 12 730.
IO.inspect(parameter_count, label: "Nombre de paramètres scalaires")

loss = fn labels, logits ->
  Axon.Losses.categorical_cross_entropy(labels, logits,
    from_logits: true, sparse: true, reduction: :mean)
end

batches = Stream.zip(Nx.to_batched(train_x, 32), Nx.to_batched(train_labels, 32))
loop = Axon.Loop.trainer(model, loss,
  Polaris.Optimizers.adam(learning_rate: 0.003), seed: 42, log: 0)
{duration_us, trained} = :timer.tc(fn ->
  Axon.Loop.run(loop, batches, Axon.ModelState.empty(), epochs: epochs)
end)
model_state = trained.step_state.model_state
{_initialize, predict} = Axon.build(model)
validation_logits = predict.(model_state, validation_x)
assert Nx.shape(validation_logits) == {200, 10}
validation = FashionMetrics.report(validation_labels, validation_logits)
IO.inspect(%{seconds: duration_us / 1_000_000,
  validation_accuracy: validation.accuracy,
  validation_balanced_accuracy: validation.balanced_accuracy}, label: "Mesure de référence")
assert validation.accuracy > 0.2
```


La *matrice de confusion* ci-dessous compte, pour chaque vraie classe en
ligne, les classes prédites en colonne : la diagonale contient les bonnes
réponses, et une case claire hors diagonale désigne deux classes que le
modèle confond. Les six erreurs affichées ensuite sont les premières images de
validation mal classées, avec la vraie classe et la prédiction dans le titre.

```elixir
confusion_rows = for actual <- 0..9, guess <- 0..9 do
  %{actual: FashionView.name(actual), predicted: FashionView.name(guess),
    count: Map.get(validation.confusion, {actual, guess}, 0)}
end

Vl.new(width: 330, height: 330, title: "Validation : vraies classes et prédictions")
|> Vl.data_from_values(confusion_rows)
|> Vl.mark(:rect)
|> Vl.encode_field(:x, "predicted", type: :nominal, title: "Prédiction")
|> Vl.encode_field(:y, "actual", type: :nominal, title: "Vraie classe")
|> Vl.encode_field(:color, "count", type: :quantitative)
|> Vl.encode_field(:tooltip, "count", type: :quantitative)
```

```elixir
errors =
  Enum.zip(Nx.to_flat_list(validation_labels), validation.predictions)
  |> Enum.with_index()
  |> Enum.filter(fn {{actual, guess}, _index} -> actual != guess end)
  |> Enum.take(6)

Enum.map(errors, fn {{actual, guess}, index} ->
  FashionView.image(validation_pixels, index,
    "#{FashionView.name(actual)} : prédit #{FashionView.name(guess)}")
end)
|> Enum.map(&Kino.VegaLite.new/1)
|> Kino.Layout.grid(columns: 3)
```


> 💡 Essai qui peut coincer : agrandir le réseau sans compter les multiplications
> peut surtout agrandir l’attente. Le réseau 784–16–10 sert de première mesure CPU.
> Annoncez l’effet attendu d’une largeur de 32 et mesurez le temps autant que la
> validation. Une meilleure précision ne suffit pas à justifier tous les coûts.

Choisissez ensuite une seule comparaison : linéaire contre MLP, largeur 16
contre 32, ou deux nombres d’époques. Un *MLP*, perceptron multicouche, est
simplement notre empilement de couches denses avec une activation entre
elles. Gardez la séparation et le budget de référence explicites. Pour
comparer l’effet du taux, repartez du même réseau initialisé avec la même
graine, sans poursuivre l’entraînement précédent. Conservez les deux scores,
le temps et les confusions dominantes.

Si la mesure augmente peu, cherchez au moins deux explications possibles :
limite de représentation, budget d’apprentissage, variabilité du petit lot…
Les six erreurs affichées suffisent-elles à départager ces hypothèses ?
Pour approfondir, répétez plusieurs graines, augmentez les effectifs, ou étudiez
une classe mal reconnue. N’enchaînez pas les réglages sur le test.

## 6. Choisir, puis ouvrir le test

Écrivez ici la configuration retenue et sa justification. Les variables
`model_state` et `predict` désignent le dernier essai exécuté, qui n’est pas
forcément celui que vous retenez. Remettez la configuration choisie dans les
cellules du modèle et de l’entraînement, puis réévaluez-les avec la même graine
avant de lancer la cellule finale. Vérifiez que la mesure de validation
retrouve celle de votre tableau ; le test doit porter sur ce choix précis.

Si le test déçoit, rapportez ce résultat : recommencer les choix
à partir de cette mesure ferait du test une nouvelle validation. L’erreur sur
200 images ne permet pas de promettre la même précision sur toute photographie
de vêtement ; cadrage, fond et résolution peuvent être différents.

```elixir
# Exécutez seulement une fois le modèle retenu et la justification écrite.
test_logits = predict.(model_state, test_x)
final_report = FashionMetrics.report(test_labels, test_logits)
IO.inspect(Map.take(final_report, [:accuracy, :balanced_accuracy]), label: "Test final réservé")
assert final_report.accuracy >= 0.0 and final_report.accuracy <= 1.0
```


## Ce que vous conservez

Le carnet doit contenir une prédiction avant l’essai, vos fonctions, un cas de
vérification ajouté, le compte des paramètres, un tableau de comparaison et une
analyse de quelques erreurs. Les modalités du rendu seront annoncées en cours.
Un score élevé sans protocole expliqué ne suffit pas. Le [poly](https://ap-2026.noce.fr/poly/poly-elixir.pdf) se
consulte au besoin, quand un bout de syntaxe vous manque.

## Sources et adaptation

Ce prolongement s’inspire des notebooks de **Sébastien Boisgérault**, Mines Paris – PSL,
*Machine Learning with Pytorch*, sous [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) :

- [Dataset](https://github.com/boisgera/pytorch/blob/d175cd0f70505c8aa13e831c43d2dc39ad6d6c3b/01%20-%20Getting%20Started/01%20-%20Dataset.py), [Training](https://github.com/boisgera/pytorch/blob/d175cd0f70505c8aa13e831c43d2dc39ad6d6c3b/01%20-%20Getting%20Started/02%20-%20Training.py) et [Inference](https://github.com/boisgera/pytorch/blob/d175cd0f70505c8aa13e831c43d2dc39ad6d6c3b/01%20-%20Getting%20Started/03%20-%20Inference.py), version `d175cd0`.


Adaptation pour ce cours : réécriture en Elixir/Nx/Axon, chargeur IDX explicite,
réseau et sous-ensembles réduits pour CPU, séparation apprentissage/validation/test,
contrats à compléter et analyse par classe. Les cellules et consignes de cette
adaptation sont également proposées sous CC BY 4.0. Les notebooks originaux
renvoient aux [tutoriels officiels PyTorch](https://docs.pytorch.org/tutorials/).

Les images et les étiquettes viennent de
[Fashion-MNIST, Zalando Research](https://github.com/zalandoresearch/fashion-mnist),
par Han Xiao, Kashif Rasul et Roland Vollgraf ; le dépôt des données est sous
[licence MIT](https://github.com/zalandoresearch/fashion-mnist/blob/master/LICENSE).

Pour toute question, vous pouvez me contacter par email, comme indiqué en cours.
