Skip to content

Commit 87d973d

Browse files
committed
Causal DAG analysis (and restructuring of CausalTestResult)
1 parent 14da903 commit 87d973d

15 files changed

Lines changed: 309 additions & 300 deletions

causal_testing/__main__.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Command(Enum):
2323
TEST = "test"
2424
GENERATE = "generate"
2525
DISCOVER = "discover"
26+
EVALUATE = "evaluate"
2627

2728

2829
def setup_logging(level: str) -> None:
@@ -48,6 +49,15 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
4849
choices=["NONE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
4950
help="Set the logging level (default: WARNING).",
5051
)
52+
main_parser.add_argument(
53+
"-a",
54+
"--alpha",
55+
help=(
56+
"The significance level of the confidence intervals used to determine causality. "
57+
"This should be a value between 0 and 1. Defaults to 0.05 for 95%% confidence intervals."
58+
),
59+
default=0.05,
60+
)
5161

5262
subparsers = main_parser.add_subparsers(
5363
help="The action you want to run - call `causal_testing {action} -h` for further details", dest="command"
@@ -108,18 +118,36 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
108118
default=False,
109119
)
110120

121+
# DAG evaluation
122+
parser_evaluate = subparsers.add_parser(
123+
Command.EVALUATE.value, help="Evaluate how well a causal DAG fits a dataset"
124+
)
125+
parser_evaluate.add_argument("-D", "--dag-path", help="Path to the DAG file (.dot)", required=True)
126+
parser_evaluate.add_argument("-o", "--output", help="Path for output file (.csv)", required=True)
127+
parser_evaluate.add_argument(
128+
"-i", "--ignore-cycles", help="Ignore cycles in DAG", action="store_true", default=False
129+
)
130+
parser_evaluate.add_argument("-d", "--data-paths", help="Paths to data files (.csv)", nargs="+", required=True)
131+
parser_evaluate.add_argument("-q", "--query", help="Query string to filter data (e.g. 'age > 18')", type=str)
132+
parser_evaluate.add_argument(
133+
"-b",
134+
"--adequacy-bootstrap-size",
135+
dest="bootstrap_size",
136+
help="Number of bootstrap samples for causal test adequacy. Defaults to 100",
137+
type=int,
138+
default=100,
139+
)
140+
parser_evaluate.add_argument(
141+
"-s",
142+
"--silent",
143+
action="store_true",
144+
help="Do not crash on error. If set to true, errors are recorded as test results.",
145+
default=False,
146+
)
147+
111148
# Discovery
112149
parser_discover = subparsers.add_parser(Command.DISCOVER.value, help="Discover causal structures from data")
113150
parser_discover.add_argument("-d", "--data-paths", help="Paths to data files (.csv)", nargs="+", required=True)
114-
parser_discover.add_argument(
115-
"-a",
116-
"--alpha",
117-
help=(
118-
"The significance level of the confidence intervals used to determine causality. "
119-
"This should be a value between 0 and 1. Defaults to 0.05 for 95%% confidence intervals."
120-
),
121-
default=0.05,
122-
)
123151
parser_discover.add_argument(
124152
"-t",
125153
"--technique",
@@ -246,6 +274,23 @@ def main() -> None:
246274
framework.save_results(args.output)
247275

248276
logging.info("Causal testing completed successfully.")
277+
case Command.EVALUATE:
278+
# Create and setup framework
279+
framework = CausalTestingFramework()
280+
281+
framework.setup(
282+
dag_path=args.dag_path,
283+
data_paths=args.data_paths,
284+
test_cases_path=args.test_config,
285+
query=args.query,
286+
ignore_cycles=args.ignore_cycles,
287+
)
288+
289+
logging.info("Running tests on entire dataset")
290+
results = framework.evaluate_dag(alpha=args.alpha, bootstrap_size=args.bootstrap_size)
291+
logging.info("Causal testing completed successfully.")
292+
logging.info("Running tests on bootstrap samples")
293+
results.to_csv(args.output)
249294

250295

251296
if __name__ == "__main__":

causal_testing/causal_testing_framework.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from causal_testing.specification.variable import Input, Output
1515
from causal_testing.testing.base_test_case import BaseTestCase
1616
from causal_testing.testing.causal_test_case import CausalTestCase
17+
from causal_testing.testing.causal_test_result import TestOutcome
1718

1819
logger = logging.getLogger(__name__)
1920

@@ -265,6 +266,49 @@ def run_tests(self, silent: bool = False, adequacy: bool = False, bootstrap_size
265266
self.df, suppress_estimation_errors=silent, adequacy=adequacy, bootstrap_size=bootstrap_size
266267
)
267268

269+
def evaluate_dag(self, bootstrap_size: bool, alpha: float) -> pd.Series:
270+
"""
271+
Calculate confidence intervals for how well a causal DAG fits a dataset by repeatedly resampling the dataset
272+
and executing the causal tests.
273+
Confidence intervals are then calcualted for how many tests pass, fail, or are inestimable.
274+
275+
:param bootstrap_size: The number of bootstrap samples to use when calculating causal test adequacy
276+
(defaults to 100)
277+
:param alpha: The significance level to use when calculating confidence intervals.
278+
"""
279+
self.run_tests(silent=True, adequacy=False)
280+
results = {
281+
test_outcome: len([test for test in self.test_cases if test.result.outcome == test_outcome])
282+
for test_outcome in TestOutcome
283+
}
284+
285+
sample_results = []
286+
for sample_index in range(bootstrap_size):
287+
test_outcomes = {test_outcome: 0 for test_outcome in TestOutcome}
288+
for test_case in tqdm(self.test_cases):
289+
effect_estimate = test_case.estimate_effect(
290+
df=self.df.sample(len(self.df), replace=True, random_state=sample_index)
291+
)
292+
if effect_estimate:
293+
if test_case.expected_causal_effect.apply(effect_estimate):
294+
test_outcomes[TestOutcome.PASS] += 1
295+
else:
296+
test_outcomes[TestOutcome.FAIL] += 1
297+
else:
298+
test_outcomes[TestOutcome.INESTIMABLE] += 1
299+
sample_results.append(test_outcomes)
300+
301+
sample_results = pd.DataFrame(sample_results)
302+
# Calculate the confidence interval of each column
303+
ci_low_inx = (alpha / 2) * bootstrap_size
304+
ci_high_inx = ((1 - alpha) / 2) * bootstrap_size
305+
for outcome in TestOutcome:
306+
data = sorted(sample_results[outcome])
307+
results[f"{outcome}_ci_low"] = data[ci_low_inx]
308+
results[f"{outcome}_ci_high"] = data[ci_high_inx]
309+
310+
return pd.Series(results).sort_index()
311+
268312
def save_results(self, output_path) -> list:
269313
"""Save test results to JSON file in the expected format."""
270314
logger.info(f"Saving results to {output_path}")
@@ -301,15 +345,11 @@ def save_results(self, output_path) -> list:
301345
result = test_case.result
302346
result_index += 1
303347

304-
test_passed = (
305-
test_case.expected_causal_effect.apply(result) if result.effect_estimate is not None else False
306-
)
307-
308348
output = {
309349
**base_output,
310350
"formula": test_case.estimator.formula if hasattr(test_case.estimator, "formula") else None,
311351
"skip": False,
312-
"passed": test_passed,
352+
"passed": test_case.result.outcome == TestOutcome.PASS,
313353
"result": (
314354
{
315355
"treatment": test_case.estimator.base_test_case.treatment_variable.name,

causal_testing/discovery/abstract_discovery.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import re
77
import warnings
88
from abc import ABC, abstractmethod
9-
from enum import Enum
109
from itertools import permutations
1110

1211
import networkx as nx
@@ -19,10 +18,9 @@
1918
from causal_testing.specification.scenario import Scenario
2019
from causal_testing.testing.causal_effect import Negative, Positive
2120
from causal_testing.testing.causal_test_case import CausalTestCase
21+
from causal_testing.testing.causal_test_result import TestOutcome
2222
from causal_testing.testing.metamorphic_relation import generate_metamorphic_relations
2323

24-
TestResult = Enum("TestResult", [("PASS", 2), ("FAIL", 0), ("INESTIMABLE", 1)])
25-
2624
# Ignore warnings from statsmodels when we try to evaluate test cases
2725
warnings.simplefilter("ignore")
2826

@@ -111,9 +109,9 @@ def effect_direction(self, test_case: CausalTestCase) -> str:
111109
if pd.api.types.is_numeric_dtype(
112110
self.df[test_case.base_test_case.treatment_variable.name]
113111
) and pd.api.types.is_numeric_dtype(self.df[test_case.base_test_case.outcome_variable.name]):
114-
if Negative().apply(test_case.result):
112+
if Negative().apply(test_case.result.effect_estimate):
115113
return "negative"
116-
if Positive().apply(test_case.result):
114+
if Positive().apply(test_case.result.effect_estimate):
117115
return "positive"
118116
return None
119117

@@ -148,15 +146,15 @@ def write_dot(self, individual: CausalDAG, output_file: str):
148146

149147
print(test)
150148

151-
if test["result"] == TestResult.PASS:
149+
if test["result"] == TestOutcome.PASS:
152150
print(" GREEN")
153151
individual[test["treatment"]][test["outcome"]]["color"] = "green"
154152
individual[test["treatment"]][test["outcome"]]["fontcolor"] = "green"
155-
elif test["result"] == TestResult.INESTIMABLE:
153+
elif test["result"] == TestOutcome.INESTIMABLE:
156154
print(" ORANGE")
157155
individual[test["treatment"]][test["outcome"]]["color"] = "orange"
158156
individual[test["treatment"]][test["outcome"]]["fontcolor"] = "orange"
159-
elif test["result"] == TestResult.FAIL:
157+
elif test["result"] == TestOutcome.FAIL:
160158
print(" RED")
161159
individual[test["treatment"]][test["outcome"]]["color"] = "red"
162160
individual[test["treatment"]][test["outcome"]]["fontcolor"] = "red"
@@ -166,13 +164,13 @@ def write_dot(self, individual: CausalDAG, output_file: str):
166164
individual.add_edge(test["treatment"], test["outcome"], ignore_cycles=True)
167165
individual[test["treatment"]][test["outcome"]]["style"] = "dashed"
168166
individual[test["treatment"]][test["outcome"]]["label"] = test["effect"]
169-
if test["result"] == TestResult.PASS:
167+
if test["result"] == TestOutcome.PASS:
170168
individual[test["treatment"]][test["outcome"]]["style"] = "invis"
171169
individual[test["treatment"]][test["outcome"]]["constraint"] = False
172-
elif test["result"] == TestResult.INESTIMABLE:
170+
elif test["result"] == TestOutcome.INESTIMABLE:
173171
individual[test["treatment"]][test["outcome"]]["color"] = "orange"
174172
individual[test["treatment"]][test["outcome"]]["fontcolor"] = "orange"
175-
elif test["result"] == TestResult.FAIL:
173+
elif test["result"] == TestOutcome.FAIL:
176174
individual[test["treatment"]][test["outcome"]]["color"] = "red"
177175
individual[test["treatment"]][test["outcome"]]["fontcolor"] = "red"
178176
else:
@@ -222,9 +220,9 @@ def evaluate_tests(self, causal_dag: CausalDAG) -> pd.DataFrame:
222220
results.append(
223221
{
224222
"result": (
225-
TestResult.PASS
226-
if test_case.expected_causal_effect.apply(test_case.result)
227-
else TestResult.FAIL
223+
TestOutcome.PASS
224+
if test_case.expected_causal_effect.apply(test_case.result.effect_estimate)
225+
else TestOutcome.FAIL
228226
),
229227
"expected_effect": test_case.expected_causal_effect.__class__.__name__,
230228
"treatment": test_case.base_test_case.treatment_variable.name,
@@ -235,7 +233,7 @@ def evaluate_tests(self, causal_dag: CausalDAG) -> pd.DataFrame:
235233
except np.linalg.LinAlgError:
236234
results.append(
237235
{
238-
"result": TestResult.INESTIMABLE,
236+
"result": TestOutcome.INESTIMABLE,
239237
"expected_effect": test_case.expected_causal_effect.__class__.__name__,
240238
"treatment": test_case.base_test_case.treatment_variable.name,
241239
"outcome": test_case.base_test_case.outcome_variable.name,

causal_testing/discovery/hill_climber_discovery.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
import numpy as np
99
import pandas as pd
1010

11-
from causal_testing.discovery.abstract_discovery import Discovery, TestResult
11+
from causal_testing.discovery.abstract_discovery import Discovery
1212
from causal_testing.specification.causal_dag import CausalDAG
13+
from causal_testing.testing.causal_test_result import TestOutcome
1314

1415

1516
class HillClimberDiscovery(Discovery):
@@ -48,10 +49,10 @@ def sum_test_outcomes(self, test_results: pd.DataFrame) -> dict:
4849
axis=1,
4950
)
5051
# Ensure every column is initialised - Test outcomes that never occurred won't be in the dataframe otherwise
51-
for col in TestResult:
52+
for col in TestOutcome:
5253
if col not in counts.columns:
5354
counts[col] = 0
54-
counts = counts.groupby(["treatment", "outcome"]).sum().reset_index()[list(TestResult)]
55+
counts = counts.groupby(["treatment", "outcome"]).sum().reset_index()[list(TestOutcome)]
5556
# The below line normalises by the number of tests *for each edge*
5657
# Independence tests X _||_ Y get two tests (X _||_ Y and Y _||_ X) because we don't know which way the
5758
# causality flows. We need to normalise this (e.g. if X _||_ Y and Y _||_ X both pass, then the score should be
@@ -89,15 +90,15 @@ def evaluate_fitness(
8990
)
9091
problem_tests = query_df.groupby(["var1", "var2"]).filter(
9192
# Groups are problematic if at least one test fails or no test passes
92-
lambda group: (group["result"] == TestResult.FAIL).any()
93-
or ~(group["result"] == TestResult.PASS).any()
93+
lambda group: (group["result"] == TestOutcome.FAIL).any()
94+
or ~(group["result"] == TestOutcome.PASS).any()
9495
)
9596
problem_edges = problem_tests[["treatment", "outcome"]].apply(tuple, axis=1).tolist()
9697

9798
fitness_values = (
98-
counts.get(TestResult.PASS, 0),
99-
-counts.get(TestResult.FAIL, 0),
100-
-counts.get(TestResult.INESTIMABLE, 0),
99+
counts.get(TestOutcome.PASS, 0),
100+
-counts.get(TestOutcome.FAIL, 0),
101+
-counts.get(TestOutcome.INESTIMABLE, 0),
101102
)
102103
return fitness_values, problem_edges
103104

0 commit comments

Comments
 (0)