Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions causal_testing/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ def main() -> None:
logging.info("Discovering causal structure")
# Need to reset index to allow for multiple files having the same index (i.e. starting at zero).
# Otherwise you end up with duplicate indices, which causes problems further down the line
df = pd.concat([read_dataframe(path) for path in args.data_paths]).reset_index()
df = pd.concat([read_dataframe(path) for path in args.data_paths]).reset_index(drop=True)
if args.variables:
df = df[args.variables]
# Drop unnamed columns
Expand All @@ -237,7 +237,8 @@ def main() -> None:
**kwargs,
)
evolved_dag = discover.discover()
discover.write_dot(evolved_dag, args.output)
if args.output is not None:
nx.drawing.nx_pydot.write_dot(evolved_dag, args.output)
logging.info("Causal structure discovery completed successfully.")
case Command.TEST:
# Create and setup framework
Expand Down
2 changes: 1 addition & 1 deletion causal_testing/causal_testing_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ def evaluate_dag(self, bootstrap_size: bool = 100, alpha: float = 0.05) -> pd.Se
}

sample_results = []
for sample_index in range(bootstrap_size):
for sample_index in tqdm(range(bootstrap_size)):
test_outcomes = {test_outcome: 0 for test_outcome in TestOutcome}
for test_case in self.test_cases:
if test_case.skip:
Expand Down
24 changes: 10 additions & 14 deletions causal_testing/discovery/hill_climber_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
"""

import random
import time

import numpy as np
import pandas as pd
from tqdm import tqdm

from causal_testing.discovery.abstract_discovery import Discovery
from causal_testing.specification.causal_dag import CausalDAG
Expand Down Expand Up @@ -94,11 +94,12 @@ def evaluate_fitness(
or ~(group["result"] == TestOutcome.PASS).any()
)
problem_edges = problem_tests[["treatment", "outcome"]].apply(tuple, axis=1).tolist()
num_tests = sum(counts.values())

fitness_values = (
counts.get(TestOutcome.PASS, 0),
-counts.get(TestOutcome.FAIL, 0),
-counts.get(TestOutcome.INESTIMABLE, 0),
counts.get(TestOutcome.PASS, 0) / num_tests,
-counts.get(TestOutcome.FAIL, 0) / num_tests,
-counts.get(TestOutcome.INESTIMABLE, 0) / num_tests,
)
return fitness_values, problem_edges

Expand All @@ -109,18 +110,17 @@ def discover(self) -> CausalDAG:
:returns: The inferred causal DAG.
"""

start_time = time.time()
individual = CausalDAG()
individual = CausalDAG(ignore_cycles=True)
individual.add_nodes_from(self.df.columns)
individual.add_edges_from(self.possible_edges)
self.remove_cycles(individual)
fitness_values, problem_edges = self.evaluate_fitness(individual)

iterations = self.max_iterations
iterations_without_improvement = 0

while problem_edges and iterations:
iterations -= 1
for _ in tqdm(range(self.max_iterations)):
if not problem_edges:
break

