Source code for causalpy.checks.power_analysis

#   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.
"""
Power analysis utilities for quasi-experimental designs.

Given a completed ``PlaceboInTime`` check, estimates the probability of
detecting a true effect as a function of effect size (i.e. a power curve).
Two strategies are supported: brute-force grid evaluation and a faster
sigmoid-fit approach that extracts the MDE analytically.

References
----------
.. [1] Gelman, A. & Carlin, J. (2014). Beyond Power Calculations:
   Assessing Type S (Sign) and Type M (Magnitude) Errors.
   *Perspectives on Psychological Science*, 9(6), 641-651.
.. [2] Kruschke, J. K. (2013). Bayesian estimation supersedes the t test.
   *Journal of Experimental Psychology: General*, 142(2), 573-603.
"""

from __future__ import annotations

import warnings
from dataclasses import dataclass, field
from typing import Literal

import numpy as np
from matplotlib import pyplot as plt
from scipy.optimize import curve_fit

from causalpy.checks.base import CheckResult


@dataclass
class LogisticFit:
    """Parameters of the fitted two-parameter logistic curve.

    The detection probability is modelled as:

    .. math::
        P(\\text{detect} \\mid x) = \\frac{1}{1 + \\exp(-k(x - x_0))}

    Attributes
    ----------
    k : float
        Steepness (slope) parameter.
    x0 : float
        Midpoint — the effect size at which detection probability is 50%.
    """

    k: float
    x0: float

    def predict(self, x: np.ndarray) -> np.ndarray:
        """Evaluate the fitted logistic at effect sizes ``x``.

        Parameters
        ----------
        x : np.ndarray
            Effect sizes at which to evaluate.

        Returns
        -------
        np.ndarray
            Predicted detection probabilities.
        """
        return _logistic(np.asarray(x, dtype=float), self.k, self.x0)

    def mde(self, power_threshold: float = 0.80) -> float:
        """Extract the Minimum Detectable Effect from the fitted logistic.

        Parameters
        ----------
        power_threshold : float, default 0.80
            The power level at which to compute the MDE.

        Returns
        -------
        float
            The smallest absolute effect size achieving the given power.
        """
        if not 0 < power_threshold < 1:
            raise ValueError(
                f"power_threshold must be in (0, 1), got {power_threshold}"
            )
        if self.k == 0:
            return np.inf
        # Invert the logistic: x = x0 + (1/k) * ln(tau / (1 - tau))
        tau = power_threshold
        return self.x0 + (1.0 / self.k) * np.log(tau / (1.0 - tau))


