Skip to content
Open
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
6 changes: 6 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Summary
What does this PR do?

## Main file changes
Summarise changes to main files to be reviewed.

## Checklist
Before you mark your PR as ready for review, please ensure you have completed the following.

Expand Down
12 changes: 10 additions & 2 deletions causal_testing/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,15 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace:
"-s",
"--silent",
action="store_true",
help="Do not crash on error. If set to true, errors are recorded as test results.",
help="Do not crash on error. If set to true, errors are recorded as test results. (Defaults to False)",
default=False,
)
parser_test.add_argument(
"-R",
"--include-adequacy-results",
action="store_true",
help="Include_adequacy_results: Whether to include the effect estimate and test outcome for adequacy "
"bootstraps. (Defaults to False)",
default=False,
)

Expand Down Expand Up @@ -257,7 +265,7 @@ def main() -> None:

logging.info("Running tests")
framework.run_tests(silent=args.silent, adequacy=args.adequacy, bootstrap_size=args.bootstrap_size)
framework.save_results(args.output)
framework.save_results(args.output, include_adequacy_results=args.include_adequacy_results)

logging.info("Causal testing completed successfully.")
case Command.EVALUATE:
Expand Down
60 changes: 29 additions & 31 deletions causal_testing/causal_testing_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,45 +145,33 @@ def create_causal_test(self, test: dict) -> CausalTestCase:
:return: CausalTestCase object
:raises: ValueError if invalid estimator or configuration is provided
"""
# Create the estimator with correct parameters
estimator_map = {ff.name: ff for ff in entry_points(group="estimators")}
effect_map = {ff.name: ff for ff in entry_points(group="causal_effects")}

if "estimator" not in test:
raise ValueError("Test configuration must specify an estimator")

if test["estimator"] not in estimator_map:
raise ValueError("Test configuration must specify an estimator.")
estimator_kwargs = test["estimator"]
estimator_name = estimator_kwargs.pop("name")
if estimator_name not in estimator_map:
raise ValueError(
f"Unsupported estimator {test['estimator']}. Supported: {sorted(estimator_map)}. "
f"Unsupported estimator {estimator_name}. Supported: {sorted(estimator_map)}. "
"If you have implemented a custom estimator, you will need to add this to your entrypoints via your "
"pyproject.toml file."
)
estimator = estimator_map.get(estimator_name).load()(**estimator_kwargs)

# Create the estimator with correct parameters
treatment_variable = test.get("treatment_variable")
outcome_variable = test.get("outcome_variable")
estimator_class = estimator_map.get(test["estimator"]).load()
estimator_kwargs = test.get("estimator_kwargs", {})
effect_type = test.get("expected_effect", {}).get("effect_type", "direct")

estimator = estimator_class(
treatment_variable=treatment_variable,
outcome_variable=outcome_variable,
treatment_value=test.get("treatment_value"),
control_value=test.get("control_value"),
alpha=test.get("alpha", 0.05),
**estimator_kwargs,
)

# Get effect type and create expected effect
expected_effect = test["expected_effect"]
effect_type = expected_effect.pop("name")
if effect_type not in effect_map:
# Create an effect with the corect parameters
effect_map = {ff.name: ff for ff in entry_points(group="causal_effects")}
if "expected_effect" not in test:
raise ValueError("Test configuration must specify an expected effect.")
expected_effect_kwargs = test["expected_effect"]
expected_effect_name = expected_effect_kwargs.pop("name")
if expected_effect_name not in effect_map:
raise ValueError(
f"Unsupported causal effect {effect_type}. Supported: {sorted(effect_map)}. "
f"Unsupported causal effect {expected_effect_name}. Supported: {sorted(effect_map)}. "
"If you have implemented a custom causal effect, you will need to add this to your entrypoints via "
"your pyproject.toml file."
)
expected_effect = effect_map[effect_type].load()(**expected_effect)
expected_effect = effect_map[expected_effect_name].load()(**expected_effect_kwargs)

return CausalTestCase(
name=test.get("name"),
Expand Down Expand Up @@ -267,15 +255,25 @@ def evaluate_dag(self, bootstrap_size: bool = 100, alpha: float = 0.05) -> pd.Se

return pd.Series(results).sort_index()

def save_results(self, output_path) -> list:
"""Save test results to JSON file in the expected format."""
def save_results(self, output_path: str, include_adequacy_results: bool = False):
"""
Save test results to JSON file in the expected format.

