from __future__ import annotations
import abc
import itertools
from abc import ABC
from typing import Any, ClassVar, 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 pydantic import Field, PrivateAttr
from phenotypic.sdk_ import ColumnRef
from phenotypic.schema import MODEL_METRICS
from ._set_analyzer import SetAnalyzer
if TYPE_CHECKING:
import plotly.graph_objects as go
LossKind = Literal["linear", "soft_l1", "huber", "cauchy", "arctan"]
"""Scipy ``least_squares`` loss names supported by :class:`ModelFitter`.
- ``"linear"`` — standard unweighted squared-residual loss (default).
- ``"soft_l1"`` — smooth L1; mild downweighting of large residuals.
- ``"huber"`` — quadratic near zero, linear past ``f_scale``; classic
robust loss.
- ``"cauchy"`` — heavy-tailed; aggressively suppresses outliers.
- ``"arctan"`` — saturates to a bounded contribution; most aggressive.
See :func:`scipy.optimize.least_squares` for the exact ρ formulas.
"""
[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 (LossKind): Loss calculation method passed through to
:func:`scipy.optimize.least_squares`. Defaults to
``"huber"`` — quadratic near zero and linear past
``f_scale``, so the fit behaves like standard
least-squares on inliers but downweights rare large
residuals (bubble artifacts, contamination spikes,
mis-segmented timepoints). Pass ``"linear"`` to recover
the classical unweighted-squared-residual loss, or
``"soft_l1"`` / ``"cauchy"`` / ``"arctan"`` for
progressively more aggressive outlier suppression.
f_scale (float): Soft margin between inlier and outlier
residuals handed to :func:`scipy.optimize.least_squares`.
Only affects robust ``loss`` choices; ignored when
``loss="linear"``. Must be positive and finite.
verbose (bool): Whether to print detailed optimizer output.
"""
_measurement_infoclass: ClassVar[type]
time_label: ColumnRef = "Metadata_Time"
loss: LossKind = "huber"
f_scale: float = Field(default=1.0, gt=0, allow_inf_nan=False)
verbose: bool = False
_latest_model_scores: pd.DataFrame = PrivateAttr(
default_factory=pd.DataFrame
)
# ------------------------------------------------------------------ #
# 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, Any]:
"""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``), or string-valued diagnostic fields (e.g. the
per-group fit mode emitted by :class:`DoubleSoftplus`).
"""
pass
@abc.abstractmethod
def _predict_kwargs(self, row) -> Dict[str, Any]:
"""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. Values may be
``None`` for optional model kwargs.
"""
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."""
# Deferred: scikit-learn is heavy and only needed at fit time, not
# at module import (this module is on the eager ``phenotypic`` path).
from sklearn.metrics import (
mean_absolute_error,
mean_squared_error,
r2_score,
root_mean_squared_error,
)
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) if len(y_true) > 2 else np.nan,
}
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_infoclass
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,
f_scale=self.f_scale,
)
fitted = self._unpack_params(out.x, group)
y_pred = self.model_func(t=t_data, **self._predict_kwargs(fitted))
row: Dict[Any, Any] = {
**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 dense, uniform time axis for plotting prediction curves.
Samples ``[0, upper]`` with ``2 * upper`` points (floor 2) so
that sharp model transitions (e.g. the softplus lag/saturation
in :class:`DoubleSoftplus`) render smoothly rather than as
a polyline sampled at the observed timepoints.
"""
upper = float(timepoints.max() if tmax is None else tmax)
if not np.isfinite(upper) or upper <= 0:
upper = 1.0
num = max(2, int(2 * upper))
t = np.linspace(0.0, upper, num=num)
step = upper / (num - 1)
return t, 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 | str = 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: Controls legend rendering. ``True`` (default) renders
the legend with one entry per ``groupby`` combination,
labeled by the first ``groupby`` column. ``False``
hides the legend. A string must be one of
``self.groupby``; groups sharing a value in that column
share both color and a single legend entry. The legend
is auto-removed if it is 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.
"""
hue = legend if isinstance(legend, str) else None
show_legend = legend is not False
if hue is not None and hue not in self.groupby:
raise ValueError(
f"legend={hue!r} must be one of self.groupby={self.groupby}"
)
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)
hue_idx = self.groupby.index(hue) if hue is not None else None
def _hue_value(k):
return k[hue_idx] if isinstance(k, tuple) else k
if hue is not None:
unique_hues: list = []
_seen: set = set()
for k in model_groups:
v = _hue_value(k)
if v not in _seen:
_seen.add(v)
unique_hues.append(v)
n_colors = max(len(unique_hues), 1)
else:
n_colors = max(len(model_groups), 1)
if cmap is not None:
try:
cmap_obj = (
matplotlib.colormaps[cmap] if isinstance(cmap, str) else cmap
)
color_palette = list(cmap_obj(
np.linspace(start=0, stop=1, num=n_colors, endpoint=False)
))
except (ValueError, AttributeError):
color_palette = [cmap]
else:
color_palette = [None] * n_colors
if hue is not None:
hue_color = {
h: color_palette[i % len(color_palette)]
for i, h in enumerate(unique_hues)
}
color_iter = None
legend_seen: set = set()
else:
hue_color = None
color_iter = itertools.cycle(color_palette)
legend_seen = set()
for model_key, model_group in model_groups.items():
curr_meas = meas_groups[model_key]
if hue is not None:
assert hue_color is not None
h_val = _hue_value(model_key)
curr_color = hue_color[h_val]
if h_val in legend_seen:
label = "_nolegend_"
else:
legend_seen.add(h_val)
label = str(h_val)
else:
assert color_iter is not None
curr_color = next(color_iter)
label = kwargs.get("label", f"{model_key[0]}")
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" : label,
}
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 show_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 | str = 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.
Args:
legend: Controls legend rendering. ``True`` (default) renders
the legend with one entry per ``groupby`` combination
(joined with ``", "``). ``False`` hides the legend. A
string must be one of ``self.groupby``; groups sharing
a value in that column share both color and a single
legend entry.
Raises:
ImportError: If ``plotly`` is not installed.
"""
hue = legend if isinstance(legend, str) else None
show_legend = legend is not False
if hue is not None and hue not in self.groupby:
raise ValueError(
f"legend={hue!r} must be one of self.groupby={self.groupby}"
)
from phenotypic.sdk_._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",
]
hue_idx = self.groupby.index(hue) if hue is not None else None
def _hue_value(k):
return k[hue_idx] if isinstance(k, tuple) else k
if hue is not None:
unique_hues: list = []
_seen: set = set()
for k in model_groups:
v = _hue_value(k)
if v not in _seen:
_seen.add(v)
unique_hues.append(v)
n_colors = max(len(unique_hues), 1)
else:
n_colors = max(len(model_groups), 1)
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, n_colors, endpoint=False)
)
]
color_palette: list = colors
except (ValueError, KeyError):
color_palette = [cmap]
else:
color_palette = list(_OKABE_ITO)
if hue is not None:
hue_color = {
h: color_palette[i % len(color_palette)]
for i, h in enumerate(unique_hues)
}
color_iter = None
else:
hue_color = None
color_iter = itertools.cycle(color_palette)
legend_seen: set = set()
fig = go.Figure()
for model_key, model_group in model_groups.items():
curr_meas = meas_groups[model_key]
row = model_group.iloc[0]
y_pred = self.model_func(t=t, **self._predict_kwargs(row))
if hue is not None:
assert hue_color is not None
h_val = _hue_value(model_key)
curr_color = hue_color[h_val]
label = str(h_val)
else:
assert color_iter is not None
curr_color = next(color_iter)
if isinstance(model_key, tuple):
label = ", ".join(str(k) for k in model_key)
else:
label = str(model_key)
show_legend_entry = label not in legend_seen
legend_seen.add(label)
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,
showlegend=show_legend_entry,
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>"
),
))
height_px = figsize[1] * 100
fig.update_layout(
autosize=True,
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=show_legend,
legend=dict(
font=dict(
family="DM Sans, system-ui, sans-serif",
size=11,
color="#2e3a4e",
),
),
hovermode="closest",
)
return fig