@dataclass
class PowerCurveResult:
    """Result of a power analysis computation.

    Attributes
    ----------
    effect_sizes : np.ndarray
        The effect sizes at which detection probability was evaluated.
    detection_rates : np.ndarray
        Monte Carlo detection probability at each evaluated effect size.
    strategy : str
        The strategy used: ``"grid"`` or ``"sigmoid"``.
    n_simulations : int
        Number of Monte Carlo replications per evaluation point.
    rope_half_width : float
        ROPE half-width used for the decision rule.
    threshold : float
        Posterior probability cutoff for an actionable decision.
    fitted_curve : LogisticFit or None
        The fitted logistic parameters (only for ``strategy="sigmoid"``).
    smooth_effect_sizes : np.ndarray or None
        Fine-grained effect sizes for the reconstructed smooth curve
        (only for ``strategy="sigmoid"``).
    smooth_detection_rates : np.ndarray or None
        Detection probabilities on the smooth grid, from the fitted
        logistic (only for ``strategy="sigmoid"``).
    mde : float or None
        Minimum Detectable Effect at 80% power (only for
        ``strategy="sigmoid"``).
    """

    effect_sizes: np.ndarray
    detection_rates: np.ndarray
    strategy: str
    n_simulations: int
    rope_half_width: float
    threshold: float
    fitted_curve: LogisticFit | None = None
    smooth_effect_sizes: np.ndarray | None = field(default=None, repr=False)
    smooth_detection_rates: np.ndarray | None = field(default=None, repr=False)
    mde: float | None = None

    def plot(  # pragma: no cover
        self,
        power_threshold: float = 0.80,
        ax: plt.Axes | None = None,
        title: str = "Power Curve",
        xlabel: str = "Effect size",
        ylabel: str = "Detection probability",
        show_mde: bool = True,
    ) -> plt.Figure:
        """Plot the power curve.

        Parameters
        ----------
        power_threshold : float, default 0.80
            Power level at which to draw a horizontal reference line.
        ax : plt.Axes or None
            Axes to plot on.  If ``None``, creates a new figure.
        title : str
            Plot title.
        xlabel : str
            X-axis label.
        ylabel : str
            Y-axis label.
        show_mde : bool, default True
            Whether to annotate the MDE on the plot (sigmoid only).

        Returns
        -------
        plt.Figure
            The figure containing the power curve.
        """

        if ax is None:
            fig, ax = plt.subplots(figsize=(8, 5))
        else:
            fig = ax.get_figure()

        # Plot raw evaluation points
        ax.scatter(
            self.effect_sizes,
            self.detection_rates,
            color="#348ABD",
            s=60,
            zorder=5,
            label="Evaluated points",
            edgecolors="white",
            linewidths=0.8,
        )

        # Plot smooth fitted curve if available
        if (
            self.smooth_effect_sizes is not None
            and self.smooth_detection_rates is not None
        ):
            ax.plot(
                self.smooth_effect_sizes,
                self.smooth_detection_rates,
                color="#348ABD",
                lw=2,
                label="Fitted logistic",
            )

        # Connect points for grid strategy
        if self.strategy == "grid":
            sorted_idx = np.argsort(self.effect_sizes)
            ax.plot(
                self.effect_sizes[sorted_idx],
                self.detection_rates[sorted_idx],
                color="#348ABD",
                lw=1.5,
                alpha=0.6,
            )

        # Power threshold line
        ax.axhline(
            power_threshold,
            color="#E24A33",
            ls="--",
            lw=1.2,
            alpha=0.7,
            label=f"Power = {power_threshold:.0%}",
        )

        # MDE annotation
        if show_mde and self.mde is not None:
            ax.axvline(
                self.mde,
                color="#22c55e",
                ls="--",
                lw=1.2,
                alpha=0.8,
            )
            ax.annotate(
                f"MDE = {self.mde:.3f}",
                xy=(self.mde, power_threshold),
                xytext=(self.mde + 0.02 * ax.get_xlim()[1], power_threshold + 0.05),
                fontsize=9,
                color="#22c55e",
                fontweight="bold",
                arrowprops={"arrowstyle": "->", "color": "#22c55e", "lw": 1.2},
            )

        # ROPE region
        ax.axvspan(
            0,
            self.rope_half_width,
            color="#9ca3af",
            alpha=0.15,
            label="Below ROPE",
        )

        ax.set_xlim(left=0)
        ax.set_ylim(0, 1.05)
        ax.set_xlabel(xlabel)
        ax.set_ylabel(ylabel)
        ax.set_title(title, fontweight="bold")
        ax.legend(loc="lower right", framealpha=0.9)
        ax.grid(True, alpha=0.3)

        fig.tight_layout()
        return fig


def _logistic(x: np.ndarray, k: float, x0: float) -> np.ndarray:
    """Standard two-parameter logistic: 1 / (1 + exp(-k*(x - x0)))."""
    return 1.0 / (1.0 + np.exp(-k * (x - x0)))


def _simulate_detection_rate(
    effect_size: float,
    null_samples: np.ndarray,
    fold_sds: np.ndarray,
    rope_half_width: float,
    threshold: float,
    n_simulations: int,
    n_posterior_samples: int,
    rng: np.random.Generator,
) -> float:
    """Run Monte Carlo simulation at a single effect size.

    Draws null noise, adds the effect, simulates a posterior, and
    checks whether the ROPE rule fires.  Returns the fraction of
    replications that result in a positive decision.
    """
    detections = 0
    for _ in range(n_simulations):
        # Draw null component (structural noise)
        null_component = float(rng.choice(null_samples))
        # True effect = null noise + injected effect
        true_effect = null_component + effect_size
        # Simulate estimation uncertainty
        sigma = float(rng.choice(fold_sds))
        simulated_posterior = rng.normal(
            loc=true_effect, scale=sigma, size=n_posterior_samples
        )
        # Apply ROPE decision
        prob_positive = float((simulated_posterior > rope_half_width).mean())
        if prob_positive >= threshold:
            detections += 1

    return detections / n_simulations


