Source code for phenotypic.analysis.abc_._model_fitter

from __future__ import annotations

import abc
import itertools
from abc import ABC
from typing import Any, Callable, Dict, List, Literal, Tuple, TYPE_CHECKING, Union

import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scipy.optimize as optimize
from joblib import Parallel, delayed
from sklearn.metrics import (
    mean_absolute_error,
    mean_squared_error,
    r2_score,
    root_mean_squared_error,
)

from phenotypic.tools_.measurement_info_ import MODEL_METRICS

from ._set_analyzer import SetAnalyzer

if TYPE_CHECKING:
    import plotly.graph_objects as go


[docs] class ModelFitter(SetAnalyzer, ABC): """Template base class for grouped least-squares model fitting. Subclasses provide the mathematical model (`model_func`), the loss function (`_loss_func`), an initial parameter guess, parameter bounds, and a small set of hooks that let the base class drive ``analyze``, ``show``, ``dash``, and ``results`` without hard-coding parameter names. Fit-quality metrics (MAE, MSE, RMSE, R²) and optimizer diagnostics (loss, status, sample count) are emitted under the shared :class:`MODEL_METRICS` category so that every ``ModelFitter`` subclass produces a consistent set of diagnostic columns. Attributes: time_label (str): Column name representing the independent variable (typically time). loss (Literal["linear"]): Loss calculation method passed through to :func:`scipy.optimize.least_squares`. verbose (bool): Whether to print detailed optimizer output. """ _measurement_info_class: type def __init__( self, on: str, groupby: List[str], time_label: str = "Metadata_Time", agg_func: Callable | str | list | dict | None = "mean", *, num_workers: int = 1, loss: Literal["linear"] = "linear", verbose: bool = False, ): super().__init__( on=on, groupby=groupby, agg_func=agg_func, num_workers=num_workers ) self._latest_model_scores: pd.DataFrame = pd.DataFrame() self.time_label = time_label self.loss = loss self.verbose = verbose # ------------------------------------------------------------------ # # Abstract model math — subclasses must implement # ------------------------------------------------------------------ #
[docs] @staticmethod @abc.abstractmethod def model_func(*args, **kwargs): """Mathematical model. First positional argument is the independent variable.""" pass
def _params_to_model_kwargs(self, params) -> Dict[str, float]: """Map the raw optimizer vector to kwargs for ``model_func``. Required only when the subclass relies on the default MSE :meth:`_loss_func`. Subclasses that override ``_loss_func`` with a fully custom residual (e.g. regularized) do not need to implement this hook. """ raise NotImplementedError( f"{type(self).__name__} uses the default MSE loss but does " f"not implement `_params_to_model_kwargs`. Either implement " f"it to map the optimizer vector to `model_func` kwargs, or " f"override `_loss_func` with a fully custom residual." ) def _loss_func(self, params, t, y, **_): """Default residual vector — plain MSE against the observations. ``scipy.optimize.least_squares`` minimizes half the sum of squared residuals, so returning ``y - model_func(t, …)`` here yields the standard MSE fit. Subclasses may override this to add regularization, penalties, or robust loss terms (see :class:`LogGrowthModel` for an example). Extra keyword arguments are accepted but ignored so that any hyperparameters supplied by :meth:`_hyperparam_kwargs` can be safely forwarded without needing to match this default signature. """ return y - self.model_func(t, **self._params_to_model_kwargs(params)) # ------------------------------------------------------------------ # # Abstract fit hooks — subclasses must implement # ------------------------------------------------------------------ # @abc.abstractmethod def _initial_guess(self, group: pd.DataFrame) -> list[float]: """Initial guess ``x0`` for :func:`scipy.optimize.least_squares`.""" pass @abc.abstractmethod def _bounds( self, group: pd.DataFrame ) -> Tuple[List[float], List[float]]: """Return ``(lower, upper)`` bounds for the fitted parameters.""" pass @abc.abstractmethod def _unpack_params( self, x: np.ndarray, group: pd.DataFrame ) -> Dict[Any, float]: """Map optimizer output to a dict keyed by MeasurementInfo members. Must include every fitted/derived parameter column produced by this model (e.g. ``r``, ``K``, ``N0``, ``µmax`` for log-growth). May also include per-group bounds that should be preserved in results (e.g. ``K_max``). """ pass @abc.abstractmethod def _predict_kwargs(self, row) -> Dict[str, float]: """Build kwargs for ``model_func(t, **kwargs)`` from a results row. ``row`` is any mapping keyed by MeasurementInfo members — a dict produced by ``_unpack_params`` at fit time, or a ``pd.Series`` drawn from the results DataFrame at plot time. """ pass # ------------------------------------------------------------------ # # Optional hooks — subclasses may override # ------------------------------------------------------------------ # def _hyperparam_kwargs(self) -> Dict[str, float]: """Extra kwargs forwarded to ``_loss_func`` (e.g. regularization).""" return {} def _prepare_group(self, group: pd.DataFrame) -> pd.DataFrame: """Preprocess one group before ``t``/``y`` extraction. Default returns the group unchanged. Subclasses override to filter rows (e.g. saturation pruning) or synthesize helper columns needed by the loss function. """ return group def _extra_loss_kwargs(self, group: pd.DataFrame) -> Dict[str, Any]: """Per-group kwargs forwarded to :meth:`_loss_func`. Default is an empty dict. Subclasses override to inject per-group arrays (e.g. weight vectors, per-group bounds) that ``_hyperparam_kwargs`` cannot express because it is group-agnostic. Merged *after* ``_hyperparam_kwargs`` so per-group entries win on collision. """ return {} def _post_fit_columns(self) -> Dict[Any, float]: """Scalar metadata columns appended to the results DataFrame. Useful for recording hyperparameters (e.g. ``lam``, ``beta``) that are constant across every fitted group. """ return {} def _extra_agg_columns(self) -> Dict[str, Any]: """Additional ``{column: agg_func}`` entries for per-timepoint aggregation.""" return {} def _hover_fields(self) -> List[Tuple[str, Any, str]]: """``(label, column_key, format_spec)`` entries for the Plotly hover tooltip.""" return [] # ------------------------------------------------------------------ # # Shared metrics / NaN templates # ------------------------------------------------------------------ # @staticmethod def _compute_metrics(y_true, y_pred) -> Dict[Any, float]: """Compute the generic fit-quality metrics shared by all subclasses.""" return { MODEL_METRICS.MAE: mean_absolute_error(y_true, y_pred), MODEL_METRICS.MSE: mean_squared_error(y_true, y_pred), MODEL_METRICS.RMSE: root_mean_squared_error(y_true, y_pred), MODEL_METRICS.R2: r2_score(y_true, y_pred), } def _nan_fit_columns(self) -> Dict[Any, float]: """NaN-filled row used when ``least_squares`` raises ``ValueError``. Covers every MeasurementInfo member declared on the subclass plus the full :class:`MODEL_METRICS` set (metrics + diagnostics). Any column supplied by ``_post_fit_columns`` is excluded so the constant hyperparameter value is preserved rather than overwritten with NaN. """ mi = self._measurement_info_class post_keys = set(self._post_fit_columns().keys()) model_cols = { member: np.nan for member in mi.__members__.values() if member not in post_keys } metric_cols = { member: np.nan for member in MODEL_METRICS.__members__.values() } return {**model_cols, **metric_cols} # ------------------------------------------------------------------ # # Per-group fit — replaces the old `_apply2group_func` boilerplate # ------------------------------------------------------------------ # def _apply2group_func(self, group_key, group: pd.DataFrame) -> pd.DataFrame: """Fit one group and return a single-row DataFrame of results.""" group = self._prepare_group(group) t_data = group[self.time_label] y_data = group[self.on] loss_kwargs: Dict[str, Any] = { **self._hyperparam_kwargs(), **self._extra_loss_kwargs(group), } try: out = optimize.least_squares( self._loss_func, x0=self._initial_guess(group), bounds=self._bounds(group), kwargs=dict(t=t_data, y=y_data, **loss_kwargs), verbose=int(self.verbose), method="trf", loss=self.loss, ) fitted = self._unpack_params(out.x, group) y_pred = self.model_func(t=t_data, **self._predict_kwargs(fitted)) row: Dict[Any, float] = { **fitted, **self._compute_metrics(y_data, y_pred), MODEL_METRICS.LOSS: out.cost, MODEL_METRICS.STATUS: out.status, MODEL_METRICS.NUM_SAMPLES: len(t_data), } except ValueError: row = self._nan_fit_columns() return pd.DataFrame( data=row, index=pd.MultiIndex.from_tuples( tuples=[group_key], names=self.groupby ), ) # ------------------------------------------------------------------ # # Orchestration # ------------------------------------------------------------------ #
[docs] def analyze(self, data: pd.DataFrame) -> pd.DataFrame: """Fit the model to every group of ``data`` and return the results. Standard template: copy, float-coerce the time column, aggregate to one sample per timepoint, dispatch per-group fits (serial or parallel via :class:`joblib.Parallel`), concatenate, and append constant hyperparameter columns from ``_post_fit_columns``. """ data = data.copy(deep=True) data.loc[:, self.time_label] = self._ensure_float_array( data.loc[:, self.time_label] ) self._latest_measurements = data agg_dict: Dict[str, Any] = {self.on: self.agg_func} agg_dict.update(self._extra_agg_columns()) agg_data = data.groupby( by=self.groupby + [self.time_label], as_index=False ).agg(agg_dict) grouped = agg_data.groupby(by=self.groupby, as_index=True) if self.n_jobs == 1: model_res = [ self._apply2group_func(key, group) for key, group in grouped ] else: model_res = Parallel(n_jobs=self.n_jobs)( delayed(self._apply2group_func)(key, group) for key, group in grouped ) results = pd.concat(model_res, axis=0).reset_index(drop=False) for col_key, val in self._post_fit_columns().items(): results.insert(loc=len(results.columns), column=col_key, value=val) self._latest_model_scores = results return self._latest_model_scores
[docs] def results(self) -> pd.DataFrame: """Return the most recent fit results produced by :meth:`analyze`.""" return self._latest_model_scores
# ------------------------------------------------------------------ # # Internal helpers for plotting # ------------------------------------------------------------------ # def _filter_for_plot( self, criteria: Dict[str, Union[Any, List[Any]]] | None ) -> Tuple[pd.DataFrame, pd.DataFrame]: """Apply `criteria` (if any) to both the model-scores and measurements frames.""" if criteria is not None: model_scores = self._filter_by( df=self._latest_model_scores, criteria=criteria, copy=True ) measurements = self._filter_by( df=self._latest_measurements, criteria=criteria, copy=True ) else: model_scores = self._latest_model_scores.copy() measurements = self._latest_measurements.copy() return model_scores, measurements def _time_axis( self, timepoints: pd.Series, tmax: int | float | None ) -> Tuple[np.ndarray, float]: """Derive a uniform time axis for plotting prediction curves.""" step = np.abs(np.mean(timepoints.sort_values().diff().dropna())) if np.isnan(step) or step <= 0: step = 1.0 upper = timepoints.max() if tmax is None else tmax return np.arange(stop=upper + step, step=step), step def _format_hover(self, row) -> str: """Join `_hover_fields` into a Plotly ``<extra>`` payload.""" parts = [] for label, col_key, fmt in self._hover_fields(): val = row[col_key] parts.append(f"{label} = {format(val, fmt)}") return "<br>".join(parts) # ------------------------------------------------------------------ # # Matplotlib visualization # ------------------------------------------------------------------ #
[docs] def show( self, tmax: int | float | None = None, criteria: Dict[str, Union[Any, List[Any]]] | None = None, figsize=(6, 4), cmap: str | None = "tab20", legend: bool = True, ax: plt.Axes | None = None, **kwargs, ) -> Tuple[plt.Figure, plt.Axes]: """Plot model predictions alongside measurements with optional filtering. Args: tmax: Upper bound of the prediction curve. If ``None``, uses the maximum observed time. criteria: Column/value filter applied to both fitted results and raw measurements before plotting. figsize: Matplotlib figure size. Used only when ``ax`` is None. cmap: Matplotlib colormap name, a single color string, or ``None`` for matplotlib's default color cycle. legend: Whether to render a legend (auto-removed if larger than the axes). ax: Existing axes to draw into. A new figure is created when omitted. **kwargs: Styling overrides — ``dpi``, ``facecolor``, ``edgecolor``, ``line_width``, ``marker_size``, ``elinewidth``, ``capsize``, ``legend_loc``, ``legend_fontsize``, ``label``. Returns: A ``(Figure, Axes)`` pair. """ fig_kwargs = { k: v for k, v in kwargs.items() if k in ("dpi", "facecolor", "edgecolor") } line_width = kwargs.get("line_width", None) marker_size = kwargs.get("marker_size", None) elinewidth = kwargs.get("elinewidth", 1) capsize = kwargs.get("capsize", 2) legend_loc = kwargs.get("legend_loc", "best") legend_fontsize = kwargs.get("legend_fontsize", None) if ax is None: fig, ax = plt.subplots(figsize=figsize, **fig_kwargs) else: fig = ax.get_figure() model_scores, measurements = self._filter_for_plot(criteria) if measurements.empty: import warnings warnings.warn("No data found matching the criteria. Returning empty plot.") return fig, ax measurements.loc[:, self.time_label] = self._ensure_float_array( measurements.loc[:, self.time_label] ) model_groups = { keys: grp for keys, grp in model_scores.groupby(by=self.groupby) } meas_groups = { keys: grp for keys, grp in measurements.groupby(by=self.groupby) } timepoints = pd.Series(measurements.loc[:, self.time_label].unique()) t, _ = self._time_axis(timepoints, tmax) if cmap is not None: try: cmap_obj = ( matplotlib.colormaps[cmap] if isinstance(cmap, str) else cmap ) color_iter = itertools.cycle( cmap_obj( np.linspace( start=0, stop=1, num=len(model_groups), endpoint=False ) ) ) except (ValueError, AttributeError): color_iter = itertools.cycle([cmap]) else: color_iter = itertools.cycle([None] * len(model_groups)) for model_key, model_group in model_groups.items(): curr_meas = meas_groups[model_key] curr_color = next(color_iter) row = model_group.iloc[0] y_pred = self.model_func(t=t, **self._predict_kwargs(row)) plot_kwargs: Dict[str, Any] = {} if curr_color is not None: plot_kwargs["color"] = curr_color if line_width is not None: plot_kwargs["linewidth"] = line_width ax.plot(t, y_pred, **plot_kwargs) curr_time_groups = curr_meas.groupby(by=self.time_label) curr_mean = curr_time_groups[self.on].mean() curr_stddev = curr_time_groups[self.on].std() curr_stderr = curr_stddev / np.sqrt(curr_time_groups[self.on].count()) # noinspection PyUnresolvedReferences errorbar_kwargs: Dict[str, Any] = { "x": curr_mean.index.values, "y": curr_mean.values, "yerr": curr_stderr, "fmt": "o", "elinewidth": elinewidth, "capsize": capsize, "label": kwargs.get("label", f"{model_key[0]}"), } if curr_color is not None: errorbar_kwargs["color"] = curr_color errorbar_kwargs["ecolor"] = curr_color if marker_size is not None: errorbar_kwargs["markersize"] = marker_size ax.errorbar(**errorbar_kwargs) if legend: legend_kwargs: Dict[str, Any] = {"loc": legend_loc} if legend_fontsize is not None: legend_kwargs["fontsize"] = legend_fontsize legend_obj = ax.legend(**legend_kwargs) fig.canvas.draw() legend_bbox = legend_obj.get_window_extent() axes_bbox = ax.get_window_extent() if ( legend_bbox.width > axes_bbox.width * 0.95 or legend_bbox.height > axes_bbox.height * 0.95 ): legend_obj.remove() ax.set_title("mean±SE") return fig, ax
# ------------------------------------------------------------------ # # Plotly visualization # ------------------------------------------------------------------ #
[docs] def dash( self, tmax: int | float | None = None, criteria: Dict[str, Union[Any, List[Any]]] | None = None, figsize=(6, 4), cmap: str | None = "tab20", legend: bool = True, **kwargs, ) -> "go.Figure": """Interactive Plotly version of :meth:`show`. Hover tooltips are populated from ``_hover_fields`` so subclasses can expose whichever fitted parameters and metrics are most meaningful for their model. Raises: ImportError: If ``plotly`` is not installed. """ from phenotypic.tools_._plotly_helpers import _require_plotly _require_plotly() import plotly.graph_objects as go model_scores, measurements = self._filter_for_plot(criteria) if measurements.empty: import warnings warnings.warn( "No data found matching the criteria. Returning empty figure." ) return go.Figure() measurements.loc[:, self.time_label] = self._ensure_float_array( measurements.loc[:, self.time_label] ) model_groups = { keys: grp for keys, grp in model_scores.groupby(by=self.groupby) } meas_groups = { keys: grp for keys, grp in measurements.groupby(by=self.groupby) } timepoints = pd.Series(measurements.loc[:, self.time_label].unique()) t, _ = self._time_axis(timepoints, tmax) _OKABE_ITO = [ "#003660", "#E69F00", "#56B4E9", "#009E73", "#0072B2", "#CC79A7", ] if cmap is not None: try: cmap_obj = matplotlib.colormaps[cmap] colors = [ f"rgb({int(c[0] * 255)},{int(c[1] * 255)},{int(c[2] * 255)})" for c in cmap_obj( np.linspace(0, 1, max(len(model_groups), 1), endpoint=False) ) ] color_iter = itertools.cycle(colors) except (ValueError, KeyError): color_iter = itertools.cycle([cmap]) else: color_iter = itertools.cycle(_OKABE_ITO) fig = go.Figure() for model_key, model_group in model_groups.items(): curr_meas = meas_groups[model_key] curr_color = next(color_iter) row = model_group.iloc[0] y_pred = self.model_func(t=t, **self._predict_kwargs(row)) if isinstance(model_key, tuple): label = ", ".join(str(k) for k in model_key) else: label = str(model_key) hover_extra = self._format_hover(row) fig.add_trace(go.Scatter( x=t, y=y_pred, mode="lines", name=label, line=dict(color=curr_color, width=2), legendgroup=label, hovertemplate=( "<b>%{fullData.name}</b><br>" "Time: %{x:.1f}<br>" "Predicted: %{y:.2f}<br>" f"<extra>{hover_extra}</extra>" ), )) curr_time_groups = curr_meas.groupby(by=self.time_label) curr_mean = curr_time_groups[self.on].mean() curr_stddev = curr_time_groups[self.on].std() curr_count = curr_time_groups[self.on].count() curr_stderr = curr_stddev / np.sqrt(curr_count) time_vals = curr_mean.index.values.astype(float) mean_vals = curr_mean.values stderr_vals = np.nan_to_num(curr_stderr.values, nan=0.0) fig.add_trace(go.Scatter( x=time_vals, y=mean_vals, mode="markers", name=label, legendgroup=label, showlegend=False, marker=dict( color=curr_color, size=7, line=dict(color=curr_color, width=1), ), error_y=dict( type="data", array=stderr_vals, visible=True, color=curr_color, thickness=1, ), customdata=np.column_stack([time_vals, mean_vals, stderr_vals]), hovertemplate=( "<b>%{fullData.name}</b><br>" "Time: %{customdata[0]:.1f}<br>" "Mean: %{customdata[1]:.2f}<br>" "SE: %{customdata[2]:.4f}<br>" "<extra></extra>" ), )) width_px = figsize[0] * 100 height_px = figsize[1] * 100 fig.update_layout( width=width_px, height=height_px, title=dict( text=kwargs.get("title", "mean±SE"), font=dict( family="DM Sans, system-ui, sans-serif", size=13, color="#003660", ), ), xaxis=dict( title=dict( text=kwargs.get("xlabel", self.time_label), font=dict( family="DM Mono, Courier New, monospace", size=9, color="#2e3a4e", ), ), tickfont=dict( family="DM Mono, Courier New, monospace", size=8, color="#8892a4", ), gridcolor="#e8ecf2", gridwidth=1, linecolor="#dde3ed", linewidth=1.5, showline=True, zeroline=False, ), yaxis=dict( title=dict( text=kwargs.get("ylabel", self.on), font=dict( family="DM Mono, Courier New, monospace", size=9, color="#2e3a4e", ), ), tickfont=dict( family="DM Mono, Courier New, monospace", size=8, color="#8892a4", ), gridcolor="#e8ecf2", gridwidth=1, linecolor="#dde3ed", linewidth=1.5, showline=True, zeroline=False, ), plot_bgcolor="#ffffff", paper_bgcolor="#f5f7fa", showlegend=legend, legend=dict( font=dict( family="DM Sans, system-ui, sans-serif", size=11, color="#2e3a4e", ), ), hovermode="closest", ) return fig