SL-7 : Integration Neuro-Symbolique

Navigation : Index | << SL-6 | Suivant >>

Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Comprendre les motivations de l’integration neuro-symbolique 2. Implementer des opérateurs logiques differentiables (t-norms) 3. Construire un reseau de predicats neuronaux simplifie 4. Appliquer le raisonnement guide par la logique sur un reseau de neurones 5. Implementer un mini-système DeepProbLog

Prerequis

  • SL-1 a SL-5 completes
  • Notions de base en numpy (opérations matricielles)
  • Python 3.10+ (numpy uniquement)

Duree estimee : 50-60 minutes


L’integration neuro-symbolique combine les reseaux de neurones (apprentissage a partir de données) avec le raisonnement symbolique (logique, règles). L’objectif est d’obtenir le meilleur des deux mondes.

1. Pourquoi combiner neural et symbolique ?

Force Neuronal Symbolique
Apprentissage a partir de données Excellent Impossible
Raisonnement formel Aucune garantie Preuves mathematiques
Tolerance au bruit Elevee Faible
Interpretation Boite noire Transparent
Generalisation Interpolative Deductive
import numpy as np
from typing import List, Tuple, Callable

np.random.seed(42)  # reproductibilite des initialisations aleatoires

print('=== SL-5 : Integration Neuro-Symbolique ===')
print(f'NumPy version : {np.__version__}')
=== SL-5 : Integration Neuro-Symbolique ===
NumPy version : 1.26.0

Interpretation : Environnement

Ce notebook utilise uniquement numpy pour les calculs. Aucune bibliotheque d’apprentissage profond n’est requise : nous implementons les concepts fondamentaux a la main pour bien les comprendre.

2. Opérateurs logiques differentiables

En logique classique, les connecteurs sont binaires (vrai/faux). En logique floue ou differentiable, on remplace ces valeurs binaires par des nombres dans [0, 1]. Les t-norms et t-conorms sont les generalisations differentiables de AND et OR.

Connecteur Logique classique Version differentiable (utilisee ici)
AND(a, b) min(a, b) a * b (t-norm produit)
OR(a, b) max(a, b) a + b - a*b (t-conorm probabiliste)
NOT(a) 1 - a 1 - a
IMPLIES(a, b) NOT(a) OR b min(1, b/a) (implication de Goguen, residuelle du produit)

Il existe plusieurs familles de t-norms : produit (a*b), Godel (min(a,b), differentiable presque partout) et Lukasiewicz (max(0, a+b-1), cf. exercice 4). Chaque t-norm induit son implication residuelle ; celle du produit est Goguen.

Origine. Les t-norms (produit, Godel/min, Lukasiewicz) et leurs implications residuelles (Goguen, etc.) sont l’objet classique de la logique floue ; la reference de synthese est Klement, E. P., Mesiar, R. & Pap, E. (2000), Triangular Norms, Springer, qui couvre les trois familles utilisees ici.

def fuzzy_and(a: float, b: float) -> float:
    return a * b

def fuzzy_or(a: float, b: float) -> float:
    return a + b - a * b

def fuzzy_not(a: float) -> float:
    return 1.0 - a

def fuzzy_implies(a: float, b: float) -> float:
    return min(1.0, b / max(a, 1e-10))

# Demonstration
print('=== Tables de verite floues ===')
print('a\tb\tAND\tOR\tNOT(a)\tIMPLIES')
print('-' * 50)
for a in [0.0, 0.3, 0.5, 0.7, 1.0]:
    for b in [0.0, 0.5, 1.0]:
        print(f'{a:.1f}\t{b:.1f}\t{fuzzy_and(a,b):.2f}\t{fuzzy_or(a,b):.2f}\t{fuzzy_not(a):.2f}\t{fuzzy_implies(a,b):.2f}')
    print()
=== Tables de verite floues ===
a   b   AND OR  NOT(a)  IMPLIES
--------------------------------------------------
0.0 0.0 0.00    0.00    1.00    0.00
0.0 0.5 0.00    0.50    1.00    1.00
0.0 1.0 0.00    1.00    1.00    1.00

0.3 0.0 0.00    0.30    0.70    0.00
0.3 0.5 0.15    0.65    0.70    1.00
0.3 1.0 0.30    1.00    0.70    1.00

0.5 0.0 0.00    0.50    0.50    0.00
0.5 0.5 0.25    0.75    0.50    1.00
0.5 1.0 0.50    1.00    0.50    1.00

0.7 0.0 0.00    0.70    0.30    0.00
0.7 0.5 0.35    0.85    0.30    0.71
0.7 1.0 0.70    1.00    0.30    1.00

1.0 0.0 0.00    1.00    0.00    0.00
1.0 0.5 0.50    1.00    0.00    0.50
1.0 1.0 1.00    1.00    0.00    1.00

Interpretation : Opérateurs differentiables

Les opérateurs flous transforment les connecteurs logiques binaires en fonctions continues derivables : - AND(a,b) = ab : la verite de la conjonction est le produit des verites - OR(a,b) = a+b-ab : la disjonction probabiliste - IMPLIES(a,b) : vaut 1 si b >= a (l’implication est satisfaite)

Ces fonctions sont derivables, ce qui permet de les utiliser dans des reseaux de neurones.

3. Predicats neuronaux

Un predicat neuronal est une fonction P(x1, …, xn) -> [0, 1] implementee par un petit reseau de neurones. Il associe une valeur de verite continue a un tuple d’entrees.

class NeuralPredicate:
    """Un predicat neuronal : P(x) -> [0, 1]."""
    
    def __init__(self, n_inputs: int, name: str = 'P'):
        self.name = name
        self.weights = np.random.randn(n_inputs) * 0.1
        self.bias = np.random.randn() * 0.1
    
    def __call__(self, x: np.ndarray) -> float:
        z = np.dot(self.weights, x) + self.bias
        return 1.0 / (1.0 + np.exp(-z))
    
    def gradient(self, x: np.ndarray) -> Tuple[np.ndarray, float]:
        val = self(x)
        d_sigmoid = val * (1 - val)
        return d_sigmoid * x, d_sigmoid
    
    def update(self, grad_w: np.ndarray, grad_b: float, lr: float = 0.1):
        self.weights += lr * grad_w
        self.bias += lr * grad_b

# Test
parent_pred = NeuralPredicate(n_inputs=2, name='parent')
x_test = np.array([1.0, 0.5])
print(f'parent({x_test[0]:.1f}, {x_test[1]:.1f}) = {parent_pred(x_test):.4f}')
parent(1.0, 0.5) = 0.5269

Interpretation : Predicats neuronaux

Chaque predicat neuronal est un petit classificateur logistique. Initialement, les poids sont aleatoires, donc les predictions sont proches de 0.5. L’entrainement va ajuster ces poids.

4. Logique Tensorielle (LTN) simplifiee

Les Logical Tensor Networks (LTN) combinent des predicats neuronaux avec des connecteurs logiques differentiables. L’idee est de maximiser la verite globale d’un ensemble de formules logiques.

# Encodage des individus
entities = {'Marie': 0, 'Pierre': 1, 'Paul': 2, 'Sophie': 3, 'Luc': 4}
entity_embeddings = np.eye(len(entities), dtype=float)

def get_embedding(name: str) -> np.ndarray:
    return entity_embeddings[entities[name]]

def pair_embedding(s: str, o: str) -> np.ndarray:
    """Concatenation des one-hot du sujet et de l'objet (2 x 5 = 10 entrees)."""
    return np.concatenate([get_embedding(s), get_embedding(o)])

# Entrainement LTN
parent_pred = NeuralPredicate(n_inputs=2 * len(entities), name='parent')

positive = [('Marie', 'Pierre'), ('Pierre', 'Paul'), ('Sophie', 'Luc')]
# Negatifs echantillonnes en monde clos (comme pour les embeddings de KG, cf. SL-6)
negative = [('Marie', 'Paul'), ('Pierre', 'Sophie'), ('Sophie', 'Pierre'),
            ('Paul', 'Marie'), ('Luc', 'Luc'), ('Luc', 'Sophie')]

