Module 2 — Conversion d'un modèle Keras vers TensorFlow Lite
Le module précédent a fixé les budgets. Ce module traite la première
transformation : le passage d'un modèle Keras entraîné à un binaire
.tflite que l'application chargera. C'est aussi la première occasion de
répéter l'erreur qui casse la moitié des déploiements — le prétraitement
laissé dehors — dans un contexte où elle est encore plus coûteuse qu'au
module 10 du cours 08.
Le convertisseur en quinze lignes
TFLiteConverter accepte trois entrées : un SavedModel, un modèle Keras
chargé en mémoire ou une fonction concrète décorée par tf.function. Le
chemin recommandé est le premier, parce qu'un SavedModel sur disque est
reproductible et versionnable.
import tensorflow as tf
# Chargement du MobileNetV2 affine sur PlantVillage (fil rouge)
modele = tf.keras.models.load_model("plantvillage_mobilenetv2.keras")
modele.export("modeles/plantvillage/1") # SavedModel
convertisseur = tf.lite.TFLiteConverter.from_saved_model("modeles/plantvillage/1")
tflite = convertisseur.convert()
with open("modeles/plantvillage.tflite", "wb") as f:
f.write(tflite)
Après cette opération, le fichier .tflite pèse à peu près la même taille
que la somme des poids float32 du modèle : autour de 14 Mo pour le
MobileNetV2 du fil rouge, contre 14,2 Mo pour le SavedModel équivalent. La
conversion ne change ni l'exactitude, ni la latence : elle réécrit le graphe
dans un format lisible par un interprète léger, sans plus.
Vérifier immédiatement que le fichier se relit :
interprete = tf.lite.Interpreter(model_path="modeles/plantvillage.tflite")
interprete.allocate_tensors()
for detail in interprete.get_input_details():
print(detail["name"], detail["shape"], detail["dtype"])
La sortie doit afficher un tenseur d'entrée en float32, de forme
[1, 224, 224, 3]. Le 1 en tête n'est pas anodin : par défaut, le
convertisseur fige la dimension de lot à 1, parce qu'un téléphone traite une
image à la fois et qu'une taille libre gonfle inutilement le graphe. Le
module 6 revient sur la manière de la rendre dynamique quand c'est vraiment
utile.
Le prétraitement doit rejoindre le modèle
C'est la première fois que le fil rouge cogne contre la même erreur que le
module 10 du cours 08. MobileNetV2 attend des pixels normalisés dans
[-1, 1]. Si vous laissez cette normalisation dans le script Python
d'entraînement, elle n'existe pas dans le .tflite. L'application
Android envoie alors des octets bruts et reçoit des probabilités qui n'ont
plus de sens.
La solution est identique à celle du cours 08 : intégrer le prétraitement comme des couches Keras avant l'export.
from tensorflow.keras import layers
base = tf.keras.applications.MobileNetV2(
input_shape=(224, 224, 3), include_top=False, weights=None,
)
# ... chargement des poids affines ...
entree = tf.keras.Input(shape=(224, 224, 3), dtype="uint8", name="image")
x = layers.Rescaling(1.0 / 127.5, offset=-1.0)(entree)
x = base(x, training=False)
x = layers.GlobalAveragePooling2D()(x)
sortie = layers.Dense(38, activation="softmax", name="probabilites")(x)
modele_pret = tf.keras.Model(entree, sortie)
modele_pret.export("modeles/plantvillage_pret/1")
Le nouveau modèle accepte des images en entiers non signés — exactement le
format que la caméra fournit — et se charge lui-même de la mise à l'échelle.
L'application n'a plus qu'à redimensionner à 224x224 et passer l'octet
brut ; toute l'arithmétique de normalisation est dans le fichier.
Sur un serveur, une divergence de prétraitement se corrige par un déploiement
en cinq minutes. Sur mobile, elle exige une nouvelle version d'application
qui met plusieurs jours à atteindre 90 % du parc. Un prétraitement absent du
.tflite est un bogue qui vit des semaines.
Les métadonnées, sinon on devine
Un .tflite seul ne dit ni ce qu'il attend, ni ce qu'il retourne au-delà de
la forme des tenseurs. « 38 flottants entre 0 et 1 » n'indique pas quelle
sortie correspond à quelle maladie. Le convertisseur permet d'attacher des
métadonnées — nom des classes, plage de valeurs attendues, description
du modèle — que l'application lit à l'exécution.
from tflite_support import metadata_schema_py_generated as schema
from tflite_support import metadata as _metadata
ecriture = _metadata.MetadataPopulator.with_model_file("modeles/plantvillage.tflite")
ecriture.load_associated_files(["etiquettes.txt"]) # 38 lignes, une classe par ligne
ecriture.populate()
Le fichier etiquettes.txt contient les 38 noms de maladies, dans l'ordre
exact de la couche de sortie. La bibliothèque de tâches Android (module 7)
lit ces métadonnées automatiquement et évite d'avoir à hardcoder la liste
dans le code Java ou Kotlin. Sans métadonnées, un décalage d'une ligne dans
le fichier d'étiquettes fait « décaler » tous les diagnostics d'une classe,
sans erreur visible.
Les signatures multi-fonctions
Un SavedModel peut porter plusieurs points d'entrée. Un modèle qui expose
predire pour l'inférence normale et expliquer pour une carte
d'importance des pixels se convertit avec le même appel :
convertisseur = tf.lite.TFLiteConverter.from_saved_model(
"modeles/plantvillage/1",
signature_keys=["serving_default", "expliquer"],
)
tflite = convertisseur.convert()
Côté application, l'interprète expose ensuite les deux signatures :
signatures = interprete.get_signature_list() # {'serving_default': ..., 'expliquer': ...}
predire = interprete.get_signature_runner("serving_default")
sortie = predire(image=lot)["probabilites"]
L'appel par signature évite d'avoir à repérer les indices 0 et 1 des
tenseurs, qui changent selon la version du convertisseur. C'est la forme
recommandée depuis TensorFlow 2.5.
Les opérateurs pris en charge, et ceux qui ne le sont pas
Le convertisseur cible un ensemble d'opérateurs restreint, appelé TFLite
built-ins. Ils couvrent les couches courantes : convolutions,
normalisation par lots, activations, dense, tri, réductions. Certaines
opérations plus exotiques — tf.strings, tf.image.non_max_suppression
avec des paramètres non standard, opérateurs personnalisés — n'y sont pas.
Quand le convertisseur tombe sur un opérateur non pris en charge, il refuse la conversion par défaut avec un message explicite qui nomme l'opérateur manquant. Deux réponses possibles :
# Chemin 1 : ajouter le lot d'operateurs TensorFlow au binaire
convertisseur.target_spec.supported_ops = [
tf.lite.OpsSet.TFLITE_BUILTINS,
tf.lite.OpsSet.SELECT_TF_OPS,
]
Cette option — TF Select ops — embarque le sous-ensemble de TensorFlow nécessaire pour exécuter l'opération manquante. Le prix est lourd : le binaire de l'interprète passe d'environ 1 Mo à 6 Mo, parfois plus, ce qui grignote une part significative de l'APK. À réserver aux modèles pour lesquels réécrire l'opérateur en couches standard demanderait plus de travail que l'inflation de taille.
# Chemin 2 : reecrire l'operation avec des couches supportees
# (souvent 30 minutes de code, souvent la meilleure decision)
La règle empirique du fil rouge : préférer la réécriture tant qu'une seule
opération pose problème. Passer à SELECT_TF_OPS dès qu'il y en a plus de
trois, en documentant le surcoût.
En résumé
- La conversion depuis un
SavedModelproduit un.tflitede la taille des poidsfloat32; rien ne change en exactitude ou en latence, seul le format est réécrit. - Le prétraitement doit être une couche Keras avant l'export, sinon il
disparaît du
.tfliteet casse silencieusement les prédictions dans l'application. - Les métadonnées (nom des classes, plage d'entrée) évitent de coder en dur des correspondances fragiles côté application et débloquent la bibliothèque de tâches Android.
- Un opérateur non pris en charge se règle en le réécrivant avec des
couches standard ou en activant
SELECT_TF_OPS, qui alourdit l'interprète de plusieurs mégaoctets.
Module suivant : la quantification après entraînement, première technique pour diviser la taille du fichier par quatre sans réentraîner le modèle.