Skip to content

Commit 920f0d6

Browse files
committed
Can now generate causal test cases directly from a DAG
1 parent 445f661 commit 920f0d6

9 files changed

Lines changed: 330 additions & 672 deletions

File tree

causal_testing/__main__.py

Lines changed: 29 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""This module contains the main entrypoint functionality to the Causal Testing Framework."""
22

33
import argparse
4+
import json
45
import logging
56
from enum import Enum
67
from importlib.metadata import entry_points
@@ -10,7 +11,7 @@
1011
import pandas as pd
1112

1213
from causal_testing.causal_testing_framework import CausalTestingFramework, read_dataframe
13-
from causal_testing.testing.metamorphic_relation import generate_causal_tests
14+
from causal_testing.specification.causal_dag import CausalDAG
1415

1516
logger = logging.getLogger(__name__)
1617

@@ -41,24 +42,6 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
4142
"A causal inference-driven framework for functional black-box testing of complex software.",
4243
)
4344

44-
main_parser.add_argument(
45-
"-l",
46-
"--log_level",
47-
default="WARNING",
48-
type=str.upper,
49-
choices=["NONE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
50-
help="Set the logging level (default: WARNING).",
51-
)
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-
)
61-
6245
subparsers = main_parser.add_subparsers(
6346
help="The action you want to run - call `causal_testing {action} -h` for further details", dest="command"
6447
)
@@ -67,24 +50,6 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
6750
parser_generate = subparsers.add_parser(Command.GENERATE.value, help="Generate causal tests from a DAG")
6851
parser_generate.add_argument("-D", "--dag-path", help="Path to the DAG file (.dot)", required=True)
6952
parser_generate.add_argument("-o", "--output", help="Path for output file (.json)", required=True)
70-
parser_generate.add_argument(
71-
"-e",
72-
"--estimator",
73-
help="The name of the estimator class to use when evaluating tests (defaults to LinearRegressionEstimator)",
74-
default="LinearRegressionEstimator",
75-
)
76-
parser_generate.add_argument(
77-
"-T",
78-
"--effect-type",
79-
help="The effect type to estimate {direct, total}",
80-
default="direct",
81-
)
82-
parser_generate.add_argument(
83-
"-E",
84-
"--estimate-type",
85-
help="The estimate type to use when evaluating tests (defaults to coefficient)",
86-
default="coefficient",
87-
)
8853
parser_generate.add_argument(
8954
"-i", "--ignore-cycles", help="Ignore cycles in DAG", action="store_true", default=False
9055
)
@@ -97,11 +62,10 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
9762
parser_test.add_argument("-D", "--dag-path", help="Path to the DAG file (.dot)", required=True)
9863
parser_test.add_argument("-o", "--output", help="Path for output file (.json)", required=True)
9964
parser_test.add_argument("-i", "--ignore-cycles", help="Ignore cycles in DAG", action="store_true", default=False)
100-
parser_test.add_argument("-d", "--data-paths", help="Paths to data files (.csv)", nargs="+", required=True)
10165
parser_test.add_argument("-t", "--test-config", help="Path to test configuration file (.json)", required=True)
10266
parser_test.add_argument("-q", "--query", help="Query string to filter data (e.g. 'age > 18')", type=str)
10367
parser_test.add_argument(
104-
"-a", "--adequacy", help="Calculate causal test adequacy for each test case", action="store_true", default=False
68+
"-A", "--adequacy", help="Calculate causal test adequacy for each test case", action="store_true", default=False
10569
)
10670
parser_test.add_argument(
10771
"-b",
@@ -127,7 +91,6 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
12791
parser_evaluate.add_argument(
12892
"-i", "--ignore-cycles", help="Ignore cycles in DAG", action="store_true", default=False
12993
)
130-
parser_evaluate.add_argument("-d", "--data-paths", help="Paths to data files (.csv)", nargs="+", required=True)
13194
parser_evaluate.add_argument("-q", "--query", help="Query string to filter data (e.g. 'age > 18')", type=str)
13295
parser_evaluate.add_argument(
13396
"-b",
@@ -147,7 +110,6 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
147110

148111
# Discovery
149112
parser_discover = subparsers.add_parser(Command.DISCOVER.value, help="Discover causal structures from data")
150-
parser_discover.add_argument("-d", "--data-paths", help="Paths to data files (.csv)", nargs="+", required=True)
151113
parser_discover.add_argument(
152114
"-t",
153115
"--technique",
@@ -175,6 +137,26 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
175137
default=[],
176138
)
177139

140+
for parser in [parser_generate, parser_discover, parser_test, parser_evaluate]:
141+
parser.add_argument(
142+
"-l",
143+
"--log_level",
144+
default="WARNING",
145+
type=str.upper,
146+
choices=["NONE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
147+
help="Set the logging level (default: WARNING).",
148+
)
149+
parser.add_argument(
150+
"-a",
151+
"--alpha",
152+
help=(
153+
"The significance level of the confidence intervals used to determine causality. "
154+
"This should be a value between 0 and 1. Defaults to 0.05 for 95%% confidence intervals."
155+
),
156+
default=0.05,
157+
)
158+
parser.add_argument("-d", "--data-paths", help="Paths to data files (.csv)", nargs="+", required=True)
159+
178160
args = main_parser.parse_args(args)
179161

180162
# Assume the user wants test adequacy if they're setting bootstrap_size
@@ -203,16 +185,14 @@ def main() -> None:
203185
match args.command:
204186
case Command.GENERATE:
205187
logging.info("Generating causal tests")
206-
generate_causal_tests(
207-
args.dag_path,
208-
args.output,
209-
args.ignore_cycles,
210-
args.threads,
211-
effect_type=args.effect_type,
212-
estimate_type=args.estimate_type,
213-
estimator=args.estimator,
188+
df = pd.concat(read_dataframe(path) for path in args.data_paths)
189+
causal_dag = CausalDAG(args.dag_path, ignore_cycles=args.ignore_cycles, datatypes=df.dtypes)
190+
causal_tests = causal_dag.generate_causal_tests(
191+
threads=args.threads,
214192
skip=False,
215193
)
194+
with open(args.output, "w", encoding="utf-8") as f:
195+
json.dump({"tests": [test.to_json() for test in causal_tests]}, f)
216196
logging.info("Causal test generation completed successfully.")
217197

218198
case Command.DISCOVER:

causal_testing/causal_testing_framework.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def read_dataframe(file_path: str, **kwargs: dict) -> pd.DataFrame:
4444

4545
suffix = Path(file_path).suffix.lower()
4646

47+
print(suffix, type(suffix))
48+
4749
if suffix in readers:
4850
return readers[suffix](file_path, **kwargs)
4951
raise ValueError(f"Unsupported file extension: '{suffix}'")

causal_testing/discovery/abstract_discovery.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from causal_testing.testing.causal_effect import Negative, Positive
2020
from causal_testing.testing.causal_test_case import CausalTestCase
2121
from causal_testing.testing.causal_test_result import TestOutcome
22-
from causal_testing.testing.metamorphic_relation import generate_metamorphic_relations
2322

2423
# Ignore warnings from statsmodels when we try to evaluate test cases
2524
warnings.simplefilter("ignore")
@@ -202,15 +201,8 @@ def evaluate_tests(self, causal_dag: CausalDAG) -> pd.DataFrame:
202201
ctf.create_variables()
203202
ctf.scenario = Scenario(list(ctf.variables["inputs"].values()) + list(ctf.variables["outputs"].values()))
204203

205-
ctf.test_cases = [
206-
ctf.create_causal_test(
207-
relation.to_json_stub(
208-
alpha=self.alpha,
209-
**self._json_stub_params(relation.base_test_case.outcome_variable),
210-
)
211-
)
212-
for relation in generate_metamorphic_relations(causal_dag)
213-
]
204+
causal_dag.datatypes = self.df.dtypes
205+
ctf.test_cases = causal_dag.generate_causal_tests()
214206

215207
results = []
216208

causal_testing/estimation/abstract_estimator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,14 @@ def add_modelling_assumptions(self):
5858
Add modelling assumptions to the estimator. This is a list of strings which list the modelling assumptions that
5959
must hold if the resulting causal inference is to be considered valid.
6060
"""
61+
62+
def to_json(self) -> dict:
63+
"""
64+
Convert to a JSON serialisable dict object containing all non-standard parameters to reconstruct.
65+
66+
:returns: A JSON serialisable dict representing the object.
67+
"""
68+
result = {"estimator": self.__class__.__name__, "estimator_kwargs": {}}
69+
if self.effect_modifiers:
70+
result["estimator_kwargs"]["effect_modifiers"] = self.effect_modifiers
71+
return result

