Aller au contenu principal

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.

L'écart entraînement-service est encore plus toxique sur mobile

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 SavedModel produit un .tflite de la taille des poids float32 ; 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 .tflite et 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.