Source code for arviz_stats.utils

"""ArviZ stats general utility functions."""

import warnings
from collections.abc import Hashable
from dataclasses import dataclass
from importlib import import_module
from typing import ClassVar

import numpy as np
import xarray as xr
from arviz_base import convert_to_dataset, rcParams
from xarray import DataArray

from arviz_stats.validate import validate_dims

__all__ = [
    "ELPDData",
    "ELPDDataLFO",
    "ELPDDataLOO",
    "ELPDDataLOOKFold",
    "ELPDDataLOOSubsample",
    "get_function",
    "get_log_likelihood",
]


def get_function(func_name):
    """Get a function from arviz_stats.

    Attempts to import the provided function from dataarray class in the module indicated
    in the rcParam ``stats.module``, and if it fails, it imports it from ``arviz_stats.base``.

    Parameters
    ----------
    func_name : str
        Name of the function to be imported and returned

    Returns
    -------
    callable
    """
    module_name = rcParams["stats.module"]
    if isinstance(module_name, str):
        preferred_module = import_module(f"arviz_stats.{module_name}")
    else:
        preferred_module = module_name
    if hasattr(preferred_module, "dataarray_stats"):
        preferred_module = preferred_module.dataarray_stats
    if not hasattr(preferred_module, func_name):
        raise KeyError(f"Requested function '{func_name}' is not available in '{preferred_module}'")
    return getattr(preferred_module, func_name)


def get_array_function(func_name):
    """Get a function from arviz_stats' array layer.

    Attempts to import the provided function from array class in the module indicated
    in the rcParam ``stats.module``. If the function is not available, a KeyError is raised.

    Parameters
    ----------
    func_name : str
        Name of the function to be imported and returned

    Returns
    -------
    callable
    """
    module_name = rcParams["stats.module"]
    if isinstance(module_name, str):
        preferred_module = import_module(f"arviz_stats.{module_name}")
    else:
        preferred_module = module_name
    if hasattr(preferred_module, "array_stats"):
        preferred_module = preferred_module.array_stats
    if not hasattr(preferred_module, func_name):
        raise KeyError(f"Requested function '{func_name}' is not available in '{preferred_module}'")
    return getattr(preferred_module, func_name)


def get_log_likelihood(idata, var_name=None):
    """Retrieve the log likelihood dataarray of a given variable."""
    if (
        not hasattr(idata, "log_likelihood")
        and hasattr(idata, "sample_stats")
        and hasattr(idata.sample_stats, "log_likelihood")
    ):
        warnings.warn(
            "Storing the log_likelihood in sample_stats groups has been deprecated",
            DeprecationWarning,
        )
        return idata.sample_stats.log_likelihood
    if not hasattr(idata, "log_likelihood"):
        raise TypeError("log likelihood not found in inference data object")
    if var_name is None:
        var_names = list(idata.log_likelihood.data_vars)
        if len(var_names) > 1:
            raise TypeError(
                f"Found several log likelihood arrays {var_names}, var_name cannot be None"
            )
        return idata.log_likelihood[var_names[0]]
    try:
        log_likelihood = idata.log_likelihood[var_name]
    except KeyError as err:
        raise TypeError(f"No log likelihood data named {var_name} found") from err
    return log_likelihood


# get_log_likelihood and get_log_prior functions should be somewhere else
def get_log_likelihood_dataset(idata, var_names=None):
    """Retrieve the log likelihood dataset of a given variable."""
    if (
        not hasattr(idata, "log_likelihood")
        and hasattr(idata, "sample_stats")
        and hasattr(idata.sample_stats, "log_likelihood")
    ):
        warnings.warn(
            "Storing the log_likelihood in sample_stats groups has been deprecated",
            DeprecationWarning,
        )
        log_lik_ds = idata.sample_stats.ds[["log_likelihood"]]
    if not hasattr(idata, "log_likelihood"):
        raise TypeError("log likelihood not found in inference data object")
    log_lik_ds = idata.log_likelihood.ds
    if var_names is None:
        return log_lik_ds
    if isinstance(var_names, Hashable):
        return log_lik_ds[[var_names]]
    return log_lik_ds[var_names]


def get_log_likelihood_dataarray(data, var_name=None):
    log_lik_ds = get_log_likelihood_dataset(data)
    if var_name is None:
        var_names = list(log_lik_ds.data_vars)
        if len(var_names) > 1:
            raise TypeError(
                f"Found several log likelihood arrays {var_names}, var_name cannot be None"
            )
        return log_lik_ds[var_names[0]]

    try:
        log_likelihood = log_lik_ds[var_name]
    except KeyError as err:
        raise TypeError(f"No log likelihood data named {var_name} found") from err
    return log_likelihood