print('=== Entrainement du predicat neuronal "parent" ===')
# Gradient de l'entropie croisee : pour une sortie sigmoide, la derivee de la
# perte par rapport au score z se simplifie en (val - cible) --- la derivee de
# la sigmoide se compense. D'ou la mise a jour w += lr * (cible - val) * x.
lr = 0.5
for epoch in range(150):
    total_loss = 0.0
    for s, o in positive:
        x = pair_embedding(s, o)
        val = parent_pred(x)
        total_loss += -np.log(max(val, 1e-10))
        parent_pred.update(x * (1 - val), (1 - val), lr)
    for s, o in negative:
        x = pair_embedding(s, o)
        val = parent_pred(x)
        total_loss += -np.log(max(1 - val, 1e-10))
        parent_pred.update(-x * val, -val, lr)
    if (epoch + 1) % 30 == 0:
        print(f'  Epoch {epoch+1:2d} : perte = {total_loss:.4f}')

print('\n=== Resultats (cible : positifs proches de 1, negatifs proches de 0) ===')
for s, o in positive:
    print(f'  parent({s}, {o}) = {parent_pred(pair_embedding(s, o)):.4f}   [positif]')
for s, o in negative:
    print(f'  parent({s}, {o}) = {parent_pred(pair_embedding(s, o)):.4f}   [negatif]')
=== Entrainement du predicat neuronal "parent" ===
  Epoch 30 : perte = 2.3809
  Epoch 60 : perte = 1.3391
  Epoch 90 : perte = 0.9147
  Epoch 120 : perte = 0.6909
  Epoch 150 : perte = 0.5539

=== Resultats (cible : positifs proches de 1, negatifs proches de 0) ===
  parent(Marie, Pierre) = 0.8966   [positif]
  parent(Pierre, Paul) = 0.9193   [positif]
  parent(Sophie, Luc) = 0.9143   [positif]
  parent(Marie, Paul) = 0.0746   [negatif]
  parent(Pierre, Sophie) = 0.0353   [negatif]
  parent(Sophie, Pierre) = 0.0804   [negatif]
  parent(Paul, Marie) = 0.0032   [negatif]
  parent(Luc, Luc) = 0.0391   [negatif]
  parent(Luc, Sophie) = 0.0000   [negatif]

Interpretation : Entrainement LTN

Le predicat neuronal parent apprend a distinguer les vraies relations parentales (valeur proche de 1) des fausses (valeur proche de 0). L’entrainement utilise la descente de gradient classique sur une perte de type entropie croisee.

Deux choix de representation sont importants :

  • Encodage des paires : on concatene les one-hot du sujet et de l’objet (10 entrees). Le classificateur logistique apprend alors un score additif w(sujet) + w(objet) — suffisant ici car les exemples sont lineairement separables dans cette representation.
  • Negatifs en monde clos : comme aucune base ne fournit de “non-parents” explicites, on echantillonne des paires absentes des faits positifs. C’est exactement la stratégie d’entrainement des embeddings de Knowledge Graphs (SL-8).
Avant entrainement Après entrainement (150 epoques)
Valeurs ~0.5 (aleatoire) Positifs ~0.9, negatifs < 0.1

5. Raisonnement guide par la logique

En Neuro-Symbolique, on peut utiliser des règles logiques pour guider l’entrainement d’un reseau de neurones. Par exemple : si parent(X,Y) AND parent(Y,Z), alors grandparent(X,Z) devrait etre vrai.

grandparent_pred = NeuralPredicate(n_inputs=2 * len(entities), name='grandparent')
chains = [('Marie', 'Pierre', 'Paul')]

print('=== Entrainement guide par regle ===')
print('Regle : parent(X,Y) AND parent(Y,Z) => grandparent(X,Z)')

lr = 0.5
for epoch in range(30):
    total_loss = 0.0
    for g, p, c in chains:
        x_gc = pair_embedding(g, c)
        gp_val = parent_pred(pair_embedding(g, p))
        pc_val = parent_pred(pair_embedding(p, c))
        gc_val = grandparent_pred(x_gc)
        body = fuzzy_and(gp_val, pc_val)
        rule_truth = fuzzy_implies(body, gc_val)
        loss = -np.log(max(rule_truth, 1e-10))
        total_loss += loss
        gw, gb = grandparent_pred.gradient(x_gc)
        error = body - gc_val
        grandparent_pred.update(gw * error, gb * error, lr)
    if (epoch + 1) % 10 == 0:
        print(f'  Epoch {epoch+1:2d} : perte = {total_loss:.4f}')

print('\n=== Verifications ===')
x_gc = pair_embedding('Marie', 'Paul')
print(f'grandparent(Marie, Paul) = {grandparent_pred(x_gc):.4f}')
print(f'  (Attendu : eleve, proche de la verite du corps de la regle ~0.8)')
x_sl = pair_embedding('Sophie', 'Luc')
print(f'grandparent(Sophie, Luc) = {grandparent_pred(x_sl):.4f}')
print(f'  (Attendu : ~0.5 --- paire non couverte par la regle, donc non contrainte)')
=== Entrainement guide par regle ===
Regle : parent(X,Y) AND parent(Y,Z) => grandparent(X,Z)
  Epoch 10 : perte = 0.2284
  Epoch 20 : perte = 0.1096
  Epoch 30 : perte = 0.0637

=== Verifications ===
grandparent(Marie, Paul) = 0.7757
  (Attendu : eleve, proche de la verite du corps de la regle ~0.8)
grandparent(Sophie, Luc) = 0.5821
  (Attendu : ~0.5 --- paire non couverte par la regle, donc non contrainte)

Interpretation : Raisonnement guide

Le predicat grandparent a ete entraine sans exemples directs de grand-parent. Il a appris uniquement a partir de la règle logique parent(X,Y) AND parent(Y,Z) => grandparent(X,Z).

C’est la puissance du neuro-symbolique : la connaissance logique guide l’apprentissage neuronal, même sans données etiquetees pour le predicat cible. > Limite a noter : la règle est un modus ponens flou — elle ne fournit > qu’une contrainte positive (si le corps est vrai, la tete doit l’etre). > Pour la paire (Sophie, Luc), aucune chaîne parent-parent n’existe : le predicat > reste a son initialisation (~0.5), ni vrai ni faux. Pousser ces paires vers 0 > demanderait des exemples negatifs ou une hypothese de monde clos.

6. DeepProbLog simplifie

DeepProbLog combine la programmation logique probabiliste avec des predicats neuronaux.

from itertools import product as iter_product

class SimpleDeepProbLog:
    def __init__(self):
        self.neural_preds = {}
        self.rules = []
        self.facts = {}

    def add_neural_predicate(self, name, pred):
        self.neural_preds[name] = pred

    def add_rule(self, body, head):
        self.rules.append((body, head))

    def add_fact(self, predicate, args, prob):
        self.facts[(predicate, args)] = prob

    def _is_variable(self, arg):
        return len(arg) == 1 and arg.isupper()

    def query(self, predicate, args, depth=0, max_depth=5):
        """Chainage arriere : probabilite que predicate(args) soit vrai.

        Les variables du corps non liees par la tete sont EXISTENTIELLES :
        on enumere les individus et on garde le meilleur binding (max).
        """
        if depth > max_depth:
            return 0.0
        key = (predicate, args)
        if key in self.facts:
            return self.facts[key]
        if predicate in self.neural_preds:
            x = np.concatenate([get_embedding(a) for a in args])
            return float(self.neural_preds[predicate](x))
        max_prob = 0.0
        for body, head in self.rules:
            if head[0] != predicate or len(head[1]) != len(args):
                continue
            subst = {}
            match = True
            for h_a, q_a in zip(head[1], args):
                if self._is_variable(h_a):
                    subst[h_a] = q_a
                elif h_a != q_a:
                    match = False
                    break
            if not match:
                continue
            free = sorted({a for _, b_args in body for a in b_args
                           if self._is_variable(a) and a not in subst})
            for binding in iter_product(entities, repeat=len(free)):
                s = dict(subst)
                s.update(zip(free, binding))
                body_truth = 1.0
                for b_pred, b_args in body:
                    resolved = tuple(s.get(a, a) for a in b_args)
                    body_truth = fuzzy_and(
                        body_truth, self.query(b_pred, resolved, depth + 1, max_depth))
                    if body_truth == 0.0:
                        break
                max_prob = max(max_prob, body_truth)
        return max_prob

dpl = SimpleDeepProbLog()
dpl.add_neural_predicate('parent', parent_pred)
dpl.add_rule([('parent', ('X', 'Y')), ('parent', ('Y', 'Z'))], ('grandparent', ('X', 'Z')))

