Aller au contenu principal

Module 3 — Quantification après entraînement

Le modèle du module 2 pèse 14 Mo et tourne encore en float32 : chaque poids occupe 4 octets, chaque calcul manipule des nombres à virgule flottante. C'est le format le plus précis, et le plus dispendieux. La quantification consiste à représenter les mêmes poids sur moins de bits, avec une perte d'exactitude presque toujours négligeable.

Ce module traite les trois recettes qui ne demandent aucun réentraînement. Elles reposent toutes sur le même convertisseur TFLiteConverter, avec un ou deux drapeaux à modifier. Le module 4 traitera le cas rare où ces recettes dégradent trop la qualité, et où il faut simuler la quantification pendant l'entraînement.

Trois formats, un seul convertisseur

RecettePoidsActivationsTaille relativeMatériel
float32 (référence)32 bits32 bits100 %CPU, GPU
Plage dynamique8 bits32 bits (calcul en 8)25 %CPU
int8 complet8 bits8 bits25 %CPU, NNAPI, EdgeTPU, DSP
float1616 bits16 bits50 %GPU (rapide), CPU (correct)

Les trois divisent la taille du fichier ; seul l'int8 complet ouvre l'accès aux accélérateurs à entiers (EdgeTPU, certains DSP) et à la plupart des délégués NNAPI utiles.

Recette 1 : plage dynamique — la plus rapide à mettre en place

C'est l'option de départ. Le convertisseur transforme les poids en int8, mais laisse les activations en float32 ; la multiplication est faite en int8 puis convertie à la volée. Aucun jeu de données requis, une ligne à ajouter.

convertisseur = tf.lite.TFLiteConverter.from_saved_model("modeles/plantvillage_pret/1")
convertisseur.optimizations = [tf.lite.Optimize.DEFAULT]
tflite = convertisseur.convert()

Effet mesuré sur le fil rouge : le fichier passe de 14 Mo à 3,6 Mo, la latence sur le Nokia G21 chute de 240 ms à 175 ms, l'exactitude descend de 96,3 % à 96,1 %. La perte d'exactitude tient dans la marge d'erreur d'une nouvelle réévaluation. C'est le meilleur rapport bénéfice sur effort du cours.

Le compromis : cette recette accélère seulement les convolutions et les denses les plus lourdes, et n'active aucun délégué matériel à entiers. Elle plafonne autour de 30 % de gain de latence sur processeur.

Recette 2 : int8 complet — le sésame des délégués

Pour aller plus loin, il faut aussi représenter les activations en int8. Or les activations dépendent des entrées : leur amplitude ne peut pas se déduire des poids seuls. Le convertisseur a besoin d'un jeu représentatif — une centaine d'images typiques — pour observer les plages atteintes par chaque couche.

def echantillons():
"""Genere 100 a 500 images typiques du domaine, deja pretraitees."""
for image in images_representatives[:200]:
yield [image.astype("uint8")[None, ...]]

convertisseur = tf.lite.TFLiteConverter.from_saved_model("modeles/plantvillage_pret/1")
convertisseur.optimizations = [tf.lite.Optimize.DEFAULT]
convertisseur.representative_dataset = echantillons
convertisseur.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
convertisseur.inference_input_type = tf.uint8
convertisseur.inference_output_type = tf.uint8
tflite = convertisseur.convert()

Trois lignes méritent l'attention :

  • representative_dataset : les 200 images guident la mesure des minima et maxima par couche. Trop peu (moins de 50) sous-estime la variabilité et produit une saturation à l'inférence. Trop biaisé (par exemple 200 feuilles saines, aucune malade) déplace les statistiques et fait chuter l'exactitude sur les classes rares.
  • TFLITE_BUILTINS_INT8 refuse la conversion si un opérateur du graphe ne supporte pas l'int8. Le message d'erreur nomme la couche coupable, ce qui évite un fichier hybride silencieux.
  • inference_input_type = tf.uint8 fait accepter des octets bruts en entrée et évite une conversion float32 du côté application.

Effet mesuré sur le fil rouge : fichier à 3,7 Mo, latence sur le Nokia G21 à 145 ms, exactitude à 95,7 % — soit 0,6 point perdu, tout en gagnant l'accès au délégué NNAPI (module 6) qui réduit la latence à 60 ms sur le Samsung Galaxy A15. La perte d'exactitude est ici la première à mériter d'être surveillée : au-delà d'un point, il faut passer au module 4 (QAT).

