Source code for modelly.sensitivity.study

"""What a sensitivity analysis evaluates: a model's output, a function, or an emulator.

A :class:`Study` turns points of the unit cube (one coordinate per varying
parameter of a :class:`~modelly.experiments.ScenarioSpace`) into one number
each:

* a Model class (or ``"module:Class"`` reference) is run through the
  experiment executors (:mod:`modelly.calibrate.simulator`); ``output`` names
  what it records every step and ``statistic`` reduces each run's series to
  one value (``"last"``, ``"max"``, ``"min"``, ``"mean"``, ``"sum"`` or a
  function), averaged over the replicates;
* a plain function is called with the parameters as keyword arguments;
* an :class:`~modelly.sensitivity.emulator.Emulator` predicts all points at once.
"""

from __future__ import annotations

import inspect
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import numpy as np

from modelly.calibrate.simulator import (
    DEFAULT_EXECUTOR,
    Batch,
    Execution,
    Simulator,
    Stream,
    generator,
)
from modelly.core.model import Model
from modelly.experiments.scratch import scratch_folder
from modelly.experiments.space import ScenarioSpace
from modelly.experiments.tasks import reference

DEFAULT_STEPS = 100
OUTPUT_COLUMN = "sensitivity_output"
STATISTICS: dict[str, Callable[[np.ndarray], float]] = {
    "last": lambda series: series[-1],
    "max": np.max,
    "min": np.min,
    "mean": np.mean,
    "sum": np.sum,
}


[docs] def is_model(model: Any) -> bool: """Whether ``model`` is a Model class or a ``module:Class`` reference to one.""" return isinstance(model, str) or ( inspect.isclass(model) and issubclass(model, Model) )
[docs] @dataclass class Study: """The quantity analysed and where it is computed. Args: model: a Model class or reference, a function of the parameters, or an Emulator. space: the parameter space; its ranges and choices are the inputs. output: for a model, the attribute, method or function of the model to record. seed: the root of the design, the bootstrap and the runs' seeds. steps: steps per run (models only). replicates: runs per point, averaged (models only). statistic: how a run's series becomes one number (models only). store: the folder of the runs (a temporary one by default). executor: where the runs execute (any experiment executor). max_parallel: the most runs at once. options: the executor's options. """ model: Any space: ScenarioSpace output: str | Callable[[Any], Any] | None = None seed: int = 0 steps: int = DEFAULT_STEPS replicates: int = 1 statistic: str | Callable[[np.ndarray], float] = "last" store: str | Path | None = None executor: Any = DEFAULT_EXECUTOR max_parallel: int | None = None options: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: """Check the space and, for a model, the output and statistic.""" if not self.space.dimensions: raise ValueError("the space has no range or choice to analyse") if is_model(self.model) and self.output is None: raise ValueError("a model needs output= (what to record every step)") if isinstance(self.statistic, str) and self.statistic not in STATISTICS: raise ValueError( f"statistic must be a function or one of {sorted(STATISTICS)}" ) space = getattr(self.model, "space", None) if space is not None and space != self.space: raise ValueError("the emulator was fitted over another space") self._temporary: Path | None = None @property def names(self) -> list[str]: """The analysed parameters, in the space's order.""" return list(self.space.dimensions)
[docs] def rng(self, stream: Stream, key: int) -> np.random.Generator: """The generator of one stream of this study's seed.""" return generator(self.seed, stream, key)
[docs] def evaluate(self, units: np.ndarray, key: int) -> np.ndarray: """One output value per row of ``units``; ``key`` names the batch of runs.""" units = np.atleast_2d(np.asarray(units, float)) if hasattr(self.model, "predict_unit"): return np.asarray(self.model.predict_unit(units), float) points = [self.space.point(row) for row in units] if not is_model(self.model): return np.array([float(self.model(**point)) for point in points]) return self._simulate(points, key)
def _simulate(self, points: list[dict[str, Any]], key: int) -> np.ndarray: """Run the model at ``points``; the replicate mean of the statistic.""" if self.store is None and self._temporary is None: self._temporary = scratch_folder(self, "modelly-sensitivity-") root = Path(self.store).resolve() if self.store else self._temporary model = self.model if isinstance(self.model, str) else reference(self.model) output = self.output if isinstance(self.output, str) else reference(self.output) simulator = Simulator( model, {OUTPUT_COLUMN: output}, self.steps, self.replicates, self.seed, root ) execution = Execution(self.executor, self.max_parallel, self.options) results = simulator.run(Batch((key,), points), execution) reduce = STATISTICS.get(self.statistic, self.statistic) return np.array( [ np.mean([reduce(row) for row in result[OUTPUT_COLUMN]]) for result in results ] )