causal_testing/specification/causal_dag.py

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
from __future__ import annotations
44

55
import logging
6+
from functools import partial
67
from itertools import combinations
8+
from multiprocessing import Pool
79
from typing import Generator, Set, Union
810

911
import networkx as nx
12+
import pandas as pd
1013

14+
from causal_testing.estimation.abstract_estimator import Estimator
15+
from causal_testing.estimation.linear_regression_estimator import LinearRegressionEstimator
16+
from causal_testing.estimation.logistic_regression_estimator import LogisticRegressionEstimator
17+
from causal_testing.estimation.multinomial_regression_estimator import MultinomialRegressionEstimator
18+
from causal_testing.specification.variable import Input, Output, Variable
1119
from causal_testing.testing.base_test_case import BaseTestCase
12-
13-
from .variable import Variable
20+
from causal_testing.testing.causal_effect import NoEffect, SomeEffect
21+
from causal_testing.testing.causal_test_case import CausalTestCase
1422

1523
Node = Union[str, int] # Node type hint: A node is a string or an int
1624

@@ -123,9 +131,10 @@ class CausalDAG(nx.DiGraph):
123131
ensures it is acyclic. A CausalDAG must be specified as a dot file.
124132
"""
125133

126-
def __init__(self, file_path: str = None, ignore_cycles: bool = False, **attr):
134+
def __init__(self, file_path: str = None, ignore_cycles: bool = False, datatypes: pd.Series = None, **attr):
127135
super().__init__(**attr)
128136
self.ignore_cycles = ignore_cycles
137+
self.datatypes = datatypes
129138
if file_path:
130139
if file_path.endswith(".dot"):
131140
graph = nx.DiGraph(nx.nx_pydot.read_dot(file_path))
@@ -533,3 +542,155 @@ def to_dot_string(self) -> str:
533542

534543
def __str__(self):
535544
return f"Nodes: {self.nodes}\nEdges: {self.edges}"
545+
546+
def _estimator(self, base_test_case: BaseTestCase, nodes_to_ignore: set) -> Estimator:
547+
treatment_variable = base_test_case.treatment_variable.name
548+
outcome_variable = base_test_case.outcome_variable.name
549+
550+
if self.datatypes is None or outcome_variable not in self.datatypes:
551+
raise ValueError(f"No datatype specified for {outcome_variable}.")
552+
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]
560+
561+
if pd.api.types.is_bool_dtype(self.datatypes[outcome_variable]):
562+
return (
563+
LogisticRegressionEstimator(base_test_case=base_test_case, adjustment_set=min_adj_set),
564+
"unit_odds_ratio",
565+
)
566+
if pd.api.types.is_categorical_dtype(self.datatypes[outcome_variable]) or pd.api.types.is_object_dtype(
567+
self.datatypes[outcome_variable]
568+
):
569+
return (
570+
MultinomialRegressionEstimator(base_test_case=base_test_case, adjustment_set=min_adj_set),
571+
"unit_odds_ratio",
572+
)
573+
if pd.api.types.is_numeric_dtype(self.datatypes[outcome_variable]):
574+
return LinearRegressionEstimator(base_test_case=base_test_case, adjustment_set=min_adj_set), "coefficient"
575+
raise ValueError(f"Invalid datatype for {outcome_variable}: {self.datatypes[outcome_variable]}")
576+
577+
def generate_causal_test( # pylint: disable=R0912
578+
self, u: str, v: str, nodes_to_ignore: set = None, **kwargs
579+
) -> list[CausalTestCase]:
580+
"""
581+
Construct a metamorphic relation for a given node pair implied by the Causal DAG, or None if no such relation
582+
can be constructed (e.g. because every valid adjustment set contains a node to ignore).
583+
584+
:param u: The treatment node.
585+
:param v: The outcome node.
586+
:param nodes_to_ignore: Set of nodes which will be excluded from causal tests.
587+
:param kwargs: Keyword arguments to be passed through to test case generation.
588+
589+
:return: A list containing ShouldCause and ShouldNotCause metamorphic relations.
590+
"""
591+
592+
if nodes_to_ignore is None:
593+
nodes_to_ignore = set()
594+
595+
causal_tests = []
596+
597+
# Create a ShouldNotCause relation for each pair of nodes that are not directly connected
598+
if ((u, v) not in self.edges) and ((v, u) not in self.edges):
599+
u_in_ancestors = u in nx.ancestors(self, v)
600+
v_in_ancestors = v in nx.ancestors(self, u)
601+
602+
# Case 1: U --> ... --> V or U _||_ V
603+
if u_in_ancestors or (not u_in_ancestors and not v_in_ancestors):
604+
base_test_case = BaseTestCase(Input(u, None), Output(v, None))
605+
estimator, estimate_type = self._estimator(base_test_case, nodes_to_ignore)
606+
if estimator and estimate_type:
607+
causal_tests.append(
608+
CausalTestCase(
609+
base_test_case=base_test_case,
610+
expected_causal_effect=NoEffect(),
611+
estimator=estimator,
612+
estimate_type=estimate_type,
613+
**kwargs,
614+
),
615+
)
616+
617+
# Case 2: V --> ... --> U or U _||_ V
618+
if v in nx.ancestors(self, u) or (not u_in_ancestors and not v_in_ancestors):
619+
base_test_case = BaseTestCase(Input(v, None), Output(u, None))
620+
estimator, estimate_type = self._estimator(base_test_case, nodes_to_ignore)
621+
if estimator and estimate_type:
622+
causal_tests.append(
623+
CausalTestCase(
624+
base_test_case=base_test_case,
625+
expected_causal_effect=NoEffect(),
626+
estimator=estimator,
627+
estimate_type=estimate_type,
628+
**kwargs,
629+
),
630+
)
631+
632+
# Create a ShouldCause relation for each edge (u, v) or (v, u)
633+
elif (u, v) in self.edges:
634+
base_test_case = BaseTestCase(Input(u, None), Output(v, None))
635+
estimator, estimate_type = self._estimator(base_test_case, nodes_to_ignore)
636+
if estimator and estimate_type:
637+
causal_tests.append(
638+
CausalTestCase(
639+
base_test_case=base_test_case,
640+
expected_causal_effect=SomeEffect(),
641+
estimator=estimator,
642+
estimate_type=estimate_type,
643+
**kwargs,
644+
),
645+
)
646+
else:
647+
base_test_case = BaseTestCase(Input(v, None), Output(u, None))
648+
estimator, estimate_type = self._estimator(base_test_case, nodes_to_ignore)
649+
if estimator and estimate_type:
650+
causal_tests.append(
651+
CausalTestCase(
652+
base_test_case=base_test_case,
653+
expected_causal_effect=SomeEffect(),
654+
estimator=estimator,
655+
estimate_type=estimate_type,
656+
**kwargs,
657+
),
658+
)
659+
return causal_tests
660+
661+
def generate_causal_tests(
662+
self, nodes_to_ignore: set = None, threads: int = 0, nodes_to_test: set = None, **kwargs: dict
663+
) -> list[CausalTestCase]:
664+
"""
665+
Construct a list of metamorphic relations implied by the Causal DAG.
666+
This list of metamorphic relations contains a ShouldCause relation for every edge, and a ShouldNotCause
667+
relation for every (minimal) conditional independence relation implied by the structure of the DAG.
668+
669+
:param nodes_to_ignore: Set of nodes which will be excluded from causal tests.
670+
:param threads: Number of threads to use (if generating in parallel).
671+
:param nodes_to_test: Set of nodes to test the relationships between (defaults to all nodes).
672+
:param kwargs: Keyword arguments to be passed through to test case generation.
673+
674+
:return: A list containing ShouldCause and ShouldNotCause metamorphic relations.
675+
"""
676+
677+
if nodes_to_ignore is None:
678+
nodes_to_ignore = set()
679+
nodes_to_ignore = nodes_to_ignore.union(set(self.cycle_nodes()))
680+
681+
if nodes_to_test is None:
682+
nodes_to_test = self.nodes
683+
684+
if threads < 2:
685+
causal_tests = [
686+
self.generate_causal_test(u, v, nodes_to_ignore, **kwargs)
687+
for u, v in combinations(filter(lambda node: node not in nodes_to_ignore, nodes_to_test), 2)
688+
]
689+
else:
690+
with Pool(threads) as pool:
691+
causal_tests = pool.starmap(
692+
partial(self.generate_causal_test, nodes_to_ignore=nodes_to_ignore, **kwargs),
693+
combinations(filter(lambda node: node not in nodes_to_ignore, nodes_to_test), 2),
694+
)
695+
696+
return [item for items in causal_tests for item in items]

0 commit comments

Comments
 (0)