Source code for causalpy.checks.bandwidth

#   Copyright 2022 - 2026 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.
"""
Bandwidth sensitivity check for Regression Discontinuity / Kink designs.

Re-fits the experiment with multiple bandwidth values and compares
effect estimates to assess sensitivity to the bandwidth choice.
"""

from __future__ import annotations

import logging
from typing import Any

import numpy as np
import pandas as pd

from causalpy.checks.base import CheckResult, clone_model
from causalpy.experiments.base import BaseExperiment
from causalpy.experiments.regression_discontinuity import RegressionDiscontinuity
from causalpy.experiments.regression_kink import RegressionKink
from causalpy.pipeline import PipelineContext

logger = logging.getLogger(__name__)


[docs] class BandwidthSensitivity: """Re-fit with multiple bandwidths and compare effect estimates. Parameters ---------- bandwidths : list of float Bandwidth values to test. ``np.inf`` means no bandwidth restriction. Examples -------- >>> import causalpy as cp # doctest: +SKIP >>> check = cp.checks.BandwidthSensitivity( # doctest: +SKIP ... bandwidths=[0.5, 1.0, 2.0, np.inf] ... ) """ applicable_methods: set[type[BaseExperiment]] = { RegressionDiscontinuity, RegressionKink, }
[docs] def __init__(self, bandwidths: list[float] | None = None) -> None: self.bandwidths = bandwidths or [0.25, 0.5, 1.0, 2.0, np.inf]
[docs] def validate(self, experiment: BaseExperiment) -> None: """Verify the experiment is an RD or RKink instance.""" if not isinstance(experiment, (RegressionDiscontinuity, RegressionKink)): raise TypeError( "BandwidthSensitivity requires a RegressionDiscontinuity " "or RegressionKink experiment." )
[docs] def run( self, experiment: BaseExperiment, context: PipelineContext, ) -> CheckResult: """Re-fit the experiment at multiple bandwidths and compare estimates.""" if context.experiment_config is None: raise RuntimeError( "No experiment_config in context. Use EstimateEffect " "before SensitivityAnalysis." ) method = context.experiment_config["method"] base_kwargs = { k: v for k, v in context.experiment_config.items() if k not in ("method", "bandwidth") } rows: list[dict[str, Any]] = [] for bw in self.bandwidths: logger.info("BandwidthSensitivity: fitting with bandwidth=%s", bw) kw = dict(base_kwargs) kw["bandwidth"] = bw if "model" in kw and kw["model"] is not None: kw["model"] = clone_model(kw["model"]) try: alt_experiment = method(context.data, **kw) summary = alt_experiment.effect_summary() row: dict[str, Any] = {"bandwidth": bw} if summary.table is not None and not summary.table.empty: for col in summary.table.columns: row[col] = summary.table[col].iloc[0] rows.append(row) except Exception as exc: logger.warning( "BandwidthSensitivity: failed for bandwidth=%s: %s", bw, exc, ) rows.append({"bandwidth": bw, "error": str(exc)}) table = pd.DataFrame(rows) if rows else None text = ( f"Bandwidth sensitivity analysis: compared {len(self.bandwidths)} " f"bandwidth values. Examine the table for consistency of effect " f"estimates across bandwidths." ) return CheckResult( check_name="BandwidthSensitivity", passed=None, table=table, text=text, )