Source code for pystatsbio.doseresponse._common

"""Shared result types for dose-response modeling.

``CurveParams`` is the fitted-curve value object (bottom/top/ec50/hill) and is
*not* a Solution — it is a nested value type like a measure. The ``XParams`` /
``XSolution`` pairs below follow the ecosystem envelope: each ``XParams`` is the
frozen payload of computed outputs; each ``XSolution`` wraps a
``core.result.Result[XParams]`` so every fit exposes the same ``.backend_name``
/ ``.timing`` / ``.warnings`` / ``.info`` metadata and Jupyter ``_repr_html_``
(``pystatsbio/CONVENTIONS.md`` B2).
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray
from pystatistics.core.result import Result, SolutionReprMixin


[docs] @dataclass(frozen=True) class CurveParams: """Parameters of a fitted dose-response curve. For 4PL: bottom + (top - bottom) / (1 + (ec50/x)^hill) """ bottom: float top: float ec50: float hill: float asymmetry: float | None = None # 5PL only hormesis: float | None = None # BC.5 only model: str = "LL.4"
[docs] def predict(self, dose: NDArray[np.floating]) -> NDArray[np.floating]: """Predict response at given dose levels.""" from pystatsbio.doseresponse._models import _MODEL_MAP func, param_names = _MODEL_MAP[self.model] kwargs = {name: getattr(self, name) for name in param_names} return func(dose, **kwargs)
[docs] def to_array(self) -> NDArray[np.floating]: """Return parameter vector in model order (for Jacobian indexing).""" from pystatsbio.doseresponse._models import _MODEL_MAP _, param_names = _MODEL_MAP[self.model] return np.array([getattr(self, name) for name in param_names], dtype=np.float64)
[docs] @staticmethod def from_array(params: NDArray[np.floating], model: str) -> CurveParams: """Construct from parameter vector and model name.""" from pystatsbio.doseresponse._models import _MODEL_MAP _, param_names = _MODEL_MAP[model] d = dict(zip(param_names, params, strict=True)) return CurveParams( bottom=d["bottom"], top=d["top"], ec50=d["ec50"], hill=d["hill"], asymmetry=d.get("asymmetry"), hormesis=d.get("hormesis"), model=model, )
[docs] @dataclass(frozen=True) class DoseResponseParams: """Computed payload of fitting a single dose-response curve. The fitted-curve value object lives in ``curve`` (a :class:`CurveParams`); the envelope reserves ``Result.params`` for this payload, so the field is named ``curve`` rather than ``params``. The public :class:`DoseResponseSolution` re-exposes it as ``.params`` to preserve the existing API (``fit_result.params.ec50`` etc.). """ curve: CurveParams se: NDArray[np.floating] # standard errors of parameters cov: NDArray[np.floating] # parameter covariance (n_params, n_params) residuals: NDArray[np.floating] rss: float aic: float bic: float converged: bool n_iter: int model: str # e.g., "LL.4", "LL.5", "W1.4" dose: NDArray[np.floating] response: NDArray[np.floating] n_obs: int jac: NDArray[np.floating] # Jacobian at solution (n_obs, n_params)
[docs] class DoseResponseSolution(SolutionReprMixin): """Public result of a single dose-response fit — wraps ``Result[DoseResponseParams]``. Exposes every fit output as a read-only property plus the uniform ``.backend_name`` / ``.timing`` / ``.warnings`` / ``.info`` metadata and a Jupyter ``_repr_html_`` (via :class:`SolutionReprMixin`). ``.params`` returns the fitted :class:`CurveParams`, preserving the public API that callers rely on (``fit_result.params.ec50`` / ``.to_array()``). """ def __init__(self, result: Result[DoseResponseParams]) -> None: self._result = result # --- Metadata (from the Result envelope) --- @property def backend_name(self) -> str: return self._result.backend_name @property def timing(self) -> dict[str, float] | None: return self._result.timing @property def warnings(self) -> tuple[str, ...]: return self._result.warnings @property def info(self) -> dict: return self._result.info # --- Fit outputs (from the payload) --- @property def params(self) -> CurveParams: """The fitted dose-response curve (bottom/top/ec50/hill).""" return self._result.params.curve @property def se(self) -> NDArray[np.floating]: return self._result.params.se @property def cov(self) -> NDArray[np.floating]: """Parameter covariance matrix (observed information, matching drc).""" return self._result.params.cov @property def residuals(self) -> NDArray[np.floating]: return self._result.params.residuals @property def rss(self) -> float: return self._result.params.rss @property def aic(self) -> float: return self._result.params.aic @property def bic(self) -> float: return self._result.params.bic @property def converged(self) -> bool: return self._result.params.converged @property def n_iter(self) -> int: return self._result.params.n_iter @property def model(self) -> str: return self._result.params.model @property def dose(self) -> NDArray[np.floating]: return self._result.params.dose @property def response(self) -> NDArray[np.floating]: return self._result.params.response @property def n_obs(self) -> int: return self._result.params.n_obs @property def jac(self) -> NDArray[np.floating]: return self._result.params.jac
[docs] def predict(self, dose: NDArray[np.floating] | None = None) -> NDArray[np.floating]: """Predict response. If *dose* is ``None``, use the fitted dose.""" p = self._result.params if dose is None: dose = p.dose return p.curve.predict(dose)
[docs] def summary(self) -> str: """Human-readable summary, similar to R drc::summary().""" from pystatsbio.doseresponse._models import _MODEL_MAP p = self._result.params _, param_names = _MODEL_MAP[p.model] lines = [ f"Dose-response model: {p.model}", "", "Parameter estimates:", ] p_arr = p.curve.to_array() for i, name in enumerate(param_names): val = p_arr[i] se_val = p.se[i] if i < len(p.se) else float("nan") t_val = val / se_val if se_val > 0 and not np.isnan(se_val) else float("nan") lines.append(f" {name:>12s} = {val:>12.6f} (SE = {se_val:.6f}, t = {t_val:.3f})") lines.append("") lines.append(f" RSS = {p.rss:.6f}") lines.append(f" AIC = {p.aic:.2f}") lines.append(f" BIC = {p.bic:.2f}") lines.append(f" n = {p.n_obs}") lines.append(f" Converged: {p.converged}") return "\n".join(lines)
def __repr__(self) -> str: p = self._result.params return ( f"DoseResponseSolution(model={p.model!r}, ec50={p.curve.ec50:.4g}, " f"n_obs={p.n_obs}, converged={p.converged})" )
[docs] @dataclass(frozen=True) class BatchDoseResponseParams: """Computed payload of batch-fitting dose-response curves (HTS). Each array has length n_compounds. """ ec50: NDArray[np.floating] hill: NDArray[np.floating] top: NDArray[np.floating] bottom: NDArray[np.floating] converged: NDArray[np.bool_] rss: NDArray[np.floating] n_compounds: int
[docs] class BatchDoseResponseSolution(SolutionReprMixin): """Public result of batch dose-response fitting — wraps ``Result[BatchDoseResponseParams]``. Exposes every output as a read-only property plus the uniform ``.backend_name`` / ``.timing`` / ``.warnings`` / ``.info`` metadata and a Jupyter ``_repr_html_`` (via :class:`SolutionReprMixin`). """ def __init__(self, result: Result[BatchDoseResponseParams]) -> None: self._result = result # --- Metadata (from the Result envelope) --- @property def backend_name(self) -> str: return self._result.backend_name @property def timing(self) -> dict[str, float] | None: return self._result.timing @property def warnings(self) -> tuple[str, ...]: return self._result.warnings @property def info(self) -> dict: return self._result.info # --- Batch outputs (from the payload) --- @property def ec50(self) -> NDArray[np.floating]: return self._result.params.ec50 @property def hill(self) -> NDArray[np.floating]: return self._result.params.hill @property def top(self) -> NDArray[np.floating]: return self._result.params.top @property def bottom(self) -> NDArray[np.floating]: return self._result.params.bottom @property def converged(self) -> NDArray[np.bool_]: return self._result.params.converged @property def rss(self) -> NDArray[np.floating]: return self._result.params.rss @property def n_compounds(self) -> int: return self._result.params.n_compounds def __repr__(self) -> str: p = self._result.params n_conv = int(np.sum(p.converged)) return ( f"BatchDoseResponseSolution(n_compounds={p.n_compounds}, " f"converged={n_conv}/{p.n_compounds})" )