diff --git a/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/DoWhy-3-Decouverte-de-Structure.ipynb b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/DoWhy-3-Decouverte-de-Structure.ipynb new file mode 100644 index 0000000000..ebe63e9058 --- /dev/null +++ b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/DoWhy-3-Decouverte-de-Structure.ipynb @@ -0,0 +1,997 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fb60aa74", + "metadata": { + "papermill": { + "duration": 0.003051, + "end_time": "2026-09-13T01:35:22.981386+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:22.978335+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "# DoWhy-3 — La découverte de structure : le graphe qu'on n'a pas\n", + "\n", + "DoWhy-1 (section 4, « le graphe assumé ») identifie, estime et réfute un effet **sur un graphe donné**. Mais d'où vient ce graphe ? D'une expertise métier — ou des données elles-mêmes. Ce notebook traite la question que DoWhy-1 laissait ouverte : **que peut-on retrouver du graphe causal depuis les données seules, et où s'arrête exactement ce que les données peuvent dire ?**\n", + "\n", + "**Moteurs** : [`causal-learn`](https://causal-learn.readthedocs.io/) pour la découverte (PC, GES, DirectLiNGAM — réellement exécutés, règle F / SOTA-OK), [`dowhy`](https://www.pywhy.org/dowhy/) pour le retour à l'estimand. Kernel `coursia-ml-training`.\n", + "\n", + "**Plan** : 1. un monde DGP-connu — 2. PC (contraintes) — 3. GES (score) — 4. la classe d'équivalence — 5. LiNGAM (hypothèse fonctionnelle) — 6. l'échec honnête — 7. l'ambiguïté se propage à l'estimand — 8. exercices — 9. synthèse.\n", + "\n", + "**Durée** : ~45 min. Prérequis : DoWhy-1 (estimand, backdoor), DAG, indépendance conditionnelle." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "34d78c9e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:22.988439Z", + "iopub.status.busy": "2026-09-13T01:35:22.987929Z", + "iopub.status.idle": "2026-09-13T01:35:24.147605Z", + "shell.execute_reply": "2026-09-13T01:35:24.147099Z" + }, + "papermill": { + "duration": 1.163051, + "end_time": "2026-09-13T01:35:24.147001+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:22.983950+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dowhy_discovery_organs charge depuis : D:\\Dev\\CoursIA-14049\\MyIA.AI.Notebooks\\Probas\\DecisionTheory\\Causal-Bridges\n", + " DAG vrai : [('C', 'X'), ('X', 'M'), ('M', 'Y'), ('C', 'Y'), ('Y', 'Z')]\n", + " alpha PC par defaut : 0.05 | seuil coef LiNGAM : 0.1\n" + ] + } + ], + "source": [ + "# Imports et kernel\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "# L'organe canonique de la serie dowhy pour la decouverte -- DoWhy-3 (issue #14049).\n", + "# Le notebook consomme l'organe, il ne redefinit pas les fonctions (lecon #13921).\n", + "# Recherche dans cwd et dans tous les dossiers proches (le kernel peut demarrer\n", + "# depuis la racine du repo, pas depuis le dossier du notebook).\n", + "_CANDIDATES = [Path.cwd().resolve()]\n", + "for _p in Path.cwd().resolve().parents:\n", + " _CANDIDATES.append(_p)\n", + "for _p in _CANDIDATES:\n", + " _hits = list(_p.rglob(\"dowhy_discovery_organs.py\"))\n", + " if _hits:\n", + " _organs_dir = _hits[0].parent\n", + " if str(_organs_dir) not in sys.path:\n", + " sys.path.insert(0, str(_organs_dir))\n", + " break\n", + "\n", + "import dowhy_discovery_organs as ddo\n", + "print(f\"dowhy_discovery_organs charge depuis : {_organs_dir}\")\n", + "print(f\" DAG vrai : {ddo.ARETES_DAG_VRAI}\")\n", + "print(f\" alpha PC par defaut : {ddo.ALPHA_PC_DEFAUT} | seuil coef LiNGAM : {ddo.SEUIL_COEF_LINGAM}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c8861a01", + "metadata": { + "papermill": { + "duration": 0.002231, + "end_time": "2026-09-13T01:35:24.152456+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:24.150225+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 1. Le monde — cinq variables, un DAG connu\n", + "\n", + "Le simulateur de l'organe produit un monde linéaire à bruits indépendants :\n", + "\n", + "| Variable | Rôle | Équation |\n", + "|---|---|---|\n", + "| `C` | confondeur **observé** | `C = Bruit(1.0)` |\n", + "| `X` | traitement | `X = 1.0·C + Bruit(0.5)` |\n", + "| `M` | médiateur | `M = 1.0·X + Bruit(0.5)` |\n", + "| `Y` | résultat | `Y = 0.8·M + 0.3·C + Bruit(0.5)` |\n", + "| `Z` | descendant de Y (capteur) | `Z = 1.0·Y + Bruit(0.5)` |\n", + "\n", + "Le DAG vrai `C→X→M→Y`, `C→Y`, `Y→Z` ne porte qu'une seule v-structure (collisionneur non briqué) : **`C → Y ← M`**.\n", + "\n", + "La question du notebook : depuis un échantillon de ce monde, **sans connaître ces équations**, que retrouve chaque famille de découverte — et que refuse-t-elle à juste titre de trancher ?" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5e2c7b77", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:24.160995Z", + "iopub.status.busy": "2026-09-13T01:35:24.160636Z", + "iopub.status.idle": "2026-09-13T01:35:24.188996Z", + "shell.execute_reply": "2026-09-13T01:35:24.188484Z" + }, + "papermill": { + "duration": 0.034057, + "end_time": "2026-09-13T01:35:24.189073+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:24.155016+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " C X M Y Z\n", + "0 0.497 0.159 -0.273 -0.626 -0.643\n", + "1 -0.138 -0.211 -0.226 -0.538 -0.790\n", + "2 0.648 0.251 0.260 -0.068 -0.155\n", + "\n", + "Matrice de correlation :\n", + " C X M Y Z\n", + "C 1.00 0.89 0.80 0.81 0.77\n", + "X 0.89 1.00 0.91 0.86 0.81\n", + "M 0.80 0.91 1.00 0.91 0.86\n", + "Y 0.81 0.86 0.91 1.00 0.94\n", + "Z 0.77 0.81 0.86 0.94 1.00\n" + ] + } + ], + "source": [ + "# Le monde gaussien, DGP-connu\n", + "df_monde = ddo.generer_donnees_decouverte(n=2000, bruit=\"gaussien\", seed=42)\n", + "print(df_monde.head(3).round(3).to_string())\n", + "print()\n", + "print(\"Matrice de correlation :\")\n", + "print(df_monde.corr().round(2).to_string())" + ] + }, + { + "cell_type": "markdown", + "id": "3354a184", + "metadata": { + "papermill": { + "duration": 0.003456, + "end_time": "2026-09-13T01:35:24.194840+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:24.191384+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture — la corrélation ne connaît pas les flèches\n", + "\n", + "Toutes les paires sont corrélées (le monde est un graphe connecté), et la matrice est **symétrique** : `corr(C, X) = corr(X, C)`. L'information d'orientation n'y est tout simplement pas. À 5 variables, il existe 29 281 DAGs étiquetés possibles ; les données gaussiennes, elles, ne distinguent que des **classes d'équivalence** — c'est ce que les sections suivantes mesurent." + ] + }, + { + "cell_type": "markdown", + "id": "6c1d1f92", + "metadata": { + "papermill": { + "duration": 0.001793, + "end_time": "2026-09-13T01:35:24.198419+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:24.196626+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 2. PC — l'algorithme à contraintes\n", + "\n", + "**PC** (Spirtes & Glymour, 1991) procède en trois temps : (1) le **squelette** — on part du graphe complet et on retire une arête `u–v` dès qu'un ensemble `S` rend `u ⊥ v | S` (test de Fisher-z, seuil `alpha`) ; (2) l'orientation des **v-structures** `a → b ← c` quand `a, c` sont non adjacents et que `b` n'appartient pas à leur sepset ; (3) propagation par les **règles de Meek**.\n", + "\n", + "Choix d'`alpha`, mesuré sur ce monde : à `0.05`, la v-structure est perdue environ **1 seed sur 4** (un test de vraie indépendance est rejeté au niveau 5 %, et le sepset qui en résulte désarme le collisionneur) ; à `0.01`, le CPDAG canonique est rendu **20 fois sur 20**. Ce compromis n'est pas gratuit — l'exercice 2 le mesure dans l'autre sens (arêtes parasites à `alpha` élevé, puissance perdue à `alpha` bas)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "79c9dad8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:24.205083Z", + "iopub.status.busy": "2026-09-13T01:35:24.204860Z", + "iopub.status.idle": "2026-09-13T01:35:31.744048Z", + "shell.execute_reply": "2026-09-13T01:35:31.743614Z" + }, + "papermill": { + "duration": 7.542759, + "end_time": "2026-09-13T01:35:31.744567+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:24.201808+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PC -- aretes orientees : [('C', 'Y'), ('M', 'Y'), ('Y', 'Z')]\n", + "PC -- aretes NON orientees : [('C', 'X'), ('M', 'X')]\n", + "\n", + "Verdict : CPDAG_AMBIGU (2 arete(s) ambigue(s))\n", + "2 arete(s) non orientee(s) : classe d'equivalence de Markov. L'ambiguite est un RESULTAT -- aucun n plus grand ne la tranche (donnees gaussiennes). Chaque extension valide donne un estimand potentiellement different (cf. effet_backdoor_depuis_aretes).\n", + "\n", + "Confrontation au DAG vrai :\n", + " squelette_trouvees : 5/5\n", + " oriente_comme_vrai : [('C', 'Y'), ('M', 'Y'), ('Y', 'Z')]\n", + " oriente_inverse : []\n", + " non_orientees_parmi_vraies : [('C', 'X'), ('M', 'X')]\n", + " parasites : []\n", + " manquantes : []\n" + ] + } + ], + "source": [ + "# PC sur le monde gaussien -- alpha=0.01 (v-structure robuste, cf. ci-dessus)\n", + "res_pc = ddo.executer_pc(df_monde, alpha=0.01)\n", + "print(\"PC -- aretes orientees :\", res_pc.aretes_orientees)\n", + "print(\"PC -- aretes NON orientees :\", res_pc.aretes_non_orientees)\n", + "print()\n", + "verdict_pc = ddo.verdict_cpdag(res_pc)\n", + "print(f\"Verdict : {verdict_pc['verdict']} ({verdict_pc['n_aretes_non_orientees']} arete(s) ambigue(s))\")\n", + "print(verdict_pc[\"message\"])\n", + "print()\n", + "comp_pc = ddo.comparer_au_dag_vrai(res_pc)\n", + "print(\"Confrontation au DAG vrai :\")\n", + "for cle, val in comp_pc.items():\n", + " print(f\" {cle:28s}: {val}\")" + ] + }, + { + "cell_type": "markdown", + "id": "e84d628a", + "metadata": { + "papermill": { + "duration": 0.002271, + "end_time": "2026-09-13T01:35:31.749349+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.747078+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du CPDAG — trois résultats dans un\n", + "\n", + "1. **Squelette exact 5/5** : aucune arête parasite, aucune manquante.\n", + "2. **La v-structure est trouvée** : `C → Y ← M` est orientée — le collisionneur *est* détectable, parce que `C ⊥ M | X` mais `C ⊭ M | Y`. La règle de Meek oriente ensuite `Y → Z` (sinon `M → Y ← Z` créerait une v-structure que la donnée exclut).\n", + "3. **`C–X` et `X–M` restent ambiguës** — et c'est le résultat central : pour des données linéaires **gaussiennes**, `C → X` et `X → C` impliquent exactement les mêmes distributions conditionnelles (même classe d'équivalence de Markov). Aucun test d'indépendance, **quelle que soit la taille `n`**, ne peut les distinguer.\n", + "\n", + "Le verdict `CPDAG_AMBIGU` n'est pas un échec d'algorithme : c'est la borne exacte de ce que ces données, avec ces hypothèses, peuvent trancher." + ] + }, + { + "cell_type": "markdown", + "id": "42b82ce1", + "metadata": { + "papermill": { + "duration": 0.001372, + "end_time": "2026-09-13T01:35:31.753236+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.751864+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 3. GES — l'algorithme à score\n", + "\n", + "**GES** (Chickering, 2002) ne fait aucun test d'indépendance : il cherche gloutonnement (phase *forward* d'ajout, phase *backward* de retrait) les classes d'équivalence qui maximisent un score BIC. Origine méthodologique complètement différente de PC — même théorie sous-jacente : GES navigue **nativement dans l'espace des CPDAGs**." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d9e5e03b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:31.759920Z", + "iopub.status.busy": "2026-09-13T01:35:31.759475Z", + "iopub.status.idle": "2026-09-13T01:35:31.786644Z", + "shell.execute_reply": "2026-09-13T01:35:31.785956Z" + }, + "papermill": { + "duration": 0.031089, + "end_time": "2026-09-13T01:35:31.786564+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.755475+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "GES -- aretes orientees : [('C', 'Y'), ('M', 'Y'), ('Y', 'Z')]\n", + "GES -- aretes NON orientees : [('C', 'X'), ('M', 'X')]\n", + "\n", + "PC et GES rendent-ils le meme CPDAG sur ce monde ? True\n" + ] + } + ], + "source": [ + "# GES (BIC) sur les memes donnees\n", + "res_ges = ddo.executer_ges(df_monde)\n", + "print(\"GES -- aretes orientees :\", res_ges.aretes_orientees)\n", + "print(\"GES -- aretes NON orientees :\", res_ges.aretes_non_orientees)\n", + "print()\n", + "meme_cpdag = (set(res_ges.aretes_orientees) == set(res_pc.aretes_orientees)\n", + " and set(res_ges.aretes_non_orientees) == set(res_pc.aretes_non_orientees))\n", + "print(\"PC et GES rendent-ils le meme CPDAG sur ce monde ?\", meme_cpdag)" + ] + }, + { + "cell_type": "markdown", + "id": "31103eeb", + "metadata": { + "papermill": { + "duration": 0.003164, + "end_time": "2026-09-13T01:35:31.794542+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.791378+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Deux familles, un verdict\n", + "\n", + "Contraintes (PC) et score (GES) partent d'axes opposés et rendent **le même CPDAG**. Ce n'est pas une convergence d'implémentation : aucun des deux ne peut dépasser la classe d'équivalence de Markov des données gaussiennes. L'ambiguïté de `C–X` n'est le défaut d'aucun des deux — c'est une propriété du monde, pas des algorithmes." + ] + }, + { + "cell_type": "markdown", + "id": "63dc46f9", + "metadata": { + "papermill": { + "duration": 0.002533, + "end_time": "2026-09-13T01:35:31.799668+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.797135+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 4. La classe d'équivalence — combien de mondes derrière ce CPDAG ?\n", + "\n", + "Deux arêtes ambiguës (`C–X`, `X–M`) → $2^2 = 4$ orientations brutes. Mais une extension n'est **valide** que si le DAG obtenu est (a) **acyclique** et (b) porte **exactement les mêmes v-structures** que le CPDAG (Verma & Pearl, 1990 : squelette + v-structures = identité de classe). L'organe énumère ces extensions — comptez-les avant de lire la suite." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "90cbefe4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:31.807436Z", + "iopub.status.busy": "2026-09-13T01:35:31.806972Z", + "iopub.status.idle": "2026-09-13T01:35:31.814746Z", + "shell.execute_reply": "2026-09-13T01:35:31.814150Z" + }, + "papermill": { + "duration": 0.012489, + "end_time": "2026-09-13T01:35:31.815670+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.803181+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3 extension(s) valide(s) pour 2^2 = 4 orientations brutes :\n", + " 1. [('C', 'X'), ('C', 'Y'), ('M', 'Y'), ('X', 'M'), ('Y', 'Z')] <-- DAG VRAI du simulateur\n", + " 2. [('C', 'Y'), ('M', 'X'), ('M', 'Y'), ('X', 'C'), ('Y', 'Z')]\n", + " 3. [('C', 'Y'), ('M', 'Y'), ('X', 'C'), ('X', 'M'), ('Y', 'Z')]\n", + "\n", + "V-structures de chaque extension : [[('C', 'Y', 'M')], [('C', 'Y', 'M')], [('C', 'Y', 'M')]]\n" + ] + } + ], + "source": [ + "# Extensions valides du CPDAG decouvert par PC\n", + "extensions = ddo.enumerer_extensions_acycliques(res_pc)\n", + "print(f\"{len(extensions)} extension(s) valide(s) pour 2^2 = 4 orientations brutes :\")\n", + "for i, ext in enumerate(extensions, 1):\n", + " est_vrai = sorted(ext) == sorted(ddo.ARETES_DAG_VRAI)\n", + " marque = \" <-- DAG VRAI du simulateur\" if est_vrai else \"\"\n", + " print(f\" {i}. {ext}{marque}\")\n", + "print()\n", + "print(\"V-structures de chaque extension :\", [ddo.v_structures(e) for e in extensions])" + ] + }, + { + "cell_type": "markdown", + "id": "3aa96b39", + "metadata": { + "papermill": { + "duration": 0.004289, + "end_time": "2026-09-13T01:35:31.823548+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.819259+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Pourquoi 3 et pas 4\n", + "\n", + "L'orientation manquante est `C → X` combiné à `M → X` : elle créerait la v-structure `C → X ← M`. Or les données disent `C ⊥ M | X` (c'est même le sepset qui a retiré l'arête `C–M` du squelette) — cette v-structure est **exclue par la donnée**, pas par notre goût. Le CPDAG n'est donc pas « un DAG partiellement deviné » : c'est la représentation **exacte** d'un ensemble de 3 DAGs, ni plus ni moins. Toute prétention à trancher `C–X` sans hypothèse supplémentaire est un mensonge statistique." + ] + }, + { + "cell_type": "markdown", + "id": "b5b77190", + "metadata": { + "papermill": { + "duration": 0.004727, + "end_time": "2026-09-13T01:35:31.832628+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.827901+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 5. LiNGAM — l'hypothèse fonctionnelle qui tranche\n", + "\n", + "**Théorème DARM** (Shimizu et al., 2006) : si le monde est linéaire **à bruit non gaussien**, alors l'ordre causal devient identifiable — les queues de distribution « cassent » la symétrie entre `u → v` et `v → u` que le monde gaussien respecte exactement. L'organe sait générer le **même monde en bruit non gaussien** (exponentiel centré, même variance — seul le kurtosis change) : tout ce qui suit porte sur les mêmes coefficients, mêmes tailles, même DAG." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ceeebf26", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:31.840957Z", + "iopub.status.busy": "2026-09-13T01:35:31.840649Z", + "iopub.status.idle": "2026-09-13T01:35:32.356625Z", + "shell.execute_reply": "2026-09-13T01:35:32.355983Z" + }, + "papermill": { + "duration": 0.520698, + "end_time": "2026-09-13T01:35:32.357031+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:31.836333+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "LiNGAM -- aretes orientees : [('C', 'X'), ('C', 'Y'), ('M', 'Y'), ('X', 'M'), ('Y', 'Z')]\n", + "LiNGAM -- ordre causal estime : ['C', 'X', 'M', 'Y', 'Z']\n", + "\n", + "Confrontation au DAG vrai :\n", + " squelette_trouvees : 5/5\n", + " oriente_comme_vrai : [('C', 'X'), ('C', 'Y'), ('M', 'Y'), ('X', 'M'), ('Y', 'Z')]\n", + " oriente_inverse : []\n", + " non_orientees_parmi_vraies : []\n", + " parasites : []\n", + " manquantes : []\n", + "\n", + "Verdict : DAG_ORIENTE\n" + ] + } + ], + "source": [ + "# Le meme monde en bruit NON GAUSSIEN, puis DirectLiNGAM\n", + "df_ng = ddo.generer_donnees_decouverte(n=2000, bruit=\"non_gaussien\", seed=42)\n", + "res_lingam = ddo.executer_lingam(df_ng)\n", + "print(\"LiNGAM -- aretes orientees :\", res_lingam.aretes_orientees)\n", + "print(\"LiNGAM -- ordre causal estime :\", res_lingam.ordre_causal)\n", + "print()\n", + "comp_lingam = ddo.comparer_au_dag_vrai(res_lingam)\n", + "print(\"Confrontation au DAG vrai :\")\n", + "for cle, val in comp_lingam.items():\n", + " print(f\" {cle:28s}: {val}\")\n", + "print()\n", + "print(\"Verdict :\", ddo.verdict_cpdag(res_lingam)[\"verdict\"])" + ] + }, + { + "cell_type": "markdown", + "id": "5c5b23c6", + "metadata": { + "papermill": { + "duration": 0.002734, + "end_time": "2026-09-13T01:35:32.362542+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:32.359808+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### DARM en lecture\n", + "\n", + "LiNGAM recouvre le DAG **exact** — y compris `C → X` et `X → M` que PC ne pouvait pas trancher. L'information qui tranche n'est pas dans les indépendances conditionnelles : elle est dans la **forme du bruit** (l'asymétrie des queues). C'est une vraie puissance d'identification — achetée au prix d'une **hypothèse forte** : « bruit non gaussien ». Et PC, sur ces mêmes données non gaussiennes ?" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "08634573", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:32.369147Z", + "iopub.status.busy": "2026-09-13T01:35:32.368796Z", + "iopub.status.idle": "2026-09-13T01:35:32.380693Z", + "shell.execute_reply": "2026-09-13T01:35:32.380121Z" + }, + "papermill": { + "duration": 0.015839, + "end_time": "2026-09-13T01:35:32.381116+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:32.365277+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "PC -- aretes NON orientees (donnees non gaussiennes) : [('C', 'X'), ('M', 'X')]\n", + "\n", + "PC ne consomme pas la forme du bruit : C--X et X--M restent ambigues,\n", + "alors que LiNGAM vient de les trancher sur les MEMES donnees.\n" + ] + } + ], + "source": [ + "# PC sur les memes donnees non gaussiennes\n", + "res_pc_ng = ddo.executer_pc(df_ng, alpha=0.01)\n", + "print(\"PC -- aretes NON orientees (donnees non gaussiennes) :\", res_pc_ng.aretes_non_orientees)\n", + "print()\n", + "print(\"PC ne consomme pas la forme du bruit : C--X et X--M restent ambigues,\")\n", + "print(\"alors que LiNGAM vient de les trancher sur les MEMES donnees.\")" + ] + }, + { + "cell_type": "markdown", + "id": "bafe32a3", + "metadata": { + "papermill": { + "duration": 0.003661, + "end_time": "2026-09-13T01:35:32.387140+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:32.383479+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 6. L'échec honnête — LiNGAM sur bruit gaussien\n", + "\n", + "Que rend LiNGAM quand son hypothèse n'est **pas tenue** ? Réponse mesurée : un DAG complet, faux, **sans erreur ni avertissement**." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "408b1722", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:32.396160Z", + "iopub.status.busy": "2026-09-13T01:35:32.395922Z", + "iopub.status.idle": "2026-09-13T01:35:32.450018Z", + "shell.execute_reply": "2026-09-13T01:35:32.449392Z" + }, + "papermill": { + "duration": 0.060672, + "end_time": "2026-09-13T01:35:32.450911+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:32.390239+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "LiNGAM gaussien seed=42 : [('C', 'M'), ('C', 'X'), ('X', 'M'), ('Y', 'C'), ('Y', 'M'), ('Y', 'X'), ('Y', 'Z')]\n", + "LiNGAM gaussien seed=7 : [('C', 'Y'), ('M', 'Y'), ('X', 'C'), ('X', 'M'), ('Z', 'C'), ('Z', 'M'), ('Z', 'X'), ('Z', 'Y')]\n", + "\n", + "DAG vrai : [('C', 'X'), ('C', 'Y'), ('M', 'Y'), ('X', 'M'), ('Y', 'Z')]\n", + "\n", + "seed=42 : squelette 5/5, parasites [('C', 'M'), ('X', 'Y')], inverses [('Y', 'C'), ('Y', 'M')]\n", + "Les deux DAG faux sont-ils au moins identiques entre eux ? False\n" + ] + } + ], + "source": [ + "# LiNGAM sur bruit GAUSSIEN (hypothese DARM violee) -- deux seeds\n", + "res_lg_a = ddo.executer_lingam(df_monde)\n", + "res_lg_b = ddo.executer_lingam(ddo.generer_donnees_decouverte(seed=7))\n", + "print(\"LiNGAM gaussien seed=42 :\", res_lg_a.aretes_orientees)\n", + "print(\"LiNGAM gaussien seed=7 :\", res_lg_b.aretes_orientees)\n", + "print()\n", + "print(\"DAG vrai :\", sorted(ddo.ARETES_DAG_VRAI))\n", + "print()\n", + "comp_a = ddo.comparer_au_dag_vrai(res_lg_a)\n", + "print(f\"seed=42 : squelette {comp_a['squelette_trouvees']}, parasites {comp_a['parasites']}, inverses {comp_a['oriente_inverse']}\")\n", + "print(\"Les deux DAG faux sont-ils au moins identiques entre eux ?\",\n", + " res_lg_a.aretes_orientees == res_lg_b.aretes_orientees)" + ] + }, + { + "cell_type": "markdown", + "id": "ab1395d1", + "metadata": { + "papermill": { + "duration": 0.001858, + "end_time": "2026-09-13T01:35:32.455694+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:32.453836+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Le silence n'est pas une preuve\n", + "\n", + "Trois faits mesurés sur ces deux seeds : le DAG rendu est **faux** (arêtes parasites, orientations inversées — `Y → C` au lieu de `C → Y`), il est **complet** (aucune arête ambiguë qui signalerait un doute), et il est **instable** (deux seeds gaussiens rendent des graphes différents). DirectLiNGAM ne détecte pas la violation de sa propre hypothèse — il produit toujours un DAG.\n", + "\n", + "Diagnostics praticiens : (1) le **kurtosis** des résidus (une valeur ≈ 0 dit que l'hypothèse non gaussienne n'est pas tenable) ; (2) l'**instabilité inter-seeds**. Le verdict « cette orientation est soutenable » vient du praticien, jamais de la librairie — c'est la leçon structurelle de la série (`NON_IDENTIFIABLE` dans DoWhy-5, CPDAG ambigu ici : chaque fois, l'honnêteté est un résultat, pas un échec)." + ] + }, + { + "cell_type": "markdown", + "id": "61982ff8", + "metadata": { + "papermill": { + "duration": 0.002804, + "end_time": "2026-09-13T01:35:32.462088+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:32.459284+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 7. L'ambiguïté se propage à l'estimand — le pont dowhy\n", + "\n", + "DoWhy-1 identifie un estimand **sur un graphe**. Reprenons le CPDAG découvert en section 2 : ses 3 extensions valides sont 3 graphes causaux incompatibles. Sur les **mêmes données**, chacune mène à un ensemble d'ajustement différent — donc à un estimand différent. L'effet total vrai de `X` sur `Y` vaut `COEF_X_M · COEF_M_Y = 0.8`." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a9550a23", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:32.468754Z", + "iopub.status.busy": "2026-09-13T01:35:32.468441Z", + "iopub.status.idle": "2026-09-13T01:35:34.890062Z", + "shell.execute_reply": "2026-09-13T01:35:34.889271Z" + }, + "papermill": { + "duration": 2.426464, + "end_time": "2026-09-13T01:35:34.890749+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:32.464285+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Effet estime de X sur Y (effet total vrai = 0.8) :\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ext1 ajustement=['C'] estimand = 0.812 <-- DAG VRAI\n", + " ext2 ajustement=['M'] estimand = 0.219\n", + " ext3 ajustement=[] estimand = 1.046\n" + ] + } + ], + "source": [ + "# Trois extensions valides du meme CPDAG, trois estimands dowhy\n", + "print(\"Effet estime de X sur Y (effet total vrai = 0.8) :\")\n", + "for i, ext in enumerate(extensions, 1):\n", + " r = ddo.effet_backdoor_depuis_aretes(df_monde, ext)\n", + " est_vrai = sorted(ext) == sorted(ddo.ARETES_DAG_VRAI)\n", + " marque = \" <-- DAG VRAI\" if est_vrai else \"\"\n", + " print(f\" ext{i} ajustement={r.ensemble_ajustement!s:6s} estimand = {r.estimate_value:.3f}{marque}\")" + ] + }, + { + "cell_type": "markdown", + "id": "12e01941", + "metadata": { + "papermill": { + "duration": 0.003115, + "end_time": "2026-09-13T01:35:34.897092+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.893977+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Décider, c'est assumer\n", + "\n", + "`0.81` en ajustant `{C}` (le DAG vrai), `0.22` en ajustant `{M}` (l'extension qui prend M pour parent de X — ajuster le médiateur écrase la part médiatisée), `1.05` sans rien ajuster (l'extension où X est racine — le confondeur n'est plus confondeur). **Aucune donnée supplémentaire, aucun `n` plus grand** ne tranche entre ces trois mondes gaussiens : le choix d'une extension est une **hypothèse causale assumée**, pas un réglage statistique.\n", + "\n", + "C'est la chaîne complète de la série : **découverte** (ce notebook) → hypothèse de graphe (DoWhy-1 §4) → identification → estimation → réfutation. Chaque flèche de cette chaîne ne supprime jamais une ambiguïté : elle la convertit en hypothèse explicite." + ] + }, + { + "cell_type": "markdown", + "id": "a4816c6a", + "metadata": { + "papermill": { + "duration": 0.002516, + "end_time": "2026-09-13T01:35:34.902915+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.900399+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 8. Exercices\n", + "\n", + "Trois exercices, stubs à compléter sans erreur volontaire (convention C.1 : le notebook s'exécute de bout en bout même non complété)." + ] + }, + { + "cell_type": "markdown", + "id": "37b4d830", + "metadata": { + "papermill": { + "duration": 0.003029, + "end_time": "2026-09-13T01:35:34.909283+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.906254+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Exercice 1 — L'ambiguïté ne se résout pas avec des données\n", + "\n", + "Pour `n ∈ {500, 2000, 10000}` : générer le monde gaussien (`seed=42`), exécuter PC (`alpha=0.01`) et afficher les arêtes non orientées et le verdict à chaque taille. Constat attendu : `C–X` et `X–M` restent ambiguës à chaque `n` — l'ambiguïté est **structurelle**, pas un problème d'échantillonnage. *Indice : boucle sur les tailles, puis `ddo.verdict_cpdag(res)[\"verdict\"]`.*" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "6147d635", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:34.916961Z", + "iopub.status.busy": "2026-09-13T01:35:34.916408Z", + "iopub.status.idle": "2026-09-13T01:35:34.921004Z", + "shell.execute_reply": "2026-09-13T01:35:34.920064Z" + }, + "papermill": { + "duration": 0.010025, + "end_time": "2026-09-13T01:35:34.921943+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.911918+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# Exercice 1 : l'ambiguite ne se resout pas avec des donnees\n", + "resultats_ex1 = None # TODO etudiant\n", + "# attendu :\n", + "# - pour n in (500, 2000, 10000) : df = ddo.generer_donnees_decouverte(n=n, seed=42)\n", + "# - res = ddo.executer_pc(df, alpha=0.01)\n", + "# - afficher n, res.aretes_non_orientees, ddo.verdict_cpdag(res)[\"verdict\"]\n", + "# - constater : C--X et X--M restent ambigues a CHAQUE taille" + ] + }, + { + "cell_type": "markdown", + "id": "50f4d8d1", + "metadata": { + "papermill": { + "duration": 0.003875, + "end_time": "2026-09-13T01:35:34.928897+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.925022+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Exercice 2 — `alpha` de PC : le compromis mesuré\n", + "\n", + "Pour `alpha ∈ {0.2, 0.05, 0.01}` et 5 seeds (`0..4`) : compter les **arêtes parasites** (`ddo.comparer_au_dag_vrai(res)[\"parasites\"]`) et détecter la **perte de v-structure** (l'arête `C–Y` non orientée). Constat attendu : à `0.2` des arêtes parasites apparaissent, à `0.05` la v-structure saute sur certains seeds, à `0.01` tout est propre **sur ce monde aux signaux forts** — au prix de la puissance sur signaux faibles. Il n'y a pas d'alpha gratuit." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "3aae8d48", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:34.939893Z", + "iopub.status.busy": "2026-09-13T01:35:34.939492Z", + "iopub.status.idle": "2026-09-13T01:35:34.942943Z", + "shell.execute_reply": "2026-09-13T01:35:34.942073Z" + }, + "papermill": { + "duration": 0.009906, + "end_time": "2026-09-13T01:35:34.943569+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.933663+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# Exercice 2 : alpha de PC -- parasites vs v-structure perdue\n", + "resultats_ex2 = None # TODO etudiant\n", + "# attendu :\n", + "# - pour alpha in (0.2, 0.05, 0.01) et seed in (0, 1, 2, 3, 4) :\n", + "# res = ddo.executer_pc(ddo.generer_donnees_decouverte(seed=seed), alpha=alpha)\n", + "# - compter len(ddo.comparer_au_dag_vrai(res)[\"parasites\"])\n", + "# - v-structure perdue si (\"C\", \"Y\") absente de res.aretes_orientees\n", + "# - resumer par alpha : parasites moyens + taux de v-structure retrouvee" + ] + }, + { + "cell_type": "markdown", + "id": "295a0e4f", + "metadata": { + "papermill": { + "duration": 0.003032, + "end_time": "2026-09-13T01:35:34.949428+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.946396+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Exercice 3 — Diagnostiquer l'échec silencieux de LiNGAM\n", + "\n", + "Sur 5 seeds gaussiens (`0..4`) : exécuter LiNGAM, mesurer le **taux d'arêtes correctes** (`len(oriente_comme_vrai) / 5` via `ddo.comparer_au_dag_vrai`) et vérifier si les DAGs rendus **coïncident entre seeds**. Constat attendu : taux < 1 et graphes différents d'un seed à l'autre — l'**instabilité inter-seeds est le signal** de l'hypothèse non tenue, là où la librairie reste muette." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "36380e97", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-13T01:35:34.960886Z", + "iopub.status.busy": "2026-09-13T01:35:34.960465Z", + "iopub.status.idle": "2026-09-13T01:35:34.965112Z", + "shell.execute_reply": "2026-09-13T01:35:34.963958Z" + }, + "papermill": { + "duration": 0.013288, + "end_time": "2026-09-13T01:35:34.966412+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.953124+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# Exercice 3 : LiNGAM sur bruit gaussien -- l'instabilite comme diagnostic\n", + "resultats_ex3 = None # TODO etudiant\n", + "# attendu :\n", + "# - pour seed in (0, 1, 2, 3, 4) : res = ddo.executer_lingam(\n", + "# ddo.generer_donnees_decouverte(seed=seed))\n", + "# - taux d'aretes correctes : len(ddo.comparer_au_dag_vrai(res)[\"oriente_comme_vrai\"]) / 5\n", + "# - comparer les ensembles d'aretes entre seeds (stabilite)\n", + "# - conclure : DAG complet + faux + instable = hypothese non tenue" + ] + }, + { + "cell_type": "markdown", + "id": "c241f17e", + "metadata": { + "papermill": { + "duration": 0.004033, + "end_time": "2026-09-13T01:35:34.975096+00:00", + "exception": false, + "start_time": "2026-09-13T01:35:34.971063+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 9. Synthèse — l'échelle de l'identifiabilité\n", + "\n", + "| Marche | Ce qu'on obtient | À quelle condition |\n", + "|---|---|---|\n", + "| Données gaussiennes | **CPDAG** (PC, GES) — squelette + v-structures | aucune hypothèse au-delà de Markov + fidélité |\n", + "| + hypothèse fonctionnelle | **DAG complet** (LiNGAM) | bruit non gaussien — et il rend un DAG faux si elle ne tient pas |\n", + "| + choix d'une extension | **estimand** (dowhy) | hypothèse causale **assumée** : 3 extensions = 3 estimands (0.81 / 0.22 / 1.05) |\n", + "\n", + "Les trois verdicts honnêtes du notebook : **CPDAG ambigu = un résultat** (l'ambiguïté est la borne exacte des données gaussiennes) ; **LiNGAM silencieux = un danger** (complétude n'est pas preuve — kurtosis et stabilité inter-seeds sont les gardes-fous) ; **ambiguïté du graphe = ambiguïté du chiffre** (le choix d'extension est causal, pas statistique).\n", + "\n", + "**Dans la constellation** : DoWhy-1 (le graphe assumé et sa sensibilité), DoWhy-5 (`NON_IDENTIFIABLE` est un résultat), `Quasi-Experimental.ipynb` (la pratique observationnelle). L'organe `dowhy_discovery_organs.py` reste importable pour tout consommateur tiers — c'est la contrainte d'architecture de la série (#13921 : module canonique, jamais de duplication cell-scoped)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (coursia-ml-training)", + "language": "python", + "name": "coursia-ml-training" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + }, + "papermill": { + "default_parameters": {}, + "duration": 14.85913, + "end_time": "2026-09-13T01:35:35.659752+00:00", + "environment_variables": {}, + "exception": null, + "input_path": "D:\\Dev\\CoursIA-14049\\MyIA.AI.Notebooks\\Probas\\DecisionTheory\\Causal-Bridges\\DoWhy-3-Decouverte-de-Structure.ipynb", + "output_path": "D:\\Dev\\CoursIA-14049\\MyIA.AI.Notebooks\\Probas\\DecisionTheory\\Causal-Bridges\\DoWhy-3-Decouverte-de-Structure_output.ipynb", + "parameters": {}, + "start_time": "2026-09-13T01:35:20.800622+00:00", + "version": "2.7.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/README.md b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/README.md index 3b65a18a25..c06f9286db 100644 --- a/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/README.md +++ b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/README.md @@ -4,7 +4,7 @@ **Série-pont** de la constellation causale du dépôt. La causalité est traitée à **quatre endroits**, chacun avec son moteur et son angle ; ce répertoire n'ajoute pas un cinquième moteur, il fournit l'**armature formelle unifiée** — l'échelle de Pearl, les trois règles du do-calculus et les méthodes quasi-expérimentales — et la fait tourner sur l'**outil de référence** [`dowhy`](https://www.pywhy.org/dowhy/) (installé et exécuté réellement, règle F / SOTA-OK, **pas** de réimplémentation jouet), avant de renvoyer à chaque série pour l'instanciation par son moteur. -**Stack** : Python 3 (kernel `coursia-ml-training`), [`dowhy`](https://www.pywhy.org/dowhy/) pour l'identification / estimation / réfutation d'estimandes causaux. Aucun kernel .NET ni GPU requis. +**Stack** : Python 3 (kernel `coursia-ml-training`), [`dowhy`](https://www.pywhy.org/dowhy/) pour l'identification / estimation / réfutation d'estimandes causaux, [`causal-learn`](https://causal-learn.readthedocs.io/) pour la découverte de structure (PC, GES, LiNGAM — DoWhy-3). Aucun kernel .NET ni GPU requis. ## Contenu @@ -13,6 +13,7 @@ | [Do-Calculus-Bridge](Do-Calculus-Bridge.ipynb) | ~55 min | Échelle de Pearl, trois règles du do-calculus, critères *backdoor* / *front-door* exécutés avec `dowhy`, Pearl (intervention) vs Hoel (émergence causale) | | [DoWhy-1 — Exiger un estimand](DoWhy-1-Estimand-et-Intervention.ipynb) | ~45 min | Identification causale **nommée** via `dowhy` (backdoor, front-door, instrumentale) sur un cas complet ; sensibilité au graphe **mesurée** quand une hypothèse saute | | [DoWhy-2 — Le contrefactuel individuel](DoWhy-2-Contrefactuel-Individuel.ipynb) | ~40 min | Troisième échelon de Pearl : `dowhy.gcm` (abduction-action-prédiction) sur **un individu** ; l'effet moyen nul cache une CATE linéaire ±3 ; fragilité du chiffre individuel à la spécification du mécanisme | +| [DoWhy-3 — Le graphe qu'on n'a pas](DoWhy-3-Decouverte-de-Structure.ipynb) | ~45 min | Découverte de structure via `causal-learn` (PC, GES, LiNGAM) : classes d'équivalence de Markov, verdict **CPDAG ambigu = résultat** ; LiNGAM tranche sous non-gaussianité mais rend un DAG faux-silencieux sinon ; l'ambiguïté se propage à l'estimand (3 extensions du même CPDAG → 3 estimands dowhy) | | [DoWhy-5 — L'instrument faible](DoWhy-5-Instrument-Faible.ipynb) | ~45 min | Variable instrumentale via `dowhy.CausalModel` (pipeline `identify` + `estimate(iv.instrumental_variable)` + `refute`) ; F-stat Staiger-Stock, biais IV vs OLS, **verdict NON_IDENTIFIABLE** honnête sur exclusion violée ; complète le 2SLS from scratch de la cellule 40 de `Quasi-Experimental.ipynb` | | [Quasi-Experimental](Quasi-Experimental.ipynb) | ~50 min | Méthodes quasi-expérimentales (DiD, contrôle synthétique, RDD, variables instrumentales) sur données réalistes ; estimands et hypothèses d'identification explicités | @@ -58,3 +59,9 @@ Exercices de DoWhy-5 (variable instrumentale via `dowhy`) : 1. **L'exclusion respectée vs VIOLEE** — générer un DGP `effet_direct_z = 0.5`, vérifier que `dowhy` identifie l'estimand `iv`, et constater l'écart entre `tau dowhy` et `TAU_VRAI = 2.0` ; verdict local NON_IDENTIFIABLE en mode terrain. 2. **Le F-stat comme garde-fou** — faire varier la taille d'échantillon `n ∈ {500, 1000, 2000, 5000}` sur instrument faible, observer comment le F-stat monte avec `n` (sans faire passer l'identification, qui reste structurelle). 3. **Verdict NON_IDENTIFIABLE sur DAG incomplet** — démontrer que `dowhy` identifie un estimand `iv` même quand l'exclusion est structurellement violée ; le verdict NON_IDENTIFIABLE doit venir du praticien, pas de `dowhy`. + +Exercices de DoWhy-3 (découverte de structure) : + +1. **L'ambiguïté ne se résout pas avec des données** — pour `n ∈ {500, 2000, 10000}`, constater que le CPDAG de PC garde `C–X` et `X–M` ambiguës : la classe de Markov est une borne structurelle, pas un problème de taille d'échantillon. +2. **`alpha` de PC, le compromis mesuré** — pour `alpha ∈ {0.2, 0.05, 0.01}` sur 5 seeds : arêtes parasites à `0.2`, v-structure perdue environ 1 seed sur 4 à `0.05` (mesuré sur ce monde), propre à `0.01` au prix de la puissance sur signaux faibles — il n'y a pas d'alpha gratuit. +3. **Diagnostiquer l'échec silencieux de LiNGAM** — sur 5 seeds gaussiens : DAG complet, faux et instable inter-seeds ; l'instabilité est le seul signal que l'hypothèse de non-gaussianité ne tient pas, la librairie reste muette. diff --git a/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/dowhy_discovery_organs.py b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/dowhy_discovery_organs.py new file mode 100644 index 0000000000..23e6d3023d --- /dev/null +++ b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/dowhy_discovery_organs.py @@ -0,0 +1,656 @@ +"""Organes canoniques de la ligne dowhy -- decouverte de structure (DoWhy-3). + +Issue #14049 (grain DoWhy-3). DoWhy-1 section 4 s'intitule « le graphe +assume » et sa section 5 mesure la sensibilite a cette hypothese. Ce module +repond a la question suivante : **et si on ne l'a pas, ce graphe ?** Trois +familles SOTA de decouverte causale, reellement executees (regle F / +SOTA-OK -- ``causal-learn``, jamais de reimplementation jouet) : + +1. **PC (contraintes)** -- tests d'independance conditionnelle (Fisher-z), + v-structures, regles de Meek. Rend un CPDAG. +2. **GES (score)** -- recherche gloutonne BIC sur les classes + d'equivalence. Rend un CPDAG. +3. **LiNGAM (fonctionnel)** -- linearite + bruit NON GAUSSIEN, ordre + causal identifiable (DARM). Rend un DAG complet. + +Le verdict honnete du module : sur donnees lineaires gaussiennes, PC et +GES rendent un CPDAG avec des aretes non orientees (``C--X`` et ``X--M`` +dans notre monde). Cette ambiguite EST la classe d'equivalence de Markov +-- un resultat, pas un echec. LiNGAM tranche ces aretes, mais au prix +d'une HYPOTHESE (non-gaussianite du bruit) : sur bruit gaussien, LiNGAM +rend quand meme un DAG complet -- confiant, faux, et sans avertissement. +Le verdict doit venir du praticien, pas de la librairie. + +Pieges pedagogiques que le module expose, mesures : + +1. **L'ambiguite est structurelle** : augmenter ``n`` ne tranche pas + ``C--X`` -- aucune quantite de donnees gaussiennes ne distingue + ``C->X`` de ``X->C`` dans la meme classe de Markov. +2. **Le CPDAG n'est pas une devinette partielle** : ses extensions valides + sont 3, pas 2^2 = 4 -- l'orientation ``C->X`` + ``M->X`` cree une + v-structure a X que la donnee exclut (cf. + ``enumerer_extensions_acycliques``). +3. **LiNGAM ne previent pas** : sur bruit gaussien il rend un DAG faux + sans lever d'erreur ; l'instabilite inter-seeds est le seul signal + (cf. section 6 du notebook). +4. **L'ambiguite se propage a l'estimand** : les 3 extensions valides du + MEME CPDAG, sur les MEMES donnees, donnent 3 estimands dowhy + differents (0.81 / 1.05 / 0.22 sur le monde par defaut) -- cf. + ``effet_backdoor_depuis_aretes``. +5. **alpha de PC est un vrai compromis** : sur ce monde, alpha = 0.05 + perd la v-structure environ 1 seed sur 4 (un test de vraie + independance rejete au niveau 5 % ; mesure : 15/20 seeds canoniques + a 0.05, 20/20 a 0.01). + +Fonctions exposees +------------------ + +- ``generer_donnees_decouverte(n, bruit, seed)`` -- monde DGP-connu a 5 + variables (C, X, M, Y, Z) : confondeur C, traitement X, mediateur M, + resultat Y, descendant Z. ``bruit="gaussien"`` -> PC/GES rendent un + CPDAG ; ``bruit="non_gaussien"`` (exponentiel centre) -> LiNGAM + recouvre le DAG exact. +- ``executer_pc(donnees, alpha)`` -- PC de causal-learn (Fisher-z), + rend les aretes orientees et non orientees. +- ``executer_ges(donnees)`` -- GES (BIC), meme sortie. +- ``executer_lingam(donnees, seuil_coef)`` -- DirectLiNGAM : matrice + d'adjacence W (convention ``W[i, j] != 0`` => ``j -> i``), ordre + causal, aretes. +- ``v_structures(aretes)`` -- les triples ``a -> b <- c`` non brides + d'un DAG donne. +- ``enumerer_extensions_acycliques(resultat)`` -- les DAGs valides + derriere un CPDAG : acycliques ET memes v-structures. +- ``verdict_cpdag(resultat)`` -- le verdict honnete : ``CPDAG_AMBIGU`` + (l'ambiguite est un resultat) ou ``DAG_ORIENTE`` (l'orientation vient + des hypotheses, pas des donnees seules). +- ``comparer_au_dag_vrai(resultat)`` -- squelette / orientations correctes + / inverses / parasites / manquantes contre ``ARETES_DAG_VRAI``. +- ``effet_backdoor_depuis_aretes(donnees, aretes, traitement, resultat)`` + -- le pont dowhy : identifie + estime l'effet backdoor sur le DAG + fourni (decouvert ou suppose), expose l'ensemble d'ajustement choisi. + +Doctrine de parametrisation (cf. ``dowhy_organs.py``, ``dowhy_iv_organs.py``) : +RandomState LOCAL par fonction, constantes en module documentees. + +References +---------- + +- Notebook consommateur : ``DoWhy-3-Decouverte-de-Structure.ipynb``. +- PC : Spirtes & Glymour, « An Algorithm for Fast Recovery of Sparse + Causal Graphs », Social Science Computer Review 9 (1991). +- GES : Chickering, « Optimal Structure Identification With Greedy + Search », JMLR 3 (2002). +- DirectLiNGAM : Shimizu et al., « DirectLiNGAM », AISTATS 2011 ; + identifiabilite DARM : Shimizu et al. 2006. +- Classes d'equivalence : Verma & Pearl, « Equivalence and Synthesis of + Causal Models », UAI 1990. +- API causal-learn : ``causallearn.search.ConstraintBased.PC.pc``, + ``causallearn.search.ScoreBased.GES.ges``, + ``causallearn.search.FCMBased.lingam.direct_lingam.DirectLiNGAM``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from itertools import combinations, product +from typing import Dict, List, Optional, Tuple + +import networkx as nx +import numpy as np +import pandas as pd + +# --------------------------------------------------------------------------- +# Constantes par defaut -- l'unique verite du simulateur +# --------------------------------------------------------------------------- +# Monde DGP (5 variables, tous bruits independants) : +# C ~ Bruit(1.0) +# X = COEF_C_X * C + Bruit(0.5) +# M = COEF_X_M * X + Bruit(0.5) +# Y = COEF_M_Y * M + COEF_C_Y * C + Bruit(0.5) +# Z = COEF_Y_Z * Y + Bruit(0.5) +ARETES_DAG_VRAI: List[Tuple[str, str]] = [ + ("C", "X"), + ("X", "M"), + ("M", "Y"), + ("C", "Y"), + ("Y", "Z"), +] +ORDRE_COLONNES: List[str] = ["C", "X", "M", "Y", "Z"] +COEF_C_X: float = 1.0 +COEF_X_M: float = 1.0 +COEF_M_Y: float = 0.8 +# COEF_C_Y = 0.3 et non 0.6 : a 0.6, la covariance partielle (C, M | Y) +# s'annule presque exactement (le chemin C->X->M compense l'ouverture du +# collisionneur C->Y<-M) -- monde quasi-infidele ou {Y} devient un sepset +# legitime pour PC, qui perd la v-structure 18 fois sur 20 (mesure +# 2026-09-13). A 0.3, corr partielle (C, M | Y) ~ 0.27 : collisionneur +# detectable 20/20 a alpha = 0.01. +COEF_C_Y: float = 0.3 +COEF_Y_Z: float = 1.0 +BRUIT_C: float = 1.0 +BRUIT_X: float = 0.5 +BRUIT_M: float = 0.5 +BRUIT_Y: float = 0.5 +BRUIT_Z: float = 0.5 +# alpha par defaut de PC : le standard causal-learn. Sur CE monde, la +# v-structure est fragile a 0.05 (15/20 seeds canoniques) et robuste a +# 0.01 (20/20) -- le notebook motive le choix, l'exercice 2 le mesure. +ALPHA_PC_DEFAUT: float = 0.05 +SEUIL_COEF_LINGAM: float = 0.1 + + +# --------------------------------------------------------------------------- +# Generateur du monde +# --------------------------------------------------------------------------- +def generer_donnees_decouverte( + n: int = 2000, + bruit: str = "gaussien", + seed: int = 42, +) -> pd.DataFrame: + """Monde DGP a 5 variables, DAG connu (``ARETES_DAG_VRAI``). + + Mecanisme (lineaire, bruits independants) : + C ~ Bruit(1.0) + X = 1.0 * C + Bruit(0.5) + M = 1.0 * X + Bruit(0.5) + Y = 0.8 * M + 0.3 * C + Bruit(0.5) + Z = 1.0 * Y + Bruit(0.5) + + Semantique : C confondeur observe, X traitement, M mediateur, + Y resultat, Z descendant de Y (capteur). Le DAG vrai porte une seule + v-structure non bridee : ``C -> Y <- M``. + + Parametrisation + --------------- + n : int, default 2000 + Taille de l'echantillon. + bruit : str, default "gaussien" + ``"gaussien"`` : N(0, sigma) -- PC et GES rendent le CPDAG + (C--X et X--M non orientees), LiNGAM n'a aucune raison d'etre + juste. ``"non_gaussien"`` : exponentiel centre et rescale + (variance identique au gaussien) -- LiNGAM recouvre le DAG + exact ; PC, qui n'utilise pas la forme du bruit, reste CPDAG. + seed : int, default 42 + RandomState LOCAL (doctrine module). + + Return + ------ + pd.DataFrame de shape ``(n, 5)`` -- colonnes ``C``, ``X``, ``M``, + ``Y``, ``Z`` dans cet ordre. + """ + if bruit not in ("gaussien", "non_gaussien"): + raise ValueError(f"bruit doit etre 'gaussien' ou 'non_gaussien', recu {bruit!r}") + rng = np.random.RandomState(seed) + + def tirage(sigma: float) -> np.ndarray: + if bruit == "gaussien": + return rng.normal(0.0, sigma, n) + # Exponentiel centre : E=1, Var=1 -> centre puis rescale a sigma + # (meme variance que le gaussien, seul change le kurtosis). + return sigma * (rng.exponential(1.0, n) - 1.0) + + c = tirage(BRUIT_C) + x = COEF_C_X * c + tirage(BRUIT_X) + m = COEF_X_M * x + tirage(BRUIT_M) + y = COEF_M_Y * m + COEF_C_Y * c + tirage(BRUIT_Y) + z = COEF_Y_Z * y + tirage(BRUIT_Z) + return pd.DataFrame({"C": c, "X": x, "M": m, "Y": y, "Z": z}) + + +# --------------------------------------------------------------------------- +# Resultat structure commun +# --------------------------------------------------------------------------- +@dataclass +class ResultatDecouverte: + """Sortie structuree des executeurs (PC, GES, LiNGAM). + + Champs : + methode : str -- "PC", "GES" ou "LiNGAM". + aretes_orientees : list[tuple[str, str]] -- aretes (u, v) lues + u -> v par l'algorithme. + aretes_non_orientees : list[tuple[str, str]] -- aretes du CPDAG + que la donnee ne tranche pas (u - v), stockees triees. + matrice_adjacence : ndarray ou None -- W de LiNGAM (convention + ``W[i, j] != 0`` => ``j -> i``), None pour PC/GES. + ordre_causal : list[str] ou None -- ordre causal estime par + LiNGAM (cause -> effet), None pour PC/GES. + n : int -- taille de l'echantillon. + details : dict -- parametres de l'algorithme (alpha, score, + seuil...). + """ + + methode: str + aretes_orientees: List[Tuple[str, str]] = field(default_factory=list) + aretes_non_orientees: List[Tuple[str, str]] = field(default_factory=list) + matrice_adjacence: Optional[np.ndarray] = None + ordre_causal: Optional[List[str]] = None + n: int = 0 + details: Dict[str, object] = field(default_factory=dict) + + +def _aretes_depuis_generalgraph(graphe, labels: List[str]): + """Convertit un GeneralGraph causal-learn en (orienteess, non_orientees). + + Conventions d'endpoints causal-learn : (TAIL, ARROW) => u -> v ; + (ARROW, TAIL) => v -> u ; toute autre combinaison (TAIL/TAIL, CIRCLE) + est une arete que la donnee ne tranche pas. + """ + from causallearn.graph.Endpoint import Endpoint + + orientees: List[Tuple[str, str]] = [] + non_orientees: List[Tuple[str, str]] = [] + for e in graphe.get_graph_edges(): + u, v = e.node1.name, e.node2.name + if e.endpoint1 == Endpoint.TAIL and e.endpoint2 == Endpoint.ARROW: + orientees.append((u, v)) + elif e.endpoint1 == Endpoint.ARROW and e.endpoint2 == Endpoint.TAIL: + orientees.append((v, u)) + else: + non_orientees.append(tuple(sorted((u, v)))) + return sorted(orientees), sorted(set(non_orientees)) + + +def executer_pc( + donnees: pd.DataFrame, + alpha: float = ALPHA_PC_DEFAUT, + labels: Optional[List[str]] = None, +) -> ResultatDecouverte: + """PC (Spirtes-Glymour) via causal-learn : tests Fisher-z + v-structures. + + Parametrisation + --------------- + donnees : pd.DataFrame + Les colonnes utilisees sont ``labels`` (defaut : ``ORDRE_COLONNES``). + alpha : float, default 0.05 + Seuil des tests d'independance. Compromis mesure sur le monde + par defaut : 0.05 -> v-structure perdue ~1 seed sur 4 ; + 0.01 -> 20/20 canonique (au prix de la puissance sur signaux + faibles -- il n'y a pas d'alpha gratuit). + labels : list[str], optional + Noms de colonnes dans l'ordre de la matrice passee a PC. + + Return + ------ + ResultatDecouverte(methode="PC") -- les aretes non orientees sont le + resultat honnete, pas un echec de convergence. + """ + from causallearn.search.ConstraintBased.PC import pc + + cols = list(labels) if labels is not None else ORDRE_COLONNES + cg = pc(donnees[cols].values, alpha=alpha, node_names=cols, show_progress=False) + orientees, non_orientees = _aretes_depuis_generalgraph(cg.G, cols) + return ResultatDecouverte( + methode="PC", + aretes_orientees=orientees, + aretes_non_orientees=non_orientees, + n=len(donnees), + details={"alpha": alpha, "test_independance": "fisherz"}, + ) + + +def executer_ges( + donnees: pd.DataFrame, + labels: Optional[List[str]] = None, +) -> ResultatDecouverte: + """GES (Chickering) via causal-learn : recherche gloutonne BIC. + + GES cherche directement sur l'espace des CLASSES d'equivalence (pas + des DAGs individuels) : son output est nativement un CPDAG. + + Parametrisation + --------------- + donnees : pd.DataFrame + Les colonnes utilisees sont ``labels`` (defaut : ``ORDRE_COLONNES``). + labels : list[str], optional + Noms de colonnes dans l'ordre de la matrice. + + Return + ------ + ResultatDecouverte(methode="GES"). + """ + from causallearn.search.ScoreBased.GES import ges + + cols = list(labels) if labels is not None else ORDRE_COLONNES + resultat = ges(donnees[cols].values, score_func="local_score_BIC", node_names=cols) + orientees, non_orientees = _aretes_depuis_generalgraph(resultat["G"], cols) + return ResultatDecouverte( + methode="GES", + aretes_orientees=orientees, + aretes_non_orientees=non_orientees, + n=len(donnees), + details={"score": "local_score_BIC"}, + ) + + +def executer_lingam( + donnees: pd.DataFrame, + seuil_coef: float = SEUIL_COEF_LINGAM, + labels: Optional[List[str]] = None, +) -> ResultatDecouverte: + """DirectLiNGAM via causal-learn : DAG complet sous non-gaussianite. + + LiNGAM exploite une hypothese que PC/GES ignorent : bruit exogene + NON GAUSSIEN + relations lineaires => l'ordre causal est identifiable + (DARM). En contrepartie, sur bruit gaussien, l'hypothese n'est pas + tenue et DirectLiNGAM rend quand meme un DAG complet -- faux, sans + erreur ni avertissement (verifie : deux seeds gaussiens rendent deux + DAGs faux differents). + + Parametrisation + --------------- + donnees : pd.DataFrame + Les colonnes utilisees sont ``labels`` (defaut : ``ORDRE_COLONNES``). + seuil_coef : float, default 0.1 + Les coefficients |W| sous ce seuil sont consideres nuls (les + vrais coefficients du monde par defaut sont >= 0.3). + labels : list[str], optional + Noms de colonnes dans l'ordre de la matrice. + + Return + ------ + ResultatDecouverte(methode="LiNGAM") avec ``matrice_adjacence`` = W + (convention ``W[i, j] != 0`` => ``j -> i``, verifiee sur DGP connu) + et ``ordre_causal`` = ordre cause -> effet estime. + """ + from causallearn.search.FCMBased.lingam.direct_lingam import DirectLiNGAM + + cols = list(labels) if labels is not None else ORDRE_COLONNES + modele = DirectLiNGAM() + modele.fit(donnees[cols].values) + w = np.asarray(modele.adjacency_matrix_, dtype=float) + aretes = [ + (cols[j], cols[i]) + for i in range(len(cols)) + for j in range(len(cols)) + if abs(w[i, j]) > seuil_coef + ] + ordre = [cols[k] for k in np.asarray(modele.causal_order_).ravel().tolist()] + return ResultatDecouverte( + methode="LiNGAM", + aretes_orientees=sorted(aretes), + aretes_non_orientees=[], + matrice_adjacence=w, + ordre_causal=ordre, + n=len(donnees), + details={"seuil_coef": seuil_coef}, + ) + + +# --------------------------------------------------------------------------- +# Classes d'equivalence +# --------------------------------------------------------------------------- +def v_structures(aretes) -> List[Tuple[str, str, str]]: + """V-structures non brides d'un ensemble d'aretes orientees. + + Une v-structure ``a -> b <- c`` est non bridee quand ``a`` et ``c`` + ne sont pas adjacents (aucune arete dans un sens ni dans l'autre) : + c'est la seule configuration locale que deux DAGs equivalents ne + peuvent PAS partager differemment (Verma & Pearl 1990 : meme + squelette + memes v-structures <=> meme classe de Markov). + + Parametrisation + --------------- + aretes : iterable de tuples (u, v) lus u -> v. + + Return + ------ + Liste triee de tuples (a, b, c) : a -> b <- c. + """ + g = nx.DiGraph(list(aretes)) + out: List[Tuple[str, str, str]] = [] + for b in sorted(g.nodes): + parents = sorted(g.predecessors(b)) + for a, c in combinations(parents, 2): + if not g.has_edge(a, c) and not g.has_edge(c, a): + out.append((a, b, c)) + return sorted(out) + + +def enumerer_extensions_acycliques( + resultat: ResultatDecouverte, +) -> List[List[Tuple[str, str]]]: + """Les DAGs valides derriere un CPDAG : 3, pas 2^k. + + Une extension d'un CPDAG est valide ssi (a) le DAG obtenu en + orientant chaque arete ambigue est acyclique, ET (b) il porte + EXACTEMENT les memes v-structures que le CPDAG. Le critere (b) + exclut des orientations acycliques pourtant invalides : dans notre + monde, orienter ``C -> X`` et ``M -> X`` cree une v-structure + ``C -> X <- M`` que la donnee exclut -- 3 extensions valides, et + non 4 (= 2^2 aretes ambigues). + + Les v-structures de reference se lisent sur les aretes DEJA orientees + du CPDAG : les fleches d'une v-structure sont toujours compelled + (meme orientation dans tous les membres de la classe). + + Parametrisation + --------------- + resultat : ResultatDecouverte + Sortie de ``executer_pc`` ou ``executer_ges``. + + Return + ------ + Liste triee d'extensions valides, chacune une liste triee d'aretes + (u, v) orientees formant un DAG de la classe. + """ + fixes = [tuple(e) for e in resultat.aretes_orientees] + ambigues = [tuple(e) for e in resultat.aretes_non_orientees] + + # Adjacence complete (oriente + non orientee) pour le test "non bride". + adjacence = nx.Graph(fixes + [tuple(sorted(e)) for e in ambigues]) + + # V-structures de reference : parents deja orientes, non adjacents. + parents_de: Dict[str, List[str]] = {} + for u, v in fixes: + parents_de.setdefault(v, []).append(u) + v_ref = set() + for b, parents in parents_de.items(): + for a, c in combinations(sorted(parents), 2): + if not adjacence.has_edge(a, c): + v_ref.add((a, b, c)) + + extensions_valides: List[List[Tuple[str, str]]] = [] + if not ambigues: + g = nx.DiGraph(fixes) + if nx.is_directed_acyclic_graph(g): + return [sorted(fixes)] + return [] + for choix in product(*[[(u, v), (v, u)] for (u, v) in ambigues]): + candidat = fixes + list(choix) + g = nx.DiGraph(candidat) + if not nx.is_directed_acyclic_graph(g): + continue + if set(v_structures(candidat)) != v_ref: + continue + extensions_valides.append(sorted(candidat)) + return sorted(extensions_valides) + + +# --------------------------------------------------------------------------- +# Verdicts +# --------------------------------------------------------------------------- +def verdict_cpdag(resultat: ResultatDecouverte) -> Dict[str, object]: + """Le verdict honnete sur la forme du graphe decouvert. + + - ``CPDAG_AMBIGU`` : la methode rend des aretes non orientees. Ce + n'est PAS un echec d'algorithme : c'est la classe d'equivalence + de Markov, la borne exacte de ce que CES donnees (avec les + hypotheses de CETTE methode) peuvent trancher. + - ``DAG_ORIENTE`` : la methode rend un DAG complet. L'orientation + ne vient PAS des donnees seules mais des hypotheses fonctionnelles + de la methode (LiNGAM : non-gaussianite du bruit). Sans cette + hypothese, rien ne distingue ce DAG des autres membres de sa + classe -- sur bruit gaussien, le meme appel rend un DAG faux, + sans avertissement. + + Parametrisation + --------------- + resultat : ResultatDecouverte + Sortie d'un executeur. + + Return + ------ + Dict avec cles ``verdict``, ``n_aretes_non_orientees``, + ``aretes_non_orientees``, ``methode``, ``message``. + """ + n_amb = len(resultat.aretes_non_orientees) + if n_amb > 0: + verdict = "CPDAG_AMBIGU" + message = ( + f"{n_amb} arete(s) non orientee(s) : classe d'equivalence de Markov. " + "L'ambiguite est un RESULTAT -- aucun n plus grand ne la tranche " + "(donnees gaussiennes). Chaque extension valide donne un estimand " + "potentiellement different (cf. effet_backdoor_depuis_aretes)." + ) + else: + verdict = "DAG_ORIENTE" + message = ( + "Orientation complete : elle vient des HYPOTHESES de la methode " + "(LiNGAM : bruit non gaussien), pas des donnees seules. Sur bruit " + "gaussien le meme appel rend un DAG faux sans avertissement -- " + "verifier l'hypothese (kurtosis, stabilite inter-seeds) avant de " + "faire parler l'orientation." + ) + return { + "verdict": verdict, + "n_aretes_non_orientees": n_amb, + "aretes_non_orientees": [tuple(e) for e in resultat.aretes_non_orientees], + "methode": resultat.methode, + "message": message, + } + + +def comparer_au_dag_vrai( + resultat: ResultatDecouverte, + aretes_vraies: Optional[List[Tuple[str, str]]] = None, +) -> Dict[str, object]: + """Confronte un resultat de decouverte au DAG vrai du simulateur. + + Parametrisation + --------------- + resultat : ResultatDecouverte + Sortie d'un executeur. + aretes_vraies : list[tuple[str, str]], optional + Defaut : ``ARETES_DAG_VRAI``. + + Return + ------ + Dict avec cles : + ``squelette_trouvees`` -- "k/K" aretes vraies presentes (orientees + ou non) ; + ``oriente_comme_vrai`` -- arets orientees dans le bon sens ; + ``oriente_inverse`` -- aretes orientees a l'envers du DAG vrai ; + ``non_orientees_parmi_vraies`` -- vraies arets laissees ambigues ; + ``parasites`` -- aretes detectees absentes du DAG vrai ; + ``manquantes`` -- vraies aretes absentes du resultat. + """ + vraies = {tuple(e) for e in (aretes_vraies or ARETES_DAG_VRAI)} + orientees = {tuple(e) for e in resultat.aretes_orientees} + non_orientees = {tuple(sorted(e)) for e in resultat.aretes_non_orientees} + + squelette_detecte = { + tuple(sorted(e)) for e in orientees + } | non_orientees + + oriente_comme_vrai = sorted(orientees & vraies) + # une arete orientee "inverse" = (v, u) avec (u, v) vraie + inversees = sorted( + (v, u) for (u, v) in vraies if (v, u) in orientees + ) + non_or_vraies = sorted( + tuple(sorted(e)) for e in vraies if tuple(sorted(e)) in non_orientees + ) + vraies_skel = {tuple(sorted(e)) for e in vraies} + parasites = sorted(squelette_detecte - vraies_skel) + manquantes = sorted(vraies_skel - squelette_detecte) + return { + "squelette_trouvees": f"{len(vraies_skel) - len(manquantes)}/{len(vraies_skel)}", + "oriente_comme_vrai": oriente_comme_vrai, + "oriente_inverse": inversees, + "non_orientees_parmi_vraies": non_or_vraies, + "parasites": parasites, + "manquantes": manquantes, + } + + +# --------------------------------------------------------------------------- +# Pont dowhy : du graphe decouvert a l'estimand +# --------------------------------------------------------------------------- +@dataclass +class DowhyBackdoorResult: + """Sortie structuree du wrapper ``effet_backdoor_depuis_aretes``. + + Champs : + estimate_value : float -- effet estime (backdoor.linear_regression). + ensemble_ajustement : list[str] -- variables d'ajustement + retenues par dowhy pour cet ensemble d'aretes. + estimand_texte : str -- estimand identifie (texte dowhy). + """ + + estimate_value: float + ensemble_ajustement: List[str] = field(default_factory=list) + estimand_texte: str = "" + + +def effet_backdoor_depuis_aretes( + donnees: pd.DataFrame, + aretes, + traitement: str = "X", + resultat: str = "Y", +) -> DowhyBackdoorResult: + """Identifie et estime l'effet backdoor sur un ensemble d'aretes donne. + + C'est le pont entre la decouverte (ce module) et l'identification + (dowhy, DoWhy-1) : le DAG decouvert -- ou n'importe quelle extension + d'un CPDAG -- alimente le pipeline dowhy. Le point pedagogique : + les differentes extensions valides du MEME CPDAG, sur les MEMES + donnees, donnent des ensembles d'ajustement et des estimands + DIFFERENTS (mesure sur le monde par defaut : 0.81 / 1.05 / 0.22 pour + un effet total vrai de 0.8). L'ambiguite de la decouverte ne s'arrete + pas au dessin : elle se propage au chiffre. + + Parametrisation + --------------- + donnees : pd.DataFrame + Doit porter toutes les variables du graphe. + aretes : iterable de tuples (u, v) lus u -> v + Le DAG a tester (decouvert par LiNGAM, extension d'un CPDAG, + ou suppose par expertise -- meme pipeline). + traitement, resultat : str + Noms du traitement et de l'outcome (defaut X et Y). + + Return + ------ + DowhyBackdoorResult -- voir la dataclass. + + Raises + ------ + RuntimeError + Si dowhy ne peut pas identifier d'ensemble backdoor pour ce + graphe (l'exception remonte au notebook, qui la commente). + """ + from dowhy import CausalModel + + graphe = nx.DiGraph([tuple(e) for e in aretes]) + modele = CausalModel( + data=donnees, + treatment=traitement, + outcome=resultat, + graph=graphe, + ) + estimand = modele.identify_effect() + effet = modele.estimate_effect( + estimand, method_name="backdoor.linear_regression" + ) + backdoor_vars = getattr(estimand, "backdoor_variables", None) + if isinstance(backdoor_vars, dict): + ensemble = sorted(backdoor_vars.get("backdoor", [])) + elif backdoor_vars: + ensemble = sorted(backdoor_vars) + else: + ensemble = [] + return DowhyBackdoorResult( + estimate_value=float(effet.value), + ensemble_ajustement=ensemble, + estimand_texte=str(estimand), + ) diff --git a/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/tests/test_dowhy_discovery_organs.py b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/tests/test_dowhy_discovery_organs.py new file mode 100644 index 0000000000..6fe25968d4 --- /dev/null +++ b/MyIA.AI.Notebooks/Probas/DecisionTheory/Causal-Bridges/tests/test_dowhy_discovery_organs.py @@ -0,0 +1,320 @@ +"""Tests pytest pour dowhy_discovery_organs.py -- grain DoWhy-3 de l'issue #14049. + +Issue #14049, acceptance 4 : « chaque notebook de la serie DoWhy expose +un organe importable des sa livraison, et les tests verifient la sortie +du module contre la valeur attendue ». Ce fichier EST le consommateur +externe de l'organe (avec le notebook). + +Strategie de test (meme convention que test_dowhy_iv_organs.py) : on +**execute reellement** les algorithmes de causal-learn (pas de mock, +H.1) et on verifie que : + +(a) ``generer_donnees_decouverte`` respecte la RandomState LOCALE et + produit un bruit gaussien ou non gaussien selon le mode (kurtosis) ; +(b) ``ARETES_DAG_VRAI`` est bien un DAG 5 aretes a une seule + v-structure C -> Y <- M ; +(c) PC (alpha=0.01) et GES rendent le CPDAG canonique sur donnees + gaussiennes : squelette 5/5, v-structure orientee, C--X et X--M + ambigues -- le verdict CPDAG_AMBIGU est un RESULTAT ; +(d) PC reste ambigu sur donnees NON gaussiennes (PC ne consomme pas la + forme du bruit -- c'est le coeur du contraste avec LiNGAM) ; +(e) LiNGAM recouvre le DAG EXACT sur bruit non gaussien (5/5, ordre + causal correct) et se trompe sur bruit gaussien (DAG faux rendu + quand meme -- l'echec honnete documente) ; +(f) ``enumerer_extensions_acycliques`` rend exactement 3 extensions + (pas 4) : l'orientation C->X + M->X est exclue car elle cree une + v-structure a X que la donnee exclut ; +(g) ``effet_backdoor_depuis_aretes`` : les extensions valides du meme + CPDAG donnent des estimands dowhy DIFFERENTS -- l'ambiguite se + propage au chiffre ; +(h) anti-derive : la cellule DGP du notebook consomme l'organe et + produit la meme DataFrame que l'appel direct du module. + +Les tolerances sont celles de la serie : le monde est DGP-connu, les +coefficients sont forts (>= 0.3), n=2000 -- les resultats ci-dessus sont +stables sur 20/20 seeds (PC a alpha=0.01, GES) et 10/10 (LiNGAM non +gaussien), mesures avant l'ecriture de ces tests. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +# Import direct sans packaging : on ajoute le dossier parent +# (Probas/DecisionTheory/Causal-Bridges/) au sys.path. +_PARENT_DIR = Path(__file__).resolve().parent.parent +if str(_PARENT_DIR) not in sys.path: + sys.path.insert(0, str(_PARENT_DIR)) + +import dowhy_discovery_organs as ddo + +# Chemin du notebook consommateur (relatif a ce test). +NB_PATH = _PARENT_DIR / "DoWhy-3-Decouverte-de-Structure.ipynb" + +# Le CPDAG canonique du monde par defaut (mesure, 20/20 seeds a alpha=0.01). +# Convention du module : les aretes non orientees sont rendues en tuple TRIE. +ORIENTEES_CANON = {("C", "Y"), ("M", "Y"), ("Y", "Z")} +AMBIGUES_CANON = {("C", "X"), ("M", "X")} + + +# --------------------------------------------------------------------------- +# (a) generer_donnees_decouverte +# --------------------------------------------------------------------------- +def test_generer_forme_et_colonnes(): + """Dimensions par defaut : n=2000 x 5 colonnes (C, X, M, Y, Z).""" + df = ddo.generer_donnees_decouverte() + assert isinstance(df, pd.DataFrame) + assert df.shape == (2000, 5) + assert list(df.columns) == ddo.ORDRE_COLONNES + + +def test_generer_seed_reproductible(): + """RandomState LOCAL : deux appels meme seed -> DataFrame egale.""" + pd.testing.assert_frame_equal( + ddo.generer_donnees_decouverte(seed=42), + ddo.generer_donnees_decouverte(seed=42), + ) + + +def test_generer_seed_change_impacte(): + """Deux seeds differentes -> deux DataFrames differentes.""" + assert not ddo.generer_donnees_decouverte(seed=42).equals( + ddo.generer_donnees_decouverte(seed=43) + ) + + +def test_generer_bruit_non_gaussien_est_non_gaussien(): + """Mode non_gaussien : kurtosis excess >> 0 (exponentiel ~ 3).""" + from scipy import stats as sps + + df_g = ddo.generer_donnees_decouverte(bruit="gaussien", seed=42) + df_ng = ddo.generer_donnees_decouverte(bruit="non_gaussien", seed=42) + k_g = sps.kurtosis(df_g["C"].values, fisher=True) + k_ng = sps.kurtosis(df_ng["C"].values, fisher=True) + assert abs(k_g) < 0.5, f"kurtosis gaussien attendu ~0, recu {k_g:.2f}" + assert k_ng > 1.5, f"kurtosis exponentiel attendu ~3, recu {k_ng:.2f}" + + +def test_generer_meme_variance_entre_modes(): + """Les deux modes portent la meme variance de bruit (seul le kurtosis change).""" + for col in ddo.ORDRE_COLONNES: + v_g = ddo.generer_donnees_decouverte(bruit="gaussien", seed=42)[col].var() + v_ng = ddo.generer_donnees_decouverte(bruit="non_gaussien", seed=42)[col].var() + assert abs(v_g - v_ng) < 0.15 * v_g, ( + f"variance {col} : gaussien {v_g:.2f} vs non_gaussien {v_ng:.2f}" + ) + + +def test_generer_mode_invalide_leve(): + """Un mode de bruit inconnu est refuse explicitement.""" + with pytest.raises(ValueError): + ddo.generer_donnees_decouverte(bruit="poissonien") + + +# --------------------------------------------------------------------------- +# (b) Le DAG vrai du simulateur +# --------------------------------------------------------------------------- +def test_aretes_dag_vrai_est_un_dag_acyclique(): + """5 aretes, acyclique, une seule v-structure C -> Y <- M.""" + import networkx as nx + + g = nx.DiGraph(ddo.ARETES_DAG_VRAI) + assert g.number_of_edges() == 5 + assert nx.is_directed_acyclic_graph(g) + assert ddo.v_structures(ddo.ARETES_DAG_VRAI) == [("C", "Y", "M")] + + +# --------------------------------------------------------------------------- +# (c) PC et GES : le CPDAG canonique sur donnees gaussiennes +# --------------------------------------------------------------------------- +def test_pc_alpha_001_cpdag_canonique(): + """PC : squelette 5/5, v-structure orientee, C--X et X--M ambigues.""" + df = ddo.generer_donnees_decouverte(seed=42) + res = ddo.executer_pc(df, alpha=0.01) + assert res.methode == "PC" + assert set(res.aretes_orientees) == ORIENTEES_CANON + assert set(res.aretes_non_orientees) == AMBIGUES_CANON + comp = ddo.comparer_au_dag_vrai(res) + assert comp["squelette_trouvees"] == "5/5" + assert comp["parasites"] == [] + assert comp["manquantes"] == [] + assert set(comp["non_orientees_parmi_vraies"]) == AMBIGUES_CANON + + +def test_ges_cpdag_canonique(): + """GES rend le meme CPDAG que PC sur ce monde (20/20 seeds mesures).""" + df = ddo.generer_donnees_decouverte(seed=42) + res = ddo.executer_ges(df) + assert res.methode == "GES" + assert set(res.aretes_orientees) == ORIENTEES_CANON + assert set(res.aretes_non_orientees) == AMBIGUES_CANON + + +def test_pc_sur_non_gaussien_reste_ambigu(): + """Point cle : PC ignore la forme du bruit -- l'ambiguite survit.""" + df_ng = ddo.generer_donnees_decouverte(bruit="non_gaussien", seed=42) + res = ddo.executer_pc(df_ng, alpha=0.01) + assert len(res.aretes_non_orientees) >= 1, ( + "PC ne doit pas trancher C--X meme sur bruit non gaussien " + "(il ne consomme pas l'information de forme)" + ) + + +# --------------------------------------------------------------------------- +# (d) LiNGAM : exact sous non-gaussianite, faux-confiant sous gaussianite +# --------------------------------------------------------------------------- +def test_lingam_non_gaussien_recouvre_le_dag_exact(): + """LiNGAM : les 5 aretes exactes, ordre causal correct (10/10 seeds).""" + df_ng = ddo.generer_donnees_decouverte(bruit="non_gaussien", seed=42) + res = ddo.executer_lingam(df_ng) + assert res.methode == "LiNGAM" + assert res.aretes_non_orientees == [] + assert set(res.aretes_orientees) == set(ddo.ARETES_DAG_VRAI) + assert res.ordre_causal == ["C", "X", "M", "Y", "Z"] + comp = ddo.comparer_au_dag_vrai(res) + assert comp["squelette_trouvees"] == "5/5" + assert comp["oriente_inverse"] == [] + assert comp["parasites"] == [] + + +def test_lingam_gaussien_rend_un_dag_faux(): + """L'echec honnete : sur bruit gaussien LiNGAM rend un DAG complet faux. + + Verifie sur seed=42 : 7 aretes dont 2 parasites et des inversions. + Le module ne masque pas cet echec -- il l'expose (verdict DAG_ORIENTE + + message de prudence). + """ + df_g = ddo.generer_donnees_decouverte(seed=42) + res = ddo.executer_lingam(df_g) + assert res.aretes_non_orientees == [] + assert set(res.aretes_orientees) != set(ddo.ARETES_DAG_VRAI), ( + "sur bruit gaussien LiNGAM ne doit pas (par chance structurelle) " + "rendre le DAG exact -- si c'est le cas, changer le seed du test" + ) + v = ddo.verdict_cpdag(res) + assert v["verdict"] == "DAG_ORIENTE" + assert "HYPOTHESES" in v["message"] + + +# --------------------------------------------------------------------------- +# (e) Verdicts +# --------------------------------------------------------------------------- +def test_verdict_cpdag_ambigu_est_un_resultat(): + """PC -> CPDAG_AMBIGU : l'ambiguite est enseignee comme resultat.""" + res = ddo.executer_pc(ddo.generer_donnees_decouverte(seed=42), alpha=0.01) + v = ddo.verdict_cpdag(res) + assert v["verdict"] == "CPDAG_AMBIGU" + assert v["n_aretes_non_orientees"] == 2 + assert set(v["aretes_non_orientees"]) == AMBIGUES_CANON + assert "RESULTAT" in v["message"] + + +def test_verdict_cpdag_oriente_vient_des_hypotheses(): + """LiNGAM -> DAG_ORIENTE : l'orientation vient des hypotheses, message clair.""" + res = ddo.executer_lingam( + ddo.generer_donnees_decouverte(bruit="non_gaussien", seed=42) + ) + v = ddo.verdict_cpdag(res) + assert v["verdict"] == "DAG_ORIENTE" + assert v["n_aretes_non_orientees"] == 0 + + +# --------------------------------------------------------------------------- +# (f) Classe d'equivalence : 3 extensions, pas 4 +# --------------------------------------------------------------------------- +def test_extensions_acycliques_exactement_trois(): + """Le CPDAG canonique a 2 aretes ambigues mais SEULEMENT 3 extensions valides. + + L'orientation C->X + M->X est exclue : elle cree la v-structure + C -> X <- M, absente des v-structures de reference du CPDAG. + """ + res = ddo.executer_pc(ddo.generer_donnees_decouverte(seed=42), alpha=0.01) + exts = ddo.enumerer_extensions_acycliques(res) + assert len(exts) == 3, f"3 extensions attendues, {len(exts)} rendues : {exts}" + assert sorted(ddo.ARETES_DAG_VRAI) in exts + for ext in exts: + assert ("C", "X") not in ext or ("M", "X") not in ext, ( + f"l'orientation C->X + M->X (v-structure exclue) est presente : {ext}" + ) + + +def test_extensions_lingam_est_lui_meme(): + """Sans aretes ambigues : l'unique extension est le DAG lui-meme.""" + res = ddo.executer_lingam( + ddo.generer_donnees_decouverte(bruit="non_gaussien", seed=42) + ) + exts = ddo.enumerer_extensions_acycliques(res) + assert exts == [sorted(ddo.ARETES_DAG_VRAI)] + + +# --------------------------------------------------------------------------- +# (g) Pont dowhy : l'ambiguite se propage a l'estimand +# --------------------------------------------------------------------------- +def test_effet_backdoor_estimations_differentes_par_extension(): + """Meme CPDAG, memes donnees : 3 extensions -> 3 estimands differents. + + Valeurs mesurees (seed=42, delta=0.3) : vrai ~0.81 (ajustement {C}), + extension sans confondeur ~1.05 (pas d'ajustement), extension qui + ajuste le mediateur ~0.22 (ecrase l'effet). L'effet total vrai + vaut beta_x_m * gamma_m_y = 0.8. + """ + df = ddo.generer_donnees_decouverte(seed=42) + res_pc = ddo.executer_pc(df, alpha=0.01) + exts = ddo.enumerer_extensions_acycliques(res_pc) + + estimands = [] + for ext in exts: + r = ddo.effet_backdoor_depuis_aretes(df, ext) + estimands.append((r.ensemble_ajustement, r.estimate_value)) + assert -0.5 < r.estimate_value < 2.0 + + valeurs = sorted(round(v, 3) for _, v in estimands) + assert len(set(valeurs)) == 3, f"3 estimands distincts attendus : {valeurs}" + + r_vrai = ddo.effet_backdoor_depuis_aretes(df, ddo.ARETES_DAG_VRAI) + assert r_vrai.ensemble_ajustement == ["C"] + assert abs(r_vrai.estimate_value - 0.8) < 0.15, ( + f"estimand du DAG vrai ~0.8 attendu, recu {r_vrai.estimate_value:.3f}" + ) + + +# --------------------------------------------------------------------------- +# (h) Anti-derive : le notebook consomme l'organe +# --------------------------------------------------------------------------- +def _lire_cellule(nb_path: Path, cell_index: int) -> str: + nb = json.load(open(nb_path, encoding="utf-8")) + return "".join(nb["cells"][cell_index]["source"]) + + +def test_notebook_cellule_dgp_byte_identique_module(): + """La cellule DGP (index 3) du notebook consomme l'organe, sans redefinition. + + Anti-derive (acceptance 4) : si quelqu'un recopiait la generation + dans une fonction locale du notebook, ce test rougirait -- le + notebook DOIT produire la meme DataFrame que l'appel canonique. + """ + src = _lire_cellule(NB_PATH, 3) + assert "ddo.generer_donnees_decouverte" in src, ( + "la cellule DGP du notebook doit appeler ddo.generer_donnees_decouverte" + ) + g: dict = {"ddo": ddo} + exec(compile(src, "", "exec"), g) + df_nb = g["df_monde"] + pd.testing.assert_frame_equal( + df_nb, ddo.generer_donnees_decouverte(n=2000, bruit="gaussien", seed=42) + ) + + +def test_notebook_cellule_pc_consomme_lorgane(): + """La cellule PC (index 6) reference ddo.executer_pc.""" + src = _lire_cellule(NB_PATH, 6) + assert "ddo.executer_pc" in src + assert "alpha=0.01" in src, ( + "le notebook motive alpha=0.01 pour PC (v-structure robuste 20/20)" + )