Source code for causalpy.experiments.prepostnegd

#   Copyright 2024 The PyMC Labs Developers
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.
"""
Pretest/posttest nonequivalent group design
"""

from typing import List

import arviz as az
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
from patsy import build_design_matrices, dmatrices
from sklearn.base import RegressorMixin

from causalpy.custom_exceptions import (
    DataException,
)
from causalpy.plot_utils import plot_xY
from causalpy.pymc_models import PyMCModel
from causalpy.utils import _is_variable_dummy_coded, round_num

from .base import BaseExperiment

LEGEND_FONT_SIZE = 12


[docs] class PrePostNEGD(BaseExperiment): """ A class to analyse data from pretest/posttest designs :param data: A pandas dataframe :param formula: A statistical model formula :param group_variable_name: Name of the column in data for the group variable, should be either binary or boolean :param pretreatment_variable_name: Name of the column in data for the pretreatment variable :param model: A PyMC model Example -------- >>> import causalpy as cp >>> df = cp.load_data("anova1") >>> seed = 42 >>> result = cp.PrePostNEGD( ... df, ... formula="post ~ 1 + C(group) + pre", ... group_variable_name="group", ... pretreatment_variable_name="pre", ... model=cp.pymc_models.LinearRegression( ... sample_kwargs={ ... "target_accept": 0.95, ... "random_seed": seed, ... "progressbar": False, ... } ... ) ... ) >>> result.summary(round_to=1) # doctest: +NUMBER ==================Pretest/posttest Nonequivalent Group Design=================== Formula: post ~ 1 + C(group) + pre <BLANKLINE> Results: Causal impact = 2, $CI_{94%}$[2, 2] Model coefficients: Intercept -0.5, 94% HDI [-1, 0.2] C(group)[T.1] 2, 94% HDI [2, 2] pre 1, 94% HDI [1, 1] sigma 0.5, 94% HDI [0.5, 0.6] """ supports_ols = False supports_bayes = True
[docs] def __init__( self, data: pd.DataFrame, formula: str, group_variable_name: str, pretreatment_variable_name: str, model=None, **kwargs, ): super().__init__(model=model) self.data = data self.expt_type = "Pretest/posttest Nonequivalent Group Design" self.formula = formula self.group_variable_name = group_variable_name self.pretreatment_variable_name = pretreatment_variable_name self.input_validation() y, X = dmatrices(formula, self.data) self._y_design_info = y.design_info self._x_design_info = X.design_info self.labels = X.design_info.column_names self.y, self.X = np.asarray(y), np.asarray(X) self.outcome_variable_name = y.design_info.column_names[0] # fit the model to the observed (pre-intervention) data if isinstance(self.model, PyMCModel): COORDS = {"coeffs": self.labels, "obs_indx": np.arange(self.X.shape[0])} self.model.fit(X=self.X, y=self.y, coords=COORDS) elif isinstance(self.model, RegressorMixin): raise NotImplementedError("Not implemented for OLS model") else: raise ValueError("Model type not recognized") # Calculate the posterior predictive for the treatment and control for an # interpolated set of pretest values # get the model predictions of the observed data self.pred_xi = np.linspace( np.min(self.data[self.pretreatment_variable_name]), np.max(self.data[self.pretreatment_variable_name]), 200, ) # untreated x_pred_untreated = pd.DataFrame( { self.pretreatment_variable_name: self.pred_xi, self.group_variable_name: np.zeros(self.pred_xi.shape), } ) (new_x_untreated,) = build_design_matrices( [self._x_design_info], x_pred_untreated ) self.pred_untreated = self.model.predict(X=np.asarray(new_x_untreated)) # treated x_pred_treated = pd.DataFrame( { self.pretreatment_variable_name: self.pred_xi, self.group_variable_name: np.ones(self.pred_xi.shape), } ) (new_x_treated,) = build_design_matrices([self._x_design_info], x_pred_treated) self.pred_treated = self.model.predict(X=np.asarray(new_x_treated)) # Evaluate causal impact as equal to the trestment effect self.causal_impact = self.model.idata.posterior["beta"].sel( {"coeffs": self._get_treatment_effect_coeff()} )
[docs] def input_validation(self) -> None: """Validate the input data and model formula for correctness""" if not _is_variable_dummy_coded(self.data[self.group_variable_name]): raise DataException( f""" There must be 2 levels of the grouping variable {self.group_variable_name}. I.e. the treated and untreated. """ )
def _get_treatment_effect_coeff(self) -> str: """Find the beta regression coefficient corresponding to the group (i.e. treatment) effect. For example if self.group_variable_name is 'group' and the labels are `['Intercept', 'C(group)[T.1]', 'pre']` then we want `C(group)[T.1]`. """ for label in self.labels: if (self.group_variable_name in label) & (":" not in label): return label raise NameError("Unable to find coefficient name for the treatment effect") def _causal_impact_summary_stat(self, round_to) -> str: """Computes the mean and 94% credible interval bounds for the causal impact.""" percentiles = self.causal_impact.quantile([0.03, 1 - 0.03]).values ci = ( r"$CI_{94%}$" + f"[{round_num(percentiles[0], round_to)}, {round_num(percentiles[1], round_to)}]" ) causal_impact = f"{round_num(self.causal_impact.mean(), round_to)}, " return f"Causal impact = {causal_impact + ci}"
[docs] def summary(self, round_to=None) -> None: """Print summary of main results and model coefficients. :param round_to: Number of decimals used to round results. Defaults to 2. Use "None" to return raw numbers """ print(f"{self.expt_type:=^80}") print(f"Formula: {self.formula}") print("\nResults:") # TODO: extra experiment specific outputs here print(self._causal_impact_summary_stat(round_to)) self.print_coefficients(round_to)
[docs] def bayesian_plot( self, round_to=None, **kwargs ) -> tuple[plt.Figure, List[plt.Axes]]: """Generate plot for ANOVA-like experiments with non-equivalent group designs.""" fig, ax = plt.subplots( 2, 1, figsize=(7, 9), gridspec_kw={"height_ratios": [3, 1]} ) # Plot raw data sns.scatterplot( x="pre", y="post", hue="group", alpha=0.5, data=self.data, legend=True, ax=ax[0], ) ax[0].set(xlabel="Pretest", ylabel="Posttest") # plot posterior predictive of untreated h_line, h_patch = plot_xY( self.pred_xi, self.pred_untreated["posterior_predictive"].mu, ax=ax[0], plot_hdi_kwargs={"color": "C0"}, label="Control group", ) handles = [(h_line, h_patch)] labels = ["Control group"] # plot posterior predictive of treated h_line, h_patch = plot_xY( self.pred_xi, self.pred_treated["posterior_predictive"].mu, ax=ax[0], plot_hdi_kwargs={"color": "C1"}, label="Treatment group", ) handles.append((h_line, h_patch)) labels.append("Treatment group") ax[0].legend( handles=(h_tuple for h_tuple in handles), labels=labels, fontsize=LEGEND_FONT_SIZE, ) # Plot estimated caual impact / treatment effect az.plot_posterior(self.causal_impact, ref_val=0, ax=ax[1], round_to=round_to) ax[1].set(title="Estimated treatment effect") return fig, ax