Source code for modelly.sensitivity.emulator

"""A Gaussian-process emulator: analyse an expensive model from a few hundred runs.

``emulate`` runs the model at a Latin hypercube of ``samples`` points and fits
the basic Gaussian process of :mod:`modelly.calibrate.gp` to the output. The
returned :class:`Emulator` stands in for the model in ``oat``, ``morris``,
``sobol`` and ``pawn``, which then cost no further runs::

    surrogate = emulate(Epidemic, space, "peak", samples=200, steps=120)
    print(surrogate.r2)            # leave-one-out accuracy, near 1 is good
    sobol(surrogate, space, samples=4096)
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import numpy as np

from modelly.calibrate.gp import GaussianProcess
from modelly.calibrate.simulator import Stream
from modelly.experiments.space import ScenarioSpace
from modelly.sensitivity.study import Study

DEFAULT_SAMPLES = 200
EMULATOR_KEY = 15
HOLDOUT_SHARE = 0.2


[docs] @dataclass class Emulator: """A fitted Gaussian process over the unit cube of ``space``. Attributes: space: the parameter space it was fitted over. process: the fitted Gaussian process. units: the training points. values: the model's outputs at them. r2: the share of variance explained on held-out training points. """ space: ScenarioSpace process: GaussianProcess units: np.ndarray values: np.ndarray r2: float
[docs] def predict_unit(self, units: np.ndarray) -> np.ndarray: """The predicted output at points of the unit cube.""" return self.process.predict(np.atleast_2d(units))[0]
def _holdout_r2( units: np.ndarray, values: np.ndarray, rng: np.random.Generator ) -> float: """R squared of a process fitted without a random ``HOLDOUT_SHARE`` of the points.""" order = rng.permutation(len(values)) cut = max(1, int(HOLDOUT_SHARE * len(values))) test, train = order[:cut], order[cut:] process = GaussianProcess.fit(units[train], values[train]) predicted = process.predict(units[test])[0] spread = np.sum((values[test] - values[test].mean()) ** 2) return ( float(1 - np.sum((values[test] - predicted) ** 2) / spread) if spread else 1.0 )
[docs] def emulate( model: Any, space: ScenarioSpace, output: Any = None, *, samples: int = DEFAULT_SAMPLES, **settings: Any, ) -> Emulator: """Run ``model`` at ``samples`` Latin-hypercube points and fit an Emulator. ``settings`` are the :class:`~modelly.sensitivity.study.Study` fields. """ from scipy.stats import qmc # noqa: PLC0415 - lazy import study = Study(model, space, output, **settings) rng = study.rng(Stream.DESIGN, EMULATOR_KEY) units = qmc.LatinHypercube(len(study.names), rng=rng).random(samples) values = study.evaluate(units, EMULATOR_KEY) r2 = _holdout_r2(units, values, study.rng(Stream.BOOTSTRAP, EMULATOR_KEY)) return Emulator(space, GaussianProcess.fit(units, values), units, values, r2)