Skip to content

Commit bcb5427

Browse files
committed
Removed MetamorphicRelation class - now generating CausalTest objects directly
1 parent 175acda commit bcb5427

15 files changed

Lines changed: 270 additions & 160 deletions

causal_testing/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def main() -> None:
192192
skip=False,
193193
)
194194
with open(args.output, "w", encoding="utf-8") as f:
195-
json.dump({"tests": [test.to_json() for test in causal_tests]}, f)
195+
json.dump({"tests": [test.to_dict() for test in causal_tests]}, f)
196196
logging.info("Causal test generation completed successfully.")
197197

198198
case Command.DISCOVER:

causal_testing/estimation/abstract_estimator.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ def __init__(
3232
# pylint: disable=R0801
3333
self,
3434
base_test_case: BaseTestCase,
35-
treatment_value: float,
36-
control_value: float,
37-
adjustment_set: set,
35+
control_value: float = None,
36+
treatment_value: float = None,
37+
adjustment_set: set = None,
3838
effect_modifiers: dict[str, Any] = None,
3939
alpha: float = 0.05,
4040
):
@@ -59,13 +59,17 @@ def add_modelling_assumptions(self):
5959
must hold if the resulting causal inference is to be considered valid.
6060
"""
6161

62-
def to_json(self) -> dict:
62+
def to_dict(self) -> dict:
6363
"""
64-
Convert to a JSON serialisable dict object containing all non-standard parameters to reconstruct.
64+
Convert the estimator to a python dictionary for easy serialisation as JSON or CSV.
6565
66-
:returns: A JSON serialisable dict representing the object.
66+
:returns: A JSON serialisable dict representing the estimator.
6767
"""
68-
result = {"estimator": self.__class__.__name__, "estimator_kwargs": {}}
68+
result = {"name": self.__class__.__name__, "alpha": self.alpha, "adjustment_set": sorted(self.adjustment_set)}
6969
if self.effect_modifiers:
70-
result["estimator_kwargs"]["effect_modifiers"] = self.effect_modifiers
70+
result["effect_modifiers"] = self.effect_modifiers
71+
if self.control_value is not None:
72+
result["control_value"] = self.control_value
73+
if self.treatment_value is not None:
74+
result["treatment_value"] = self.treatment_value
7175
return result

causal_testing/estimation/abstract_regression_estimator.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ def __init__(
2424
# pylint: disable=too-many-arguments
2525
self,
2626
base_test_case: BaseTestCase,
27-
treatment_value: float = None,
2827
control_value: float = None,
28+
treatment_value: float = None,
2929
adjustment_set: set = None,
3030
effect_modifiers: dict[Variable, Any] = None,
3131
adjustment_config: dict[Variable, Any] = None,
@@ -35,8 +35,8 @@ def __init__(
3535
# pylint: disable=R0801
3636
super().__init__(
3737
base_test_case=base_test_case,
38-
treatment_value=treatment_value,
3938
control_value=control_value,
39+
treatment_value=treatment_value,
4040
adjustment_set=adjustment_set,
4141
effect_modifiers=effect_modifiers,
4242
alpha=alpha,
@@ -144,3 +144,16 @@ def _predict(self, df) -> pd.DataFrame:
144144
x = pd.get_dummies(x, columns=[col], drop_first=True)
145145

146146
return model.get_prediction(x).summary_frame()
147+
148+
def to_dict(self) -> dict:
149+
"""
150+
Convert the estimator to a python dictionary for easy serialisation as JSON or CSV.
151+
152+
:returns: A JSON serialisable dict representing the estimator.
153+
"""
154+
result = super().to_dict()
155+
if self.adjustment_config:
156+
result["adjustment_config"] = self.adjustment_config
157+
if self.formula:
158+
result["formula"] = self.formula
159+
return result

causal_testing/estimation/instrumental_variable_estimator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,11 @@ def estimate_coefficient(self, df: pd.DataFrame) -> EffectEstimate:
8787
ci_high = pd.Series(bootstraps[self.bootstrap_size - bound])
8888

8989
return EffectEstimate("coefficient", pd.Series(self.iv_coefficient(df)), ci_low, ci_high)
90+
91+
def to_dict(self) -> dict:
92+
"""
93+
Convert the estimator to a python dictionary for easy serialisation as JSON or CSV.
94+
95+
:returns: A JSON serialisable dict representing the estimator.
96+
"""
97+
return super().to_dict() | {"instrument": self.instrument, "bootstrap_size": self.bootstrap_size}

causal_testing/estimation/linear_regression_estimator.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,6 @@ def estimate_coefficient(self, df: pd.DataFrame) -> EffectEstimate:
9595
unit_effect = model.params[treatment] # Unit effect is the coefficient of the treatment
9696
[ci_low, ci_high] = self._get_confidence_intervals(model, treatment)
9797

98-
if len(unit_effect) == 0:
99-
unit_effect = pd.Series({self.base_test_case.treatment_variable.name: None})
100-
10198
return EffectEstimate("coefficient", unit_effect, ci_low, ci_high)
10299

103100
def estimate_ate(self, df: pd.DataFrame) -> EffectEstimate:

causal_testing/specification/causal_dag.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -498,12 +498,12 @@ def get_backdoor_graph(self, treatments: list[str]) -> CausalDAG:
498498
backdoor_graph.add_edges_from(filter(lambda x: x not in outgoing_edges, self.edges))
499499
return backdoor_graph
500500

501-
def identification(self, base_test_case: BaseTestCase, avoid_variables: set[Variable] = None):
501+
def identification(self, base_test_case: BaseTestCase, nodes_to_ignore: set[Variable] = None):
502502
"""Identify and return the minimum adjustment set
503503
504504
:param base_test_case: A base test case instance containing the outcome_variable and the
505505
treatment_variable required for identification.
506-
:param avoid_variables: Variables not to be adjusted for (e.g. hidden variables).
506+
:param nodes_to_ignore: Variables not to be adjusted for (e.g. hidden variables).
507507
:return: The smallest set of variables which can be adjusted for to obtain a causal
508508
estimate as opposed to a purely associational estimate.
509509
"""
@@ -517,14 +517,16 @@ def identification(self, base_test_case: BaseTestCase, avoid_variables: set[Vari
517517
)
518518
elif base_test_case.effect == "direct":
519519
minimal_adjustment_sets = self.direct_effect_adjustment_sets(
520-
[base_test_case.treatment_variable.name], [base_test_case.outcome_variable.name]
520+
[base_test_case.treatment_variable.name],
521+
[base_test_case.outcome_variable.name],
522+
nodes_to_ignore=nodes_to_ignore,
521523
)
522524
else:
523525
raise ValueError("Causal effect should be 'total' or 'direct'")
524526

525-
if avoid_variables is not None:
527+
if nodes_to_ignore is not None:
526528
minimal_adjustment_sets = [
527-
adj for adj in minimal_adjustment_sets if not {x.name for x in avoid_variables}.intersection(adj)
529+
adj for adj in minimal_adjustment_sets if not {x.name for x in nodes_to_ignore}.intersection(adj)
528530
]
529531

530532
minimal_adjustment_set = min(minimal_adjustment_sets, key=len, default=set())
@@ -544,19 +546,12 @@ def __str__(self):
544546
return f"Nodes: {self.nodes}\nEdges: {self.edges}"
545547

546548
def _estimator(self, base_test_case: BaseTestCase, nodes_to_ignore: set) -> Estimator:
547-
treatment_variable = base_test_case.treatment_variable.name
548549
outcome_variable = base_test_case.outcome_variable.name
549550

550551
if self.datatypes is None or outcome_variable not in self.datatypes:
551552
raise ValueError(f"No datatype specified for {outcome_variable}.")
552553

553-
adj_sets = self.direct_effect_adjustment_sets(
554-
[treatment_variable], [outcome_variable], nodes_to_ignore=nodes_to_ignore
555-
)
556-
if not adj_sets:
557-
return None, None
558-
559-
min_adj_set = sorted(list(map(lambda s: sorted(list(s)), adj_sets)))[0]
554+
min_adj_set = self.identification(base_test_case, nodes_to_ignore=nodes_to_ignore)
560555

561556
if pd.api.types.is_bool_dtype(self.datatypes[outcome_variable]):
562557
return (
@@ -606,6 +601,7 @@ def generate_causal_test( # pylint: disable=R0912
606601
if estimator and estimate_type:
607602
causal_tests.append(
608603
CausalTestCase(
604+
name=f"{u} _||_ {v}",
609605
base_test_case=base_test_case,
610606
expected_causal_effect=NoEffect(),
611607
estimator=estimator,
@@ -621,6 +617,7 @@ def generate_causal_test( # pylint: disable=R0912
621617
if estimator and estimate_type:
622618
causal_tests.append(
623619
CausalTestCase(
620+
name=f"{v} _||_ {u}",
624621
base_test_case=base_test_case,
625622
expected_causal_effect=NoEffect(),
626623
estimator=estimator,
@@ -636,6 +633,7 @@ def generate_causal_test( # pylint: disable=R0912
636633
if estimator and estimate_type:
637634
causal_tests.append(
638635
CausalTestCase(
636+
name=f"{u} -> {v}",
639637
base_test_case=base_test_case,
640638
expected_causal_effect=SomeEffect(),
641639
estimator=estimator,
@@ -649,6 +647,7 @@ def generate_causal_test( # pylint: disable=R0912
649647
if estimator and estimate_type:
650648
causal_tests.append(
651649
CausalTestCase(
650+
name=f"{v} -> {u}",
652651
base_test_case=base_test_case,
653652
expected_causal_effect=SomeEffect(),
654653
estimator=estimator,

causal_testing/testing/base_test_case.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,13 @@ class BaseTestCase:
2222
def __post_init__(self):
2323
if self.treatment_variable == self.outcome_variable:
2424
raise ValueError(f"Treatment variable {self.treatment_variable} cannot also be the outcome variable.")
25+
26+
def to_dict(self) -> dict:
27+
"""
28+
:returns: A JSON serialisable dictionary representing the base test case.
29+
"""
30+
return {
31+
"treatment_variable": self.treatment_variable.name,
32+
"outcome_variable": self.outcome_variable.name,
33+
"effect": self.effect,
34+
}

causal_testing/testing/causal_effect.py

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
ExactValue, Positive, Negative, SomeEffect, NoEffect"""
44

