Tweety-11 — Inférence causale & do-calculus (twin C# from-scratch)

Ce notebook est le jumeau C# / .NET de Tweety-11-Causal.ipynb — marathon de parité #4956.

La version Python s’appuie sur Tweety causal-1.30.jar (via JPype) : un reasoner causal argumentatif construit sur des Structural Causal Models booléens. Ce twin C# réimplémente le moteur causal from-scratch (BCL .NET 9, 0 NuGet) pour rendre visibles les internes : la chirurgie de graphe du do-operator, l’énumération des mondes possibles, la sémantique contrefactuelle. La lib Java Tweety = RECOVERABLE-MACHINE (Python-only) ; le moteur from-scratch EST la substance.

Pourquoi from-scratch ? L’enjeu pédagogique de l’inférence causale (Pearl) n’est pas d’appeler une boîte noire, mais de voir pourquoi P(rain | observe drops) != P(rain | do drops). Cela exige d’exposer le graphe causal orienté, la surgery do(X) qui rompt les liens entrants, et l’évaluation des mondes possibles.

1. Moteur causal — modèles structurels booléens

Un Structural Causal Model (SCM) booléen est un DAG d’atomes propositionnels : - les atomes de fond (exogènes) sont les causes racines, libres ; - les atomes expliqués (endogènes) sont définis par une équation booléenne sur leurs parents.

Étant donné une assignation des atomes de fond, tous les atomes expliqués sont déterminés par propagation (le graphe est acyclique). Un monde possible = une assignation complète cohérente avec les équations.

using System;
using System.Linq;
using System.Text;
using System.Collections.Generic;

static void Show(string s) { s.Display(); }
Show("Moteur d'inference causale (Boolean structural causal models, from-scratch).");
Moteur d'inference causale (Boolean structural causal models, from-scratch).
#nullable enable
// --- Formule booleenne sur des atomes : || && ! ( ) + (vrai) - (faux) ---
public sealed class Formula
{
    public Func<IReadOnlyDictionary<string,bool>, bool> Eval;
    public HashSet<string> Vars;
    public Formula(Func<IReadOnlyDictionary<string,bool>, bool> e, HashSet<string> v) { Eval = e; Vars = v; }
    public static Formula Atom(string n) => new(a => a[n], new() { n });
    public static Formula Const(bool b) => new(_ => b, new());
    public static Formula Not(Formula f) => new(a => !f.Eval(a), f.Vars);
    public static Formula And(Formula x, Formula y) => new(a => x.Eval(a) && y.Eval(a), Union(x.Vars, y.Vars));
    public static Formula Or(Formula x, Formula y)  => new(a => x.Eval(a) || y.Eval(a), Union(x.Vars, y.Vars));
    static HashSet<string> Union(HashSet<string> a, HashSet<string> b) { var s = new HashSet<string>(a); s.UnionWith(b); return s; }
}

// Petite analyse recursive descendante (tokeniseur + grammaire PL minimale).
public static class FormulaParser
{
    public static Formula Parse(string src)
    {
        var toks = Tokenize(src);
        int i = 0;
        Formula f = ParseOr(toks, ref i);
        return f;
    }
    static List<string> Tokenize(string s)
    {
        var t = new List<string>();
        int i = 0;
        while (i < s.Length)
        {
            char c = s[i];
            if (char.IsWhiteSpace(c)) { i++; continue; }
            if (c == '(' || c == ')' || c == '+' || c == '-' || c == '!') { t.Add(c.ToString()); i++; continue; }
            if (c == '&' && i + 1 < s.Length && s[i + 1] == '&') { t.Add("&&"); i += 2; continue; }
            if (c == '|' && i + 1 < s.Length && s[i + 1] == '|') { t.Add("||"); i += 2; continue; }
            if (char.IsLetter(c) || c == '_')
            {
                int j = i;
                while (j < s.Length && (char.IsLetterOrDigit(s[j]) || s[j] == '_')) j++;
                t.Add(s.Substring(i, j - i)); i = j; continue;
            }
            throw new ArgumentException($"Caractere inattendu '{c}' dans la formule : {s}");
        }
        return t;
    }
    static Formula ParseOr(List<string> t, ref int i)
    {
        Formula f = ParseAnd(t, ref i);
        while (i < t.Count && t[i] == "||") { i++; f = Formula.Or(f, ParseAnd(t, ref i)); }
        return f;
    }
    static Formula ParseAnd(List<string> t, ref int i)
    {
        Formula f = ParseNot(t, ref i);
        while (i < t.Count && t[i] == "&&") { i++; f = Formula.And(f, ParseNot(t, ref i)); }
        return f;
    }
    static Formula ParseNot(List<string> t, ref int i)
    {
        if (i < t.Count && t[i] == "!") { i++; return Formula.Not(ParseNot(t, ref i)); }
        return ParsePrimary(t, ref i);
    }
    static Formula ParsePrimary(List<string> t, ref int i)
    {
        string tok = t[i];
        if (tok == "+") { i++; return Formula.Const(true); }
        if (tok == "-") { i++; return Formula.Const(false); }
        if (tok == "(") { i++; Formula f = ParseOr(t, ref i); i++; return f; } // saute ')'
        i++; return Formula.Atom(tok); // identificateur
    }
}

// --- Modele causal structurel booleen ---
public sealed class StructuralCausalModel
{
    public enum Kind { Background, Explainable }
    public Dictionary<string, Kind> Kinds = new();
    public Dictionary<string, Formula> Eq = new();   // eq d'evaluation
    public Dictionary<string, string> EqSrc = new(); // eq source pour affichage

    public List<string> Background => Kinds.Where(kv => kv.Value == Kind.Background).Select(kv => kv.Key).ToList();
    public List<string> Explainable => Kinds.Where(kv => kv.Value == Kind.Explainable).Select(kv => kv.Key).ToList();

    public void AddBackgroundAtom(string a) { Kinds[a] = Kind.Background; }
    public void AddExplainableAtom(string a, string src) { Kinds[a] = Kind.Explainable; Eq[a] = FormulaParser.Parse(src); EqSrc[a] = src; }

    // Ordre topologique sur les atomes expliques (Kahn ; les atomes de fond sont des sources).
    List<string> TopoOrder()
    {
        var remaining = Explainable.ToHashSet();
        var order = new List<string>();
        bool progress = true;
        while (remaining.Count > 0)
        {
            progress = false;
            foreach (var a in remaining.ToList())
            {
                bool depsOk = Eq[a].Vars.All(d => !remaining.Contains(d));
                if (depsOk) { order.Add(a); remaining.Remove(a); progress = true; }
            }
            if (!progress) throw new InvalidOperationException("SCM cyclique (equations structurelles doivent etre acycliques).");
        }
        return order;
    }

    // Propage : etant donne les atomes de fond, calcule le monde complet.
    public Dictionary<string, bool> Propagate(Dictionary<string, bool> bg)
    {
        var w = new Dictionary<string, bool>(bg);
        foreach (var a in TopoOrder()) w[a] = Eq[a].Eval(w);
        return w;
    }

    // Tous les mondes possibles = produit de toutes les assignations des atomes de fond.
    public List<Dictionary<string, bool>> AllWorlds()
    {
        var bg = Background;
        var worlds = new List<Dictionary<string, bool>>();
        int n = bg.Count;
        for (int mask = 0; mask < (1 << n); mask++)
        {
            var assign = new Dictionary<string, bool>();
            for (int i = 0; i < n; i++) assign[bg[i]] = ((mask >> i) & 1) == 1;
            worlds.Add(Propagate(assign));
        }
        return worlds;
    }

    public Dictionary<string, bool> BackgroundOf(Dictionary<string, bool> world)
    {
        var b = new Dictionary<string, bool>();
        foreach (var a in Background) b[a] = world[a];
        return b;
    }

    // Chirurgie du do-operator : do(X = val) rompt les liens entrants vers X (X devient constante).
    public StructuralCausalModel Intervene(string atom, bool val)
    {
        var c = Clone();
        c.Kinds[atom] = Kind.Explainable;   // n'est plus libre : fixe par l'intervention
        c.Eq[atom] = Formula.Const(val);
        c.EqSrc[atom] = val ? "+" : "-";
        return c;
    }

    public StructuralCausalModel Clone()
    {
        var c = new StructuralCausalModel();
        foreach (var kv in Kinds) c.Kinds[kv.Key] = kv.Value;
        foreach (var kv in Eq) c.Eq[kv.Key] = kv.Value;
        foreach (var kv in EqSrc) c.EqSrc[kv.Key] = kv.Value;
        return c;
    }

    public string PrettyPrint()
    {
        var sb = new StringBuilder();
        foreach (var a in Background) sb.AppendLine($"  [fond]      {a}");
        foreach (var a in Explainable) sb.AppendLine($"  [explique]  {a} <=> {EqSrc[a]}");
        return sb.ToString().TrimEnd();
    }
}

Show("StructuralCausalModel + parser de formules + propagation + Intervene (do-operator) prets.");
StructuralCausalModel + parser de formules + propagation + Intervene (do-operator) prets.

Ce que « from scratch » veut dire ici. Le moteur définit ci-dessus les trois briques minimales d’un modèle causal structural (Pearl) en C# pur : les variables de fond (storm — des faits exogènes sans équation), les équations structurelles (drops <=> storm — chaque variable expliquée est une fonction de ses causes), et l’intervention do (remplacer une équation par une constante — la « chirurgie »). Pas de probabilités dans cette version : les valeurs sont booléennes et la propagation déterministe, ce qui isole la structure du raisonnement causal — le squelette que la version probabiliste habille ensuite de chiffres.

2. Reasoner causal — observation, intervention, contrefactuel

Trois types de requêtes causales (Pearl) :

  • Observation P(Y | observe X) : correlation. Parmi les mondes ou X est vrai, Y est-il etabli ?
  • Intervention P(Y | do X) : on applique la chirurgie do(X) puis on mesure Y. Casse les confondeurs.
  • Contrefactuel observe W ; si X avait ete autre, Y aurait-il eu lieu ? : monde reel + monde contrefactuel.

Convention : une requête renvoie True si la conclusion est etablie dans tous les mondes consistants (si elle est vraie dans certains et fausse dans d’autres, la reponse est False = non determine).

#nullable enable
// Base de connaissance causale : enveloppe le SCM (tous les mondes du modele sont candidats).
public sealed class CausalKnowledgeBase
{
    public StructuralCausalModel Model;
    public CausalKnowledgeBase(StructuralCausalModel m) { Model = m; }
    public List<Dictionary<string,bool>> Worlds() => Model.AllWorlds();
}

// Declaration d'intervention : do(atom=val) pour plusieurs atomes + conclusion.
public sealed class InterventionalStatement
{
    public List<(string Atom, bool Val)> Interventions = new();
    public string Conclusion = "";
    public void AddIntervention(string atom, bool val) => Interventions.Add((atom, val));
    public void SetConclusion(string c) => Conclusion = c;
}

// Declaration contrefactuelle : observations (monde reel) + interventions contrefactuelles + conclusion.
public sealed class CounterfactualStatement
{
    public List<(string Atom, bool Val)> Observations = new();
    public List<(string Atom, bool Val)> CfInterventions = new();
    public string Conclusion = "";
    public void AddObservation(string atom, bool val) => Observations.Add((atom, val));
    public void AddCounterfactualIntervention(string atom, bool val) => CfInterventions.Add((atom, val));
    public void SetConclusion(string c) => Conclusion = c;
}

public sealed class CausalReasoner
{
    // Observation : conclusion etablie dans tous les mondes ou les atomes observes ont la valeur donnee.
    public bool Query(CausalKnowledgeBase ckb, List<(string Atom, bool Val)> observed, string conclusion)
    {
        var consistent = ckb.Worlds().Where(w => observed.All(o => w[o.Atom] == o.Val)).ToList();
        if (consistent.Count == 0) return false;
        return consistent.All(w => w[conclusion]);
    }
    // Raccourci : observer un atome = True.
    public bool Query(CausalKnowledgeBase ckb, List<string> observedTrue, string conclusion)
        => Query(ckb, observedTrue.Select(a => (a, true)).ToList(), conclusion);

    // Intervention : on applique do(*) puis on demande si la conclusion est etablie dans tous les mondes.
    public bool Query(CausalKnowledgeBase ckb, InterventionalStatement stmt)
    {
        var m = ckb.Model;
        foreach (var iv in stmt.Interventions) m = m.Intervene(iv.Atom, iv.Val);
        var worlds = m.AllWorlds();
        if (worlds.Count == 0) return false;
        return worlds.All(w => w[stmt.Conclusion]);
    }

    // Contrefactuel : mondes reels consistants avec l'observation ; sur CHACUN (fond fixe) on applique
    // l'intervention contrefactuelle et on re-evalue. La conclusion doit tenir dans tous les mondes contrefactuels.
    public bool Query(CausalKnowledgeBase ckb, CounterfactualStatement stmt)
    {
        var actual = ckb.Worlds().Where(w => stmt.Observations.All(o => w[o.Atom] == o.Val)).ToList();
        if (actual.Count == 0) return false;
        foreach (var w in actual)
        {
            var m = ckb.Model;
            foreach (var ci in stmt.CfInterventions) m = m.Intervene(ci.Atom, ci.Val);
            var cfWorld = m.Propagate(m.BackgroundOf(w));  // fond du monde reel, puis surgery
            if (!cfWorld[stmt.Conclusion]) return false;
        }
        return true;
    }
}

Show("CausalKnowledgeBase + InterventionalStatement + CounterfactualStatement + CausalReasoner prets.");
CausalKnowledgeBase + InterventionalStatement + CounterfactualStatement + CausalReasoner prets.

Les trois modes d’interrogation. Le CausalReasoner exposé ci-dessus distingue exactement les trois régimes de requêtes qui structurent la suite du notebook :

  • observe(X) — conditionnement : « sachant ce que je vois, que déduire ? » (statistique) ;
  • do(X) — intervention : « si je force X, que devient le système ? » (la flèche entrante de X est coupée, son équation remplacée par la valeur forcée) ;
  • contrefactuel — rétro-mondes : « dans le monde qui explique mon observation, si X avait différé… »

L’API du reasoner est volontairement verbeuse sur ce point : confondre observe et do est l’erreur n°1 du raisonnement causal, et le type (ObservationalStatement vs InterventionalStatement vs CounterfactualStatement) rend la confusion impossible à écrire par accident.

3. Le baromètre — corrélation n’est pas causalité

Exemple canonique : un baromètre qui tombe (drops) et la pluie (rain) sont toutes deux causées par l’orage (storm). Observer le baromètre prédit la pluie (corrélation). Forcer le baromètre ne fait PAS pleuvoir (pas de lien causal drops -> rain).

C’est la distinction fondamentale P(rain | drops) != P(rain | do drops).

var storm = "storm"; var drops = "drops"; var rain = "rain";

var barometer = new StructuralCausalModel();
barometer.AddBackgroundAtom(storm);            // storm = cause racine exogene
barometer.AddExplainableAtom(drops, "storm");  // drops  <=> storm
barometer.AddExplainableAtom(rain,  "storm");  // rain   <=> storm

Show("SCM barometre :");
Show(barometer.PrettyPrint());
SCM barometre :
  [fond]      storm
  [explique]  drops <=> storm
  [explique]  rain <=> storm
var ckb_bar = new CausalKnowledgeBase(barometer);
var reasoner = new CausalReasoner();

// NIVEAU 1 : observation (correlation)
bool p_rain_if_drops = reasoner.Query(ckb_bar, new List<string> { drops }, rain);
Show($"[OBSERVATION] observe(drops=True) -> pleut-il ? {p_rain_if_drops}");
Show("  -> Le barometre predit la pluie : P(rain | drops) = True (correlation via storm)");

// NIVEAU 2 : intervention do(drops=True)
var do_drops = new InterventionalStatement();
do_drops.AddIntervention(drops, true);
do_drops.SetConclusion(rain);
bool p_rain_if_do_drops = reasoner.Query(ckb_bar, do_drops);
Show($"[INTERVENTION] do(drops=True)     -> pleut-il ? {p_rain_if_do_drops}");
Show("  -> Forcer le barometre NE FAIT PAS pleuvoir : P(rain | do(drops)) = False");
[OBSERVATION] observe(drops=True) -> pleut-il ? True
  -> Le barometre predit la pluie : P(rain | drops) = True (correlation via storm)
[INTERVENTION] do(drops=True)     -> pleut-il ? False
  -> Forcer le barometre NE FAIT PAS pleuvoir : P(rain | do(drops)) = False

Visualisation de la chirurgie : do(drops=True) remplace l’équation drops <=> storm par drops <=> + (constante). Le lien storm -> drops est rompu — drops ne dépend plus de storm.

Show("SCM original :");
Show("  " + barometer.PrettyPrint().Replace("\n", "\n  "));

Show("\nSCM apres do(drops=True) :");
var barometer_do = barometer.Intervene(drops, true);
Show("  " + barometer_do.PrettyPrint().Replace("\n", "\n  "));
Show("\n=> L'equation 'drops <=> storm' est devenue 'drops <=> +' (constante).");
Show("   Le lien storm -> drops est ROMPU : drops ne depend plus de storm.");
SCM original :
    [fond]      storm
    [explique]  drops <=> storm
    [explique]  rain <=> storm

SCM apres do(drops=True) :
    [fond]      storm
    [explique]  drops <=> +
    [explique]  rain <=> storm

=> L'equation 'drops <=> storm' est devenue 'drops <=> +' (constante).
   Le lien storm -> drops est ROMPU : drops ne depend plus de storm.

4. Réseau Sprinkler — isoler l’effet causal propre

Un SCM plus riche : cloudy cause sprinkler et rain ; tous deux causent wet. C’est lejouet classique pour montrer que do(rain) casse le confondeur cloudy tandis que do(sprinkler) préserve la causalité directe vers wet.

var cloudy = "cloudy"; var sprinkler = "sprinkler"; var rain2 = "rain"; var wet = "wet";

var sprinkler_net = new StructuralCausalModel();
sprinkler_net.AddBackgroundAtom(cloudy);
sprinkler_net.AddExplainableAtom(sprinkler, "cloudy");
sprinkler_net.AddExplainableAtom(rain2,    "cloudy");
sprinkler_net.AddExplainableAtom(wet, "sprinkler || rain");   // wet = sprinkler OR rain

Show("SCM reseau Sprinkler :");
Show(sprinkler_net.PrettyPrint());

var ckb_sp = new CausalKnowledgeBase(sprinkler_net);
Show("Base causale prete.");
SCM reseau Sprinkler :
  [fond]      cloudy
  [explique]  sprinkler <=> cloudy
  [explique]  rain <=> cloudy
  [explique]  wet <=> sprinkler || rain
Base causale prete.

Lecture du réseau. Trois différences structurelles avec le baromètre méritent d’être dites avant de lancer les requêtes :

  • le nœud cloudy est la cause commune de deux effets indépendants (sprinkler, rain) — c’est un confondu : il crée de la corrélation entre ses deux enfants sans que l’un cause l’autre ;
  • wet est un collier (collider) : il a deux parents. La corrélation entre sprinkler et rain est nulle par défaut, mais elle apparaît dès qu’on conditionne sur wet (sélection sur l’effet) ;
  • les équations sont déterministes (<=>), la version probabiliste de Pearl remplacerait chaque <=> par une table conditionnelle — le raisonnement structurel, lui, est identique. C’est le biais de sélection dans sa forme la plus pure : filtrer sur un effet fabrique de la dépendance entre ses causes.
Show("=== Correlation vs Causalite dans le reseau Sprinkler ===\n");

// (a) OBSERVATION : observe(rain=True) -> sprinkler ?
bool p_sp_if_rain = reasoner.Query(ckb_sp, new List<string> { rain2 }, sprinkler);
Show($"[OBS]  observe(rain=True)  -> sprinkler actif ? {p_sp_if_rain}");
Show("       (correlation : rain et sprinkler coincident via cloudy)\n");

// (b) INTERVENTION : do(rain=True) -> sprinkler ?
var do_rain = new InterventionalStatement();
do_rain.AddIntervention(rain2, true);
do_rain.SetConclusion(sprinkler);
bool p_sp_if_do_rain = reasoner.Query(ckb_sp, do_rain);
Show($"[INT]  do(rain=True)       -> sprinkler actif ? {p_sp_if_do_rain}");
Show("       (intervention : do(rain) CASSE cloudy->rain, donc plus de correlation avec sprinkler)\n");

// (c) INTERVENTION causale directe : do(sprinkler=True) -> wet ?
var do_sp = new InterventionalStatement();
do_sp.AddIntervention(sprinkler, true);
do_sp.SetConclusion(wet);
bool p_wet_if_do_sp = reasoner.Query(ckb_sp, do_sp);
Show($"[INT]  do(sprinkler=True)  -> herbe mouillee ? {p_wet_if_do_sp}");
Show("       (causalite directe preservee : sprinkler CAUSE wet)\n");

Show("=> P(sprinkler | observe rain) != P(sprinkler | do rain)");
Show("   Signature du do-calculus : l'intervention isole l'effet causal propre.");
=== Correlation vs Causalite dans le reseau Sprinkler ===
[OBS]  observe(rain=True)  -> sprinkler actif ? True
       (correlation : rain et sprinkler coincident via cloudy)
[INT]  do(rain=True)       -> sprinkler actif ? False
       (intervention : do(rain) CASSE cloudy->rain, donc plus de correlation avec sprinkler)
[INT]  do(sprinkler=True)  -> herbe mouillee ? True
       (causalite directe preservee : sprinkler CAUSE wet)
=> P(sprinkler | observe rain) != P(sprinkler | do rain)
   Signature du do-calculus : l'intervention isole l'effet causal propre.

Lecture du résultat — la signature du do-calculus. Le réseau Sprinkler montre pourquoi observer n’est pas intervenir. La variable cloudy est un facteur de confusion (confounder) : elle cause à la fois rain (nuage → pluie) et sprinkler (nuage → on n’arrose pas), ce qui crée une corrélation fallacieuse entre rain et sprinkler. C’est pourquoi observe(rain=True) re monte vers sprinkler (P = True) : l’observation circule par le chemin du dos sprinkler ← cloudy → rain.

L’intervention do(rain=True) chirurgie ce chemin : elle rompt cloudy → rain et fixe rain depuis l’extérieur du système. La remontée vers sprinkler disparaît (P = False) — une intervention ne remonte pas le courant causal. En revanche, do(sprinkler=True) → wet reste vraie : aucun confondeur sur ce chemin, la causalité directe est préservée.

La dernière ligne formalise la leçon centrale de Pearl : P(sprinkler | observe rain) ≠ P(sprinkler | do rain). Cette inégalité — invisible à la seule statistique observationnelle — est exactement ce que le do-calculus permet de calculer, en identifiant puis en « chirurgiant » les chemins du dos (backdoor paths) qui mêlent corrélation et causalité.

5. Raisonnement contrefactuel — mondes possibles

Un contrefactuel : « on a observé wet=True ; si sprinkler avait été OFF, l’herbe aurait-elle tout de même été mouillée ? »

Sémantique des mondes possibles : on fixe le monde réel (observé), on applique l’intervention contrefactuelle en gardant les variables exogènes fixes, puis on ré-évalue la conclusion dans le monde contrefactuel.

var cf = new CounterfactualStatement();
cf.AddObservation(wet, true);                  // monde reel : on a observe wet=True
cf.AddCounterfactualIntervention(sprinkler, false); // monde contrefactuel : sprinkler=False
cf.SetConclusion(wet);                         // question : wet dans le monde contrefactuel ?

bool result_cf = reasoner.Query(ckb_sp, cf);
Show($"[CONTREFACTUEL] observe(wet=True) ; 'si sprinkler avait ete OFF, herbe mouillee ?' {result_cf}");
Show("  -> Oui : sous cloudy=True, rain aurait mouille l'herbe meme sans arrosage.");
[CONTREFACTUEL] observe(wet=True) ; 'si sprinkler avait ete OFF, herbe mouillee ?' True
  -> Oui : sous cloudy=True, rain aurait mouille l'herbe meme sans arrosage.

Les trois temps du contrefactuel. Le verdict True ci-dessus se déroule en trois étapes, toutes présentes dans le code de la cellule :

  1. Abduction — conditionner sur l’observation wet=True : le monde possible est reconstitué (cloudy=True, donc rain=True a eu lieu — c’est l’inférence qui explique le fait observé) ;
  2. Action — appliquer l’antécédent du contrefactuel dans ce monde : écraser l’équation sprinkler avec OFF (la même chirurgie do que section 4, mais dans le monde contrefactuel) ;
  3. Prédiction — évaluer la conséquence dans le monde modifié : rain reste actif, l’herbe reste mouillée, réponse True.

C’est exactement la structure du raisonnement « si Napoléon n’avait pas… » : on n’imagine pas un monde au hasard, on imagine le nôtre avec une seule équation changée. Le contraste avec l’exercice 3 sera instructif : quand l’antécédent porte sur la cause, la même machinerie répond l’inverse.

6. Parité jpype ⇔ IKVM — le moteur causal TweetyProject via le pont .NET

Le jumeau Python (Tweety-11-Causal.ipynb) charge la même librairie Java TweetyProject org.tweetyproject.causal (v1.30) via jpype ; ce twin C# charge la même librairie via IKVM (pont Java⇄.NET). C’est la parité visée par la série (#10381) : deux écosystèmes, un moteur de production.

L’implémentation from-scratch des sections 1–5 reste le cœur pédagogique (elle expose la chirurgie du graphe). Cette section l’accompagne et démontre que le vrai moteur Java produit les mêmes équations structurelles, chirurgies do(.) et mondes contrefactuels — la sémantique from-scratch n’est pas une réinvention arbitraire, c’est la sémantique de TweetyProject.

La DLL org.tweetyproject.tweety-causal.dll (shade IKVM 8.15, v1.30) expose 13/13 classes causales actives — la 14ᵉ (SimpleCausalReasoner) est du code mort commenté en amont (PR #10566, leçon tweety8-gate).

// Chargement du pont IKVM (TweetyProject v1.30 -> .NET) — recette éprouvée (cf. Tweety-5 §4.1.2).
#r "nuget: IKVM, 8.15.0"
#r "nuget: IKVM.Image, 8.15.0"

#r "org.tweetyproject.tweety-causal.dll"

using System.IO;
using System.Reflection;

// IKVM runtime : home explicite (économie de cold-restart, recette c.758)
string ikvmVer  = "8.15.0";
// RID de la machine courante (win-x64, linux-x64, osx-arm64...) : IKVM.Image tire l'image native de chaque plateforme.
string ikvmRid  = (OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsMacOS() ? "osx" : "linux") + "-" + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
string nugetRoot = Environment.GetEnvironmentVariable("NUGET_PACKAGES")
    ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages");
string ikvmBaseAny = Path.Combine(nugetRoot, "ikvm.image", ikvmVer, "ikvm", "any", "any");
string ikvmArchDir = Path.Combine(nugetRoot, "ikvm.image.runtime." + ikvmRid, ikvmVer, "ikvm", "any", ikvmRid);
string ikvmHome = Path.Combine(Path.GetTempPath(), "ikvm-home-" + ikvmVer + "-" + ikvmRid);

if (Directory.Exists(ikvmBaseAny) && Directory.Exists(ikvmArchDir))
{
    Directory.CreateDirectory(ikvmHome);
    foreach (var f in Directory.GetFiles(ikvmBaseAny, "*", SearchOption.AllDirectories))
    { var t = f.Replace(ikvmBaseAny, ikvmHome); Directory.CreateDirectory(Path.GetDirectoryName(t)); File.Copy(f, t, true); }
    foreach (var f in Directory.GetFiles(ikvmArchDir, "*", SearchOption.AllDirectories))
    { var t = f.Replace(ikvmArchDir, ikvmHome); Directory.CreateDirectory(Path.GetDirectoryName(t)); File.Copy(f, t, true); }
}
AppContext.SetData("IKVM.Home", ikvmHome);

var causalDll = "org.tweetyproject.tweety-causal.dll";
Show($"DLL causale chargée : {AssemblyName.GetAssemblyName(causalDll)} ({new FileInfo(causalDll).Length / 1024.0:F0} Ko)");
Installed Packages
  • IKVM, 8.15.0
  • IKVM.Image, 8.15.0
DLL causale chargée : org.tweetyproject.tweety-causal, Version=1.30.0.0, Culture=neutral, PublicKeyToken=13235d27fcbfff58 (894 Ko)
// Réseau Sprinkler via le VRAI moteur TweetyProject (même scénario qu'en §4, pour comparer directement).
var ikvmAsm = Assembly.LoadFrom("org.tweetyproject.tweety-causal.dll");
var IKVM_SCM  = ikvmAsm.GetType("org.tweetyproject.causal.syntax.StructuralCausalModel");
var IKVM_Prop = ikvmAsm.GetType("org.tweetyproject.logics.pl.syntax.Proposition");
var IKVM_Disj = ikvmAsm.GetType("org.tweetyproject.logics.pl.syntax.Disjunction");
var IKVM_PlF  = ikvmAsm.GetType("org.tweetyproject.logics.pl.syntax.PlFormula");

object Prop(string s) => IKVM_Prop.GetConstructor(new[]{typeof(string)}).Invoke(new object[]{s});
object Or(object a, object b) => IKVM_Disj.GetConstructor(new[]{IKVM_PlF, IKVM_PlF}).Invoke(new[]{a, b});

var ikvmModel = IKVM_SCM.GetConstructor(Type.EmptyTypes).Invoke(null);
var ikvmAddBg   = IKVM_SCM.GetMethod("addBackgroundAtom", new[]{IKVM_Prop});
var ikvmAddExpl = IKVM_SCM.GetMethod("addExplainableAtom", new[]{IKVM_PlF, IKVM_PlF});
var ikvmPretty  = IKVM_SCM.GetMethod("prettyPrint");
var ikvmInter   = IKVM_SCM.GetMethod("intervene", new[]{IKVM_Prop, typeof(bool)});
var ikvmTwin    = IKVM_SCM.GetMethod("getTwinModel");

var ikvmCloudy = Prop("cloudy"); var ikvmSprinkler = Prop("sprinkler");
var ikvmRain = Prop("rain"); var ikvmWet = Prop("wet");

ikvmAddBg.Invoke(ikvmModel, new object[]{ikvmCloudy});
ikvmAddExpl.Invoke(ikvmModel, new object[]{ikvmSprinkler, ikvmCloudy});
ikvmAddExpl.Invoke(ikvmModel, new object[]{ikvmRain, ikvmCloudy});
ikvmAddExpl.Invoke(ikvmModel, new object[]{ikvmWet, Or(ikvmSprinkler, ikvmRain)});

Show("=== Réseau Sprinkler — moteur TweetyProject (IKVM) ===");
Show(ikvmPretty.Invoke(ikvmModel, null).ToString());

// do(sprinkler = true) : chirurgie causale (cf. §3 from-scratch)
var ikvmDoSpr = ikvmInter.Invoke(ikvmModel, new object[]{ikvmSprinkler, true});
Show("\n=== Après do(sprinkler=True) — chirurgie causale ===");
Show(ikvmPretty.Invoke(ikvmDoSpr, null).ToString());

Show("\n=> 'sprinkler <=> cloudy' est devenu 'sprinkler <=> +' (constante) : le moteur Java");
Show("   coupe bien le lien cloudy -> sprinkler, exactement comme la chirurgie from-scratch du §3.");
=== Réseau Sprinkler — moteur TweetyProject (IKVM) ===
Background atoms: [cloudy]
Structural Equations:
sprinkler <=> cloudy
wet <=> sprinkler||rain
rain <=> cloudy

=== Après do(sprinkler=True) — chirurgie causale ===
Background atoms: [cloudy]
Structural Equations:
sprinkler <=> +
wet <=> sprinkler||rain
rain <=> cloudy

=> 'sprinkler <=> cloudy' est devenu 'sprinkler <=> +' (constante) : le moteur Java
   coupe bien le lien cloudy -> sprinkler, exactement comme la chirurgie from-scratch du §3.

Parité confirmée. Le moteur causal TweetyProject (Java, via IKVM) produit exactement la même chirurgie que l’implémentation from-scratch du §3–§4 : do(sprinkler=True) remplace l’équation sprinkler <=> cloudy par la constante + (vrai), rompant le lien cloudy -> sprinkler.

Les deux ponts — jpype (côté Python) et IKVM (côté C#) — accèdent au même moteur de production. La réimplémentation from-scratch n’était pas une réinvention arbitraire : c’est la sémantique exposée de ce moteur. C’est la valeur pédagogique de la parité : voir les internes (from-scratch) puis confirmer que le moteur de référence les reproduit (IKVM).

Au-delà de la chirurgie — getTwinModel() construit le twin network de Pearl (les variables étoilées sprinkler*, wet* sont les copies contrefactuelles du §5), et le reasoner argumentatif ArgumentationBasedCausalReasoner résout les requêtes via SAT (sat4j). Ces capacités supplémentaires du moteur Java sont accessibles par le même pont IKVM.

Plafond upstream du pont causal — diagnostic réfuté (PR #10471). La v1 du shade IKVM ne matérialisait que 5 des 14 classes du contrat org.tweetyproject.causal.* — un verdict INTRINSIC avait été posé au registre SOTA (#3801). Les 9 classes alors réputées inaccessibles : CausalParser, AbstractArgumentationBasedCausalReasoner, AbstractCausalReasoner, ArgumentationBasedCausalReasoner, ArgumentationBasedCounterfactualReasoner, CausalInterpretation, CausalArgument, CausalKnowledgeBase, StructuralCausalModel.

La cause n’était pas une limite IKVM mais un shade JAR incomplet : recompilées javac --release 8 (recette #10566) puis re-shadées, la DLL v1.30 committée (#10570) matérialise l’intégralité du contrat — les 13 classes d’API et la classe imbriquée StructuralCausalModel+CyclicDependencyException (séparateur .NET +), vérifié par GetType sur la DLL committée — cohérent avec le « 13/13 classes actives » annoncé à la cellule 17 et la parité vérifiée ci-dessus. L’issue #10471 est close comme superseded : il n’y a plus de plafond upstream.

7. Exercices

Exercice 1 — Conclondant caché (ability / education / income)

Construisez un SCM où ability cause à la fois education et income (ability est le conclondant). Comparez observe(education=True) -> income (biaisé par le conclondant) et do(education=True) -> income (effet causal propre).

Indice : avec ability comme cause commune, l’observation education porte l’information sur ability (donc sur income), mais l’intervention do(education) coupe ce lien — l’effet direct devrait tomber à False.

#nullable enable
// Exercice 1 : a completer
var ability = "ability"; var education = "education"; var income = "income";

// TODO etudiant : construire le SCM edu_model
//   - ability  = atome de fond
//   - education = f(ability)
//   - income    = f(ability)   [ability est le confondeur !]
StructuralCausalModel? edu_model = null;  // TODO etudiant : new StructuralCausalModel() ...

// TODO etudiant : comparer observe(education) -> income  vs  do(education) -> income
bool? result_observe = null;  // TODO etudiant
bool? result_do      = null;  // TODO etudiant

Show("Exercice 1 a completer");
Show("  (attendu : observe(education) -> income True ; do(education) -> income False)");
Exercice 1 a completer
  (attendu : observe(education) -> income True ; do(education) -> income False)

Exercice 2 — L’intervention ne remonte pas la causalité

Sur ckb_sp, comparez do(cloudy=True) -> wet (cause ancêtre, effet transmis) et do(wet=True) -> rain. Le graphe est orienté : forcer un effet ne propage rien vers ses causes.

#nullable enable
// Exercice 2 : a completer
// TODO etudiant : construire 2 InterventionalStatement et interroger ckb_sp
// Indice : do(wet=True) sur un EFFET ne remonte pas la causalite (le graphe est oriente).

InterventionalStatement? ex2_a = null;  // TODO etudiant : do(cloudy=True) -> wet
InterventionalStatement? ex2_b = null;  // TODO etudiant : do(wet=True)    -> rain
Show("Exercice 2 a completer");
Exercice 2 a completer

Exercice 3 — Contrefactuel sur la cause

Construisez un CounterfactualStatement sur ckb_sp : observe(rain=True) ; intervention contrefactuelle cloudy=False ; conclusion rain. Réfléchissez : cloudy est la cause de rain — si cloudy avait été faux, rain aurait-il eu lieu ?

#nullable enable
// Exercice 3 : a completer
// TODO etudiant : CounterfactualStatement sur ckb_sp
//   observe(rain=True) ; cf-intervention cloudy=False ; conclusion = rain
CounterfactualStatement? cf_ex3 = null;  // TODO etudiant
Show("Exercice 3 a completer");
Show("  (reflechir : cloudy est la cause de rain ; si cloudy avait ete faux, rain n'aurait pas eu lieu)");
Exercice 3 a completer
  (reflechir : cloudy est la cause de rain ; si cloudy avait ete faux, rain n'aurait pas eu lieu)

Synthèse

Ce twin C# a réimplémenté from-scratch les trois piliers de l’inférence causale de Pearl :

Requête Sémantique implémentée
observe(X) filtrer les mondes possibles consistants
do(X) chirurgie de graphe : remplacer l’équation de X par une constante
contrefactuel monde réel observé + surgery sur fond fixe

La lib Tweety causal-1.30.jar (reasoner argumentatif) fournit la même sémantique en boîte noire ; l’implémentation from-scratch rend visible pourquoi l’intervention isole l’effet causal propre — la valeur pédagogique.

Voir aussi : Tweety-11-Causal.ipynb (Python + Tweety jar), marathon #4956, EPIC #3801.

Pour aller plus loin

  • Notebook-pont — du graphe causal au do-calculus : la chirurgie de graphe implémentée from-scratch ici (remplacer l’équation de X par une constante) est la sémantique du do-operator ; le pont en montre le calcul — l’échelle de causalité et les 3 règles de Pearl exécutées sur l’outil de référence dowhy (identification backdoor/front-door + estimation + réfutation), en quantitatif là où ce twin décide en booléen. Il relie cette série à Infer-5, PyMC-5 et ICT-5.
Retour au sommet