Source code for phenotypic.analysis._log_growth_model

from typing import Any, Dict, List, Tuple

import numpy as np
import pandas as pd

from phenotypic.analysis.abc_ import ModelFitter
from phenotypic.sdk_ import ColumnRef
from phenotypic.schema import LOG_GROWTH_MODEL, MODEL_METRICS


[docs] class LogGrowthModel(ModelFitter): r"""Logistic-growth model fitter with regularized least-squares objective. Logistic Kinetics Model: .. math:: N(t) = \frac{K}{1 + \frac{K - N_0}{N_0} e^{-rt}} :math:`N_t`: population size at time :math:`t` :math:`N_0`: initial population size at time :math:`t` :math:`r`: growth rate :math:`K`: carrying capacity (maximum population size) From this we derive: .. math:: \mu_{\max} = \frac{K r}{4} :math:`\mu_{\max}`: maximum specific growth rate Loss Function: To solve for the parameters, we use the following loss function with the SciPy linear least-squares solver: .. math:: J(K, N_0, r) = \frac{1}{n}\sum_{i=1}^{n} \frac{1}{2}\left(f_{K,N_0,r}(t^{(i)}) - N_t^{(i)}\right)^2 + \lambda\left(\left(\frac{dN}{dt}\right)^2 + N_0^2\right) + \beta \frac{\lvert K - \max(N_t) \rvert}{N_t} :math:`\lambda`: regularization term for growth rate and initial population size :math:`\beta`: penalty term for deviations in carrying capacity relative to the largest measurement Attributes: lam (float): The penalty factor applied to growth rates. beta (float): The maximum penalty factor applied to the carrying capacity. Kmax_label (str | None): The column name for the maximum carrying capacity values, if provided. """ _measurement_infoclass = LOG_GROWTH_MODEL lam: float = 1.2 beta: int | float = 2 Kmax_label: ColumnRef | None = None # ------------------------------------------------------------------ # # Model math # ------------------------------------------------------------------ #
[docs] @staticmethod def model_func(t: np.ndarray | float, r: float, K: float, N0: float): r"""Logistic growth model evaluated at ``t``. .. math:: N(t) = K / \left(1 + \frac{K - N_0}{N_0} e^{-rt}\right) Args: t: Time at which the population is evaluated (scalar or array). r: Growth rate. K: Carrying capacity. N0: Initial population size at ``t = 0``. Returns: Population size at ``t``. Scalar when ``t`` is scalar, otherwise an array. """ a = (K - N0) / N0 return K / (1 + a * np.exp(-r * t))
@staticmethod def _loss_func(params, t, y, lam, beta): # type: ignore[override] r"""Regularized residuals for :func:`scipy.optimize.least_squares`. .. math:: J(K, N_0, r) = \frac{1}{n}\sum_{i=1}^{n} \left(f_{K,N_0,r}(t^{(i)}) - N_t^{(i)}\right)^2 + \lambda\left(\left(\frac{dN}{dt}\right)^2 + N_0^2\right) + \beta \frac{\lvert K - N_{n} \rvert}{N_{n}} Args: params: ``[r, K, N0]`` — candidate parameter vector. t: Time points for the observations. y: Observed population sizes (list, ndarray, or pandas Series). lam: Regularization weight for ``dN/dt`` and ``N0``. beta: Scaling factor for the ``K`` vs. observed-max penalty. Returns: Flat residual vector consumed by :func:`scipy.optimize.least_squares`. """ r, K, N0 = params residuals = y - LogGrowthModel.model_func(t=t, r=r, K=K, N0=N0) dN_dt = r * K / 4 reg_term = np.sqrt(lam) * np.array([dN_dt, N0]) if hasattr(y, "values"): y_array = y.values else: y_array = np.array(y) # Last-timepoint value as the best proxy for carrying capacity. y_max_observed = y_array[np.argmax(t)] epsilon = 1e-8 * np.median(np.abs(y_array[y_array > 0])) if epsilon == 0 or np.isnan(epsilon): epsilon = 1e-8 K_penalty_weight = np.sqrt(beta) K_penalty = ( K_penalty_weight * np.abs(K - y_max_observed) / (y_max_observed + epsilon) ) return np.hstack([residuals, reg_term, [K_penalty]]) # ------------------------------------------------------------------ # # Fit hooks # ------------------------------------------------------------------ # def _kmax_for(self, group: pd.DataFrame) -> float: if self.Kmax_label is None: return group[self.on].max() k_max = group[self.Kmax_label].max() if pd.isna(k_max): return group[self.on].max() + 1 return k_max def _initial_guess(self, group: pd.DataFrame) -> List[float]: return [1e-5, group[self.on].max(), 0] def _bounds( self, group: pd.DataFrame ) -> Tuple[List[float], List[float]]: size_data = group[self.on] r_min, r_max = 1e-5, np.inf N0_min, N0_max = 0, size_data.min() # Ensure upper > lower per least_squares contract. if N0_max <= N0_min: N0_max = N0_min + 1 K_min = 0 K_max = self._kmax_for(group) return [r_min, K_min, N0_min], [r_max, K_max, N0_max] def _unpack_params( self, x: np.ndarray, group: pd.DataFrame ) -> Dict[Any, float]: r, K, N0 = float(x[0]), float(x[1]), float(x[2]) return { LOG_GROWTH_MODEL.R_FIT : r, LOG_GROWTH_MODEL.K_FIT : K, LOG_GROWTH_MODEL.N0_FIT : N0, LOG_GROWTH_MODEL.GROWTH_RATE: (r * K) / 4, LOG_GROWTH_MODEL.K_MAX : self._kmax_for(group), } def _predict_kwargs(self, row) -> Dict[str, float]: return { "r" : row[LOG_GROWTH_MODEL.R_FIT], "K" : row[LOG_GROWTH_MODEL.K_FIT], "N0": row[LOG_GROWTH_MODEL.N0_FIT], } def _hyperparam_kwargs(self) -> Dict[str, float]: return {"lam": self.lam, "beta": self.beta} def _post_fit_columns(self) -> Dict[Any, float]: return { LOG_GROWTH_MODEL.LAM : self.lam, LOG_GROWTH_MODEL.BETA: self.beta, } def _extra_agg_columns(self) -> Dict[str, Any]: if self.Kmax_label is None: return {} return {self.Kmax_label: "max"} def _hover_fields(self) -> List[Tuple[str, Any, str]]: return [ ("r", LOG_GROWTH_MODEL.R_FIT, ".4f"), ("K", LOG_GROWTH_MODEL.K_FIT, ".2f"), ("N₀", LOG_GROWTH_MODEL.N0_FIT, ".2f"), ("µmax", LOG_GROWTH_MODEL.GROWTH_RATE, ".4f"), ("RMSE", MODEL_METRICS.RMSE, ".4f"), ]
LogGrowthModel.__doc__ = LOG_GROWTH_MODEL.append_rst_to_doc(LogGrowthModel)