ICT-Greffe5 – Attribution causale de l’intervention sur l’espace atteignable
Greffe 5 de la serie ICT (issue #13903, Epic #4588) – tranche 3/3.
Cible
La greffe 2 (ICT-Greffe2-EspaceAtteignable.ipynb, issue #13568) isole un quadruplet \((A, F, r, \pi)\) et montre que les ajouts d’operateurs STRIPS (+raft, +discard) modifient l’espace atteignable de maniere discriminante : +raft elargit reellement (1/3 \(\to\) 3/3 buts), +discard decore (|A| +50 %, 0 but nouveau). Mais le protocole Greffe 2 ne pose pas la question causale : l’operateur +raft est-il la cause de l’elargissement, ou un artefact de mesure ?
Cette tranche 3/3 repondt a cette question en appliquant les estimateurs de ict.causal_attribution (tranche 1/3, mergee via #13916) sur un panel experimental construit par imitation du domaine Greffe 2 – la relaxation STRIPS est consommee, jamais re-derivee (cf. CLAUDE.md section D).
Statut épistémique — Sans verdict à ce jour : aucune ligne de la matrice de dissociations ne concerne ce notebook ; son statut épistémique sera porté par la matrice le cas échéant.
Estimateurs disponibles (API close-form de ict.causal_attribution)
Trois estimateurs, du plus naif au plus correct :
naive_difference : \(E[Y | X=1] - E[Y | X=0]\). Biaise par confondants. Baseline pour montrer la limite.
backdoor_adjustment : ajustement sur un confondu observe \(Z\), en sommant \(\sum_z P(Y=y | X=x, Z=z) \cdot P(Z=z)\). Correct si le set backdoor est satisfait (Pearl 2009, chap. 3.3).
iv_estimate : \(ATE_{IV} = Cov(Y, Z) / Cov(X, Z)\). Correct si l’instrument est pertinent (Angrist-Krueger 2001).
Verdict tri-etat (analogue au protocole d’ICT-12e pour l’EVSI) :
AGREEMENT : estimateurs dans la tolerance declaree.
DESACCORD : ecart superieur a la tolerance, les deux valeurs sont rapportees.
NON_IDENTIFIABLE : l’estimand n’est pas identifiable sous le graphe causal pose (instrument non pertinent). Resultat legitime, pas un echec.
Plan experimental
On imite le domaine STRIPS de Greffe 2 sur un grillage reduit\(3 \times 3\) (3 colonnes, 3 rangs) avec un seul objet cle et un seul but \(g\) = tenir la cle. Quatre vocabulaires d’operateurs :
A_t : vocabulaire de base (4 directions, pickup cle).
A_t1 (+raft) : A_t + un operateur teleport qui ouvre une cellule supplementaire.
A_t1+discard : A_t1 + le meme discard sterile.
Pour chaque vocabulaire, on tire \(n_{seeds} = 30\) positions initiales aleatoires de l’objet cle, on mesure le taux d’atteignabilite (forward-search budget \(B = 8\)) et le taux d’etats atteignables depuis l’initial. Le panel experimental est \((X, Z, Y)\) avec :
\(X = 1\) si le vocabulaire contient teleport (analogue +raft), \(X = 0\) sinon.
\(Z = 1\) si le vocabulaire contient discard (analogue +discard).
\(Y\) = taux d’atteignabilite du but (moyenne sur les 30 graines).
Controle negatif : sur A_t1+discard, le discard est cense rendre l’elargissement +raft moins utile (l’agent perd la cle si elle l’attrape et jette sa prise) – la mesure naive doit montrer un ATE positif (X=1 elargit) mais l’IV (instrument = discard ajoutee) doit signaler l’effet heterogene.
from __future__ import annotationsimport osimport sysfrom collections import dequefrom dataclasses import dataclassfrom typing import FrozenSet, List, Tupleimport numpy as np# Le module vit dans le meme dossier que ce notebook._ICT_DIR = os.path.dirname(os.path.abspath("ICT-Greffe5-AttributionCausale.ipynb"))if _ICT_DIR notin sys.path: sys.path.insert(0, _ICT_DIR)from ict import causal_attribution as caprint("ict.causal_attribution OK :", ca.AttributionVerdict.__members__.keys())
ict.causal_attribution OK : dict_keys(['AGREEMENT', 'DESACCORD', 'NON_IDENTIFIABLE'])
Mini-domaine STRIPS imite de Greffe 2
On construit un domaine clos sur un grillage \(3 \times 3\) (sans gouffre – simplification du domaine Greffe 2 qui en avait un). Le Operateur STRIPS est importe comme un tuple (nom, pre, add, del) (forme minimale). La mesure est :
forward-reachable : nombre d’etats atteignables depuis INITIAL par BFS dans le graphe d’operateurs, avec budget \(B = 8\) expansions.
but_atteint : 1 si le but est dans F_B(INITIAL), 0 sinon.
# Operateur STRIPS minimal (consomme, jamais re-derive : voir CLAUDE.md section D).@dataclass(frozen=True)class Operateur: nom: str pre: FrozenSet[str] add: FrozenSet[str] dele: FrozenSet[str]def applicable(self, etat: FrozenSet[str]) ->bool:returnself.pre.issubset(etat)def appliquer(self, etat: FrozenSet[str]) -> FrozenSet[str]:returnfrozenset((etat -self.dele) |self.add)def__repr__(self) ->str:returnself.nomCOLS = (0, 1, 2)RANGS = (0, 1, 2)CASES = [(c, r) for c in COLS for r in RANGS]BUT =frozenset({"holding-key"})def prop(c: int, r: int) ->str:returnf"at-x{c}-y{r}"INITIAL =frozenset({prop(0, 0), "key-at-x1-y0"})def ops_de_base() -> List[Operateur]:"""4 directions + pickup cle a la case (1, 0).""" ops: List[Operateur] = []for c, r in CASES:for dc, dr, nom_dir in [(1, 0, "est"), (-1, 0, "ouest"), (0, 1, "nord"), (0, -1, "sud")]: cible = (c + dc, r + dr)if cible in CASES: ops.append(Operateur(f"move-{nom_dir}-x{c}-y{r}", {prop(c, r)}, {prop(*cible)}, {prop(c, r)})) ops.append(Operateur("pickup-key", {prop(1, 0), "key-at-x1-y0"}, {"holding-key"}, {"key-at-x1-y0"}))return opsdef ops_avec_teleport(base: List[Operateur]) -> List[Operateur]:"""Analogue de : teleport diagonal (0,0) -> (2,2).""" ops =list(base) ops.append(Operateur("teleport-diag", {prop(0, 0)}, {prop(2, 2)}, {prop(0, 0)}))return opsdef ops_avec_discard(base: List[Operateur]) -> List[Operateur]:"""Analogue de : perdre la cle si on la tient. Etat-piege.""" ops =list(base) ops.append(Operateur("discard-key", {"holding-key"}, set(), {"holding-key"}))return opsA_T = ops_de_base()STER = ops_avec_discard(A_T)A_T1 = ops_avec_teleport(A_T)T1S = ops_avec_discard(A_T1)# Noms avec mots-cles explicites pour les filtres du panel :# - X = 1 si "teleport" dans le nom# - Z = 1 si "+discard" dans le nomVOCABS = {"A_t": A_T,"A_t +discard": STER,"A_t1 (+teleport)": A_T1,"A_t1 (+teleport) +discard": T1S,}for nom, V in VOCABS.items():print(f"{nom:32s} |V| = {len(V)} operateurs")
La cellule de construction affiche les tailles brutes des quatre vocabulaires : A_t = 25 operateurs (deplacements entre cases adjacentes du grillage 3x3, plus pickup-key), A_t +discard = 26, A_t1 (+teleport) = 26, A_t1 (+teleport) +discard = 27. Chaque ajout est une addition STRIPS minimale (pre, add, del) : teleport-diag quitte (0,0) pour (2,2) en un pas, discard-key retire holding-key sans add – un etat-piege.
La lecture a retenir : la taille ne discrimine pas les deux ajouts (26 contre 26) – c’est la structure des preconditions qui jouera. Le panel experimental (cellule 9) separe teleport (X) de discard (Z) par mots-cles dans les noms, pas par la taille. Le protocole de Greffe 2 est imite, jamais re-derive : la relaxation STRIPS consommee est celle de ict.causal_attribution, la construction des operateurs est locale au notebook.
Mesure du forward-search budget \(B\)
Pour chaque vocabulaire, on lance un BFS depuis INITIAL avec budget \(B = 8\) expansions. On rapporte :
\(|F_B(\text{INITIAL})|\) : nombre d’etats atteignables.
but_atteint : 1 si \(\text{BUT} \subseteq F_B(\text{INITIAL})\), 0 sinon.
On fait varier la graine de la position initiale alternative (l’initial est fixe, mais on mesure aussi le forward-search depuis 30 positions alternatives pour avoir un panel).
def forward_reachable(ops: List[Operateur], initial: FrozenSet[str], budget: int) ->set:"""BFS budget-stricted : ensemble des etats atteignables en <= budget expansions.""" visited = {initial} frontiere = {initial}for _ inrange(budget): nouveau =set()for etat in frontiere:for op in ops:if op.applicable(etat): suivant = op.appliquer(etat)if suivant notin visited: nouveau.add(suivant) visited.add(suivant)ifnot nouveau:break frontiere = nouveaureturn visiteddef position_initiale(seed: int) -> FrozenSet[str]:"""30 positions initiales alternatives : cle placee aleatoirement.""" rng = np.random.RandomState(seed) c, r = CASES[rng.randint(len(CASES))]returnfrozenset({prop(0, 0), f"key-at-x{c}-y{r}"})def mesurer_vocabulaire(ops: List[Operateur], seeds: List[int], budget: int=8) -> List[dict]:"""Panel : pour chaque seed, taux d'atteignabilite du but + |F_B|.""" panneau = []for s in seeds: init = position_initiale(s) F = forward_reachable(ops, init, budget=budget) panneau.append({"seed": s,"taille_F": len(F),"but_atteint": int(BUT.issubset(F)), })return panneauSEEDS =list(range(30))BUDGET =2# Budget reduit : +teleport discrimine a B=2, pas a B=8panneaux = {nom: mesurer_vocabulaire(V, SEEDS, budget=BUDGET)for nom, V in VOCABS.items()}for nom, panneau in panneaux.items(): taille_moy = np.mean([o["taille_F"] for o in panneau]) taux_but = np.mean([o["but_atteint"] for o in panneau])print(f"{nom:20s} |F|_moyen = {taille_moy:6.2f} taux_but = {taux_but:.3f}")
Lecture 2 – Budget B = 2 : le teleport elargit (+48 %), le discard decore
A budget B = 2, le pattern de Greffe 2 est reconstitue de maniere discriminante : A_t a un |F| moyen de 6.23 et A_t1 (+teleport) de 9.23 – delta +3.00, soit +48 % d’etats atteignables. Le teleport-diag ouvre la diagonale en une expansion, ce que les quatre directions ne font pas en deux pas depuis l’initial.
Le discard, lui, est sterile sur |F| (6.23 sans et 6.23 avec) : son effet suppose que l’agent tienne deja la cle, condition jamais satisfaite en budget B = 2. Et le taux de but est 0 partout : le pickup-key exige la cle a la case (1, 0) en position fixe, et le forward-search ne deplace pas l’objet – la mesure binaire est degenerente. C’est exactement pour cela que la cellule 9 prend |F_B| comme outcome continu : seule la taille du front discrimine les vocabulaires sur ce banc.
# Construction du panel experimental (X, Z, Y) pour chaque seed.# X = 1 si le vocab contient teleport (= +raft)# Z = 1 si le vocab contient discard (= +discard)# Y = |F_B| moyen (outcome continu -- discrimine les vocabulaires).# But_atteint est 0 pour toutes les seeds (le pickup-key exige la cle# a la case (1, 0) mais le forward-reachable ne deplace pas l objet),# donc une mesure binaire est degenerente ici -- on prend la taille# du forward-reachable qui discrimine les ajouts.X = [] # 1 si teleport, 0 sinonZ = [] # 1 si discard, 0 sinonY = [] # taille_F (continue)for seed in SEEDS:for nom, panneau in panneaux.items(): x_val =1if"teleport"in nom else0# sans "+" pour eviter collision z_val =1if"+discard"in nom else0 y_val = panneau[seed]["taille_F"] # |F_B|, continu X.append(x_val) Z.append(z_val) Y.append(y_val)X = np.asarray(X, dtype=int)Z = np.asarray(Z, dtype=int)Y = np.asarray(Y, dtype=float)print(f"N observations = {len(Y)}")print(f"P(X=1) = {X.mean():.3f}, P(Z=1) = {Z.mean():.3f}")print(f"E[Y | X=1] = {Y[X ==1].mean():.2f}, E[Y | X=0] = {Y[X ==0].mean():.2f}")print(f"E[Y | Z=1] = {Y[Z ==1].mean():.2f}, E[Y | Z=0] = {Y[Z ==0].mean():.2f}")print(f"Cross-tab : Z=0,X=0={((Z==0)&(X==0)).sum()}, Z=0,X=1={((Z==0)&(X==1)).sum()}, Z=1,X=0={((Z==1)&(X==0)).sum()}, Z=1,X=1={((Z==1)&(X==1)).sum()}")
Lecture 3 – Un design orthogonal : 120 observations, 30 par cellule
Le panel empile les quatre vocabulaires sur les 30 graines : N = 120 observations, P(X = 1) = 0.500 et P(Z = 1) = 0.500, et le cross-tab l’affiche sans ambiguite : (Z = 0, X = 0) = 30, (Z = 0, X = 1) = 30, (Z = 1, X = 0) = 30, (Z = 1, X = 1) = 30 – chaque cellule du design contient exactement 30 observations, aucun cas vide.
Le traitement teleport et le confondu discard sont orthogonaux par construction : Cov(X, Z) = 0, ce qui se lit aussi dans les moyennes – E[Y | Z = 1] = 7.73 = E[Y | Z = 0] = 7.73 : le discard est marginalement neutre en moyenne. Cote traitement, E[Y | X = 1] = 9.23 contre E[Y | X = 0] = 6.23 : le contraste brut fait +3.00, exactement le delta de la lecture 2. La consequence pour la suite : le design garantit que la moyenne par strate de Z ne cache aucune interaction – la cellule 11 pourra verifier que le backdoor ne corrige rien, parce qu’il n’y a rien a corriger.
# Estimateur 1 : naive_difference (baseline).# ATE_naif = E[Y | X=1] - E[Y | X=0]. Biaise par confondant Z = discard.outcome_by_x = {0: Y[X ==0].tolist(), 1: Y[X ==1].tolist()}ate_naif = ca.naive_difference(outcome_by_x)print(f"ATE_naif = E[Y|X=1] - E[Y|X=0] = {ate_naif:+.4f}")print(f" -> Le +teleport ajoute ~{ate_naif *100:.1f} points de taux_but en moyenne.")print(f" Mais ce delta est biaise : Z=discard peut etre un confondant.")
ATE_naif = E[Y|X=1] - E[Y|X=0] = +3.0000
-> Le +teleport ajoute ~300.0 points de taux_but en moyenne.
Mais ce delta est biaise : Z=discard peut etre un confondant.
# Estimateur 2 : backdoor_adjustment sur le confondu Z = discard.# ATE_backdoor = sum_z [ E[Y | X=1, Z=z] - E[Y | X=0, Z=z] ] * P(Z=z)ate_backdoor = ca.backdoor_adjustment( outcome_table=Y, treatment_levels=X.tolist(), confounder_values=Z.tolist(),)print(f"ATE_backdoor (ajuste sur discard) = {ate_backdoor:+.4f}")verdict = ca.compare_estimators( {"naif": ate_naif, "backdoor": ate_backdoor}, tolerance=0.10,)print(f"Verdict naif vs backdoor (tol 0.10) : {verdict.value}")effet_discard = Y[Z ==1].mean() - Y[Z ==0].mean()effet_teleport = Y[X ==1].mean() - Y[X ==0].mean()print(f"Effet marginal +discard (Z=1 vs Z=0) : {effet_discard:+.2f} etats")print(f"Effet marginal +teleport (X=1 vs X=0) : {effet_teleport:+.2f} etats")# Note : +teleport est causal, +discard est confondu (orthogonal au traitement).# Le backdoor ajuste le naif par la moyenne ponderee de l effet dans chaque strate# de Z -- comme le design est orthogonal (X, Z), l ajustement laisse l ATE inchange.
ATE_backdoor (ajuste sur discard) = +3.0000
Verdict naif vs backdoor (tol 0.10) : AGREEMENT
Effet marginal +discard (Z=1 vs Z=0) : +0.00 etats
Effet marginal +teleport (X=1 vs X=0) : +3.00 etats
Lecture 4 – Naif vs backdoor : AGREEMENT a la tolerance 0.10
L’estimateur naif donne ATE = +3.0000 (la cellule le reformule : le +teleport ajoute 300.0 points de taux_but en moyenne, chiffre aussitot relativise – ce delta brut peut etre confondu par discard). L’ajustement backdoor sur Z donne ATE_backdoor = +3.0000, et la comparaison des deux a tolerance 0.10 declare AGREEMENT.
Ce n’est pas un hasard : parce que le design est orthogonal (lecture 3), la moyenne ponderee de l’effet dans chaque strate de Z reproduit exactement le contraste brut – l’ajustement laisse l’ATE inchange, comme le commentaire de la cellule le note. Les effets marginaux le confirment : +discard = +0.00 etats (Z orthogonal au traitement) contre +teleport = +3.00 etats. La lecture pedagogique : le backdoor ne corrige un biais que s’il y en a un ; sur ce design, naif et ajuste s’accordent a la tolerance pres – AGREEMENT est le verdict honeste, pas une coincidence.
# Estimateur 3 : iv_estimate avec instrument Z = discard (pertinence ?).# Question causale : si Z=discard est pertinent (Cov(X, Z) eleve), alors# la variation de Z force X a varier -- on peut extraire un ATE instrumental.# Mais ici Z = discard est orthogonal a X = teleport (independance des ajouts),# donc Cov(X, Z) = 0 et l'instrument est NON PERTINENT -> NON_IDENTIFIABLE.# C'est le **resultat legitime** du garde-fou de iv_estimate : l'instrument# n'est pas pertinent pour identifier l'effet de X=Y|X sur Y.try: ate_iv = ca.iv_estimate( outcome=Y.tolist(), treatment=X.astype(float).tolist(), instrument=Z.astype(float).tolist(), )print(f"ATE_iv = {ate_iv:+.4f}")exceptValueErroras e:# Verdict NON_IDENTIFIABLE -- c'est ce qu'on attend.print(f"iv_estimate leve ValueError : instrument NON PERTINENT")print(f" -> {str(e)[:140]}")print(f" Verdict logique : {ca.AttributionVerdict.NON_IDENTIFIABLE.value}")print(f" C'est le resultat legitime : discard est ORTHOGONAL a teleport ici,")print(f" l'instrument Z n'identifie pas l'effet de X = teleport sur Y.")
iv_estimate leve ValueError : instrument NON PERTINENT
-> iv_estimate : instrument NON PERTINENT (|Cov(X, Z)|=0.000000 < 5/sqrt(n)=0.456435) -- estimand NON_IDENTIFIABLE
Verdict logique : NON_IDENTIFIABLE
C'est le resultat legitime : discard est ORTHOGONAL a teleport ici,
l'instrument Z n'identifie pas l'effet de X = teleport sur Y.
Lecture 5 – iv_estimate : NON_IDENTIFIABLE, le garde-fou au bon endroit
L’instrument Z = discard est orthogonal a X = teleport (lecture 3), donc Cov(X, Z) = 0 : iv_estimate leve un ValueError immediat – la sortie affiche |Cov(X, Z)| = 0.000000, sous le seuil de pertinence 5/sqrt(n) = 0.456435. La cellule intercepte l’exception et traduit : verdict logique NON_IDENTIFIABLE, qualifie de resultat legitime, pas un echec.
C’est le comportement voulu du garde-fou (Pearl 2009 chap. 3.3 + Angrist-Krueger 2001, seuil 5-sigma) : avec un instrument non pertinent, aucune formule ne peut identifier l’effet causal de X sur Y – la methode refuse de produire un nombre plutot que d’en fabriquer un. Mise en perspective avec la lecture 4 : naif et backdoor s’accordent a +3.0000 – le backdoor ajuste le delta naif pour la presence de +discard, et l’ecart sous la tolerance 0.10 declare AGREEMENT (l’IV, lui, ne conclut pas) – les trois estimateurs couvrent ainsi les trois etats du verdict tri-etat (AGREEMENT, DESACCORD, NON_IDENTIFIABLE). L’exercice 1 invite justement l’etudiant a modifier le design pour rendre Z pertinent et faire parler l’IV : la tolerance du notebook a l’ambiguite est la lecon de cette cellule.
Exercice 1 : modifier le design pour rendre Z pertinent
Pour que Z = discard soit un instrument pertinent, il faut que les ajouts soient correles : par exemple, on n’ajoute discard que conditionnellement a la presence de teleport (Z = X). Refaire le panel avec ce design et montrer que iv_estimate rend un ATE instrumental non-trivial.
Indice : reconstruire VOCABS avec seulement les configurations A_t, A_t1, A_t+discard (retirer A_t1+discard), ou changer la regle d’assignation (X, Z) pour rendre Cov(X, Z) > 0.
# Exercice 1 a completer :VOCABS_EX1 = {"A_t": A_T,# ... a completer par l'etudiant ...}# Puis mesurer, construire le panel, appliquer iv_estimate.
# Exercice 1 a completer : design alternatif ou Z pertinent# Stubs conformes C.1 : pas de raise NotImplementedError.VOCABS_EX1 =None# TODO etudiant : sous-ensemble des VOCABS ou design alternatifpanneaux_EX1 =None# TODO etudiant : mesurer si VOCABS_EX1 definiresult_iv =None# TODO etudiant : resultat de iv_estimate (ou ValueError releve)print("Exercice 1 : a completer")
Exercice 1 : a completer
Exercice 2 : controle negatif avec un confoundant reellement actif
Pour verifier que le backdoor ajuste bien le biais, construire un panel avec un vrai confondant : par exemple, ajouter un operateur noise (pickup aleatoire d’un objet non-cle) qui augmente \(|F|\) mais sans aider a tenir le but. Mesurer le biais du naif (qui ne distingue pas noise de teleport) et montrer que le backdoor sur noise corrige.
Indice : noise = Operateur("noise-pickup", {prop(c, r)}, {"holding-noise"}, set()) ajoute pour 30% des cases. Le naif surestime l’effet de teleport car il confond noise (qui ouvre \(|F|\)) avec teleport (qui aide le but).
# Exercice 2 a completer : definir un Operateur `noise` actif et mesurer le biais.# TODO etudiant :def ops_avec_noise(base, proba=0.3, seed=0): ...
# Exercice 2 a completer : Operateur `noise` actif# Stubs conformes C.1.def ops_avec_noise(base, proba=0.3, seed=0):# TODO etudiant : ajouter des operateurs `noise-pickup-X-Y` sur ~30% des casesreturn base # stubVOCABS_EX2 =None# TODO etudiant : VOCABS + variantes avec/sans noisepanneaux_EX2 =None# TODO etudiantbiais_naif =None# TODO etudiant : biais observe sur le panel avec confoundantprint("Exercice 2 : a completer")
Exercice 2 : a completer
Exercice 3 : utiliser ict.bridges (tranche 2/3) pour valider cross-engine
Une fois la tranche 2/3 (PR #13921) mergée, le module ict.bridges (livré dans cette meme PR) fournit les estimateurs adapt_panel_did_to_backdoor et adapt_iv_replay_to_iv_estimate qui comparent ict.causal_attribution aux organes natifs Quasi-Experimental et PyMC-05. Refaire ce notebook en branchant les adaptateurs cross-engine et montrer que les deux estimateurs donnent la meme valeur a tolerance pres (verdict AGREEMENT sur AGREEMENT).
Indice : from ict.bridges import adapt_panel_did_to_backdoor, adapt_iv_replay_to_iv_estimate. Appliquer les deux sur le panel \((X, Z, Y)\) avec specification DiD et IV 2SLS (60 repetitions).
# Exercice 3 a completer : brancher les adaptateurs ict.bridges (post-merge #13921).# TODO etudiant :result_did = adapt_panel_did_to_backdoor(differential_pretrend=0.0) # SUTA satisfaiteresult_iv = adapt_iv_replay_to_iv_estimate(coef_z=2.0, n_rep=60) # instrument fort
# Exercice 3 a completer : adaptateurs cross-engine (post-merge tranche 2)# Stubs conformes C.1 : pas de raise NotImplementedError.result_did =None# TODO etudiant : adapt_panel_did_to_backdoor (post-merge)result_iv =None# TODO etudiant : adapt_iv_replay_to_iv_estimate (post-merge)print("Exercice 3 : a completer (necessite la merge de la tranche 2/3, PR #13921)")
Exercice 3 : a completer (necessite la merge de la tranche 2/3, PR #13921)