def get_log_prior(idata, var_names=None):
    """Retrieve the log prior dataset of a given variable."""
    if not hasattr(idata, "log_prior"):
        raise TypeError("log prior not found in inference data object")
    if var_names is None:
        return idata.log_prior.ds
    if isinstance(var_names, Hashable):
        return idata.log_prior.ds[[var_names]]
    return idata.log_prior.ds[var_names]


BASE_HEADER_FMT = (
    "Computed from {n_samples} posterior samples and {n_points} observations log-likelihood matrix."
)
BASE_TABLE_FMT = """{{0:{0}}} Estimate       SE
{{ic_label}} {{ic_value:8.2f}}  {{ic_se:7.2f}}
{{p_label:{0}}} {{p_value:8.2f}}        -"""
WARNING_FMT = "\n\nThere has been a warning during the calculation. Please check the results."
POINTWISE_LOO_FMT = """------

Pareto k diagnostic values:
                         {{0:>{0}}} {{1:>6}}
(-Inf, {{8:.2f}}]   (good)     {{2:{0}d}} {{5:6.1f}}%
   ({{8:.2f}}, 1]   (bad)      {{3:{0}d}} {{6:6.1f}}%
    (1, Inf)   (very bad) {{4:{0}d}} {{7:6.1f}}%
"""
SCALE_DICT = {"deviance": "deviance", "log": "elpd", "negative_log": "-elpd"}


