Source code for openpkpd.plots.pd

"""
PD effect plots.
"""

from __future__ import annotations

import contextlib

import numpy as np
import pandas as pd

from openpkpd.plots._core import _IBM_COLORS, _check_matplotlib, _make_fig_ax, _style


[docs] def effect_time( diag_df: pd.DataFrame, effect_col: str, *, individual: bool = True, mean_overlay: bool = True, ax=None, title: str = "Effect-Time Profile", ): """ Effect vs time plot. Args: diag_df: Diagnostic DataFrame with an effect column. effect_col: Column name for the effect variable. individual: Plot individual profiles. mean_overlay: Overlay mean profile. ax: Existing axes. title: Plot title. """ _check_matplotlib() with _style(): fig, ax = _make_fig_ax(ax) for sid in diag_df["ID"].unique(): sub = diag_df[diag_df["ID"] == sid].sort_values("TIME") ax.scatter( sub["TIME"], sub[effect_col], s=20, alpha=0.5, color="steelblue", edgecolors="none" ) if individual: ax.plot(sub["TIME"], sub[effect_col], color="steelblue", alpha=0.4, linewidth=1.0) if mean_overlay: mean_df = diag_df.groupby("TIME")[effect_col].mean().reset_index() mean_df = mean_df.sort_values("TIME") ax.plot( mean_df["TIME"], mean_df[effect_col], color="black", linewidth=2.0, label="Mean" ) ax.legend(fontsize=9) ax.set_xlabel("Time") ax.set_ylabel(effect_col) ax.set_title(title) fig.tight_layout() return fig
[docs] def emax_curve( diag_df: pd.DataFrame, conc_col: str, effect_col: str, *, emax: float | None = None, ec50: float | None = None, gamma: float = 1.0, e0: float = 0.0, ax=None, title: str = "Emax Curve", ): """ Observed E vs C scatter with optional Emax model overlay. Args: diag_df: Diagnostic DataFrame. conc_col: Column for concentration. effect_col: Column for effect. emax: Emax parameter (skip overlay if None). ec50: EC50 parameter (skip overlay if None). gamma: Hill exponent (default 1.0). e0: Baseline effect. ax: Existing axes. title: Plot title. """ _check_matplotlib() with _style(): fig, ax = _make_fig_ax(ax) x = diag_df[conc_col].values y = diag_df[effect_col].values ax.scatter(x, y, alpha=0.6, edgecolors="none", zorder=2, label="Observed") if emax is not None and ec50 is not None: c_range = np.linspace(0, float(np.nanmax(x)) * 1.1, 200) c_g = c_range**gamma ec50_g = ec50**gamma eff = e0 + emax * c_g / (ec50_g + c_g) ax.plot( c_range, eff, color=_IBM_COLORS[2], linewidth=2.0, label=f"Emax={emax:.2g}, EC50={ec50:.2g}", ) ax.legend(fontsize=9) ax.set_xlabel(conc_col) ax.set_ylabel(effect_col) ax.set_title(title) fig.tight_layout() return fig
[docs] def hysteresis_loop( diag_df: pd.DataFrame, conc_col: str, effect_col: str, *, color_by_time: bool = True, ax=None, title: str = "Hysteresis Loop", ): """ C–E loop colored by time (one loop per subject, then mean). Args: diag_df: Diagnostic DataFrame. conc_col: Concentration column. effect_col: Effect column. color_by_time: Color scatter points by time value. ax: Existing axes. title: Plot title. """ _check_matplotlib() import matplotlib.pyplot as plt with _style(): fig, ax = _make_fig_ax(ax) for sid in diag_df["ID"].unique(): sub = diag_df[diag_df["ID"] == sid].sort_values("TIME") x = sub[conc_col].values y = sub[effect_col].values t = sub["TIME"].values if color_by_time and len(t) > 0: sc = ax.scatter( x, y, c=t, cmap="viridis", s=20, alpha=0.7, edgecolors="none", zorder=2 ) else: ax.scatter(x, y, s=20, alpha=0.5, edgecolors="none", zorder=2) ax.plot(x, y, alpha=0.3, linewidth=0.8, color="gray", zorder=1) if color_by_time: with contextlib.suppress(Exception): plt.colorbar(sc, ax=ax, label="Time") ax.set_xlabel(conc_col) ax.set_ylabel(effect_col) ax.set_title(title) fig.tight_layout() return fig
def indirect_response_plot( diag_df: pd.DataFrame, conc_col: str, response_col: str, *, individual: bool = True, mean_overlay: bool = True, title: str = "Indirect Response", ): """ Dual-panel (PK / PD) time course for indirect response models. Top panel: drug concentration vs time. Bottom panel: response R(t) vs time with observed DV and IPRED. Args: diag_df: Diagnostic DataFrame with TIME, DV, IPRED, conc_col, response_col. conc_col: Column name for drug concentration (top panel). response_col: Column name for pharmacodynamic response (bottom panel). individual: Plot per-subject profiles. mean_overlay: Overlay mean profiles. title: Figure title. """ _check_matplotlib() import matplotlib.pyplot as plt with _style(): fig, (ax_pk, ax_pd) = plt.subplots(2, 1, figsize=(8, 8), sharex=True) ids = diag_df["ID"].unique() for sid in ids: sub = diag_df[diag_df["ID"] == sid].sort_values("TIME") if individual: ax_pk.plot( sub["TIME"], sub[conc_col], color=_IBM_COLORS[0], alpha=0.4, linewidth=1.0 ) ax_pd.scatter( sub["TIME"], sub["DV"], s=15, alpha=0.5, color=_IBM_COLORS[1], edgecolors="none" ) if response_col in sub.columns: ax_pd.plot( sub["TIME"], sub[response_col], color=_IBM_COLORS[1], alpha=0.4, linewidth=1.0, ) if mean_overlay: mean_pk = diag_df.groupby("TIME")[conc_col].mean().reset_index().sort_values("TIME") ax_pk.plot( mean_pk["TIME"], mean_pk[conc_col], color=_IBM_COLORS[0], linewidth=2.0, label="Mean Conc", ) ax_pk.legend(fontsize=9) if response_col in diag_df.columns: mean_pd = ( diag_df.groupby("TIME")[response_col].mean().reset_index().sort_values("TIME") ) ax_pd.plot( mean_pd["TIME"], mean_pd[response_col], color=_IBM_COLORS[1], linewidth=2.0, label="Mean Response", ) ax_pd.legend(fontsize=9) ax_pk.set_ylabel("Concentration") ax_pk.set_title(title) ax_pd.set_xlabel("Time") ax_pd.set_ylabel(response_col) fig.tight_layout() return fig def effect_compartment_plot( diag_df: pd.DataFrame, cp_col: str, ce_col: str, *, individual: bool = False, mean_overlay: bool = True, ax=None, title: str = "Effect Compartment", ): """ Overlay central (Cp) and effect compartment (Ce) concentrations vs time. Illustrates the temporal lag between Cp and Ce caused by Ke0. Args: diag_df: Diagnostic DataFrame with TIME, cp_col, ce_col. cp_col: Column name for central compartment concentration. ce_col: Column name for effect compartment concentration. individual: Plot per-subject thin lines. mean_overlay: Overlay mean Cp and Ce profiles. ax: Existing axes. title: Plot title. """ _check_matplotlib() with _style(): fig, ax = _make_fig_ax(ax) if individual: for sid in diag_df["ID"].unique(): sub = diag_df[diag_df["ID"] == sid].sort_values("TIME") ax.plot(sub["TIME"], sub[cp_col], color=_IBM_COLORS[0], alpha=0.3, linewidth=0.8) ax.plot( sub["TIME"], sub[ce_col], color=_IBM_COLORS[2], alpha=0.3, linewidth=0.8, linestyle="--", ) if mean_overlay: mean_cp = diag_df.groupby("TIME")[cp_col].mean().reset_index().sort_values("TIME") mean_ce = diag_df.groupby("TIME")[ce_col].mean().reset_index().sort_values("TIME") ax.plot( mean_cp["TIME"], mean_cp[cp_col], color=_IBM_COLORS[0], linewidth=2.0, label=f"{cp_col} (Central)", ) ax.plot( mean_ce["TIME"], mean_ce[ce_col], color=_IBM_COLORS[2], linewidth=2.0, linestyle="--", label=f"{ce_col} (Effect)", ) ax.legend(fontsize=9) ax.set_xlabel("Time") ax.set_ylabel("Concentration") ax.set_title(title) fig.tight_layout() return fig
[docs] def pd_individual( diag_df: pd.DataFrame, conc_col: str, effect_col: str, *, subject_ids: list[int] | None = None, n_cols: int = 3, title: str = "Individual PK+PD Profiles", ): """ Per-subject dual-panel (PK and PD) plot. Args: diag_df: Diagnostic DataFrame. conc_col: Concentration column. effect_col: Effect column. subject_ids: Subset of IDs (default: all). n_cols: Number of columns in the grid. title: Overall figure title. """ _check_matplotlib() import matplotlib.pyplot as plt ids = subject_ids or sorted(diag_df["ID"].unique().tolist()) n_subj = len(ids) n_rows = (n_subj + n_cols - 1) // n_cols with _style(): fig, axes = plt.subplots(n_rows * 2, n_cols, figsize=(4 * n_cols, 3 * n_rows * 2)) if axes.ndim == 1: axes = axes.reshape(-1, 1) for idx, sid in enumerate(ids): row = (idx // n_cols) * 2 col = idx % n_cols sub = diag_df[diag_df["ID"] == sid].sort_values("TIME") # PK panel ax_pk = axes[row, col] ax_pk.plot(sub["TIME"], sub[conc_col], color=_IBM_COLORS[0]) ax_pk.scatter( sub["TIME"], sub["DV"], s=15, color=_IBM_COLORS[0], edgecolors="none", alpha=0.8 ) ax_pk.set_ylabel("Conc") ax_pk.set_title(f"Subject {sid}") # PD panel ax_pd = axes[row + 1, col] ax_pd.plot(sub["TIME"], sub[effect_col], color=_IBM_COLORS[2]) ax_pd.set_xlabel("Time") ax_pd.set_ylabel(effect_col) # Hide empty panels for idx in range(n_subj, n_rows * n_cols): row = (idx // n_cols) * 2 col = idx % n_cols axes[row, col].set_visible(False) axes[row + 1, col].set_visible(False) fig.suptitle(title, fontsize=13) fig.tight_layout() return fig