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
| Recette | Poids | Activations | Taille relative | Matériel |
|---|---|---|---|---|
float32 (référence) | 32 bits | 32 bits | 100 % | CPU, GPU |
| Plage dynamique | 8 bits | 32 bits (calcul en 8) | 25 % | CPU |
int8 complet | 8 bits | 8 bits | 25 % | CPU, NNAPI, EdgeTPU, DSP |
float16 | 16 bits | 16 bits | 50 % | 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_INT8refuse 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.uint8fait accepter des octets bruts en entrée et évite une conversionfloat32du 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 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 :
| Variante | Taille | Latence Nokia G21 | Exactitude |
|---|---|---|---|
float32 (module 2) | 14 Mo | 240 ms | 96,3 % |
| Plage dynamique | 3,6 Mo | 175 ms | 96,1 % |
int8 complet | 3,7 Mo | 145 ms | 95,7 % |
float16 | 7,1 Mo | 240 ms | 96,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é),
int8complet (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'
int8complet : 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'
int8complet 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
.tflitefinal, 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.