Skip to content

Commit a4ce4e5

Browse files
authored
Merge pull request #382 from CITCOM-project/jmafoster1/329-estimator-endpoint
Jmafoster1/329 estimator endpoint
2 parents 2bd52f1 + 6dae63f commit a4ce4e5

18 files changed

Lines changed: 268 additions & 121 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,12 @@ For more information on how to use the Causal Testing Framework, please refer to
8989

9090
2. If you do not already have causal test cases, you can convert your causal DAG to causal tests by running the following command.
9191
```
92-
python -m causal_testing generate --dag-path $PATH_TO_DAG --output $PATH_TO_TESTS
92+
causal-testing generate --dag-path $PATH_TO_DAG --output $PATH_TO_TESTS
9393
```
9494

9595
3. You can now execute your tests by running the following command.
9696
```
97-
python -m causal_testing test --dag-path $PATH_TO_DAG --data-paths $PATH_TO_DATA --test-config $PATH_TO_TESTS --output $OUTPUT
97+
causal-testing test --dag-path $PATH_TO_DAG --data-paths $PATH_TO_DATA --test-config $PATH_TO_TESTS --output $OUTPUT
9898
```
9999
The results will be saved for inspection in a JSON file located at `$OUTPUT`.
100100
In the future, we hope to add a visualisation tool to assist with this.

causal_testing/main.py

Lines changed: 25 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,16 @@
77
from enum import Enum
88
from pathlib import Path
99
from typing import Any, Dict, List, Optional, Sequence, Union
10+
from importlib.metadata import entry_points
1011

1112
import numpy as np
1213
import pandas as pd
1314
from tqdm import tqdm
1415

