Source code for modelly.sensitivity.variance

"""Variance-based sensitivity: first-order and total Sobol indices.

Saltelli sampling: two independent matrices ``A`` and ``B`` of ``samples``
rows each come from one scrambled Sobol sequence of twice the dimension
(``scipy.stats.qmc``, scrambled from the seed), and ``AB_i`` is ``A`` with
column ``i`` taken from ``B``: ``samples * (parameters + 2)`` evaluations.

Estimators (Saltelli et al., 2010), with ``V`` the variance of ``f(A)`` and ``f(B)``:

=============  ============================================  ==========================================
``estimator``  S1                                            ST
=============  ============================================  ==========================================
``jansen``     ``(V - mean((f(B) - f(AB_i))**2) / 2) / V``   ``mean((f(A) - f(AB_i))**2) / 2 / V``
``saltelli``   ``mean(f(B) * (f(AB_i) - f(A))) / V``         ``mean(f(A) * (f(A) - f(AB_i))) / V``
=============  ============================================  ==========================================

Confidence intervals come from resampling the rows ``bootstrap`` times (the
stream ``(BOOTSTRAP, SOBOL_KEY)`` of the seed) and taking percentiles.
"""

from __future__ import annotations

from typing import Any

import numpy as np
import pandas as pd

from modelly.calibrate.simulator import Stream
from modelly.experiments.space import ScenarioSpace
from modelly.sensitivity.results import SobolResult
from modelly.sensitivity.study import Study

DEFAULT_SAMPLES = 1024
DEFAULT_BOOTSTRAP = 200
DEFAULT_CONFIDENCE = 0.95
ESTIMATORS = ("jansen", "saltelli")
SOBOL_KEY = 13
PERCENT = 100


[docs] def saltelli_sample( dimensions: int, samples: int, rng: np.random.Generator ) -> np.ndarray: """The points ``[A; B; AB_1; ...; AB_d]`` in the unit cube.""" from scipy.stats import qmc # noqa: PLC0415 - lazy import base = qmc.Sobol(2 * dimensions, scramble=True, rng=rng).random(samples) a, b = base[:, :dimensions], base[:, dimensions:] blocks = [a, b] for i in range(dimensions): mixed = a.copy() mixed[:, i] = b[:, i] blocks.append(mixed) return np.vstack(blocks)
[docs] def indices( f_a: np.ndarray, f_b: np.ndarray, f_ab: np.ndarray, estimator: str ) -> tuple[np.ndarray, np.ndarray]: """S1 and ST from ``f(A)``, ``f(B)`` and ``f(AB)[parameter, row]``.""" variance = np.var(np.concatenate([f_a, f_b])) if variance == 0: return np.zeros(len(f_ab)), np.zeros(len(f_ab)) if estimator == "jansen": first = (variance - 0.5 * np.mean((f_b - f_ab) ** 2, axis=1)) / variance total = 0.5 * np.mean((f_a - f_ab) ** 2, axis=1) / variance return first, total first = np.mean(f_b * (f_ab - f_a), axis=1) / variance total = np.mean(f_a * (f_a - f_ab), axis=1) / variance return first, total
def _bootstrap( values: tuple[np.ndarray, np.ndarray, np.ndarray], estimator: str, resamples: int, rng: np.random.Generator, ) -> tuple[np.ndarray, np.ndarray]: """S1 and ST of ``resamples`` row resamples: ``[resample, parameter]`` each.""" f_a, f_b, f_ab = values rows = len(f_a) firsts, totals = [], [] for _ in range(resamples): pick = rng.integers(0, rows, rows) first, total = indices(f_a[pick], f_b[pick], f_ab[:, pick], estimator) firsts.append(first) totals.append(total) return np.array(firsts), np.array(totals)
[docs] def sobol( model: Any, space: ScenarioSpace, output: Any = None, *, samples: int = DEFAULT_SAMPLES, bootstrap: int = DEFAULT_BOOTSTRAP, confidence: float = DEFAULT_CONFIDENCE, estimator: str = "jansen", **settings: Any, ) -> SobolResult: """First-order (``S1``) and total (``ST``) Sobol indices with bootstrap intervals. ``samples`` should be a power of two. ``settings`` are the :class:`~modelly.sensitivity.study.Study` fields (seed, steps, replicates, statistic, store, executor, max_parallel, options). """ if estimator not in ESTIMATORS: raise ValueError(f"estimator must be one of {ESTIMATORS}, got {estimator!r}") if bootstrap < 1 or not 0 < confidence < 1: raise ValueError("bootstrap >= 1 and 0 < confidence < 1 are required") study = Study(model, space, output, **settings) d = len(study.names) units = saltelli_sample(d, samples, study.rng(Stream.DESIGN, SOBOL_KEY)) blocks = study.evaluate(units, SOBOL_KEY).reshape(d + 2, samples) values = (blocks[0], blocks[1], blocks[2:]) first, total = indices(*values, estimator) rng = study.rng(Stream.BOOTSTRAP, SOBOL_KEY) firsts, totals = _bootstrap(values, estimator, bootstrap, rng) tails = (PERCENT * (1 - confidence) / 2, PERCENT * (1 + confidence) / 2) table = pd.DataFrame({"S1": first, "ST": total}, index=study.names) for name, draws in (("S1", firsts), ("ST", totals)): table[f"{name}_low"], table[f"{name}_high"] = np.percentile( draws, tails, axis=0 ) info = {"samples": samples, "evaluations": units.shape[0], "estimator": estimator} info |= {"bootstrap": bootstrap, "confidence": confidence} return SobolResult("sobol", table, "ST", info)