:param output_path: Path for output file (.json).
:param include_adequacy_results: Whether to include the effect estimate and test outcome for adequacy
bootstraps.
"""
logger.info(f"Saving results to {output_path}")

# Create parent directory if it doesn't exist
Path(output_path).parent.mkdir(parents=True, exist_ok=True)

# Save to file
with open(output_path, "w", encoding="utf-8") as f:
json.dump([test.to_dict() for test in self.test_cases], f, indent=2)
json.dump(
[test.to_dict(include_adequacy_results=include_adequacy_results) for test in self.test_cases],
f,
indent=2,
)

logger.info("Results saved successfully")
16 changes: 8 additions & 8 deletions causal_testing/estimation/instrumental_variable_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,13 @@ def __init__(
self,
outcome_variable: str,
treatment_variable: str,
treatment_value: float,
control_value: float,
instrument: str,
alpha: float = 0.05,
bootstrap_size=100,
):
super().__init__(
treatment_variable=treatment_variable,
outcome_variable=outcome_variable,
treatment_value=treatment_value,
control_value=control_value,
alpha=alpha,
)

Expand All @@ -47,13 +43,17 @@ def add_modelling_assumptions(self):
Add modelling assumptions to the estimator. This is a list of strings which list the modelling assumptions that
must hold if the resulting causal inference is to be considered valid.
"""
self.modelling_assumptions.append("""The instrument and the treatment, and the treatment and the outcome must be
related linearly in the form Y = aX + b.""")
self.modelling_assumptions.append("""The three IV conditions must hold
self.modelling_assumptions.append(
"""The instrument and the treatment, and the treatment and the outcome must be
related linearly in the form Y = aX + b."""
)
self.modelling_assumptions.append(
"""The three IV conditions must hold
(i) Instrument is associated with treatment
(ii) Instrument does not affect outcome except through its potential effect on treatment
(iii) Instrument and outcome do not share causes
""")
"""
)

def iv_coefficient(self, df) -> float:
"""
Expand Down
2 changes: 1 addition & 1 deletion causal_testing/estimation/linear_regression_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def estimate_ate_calculated(self, df: pd.DataFrame) -> EffectEstimate:
return EffectEstimate("ate", pd.Series(treatment_outcome["mean"] - control_outcome["mean"]), ci_low, ci_high)

def _get_confidence_intervals(self, model, treatment):
confidence_intervals = model.conf_int(alpha=self.alpha, cols=None)
confidence_intervals = model.conf_int(alpha=self.alpha)
ci_low, ci_high = (
pd.Series(confidence_intervals[0].loc[treatment]),
pd.Series(confidence_intervals[1].loc[treatment]),
Expand Down
16 changes: 8 additions & 8 deletions causal_testing/testing/causal_test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,11 @@ def estimate_effect(self, df: pd.DataFrame) -> CausalTestResult:
estimate_effect = getattr(self.estimator, f"estimate_{self.effect_measure}")
return estimate_effect(df)

def to_dict(self) -> dict:
def to_dict(self, include_adequacy_results: bool = False) -> dict:
"""
Convert the test case to a python dictionary for easy serialisation as JSON.

:bool include_adequacy_results: Whether to include the effect estimate and test outcome for adequacy bootstraps.
:returns: A JSON serialisable dict representing the test case.
"""
test_case = {
Expand All @@ -185,12 +186,11 @@ def to_dict(self) -> dict:
"query": self.query,
}

for label, attribute in [
("expected_effect", self.expected_causal_effect),
("estimator", self.estimator),
("result", self.result),
]:
if attribute is not None:
test_case[label] = attribute.to_dict()
if self.expected_causal_effect is not None:
test_case["expected_effect"] = self.expected_causal_effect.to_dict()
if self.estimator is not None:
test_case["estimator"] = self.estimator.to_dict()
if self.result is not None:
test_case["result"] = self.result.to_dict(include_adequacy_results=include_adequacy_results)

return test_case
6 changes: 4 additions & 2 deletions causal_testing/testing/causal_test_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ def passed(self) -> bool:
"""
return self.outcome == TestOutcome.PASS

def to_dict(self):
def to_dict(self, include_adequacy_results: bool = False):
"""
Convert the result to a python dictionary for easy serialisation as JSON.

:param include_adequacy_results: Whether to include the effect estimate and test outcome for adequacy
bootstraps.
:returns: A JSON serialisable dict representing the test result.
"""

Expand All @@ -47,6 +49,6 @@ def to_dict(self):

effect_estimate = self.effect_estimate.to_dict() if self.effect_estimate else {}

adequacy = self.adequacy.to_dict() if self.adequacy else {}
adequacy = self.adequacy.to_dict(include_adequacy_results=include_adequacy_results) if self.adequacy else {}

return outcome | effect_estimate | {"adequacy": adequacy}
6 changes: 3 additions & 3 deletions causal_testing/testing/data_adequacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,17 @@ def __init__(
self.successful = successful
self.bootstrap_size = bootstrap_size

def to_dict(self, include_results: bool = False):
def to_dict(self, include_adequacy_results: bool = False):
"""
:returns: the adequacy object as a dictionary.
:param include_results: Whether to serialise the results.
:param include_adequacy_results: Whether to serialise the results.
"""
result = {
"kurtosis": self.kurtosis.to_dict(),
"passing": self.passing,
"successful": self.successful,
"bootstrap_size": self.bootstrap_size,
}
if include_results:
if include_adequacy_results:
return result | {"results": self.results.reset_index(drop=True).to_dict()}
return result
Loading
Loading