new_individual = individual.copy()
for origin, dest in random.sample(
Expand All @@ -137,7 +137,7 @@ def discover(self) -> CausalDAG:
new_individual.remove_edge(origin, dest)
elif not new_individual.has_edge(origin, dest) and (origin, dest) not in self.exclude_edges:
# Want to bypass the cycle check of CausalDAG as we remove the cycles afterwards
new_individual.add_edge(origin, dest, ignore_cycles=True)
new_individual.add_edge(origin, dest)
self.remove_cycles(new_individual)
new_fitness_values, new_problem_edges = self.evaluate_fitness(new_individual)

Expand All @@ -149,8 +149,4 @@ def discover(self) -> CausalDAG:
else:
iterations_without_improvement += 1

end_time = time.time()
individual.graph["fitness"] = fitness_values
individual.graph["time"] = round(end_time - start_time)

return individual
6 changes: 4 additions & 2 deletions causal_testing/discovery/nsga_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ def binary_string_to_causal_dag(self, individual: np.array) -> CausalDAG:
possible_edges[i] being an edge in the graph and 0 represents it not being.
:returns: The converted CausalDAG instance.
"""
causal_dag = CausalDAG()
causal_dag = CausalDAG(ignore_cycles=True)
origins, destinations = zip(*self.possible_edges)
causal_dag.add_nodes_from(set(origins).union(set(destinations)))
causal_dag.add_edges_from([edge for edge, add in zip(self.possible_edges, individual) if add])
causal_dag.add_edges_from(
[edge for edge, add in zip(self.possible_edges, individual) if add], ignore_cycles=True
)
return causal_dag

def causal_dag_to_binary_string(self, causal_dag: CausalDAG) -> np.array:
Expand Down
57 changes: 48 additions & 9 deletions causal_testing/specification/causal_dag.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,27 @@ def __init__(self, file_path: str = None, ignore_cycles: bool = False, datatypes
"Cycles found. Ignoring them can invalidate causal estimates. Proceed with extreme caution."
)
else:
raise nx.HasACycle("Invalid Causal DAG: contains a cycle.")
raise nx.HasACycle(
f"Invalid Causal DAG: contains a cycle {next(nx.simple_cycles(self))}. "
"If this was intentional, set `dag.ignore_cycles` to true."
)

def copy(self, as_view: bool = False) -> CausalDAG:
"""
Returns a copy of the graph.
The copy method by default returns an independent shallow copy of the graph and attributes. That is, if an
attribute is a container, that container is shared by the original an the copy. Use Python’s copy.deepcopy for
new containers.
If as_view is True then a view is returned instead of a copy.

:param as_view: optional (default=False).
If True, the returned graph-view provides a read-only view of the original graph without
actually copying any data.
"""
new_individual = super().copy(as_view=as_view)
new_individual.datatypes = self.datatypes
new_individual.ignore_cycles = self.ignore_cycles
return new_individual

def check_iv_assumptions(self, treatment, outcome, instrument) -> bool:
"""
Expand Down Expand Up @@ -179,19 +199,38 @@ def check_iv_assumptions(self, treatment, outcome, instrument) -> bool:
raise ValueError(f"Instrument {instrument} and outcome {outcome} share common causes")
return True

def add_edge(self, u_of_edge: Node, v_of_edge: Node, ignore_cycles: bool = False, **attr):
"""Add an edge to the causal DAG.

Overrides the default networkx method to prevent users from adding a cycle.
def add_edge(self, u_of_edge: Node, v_of_edge: Node, **attr):
"""
Add an edge to the causal DAG.
Overrides the default networkx method to prevent users from inadvertently adding a cycle.

:param u_of_edge: Origin node
:param v_of_edge: Destination node
:param ignore_cycles: Whether to ignore cycles that adding the new edge may have introduced.
:param attr: Attributes
:param attr: Attributes passed to superclass method.
"""
super().add_edge(u_of_edge, v_of_edge, **attr)
if not ignore_cycles and not self.is_acyclic():
raise nx.HasACycle("Invalid Causal DAG: contains a cycle.")
if not self.ignore_cycles and not self.is_acyclic():
raise nx.HasACycle(
f"Invalid Causal DAG: contains a cycle {next(nx.simple_cycles(self))}. "
"If this was intentional, set `dag.ignore_cycles` to true."
)

def add_edges_from(self, ebunch_to_add: list, **attr):
"""
Add all the edges in ebunch_to_add.
Overrides the default networkx method to prevent users from inadvertently adding a cycle.

:param ebunch_to_add: container of edges. Each edge given in the container will be added to the graph.
The edges must be given as 2-tuples (u, v) or 3-tuples (u, v, d) where d is a dictionary
containing edge data.
:param attr: Attributes passed to superclass method.
"""
super().add_edges_from(ebunch_to_add, **attr)
if not self.ignore_cycles and not self.is_acyclic():
raise nx.HasACycle(
f"Invalid Causal DAG: contains a cycle {next(nx.simple_cycles(self))}. "
"If this was intentional, set `dag.ignore_cycles` to true."
)

def cycle_nodes(self) -> list:
"""Get the nodes involved in any cycles.
Expand Down
15 changes: 8 additions & 7 deletions tests/discovery_tests/test_abstract_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import unittest
from tempfile import TemporaryDirectory

import networkx as nx
import pandas as pd
from numpy import nan

Expand Down Expand Up @@ -44,7 +45,7 @@ def setUp(self) -> None:
)

