Aller au contenu principal

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.

add_weight, et non tf.Variable

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 dans build et calcul dans call ; build reçoit la forme d'entrée, ce qui rend la couche réutilisable.
  • Créer les poids avec add_weight et non tf.Variable, sinon ils échappent au suivi de Keras et ne s'entraînent jamais.
  • Propager training=training aux 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_step plutôt qu'écrire une boucle complète : on garde fit, les rappels, TensorBoard et la distribution.

Module suivant : les pipelines tf.data, car un modèle rapide alimenté trop lentement reste lent.