[docs] @dataclass(kw_only=True) class ELPDData: """Base container for expected log pointwise predictive density (ELPD) results. Every cross-validation function returns a subclass of this class. :class:`ELPDDataLOO` holds PSIS-LOO-CV results, :class:`ELPDDataLOOSubsample` holds subsampled PSIS-LOO-CV results, :class:`ELPDDataLOOKFold` holds k-fold cross-validation results and :class:`ELPDDataLFO` holds leave-future-out cross-validation results. The class fixes the ``kind`` attribute that names the estimator and determines which additional attributes are available. Attributes ---------- kind : str or None Name of the estimator, one of ``"loo"``, ``"loo_kfold"`` or ``"lfo_cv"``. It is read-only and None for a bare ``ELPDData``. elpd : float Expected log pointwise predictive density. se : float Standard error of ``elpd``. p : float Effective number of parameters. n_samples : int Number of posterior samples. n_data_points : int Number of observations. scale : str Scale of the estimate, ``"log"``, ``"negative_log"`` or ``"deviance"``. warning : bool True when a warning was raised during the computation. good_k : float Threshold on the Pareto k diagnostic above which estimates are unreliable. elpd_i : DataArray, optional Pointwise ELPD values, only when ``pointwise=True`` was requested. pareto_k : DataArray, optional Pointwise Pareto k diagnostics, only when ``pointwise=True`` was requested. """ elpd: float se: float p: float n_samples: int n_data_points: int scale: str warning: bool good_k: float elpd_i: DataArray = None pareto_k: DataArray = None _kind: ClassVar[str | None] = None _display_kind: ClassVar[str | None] = None @property def kind(self): """Name of the estimator that produced the result.""" return self._kind def _header(self): return BASE_HEADER_FMT.format(n_samples=self.n_samples, n_points=self.n_data_points) def _table(self): scale_str = SCALE_DICT[self.scale] display_kind = self._display_kind or self.kind ic_label = f"{scale_str}_{display_kind}" if display_kind else scale_str p_label = f"p_{display_kind}" if display_kind else "p" table = BASE_TABLE_FMT.format(len(ic_label)) return table.format( "", ic_label=ic_label, p_label=p_label, ic_value=self.elpd, ic_se=self.se, p_value=self.p, ) def _footer(self): return WARNING_FMT if self.warning else "" def __str__(self): """Print elpd data in a user friendly way.""" return self._header() + "\n\n" + self._table() + self._footer() def __repr__(self): """Alias to ``__str__``.""" return self.__str__() def __getitem__(self, key): """Define getitem magic method.""" return getattr(self, key) def __setitem__(self, key, item): """Define setitem magic method.""" setattr(self, key, item)
[docs] @dataclass(kw_only=True, repr=False) class ELPDDataLOO(ELPDData): """PSIS-LOO-CV results returned by :func:`loo` and related functions. Also returned by :func:`loo_i`, :func:`loo_approximate_posterior`, :func:`loo_moment_match` and :func:`reloo`. Inherits the attributes of :class:`ELPDData`. Attributes ---------- approx_posterior : bool True when the approximate posterior correction was applied. log_weights : DataArray, optional Smoothed log importance weights. log_jacobian : DataArray, optional Log-Jacobian adjustment for variable transformations. p_loo_i : DataArray, optional Pointwise effective number of parameters, set by ``reloo`` and ``loo_moment_match``. influence_pareto_k : DataArray, optional Pareto k values before moment matching, set by ``loo_moment_match``. n_eff_i : DataArray, optional Effective sample size per observation, set by ``loo_moment_match``. """ _kind: ClassVar[str] = "loo" approx_posterior: bool = False log_weights: DataArray = None log_jacobian: DataArray = None p_loo_i: DataArray = None influence_pareto_k: DataArray = None n_eff_i: DataArray = None def _header(self): header = super()._header() if self.approx_posterior: header += "\nPosterior approximation correction used." return header def _footer(self): footer = super()._footer() if self.pareto_k is None or self.good_k is None: return footer bins = np.asarray([-np.inf, self.good_k, 1, np.inf]) counts, *_ = np.histogram(self.pareto_k, bins=bins, density=False) extended = POINTWISE_LOO_FMT.format(max(4, len(str(np.max(counts))))) extended = extended.format( "Count", "Pct.", *[*counts, *(counts / np.sum(counts) * 100)], self.good_k, ) return footer + "\n" + extended
[docs] @dataclass(kw_only=True, repr=False) class ELPDDataLOOSubsample(ELPDDataLOO): """Subsampled PSIS-LOO-CV results returned by :func:`loo_subsample`. Also returned by :func:`update_subsample`. Inherits the attributes of :class:`ELPDDataLOO`. With ``pointwise=True`` the ``log_weights`` attribute holds a :class:`~xarray.Dataset` with one variable named after the log likelihood variable, otherwise a :class:`~xarray.DataArray` restricted to the subsampled observations. Attributes ---------- subsample_size : int Number of observations in the subsample. subsampling_se : float Standard error due to subsampling uncertainty only. loo_subsample_observations : ndarray Indices of the subsampled observations. elpd_loo_approx : DataArray Approximate pointwise ELPD for every observation. log_p : DataArray or ndarray, optional Log density of the target posterior. log_q : DataArray or ndarray, optional Log density of the proposal posterior. thin_factor : int or str, optional Thinning factor applied to the posterior draws, an integer or ``"auto"``. """ subsample_size: int subsampling_se: float loo_subsample_observations: np.ndarray elpd_loo_approx: DataArray log_p: DataArray | np.ndarray = None log_q: DataArray | np.ndarray = None thin_factor: int | str = None def _header(self): header = ( f"Computed from {self.n_samples} by {self.subsample_size} " f"subsampled log-likelihood\nvalues from {self.n_data_points} total observations." ) if self.approx_posterior: header += " Posterior approximation correction used." return header def _table(self): scale_str = SCALE_DICT[self.scale] display_kind = self._display_kind or self.kind return ( " Estimate SE subsampling SE\n" f"{scale_str}_{display_kind} {self.elpd:8.1f} {self.se:4.1f} " f" {self.subsampling_se:0.1f}\n" f"p_{display_kind} {self.p:4.1f}\n" )
[docs] @dataclass(kw_only=True, repr=False) class ELPDDataLOOKFold(ELPDData): """K-fold cross-validation results returned by :func:`loo_kfold`. Inherits the attributes of :class:`ELPDData`. ``good_k`` and ``pareto_k`` are always None because k-fold cross-validation does not use importance sampling. Attributes ---------- n_folds : int Number of folds. p_kfold_i : DataArray, optional Pointwise effective number of parameters, only when ``pointwise=True``. fold_fits : dict, optional Fitted models for each fold, only when ``save_fits=True``. """ _kind: ClassVar[str] = "loo_kfold" _display_kind: ClassVar[str] = "kfold" n_folds: int p_kfold_i: DataArray = None fold_fits: dict = None def _header(self): return f"Computed from {self.n_folds}-fold cross validation."
[docs] @dataclass(kw_only=True, repr=False) class ELPDDataLFO(ELPDData): """Leave-future-out cross-validation results returned by :func:`lfo_cv`. Inherits the attributes of :class:`ELPDData`. ``n_data_points`` counts forecast origins. Attributes ---------- forecast_horizon : int Number of steps ahead that were predicted. min_observations : int Minimum number of training observations. refits : ndarray Time indices at which the model was refit. n_refits : int Number of refits performed. p_lfo_i : DataArray, optional Pointwise effective number of parameters, only when ``pointwise=True``. """ _kind: ClassVar[str] = "lfo_cv" _display_kind: ClassVar[str] = "lfo" forecast_horizon: int min_observations: int refits: np.ndarray n_refits: int p_lfo_i: DataArray = None def _header(self): origin_word = "origin" if self.n_data_points == 1 else "origins" header = ( f"Computed from {self.n_data_points} forecast {origin_word} with " f"{self.forecast_horizon}-step-ahead predictions.\n" f"Minimum training observations: {self.min_observations}.\n" ) if self.good_k is None: return header + "The model was fit at every forecast origin." refit_word = "refit" if self.n_refits == 1 else "refits" return header + ( f"PSIS triggered {self.n_refits} additional exact {refit_word} " f"(k threshold: {self.good_k:.2f})." )
def _warn_non_unique_coords(xr_obj, dims_to_reduce): """Warn if coordinates in `xr_obj` for `dims_to_reduce` have duplicated values.""" non_unique_coords = [ dim for dim in xr_obj.dims if (dim not in dims_to_reduce) and (dim in xr_obj.coords) and (len(np.unique(xr_obj.coords[dim])) != xr_obj.sizes[dim]) ] if non_unique_coords: warnings.warn( f"Dimensions {non_unique_coords} don't have unique coordinates which might result " "in unexpected results. Either use 'groupby' beforehand or define unique coordiates " "to remove this warning" ) def _apply_multi_input_function( name, data, dims, dims_arg, *, group, var_names, filter_vars, coords, **kwargs, ): all_kwargs = {dims_arg: dims} | kwargs if isinstance(data, list | tuple | np.ndarray): data = np.array(data) return get_array_function(name)(data, **kwargs) dims = validate_dims(dims) if isinstance(data, xr.core.groupby.DataArrayGroupBy | xr.core.groupby.DatasetGroupBy): # Make sure the grouped dimension is added as one of the dimensions to be reduced dims = list(set(dims).union(data.group1d.dims)) if isinstance(data, xr.core.groupby.DataArrayGroupBy): def reduce_func(xr_obj): if coords is not None: xr_obj = xr_obj.sel(coords) return getattr(xr_obj.azstats, name)(**{dims_arg: dims}, **kwargs) else: def reduce_func(xr_obj): xr_obj = xr_obj.azstats.filter_vars( var_names=var_names, filter_vars=filter_vars ).dataset if coords is not None: xr_obj = xr_obj.sel(coords) return getattr(xr_obj.azstats, name)(**{dims_arg: dims}, **kwargs) return data.map(reduce_func) if isinstance(data, xr.DataArray): if coords is not None: data = data.sel(coords) _warn_non_unique_coords(data, dims) return getattr(data.azstats, name)(**all_kwargs) if isinstance(data, xr.DataTree): data = data.azstats.filter_vars( group=group, var_names=var_names, filter_vars=filter_vars ).datatree if coords is not None: data = data.sel(coords) _warn_non_unique_coords(data, dims) return getattr(data.azstats, name)(**all_kwargs, group=group) data = convert_to_dataset(data, group=group) data = data.azstats.filter_vars(var_names=var_names, filter_vars=filter_vars).dataset if coords is not None: data = data.sel(coords) _warn_non_unique_coords(data, dims) return getattr(data.azstats, name)(**all_kwargs) def _warn_pareto_k(pareto_k_values, n_samples, suppress=False, usage="loo"): """Check Pareto k values and issue warnings if necessary.""" good_k = min(1 - 1 / np.log10(n_samples), 0.7) if n_samples > 1 else 0.7 warn_mg = False if np.any(pareto_k_values > good_k): if not suppress: if usage == "loo": warnings.warn( "Estimated shape parameter of Pareto distribution is greater than " f"{good_k:.2f} for one or more samples. You should consider using a " " more robust model, this is because importance sampling is less likely " "to work well if the marginal posterior and LOO posterior are very different. " "This is more likely to happen with a non-robust model and highly influential " "observations." ) elif usage == "power_scale": warnings.warn( "Estimated shape parameter of Pareto distribution is greater than " f"{good_k:.2f} for the tail of the importance weights for " "one or more power-scaled posterior estimates. This means that the posterior " "is shifting more than can be reliably estimated by importance sampling." ) warn_mg = True return warn_mg, good_k