Skip to content

Commit fc0ba67

Browse files
committed
Multinomial regression to support categorical variables
1 parent 468340c commit fc0ba67

5 files changed

Lines changed: 120 additions & 8 deletions

File tree

causal_testing/discovery/abstract_discovery.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ def write_dot(self, individual: CausalDAG, output_file: str):
167167

168168
nx.drawing.nx_pydot.write_dot(individual, output_file)
169169

170+
def _json_stub_params(self, outcome: str) -> str:
171+
if pd.api.types.is_bool_dtype(self.df[outcome]):
172+
return {"estimator": "LogisticRegressionEstimator", "estimate_type": "unit_odds_ratio"}
173+
if pd.api.types.is_categorical_dtype(self.df[outcome]) or pd.api.types.is_object_dtype(self.df[outcome]):
174+
return {"estimator": "MultinomialRegressionEstimator", "estimate_type": "unit_odds_ratio"}
175+
if pd.api.types.is_numeric_dtype(self.df[outcome]):
176+
return {"estimator": "LinearRegressionEstimator", "estimate_type": "coefficient"}
177+
raise ValueError(f"Invalid datatype {self.df.dtypes[outcome]}")
178+
170179
def evaluate_tests(self, causal_dag: CausalDAG) -> pd.DataFrame:
171180
"""
172181
Generate and evaluate causal test cases from the supplied CausalDAG and return a list of edges for which the
@@ -188,13 +197,8 @@ def evaluate_tests(self, causal_dag: CausalDAG) -> pd.DataFrame:
188197
{
189198
"tests": [
190199
relation.to_json_stub(
191-
estimator=(
192-
"LinearRegressionEstimator"
193-
if pd.api.types.is_numeric_dtype(relation.base_test_case.outcome_variable)
194-
else "LogisticRegressionEstimator"
195-
),
196-
estimate_type="unit_odds_ratio",
197200
alpha=self.alpha,
201+
**self._json_stub_params(relation.base_test_case.outcome_variable),
198202
)
199203
for relation in generate_metamorphic_relations(causal_dag)
200204
]
@@ -203,7 +207,9 @@ def evaluate_tests(self, causal_dag: CausalDAG) -> pd.DataFrame:
203207

204208
results = []
205209

206-
for test_case, result in zip(ctf.test_cases, ctf.run_tests(silent=True)):
210+
# We use "silent=False" here to allow for inestimable edges, but it'd be good to have a more stringent
211+
# error catching strategy to catch "genuine" problems (e.g. to do with the structure of the data)
212+
for test_case, result in zip(ctf.test_cases, ctf.run_tests(silent=False)):
207213
if result.effect_estimate is None:
208214
results.append(
209215
{
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""This module contains the LogisticRegressionEstimator class for estimating categorical outcomes."""
2+
3+
import logging
4+
5+
import numpy as np
6+
import pandas as pd
7+
import statsmodels.api as sm
8+
9+
from causal_testing.estimation.abstract_regression_estimator import RegressionEstimator
10+
from causal_testing.estimation.effect_estimate import EffectEstimate
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class MultinomialRegressionEstimator(RegressionEstimator):
16+
"""A Logistic Regression Estimator is a parametric estimator which restricts the variables in the data to a linear
17+
combination of parameters and functions of the variables (note these functions need not be linear). It is designed
18+
for estimating categorical outcomes.
19+
"""
20+
21+
regressor = sm.MNLogit
22+
23+
def add_modelling_assumptions(self):
24+
"""
25+
Add modelling assumptions to the estimator. This is a list of strings which list the modelling assumptions that
26+
must hold if the resulting causal inference is to be considered valid.
27+
"""
28+
self.modelling_assumptions.append(
29+
"The variables in the data must fit a shape which can be expressed as a linear"
30+
"combination of parameters and functions of variables. Note that these functions"
31+
"do not need to be linear."
32+
)
33+
self.modelling_assumptions.append("The outcome must be binary.")
34+
self.modelling_assumptions.append("Independently and identically distributed errors.")
35+
36+
def estimate_unit_odds_ratio(self) -> EffectEstimate:
37+
"""Estimate the odds ratio of increasing the treatment by one. In logistic regression, this corresponds to the
38+
coefficient of the treatment of interest.
39+
40+
:return: The odds ratio with confidence intervals.
41+
"""
42+
model = self.fit_model(self.df)
43+
44+
treatment_columns = [
45+
param
46+
for param in model.params.index
47+
if param == self.base_test_case.treatment_variable.name
48+
or param.startswith(self.base_test_case.treatment_variable.name + "[")
49+
]
50+
51+
conf_int = model.conf_int(self.alpha)
52+
levels_of_interest = [
53+
(level, covariate) for level, covariate in conf_int.index if covariate in treatment_columns
54+
]
55+
confidence_intervals = np.exp(conf_int.loc[levels_of_interest])
56+
57+
# Format the params to a MultiIndexed Series like the confidence intervals for consistent indexing
58+
stacked_params = model.params.stack(dropna=False)
59+
multi_indexed_params = stacked_params.swaplevel(0, 1).sort_index()
60+
multi_indexed_params.index = conf_int.index
61+
62+
result = EffectEstimate(
63+
"unit_odds_ratio",
64+
pd.Series(np.exp(multi_indexed_params[levels_of_interest])),
65+
pd.Series(confidence_intervals["lower"]),
66+
pd.Series(confidence_intervals["upper"]),
67+
)
68+
return result

