Skip to content

Commit 3123ceb

Browse files
committed
More tests to make codecov happy
1 parent b07d503 commit 3123ceb

10 files changed

Lines changed: 167 additions & 56 deletions

File tree

causal_testing/causal_testing_framework.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from importlib.metadata import entry_points
88
from pathlib import Path
99

10+
import numpy as np
1011
import pandas as pd
1112
from tqdm import tqdm
1213

@@ -43,8 +44,6 @@ def read_dataframe(file_path: str, **kwargs: dict) -> pd.DataFrame:
4344
suffix = Path(file_path).suffix.lower()
4445

4546
if suffix in readers:
46-
print("READING FROM", file_path, kwargs)
47-
print(readers[suffix](file_path, **kwargs))
4847
return readers[suffix](file_path, **kwargs)
4948
raise ValueError(f"Unsupported file extension: '{suffix}'")
5049

@@ -242,16 +241,18 @@ def evaluate_dag(self, bootstrap_size: bool = 100, alpha: float = 0.05) -> pd.Se
242241
for test_case in self.test_cases:
243242
if test_case.skip:
244243
continue
245-
effect_estimate = test_case.estimate_effect(
246-
df=self.df.sample(len(self.df), replace=True, random_state=sample_index)
247-
)
244+
try:
245+
effect_estimate = test_case.estimate_effect(
246+
df=self.df.sample(len(self.df), replace=True, random_state=sample_index)
247+
)
248+
except (np.linalg.LinAlgError, ValueError):
249+
test_outcomes[TestOutcome.INESTIMABLE] += 1
250+
248251
if effect_estimate:
249252
if test_case.expected_causal_effect.apply(effect_estimate):
250253
test_outcomes[TestOutcome.PASS] += 1
251254
else:
252255
test_outcomes[TestOutcome.FAIL] += 1
253-
else:
254-
test_outcomes[TestOutcome.INESTIMABLE] += 1
255256
sample_results.append(test_outcomes)
256257

257258
sample_results = pd.DataFrame(sample_results)
@@ -264,7 +265,6 @@ def evaluate_dag(self, bootstrap_size: bool = 100, alpha: float = 0.05) -> pd.Se
264265
results[f"{outcome.name}_ci_low"] = data[ci_low_inx]
265266
results[f"{outcome.name}_ci_high"] = data[ci_high_inx]
266267

267-
print(results)
268268
return pd.Series(results).sort_index()
269269

270270
def save_results(self, output_path) -> list:

