From c1afb9d47671c73a3870d3c767f24f425e5f42f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 14:16:24 +0000 Subject: [PATCH] fix: preserve mediation confounders and no_directed_path in IdentifiedEstimand.__deepcopy__ The __deepcopy__ method of IdentifiedEstimand was missing three fields: - mediation_first_stage_confounders - mediation_second_stage_confounders - no_directed_path These are set during NDE/NIE identification. The bug caused them to be lost when estimands are deep-copied (e.g. in refuters, TwoStageRegressionEstimator), which could silently produce incorrect results in mediation analysis. Adds two regression tests for the fixed behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: github-actions[bot] --- .../causal_identifier/identified_estimand.py | 3 ++ .../test_auto_identifier.py | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/dowhy/causal_identifier/identified_estimand.py b/dowhy/causal_identifier/identified_estimand.py index 84bd1d8b67..b3873b72f7 100644 --- a/dowhy/causal_identifier/identified_estimand.py +++ b/dowhy/causal_identifier/identified_estimand.py @@ -114,9 +114,12 @@ def __deepcopy__(self, memo): instrumental_variables=copy.deepcopy(self.instrumental_variables), frontdoor_variables=copy.deepcopy(self.frontdoor_variables), mediator_variables=copy.deepcopy(self.mediator_variables), + mediation_first_stage_confounders=copy.deepcopy(self.mediation_first_stage_confounders), + mediation_second_stage_confounders=copy.deepcopy(self.mediation_second_stage_confounders), default_backdoor_id=copy.deepcopy(self.default_backdoor_id), default_adjustment_set_id=copy.deepcopy(self.default_adjustment_set_id), identifier_method=copy.deepcopy(self.identifier_method), + no_directed_path=self.no_directed_path, ) def __str__(self, only_target_estimand: bool = False, show_all_backdoor_sets: bool = False): diff --git a/tests/causal_identifiers/test_auto_identifier.py b/tests/causal_identifiers/test_auto_identifier.py index 51c04daa6e..bf8b4b17fc 100644 --- a/tests/causal_identifiers/test_auto_identifier.py +++ b/tests/causal_identifiers/test_auto_identifier.py @@ -1,3 +1,5 @@ +import copy + from dowhy.causal_identifier import AutoIdentifier from dowhy.causal_identifier.identify_effect import EstimandType from dowhy.graph import build_graph_from_str @@ -15,3 +17,33 @@ def test_auto_identify_identifies_no_directed_path(self): assert identifier.identify_effect( graph, action_nodes=["B", "T"], outcome_nodes=["Y"], observed_nodes=["T", "Y", "A", "B"] ).no_directed_path + + def test_deepcopy_preserves_all_fields(self): + """Regression test: __deepcopy__ must copy mediation confounders and no_directed_path.""" + # Build NDE estimand with mediation confounders + graph = build_graph_from_str("digraph{T->M;T->Y;M->Y;W->T;W->Y;}") + identifier = AutoIdentifier(estimand_type=EstimandType.NONPARAMETRIC_NDE) + estimand = identifier.identify_effect( + graph, + action_nodes=["T"], + outcome_nodes=["Y"], + observed_nodes=["T", "M", "Y", "W"], + ) + estimand_copy = copy.deepcopy(estimand) + + assert estimand_copy.mediator_variables == estimand.mediator_variables + assert estimand_copy.mediation_first_stage_confounders == estimand.mediation_first_stage_confounders + assert estimand_copy.mediation_second_stage_confounders == estimand.mediation_second_stage_confounders + assert estimand_copy.no_directed_path == estimand.no_directed_path + + def test_deepcopy_preserves_no_directed_path_flag(self): + """Regression test: deepcopy of an estimand with no directed path preserves the flag.""" + graph = build_graph_from_str("digraph{T->Y;A->Y;A->B;}") + identifier = AutoIdentifier(estimand_type=EstimandType.NONPARAMETRIC_ATE) + estimand = identifier.identify_effect( + graph, action_nodes=["T", "B"], outcome_nodes=["Y"], observed_nodes=["T", "Y", "A", "B"] + ) + assert estimand.no_directed_path + + estimand_copy = copy.deepcopy(estimand) + assert estimand_copy.no_directed_path