15-
from causal_testing.estimation.linear_regression_estimator import LinearRegressionEstimator
16-
from causal_testing.estimation.logistic_regression_estimator import LogisticRegressionEstimator
1716
from causal_testing.specification.causal_dag import CausalDAG
1817
from causal_testing.specification.scenario import Scenario
1918
from causal_testing.specification.variable import Input, Output
2019
from causal_testing.testing.base_test_case import BaseTestCase
21-
from causal_testing.testing.causal_effect import Negative, NoEffect, Positive, SomeEffect
2220
from causal_testing.testing.causal_test_adequacy import DataAdequacy
2321
from causal_testing.testing.causal_test_case import CausalTestCase
2422
from causal_testing.testing.causal_test_result import CausalTestResult
@@ -259,48 +257,30 @@ def create_causal_test(self, test: dict, base_test: BaseTestCase) -> CausalTestC
259257
:return: CausalTestCase object
260258
:raises: ValueError if invalid estimator or configuration is provided
261259
"""
262-
# Map effect string to effect class
263-
effect_map = {
264-
"NoEffect": NoEffect(),
265-
"SomeEffect": SomeEffect(),
266-
"Positive": Positive(),
267-
"Negative": Negative(),
268-
}
269-
270-
# Map estimator string to estimator class
271-
estimator_map = {
272-
"LinearRegressionEstimator": LinearRegressionEstimator,
273-
"LogisticRegressionEstimator": LogisticRegressionEstimator,
274-
}
260+
estimator_map = {ff.name: ff for ff in entry_points(group="estimators")}
261+
effect_map = {ff.name: ff for ff in entry_points(group="causal_effects")}
275262

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

279-
# Get the estimator class
280-
estimator_class = estimator_map.get(test["estimator"])
281-
if estimator_class is None:
282-
raise ValueError(f"Unknown estimator: {test['estimator']}")
283-
284-
# Handle combined queries (global and test-specific)
285-
test_query = test.get("query")
286-
combined_query = None
287-
288-
if self.query and test_query:
289-
combined_query = f"({self.query}) and ({test_query})"
290-
logger.info(
291-
f"Combining global query '{self.query}' with test-specific query "
292-
f"'{test_query}' for test '{test['name']}'"
266+
if test["estimator"] not in estimator_map:
267+
raise ValueError(
268+
f"Unsupported estimator {test['estimator']}. Supported: {sorted(estimator_map)}. "
269+
"If you have implemented a custom estimator, you will need to add this to your entrypoints via your "
270+
"pyproject.toml file."
293271
)
294-
elif test_query:
295-
combined_query = test_query
296-
logger.info(f"Using test-specific query for '{test['name']}': {test_query}")
297-
elif self.query:
298-
combined_query = self.query
299-
logger.info(f"Using global query for '{test['name']}': {self.query}")
300272

301-
filtered_df = self.data.query(combined_query) if combined_query else self.data
273+
# Handle global queries
274+
# Test-specific queries are handled by the estimator as not all estimators support them
275+
filtered_df = self.data
276+
if self.query:
277+
filtered_df = self.data.query(self.query)
302278

303279
# Create the estimator with correct parameters
280+
estimator_class = estimator_map.get(test["estimator"]).load()
281+
estimator_kwargs = test.get("estimator_kwargs", {})
282+
if "query" in test:
283+
estimator_kwargs["query"] = test["query"]
304284
estimator = estimator_class(
305285
base_test_case=base_test,
306286
treatment_value=test.get("treatment_value"),
@@ -310,15 +290,19 @@ def create_causal_test(self, test: dict, base_test: BaseTestCase) -> CausalTestC
310290
self.dag.identification(base_test, self.scenario.hidden_variables()),
311291
),
312292
df=filtered_df,
313-
effect_modifiers=None,
314-
formula=test.get("formula"),
315293
alpha=test.get("alpha", 0.05),
316-
query=combined_query,
294+
**estimator_kwargs,
317295
)
318296

319297
# Get effect type and create expected effect
320298
effect_type = test["expected_effect"][base_test.outcome_variable.name]
321-
expected_effect = effect_map[effect_type]
299+
if effect_type not in effect_map:
300+
raise ValueError(
301+
f"Unsupported causal effect {effect_type}. Supported: {sorted(effect_map)}. "
302+
"If you have implemented a custom causal effect, you will need to add this to your entrypoints via "
303+
"your pyproject.toml file."
304+
)
305+
expected_effect = effect_map[effect_type].load()(**test.get("effect_kwargs", {}))
322306

323307
return CausalTestCase(
324308
base_test_case=base_test,

causal_testing/testing/metamorphic_relation.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,26 @@ def to_json_stub(
4949
:param estimator: The name of the estimator class to use when evaluating the test
5050
:param alpha: The significance level to use when calculating the confidence intervals
5151
"""
52+
if estimator not in ["LinearRegressionEstimator", "LogisticRegressionEstimator"]:
53+
raise ValueError(
54+
f"Unsupported estimator {estimator}. "
55+
"We only support autogeneration using LinearRegressionEstimator or LogisticRegressionEstimator."
56+
"More advanced estimators require careful thought that cannot be easily automated."
57+
)
5258
return {
5359
"name": str(self),
5460
"estimator": estimator,
5561
"estimate_type": estimate_type,
5662
"effect": effect_type,
5763
"treatment_variable": self.base_test_case.treatment_variable,
58-
"formula": (
59-
f"{self.base_test_case.outcome_variable} ~ "
60-
f"{' + '.join([self.base_test_case.treatment_variable] + self.adjustment_vars)}"
61-
),
6264
"alpha": alpha,
6365
"skip": skip,
66+
"estimator_kwargs": {
67+
"formula": (
68+
f"{self.base_test_case.outcome_variable} ~ "
69+
f"{' + '.join([self.base_test_case.treatment_variable] + self.adjustment_vars)}"
70+
),
71+
},
6472
}
6573

6674