print('=== Requetes DeepProbLog ===')
for pred, args in [('parent', ('Marie', 'Pierre')), ('parent', ('Marie', 'Paul')),
                   ('grandparent', ('Marie', 'Paul')), ('grandparent', ('Sophie', 'Luc'))]:
    prob = dpl.query(pred, args)
    print(f'  {pred}{args} = {prob:.4f}')
=== Requetes DeepProbLog ===
  parent('Marie', 'Pierre') = 0.8966
  parent('Marie', 'Paul') = 0.0746
  grandparent('Marie', 'Paul') = 0.8242
  grandparent('Sophie', 'Luc') = 0.0804

Interpretation : DeepProbLog

Le système DeepProbLog combine : 1. Predicats neuronaux : parent est le predicat appris en section 4 2. Règles logiques : grandparent(X,Z) :- parent(X,Y), parent(Y,Z) 3. Inference : la requête grandparent(Marie, Paul) est resolue par chainage arriere. La variable Y du corps n’est pas liee par la tete : elle est existentielle. Le moteur enumere donc les 5 individus et garde le meilleur binding — ici Y = Pierre, qui donne parent(Marie, Pierre) * parent(Pierre, Paul), un produit eleve.

La probabilite du corps est le produit des probabilites de ses atomes (t-norm produit), et la quantification existentielle est approximee par le max sur les bindings. grandparent(Sophie, Luc) reste faible : aucune chaîne parent-parent plausible ne relie Sophie a Luc en deux pas.

7. La vraie librairie : LTNtorch

Les sections 2-5 ont construit une LTN jouet en NumPy pur : c’est ideal pour comprendre la mecanique (opérateurs flous, gradient, modus ponens differentiable), mais l’implementation reelle de reference est LTNtorch (Badreddine, d’Avila Garcez, Serafini & Spranger, Logic Tensor Networks, Artificial Intelligence 303, 2022). Ce qu’elle apporte par rapport a notre jouet :

Notre implementation (sections 2-5) LTNtorch
Gradient calcule a la main, perceptron 1 couche Autograd PyTorch : n’importe quel nn.Module comme predicat
Une règle = une paire codee en dur Quantificateurs \(\forall\)/\(\exists\) avec broadcasting : la règle est evaluee sur tout le produit cartesien
2 t-normes codees a la main Catalogue complet de sémantiques floues (fuzzy_ops) : produit, Godel, Lukasiewicz…
CPU, petites boucles Python Tenseurs batches, GPU si disponible

Installation : pip install LTNtorch — attention, pas pip install ltn, qui est un paquet homonyme base TensorFlow avec une autre API.

On reprend exactement la même tâche que les sections 4-5 (mêmes individus, mêmes faits parent), avec une différence importante : le predicat grandparent sera appris sans aucun fait direct, uniquement contraint par la règle logique \(\forall x, y, z : parent(x,y) \wedge parent(y,z) \Rightarrow grandparent(x,z)\), quantifiee sur les \(5^3 = 125\) triplets d’individus.

# LTNtorch : memes faits, vraie librairie (pip install LTNtorch)
import warnings
warnings.filterwarnings("ignore", message=".*pynvml.*")  # bruit cosmetique torch.cuda/Windows
import torch
import ltn

torch.manual_seed(42)

# Memes individus et memes faits que la section 4 (reutilises du kernel)
n = len(entities)
emb = torch.eye(n)

# Negatifs closed-world pour grandparent : tout sauf (Marie, Paul).
# On inclut (Marie, Pierre) -- un VRAI parent -- comme negatif difficile.
gp_negative = [('Marie', 'Pierre'), ('Pierre', 'Marie'), ('Sophie', 'Paul'),
               ('Paul', 'Luc'), ('Luc', 'Sophie'), ('Pierre', 'Paul')]


class PairPredicate(torch.nn.Module):
    """Predicat binaire : MLP sur la concatenation des embeddings (s, o).

    Contrairement a notre NeuralPredicate (1 couche, gradient manuel),
    n'importe quelle architecture differentiable convient : l'autograd
    de PyTorch se charge du calcul du gradient a travers les operateurs flous.
    """
    def __init__(self):
        super().__init__()
        self.net = torch.nn.Sequential(
            torch.nn.Linear(2 * n, 16), torch.nn.ELU(),
            torch.nn.Linear(16, 1), torch.nn.Sigmoid())

    def forward(self, s, o):
        return self.net(torch.cat([s, o], dim=-1)).squeeze(-1)


Parent = ltn.Predicate(PairPredicate())
Grandparent = ltn.Predicate(PairPredicate())

# Semantique floue : les memes operateurs que notre section 2, version librairie
Not = ltn.Connective(ltn.fuzzy_ops.NotStandard())
And = ltn.Connective(ltn.fuzzy_ops.AndProd())            # t-norme produit
Implies = ltn.Connective(ltn.fuzzy_ops.ImpliesReichenbach())
Forall = ltn.Quantifier(ltn.fuzzy_ops.AggregPMeanError(p=2), quantifier="f")
sat_agg = ltn.fuzzy_ops.SatAgg()                          # agregation des axiomes


def pairs_to_vars(pairs, prefix):
    """Transforme une liste de paires (sujet, objet) en variables LTN batchees."""
    s = ltn.Variable(prefix + "_s", torch.stack([emb[entities[a]] for a, b in pairs]))
    o = ltn.Variable(prefix + "_o", torch.stack([emb[entities[b]] for a, b in pairs]))
    return s, o


pos_s, pos_o = pairs_to_vars(positive, "pos")      # faits parent (section 4)
neg_s, neg_o = pairs_to_vars(negative, "neg")      # negatifs closed-world (section 4)
gpn_s, gpn_o = pairs_to_vars(gp_negative, "gpn")

# Variables libres sur TOUS les individus : le Forall de l'axiome 3 est
# evalue sur le produit cartesien complet (5x5x5 = 125 triplets) par
# broadcasting -- la ou notre jouet ne traitait qu'une paire codee en dur.
x = ltn.Variable("x", emb)
y = ltn.Variable("y", emb)
z = ltn.Variable("z", emb)

params = list(Parent.parameters()) + list(Grandparent.parameters())
opt = torch.optim.Adam(params, lr=0.05)

print("=== Entrainement LTNtorch (4 axiomes, satisfaction a maximiser) ===")
for epoch in range(400):
    opt.zero_grad()
    axioms = [
        # 1-2. Faits parent : positifs vrais, negatifs faux (ltn.diag = paires
        #      alignees element par element, pas de produit cartesien)
        Forall(ltn.diag(pos_s, pos_o), Parent(pos_s, pos_o)),
        Forall(ltn.diag(neg_s, neg_o), Not(Parent(neg_s, neg_o))),
        # 3. LA regle : aucun fait grandparent n'est fourni, seule cette
        #    contrainte universelle relie les deux predicats
        Forall([x, y, z],
               Implies(And(Parent(x, y), Parent(y, z)), Grandparent(x, z))),
        # 4. Negatifs closed-world pour grandparent (sinon "tout est
        #    grandparent" satisferait trivialement l'implication)
        Forall(ltn.diag(gpn_s, gpn_o), Not(Grandparent(gpn_s, gpn_o))),
    ]
    sat = sat_agg(*axioms)
    loss = 1.0 - sat
    loss.backward()
    opt.step()
    if (epoch + 1) % 100 == 0:
        print(f"  Epoch {epoch+1:3d} : satisfaction globale = {sat.item():.4f}")


def query(P, a, b):
    """Interroge un predicat appris sur une paire d'individus."""
    return P(ltn.Constant(emb[entities[a]]), ltn.Constant(emb[entities[b]])).value.item()


print()
print("=== Predicat parent (faits fournis, comme section 4) ===")
for s, o in positive:
    print(f"  parent({s}, {o}) = {query(Parent, s, o):.3f}   [positif]")
for s, o in negative[:3]:
    print(f"  parent({s}, {o}) = {query(Parent, s, o):.3f}   [negatif]")

