Dans la partie 2, on a vu que LTN remplace les connecteurs et les quantificateurs par des opérateurs flous. On a aussi retenu une configuration recommandée, sans vraiment justifier ce choix.
Cette partie répond à la question laissée en suspens. Quand on se contente d’interroger une formule déjà construite, n’importe quel opérateur flou valide a un sens. Mais quand on veut apprendre, c’est-à-dire entraîner un modèle par descente de gradient à partir de cette formule, tous ne se valent pas. On va voir les problèmes que posent certains opérateurs, et pourquoi la configuration recommandée les évite.
Interroger : des opérateurs qui ne donnent pas les mêmes réponses
Le module ltn.fuzzy_ops fournit les sémantiques floues les plus courantes, construites à partir d’opérations PyTorch. Comparons quatre opérateurs :
- la t-norme produit : ;
- la t-norme de Łukasiewicz : ;
- l’agrégateur minimum : ;
- l’agrégateur
pMeanError, vu dans la partie 2 : .
Chacun porte un sens différent et peut être légitime selon l’intention de la requête. Mais sur les mêmes entrées, ils donnent des résultats très différents.
Prenons et . Avec le produit, . Avec Łukasiewicz, . Le résultat varie presque du simple au triple selon l’opérateur : le choix de la sémantique n’est pas neutre.
Le paramètre stable qui apparaît dans le code sera expliqué à la fin de cette partie.
import ltn
import torch
x1 = torch.tensor(0.4)
x2 = torch.tensor(0.7)
and_prod = ltn.fuzzy_ops.AndProd(stable=False)
and_luk = ltn.fuzzy_ops.AndLuk()
print(and_prod(x1, x2))
print(and_luk(x1, x2))
tensor(0.2800)
tensor(0.1000)
On observe le même type d’écart entre deux agrégateurs. Prenons la suite . Le minimum ne regarde que la pire valeur, , et ignore complètement les sept autres. pMeanError avec tient compte de toute la suite : les bonnes valeurs, comme les trois , compensent en partie les mauvaises, et le résultat est nettement plus élevé.
xs = torch.tensor([1., 1., 1., 0.5, 0.3, 0.2, 0.2, 0.1])
forall_min = ltn.fuzzy_ops.AggregMin()
forall_pME = ltn.fuzzy_ops.AggregPMeanError(p=4, stable=False)
print(forall_min(xs, dim=0))
print(forall_pME(xs, dim=0))
tensor(0.1000)
tensor(0.3134)
C’est ce qu’annonçait la démonstration de la partie 2 : avec un fini, pMeanError est une version lissée du minimum, pas le minimum lui-même. Toute la question de cette partie est là : pourquoi ce lissage est-il souhaitable pour l’entraînement, alors qu’il s’écarte de la sémantique logique stricte ?
Apprendre : trois pièges du gradient
Beaucoup d’opérateurs de logique floue ont des dérivées mal adaptées aux méthodes d’optimisation par gradient. Pour une analyse détaillée, voir van Krieken et al., Analyzing Differentiable Fuzzy Logic Operators (2020). On illustre ici trois problèmes caractéristiques sur des cas simples.
1. Le gradient qui s’annule
Certains opérateurs ont un gradient nul sur toute une partie de leur domaine, ce qui bloque l’apprentissage dans cette zone.
C’est le cas de la conjonction de Łukasiewicz, . Dès que , le renvoie , et la dérivée de par rapport à est nulle pour tout strictement négatif.
Avec et , on a , donc , et le gradient par rapport à comme à est exactement nul.
x1 = torch.tensor(0.3, requires_grad=True)
x2 = torch.tensor(0.5, requires_grad=True)
y = and_luk(x1, x2)
y.backward() # calcule les gradients
res = y.item()
gradients = [v.grad for v in [x1, x2]]
print(res) # résultat de la conjonction
print(gradients) # gradients par rapport à x1 et x2
0.0
[tensor(0.), tensor(0.)]
L’optimiseur ne reçoit aucun signal lui indiquant qu’il faudrait augmenter ou pour mieux satisfaire la conjonction, alors que c’est intuitivement ce qu’il faudrait faire. Et comme le gradient s’annule sur toute une région, pas en un point isolé, l’apprentissage peut y rester bloqué durablement.
2. Le gradient à passage unique
Certains opérateurs ne laissent passer le gradient que vers une seule entrée à la fois, ce qui prive toutes les autres de signal à cette étape.
C’est le comportement du minimum : sa dérivée par rapport à vaut pour l’indice qui réalise le minimum, et pour tous les autres, quelle que soit leur valeur.
Sur la suite , seul le dernier terme réalise le minimum :
xs = torch.tensor([1., 1., 1., 0.5, 0.3, 0.2, 0.2, 0.1], requires_grad=True)
y = forall_min(xs, dim=0)
res = y.item()
y.backward()
gradients = xs.grad
print(res)
print(gradients)
0.10000000149011612
tensor([0., 0., 0., 0., 0., 0., 0., 1.])
Seul reçoit un gradient. Les sept autres valeurs, y compris celles qui sont loin d’être parfaites comme ou , ne sont pas mises à jour. Sur un grand batch, c’est très inefficace : à chaque pas d’entraînement, un seul individu progresse pendant que tous les autres restent inchangés.
3. Le gradient qui explose
À l’inverse, certains opérateurs ont un gradient qui devient démesuré, voire infini, sur une partie de leur domaine.
C’est le cas de pMeanError quand toutes les entrées valent exactement . Chaque terme s’annule, la somme aussi, et l’expression prend la forme . Or la dérivée de vaut : avec , l’exposant est négatif, donc la dérivée croît sans limite quand s’approche de . Numériquement, on obtient une valeur non définie (nan).
xs = torch.tensor([1., 1., 1.], requires_grad=True)
y = forall_pME(xs, dim=0, p=4)
res = y.item()
y.backward()
gradients = xs.grad
print(res)
print(gradients)
1.0
tensor([nan, nan, nan])
La situation est paradoxale : le meilleur cas possible, où tous les individus sont parfaitement vrais, est justement celui qui rend l’entraînement instable. Un seul nan suffit à contaminer tous les poids du modèle à la mise à jour suivante.
La configuration produit stable
La configuration produit
La configuration recommandée dans la partie 2, qu’on appelle la configuration produit, est la suivante :
| Élément | Opérateur | Formule |
|---|---|---|
| négation standard | ||
| t-norme produit | ||
| t-conorme produit | ||
| implication de Reichenbach | ||
pMean | ||
pMeanError |
Elle n’échappe pourtant pas totalement aux problèmes qu’on vient de voir, mais seulement sur des cas limites bien identifiés :
- la t-norme produit a un gradient qui s’annule quand (ses dérivées valent et ) ;
- la t-conorme produit a un gradient qui s’annule quand (ses dérivées valent et ) ;
- l’implication de Reichenbach a un gradient qui s’annule quand et (ses dérivées valent et ) ;
pMeana un gradient qui explose quand tous les valent ;pMeanErrora un gradient qui explose quand tous les valent , exactement le cas qu’on vient d’observer.
La différence avec Łukasiewicz ou le minimum est importante : ici, les problèmes n’apparaissent qu’en des points précis, et non sur des régions entières du domaine.
La version stable
Puisque ces problèmes ne surviennent qu’en des points précis, une astuce simple suffit à les corriger, avec une petite valeur positive (par exemple ) :
- si le cas limite survient quand une entrée vaut , on remplace chaque entrée par ;
- si le cas limite survient quand une entrée vaut , on remplace chaque entrée par .
En décalant très légèrement les entrées pour qu’elles n’atteignent jamais exactement ou , on évite les points où la dérivée s’annule ou explose, sans changer de façon perceptible le résultat partout ailleurs. C’est cette version corrigée qu’on appelle stable.
On l’active avec le paramètre booléen stable, qu’on peut fixer à la création de l’opérateur ou changer à chaque appel. Reprenons le cas où toutes les entrées valaient :
xs = torch.tensor([1., 1., 1.], requires_grad=True)
y = forall_pME(xs, dim=0, p=4, stable=True) # le gradient n'explose plus
res = y.item()
y.backward()
gradients = xs.grad
print(res)
print(gradients)
0.9998999834060669
tensor([0.3333, 0.3333, 0.3333])
Le résultat passe de à , une différence négligeable, et les gradients ne sont plus nan : chaque entrée reçoit le même gradient fini, ce qui est logique puisqu’elles jouent toutes le même rôle.
Le choix de p
On a vu dans la partie 2 que permet d’écrire des formules plus ou moins strictes selon l’application. Mais il doit être choisi avec précaution, car il a des conséquences importantes sur l’entraînement.
Quand augmente fortement, pMeanError retombe dans le problème du gradient à passage unique. C’est cohérent avec la démonstration de la partie 2 : pMeanError tend vers le minimum quand , et le minimum a exactement ce défaut. Comparons les gradients avec et sur la même suite :
xs = torch.tensor([1., 1., 1., 0.5, 0.3, 0.2, 0.2, 0.1], requires_grad=True)
y = forall_pME(xs, dim=0, p=4)
res = y.item()
y.backward()
gradients = xs.grad
print(res)
print(gradients)
0.31339913606643677
tensor([0.0000, 0.0000, 0.0000, 0.0483, 0.1325, 0.1977, 0.1977, 0.2815])
xs = torch.tensor([1., 1., 1., 0.5, 0.3, 0.2, 0.2, 0.1], requires_grad=True)
y = forall_pME(xs, dim=0, p=20)
res = y.item()
y.backward()
gradients = xs.grad
print(res)
print(gradients)
0.18157517910003662
tensor([0.0000e+00, 0.0000e+00, 0.0000e+00, 1.0734e-05, 6.4147e-03, 8.1100e-02,
8.1100e-02, 7.6019e-01])
Avec , le gradient est réparti sur toutes les valeurs imparfaites, et d’autant plus fort que la valeur est mauvaise : pour , pour . Les trois ne reçoivent rien, ce qui est normal puisqu’ils sont déjà parfaitement vrais. Avec , le gradient se concentre presque entièrement sur (), et la valeur ne reçoit pratiquement plus rien (). On se rapproche du minimum strict.
Il peut donc être tentant de choisir un grand pour obtenir des résultats logiquement stricts quand on interroge une formule, mais c’est problématique à l’entraînement : l’opérateur devient presque à passage unique et se concentre à chaque étape sur les valeurs extrêmes, au détriment du reste du batch. Il est recommandé de ne pas fixer un trop élevé pendant l’apprentissage.
À retenir
- Pour interroger une formule, tout opérateur flou valide convient ; pour apprendre, le comportement du gradient devient décisif.
- Trois pièges : le gradient qui s’annule (Łukasiewicz), le gradient à passage unique (minimum et maximum) et le gradient qui explose (
pMeanErrorquand toutes les entrées valent 1). - La configuration produit n’a ces problèmes qu’en quelques points limites, que la version stable évite en décalant légèrement les entrées de ou de .
- Un trop élevé rend
pMeanetpMeanErrorpresque à passage unique : on garde un modéré pour l’entraînement.
On dispose maintenant de tous les outils : des symboles ancrés dans des tenseurs, des formules évaluées par des opérateurs différentiables, et des opérateurs dont le gradient se comporte bien. Dans la partie 4, on assemble le tout pour apprendre : une base de connaissances devient une fonction de perte, et un réseau s’entraîne à la satisfaire.
Le notebook complet de cette partie est disponible dans mon dépôt.
Notes de la semaine
Chaque dimanche, je partage ce que j'ai appris : articles de recherche, idées, expériences et questions qui me sont restées en tête.
Vous pouvez vous désabonner à tout moment en un clic.
Discussion sur cet article0
Rejoindre la discussion
A secure sign-in link will be sent to your email address.
Chargement de la discussion...