def _extract_null_distribution(
    pit_result: CheckResult,
) -> tuple[np.ndarray, np.ndarray, float, float]:
    """Pull null_samples, fold_sds, rope_half_width, threshold from metadata.

    Raises ValueError if any required key is missing.
    """
    meta = pit_result.metadata
    if "null_samples" not in meta:
        raise ValueError(
            "pit_result does not contain a learned null distribution. "
            "Ensure PlaceboInTime completed successfully with at least "
            "one fold."
        )

    null_samples = np.asarray(meta["null_samples"]).ravel()
    fold_sds_raw = meta.get("fold_sds")
    if fold_sds_raw is None:
        fold_results = meta.get("fold_results", [])
        if not fold_results:
            raise ValueError("pit_result has no fold_sds or fold_results in metadata.")
        fold_sds_raw = [fr.fold_sd for fr in fold_results]
    fold_sds = np.asarray(fold_sds_raw)

    rope_half_width = meta.get("rope_half_width")
    if rope_half_width is None:
        raise ValueError(
            "pit_result has no rope_half_width in metadata. "
            "PlaceboInTime must be configured with a rope_half_width."
        )
    rope_half_width = float(rope_half_width)

    threshold = float(meta.get("threshold", 0.95))
    return null_samples, fold_sds, rope_half_width, threshold


def _build_effect_sizes(
    strategy: str,
    effect_sizes: list[float] | np.ndarray | None,
    tau: float,
    rope_half_width: float,
    n_evaluation_points: int,
) -> np.ndarray:
    """Construct the evaluation grid from user inputs or sensible defaults."""
    if strategy == "grid":
        if effect_sizes is None:
            return np.linspace(0, max(4.0 * tau, 3.0 * rope_half_width), 8)
        return np.asarray(effect_sizes, dtype=float)
    elif strategy == "sigmoid":
        if effect_sizes is None:
            range_min, range_max = 0.0, max(4.0 * tau, 3.0 * rope_half_width)
        elif len(effect_sizes) == 2:
            range_min, range_max = float(effect_sizes[0]), float(effect_sizes[1])
        else:
            range_min = float(np.min(effect_sizes))
            range_max = float(np.max(effect_sizes))
        return np.linspace(range_min, range_max, n_evaluation_points)
    else:
        raise ValueError(f"strategy must be 'grid' or 'sigmoid', got {strategy!r}")