print()
print("=== Predicat grandparent (AUCUN fait fourni, appris via la regle) ===")
print(f"  grandparent(Marie, Paul)   = {query(Grandparent, 'Marie', 'Paul'):.3f}   [seul vrai grandparent]")
for s, o in [('Marie', 'Pierre'), ('Sophie', 'Paul'), ('Luc', 'Marie')]:
    print(f"  grandparent({s}, {o}) = {query(Grandparent, s, o):.3f}   [faux]")
=== Entrainement LTNtorch (4 axiomes, satisfaction a maximiser) ===
  Epoch 100 : satisfaction globale = 0.8930
  Epoch 200 : satisfaction globale = 0.8930
  Epoch 300 : satisfaction globale = 0.8930
  Epoch 400 : satisfaction globale = 0.9242

=== Predicat parent (faits fournis, comme section 4) ===
  parent(Marie, Pierre) = 0.981   [positif]
  parent(Pierre, Paul) = 0.980   [positif]
  parent(Sophie, Luc) = 0.999   [positif]
  parent(Marie, Paul) = 0.000   [negatif]
  parent(Pierre, Sophie) = 0.001   [negatif]
  parent(Sophie, Pierre) = 0.000   [negatif]

=== Predicat grandparent (AUCUN fait fourni, appris via la regle) ===
  grandparent(Marie, Paul)   = 1.000   [seul vrai grandparent]
  grandparent(Marie, Pierre) = 0.043   [faux]
  grandparent(Sophie, Paul) = 0.000   [faux]
  grandparent(Luc, Marie) = 0.000   [faux]

Interpretation : LTNtorch

Le résultat cle est le predicat grandparent : il atteint une verite proche de 1 sur l’unique vraie paire (Marie, Paul) et proche de 0 ailleurs, alors qu’aucun fait grandparent ne lui a ete fourni. L’information a circule de parent vers grandparent a travers l’axiome universel — c’est le raisonnement guide par la logique de la section 5, mais quantifie sur les 125 triplets au lieu d’une paire codee en dur, et entraine par autograd au lieu de notre gradient manuel.

Deux details de modelisation meritent l’attention :

  1. ltn.diag vs produit cartesien. Pour les faits (axiomes 1, 2, 4), les variables sujet/objet sont verrouillees en diagonale : la i-eme ligne de pos_s va avec la i-eme ligne de pos_o. Sans diag, LTNtorch evaluerait le predicat sur toutes les combinaisons — sémantique voulue uniquement pour l’axiome 3.

  2. L’axiome 4 est indispensable. Une implication \(A \Rightarrow B\) se satisfait trivialement en rendant \(B\) vrai partout. Les negatifs closed-world sur grandparent (dont (Marie, Pierre), un vrai parent mais pas grandparent) forcent le reseau a discriminer au lieu de tout accepter — le même rôle que les negatifs echantillonnes de la section 4.

Pour aller plus loin avec la librairie : les exemples officiels LTNtorch couvrent la classification multi-classe, le clustering contraint et la regression avec contraintes logiques.

8. Comparaison des approches neuro-symboliques

Approche Principe Forces Limites
LTN Predicats neuronaux + sémantique logique Theoriquement fonde Complexe a entrainer
Neural Theorem Prover Unification differentiable Interpretable Passage a l’echelle
DeepProbLog Problog + predicats neuronaux Flexible Complexite combinatoire
EBL Neural Explications differentiables Efficace Domaine-spécifique

Exercices et exemple guidé

