Source code for modelly.sensitivity.results

"""Results of a sensitivity analysis: a table per parameter, a ranking and a plot.

Every method returns a :class:`SensitivityResult` (or a subclass) whose
``table`` has one row per parameter. ``ranking()`` sorts it by the method's
main measure, most influential first; ``to_dataframe()`` returns a copy;
``plot()`` draws a Matplotlib figure (``figure.savefig(path)`` writes it).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

import pandas as pd

from modelly._optional import require

PANEL_INCHES = (6.0, 3.5)
BAR_WIDTH = 0.4
PARAMETER = "parameter"


def _figure(size: tuple[float, float] = PANEL_INCHES) -> tuple[Any, Any]:
    """A Matplotlib figure (no pyplot state) and its single axis."""
    matplotlib_figure = require("matplotlib.figure", "viz")
    figure = matplotlib_figure.Figure(figsize=size, layout="constrained")
    return figure, figure.subplots()


[docs] @dataclass(repr=False) class SensitivityResult: """One analysis: ``table`` (index: parameter) and the measure ranking uses. Attributes: method: the method's name. table: one row per parameter. measure: the column ``ranking`` sorts by. info: how the analysis ran (samples, model runs, seed...). """ method: str table: pd.DataFrame measure: str info: dict[str, Any] = field(default_factory=dict)
[docs] def ranking(self) -> pd.DataFrame: """The table sorted by the main measure, most influential first.""" return self.table.sort_values(self.measure, ascending=False, kind="stable")
[docs] def to_dataframe(self) -> pd.DataFrame: """A copy of the table, the parameter as a column.""" return self.table.rename_axis(PARAMETER).reset_index()
[docs] def plot(self) -> Any: """A bar chart of the main measure, parameters in ranking order.""" ranked = self.ranking() figure, axis = _figure() axis.bar(ranked.index, ranked[self.measure]) axis.set_ylabel(self.measure) axis.set_title(self.method) return figure
def __repr__(self) -> str: """The method and the ranked parameters.""" return f"{type(self).__name__}({self.method}, {list(self.ranking().index)})"
[docs] class MorrisResult(SensitivityResult): """Morris screening: ``mu``, ``mu_star`` and ``sigma`` of the elementary effects."""
[docs] def plot(self) -> Any: """``sigma`` against ``mu_star``: far right is influential, high is non-linear.""" figure, axis = _figure() axis.scatter(self.table["mu_star"], self.table["sigma"]) for name, row in self.table.iterrows(): axis.annotate(str(name), (row["mu_star"], row["sigma"])) axis.set_xlabel("mu* (mean absolute effect)") axis.set_ylabel("sigma (interactions, non-linearity)") return figure
[docs] class SobolResult(SensitivityResult): """Sobol indices ``S1`` and ``ST`` with bootstrap intervals."""
[docs] def plot(self) -> Any: """First-order and total indices side by side, with their intervals.""" figure, axis = _figure() positions = range(len(self.table)) for offset, index in ((-BAR_WIDTH / 2, "S1"), (BAR_WIDTH / 2, "ST")): values = self.table[index] error = [ values - self.table[f"{index}_low"], self.table[f"{index}_high"] - values, ] axis.bar( [p + offset for p in positions], values, BAR_WIDTH, yerr=error, label=index, ) axis.set_xticks(list(positions), list(self.table.index)) axis.set_ylabel("Sobol index") axis.legend() return figure
[docs] @dataclass(repr=False) class OATResult(SensitivityResult): """One-at-a-time sweeps: ``sweeps`` holds every evaluated value.""" sweeps: pd.DataFrame = field(default_factory=pd.DataFrame)
[docs] def plot(self) -> Any: """The output along each parameter's sweep, the others at their midpoint.""" figure, axis = _figure() for name, sweep in self.sweeps.groupby(PARAMETER, sort=False): axis.plot(sweep["position"], sweep["output"], marker="o", label=str(name)) axis.set_xlabel("position in the parameter's range (0 to 1)") axis.set_ylabel("output") axis.legend() return figure