CausalBridges-02 — Exiger un estimand : l’identification causale avant le chiffre
Renumerotation #17421 : ce carnet etait publie sous le nom DoWhy-1 ; les renvois internes de la serie citent desormais le numero courant.
Serie : Inference causale avec DoWhy (Probas, Python) Prerequis : PyMC-05-Causal-Inference (l’echelle de Pearl, le do-calcul sur un SCM connu) et le notebook-pont Do-Calculus-Bridge (le criteres backdoor et front-door demontres une fois chacun sur leurs cas). Outil : DoWhy 0.14 — le vrai outil d’identification causale, pas une reimplementation.
Le vocabulaire causal est facile a emprunter et difficile a honorer. Ecrire do(X) devant une distribution ne fait pas une intervention ; ce qui la fait, c’est un estimand : l’identification EXPLICITE de la quantite causale visee a partir de ce qui est observable, sous un graphe ASSUME.
Le pont a montre UNE fois comment dowhy identifie (backdoor sur college/earnings, frontdoor sur genotype/tar). Ce notebook change d’exigence : sur un seul cas, tenu du generateur a la refutation, chaque etape doit dire quelle hypothese elle suppose et ce qui arrive au chiffre quand l’hypothese saute :
Quel graphe supposons-nous — et que suppose-t-il, qu’exclut-il ?
Quel estimand en decoule — backdoor, frontdoor, variable instrumentale — nomme, pas implicite ?
Que devient le chiffre si le graphe change — la sensibilite mesuree, pas commentee ?
1. Le cas propre — reeducation post-operatoire
Un hopital deploye un nouveau protocole de reeducation intensive (T) et mesure la mobilite recuperee a 3 mois (Y). Le service de biostatistiques fournit 3 000 dossiers. Trois autres variables vivent dans chaque dossier :
Z — severite initiale observee (score clinique pre-operatoire) ;
M — adherence au programme (nombre de seances effectuees, binarisee) ;
le protocole n’est pas assigne au hasard : les cas severes sont orientes plus souvent vers le nouveau protocole, et la severite ralentit aussi la recuperation.
Sur CE cas, la difference entre association et intervention est concrete, pas theorique :
Association : « comparer la mobilite des patients qui ont recu le protocole a celle de ceux qui ne l’ont pas recu ». Les deux groupes different precisement parce que les cas severes sont sur-representes chez les traites — le chiffre melange l’effet du protocole et l’effet de la severite.
Intervention : « administrer le protocole a TOUS les patients, a severite egale, et mesurer l’ecart avec personne ne le recevant ». C’est P(Y | do(T=1)) - P(Y | do(T=0)), une quantite que l’observation seule ne donne jamais sans hypothese.
Pour que la lecon soit verifiable, ce notebook SIMULE les dossiers : le generateur est le monde, donc la verite de l’intervention est accessible par re-simulation — un juge que la vraie vie ne fournit pas, et que DoWhy ne verra jamais : l’outil ne recoit que le tableau de donnees et le graphe que nous lui donnons.
import numpy as npimport pandas as pdrng = np.random.default_rng(7)N =3000# Le monde (verite du generateur, invisible de DoWhy) :# Z (severite) -> T, -> Y confondeur observe# T (protocole) -> M traitement# M (adherence) -> Y mediateurZ = rng.binomial(1, 0.5, N) # severite initiale (0: legere, 1: severe)T = rng.binomial(1, 1/ (1+ np.exp(-(1.2* Z -0.8)))) # assignation depend de la severiteM = rng.binomial(1, 1/ (1+ np.exp(-(1.5* T +0.5)))) # adherence depend du protocoleY =2.0* T +1.5* M +3.0* Z + rng.normal(0, 1, N) # mobilite recupereedossiers = pd.DataFrame({"Z": Z, "T": T, "M": M, "Y": Y})dossiers.head()
Z
T
M
Y
0
1
1
1
6.340874
1
1
0
1
4.648793
2
1
0
0
2.553809
3
0
1
1
3.442216
4
0
1
1
4.489245
2. La verite de l’intervention, par re-simulation
Le generateur permet ce que le terrain interdit : figer T par une intervention (do) et re-simuler le monde. L’effet total du protocole se decompose en :
un effet directT -> Y de 2.0 points de mobilite ;
un effet indirectT -> M -> Y : le protocole pousse l’adherence, l’adherence pousse la recuperation (1.5 points par patient adherent).
La simulation Monte-Carlo a grande echelle donne le juge de paix auquel chaque estimand sera confronte. En pratique ce chiffre n’existe pas — c’est justement pourquoi l’identification (causalite) ne se reduit pas a l’estimation (statistique).
def simuler_monde(t_fixe, n=200_000, seed=999):"""Re-simule le monde avec T IMPOSE par intervention (do).""" g = np.random.default_rng(seed) z = g.binomial(1, 0.5, n) m = g.binomial(1, 1/ (1+ np.exp(-(1.5* t_fixe +0.5))), size=n)return2.0* t_fixe +1.5* m +3.0* z + g.normal(0, 1, n)verite_totale = simuler_monde(1).mean() - simuler_monde(0).mean()print(f"Effet total vrai do(T=1) - do(T=0) : {verite_totale:.3f} points de mobilite")
Effet total vrai do(T=1) - do(T=0) : 2.390 points de mobilite
3. L’association naive — et son biais, chiffre
La difference de moyennes P(Y | T=1) - P(Y | T=0) est ce que produit un groupby sans hypothese causale. Elle repond a une question d’association : « a quoi ressemblent les patients traites, comparés aux autres ? » — pas a la question d’intervention qui interesse l’hopital.
naif = dossiers[dossiers["T"] ==1]["Y"].mean() - dossiers[dossiers["T"] ==0]["Y"].mean()print(f"Association naive P(Y|T=1) - P(Y|T=0) : {naif:.3f}")print(f"Biais de confusion (naif - verite) : {naif - verite_totale:+.3f}")print()print("Le chiffre observe surestime l'effet du protocole : les cas severes (mobilite de base")print("plus faible, recuperation plus lente) sont sur-representes chez les traites... et Z")print("porte +3.0 points de mobilite par unite : la confusion est massive ici.")
Association naive P(Y|T=1) - P(Y|T=0) : 3.274
Biais de confusion (naif - verite) : +0.884
Le chiffre observe surestime l'effet du protocole : les cas severes (mobilite de base
plus faible, recuperation plus lente) sont sur-representes chez les traites... et Z
porte +3.0 points de mobilite par unite : la confusion est massive ici.
4. Le graphe assume — puis l’estimand, nomme
DoWhy ne devine rien : CausalModel recoit le tableau ET un graphe DOT que NOUS ecrivons. Ce graphe est une prise de position, pas un resultat :
il suppose que la severite Z cause a la fois l’assignation T et la recuperation Y (chemin T <- Z -> Y, la porte arriere) ;
il suppose que l’adherence M est un mediateur (T -> M -> Y), pas un confondeur ;
il exclut tout autre chemin de dependence (pas d’arc M -> T, pas de latent commun a T et Y — hypothese forte que la section 7 mettra a l’epreuve).
identify_effect traduit alors le graphe en estimand : la stratégie employée est nommee (backdoor), et la formule d’ajustement est explicite — « DoWhy a renvoye un nombre » n’est pas un estimand.
from dowhy import CausalModelgraphe ='digraph { Z -> T; Z -> Y; T -> M; M -> Y; }'modele = CausalModel( data=dossiers, treatment="T", outcome="Y", graph=graphe,)estimand = modele.identify_effect(proceed_when_unidentifiable=True)print(estimand)print()print("Set d'ajustement backdoor :", estimand.get_backdoor_variables())
Estimand type: EstimandType.NONPARAMETRIC_ATE
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d
────(E[Y|Z])
d[T]
Estimand assumption 1, Unconfoundedness: If U→{T} and U→Y then P(Y|T,Z,U) = P(Y|T,Z)
### Estimand : 2
Estimand name: iv
No such variable(s) found!
### Estimand : 3
Estimand name: frontdoor
Estimand expression:
⎡ d d ⎤
E⎢────(Y)⋅────([M])⎥
⎣d[M] d[T] ⎦
Estimand assumption 1, Full-mediation: M intercepts (blocks) all directed paths from T to Y.
Estimand assumption 2, First-stage-unconfoundedness: If U→{T} and U→{M} then P(M|T,U) = P(M|T)
Estimand assumption 3, Second-stage-unconfoundedness: If U→{M} and U→Y then P(Y|M, T, U) = P(Y|M, T)
### Estimand : 4
Estimand name: general_adjustment
Estimand expression:
d
────(E[Y|Z])
d[T]
Estimand assumption 1, Unconfoundedness: If U→{T} and U→Y then P(Y|T,Z,U) = P(Y|T,Z)
Set d'ajustement backdoor : ['Z']
L’estimand backdoor dit : ajuster sur Z suffit pour fermer toutes les portes arrieres. Le mediateur M, lui, ne doit PAS etre ajuste — ajuster un mediateur amputerait l’effet de sa partie indirecte. Reste a estimer la formule sur les donnees.
effet_backdoor = modele.estimate_effect( estimand, method_name="backdoor.linear_regression",)comparaison = pd.DataFrame({"Quantite": ["Verite (do, par simulation)", "Association naive", "Backdoor ajustee sur Z"],"Estimation": [verite_totale, naif, effet_backdoor.value],})comparaison["Ecart a la verite"] = comparaison["Estimation"] - verite_totalecomparaison.round(3)
Quantite
Estimation
Ecart a la verite
0
Verite (do, par simulation)
2.390
0.000
1
Association naive
3.274
0.884
2
Backdoor ajustee sur Z
2.382
-0.008
5. La sensibilite au graphe — mesuree, pas commentee
Un estimand qui ne bouge pas quand une hypothese causale fausse est retire est suspect ; un qui explose est informatif. Les quatre graphes candidats ci-dessous different d’UNE seule hypothese chacun, et chaque chiffre est compare au juge de la section 2 :
Graphe candidat
Hypothese retenue
A — correct
le graphe du generateur
B — sans Z -> T
« la severite n’influence pas l’assignation » : Z n’est plus un confondeur, aucun ajustement requis
C — sans Z -> Y
« la severite n’influence pas la recuperation » : l’autre nier de la porte arriere
D — fleche inversee
« l’adherence cause l’assignation » (M -> T au lieu de T -> M) : plus AUCUN chemin dirige de T vers Y
variantes = {"A correct": "digraph { Z -> T; Z -> Y; T -> M; M -> Y; }","B sans Z->T": "digraph { Z -> Y; T -> M; M -> Y; }","C sans Z->Y": "digraph { Z -> T; T -> M; M -> Y; }","D fleche inv": "digraph { Z -> T; Z -> Y; M -> T; M -> Y; }",}def set_ajustement(estimand):"""Set backdoor d'un estimand ; distingue les trois cas None."""if"No directed path"instr(estimand):return"effet declare nul"try:returnsorted(estimand.get_backdoor_variables()) or"(aucun)"exceptTypeError: # backdoor_variables est None : aucune porte arriere declareereturn"(aucun)"lignes = []for nom, dot in variantes.items(): m = CausalModel(data=dossiers, treatment="T", outcome="Y", graph=dot) e = m.identify_effect(proceed_when_unidentifiable=True) r = m.estimate_effect(e, method_name="backdoor.linear_regression") lignes.append({"Graphe": nom,"Ajustement": set_ajustement(e),"Estime": round(r.value, 3),"Ecart a la verite": round(r.value - verite_totale, 3), })sensibilite = pd.DataFrame(lignes)sensibilite
Graphe
Ajustement
Estime
Ecart a la verite
0
A correct
[Z]
2.382
-0.008
1
B sans Z->T
(aucun)
2.789
0.400
2
C sans Z->Y
(aucun)
3.274
0.884
3
D fleche inv
effet declare nul
0.000
-2.390
Lecture du tableau :
B declare la porte arriere inexistante : l’estimand n’ajuste plus sur Z, et l’ecart se creuse deja (+0.4) — une partie seulement de la confusion revient, celle que le chemin T <- Z -> Y laisse passer.
C est pire : nier Z -> Y rend l’estimation egale a l’association naive (+0.88 d’ecart) — TOUT le biais de confusion revient, le chiffre « significatif » ne mesure plus que la selection des severes vers le protocole.
D est la catastrophe la plus instructive : inverser la fleche T -> M en M -> T supprime tout chemin dirige de T vers Y — l’identification conclut « effet causal NUL » et l’estime rend exactement 0.000. L’hypothese fausse ne brouille pas le chiffre, elle DECIDE du chiffre : croire que l’adherence cause l’assignation, c’est declarer que le protocole ne peut rien.
Deux signatures chiffrees distinctes : sous-specifier la confusion (B, C) gonfle le chiffre a hauteur du biais laisse passer ; se tromper de SENS pour une seule fleche (D) ecrase l’effet a zero. Aucune des deux ne crie toute seule — c’est la comparaison au graphe correct, et au juge de la section 2, qui les revele.
6. sensibilite causale vs robustesse statistique
La section 5 fait varier le graphe (hypothese causale). DoWhy fournit aussi des refuters statistiques — placebo, ajout d’un confondeur aleatoire, sous-echantillonnage — qui testent la robustesse de l’ESTIMATION aux donnees (le pont Do-Calculus-Bridge les utilise deja comme garde-fou). Les deux familles repondent a des questions differentes, et l’experience ci-dessous le prouve : on passe les TROIS refuters sur le graphe C faux (celui qui nie Z -> Y et rend l’estimation egale au naive biaise de +0.9).
def passer_refuters(modele, estimand, effet, etiquette):print(f"=== {etiquette}")for refuter in ["placebo_treatment_refuter", "random_common_cause","data_subset_refuter"]: rep = modele.refute_estimate(estimand, effet, method_name=refuter, random_seed=7)print(f" {refuter:28s} : {rep.new_effect:+.3f}")# Graphe correct : les refuters confirment la solidite statistique.passer_refuters(modele, estimand, effet_backdoor, "Graphe correct (A)")# Graphe C FAUX : l'estimation vaut le naive (biais +0.88)...mC = CausalModel(data=dossiers, treatment="T", outcome="Y", graph=variantes["C sans Z->Y"])eC = mC.identify_effect(proceed_when_unidentifiable=True)rC = mC.estimate_effect(eC, method_name="backdoor.linear_regression")print(f" estimation sur graphe C : {rC.value:+.3f} (verite {verite_totale:.3f})")# ...et les refuters statistiques ne s'en apercoivent PAS.passer_refuters(mC, eC, rC, "Graphe faux (C) — refuters quand meme verts")
Le graphe C passe ses trois refuters haut la main : placebo proche de zero, estimateur stable au confondeur aleatoire et au sous-echantillonnage. Vert partout — et faux de +0.88. C’est la preuve experimentale que la robustesse statistique ne detecte pas une faute causale : les refuters interrogent les DONNEES, jamais le GRAPHE. Seule la sensibilite de la section 5 (relire une hypothese du graphe et mesurer l’ecart) attaque la bonne couche.
7. Un second cas pour l’exercice : application de revision
Un editeur mesure l’effet d’une application de revision (X) sur le score final (Y). Le terrain (U — motivation, niveau initial) est latent : il cause a la fois l’adoption de l’application et le score, et n’apparait dans AUCUN fichier de donnees. Le temps d’etude (M, heures declarees) est, lui, observe. Le generateur ci-dessous fixe la verite — l’identification de ce cas est l’objet de l’exercice 2 : la porte arriere est fermee (confondeur latent), une autre strategie est necessaire. La demonstration pas-a-pas du frontdoor vit au §5 du pont Do-Calculus-Bridge sur le cas genotype/goudron ; ici, c’est a vous de la reconstruire sur un cas neuf.
Effet total vrai do(X=1) - do(X=0) : 1.800 points
Association naive (mediane split) : 4.234 — biais +2.434
X
M
Y
0
0.496978
0.863256
1.167926
1
0.063231
1.064952
1.033674
2
0.873682
2.021082
2.241138
3
2.991283
3.228775
4.019747
4
-0.131041
-0.002035
-0.991376
8. Exercices
Trois exercices pour que l’exigence d’estimand devienne un reflexe. Chaque stub s’execute sans erreur (print de consigne) — completer, pas remplacer.
Exercice 1 — Le graphe assume
Une chaine de retail hésite entre deux leviers : une campagne publicitaire (P) et une baisse de prix (B) ; la cible est le chiffre de ventes (V). Indices du terrain : les campagnes sont lancees en periode de forte saisonnalite (S, observee), qui porte aussi les ventes ; la campagne augmente la notoriete (N, observee) qui porte les ventes ; le budget promo (B) est decide par les memes equipes qui observent les ventes faiblir. Ecrire le graphe DOT suppose (variables observees seulement), puis lister par ecrit : (a) les chemins de porte arriere vers P, (b) ce que le graphe EXCLUT explicitement, (c) le set d’ajustement attendu.
# Exercice 1 : le graphe assume# Etape 1 : declarer le graphe DOT (variables observees : S, P, B, N, V).# Etape 2 : le passer a CausalModel (treatment='P', outcome='V') et identifier.# Indice : la saisonnalite S est un confondeur observe ; N est un mediateur, pas un confondeur.# Attendu : l'estimand doit NOMMER la strategie et le set d'ajustement.graphe_retail =None# TODO etudiant : "digraph { ... }"print("Exercice a completer : ecrire graphe_retail (DOT), puis CausalModel + identify_effect,")print("et noter (a) les portes arrieres, (b) les exclusions du graphe, (c) le set d'ajustement.")
Exercice a completer : ecrire graphe_retail (DOT), puis CausalModel + identify_effect,
et noter (a) les portes arrieres, (b) les exclusions du graphe, (c) le set d'ajustement.
Exercice 2 — L’estimand (et son NOM)
Sur le cas de la section 7 (application de revision, terrain latent U, mediateur observe M) : construire le CausalModel — le graphe doit declarer U comme [latent] — puis identifier, estimer, et nommer la strategie d’identification employee. Le verifier contre verite_scores : un ecart faible confirme que la strategie ferme la bonne porte.
# Exercice 2 : l'estimand# Etape 1 : graphe DOT avec U [latent] -> {X, Y} ; X -> M -> Y.# Etape 2 : CausalModel(treatment='X', outcome='Y', graph=...) puis identify_effect.# Etape 3 : estimer avec la methode adequate et comparer a verite_scores.# Indice : backdoor est FERME (U latent) — quelle porte reste-t-il ? Le nommer explicitement.print("Exercice a completer : identifier + estimer l'effet de X sur Y, et NOMMER la strategie.")print("Verdict attendu : estimand ~ verite_scores =", round(verite_scores, 3))
Exercice a completer : identifier + estimer l'effet de X sur Y, et NOMMER la strategie.
Verdict attendu : estimand ~ verite_scores = 1.8
Exercice 3 — La réfutation : le tableau de sensibilite
Reprendre le cas principal (sections 1-5) et produire le tableau de sensibilite de VOTRE graphe : au moins trois hypotheses retorques (arêtes retirees ou ajoutees, differentes de B/C/D), chacune avec son estime et son ecart a la verite. Un tableau, pas un commentaire — et une phrase de lecture : quelle hypothese, si elle est fausse, fait le plus mal au chiffre ?
# Exercice 3 : le tableau de sensibilite# Etape 1 : choisir 3 hypotheses a retirer (ex. retirer M -> Y ; ajouter Z -> M ; ajouter T -> Z).# Etape 2 : pour chaque variante, identifier + estimer, collecter (estime, ecart a la verite).# Etape 3 : rendre un DataFrame colonnes = Hypothese | Estime | Ecart a la verite.# Indice : simuler les variants dans un dict {nom: dot} comme la section 5.lignes_ex3 = [] # TODO etudiant : remplir avec les trois variantes choisiesprint("Exercice a completer : produire le tableau (DataFrame) de sensibilite a 3+ hypotheses,")print("puis une phrase : quelle hypothese fausse degrade le plus l'estimation ?")
Exercice a completer : produire le tableau (DataFrame) de sensibilite a 3+ hypotheses,
puis une phrase : quelle hypothese fausse degrade le plus l'estimation ?
9. Synthese
Question
Reponse du notebook
Ou
Association ou intervention ?
P(Y\|T=1)-P(Y\|T=0) melange severite et protocole ; seul do(T) isole le protocole — sur CE cas, +0.9 d’ecart
sections 2-3
Quel estimand ?
backdoor, set {Z}, NOMME par identify_effect — et le mediateur M explicitement NON ajuste
section 4
Le graphe est-il solide ?
sensibilite mesuree : nier Z -> Y ramene tout le biais de confusion (+0.88) ; traiter M comme confondeur ecrase l’effet (-2.4)
section 5
Et la variance ?
refuters statistiques (placebo, subset) — robustesse des donnees, qui ne protege PAS d’un graphe faux
section 6
Porte fermee ?
frontdoor via le mediateur observe, quand le confondeur est latent
exercice 2
L’estimand n’est pas une formalite avant le chiffre : c’est le contrat qui dit quelle quantite le chiffre estime, sous quelles hypotheses, et lesquelles de ces hypotheses — si elles sautent — emportent la conclusion. Exiger l’estimand, c’est exiger que ces trois reponses soient ecrites AVANT que le nombre n’ait l’air d’etre une preuve.