55
from abc import ABC, abstractmethod
6-
from collections.abc import Iterable
76

87
import numpy as np
98

@@ -23,25 +22,27 @@ def apply(self, effect_estimate: EffectEstimate) -> bool:
2322
def __str__(self) -> str:
2423
return type(self).__name__
2524

25+
def to_dict(self):
26+
"""
27+
Convert the expected effect to a python dictionary for easy serialisation as JSON.
28+
29+
:returns: A JSON serialisable dict representing the expected effect.
30+
"""
31+
return {"name": self.__class__.__name__}
32+
2633

2734
class SomeEffect(CausalEffect):
2835
"""An extension of CausalEffect representing that the expected causal effect should not be zero."""
2936

3037
def apply(self, effect_estimate: EffectEstimate) -> bool:
31-
if effect_estimate.ci_low is None or effect_estimate.ci_high is None:
32-
return None
33-
if effect_estimate.type in ("risk_ratio", "hazard_ratio", "unit_odds_ratio"):
34-
return any(
35-
1 < ci_low < ci_high or ci_low < ci_high < 1
36-
for ci_low, ci_high in zip(effect_estimate.ci_low, effect_estimate.ci_high)
37-
)
38-
if effect_estimate.type in ("coefficient", "ate"):
39-
return any(
40-
0 < ci_low < ci_high or ci_low < ci_high < 0
41-
for ci_low, ci_high in zip(effect_estimate.ci_low, effect_estimate.ci_high)
42-
)
38+
if effect_estimate.type in ("risk_ratio", "hazard_ratio", "unit_odds_ratio", "odds_ratio"):
39+
value_to_check = 1
40+
elif effect_estimate.type in ("coefficient", "ate"):
41+
value_to_check = 0
42+
else:
43+
raise ValueError(f"Test Value type {effect_estimate.type} is not valid for this CausalEffect")
4344

