Source code for modelly.calibrate.distance

"""Distances between a simulated summary and the observed one.

A distance is any function ``distance(simulated, observed) -> float`` of two
arrays of the same shape; smaller is closer. Example:

    >>> import numpy as np
    >>> rmse(np.array([1.0, 2.0]), np.array([1.0, 4.0]))
    1.4142135623730951
    >>> weighted([1.0, 0.0])(np.array([1.0, 2.0]), np.array([1.0, 4.0]))
    0.0

Every built-in is a module-level function or a picklable object, so a
calibration that uses them can be saved and resumed.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from dataclasses import dataclass

import numpy as np

Distance = Callable[[np.ndarray, np.ndarray], float]


def _pair(simulated: np.ndarray, observed: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Both arrays as float, flattened, with a ValueError when their sizes differ."""
    sim = np.asarray(simulated, dtype=float).ravel()
    obs = np.asarray(observed, dtype=float).ravel()
    if sim.shape != obs.shape:
        raise ValueError(f"simulated has {sim.size} values, observed {obs.size}")
    return sim, obs


[docs] def rmse(simulated: np.ndarray, observed: np.ndarray) -> float: """Root mean squared error.""" sim, obs = _pair(simulated, observed) return float(np.sqrt(np.mean((sim - obs) ** 2)))
[docs] def mae(simulated: np.ndarray, observed: np.ndarray) -> float: """Mean absolute error.""" sim, obs = _pair(simulated, observed) return float(np.mean(np.abs(sim - obs)))
[docs] def euclidean(simulated: np.ndarray, observed: np.ndarray) -> float: """The Euclidean norm of the difference (for vectors of summary statistics).""" sim, obs = _pair(simulated, observed) return float(np.linalg.norm(sim - obs))
[docs] def wasserstein(simulated: np.ndarray, observed: np.ndarray) -> float: """The Wasserstein-1 distance between the two sets of values as distributions. The values are compared as samples, whatever their order or count. """ from scipy.stats import wasserstein_distance # noqa: PLC0415 - lazy import sim = np.asarray(simulated, dtype=float).ravel() obs = np.asarray(observed, dtype=float).ravel() return float(wasserstein_distance(sim, obs))
[docs] @dataclass(frozen=True) class Weighted: """Root of the weighted mean of squared errors; one weight per value.""" weights: tuple[float, ...] def __call__(self, simulated: np.ndarray, observed: np.ndarray) -> float: """The weighted RMSE of ``simulated`` against ``observed``.""" sim, obs = _pair(simulated, observed) weights = np.asarray(self.weights, dtype=float) if weights.shape != sim.shape: raise ValueError(f"{weights.size} weights for {sim.size} values") return float(np.sqrt(np.sum(weights * (sim - obs) ** 2) / np.sum(weights)))
[docs] def weighted(weights: Sequence[float]) -> Weighted: """A weighted RMSE: ``weights[i]`` scales the squared error of value ``i``.""" values = tuple(float(w) for w in weights) if not values or min(values) < 0 or sum(values) == 0: raise ValueError("weights must be >= 0 and not all zero") return Weighted(values)
DISTANCES: dict[str, Distance] = { "rmse": rmse, "mae": mae, "euclidean": euclidean, "wasserstein": wasserstein, }