Module 3 — API fonctionnelle : entrées multiples et branches
Le module précédent s'est arrêté sur quatre limites de l'API séquentielle. L'API fonctionnelle les lève toutes, au prix d'une seule idée nouvelle : une couche est un objet appelable sur un tenseur.
Le changement de point de vue
En séquentiel, on déclare une liste de couches. En fonctionnel, on branche des tenseurs les uns aux autres et on désigne à la fin les extrémités du graphe.
from tensorflow import keras
from tensorflow.keras import layers
entree = keras.Input(shape=(784,))
h = layers.Dense(128, activation="relu")(entree)
h = layers.Dropout(0.2)(h)
sortie = layers.Dense(10, activation="softmax")(h)
modele = keras.Model(inputs=entree, outputs=sortie)
layers.Dense(128) crée la couche ; le (entree) qui suit l'applique. Ces deux étapes, confondues en séquentiel, sont ici séparées, et c'est cette séparation qui rend tout le reste possible.
Le tenseur manipulé est symbolique : il ne contient aucune donnée, seulement une forme et un type. Keras l'utilise pour vérifier la compatibilité des couches au moment de la construction, avant tout entraînement.
Deux entrées de natures différentes
Prenons un cas concret : prédire le prix d'un logement à partir de sa photographie et de ses caractéristiques chiffrées. Deux natures de données, donc deux traitements avant fusion.
image = keras.Input(shape=(128, 128, 3), name="image")
tableau = keras.Input(shape=(12,), name="caracteristiques")
v = layers.Conv2D(32, 3, activation="relu")(image)
v = layers.GlobalAveragePooling2D()(v)
t = layers.Dense(32, activation="relu")(tableau)
fusion = layers.Concatenate()([v, t])
h = layers.Dense(64, activation="relu")(fusion)
prix = layers.Dense(1, name="prix")(h)
modele = keras.Model(inputs=[image, tableau], outputs=prix)
Nommer les entrées n'est pas cosmétique : cela permet d'alimenter fit avec un dictionnaire, bien plus lisible et bien moins fragile qu'un ordre positionnel.
modele.fit({"image": images, "caracteristiques": tableaux}, prix_reels, epochs=10)
Concatenate fusionne sur le dernier axe par défaut : toutes les autres dimensions doivent coïncider. Fusionner un tenseur (None, 8, 8, 32) avec un (None, 32) échoue. C'est pourquoi le GlobalAveragePooling2D ci-dessus est indispensable : il réduit la sortie convolutive à un vecteur, qui devient alors compatible avec la branche tabulaire.
Choisir sa fusion
Concatenate n'est pas la seule option, et le choix a des conséquences.
| Couche de fusion | Effet | Quand l'employer |
|---|---|---|
Concatenate | juxtapose, la dimension s'additionne | branches d'informations distinctes |
Add | somme terme à terme, dimensions identiques | connexions résiduelles |
Multiply | produit terme à terme | mécanismes de porte et d'attention |
Average | moyenne | assemblage de modèles équivalents |
La distinction entre Concatenate et Add est structurante. La concaténation conserve toute l'information des deux branches et laisse la couche suivante décider quoi en faire, au prix d'une dimension plus grande. L'addition superpose les deux signaux et n'augmente rien, mais suppose que les deux branches vivent dans le même espace de représentation. C'est exactement le mécanisme de la connexion résiduelle du module 5 du cours 07.
Deux sorties, deux pertes, une pondération
Un même réseau peut apprendre plusieurs tâches, ce qui est souvent bénéfique : les tâches se régularisent mutuellement.
entree = keras.Input(shape=(64,))
tronc = layers.Dense(128, activation="relu")(entree)
categorie = layers.Dense(5, activation="softmax", name="categorie")(tronc)
montant = layers.Dense(1, name="montant")(tronc)
modele = keras.Model(inputs=entree, outputs=[categorie, montant])
modele.compile(
optimizer="adam",
loss={"categorie": "sparse_categorical_crossentropy", "montant": "mse"},
loss_weights={"categorie": 1.0, "montant": 0.2},
metrics={"categorie": ["accuracy"], "montant": ["mae"]},
)
loss_weights est le paramètre qu'on oublie et qu'il faut régler. Les deux pertes n'ont aucune raison d'avoir le même ordre de grandeur : une entropie croisée tourne autour de 1, une erreur quadratique sur des montants en euros peut atteindre 10 000. Sans pondération, l'optimiseur consacre toute son énergie à la seconde et la classification n'apprend rien.
Lancez d'abord une époque et relevez l'ordre de grandeur de chaque perte. Choisissez ensuite les poids pour que les contributions soient comparables : si la régression vaut 500 fois la classification, un poids de 0,002 sur la régression les remet à égalité. Ajustez ensuite selon la tâche qui vous importe le plus, en connaissance de cause.
Partager des poids entre deux branches
Une couche appliquée deux fois utilise les mêmes poids aux deux endroits. C'est le fondement des architectures qui comparent deux entrées.
encodeur = layers.Dense(64, activation="relu") # cree une seule fois
gauche = keras.Input(shape=(128,))
droite = keras.Input(shape=(128,))
eg = encodeur(gauche) # memes poids
ed = encodeur(droite) # memes poids
ecart = layers.Subtract()([eg, ed])
similaire = layers.Dense(1, activation="sigmoid")(ecart)
modele = keras.Model(inputs=[gauche, droite], outputs=similaire)
Les deux entrées passent par le même encodeur, donc dans le même espace de représentation, et leur écart devient comparable. Créer deux couches Dense(64) distinctes casserait tout le raisonnement : chacune apprendrait sa propre projection et la soustraction n'aurait plus de sens.
Réutiliser un modèle comme une couche
Un keras.Model est lui-même appelable, ce qui permet d'assembler des blocs.
def bloc_residuel(dimension):
e = keras.Input(shape=(dimension,))
h = layers.Dense(dimension, activation="relu")(e)
h = layers.Dense(dimension)(h)
s = layers.Add()([e, h])
return keras.Model(e, s, name=f"residuel_{dimension}")
bloc = bloc_residuel(64)
x = bloc(x) # s'insere comme une couche ordinaire
Cette composition rend les architectures profondes lisibles, et model.summary() affiche alors le bloc comme une seule ligne, ce qui évite les résumés de trois cents lignes.
En résumé
- En fonctionnel, créer une couche et l'appliquer sont deux gestes distincts ; c'est cette séparation qui autorise branches, fusions et poids partagés.
- Les entrées nommées permettent d'alimenter
fitpar dictionnaire ;Concatenateconserve l'information des branches,Addles superpose sans augmenter la dimension. - Avec plusieurs sorties,
loss_weightsest indispensable : sans lui, la perte du plus grand ordre de grandeur écrase les autres. - Appliquer une même couche à deux tenseurs partage ses poids, ce qui place les deux entrées dans un espace de représentation commun.
Module suivant : les couches personnalisées, pour les cas où aucune couche existante ne fait ce que l'on veut.