Source code for phenotypic.analysis._linear_cap_and_lag_model

from __future__ import annotations

from typing import Any, Dict, List, Tuple

import numpy as np
import pandas as pd
from pydantic import field_validator

from phenotypic.analysis.abc_._linear_softplus_base import (
    _DEFAULT_BETA,
    _LinearSoftplusBase,
)
from phenotypic.schema import (
    LINEAR_CAP_AND_LAG_MODEL,
    MODEL_METRICS,
)

_MODE_FIXED_BETA = "fixed_beta"
_MODE_FITTED_BETA = "fitted_beta"


[docs] class LinearCapAndLagModel(_LinearSoftplusBase): r"""Linear-softplus growth fitter with a softplus saturation ceiling. Fits a linear post-lag growth phase with a softplus lag transition *and* a softplus saturation ceiling: .. math:: s_{\text{unclamped}}(t) = \frac{v}{\alpha}\, \ln\!\bigl(1 + e^{\alpha(t-\lambda)}\bigr) + s_0 .. math:: s(t) = s_{\max} - \frac{1}{\beta}\,\ln\!\bigl(1 + e^{\beta(s_{\max} - s_{\text{unclamped}}(t))}\bigr) Use this class when colonies show a clear carrying-capacity plateau in the observation window. For pre-saturation linear growth, use :class:`LinearLagModel` instead. Per-group mode dispatch: The fit picks one of two variants per fit group, recorded in ``LINEAR_CAP_AND_LAG_MODEL.mode``: - ``"fitted_beta"`` — 5-parameter fit. Triggered when ``beta`` is ``None`` *and* a saturation shoulder is detected in the group (smoothed tail slope flattens below ``shoulder_slope_ratio`` times the peak slope). - ``"fixed_beta"`` — 4-parameter fit with ``beta`` held constant. Triggered when the user supplied an explicit scalar ``beta``, *or* when no shoulder is detected. The effective ``beta`` is ``self.beta`` when set, else the module default (``10.0``). Pruning is intentionally not exposed on this class — the saturation plateau IS the model, so dropping the tail would defeat the fit. Attributes: smax (float | None): Fixed carrying capacity. ``None`` falls back to the per-group observed max. beta (float | None): Saturation transition sharpness. ``None`` (default) opts into per-group mode dispatch — fit when a shoulder is present, otherwise held at the module default. Set a positive scalar to force ``"fixed_beta"`` mode unconditionally. stderr_label (str | None): Column providing per-timepoint standard errors used as weights. Same semantics as :class:`LinearLagModel`. s0_prior (bool | float | str | None): Unified Gaussian-prior source for ``s0``. Same dispatch as :class:`LinearLagModel`. s0_prior_cv (float | None): CV coefficient for the prior σ (``σ = cv × µ``). Mutually exclusive with ``s0_prior_sigma``. Defaults to ``None``; if neither knob is set and the prior is engaged, CV=0.05 is applied. s0_prior_sigma (float | None): Absolute σ for the prior. Mutually exclusive with ``s0_prior_cv``. s0_prior_groupby (List[str] | None): Optional coarser grouping for empirical-Bayes pooling of the per-group prior ``µ``. shoulder_slope_ratio (float): Fraction of peak ``ds/dt`` below which the tail slope counts as a saturation shoulder for mode dispatch. Defaults to ``0.05``. Output column naming: ``analyze`` emits metric-qualified columns ``LinearCapAndLagModel_<metric>_<parameter>`` (e.g. fitting ``on="Shape_Area"`` yields ``LinearCapAndLagModel_Area_v``), plus qualified fit-quality columns ``ModelMetrics_<metric>_<label>``. The ``<metric>`` segment is ``self.on`` with a recognized measurement-category prefix stripped. .. note:: **``f_scale`` is unit-sensitive only on the unweighted fit path.** The inherited ``f_scale`` (see :class:`ModelFitter`) is the Huber/robust inlier–outlier threshold expressed in *residual units*, and those units depend on whether the fit is weighted: - **Weighted** (``stderr_label`` set, or the default auto-derived replicate SEM when timepoints carry ≥2 replicates): residuals are divided by σ and are therefore dimensionless, so ``f_scale=1.0`` means "one standard error" and is invariant to the units of ``on``. No retuning is needed when the measurement scale changes. - **Unweighted** (no σ — e.g. single-replicate timepoints): residuals are in the native units of ``on``, so ``f_scale`` is an absolute size threshold. If those units change (e.g. radius in px → mm, which shrinks residuals ~50×) ``f_scale`` must be rescaled to match, or the default robust ``loss="huber"`` never reaches its linear arm and silently degrades to ordinary least squares — losing all outlier suppression. ``loss="linear"`` ignores ``f_scale`` and is unaffected. """ _measurement_infoclass = LINEAR_CAP_AND_LAG_MODEL smax: float | None = None beta: float | None = None shoulder_slope_ratio: float = 0.05 @field_validator("beta") @classmethod def _validate_beta(cls, value: float | None) -> float | None: """Reject a non-positive or non-finite scalar ``beta``. Args: value: The candidate ``beta``. ``None`` opts into per-group mode dispatch and is left untouched. Returns: The validated ``beta`` value. Raises: ValueError: If ``beta`` is not ``None`` and is not a positive finite number. """ if value is not None and (not np.isfinite(value) or value <= 0): raise ValueError( f"beta must be None or a positive finite number, " f"got {value!r}." ) return value @field_validator("shoulder_slope_ratio") @classmethod def _validate_shoulder_slope_ratio(cls, value: float) -> float: """Reject a ``shoulder_slope_ratio`` outside the open ``(0, 1)``. Args: value: The candidate ``shoulder_slope_ratio``. Returns: The validated ratio. Raises: ValueError: If the ratio is not finite or not in ``(0, 1)``. """ if not np.isfinite(value) or value <= 0 or value >= 1: raise ValueError( f"shoulder_slope_ratio must be in (0, 1), " f"got {value!r}." ) return value # ------------------------------------------------------------------ # # Model math # ------------------------------------------------------------------ #
[docs] @staticmethod def model_func( t: np.ndarray | float, v: float, s0: float, lam: float, alpha: float, smax: float, beta: float = _DEFAULT_BETA, ) -> float | np.ndarray: r"""Linear-softplus growth curve with softplus saturation ceiling. Args: t: Time (scalar or array). v: Post-lag growth rate. s0: Initial size. lam: Lag duration. alpha: Lag transition sharpness. smax: Carrying capacity (saturation ceiling). beta: Saturation transition sharpness. Returns: Predicted size at ``t``; scalar when ``t`` is scalar, otherwise an array. """ unclamped = _LinearSoftplusBase._lag_softplus( t=t, v=v, s0=s0, lam=lam, alpha=alpha ) softplus_sat = np.logaddexp(0.0, beta * (smax - unclamped)) / beta return smax - softplus_sat
# ------------------------------------------------------------------ # # Saturation-shoulder detection and per-group mode dispatch # ------------------------------------------------------------------ # def _has_saturation_shoulder(self, group: pd.DataFrame) -> bool: """Return ``True`` iff ``group`` shows a saturation shoulder. Compares a robust peak slope (smoothed ``dy/dt`` maximum) to the minimum of the last few raw gradients. A shoulder is declared when the curve's terminal slope fell to ``self.shoulder_slope_ratio`` times the peak or below — a criterion that still fires on curves with a short post-saturation tail (as few as 2 plateau points), where tail-mean statistics would be diluted by transitional points. A dynamic-range guard rejects flat-noise groups that would otherwise trivially satisfy the ratio test. """ if len(group) < 6: return False g = group.sort_values(self.time_label).reset_index(drop=True) y = g[self.on].to_numpy(dtype=float) t = g[self.time_label].to_numpy(dtype=float) y_finite = y[np.isfinite(y)] if y_finite.size == 0: return False med_abs = abs(float(np.median(y_finite))) + 1e-9 if float(y_finite.max() - y_finite.min()) <= 0.05 * med_abs: return False dy_dt = np.gradient(y, t) # Peak slope: smoothed max — robust to single-point noise spikes. window = min(5, max(3, len(y) // 4)) smoothed = np.convolve(dy_dt, np.ones(window) / window, mode="same") peak_slope = float(np.nanmax(smoothed)) if not np.isfinite(peak_slope) or peak_slope <= 0: return False # Terminal slope: min of the last few raw gradients. Using the # min (not mean) keeps the criterion sensitive to short plateaus # where the final 2-3 points have collapsed to near-zero slope # but earlier tail points are still in the transition. tail_k = max(2, min(3, len(dy_dt) // 5)) tail_min = float(np.nanmin(dy_dt[-tail_k:])) if not np.isfinite(tail_min): return False return tail_min <= self.shoulder_slope_ratio * peak_slope def _mode_for(self, group: pd.DataFrame) -> str: """Pick the fit variant for ``group``. Two-way dispatch: 1. User-explicit scalar ``beta`` → ``"fixed_beta"``. 2. Shoulder detected (``beta is None``) → ``"fitted_beta"``. 3. Otherwise (``beta is None``, no shoulder) → ``"fixed_beta"`` with the module-default ``β``. """ if self.beta is not None: return _MODE_FIXED_BETA if self._has_saturation_shoulder(group): return _MODE_FITTED_BETA return _MODE_FIXED_BETA # ------------------------------------------------------------------ # # Per-group loss kwargs (smax, beta on top of the base σ + prior). # ------------------------------------------------------------------ # def _extra_loss_kwargs(self, group: pd.DataFrame) -> Dict[str, Any]: kw = super()._extra_loss_kwargs(group) kw["smax"] = self._smax_for(group) if self._mode_for(group) == _MODE_FIXED_BETA: kw["beta"] = ( float(self.beta) if self.beta is not None else _DEFAULT_BETA ) # In fitted-beta mode the 5th optimizer entry supplies beta # directly; no kwarg needed. return kw def _smax_for(self, group: pd.DataFrame) -> float: if self.smax is not None: return float(self.smax) return float(group[self.on].max()) # ------------------------------------------------------------------ # # Required fit hooks — 4-or-5 vector depending on mode. # ------------------------------------------------------------------ # def _initial_guess(self, group: pd.DataFrame) -> list[float]: """Heuristic initial guess for the per-group optimizer vector. Returns ``[v, s0, lam, alpha]`` in fixed-beta mode, or ``[v, s0, lam, alpha, beta]`` in fitted-beta mode. The fifth entry seeds at the module-default ``beta``. """ g = group.sort_values(self.time_label) y = g[self.on].to_numpy(dtype=float) t = g[self.time_label].to_numpy(dtype=float) stats = self._inoc_stats(group) if stats is not None: s0_init = stats[0] else: s0_init = float(np.median(y[: max(2, len(y) // 4)])) cut = max(2, int(0.4 * len(y))) if len(y) - cut >= 2 and np.ptp(t[cut:]) > 0: slope = float(np.polyfit(t[cut:], y[cut:], 1)[0]) else: slope = 1.0 v_init = float(np.clip(slope, 1e-4, self._V_UPPER)) y_range = max(y.max() - s0_init, 1e-6) crossing_mask = y > s0_init + 0.1 * y_range if crossing_mask.any(): lam_init = float(t[np.argmax(crossing_mask)]) else: lam_init = float(t[0]) alpha_init = 10.0 guess = [v_init, s0_init, lam_init, alpha_init] if self._mode_for(group) == _MODE_FITTED_BETA: guess.append(_DEFAULT_BETA) return guess def _bounds(self, group: pd.DataFrame) -> Tuple[List[float], List[float]]: """Parameter bounds ``(lower, upper)``. Four entries in fixed-beta mode; a fifth ``(2.0, 50.0)`` pair appended in fitted-beta mode, matching the ``alpha`` range convention (softplus is ~linear below 2 and effectively a hard step above 50). """ t_max = float(group[self.time_label].max()) if t_max <= 0: t_max = 1.0 stats = self._inoc_stats(group) if stats is not None: s0_upper = float(max(3.0 * stats[0], 1e-6)) else: s0_upper = float(group[self.on].max()) or 1.0 if s0_upper <= 0: s0_upper = 1.0 lower = [0.0, 0.0, 0.0, 2.0] upper = [self._V_UPPER, s0_upper, t_max, 50.0] if self._mode_for(group) == _MODE_FITTED_BETA: lower.append(2.0) upper.append(50.0) return lower, upper def _unpack_params( self, x: np.ndarray, group: pd.DataFrame ) -> Dict[Any, Any]: v, s0, lam, alpha = (float(x[i]) for i in range(4)) smax_val = self._smax_for(group) # Derive mode from the optimizer-vector length. The vector # length was set by ``_initial_guess`` (which called # ``_mode_for``); reading it back here is both faster than # re-running shoulder detection and structurally consistent # with whatever mode set the bounds and x0 — no risk of the # detector flipping between calls if a subclass mutates the # group in-place. if len(x) >= 5: mode = _MODE_FITTED_BETA beta_val = float(x[4]) else: mode = _MODE_FIXED_BETA beta_val = ( float(self.beta) if self.beta is not None else _DEFAULT_BETA ) return { LINEAR_CAP_AND_LAG_MODEL.v : v, LINEAR_CAP_AND_LAG_MODEL.s0 : s0, LINEAR_CAP_AND_LAG_MODEL.lam : lam, LINEAR_CAP_AND_LAG_MODEL.alpha: alpha, LINEAR_CAP_AND_LAG_MODEL.smax : smax_val, LINEAR_CAP_AND_LAG_MODEL.beta : beta_val, LINEAR_CAP_AND_LAG_MODEL.mode : mode, } def _predict_kwargs(self, row) -> Dict[str, Any]: smax_val = row[LINEAR_CAP_AND_LAG_MODEL.smax] beta_val = row[LINEAR_CAP_AND_LAG_MODEL.beta] return { "v" : float(row[LINEAR_CAP_AND_LAG_MODEL.v]), "s0" : float(row[LINEAR_CAP_AND_LAG_MODEL.s0]), "lam" : float(row[LINEAR_CAP_AND_LAG_MODEL.lam]), "alpha": float(row[LINEAR_CAP_AND_LAG_MODEL.alpha]), "smax" : float(smax_val), "beta" : _DEFAULT_BETA if pd.isna(beta_val) else float(beta_val), } def _hover_fields(self) -> List[Tuple[str, Any, str]]: return [ ("v", LINEAR_CAP_AND_LAG_MODEL.v, ".4f"), ("s0", LINEAR_CAP_AND_LAG_MODEL.s0, ".3f"), ("lambda", LINEAR_CAP_AND_LAG_MODEL.lam, ".3f"), ("alpha", LINEAR_CAP_AND_LAG_MODEL.alpha, ".2f"), ("smax", LINEAR_CAP_AND_LAG_MODEL.smax, ".3f"), ("beta", LINEAR_CAP_AND_LAG_MODEL.beta, ".2f"), ("mode", LINEAR_CAP_AND_LAG_MODEL.mode, ""), ("RMSE", MODEL_METRICS.RMSE, ".4f"), ]
LinearCapAndLagModel.__doc__ = LINEAR_CAP_AND_LAG_MODEL.append_rst_to_doc(LinearCapAndLagModel)