def _fit_sigmoid(
    effect_sizes_arr: np.ndarray,
    detection_rates: np.ndarray,
    tau: float,
    power_threshold: float,
) -> tuple[LogisticFit | None, np.ndarray | None, np.ndarray | None, float | None]:
    """Fit a logistic to the detection rates and extract MDE.

    Returns (fitted_curve, smooth_x, smooth_y, mde).  All four are None
    if curve_fit fails.
    """
    try:
        x0_guess = float(effect_sizes_arr[len(effect_sizes_arr) // 2])
        k_guess = 1.0 / max(tau, 1e-8)

        popt, _ = curve_fit(
            _logistic,
            effect_sizes_arr,
            detection_rates,
            p0=[k_guess, x0_guess],
            bounds=([0, 0], [np.inf, np.inf]),
            maxfev=5000,
        )
        fitted_curve = LogisticFit(k=popt[0], x0=popt[1])

        smooth_effect_sizes = np.linspace(
            float(effect_sizes_arr[0]),
            float(effect_sizes_arr[-1]),
            200,
        )
        smooth_detection_rates = fitted_curve.predict(smooth_effect_sizes)
        mde = fitted_curve.mde(power_threshold)

        if mde < effect_sizes_arr[0] or mde > effect_sizes_arr[-1]:
            warnings.warn(
                f"Fitted MDE ({mde:.4f}) is outside the evaluated range "
                f"[{effect_sizes_arr[0]:.4f}, {effect_sizes_arr[-1]:.4f}]. "
                f"Consider widening the effect_sizes range.",
                stacklevel=3,
            )
        return fitted_curve, smooth_effect_sizes, smooth_detection_rates, mde
    except (RuntimeError, ValueError) as e:
        warnings.warn(
            f"Sigmoid fitting failed: {e}. "
            f"Returning raw evaluation points without fitted curve.",
            stacklevel=3,
        )
        return None, None, None, None


[docs] def power_analysis( pit_result: CheckResult, effect_sizes: list[float] | np.ndarray | None = None, n_simulations: int = 200, strategy: Literal["grid", "sigmoid"] = "grid", n_evaluation_points: int = 5, power_threshold: float = 0.80, n_posterior_samples: int = 1000, random_seed: int | None = None, ) -> PowerCurveResult: """Compute a power curve from a completed PlaceboInTime check. Uses the learned null distribution to simulate what the decision rule would conclude at each hypothetical effect size. Two modes: * ``"grid"`` — brute-force evaluation at every requested point. * ``"sigmoid"`` — evaluate at a few points, fit a logistic, and read off the MDE. Faster when you only need the MDE. Parameters ---------- pit_result : CheckResult A completed ``PlaceboInTime`` check result containing the learned null distribution in its metadata (keys: ``"null_samples"``, ``"fold_sds"``, ``"rope_half_width"``, ``"threshold"``). effect_sizes : list or ndarray or None For ``strategy="grid"``: the exact effect sizes to evaluate. For ``strategy="sigmoid"``: a two-element ``[min, max]`` range within which evaluation points are placed. If ``None``, defaults to ``np.linspace(0, max(4*tau, 3*rope), 8)`` for grid or ``[0, max(4*tau, 3*rope)]`` for sigmoid, where ``tau`` is the null SD and ``rope`` is the ROPE half-width. n_simulations : int, default 200 Number of Monte Carlo replications per evaluation point. strategy : {"grid", "sigmoid"}, default "grid" Estimation strategy. n_evaluation_points : int, default 5 Number of evaluation points for the sigmoid strategy. power_threshold : float, default 0.80 Power level for MDE extraction (sigmoid strategy). n_posterior_samples : int, default 1000 Number of posterior draws per simulated experiment. random_seed : int or None RNG seed for reproducibility. Returns ------- PowerCurveResult Contains evaluated points, optional fitted curve, and MDE. Raises ------ ValueError If ``pit_result`` does not contain the required metadata. Notes ----- At each effect size the function runs ``n_simulations`` Monte Carlo replications. Each replication draws a null component from the status-quo posterior, adds the hypothetical effect, simulates a posterior around that total (using the observed fold SDs), and applies the ROPE rule. The fraction of replications that trigger a positive decision is the estimated power. For the sigmoid strategy a two-parameter logistic is fitted: .. math:: P(\\text{detect} \\mid x) = \\frac{1}{1 + \\exp(-k(x - x_0))} and the MDE is obtained by inverting at the desired power level. Examples -------- >>> import causalpy as cp >>> # After running a PlaceboInTime check: >>> # result = pipeline.run() >>> # pit_check = result.sensitivity_results[0] >>> # curve = cp.checks.power_analysis(pit_check, strategy="sigmoid") >>> # curve.mde # Minimum Detectable Effect at 80% power >>> # curve.plot() """ null_samples, fold_sds, rope_half_width, threshold = _extract_null_distribution( pit_result ) tau = float(null_samples.std()) effect_sizes_arr = _build_effect_sizes( strategy, effect_sizes, tau, rope_half_width, n_evaluation_points ) # Run Monte Carlo simulation at each evaluation point rng = np.random.default_rng(random_seed) detection_rates = np.zeros(len(effect_sizes_arr)) for i, eff in enumerate(effect_sizes_arr): detection_rates[i] = _simulate_detection_rate( effect_size=eff, null_samples=null_samples, fold_sds=fold_sds, rope_half_width=rope_half_width, threshold=threshold, n_simulations=n_simulations, n_posterior_samples=n_posterior_samples, rng=rng, ) # Fit sigmoid if requested fitted_curve = None smooth_effect_sizes = None smooth_detection_rates = None mde = None if strategy == "sigmoid": fitted_curve, smooth_effect_sizes, smooth_detection_rates, mde = _fit_sigmoid( effect_sizes_arr, detection_rates, tau, power_threshold ) return PowerCurveResult( effect_sizes=effect_sizes_arr, detection_rates=detection_rates, strategy=strategy, n_simulations=n_simulations, rope_half_width=rope_half_width, threshold=threshold, fitted_curve=fitted_curve, smooth_effect_sizes=smooth_effect_sizes, smooth_detection_rates=smooth_detection_rates, mde=mde, )