Retrouvez la logique en data et en IA
Vous ne programmerez sans doute pas en Prolog dans votre prochain poste de data scientist. Mais vous écrirez des requêtes SQL, peut-être des requêtes récursives sur une hiérarchie de produits ; vous interrogerez peut-être un graphe de connaissances ; vous croiserez des outils d’analyse de code qui raisonnent par règles. Tous héritent directement des idées de ce cours : faits, règles, fermeture transitive, monde clos.
Ce dernier chapitre fait le pont. Il n’ajoute pas de Prolog, il montre où ses idées vivent aujourd’hui, et ce qu’elles valent face à l’apprentissage automatique. Nous reprenons le réseau ferré du chapitre 4, avec sa liaison retour liaison(paris, rennes) qui crée un cycle.
Datalog : Prolog sans les pièges
Section intitulée « Datalog : Prolog sans les pièges »Au chapitre 4, chemin/2 bouclait dès que le réseau contenait un cycle, et une récursion à gauche suffisait à tout bloquer. Ces défauts ne viennent pas de la logique, mais de la stratégie de Prolog : une recherche en profondeur, du but vers les faits (on dit descendante, top-down).
Datalog est un sous-ensemble de Prolog conçu pour les bases de données. Il garde les faits et les règles, mais impose trois restrictions :
- pas de termes composés : les arguments sont des constantes ou des variables, donc pas de
par(Z, T)ni de listes ; - pas de coupure, ni de prédicats extra-logiques ;
- chaque variable de la tête doit apparaître dans le corps (règles « sûres »), et la négation doit être stratifiée : un prédicat ne peut pas dépendre de sa propre négation.
En échange, Datalog change de stratégie. Au lieu de partir de la question, il part des faits et applique toutes les règles, encore et encore, jusqu’à ce que plus rien de nouveau n’apparaisse : c’est l’évaluation ascendante (bottom-up), qui calcule un point fixe. Comme il n’y a pas de termes composés, le nombre de faits déductibles est fini (au plus toutes les combinaisons des constantes existantes), et le calcul termine toujours.
Conséquence remarquable : en Datalog, l’ordre des règles et des buts ne compte plus. La récursion à gauche chemin(X, Y) :- chemin(X, Z), liaison(Z, Y). est parfaitement valable. On retrouve la promesse initiale de la programmation déclarative : on décrit ce qui est vrai, et le moteur s’occupe du reste.
Datalog est resté longtemps un objet de théorie des bases de données, puis il est revenu dans l’industrie. On retrouve des langages de sa famille dans l’analyse statique de programmes (des outils comme Soufflé, ou le langage de requêtes de CodeQL, qui cherchent des failles en raisonnant sur le code par règles), dans certaines bases de données comme Datomic, ou dans Logica, qui compile des règles vers du SQL.
Le SQL récursif : la fermeture transitive en SQL
Section intitulée « Le SQL récursif : la fermeture transitive en SQL »Un prédicat est une table, une règle est une vue : vous l’avez vu dès le chapitre 2. Mais une vue SQL classique ne peut pas s’appeler elle-même. Depuis la norme SQL:1999, les expressions de table communes récursives (WITH RECURSIVE) comblent ce manque, et elles sont disponibles dans PostgreSQL, SQLite, MySQL 8 ou SQL Server (ce dernier omet le mot-clé RECURSIVE). Voici chemin/2 en SQL :
-- table liaison(depart, arrivee)WITH RECURSIVE chemin(depart, arrivee) AS ( SELECT depart, arrivee FROM liaison -- cas de base UNION SELECT l.depart, c.arrivee -- cas récursif FROM liaison AS l JOIN chemin AS c ON l.arrivee = c.depart -- jointure sur la ville intermédiaire)SELECT arrivee FROM chemin WHERE depart = 'rennes' ORDER BY arrivee;La correspondance avec Prolog est terme à terme : la partie avant UNION est le cas de base, la partie après est le cas récursif, et la condition l.arrivee = c.depart joue le rôle de la variable partagée Z dans liaison(X, Z), chemin(Z, Y). Le moteur évalue la requête exactement comme Datalog, par tours jusqu’au point fixe.
Exécutée avec SQLite sur le réseau avec cycle, la requête renvoie 8 villes : Angers, Laval, Le Mans, Nantes, Paris, Rennes (grâce au retour depuis Paris), Saint-Malo et Vannes. Elle termine sans difficulté.
| Prolog | Datalog | SQL |
|---|---|---|
fait liaison(rennes, laval). | fait | ligne d’une table |
prédicat liaison/2 | relation | table à deux colonnes |
| règle non récursive | règle | vue (CREATE VIEW) |
| variable partagée entre deux buts | variable partagée | JOIN … ON |
| deux clauses | deux règles | UNION |
| règle récursive | règle récursive | WITH RECURSIVE |
not(P), monde clos | négation stratifiée | NOT EXISTS, monde clos |
findall, setof | agrégats (selon les moteurs) | array_agg, GROUP BY |
Les graphes de connaissances
Section intitulée « Les graphes de connaissances »Un graphe de connaissances (knowledge graph) stocke des faits sous forme de triplets sujet, prédicat, objet : « le far breton est une sorte de gâteau », « Rennes est la préfecture d’Ille-et-Vilaine ». C’est la structure du format RDF et de bases publiques comme Wikidata. Un triplet, c’est un fait binaire comme sorte_de(far_breton, gateau) ; pour manipuler le prédicat lui-même comme une donnée, on l’écrit plutôt sous la forme d’un fait à trois arguments :
triplet(far_breton, sorte_de, gateau).triplet(gateau, sorte_de, dessert).triplet(dessert, sorte_de, plat).
% est_un(X, C) : X appartient à la catégorie C, directement ou par héritageest_un(X, C) :- triplet(X, sorte_de, C).est_un(X, C) :- triplet(X, sorte_de, D), est_un(D, C).?- est_un(far_breton, C). donne gâteau, dessert, puis plat. C’est encore une fermeture transitive. Le langage de requêtes SPARQL l’écrit avec un chemin de propriétés : dans Wikidata, ?x wdt:P279+ ?c suit la propriété « sous-classe de » (P279) une ou plusieurs fois, comme est_un, et wdt:P279* accepte aussi zéro pas, comme une fermeture réflexo-transitive.
Les graphes de connaissances vont plus loin que la simple fermeture : des langages de règles et d’ontologies (RDFS, OWL) permettent de déduire de nouveaux triplets, par exemple qu’un individu d’une sous-classe appartient aussi à la classe mère. Ces raisonneurs appliquent des règles très proches de Datalog. Ils servent en recherche documentaire, en biologie (ontologies de gènes et de maladies) ou pour intégrer des données hétérogènes. En pratique, on les combine souvent avec l’apprentissage : des modèles appris proposent des triplets manquants, que des règles valident ou complètent.
IA symbolique et apprentissage
Section intitulée « IA symbolique et apprentissage »Prolog appartient à l’IA symbolique : on représente explicitement les connaissances par des symboles et des règles, et on raisonne par déduction. L’apprentissage automatique, lui, apprend une fonction à partir d’exemples, sans règles explicites (voir le cours Apprentissage automatique). Les deux approches ont des forces opposées.
| Critère | IA symbolique (règles, logique) | Apprentissage automatique |
|---|---|---|
| Point de départ | des connaissances d’experts | des données étiquetées |
| Raisonnement | exact, déductif | statistique, approximatif |
| Explication | la démonstration est l’explication | à reconstruire (importance des variables…) |
| Données bruitées, perception (images, sons) | mal à l’aise | point fort |
| Coût principal | écrire et maintenir les règles | collecter et étiqueter les données |
| Cas nouveaux imprévus | échoue (hors des règles) | généralise, parfois à tort |
L’IA symbolique n’a pas disparu : elle s’est spécialisée. Les solveurs de contraintes (ECLiPSe lui-même est d’abord un système de programmation logique par contraintes) planifient des emplois du temps, des tournées et des productions industrielles. Les moteurs de règles encodent des réglementations (éligibilité, tarification, conformité), là où chaque décision doit pouvoir être justifiée. La programmation par ensembles réponses (Answer Set Programming), héritière directe de la négation par l’échec, résout des problèmes combinatoires.
Une piste de recherche active, dite neuro-symbolique, cherche à combiner les deux mondes : un modèle de langage traduit une question en requête SQL ou en programme logique, qu’un moteur exact exécute ensuite ; ou des règles vérifient la cohérence des sorties d’un modèle appris. Ces combinaisons sont prometteuses, mais encore loin d’être la norme : il faut les voir comme des outils à choisir selon le problème, pas comme une révolution acquise.
En résumé
Section intitulée « En résumé »- Datalog restreint Prolog (pas de termes composés, pas de coupure, négation stratifiée) et l’évalue de façon ascendante : il termine toujours et l’ordre des règles ne compte plus.
- L’évaluation semi-naïve calcule le point fixe par tours, en ne combinant que les faits nouveaux.
- Le SQL récursif (
WITH RECURSIVE) écrit une fermeture transitive avec un cas de base, unUNIONet un cas récursif ;UNIONgarantit la terminaison sur les cycles,UNION ALLnon. - Un graphe de connaissances est un ensemble de triplets, donc de faits binaires ; l’héritage de catégories est une fermeture transitive, que SPARQL écrit avec un chemin de propriétés.
- L’IA symbolique est exacte et explicable, l’apprentissage robuste au bruit et à la perception ; les approches neuro-symboliques cherchent à les combiner.
Exercices
Section intitulée « Exercices »Exercice · Est-ce du Datalog ?
facileParmi ces programmes Prolog, lesquels sont du Datalog valide ? Justifiez.
grand_parent(X, Y) :- parent(X, Z), parent(Z, Y).trajet(X, Y, par(Z, T)) :- liaison(X, Z), trajet(Z, Y, T).ancetre(X, Y) :- ancetre(X, Z), parent(Z, Y).tarif(P, T) :- etudiant(P), !, T = 7.connu(X, Y) :- personne(X).
Voir la solution
- Oui : que des constantes et des variables, et les variables de la tête (X et Y) apparaissent dans le corps.
- Non :
par(Z, T)est un terme composé, interdit en Datalog. - Oui, et c’est même là que Datalog brille : cette récursion à gauche ferait boucler Prolog, mais l’évaluation ascendante la calcule sans difficulté.
- Non : la coupure n’existe pas en Datalog.
- Non : la variable Y de la tête n’apparaît pas dans le corps. La règle n’est pas « sûre » : elle affirmerait
connu(X, Y)pour toutes les valeurs possibles de Y, un ensemble non borné.
En code
% datalog(+Regle) : la règle respecte les trois contraintes de Datalogdatalog((Tete :- Corps)) :- buts(Corps, Buts), \+ (member(B, Buts), B == '!'), % pas de coupure \+ (member(A, [Tete|Buts]), A =.. [_|Args], member(Arg, Args), compound(Arg)), % pas de terme composé term_variables(Tete, VT), term_variables(Corps, VC), \+ (member(V, VT), \+ (member(W, VC), W == V)). % règle sûre
% buts(+Corps, -Buts) : la conjonction (A, B) mise sous forme de listebuts((A, B), [A|L]) :- !, buts(B, L).buts(A, [A]).
% ?- datalog((grand_parent(X, Y) :- parent(X, Z), parent(Z, Y))). Yes% ?- datalog((trajet(X, Y, par(Z, T)) :- liaison(X, Z), trajet(Z, Y, T))). No% ?- datalog((ancetre(X, Y) :- ancetre(X, Z), parent(Z, Y))). Yes% ?- datalog((tarif(P, T) :- etudiant(P), !, T = 7)). No% ?- datalog((connu(X, Y) :- personne(X))). No# Une règle = (tête, [buts]) ; un atome logique = tuple (prédicat, arg1, ...)regles = { 1: (("grand_parent", "X", "Y"), [("parent", "X", "Z"), ("parent", "Z", "Y")]), 2: (("trajet", "X", "Y", ("par", "Z", "T")), [("liaison", "X", "Z"), ("trajet", "Z", "Y", "T")]), 3: (("ancetre", "X", "Y"), [("ancetre", "X", "Z"), ("parent", "Z", "Y")]), 4: (("tarif", "P", "T"), [("etudiant", "P"), "!", ("=", "T", 7)]), 5: (("connu", "X", "Y"), [("personne", "X")]),}def variables(atomes): return {a for at in atomes for a in at[1:] if isinstance(a, str) and a[0].isupper()}
def verdict(tete, corps): if "!" in corps: return "non : coupure" if any(isinstance(a, tuple) for at in [tete, *corps] for a in at[1:]): return "non : terme composé" if not variables([tete]) <= variables(corps): return "non : règle pas sûre" return "oui"
for n, (tete, corps) in regles.items(): print(n, verdict(tete, corps))# 1 oui / 2 non : terme composé / 3 oui / 4 non : coupure / 5 non : règle pas sûre# Une règle = list(tête, corps) ; un atome logique = list(prédicat, arg1, ...)A <- function(...) list(...)regles <- list( list(A("grand_parent", "X", "Y"), list(A("parent", "X", "Z"), A("parent", "Z", "Y"))), list(A("trajet", "X", "Y", A("par", "Z", "T")), list(A("liaison", "X", "Z"), A("trajet", "Z", "Y", "T"))), list(A("ancetre", "X", "Y"), list(A("ancetre", "X", "Z"), A("parent", "Z", "Y"))), list(A("tarif", "P", "T"), list(A("etudiant", "P"), "!", A("=", "T", 7))), list(A("connu", "X", "Y"), list(A("personne", "X"))))
variables <- function(atomes) unique(unlist(lapply(atomes, function(at) Filter(function(a) is.character(a) && grepl("^[A-Z]", a), at[-1]))))
verdict <- function(tete, corps) { if (any(sapply(corps, identical, "!"))) return("non : coupure") if (any(sapply(c(list(tete), corps), function(at) any(sapply(at[-1], is.list))))) return("non : terme composé") if (!all(variables(list(tete)) %in% variables(corps))) return("non : règle pas sûre") "oui"}for (n in seq_along(regles)) cat(n, verdict(regles[[n]][[1]], regles[[n]][[2]]), "\n")# 1 oui / 2 non : terme composé / 3 oui / 4 non : coupure / 5 non : règle pas sûreExercice · Les ancêtres en SQL
moyenLa table parent(parent, enfant) contient les lignes (anne, bruno), (bruno, chloe) et (chloe, denis). Écrivez une requête SQL récursive qui calcule la relation ancetre(anc, descendant), puis donnez son résultat.
Voir la solution
WITH RECURSIVE ancetre(anc, descendant) AS ( SELECT parent, enfant FROM parent UNION SELECT p.parent, a.descendant FROM parent AS p JOIN ancetre AS a ON p.enfant = a.anc)SELECT anc, descendant FROM ancetre ORDER BY anc, descendant;C’est la traduction directe de ancetre(X, Y) :- parent(X, Z), ancetre(Z, Y). : la jointure p.enfant = a.anc joue le rôle de Z. Le résultat contient 6 couples : (anne, bruno), (anne, chloe), (anne, denis), (bruno, chloe), (bruno, denis) et (chloe, denis).
En code
parent(anne, bruno).parent(bruno, chloe).parent(chloe, denis).
ancetre(X, Y) :- parent(X, Y).ancetre(X, Y) :- parent(X, Z), ancetre(Z, Y).
% ?- findall(A-D, ancetre(A, D), L0), sort(L0, L), length(L, N).% L = [anne-bruno, anne-chloe, anne-denis, bruno-chloe, bruno-denis, chloe-denis]% N = 6import sqlite3
parent = {("anne", "bruno"), ("bruno", "chloe"), ("chloe", "denis")}
# Point fixe : ancetre(X, Y) :- parent(X, Z), ancetre(Z, Y).ancetre = set(parent) # cas de basewhile True: nouveaux = {(x, y) for x, z in parent for z2, y in ancetre if z == z2} - ancetre if not nouveaux: # rien de neuf : point fixe break ancetre |= nouveauxprint(len(ancetre), sorted(ancetre))# 6 [('anne', 'bruno'), ('anne', 'chloe'), ('anne', 'denis'), ('bruno', 'chloe'), ('bruno', 'denis'), ('chloe', 'denis')]
# La requête SQL de la solution, exécutée par SQLitebd = sqlite3.connect(":memory:")bd.execute("CREATE TABLE parent(parent TEXT, enfant TEXT)")bd.executemany("INSERT INTO parent VALUES (?, ?)", sorted(parent))requete = """WITH RECURSIVE ancetre(anc, descendant) AS ( SELECT parent, enfant FROM parent UNION SELECT p.parent, a.descendant FROM parent AS p JOIN ancetre AS a ON p.enfant = a.anc)SELECT anc, descendant FROM ancetre ORDER BY anc, descendant"""print(bd.execute(requete).fetchall() == sorted(ancetre)) # True : mêmes 6 couplesparent <- data.frame(p = c("anne", "bruno", "chloe"), e = c("bruno", "chloe", "denis"))
# Point fixe : ancetre(X, Y) :- parent(X, Z), ancetre(Z, Y).ancetre <- data.frame(anc = parent$p, desc = parent$e) # cas de baserepeat { j <- merge(parent, ancetre, by.x = "e", by.y = "anc") # jointure sur Z candidats <- unique(rbind(ancetre, data.frame(anc = j$p, desc = j$desc))) if (nrow(candidats) == nrow(ancetre)) break # rien de neuf : point fixe ancetre <- candidats}ancetre[order(ancetre$anc, ancetre$desc), ]# anne bruno ; anne chloe ; anne denis ; bruno chloe ; bruno denis ; chloe denis (6 couples)Exercice · Dérouler un point fixe
difficileAvec les trois faits parent/2 de l’exercice précédent et les règles
ancetre(X, Y) :- parent(X, Y).ancetre(X, Y) :- ancetre(X, Z), parent(Z, Y).- Déroulez l’évaluation semi-naïve : quels faits
ancetreapparaissent à chaque tour, et quand le point fixe est-il atteint ? - Pour une chaîne de n générations (n + 1 personnes, n faits
parent), combien de faitsancetreobtient-on, et combien de tours productifs faut-il ? - Que ferait Prolog avec la question
?- ancetre(denis, X).et ces règles ?
Voir la solution
-
Tour 1 : la première règle copie les faits
parent:ancetre(anne, bruno),ancetre(bruno, chloe),ancetre(chloe, denis). Tour 2 : on combine les faits nouveaux du tour 1 avecparent:ancetre(anne, chloe)etancetre(bruno, denis). Tour 3 : à partir des nouveaux faits du tour 2,ancetre(anne, denis). Tour 4 : rien de nouveau, le point fixe est atteint avec 3 + 2 + 1 = 6 faits. -
Chaque couple (ancêtre, descendant) correspond à un choix de deux personnes distinctes sur la chaîne, l’ancêtre étant la plus ancienne : il y en a n(n + 1)/2. Le tour k produit les couples séparés par k générations, soit n − k + 1 faits : il faut n tours productifs, puis un tour vide pour constater le point fixe. Pour n = 3, on retrouve 6 faits en 3 tours productifs.
-
Prolog boucle. La première clause échoue (Denis n’a pas d’enfant), la seconde appelle
ancetre(denis, Z)avec les mêmes arguments, qui rappelle la seconde clause, et ainsi de suite : c’est la récursion à gauche du chapitre 4. Datalog, lui, répond immédiatement qu’il n’y a aucun faitancetre(denis, …)dans le point fixe.
En code
% un tour semi-naïf : on combine les faits NOUVEAUX (Delta) avec parent/2tour(Delta, Connus, Nouveaux) :- findall(X-Y, (member(X-Z, Delta), parent(Z, Y)), L0), sort(L0, L), findall(F, (member(F, L), \+ member(F, Connus)), Nouveaux).
point_fixe([], Connus, Connus).point_fixe([F|Fs], Connus, Tout) :- tour([F|Fs], Connus, Nouveaux), append(Connus, Nouveaux, Connus2), point_fixe(Nouveaux, Connus2, Tout).
% ?- findall(X-Y, parent(X, Y), T1), point_fixe(T1, T1, Tout), length(Tout, N).% tours : 3 faits, puis 2, puis 1, puis aucun (point fixe)% Tout = [anne-bruno, bruno-chloe, chloe-denis, anne-chloe, bruno-denis, anne-denis]% N = 6def semi_naive(parent, afficher=False): """ancetre(X, Y) :- ancetre(X, Z), parent(Z, Y), évalué tour par tour.""" delta = set(parent) # tour 1 : la règle de base copie parent connus, tours = set(delta), 0 while delta: # un delta vide : point fixe atteint tours += 1 if afficher: print("tour", tours, sorted(delta)) # on ne combine que les faits NOUVEAUX du tour précédent delta = {(x, y) for x, z in delta for z2, y in parent if z == z2} - connus connus |= delta return len(connus), tours
parent = [("anne", "bruno"), ("bruno", "chloe"), ("chloe", "denis")]print(semi_naive(parent, True))# tour 1 [('anne', 'bruno'), ('bruno', 'chloe'), ('chloe', 'denis')]# tour 2 [('anne', 'chloe'), ('bruno', 'denis')]# tour 3 [('anne', 'denis')]# (6, 3) : 6 faits, 3 tours productifs (le 4e ne produit rien)
for n in range(1, 7): # chaîne de n générations : 0 -> 1 -> ... -> n faits, tours = semi_naive([(i, i + 1) for i in range(n)]) print(n, faits, n * (n + 1) // 2, tours) # faits = n(n+1)/2, tours = n# 1 1 1 1 / 2 3 3 2 / 3 6 6 3 / 4 10 10 4 / 5 15 15 5 / 6 21 21 6
def ancetre_prolog(x): # 3. la clause 2 rappelle ancetre(X, Z) d'abord yield from (y for p, y in parent if p == x) for z in ancetre_prolog(x): yield from (y for p, y in parent if p == z)try: list(ancetre_prolog("denis"))except RecursionError: print("ancetre(denis, X) : boucle") # récursion à gauche# ancetre(X, Y) :- ancetre(X, Z), parent(Z, Y), évalué tour par toursemi_naive <- function(parent, afficher = FALSE) { delta <- unique(parent) # tour 1 : la règle de base copie parent connus <- paste(delta$p, delta$e); tours <- 0 while (nrow(delta) > 0) { # un delta vide : point fixe atteint tours <- tours + 1 if (afficher) cat("tour", tours, ":", paste(delta$p, delta$e, sep = "-"), "\n") j <- merge(delta, parent, by.x = "e", by.y = "p") # ne combine que les NOUVEAUX faits delta <- unique(data.frame(p = j$p, e = j$e.y)) delta <- delta[!(paste(delta$p, delta$e) %in% connus), ] connus <- c(connus, paste(delta$p, delta$e)) } c(faits = length(connus), tours = tours)}parent <- data.frame(p = c("anne", "bruno", "chloe"), e = c("bruno", "chloe", "denis"))semi_naive(parent, TRUE)# tour 1 : anne-bruno bruno-chloe chloe-denis# tour 2 : anne-chloe bruno-denis# tour 3 : anne-denis# faits 6, tours 3
for (n in 1:6) { # chaîne de n générations : 0 -> 1 -> ... -> n r <- semi_naive(data.frame(p = 0:(n - 1), e = 1:n)) cat(n, r, n * (n + 1) / 2, "\n") # faits = n(n+1)/2, tours = n}# 1 1 1 1 / 2 3 2 3 / 3 6 3 6 / 4 10 4 10 / 5 15 5 15 / 6 21 6 21