Cette section illustre les concepts par la pratique. Elle contient :

  • Exemple guidé 1 : opérateur OR différentiable paramétrique (solution démontrée — bascule #1455).
  • Exercice 2 : entraînement LTN d’un prédicat frere puis dérivation d’oncle.
  • Exercice 3 : règle transitive ancestor dans DeepProbLog.
  • Exercice 4 (nouveau, ajouté avec la bascule #1455) : t-norm de Łukasiewicz et famille paramétrique à 3 t-conorms.

Exemple guidé 1 : Opérateur OR différentiable paramétrique

Bascule TP étudiant → Exemple guidé (#1455). Solution démontrée ci-dessous.

Objectif

Construire une fonction parametric_or(a, b, alpha) qui interpole entre deux opérateurs OR flous classiques :

alpha Forme Interprétation
0.0 max(a, b) (Zadeh / Gödel t-conorm) OR optimiste : “au moins l’un”
1.0 a + b - a * b (Goguen / probabiliste) OR indépendant : événements probabilistes
0 < alpha < 1 combinaison convexe des deux t-conorm paramétrique

L’intérêt pédagogique : le paramètre alpha devient différentiable, donc apprenable dans un réseau neuro-symbolique. On peut laisser le réseau choisir entre les deux sémantiques selon la tâche.

Solution démontrée

La formule est une combinaison convexe :

\[\text{parametric\_or}(a, b, \alpha) = \alpha \cdot (a + b - a \cdot b) + (1 - \alpha) \cdot \max(a, b)\]

Propriétés vérifiées dans la cellule code suivante : - Bornes : parametric_or(0, 0, alpha) = 0 et parametric_or(1, 1, alpha) = 1 - Continuité en alpha : le passage de max à a+b-ab est lisse - Monotonie : parametric_or est croissante en a (et en b) pour tout alpha ∈ [0, 1]

# Exemple guide 1 : Operateur OR parametrique
# Bascule TP -> Exemple guide (#1455) : solution demontree ci-dessous.

# Indice initial conserve a titre pedagogique :
#   parametric_or(a, b, alpha) = alpha * (a + b - a*b) + (1 - alpha) * max(a, b)

def parametric_or(a: float, b: float, alpha: float) -> float:
    """OR flou parametrique interpolant Godel (max) et probabiliste (a+b-a*b).

    Parameters
    ----------
    a, b : float in [0, 1]
        Verites floues des deux propositions.
    alpha : float in [0, 1]
        Poids d'interpolation. alpha=0 -> max(a, b) (Godel),
        alpha=1 -> a + b - a*b (probabiliste).

    Returns
    -------
    float in [0, 1]
        Combinaison convexe des deux t-conorms.
    """
    return alpha * (a + b - a * b) + (1.0 - alpha) * max(a, b)


# Etape 1 : verification des bornes
print('=== Verification des bornes ===')
for alpha in [0.0, 0.5, 1.0]:
    v00 = parametric_or(0.0, 0.0, alpha)
    v11 = parametric_or(1.0, 1.0, alpha)
    print(f'  alpha={alpha:.2f}  parametric_or(0,0)={v00:.4f}   parametric_or(1,1)={v11:.4f}')

# Etape 2 : interpolation lisse entre Godel (alpha=0) et probabiliste (alpha=1)
print('\n=== Sweep alpha pour (a, b) = (0.3, 0.6) ===')
print('alpha\tparametric_or\tcomparaison')
print('-' * 60)
ref_godel = max(0.3, 0.6)
ref_prob = 0.3 + 0.6 - 0.3 * 0.6
for alpha in [0.0, 0.25, 0.5, 0.75, 1.0]:
    val = parametric_or(0.3, 0.6, alpha)
    if alpha == 0.0:
        tag = f'= max(0.3, 0.6) = {ref_godel:.4f}'
    elif alpha == 1.0:
        tag = f'= 0.3+0.6-0.3*0.6 = {ref_prob:.4f}'
    else:
        tag = f'(entre {ref_godel:.4f} et {ref_prob:.4f})'
    print(f'{alpha:.2f}\t{val:.4f}\t\t{tag}')

# Etape 3 : monotonie en a (verification simple)
print('\n=== Monotonie en a (alpha=0.5, b=0.4) ===')
for a in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]:
    print(f'  parametric_or({a:.1f}, 0.4, 0.5) = {parametric_or(a, 0.4, 0.5):.4f}')

print('\nExemple guide 1 OK : parametric_or implemente, bornes verifiees, sweep alpha affiche.')
=== Verification des bornes ===
  alpha=0.00  parametric_or(0,0)=0.0000   parametric_or(1,1)=1.0000
  alpha=0.50  parametric_or(0,0)=0.0000   parametric_or(1,1)=1.0000
  alpha=1.00  parametric_or(0,0)=0.0000   parametric_or(1,1)=1.0000

=== Sweep alpha pour (a, b) = (0.3, 0.6) ===
alpha   parametric_or   comparaison
------------------------------------------------------------
0.00    0.6000      = max(0.3, 0.6) = 0.6000
0.25    0.6300      (entre 0.6000 et 0.7200)
0.50    0.6600      (entre 0.6000 et 0.7200)
0.75    0.6900      (entre 0.6000 et 0.7200)
1.00    0.7200      = 0.3+0.6-0.3*0.6 = 0.7200

=== Monotonie en a (alpha=0.5, b=0.4) ===
  parametric_or(0.0, 0.4, 0.5) = 0.4000
  parametric_or(0.2, 0.4, 0.5) = 0.4600
  parametric_or(0.4, 0.4, 0.5) = 0.5200
  parametric_or(0.6, 0.4, 0.5) = 0.6800
  parametric_or(0.8, 0.4, 0.5) = 0.8400
  parametric_or(1.0, 0.4, 0.5) = 1.0000

Exemple guide 1 OK : parametric_or implemente, bornes verifiees, sweep alpha affiche.

Exemple guide 2 : Entrainement LTN pour frere et oncle

Bascule TP -> Exemple guide (See #2161). Solution demontree ci-dessous.

On entraine un predicat neuronal frere(X,Y) sur des faits positifs et negatifs, puis on utilise la règle frere(X,Y) AND parent(Y,Z) => oncle(X,Z) pour apprendre oncle. Cela illustre le deuxieme pilier des LTN : l’apprentissage guide par règle, ou un predicat est contraint non seulement par des faits mais aussi par la sémantique logique d’une règle.

# Exemple guide 2 : Entrainement LTN pour frere et oncle
# Resolution de l'Exercice 2 (See #2161) : solution demontree ci-dessous.
#
# On reproduit le pattern de l'entrainement de parent (par faits) puis de
# grandparent (par regle) : (1) on entraine frere(X,Y) sur des faits positifs
# et negatifs, puis (2) on derive oncle(X,Z) via la regle
#   frere(X,Y) AND parent(Y,Z) => oncle(X,Z)
# en reutilisant le parent_pred deja entraine plus haut.

# Etape 1 : faits du predicat frere. On introduit une fratrie : Marie et Sophie
# sont soeurs. Les negatifs sont echantillonnes en monde clos (toute paire non
# listee comme positive est supposee fausse), comme pour parent precedemment.
frere_positive = [('Marie', 'Sophie'), ('Sophie', 'Marie')]
frere_negative = [('Marie', 'Pierre'), ('Pierre', 'Paul'), ('Sophie', 'Luc'),
                  ('Pierre', 'Sophie'), ('Paul', 'Marie'), ('Luc', 'Pierre'),
                  ('Marie', 'Luc'), ('Paul', 'Sophie')]

# Etape 2 : entrainement du predicat neuronal frere (meme idiom que parent :
# entropie croisee, la derivee de la sigmoide se compense dans le gradient).
frere_pred = NeuralPredicate(n_inputs=2 * len(entities), name='frere')

print('=== Entrainement du predicat neuronal "frere" ===')
lr = 0.5
for epoch in range(150):
    total_loss = 0.0
    for s, o in frere_positive:
        x = pair_embedding(s, o)
        val = frere_pred(x)
        total_loss += -np.log(max(val, 1e-10))
        frere_pred.update(x * (1 - val), (1 - val), lr)
    for s, o in frere_negative:
        x = pair_embedding(s, o)
        val = frere_pred(x)
        total_loss += -np.log(max(1 - val, 1e-10))
        frere_pred.update(-x * val, -val, lr)
    if (epoch + 1) % 30 == 0:
        print(f'  Epoch {epoch+1:2d} : perte = {total_loss:.4f}')

print('\n=== Resultats frere (cible : positifs ~1, negatifs ~0) ===')
for s, o in frere_positive:
    print(f'  frere({s}, {o}) = {frere_pred(pair_embedding(s, o)):.4f}   [positif]')
for s, o in frere_negative:
    print(f'  frere({s}, {o}) = {frere_pred(pair_embedding(s, o)):.4f}   [negatif]')

# Etape 3 : derivation de oncle par la regle frere(X,Y) AND parent(Y,Z) => oncle(X,Z).
# Les chaines (X frere/soeur de Y, Y parent de Z) :
#   - Marie est soeur de Sophie, qui est parent de Luc  -> oncle(Marie, Luc)
#   - Sophie est soeur de Marie, qui est parent de Pierre -> oncle(Sophie, Pierre)
# On reutilise le parent_pred deja entraine (cellule parent).
chains_oncle = [('Marie', 'Sophie', 'Luc'), ('Sophie', 'Marie', 'Pierre')]

oncle_pred = NeuralPredicate(n_inputs=2 * len(entities), name='oncle')

print('\n=== Entrainement guide par regle : frere(X,Y) AND parent(Y,Z) => oncle(X,Z) ===')
lr = 0.5
for epoch in range(30):
    total_loss = 0.0
    for x_bro, y_par, z_child in chains_oncle:
        x_xz = pair_embedding(x_bro, z_child)
        fr_val = frere_pred(pair_embedding(x_bro, y_par))
        par_val = parent_pred(pair_embedding(y_par, z_child))
        onc_val = oncle_pred(x_xz)
        body = fuzzy_and(fr_val, par_val)
        rule_truth = fuzzy_implies(body, onc_val)
        total_loss += -np.log(max(rule_truth, 1e-10))
        gw, gb = oncle_pred.gradient(x_xz)
        error = body - onc_val
        oncle_pred.update(gw * error, gb * error, lr)
    if (epoch + 1) % 10 == 0:
        print(f'  Epoch {epoch+1:2d} : perte = {total_loss:.4f}')

print('\n=== Verifications oncle (le corps de la regle tire la verite vers le haut) ===')
for x_bro, y_par, z_child in chains_oncle:
    val = oncle_pred(pair_embedding(x_bro, z_child))
    fr_val = frere_pred(pair_embedding(x_bro, y_par))
    par_val = parent_pred(pair_embedding(y_par, z_child))
    print(f'oncle({x_bro}, {z_child}) = {val:.4f}   '
          f'[corps = frere({x_bro},{y_par})={fr_val:.3f} AND parent({y_par},{z_child})={par_val:.3f}]')

print('\nExemple guide 2 OK : frere appris par faits, oncle derive par regle.')
=== Entrainement du predicat neuronal "frere" ===
  Epoch 30 : perte = 0.5833
  Epoch 60 : perte = 0.2891
  Epoch 90 : perte = 0.1923
  Epoch 120 : perte = 0.1441
  Epoch 150 : perte = 0.1152

=== Resultats frere (cible : positifs ~1, negatifs ~0) ===
  frere(Marie, Sophie) = 0.9677   [positif]
  frere(Sophie, Marie) = 0.9795   [positif]
  frere(Marie, Pierre) = 0.0137   [negatif]
  frere(Pierre, Paul) = 0.0002   [negatif]
  frere(Sophie, Luc) = 0.0087   [negatif]
  frere(Pierre, Sophie) = 0.0140   [negatif]
  frere(Paul, Marie) = 0.0088   [negatif]
  frere(Luc, Pierre) = 0.0003   [negatif]
  frere(Marie, Luc) = 0.0069   [negatif]
  frere(Paul, Sophie) = 0.0070   [negatif]

=== Entrainement guide par regle : frere(X,Y) AND parent(Y,Z) => oncle(X,Z) ===
  Epoch 10 : perte = 0.3700
  Epoch 20 : perte = 0.1880
  Epoch 30 : perte = 0.1177

=== Verifications oncle (le corps de la regle tire la verite vers le haut) ===
oncle(Marie, Luc) = 0.8340   [corps = frere(Marie,Sophie)=0.968 AND parent(Sophie,Luc)=0.914]
oncle(Sophie, Pierre) = 0.8317   [corps = frere(Sophie,Marie)=0.980 AND parent(Marie,Pierre)=0.897]

Exemple guide 2 OK : frere appris par faits, oncle derive par regle.

Exercice 2 (variation) : Amitie et transitivite discutable

Reproduisez le schema (predicat appris par faits, puis derive par règle) avec un nouveau domaine : entrainez ami(X,Y) sur des faits, puis derivez ami_ami(X,Z) par la règle ami(X,Y) AND ami(Y,Z) => ami_ami(X,Z).

Étapes : 1. Définir 2-3 faits positifs ami (ex. Pierre ami de Paul, Paul ami de Luc) et un echantillon de negatifs en monde clos, puis entrainer ami_pred (même idiom que frere). 2. Construire les chaînes de transitivite et entrainer ami_ami_pred par la règle (même idiom que oncle). 3. Question : la règle force l’agent a croire que l’ami d’un ami est un ami. Pourquoi est-ce semantiquement discutable ? Que se passe-t-il si la règle est fausse mais qu’on l’entraine quand même ?

Indice : un reseau neuro-symbolique apprend exactement ce que les règles et les faits lui disent d’apprendre – ni plus, ni moins. Une règle fausse produit un predicat coherent avec la règle, pas avec le monde. C’est le coeur du debat sur la soundness vs l’expressivite des LTN.

# Exercice 2 (variation) : Amitie et transitivite discutable
# TODO etudiant : entrainez ami_pred par faits, puis ami_ami_pred par regle.

# Etape 1 : faits ami (positifs + negatifs en monde clos) puis entrainement
# ami_pred = NeuralPredicate(n_inputs=2 * len(entities), name='ami')
# ami_positive = [...]
# ami_negative = [...]
# (meme boucle d'entrainement que frere)

# Etape 2 : chaines de transitivite puis entrainement de ami_ami_pred
# chains_ami_ami = [('Pierre', 'Paul', 'Luc'), ...]   # ami(X,Y) AND ami(Y,Z)
# (meme boucle guidee par regle que oncle)

# Etape 3 : pourquoi la transitivite de l'amitie est-elle discutable ?
print('Exercice a completer : ami par faits, ami_ami par regle')
print('Question : la regle force ami_ami meme si semantiquement faux.')

# Indice : un LTN apprend ce que les regles disent, pas ce qui est vrai.
# result = None  # TODO etudiant : ami_ami(Pierre, Luc) = ?
Exercice a completer : ami par faits, ami_ami par regle
Question : la regle force ami_ami meme si semantiquement faux.

Exemple guide 3 : Règle transitive ancestor dans DeepProbLog

Bascule TP -> Exemple guide (See #2161). Solution demontree ci-dessous.

On etend le système dpl (cellule SimpleDeepProbLog) avec la fermeture transitive de parent : le predicat ancestor. Deux règles suffisent – un cas de base et une règle récursive – et c’est le chainage arriere du moteur (avec sa limite max_depth) qui resout la recursion.

# Exemple guide 3 : Regle transitive ancestor dans DeepProbLog
# Resolution de l'Exercice 3 (See #2161) : solution demontree ci-dessous.
#
# On etend le systeme dpl avec la fermeture transitive de parent : ancestor.
# Deux regles suffisent :
#   (base)       ancestor(X,Y) :- parent(X,Y)            # un parent est un ancetre
#   (transitive) ancestor(X,Z) :- parent(X,Y), ancestor(Y,Z)   # on remonte d'un cran
# La limite max_depth du chainage arriere evite les boucles infinies sur la recursion.

# Etape 1 : regle de base -- un parent est un ancetre (cas d'arret).
dpl.add_rule([('parent', ('X', 'Y'))], ('ancestor', ('X', 'Y')))

# Etape 2 : regle transitive -- on remonte d'un niveau, puis on continue.
dpl.add_rule([('parent', ('X', 'Y')), ('ancestor', ('Y', 'Z'))],
             ('ancestor', ('X', 'Z')))

# Etape 3 : on interroge la fermeture transitive.
print('=== Regle transitive ancestor (fermeture de parent) ===')
requetes = [
    ('ancestor', ('Marie', 'Paul')),    # Marie -> Pierre -> Paul : VRAI ancetre (2 sauts)
    ('ancestor', ('Pierre', 'Paul')),   # Pierre -> Paul : VRAI (direct)
    ('ancestor', ('Marie', 'Pierre')),  # Marie -> Pierre : VRAI (direct)
    ('ancestor', ('Marie', 'Sophie')),  # aucun chemin Marie ..-> Sophie
    ('ancestor', ('Sophie', 'Paul')),   # Sophie -> Luc : pas de lien vers Paul
]
for pred, args in requetes:
    prob = dpl.query(pred, args)
    print(f'  {pred}{args} = {prob:.4f}')

print()
print('Lecture : ancestor est la FERMETURE TRANSITIVE de parent.')
print('ancestor(Marie, Paul) = parent(Marie, Pierre) AND parent(Pierre, Paul)')
print('                     = 0.8966 * 0.9193 = 0.8242  (chemin Marie -> Pierre -> Paul).')
print('Les ancetres directs (Pierre->Paul, Marie->Pierre) valent ~0.9 (un seul saut).')
print('Les paires sans chemin restent faibles (< 0.1) : Marie->Sophie = 0.03,')
print('Sophie->Paul = 0.07. La fermeture ne cree rien, elle propage parent.')
print('Le chainage arriere plonge jusqu a max_depth (anti-boucle sur la recursion).')
print()
print('Exemple guide 3 OK : regle ancestor transitive ajoutee, fermeture verifiee.')
=== Regle transitive ancestor (fermeture de parent) ===
  ancestor('Marie', 'Paul') = 0.8242
  ancestor('Pierre', 'Paul') = 0.9193
  ancestor('Marie', 'Pierre') = 0.8966
  ancestor('Marie', 'Sophie') = 0.0316
  ancestor('Sophie', 'Paul') = 0.0739

Lecture : ancestor est la FERMETURE TRANSITIVE de parent.
ancestor(Marie, Paul) = parent(Marie, Pierre) AND parent(Pierre, Paul)
                     = 0.8966 * 0.9193 = 0.8242  (chemin Marie -> Pierre -> Paul).
Les ancetres directs (Pierre->Paul, Marie->Pierre) valent ~0.9 (un seul saut).
Les paires sans chemin restent faibles (< 0.1) : Marie->Sophie = 0.03,
Sophie->Paul = 0.07. La fermeture ne cree rien, elle propage parent.
Le chainage arriere plonge jusqu a max_depth (anti-boucle sur la recursion).

Exemple guide 3 OK : regle ancestor transitive ajoutee, fermeture verifiee.

Exercice 3 (variation) : La limite de profondeur compte

La règle ancestor est récursive : ancestor(X,Z) :- parent(X,Y), ancestor(Y,Z). Le chainage arriere du moteur s’arrete a max_depth.

Étapes : 1. Pour depth dans [0, 1, 2, 3, 5], calculez dpl.query('ancestor', ('Marie', 'Paul'), max_depth=depth). 2. A partir de quelle profondeur la fermeture transitive (valeur ~0.82) apparait-elle ? 3. Que donne max_depth=0 ? Que donne max_depth=1 (pourquoi seulement ~0.07, c’est-a-dire le fait direct parent(Marie, Paul), sans la transitivite) ?

Indice : la requête est emise a depth=0 et plonge d’un niveau a chaque litteral du corps (depth+1). Le chemin Marie -> Pierre -> Paul demande 2 plongees ; en dessous de max_depth=2, la recursion est coupee avant d’atteindre le fait neural. C’est le compromis expressivite vs terminaison du chainage arriere.

# Exercice 3 (variation) : La limite de profondeur compte
# TODO etudiant : mesurez ancestor(Marie, Paul) selon max_depth.

# Etape 1 : pour depth in [0, 1, 2, 3, 5], interroger :
#   prob = dpl.query('ancestor', ('Marie', 'Paul'), max_depth=depth)

# Etape 2 : a partir de quelle depth la fermeture transitive (~0.82) apparait-elle ?
# Etape 3 : que donne max_depth=0 ? max_depth=1 (pourquoi seulement le fait direct) ?
print('Exercice a completer : impact de max_depth sur ancestor')
print('Question : a partir de quelle profondeur la transitivite (Marie -> Paul) se revele ?')

# Indice : le chemin Marie->Paul = 2 plongees ; max_depth < 2 coupe avant le fait neural.
# result = None  # TODO etudiant : tableau depth -> prob
Exercice a completer : impact de max_depth sur ancestor
Question : a partir de quelle profondeur la transitivite (Marie -> Paul) se revele ?

Exemple guide 4 : T-norm de Lukasiewicz et famille parametrique a 3 t-conorms

Bascule TP -> Exemple guide (See #2161). Solution demontree ci-dessous.

Exercice 4 : T-norm de Łukasiewicz et famille paramétrique à 3 t-conorms

Nouvel exercice (#1455). Ajouté à la suite de l’Exemple guidé 1, comme prolongement de l’opérateur OR paramétrique.

Contexte

La t-norm probabiliste (a * b) et la t-norm de Gödel / min-max ne sont pas les seules. La t-norm de Łukasiewicz est une troisième famille classique, particulièrement utilisée en logique floue car elle satisfait l’involution de la négation (NOT NOT a = a) tout en restant différentiable presque partout.

Opérateur Définition
AND_L(a, b) max(0, a + b − 1)
OR_L(a, b) min(1, a + b)

Tâche

  1. Étape 1 : implémenter lukasiewicz_and et lukasiewicz_or dans la cellule code suivante.
  2. Étape 2 : vérifier que les bornes (0,0) et (1,1) donnent bien 0 et 1 respectivement.
  3. Étape 3 : généraliser l’Exemple guidé 1 en une famille paramétrique à deux paramètres alpha, beta qui combine 3 t-conorms : probabiliste, max (Gödel) et Łukasiewicz. La combinaison doit rester une combinaison convexe (les poids somment à 1).
  4. Étape 4 : produire un sweep sur alpha ∈ {0, 0.25, 0.5, 0.75, 1.0} pour (a=0.3, b=0.6) en réutilisant parametric_or de l’Exemple guidé 1 (mono-paramètre).
  5. Étape 5 (bonus) : explorer la surface parametric_or_family(0.3, 0.6, alpha, beta) sur une grille numpy et identifier les (alpha, beta) qui maximisent la valeur OR.

Pistes pédagogiques : - L’Étape 1 est très courte. Elle force à manipuler les bornes [0, 1] correctement avec max et min. - L’Étape 3 est conceptuelle : si l’on a 3 opérateurs, une combinaison convexe à 2 paramètres (alpha, beta) avec alpha + beta ≤ 1 couvre tout le simplexe. - L’Étape 5 prépare au concept de t-conorm apprenable : dans un réseau neuro-symbolique réel, (alpha, beta) seraient des paramètres entraînés par descente de gradient.

# Exemple guide 4 : T-norm de Lukasiewicz et famille parametrique a 3 t-conorms
# Resolution de l'Exercice 4 (See #2161) : solution demontree ci-dessous.
#
# On ajoute la troisieme famille classique de t-norm/t-conorm -- Lukasiewicz --
# puis on generalise l'Exemple guide 1 (operateur OR parametrique mono-parametre)
# en une famille a 2 parametres combinant les 3 t-conorms : probabiliste, Godel
# (max) et Lukasiewicz.

# Etape 1 : t-norm et t-conorm de Lukasiewicz.
def lukasiewicz_and(a: float, b: float) -> float:
    """T-norm de Lukasiewicz : AND_L(a,b) = max(0, a + b - 1)."""
    return max(0.0, a + b - 1.0)

def lukasiewicz_or(a: float, b: float) -> float:
    """T-conorm de Lukasiewicz : OR_L(a,b) = min(1, a + b)."""
    return min(1.0, a + b)

# Etape 2 : verification des bornes (cas limites d'une t-(co)norm).
assert lukasiewicz_and(0.0, 0.0) == 0.0 and lukasiewicz_or(0.0, 0.0) == 0.0
assert lukasiewicz_and(1.0, 1.0) == 1.0 and lukasiewicz_or(1.0, 1.0) == 1.0
print('=== Etape 1-2 : Lukasiewicz + bornes ===')
print('  (0,0): AND_L=0.0, OR_L=0.0 | (1,1): AND_L=1.0, OR_L=1.0  [bornes OK]')
print(f'  (0.3,0.6): AND_L={lukasiewicz_and(0.3, 0.6):.4f}  '
      f'OR_L={lukasiewicz_or(0.3, 0.6):.4f}')
print('  AND_L=0 car 0.3+0.6-1 < 0 (tronque) ; OR_L=0.9 = min(1, 0.9).')

# Etape 3 : famille parametrique a 2 parametres (3 t-conorms).
# Combinaison convexe : les poids (alpha, beta, 1-alpha-beta) somment a 1.
#   alpha         -> poids de la t-conorm probabiliste (a + b - a*b)
#   beta          -> poids de la t-conorm de Godel (max(a, b))
#   1-alpha-beta  -> poids de la t-conorm de Lukasiewicz (min(1, a + b))
def parametric_or_family(a: float, b: float, alpha: float, beta: float) -> float:
    """Combinaison convexe des 3 t-conorms (probabiliste, Godel, Lukasiewicz).

    Parameters
    ----------
    a, b : float in [0, 1]
        Verites floues.
    alpha, beta : float in [0, 1] avec alpha + beta <= 1
        Poids des t-conorms probabiliste et Godel ; le reste (1-alpha-beta)
        va a Lukasiewicz.

    Returns
    -------
    float in [0, 1]
        t-conorm parametrique a 2 parametres.
    """
    assert alpha >= 0.0 and beta >= 0.0 and alpha + beta <= 1.0 + 1e-12, \
        'alpha + beta doit etre <= 1 (simplexe)'
    w_prob, w_godel, w_luk = alpha, beta, 1.0 - alpha - beta
    return (w_prob * (a + b - a * b)
            + w_godel * max(a, b)
            + w_luk * min(1.0, a + b))

# Etape 4 : sweep du mono-parametre alpha (Exemple guide 1) pour (a,b)=(0.3,0.6).
print('\n=== Etape 4 : sweep alpha (mono-parametre, Exemple guide 1) pour (0.3, 0.6) ===')
print('alpha\tparametric_or(0.3, 0.6)')
for alpha in [0.0, 0.25, 0.5, 0.75, 1.0]:
    print(f'{alpha:.2f}\t{parametric_or(0.3, 0.6, alpha):.4f}')
print('  alpha=0 -> max=0.6 (Godel) ; alpha=1 -> 0.3+0.6-0.18=0.72 (probabiliste).')

# Etape 5 (bonus) : surface sur la grille (alpha, beta) -> argmax.
print('\n=== Etape 5 (bonus) : argmax de parametric_or_family(0.3, 0.6, alpha, beta) ===')
best_val, best_ab = -1.0, None
grid = [0.0, 0.25, 0.5, 0.75, 1.0]
for alpha in grid:
    for beta in grid:
        if alpha + beta <= 1.0 + 1e-9:
            val = parametric_or_family(0.3, 0.6, alpha, beta)
            if val > best_val:
                best_val, best_ab = val, (alpha, beta)
print(f'  argmax (alpha, beta) = {best_ab} -> OR = {best_val:.4f}')
print('  Les 3 t-conorms valent : probabiliste=0.72, Godel=0.6, Lukasiewicz=0.9.')
print('  Lukasiewicz (min(1, 0.9)=0.9) est la plus elevee -> argmax = (0, 0),')
print('  c.-a-d. le pur Lukasiewicz. Pour cette paire, OR_L est le plus optimiste.')

print('\nExemple guide 4 OK : Lukasiewicz + famille 3 t-conorms + sweep + argmax.')
=== Etape 1-2 : Lukasiewicz + bornes ===
  (0,0): AND_L=0.0, OR_L=0.0 | (1,1): AND_L=1.0, OR_L=1.0  [bornes OK]
  (0.3,0.6): AND_L=0.0000  OR_L=0.9000
  AND_L=0 car 0.3+0.6-1 < 0 (tronque) ; OR_L=0.9 = min(1, 0.9).

=== Etape 4 : sweep alpha (mono-parametre, Exemple guide 1) pour (0.3, 0.6) ===
alpha   parametric_or(0.3, 0.6)
0.00    0.6000
0.25    0.6300
0.50    0.6600
0.75    0.6900
1.00    0.7200
  alpha=0 -> max=0.6 (Godel) ; alpha=1 -> 0.3+0.6-0.18=0.72 (probabiliste).

=== Etape 5 (bonus) : argmax de parametric_or_family(0.3, 0.6, alpha, beta) ===
  argmax (alpha, beta) = (0.0, 0.0) -> OR = 0.9000
  Les 3 t-conorms valent : probabiliste=0.72, Godel=0.6, Lukasiewicz=0.9.
  Lukasiewicz (min(1, 0.9)=0.9) est la plus elevee -> argmax = (0, 0),
  c.-a-d. le pur Lukasiewicz. Pour cette paire, OR_L est le plus optimiste.

Exemple guide 4 OK : Lukasiewicz + famille 3 t-conorms + sweep + argmax.

Exercice 4 (variation) : Zones plates et gradient nul

La t-norm de Lukasiewicz AND_L(a,b) = max(0, a+b-1) a un defaut majeur pour l’apprentissage : quand a + b < 1, sa valeur est plate a 0 et son gradient est exactement nul – le reseau n’apprend plus.

Étapes : 1. Calculer la derivee partielle de AND_L par rapport a a (1 si a+b > 1, sinon 0). 2. Comparer avec la t-norm produit AND_P(a,b) = a*b, dont la derivee en a vaut b (jamais nulle si b != 0). 3. Exhiber une paire (a, b) ou Lukasiewicz stagne (gradient 0) alors que le produit apprend encore. Que conclure pour le choix d’une sémantique floue apprenable par descente de gradient ?

Indice : tout (a, b) avec a + b < 1 bloque Lukasiewicz (ex. (0.2, 0.3) -> AND_L = 0, gradient 0), tandis que le produit donne 0.06 avec un gradient non-nul. C’est pourquoi les LTN pratiques preferent le produit ou Godel a Lukasiewicz comme t-norm d’entrainement.

# Exercice 4 (variation) : Zones plates et gradient nul
# TODO etudiant : comparez le gradient de AND_L (Lukasiewicz) et AND_P (produit).

# Etape 1 : d/da AND_L(a,b) = 1 si a+b>1, sinon 0  (plate sous la diagonale a+b=1)
# Etape 2 : d/da AND_P(a,b) = b                     (jamais nul si b != 0)
# Etape 3 : exhiber (a,b) avec a+b<1 ou Lukasiewicz stagne mais le produit apprend.
print('Exercice a completer : zones plates de Lukasiewicz vs gradient du produit')
print('Question : pour quelle paire (a,b) le gradient de AND_L est-il nul mais pas celui du produit ?')

# Indice : les LTN pratiques preferent produit/Godel comme t-norm d'entrainement.
# result = None  # TODO etudiant : tableau (a,b) -> (AND_L, grad_L, AND_P, grad_P)
Exercice a completer : zones plates de Lukasiewicz vs gradient du produit
Question : pour quelle paire (a,b) le gradient de AND_L est-il nul mais pas celui du produit ?

Exercice 5 : L’ablation de l’axiome 4 (LTNtorch)

L’interpretation de la section 7 affirme que l’axiome 4 (negatifs closed-world sur grandparent) est indispensable : sans lui, l’implication de l’axiome 3 se satisfait trivialement en rendant Grandparent vrai partout. Transformez cette affirmation en expérience.

# Exercice 5 : L'ablation de l'axiome 4
# TODO etudiant : re-entrainez le modele LTNtorch de la section 7 SANS l'axiome 4
# et mesurez ce que devient le predicat grandparent.
# Etape 1 : re-instanciez des predicats frais (Parent2, Grandparent2 : deux
#           nouveaux ltn.Predicate(PairPredicate())) et torch.manual_seed(42),
#           pour ne pas reutiliser les poids deja entraines.
# Etape 2 : recopiez la boucle d'entrainement de la section 7 avec SEULEMENT
#           les axiomes 1, 2 et 3 (supprimez le Forall sur gpn_s/gpn_o).
# Etape 3 : interrogez Grandparent2 sur (Marie, Paul) ET sur les 6 paires de
#           gp_negative : comparez avec les valeurs de la section 7.
# Etape 4 : la satisfaction globale est-elle meilleure ou pire qu'avec 4 axiomes ?
#           Pourquoi une satisfaction elevee peut-elle cacher un predicat inutile ?
# Indice : un predicat constant a 1 satisfait parfaitement A => B quel que soit A.

print("Exercice a completer")
Exercice a completer

8. Resume

Points cles

Concept Description
T-norm / T-conorm Generalisation differentiable de AND / OR
Predicat neuronal Fonction P(x) -> [0,1] apprise par reseau de neurones
LTN Logique Tensorielle : predicats neuronaux + sémantique logique
Raisonnement guide Règles logiques guident l’entrainement neuronal
DeepProbLog Programmation logique probabiliste + predicats neuronaux

Ce qu’il faut retenir : - La logique differentiable transforme les connecteurs binaires en fonctions continues derivables - Les predicats neuronaux peuvent etre combines avec des règles logiques - L’entrainement peut etre guide par la connaissance logique, même sans données etiquetees - DeepProbLog unifie programmation logique et reseaux de neurones

Perspectives

Ce notebook a explore l’integration neuro-symbolique, qui vise a combiner les forces complementaires des reseaux de neurones (apprentissage a partir de données, tolerance au bruit) et du raisonnement symbolique (garanties formelles, interpretabilite). Les opérateurs logiques differentiables (t-norms) transforment les connecteurs binaires AND, OR, NOT en fonctions continues derivables sur [0, 1], permettant leur integration dans des graphes de calcul optimisables par descente de gradient. Les predicats neuronaux implementent des fonctions P(x) -> [0, 1] via des reseaux de neurones, et la Logique Tensorielle (LTN) combine ces predicats avec une sémantique logique pour entrainer des modèles guides par des règles.

L’implementation du raisonnement guide par règle a illustre un résultat remarquable : le predicat grandparent a ete appris sans aucun exemple direct de grand-parent, uniquement a partir de la règle logique parent(X,Y) AND parent(Y,Z) => grandparent(X,Z). Le système DeepProbLog simplifie a ensuite unifie la programmation logique probabiliste et les predicats neuronaux, permettant de resoudre des requêtes par chainage arriere. L’exemple guide de l’opérateur OR parametrique a demontre comment un paramètre de combinaison convexe (alpha) peut etre appris pour choisir automatiquement entre la sémantique de Godel (max) et la sémantique probabiliste (a+b-a*b).

L’integration neuro-symbolique reste un domaine de recherche actif. Les approches LTN, Neural Theorem Prover et DeepProbLog chacune proposent des compromis différents entre expressivite logique, passage a l’echelle et facilite d’entrainement. Le notebook suivant, SL-8 - Knowledge Graphs et ILP, aborde une autre dimension de l’apprentissage symbolique moderne : le minage de règles dans les Knowledge Graphs, ou les triples RDF remplacent les faits logiques traditionnels et l’echelle atteint des milliards de données.



Defi presentation

Modalite du cours : chaque groupe choisit un exercice de la serie, le prepare, et le presente en seance. Resoudre l’exercice est le minimum ; ce qui distingue une presentation qui maitrise le sujet, c’est la question-twist associee ci-dessous. Elle fait partie integrante de la presentation attendue.

Exercice Question-twist a traiter en plus
Ex. 2 (LTN frere/oncle) Retirez les axiomes negatifs (closed-world) de l’entrainement et montrez ce qui casse dans les predictions. Pourquoi une LTN a-t-elle besoin de negatifs explicites la ou Prolog s’en passe ?
Ex. 3 (règle transitive ancestor) La règle transitive seule admet le modèle trivial « ancestor vrai partout » (satisfaction 1.0). Votre apprentissage y tombe-t-il ? Identifiez ce qui l’en empeche — ou ce qui l’y pousse.
Ex. 4 (t-norm de Lukasiewicz) Avec Lukasiewicz, les gradients peuvent etre exactement nuls (zones plates de max(0, .)). Exhibez une configuration ou l’apprentissage stagne avec Lukasiewicz mais pas avec le produit ; qu’en conclure pour le choix d’une sémantique floue apprenable ?
Ex. 5 (ablation de l’axiome 4) Remplacez les 6 negatifs closed-world par les seules paires « faciles » (aucun lien parent direct) : le negatif difficile (Marie, Pierre) est-il indispensable pour que grandparent ne degenere pas en « parent-de » ? Que dit ce besoin de negatifs choisis de la différence avec la derivation exacte de clingo (SL-8) ?

Ressources

  • Serafini & Garcez, “Logic Tensor Networks” (2016)
  • Manhaeve et al., “DeepProbLog: Neural Probabilistic Logic Programming” (2018)
  • Rocktaschel & Riedel, “End-to-End Differentiable Proving” (2017)
  • Russell & Norvig, AIMA, 4e ed., Chapitre 19

Navigation : Index | << SL-6 Moteurs ILP modernes | Suivant >> SL-8 KG-ILP

Retour au sommet