causal_testing/specification/causal_dag.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -512,11 +512,12 @@ def identification(
512512
:return: The smallest set of variables which can be adjusted for to obtain a causal
513513
estimate as opposed to a purely associational estimate.
514514
"""
515-
# Naive method to guarantee termination when we have cycles
515+
nodes_to_ignore = set(nodes_to_ignore) if nodes_to_ignore is not None else set()
516+
516517
if self.ignore_cycles:
517-
return set(self.predecessors(treatment_variable))
518-
minimal_adjustment_sets = []
519-
if effect_type == "total":
518+
# Naive method to guarantee termination when we have cycles
519+
minimal_adjustment_sets = [set(self.predecessors(treatment_variable))]
520+
elif effect_type == "total":
520521
minimal_adjustment_sets = self.enumerate_minimal_adjustment_sets([treatment_variable], [outcome_variable])
521522
elif effect_type == "direct":
522523
minimal_adjustment_sets = self.direct_effect_adjustment_sets(
@@ -525,14 +526,20 @@ def identification(
525526
nodes_to_ignore=nodes_to_ignore,
526527
)
527528
else:
528-
raise ValueError("Causal effect should be 'total' or 'direct'")
529+
raise ValueError(f"Causal effect should be 'total' or 'direct', not '{effect_type}'")
529530

530531
if nodes_to_ignore is not None:
531532
minimal_adjustment_sets = [
532-
adj for adj in minimal_adjustment_sets if not {x.name for x in nodes_to_ignore}.intersection(adj)
533+
adj for adj in minimal_adjustment_sets if not set(nodes_to_ignore).intersection(adj)
533534
]
534535

535-
minimal_adjustment_set = min(minimal_adjustment_sets, key=len, default=set())
536+
if not minimal_adjustment_sets:
537+
raise ValueError(
538+
f"Could not find a suitable adjustment set for the {effect_type} effect of {treatment_variable} on "
539+
f"{outcome_variable} while avoiding nodes in set {nodes_to_ignore}."
540+
)
541+
542+
minimal_adjustment_set = min(minimal_adjustment_sets, key=len)
536543
return set(minimal_adjustment_set)
537544

538545
def to_dot_string(self) -> str:
@@ -601,8 +608,7 @@ def generate_causal_test( # pylint: disable=R0912
601608
:return: A list containing ShouldCause and ShouldNotCause metamorphic relations.
602609
"""
603610

604-
if nodes_to_ignore is None:
605-
nodes_to_ignore = set()
611+
nodes_to_ignore = set(nodes_to_ignore) if nodes_to_ignore is not None else set()
606612

607613
causal_tests = []
608614

@@ -690,8 +696,7 @@ def generate_causal_tests(
690696
:return: A list containing ShouldCause and ShouldNotCause metamorphic relations.
691697
"""
692698

693-
if nodes_to_ignore is None:
694-
nodes_to_ignore = set()
699+
nodes_to_ignore = set(nodes_to_ignore) if nodes_to_ignore is not None else set()
695700
nodes_to_ignore = nodes_to_ignore.union(set(self.cycle_nodes()))
696701

697702
if nodes_to_test is None:

causal_testing/testing/causal_effect.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class ExactValue(CausalEffect):
8787
"""An extension of CausalEffect representing that the expected causal effect should be a specific value."""
8888

8989
def __init__(
90-
self, value: float, effect_type: str = "direct", atol: float = None, ci_low: float = None, ci_high: float = None
90+
self, value: float, effect_type: str = "direct", atol: float = 0, ci_low: float = None, ci_high: float = None
9191
):
9292
super().__init__(effect_type=effect_type)
9393
if (ci_low is not None) ^ (ci_high is not None):
@@ -98,7 +98,7 @@ def __init__(
9898
self.value = value
9999
self.ci_low = ci_low
100100
self.ci_high = ci_high
101-
self.atol = atol if atol is not None else abs(value * 0.05)
101+
self.atol = atol
102102

103103
if self.ci_low is not None and self.ci_high is not None:
104104
if not self.ci_low <= self.value <= self.ci_high:
@@ -128,13 +128,12 @@ def to_dict(self):
128128
129129
:returns: A JSON serialisable dict representing the expected effect.
130130
"""
131-
effect = {"value": self.value}
131+
effect = {"value": self.value, "atol": self.atol}
132132
if self.ci_low:
133133
effect["ci_low"] = self.ci_low
134134
if self.ci_low:
135135
effect["ci_high"] = self.ci_high
136-
if self.atol:
137-
effect["atol"] = self.atol
136+
138137
return super().to_dict() | effect
139138

140139

causal_testing/testing/causal_test_case.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,20 @@ def measure_adequacy(
9494
except Exception: # pylint: disable=W0718
9595
outcomes.append(None)
9696

97-
results = pd.concat(results)
97+
if results:
98+
results = pd.concat(results)
9899

99-
results["var"] = results.index
100+
results["var"] = results.index
100101

102+
return DataAdequacy(
103+
results=results,
104+
kurtosis=results.groupby("var")["effect_estimate"].apply(lambda x: x.kurtosis()),
105+
passing=int(sum(filter(lambda x: x is not None, outcomes))),
106+
successful=int(sum(x is not None for x in outcomes)),
107+
)
101108
return DataAdequacy(
102109
results=results,
103-
kurtosis=results.groupby("var")["effect_estimate"].apply(lambda x: x.kurtosis()),
110+
kurtosis=None,
104111
passing=int(sum(filter(lambda x: x is not None, outcomes))),
105112
successful=int(sum(x is not None for x in outcomes)),
106113
)

tests/main_tests/test_ctf.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pandas as pd
66

77
from causal_testing.causal_testing_framework import CausalTestingFramework
8+
from causal_testing.specification.causal_dag import CausalDAG
89

910

1011
class TestCausalTestingFramework(unittest.TestCase):
@@ -184,3 +185,27 @@ def test_ctf_evaluate_dag(self):
184185
}
185186
).sort_index()
186187
pd.testing.assert_series_equal(results, expected)
188+
189+
def test_ctf_evaluate_dag_inestimable(self):
190+
framework = CausalTestingFramework()
191+
framework.df = pd.read_csv("tests/resources/data/scarf_data.csv", index_col=0).query("length_in > 60")
192+
framework.dag = CausalDAG(datatypes=framework.df.dtypes)
193+
framework.dag.add_nodes_from(framework.df.columns)
194+
framework.test_cases = framework.dag.generate_causal_tests()
195+
196+
results = framework.evaluate_dag()
197+
expected = pd.Series(
198+
{
199+
"FAIL": 1,
200+
"FAIL_ci_high": 2,
201+
"FAIL_ci_low": 0,
202+
"INESTIMABLE": 1,
203+
"INESTIMABLE_ci_high": 1,
204+
"INESTIMABLE_ci_low": 0,
205+
"PASS": 4,
206+
"PASS_ci_high": 4,
207+
"PASS_ci_low": 0,
208+
}
209+
).sort_index()
210+
print(results)
211+
pd.testing.assert_series_equal(results, expected)

tests/specification_tests/test_causal_dag.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,21 @@ def test_enumerate_minimal_adjustment_sets(self):
291291
adjustment_sets = causal_dag.enumerate_minimal_adjustment_sets(xs, ys)
292292
self.assertEqual([{"Z"}], list(adjustment_sets))
293293

294+
def test_identification_total_effect(self):
295+
"""Test whether identification works for total effect."""
296+
causal_dag = CausalDAG()
297+
causal_dag.add_edges_from([("X", "M"), ("M", "Y")])
298+
299+
self.assertEqual(
300+
set(), causal_dag.identification(treatment_variable="X", outcome_variable="Y", effect_type="total")
301+
)
302+
303+
def test_identification_invalid_effect(self):
304+
causal_dag = CausalDAG()
305+
with self.assertRaises(ValueError) as e:
306+
causal_dag.identification(treatment_variable="X", outcome_variable="Y", effect_type="invalid")
307+
self.assertEqual(e.exception, f"Causal effect should be 'total' or 'direct', not 'invalid'.")
308+
294309
def test_enumerate_minimal_adjustment_sets_multiple(self):
295310
"""Test whether enumerate_minimal_adjustment_sets lists all minimum adjustment sets if multiple are possible."""
296311
causal_dag = CausalDAG()
@@ -403,32 +418,48 @@ def test_list_all_min_sep(self):
403418
min_separators = set(frozenset(min_separator) for min_separator in min_separators)
404419
self.assertEqual({frozenset({2, 3}), frozenset({3, 4}), frozenset({4, 5})}, min_separators)
405420

421+
def test_close_separator_exception(self):
422+
g = nx.Graph()
423+
g.add_edges_from([("X", "Y")])
424+
425+
with self.assertRaises(ValueError) as e:
426+
close_separator(
427+
graph=g,
428+
treatment_node="X",
429+
outcome_node="X",
430+
treatment_node_set={"Y"},
431+
)
432+
self.assertEqual(e.exception, "No X-Y separator in the graph.")
433+
406434

407435
class TestHiddenVariableDAG(unittest.TestCase):
408436
"""
409437
Test the CausalDAG identification for the exclusion of hidden variables.
410438
"""
411439

412-
def setUp(self) -> None:
413-
self.temp_dir_path = tempfile.mkdtemp()
414-
self.dag_dot_path = os.path.join(self.temp_dir_path, "dag.dot")
415-
dag_dot = """digraph DAG { rankdir=LR; Z -> X; X -> M; M -> Y; Z -> M; }"""
416-
with open(self.dag_dot_path, "w") as f:
417-
f.write(dag_dot)
418-
419-
def test_ignore_varaible_adjustment_sets(self):
440+
def test_impossible_identification(self):
420441
"""Test whether identification produces different adjustment sets if nodes_to_ignore is set."""
421-
causal_dag = CausalDAG(self.dag_dot_path)
422-
adjustment_sets = causal_dag.identification(treatment_variable="X", outcome_variable="M")
442+
causal_dag = CausalDAG()
443+
causal_dag.add_edges_from([("X", "M"), ("M", "Y"), ("X", "Y")])
423444

424-
adjustment_sets_with_hidden = causal_dag.identification(
425-
treatment_variable="X", outcome_variable="M", nodes_to_ignore=["Z"]
426-
)
445+
self.assertEqual(causal_dag.identification(treatment_variable="X", outcome_variable="Y"), {"M"})
427446

428-
self.assertNotEqual(adjustment_sets, adjustment_sets_with_hidden)
447+
with self.assertRaises(ValueError) as e:
448+
causal_dag.identification(treatment_variable="X", outcome_variable="Y", nodes_to_ignore=["M"])
449+
self.assertEqual(
450+
e.exception,
451+
"Could not find a suitable adjustment set for the direct effect of X on Y while avoiding nodes in set {M}.",
452+
)
429453

430-
def tearDown(self) -> None:
431-
shutil.rmtree(self.temp_dir_path)
454+
def test_adjustment_set_nodes_to_ignore(self):
455+
"""Test whether identification produces different adjustment sets if nodes_to_ignore is set."""
456+
causal_dag = CausalDAG()
457+
causal_dag.add_edges_from([("L", "V"), ("V", "X"), ("X", "Y"), ("L", "C"), ("C", "Y")])
458+
459+
self.assertEqual(causal_dag.identification(treatment_variable="X", outcome_variable="Y"), {"C"})
460+
self.assertEqual(
461+
causal_dag.identification(treatment_variable="X", outcome_variable="Y", nodes_to_ignore={"C"}), {"L"}
462+
)
432463

433464

434465
def time_it(label, func, *args, **kwargs):
File renamed without changes.

tests/testing_tests/test_causal_effect.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,17 @@ def test_exactValue_pass(self):
6666
effect_estimate = EffectEstimate(type="ate", value=pd.Series(5.05))
6767
self.assertTrue(ExactValue(value=5, atol=0.1).apply(effect_estimate))
6868

69+
def test_exactValue_to_dict(self):
70+
self.assertTrue(
71+
ExactValue(value=5.01, ci_low=5.0, ci_high=5.08).to_dict(),
72+
{"name": "ExactValue", "effect_type": "direct", "value": 5, "ci_high": 5.0, "ci_low": 5.08},
73+
)
74+
6975
def test_exactValue_categorical_pass(self):
7076
effect_estimate = EffectEstimate(type="ate", value=pd.Series({"color[T.red]": 5.05, "color[T.blue]": 4.03}))
71-
self.assertTrue(ExactValue(pd.Series({"color[T.red]": 5, "color[T.blue]": 4}), 0.1).apply(effect_estimate))
77+
self.assertTrue(
78+
ExactValue(value=pd.Series({"color[T.red]": 5, "color[T.blue]": 4}), atol=0.1).apply(effect_estimate)
79+
)
7280

7381
def test_exactValue_pass_ci(self):
7482
effect_estimate = EffectEstimate(type="ate", value=pd.Series(5.05), ci_low=pd.Series(4), ci_high=pd.Series(6))
@@ -78,13 +86,13 @@ def test_exactValue_ci_pass_ci(self):
7886
effect_estimate = EffectEstimate(
7987
type="ate", value=pd.Series(5.05), ci_low=pd.Series(4.1), ci_high=pd.Series(5.9)
8088
)
81-
self.assertTrue(ExactValue(value=5, ci_low=4, ci_high=6).apply(effect_estimate))
89+
self.assertTrue(ExactValue(value=5, atol=0.05, ci_low=4, ci_high=6).apply(effect_estimate))
8290

8391
def test_exactValue_ci_fail_ci(self):
8492
effect_estimate = EffectEstimate(
85-
type="ate", value=pd.Series(5.05), ci_low=pd.Series(3.9), ci_high=pd.Series(6.1)
93+
type="ate", value=pd.Series(5.05), ci_low=pd.Series(4.1), ci_high=pd.Series(5.9)
8694
)
87-
self.assertFalse(ExactValue(value=5, ci_low=4, ci_high=6).apply(effect_estimate))
95+
self.assertFalse(ExactValue(value=5, atol=0.04, ci_low=4, ci_high=6).apply(effect_estimate))
8896

8997
def test_exactValue_fail(self):
9098
effect_estimate = EffectEstimate(type="ate", value=pd.Series(0))

tests/testing_tests/test_causal_test_adequacy.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,37 @@ def test_data_adequacy_categorical(self):
6363
self.assertEqual(adequacy_metric.passing, 100, f"Expected passing 100 not {adequacy_metric.passing}")
6464
self.assertEqual(adequacy_metric.successful, 100, f"Expected successful 100 not {adequacy_metric.successful}")
6565

66+
def test_data_adequacy_categorical_inestimable(self):
67+
df = pd.read_csv("tests/resources/data/scarf_data.csv")
68+
causal_test_case = CausalTestCase(
69+
expected_causal_effect=NoEffect(atol=1e-10),
70+
effect_measure="coefficient",
71+
estimator=LinearRegressionEstimator(
72+
treatment_variable="color", outcome_variable="completed", adjustment_set=set()
73+
),
74+
)
75+
adequacy_metric = causal_test_case.measure_adequacy(df.loc[df["color"] == "grey"])
76+
77+
self.assertEqual(adequacy_metric.kurtosis, None, f"Expected passing None not {adequacy_metric.kurtosis}")
78+
self.assertEqual(adequacy_metric.passing, 0, f"Expected passing 0 not {adequacy_metric.passing}")
79+
self.assertEqual(adequacy_metric.successful, 0, f"Expected successful 0 not {adequacy_metric.successful}")
80+
self.assertEqual(adequacy_metric.results, [])
81+
82+
def test_data_adequacy_categorical_partly_inestimable(self):
83+
df = pd.read_csv("tests/resources/data/scarf_data.csv")
84+
causal_test_case = CausalTestCase(
85+
expected_causal_effect=NoEffect(atol=1e-10),
86+
effect_measure="coefficient",
87+
estimator=LinearRegressionEstimator(
88+
treatment_variable="color", outcome_variable="completed", adjustment_set=set()
89+
),
90+
)
91+
adequacy_metric = causal_test_case.measure_adequacy(df.loc[df["length_in"] == 55])
92+
93+
self.assertEqual(adequacy_metric.kurtosis.values, [0], f"Expected [0] not {adequacy_metric.kurtosis.values}")
94+
self.assertEqual(adequacy_metric.passing, 63, f"Expected passing 63 not {adequacy_metric.passing}")
95+
self.assertEqual(adequacy_metric.successful, 63, f"Expected successful 63 not {adequacy_metric.successful}")
96+
6697
def test_data_adequacy_group_by(self):
6798
timesteps_per_intervention = 1
6899
control_strategy = [[t, "t", 0] for t in range(1, 4, timesteps_per_intervention)]

0 commit comments

Comments
 (0)