44-
raise ValueError(f"Test Value type {effect_estimate.type} is not valid for this CausalEffect")
45+
return (~((effect_estimate.ci_low <= value_to_check) & (value_to_check <= effect_estimate.ci_high))).all()
4546

4647

4748
class NoEffect(CausalEffect):
@@ -52,30 +53,30 @@ class NoEffect(CausalEffect):
5253
:param ctol: Categorical tolerance. The test will pass if this proportion of categories pass.
5354
"""
5455

55-
def __init__(self, atol: float = 1e-10, ctol: float = 0.05):
56+
def __init__(self, atol: float = 0, ctol: float = 0.0):
5657
self.atol = atol
5758
self.ctol = ctol
5859

5960
def apply(self, effect_estimate: EffectEstimate) -> bool:
6061
if effect_estimate.type in ("risk_ratio", "hazard_ratio", "unit_odds_ratio", "odds_ratio"):
61-
return any(
62-
ci_low < 1 < ci_high or np.isclose(value, 1.0, atol=self.atol)
63-
for ci_low, ci_high, value in zip(
64-
effect_estimate.ci_low, effect_estimate.ci_high, effect_estimate.value
65-
)
66-
)
67-
if effect_estimate.type in ("coefficient", "ate"):
68-
value = effect_estimate.value if isinstance(effect_estimate.ci_high, Iterable) else [effect_estimate.value]
69-
return (
70-
sum(
71-
not ((ci_low < 0 < ci_high) or abs(v) < self.atol)
72-
for ci_low, ci_high, v in zip(effect_estimate.ci_low, effect_estimate.ci_high, value)
73-
)
74-
/ len(value)
75-
< self.ctol
76-
)
62+
value_to_check = 1
63+
elif effect_estimate.type in ("coefficient", "ate"):
64+
value_to_check = 0
65+
else:
66+
raise ValueError(f"Test Value type {effect_estimate.type} is not valid for this CausalEffect")
67+
68+
return sum(
69+
((effect_estimate.ci_low <= value_to_check) & (value_to_check <= effect_estimate.ci_high))
70+
| (np.isclose(effect_estimate.value, value_to_check, atol=self.atol))
71+
) / len(effect_estimate.value) >= (1 - self.ctol)
72+
73+
def to_dict(self):
74+
"""
75+
Convert the expected effect to a python dictionary for easy serialisation as JSON.
7776
78-
raise ValueError(f"Test Value type {effect_estimate.type} is not valid for this CausalEffect")
77+
:returns: A JSON serialisable dict representing the expected effect.
78+
"""
79+
return {"name": self.__class__.__name__, "atol": self.atol, "ctol": self.ctol}
7980

8081

8182
class ExactValue(CausalEffect):
@@ -98,21 +99,37 @@ def __init__(self, value: float, atol: float = None, ci_low: float = None, ci_hi
9899
if self.value - self.atol < self.ci_low or self.value + self.atol > self.ci_high:
99100
raise ValueError(
100101
"Arithmetic tolerance falls outside the confidence intervals."
101-
"Try specifying a smaller value of atol."
102+
f"Try specifying wider intervals or a value of atol smaller than the current vlaue {self.atol}."
102103
)
103104

104105
def apply(self, effect_estimate: EffectEstimate) -> bool:
105106
close = np.isclose(effect_estimate.value, self.value, atol=self.atol)
106107
if effect_estimate.ci_valid and self.ci_low is not None and self.ci_high is not None:
107-
return all(
108-
close and self.ci_low <= ci_low and self.ci_high >= ci_high
109-
for ci_low, ci_high in zip(effect_estimate.ci_low, effect_estimate.ci_high)
108+
return (
109+
close.all()
110+
and (self.ci_low <= effect_estimate.ci_low).all()
111+
and (self.ci_high >= effect_estimate.ci_high).all()
110112
)
111-
return close
113+
return close.all()
112114

113115
def __str__(self):
114116
return f"ExactValue: {self.value}±{self.atol}"
115117

118+
def to_dict(self):
119+
"""
120+
Convert the expected effect to a python dictionary for easy serialisation as JSON or CSV.
121+
122+
:returns: A JSON serialisable dict representing the expected effect.
123+
"""
124+
effect = {"value": self.value}
125+
if self.ci_low:
126+
effect["ci_low"] = self.ci_low
127+
if self.ci_low:
128+
effect["ci_high"] = self.ci_high
129+
if self.atol:
130+
effect["atol"] = self.atol
131+
return {"name": self.__class__.__name__} | effect
132+
116133

117134
class Positive(SomeEffect):
118135
"""An extension of CausalEffect representing that the expected causal effect should be positive.

0 commit comments

Comments
 (0)