Module 4 — Couches personnalisées et sous-classement de Model
Les deux API précédentes assemblent des couches existantes. Ce module descend d'un cran : écrire la couche elle-même, puis reprendre le contrôle de la boucle d'entraînement. On y recourt moins souvent qu'on ne l'imagine, mais quand le besoin se présente, il n'y a pas de contournement.
Une couche, trois méthodes
Toute couche personnalisée hérite de keras.layers.Layer et se répartit sur trois méthodes aux rôles bien séparés.
from tensorflow import keras
import tensorflow as tf
class DenseAvecEchelle(keras.layers.Layer):
def __init__(self, unites, **kwargs):
super().__init__(**kwargs)
self.unites = unites # hyperparametres seulement
def build(self, forme_entree):
self.noyau = self.add_weight(
shape=(forme_entree[-1], self.unites),
initializer="glorot_uniform",
trainable=True,
name="noyau",
)
self.echelle = self.add_weight(
shape=(), initializer="ones", trainable=True, name="echelle",
)
def call(self, entrees):
return tf.matmul(entrees, self.noyau) * self.echelle
__init__ reçoit les hyperparamètres et rien de plus. build crée les poids, et il est appelé une seule fois, au premier passage de données. call décrit le calcul.
La raison d'être de build est simple : au moment de __init__, la dimension d'entrée est inconnue. C'est build qui la reçoit via forme_entree, ce qui permet d'écrire Dense(64) sans jamais préciser combien d'entrées arrivent. Créer les poids dans __init__ fonctionne si vous codez la dimension en dur, mais vous perdez cette souplesse et la couche devient inutilisable ailleurs.
Un tf.Variable déclaré directement dans la couche n'est pas suivi de façon fiable : il peut ne pas apparaître dans layer.trainable_weights, ne pas être sauvegardé, et ne pas recevoir de gradient. add_weight l'enregistre auprès de Keras. Le symptôme d'une erreur ici est un poids qui ne bouge jamais, sans aucun message.
Le mode entraînement doit être transmis
Certaines couches se comportent différemment selon qu'on entraîne ou qu'on prédit. Elles doivent recevoir l'information et la propager.
class BlocRegularise(keras.layers.Layer):
def __init__(self, unites, taux=0.3, **kwargs):
super().__init__(**kwargs)
self.dense = keras.layers.Dense(unites, activation="relu")
self.abandon = keras.layers.Dropout(taux)
def call(self, entrees, training=None):
h = self.dense(entrees)
return self.abandon(h, training=training)
Omettre training=training dans l'appel à Dropout produit un bogue redoutable : l'abandon reste actif pendant l'évaluation et la prédiction. Les scores de validation deviennent bruités et systématiquement pessimistes, les prédictions changent d'un appel à l'autre, et rien n'indique la cause. Le même piège concerne BatchNormalization, dont les statistiques ne doivent pas se mettre à jour hors entraînement.
Sérialiser pour pouvoir recharger
Sauvegarder un modèle qui contient une couche personnalisée ne suffit pas : Keras doit savoir reconstruire cette couche au rechargement.
def get_config(self):
config = super().get_config()
config.update({"unites": self.unites, "taux": self.taux})
return config
get_config retourne les arguments de __init__ sous forme de dictionnaire. Sans elle, keras.models.load_model échoue avec une erreur d'objet inconnu. Avec elle, et le décorateur d'enregistrement, le rechargement est transparent :
@keras.saving.register_keras_serializable(package="inskillml")
class BlocRegularise(keras.layers.Layer):
...
C'est un détail administratif, mais c'est celui qui décide si votre modèle est déployable ou non — sujet du module 10.
Sous-classer Model quand le flux est dynamique
Sous-classer keras.Model déplace la définition de l'architecture dans du code Python impératif.
class ClassifieurAdaptatif(keras.Model):
def __init__(self, nb_classes, **kwargs):
super().__init__(**kwargs)
self.tronc = keras.layers.Dense(128, activation="relu")
self.tete = keras.layers.Dense(nb_classes, activation="softmax")
def call(self, entrees, training=None):
h = self.tronc(entrees)
if training:
h = tf.nn.dropout(h, rate=0.2)
return self.tete(h)
Le gain est la liberté totale : conditions, boucles dont le nombre d'itérations dépend des données, appels récursifs. Le coût est réel et souvent sous-estimé. Le graphe n'existe pas avant la première donnée, donc model.summary() reste muet jusque-là, les erreurs de forme n'apparaissent qu'à l'exécution, et le modèle ne se sérialise plus aussi simplement.
Le critère de décision est net : restez en fonctionnel tant que l'architecture est un graphe fixe. Le sous-classement se justifie quand le flux dépend des données à l'exécution, ce qui est rare hors recherche.
Redéfinir train_step plutôt que toute la boucle
Voici le point le plus utile du module. Quand la boucle standard ne convient pas, la réaction courante est d'écrire une boucle complète avec GradientTape, et de perdre du même coup les rappels, les barres de progression, l'entraînement distribué et l'historique. Il existe un point d'entrée bien plus économe.
class ModeleAvecPerteCustom(keras.Model):
def train_step(self, donnees):
x, y = donnees
with tf.GradientTape() as ruban:
prediction = self(x, training=True)
perte = self.compute_loss(x=x, y=y, y_pred=prediction)
gradients = ruban.gradient(perte, self.trainable_variables)
self.optimizer.apply_gradients(zip(gradients, self.trainable_variables))
for metrique in self.metrics:
if metrique.name != "loss":
metrique.update_state(y, prediction)
return {m.name: m.result() for m in self.metrics}
train_step reçoit un lot et retourne un dictionnaire de métriques. Tout le reste de l'infrastructure Keras continue de fonctionner : fit, les rappels du module 6, TensorBoard du module 7, la distribution du module 9. C'est le bon niveau d'intervention pour un écrêtage de gradient, un entraînement antagoniste, une accumulation de gradients sur plusieurs lots, ou une perte qui dépend d'autre chose que du seul couple entrée-sortie.
training=True dans l'appel à self(x, ...) n'est pas optionnel : c'est lui qui active l'abandon et la mise à jour des statistiques de normalisation.
En résumé
- Une couche personnalisée sépare hyperparamètres dans
__init__, poids dansbuildet calcul danscall;buildreçoit la forme d'entrée, ce qui rend la couche réutilisable. - Créer les poids avec
add_weightet nontf.Variable, sinon ils échappent au suivi de Keras et ne s'entraînent jamais. - Propager
training=trainingaux sous-couches ; l'oublier laisse l'abandon actif en prédiction, avec des scores pessimistes et aucun message d'erreur. - Pour modifier l'entraînement, redéfinir
train_stepplutôt qu'écrire une boucle complète : on gardefit, les rappels, TensorBoard et la distribution.
Module suivant : les pipelines tf.data, car un modèle rapide alimenté trop lentement reste lent.