def test_simple_cycle(self):
dag = CausalDAG()
dag = CausalDAG(ignore_cycles=True)
dag.add_edges_from([("A", "B"), ("B", "C"), ("C", "A")])
self.assertEqual(simple_cycle(dag), [("A", "B"), ("B", "C"), ("C", "A")])

Expand Down Expand Up @@ -101,7 +102,7 @@ def test_include_edge_wildcard(self):
self.assertEqual(abstract_discovery.include_edges, [(f"x_{n}", "y_1") for n in range(1, 4)])

def test_include_edge_cycle(self):
with self.assertRaises(ValueError):
with self.assertRaises(nx.exception.HasACycle):
AbstractDiscovery(
df=pd.DataFrame(columns=["x_1", "x_2", "x_3", "y_1", "y_2", "y_3", "z_1", "z_2"]),
include_edges=[("x_1", "y_1"), ("y_1", "x_1")],
Expand All @@ -115,19 +116,19 @@ def test_exclude_edge_wildcard(self):
self.assertEqual(abstract_discovery.exclude_edges, [(f"x_{n}", "y_1") for n in range(1, 4)])

def test_remove_cycles(self):
dag = CausalDAG()
dag = CausalDAG(ignore_cycles=True)
dag.add_edges_from([("A", "B"), ("B", "C")])
dag.add_edge("C", "A", ignore_cycles=True)
dag.add_edge("C", "A")
self.assertFalse(dag.is_acyclic(), "A -> B -> C -> A should form a cycle.")

abstract_discovery = AbstractDiscovery(pd.DataFrame())
abstract_discovery.remove_cycles(dag)
self.assertTrue(dag.is_acyclic())

def test_remove_cycles_respects_include_edges(self):
dag = CausalDAG()
dag = CausalDAG(ignore_cycles=True)
dag.add_edges_from([("A", "B"), ("B", "C")])
dag.add_edge("C", "A", ignore_cycles=True)
dag.add_edge("C", "A")

include_edges = {("A", "B"), ("B", "C")}
abstract_discovery = AbstractDiscovery(pd.DataFrame(columns=dag.nodes), include_edges=include_edges)
Expand All @@ -146,7 +147,7 @@ def test_remove_cycles_no_cycles_present(self):
self.assertEqual(len(dag.edges()), 1)

def test_remove_cycles_multiple_cycles(self):
dag = CausalDAG()
dag = CausalDAG(ignore_cycles=True)
dag.add_edges_from([("A", "B"), ("C", "D"), ("B", "A"), ("D", "C")])

abstract_discovery = AbstractDiscovery(pd.DataFrame())
Expand Down
2 changes: 1 addition & 1 deletion tests/discovery_tests/test_hill_climber_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def test_evaluate_fitness(self):

hill_climber = HillClimberDiscovery(scarf_df)
fitness_values, problem_edges = hill_climber.evaluate_fitness(dag)
expected_fitness_values = (4, -2, 0)
expected_fitness_values = (4 / 6, -2 / 6, 0)
expected_problem_edges = [
("length_in", "completed"),
("large_gauge", "completed"),
Expand Down
7 changes: 4 additions & 3 deletions tests/discovery_tests/test_nsga_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ def test_binary_string_to_causal_dag(self):
dag.add_edges_from([("length_in", "completed"), ("large_gauge", "completed")])
nsga = NSGADiscovery(scarf_df)

self.assertTrue(
nx.utils.graphs_equal(dag, nsga.binary_string_to_causal_dag(nsga.causal_dag_to_binary_string(dag)))
)
back_translated_dag = nsga.binary_string_to_causal_dag(nsga.causal_dag_to_binary_string(dag))

self.assertEqual(dag.nodes, back_translated_dag.nodes)
self.assertEqual(dag.edges, back_translated_dag.edges)

def test_multiobjective_fitness(self):
scarf_df = pd.read_csv("tests/resources/data/scarf_data.csv")
Expand Down
Loading