@@ -271,7 +279,7 @@ def generate_causal_tests(
271279

272280
logger.warning(
273281
"The skip parameter is hard-coded to False during test generation for better integration with the "
274-
"causal testing component (python -m causal_testing test ...)"
282+
"causal testing component (causal-testing test ...)"
275283
"Please carefully review the generated tests and decide which to skip."
276284
)
277285

dafni/entrypoint.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ if [ "$EXECUTION_MODE" = "generate" ]; then
7474
echo "Running causal_testing GENERATE..."
7575
echo "Will write causal tests to: $CAUSAL_TESTS_OUTPUT_PATH"
7676

77-
python -m causal_testing generate \
77+
causal-testing generate \
7878
-D "$DAG_PATH" \
7979
-o "$CAUSAL_TESTS_OUTPUT_PATH" \
8080
-e "$ESTIMATOR" \
@@ -107,7 +107,7 @@ elif [ "$EXECUTION_MODE" = "test" ]; then
107107
# Build command with adequacy flags only when ADEQUACY is true
108108
if [ "$ADEQUACY" = "true" ]; then
109109
echo "DEBUG: Executing WITH adequacy flags"
110-
python -m causal_testing test \
110+
causal-testing test \
111111
-D "$DAG_PATH" \
112112
-d $DATA_PATHS \
113113
-t "$CAUSAL_TESTS_INPUT_PATH" \
@@ -120,7 +120,7 @@ elif [ "$EXECUTION_MODE" = "test" ]; then
120120
$([ "$BATCH_SIZE" != "0" ] && echo "--batch-size $BATCH_SIZE")
121121
else
122122
echo "DEBUG: Executing WITHOUT adequacy flags"
123-
python -m causal_testing test \
123+
causal-testing test \
124124
-D "$DAG_PATH" \
125125
-d $DATA_PATHS \
126126
-t "$CAUSAL_TESTS_INPUT_PATH" \

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ If you have any questions about our framework, you can also reach us by `email <
4141

4242
/modules/causal_specification
4343
/modules/estimators
44+
/modules/custom_estimators
4445
/modules/causal_testing
4546

4647
.. toctree::

docs/source/installation.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,11 @@ Next Steps
9595
* Read about :doc:`modules/causal_specification` to understand causal specifications and :doc:`modules/causal_testing` for the end-to-end causal testing process.
9696
* Run the command for guidance on how to generate your causal tests directly from your input DAG::
9797

98-
python -m causal_testing generate --help
98+
causal-testing generate --help
9999

100100
* and the command on guidance on how to execute your causal tests::
101101

102-
python -m causal_testing test --help
102+
causal-testing test --help
103103

104104

105105
Using the CTF on DAFNI

docs/source/modules/causal_testing.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ In the following sections, we describe the end-to-end process of ``causal testin
3434
In particular, suppose we're interested in how various precautions, such as hand-washing and mask-wearing, can prevent the spread of a virus within a classroom.
3535

3636
1. Modelling Scenario
37-
----------------
37+
---------------------
3838

3939
For our modelling scenario, suppose we define the scenario with the following constraints:
4040

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
Custom Estimators
2+
=================
3+
4+
If the supported :ref:`estimators` are not sufficient for your needs, you can implement your own custom estimator by extending the :code:`Estimator` class and implementing the abstract :code:`add_modelling_assumptions` method and the estimation method for the causal effect measure you wish to calculate.
5+
For example, if you wished to estimate the ATE using the empirical mean of the recorded outcome under the control and treatment values, you would need to implement a method called :code:`estimate_ate`.
6+
If you wished to estimate the risk ratio, you would need to call your method :code:`estimate_risk_ratio`.
7+
The code for the :code:`EmpiricalMeanEstimator` is shown below.
8+
9+
.. code-block:: python
10+
11+
from causal_testing.estimation.abstract_estimator import Estimator
12+
from scipy.stats import bootstrap
13+
14+
class EmpiricalMeanEstimator(Estimator):
15+
"""
16+
Custom estimator class to estimate the causal effect based on the empirical mean.
17+
"""
18+
19+
def add_modelling_assumptions(self):
20+
"""
21+
Add modelling assumptions to the estimator. This is a list of strings which list the modelling assumptions that
22+
must hold if the resulting causal inference is to be considered valid.
23+
"""
24+
self.modelling_assumptions += "The data must contain runs with the exact configuration of interest."
25+
26+
def estimate_ate(self) -> EffectEstimate:
27+
"""Estimate the outcomes under control and treatment.
28+
:return: The empirical average treatment effect.
29+
"""
30+
treatment_variable = self.base_test_case.treatment_variable.name
31+
outcome_variable = self.base_test_case.outcome_variable.name
32+
33+
control_results = self.df.where(self.df[treatment_variable] == self.control_value)[outcome_variable].dropna()
34+
treatment_results = self.df.where(self.df[treatment_variable] == self.treatment_value)[
35+
outcome_variable
36+
].dropna()
37+
38+
def risk_ratio(sample1, sample2):
39+
return sample1.mean() - sample2.mean()
40+
41+
bootstraps = bootstrap((treatment_results, control_results), risk_ratio, confidence_level=self.alpha)
42+
return EffectEstimate(
43+
type="risk_ratio",
44+
value=risk_ratio(treatment_results, control_results),
45+
ci_low=bootstraps.confidence_interval.low,
46+
ci_high=bootstraps.confidence_interval.high,
47+
)
48+
49+
Once you have implemented your estimator, you will need to register it as an extra entry point in your project's :code:`pyproject.toml` file so that the Causal Testing Framework can find it.
50+
For example, if you had defined your :code:`EmpiricalMeanEstimator` class in a module called :code:`empirical_mean_estimator` in a folder called :code:`custom_estimators`, you would register it as follows.
51+
You will also need to reinstall your project, e.g. with :code:`pip install -e .` each time you add a new estimator to your :code:`pyproject.toml`.
52+
You do not need to reinstall each time you edit your project for source code edits.
53+
54+
55+
.. code-block:: ini
56+
57+
[project.entry-points."estimators"]
58+
CustomFlakefighter = "custom_estimators.empirical_mean_estimator:EmpiricalMeanEstimator"
59+
60+
Of course, for this to work, your module needs to be discoverable on your python path.
61+
That is, you should be able to execute :code:`from custom_estimators.empirical_mean_estimator import EmpiricalMeanEstimator` successfully from within the current working directory.
62+
63+
You can also add your custom estimator to causal test cases specified in JSON.
64+
To do so, you can simply set the :code:`estimator` property to the name of your estimator class and the :code:`estimate_type` property to the name of your causal effect measure.
65+
In the above :code:`EmpiricalMeanEstimator` example, :code:`estimator` would be set to :code:`"EmpiricalMeanEstimator"` and :code:`estimate_type` would be set to :code:`"ate"`.

docs/source/modules/estimators.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.. _estimators:
2+
13
Estimators Overview
24
===================
35

docs/source/tutorials/poisson_line_process/poisson_line_process_tutorial.ipynb

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"id": "5adf7cdc-fd96-47a4-a194-f1f060a4c0c5",
66
"metadata": {},
77
"source": [
8-
"## Overview"
8+
"# Statistical Metamorphic Testing using the API"
99
]
1010
},
1111
{
@@ -26,14 +26,6 @@
2626
"Before diving into the details, a good first step is to define your file paths, including your input configurations:"
2727
]
2828
},
29-
{
30-
"cell_type": "markdown",
31-
"id": "56965fba-b90b-4233-a819-bb747ecd9d81",
32-
"metadata": {},
33-
"source": [
34-
"# Statistical Metamorphic Testing using the API"
35-
]
36-
},
3729
{
3830
"cell_type": "code",
3931
"execution_count": 1,
@@ -841,7 +833,7 @@
841833
"name": "python",
842834
"nbconvert_exporter": "python",
843835
"pygments_lexer": "ipython3",
844-
"version": "3.11.14"
836+
"version": "3.11.15"
845837
}
846838
},
847839
"nbformat": 4,

0 commit comments

Comments
 (0)