causal_testing/testing/metamorphic_relation.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ 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"]:
52+
if estimator not in [
53+
"LinearRegressionEstimator",
54+
"LogisticRegressionEstimator",
55+
"MultinomialRegressionEstimator",
56+
]:
5357
raise ValueError(
5458
f"Unsupported estimator {estimator}. "
5559
"We only support autogeneration using LinearRegressionEstimator or LogisticRegressionEstimator."

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ LogisticRegressionEstimator = "causal_testing.estimation.logistic_regression_est
7373
CubicSplineEstimator = "causal_testing.estimation.cubic_spline_estimator:CubicSplineEstimator"
7474
InstrumentalVariableEstimator = "causal_testing.estimation.instrumental_variable_estimator:InstrumentalVariableEstimator"
7575
IPCWEstimator = "causal_testing.estimation.ipcw_estimator:IPCWEstimator"
76+
MultinomialRegressionEstimator = "causal_testing.estimation.multinomial_regression_estimator:MultinomialRegressionEstimator"
7677

7778
[project.entry-points."causal_effects"]
7879
NoEffect = "causal_testing.testing.causal_effect:NoEffect"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import unittest
2+
import pandas as pd
3+
from causal_testing.estimation.multinomial_regression_estimator import MultinomialRegressionEstimator
4+
from causal_testing.testing.base_test_case import BaseTestCase
5+
from causal_testing.specification.variable import Input, Output
6+
7+
8+
class TestMultinomialRegressionEstimator(unittest.TestCase):
9+
"""Test the multinomial regression estimator against the scarf example from
10+
https://investigate.ai/regression/multinomial-regression/.
11+
(For binary categories, this should behave the same as for logistic regression)
12+
"""
13+
14+
@classmethod
15+
def setUpClass(cls) -> None:
16+
cls.scarf_df = pd.read_csv("tests/resources/data/scarf_data.csv")
17+
18+
def test_odds_ratio(self):
19+
df = self.scarf_df.copy()
20+
multinomial_regression_estimator = MultinomialRegressionEstimator(
21+
BaseTestCase(Input("length_in", float), Output("completed", bool)), 65, 55, set(), df
22+
)
23+
effect_estimate = multinomial_regression_estimator.estimate_unit_odds_ratio()
24+
self.assertEqual(round(effect_estimate.value.iloc[0], 4), 0.8948)
25+
26+
def test_odds_ratio_data(self):
27+
df = self.scarf_df.copy()
28+
multinomial_regression_estimator = MultinomialRegressionEstimator(
29+
BaseTestCase(Input("length_in", float), Output("completed", bool)), 65, 55, set()
30+
)
31+
multinomial_regression_estimator.df = df
32+
effect_estimate = multinomial_regression_estimator.estimate_unit_odds_ratio()
33+
self.assertEqual(round(effect_estimate.value.iloc[0], 4), 0.8948)

0 commit comments

Comments
 (0)