Du texte qu'on découpe aux triplets qui relient les idées.
Unification, Prolog, Turtle — ou comment donner une structure à la connaissance.
Dans le premier cours, on a appris à découper du texte en mots, les pondérer (TF-IDF), les projeter dans l'espace (embeddings). Mais un mot tout seul, c'est pauvre. « Socrate » — qui est-ce ? « est mortel » — qu'est-ce que ça veut dire ?
Pour qu'une machine raisonne, il faut une représentation structurée de la connaissance. Pas juste des sacs de mots, mais des faits :
Ce deuxième cours vous emmène du triplet (la molécule de la connaissance) jusqu'à Turtle, le langage standard du web sémantique, en passant par l'unification et un mini-Prolog qu'on va construire ensemble.
Toute phrase simple peut se réduire à une structure à trois parties :
Sujet — Verbe — Complément
| Sujet | Verbe | Complément |
|---|---|---|
| Socrate | est_un | homme |
| Socrate | est_le_maitre_de | Platon |
| Platon | est_le_maitre_de | Aristote |
| Homme | est | mortel |
Chaque ligne est un triplet (sujet, verbe, complément).
En RDF (le standard du web sémantique), on dit :
sujet — prédicat — objet. Même idée, vocabulaire différent.
Python # Chaque connaissance est un triplet (sujet, verbe, complément) connaissance = [ ("Socrate", "est_un", "homme"), ("Socrate", "est_le_maitre_de", "Platon"), ("Platon", "est_le_maitre_de", "Aristote"), ("Aristote", "est_le_maitre_de", "Alexandre"), ("Astyanax", "est_le_fils_de", "Hector"), ("Hector", "est_le_fils_de", "Priam"), ("Priam", "est_le_roi_de", "Troie"), ("homme", "est", "mortel"), ] # Index : pour chaque sujet, ses (verbe, complément) par_sujet = {} for s, v, c in connaissance: par_sujet.setdefault(s, []).append((v, c)) def chercher(sujet, verbe=None): return [c for v, c in par_sujet.get(sujet, []) if verbe is None or v == verbe] print(chercher("Platon", "est_le_maitre_de")) # → ['Aristote']
Ajouter un 4e champ (contexte, temps, confiance) pour faire du quadruplet.
Suivre les relations : « Astyanax → fils_de → Hector → fils_de → Priam ».
Un triplet, c'est une flèche entre deux nœuds. Tous les triplets ensemble forment un graphe orienté.
Dans ce graphe, on peut naviguer, inférer, joindre. Un graphe de connaissances, c'est le cerveau d'une machine — en moins humide et avec plus de JSON.
On veut savoir si Socrate est mortel. Le triplet n'existe pas explicitement. Mais on peut remonter la chaîne :
Python def heriter(sujet, verbe): """Remonte la chaîne 'est_un'/'est' pour trouver un triplet.""" vus = set() pile = [sujet] while pile: s = pile.pop() if s in vus: continue vus.add(s) for v, c in chercher(s): if v == verbe: return c if v in ("est_un", "est"): pile.append(c) return None print(heriter("Socrate", "est_mortel")) # → None (le prédicat s'appelle "est", pas "est_mortel") # Correction : on veut la valeur du prédicat "est" print(heriter("Socrate", "est")) # → 'mortel' (Socrate → est_un → homme → est → mortel)
C'est de l'inférence par chaînage : on suit les pointeurs jusqu'à trouver ce qu'on cherche. C'est primitif, mais c'est la base du raisonnement automatique.
L'unification, c'est l'art de faire correspondre deux structures en permettant des variables. C'est le moteur de Prolog, des systèmes de types, de la résolution de contraintes, et de votre capacité à reconnaître qu'un chat vu de dos est quand même un chat.
On prend deux termes, on essaie de les rendre identiques en remplaçant les variables par des valeurs. Si on y arrive, on retourne les substitutions.
unifier("est_mortel", "$X") avec ("est_mortel", "Socrate")
→ {"$X": "Socrate"}
unifier("est_un", "$X", "$Y") avec ("est_un", "Platon", "homme")
→ {"$X": "Platon", "$Y": "homme"}
unifier("est_un", "$X", "$X") avec ("est_un", "chat", "animal")
→ impossible ($X ≠ chat ET $X ≠ animal simultanément)
Python def unifier(t1, t2, subs=None): if subs is None: subs = {} if t1 == t2: return subs if isinstance(t1, str) and t1.startswith("$"): subs[t1] = t2 return subs if isinstance(t2, str) and t2.startswith("$"): subs[t2] = t1 return subs if isinstance(t1, tuple) and isinstance(t2, tuple) and len(t1) == len(t2): for a, b in zip(t1, t2): subs = unifier(a, b, subs) if subs is None: return None return subs return None # Tests print(unifier(("est_mortel", "$X"), ("est_mortel", "Socrate"))) # → {'$X': 'Socrate'} print(unifier(("est_un", "$X", "$Y"), ("est_un", "chat", "animal"))) # → {'$X': 'chat', '$Y': 'animal'}
X = f(X)).
C'est ce qu'on appelle l'occurs check, et c'est désactivé
dans la plupart des Prologs pour des raisons de performance.
Le casse-tête des gens qui écrivent des solveurs.
On a des faits, on a l'unification. Il manque les règles. Une règle dit : si telles conditions sont vraies, alors cette conclusion est vraie. En Prolog, ça s'écrit :
est_mortel(X) :- homme(X).
↑ conclusion ↑ prémisses
Le chaînage avant (forward chaining) applique les règles aux faits pour produire de nouveaux faits. C'est ce qu'on fait ici :
Python # Faits de base faits = [ ("est_un", "Socrate", "homme"), ("est_un", "Platon", "homme"), ("est_un", "Aristote","homme"), ("est_le_maitre_de", "Socrate", "Platon"), ("est_le_maitre_de", "Platon", "Aristote"), ] # Règles regles = [ # Si X est un homme, alors X est mortel (("est_mortel", "$X"), [("est_un", "$X", "homme")]), # Si X est un homme ET Y est le maître de X, alors X est un grand philosophe (("grand_philosophe", "$X"), [("est_un", "$X", "homme"), ("est_le_maitre_de", "$Y", "$X")]), ] # Appliquer les règles (chaînage avant) def appliquer_regles(faits, regles): nouveaux = [] for conclusion, premisses in regles: for fait in faits: subs = unifier(premisses[0], fait) if subs is not None: # Vérifier les prémisses restantes... nouveau = appliquer_subs(conclusion, subs) if nouveau not in faits: nouveaux.append(nouveau) return faits + nouveaux def appliquer_subs(terme, subs): if isinstance(terme, str): return subs.get(terme, terme) if isinstance(terme, tuple): return tuple(appliquer_subs(t, subs) for t in terme) return terme # Test : inférence complète faits_complets = appliquer_regles(faits, regles) print([f for f in faits_complets if f[0] == "grand_philosophe"]) # → [('grand_philosophe', 'Platon'), ('grand_philosophe', 'Aristote')]
Nos tuples Python, c'est pratique, mais ce n'est pas très glamour. Donnons une véritable syntaxe Prolog à notre moteur :
| Syntaxe Prolog | Notre interne |
|---|---|
homme(socrate). | ('homme', 'socrate') |
est_mortel(X) :- homme(X). | (('est_mortel','$X'), [('homme','$X')]) |
?- est_mortel(X). | [('est_mortel','$X')] |
Python import re def parse_terme(chaine): """'est_mortel(X)' → ('est_mortel', '$X')""" m = re.fullmatch(r'(\w+)\(([^)]*)\)', chaine.strip()) if not m: return None nom = m.group(1) args = [a.strip() for a in m.group(2).split(',') if a.strip()] args = tuple(f'${a}' if a[0].isupper() else a for a in args) return (nom,) + args def parse_programme(texte): faits, regles = [], [] for ligne in texte.strip().split('\n'): ligne = ligne.strip() if not ligne or ligne.startswith('%'): continue if ':-' in ligne: gauche, droite = ligne.replace(' ','').split(':-') conclusion = parse_terme(gauche) premisses = [parse_terme(p+')') for p in droite.rstrip('.').split('),')] regles.append((conclusion, premisses)) else: f = parse_terme(ligne.rstrip('.')) if f: faits.append(f) return faits, regles --- Exemple --- programme = """ homme(socrate). homme(platon). est_mortel(X) :- homme(X). grand_philosophe(X) :- homme(X), est_le_maitre_de(Y, X). """ faits_prog, regles_prog = parse_programme(programme) print(faits_prog) # → [('homme', 'socrate'), ('homme', 'platon')]
Ajouter la syntaxe [X|Xs] pour les listes, avec unification récursive.
C'est ce qui rend Prolog si puissant sur le traitement de séquences.
Le cut Prolog (!) empêche le backtracking.
À implémenter avec un flag dans le moteur.
is/2 pour évaluer des expressions.
X is 2 + 3 → unifie X avec 5.
Nos triplets maison, c'est bien. Mais pour partager de la connaissance sur le web, il faut un standard. C'est là que RDF (Resource Description Framework) et sa syntaxe Turtle interviennent.
| Notation | Sens | Exemple |
|---|---|---|
@prefix ns: <uri> | Déclare un préfixe | @prefix foaf: <http://xmlns.com/foaf/0.1/> . |
ns:nom | Nom qualifié (QName) | foaf:Person |
sujet prédicat objet . | Triplet RDF | ex:Socrate ex:estUn ex:Humain . |
rdf:type | « est un » standard | ex:Socrate rdf:type ex:Humain . |
rdfs:subClassOf | Héritage de classes | ex:Chien rdfs:subClassOf ex:Animal . |
"valeur" | Littéral (chaîne) | ex:Humain rdfs:label "Humain" . |
Python import re from collections import defaultdict PREFIXES = { "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "owl": "http://www.w3.org/2002/07/owl#", "xsd": "http://www.w3.org/2001/XMLSchema#", "foaf": "http://xmlns.com/foaf/0.1/", } class GraphRDF: def __init__(self): self.triplets = [] self.prefixes = dict(PREFIXES) def _expand(self, qname): if ':' in qname and not qname.startswith('"'): p, reste = qname.split(':', 1) return self.prefixes.get(p, p) + reste return qname.strip('"') def charger(self, texte): for ligne in texte.split('\n'): ligne = ligne.strip() if not ligne or ligne.startswith('#'): continue # @prefix m = re.match(r'@prefix\s+(\w+):\s*<(.*?)>\s*\.', ligne) if m: self.prefixes[m.group(1)] = m.group(2) continue # triplet m = re.match(r'(\S+)\s+(\S+)\s+(.+?)\s*\.\s*$', ligne) if m: s = self._expand(m.group(1)) p = self._expand(m.group(2)) o = self._expand(m.group(3)) self.triplets.append((s, p, o)) return self.triplets def chercher(self, sujet=None, predicat=None, objet=None): return [(s,p,o) for s,p,o in self.triplets if (sujet is None or s == sujet) and (predicat is None or p == predicat) and (objet is None or o == objet)] --- Exemple --- TURTLE = """ @prefix ex: <http://example.org/> . @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> . ex:Socrate ex:estUn ex:Humain . ex:Socrate ex:estLeMaitreDe ex:Platon . ex:Platon ex:estUn ex:Humain . ex:Platon ex:estLeMaitreDe ex:Aristote . ex:Humain ex:estMortel "vrai" . """ g = GraphRDF() g.charger(TURTLE) print(g.chercher(predicat="http://example.org/estUn", objet="http://example.org/Humain")) # → [('http://example.org/Socrate', 'http://example.org/estUn', # 'http://example.org/Humain'), # ('http://example.org/Platon', ...)]
Python # 1. Trouver la classe "Mortel" mortel = g.chercher(predicat="http://example.org/estMortel") # → [('http://example.org/Humain', 'ex:estMortel', '"vrai"')] # 2. Trouver toutes les instances de Humain humains = g.chercher(predicat="http://example.org/estUn", objet="http://example.org/Humain") print([s.split('/')[-1] for s,_,_ in humains]) # → ['Socrate', 'Platon'] # Les deux sont mortels par héritage !
Ce cours vous a emmené du triplet au graphe RDF en passant par l'unification et le Prolog. Vous avez construit, ligne par ligne, les briques fondamentales du raisonnement automatique.
Mais le chemin est long. Quelques directions pour la suite :
Le SQL du web sémantique. Interrogez des graphes RDF avec une syntaxe
puissante : SELECT ?x WHERE { ?x ex:estUn ex:Humain }.
Le langage d'ontologies. Ajoutez de la logique descriptive : cardinalités, équivalences, restrictions. C'est Prolog en plus costaud.
Le principe de Tim Berners-Lee : publier des données reliées sur le web. DBpedia, Wikidata, etc. 5 étoiles pour le data publishing.
Combiner la structure des graphes avec la puissance des vecteurs. RDF2Vec, Knowledge Graph Embeddings — le meilleur des deux mondes.
La base de connaissance collaborative de Wikipedia. 100 millions de triplets, requêtable en SPARQL. Le plus grand graphe de connaissances public du monde.
Implémenter un algorithme de tableau (tableau) pour la logique descriptive. C'est ce qui fait tourner les raisonneurs OWL.