Un jeu représentatif biaisé coûte plus que sa taille

Un jeu de 200 images pris uniquement dans une bibliothèque de photos propres, sans arrière-plan naturel, produit un modèle qui sature dès qu'une feuille apparaît sur fond de terre. Le jeu représentatif doit ressembler à ce que la caméra du terrain fournira, pas à un catalogue.

Recette 3 : float16 — le compromis pour GPU

La quantification float16 divise seulement les poids par deux, sans toucher aux activations. Elle a un intérêt précis : le délégué GPU sur mobile exécute nativement le float16 avec une bonne accélération, alors que l'int8 sur GPU est mal supporté selon les puces.

convertisseur = tf.lite.TFLiteConverter.from_saved_model("modeles/plantvillage_pret/1")
convertisseur.optimizations = [tf.lite.Optimize.DEFAULT]
convertisseur.target_spec.supported_types = [tf.float16]
tflite = convertisseur.convert()

Effet mesuré sur le fil rouge : fichier à 7,1 Mo, latence sur le Nokia G21 identique au float32 (240 ms) parce que le G21 n'a pas d'unité float16 efficace, latence sur l'iPhone 12 avec délégué GPU à 28 ms, exactitude à 96,3 % (aucune perte détectable).

C'est la recette qui produit le meilleur compromis quand la cible est un appareil doté d'un bon GPU mobile et qu'aucune perte d'exactitude n'est acceptable. Le fichier reste plus lourd qu'en int8, mais la qualité est préservée au bit près.

Le tableau qui se remplit à chaque module

Le fil rouge tient un tableau récapitulatif que chaque module met à jour. Après le module 3, il ressemble à ceci :

VarianteTailleLatence Nokia G21Exactitude
float32 (module 2)14 Mo240 ms96,3 %
Plage dynamique3,6 Mo175 ms96,1 %
int8 complet3,7 Mo145 ms95,7 %
float167,1 Mo240 ms96,3 %

La lecture est immédiate : sur un modèle qui tient dans l'enveloppe des 96 %, l'int8 complet est le meilleur choix par défaut, sauf si les 0,6 point perdus pèsent lourd pour l'application. Dans ce cas, le module 4 récupère cette exactitude tout en gardant les 3,7 Mo.

Vérifier après conversion, pas seulement pendant

Un .tflite quantifié peut passer la conversion sans lever d'erreur et pourtant produire des sorties dégradées d'une manière difficile à repérer. Le test décisif tient en dix lignes :

interprete = tf.lite.Interpreter(model_path="modeles/plantvillage_int8.tflite")
interprete.allocate_tensors()
entree_id = interprete.get_input_details()[0]["index"]
sortie_id = interprete.get_output_details()[0]["index"]

correct = 0
for image, etiquette in jeu_de_test:
interprete.set_tensor(entree_id, image.astype("uint8")[None, ...])
interprete.invoke()
if interprete.get_tensor(sortie_id)[0].argmax() == etiquette:
correct += 1
print("exactitude :", correct / len(jeu_de_test))

Ce script réévalue l'exactitude sur le jeu de test complet avec le .tflite réel, pas avec le modèle Keras d'origine. Un écart de plus d'un point avec la mesure float32 révèle un problème : jeu représentatif insuffisant, opérateur mal quantifié, ou saturation dans une couche spécifique. Sans cette vérification, la dégradation se découvre sur le terrain, plusieurs jours après le déploiement.

En résumé

  • Trois recettes sans réentraîner : plage dynamique (rapide, gain modéré), int8 complet (le meilleur compromis par défaut, exige un jeu représentatif), float16 (pour GPU, sans perte d'exactitude).
  • Le jeu représentatif est le paramètre critique de l'int8 complet : taille de 100 à 500 exemples, distribution proche du terrain, sans quoi les activations saturent.
  • Le fichier passe de 14 Mo à 3,7 Mo avec l'int8 complet et la latence chute de 240 à 145 ms sur processeur d'entrée de gamme, pour 0,6 point d'exactitude perdu.
  • Toujours réévaluer l'exactitude sur le .tflite final, pas sur le Keras d'origine : la conversion peut passer sans erreur et dégrader discrètement les sorties.

Module suivant : l'entraînement conscient de la quantification, qui récupère les points perdus quand ils comptent vraiment.