Skip to content

Commit a180925

Browse files
Merge branch 'main' into windows_wheel_install
2 parents d9bed67 + 7ad5827 commit a180925

File tree

11 files changed

+142
-69
lines changed

11 files changed

+142
-69
lines changed

causal_testing/json_front/json_class.py

Lines changed: 69 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -123,54 +123,15 @@ def run_json_tests(self, effects: dict, estimators: dict, f_flag: bool = False,
123123
:param estimators: Dictionary mapping estimator classes to string representations.
124124
:param f_flag: Failure flag that if True the script will stop executing when a test fails.
125125
"""
126-
failures = 0
127-
msg = ""
128126
for test in self.test_plan["tests"]:
129127
if "skip" in test and test["skip"]:
130128
continue
131129
test["estimator"] = estimators[test["estimator"]]
132130
if "mutations" in test:
133131
if test["estimate_type"] == "coefficient":
134-
base_test_case = BaseTestCase(
135-
treatment_variable=next(self.scenario.variables[v] for v in test["mutations"]),
136-
outcome_variable=next(self.scenario.variables[v] for v in test["expected_effect"]),
137-
effect=test.get("effect", "direct"),
138-
)
139-
assert len(test["expected_effect"]) == 1, "Can only have one expected effect."
140-
causal_test_case = CausalTestCase(
141-
base_test_case=base_test_case,
142-
expected_causal_effect=next(
143-
effects[effect] for variable, effect in test["expected_effect"].items()
144-
),
145-
estimate_type="coefficient",
146-
effect_modifier_configuration={
147-
self.scenario.variables[v] for v in test.get("effect_modifiers", [])
148-
},
149-
)
150-
result = self._execute_test_case(causal_test_case=causal_test_case, test=test, f_flag=f_flag)
151-
msg = (
152-
f"Executing test: {test['name']} \n"
153-
+ f" {causal_test_case} \n"
154-
+ " "
155-
+ ("\n ").join(str(result[1]).split("\n"))
156-
+ "==============\n"
157-
+ f" Result: {'FAILED' if result[0] else 'Passed'}"
158-
)
159-
print(msg)
132+
msg = self._run_coefficient_test(test=test, f_flag=f_flag, effects=effects)
160133
else:
161-
abstract_test = self._create_abstract_test_case(test, mutates, effects)
162-
concrete_tests, _ = abstract_test.generate_concrete_tests(5, 0.05)
163-
failures, _ = self._execute_tests(concrete_tests, test, f_flag)
164-
165-
msg = (
166-
f"Executing test: {test['name']} \n"
167-
+ " abstract_test \n"
168-
+ f" {abstract_test} \n"
169-
+ f" {abstract_test.treatment_variable.name},"
170-
+ f" {abstract_test.treatment_variable.distribution} \n"
171-
+ f" Number of concrete tests for test case: {str(len(concrete_tests))} \n"
172-
+ f" {failures}/{len(concrete_tests)} failed for {test['name']}"
173-
)
134+
msg = self._run_ate_test(test=test, f_flag=f_flag, effects=effects, mutates=mutates)
174135
self._append_to_file(msg, logging.INFO)
175136
else:
176137
outcome_variable = next(
@@ -197,8 +158,74 @@ def run_json_tests(self, effects: dict, estimators: dict, f_flag: bool = False,
197158
+ f"control value = {test['control_value']}, treatment value = {test['treatment_value']} \n"
198159
+ f"Result: {'FAILED' if failed else 'Passed'}"
199160
)
161+
print(msg)
200162
self._append_to_file(msg, logging.INFO)
201163

164+
def _run_coefficient_test(self, test: dict, f_flag: bool, effects: dict):
165+
"""Builds structures and runs test case for tests with an estimate_type of 'coefficient'.
166+
167+
:param test: Single JSON test definition stored in a mapping (dict)
168+
:param f_flag: Failure flag that if True the script will stop executing when a test fails.
169+
:param effects: Dictionary mapping effect class instances to string representations.
170+
:return: String containing the message to be outputted
171+
"""
172+
base_test_case = BaseTestCase(
173+
treatment_variable=next(self.scenario.variables[v] for v in test["mutations"]),
174+
outcome_variable=next(self.scenario.variables[v] for v in test["expected_effect"]),
175+
effect=test.get("effect", "direct"),
176+
)
177+
assert len(test["expected_effect"]) == 1, "Can only have one expected effect."
178+
causal_test_case = CausalTestCase(
179+
base_test_case=base_test_case,
180+
expected_causal_effect=next(effects[effect] for variable, effect in test["expected_effect"].items()),
181+
estimate_type="coefficient",
182+
effect_modifier_configuration={self.scenario.variables[v] for v in test.get("effect_modifiers", [])},
183+
)
184+
result = self._execute_test_case(causal_test_case=causal_test_case, test=test, f_flag=f_flag)
185+
msg = (
186+
f"Executing test: {test['name']} \n"
187+
+ f" {causal_test_case} \n"
188+
+ " "
189+
+ ("\n ").join(str(result[1]).split("\n"))
190+
+ "==============\n"
191+
+ f" Result: {'FAILED' if result[0] else 'Passed'}"
192+
)
193+
return msg
194+
195+
def _run_ate_test(self, test: dict, f_flag: bool, effects: dict, mutates: dict):
196+
"""Builds structures and runs test case for tests with an estimate_type of 'ate'.
197+
198+
:param test: Single JSON test definition stored in a mapping (dict)
199+
:param f_flag: Failure flag that if True the script will stop executing when a test fails.
200+
:param effects: Dictionary mapping effect class instances to string representations.
201+
:param mutates: Dictionary mapping mutation functions to string representations.
202+
:return: String containing the message to be outputted
203+
"""
204+
if "sample_size" in test:
205+
sample_size = test["sample_size"]
206+
else:
207+
sample_size = 5
208+
if "target_ks_score" in test:
209+
target_ks_score = test["target_ks_score"]
210+
else:
211+
target_ks_score = 0.05
212+
abstract_test = self._create_abstract_test_case(test, mutates, effects)
213+
concrete_tests, _ = abstract_test.generate_concrete_tests(
214+
sample_size=sample_size, target_ks_score=target_ks_score
215+
)
216+
failures, _ = self._execute_tests(concrete_tests, test, f_flag)
217+
218+
msg = (
219+
f"Executing test: {test['name']} \n"
220+
+ " abstract_test \n"
221+
+ f" {abstract_test} \n"
222+
+ f" {abstract_test.treatment_variable.name},"
223+
+ f" {abstract_test.treatment_variable.distribution} \n"
224+
+ f" Number of concrete tests for test case: {str(len(concrete_tests))} \n"
225+
+ f" {failures}/{len(concrete_tests)} failed for {test['name']}"
226+
)
227+
return msg
228+
202229
def _execute_tests(self, concrete_tests, test, f_flag):
203230
failures = 0
204231
details = []
@@ -286,6 +313,7 @@ def _setup_test(
286313
"outcome": causal_test_case.outcome_variable.name,
287314
"df": causal_test_engine.scenario_execution_data_df,
288315
"effect_modifiers": causal_test_case.effect_modifier_configuration,
316+
"alpha": test["alpha"] if "alpha" in test else 0.05,
289317
}
290318
if "formula" in test:
291319
estimator_kwargs["formula"] = test["formula"]

causal_testing/specification/metamorphic_relation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ def to_json_stub(self, skip=True) -> dict:
181181
"mutations": [self.treatment_var],
182182
"expected_effect": {self.output_var: "NoEffect"},
183183
"formula": f"{self.output_var} ~ {' + '.join([self.treatment_var] + self.adjustment_vars)}",
184+
"alpha": 0.05,
184185
"skip": skip,
185186
}
186187

causal_testing/testing/causal_test_case.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def __init__(
3030
control_value: Any = None,
3131
treatment_value: Any = None,
3232
estimate_type: str = "ate",
33+
estimate_params: dict = None,
3334
effect_modifier_configuration: dict[Variable:Any] = None,
3435
):
3536
"""
@@ -47,6 +48,8 @@ def __init__(
4748
self.treatment_variable = base_test_case.treatment_variable
4849
self.treatment_value = treatment_value
4950
self.estimate_type = estimate_type
51+
if estimate_params is None:
52+
self.estimate_params = {}
5053
self.effect = base_test_case.effect
5154

5255
if effect_modifier_configuration:

causal_testing/testing/causal_test_engine.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ def _return_causal_test_results(self, estimator, causal_test_case):
162162
)
163163
elif causal_test_case.estimate_type == "risk_ratio":
164164
logger.debug("calculating risk_ratio")
165-
risk_ratio, confidence_intervals = estimator.estimate_risk_ratio()
165+
risk_ratio, confidence_intervals = estimator.estimate_risk_ratio(**causal_test_case.estimate_params)
166+
166167
causal_test_result = CausalTestResult(
167168
estimator=estimator,
168169
test_value=TestValue("risk_ratio", risk_ratio),

causal_testing/testing/causal_test_outcome.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ def apply(self, res: CausalTestResult) -> bool:
5151
ci_low = res.ci_low() if isinstance(res.ci_low(), Iterable) else [res.ci_low()]
5252
ci_high = res.ci_high() if isinstance(res.ci_high(), Iterable) else [res.ci_high()]
5353
value = res.test_value.value if isinstance(res.ci_high(), Iterable) else [res.test_value.value]
54+
55+
if not all(ci_low < 0 < ci_high for ci_low, ci_high in zip(ci_low, ci_high)):
56+
print(
57+
"FAILING ON",
58+
[(ci_low, ci_high) for ci_low, ci_high in zip(ci_low, ci_high) if not ci_low < 0 < ci_high],
59+
)
60+
5461
return all(ci_low < 0 < ci_high for ci_low, ci_high in zip(ci_low, ci_high)) or all(
5562
abs(v) < self.atol for v in value
5663
)

causal_testing/testing/causal_test_result.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def push(s, inc=" "):
5656
f"Treatment value: {self.estimator.treatment_value}\n"
5757
f"Outcome: {self.estimator.outcome}\n"
5858
f"Adjustment set: {self.adjustment_set}\n"
59+
f"Formula: {self.estimator.formula}\n"
5960
f"{self.test_value.type}: {result_str}\n"
6061
)
6162
confidence_str = ""
@@ -64,6 +65,7 @@ def push(s, inc=" "):
6465
if "\n" in ci_str:
6566
ci_str = " " + push(pd.DataFrame(self.confidence_intervals).transpose().to_string(header=False))
6667
confidence_str += f"Confidence intervals:{ci_str}\n"
68+
confidence_str += f"Alpha:{self.estimator.alpha}\n"
6769
return base_str + confidence_str
6870

6971
def to_dict(self):

causal_testing/testing/estimators.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ def __init__(
4949
outcome: str,
5050
df: pd.DataFrame = None,
5151
effect_modifiers: dict[str:Any] = None,
52+
alpha: float = 0.05,
5253
):
5354
self.treatment = treatment
5455
self.treatment_value = treatment_value
5556
self.control_value = control_value
5657
self.adjustment_set = adjustment_set
5758
self.outcome = outcome
5859
self.df = df
60+
self.alpha = alpha
5961
if effect_modifiers is None:
6062
self.effect_modifiers = {}
6163
elif isinstance(effect_modifiers, dict):
@@ -179,7 +181,7 @@ def estimate(self, data: pd.DataFrame, adjustment_config: dict = None) -> Regres
179181
# x = x[model.params.index]
180182
return model.predict(x)
181183

182-
def estimate_control_treatment(self, bootstrap_size=100, adjustment_config=None) -> tuple[pd.Series, pd.Series]:
184+
def estimate_control_treatment(self, bootstrap_size, adjustment_config) -> tuple[pd.Series, pd.Series]:
183185
"""Estimate the outcomes under control and treatment.
184186
185187
:return: The estimated control and treatment values and their confidence
@@ -215,14 +217,18 @@ def estimate_control_treatment(self, bootstrap_size=100, adjustment_config=None)
215217

216218
return (y.iloc[1], np.array(control)), (y.iloc[0], np.array(treatment))
217219

218-
def estimate_ate(self, bootstrap_size=100, adjustment_config=None) -> float:
220+
def estimate_ate(self, estimator_params: dict = None) -> float:
219221
"""Estimate the ate effect of the treatment on the outcome. That is, the change in outcome caused
220222
by changing the treatment variable from the control value to the treatment value. Here, we actually
221223
calculate the expected outcomes under control and treatment and take one away from the other. This
222224
allows for custom terms to be put in such as squares, inverses, products, etc.
223225
224226
:return: The estimated average treatment effect and 95% confidence intervals
225227
"""
228+
if estimator_params is None:
229+
estimator_params = {}
230+
bootstrap_size = estimator_params.get("bootstrap_size", 100)
231+
adjustment_config = estimator_params.get("adjustment_config", None)
226232
(control_outcome, control_bootstraps), (
227233
treatment_outcome,
228234
treatment_bootstraps,
@@ -233,7 +239,7 @@ def estimate_ate(self, bootstrap_size=100, adjustment_config=None) -> float:
233239
return estimate, (None, None)
234240

235241
bootstraps = sorted(list(treatment_bootstraps - control_bootstraps))
236-
bound = int((bootstrap_size * 0.05) / 2)
242+
bound = int((bootstrap_size * self.alpha) / 2)
237243
ci_low = bootstraps[bound]
238244
ci_high = bootstraps[bootstrap_size - bound]
239245

@@ -245,14 +251,18 @@ def estimate_ate(self, bootstrap_size=100, adjustment_config=None) -> float:
245251

246252
return estimate, (ci_low, ci_high)
247253

248-
def estimate_risk_ratio(self, bootstrap_size=100, adjustment_config=None) -> float:
254+
def estimate_risk_ratio(self, estimator_params: dict = None) -> float:
249255
"""Estimate the ate effect of the treatment on the outcome. That is, the change in outcome caused
250256
by changing the treatment variable from the control value to the treatment value. Here, we actually
251257
calculate the expected outcomes under control and treatment and divide one by the other. This
252258
allows for custom terms to be put in such as squares, inverses, products, etc.
253259
254260
:return: The estimated risk ratio and 95% confidence intervals.
255261
"""
262+
if estimator_params is None:
263+
estimator_params = {}
264+
bootstrap_size = estimator_params.get("bootstrap_size", 100)
265+
adjustment_config = estimator_params.get("adjustment_config", None)
256266
(control_outcome, control_bootstraps), (
257267
treatment_outcome,
258268
treatment_bootstraps,
@@ -263,7 +273,7 @@ def estimate_risk_ratio(self, bootstrap_size=100, adjustment_config=None) -> flo
263273
return estimate, (None, None)
264274

265275
bootstraps = sorted(list(treatment_bootstraps / control_bootstraps))
266-
bound = ceil((bootstrap_size * 0.05) / 2)
276+
bound = ceil((bootstrap_size * self.alpha) / 2)
267277
ci_low = bootstraps[bound]
268278
ci_high = bootstraps[bootstrap_size - bound]
269279

@@ -301,8 +311,11 @@ def __init__(
301311
df: pd.DataFrame = None,
302312
effect_modifiers: dict[Variable:Any] = None,
303313
formula: str = None,
314+
alpha: float = 0.05,
304315
):
305-
super().__init__(treatment, treatment_value, control_value, adjustment_set, outcome, df, effect_modifiers)
316+
super().__init__(
317+
treatment, treatment_value, control_value, adjustment_set, outcome, df, effect_modifiers, alpha=alpha
318+
)
306319

307320
self.model = None
308321
if effect_modifiers is None:
@@ -336,7 +349,6 @@ def estimate_unit_ate(self) -> float:
336349
"""
337350
model = self._run_linear_regression()
338351
newline = "\n"
339-
print(model.conf_int())
340352
treatment = [self.treatment]
341353
if str(self.df.dtypes[self.treatment]) == "object":
342354
design_info = dmatrix(self.formula.split("~")[1], self.df).design_info
@@ -372,7 +384,7 @@ def estimate_ate(self) -> tuple[float, list[float, float], float]:
372384
# Perform a t-test to compare the predicted outcome of the control and treated individual (ATE)
373385
t_test_results = model.t_test(individuals.loc["treated"] - individuals.loc["control"])
374386
ate = t_test_results.effect[0]
375-
confidence_intervals = list(t_test_results.conf_int().flatten())
387+
confidence_intervals = list(t_test_results.conf_int(alpha=self.alpha).flatten())
376388
return ate, confidence_intervals
377389

378390
def estimate_control_treatment(self, adjustment_config: dict = None) -> tuple[pd.Series, pd.Series]:
@@ -434,25 +446,11 @@ def _run_linear_regression(self) -> RegressionResultsWrapper:
434446
435447
:return: The model after fitting to data.
436448
"""
437-
# 1. Reduce dataframe to contain only the necessary columns
438-
reduced_df = self.df.copy()
439-
necessary_cols = [self.treatment] + list(self.adjustment_set) + [self.outcome]
440-
missing_rows = reduced_df[necessary_cols].isnull().any(axis=1)
441-
reduced_df = reduced_df[~missing_rows]
442-
reduced_df = reduced_df.sort_values([self.treatment])
443-
logger.debug(reduced_df[necessary_cols])
444-
445-
# 2. Add intercept
446-
reduced_df["Intercept"] = 1 # self.intercept
447-
448-
# 3. Estimate the unit difference in outcome caused by unit difference in treatment
449-
cols = [self.treatment]
450-
cols += [x for x in self.adjustment_set if x not in cols]
451449
model = smf.ols(formula=self.formula, data=self.df).fit()
452450
return model
453451

454452
def _get_confidence_intervals(self, model, treatment):
455-
confidence_intervals = model.conf_int(alpha=0.05, cols=None)
453+
confidence_intervals = model.conf_int(alpha=self.alpha, cols=None)
456454
ci_low, ci_high = (
457455
confidence_intervals[0].loc[treatment],
458456
confidence_intervals[1].loc[treatment],
@@ -519,7 +517,7 @@ def estimate_unit_ate(self, bootstrap_size=100):
519517
bootstraps = sorted(
520518
[self.estimate_coefficient(self.df.sample(len(self.df), replace=True)) for _ in range(bootstrap_size)]
521519
)
522-
bound = ceil((bootstrap_size * 0.05) / 2)
520+
bound = ceil((bootstrap_size * self.alpha) / 2)
523521
ci_low = bootstraps[bound]
524522
ci_high = bootstraps[bootstrap_size - bound]
525523

@@ -610,7 +608,7 @@ def estimate_cates(self) -> pd.DataFrame:
610608
# Obtain CATES and confidence intervals
611609
conditional_ates = model.effect(effect_modifier_df, T0=self.control_value, T1=self.treatment_value).flatten()
612610
[ci_low, ci_high] = model.effect_interval(
613-
effect_modifier_df, T0=self.control_value, T1=self.treatment_value, alpha=0.05
611+
effect_modifier_df, T0=self.control_value, T1=self.treatment_value, alpha=self.alpha
614612
)
615613

616614
# Merge results into a dataframe (CATE, confidence intervals, and effect modifier values)

0 commit comments

Comments
 (0)