Source code for pystatsbio.doseresponse._potency

"""EC50/IC50 estimation and relative potency analysis.

EC50 confidence intervals via the delta method on the log scale.
Relative potency (ratio of EC50s from two independent fits) uses
Fieller's theorem.

Validates against: R drc::ED(), drc::EDcomp()
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray
from scipy.stats import norm

from pystatsbio.doseresponse._common import DoseResponseResult
from pystatsbio.doseresponse._models import _MODEL_MAP


[docs] @dataclass(frozen=True) class EC50Result: """EC50 (or IC50) with confidence interval.""" estimate: float se: float ci_lower: float ci_upper: float conf_level: float method: str # 'delta'
[docs] @dataclass(frozen=True) class RelativePotencyResult: """Relative potency (ratio of EC50s) with Fieller's CI.""" ratio: float ci_lower: float ci_upper: float conf_level: float method: str # 'fieller'
# --------------------------------------------------------------------------- # Public API # ---------------------------------------------------------------------------
[docs] def ec50( fit_result: DoseResponseResult, *, conf_level: float = 0.95, method: str = "delta", ) -> EC50Result: """Extract EC50 with confidence interval from a fitted model. For models where EC50 is a direct parameter (LL.4, LL.5, W1.4, W2.4, BC.5), the standard error comes from the parameter covariance matrix. The confidence interval is constructed on the log scale (since EC50 is positive) and back-transformed. Parameters ---------- fit_result : DoseResponseResult A fitted dose-response model. conf_level : float Confidence level (default 0.95). method : str ``'delta'`` (delta method). Returns ------- EC50Result Validates against: R drc::ED() """ if not (0.0 < conf_level < 1.0): raise ValueError(f"conf_level must be in (0, 1), got {conf_level}") if method != "delta": raise ValueError(f"method must be 'delta', got {method!r}") _, param_names = _MODEL_MAP[fit_result.model] ec50_idx = param_names.index("ec50") ec50_val = fit_result.params.ec50 se_ec50 = float(fit_result.se[ec50_idx]) z = norm.ppf(1.0 - (1.0 - conf_level) / 2.0) # CI on log scale (EC50 is always positive) if ec50_val > 0 and se_ec50 > 0 and not np.isnan(se_ec50): se_log = se_ec50 / ec50_val # delta method: se(log(x)) ≈ se(x)/x log_ec50 = np.log(ec50_val) ci_lower = float(np.exp(log_ec50 - z * se_log)) ci_upper = float(np.exp(log_ec50 + z * se_log)) else: ci_lower = float("nan") ci_upper = float("nan") return EC50Result( estimate=ec50_val, se=se_ec50, ci_lower=ci_lower, ci_upper=ci_upper, conf_level=conf_level, method=method, )
[docs] def relative_potency( fit1: DoseResponseResult, fit2: DoseResponseResult, *, conf_level: float = 0.95, ) -> RelativePotencyResult: """Relative potency: ratio of EC50s between two curves with Fieller's CI. Computes ``rho = EC50_2 / EC50_1`` with a confidence interval based on Fieller's theorem for the ratio of two independent estimates. Parameters ---------- fit1 : DoseResponseResult First fitted model (reference). fit2 : DoseResponseResult Second fitted model (test). conf_level : float Confidence level (default 0.95). Returns ------- RelativePotencyResult Validates against: R drc::compParm(), drc::EDcomp() """ if not (0.0 < conf_level < 1.0): raise ValueError(f"conf_level must be in (0, 1), got {conf_level}") _, names1 = _MODEL_MAP[fit1.model] _, names2 = _MODEL_MAP[fit2.model] e1 = fit1.params.ec50 e2 = fit2.params.ec50 se1 = float(fit1.se[names1.index("ec50")]) se2 = float(fit2.se[names2.index("ec50")]) ratio = e2 / e1 if e1 != 0 else float("nan") z = norm.ppf(1.0 - (1.0 - conf_level) / 2.0) z2 = z**2 # Fieller's theorem for a/b where a = e2, b = e1, independent fits (cov=0) a, b = e2, e1 var_a, var_b = se2**2, se1**2 denom = b**2 - z2 * var_b if denom <= 0: # Fieller's CI undefined (denominator crosses zero → infinite CI) ci_lower, ci_upper = float("-inf"), float("inf") else: num_center = a * b D = num_center**2 - (a**2 - z2 * var_a) * (b**2 - z2 * var_b) if D < 0: ci_lower, ci_upper = float("-inf"), float("inf") else: ci_lower = float((num_center - np.sqrt(D)) / denom) ci_upper = float((num_center + np.sqrt(D)) / denom) return RelativePotencyResult( ratio=ratio, ci_lower=ci_lower, ci_upper=ci_upper, conf_level=conf_level, method="fieller", )