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 :

  1. Quel graphe supposons-nous — et que suppose-t-il, qu’exclut-il ?
  2. Quel estimand en decoule — backdoor, frontdoor, variable instrumentale — nomme, pas implicite ?
  3. 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 np
import pandas as pd

rng = 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           mediateur
Z = 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 severite
M = rng.binomial(1, 1 / (1 + np.exp(-(1.5 * T + 0.5))))  # adherence depend du protocole
Y = 2.0 * T + 1.5 * M + 3.0 * Z + rng.normal(0, 1, N)    # mobilite recuperee

dossiers = 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 direct T -> Y de 2.0 points de mobilite ;
  • un effet indirect T -> 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)
    return 2.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 CausalModel

graphe = '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_totale
comparaison.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" in str(estimand):
        return "effet declare nul"
    try:
        return sorted(estimand.get_backdoor_variables()) or "(aucun)"
    except TypeError:  # backdoor_variables est None : aucune porte arriere declaree
        return "(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")
=== Graphe correct (A)
  placebo_treatment_refuter    : +0.005
  random_common_cause          : +2.382
  data_subset_refuter          : +2.384
  estimation sur graphe C           : +3.274 (verite 2.390)
=== Graphe faux (C) — refuters quand meme verts
  placebo_treatment_refuter    : -0.002
  random_common_cause          : +3.274
  data_subset_refuter          : +3.275

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.

rng2 = np.random.default_rng(42)
N2 = 4000
U2 = rng2.normal(0, 1, N2)                    # terrain latent (NON observe)
X2 = 0.8 * U2 + rng2.normal(0, 1, N2)         # usage de l'application
M2 = 1.2 * X2 + rng2.normal(0, 0.8, N2)       # heures d'etude (mediateur observe)
Y2 = 1.5 * M2 + 0.6 * U2 + rng2.normal(0, 1, N2)  # score final

scores = pd.DataFrame({"X": X2, "M": M2, "Y": Y2})   # U volontairement absent

def simuler_terrain(x_fixe, n=200_000, seed=1234):
    g = np.random.default_rng(seed)
    u = g.normal(0, 1, n)
    m = 1.2 * x_fixe + g.normal(0, 0.8, n)
    return 1.5 * m + 0.6 * u + g.normal(0, 1, n)

verite_scores = simuler_terrain(1.0).mean() - simuler_terrain(0.0).mean()
naif_scores = scores[scores["X"] > scores["X"].median()]["Y"].mean() - \
              scores[scores["X"] <= scores["X"].median()]["Y"].mean()
print(f"Effet total vrai  do(X=1) - do(X=0)   : {verite_scores:.3f} points")
print(f"Association naive (mediane split)    : {naif_scores:.3f} — biais {naif_scores - verite_scores:+.3f}")
scores.head()
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 choisies

print("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.

Retour au sommet