From e401a026d9f7e2d7b0eefe79f30c07c18de7ecba Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 24 Mar 2026 12:34:11 +0100 Subject: [PATCH 01/11] refactor score functions and add utility functions --- scoringrules/_brier.py | 102 ++++++++++++------ scoringrules/_crps.py | 98 ++++++----------- scoringrules/_dss.py | 19 ++-- scoringrules/_energy.py | 50 ++++----- scoringrules/_error_spread.py | 25 ++--- scoringrules/_interval.py | 4 +- scoringrules/_kernels.py | 135 ++++++++---------------- scoringrules/_logs.py | 19 ++-- scoringrules/_variogram.py | 92 ++++++++++------ scoringrules/backend/base.py | 24 +++++ scoringrules/backend/jax.py | 18 ++++ scoringrules/backend/numpy.py | 19 ++++ scoringrules/backend/tensorflow.py | 25 +++++ scoringrules/backend/torch.py | 22 ++++ scoringrules/core/brier.py | 41 +------ scoringrules/core/crps/__init__.py | 2 +- scoringrules/core/crps/_gufuncs.py | 54 +--------- scoringrules/core/energy/_gufuncs.py | 22 ++-- scoringrules/core/energy/_score.py | 16 ++- scoringrules/core/kernels/__init__.py | 9 +- scoringrules/core/kernels/_gufuncs.py | 60 +++++------ scoringrules/core/utils.py | 129 +++++++++++++++++++++- scoringrules/core/variogram/__init__.py | 17 +-- scoringrules/core/variogram/_gufuncs.py | 70 +++++++----- scoringrules/core/variogram/_score.py | 52 +++++---- tests/test_kernels.py | 5 + tests/test_variogram.py | 47 ++++++--- tests/test_wcrps.py | 31 ++++-- 28 files changed, 666 insertions(+), 541 deletions(-) diff --git a/scoringrules/_brier.py b/scoringrules/_brier.py index 554f0f2..b7a9c5c 100644 --- a/scoringrules/_brier.py +++ b/scoringrules/_brier.py @@ -1,7 +1,7 @@ import typing as tp -from scoringrules.backend import backends from scoringrules.core import brier +from scoringrules.core.utils import binary_array_check, categorical_array_check if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, ArrayLike, Backend @@ -15,30 +15,43 @@ def brier_score( backend: "Backend" = None, ) -> "Array": r""" - Compute the Brier Score (BS). + Brier Score - The BS is formulated as + The Brier Score is defined as .. math:: - BS(f, y) = (f - y)^2, + \text{BS}(F, y) = (F - y)^2, - where :math:`f \in [0, 1]` is the predicted probability of an event and :math:`y \in \{0, 1\}` the actual outcome. + where :math:`F \in [0, 1]` is the predicted probability of an event and :math:`y \in \{0, 1\}` + is the outcome [1]_. Parameters ---------- obs : array_like - Observed outcome, either 0 or 1. + Observed outcomes, either 0 or 1. fct : array_like - Forecasted probabilities between 0 and 1. + Forecast probabilities, between 0 and 1. backend : str - The name of the backend used for computations. Defaults to 'numpy'. + The name of the backend used for computations. Default is 'numpy'. Returns ------- - brier_score : array_like + score : array_like The computed Brier Score. + References + ---------- + .. [1] Brier, G. W. (1950). + Verification of forecasts expressed in terms of probability. + Monthly Weather Review, 78, 1-3. + + Examples + -------- + >>> import scoringrules as sr + >>> sr.brier_score(1, 0.2) + 0.64000 """ + obs, fct = binary_array_check(obs, fct, backend=backend) return brier.brier_score(obs=obs, fct=fct, backend=backend) @@ -48,45 +61,68 @@ def rps_score( /, k_axis: int = -1, *, + onehot: bool = False, backend: "Backend" = None, ) -> "Array": r""" - Compute the (Discrete) Ranked Probability Score (RPS). + (Discrete) Ranked Probability Score (RPS) Suppose the outcome corresponds to one of :math:`K` ordered categories. The RPS is defined as .. math:: - RPS(f, y) = \sum_{k=1}^{K}(\tilde{f}_{k} - \tilde{y}_{k})^2, + \text{RPS}(F, y) = \sum_{k=1}^{K}(\tilde{F}_{k} - \tilde{y}_{k})^2, - where :math:`f \in [0, 1]^{K}` is a vector of length :math:`K` containing forecast probabilities - that each of the :math:`K` categories will occur, and :math:`y \in \{0, 1\}^{K}` is a vector of - length :math:`K`, with the :math:`k`-th element equal to one if the :math:`k`-th category occurs. We - have :math:`\sum_{k=1}^{K} y_{k} = \sum_{k=1}^{K} f_{k} = 1`, and, for :math:`k = 1, \dots, K`, - :math:`\tilde{y}_{k} = \sum_{i=1}^{k} y_{i}` and :math:`\tilde{f}_{k} = \sum_{i=1}^{k} f_{i}`. + where :math:`F \in [0, 1]^{K}` is a vector of length :math:`K`, containing forecast probabilities + that each of the :math:`K` categories will occur, with :math:`\sum_{k=1}^{K} F_{k} = 1` and + :math:`\tilde{F}_{k} = \sum_{i=1}^{k} F_{i}` for all :math:`k = 1, \dots, K`, and where + :math:`y \in \{1, \dots, K\}` is the category that occurs, with :math:`\tilde{y}_{k} = 1\{y \le i\}` + for all :math:`k = 1, \dots, K` [1]_. + + The outcome can alternatively be interpreted as a vector :math:`y \in \{0, 1\}^K` of length :math:`K`, with the + :math:`k`-th element equal to one if the :math:`k`-th category occurs, and zero otherwise. + Using this one-hot encoding, the RPS is defined analogously to as above, but with + :math:`\tilde{y}_{k} = \sum_{i=1}^{k} y_{i}`. Parameters ---------- obs : array_like - Array of 0's and 1's corresponding to unobserved and observed categories - forecasts : + Category that occurs. Or array of 0's and 1's corresponding to unobserved and + observed categories if `onehot=True`. + fct : array Array of forecast probabilities for each category. k_axis: int - The axis corresponding to the categories. Default is the last axis. + The axis of `obs` and `fct` corresponding to the categories. Default is the last axis. + onehot: bool + Boolean indicating whether the observation is the category that occurs or a onehot + encoded vector of 0's and 1's. Default is False. backend : str - The name of the backend used for computations. Defaults to 'numpy'. + The name of the backend used for computations. Default is 'numpy'. Returns ------- - score: + score: array_like The computed Ranked Probability Score. + References + ---------- + .. [1] Epstein, E. S. (1969). + A scoring system for probability forecasts of ranked categories. + Journal of Applied Meteorology, 8, 985-987. + https://www.jstor.org/stable/26174707. + + Examples + -------- + >>> import scoringrules as sr + >>> import numpy as np + >>> fct = np.array([0.1, 0.2, 0.3, 0.4]) + >>> obs = 3 + >>> sr.rps_score(obs, fct) + 0.25999999999999995 + >>> obs = np.array([0, 0, 1, 0]) + >>> sr.rps_score(obs, fct, onehot=True) + 0.25999999999999995 """ - B = backends.active if backend is None else backends[backend] - fct = B.asarray(fct) - - if k_axis != -1: - fct = B.moveaxis(fct, k_axis, -1) - + obs, fct = categorical_array_check(obs, fct, k_axis, onehot, backend=backend) return brier.rps_score(obs=obs, fct=fct, backend=backend) @@ -122,6 +158,7 @@ def log_score( The computed Log Score. """ + obs, fct = binary_array_check(obs, fct, backend=backend) return brier.log_score(obs=obs, fct=fct, backend=backend) @@ -131,6 +168,7 @@ def rls_score( /, k_axis: int = -1, *, + onehot: bool = False, backend: "Backend" = None, ) -> "Array": r""" @@ -155,6 +193,9 @@ def rls_score( Forecasted probabilities between 0 and 1. k_axis: int The axis corresponding to the categories. Default is the last axis. + onehot: bool + Boolean indicating whether the observation is the category that occurs or a onehot + encoded vector of 0's and 1's. Default is False. backend : str The name of the backend used for computations. Defaults to 'numpy'. @@ -164,12 +205,7 @@ def rls_score( The computed Ranked Logarithmic Score. """ - B = backends.active if backend is None else backends[backend] - fct = B.asarray(fct) - - if k_axis != -1: - fct = B.moveaxis(fct, k_axis, -1) - + obs, fct = categorical_array_check(obs, fct, k_axis, onehot, backend=backend) return brier.rls_score(obs=obs, fct=fct, backend=backend) diff --git a/scoringrules/_crps.py b/scoringrules/_crps.py index 62e806e..8050a4b 100644 --- a/scoringrules/_crps.py +++ b/scoringrules/_crps.py @@ -2,6 +2,13 @@ from scoringrules.backend import backends from scoringrules.core import crps, stats +from scoringrules.core.utils import ( + univariate_array_check, + estimator_check, + uv_weighted_score_weights, + uv_weighted_score_chain, + univariate_sort_ens, +) if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, ArrayLike, Backend @@ -96,29 +103,13 @@ def crps_ensemble( >>> sr.crps_ensemble(obs, pred) array([0.69605316, 0.32865417, 0.39048665]) """ - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - - if not sorted_ensemble and estimator not in [ - "nrg", - "akr", - "akr_circperm", - "fair", - ]: - fct = B.sort(fct, axis=-1) - + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) + fct = univariate_sort_ens(fct, estimator, sorted_ensemble, backend=backend) if backend == "numba": - if estimator not in crps.estimator_gufuncs: - raise ValueError( - f"{estimator} is not a valid estimator. " - f"Must be one of {crps.estimator_gufuncs.keys()}" - ) + estimator_check(estimator, crps.estimator_gufuncs) return crps.estimator_gufuncs[estimator](obs, fct) - - return crps.ensemble(obs, fct, estimator, backend=backend) + else: + return crps.ensemble(obs, fct, estimator, backend=backend) def twcrps_ensemble( @@ -204,14 +195,9 @@ def twcrps_ensemble( >>> sr.twcrps_ensemble(obs, fct, v_func=v_func) array([0.69605316, 0.32865417, 0.39048665]) """ - if v_func is None: - B = backends.active if backend is None else backends[backend] - a, b, obs, fct = map(B.asarray, (a, b, obs, fct)) - - def v_func(x): - return B.minimum(B.maximum(x, a), b) - - obs, fct = map(v_func, (obs, fct)) + obs, fct = uv_weighted_score_chain( + obs, fct, a=a, b=b, v_func=v_func, backend=backend + ) return crps_ensemble( obs, fct, @@ -303,25 +289,12 @@ def owcrps_ensemble( >>> sr.owcrps_ensemble(obs, fct, w_func=w_func) array([0.91103733, 0.45212402, 0.35686667]) """ - - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - - if w_func is None: - - def w_func(x): - return ((a <= x) & (x <= b)) * 1.0 - - obs_weights, fct_weights = map(w_func, (obs, fct)) - obs_weights, fct_weights = map(B.asarray, (obs_weights, fct_weights)) - + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) + obs_w, fct_w = uv_weighted_score_weights(obs, fct, a, b, w_func, backend=backend) if backend == "numba": - return crps.estimator_gufuncs["ownrg"](obs, fct, obs_weights, fct_weights) - - return crps.ow_ensemble(obs, fct, obs_weights, fct_weights, backend=backend) + return crps.estimator_gufuncs["ownrg"](obs, fct, obs_w, fct_w) + else: + return crps.ow_ensemble(obs, fct, obs_w, fct_w, backend=backend) def vrcrps_ensemble( @@ -403,31 +376,19 @@ def vrcrps_ensemble( >>> sr.vrcrps_ensemble(obs, fct, w_func) array([0.90036433, 0.41515255, 0.41653833]) """ - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - - if w_func is None: - - def w_func(x): - return ((a <= x) & (x <= b)) * 1.0 - - obs_weights, fct_weights = map(w_func, (obs, fct)) - obs_weights, fct_weights = map(B.asarray, (obs_weights, fct_weights)) - + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) + obs_w, fct_w = uv_weighted_score_weights(obs, fct, a, b, w_func, backend=backend) if backend == "numba": - return crps.estimator_gufuncs["vrnrg"](obs, fct, obs_weights, fct_weights) - - return crps.vr_ensemble(obs, fct, obs_weights, fct_weights, backend=backend) + return crps.estimator_gufuncs["vrnrg"](obs, fct, obs_w, fct_w) + else: + return crps.vr_ensemble(obs, fct, obs_w, fct_w, backend=backend) def crps_quantile( obs: "ArrayLike", fct: "Array", - alpha: "Array", /, + alpha: "Array", m_axis: int = -1, *, backend: "Backend" = None, @@ -478,10 +439,11 @@ def crps_quantile( # TODO: add example """ B = backends.active if backend is None else backends[backend] - obs, fct, alpha = map(B.asarray, (obs, fct, alpha)) + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) + alpha = B.asarray(alpha) + if B.any(alpha <= 0) or B.any(alpha >= 1): + raise ValueError("`alpha` contains entries that are not between 0 and 1.") if not fct.shape[-1] == alpha.shape[-1]: raise ValueError("Expected matching length of `fct` and `alpha` values.") diff --git a/scoringrules/_dss.py b/scoringrules/_dss.py index 391b38c..de0ce37 100644 --- a/scoringrules/_dss.py +++ b/scoringrules/_dss.py @@ -1,8 +1,7 @@ import typing as tp -from scoringrules.backend import backends from scoringrules.core import dss -from scoringrules.core.utils import multivariate_array_check +from scoringrules.core.utils import univariate_array_check, multivariate_array_check if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, Backend @@ -46,16 +45,11 @@ def dssuv_ensemble( score: Array The computed Dawid-Sebastiani Score. """ - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) if backend == "numba": return dss._dss_uv_gufunc(obs, fct, bias) - - return dss.ds_score_uv(obs, fct, bias, backend=backend) + else: + return dss.ds_score_uv(obs, fct, bias, backend=backend) def dssmv_ensemble( @@ -101,8 +95,7 @@ def dssmv_ensemble( The computed Dawid-Sebastiani Score. """ obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - if backend == "numba": return dss._dss_mv_gufunc(obs, fct, bias) - - return dss.ds_score_mv(obs, fct, bias, backend=backend) + else: + return dss.ds_score_mv(obs, fct, bias, backend=backend) diff --git a/scoringrules/_energy.py b/scoringrules/_energy.py index 263deec..e4db45e 100644 --- a/scoringrules/_energy.py +++ b/scoringrules/_energy.py @@ -1,8 +1,12 @@ import typing as tp -from scoringrules.backend import backends from scoringrules.core import energy -from scoringrules.core.utils import multivariate_array_check +from scoringrules.core.utils import ( + multivariate_array_check, + estimator_check, + mv_weighted_score_chain, + mv_weighted_score_weights, +) if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, ArrayLike, Backend @@ -62,18 +66,12 @@ def es_ensemble( :ref:`theory.multivariate` Some theoretical background on scoring rules for multivariate forecasts. """ - backend = backend if backend is not None else backends._active obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - if backend == "numba": - if estimator not in energy.estimator_gufuncs: - raise ValueError( - f"{estimator} is not a valid estimator. " - f"Must be one of {energy.estimator_gufuncs.keys()}" - ) + estimator_check(estimator, energy.estimator_gufuncs) return energy.estimator_gufuncs[estimator](obs, fct) - - return energy.es(obs, fct, estimator=estimator, backend=backend) + else: + return energy.es(obs, fct, estimator=estimator, backend=backend) def twes_ensemble( @@ -124,7 +122,7 @@ def twes_ensemble( twes_ensemble : array_like The computed Threshold-Weighted Energy Score. """ - obs, fct = map(v_func, (obs, fct)) + obs, fct = mv_weighted_score_chain(obs, fct, v_func) return es_ensemble( obs, fct, m_axis=m_axis, v_axis=v_axis, estimator=estimator, backend=backend ) @@ -178,17 +176,12 @@ def owes_ensemble( owes_ensemble : array_like The computed Outcome-Weighted Energy Score. """ - B = backends.active if backend is None else backends[backend] - obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - - fct_weights = B.apply_along_axis(w_func, fct, -1) - obs_weights = B.apply_along_axis(w_func, obs, -1) - - if B.name == "numba": - return energy.estimator_gufuncs["ownrg"](obs, fct, obs_weights, fct_weights) - - return energy.owes(obs, fct, obs_weights, fct_weights, backend=backend) + obs_w, fct_w = mv_weighted_score_weights(obs, fct, w_func=w_func, backend=backend) + if backend == "numba": + return energy.estimator_gufuncs["ownrg"](obs, fct, obs_w, fct_w) + else: + return energy.owes(obs, fct, obs_w, fct_w, backend=backend) def vres_ensemble( @@ -240,14 +233,9 @@ def vres_ensemble( vres_ensemble : array_like The computed Vertically Re-scaled Energy Score. """ - B = backends.active if backend is None else backends[backend] - obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - - fct_weights = B.apply_along_axis(w_func, fct, -1) - obs_weights = B.apply_along_axis(w_func, obs, -1) - + obs_w, fct_w = mv_weighted_score_weights(obs, fct, w_func=w_func, backend=backend) if backend == "numba": - return energy.estimator_gufuncs["vrnrg"](obs, fct, obs_weights, fct_weights) - - return energy.vres(obs, fct, obs_weights, fct_weights, backend=backend) + return energy.estimator_gufuncs["vrnrg"](obs, fct, obs_w, fct_w) + else: + return energy.vres(obs, fct, obs_w, fct_w, backend=backend) diff --git a/scoringrules/_error_spread.py b/scoringrules/_error_spread.py index e712c6b..f9ed694 100644 --- a/scoringrules/_error_spread.py +++ b/scoringrules/_error_spread.py @@ -1,15 +1,15 @@ import typing as tp -from scoringrules.backend import backends from scoringrules.core import error_spread +from scoringrules.core.utils import univariate_array_check if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, ArrayLike, Backend def error_spread_score( - observations: "ArrayLike", - forecasts: "Array", + obs: "ArrayLike", + fct: "Array", /, m_axis: int = -1, *, @@ -19,9 +19,9 @@ def error_spread_score( Parameters ---------- - observations: ArrayLike + obs: ArrayLike The observed values. - forecasts: Array + fct: Array The predicted forecast ensemble, where the ensemble dimension is by default represented by the last axis. m_axis: int @@ -34,13 +34,8 @@ def error_spread_score( - Array An array of error spread scores for each ensemble forecast, which should be averaged to get meaningful values. """ - B = backends.active if backend is None else backends[backend] - observations, forecasts = map(B.asarray, (observations, forecasts)) - - if m_axis != -1: - forecasts = B.moveaxis(forecasts, m_axis, -1) - - if B.name == "numba": - return error_spread._ess_gufunc(observations, forecasts) - - return error_spread.ess(observations, forecasts, backend=backend) + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) + if backend == "numba": + return error_spread._ess_gufunc(obs, fct) + else: + return error_spread.ess(obs, fct, backend=backend) diff --git a/scoringrules/_interval.py b/scoringrules/_interval.py index ce742fb..604a87d 100644 --- a/scoringrules/_interval.py +++ b/scoringrules/_interval.py @@ -178,5 +178,5 @@ def weighted_interval_score( if B.name == "numba": return interval._weighted_interval_score_gufunc(*args) - - return interval.weighted_interval_score(*args, backend=backend) + else: + return interval.weighted_interval_score(*args, backend=backend) diff --git a/scoringrules/_kernels.py b/scoringrules/_kernels.py index c78e923..1ba4a50 100644 --- a/scoringrules/_kernels.py +++ b/scoringrules/_kernels.py @@ -1,8 +1,14 @@ import typing as tp -from scoringrules.backend import backends from scoringrules.core import kernels -from scoringrules.core.utils import multivariate_array_check +from scoringrules.core.utils import ( + univariate_array_check, + multivariate_array_check, + estimator_check, + uv_weighted_score_weights, + uv_weighted_score_chain, + mv_weighted_score_weights, +) if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, ArrayLike, Backend @@ -57,22 +63,12 @@ def gksuv_ensemble( >>> import scoringrules as sr >>> sr.gks_ensemble(obs, pred) """ - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) if backend == "numba": - if estimator not in kernels.estimator_gufuncs: - raise ValueError( - f"{estimator} is not a valid estimator. " - f"Must be one of {kernels.estimator_gufuncs.keys()}" - ) - else: - return kernels.estimator_gufuncs[estimator](obs, fct) - - return kernels.ensemble_uv(obs, fct, estimator, backend=backend) + estimator_check(estimator, kernels.estimator_gufuncs_uv) + return kernels.estimator_gufuncs_uv[estimator](obs, fct) + else: + return kernels.ensemble_uv(obs, fct, estimator=estimator, backend=backend) def twgksuv_ensemble( @@ -141,14 +137,7 @@ def twgksuv_ensemble( >>> >>> sr.twgksuv_ensemble(obs, pred, v_func=v_func) """ - if v_func is None: - B = backends.active if backend is None else backends[backend] - a, b, obs, fct = map(B.asarray, (a, b, obs, fct)) - - def v_func(x): - return B.minimum(B.maximum(x, a), b) - - obs, fct = map(v_func, (obs, fct)) + obs, fct = uv_weighted_score_chain(obs, fct, a, b, v_func, backend=backend) return gksuv_ensemble( obs, fct, @@ -222,24 +211,12 @@ def owgksuv_ensemble( >>> >>> sr.owgksuv_ensemble(obs, pred, w_func=w_func) """ - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - - if w_func is None: - - def w_func(x): - return ((a <= x) & (x <= b)) * 1.0 - - obs_weights, fct_weights = map(w_func, (obs, fct)) - obs_weights, fct_weights = map(B.asarray, (obs_weights, fct_weights)) - + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) + obs_w, fct_w = uv_weighted_score_weights(obs, fct, a, b, w_func, backend=backend) if backend == "numba": - return kernels.estimator_gufuncs["ow"](obs, fct, obs_weights, fct_weights) - - return kernels.ow_ensemble_uv(obs, fct, obs_weights, fct_weights, backend=backend) + return kernels.estimator_gufuncs_uv["ow"](obs, fct, obs_w, fct_w) + else: + return kernels.ow_ensemble_uv(obs, fct, obs_w, fct_w, backend=backend) def vrgksuv_ensemble( @@ -305,24 +282,12 @@ def vrgksuv_ensemble( >>> >>> sr.vrgksuv_ensemble(obs, pred, w_func=w_func) """ - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - - if w_func is None: - - def w_func(x): - return ((a <= x) & (x <= b)) * 1.0 - - obs_weights, fct_weights = map(w_func, (obs, fct)) - obs_weights, fct_weights = map(B.asarray, (obs_weights, fct_weights)) - + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) + obs_w, fct_w = uv_weighted_score_weights(obs, fct, a, b, w_func, backend=backend) if backend == "numba": - return kernels.estimator_gufuncs["vr"](obs, fct, obs_weights, fct_weights) - - return kernels.vr_ensemble_uv(obs, fct, obs_weights, fct_weights, backend=backend) + return kernels.estimator_gufuncs_uv["vr"](obs, fct, obs_w, fct_w) + else: + return kernels.vr_ensemble_uv(obs, fct, obs_w, fct_w, backend=backend) def gksmv_ensemble( @@ -376,19 +341,12 @@ def gksmv_ensemble( score : array_like The GKS between the forecast ensemble and obs. """ - backend = backend if backend is not None else backends._active obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - if backend == "numba": - if estimator not in kernels.estimator_gufuncs_mv: - raise ValueError( - f"{estimator} is not a valid estimator. " - f"Must be one of {kernels.estimator_gufuncs_mv.keys()}" - ) - else: - return kernels.estimator_gufuncs_mv[estimator](obs, fct) - - return kernels.ensemble_mv(obs, fct, estimator, backend=backend) + estimator_check(estimator, kernels.estimator_gufuncs_mv) + return kernels.estimator_gufuncs_mv[estimator](obs, fct) + else: + return kernels.ensemble_mv(obs, fct, estimator=estimator, backend=backend) def twgksmv_ensemble( @@ -399,6 +357,7 @@ def twgksmv_ensemble( m_axis: int = -2, v_axis: int = -1, *, + estimator: str = "nrg", backend: "Backend" = None, ) -> "Array": r"""Compute the Threshold-Weighted Gaussian Kernel Score (twGKS) for a finite multivariate ensemble. @@ -431,6 +390,8 @@ def twgksmv_ensemble( The axis corresponding to the ensemble dimension. Defaults to -2. v_axis : int or tuple of ints The axis corresponding to the variables dimension. Defaults to -1. + estimator : str + Indicates the estimator to be used. backend : str The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. @@ -440,7 +401,9 @@ def twgksmv_ensemble( The computed Threshold-Weighted Gaussian Kernel Score. """ obs, fct = map(v_func, (obs, fct)) - return gksmv_ensemble(obs, fct, m_axis=m_axis, v_axis=v_axis, backend=backend) + return gksmv_ensemble( + obs, fct, m_axis=m_axis, v_axis=v_axis, estimator=estimator, backend=backend + ) def owgksmv_ensemble( @@ -506,17 +469,12 @@ def owgksmv_ensemble( score : array_like The computed Outcome-Weighted GKS. """ - B = backends.active if backend is None else backends[backend] - obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - - fct_weights = B.apply_along_axis(w_func, fct, -1) - obs_weights = B.apply_along_axis(w_func, obs, -1) - - if B.name == "numba": - return kernels.estimator_gufuncs_mv["ow"](obs, fct, obs_weights, fct_weights) - - return kernels.ow_ensemble_mv(obs, fct, obs_weights, fct_weights, backend=backend) + obs_w, fct_w = mv_weighted_score_weights(obs, fct, w_func=w_func, backend=backend) + if backend == "numba": + return kernels.estimator_gufuncs_mv["ow"](obs, fct, obs_w, fct_w) + else: + return kernels.ow_ensemble_mv(obs, fct, obs_w, fct_w, backend=backend) def vrgksmv_ensemble( @@ -568,17 +526,12 @@ def vrgksmv_ensemble( score : array_like The computed Vertically Re-scaled Gaussian Kernel Score. """ - B = backends.active if backend is None else backends[backend] - obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - - fct_weights = B.apply_along_axis(w_func, fct, -1) - obs_weights = B.apply_along_axis(w_func, obs, -1) - - if B.name == "numba": - return kernels.estimator_gufuncs_mv["vr"](obs, fct, obs_weights, fct_weights) - - return kernels.vr_ensemble_mv(obs, fct, obs_weights, fct_weights, backend=backend) + obs_w, fct_w = mv_weighted_score_weights(obs, fct, w_func=w_func, backend=backend) + if backend == "numba": + return kernels.estimator_gufuncs_mv["vr"](obs, fct, obs_w, fct_w) + else: + return kernels.vr_ensemble_mv(obs, fct, obs_w, fct_w, backend=backend) __all__ = [ diff --git a/scoringrules/_logs.py b/scoringrules/_logs.py index 6b5f96d..4e7c65e 100644 --- a/scoringrules/_logs.py +++ b/scoringrules/_logs.py @@ -2,6 +2,7 @@ from scoringrules.backend import backends from scoringrules.core import logarithmic +from scoringrules.core.utils import univariate_array_check if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, ArrayLike, Backend @@ -51,12 +52,9 @@ def logs_ensemble( >>> sr.logs_ensemble(obs, pred) """ B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) - - M = fct.shape[-1] + M = fct.shape[-1] # number of ensemble members # Silverman's rule of thumb for estimating the bandwidth parameter if bw is None: @@ -67,7 +65,7 @@ def logs_ensemble( bw = 1.06 * B.minimum(sigmahat, iqr / 1.34) * (M ** (-1 / 5)) bw = B.stack([bw] * M, axis=-1) - w = B.zeros(fct.shape) + 1 / M + w = B.ones(fct.shape) / M return logarithmic.mixnorm(obs, fct, bw, w, backend=backend) @@ -127,12 +125,9 @@ def clogs_ensemble( >>> sr.clogs_ensemble(obs, pred, -1.0, 1.0) """ B = backends.active if backend is None else backends[backend] - fct = B.asarray(fct) - - if m_axis != -1: - fct = B.moveaxis(fct, m_axis, -1) + obs, fct = univariate_array_check(obs, fct, m_axis, backend=backend) - M = fct.shape[-1] + M = fct.shape[-1] # number of ensemble members # Silverman's rule of thumb for estimating the bandwidth parameter if bw is None: @@ -721,7 +716,7 @@ def logs_mixnorm( if w is None: M: int = m.shape[mc_axis] - w = B.zeros(m.shape) + 1 / M + w = B.ones(m.shape) / M else: w = B.asarray(w) diff --git a/scoringrules/_variogram.py b/scoringrules/_variogram.py index fb2b118..64513d5 100644 --- a/scoringrules/_variogram.py +++ b/scoringrules/_variogram.py @@ -2,7 +2,12 @@ from scoringrules.backend import backends from scoringrules.core import variogram -from scoringrules.core.utils import multivariate_array_check +from scoringrules.core.utils import ( + multivariate_array_check, + estimator_check, + mv_weighted_score_chain, + mv_weighted_score_weights, +) if tp.TYPE_CHECKING: from scoringrules.core.typing import Array, Backend @@ -12,6 +17,7 @@ def vs_ensemble( obs: "Array", fct: "Array", /, + w: "Array" = None, m_axis: int = -2, v_axis: int = -1, *, @@ -24,7 +30,7 @@ def vs_ensemble( For a :math:`D`-variate ensemble the Variogram Score [1]_ is defined as: .. math:: - \text{VS}_{p}(F_{ens}, \mathbf{y})= \sum_{i=1}^{d} \sum_{j=1}^{d} + \text{VS}_{p}(F_{ens}, \mathbf{y})= \sum_{i=1}^{d} \sum_{j=1}^{d} w_{i,j} \left( \frac{1}{M} \sum_{m=1}^{M} | x_{m,i} - x_{m,j} |^{p} - | y_{i} - y_{j} |^{p} \right)^{2}, where :math:`\mathbf{X}` and :math:`\mathbf{X'}` are independently sampled ensembles from from :math:`F`. @@ -37,12 +43,15 @@ def vs_ensemble( fct : array_like The predicted forecast ensemble, where the ensemble dimension is by default represented by the second last axis and the variables dimension by the last axis. - p : float - The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 1.0. + w : array_like + The weights assigned to pairs of dimensions. Must be of shape (..., D, D), where + D is the dimension, so that the weights are in the last two axes. m_axis : int The axis corresponding to the ensemble dimension. Defaults to -2. v_axis : int The axis corresponding to the variables dimension. Defaults to -1. + p : float + The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 0.5. estimator : str The variogram score estimator to be used. backend: str @@ -69,15 +78,20 @@ def vs_ensemble( >>> sr.vs_ensemble(obs, fct) array([ 8.65630139, 6.84693866, 19.52993307]) """ + B = backends.active if backend is None else backends[backend] obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) - if backend == "numba": - if estimator == "nrg": - return variogram._variogram_score_nrg_gufunc(obs, fct, p) - elif estimator == "fair": - return variogram._variogram_score_fair_gufunc(obs, fct, p) + if w is None: + D = fct.shape[-1] + w = B.ones(obs.shape + (D,)) + else: + w = B.asarray(w) - return variogram.vs(obs, fct, p, estimator=estimator, backend=backend) + if backend == "numba": + estimator_check(estimator, variogram.estimator_gufuncs) + return variogram.estimator_gufuncs[estimator](obs, fct, w, p) + else: + return variogram.vs(obs, fct, w, p, estimator=estimator, backend=backend) def twvs_ensemble( @@ -85,6 +99,7 @@ def twvs_ensemble( fct: "Array", v_func: tp.Callable, /, + w: "Array" = None, m_axis: int = -2, v_axis: int = -1, *, @@ -110,14 +125,17 @@ def twvs_ensemble( fct : array_like The predicted forecast ensemble, where the ensemble dimension is by default represented by the second last axis and the variables dimension by the last axis. - p : float - The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 1.0. + w : array_like + The weights assigned to pairs of dimensions. Must be of shape (..., D, D), where + D is the dimension, so that the weights are in the last two axes. v_func : callable, array_like -> array_like Chaining function used to emphasise particular outcomes. m_axis : int The axis corresponding to the ensemble dimension. Defaults to -2. v_axis : int The axis corresponding to the variables dimension. Defaults to -1. + p : float + The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 0.5. estimator : str The variogram score estimator to be used. backend : str @@ -145,9 +163,9 @@ def twvs_ensemble( >>> sr.twvs_ensemble(obs, fct, lambda x: np.maximum(x, -0.2)) array([5.94996894, 4.72029765, 6.08947229]) """ - obs, fct = map(v_func, (obs, fct)) + obs, fct = mv_weighted_score_chain(obs, fct, v_func) return vs_ensemble( - obs, fct, m_axis, v_axis, p=p, estimator=estimator, backend=backend + obs, fct, w, m_axis, v_axis, p=p, estimator=estimator, backend=backend ) @@ -156,6 +174,7 @@ def owvs_ensemble( fct: "Array", w_func: tp.Callable, /, + w: "Array" = None, m_axis: int = -2, v_axis: int = -1, *, @@ -185,14 +204,17 @@ def owvs_ensemble( fct : array_like The predicted forecast ensemble, where the ensemble dimension is by default represented by the second last axis and the variables dimension by the last axis. - p : float - The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 1.0. w_func : callable, array_like -> array_like Weight function used to emphasise particular outcomes. + w : array_like + The weights assigned to pairs of dimensions. Must be of shape (..., D, D), where + D is the dimension, so that the weights are in the last two axes. m_axis : int The axis corresponding to the ensemble dimension. Defaults to -2. v_axis : int The axis corresponding to the variables dimension. Defaults to -1. + p : float + The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 0.5. backend : str The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. @@ -212,18 +234,17 @@ def owvs_ensemble( array([ 9.86816636, 6.75532522, 19.59353723]) """ B = backends.active if backend is None else backends[backend] - obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) + obs_w, fct_w = mv_weighted_score_weights(obs, fct, w_func=w_func, backend=backend) - obs_weights = B.apply_along_axis(w_func, obs, -1) - fct_weights = B.apply_along_axis(w_func, fct, -1) + if w is None: + D = fct.shape[-1] + w = B.ones(obs.shape + (D,)) if backend == "numba": - return variogram._owvariogram_score_gufunc( - obs, fct, p, obs_weights, fct_weights - ) - - return variogram.owvs(obs, fct, obs_weights, fct_weights, p=p, backend=backend) + return variogram.estimator_gufuncs["ownrg"](obs, fct, w, obs_w, fct_w, p) + else: + return variogram.owvs(obs, fct, w, obs_w, fct_w, p=p, backend=backend) def vrvs_ensemble( @@ -231,6 +252,7 @@ def vrvs_ensemble( fct: "Array", w_func: tp.Callable, /, + w: "Array" = None, m_axis: int = -2, v_axis: int = -1, *, @@ -262,14 +284,17 @@ def vrvs_ensemble( fct : array_like The predicted forecast ensemble, where the ensemble dimension is by default represented by the second last axis and the variables dimension by the last axis. - p : float - The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 1.0. w_func : callable, array_like -> array_like Weight function used to emphasise particular outcomes. + w : array_like + The weights assigned to pairs of dimensions. Must be of shape (..., D, D), where + D is the dimension, so that the weights are in the last two axes. m_axis : int The axis corresponding to the ensemble dimension. Defaults to -2. v_axis : int The axis corresponding to the variables dimension. Defaults to -1. + p : float + The order of the Variogram Score. Typical values are 0.5, 1.0 or 2.0. Defaults to 0.5. backend : str The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. @@ -289,15 +314,14 @@ def vrvs_ensemble( array([46.48256493, 57.90759816, 92.37153472]) """ B = backends.active if backend is None else backends[backend] - obs, fct = multivariate_array_check(obs, fct, m_axis, v_axis, backend=backend) + obs_w, fct_w = mv_weighted_score_weights(obs, fct, w_func=w_func, backend=backend) - obs_weights = B.apply_along_axis(w_func, obs, -1) - fct_weights = B.apply_along_axis(w_func, fct, -1) + if w is None: + D = fct.shape[-1] + w = B.ones(obs.shape + (D,)) if backend == "numba": - return variogram._vrvariogram_score_gufunc( - obs, fct, p, obs_weights, fct_weights - ) - - return variogram.vrvs(obs, fct, obs_weights, fct_weights, p=p, backend=backend) + return variogram.estimator_gufuncs["vrnrg"](obs, fct, w, obs_w, fct_w, p) + else: + return variogram.vrvs(obs, fct, w, obs_w, fct_w, p=p, backend=backend) diff --git a/scoringrules/backend/base.py b/scoringrules/backend/base.py index 5e0e710..1f99917 100644 --- a/scoringrules/backend/base.py +++ b/scoringrules/backend/base.py @@ -160,6 +160,15 @@ def zeros( ) -> "Array": """Return a new array having a specified ``shape`` and filled with zeros.""" + @abc.abstractmethod + def ones( + self, + shape: int | tuple[int, ...], + *, + dtype: Dtype | None = None, + ) -> "Array": + """Return a new array having a specified ``shape`` and filled with ones.""" + @abc.abstractmethod def abs(self, x: "Array", /) -> "Array": """Calculate the absolute value for each element ``x_i`` of the input array ``x``.""" @@ -202,6 +211,17 @@ def any( ) -> "Array": """Test whether any input array element evaluates to ``True`` along a specified axis.""" + @abc.abstractmethod + def argsort( + self, + x: "Array", + /, + *, + axis: int = -1, + descending: bool = False, + ) -> "Array": + """Return the indices of a sorted copy of an input array ``x``.""" + @abc.abstractmethod def sort( self, @@ -299,6 +319,10 @@ def size(self, x: "Array") -> int: def indices(self, x: "Array") -> int: """Return an array representing the indices of a grid.""" + @abc.abstractmethod + def gather(self, x: "Array", ind: "Array", axis: int) -> "Array": + """Reorder an array ``x`` depending on a template ``ind`` across an axis ``axis``.""" + @abc.abstractmethod def roll(self, x: "Array", shift: int = 1, axis: int = -1) -> int: """Roll elements of an array along a given axis.""" diff --git a/scoringrules/backend/jax.py b/scoringrules/backend/jax.py index 279781a..9cf4074 100644 --- a/scoringrules/backend/jax.py +++ b/scoringrules/backend/jax.py @@ -149,6 +149,11 @@ def zeros( ) -> "Array": return jnp.zeros(shape, dtype=dtype) + def ones( + self, shape: int | tuple[int, ...], *, dtype: Dtype | None = None + ) -> "Array": + return jnp.ones(shape, dtype=dtype) + def abs(self, x: "Array") -> "Array": return jnp.abs(x) @@ -184,6 +189,16 @@ def all( ) -> "Array": return jnp.all(x, axis=axis, keepdims=keepdims) + def argsort( + self, + x: "Array", + /, + *, + axis: int = -1, + descending: bool = False, + ) -> "Array": + return jnp.argsort(x, axis=axis, descending=descending) + def sort( self, x: "Array", @@ -271,6 +286,9 @@ def size(self, x: "Array") -> int: def indices(self, dimensions: tuple) -> "Array": return jnp.indices(dimensions) + def gather(self, x: "Array", ind: "Array", axis: int) -> "Array": + return jnp.take_along_axis(x, ind, axis=axis) + def roll(self, x: "Array", shift: int = 1, axis: int = -1) -> "Array": return jnp.roll(x, shift=shift, axis=axis) diff --git a/scoringrules/backend/numpy.py b/scoringrules/backend/numpy.py index 03f78d5..6cbfc08 100644 --- a/scoringrules/backend/numpy.py +++ b/scoringrules/backend/numpy.py @@ -153,6 +153,11 @@ def zeros( ) -> "NDArray": return np.zeros(shape, dtype=dtype) + def ones( + self, shape: int | tuple[int, ...], *, dtype: Dtype | None = None + ) -> "NDArray": + return np.ones(shape, dtype=dtype) + def abs(self, x: "NDArray") -> "NDArray": return np.abs(x) @@ -188,6 +193,17 @@ def all( ) -> "NDArray": return np.all(x, axis=axis, keepdims=keepdims) + def argsort( + self, + x: "NDArray", + /, + *, + axis: int = -1, + descending: bool = False, + ) -> "NDArray": + x = -x if descending else x + return np.argsort(x, axis=axis) + def sort( self, x: "NDArray", @@ -267,6 +283,9 @@ def size(self, x: "NDArray") -> int: def indices(self, dimensions: tuple) -> "NDArray": return np.indices(dimensions) + def gather(self, x: "NDArray", ind: "NDArray", axis: int) -> "NDArray": + return np.take_along_axis(x, ind, axis=axis) + def roll(self, x: "NDArray", shift: int = 1, axis: int = -1) -> "NDArray": return np.roll(x, shift=shift, axis=axis) diff --git a/scoringrules/backend/tensorflow.py b/scoringrules/backend/tensorflow.py index 70060c3..9d58712 100644 --- a/scoringrules/backend/tensorflow.py +++ b/scoringrules/backend/tensorflow.py @@ -168,6 +168,16 @@ def zeros( dtype = DTYPE return tf.zeros(shape, dtype=dtype) + def ones( + self, + shape: int | tuple[int, ...], + *, + dtype: Dtype | None = None, + ) -> "Tensor": + if dtype is None: + dtype = DTYPE + return tf.ones(shape, dtype=dtype) + def abs(self, x: "Tensor") -> "Tensor": return tf.math.abs(x) @@ -216,6 +226,17 @@ def all( dtype = DTYPE return tf.cast(tf.math.reduce_all(x, axis=axis, keepdims=keepdims), dtype=dtype) + def argsort( + self, + x: "Tensor", + /, + *, + axis: int = -1, + descending: bool = False, + ) -> "Tensor": + direction = "DESCENDING" if descending else "ASCENDING" + return tf.argsort(x, axis=axis, direction=direction) + def sort( self, x: "Tensor", @@ -306,6 +327,10 @@ def indices(self, dimensions: tuple) -> "Tensor": indices = tf.stack(index_grids) return indices + def gather(self, x: "Tensor", ind: "Tensor", axis: int) -> "Tensor": + d = len(x.shape) + return tf.gather(x, ind, axis=axis, batch_dims=d) + def roll(self, x: "Tensor", shift: int = 1, axis: int = -1) -> "Tensor": return tf.roll(x, shift=shift, axis=axis) diff --git a/scoringrules/backend/torch.py b/scoringrules/backend/torch.py index 1ef9831..1eb141c 100644 --- a/scoringrules/backend/torch.py +++ b/scoringrules/backend/torch.py @@ -157,6 +157,14 @@ def zeros( ) -> "Tensor": return torch.zeros(shape, dtype=dtype) + def ones( + self, + shape: int | tuple[int, ...], + *, + dtype: Dtype | None = None, + ) -> "Tensor": + return torch.ones(shape, dtype=dtype) + def abs(self, x: "Tensor") -> "Tensor": return torch.abs(x) @@ -195,6 +203,17 @@ def all( else: return torch.all(x, dim=axis, keepdim=keepdims) + def argsort( + self, + x: "Tensor", + /, + *, + axis: int = -1, + descending: bool = False, + stable: bool = True, + ) -> "Tensor": + return torch.argsort(x, stable=stable, dim=axis, descending=descending) + def sort( self, x: "Tensor", @@ -289,6 +308,9 @@ def indices(self, dimensions: tuple) -> "Tensor": indices = torch.stack(index_grids) return indices + def gather(self, x: "Tensor", ind: "Tensor", axis: int) -> "Tensor": + return torch.gather(x, index=ind, dim=axis) + def roll(self, x: "Tensor", shift: int = 1, axis: int = -1) -> "Tensor": return torch.roll(x, shifts=shift, dims=axis) diff --git a/scoringrules/core/brier.py b/scoringrules/core/brier.py index e66fbae..18f05ed 100644 --- a/scoringrules/core/brier.py +++ b/scoringrules/core/brier.py @@ -12,16 +12,7 @@ def brier_score( obs: "ArrayLike", fct: "ArrayLike", backend: "Backend" = None ) -> "Array": """Compute the Brier Score for predicted probabilities of events.""" - B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if B.any(fct < 0.0) or B.any(fct > 1.0 + EPSILON): - raise ValueError("Forecasted probabilities must be within 0 and 1.") - - if not set(v.item() for v in B.unique_values(obs)) <= {0, 1}: - raise ValueError("Observations must be 0, 1, or NaN.") - - return B.asarray((fct - obs) ** 2) + return (fct - obs) ** 2 def rps_score( @@ -31,30 +22,12 @@ def rps_score( ) -> "Array": """Compute the Ranked Probability Score for ordinal categorical forecasts.""" B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if B.any(fct < 0.0) or B.any(fct > 1.0 + EPSILON): - raise ValueError("Forecast probabilities must be between 0 and 1.") - - categories = B.arange(1, fct.shape[-1] + 1) - obs_one_hot = B.where(B.expand_dims(obs, -1) == categories, 1, 0) - - return B.sum( - (B.cumsum(fct, axis=-1) - B.cumsum(obs_one_hot, axis=-1)) ** 2, axis=-1 - ) + return B.sum((B.cumsum(fct, axis=-1) - B.cumsum(obs, axis=-1)) ** 2, axis=-1) def log_score(obs: "ArrayLike", fct: "ArrayLike", backend: "Backend" = None) -> "Array": """Compute the Log Score for predicted probabilities of binary events.""" B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if B.any(fct < 0.0) or B.any(fct > 1.0 + EPSILON): - raise ValueError("Forecasted probabilities must be within 0 and 1.") - - if not set(v.item() for v in B.unique_values(obs)) <= {0, 1}: - raise ValueError("Observations must be 0, 1, or NaN.") - return B.asarray(-B.log(B.abs(fct + obs - 1.0))) @@ -65,15 +38,7 @@ def rls_score( ) -> "Array": """Compute the Ranked Logarithmic Score for ordinal categorical forecasts.""" B = backends.active if backend is None else backends[backend] - obs, fct = map(B.asarray, (obs, fct)) - - if B.any(fct < 0.0) or B.any(fct > 1.0 + EPSILON): - raise ValueError("Forecast probabilities must be between 0 and 1.") - - categories = B.arange(1, fct.shape[-1] + 1) - obs_one_hot = B.where(B.expand_dims(obs, -1) == categories, 1, 0) - return B.sum( - -B.log(B.abs(B.cumsum(fct, axis=-1) + B.cumsum(obs_one_hot, axis=-1) - 1.0)), + -B.log(B.abs(B.cumsum(fct, axis=-1) + B.cumsum(obs, axis=-1) - 1.0)), axis=-1, ) diff --git a/scoringrules/core/crps/__init__.py b/scoringrules/core/crps/__init__.py index c54b179..ddeae70 100644 --- a/scoringrules/core/crps/__init__.py +++ b/scoringrules/core/crps/__init__.py @@ -1,4 +1,4 @@ -from ._approx import ensemble, ow_ensemble, quantile_pinball, vr_ensemble +from ._approx import ensemble, ow_ensemble, vr_ensemble, quantile_pinball from ._closed import ( beta, binomial, diff --git a/scoringrules/core/crps/_gufuncs.py b/scoringrules/core/crps/_gufuncs.py index 1e2ed1d..e3ecc2b 100644 --- a/scoringrules/core/crps/_gufuncs.py +++ b/scoringrules/core/crps/_gufuncs.py @@ -1,7 +1,5 @@ -import math - import numpy as np -from numba import guvectorize, njit, vectorize +from numba import guvectorize from scoringrules.core.utils import lazy_gufunc_wrapper_uv @@ -250,57 +248,12 @@ def _vrcrps_ensemble_nrg_gufunc( out[0] = e_1 / M - 0.5 * e_2 / (M**2) + (wabs_x - wabs_y) * (wbar - ow) -@njit(["float32(float32)", "float64(float64)"]) -def _norm_cdf(x: float) -> float: - """Cumulative distribution function for the standard normal distribution.""" - out: float = (1.0 + math.erf(x / math.sqrt(2.0))) / 2.0 - return out - - -@njit(["float32(float32)", "float64(float64)"]) -def _norm_pdf(x: float) -> float: - """Probability density function for the standard normal distribution.""" - out: float = (1 / math.sqrt(2 * math.pi)) * math.exp(-(x**2) / 2) - return out - - -@njit(["float32(float32)", "float64(float64)"]) -def _logis_cdf(x: float) -> float: - """Cumulative distribution function for the standard logistic distribution.""" - out: float = 1.0 / (1.0 + math.exp(-x)) - return out - - -@vectorize(["float32(float32, float32, float32)", "float64(float64, float64, float64)"]) -def _crps_normal_ufunc(obs: float, mu: float, sigma: float) -> float: - ω = (obs - mu) / sigma - out: float = sigma * (ω * (2 * _norm_cdf(ω) - 1) + 2 * _norm_pdf(ω) - INV_SQRT_PI) - return out - - -@vectorize(["float32(float32, float32, float32)", "float64(float64, float64, float64)"]) -def _crps_lognormal_ufunc(obs: float, mulog: float, sigmalog: float) -> float: - ω = (np.log(obs) - mulog) / sigmalog - ex = 2 * np.exp(mulog + sigmalog**2 / 2) - out: float = obs * (2 * _norm_cdf(ω) - 1) - ex * ( - _norm_cdf(ω - sigmalog) + _norm_cdf(sigmalog / np.sqrt(2)) - 1 - ) - return out - - -@vectorize(["float32(float32, float32, float32)", "float64(float64, float64, float64)"]) -def _crps_logistic_ufunc(obs: float, mu: float, sigma: float) -> float: - ω = (obs - mu) / sigma - out: float = sigma * (ω - 2 * np.log(_logis_cdf(ω)) - 1) - return out - - estimator_gufuncs = { "akr_circperm": lazy_gufunc_wrapper_uv(_crps_ensemble_akr_circperm_gufunc), "akr": lazy_gufunc_wrapper_uv(_crps_ensemble_akr_gufunc), "fair": _crps_ensemble_fair_gufunc, "int": lazy_gufunc_wrapper_uv(_crps_ensemble_int_gufunc), - "nrg": lazy_gufunc_wrapper_uv(_crps_ensemble_nrg_gufunc), + "nrg": _crps_ensemble_nrg_gufunc, "pwm": lazy_gufunc_wrapper_uv(_crps_ensemble_pwm_gufunc), "qd": _crps_ensemble_qd_gufunc, "ownrg": lazy_gufunc_wrapper_uv(_owcrps_ensemble_nrg_gufunc), @@ -315,8 +268,5 @@ def _crps_logistic_ufunc(obs: float, mu: float, sigma: float) -> float: "_crps_ensemble_nrg_gufunc", "_crps_ensemble_pwm_gufunc", "_crps_ensemble_qd_gufunc", - "_crps_normal_ufunc", - "_crps_lognormal_ufunc", - "_crps_logistic_ufunc", "quantile_pinball_gufunc", ] diff --git a/scoringrules/core/energy/_gufuncs.py b/scoringrules/core/energy/_gufuncs.py index bd38fba..2359760 100644 --- a/scoringrules/core/energy/_gufuncs.py +++ b/scoringrules/core/energy/_gufuncs.py @@ -19,7 +19,6 @@ def _energy_score_nrg_gufunc( ): """Compute the Energy Score for a finite ensemble.""" M = fct.shape[0] - e_1 = 0.0 e_2 = 0.0 for i in range(M): @@ -45,7 +44,6 @@ def _energy_score_fair_gufunc( ): """Compute the fair Energy Score for a finite ensemble.""" M = fct.shape[0] - e_1 = 0.0 e_2 = 0.0 for i in range(M): @@ -56,12 +54,10 @@ def _energy_score_fair_gufunc( out[0] = e_1 / M - 0.5 / (M * (M - 1)) * e_2 -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d)->()") def _energy_score_akr_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): """Compute the Energy Score for a finite ensemble using the approximate kernel representation.""" M = fct.shape[0] - e_1 = 0.0 e_2 = 0.0 for i in range(M): @@ -71,14 +67,12 @@ def _energy_score_akr_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): out[0] = e_1 / M - 0.5 * 1 / M * e_2 -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d)->()") def _energy_score_akr_circperm_gufunc( obs: np.ndarray, fct: np.ndarray, out: np.ndarray ): """Compute the Energy Score for a finite ensemble using the AKR with cyclic permutation.""" M = fct.shape[0] - e_1 = 0.0 e_2 = 0.0 for i in range(M): @@ -89,7 +83,6 @@ def _energy_score_akr_circperm_gufunc( out[0] = e_1 / M - 0.5 * 1 / M * e_2 -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d),(),(m)->()") def _owenergy_score_gufunc( obs: np.ndarray, @@ -100,7 +93,6 @@ def _owenergy_score_gufunc( ): """Compute the Outcome-Weighted Energy Score for a finite ensemble.""" M = fct.shape[0] - e_1 = 0.0 e_2 = 0.0 for i in range(M): @@ -113,7 +105,6 @@ def _owenergy_score_gufunc( out[0] = e_1 / (M * wbar) - 0.5 * e_2 / (M**2 * wbar**2) -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d),(),(m)->()") def _vrenergy_score_gufunc( obs: np.ndarray, @@ -124,7 +115,6 @@ def _vrenergy_score_gufunc( ): """Compute the Vertically Re-scaled Energy Score for a finite ensemble.""" M = fct.shape[0] - e_1 = 0.0 e_2 = 0.0 wabs_x = 0.0 @@ -142,12 +132,12 @@ def _vrenergy_score_gufunc( estimator_gufuncs = { - "akr_circperm": _energy_score_akr_circperm_gufunc, - "akr": _energy_score_akr_gufunc, - "fair": _energy_score_fair_gufunc, - "nrg": _energy_score_nrg_gufunc, - "ownrg": _owenergy_score_gufunc, - "vrnrg": _vrenergy_score_gufunc, + "akr_circperm": lazy_gufunc_wrapper_mv(_energy_score_akr_circperm_gufunc), + "akr": lazy_gufunc_wrapper_mv(_energy_score_akr_gufunc), + "fair": lazy_gufunc_wrapper_mv(_energy_score_fair_gufunc), + "nrg": lazy_gufunc_wrapper_mv(_energy_score_nrg_gufunc), + "ownrg": lazy_gufunc_wrapper_mv(_owenergy_score_gufunc), + "vrnrg": lazy_gufunc_wrapper_mv(_vrenergy_score_gufunc), } __all__ = [ diff --git a/scoringrules/core/energy/_score.py b/scoringrules/core/energy/_score.py index ad9f63c..1dd6ec5 100644 --- a/scoringrules/core/energy/_score.py +++ b/scoringrules/core/energy/_score.py @@ -98,18 +98,17 @@ def owes_ensemble( ) -> "Array": """Compute the outcome-weighted energy score based on a finite ensemble.""" B = backends.active if backend is None else backends[backend] - M = fct.shape[-2] - wbar = B.sum(fw, -1) / M + wbar = B.mean(fw, axis=-1) err_norm = B.norm(fct - B.expand_dims(obs, -2), -1) # (... M) - E_1 = B.sum(err_norm * fw * B.expand_dims(ow, -1), -1) / (M * wbar) # (...) + E_1 = B.mean(err_norm * fw * B.expand_dims(ow, -1), axis=-1) / wbar # (...) spread_norm = B.norm( B.expand_dims(fct, -2) - B.expand_dims(fct, -3), -1 ) # (... M M) fw_prod = B.expand_dims(fw, -1) * B.expand_dims(fw, -2) # (... M M) spread_norm *= fw_prod * B.expand_dims(ow, (-2, -1)) # (... M M) - E_2 = B.sum(spread_norm, (-2, -1)) / (M**2 * wbar**2) # (...) + E_2 = B.mean(spread_norm, axis=(-2, -1)) / (wbar**2) # (...) return E_1 - 0.5 * E_2 @@ -123,19 +122,18 @@ def vres_ensemble( ) -> "Array": """Compute the vertically re-scaled energy score based on a finite ensemble.""" B = backends.active if backend is None else backends[backend] - M, D = fct.shape[-2:] - wbar = B.sum(fw, -1) / M + wbar = B.mean(fw, axis=-1) err_norm = B.norm(fct - B.expand_dims(obs, -2), -1) # (... M) err_norm *= fw * B.expand_dims(ow, -1) # (... M) - E_1 = B.sum(err_norm, -1) / M # (...) + E_1 = B.mean(err_norm, axis=-1) # (...) spread_norm = B.norm( B.expand_dims(fct, -2) - B.expand_dims(fct, -3), -1 ) # (... M M) fw_prod = B.expand_dims(fw, -2) * B.expand_dims(fw, -1) # (... M M) - E_2 = B.sum(spread_norm * fw_prod, (-2, -1)) / (M**2) # (...) + E_2 = B.mean(spread_norm * fw_prod, axis=(-2, -1)) # (...) - rhobar = B.sum(B.norm(fct, -1) * fw, -1) / M # (...) + rhobar = B.mean(B.norm(fct, -1) * fw, axis=-1) # (...) E_3 = (rhobar - B.norm(obs, -1) * ow) * (wbar - ow) # (...) return E_1 - 0.5 * E_2 + E_3 diff --git a/scoringrules/core/kernels/__init__.py b/scoringrules/core/kernels/__init__.py index d0377b7..2d02ba9 100644 --- a/scoringrules/core/kernels/__init__.py +++ b/scoringrules/core/kernels/__init__.py @@ -8,15 +8,12 @@ ) try: - from ._gufuncs import estimator_gufuncs -except ImportError: - estimator_gufuncs = None - -try: - from ._gufuncs import estimator_gufuncs_mv + from ._gufuncs import estimator_gufuncs_uv, estimator_gufuncs_mv except ImportError: + estimator_gufuncs_uv = None estimator_gufuncs_mv = None + __all__ = [ "ensemble_uv", "ow_ensemble_uv", diff --git a/scoringrules/core/kernels/_gufuncs.py b/scoringrules/core/kernels/_gufuncs.py index 90fe8fa..e07fa84 100644 --- a/scoringrules/core/kernels/_gufuncs.py +++ b/scoringrules/core/kernels/_gufuncs.py @@ -20,7 +20,6 @@ def _gauss_kern_mv(x1: float, x2: float) -> float: return out -@lazy_gufunc_wrapper_uv @guvectorize("(),(n)->()") def _ks_ensemble_uv_nrg_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): """Standard version of the kernel score.""" @@ -33,16 +32,15 @@ def _ks_ensemble_uv_nrg_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray e_1 = 0 e_2 = 0 - for x_i in fct: - e_1 += _gauss_kern_uv(x_i, obs) - for x_j in fct: - e_2 += _gauss_kern_uv(x_i, x_j) + for i in range(M): + e_1 += _gauss_kern_uv(fct[i], obs) + for j in range(M): + e_2 += _gauss_kern_uv(fct[i], fct[j]) e_3 = _gauss_kern_uv(obs, obs) - out[0] = -(e_1 / M - 0.5 * e_2 / (M**2) - 0.5 * e_3) + out[0] = -((e_1 / M) - 0.5 * (e_2 / (M**2)) - 0.5 * e_3) -@lazy_gufunc_wrapper_uv @guvectorize("(),(n)->()") def _ks_ensemble_uv_fair_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): """Fair version of the kernel score.""" @@ -58,13 +56,12 @@ def _ks_ensemble_uv_fair_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarra for i in range(M): e_1 += _gauss_kern_uv(fct[i], obs) for j in range(i + 1, M): # important to start from i + 1 and not i - e_2 += 2 * _gauss_kern_uv(fct[j], fct[i]) + e_2 += _gauss_kern_uv(fct[j], fct[i]) e_3 = _gauss_kern_uv(obs, obs) - out[0] = -(e_1 / M - 0.5 * e_2 / (M * (M - 1)) - 0.5 * e_3) + out[0] = -((e_1 / M) - e_2 / (M * (M - 1)) - 0.5 * e_3) -@lazy_gufunc_wrapper_uv @guvectorize("(),(n)->()") def _ks_ensemble_uv_akr_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): """Approximate kernel representation estimator of the kernel score.""" @@ -84,7 +81,6 @@ def _ks_ensemble_uv_akr_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray out[0] = -(e_1 / M - 0.5 * e_2 / M - 0.5 * e_3) -@lazy_gufunc_wrapper_uv @guvectorize("(),(n)->()") def _ks_ensemble_uv_akr_circperm_gufunc( obs: np.ndarray, fct: np.ndarray, out: np.ndarray @@ -107,7 +103,6 @@ def _ks_ensemble_uv_akr_circperm_gufunc( out[0] = -(e_1 / M - 0.5 * e_2 / M - 0.5 * e_3) -@lazy_gufunc_wrapper_uv @guvectorize("(),(n),(),(n)->()") def _owks_ensemble_uv_gufunc( obs: np.ndarray, @@ -134,10 +129,9 @@ def _owks_ensemble_uv_gufunc( wbar = np.mean(fw) - out[0] = -(e_1 / (M * wbar) - 0.5 * e_2 / ((M * wbar) ** 2) - 0.5 * e_3) + out[0] = -(e_1 / (M * wbar) - 0.5 * e_2 / (M**2 * wbar**2) - 0.5 * e_3) -@lazy_gufunc_wrapper_uv @guvectorize("(),(n),(),(n)->()") def _vrks_ensemble_uv_gufunc( obs: np.ndarray, @@ -162,10 +156,9 @@ def _vrks_ensemble_uv_gufunc( e_2 += _gauss_kern_uv(x_i, x_j) * fw[i] * fw[j] e_3 = _gauss_kern_uv(obs, obs) * ow * ow - out[0] = -(e_1 / M - 0.5 * e_2 / (M**2) - 0.5 * e_3) + out[0] = -((e_1 / M) - 0.5 * (e_2 / (M**2)) - 0.5 * e_3) -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d)->()") def _ks_ensemble_mv_nrg_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): """Standard version of the multivariate kernel score.""" @@ -179,10 +172,9 @@ def _ks_ensemble_mv_nrg_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray e_2 += float(_gauss_kern_mv(fct[i], fct[j])) e_3 = float(_gauss_kern_mv(obs, obs)) - out[0] = -(e_1 / M - 0.5 * e_2 / (M**2) - 0.5 * e_3) + out[0] = -((e_1 / M) - 0.5 * (e_2 / (M**2)) - 0.5 * e_3) -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d)->()") def _ks_ensemble_mv_fair_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): """Fair version of the multivariate kernel score.""" @@ -199,7 +191,6 @@ def _ks_ensemble_mv_fair_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarra out[0] = -(e_1 / M - e_2 / (M * (M - 1)) - 0.5 * e_3) -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d)->()") def _ks_ensemble_mv_akr_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray): """Approximate kernel representation estimator of the multivariate kernel score.""" @@ -215,7 +206,6 @@ def _ks_ensemble_mv_akr_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray out[0] = -(e_1 / M - 0.5 * e_2 / M - 0.5 * e_3) -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d)->()") def _ks_ensemble_mv_akr_circperm_gufunc( obs: np.ndarray, fct: np.ndarray, out: np.ndarray @@ -234,7 +224,6 @@ def _ks_ensemble_mv_akr_circperm_gufunc( out[0] = -(e_1 / M - 0.5 * e_2 / M - 0.5 * e_3) -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d),(),(m)->()") def _owks_ensemble_mv_gufunc( obs: np.ndarray, @@ -259,7 +248,6 @@ def _owks_ensemble_mv_gufunc( out[0] = -(e_1 / (M * wbar) - 0.5 * e_2 / (M**2 * wbar**2) - 0.5 * e_3) -@lazy_gufunc_wrapper_mv @guvectorize("(d),(m,d),(),(m)->()") def _vrks_ensemble_mv_gufunc( obs: np.ndarray, @@ -279,25 +267,25 @@ def _vrks_ensemble_mv_gufunc( e_2 += float(_gauss_kern_mv(fct[i], fct[j]) * fw[i] * fw[j]) e_3 = float(_gauss_kern_mv(obs, obs)) * ow * ow - out[0] = -(e_1 / M - 0.5 * e_2 / (M**2) - 0.5 * e_3) + out[0] = -((e_1 / M) - 0.5 * (e_2 / (M**2)) - 0.5 * e_3) -estimator_gufuncs = { - "akr": _ks_ensemble_uv_akr_gufunc, - "akr_circperm": _ks_ensemble_uv_akr_circperm_gufunc, - "fair": _ks_ensemble_uv_fair_gufunc, - "nrg": _ks_ensemble_uv_nrg_gufunc, - "ow": _owks_ensemble_uv_gufunc, - "vr": _vrks_ensemble_uv_gufunc, +estimator_gufuncs_uv = { + "akr": lazy_gufunc_wrapper_uv(_ks_ensemble_uv_akr_gufunc), + "akr_circperm": lazy_gufunc_wrapper_uv(_ks_ensemble_uv_akr_circperm_gufunc), + "fair": lazy_gufunc_wrapper_uv(_ks_ensemble_uv_fair_gufunc), + "nrg": lazy_gufunc_wrapper_uv(_ks_ensemble_uv_nrg_gufunc), + "ow": lazy_gufunc_wrapper_uv(_owks_ensemble_uv_gufunc), + "vr": lazy_gufunc_wrapper_uv(_vrks_ensemble_uv_gufunc), } estimator_gufuncs_mv = { - "akr": _ks_ensemble_mv_akr_gufunc, - "akr_circperm": _ks_ensemble_mv_akr_circperm_gufunc, - "fair": _ks_ensemble_mv_fair_gufunc, - "nrg": _ks_ensemble_mv_nrg_gufunc, - "ow": _owks_ensemble_mv_gufunc, - "vr": _vrks_ensemble_mv_gufunc, + "akr": lazy_gufunc_wrapper_mv(_ks_ensemble_mv_akr_gufunc), + "akr_circperm": lazy_gufunc_wrapper_mv(_ks_ensemble_mv_akr_circperm_gufunc), + "fair": lazy_gufunc_wrapper_mv(_ks_ensemble_mv_fair_gufunc), + "nrg": lazy_gufunc_wrapper_mv(_ks_ensemble_mv_nrg_gufunc), + "ow": lazy_gufunc_wrapper_mv(_owks_ensemble_mv_gufunc), + "vr": lazy_gufunc_wrapper_mv(_vrks_ensemble_mv_gufunc), } __all__ = [ diff --git a/scoringrules/core/utils.py b/scoringrules/core/utils.py index 6aa1c13..47450f9 100644 --- a/scoringrules/core/utils.py +++ b/scoringrules/core/utils.py @@ -2,11 +2,14 @@ from scoringrules.backend import backends -_V_AXIS = -1 -_M_AXIS = -2 +_V_AXIS = -1 # variable index for multivariate forecasts +_M_AXIS = -2 # ensemble index for multivariate forecasts +_M_AXIS_UV = -1 # ensemble index for univariate forecasts +_K_AXIS = -1 # categories index for categorical forecasts +EPSILON = 1e-5 -def _multivariate_shape_compatibility(obs, fct, m_axis) -> None: +def _shape_compatibility_check(obs, fct, m_axis) -> None: f_shape = fct.shape o_shape = obs.shape o_shape_broadcast = o_shape[:m_axis] + (f_shape[m_axis],) + o_shape[m_axis:] @@ -16,6 +19,53 @@ def _multivariate_shape_compatibility(obs, fct, m_axis) -> None: ) +def binary_array_check(obs, fct, backend=None): + """Check and adapt the shapes of binary forecasts and observations arrays.""" + B = backends.active if backend is None else backends[backend] + obs, fct = map(B.asarray, (obs, fct)) + if B.any(fct < 0.0) or B.any(fct > 1.0 + EPSILON): + raise ValueError("Forecasted probabilities must be within 0 and 1.") + if not set(v.item() for v in B.unique_values(obs)) <= {0, 1}: + raise ValueError("Observations must be 0, 1, or NaN.") + return obs, fct + + +def categorical_array_check(obs, fct, k_axis, onehot, backend=None): + """Check and adapt the shapes of categorical forecasts and observations arrays.""" + B = backends.active if backend is None else backends[backend] + obs, fct = map(B.asarray, (obs, fct)) + if B.any(fct < 0.0) or B.any(fct > 1.0 + EPSILON): + raise ValueError("Forecasted probabilities must be within 0 and 1.") + if k_axis != -1: + fct = B.moveaxis(fct, k_axis, _K_AXIS) + if onehot: + obs = B.moveaxis(obs, k_axis, _K_AXIS) + if onehot: + if obs.shape != fct.shape: + raise ValueError( + f"Forecasts shape {fct.shape} and observations shape {obs.shape} are not compatible for broadcasting!" + ) + else: + if obs.shape != fct.shape[:-1]: + raise ValueError( + f"Forecasts shape {fct.shape} and observations shape {obs.shape} are not compatible for broadcasting!" + ) + categories = B.arange(1, fct.shape[-1] + 1) + obs = B.where(B.expand_dims(obs, -1) == categories, 1, 0) + return obs, fct + + +def univariate_array_check(obs, fct, m_axis, backend=None): + """Check and adapt the shapes of univariate forecasts and observations arrays.""" + B = backends.active if backend is None else backends[backend] + obs, fct = map(B.asarray, (obs, fct)) + m_axis = m_axis if m_axis >= 0 else fct.ndim + m_axis + _shape_compatibility_check(obs, fct, m_axis) + if m_axis != _M_AXIS_UV: + fct = B.moveaxis(fct, m_axis, _M_AXIS_UV) + return obs, fct + + def _multivariate_shape_permute(obs, fct, m_axis, v_axis, backend=None): B = backends.active if backend is None else backends[backend] v_axis_obs = v_axis - 1 if m_axis < v_axis else v_axis @@ -30,10 +80,81 @@ def multivariate_array_check(obs, fct, m_axis, v_axis, backend=None): obs, fct = map(B.asarray, (obs, fct)) m_axis = m_axis if m_axis >= 0 else fct.ndim + m_axis v_axis = v_axis if v_axis >= 0 else fct.ndim + v_axis - _multivariate_shape_compatibility(obs, fct, m_axis) + _shape_compatibility_check(obs, fct, m_axis) return _multivariate_shape_permute(obs, fct, m_axis, v_axis, backend=backend) +def estimator_check(estimator, gufuncs): + """Check that the estimator is valid for the given score.""" + if estimator not in gufuncs: + raise ValueError( + f"{estimator} is not a valid estimator. " f"Must be one of {gufuncs.keys()}" + ) + + +def uv_weighted_score_weights( + obs, fct, a=float("-inf"), b=float("inf"), w_func=None, backend=None +): + """Calculate weights for a weighted scoring rule corresponding to the observations + and ensemble members given a weight function.""" + B = backends.active if backend is None else backends[backend] + if w_func is None: + + def w_func(x): + return ((a <= x) & (x <= b)) * 1.0 + + obs_w, fct_w = map(w_func, (obs, fct)) + obs_w, fct_w = map(B.asarray, (obs_w, fct_w)) + if B.any(obs_w < 0) or B.any(fct_w < 0): + raise ValueError("`w_func` returns negative values") + return obs_w, fct_w + + +def uv_weighted_score_chain( + obs, fct, a=float("-inf"), b=float("inf"), v_func=None, backend=None +): + """Calculate transformed observations and ensemble members for threshold-weighted scoring rules given a chaining function.""" + B = backends.active if backend is None else backends[backend] + if v_func is None: + a, b, obs, fct = map(B.asarray, (a, b, obs, fct)) + + def v_func(x): + return B.minimum(B.maximum(x, a), b) + + obs, fct = map(v_func, (obs, fct)) + return obs, fct + + +def mv_weighted_score_weights(obs, fct, w_func=None, backend=None): + """Calculate weights for a weighted scoring rule corresponding to the observations + and ensemble members given a weight function.""" + B = backends.active if backend is None else backends[backend] + obs_w = B.apply_along_axis(w_func, obs, -1) + fct_w = B.apply_along_axis(w_func, fct, -1) + if backend == "torch": + obs_w = obs_w * 1.0 + fct_w = fct_w * 1.0 + if B.any(obs_w < 0) or B.any(fct_w < 0): + raise ValueError("`w_func` returns negative values") + return obs_w, fct_w + + +def mv_weighted_score_chain(obs, fct, v_func=None): + """Calculate transformed observations and ensemble members for threshold-weighted scoring rules given a chaining function.""" + obs, fct = map(v_func, (obs, fct)) + return obs, fct + + +def univariate_sort_ens(fct, estimator=None, sorted_ensemble=False, backend=None): + """Sort ensemble members if not already sorted.""" + B = backends.active if backend is None else backends[backend] + sort_ensemble = not sorted_ensemble and estimator in ["qd", "pwm", "int"] + if sort_ensemble: + ind = B.argsort(fct, axis=-1) + fct = B.gather(fct, ind, axis=-1) + return fct + + def lazy_gufunc_wrapper_uv(func): """ Wrapper for lazy/dynamic generalized universal functions so diff --git a/scoringrules/core/variogram/__init__.py b/scoringrules/core/variogram/__init__.py index 26a37d4..f49f7fe 100644 --- a/scoringrules/core/variogram/__init__.py +++ b/scoringrules/core/variogram/__init__.py @@ -1,15 +1,7 @@ try: - from ._gufuncs import ( - _variogram_score_nrg_gufunc, - _variogram_score_fair_gufunc, - _owvariogram_score_gufunc, - _vrvariogram_score_gufunc, - ) + from ._gufuncs import estimator_gufuncs except ImportError: - _variogram_score_nrg_gufunc = None - _variogram_score_fair_gufunc = None - _owvariogram_score_gufunc = None - _vrvariogram_score_gufunc = None + estimator_gufuncs = None from ._score import owvs_ensemble as owvs from ._score import vs_ensemble as vs @@ -19,8 +11,5 @@ "vs", "owvs", "vrvs", - "_variogram_score_nrg_gufunc", - "_variogram_score_fair_gufunc", - "_owvariogram_score_gufunc", - "_vrvariogram_score_gufunc", + "estimator_gufuncs", ] diff --git a/scoringrules/core/variogram/_gufuncs.py b/scoringrules/core/variogram/_gufuncs.py index 1402d28..5eb0484 100644 --- a/scoringrules/core/variogram/_gufuncs.py +++ b/scoringrules/core/variogram/_gufuncs.py @@ -4,14 +4,14 @@ from scoringrules.core.utils import lazy_gufunc_wrapper_mv -@guvectorize( - [ - "void(float32[:], float32[:,:], float32, float32[:])", - "void(float64[:], float64[:,:], float64, float64[:])", - ], - "(d),(m,d),()->()", -) -def _variogram_score_nrg_gufunc(obs, fct, p, out): +@guvectorize("(d),(m,d),(d,d),()->()") +def _variogram_score_nrg_gufunc( + obs: np.ndarray, + fct: np.ndarray, + w: np.ndarray, + p: np.ndarray, + out: np.ndarray, +): M = fct.shape[-2] D = fct.shape[-1] out[0] = 0.0 @@ -22,12 +22,17 @@ def _variogram_score_nrg_gufunc(obs, fct, p, out): vfct += abs(fct[m, i] - fct[m, j]) ** p vfct = vfct / M vobs = abs(obs[i] - obs[j]) ** p - out[0] += (vobs - vfct) ** 2 + out[0] += w[i, j] * (vobs - vfct) ** 2 -@lazy_gufunc_wrapper_mv -@guvectorize("(d),(m,d),()->()") -def _variogram_score_fair_gufunc(obs, fct, p, out): +@guvectorize("(d),(m,d),(d,d),()->()") +def _variogram_score_fair_gufunc( + obs: np.ndarray, + fct: np.ndarray, + w: np.ndarray, + p: np.ndarray, + out: np.ndarray, +): M = fct.shape[-2] D = fct.shape[-1] @@ -38,20 +43,19 @@ def _variogram_score_fair_gufunc(obs, fct, p, out): for j in range(D): rho1 = abs(fct[k, i] - fct[k, j]) ** p rho2 = abs(obs[i] - obs[j]) ** p - e_1 += (rho1 - rho2) ** 2 + e_1 += w[i, j] * (rho1 - rho2) ** 2 for m in range(k + 1, M): for i in range(D): for j in range(D): rho1 = abs(fct[k, i] - fct[k, j]) ** p rho2 = abs(fct[m, i] - fct[m, j]) ** p - e_2 += 2 * ((rho1 - rho2) ** 2) + e_2 += w[i, j] * 2 * ((rho1 - rho2) ** 2) out[0] = e_1 / M - 0.5 * e_2 / (M * (M - 1)) -@lazy_gufunc_wrapper_mv -@guvectorize("(d),(m,d),(),(),(m)->()") -def _owvariogram_score_gufunc(obs, fct, p, ow, fw, out): +@guvectorize("(d),(m,d),(d,d),(),(m),()->()") +def _owvariogram_score_gufunc(obs, fct, w, ow, fw, p, out): M = fct.shape[-2] D = fct.shape[-1] @@ -62,22 +66,21 @@ def _owvariogram_score_gufunc(obs, fct, p, ow, fw, out): for j in range(D): rho1 = abs(fct[k, i] - fct[k, j]) ** p rho2 = abs(obs[i] - obs[j]) ** p - e_1 += (rho1 - rho2) ** 2 * fw[k] * ow + e_1 += w[i, j] * (rho1 - rho2) ** 2 * fw[k] * ow for m in range(k + 1, M): for i in range(D): for j in range(D): rho1 = abs(fct[k, i] - fct[k, j]) ** p rho2 = abs(fct[m, i] - fct[m, j]) ** p - e_2 += 2 * ((rho1 - rho2) ** 2) * fw[k] * fw[m] * ow + e_2 += 2 * w[i, j] * ((rho1 - rho2) ** 2) * fw[k] * fw[m] * ow wbar = np.mean(fw) out[0] = e_1 / (M * wbar) - 0.5 * e_2 / (M**2 * wbar**2) -@lazy_gufunc_wrapper_mv -@guvectorize("(d),(m,d),(),(),(m)->()") -def _vrvariogram_score_gufunc(obs, fct, p, ow, fw, out): +@guvectorize("(d),(m,d),(d,d),(),(m),()->()") +def _vrvariogram_score_gufunc(obs, fct, w, ow, fw, p, out): M = fct.shape[-2] D = fct.shape[-1] @@ -89,14 +92,14 @@ def _vrvariogram_score_gufunc(obs, fct, p, ow, fw, out): for j in range(D): rho1 = abs(fct[k, i] - fct[k, j]) ** p rho2 = abs(obs[i] - obs[j]) ** p - e_1 += (rho1 - rho2) ** 2 * fw[k] * ow - e_3_x += (rho1) ** 2 * fw[k] + e_1 += w[i, j] * (rho1 - rho2) ** 2 * fw[k] * ow + e_3_x += w[i, j] * (rho1) ** 2 * fw[k] for m in range(k + 1, M): for i in range(D): for j in range(D): rho1 = abs(fct[k, i] - fct[k, j]) ** p rho2 = abs(fct[m, i] - fct[m, j]) ** p - e_2 += 2 * ((rho1 - rho2) ** 2) * fw[k] * fw[m] + e_2 += 2 * w[i, j] * ((rho1 - rho2) ** 2) * fw[k] * fw[m] e_3_x *= 1 / M wbar = np.mean(fw) @@ -104,6 +107,21 @@ def _vrvariogram_score_gufunc(obs, fct, p, ow, fw, out): for i in range(D): for j in range(D): rho1 = abs(obs[i] - obs[j]) ** p - e_3_y += (rho1) ** 2 * ow + e_3_y += w[i, j] * (rho1) ** 2 * ow out[0] = e_1 / M - 0.5 * e_2 / (M**2) + (e_3_x - e_3_y) * (wbar - ow) + + +estimator_gufuncs = { + "fair": lazy_gufunc_wrapper_mv(_variogram_score_fair_gufunc), + "nrg": lazy_gufunc_wrapper_mv(_variogram_score_nrg_gufunc), + "ownrg": lazy_gufunc_wrapper_mv(_owvariogram_score_gufunc), + "vrnrg": lazy_gufunc_wrapper_mv(_vrvariogram_score_gufunc), +} + +__all__ = [ + "_variogram_score_fair_gufunc", + "_variogram_score_nrg_gufunc", + "_owvariogram_score_gufunc", + "_vrvariogram_score_gufunc", +] diff --git a/scoringrules/core/variogram/_score.py b/scoringrules/core/variogram/_score.py index f6d091b..7ff36f4 100644 --- a/scoringrules/core/variogram/_score.py +++ b/scoringrules/core/variogram/_score.py @@ -9,6 +9,7 @@ def vs_ensemble( obs: "Array", # (... D) fct: "Array", # (... M D) + w: "Array", # (..., D D) p: float = 0.5, estimator: str = "nrg", backend: "Backend" = None, @@ -23,21 +24,25 @@ def vs_ensemble( obs_diff = B.abs(B.expand_dims(obs, -2) - B.expand_dims(obs, -1)) ** p # (... D D) if estimator == "nrg": - vfct = B.sum(fct_diff, axis=-3) / M # (... D D) - out = B.sum((obs_diff - vfct) ** 2, axis=(-2, -1)) # (...) + fct_diff = B.sum(fct_diff, axis=-3) / M # (... D D) + out = B.sum(w * (obs_diff - fct_diff) ** 2, axis=(-2, -1)) # (...) elif estimator == "fair": E_1 = (fct_diff - B.expand_dims(obs_diff, axis=-3)) ** 2 # (... M D D) - E_1 = B.sum(E_1, axis=(-2, -1)) # (... M) + E_1 = B.sum(B.expand_dims(w, axis=-3) * E_1, axis=(-2, -1)) # (... M) E_1 = B.sum(E_1, axis=-1) / M # (...) E_2 = ( B.expand_dims(fct_diff, -3) - B.expand_dims(fct_diff, -4) ) ** 2 # (... M M D D) - E_2 = B.sum(E_2, axis=(-2, -1)) # (... M M) + E_2 = B.sum(B.expand_dims(w, (-3, -4)) * E_2, axis=(-2, -1)) # (... M M) E_2 = B.sum(E_2, axis=(-2, -1)) / (M * (M - 1)) # (...) out = E_1 - 0.5 * E_2 + else: + raise ValueError( + f"For the variogram score, {estimator} must be one of 'nrg', and 'fair'." + ) return out @@ -45,6 +50,7 @@ def vs_ensemble( def owvs_ensemble( obs: "Array", fct: "Array", + w: "Array", ow: "Array", fw: "Array", p: float = 0.5, @@ -52,25 +58,34 @@ def owvs_ensemble( ) -> "Array": """Compute the Outcome-Weighted Variogram Score for a multivariate finite ensemble.""" B = backends.active if backend is None else backends[backend] - M = fct.shape[-2] - wbar = B.sum(fw, -1) / M + wbar = B.mean(fw, axis=-1) fct_diff = ( B.abs(B.expand_dims(fct, -2) - B.expand_dims(fct, -1)) ** p ) # (... M D D) obs_diff = B.abs(B.expand_dims(obs, -2) - B.expand_dims(obs, -1)) ** p # (... D D) - vfct = B.sum(fct_diff * B.expand_dims(fw, (-2, -1)), axis=-3) / ( - M * B.expand_dims(wbar, (-2, -1)) - ) # (... D D) - out = B.sum(((obs_diff - vfct) ** 2), axis=(-2, -1)) * ow # (...) + E_1 = (fct_diff - B.expand_dims(obs_diff, -3)) ** 2 # (... M D D) + E_1 = B.sum(B.expand_dims(w, -3) * E_1, axis=(-2, -1)) # (... M) + E_1 = B.mean(E_1 * fw * B.expand_dims(ow, -1), axis=-1) / wbar # (...) - return out + fct_diff_spread = B.expand_dims(fct_diff, -3) - B.expand_dims( + fct_diff, -4 + ) # (... M M D D) + fw_prod = B.expand_dims(fw, -2) * B.expand_dims(fw, -1) # (... M M) + E_2 = B.sum( + B.expand_dims(w, (-3, -4)) * fct_diff_spread**2, axis=(-2, -1) + ) # (... M M) + E_2 *= fw_prod * B.expand_dims(ow, (-2, -1)) # (... M M) + E_2 = B.mean(E_2, axis=(-2, -1)) / (wbar**2) # (...) + + return E_1 - 0.5 * E_2 def vrvs_ensemble( obs: "Array", fct: "Array", + w: "Array", ow: "Array", fw: "Array", p: float = 0.5, @@ -78,7 +93,6 @@ def vrvs_ensemble( ) -> "Array": """Compute the Vertically Re-scaled Variogram Score for a multivariate finite ensemble.""" B = backends.active if backend is None else backends[backend] - M: int = fct.shape[-2] wbar = B.mean(fw, axis=-1) fct_diff = ( @@ -87,20 +101,20 @@ def vrvs_ensemble( obs_diff = B.abs(B.expand_dims(obs, -2) - B.expand_dims(obs, -1)) ** p # (... D D) E_1 = (fct_diff - B.expand_dims(obs_diff, axis=-3)) ** 2 # (... M D D) - E_1 = B.sum(E_1, axis=(-2, -1)) # (... M) - E_1 = B.sum(E_1 * fw * B.expand_dims(ow, axis=-1), axis=-1) / M # (...) + E_1 = B.sum(B.expand_dims(w, -3) * E_1, axis=(-2, -1)) # (... M) + E_1 = B.mean(E_1 * fw * B.expand_dims(ow, axis=-1), axis=-1) # (...) E_2 = ( B.expand_dims(fct_diff, -3) - B.expand_dims(fct_diff, -4) ) ** 2 # (... M M D D) - E_2 = B.sum(E_2, axis=(-2, -1)) # (... M M) + E_2 = B.sum(B.expand_dims(w, (-3, -4)) * E_2, axis=(-2, -1)) # (... M M) fw_prod = B.expand_dims(fw, axis=-2) * B.expand_dims(fw, axis=-1) # (... M M) - E_2 = B.sum(E_2 * fw_prod, axis=(-2, -1)) / (M**2) # (...) + E_2 = B.mean(E_2 * fw_prod, axis=(-2, -1)) # (...) - E_3 = B.sum(fct_diff**2, axis=(-2, -1)) # (... M) - E_3 = B.sum(E_3 * fw, axis=-1) / M # (...) - E_3 -= B.sum(obs_diff**2, axis=(-2, -1)) * ow # (...) + E_3 = B.sum(B.expand_dims(w, -3) * fct_diff**2, axis=(-2, -1)) # (... M) + E_3 = B.mean(E_3 * fw, axis=-1) # (...) + E_3 -= B.sum(w * obs_diff**2, axis=(-2, -1)) * ow # (...) E_3 *= wbar - ow # (...) return E_1 - 0.5 * E_2 + E_3 diff --git a/tests/test_kernels.py b/tests/test_kernels.py index 1fa32e7..efd5b29 100644 --- a/tests/test_kernels.py +++ b/tests/test_kernels.py @@ -97,6 +97,11 @@ def test_gksmv(estimator, backend): [[9.8, 8.7, 11.9, 12.1, 13.4], [-24.8, -18.5, -29.9, -18.3, -21.0]] ).transpose() + # test exceptions + with pytest.raises(ValueError): + est = "undefined_estimator" + sr.gksmv_ensemble(obs, fct, estimator=est, backend=backend) + if estimator == "nrg": res = sr.gksmv_ensemble(obs, fct, estimator=estimator, backend=backend) expected = 0.5868737 diff --git a/tests/test_variogram.py b/tests/test_variogram.py index 5cf3985..d3366b3 100644 --- a/tests/test_variogram.py +++ b/tests/test_variogram.py @@ -39,7 +39,8 @@ def test_variogram_score_permuted_dims(estimator, backend): assert "jax" in res.__module__ -def test_variogram_score_correctness(backend): +@pytest.mark.parametrize("estimator", ESTIMATORS) +def test_variogram_score_correctness(estimator, backend): fct = np.array( [ [0.79546742, 0.4777960, 0.2164079, 0.5409873], @@ -48,14 +49,36 @@ def test_variogram_score_correctness(backend): ).T obs = np.array([0.2743836, 0.8146400]) - res = sr.vs_ensemble(obs, fct, p=0.5, estimator="nrg", backend=backend) - np.testing.assert_allclose(res, 0.04114727, rtol=1e-5) - - res = sr.vs_ensemble(obs, fct, p=0.5, estimator="fair", backend=backend) - np.testing.assert_allclose(res, 0.01407421, rtol=1e-5) - - res = sr.vs_ensemble(obs, fct, p=1.0, estimator="nrg", backend=backend) - np.testing.assert_allclose(res, 0.04480374, rtol=1e-5) - - res = sr.vs_ensemble(obs, fct, p=1.0, estimator="fair", backend=backend) - np.testing.assert_allclose(res, 0.004730382, rtol=1e-5) + res = sr.vs_ensemble(obs, fct, p=0.5, estimator=estimator, backend=backend) + if estimator == "nrg": + expected = 0.04114727 + np.testing.assert_allclose(res, expected, rtol=1e-5) + elif estimator == "fair": + expected = 0.01407421 + np.testing.assert_allclose(res, expected, rtol=1e-5) + + res = sr.vs_ensemble(obs, fct, p=1.0, estimator=estimator, backend=backend) + if estimator == "nrg": + expected = 0.04480374 + np.testing.assert_allclose(res, expected, rtol=1e-5) + elif estimator == "fair": + expected = 0.004730382 + np.testing.assert_allclose(res, expected, rtol=1e-5) + + # with and without dimension weights + w = np.ones((2, 2)) + res_w = sr.vs_ensemble(obs, fct, w=w, p=1.0, estimator=estimator, backend=backend) + np.testing.assert_allclose(res_w, res, rtol=1e-5) + + # with dimension weights + w = np.array([[0.1, 0.2], [0.2, 0.4]]) + res = sr.vs_ensemble(obs, fct, w=w, p=0.5, estimator=estimator, backend=backend) + if estimator == "nrg": + expected = 0.008229455 + np.testing.assert_allclose(res, expected, atol=1e-6) + + # invariance to diagonal terms of weight matrix + w = np.array([[1.1, 0.2], [0.2, 100]]) + res = sr.vs_ensemble(obs, fct, w=w, p=0.5, estimator=estimator, backend=backend) + if estimator == "nrg": + np.testing.assert_allclose(res, expected, atol=1e-6) diff --git a/tests/test_wcrps.py b/tests/test_wcrps.py index 2fd2c43..731a277 100644 --- a/tests/test_wcrps.py +++ b/tests/test_wcrps.py @@ -12,21 +12,34 @@ def test_owcrps_ensemble(backend): # test shapes obs = np.random.randn(N) - res = sr.owcrps_ensemble(obs, np.random.randn(N, M), w_func=lambda x: x * 0.0 + 1.0) - assert res.shape == (N,) res = sr.owcrps_ensemble( - obs, np.random.randn(M, N), w_func=lambda x: x * 0.0 + 1.0, m_axis=0 + obs, np.random.randn(N, M), w_func=lambda x: x * 0.0 + 1.0, backend=backend ) assert res.shape == (N,) + fct = np.random.randn(M, N) + res = sr.owcrps_ensemble( + obs, + fct, + w_func=lambda x: x * 0.0 + 1.0, + m_axis=0, + backend=backend, + ) + def test_vrcrps_ensemble(backend): # test shapes obs = np.random.randn(N) - res = sr.vrcrps_ensemble(obs, np.random.randn(N, M), w_func=lambda x: x * 0.0 + 1.0) + res = sr.vrcrps_ensemble( + obs, np.random.randn(N, M), w_func=lambda x: x * 0.0 + 1.0, backend=backend + ) assert res.shape == (N,) res = sr.vrcrps_ensemble( - obs, np.random.randn(M, N), w_func=lambda x: x * 0.0 + 1.0, m_axis=0 + obs, + np.random.randn(M, N), + w_func=lambda x: x * 0.0 + 1.0, + m_axis=0, + backend=backend, ) assert res.shape == (N,) @@ -63,21 +76,21 @@ def test_owcrps_vs_crps(backend): sigma = abs(np.random.randn(N)) * 0.5 fct = np.random.randn(N, M) * sigma[..., None] + mu[..., None] - res = sr.crps_ensemble(obs, fct, backend=backend, estimator="nrg") + res = sr.crps_ensemble(obs, fct, estimator="qd", backend=backend) # no argument given resw = sr.owcrps_ensemble(obs, fct, backend=backend) - np.testing.assert_allclose(res, resw, rtol=1e-5) + np.testing.assert_allclose(res, resw, rtol=1e-4) # a and b resw = sr.owcrps_ensemble( obs, fct, a=float("-inf"), b=float("inf"), backend=backend ) - np.testing.assert_allclose(res, resw, rtol=1e-5) + np.testing.assert_allclose(res, resw, rtol=1e-4) # w_func as identity function resw = sr.owcrps_ensemble(obs, fct, w_func=lambda x: x * 0.0 + 1.0, backend=backend) - np.testing.assert_allclose(res, resw, rtol=1e-5) + np.testing.assert_allclose(res, resw, rtol=1e-4) def test_vrcrps_vs_crps(backend): From 60ca6090c245f6ca85132c99ebd72e94ea9e9c47 Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 24 Mar 2026 12:38:45 +0100 Subject: [PATCH 02/11] reduce tolerance of weighted energy score test for jax --- tests/test_wenergy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_wenergy.py b/tests/test_wenergy.py index 5f2f568..368c4b7 100644 --- a/tests/test_wenergy.py +++ b/tests/test_wenergy.py @@ -20,7 +20,7 @@ def test_owes_vs_es(backend): lambda x: backends[backend].mean(x) * 0.0 + 1.0, backend=backend, ) - np.testing.assert_allclose(res, resw, atol=1e-7) + np.testing.assert_allclose(res, resw, atol=1e-6) def test_twes_vs_es(backend): From 6a8896b40a064a485d4c600921ab59c53ceb3301 Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 24 Mar 2026 15:14:15 +0100 Subject: [PATCH 03/11] add parameter checks to parametric crps and log scores --- scoringrules/_crps.py | 496 ++++++++++++++++++++++++++++- scoringrules/_logs.py | 347 ++++++++++++++++++-- scoringrules/core/crps/_approx.py | 100 +++--- scoringrules/core/crps/_closed.py | 45 +-- scoringrules/core/crps/_gufuncs.py | 9 +- scoringrules/core/logarithmic.py | 5 +- tests/test_crps.py | 5 +- 7 files changed, 879 insertions(+), 128 deletions(-) diff --git a/scoringrules/_crps.py b/scoringrules/_crps.py index 0800520..607d350 100644 --- a/scoringrules/_crps.py +++ b/scoringrules/_crps.py @@ -457,6 +457,7 @@ def crps_beta( upper: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the beta distribution. @@ -490,6 +491,9 @@ def crps_beta( Upper bound of the forecast beta distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -509,6 +513,19 @@ def crps_beta( >>> sr.crps_beta(0.3, 0.7, 1.1) 0.08501024366637236 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + a, b, lower, upper = map(B.asarray, (a, b, lower, upper)) + if B.any(a <= 0): + raise ValueError( + "`a` contains non-positive entries. The shape parameters of the Beta distribution must be positive." + ) + if B.any(b <= 0): + raise ValueError( + "`b` contains non-positive entries. The shape parameters of the Beta distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return crps.beta(obs, a, b, lower, upper, backend=backend) @@ -518,6 +535,7 @@ def crps_binomial( prob: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the binomial distribution. @@ -540,6 +558,9 @@ def crps_binomial( Probability parameter of the forecast binomial distribution as a float or array of floats. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -559,6 +580,11 @@ def crps_binomial( >>> sr.crps_binomial(4, 10, 0.5) 0.5955772399902344 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + prob = B.asarray(prob) + if B.any(prob < 0) or B.any(prob > 1): + raise ValueError("`prob` contains values outside the range [0, 1].") return crps.binomial(obs, n, prob, backend=backend) @@ -567,6 +593,7 @@ def crps_exponential( rate: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the exponential distribution. @@ -585,6 +612,9 @@ def crps_exponential( Rate parameter of the forecast exponential distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -607,6 +637,13 @@ def crps_exponential( >>> sr.crps_exponential(np.array([0.8, 0.9]), np.array([3.0, 2.0])) array([0.36047864, 0.31529889]) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + rate = B.asarray(rate) + if B.any(rate <= 0): + raise ValueError( + "`rate` contains non-positive entries. The rate parameter of the exponential distribution must be positive." + ) return crps.exponential(obs, rate, backend=backend) @@ -617,6 +654,7 @@ def crps_exponentialM( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the standard exponential distribution with a point mass at the boundary. @@ -650,6 +688,9 @@ def crps_exponentialM( Scale parameter of the forecast exponential distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -669,7 +710,16 @@ def crps_exponentialM( >>> sr.crps_exponentialM(0.4, 0.2, 0.0, 1.0) 0.19251207365702294 """ - return crps.exponentialM(obs, mass, location, scale, backend=backend) + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, mass = map(B.asarray, (scale, mass)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter must be positive." + ) + if B.any(mass < 0) or B.any(mass > 1): + raise ValueError("`mass` contains entries outside the range [0, 1].") + return crps.exponentialM(obs, location, scale, mass, backend=backend) def crps_2pexponential( @@ -679,6 +729,7 @@ def crps_2pexponential( location: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the two-piece exponential distribution. @@ -705,6 +756,9 @@ def crps_2pexponential( Location parameter of the forecast two-piece exponential distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -724,6 +778,17 @@ def crps_2pexponential( >>> sr.crps_2pexponential(0.8, 3.0, 1.4, 0.0) array(1.18038524) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale1, scale2 = map(B.asarray, (scale1, scale2)) + if B.any(scale1 <= 0): + raise ValueError( + "`scale1` contains non-positive entries. The scale parameters of the two-piece exponential distribution must be positive." + ) + if B.any(scale2 <= 0): + raise ValueError( + "`scale2` contains non-positive entries. The scale parameters of the two-piece exponential distribution must be positive." + ) return crps.twopexponential(obs, scale1, scale2, location, backend=backend) @@ -734,6 +799,7 @@ def crps_gamma( *, scale: "ArrayLike | None" = None, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the gamma distribution. @@ -762,6 +828,9 @@ def crps_gamma( Either ``rate`` or ``scale`` must be provided. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -794,6 +863,18 @@ def crps_gamma( if rate is None: rate = 1.0 / scale + if check_pars: + B = backends.active if backend is None else backends[backend] + shape, rate = map(B.asarray, (shape, rate)) + if B.any(shape <= 0): + raise ValueError( + "`shape` contains non-positive entries. The shape parameter of the gamma distribution must be positive." + ) + if B.any(rate <= 0): + raise ValueError( + "`rate` or `scale` contains non-positive entries. The rate and scale parameters of the gamma distribution must be positive." + ) + return crps.gamma(obs, shape, rate, backend=backend) @@ -801,10 +882,11 @@ def crps_csg0( obs: "ArrayLike", shape: "ArrayLike", rate: "ArrayLike | None" = None, - *, scale: "ArrayLike | None" = None, shift: "ArrayLike" = 0.0, + *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the censored, shifted gamma distribution. @@ -837,6 +919,9 @@ def crps_csg0( Shift parameter of the forecast CSG distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -870,6 +955,18 @@ def crps_csg0( if rate is None: rate = 1.0 / scale + if check_pars: + B = backends.active if backend is None else backends[backend] + shape, rate = map(B.asarray, (shape, rate)) + if B.any(shape <= 0): + raise ValueError( + "`shape` contains non-positive entries. The shape parameter of the gamma distribution must be positive." + ) + if B.any(rate <= 0): + raise ValueError( + "`rate` or `scale` contains non-positive entries. The rate and scale parameters of the gamma distribution must be positive." + ) + return crps.csg0(obs, shape, rate, shift, backend=backend) @@ -880,6 +977,7 @@ def crps_gev( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the generalised extreme value (GEV) distribution. @@ -903,6 +1001,9 @@ def crps_gev( Scale parameter of the forecast GEV distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -964,6 +1065,17 @@ def crps_gev( >>> sr.crps_gev(0.3, 0.1) 0.2924712413052034 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, shape = map(B.asarray, (scale, shape)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the GEV distribution must be positive." + ) + if B.any(shape >= 1): + raise ValueError( + "`shape` contains entries larger than 1. The CRPS for the GEV distribution is only valid for shape values less than 1." + ) return crps.gev(obs, shape, location, scale, backend=backend) @@ -975,6 +1087,7 @@ def crps_gpd( mass: "ArrayLike" = 0.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the generalised pareto distribution (GPD). @@ -1007,6 +1120,9 @@ def crps_gpd( Mass parameter at the lower boundary of the forecast GPD distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1026,19 +1142,35 @@ def crps_gpd( >>> sr.crps_gpd(0.3, 0.9) 0.6849331901197213 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, shape, mass = map(B.asarray, (scale, shape, mass)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the GPD distribution must be positive. `nan` is returned in these places." + ) + if B.any(shape >= 1): + raise ValueError( + "`shape` contains entries larger than 1. The CRPS for the GPD distribution is only valid for shape values less than 1. `nan` is returned in these places." + ) + if B.any(mass < 0) or B.any(mass > 1): + raise ValueError( + "`mass` contains entries outside the range [0, 1]. `nan` is returned in these places." + ) return crps.gpd(obs, shape, location, scale, mass, backend=backend) def crps_gtclogistic( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), lmass: "ArrayLike" = 0.0, umass: "ArrayLike" = 0.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the generalised truncated and censored logistic distribution. @@ -1085,6 +1217,11 @@ def crps_gtclogistic( Point mass assigned to the lower boundary of the forecast distribution. umass : array_like Point mass assigned to the upper boundary of the forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1097,6 +1234,23 @@ def crps_gtclogistic( >>> sr.crps_gtclogistic(0.0, 0.1, 0.4, -1.0, 1.0, 0.1, 0.1) 0.1658713056903939 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lmass, umass, lower, upper = map( + B.asarray, (scale, lmass, umass, lower, upper) + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the generalised logistic distribution must be positive." + ) + if B.any(lmass < 0) or B.any(lmass > 1): + raise ValueError("`lmass` contains entries outside the range [0, 1].") + if B.any(umass < 0) or B.any(umass > 1): + raise ValueError("`umass` contains entries outside the range [0, 1].") + if B.any(umass + lmass >= 1): + raise ValueError("The sum of `umass` and `lmass` should be smaller than 1.") + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return crps.gtclogistic( obs, location, @@ -1111,12 +1265,13 @@ def crps_gtclogistic( def crps_tlogistic( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the truncated logistic distribution. @@ -1135,6 +1290,11 @@ def crps_tlogistic( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1147,6 +1307,15 @@ def crps_tlogistic( >>> sr.crps_tlogistic(0.0, 0.1, 0.4, -1.0, 1.0) 0.12714830546327846 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the truncated logistic distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return crps.gtclogistic( obs, location, scale, lower, upper, 0.0, 0.0, backend=backend ) @@ -1154,12 +1323,13 @@ def crps_tlogistic( def crps_clogistic( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the censored logistic distribution. @@ -1178,6 +1348,11 @@ def crps_clogistic( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1190,6 +1365,15 @@ def crps_clogistic( >>> sr.crps_clogistic(0.0, 0.1, 0.4, -1.0, 1.0) 0.15805632276434345 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the censored logistic distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") lmass = stats._logis_cdf((lower - location) / scale) umass = 1 - stats._logis_cdf((upper - location) / scale) return crps.gtclogistic( @@ -1206,14 +1390,15 @@ def crps_clogistic( def crps_gtcnormal( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), lmass: "ArrayLike" = 0.0, umass: "ArrayLike" = 0.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the generalised truncated and censored normal distribution. @@ -1247,6 +1432,23 @@ def crps_gtcnormal( >>> sr.crps_gtcnormal(0.0, 0.1, 0.4, -1.0, 1.0, 0.1, 0.1) 0.1351100832878575 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lmass, umass, lower, upper = map( + B.asarray, (scale, lmass, umass, lower, upper) + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the generalised normal distribution must be positive." + ) + if B.any(lmass < 0) or B.any(lmass > 1): + raise ValueError("`lmass` contains entries outside the range [0, 1].") + if B.any(umass < 0) or B.any(umass > 1): + raise ValueError("`umass` contains entries outside the range [0, 1].") + if B.any(umass + lmass >= 1): + raise ValueError("The sum of `umass` and `lmass` should be smaller than 1.") + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return crps.gtcnormal( obs, location, @@ -1261,12 +1463,13 @@ def crps_gtcnormal( def crps_tnormal( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the truncated normal distribution. @@ -1285,6 +1488,11 @@ def crps_tnormal( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1297,17 +1505,27 @@ def crps_tnormal( >>> sr.crps_tnormal(0.0, 0.1, 0.4, -1.0, 1.0) 0.10070146718008832 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the truncated normal distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return crps.gtcnormal(obs, location, scale, lower, upper, 0.0, 0.0, backend=backend) def crps_cnormal( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the censored normal distribution. @@ -1326,6 +1544,11 @@ def crps_cnormal( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1338,6 +1561,15 @@ def crps_cnormal( >>> sr.crps_cnormal(0.0, 0.1, 0.4, -1.0, 1.0) 0.10338851213123085 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the censored normal distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") lmass = stats._norm_cdf((lower - location) / scale) umass = 1 - stats._norm_cdf((upper - location) / scale) return crps.gtcnormal( @@ -1363,6 +1595,7 @@ def crps_gtct( umass: "ArrayLike" = 0.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the generalised truncated and censored t distribution. @@ -1407,6 +1640,11 @@ def crps_gtct( Point mass assigned to the lower boundary of the forecast distribution. umass : array_like Point mass assigned to the upper boundary of the forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1426,6 +1664,27 @@ def crps_gtct( >>> sr.crps_gtct(0.0, 2.0, 0.1, 0.4, -1.0, 1.0, 0.1, 0.1) 0.13997789333289662 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lmass, umass, lower, upper = map( + B.asarray, (scale, lmass, umass, lower, upper) + ) + if B.any(df <= 0): + raise ValueError( + "`df` contains non-positive entries. The degrees of freedom parameter of the generalised t distribution must be positive." + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the generalised t distribution must be positive." + ) + if B.any(lmass < 0) or B.any(lmass > 1): + raise ValueError("`lmass` contains entries outside the range [0, 1].") + if B.any(umass < 0) or B.any(umass > 1): + raise ValueError("`umass` contains entries outside the range [0, 1].") + if B.any(umass + lmass >= 1): + raise ValueError("The sum of `umass` and `lmass` should be smaller than 1.") + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return crps.gtct( obs, df, @@ -1448,6 +1707,7 @@ def crps_tt( upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the truncated t distribution. @@ -1468,6 +1728,11 @@ def crps_tt( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1480,6 +1745,19 @@ def crps_tt( >>> sr.crps_tt(0.0, 2.0, 0.1, 0.4, -1.0, 1.0) 0.10323007471747117 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(df <= 0): + raise ValueError( + "`df` contains non-positive entries. The degrees of freedom parameter of the truncated t distribution must be positive." + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the truncated t distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return crps.gtct( obs, df, @@ -1502,6 +1780,7 @@ def crps_ct( upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the censored t distribution. @@ -1522,6 +1801,11 @@ def crps_ct( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1534,6 +1818,19 @@ def crps_ct( >>> sr.crps_ct(0.0, 2.0, 0.1, 0.4, -1.0, 1.0) 0.12672580744453948 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(df <= 0): + raise ValueError( + "`df` contains non-positive entries. The degrees of freedom parameter of the censored t distribution must be positive." + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the censored t distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") lmass = stats._t_cdf((lower - location) / scale, df) umass = 1 - stats._t_cdf((upper - location) / scale, df) return crps.gtct( @@ -1556,6 +1853,7 @@ def crps_hypergeometric( k: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the hypergeometric distribution. @@ -1581,6 +1879,9 @@ def crps_hypergeometric( Number of draws, without replacement. Must be in 0, 1, ..., m + n. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1609,6 +1910,7 @@ def crps_laplace( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the laplace distribution. @@ -1631,6 +1933,9 @@ def crps_laplace( Scale parameter of the forecast laplace distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1650,6 +1955,13 @@ def crps_laplace( >>> sr.crps_laplace(0.3, 0.1, 0.2) 0.12357588823428847 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale = B.asarray(scale) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the laplace must be positive." + ) return crps.laplace(obs, location, scale, backend=backend) @@ -1659,6 +1971,7 @@ def crps_logistic( sigma: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the logistic distribution. @@ -1678,6 +1991,11 @@ def crps_logistic( Location parameter of the forecast logistic distribution. sigma: array_like Scale parameter of the forecast logistic distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1697,6 +2015,13 @@ def crps_logistic( >>> sr.crps_logistic(0.0, 0.4, 0.1) 0.3036299855835619 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + sigma = B.asarray(sigma) + if B.any(sigma <= 0): + raise ValueError( + "`sigma` contains non-positive entries. The scale parameter of the logistic distribution must be positive." + ) return crps.logistic(obs, mu, sigma, backend=backend) @@ -1706,6 +2031,7 @@ def crps_loglaplace( scalelog: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the log-Laplace distribution. @@ -1738,6 +2064,9 @@ def crps_loglaplace( Scale parameter of the forecast log-laplace distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1757,6 +2086,13 @@ def crps_loglaplace( >>> sr.crps_loglaplace(3.0, 0.1, 0.9) 1.162020513653791 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scalelog = B.asarray(scalelog) + if B.any(scalelog <= 0) or B.any(scalelog >= 1): + raise ValueError( + "`scalelog` contains entries outside of the range (0, 1). The scale parameter of the log-laplace distribution must be between 0 and 1." + ) return crps.loglaplace(obs, locationlog, scalelog, backend=backend) @@ -1764,7 +2100,9 @@ def crps_loglogistic( obs: "ArrayLike", mulog: "ArrayLike", sigmalog: "ArrayLike", + *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the log-logistic distribution. @@ -1797,7 +2135,9 @@ def crps_loglogistic( Scale parameter of the log-logistic distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. - + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1817,6 +2157,13 @@ def crps_loglogistic( >>> sr.crps_loglogistic(3.0, 0.1, 0.9) 1.1329527730161177 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + sigmalog = B.asarray(sigmalog) + if B.any(sigmalog <= 0) or B.any(sigmalog >= 1): + raise ValueError( + "`sigmalog` contains entries outside of the range (0, 1). The scale parameter of the log-logistic distribution must be between 0 and 1." + ) return crps.loglogistic(obs, mulog, sigmalog, backend=backend) @@ -1824,7 +2171,9 @@ def crps_lognormal( obs: "ArrayLike", mulog: "ArrayLike", sigmalog: "ArrayLike", + *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the lognormal distribution. @@ -1849,6 +2198,11 @@ def crps_lognormal( Mean of the normal underlying distribution. sigmalog : array_like Standard deviation of the underlying normal distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1867,6 +2221,13 @@ def crps_lognormal( >>> sr.crps_lognormal(0.1, 0.4, 0.0) 1.3918246976412703 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + sigmalog = B.asarray(sigmalog) + if B.any(sigmalog <= 0): + raise ValueError( + "`sigmalog` contains non-positive entries. The scale parameter of the log-normal distribution must be positive." + ) return crps.lognormal(obs, mulog, sigmalog, backend=backend) @@ -1878,6 +2239,7 @@ def crps_mixnorm( m_axis: "ArrayLike" = -1, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for a mixture of normal distributions. @@ -1903,6 +2265,9 @@ def crps_mixnorm( The axis corresponding to the mixture components. Default is the last axis. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1930,6 +2295,15 @@ def crps_mixnorm( w = B.zeros(m.shape) + 1 / M else: w = B.asarray(w) + w = w / B.sum(w, axis=m_axis, keepdims=True) + + if check_pars: + if B.any(s <= 0): + raise ValueError( + "`s` contains non-positive entries. The scale parameters of the normal distributions should be positive." + ) + if B.any(w < 0): + raise ValueError("`w` contains negative entries") if m_axis != -1: m = B.moveaxis(m, m_axis, -1) @@ -1943,9 +2317,10 @@ def crps_negbinom( obs: "ArrayLike", n: "ArrayLike", prob: "ArrayLike | None" = None, - *, mu: "ArrayLike | None" = None, + *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the negative binomial distribution. @@ -1968,6 +2343,11 @@ def crps_negbinom( Probability parameter of the forecast negative binomial distribution. mu: array_like Mean of the forecast negative binomial distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1999,6 +2379,12 @@ def crps_negbinom( if prob is None: prob = n / (n + mu) + if check_pars: + B = backends.active if backend is None else backends[backend] + prob = B.asarray(prob) + if B.any(prob < 0) or B.any(prob > 1): + raise ValueError("`prob` contains values outside the range [0, 1].") + return crps.negbinom(obs, n, prob, backend=backend) @@ -2008,6 +2394,7 @@ def crps_normal( sigma: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the normal distribution. @@ -2027,6 +2414,11 @@ def crps_normal( Mean of the forecast normal distribution. sigma: array_like Standard deviation of the forecast normal distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -2045,6 +2437,13 @@ def crps_normal( >>> sr.crps_normal(0.0, 0.1, 0.4) 0.10339992515976162 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + sigma = B.asarray(sigma) + if B.any(sigma <= 0): + raise ValueError( + "`sigma` contains non-positive entries. The standard deviation of the normal distribution must be positive." + ) return crps.normal(obs, mu, sigma, backend=backend) @@ -2055,6 +2454,7 @@ def crps_2pnormal( location: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the two-piece normal distribution. @@ -2079,6 +2479,11 @@ def crps_2pnormal( Scale parameter of the upper half of the forecast two-piece normal distribution. mu: array_like Location parameter of the forecast two-piece normal distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -2100,6 +2505,16 @@ def crps_2pnormal( """ B = backends.active if backend is None else backends[backend] obs, scale1, scale2, location = map(B.asarray, (obs, scale1, scale2, location)) + if check_pars: + if B.any(scale1 <= 0): + raise ValueError( + "`scale1` contains non-positive entries. The scale parameters of the two-piece normal distribution must be positive." + ) + if B.any(scale2 <= 0): + raise ValueError( + "`scale2` contains non-positive entries. The scale parameters of the two-piece normal distribution must be positive." + ) + lower = float("-inf") upper = 0.0 lmass = 0.0 @@ -2124,6 +2539,7 @@ def crps_poisson( mean: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the Poisson distribution. @@ -2143,6 +2559,11 @@ def crps_poisson( The observed values. mean : array_like Mean parameter of the forecast poisson distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -2161,6 +2582,13 @@ def crps_poisson( >>> sr.crps_poisson(1, 2) 0.4991650450203817 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + mean = B.asarray(mean) + if B.any(mean <= 0): + raise ValueError( + "`mean` contains non-positive entries. The mean parameter of the Poisson distribution must be positive." + ) return crps.poisson(obs, mean, backend=backend) @@ -2171,6 +2599,7 @@ def crps_t( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the student's t distribution. @@ -2195,8 +2624,13 @@ def crps_t( Degrees of freedom parameter of the forecast t distribution. location : array_like Location parameter of the forecast t distribution. - sigma : array_like + scale : array_like Scale parameter of the forecast t distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -2216,6 +2650,17 @@ def crps_t( >>> sr.crps_t(0.0, 0.1, 0.4, 0.1) 0.07687151141732129 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + df, scale = map(B.asarray, (df, scale)) + if B.any(df <= 0): + raise ValueError( + "`df` contains non-positive entries. The degrees of freedom parameter of the t distribution must be positive." + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the t distribution must be positive." + ) return crps.t(obs, df, location, scale, backend=backend) @@ -2227,6 +2672,7 @@ def crps_uniform( umass: "ArrayLike" = 0.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the closed form of the CRPS for the uniform distribution. @@ -2254,6 +2700,11 @@ def crps_uniform( Point mass on the lower bound of the forecast uniform distribution. umass : array_like Point mass on the upper bound of the forecast uniform distribution. + backend : str, optional + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -2273,6 +2724,17 @@ def crps_uniform( >>> sr.crps_uniform(0.4, 0.0, 1.0, 0.0, 0.0) 0.09333333333333332 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + lmass, umass, min, max = map(B.asarray, (lmass, umass, min, max)) + if B.any(lmass < 0) or B.any(lmass > 1): + raise ValueError("`lmass` contains entries outside the range [0, 1].") + if B.any(umass < 0) or B.any(umass > 1): + raise ValueError("`umass` contains entries outside the range [0, 1].") + if B.any(umass + lmass >= 1): + raise ValueError("The sum of `umass` and `lmass` should be smaller than 1.") + if B.any(min >= max): + raise ValueError("`min` is not always smaller than `max`.") return crps.uniform(obs, min, max, lmass, umass, backend=backend) diff --git a/scoringrules/_logs.py b/scoringrules/_logs.py index 85c7694..3d2cd28 100644 --- a/scoringrules/_logs.py +++ b/scoringrules/_logs.py @@ -155,6 +155,7 @@ def logs_beta( upper: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the beta distribution. @@ -173,7 +174,10 @@ def logs_beta( upper : array_like Upper bound of the forecast beta distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -185,6 +189,19 @@ def logs_beta( >>> import scoringrules as sr >>> sr.logs_beta(0.3, 0.7, 1.1) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + a, b, lower, upper = map(B.asarray, (a, b, lower, upper)) + if B.any(a <= 0): + raise ValueError( + "`a` contains non-positive entries. The shape parameters of the Beta distribution must be positive." + ) + if B.any(b <= 0): + raise ValueError( + "`b` contains non-positive entries. The shape parameters of the Beta distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return logarithmic.beta(obs, a, b, lower, upper, backend=backend) @@ -194,6 +211,7 @@ def logs_binomial( prob: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the binomial distribution. @@ -208,7 +226,10 @@ def logs_binomial( prob : array_like Probability parameter of the forecast binomial distribution as a float or array of floats. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -220,6 +241,11 @@ def logs_binomial( >>> import scoringrules as sr >>> sr.logs_binomial(4, 10, 0.5) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + prob = B.asarray(prob) + if B.any(prob < 0) or B.any(prob > 1): + raise ValueError("`prob` contains values outside the range [0, 1].") return logarithmic.binomial(obs, n, prob, backend=backend) @@ -228,6 +254,7 @@ def logs_exponential( rate: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the exponential distribution. @@ -240,7 +267,10 @@ def logs_exponential( rate : array_like Rate parameter of the forecast exponential distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -252,6 +282,13 @@ def logs_exponential( >>> import scoringrules as sr >>> sr.logs_exponential(0.8, 3.0) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + rate = B.asarray(rate) + if B.any(rate <= 0): + raise ValueError( + "`rate` contains non-positive entries. The rate parameter of the exponential distribution must be positive." + ) return logarithmic.exponential(obs, rate, backend=backend) @@ -261,6 +298,7 @@ def logs_exponential2( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the exponential distribution with location and scale parameters. @@ -275,7 +313,10 @@ def logs_exponential2( scale : array_like Scale parameter of the forecast exponential distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -287,6 +328,13 @@ def logs_exponential2( >>> import scoringrules as sr >>> sr.logs_exponential2(0.2, 0.0, 1.0) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale = B.asarray(scale) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the exponential distribution must be positive." + ) return logarithmic.exponential2(obs, location, scale, backend=backend) @@ -297,6 +345,7 @@ def logs_2pexponential( location: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the two-piece exponential distribution. @@ -313,7 +362,10 @@ def logs_2pexponential( location : array_like Location parameter of the forecast two-piece exponential distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -325,6 +377,17 @@ def logs_2pexponential( >>> import scoringrules as sr >>> sr.logs_2pexponential(0.8, 3.0, 1.4, 0.0) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale1, scale2 = map(B.asarray, (scale1, scale2)) + if B.any(scale1 <= 0): + raise ValueError( + "`scale1` contains non-positive entries. The scale parameters of the two-piece exponential distribution must be positive." + ) + if B.any(scale2 <= 0): + raise ValueError( + "`scale2` contains non-positive entries. The scale parameters of the two-piece exponential distribution must be positive." + ) return logarithmic.twopexponential(obs, scale1, scale2, location, backend=backend) @@ -335,6 +398,7 @@ def logs_gamma( *, scale: "ArrayLike | None" = None, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the gamma distribution. @@ -350,6 +414,11 @@ def logs_gamma( Rate parameter of the forecast gamma distribution. scale : array_like Scale parameter of the forecast gamma distribution, where `scale = 1 / rate`. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -374,6 +443,18 @@ def logs_gamma( if rate is None: rate = 1.0 / scale + if check_pars: + B = backends.active if backend is None else backends[backend] + shape, rate = map(B.asarray, (shape, rate)) + if B.any(shape <= 0): + raise ValueError( + "`shape` contains non-positive entries. The shape parameter of the gamma distribution must be positive." + ) + if B.any(rate <= 0): + raise ValueError( + "`rate` or `scale` contains non-positive entries. The rate and scale parameters of the gamma distribution must be positive." + ) + return logarithmic.gamma(obs, shape, rate, backend=backend) @@ -384,6 +465,7 @@ def logs_gev( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the generalised extreme value (GEV) distribution. @@ -400,7 +482,10 @@ def logs_gev( scale : array_like Scale parameter of the forecast GEV distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -412,6 +497,13 @@ def logs_gev( >>> import scoringrules as sr >>> sr.logs_gev(0.3, 0.1) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, shape = map(B.asarray, (scale, shape)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the GEV distribution must be positive." + ) return logarithmic.gev(obs, shape, location, scale, backend=backend) @@ -422,6 +514,7 @@ def logs_gpd( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the generalised Pareto distribution (GPD). @@ -439,7 +532,10 @@ def logs_gpd( scale : array_like Scale parameter of the forecast GPD distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -451,6 +547,13 @@ def logs_gpd( >>> import scoringrules as sr >>> sr.logs_gpd(0.3, 0.9) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, shape = map(B.asarray, (scale, shape)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the GPD distribution must be positive. `nan` is returned in these places." + ) return logarithmic.gpd(obs, shape, location, scale, backend=backend) @@ -461,6 +564,7 @@ def logs_hypergeometric( k: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the hypergeometric distribution. @@ -477,7 +581,10 @@ def logs_hypergeometric( k : array_like Number of draws, without replacement. Must be in 0, 1, ..., m + n. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -489,6 +596,7 @@ def logs_hypergeometric( >>> import scoringrules as sr >>> sr.logs_hypergeometric(5, 7, 13, 12) """ + # TODO: add check that m,n,k are integers return logarithmic.hypergeometric(obs, m, n, k, backend=backend) @@ -498,6 +606,7 @@ def logs_laplace( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the Laplace distribution. @@ -513,6 +622,11 @@ def logs_laplace( scale : array_like Scale parameter of the forecast laplace distribution. The LS between obs and Laplace(location, scale). + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -524,6 +638,13 @@ def logs_laplace( >>> import scoringrules as sr >>> sr.logs_laplace(0.3, 0.1, 0.2) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale = B.asarray(scale) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the laplace must be positive." + ) return logarithmic.laplace(obs, location, scale, backend=backend) @@ -533,6 +654,7 @@ def logs_loglaplace( scalelog: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the log-Laplace distribution. @@ -546,6 +668,11 @@ def logs_loglaplace( Location parameter of the forecast log-laplace distribution. scalelog : array_like Scale parameter of the forecast log-laplace distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -557,6 +684,13 @@ def logs_loglaplace( >>> import scoringrules as sr >>> sr.logs_loglaplace(3.0, 0.1, 0.9) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scalelog = B.asarray(scalelog) + if B.any(scalelog <= 0) or B.any(scalelog >= 1): + raise ValueError( + "`scalelog` contains entries outside of the range (0, 1). The scale parameter of the log-laplace distribution must be between 0 and 1." + ) return logarithmic.loglaplace(obs, locationlog, scalelog, backend=backend) @@ -566,6 +700,7 @@ def logs_logistic( sigma: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the logistic distribution. @@ -579,6 +714,11 @@ def logs_logistic( Location parameter of the forecast logistic distribution. sigma : array_like Scale parameter of the forecast logistic distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -590,6 +730,13 @@ def logs_logistic( >>> import scoringrules as sr >>> sr.logs_logistic(0.0, 0.4, 0.1) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + sigma = B.asarray(sigma) + if B.any(sigma <= 0): + raise ValueError( + "`sigma` contains non-positive entries. The scale parameter of the logistic distribution must be positive." + ) return logarithmic.logistic(obs, mu, sigma, backend=backend) @@ -597,7 +744,9 @@ def logs_loglogistic( obs: "ArrayLike", mulog: "ArrayLike", sigmalog: "ArrayLike", + *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the log-logistic distribution. @@ -612,7 +761,10 @@ def logs_loglogistic( sigmalog : array_like Scale parameter of the log-logistic distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -624,6 +776,13 @@ def logs_loglogistic( >>> import scoringrules as sr >>> sr.logs_loglogistic(3.0, 0.1, 0.9) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + sigmalog = B.asarray(sigmalog) + if B.any(sigmalog <= 0) or B.any(sigmalog >= 1): + raise ValueError( + "`sigmalog` contains entries outside of the range (0, 1). The scale parameter of the log-logistic distribution must be between 0 and 1." + ) return logarithmic.loglogistic(obs, mulog, sigmalog, backend=backend) @@ -631,7 +790,9 @@ def logs_lognormal( obs: "ArrayLike", mulog: "ArrayLike", sigmalog: "ArrayLike", + *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the log-normal distribution. @@ -645,6 +806,11 @@ def logs_lognormal( Mean of the normal underlying distribution. sigmalog : array_like Standard deviation of the underlying normal distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -656,6 +822,13 @@ def logs_lognormal( >>> import scoringrules as sr >>> sr.logs_lognormal(0.0, 0.4, 0.1) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + sigmalog = B.asarray(sigmalog) + if B.any(sigmalog <= 0): + raise ValueError( + "`sigmalog` contains non-positive entries. The scale parameter of the log-normal distribution must be positive." + ) return logarithmic.lognormal(obs, mulog, sigmalog, backend=backend) @@ -667,6 +840,7 @@ def logs_mixnorm( mc_axis: "ArrayLike" = -1, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score for a mixture of normal distributions. @@ -685,7 +859,10 @@ def logs_mixnorm( mc_axis : int The axis corresponding to the mixture components. Default is the last axis. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -705,6 +882,15 @@ def logs_mixnorm( w = B.ones(m.shape) / M else: w = B.asarray(w) + w = w / B.sum(w, axis=mc_axis, keepdims=True) + + if check_pars: + if B.any(s <= 0): + raise ValueError( + "`s` contains non-positive entries. The scale parameters of the normal distributions should be positive." + ) + if B.any(w < 0): + raise ValueError("`w` contains negative entries") if mc_axis != -1: m = B.moveaxis(m, mc_axis, -1) @@ -718,9 +904,10 @@ def logs_negbinom( obs: "ArrayLike", n: "ArrayLike", prob: "ArrayLike | None" = None, - *, mu: "ArrayLike | None" = None, + *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the negative binomial distribution. @@ -736,6 +923,11 @@ def logs_negbinom( Probability parameter of the forecast negative binomial distribution. mu : array_like Mean of the forecast negative binomial distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -760,6 +952,12 @@ def logs_negbinom( if prob is None: prob = n / (n + mu) + if check_pars: + B = backends.active if backend is None else backends[backend] + prob = B.asarray(prob) + if B.any(prob < 0) or B.any(prob > 1): + raise ValueError("`prob` contains values outside the range [0, 1].") + return logarithmic.negbinom(obs, n, prob, backend=backend) @@ -768,12 +966,12 @@ def logs_normal( mu: "ArrayLike", sigma: "ArrayLike", *, - negative: bool = True, backend: "Backend" = None, + check_pars: bool = False, ) -> "Array": r"""Compute the logarithmic score (LS) for the normal distribution. - This score is equivalent to the (negative) log likelihood (if `negative = True`) + This score is equivalent to the negative log likelihood of a normal distribution. Parameters ---------- @@ -783,8 +981,11 @@ def logs_normal( Mean of the forecast normal distribution. sigma : array_like Standard deviation of the forecast normal distribution. - backend : str, optional - The backend used for computations. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -796,7 +997,14 @@ def logs_normal( >>> import scoringrules as sr >>> sr.logs_normal(0.0, 0.4, 0.1) """ - return logarithmic.normal(obs, mu, sigma, negative=negative, backend=backend) + if check_pars: + B = backends.active if backend is None else backends[backend] + sigma = B.asarray(sigma) + if B.any(sigma <= 0): + raise ValueError( + "`sigma` contains non-positive entries. The standard deviation of the normal distribution must be positive." + ) + return logarithmic.normal(obs, mu, sigma, backend=backend) def logs_2pnormal( @@ -806,6 +1014,7 @@ def logs_2pnormal( location: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the two-piece normal distribution. @@ -822,7 +1031,10 @@ def logs_2pnormal( location : array_like Location parameter of the forecast two-piece normal distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -833,6 +1045,17 @@ def logs_2pnormal( >>> import scoringrules as sr >>> sr.logs_2pnormal(0.0, 0.4, 2.0, 0.1) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale1, scale2 = map(B.asarray, (scale1, scale2)) + if B.any(scale1 <= 0): + raise ValueError( + "`scale1` contains non-positive entries. The scale parameters of the two-piece normal distribution must be positive." + ) + if B.any(scale2 <= 0): + raise ValueError( + "`scale2` contains non-positive entries. The scale parameters of the two-piece normal distribution must be positive." + ) return logarithmic.twopnormal(obs, scale1, scale2, location, backend=backend) @@ -841,6 +1064,7 @@ def logs_poisson( mean: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the Poisson distribution. @@ -853,7 +1077,10 @@ def logs_poisson( mean : array_like Mean parameter of the forecast poisson distribution. backend : str - The name of the backend used for computations. Defaults to 'numba' if available, else 'numpy'. + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -865,6 +1092,13 @@ def logs_poisson( >>> import scoringrules as sr >>> sr.logs_poisson(1, 2) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + mean = B.asarray(mean) + if B.any(mean <= 0): + raise ValueError( + "`mean` contains non-positive entries. The mean parameter of the Poisson distribution must be positive." + ) return logarithmic.poisson(obs, mean, backend=backend) @@ -875,6 +1109,7 @@ def logs_t( scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the Student's t distribution. @@ -890,6 +1125,11 @@ def logs_t( Location parameter of the forecast t distribution. sigma : array_like Scale parameter of the forecast t distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -901,6 +1141,17 @@ def logs_t( >>> import scoringrules as sr >>> sr.logs_t(0.0, 0.1, 0.4, 0.1) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + df, scale = map(B.asarray, (df, scale)) + if B.any(df <= 0): + raise ValueError( + "`df` contains non-positive entries. The degrees of freedom parameter of the t distribution must be positive." + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the t distribution must be positive." + ) return logarithmic.t(obs, df, location, scale, backend=backend) @@ -912,6 +1163,7 @@ def logs_tlogistic( upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the truncated logistic distribution. @@ -929,6 +1181,11 @@ def logs_tlogistic( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -940,6 +1197,15 @@ def logs_tlogistic( >>> import scoringrules as sr >>> sr.logs_tlogistic(0.0, 0.1, 0.4, -1.0, 1.0) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the truncated logistic distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return logarithmic.tlogistic(obs, location, scale, lower, upper, backend=backend) @@ -951,6 +1217,7 @@ def logs_tnormal( upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the truncated normal distribution. @@ -968,6 +1235,11 @@ def logs_tnormal( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -979,6 +1251,15 @@ def logs_tnormal( >>> import scoringrules as sr >>> sr.logs_tnormal(0.0, 0.1, 0.4, -1.0, 1.0) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the truncated normal distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return logarithmic.tnormal(obs, location, scale, lower, upper, backend=backend) @@ -991,6 +1272,7 @@ def logs_tt( upper: "ArrayLike" = float("inf"), *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the truncated Student's t distribution. @@ -1010,6 +1292,11 @@ def logs_tt( Lower boundary of the truncated forecast distribution. upper : array_like Upper boundary of the truncated forecast distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1021,6 +1308,19 @@ def logs_tt( >>> import scoringrules as sr >>> sr.logs_tt(0.0, 2.0, 0.1, 0.4, -1.0, 1.0) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + scale, lower, upper = map(B.asarray, (scale, lower, upper)) + if B.any(df <= 0): + raise ValueError( + "`df` contains non-positive entries. The degrees of freedom parameter of the truncated t distribution must be positive." + ) + if B.any(scale <= 0): + raise ValueError( + "`scale` contains non-positive entries. The scale parameter of the truncated t distribution must be positive." + ) + if B.any(lower >= upper): + raise ValueError("`lower` is not always smaller than `upper`.") return logarithmic.tt(obs, df, location, scale, lower, upper, backend=backend) @@ -1030,6 +1330,7 @@ def logs_uniform( max: "ArrayLike", *, backend: "Backend" = None, + check_pars: bool = False, ) -> "ArrayLike": r"""Compute the logarithmic score (LS) for the uniform distribution. @@ -1043,6 +1344,11 @@ def logs_uniform( Lower bound of the forecast uniform distribution. max : array_like Upper bound of the forecast uniform distribution. + backend : str + The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. + check_pars: bool + Boolean indicating whether distribution parameter checks should be carried out prior to implementation. + Default is False. Returns ------- @@ -1054,6 +1360,11 @@ def logs_uniform( >>> import scoringrules as sr >>> sr.logs_uniform(0.4, 0.0, 1.0) """ + if check_pars: + B = backends.active if backend is None else backends[backend] + min, max = map(B.asarray, (min, max)) + if B.any(min >= max): + raise ValueError("`min` is not always smaller than `max`.") return logarithmic.uniform(obs, min, max, backend=backend) diff --git a/scoringrules/core/crps/_approx.py b/scoringrules/core/crps/_approx.py index 3f282a2..04c99eb 100644 --- a/scoringrules/core/crps/_approx.py +++ b/scoringrules/core/crps/_approx.py @@ -34,66 +34,84 @@ def ensemble( return out -def _crps_ensemble_fair( +def _crps_ensemble_akr( obs: "Array", fct: "Array", backend: "Backend" = None ) -> "Array": - """Fair version of the CRPS estimator based on the energy form.""" + """CRPS estimator based on the approximate kernel representation.""" B = backends.active if backend is None else backends[backend] M: int = fct.shape[-1] e_1 = B.sum(B.abs(obs[..., None] - fct), axis=-1) / M - e_2 = B.sum( - B.abs(fct[..., None] - fct[..., None, :]), - axis=(-1, -2), - ) / (M * (M - 1)) + e_2 = B.sum(B.abs(fct - B.roll(fct, shift=1, axis=-1)), -1) / M return e_1 - 0.5 * e_2 -def _crps_ensemble_nrg( +def _crps_ensemble_akr_circperm( obs: "Array", fct: "Array", backend: "Backend" = None ) -> "Array": - """CRPS estimator based on the energy form.""" + """CRPS estimator based on the AKR with cyclic permutation.""" B = backends.active if backend is None else backends[backend] M: int = fct.shape[-1] e_1 = B.sum(B.abs(obs[..., None] - fct), axis=-1) / M - e_2 = B.sum(B.abs(fct[..., None] - fct[..., None, :]), (-1, -2)) / (M**2) + shift = M // 2 + e_2 = B.sum(B.abs(fct - B.roll(fct, shift=shift, axis=-1)), -1) / M return e_1 - 0.5 * e_2 -def _crps_ensemble_pwm( +def _crps_ensemble_int( obs: "Array", fct: "Array", backend: "Backend" = None ) -> "Array": - """CRPS estimator based on the probability weighted moment (PWM) form.""" + """CRPS estimator based on the integral representation.""" B = backends.active if backend is None else backends[backend] M: int = fct.shape[-1] - expected_diff = B.sum(B.abs(obs[..., None] - fct), axis=-1) / M - β_0 = B.sum(fct, axis=-1) / M - β_1 = B.sum(fct * B.arange(0, M), axis=-1) / (M * (M - 1.0)) - return expected_diff + β_0 - 2.0 * β_1 + y_pos = B.mean((fct <= obs[..., None]) * 1.0, axis=-1, keepdims=True) + fct_cdf = B.zeros(fct.shape) + B.arange(1, M + 1) / M + fct_cdf = B.concat((fct_cdf, y_pos), axis=-1) + fct_cdf = B.sort(fct_cdf, axis=-1) + fct_exp = B.concat((fct, obs[..., None]), axis=-1) + fct_exp = B.sort(fct_exp, axis=-1) + fct_dif = fct_exp[..., 1:] - fct_exp[..., :M] + obs_cdf = (obs[..., None] <= fct_exp) * 1.0 + out = fct_dif * (fct_cdf[..., :M] - obs_cdf[..., :M]) ** 2 + return B.sum(out, axis=-1) -def _crps_ensemble_akr( +def _crps_ensemble_fair( obs: "Array", fct: "Array", backend: "Backend" = None ) -> "Array": - """CRPS estimator based on the approximate kernel representation.""" + """Fair version of the CRPS estimator based on the energy form.""" B = backends.active if backend is None else backends[backend] M: int = fct.shape[-1] e_1 = B.sum(B.abs(obs[..., None] - fct), axis=-1) / M - e_2 = B.sum(B.abs(fct - B.roll(fct, shift=1, axis=-1)), -1) / M + e_2 = B.sum( + B.abs(fct[..., None] - fct[..., None, :]), + axis=(-1, -2), + ) / (M * (M - 1)) return e_1 - 0.5 * e_2 -def _crps_ensemble_akr_circperm( +def _crps_ensemble_nrg( obs: "Array", fct: "Array", backend: "Backend" = None ) -> "Array": - """CRPS estimator based on the AKR with cyclic permutation.""" + """CRPS estimator based on the energy form.""" B = backends.active if backend is None else backends[backend] M: int = fct.shape[-1] e_1 = B.sum(B.abs(obs[..., None] - fct), axis=-1) / M - shift = M // 2 - e_2 = B.sum(B.abs(fct - B.roll(fct, shift=shift, axis=-1)), -1) / M + e_2 = B.sum(B.abs(fct[..., None] - fct[..., None, :]), (-1, -2)) / (M**2) return e_1 - 0.5 * e_2 +def _crps_ensemble_pwm( + obs: "Array", fct: "Array", backend: "Backend" = None +) -> "Array": + """CRPS estimator based on the probability weighted moment (PWM) form.""" + B = backends.active if backend is None else backends[backend] + M: int = fct.shape[-1] + expected_diff = B.sum(B.abs(obs[..., None] - fct), axis=-1) / M + β_0 = B.sum(fct, axis=-1) / M + β_1 = B.sum(fct * B.arange(0, M), axis=-1) / (M * (M - 1.0)) + return expected_diff + β_0 - 2.0 * β_1 + + def _crps_ensemble_qd(obs: "Array", fct: "Array", backend: "Backend" = None) -> "Array": """CRPS estimator based on the quantile decomposition form.""" B = backends.active if backend is None else backends[backend] @@ -105,28 +123,10 @@ def _crps_ensemble_qd(obs: "Array", fct: "Array", backend: "Backend" = None) -> return 2 * out -def _crps_ensemble_int( - obs: "Array", fct: "Array", backend: "Backend" = None -) -> "Array": - """CRPS estimator based on the integral representation.""" - B = backends.active if backend is None else backends[backend] - M: int = fct.shape[-1] - y_pos = B.mean((fct <= obs[..., None]) * 1.0, axis=-1, keepdims=True) - fct_cdf = B.zeros(fct.shape) + B.arange(1, M + 1) / M - fct_cdf = B.concat((fct_cdf, y_pos), axis=-1) - fct_cdf = B.sort(fct_cdf, axis=-1) - fct_exp = B.concat((fct, obs[..., None]), axis=-1) - fct_exp = B.sort(fct_exp, axis=-1) - fct_dif = fct_exp[..., 1:] - fct_exp[..., :M] - obs_cdf = (obs[..., None] <= fct_exp) * 1.0 - out = fct_dif * (fct_cdf[..., :M] - obs_cdf[..., :M]) ** 2 - return B.sum(out, axis=-1) - - def quantile_pinball( obs: "Array", fct: "Array", alpha: "Array", backend: "Backend" = None ) -> "Array": - """CRPS approximation via Pinball Loss.""" + """CRPS estimator based on a sample of quantiles and the quantile/pinball loss.""" B = backends.active if backend is None else backends[backend] below = (fct <= obs[..., None]) * alpha * (obs[..., None] - fct) above = (fct > obs[..., None]) * (1 - alpha) * (fct - obs[..., None]) @@ -140,16 +140,15 @@ def ow_ensemble( fw: "Array", backend: "Backend" = None, ) -> "Array": - """Outcome-Weighted CRPS estimator based on the energy form.""" + """Outcome-weighted CRPS for an ensemble forecast.""" B = backends.active if backend is None else backends[backend] - M: int = fct.shape[-1] wbar = B.mean(fw, axis=-1) - e_1 = B.sum(B.abs(obs[..., None] - fct) * fw, axis=-1) * ow / (M * wbar) - e_2 = B.sum( + e_1 = B.mean(B.abs(obs[..., None] - fct) * fw, axis=-1) * ow / wbar + e_2 = B.mean( B.abs(fct[..., None] - fct[..., None, :]) * fw[..., None] * fw[..., None, :], axis=(-1, -2), ) - e_2 *= ow / (M**2 * wbar**2) + e_2 *= ow / (wbar**2) return e_1 - 0.5 * e_2 @@ -160,15 +159,14 @@ def vr_ensemble( fw: "Array", backend: "Backend" = None, ) -> "Array": - """Vertically Re-scaled CRPS estimator based on the energy form.""" + """Vertically re-scaled CRPS for an ensemble forecast.""" B = backends.active if backend is None else backends[backend] - M: int = fct.shape[-1] - e_1 = B.sum(B.abs(obs[..., None] - fct) * fw, axis=-1) * ow / M - e_2 = B.sum( + e_1 = B.mean(B.abs(obs[..., None] - fct) * fw, axis=-1) * ow + e_2 = B.mean( B.abs(B.expand_dims(fct, axis=-1) - B.expand_dims(fct, axis=-2)) * (B.expand_dims(fw, axis=-1) * B.expand_dims(fw, axis=-2)), axis=(-1, -2), - ) / (M**2) + ) e_3 = B.mean(B.abs(fct) * fw, axis=-1) - B.abs(obs) * ow e_3 *= B.mean(fw, axis=1) - ow return e_1 - 0.5 * e_2 + e_3 diff --git a/scoringrules/core/crps/_closed.py b/scoringrules/core/crps/_closed.py index 713c6aa..519723a 100644 --- a/scoringrules/core/crps/_closed.py +++ b/scoringrules/core/crps/_closed.py @@ -35,28 +35,20 @@ def beta( """Compute the CRPS for the beta distribution.""" B = backends.active if backend is None else backends[backend] obs, a, b, lower, upper = map(B.asarray, (obs, a, b, lower, upper)) + a = B.where(a > 0.0, a, B.nan) + b = B.where(b > 0.0, b, B.nan) + lower = B.where(lower < upper, lower, B.nan) - if _is_scalar_value(lower, 0.0) and _is_scalar_value(upper, 1.0): - special_limits = False - else: - if B.any(lower >= upper): - raise ValueError("lower must be less than upper") - special_limits = True + obs = (obs - lower) / (upper - lower) + obs_std = B.minimum(B.maximum(obs, 0.0), 1.0) - if special_limits: - obs = (obs - lower) / (upper - lower) + F_ab = B.betainc(a, b, obs_std) + F_a1b = B.betainc(a + 1, b, obs_std) - I_ab = B.betainc(a, b, obs) - I_a1b = B.betainc(a + 1, b, obs) - F_ab = B.minimum(B.maximum(I_ab, 0), 1) - F_a1b = B.minimum(B.maximum(I_a1b, 0), 1) bet_rat = 2 * B.beta(2 * a, 2 * b) / (a * B.beta(a, b) ** 2) s = obs * (2 * F_ab - 1) + (a / (a + b)) * (1 - 2 * F_a1b - bet_rat) - if special_limits: - s = s * (upper - lower) - - return s + return s * (upper - lower) def binomial( @@ -133,26 +125,18 @@ def exponential( def exponentialM( obs: "ArrayLike", - mass: "ArrayLike", location: "ArrayLike", scale: "ArrayLike", + mass: "ArrayLike", backend: "Backend" = None, ) -> "Array": """Compute the CRPS for the standard exponential distribution with a point mass at the boundary.""" B = backends.active if backend is None else backends[backend] obs, location, scale, mass = map(B.asarray, (obs, location, scale, mass)) - - if not _is_scalar_value(location, 0.0): - obs -= location - - a = 1.0 if _is_scalar_value(mass, 0.0) else 1 - mass + obs -= location + a = 1.0 - mass s = B.abs(obs) - - if _is_scalar_value(scale, 1.0): - s -= a * (2 * _exp_cdf(obs, 1.0, backend=backend) - 0.5 * a) - else: - s -= scale * a * (2 * _exp_cdf(obs, 1 / scale, backend=backend) - 0.5 * a) - + s -= scale * a * (2 * _exp_cdf(obs, 1 / scale, backend=backend) - 0.5 * a) return s @@ -291,6 +275,7 @@ def gpd( ) shape = B.where(shape < 1.0, shape, B.nan) mass = B.where((mass >= 0.0) & (mass <= 1.0), mass, B.nan) + scale = B.where(scale > 0.0, scale, B.nan) ω = (obs - location) / scale F_xi = _gpd_cdf(ω, shape, backend=backend) s = ( @@ -561,7 +546,7 @@ def logistic( sigma: "ArrayLike", backend: "Backend" = None, ) -> "Array": - """Compute the CRPS for the normal distribution.""" + """Compute the CRPS for the logistic distribution.""" B = backends.active if backend is None else backends[backend] mu, sigma, obs = map(B.asarray, (mu, sigma, obs)) ω = (obs - mu) / sigma @@ -690,7 +675,7 @@ def normal( sigma: "ArrayLike", backend: "Backend" = None, ) -> "Array": - """Compute the CRPS for the logistic distribution.""" + """Compute the CRPS for the normal distribution.""" B = backends.active if backend is None else backends[backend] mu, sigma, obs = map(B.asarray, (mu, sigma, obs)) ω = (obs - mu) / sigma diff --git a/scoringrules/core/crps/_gufuncs.py b/scoringrules/core/crps/_gufuncs.py index e3ecc2b..6b46957 100644 --- a/scoringrules/core/crps/_gufuncs.py +++ b/scoringrules/core/crps/_gufuncs.py @@ -135,9 +135,9 @@ def _crps_ensemble_fair_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray for i in range(M): e_1 += abs(fct[i] - obs) for j in range(i + 1, M): - e_2 += 2 * abs(fct[j] - fct[i]) + e_2 += abs(fct[j] - fct[i]) - out[0] = e_1 / M - 0.5 * e_2 / (M * (M - 1)) + out[0] = e_1 / M - e_2 / (M * (M - 1)) @guvectorize("(),(n)->()") @@ -172,6 +172,7 @@ def _crps_ensemble_akr_gufunc(obs: np.ndarray, fct: np.ndarray, out: np.ndarray) i = M - 1 e_1 += abs(forecast - obs) e_2 += abs(forecast - fct[i - 1]) + out[0] = e_1 / M - 0.5 * 1 / M * e_2 @@ -200,11 +201,9 @@ def _owcrps_ensemble_nrg_gufunc( ): """Outcome-weighted CRPS estimator based on the energy form.""" M = fct.shape[-1] - if np.isnan(obs): out[0] = np.nan return - e_1 = 0.0 e_2 = 0.0 @@ -228,11 +227,9 @@ def _vrcrps_ensemble_nrg_gufunc( ): """Vertically re-scaled CRPS estimator based on the energy form.""" M = fct.shape[-1] - if np.isnan(obs): out[0] = np.nan return - e_1 = 0.0 e_2 = 0.0 diff --git a/scoringrules/core/logarithmic.py b/scoringrules/core/logarithmic.py index cd4d7dc..fffc1f6 100644 --- a/scoringrules/core/logarithmic.py +++ b/scoringrules/core/logarithmic.py @@ -329,17 +329,14 @@ def normal( obs: "ArrayLike", mu: "ArrayLike", sigma: "ArrayLike", - negative: bool = True, backend: "Backend" = None, ) -> "Array": """Compute the logarithmic score for the normal distribution.""" B = backends.active if backend is None else backends[backend] mu, sigma, obs = map(B.asarray, (mu, sigma, obs)) - - constant = -1.0 if negative else 1.0 ω = (obs - mu) / sigma prob = _norm_pdf(ω, backend=backend) / sigma - return constant * B.log(prob) + return -B.log(prob) def twopnormal( diff --git a/tests/test_crps.py b/tests/test_crps.py index 90c9e0c..f10f3b5 100644 --- a/tests/test_crps.py +++ b/tests/test_crps.py @@ -118,8 +118,9 @@ def test_crps_beta(backend): # test exceptions with pytest.raises(ValueError): - sr.crps_beta(0.3, 0.7, 1.1, lower=1.0, upper=0.0, backend=backend) - return + sr.crps_beta( + 0.3, 0.7, 1.1, lower=1.0, upper=0.0, backend=backend, check_pars=True + ) # correctness tests res = sr.crps_beta(0.3, 0.7, 1.1, backend=backend) From 84157616711a8ec7f7e4ecd1bb4f4c69e43d5efa Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 24 Mar 2026 15:45:03 +0100 Subject: [PATCH 04/11] add parameter checks for discrete parametric distributions --- scoringrules/_crps.py | 65 +++++++++++++++++++++++++--- scoringrules/_logs.py | 68 ++++++++++++++++++++++++++---- scoringrules/backend/base.py | 4 ++ scoringrules/backend/jax.py | 3 ++ scoringrules/backend/numpy.py | 3 ++ scoringrules/backend/tensorflow.py | 3 ++ scoringrules/backend/torch.py | 3 ++ tests/test_crps.py | 6 +-- 8 files changed, 138 insertions(+), 17 deletions(-) diff --git a/scoringrules/_crps.py b/scoringrules/_crps.py index 607d350..1efe68e 100644 --- a/scoringrules/_crps.py +++ b/scoringrules/_crps.py @@ -585,6 +585,14 @@ def crps_binomial( prob = B.asarray(prob) if B.any(prob < 0) or B.any(prob > 1): raise ValueError("`prob` contains values outside the range [0, 1].") + if B.any(n <= 0): + raise ValueError( + "`n` contains non-positive entries. The size parameter of the binomial distribution must be positive." + ) + if not B.all_integer(n): + raise ValueError( + "`n` contains non-integer entries. The size parameter of the binomial distribution must be an integer." + ) return crps.binomial(obs, n, prob, backend=backend) @@ -1901,6 +1909,36 @@ def crps_hypergeometric( >>> sr.crps_hypergeometric(5, 7, 13, 12) 0.44697415547610597 """ + if check_pars: + B = backends.active if backend is None else backends[backend] + if B.any(m < 0): + raise ValueError( + "`m` contains negative entries. The number of successes in the hypergeometric distribution must be non-negative." + ) + if B.any(n < 0): + raise ValueError( + "`n` contains negative entries. The number of failures in the hypergeometric distribution must be non-negative." + ) + if B.any(k < 0): + raise ValueError( + "`k` contains negative entries. The number of draws in the hypergeometric distribution must be non-negative." + ) + if B.any(k > m + n): + raise ValueError( + "`k` contains values larger than `m + n`. The number of draws in the hypergeometric distribution must be between the number of successes and failures." + ) + if not B.all_integer(m): + raise ValueError( + "`m` contains non-integer entries. The number of successes in the hypergeometric distribution must be an integer." + ) + if not B.all_integer(n): + raise ValueError( + "`n` contains non-integer entries. The number of failures in the hypergeometric distribution must be an integer." + ) + if not B.all_integer(k): + raise ValueError( + "`k` contains non-integer entries. The number of draws in the hypergeometric distribution must be an integer." + ) return crps.hypergeometric(obs, m, n, k, backend=backend) @@ -2376,14 +2414,29 @@ def crps_negbinom( "Either `prob` or `mu` must be provided, but not both or neither." ) - if prob is None: - prob = n / (n + mu) - if check_pars: B = backends.active if backend is None else backends[backend] - prob = B.asarray(prob) - if B.any(prob < 0) or B.any(prob > 1): - raise ValueError("`prob` contains values outside the range [0, 1].") + if prob is not None: + prob = B.asarray(prob) + if B.any(prob < 0) or B.any(prob > 1): + raise ValueError("`prob` contains values outside the range [0, 1].") + else: + mu = B.asarray(mu) + if B.any(mu < 0): + raise ValueError( + "`mu` contains negative values. The mean of the negative binomial distribution must be non-negative." + ) + if B.any(n <= 0): + raise ValueError( + "`n` contains non-positive entries. The size parameter of the negative binomial distribution must be positive." + ) + if not B.all_integer(n): + raise ValueError( + "`n` contains non-integer entries. The size parameter of the negative binomial distribution must be an integer." + ) + + if prob is None: + prob = n / (n + mu) return crps.negbinom(obs, n, prob, backend=backend) diff --git a/scoringrules/_logs.py b/scoringrules/_logs.py index 3d2cd28..a817ea2 100644 --- a/scoringrules/_logs.py +++ b/scoringrules/_logs.py @@ -222,7 +222,7 @@ def logs_binomial( obs : array_like The observed values. n : array_like - Size parameter of the forecast binomial distribution as an integer or array of integers. + Size parameter of the forecast binomial distribution as an integer or array of positive integers. prob : array_like Probability parameter of the forecast binomial distribution as a float or array of floats. backend : str @@ -246,6 +246,14 @@ def logs_binomial( prob = B.asarray(prob) if B.any(prob < 0) or B.any(prob > 1): raise ValueError("`prob` contains values outside the range [0, 1].") + if B.any(n <= 0): + raise ValueError( + "`n` contains non-positive entries. The size parameter of the binomial distribution must be positive." + ) + if not B.all_integer(n): + raise ValueError( + "`n` contains non-integer entries. The size parameter of the binomial distribution must be an integer." + ) return logarithmic.binomial(obs, n, prob, backend=backend) @@ -596,7 +604,36 @@ def logs_hypergeometric( >>> import scoringrules as sr >>> sr.logs_hypergeometric(5, 7, 13, 12) """ - # TODO: add check that m,n,k are integers + if check_pars: + B = backends.active if backend is None else backends[backend] + if B.any(m < 0): + raise ValueError( + "`m` contains negative entries. The number of successes in the hypergeometric distribution must be non-negative." + ) + if B.any(n < 0): + raise ValueError( + "`n` contains negative entries. The number of failures in the hypergeometric distribution must be non-negative." + ) + if B.any(k < 0): + raise ValueError( + "`k` contains negative entries. The number of draws in the hypergeometric distribution must be non-negative." + ) + if B.any(k > m + n): + raise ValueError( + "`k` contains values larger than `m + n`. The number of draws in the hypergeometric distribution must be between the number of successes and failures." + ) + if not B.all_integer(m): + raise ValueError( + "`m` contains non-integer entries. The number of successes in the hypergeometric distribution must be an integer." + ) + if not B.all_integer(n): + raise ValueError( + "`n` contains non-integer entries. The number of failures in the hypergeometric distribution must be an integer." + ) + if not B.all_integer(k): + raise ValueError( + "`k` contains non-integer entries. The number of draws in the hypergeometric distribution must be an integer." + ) return logarithmic.hypergeometric(obs, m, n, k, backend=backend) @@ -949,14 +986,29 @@ def logs_negbinom( "Either `prob` or `mu` must be provided, but not both or neither." ) - if prob is None: - prob = n / (n + mu) - if check_pars: B = backends.active if backend is None else backends[backend] - prob = B.asarray(prob) - if B.any(prob < 0) or B.any(prob > 1): - raise ValueError("`prob` contains values outside the range [0, 1].") + if prob is not None: + prob = B.asarray(prob) + if B.any(prob < 0) or B.any(prob > 1): + raise ValueError("`prob` contains values outside the range [0, 1].") + else: + mu = B.asarray(mu) + if B.any(mu < 0): + raise ValueError( + "`mu` contains negative values. The mean of the negative binomial distribution must be non-negative." + ) + if B.any(n <= 0): + raise ValueError( + "`n` contains non-positive entries. The size parameter of the negative binomial distribution must be positive." + ) + if not B.all_integer(n): + raise ValueError( + "`n` contains non-integer entries. The size parameter of the negative binomial distribution must be an integer." + ) + + if prob is None: + prob = n / (n + mu) return logarithmic.negbinom(obs, n, prob, backend=backend) diff --git a/scoringrules/backend/base.py b/scoringrules/backend/base.py index 1f99917..ba58843 100644 --- a/scoringrules/backend/base.py +++ b/scoringrules/backend/base.py @@ -342,3 +342,7 @@ def det(self, x: "Array") -> "Array": @abc.abstractmethod def reshape(self, x: "Array", shape: int | tuple[int, ...]) -> "Array": """Reshape an array to a new ``shape``.""" + + @abc.abstractmethod + def all_integer(self, x: "Array") -> bool: + """Check whether all entries of an array are integers""" diff --git a/scoringrules/backend/jax.py b/scoringrules/backend/jax.py index 9cf4074..d13f324 100644 --- a/scoringrules/backend/jax.py +++ b/scoringrules/backend/jax.py @@ -304,6 +304,9 @@ def det(self, x: "Array") -> "Array": def reshape(self, x: "Array", shape: int | tuple[int, ...]) -> "Array": return jnp.reshape(x, shape) + def all_integer(self, x: "Array"): + return jnp.all(x % 1 == 0).item() + if __name__ == "__main__": B = JaxBackend() diff --git a/scoringrules/backend/numpy.py b/scoringrules/backend/numpy.py index 6cbfc08..8110503 100644 --- a/scoringrules/backend/numpy.py +++ b/scoringrules/backend/numpy.py @@ -301,6 +301,9 @@ def det(self, x: "NDArray") -> "NDArray": def reshape(self, x: "NDArray", shape: int | tuple[int, ...]) -> "NDArray": return np.reshape(x, shape) + def all_integer(self, x) -> bool: + return np.all(np.mod(x, 1) == 0) + class NumbaBackend(NumpyBackend): """Numba backend.""" diff --git a/scoringrules/backend/tensorflow.py b/scoringrules/backend/tensorflow.py index 9d58712..698fb07 100644 --- a/scoringrules/backend/tensorflow.py +++ b/scoringrules/backend/tensorflow.py @@ -352,6 +352,9 @@ def det(self, x: "Tensor") -> "Tensor": def reshape(self, x: "Tensor", shape: int | tuple[int, ...]) -> "Tensor": return tf.reshape(x, shape) + def all_integer(self, x: "Tensor") -> bool: + return tf.reduce_all(tf.math.mod(x, 1) == 0).numpy().item() + if __name__ == "__main__": B = TensorflowBackend() diff --git a/scoringrules/backend/torch.py b/scoringrules/backend/torch.py index 1eb141c..3274541 100644 --- a/scoringrules/backend/torch.py +++ b/scoringrules/backend/torch.py @@ -328,3 +328,6 @@ def det(self, x: "Tensor") -> "Tensor": def reshape(self, x: "Tensor", shape: int | tuple[int, ...]) -> "Tensor": return torch.reshape(x, shape) + + def all_integer(self, x: "Tensor") -> bool: + return torch.all(x % 1 == 0).item() diff --git a/tests/test_crps.py b/tests/test_crps.py index f10f3b5..74344f2 100644 --- a/tests/test_crps.py +++ b/tests/test_crps.py @@ -583,13 +583,13 @@ def test_crps_mixnorm(backend): def test_crps_negbinom(backend): - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") - # test exceptions with pytest.raises(ValueError): sr.crps_negbinom(0.3, 7.0, 0.8, mu=7.3, backend=backend) + if backend in ["jax", "torch", "tensorflow"]: + pytest.skip("Not implemented in jax, torch or tensorflow backends") + # test correctness obs, n, prob = 2.0, 7.0, 0.8 res = sr.crps_negbinom(obs, n, prob, backend=backend) From 9b88fbc767b1080e2fb1b2582790a0fb38aad2d3 Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 24 Mar 2026 17:04:46 +0100 Subject: [PATCH 05/11] add tests for some parameter checks --- tests/test_crps.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_crps.py b/tests/test_crps.py index 74344f2..9f791e8 100644 --- a/tests/test_crps.py +++ b/tests/test_crps.py @@ -122,6 +122,14 @@ def test_crps_beta(backend): 0.3, 0.7, 1.1, lower=1.0, upper=0.0, backend=backend, check_pars=True ) + with pytest.raises(ValueError): + sr.crps_beta(0.3, -0.7, 1.1, backend=backend, check_pars=True) + return + + with pytest.raises(ValueError): + sr.crps_beta(0.3, 0.7, -1.1, backend=backend, check_pars=True) + return + # correctness tests res = sr.crps_beta(0.3, 0.7, 1.1, backend=backend) expected = 0.0850102437 @@ -187,17 +195,17 @@ def test_crps_exponential(backend): def test_crps_exponentialM(backend): obs, mass, location, scale = 0.3, 0.1, 0.0, 1.0 - res = sr.crps_exponentialM(obs, mass, location, scale, backend=backend) + res = sr.crps_exponentialM(obs, location, scale, mass, backend=backend) expected = 0.2384728 assert np.isclose(res, expected) obs, mass, location, scale = 0.3, 0.1, -2.0, 3.0 - res = sr.crps_exponentialM(obs, mass, location, scale, backend=backend) + res = sr.crps_exponentialM(obs, location, scale, mass, backend=backend) expected = 0.6236187 assert np.isclose(res, expected) obs, mass, location, scale = -1.2, 0.1, -2.0, 3.0 - res = sr.crps_exponentialM(obs, mass, location, scale, backend=backend) + res = sr.crps_exponentialM(obs, location, scale, mass, backend=backend) expected = 0.751013 assert np.isclose(res, expected) From 9e3b955226a21f21cf28529560cd98dfd7a659cf Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 24 Mar 2026 17:11:14 +0100 Subject: [PATCH 06/11] fix bug in crps test --- scoringrules/_crps.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scoringrules/_crps.py b/scoringrules/_crps.py index 1efe68e..7930381 100644 --- a/scoringrules/_crps.py +++ b/scoringrules/_crps.py @@ -657,9 +657,9 @@ def crps_exponential( def crps_exponentialM( obs: "ArrayLike", - mass: "ArrayLike" = 0.0, location: "ArrayLike" = 0.0, scale: "ArrayLike" = 1.0, + mass: "ArrayLike" = 0.0, *, backend: "Backend" = None, check_pars: bool = False, @@ -715,7 +715,7 @@ def crps_exponentialM( Examples -------- >>> import scoringrules as sr - >>> sr.crps_exponentialM(0.4, 0.2, 0.0, 1.0) + >>> sr.crps_exponentialM(0.4, 0.0, 1.0, 0.2) 0.19251207365702294 """ if check_pars: From bbe10ddce9aafc75889a365d8505752d2e373d99 Mon Sep 17 00:00:00 2001 From: sallen12 Date: Thu, 26 Mar 2026 10:06:16 +0100 Subject: [PATCH 07/11] add parameter checks to crps tests --- scoringrules/_crps.py | 71 +- scoringrules/backend/numpy.py | 2 +- scoringrules/core/crps/_closed.py | 116 +- tests/test_crps.py | 2955 +++++++++++++++++++++++++---- 4 files changed, 2694 insertions(+), 450 deletions(-) diff --git a/scoringrules/_crps.py b/scoringrules/_crps.py index 7930381..115b611 100644 --- a/scoringrules/_crps.py +++ b/scoringrules/_crps.py @@ -920,11 +920,11 @@ def crps_csg0( rate : array_like, optional Rate parameter of the forecast CSG distribution. Either ``rate`` or ``scale`` must be provided. + shift : array_like + Shift parameter of the forecast CSG distribution. scale : array_like, optional Scale parameter of the forecast CSG distribution, where ``scale = 1 / rate``. Either ``rate`` or ``scale`` must be provided. - shift : array_like - Shift parameter of the forecast CSG distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. check_pars: bool @@ -1255,10 +1255,13 @@ def crps_gtclogistic( raise ValueError("`lmass` contains entries outside the range [0, 1].") if B.any(umass < 0) or B.any(umass > 1): raise ValueError("`umass` contains entries outside the range [0, 1].") - if B.any(umass + lmass >= 1): - raise ValueError("The sum of `umass` and `lmass` should be smaller than 1.") + if B.any(umass + lmass > 1): + raise ValueError( + "The sum of `umass` and `lmass` should be no larger than 1." + ) if B.any(lower >= upper): raise ValueError("`lower` is not always smaller than `upper`.") + return crps.gtclogistic( obs, location, @@ -1925,7 +1928,7 @@ def crps_hypergeometric( ) if B.any(k > m + n): raise ValueError( - "`k` contains values larger than `m + n`. The number of draws in the hypergeometric distribution must be between the number of successes and failures." + "`k` contains values larger than `m + n`. The number of draws in the hypergeometric distribution must be larger than the total number of successes and failures." ) if not B.all_integer(m): raise ValueError( @@ -2005,8 +2008,8 @@ def crps_laplace( def crps_logistic( obs: "ArrayLike", - mu: "ArrayLike", - sigma: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, check_pars: bool = False, @@ -2025,9 +2028,9 @@ def crps_logistic( ---------- obs : array_like Observed values. - mu: array_like + location: array_like Location parameter of the forecast logistic distribution. - sigma: array_like + scale: array_like Scale parameter of the forecast logistic distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. @@ -2055,12 +2058,12 @@ def crps_logistic( """ if check_pars: B = backends.active if backend is None else backends[backend] - sigma = B.asarray(sigma) - if B.any(sigma <= 0): + scale = B.asarray(scale) + if B.any(scale <= 0): raise ValueError( - "`sigma` contains non-positive entries. The scale parameter of the logistic distribution must be positive." + "`scale` contains non-positive entries. The scale parameter of the logistic distribution must be positive." ) - return crps.logistic(obs, mu, sigma, backend=backend) + return crps.logistic(obs, location, scale, backend=backend) def crps_loglaplace( @@ -2136,8 +2139,8 @@ def crps_loglaplace( def crps_loglogistic( obs: "ArrayLike", - mulog: "ArrayLike", - sigmalog: "ArrayLike", + locationlog: "ArrayLike", + scalelog: "ArrayLike", *, backend: "Backend" = None, check_pars: bool = False, @@ -2167,9 +2170,9 @@ def crps_loglogistic( ---------- obs : array_like The observed values. - mulog : array_like + locationlog : array_like Location parameter of the log-logistic distribution. - sigmalog : array_like + scalelog : array_like Scale parameter of the log-logistic distribution. backend : str, optional The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. @@ -2197,12 +2200,12 @@ def crps_loglogistic( """ if check_pars: B = backends.active if backend is None else backends[backend] - sigmalog = B.asarray(sigmalog) - if B.any(sigmalog <= 0) or B.any(sigmalog >= 1): + scalelog = B.asarray(scalelog) + if B.any(scalelog <= 0) or B.any(scalelog >= 1): raise ValueError( - "`sigmalog` contains entries outside of the range (0, 1). The scale parameter of the log-logistic distribution must be between 0 and 1." + "`scalelog` contains entries outside of the range (0, 1). The scale parameter of the log-logistic distribution must be between 0 and 1." ) - return crps.loglogistic(obs, mulog, sigmalog, backend=backend) + return crps.loglogistic(obs, locationlog, scalelog, backend=backend) def crps_lognormal( @@ -2330,7 +2333,7 @@ def crps_mixnorm( if w is None: M: int = m.shape[m_axis] - w = B.zeros(m.shape) + 1 / M + w = B.ones(m.shape) / M else: w = B.asarray(w) w = w / B.sum(w, axis=m_axis, keepdims=True) @@ -2338,10 +2341,12 @@ def crps_mixnorm( if check_pars: if B.any(s <= 0): raise ValueError( - "`s` contains non-positive entries. The scale parameters of the normal distributions should be positive." + "`s` contains non-positive entries. The scale parameters of the normal distributions must be positive." ) if B.any(w < 0): - raise ValueError("`w` contains negative entries") + raise ValueError( + "`w` contains negative entries. The weights of the component distributions must be non-negative." + ) if m_axis != -1: m = B.moveaxis(m, m_axis, -1) @@ -2430,10 +2435,6 @@ def crps_negbinom( raise ValueError( "`n` contains non-positive entries. The size parameter of the negative binomial distribution must be positive." ) - if not B.all_integer(n): - raise ValueError( - "`n` contains non-integer entries. The size parameter of the negative binomial distribution must be an integer." - ) if prob is None: prob = n / (n + mu) @@ -2443,8 +2444,8 @@ def crps_negbinom( def crps_normal( obs: "ArrayLike", - mu: "ArrayLike", - sigma: "ArrayLike", + mu: "ArrayLike" = 0.0, + sigma: "ArrayLike" = 1.0, *, backend: "Backend" = None, check_pars: bool = False, @@ -2502,9 +2503,9 @@ def crps_normal( def crps_2pnormal( obs: "ArrayLike", - scale1: "ArrayLike", - scale2: "ArrayLike", - location: "ArrayLike", + scale1: "ArrayLike" = 1.0, + scale2: "ArrayLike" = 1.0, + location: "ArrayLike" = 0.0, *, backend: "Backend" = None, check_pars: bool = False, @@ -2719,8 +2720,8 @@ def crps_t( def crps_uniform( obs: "ArrayLike", - min: "ArrayLike", - max: "ArrayLike", + min: "ArrayLike" = 0.0, + max: "ArrayLike" = 1.0, lmass: "ArrayLike" = 0.0, umass: "ArrayLike" = 0.0, *, diff --git a/scoringrules/backend/numpy.py b/scoringrules/backend/numpy.py index 8110503..e7fd3a8 100644 --- a/scoringrules/backend/numpy.py +++ b/scoringrules/backend/numpy.py @@ -301,7 +301,7 @@ def det(self, x: "NDArray") -> "NDArray": def reshape(self, x: "NDArray", shape: int | tuple[int, ...]) -> "NDArray": return np.reshape(x, shape) - def all_integer(self, x) -> bool: + def all_integer(self, x: "NDArray") -> bool: return np.all(np.mod(x, 1) == 0) diff --git a/scoringrules/core/crps/_closed.py b/scoringrules/core/crps/_closed.py index 519723a..24ee6e7 100644 --- a/scoringrules/core/crps/_closed.py +++ b/scoringrules/core/crps/_closed.py @@ -302,38 +302,40 @@ def gtclogistic( B.asarray, (obs, location, scale, lower, upper, lmass, umass) ) ω = (obs - mu) / sigma - u = (upper - mu) / sigma l = (lower - mu) / sigma + u = (upper - mu) / sigma z = B.minimum(B.maximum(ω, l), u) + + u_inf = upper == float("inf") + l_inf = lower == float("-inf") + U_pos = umass > 0.0 + L_pos = lmass > 0.0 + F_u = _logis_cdf(u, backend=backend) F_l = _logis_cdf(l, backend=backend) F_mu = _logis_cdf(-u, backend=backend) F_ml = _logis_cdf(-l, backend=backend) F_mz = _logis_cdf(-z, backend=backend) - u_inf = u == float("inf") - l_inf = l == float("-inf") - - F_mu = B.where(u_inf | l_inf, B.nan, F_mu) - F_ml = B.where(u_inf | l_inf, B.nan, F_ml) u = B.where(u_inf, B.nan, u) l = B.where(l_inf, B.nan, l) - G_u = B.where(u_inf, 0.0, u * F_u + B.log(F_mu)) - G_l = B.where(l_inf, 0.0, l * F_l + B.log(F_ml)) - H_u = B.where(u_inf, 1.0, F_u - u * F_u**2 + (1 - 2 * F_u) * B.log(F_mu)) - H_l = B.where(l_inf, 0.0, F_l - l * F_l**2 + (1 - 2 * F_l) * B.log(F_ml)) - - c = (1 - lmass - umass) / (F_u - F_l) + uU2 = B.where(~u_inf | U_pos, u * umass**2, 0.0) + lL2 = B.where(~l_inf | L_pos, l * lmass**2, 0.0) + GuU = B.where(~u_inf, (u * F_u + B.log(F_mu)) * umass, 0.0) + GlL = B.where(~l_inf, (l * F_l + B.log(F_ml)) * lmass, 0.0) + Hu = B.where(~u_inf, F_u - u * F_u**2 + (1 - 2 * F_u) * B.log(F_mu), 1.0) + Hl = B.where(~l_inf, F_l - l * F_l**2 + (1 - 2 * F_l) * B.log(F_ml), 0.0) - s1_u = B.where(u_inf & (umass == 0.0), 0.0, u * umass**2) - s1_l = B.where(l_inf & (lmass == 0.0), 0.0, l * lmass**2) + a1 = F_u - F_l + a2 = 1 - lmass - umass - s1 = B.abs(ω - z) + s1_u - s1_l - s2 = c * z * ((1 - 2 * lmass) * F_u + (1 - 2 * umass) * F_l) / (1 - lmass - umass) - s3 = c * (2 * B.log(F_mz) - 2 * G_u * umass - 2 * G_l * lmass) - s4 = c**2 * (H_u - H_l) - return sigma * (s1 - s2 - s3 - s4) + s1 = B.abs(ω - z) + uU2 - lL2 + s2 = z * ((1 - 2 * lmass) * F_u + (1 - 2 * umass) * F_l) + s3 = 2 * (B.log(F_mz) - GuU - GlL) + s4 = Hu - Hl + s = s1 - (s2 / a1) - (s3 * a2 / a1) - (s4 * (a2 / a1) ** 2) + return sigma * s def gtcnormal( @@ -375,17 +377,11 @@ def gtcnormal( c = (1 - lmass - umass) / (F_u - F_l) s1 = B.abs(ω - z) + s1_u - s1_l - s2 = ( - c - * z - * ( - 2 * F_z - - ((1 - 2 * lmass) * F_u + (1 - 2 * umass) * F_l) / (1 - lmass - umass) - ) - ) - s3 = c * (2 * f_z - 2 * f_u * umass - 2 * f_l * lmass) - s4 = c**2 * (F_u2 - F_l2) / B.sqrt(B.pi) - return sigma * (s1 + s2 + s3 - s4) + s2 = c * z * 2 * F_z + s3 = z * ((1 - 2 * lmass) * F_u + (1 - 2 * umass) * F_l) / (F_u - F_l) + s4 = c * (2 * f_z - 2 * f_u * umass - 2 * f_l * lmass) + s5 = c**2 * (F_u2 - F_l2) / B.sqrt(B.pi) + return sigma * (s1 + s2 - s3 + s4 - s5) def gtct( @@ -443,17 +439,11 @@ def gtct( c = (1 - lmass - umass) / (F_u - F_l) s1 = B.abs(ω - z) + s1_u - s1_l - s2 = ( - c - * z - * ( - 2 * F_z - - ((1 - 2 * lmass) * F_u + (1 - 2 * umass) * F_l) / (1 - lmass - umass) - ) - ) - s3 = 2 * c * (G_z - G_u * umass - G_l * lmass) - s4 = c**2 * Bbar * (H_u - H_l) - return sigma * (s1 + s2 - s3 - s4) + s2 = c * z * 2 * F_z + s3 = z * ((1 - 2 * lmass) * F_u + (1 - 2 * umass) * F_l) / (F_u - F_l) + s4 = 2 * c * (G_z - G_u * umass - G_l * lmass) + s5 = c**2 * Bbar * (H_u - H_l) + return sigma * (s1 + s2 - s3 - s4 - s5) def hypergeometric( @@ -564,25 +554,25 @@ def loglaplace( obs, mulog, sigmalog = map(B.asarray, (obs, locationlog, scalelog)) obs, mulog, sigmalog = B.broadcast_arrays(obs, mulog, sigmalog) - logx_norm = (B.log(obs) - mulog) / sigmalog - cond_0 = obs <= 0.0 cond_1 = obs < B.exp(mulog) - F_case_0 = B.asarray(cond_0, dtype=int) - F_case_1 = B.asarray(~cond_0 & cond_1, dtype=int) + obs_pos = B.where(cond_0, B.nan, obs) + logx_norm = (B.log(obs_pos) - mulog) / sigmalog + + F_case_1 = B.asarray(cond_1 & ~cond_0, dtype=int) F_case_2 = B.asarray(~cond_1, dtype=int) - F = ( - F_case_0 * 0.0 - + F_case_1 * (0.5 * B.exp(logx_norm)) - + F_case_2 * (1 - 0.5 * B.exp(-logx_norm)) + F = B.where( + obs <= 0.0, + 0.0, + F_case_1 * (0.5 * B.exp(logx_norm)) + F_case_2 * (1 - 0.5 * B.exp(-logx_norm)), ) - A_case_0 = B.asarray(cond_1, dtype=int) - A_case_1 = B.asarray(~cond_1, dtype=int) - A = A_case_0 * 1 / (1 + sigmalog) * ( - 1 - (2 * F) ** (1 + sigmalog) - ) + A_case_1 * -1 / (1 - sigmalog) * (1 - (2 * (1 - F)) ** (1 - sigmalog)) + A = B.where( + cond_1, + 1 / (1 + sigmalog) * (1 - (2 * F) ** (1 + sigmalog)), + -1 / (1 - sigmalog) * (1 - (2 * (1 - F)) ** (1 - sigmalog)), + ) s = obs * (2 * F - 1) + B.exp(mulog) * (A + sigmalog / (4 - sigmalog**2)) return s @@ -597,7 +587,9 @@ def loglogistic( """Compute the CRPS for the log-logistic distribution.""" B = backends.active if backend is None else backends[backend] mulog, sigmalog, obs = map(B.asarray, (mulog, sigmalog, obs)) - F_ms = 1 / (1 + B.exp(-(B.log(obs) - mulog) / sigmalog)) + cond_0 = obs <= 0.0 + obs_pos = B.where(cond_0, B.nan, obs) + F_ms = B.where(cond_0, 0, 1 / (1 + B.exp(-(B.log(obs_pos) - mulog) / sigmalog))) b = B.beta(1 + sigmalog, 1 - sigmalog) I_B = B.betainc(1 + sigmalog, 1 - sigmalog, F_ms) s = obs * (2 * F_ms - 1) - B.exp(mulog) * b * (2 * I_B + sigmalog - 1) @@ -613,13 +605,15 @@ def lognormal( """Compute the CRPS for the lognormal distribution.""" B = backends.active if backend is None else backends[backend] mulog, sigmalog, obs = map(B.asarray, (mulog, sigmalog, obs)) - ω = (B.log(obs) - mulog) / sigmalog + cond_0 = obs <= 0.0 + obs_pos = B.where(cond_0, B.nan, obs) + F_s = _norm_cdf(sigmalog / B.sqrt(B.asarray(2.0)), backend=backend) + w_ms = (B.log(obs_pos) - mulog) / sigmalog + F_ms = B.where(cond_0, 0, _norm_cdf(w_ms, backend=backend)) + w_mss = (B.log(obs_pos) - mulog - sigmalog**2) / sigmalog + F_mss = B.where(cond_0, 0, _norm_cdf(w_mss, backend=backend)) ex = 2 * B.exp(mulog + sigmalog**2 / 2) - return obs * (2.0 * _norm_cdf(ω, backend=backend) - 1) - ex * ( - _norm_cdf(ω - sigmalog, backend=backend) - + _norm_cdf(sigmalog / B.sqrt(B.asarray(2.0)), backend=backend) - - 1 - ) + return obs * (2.0 * F_ms - 1) - ex * (F_mss + F_s - 1) def mixnorm( diff --git a/tests/test_crps.py b/tests/test_crps.py index 9f791e8..03d9e1e 100644 --- a/tests/test_crps.py +++ b/tests/test_crps.py @@ -21,7 +21,7 @@ def test_crps_ensemble(estimator, backend): est = "undefined_estimator" sr.crps_ensemble(obs, fct, estimator=est, backend=backend) - # test shapes + # test shape res = sr.crps_ensemble(obs, fct, estimator=estimator, backend=backend) assert res.shape == (N,) res = sr.crps_ensemble( @@ -65,7 +65,7 @@ def test_crps_estimators(backend): def test_crps_quantile(backend): - # test shapes + # test shape obs = np.random.randn(N) fct = np.random.randn(N, ENSEMBLE_SIZE) alpha = np.linspace(0.1, 0.9, ENSEMBLE_SIZE) @@ -107,21 +107,33 @@ def test_crps_beta(backend): if backend == "torch": pytest.skip("Not implemented in torch backend") + ## test shape + res = sr.crps_beta( - np.random.uniform(0, 1, (3, 3)), - np.random.uniform(0, 3, (3, 3)), + np.random.uniform(0, 1, (4, 3)), + np.random.uniform(0, 3, (4, 3)), 1.1, backend=backend, ) - assert res.shape == (3, 3) - assert not np.any(np.isnan(res)) + assert res.shape == (4, 3) - # test exceptions + ## test exceptions + + # lower bound smaller than upper bound with pytest.raises(ValueError): sr.crps_beta( 0.3, 0.7, 1.1, lower=1.0, upper=0.0, backend=backend, check_pars=True ) + return + + # lower bound equal to upper bound + with pytest.raises(ValueError): + sr.crps_beta( + 0.3, 0.7, 1.1, lower=0.5, upper=0.5, backend=backend, check_pars=True + ) + return + # negative shape parameters with pytest.raises(ValueError): sr.crps_beta(0.3, -0.7, 1.1, backend=backend, check_pars=True) return @@ -130,16 +142,24 @@ def test_crps_beta(backend): sr.crps_beta(0.3, 0.7, -1.1, backend=backend, check_pars=True) return - # correctness tests + ## correctness tests + + # single forecast res = sr.crps_beta(0.3, 0.7, 1.1, backend=backend) expected = 0.0850102437 assert np.isclose(res, expected) + # custom bounds res = sr.crps_beta(-3.0, 0.7, 1.1, lower=-5.0, upper=4.0, backend=backend) expected = 0.883206751 assert np.isclose(res, expected) - # test when lower and upper are arrays + # observation outside bounds + res = sr.crps_beta(-3.0, 0.7, 1.1, lower=-1.3, upper=4.0, backend=backend) + expected = 2.880094 + assert np.isclose(res, expected) + + # multiple forecasts res = sr.crps_beta( -3.0, 0.7, @@ -155,20 +175,17 @@ def test_crps_binomial(backend): if backend == "torch": pytest.skip("Not implemented in torch backend") - # test correctness - res = sr.crps_binomial(8, 10, 0.9, backend=backend) - expected = 0.6685115 - assert np.isclose(res, expected) - - res = sr.crps_binomial(-8, 10, 0.9, backend=backend) - expected = 16.49896 - assert np.isclose(res, expected) + ## test shape - res = sr.crps_binomial(18, 10, 0.9, backend=backend) - expected = 8.498957 - assert np.isclose(res, expected) + # res = sr.crps_binomial( + # np.random.randint(0, 10, size=(4, 3)), + # np.full((4, 3), 10), + # np.random.uniform(0, 1, (4, 3)), + # backend=backend, + # ) + # assert res.shape == (4, 3) + # - # test broadcasting ones = np.ones(2) k, n, p = 8, 10, 0.9 s = sr.crps_binomial(k * ones, n, p, backend=backend) @@ -182,403 +199,2213 @@ def test_crps_binomial(backend): s = sr.crps_binomial(k * ones, n, p * ones, backend=backend) assert np.isclose(s, np.array([0.6685115, 0.6685115])).all() + ## test exceptions -def test_crps_exponential(backend): - # TODO: add and test exception handling + # negative size parameter + with pytest.raises(ValueError): + sr.crps_binomial(7, -1, 0.9, backend=backend, check_pars=True) + return - # test correctness - obs, rate = 3, 0.7 - res = sr.crps_exponential(obs, rate, backend=backend) - expected = 1.20701837 - assert np.isclose(res, expected) + # zero size parameter + with pytest.raises(ValueError): + sr.crps_binomial(2.1, 0, 0.1, backend=backend, check_pars=True) + return + # negative prob parameter + with pytest.raises(ValueError): + sr.crps_binomial(7, 15, -0.1, backend=backend, check_pars=True) + return -def test_crps_exponentialM(backend): - obs, mass, location, scale = 0.3, 0.1, 0.0, 1.0 - res = sr.crps_exponentialM(obs, location, scale, mass, backend=backend) - expected = 0.2384728 - assert np.isclose(res, expected) + # prob parameter greater than one + with pytest.raises(ValueError): + sr.crps_binomial(1, 10, 1.1, backend=backend, check_pars=True) + return - obs, mass, location, scale = 0.3, 0.1, -2.0, 3.0 - res = sr.crps_exponentialM(obs, location, scale, mass, backend=backend) - expected = 0.6236187 + # non-integer size parameter + with pytest.raises(ValueError): + sr.crps_binomial(4, 8.99, 0.5, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.crps_binomial(8, 10, 0.9, backend=backend) + expected = 0.6685115 assert np.isclose(res, expected) - obs, mass, location, scale = -1.2, 0.1, -2.0, 3.0 - res = sr.crps_exponentialM(obs, location, scale, mass, backend=backend) - expected = 0.751013 + # negative observation + res = sr.crps_binomial(-8, 10, 0.9, backend=backend) + expected = 16.49896 assert np.isclose(res, expected) + # zero prob parameter + res = sr.crps_binomial(71, 212, 0, backend=backend, check_pars=True) + expected = 71 + assert np.isclose(res, expected) -def test_crps_2pexponential(backend): - obs, scale1, scale2, location = 0.3, 0.1, 4.3, 0.0 - res = sr.crps_2pexponential(obs, scale1, scale2, location, backend=backend) - expected = 1.787032 + # observation larger than size + res = sr.crps_binomial(18, 10, 0.9, backend=backend) + expected = 8.498957 assert np.isclose(res, expected) - obs, scale1, scale2, location = -20.8, 7.1, 2.0, -25.4 - res = sr.crps_2pexponential(obs, scale1, scale2, location, backend=backend) - expected = 6.018359 + # non-integer observation + res = sr.crps_binomial(5.6, 10, 0.9, backend=backend) + expected = 2.901231 assert np.isclose(res, expected) + # multiple observations + res = sr.crps_binomial(np.array([5.6, 17.2]), 10, 0.9, backend=backend) + expected = np.array([2.901231, 7.698957]) + assert np.allclose(res, expected) -def test_crps_gamma(backend): - obs, shape, rate = 0.2, 1.1, 0.7 - expected = 0.6343718 - res = sr.crps_gamma(obs, shape, rate, backend=backend) - assert np.isclose(res, expected) +def test_crps_exponential(backend): + ## test shape - res = sr.crps_gamma(obs, shape, scale=1 / rate, backend=backend) - assert np.isclose(res, expected) + # one observation, multiple forecasts + res = sr.crps_exponential( + 7.2, + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_exponential( + np.random.uniform(0, 10, (4, 3)), + 7.2, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_exponential( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + ## test exceptions + + # negative rate with pytest.raises(ValueError): - sr.crps_gamma(obs, shape, rate, scale=1 / rate, backend=backend) + sr.crps_exponential(3.2, -1, backend=backend, check_pars=True) return + # zero rate with pytest.raises(ValueError): - sr.crps_gamma(obs, shape, backend=backend) + sr.crps_exponential(3.2, 0, backend=backend, check_pars=True) return + ## test correctness -def test_crps_csg0(backend): - obs, shape, rate, shift = 0.7, 0.5, 2.0, 0.3 - expected = 0.5411044 - - expected_gamma = sr.crps_gamma(obs, shape, rate, backend=backend) - res_gamma = sr.crps_csg0(obs, shape=shape, rate=rate, shift=0.0, backend=backend) - assert np.isclose(res_gamma, expected_gamma) - - res = sr.crps_csg0(obs, shape=shape, rate=rate, shift=shift, backend=backend) + # single observation + res = sr.crps_exponential(3, 0.7, backend=backend) + expected = 1.20701837 assert np.isclose(res, expected) - res = sr.crps_csg0(obs, shape=shape, scale=1.0 / rate, shift=shift, backend=backend) + # negative observation + res = sr.crps_exponential(-3, 0.7, backend=backend) + expected = 3.714286 assert np.isclose(res, expected) + # multiple observations + res = sr.crps_exponential(np.array([5.6, 17.2]), 10.1, backend=backend) + expected = np.array([5.451485, 17.051485]) + assert np.allclose(res, expected) + + +def test_crps_exponentialM(backend): + ## test shape + + # one observation, multiple forecasts + # res = sr.crps_exponentialM( + # 6.2, + # np.random.uniform(0, 5, (4, 3)), + # np.random.uniform(0, 5, (4, 3)), + # np.random.uniform(0, 1, (4, 3)), + # backend=backend, + # ) + # assert res.shape == (4, 3) + # + + # multiple observations, one forecast + res = sr.crps_exponentialM( + np.random.uniform(0, 10, (4, 3)), + 2.4, + 3.5, + 0.1, + backend=backend, + ) + + # multiple observations, multiple forecasts + res = sr.crps_exponentialM( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale with pytest.raises(ValueError): - sr.crps_csg0( - obs, shape=shape, rate=rate, scale=1.0 / rate, shift=shift, backend=backend - ) + sr.crps_exponentialM(3.2, -1, -1, 0.0, backend=backend, check_pars=True) return + # zero scale with pytest.raises(ValueError): - sr.crps_csg0(obs, shape=shape, shift=shift, backend=backend) + sr.crps_exponentialM(3.2, -1, 0.0, 0.0, backend=backend, check_pars=True) return + # negative mass + with pytest.raises(ValueError): + sr.crps_exponentialM(3.2, -1, 2, -0.1, backend=backend, check_pars=True) + return -def test_crps_gev(backend): - if backend == "torch": - pytest.skip("`expi` not implemented in torch backend") + # mass larger than one + with pytest.raises(ValueError): + sr.crps_exponentialM(3.2, -1, 2, 2.1, backend=backend, check_pars=True) + return - obs, xi, mu, sigma = 0.3, 0.0, 0.0, 1.0 - assert np.isclose(sr.crps_gev(obs, xi, backend=backend), 0.276440963) - mu = 0.1 - assert np.isclose( - sr.crps_gev(obs + mu, xi, location=mu, backend=backend), 0.276440963 - ) - sigma = 0.9 - mu = 0.0 - assert np.isclose( - sr.crps_gev(obs * sigma, xi, scale=sigma, backend=backend), - 0.276440963 * sigma, - ) + ## test correctness - obs, xi, mu, sigma = 0.3, 0.7, 0.0, 1.0 - assert np.isclose(sr.crps_gev(obs, xi, backend=backend), 0.458044365) - mu = 0.1 - assert np.isclose( - sr.crps_gev(obs + mu, xi, location=mu, backend=backend), 0.458044365 - ) - sigma = 0.9 - mu = 0.0 - assert np.isclose( - sr.crps_gev(obs * sigma, xi, scale=sigma, backend=backend), - 0.458044365 * sigma, - ) + # single observation + res = sr.crps_exponentialM(0.3, 0.0, 1.0, 0.1, backend=backend) + expected = 0.2384728 + assert np.isclose(res, expected) - obs, xi, mu, sigma = 0.3, -0.7, 0.0, 1.0 - assert np.isclose(sr.crps_gev(obs, xi, backend=backend), 0.207621488) - mu = 0.1 - assert np.isclose( - sr.crps_gev(obs + mu, xi, location=mu, backend=backend), 0.207621488 - ) - sigma = 0.9 - mu = 0.0 - assert np.isclose( - sr.crps_gev(obs * sigma, xi, scale=sigma, backend=backend), - 0.207621488 * sigma, - ) + # negative location + res = sr.crps_exponentialM(0.3, -2.0, 3.0, 0.1, backend=backend) + expected = 0.6236187 + assert np.isclose(res, expected) + # negative observation + res = sr.crps_exponentialM(-1.2, -2.0, 3.0, 0.1, backend=backend) + expected = 0.751013 + assert np.isclose(res, expected) -def test_crps_gpd(backend): - assert np.isclose(sr.crps_gpd(0.3, 0.9, backend=backend), 0.6849332) - assert np.isclose(sr.crps_gpd(-0.3, 0.9, backend=backend), 1.209091) - assert np.isclose(sr.crps_gpd(0.3, -0.9, backend=backend), 0.1338672) - assert np.isclose(sr.crps_gpd(-0.3, -0.9, backend=backend), 0.6448276) + # observation smaller than location + res = sr.crps_exponentialM(-1.2, 2.0, 3.0, 0.1, backend=backend) + expected = 4.415 + assert np.isclose(res, expected) - assert np.isnan(sr.crps_gpd(0.3, 1.0, backend=backend)) - assert np.isnan(sr.crps_gpd(0.3, 1.2, backend=backend)) - assert np.isnan(sr.crps_gpd(0.3, 0.9, mass=-0.1, backend=backend)) - assert np.isnan(sr.crps_gpd(0.3, 0.9, mass=1.1, backend=backend)) + # zero mass + res = sr.crps_exponentialM(-1.2, -2.0, 3.0, 0, backend=backend) + expected = 0.89557 + assert np.isclose(res, expected) - res = 0.281636441 - assert np.isclose(sr.crps_gpd(0.3 + 0.1, 0.0, location=0.1, backend=backend), res) - assert np.isclose( - sr.crps_gpd(0.3 * 0.9, 0.0, scale=0.9, backend=backend), res * 0.9 + # multiple observations + res = sr.crps_exponentialM( + np.array([6.4, 2.2, 17.2]), + 10.1, + 4.1, + np.array([0.1, 0.2, 0.3]), + backend=backend, ) + expected = np.array([5.360500, 9.212000, 3.380377]) + assert np.allclose(res, expected) + + # check equivalence with crps_exponential + res0 = sr.crps_exponential(8.2, 3, backend=backend) + res = sr.crps_exponentialM(8.2, 0, 1 / 3, 0, backend=backend) + assert np.isclose(res, res0) -def test_crps_gtclogis(backend): - obs, location, scale, lower, upper, lmass, umass = ( - 1.8, - -3.0, - 3.3, - -5.0, - 4.7, - 0.1, - 0.15, +def test_crps_2pexponential(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_2pexponential( + 6.2, + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(-2, 2, (4, 3)), + backend=backend, ) - expected = 1.599721 - res = sr.crps_gtclogistic( - obs, location, scale, lower, upper, lmass, umass, backend=backend + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_2pexponential( + np.random.uniform(-5, 5, (4, 3)), + 2.4, + 10.1, + np.random.uniform(-2, 2, (4, 3)), + backend=backend, ) - assert np.isclose(res, expected) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_2pexponential( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(-2, 2, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - # aligns with crps_logistic - res0 = sr.crps_logistic(obs, location, scale, backend=backend) - res = sr.crps_gtclogistic(obs, location, scale, backend=backend) - assert np.isclose(res, res0) + ## test exceptions - # aligns with crps_tlogistic - res0 = sr.crps_tlogistic(obs, location, scale, lower, upper, backend=backend) - res = sr.crps_gtclogistic(obs, location, scale, lower, upper, backend=backend) - assert np.isclose(res, res0) + # negative scale parameters + with pytest.raises(ValueError): + sr.crps_2pexponential(3.2, -1, 2, 2.1, backend=backend, check_pars=True) + return + with pytest.raises(ValueError): + sr.crps_2pexponential(3.2, 1, -2, 2.1, backend=backend, check_pars=True) + return -def test_crps_tlogis(backend): - obs, location, scale, lower, upper = 4.9, 3.5, 2.3, 0.0, 20.0 - expected = 0.7658979 - res = sr.crps_tlogistic(obs, location, scale, lower, upper, backend=backend) - assert np.isclose(res, expected) + # zero scale parameters + with pytest.raises(ValueError): + sr.crps_2pexponential(3.2, 0, 2, 2.1, backend=backend, check_pars=True) + return - # aligns with crps_logistic - res0 = sr.crps_logistic(obs, location, scale, backend=backend) - res = sr.crps_tlogistic(obs, location, scale, backend=backend) - assert np.isclose(res, res0) + with pytest.raises(ValueError): + sr.crps_2pexponential(3.2, 1, 0, 2.1, backend=backend, check_pars=True) + return + ## test correctness -def test_crps_clogis(backend): - obs, location, scale, lower, upper = -0.9, 0.4, 1.1, 0.0, 1.0 - expected = 1.13237 - res = sr.crps_clogistic(obs, location, scale, lower, upper, backend=backend) + # single observation + res = sr.crps_2pexponential(0.3, 0.1, 4.3, 0.0, backend=backend) + expected = 1.787032 assert np.isclose(res, expected) - # aligns with crps_logistic - res0 = sr.crps_logistic(obs, location, scale, backend=backend) - res = sr.crps_clogistic(obs, location, scale, backend=backend) - assert np.isclose(res, res0) - - -def test_crps_gtcnormal(backend): - obs, location, scale, lower, upper, lmass, umass = ( - 0.9, - -2.3, - 4.1, - -7.3, - 1.7, - 0.0, - 0.21, - ) - expected = 1.422805 - res = sr.crps_gtcnormal( - obs, location, scale, lower, upper, lmass, umass, backend=backend - ) + # negative location + res = sr.crps_2pexponential(-20.8, 7.1, 2.0, -25.4, backend=backend) + expected = 6.018359 assert np.isclose(res, expected) - # aligns with crps_normal - res0 = sr.crps_normal(obs, location, scale, backend=backend) - res = sr.crps_gtcnormal(obs, location, scale, backend=backend) - assert np.isclose(res, res0) + # multiple observations + res = sr.crps_2pexponential( + np.array([-1.2, 3.7]), 0.1, 4.3, np.array([-0.1, 0.1]), backend=backend + ) + expected = np.array([3.1488637, 0.8873341]) + assert np.allclose(res, expected) - # aligns with crps_tnormal - res0 = sr.crps_tnormal(obs, location, scale, lower, upper, backend=backend) - res = sr.crps_gtcnormal(obs, location, scale, lower, upper, backend=backend) + # check equivalence with laplace distribution + res = sr.crps_2pexponential(-20.8, 2.0, 2.0, -25.4, backend=backend) + res0 = sr.crps_laplace(-20.8, -25.4, 2.0, backend=backend) assert np.isclose(res, res0) -def test_crps_tnormal(backend): - obs, location, scale, lower, upper = -1.0, 2.9, 2.2, 1.5, 17.3 - expected = 3.982434 - res = sr.crps_tnormal(obs, location, scale, lower, upper, backend=backend) - assert np.isclose(res, expected) +def test_crps_gamma(backend): + ## test shape - # aligns with crps_normal - res0 = sr.crps_normal(obs, location, scale, backend=backend) - res = sr.crps_tnormal(obs, location, scale, backend=backend) - assert np.isclose(res, res0) + # one observation, multiple forecasts + res = sr.crps_gamma( + 6.2, + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + # multiple observations, one forecast + res = sr.crps_gamma( + np.random.uniform(0, 10, (4, 3)), + 4.0, + 2.1, + backend=backend, + ) + assert res.shape == (4, 3) -def test_crps_cnormal(backend): - obs, location, scale, lower, upper = 1.8, 0.4, 1.1, 0.0, 2.0 - expected = 0.8296078 - res = sr.crps_cnormal(obs, location, scale, lower, upper, backend=backend) - assert np.isclose(res, expected) + # multiple observations, multiple forecasts + res = sr.crps_gamma( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - # aligns with crps_normal - res0 = sr.crps_normal(obs, location, scale, backend=backend) - res = sr.crps_cnormal(obs, location, scale, backend=backend) - assert np.isclose(res, res0) + ## test exceptions + # rate and scale provided + with pytest.raises(ValueError): + sr.crps_gamma(3.2, -1, rate=2, scale=0.4, backend=backend, check_pars=True) + return -def test_crps_gtct(backend): - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") - obs, df, location, scale, lower, upper, lmass, umass = ( - 0.9, - 20.1, - -2.3, - 4.1, - -7.3, - 1.7, - 0.0, - 0.21, - ) - expected = 1.423042 - res = sr.crps_gtct( - obs, df, location, scale, lower, upper, lmass, umass, backend=backend - ) - assert np.isclose(res, expected) + # neither rate nor scale provided + with pytest.raises(ValueError): + sr.crps_gamma(3.2, -1, backend=backend, check_pars=True) + return - # aligns with crps_t - res0 = sr.crps_t(obs, df, location, scale, backend=backend) - res = sr.crps_gtct(obs, df, location, scale, backend=backend) - assert np.isclose(res, res0) + # negative shape parameter + with pytest.raises(ValueError): + sr.crps_gamma(3.2, -1, 2, backend=backend, check_pars=True) + return - # aligns with crps_tnormal - res0 = sr.crps_tt(obs, df, location, scale, lower, upper, backend=backend) - res = sr.crps_gtct(obs, df, location, scale, lower, upper, backend=backend) - assert np.isclose(res, res0) + # negative rate/scale parameter + with pytest.raises(ValueError): + sr.crps_gamma(3.2, 1, -2, backend=backend, check_pars=True) + return + # zero shape parameter + with pytest.raises(ValueError): + sr.crps_gamma(3.2, 0.0, 2, backend=backend, check_pars=True) + return -def test_crps_tt(backend): - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") + ## test correctness - obs, df, location, scale, lower, upper = -1.0, 2.9, 3.1, 4.2, 1.5, 17.3 - expected = 5.084272 - res = sr.crps_tt(obs, df, location, scale, lower, upper, backend=backend) + # single observation + res = sr.crps_gamma(0.2, 1.1, 0.7, backend=backend) + expected = 0.6343718 assert np.isclose(res, expected) - # aligns with crps_t - res0 = sr.crps_t(obs, df, location, scale, backend=backend) - res = sr.crps_tt(obs, df, location, scale, backend=backend) - assert np.isclose(res, res0) + # scale instead of rate + res = sr.crps_gamma(0.2, 1.1, scale=1 / 0.7, backend=backend) + assert np.isclose(res, expected) + # negative observation + res = sr.crps_gamma(-4.2, 1.1, rate=0.7, backend=backend) + expected = 5.014442 + assert np.isclose(res, expected) -def test_crps_ct(backend): - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") + # multiple observations + res = sr.crps_gamma(np.array([-1.2, 2.3]), 1.1, 0.7, backend=backend) + expected = np.array([2.0144417, 0.6500017]) + assert np.allclose(res, expected) - obs, df, location, scale, lower, upper = 1.8, 5.4, 0.4, 1.1, 0.0, 2.0 - expected = 0.8028996 - res = sr.crps_ct(obs, df, location, scale, lower, upper, backend=backend) - assert np.isclose(res, expected) + # check equivalence with exponential distribution + res0 = sr.crps_exponential(3.1, 2, backend=backend) + res = sr.crps_gamma(3.1, 1, 2, backend=backend) + assert np.isclose(res0, res) - # aligns with crps_t - res0 = sr.crps_t(obs, df, location, scale, backend=backend) - res = sr.crps_ct(obs, df, location, scale, backend=backend) - assert np.isclose(res, res0) +def test_crps_csg0(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_csg0( + 6.2, + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + shift=np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_csg0( + np.random.uniform(0, 5, (4, 3)), + 9.8, + 8.8, + shift=1.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_csg0( + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + shift=np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) -def test_crps_hypergeometric(backend): - if backend == "torch": - pytest.skip("Currently not working in torch backend") + ## test exceptions - # test shapes - res = sr.crps_hypergeometric(5 * np.ones((2, 2)), 7, 13, 12, backend=backend) - assert res.shape == (2, 2) + # rate and scale provided + with pytest.raises(ValueError): + sr.crps_csg0(0.7, 0.5, rate=2.0, scale=0.5, shift=0.3, backend=backend) + return - res = sr.crps_hypergeometric(5, 7 * np.ones((2, 2)), 13, 12, backend=backend) - assert res.shape == (2, 2) + # neither rate nor scale provided + with pytest.raises(ValueError): + sr.crps_csg0(0.7, 0.5, shift=0.3, backend=backend) + return - res = sr.crps_hypergeometric(5, 7, 13 * np.ones((2, 2)), 12, backend=backend) - assert res.shape == (2, 2) + # negative shape parameter + with pytest.raises(ValueError): + sr.crps_csg0(0.7, -0.5, 2.0, shift=2, backend=backend, check_pars=True) + return - # test correctness - assert np.isclose(sr.crps_hypergeometric(5, 7, 13, 12), 0.4469742) + # negative rate/scale parameter + with pytest.raises(ValueError): + sr.crps_csg0(0.7, 0.5, -2.0, shift=2, backend=backend, check_pars=True) + return + # zero shape parameter + with pytest.raises(ValueError): + sr.crps_csg0(0.7, 0, 2.0, shift=2, backend=backend, check_pars=True) + return -def test_crps_laplace(backend): - assert np.isclose(sr.crps_laplace(-3, backend=backend), 2.29978707) - assert np.isclose( - sr.crps_laplace(-3 + 0.1, location=0.1, backend=backend), 2.29978707 - ) - assert np.isclose( - sr.crps_laplace(-3 * 0.9, scale=0.9, backend=backend), 0.9 * 2.29978707 - ) + ## test correctness + obs, shape, rate, shift = 0.7, 0.5, 2.0, 0.3 + expected = 0.5411044 -def test_crps_logis(backend): - obs, mu, sigma = 17.1, 13.8, 3.3 - expected = 2.067527 - res = sr.crps_logistic(obs, mu, sigma, backend=backend) + # single observation + res = sr.crps_csg0(obs, shape=shape, rate=rate, shift=shift, backend=backend) assert np.isclose(res, expected) - obs, mu, sigma = 3.1, 4.0, 0.5 - expected = 0.5529776 - res = sr.crps_logistic(obs, mu, sigma, backend=backend) + # scale instead of rate + res = sr.crps_csg0(obs, shape=shape, scale=1.0 / rate, shift=shift, backend=backend) assert np.isclose(res, expected) + # with and without shift parameter + res0 = sr.crps_csg0(obs, shape, rate, backend=backend) + res = sr.crps_csg0(obs, shape, rate, shift=0.0, backend=backend) + assert np.isclose(res0, res) -def test_crps_loglaplace(backend): - assert np.isclose(sr.crps_loglaplace(3.0, 0.1, 0.9, backend=backend), 1.16202051) + # check equivalence with gamma distribution + res0 = sr.crps_gamma(obs, shape, rate, backend=backend) + assert np.isclose(res0, res) -def test_crps_loglogistic(backend): +def test_crps_gev(backend): if backend == "torch": - pytest.skip("Not implemented in torch backend") + pytest.skip("`expi` not implemented in torch backend") - # TODO: investigate why JAX results are different from other backends - # (would fail test with smaller tolerance) - assert np.isclose( - sr.crps_loglogistic(3.0, 0.1, 0.9, backend=backend), 1.13295277, atol=1e-4 + ## test shape + + # one observation, multiple forecasts + res = sr.crps_gev( + 6.2, + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + 0.5, + backend=backend, ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_gev( + np.random.uniform(-5, 5, (4, 3)), + -4.9, + 2.3, + 0.7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_gev( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + 0.7, + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_gev(0.7, 0.5, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_gev(0.7, 0.5, 2.0, 0.0, backend=backend, check_pars=True) + return + + # shape parameter greater than one + with pytest.raises(ValueError): + sr.crps_gev(0.7, 1.5, 0.0, 0.5, backend=backend, check_pars=True) + return + + ## test correctness + + # test default location + res0 = sr.crps_gev(0.3, 0.0, backend=backend) + res = sr.crps_gev(0.3, 0.0, 0.0, backend=backend) + assert np.isclose(res0, res) + + # test default scale + res = sr.crps_gev(0.3, 0.0, 0.0, 1.0, backend=backend) + assert np.isclose(res0, res) + + # test invariance to shift + mu = 0.1 + res = sr.crps_gev(0.3 + mu, 0.0, mu, 1.0, backend=backend) + assert np.isclose(res0, res) + + # test invariance to rescaling + sigma = 0.9 + res = sr.crps_gev(0.3 * sigma, 0.0, scale=sigma, backend=backend) + assert np.isclose(res0 * sigma, res) + + # positive shape parameter + res = sr.crps_gev(0.3, 0.7, backend=backend) + expected = 0.458044365 + assert np.isclose(res, expected) + + # negative shape parameter + res = sr.crps_gev(0.3, -0.7, backend=backend) + expected = 0.207621488 + assert np.isclose(res, expected) + + # zero shape parameter + res = sr.crps_gev(0.3, 0.0, backend=backend) + expected = 0.276441 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_gev(np.array([0.9, -2.1]), -0.7, 0, 1.4, backend=backend) + expected = np.array([0.3575414, 1.7008711]) + assert np.allclose(res, expected) + + +def test_crps_gpd(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_gpd( + 6.2, + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_gpd( + np.random.uniform(-5, 5, (4, 3)), + -4.9, + 2.3, + 0.7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_gpd( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_gpd(0.7, 0.5, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_gpd(0.7, 0.5, 2.0, 0.0, backend=backend, check_pars=True) + return + + # shape parameter greater than one + with pytest.raises(ValueError): + sr.crps_gpd(0.7, 1.5, 0.0, 0.5, backend=backend, check_pars=True) + return + + # mass parameter smaller than zero + with pytest.raises(ValueError): + sr.crps_gpd(0.7, 0.5, 0.0, 0.5, mass=-0.1, backend=backend, check_pars=True) + return + + # mass parameter smaller than zero + with pytest.raises(ValueError): + sr.crps_gpd(0.7, 0.5, 0.0, 0.5, mass=-0.1, backend=backend, check_pars=True) + return + + # mass parameter greater than one + with pytest.raises(ValueError): + sr.crps_gpd(0.7, 0.5, 0.0, 0.5, mass=1.1, backend=backend, check_pars=True) + return + + ## test correctness + + # test default location + res0 = sr.crps_gpd(0.3, 0.0, backend=backend) + res = sr.crps_gpd(0.3, 0.0, 0.0, backend=backend) + assert np.isclose(res0, res) + + # test default scale + res = sr.crps_gpd(0.3, 0.0, 0.0, 1.0, backend=backend) + assert np.isclose(res0, res) + + # test default mass + res = sr.crps_gpd(0.3, 0.0, 0.0, 1.0, 0.0, backend=backend) + assert np.isclose(res0, res) + + # test invariance to shift + res = sr.crps_gpd(0.3 + 0.1, 0.0, 0.1, 1.0, backend=backend) + assert np.isclose(res0, res) + + # test invariance to rescaling + res0 = sr.crps_gpd((0.3 - 0.1) / 0.8, 0.4, backend=backend) + res = sr.crps_gpd(0.3, 0.4, 0.1, 0.8, backend=backend) + assert np.isclose(res0 * 0.8, res) + + # single observation + res = sr.crps_gpd(0.3, 0.9, backend=backend) + expected = 0.6849332 + assert np.isclose(res, expected) + + # negative observation + res = sr.crps_gpd(-0.3, 0.9, backend=backend) + expected = 1.209091 + assert np.isclose(res, expected) + + # negative shape parameter + res = sr.crps_gpd(0.3, -0.9, backend=backend) + expected = 0.1338672 + assert np.isclose(res, expected) + + # non-zero mass parameter + res = sr.crps_gpd(-0.3, -0.9, -2.1, 10.0, mass=0.3, backend=backend) + expected = 1.195042 + assert np.isclose(res, expected) + + # mass parameter equal to one + res = sr.crps_gpd(-0.3, -0.9, -2.1, 10.0, mass=1.0, backend=backend) + expected = 1.8 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_gpd(np.array([0.9, -2.1]), -0.7, 0, 1.4, backend=backend) + expected = np.array([0.1570813, 2.6185185]) + assert np.allclose(res, expected) + + +def test_crps_gtclogis(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_gtclogistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_gtclogistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_gtclogistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_gtclogistic(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_gtclogistic(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # negative lower mass parameter + with pytest.raises(ValueError): + sr.crps_gtclogistic( + 0.7, 2.0, 0.4, lower=-1, lmass=-0.4, backend=backend, check_pars=True + ) + return + + # lower mass parameter larger than one + with pytest.raises(ValueError): + sr.crps_gtclogistic( + 0.7, 2.0, 0.4, lower=-1, lmass=1.4, backend=backend, check_pars=True + ) + return + + # negative upper mass parameter + with pytest.raises(ValueError): + sr.crps_gtclogistic( + 0.7, 2.0, 0.4, upper=4, umass=-0.4, backend=backend, check_pars=True + ) + return + + # upper mass parameter larger than one + with pytest.raises(ValueError): + sr.crps_gtclogistic( + 0.7, 2.0, 0.4, upper=4, umass=1.4, backend=backend, check_pars=True + ) + return + + # sum of lower and upper mass parameters larger than one + with pytest.raises(ValueError): + sr.crps_gtclogistic( + 0.7, + 2.0, + 0.4, + lower=-1, + upper=4, + lmass=0.7, + umass=0.4, + backend=backend, + check_pars=True, + ) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_gtclogistic( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_gtclogistic(1.8, backend=backend) + res = sr.crps_gtclogistic(1.8, 0.0, 1.0, -np.inf, np.inf, 0.0, 0.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_gtclogistic(1.8, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 1.599721 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_gtclogistic(-5.8, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 3.393796 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_gtclogistic(7.8, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 6.378633 + assert np.isclose(res, expected) + + # mass on lower bound equal to one + res = sr.crps_gtclogistic(7.8, -3.0, 3.3, -5.0, 4.7, 1.0, 0.0, backend=backend) + expected = 12.8 + assert np.isclose(res, expected) + + # mass on upper bound equal to one + res = sr.crps_gtclogistic(7.8, -3.0, 3.3, -5.0, 4.7, 0.0, 1.0, backend=backend) + expected = 3.1 + assert np.isclose(res, expected) + + # lower bound equal to infinite + res = sr.crps_gtclogistic( + 7.8, -3.0, 3.3, float("-inf"), 4.7, 0.0, 0.15, backend=backend + ) + expected = 7.444611 + assert np.isclose(res, expected) + + # upper bound equal to infinite + res = sr.crps_gtclogistic( + 7.8, -3.0, 3.3, -5.0, float("inf"), 0.15, 0.0, backend=backend + ) + expected = 6.339197 + assert np.isclose(res, expected) + + # aligns with crps_logistic + res0 = sr.crps_logistic(1.8, -3.0, 3.3, backend=backend) + res = sr.crps_gtclogistic(1.8, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # aligns with crps_tlogistic + res0 = sr.crps_tlogistic(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + res = sr.crps_gtclogistic(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_gtclogistic( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, 0.5, 0.0, backend=backend + ) + expected = np.array([3.627106, 3.270710]) + assert np.allclose(res, expected) + + +def test_crps_tlogis(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_tlogistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_tlogistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_tlogistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_tlogistic(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_tlogistic(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_tlogistic( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_tlogistic(1.8, backend=backend) + res = sr.crps_tlogistic(1.8, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_tlogistic(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 1.72305 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_tlogistic(-5.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 3.395149 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_tlogistic(7.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 7.254933 + assert np.isclose(res, expected) + + # aligns with crps_logistic + res0 = sr.crps_logistic(1.8, -3.0, 3.3, backend=backend) + res = sr.crps_tlogistic(1.8, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_tlogistic( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([7.932799, 11.320006]) + assert np.allclose(res, expected) + + +def test_crps_clogis(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_clogistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_clogistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_clogistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_clogistic(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_clogistic(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_clogistic( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_clogistic(1.8, backend=backend) + res = sr.crps_clogistic(1.8, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_clogistic(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.599499 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_clogistic(-5.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.087692 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_clogistic(7.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 7.825271 + assert np.isclose(res, expected) + + # aligns with crps_logistic + res0 = sr.crps_logistic(1.8, -3.0, 3.3, backend=backend) + res = sr.crps_clogistic(1.8, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_clogistic( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([5.102037, 6.729603]) + assert np.allclose(res, expected) + + +def test_crps_gtcnormal(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_gtcnormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_gtcnormal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_gtcnormal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_gtcnormal(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_gtcnormal(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # negative lower mass parameter + with pytest.raises(ValueError): + sr.crps_gtcnormal( + 0.7, 2.0, 0.4, lower=-1, lmass=-0.4, backend=backend, check_pars=True + ) + return + + # lower mass parameter larger than one + with pytest.raises(ValueError): + sr.crps_gtcnormal( + 0.7, 2.0, 0.4, lower=-1, lmass=1.4, backend=backend, check_pars=True + ) + return + + # negative upper mass parameter + with pytest.raises(ValueError): + sr.crps_gtcnormal( + 0.7, 2.0, 0.4, upper=4, umass=-0.4, backend=backend, check_pars=True + ) + return + + # upper mass parameter larger than one + with pytest.raises(ValueError): + sr.crps_gtcnormal( + 0.7, 2.0, 0.4, upper=4, umass=1.4, backend=backend, check_pars=True + ) + return + + # sum of lower and upper mass parameters larger than one + with pytest.raises(ValueError): + sr.crps_gtcnormal( + 0.7, + 2.0, + 0.4, + lower=-1, + upper=4, + lmass=0.7, + umass=0.4, + backend=backend, + check_pars=True, + ) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_gtcnormal( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_gtcnormal(1.8, backend=backend) + res = sr.crps_gtcnormal(1.8, 0.0, 1.0, -np.inf, np.inf, 0.0, 0.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_gtcnormal(1.8, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 1.986411 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_gtcnormal(-5.8, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 2.993124 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_gtcnormal(7.8, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 6.974827 + assert np.isclose(res, expected) + + # mass on lower bound equal to one + res = sr.crps_gtcnormal(7.8, -3.0, 3.3, -5.0, 4.7, 1.0, 0.0, backend=backend) + expected = 12.8 + assert np.isclose(res, expected) + + # mass on upper bound equal to one + res = sr.crps_gtcnormal(7.8, -3.0, 3.3, -5.0, 4.7, 0.0, 1.0, backend=backend) + expected = 3.1 + assert np.isclose(res, expected) + + # aligns with crps_normal + res0 = sr.crps_normal(1.8, -3.0, 3.3, backend=backend) + res = sr.crps_gtcnormal(1.8, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # aligns with crps_tnormal + res0 = sr.crps_tnormal(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + res = sr.crps_gtcnormal(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_gtcnormal( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, 0.5, 0.0, backend=backend + ) + expected = np.array([3.019942, 2.637561]) + assert np.allclose(res, expected) + + +def test_crps_tnormal(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_tnormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_tnormal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_tnormal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_tnormal(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_tnormal(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_tnormal( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_tnormal(1.8, backend=backend) + res = sr.crps_tnormal(1.8, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_tnormal(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.326391 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_tnormal(-5.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.948674 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_tnormal(7.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 8.137612 + assert np.isclose(res, expected) + + # aligns with crps_normal + res0 = sr.crps_normal(1.8, -3.0, 3.3, backend=backend) + res = sr.crps_tnormal(1.8, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_tnormal( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([5.452673, 8.787913]) + assert np.allclose(res, expected) + + +def test_crps_cnormal(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_cnormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_cnormal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_cnormal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_cnormal(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_cnormal(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_cnormal( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_cnormal(1.8, backend=backend) + res = sr.crps_cnormal(1.8, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_cnormal(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 3.068522 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_cnormal(-5.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 1.956462 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_cnormal(7.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 8.87606 + assert np.isclose(res, expected) + + # aligns with crps_normal + res0 = sr.crps_cnormal(1.8, -3.0, 3.3, backend=backend) + res = sr.crps_cnormal(1.8, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_cnormal( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([4.401269, 6.851981]) + assert np.allclose(res, expected) + + +def test_crps_gtct(backend): + if backend in ["jax", "torch", "tensorflow"]: + pytest.skip("Not implemented in jax, torch or tensorflow backends") + + ## test shape + + # one observation, multiple forecasts + res = sr.crps_gtct( + 17, + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_gtct( + np.random.uniform(-10, 10, (4, 3)), + 11.1, + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_gtct( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative df parameter + with pytest.raises(ValueError): + sr.crps_gtct(0.7, -1.7, 2.0, 0.5, backend=backend, check_pars=True) + return + + # zero df parameter + with pytest.raises(ValueError): + sr.crps_gtct(0.7, 0.0, 2.0, 0.5, backend=backend, check_pars=True) + return + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_gtct(0.7, 4.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_gtct(0.7, 4.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # negative lower mass parameter + with pytest.raises(ValueError): + sr.crps_gtct( + 0.7, 4.7, 2.0, 0.4, lower=-1, lmass=-0.4, backend=backend, check_pars=True + ) + return + + # lower mass parameter larger than one + with pytest.raises(ValueError): + sr.crps_gtct( + 0.7, 4.7, 2.0, 0.4, lower=-1, lmass=1.4, backend=backend, check_pars=True + ) + return + + # negative upper mass parameter + with pytest.raises(ValueError): + sr.crps_gtct( + 0.7, 4.7, 2.0, 0.4, upper=4, umass=-0.4, backend=backend, check_pars=True + ) + return + + # upper mass parameter larger than one + with pytest.raises(ValueError): + sr.crps_gtct( + 0.7, 4.7, 2.0, 0.4, upper=4, umass=1.4, backend=backend, check_pars=True + ) + return + + # sum of lower and upper mass parameters larger than one + with pytest.raises(ValueError): + sr.crps_gtct( + 0.7, + 4.7, + 2.0, + 0.4, + lower=-1, + upper=4, + lmass=0.7, + umass=0.4, + backend=backend, + check_pars=True, + ) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_gtct( + 0.7, 4.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_gtct(1.8, 6.9, backend=backend) + res = sr.crps_gtct(1.8, 6.9, 0.0, 1.0, -np.inf, np.inf, 0.0, 0.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_gtct(1.8, 6.9, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 1.963135 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_gtct(-5.8, 6.9, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 3.016045 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_gtct(7.8, 6.9, -3.0, 3.3, -5.0, 4.7, 0.1, 0.15, backend=backend) + expected = 6.921271 + assert np.isclose(res, expected) + + # mass on lower bound equal to one + res = sr.crps_gtct(7.8, 6.9, -3.0, 3.3, -5.0, 4.7, 1.0, 0.0, backend=backend) + expected = 12.8 + assert np.isclose(res, expected) + + # mass on upper bound equal to one + res = sr.crps_gtct(7.8, 6.9, -3.0, 3.3, -5.0, 4.7, 0.0, 1.0, backend=backend) + expected = 3.1 + assert np.isclose(res, expected) + + # aligns with crps_t + res0 = sr.crps_t(1.8, 6.9, -3.0, 3.3, backend=backend) + res = sr.crps_gtct(1.8, 6.9, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # aligns with crps_tt + res0 = sr.crps_tt(1.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + res = sr.crps_gtct(1.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_gtct( + np.array([1.8, -2.3]), 6.9, 9.1, 11.1, -4.0, np.inf, 0.5, 0.0, backend=backend + ) + expected = np.array([3.085881, 2.720847]) + assert np.allclose(res, expected) + + +def test_crps_tt(backend): + if backend in ["jax", "torch", "tensorflow"]: + pytest.skip("Not implemented in jax, torch or tensorflow backends") + + ## test shape + + # one observation, multiple forecasts + res = sr.crps_tt( + 17, + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_tt( + np.random.uniform(-10, 10, (4, 3)), + 11.1, + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_tt( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative df parameter + with pytest.raises(ValueError): + sr.crps_tt(0.7, -1.7, 2.0, 0.5, backend=backend, check_pars=True) + return + + # zero df parameter + with pytest.raises(ValueError): + sr.crps_tt(0.7, 0.0, 2.0, 0.5, backend=backend, check_pars=True) + return + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_tt(0.7, 4.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_tt(0.7, 4.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_tt( + 0.7, 4.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_tt(1.8, 6.9, backend=backend) + res = sr.crps_tt(1.8, 6.9, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_tt(1.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.285149 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_tt(-5.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.969029 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_tt(7.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 8.055997 + assert np.isclose(res, expected) + + # aligns with crps_t + res0 = sr.crps_t(1.8, 6.9, -3.0, 3.3, backend=backend) + res = sr.crps_tt(1.8, 6.9, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_tt( + np.array([1.8, -2.3]), 6.9, 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([5.753912, 9.123845]) + assert np.allclose(res, expected) + + +def test_crps_ct(backend): + if backend in ["jax", "torch", "tensorflow"]: + pytest.skip("Not implemented in jax, torch or tensorflow backends") + + ## test shape + + # one observation, multiple forecasts + res = sr.crps_ct( + 17, + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_ct( + np.random.uniform(-10, 10, (4, 3)), + 11.1, + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_ct( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative df parameter + with pytest.raises(ValueError): + sr.crps_ct(0.7, -1.7, 2.0, 0.5, backend=backend, check_pars=True) + return + + # zero df parameter + with pytest.raises(ValueError): + sr.crps_ct(0.7, 0.0, 2.0, 0.5, backend=backend, check_pars=True) + return + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_ct(0.7, 4.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_ct(0.7, 4.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.crps_ct( + 0.7, 4.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.crps_ct(1.8, 6.9, backend=backend) + res = sr.crps_ct(1.8, 6.9, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_ct(1.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.988432 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.crps_ct(-5.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 1.970734 + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.crps_ct(7.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 8.676605 + assert np.isclose(res, expected) + + # aligns with crps_t + res0 = sr.crps_t(1.8, 6.9, -3.0, 3.3, backend=backend) + res = sr.crps_ct(1.8, 6.9, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.crps_ct( + np.array([1.8, -2.3]), 6.9, 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([4.475883, 6.811192]) + assert np.allclose(res, expected) + + +def test_crps_hypergeometric(backend): + if backend == "torch": + pytest.skip("Currently not working in torch backend") + + ## test shape + + # one observation, multiple forecasts + res = sr.crps_hypergeometric( + 17, + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(1, 10, size=(4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_hypergeometric( + np.random.randint(0, 20, size=(4, 3)), + 13, + 4, + 7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_hypergeometric( + np.random.randint(0, 20, size=(4, 3)), + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(1, 10, size=(4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative m parameter + with pytest.raises(ValueError): + sr.crps_hypergeometric(7, m=-1, n=10, k=5, backend=backend, check_pars=True) + return + + # negative n parameter + with pytest.raises(ValueError): + sr.crps_hypergeometric(7, m=11, n=-2, k=5, backend=backend, check_pars=True) + return + + # negative k parameter + with pytest.raises(ValueError): + sr.crps_hypergeometric(7, m=11, n=10, k=-5, backend=backend, check_pars=True) + return + + # k > m + n + with pytest.raises(ValueError): + sr.crps_hypergeometric(7, m=11, n=10, k=25, backend=backend, check_pars=True) + return + + # non-integer m parameter + with pytest.raises(ValueError): + sr.crps_hypergeometric(7, m=11.1, n=10, k=5, backend=backend, check_pars=True) + return + + # non-integer n parameter + with pytest.raises(ValueError): + sr.crps_hypergeometric(7, m=11, n=10.8, k=5, backend=backend, check_pars=True) + return + + # non-integer k parameter + with pytest.raises(ValueError): + sr.crps_hypergeometric(7, m=11, n=10, k=4.3, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.crps_hypergeometric(5, 7, 13, 12) + expected = 0.4469742 + assert np.isclose(res, expected) + + # negative observation + res = sr.crps_hypergeometric(-5, 7, 13, 12) + expected = 8.615395 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_hypergeometric(np.array([2, 7.6, -2.1]), 7, 13, 3) + expected = np.array([0.5965259, 6.1351223, 2.7351223]) + assert np.allclose(res, expected) + + +def test_crps_laplace(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_laplace( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_laplace( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_laplace( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_laplace(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_laplace(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default location + res0 = sr.crps_laplace(-3, backend=backend) + res = sr.crps_laplace(-3, location=0, backend=backend) + assert np.isclose(res0, res) + + # default scale + res = sr.crps_laplace(-3, location=0, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.crps_laplace(-3 + 0.1, location=0.1, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to rescaling + res0 = sr.crps_laplace((-3 - 0.1) / 0.8, backend=backend) + res = sr.crps_laplace(-3, location=0.1, scale=0.8, backend=backend) + assert np.isclose(res0 * 0.8, res) + + # single observation + res = sr.crps_laplace(-2.9, 0.1, backend=backend) + expected = 2.29978707 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_laplace( + np.array([-2.9, 4.1]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([3.222098, 1.576516]) + assert np.allclose(res, expected) + + +def test_crps_logis(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_logistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_logistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_logistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_logistic(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_logistic(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default location + res0 = sr.crps_logistic(-3, backend=backend) + res = sr.crps_logistic(-3, location=0, backend=backend) + assert np.isclose(res0, res) + + # default scale + res = sr.crps_logistic(-3, location=0, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.crps_logistic(-3 + 0.1, location=0.1, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to rescaling + res0 = sr.crps_logistic((-3 - 0.1) / 0.8, backend=backend) + res = sr.crps_logistic(-3, location=0.1, scale=0.8, backend=backend) + assert np.isclose(res0 * 0.8, res) + + # single observation + res = sr.crps_logistic(17.1, 13.8, 3.3, backend=backend) + expected = 2.067527 + assert np.isclose(res, expected) + + res = sr.crps_logistic(3.1, 4.0, 0.5, backend=backend) + expected = 0.5529776 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_logistic( + np.array([-2.9, 4.1]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([4.295051, 1.429361]) + assert np.allclose(res, expected) + + +def test_crps_loglaplace(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_loglaplace( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_loglaplace( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 0.9, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_loglaplace( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_loglaplace(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_loglaplace(7, 4, 0.0, backend=backend, check_pars=True) + return + + # scale parameter larger than one + with pytest.raises(ValueError): + sr.crps_loglaplace(7, 4, 1.1, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.crps_loglaplace(7.1, 3.8, 0.3, backend=backend) + expected = 30.71884 + assert np.isclose(res, expected) + + # negative observation + res = sr.crps_loglaplace(-4.1, -3.8, 0.3, backend=backend) + expected = 4.118925 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_loglaplace( + np.array([2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([0.1, 0.9]), + backend=backend, + ) + expected = np.array([0.09159719, 1.95400976]) + assert np.allclose(res, expected) + + +def test_crps_loglogistic(backend): + if backend == "torch": + pytest.skip("Not implemented in torch backend") + + ## test shape + + # one observation, multiple forecasts + res = sr.crps_loglogistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_loglogistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 0.9, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_loglogistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_loglogistic(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_loglogistic(7, 4, 0.0, backend=backend, check_pars=True) + return + + # scale parameter larger than one + with pytest.raises(ValueError): + sr.crps_loglogistic(7, 4, 1.1, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.crps_loglogistic(7.1, 3.8, 0.3, backend=backend) + expected = 29.35987 + assert np.isclose(res, expected) + + # negative observation + res = sr.crps_loglogistic(-4.1, -3.8, 0.3, backend=backend) + expected = 4.118243 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_loglogistic( + np.array([2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([0.1, 0.9]), + backend=backend, + ) + expected = np.array([0.1256149, 3.1692104]) + assert np.allclose(res, expected) def test_crps_lognormal(backend): - obs = np.exp(np.random.randn(N)) - mulog = np.log(obs) + np.random.randn(N) * 0.1 - sigmalog = abs(np.random.randn(N)) * 0.3 + ## test shape - # non-negative values - res = sr.crps_lognormal(obs, mulog, sigmalog, backend=backend) - res = np.asarray(res) - assert not np.any(np.isnan(res)) - assert not np.any(res < 0.0) + # one observation, multiple forecasts + res = sr.crps_lognormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - # approx zero when perfect forecast - mulog = np.log(obs) + np.random.randn(N) * 1e-6 - sigmalog = abs(np.random.randn(N)) * 1e-6 - res = sr.crps_lognormal(obs, mulog, sigmalog, backend=backend) - res = np.asarray(res) - assert not np.any(np.isnan(res)) - assert not np.any(res - 0.0 > 0.0001) + # multiple observations, one forecast + res = sr.crps_lognormal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 2.9, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_lognormal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_lognormal(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_lognormal(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.crps_lognormal(7.1, 3.8, 0.3, backend=backend) + expected = 31.80341 + assert np.isclose(res, expected) + + # negative observation + res = sr.crps_lognormal(-4.1, -3.8, 0.3, backend=backend) + expected = 4.119469 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_lognormal( + np.array([2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([0.1, 0.9]), + backend=backend, + ) + expected = np.array([0.08472861, 1.63688414]) + assert np.allclose(res, expected) def test_crps_mixnorm(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.crps_mixnorm( + 17, + np.random.uniform(-5, 5, (4, 3, 5)), + np.random.uniform(0, 5, (4, 3, 5)), + np.random.uniform(0, 1, (4, 3, 5)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_mixnorm( + np.random.uniform(-5, 5, (4, 3)), + np.array([0.0, -2.9, 0.9, 3.2, 7.0]), + np.array([0.6, 2.9, 0.9, 3.2, 7.0]), + np.array([0.6, 2.9, 0.9, 0.2, 0.1]), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_mixnorm( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(-5, 5, (4, 3, 5)), + np.random.uniform(0, 5, (4, 3, 5)), + np.random.uniform(0, 1, (4, 3, 5)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_mixnorm( + 7, np.array([4, 2]), np.array([-1, 2]), backend=backend, check_pars=True + ) + return + + # negative weight parameter + with pytest.raises(ValueError): + sr.crps_mixnorm( + 7, + np.array([4, 2]), + np.array([1, 2]), + np.array([-1, 2]), + backend=backend, + check_pars=True, + ) + return + + ## test correctness + + # single observation obs, m, s, w = 0.3, [0.0, -2.9, 0.9], [0.5, 1.4, 0.7], [1 / 3, 1 / 3, 1 / 3] res = sr.crps_mixnorm(obs, m, s, w, backend=backend) expected = 0.4510451 assert np.isclose(res, expected) + # default weights res0 = sr.crps_mixnorm(obs, m, s, backend=backend) assert np.isclose(res, res0) + # non-constant weights w = [0.3, 0.1, 0.6] res = sr.crps_mixnorm(obs, m, s, w, backend=backend) expected = 0.2354619 assert np.isclose(res, expected) + # weights that do not sum to one + w = [8.3, 0.1, 4.6] + res = sr.crps_mixnorm(obs, m, s, w, backend=backend) + expected = 0.1678256 + assert np.isclose(res, expected) + + # m-axis argument obs = [-1.6, 0.3] m = [[0.0, -2.9], [0.6, 0.0], [-1.1, -2.3]] s = [[0.5, 1.7], [1.1, 0.7], [1.4, 1.5]] @@ -589,114 +2416,536 @@ def test_crps_mixnorm(backend): res2 = sr.crps_mixnorm(obs, m, s, backend=backend) assert np.allclose(res1, res2) + # check equality with normal distribution + obs = 1.6 + m, s, w = [1.8, 1.8], [2.3, 2.3], [0.6, 0.8] + res0 = sr.crps_normal(obs, m[0], s[0], backend=backend) + res = sr.crps_mixnorm(obs, m, s, w, backend=backend) + assert np.isclose(res0, res) + def test_crps_negbinom(backend): - # test exceptions + if backend in ["jax", "torch", "tensorflow"]: + pytest.skip("Not implemented in jax, torch or tensorflow backends") + + ## test shape + + # one observation, multiple forecasts + res = sr.crps_negbinom( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_negbinom( + np.random.uniform(0, 20, (4, 3)), + 12, + 0.2, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_negbinom( + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # prob and mu are both provided with pytest.raises(ValueError): sr.crps_negbinom(0.3, 7.0, 0.8, mu=7.3, backend=backend) - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") + # neither prob nor mu are provided + with pytest.raises(ValueError): + sr.crps_negbinom(0.3, 8, backend=backend) + + # negative size parameter + with pytest.raises(ValueError): + sr.crps_negbinom(4.3, -8, 0.8, backend=backend, check_pars=True) + + # negative mu parameter + with pytest.raises(ValueError): + sr.crps_negbinom(4.3, 8, mu=-0.8, backend=backend, check_pars=True) + + # negative prob parameter + with pytest.raises(ValueError): + sr.crps_negbinom(4.3, 8, -0.8, backend=backend, check_pars=True) + + # prob parameter larger than one + with pytest.raises(ValueError): + sr.crps_negbinom(4.3, 8, 1.8, backend=backend, check_pars=True) + + ## test correctness - # test correctness - obs, n, prob = 2.0, 7.0, 0.8 - res = sr.crps_negbinom(obs, n, prob, backend=backend) + # single observation + res = sr.crps_negbinom(2.0, 7.0, 0.8, backend=backend) expected = 0.3834322 assert np.isclose(res, expected) - obs, n, prob = 1.5, 2.0, 0.5 - res = sr.crps_negbinom(obs, n, prob, backend=backend) - expected = 0.462963 + # non-integer size + res = sr.crps_negbinom(1.5, 2.4, 0.5, backend=backend) + expected = 0.5423484 assert np.isclose(res, expected) - obs, n, prob = -1.0, 17.0, 0.1 - res = sr.crps_negbinom(obs, n, prob, backend=backend) + # negative observation + res = sr.crps_negbinom(-1.0, 17.0, 0.1, backend=backend) expected = 132.0942 assert np.isclose(res, expected) - obs, n, mu = 2.3, 11.0, 7.3 - res = sr.crps_negbinom(obs, n, mu=mu, backend=backend) + # mu given instead of prob + res = sr.crps_negbinom(2.3, 11.0, mu=7.3, backend=backend) expected = 3.149218 assert np.isclose(res, expected) + # multiple observations + res = sr.crps_negbinom(np.array([1.9, 7.3]), 7.0, 0.8, backend=backend) + expected = np.array([0.3827689, 4.7630042]) + assert np.allclose(res, expected) + def test_crps_normal(backend): - obs = np.random.randn(N) - mu = obs + np.random.randn(N) * 0.1 - sigma = abs(np.random.randn(N)) * 0.3 + ## test shape - # non-negative values - res = sr.crps_normal(obs, mu, sigma, backend=backend) - res = np.asarray(res) - assert not np.any(np.isnan(res)) - assert not np.any(res < 0.0) + # one observation, multiple forecasts + res = sr.crps_normal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - # approx zero when perfect forecast - mu = obs + np.random.randn(N) * 1e-6 - sigma = abs(np.random.randn(N)) * 1e-6 - res = sr.crps_normal(obs, mu, sigma, backend=backend) - res = np.asarray(res) + # multiple observations, one forecast + res = sr.crps_normal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) - assert not np.any(np.isnan(res)) - assert not np.any(res - 0.0 > 0.0001) + # multiple observations, multiple forecasts + res = sr.crps_normal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_normal(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_normal(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default mu + res0 = sr.crps_normal(-3, backend=backend) + res = sr.crps_normal(-3, mu=0, backend=backend) + assert np.isclose(res0, res) + + # default sigma + res = sr.crps_normal(-3, mu=0, sigma=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.crps_normal(-3 + 0.1, mu=0.1, sigma=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to rescaling + res0 = sr.crps_normal((-3 - 0.1) / 0.8, backend=backend) + res = sr.crps_normal(-3, mu=0.1, sigma=0.8, backend=backend) + assert np.isclose(res0 * 0.8, res) + + # single observation + res = sr.crps_normal(7.1, 3.8, 0.3, backend=backend) + expected = 3.130743 + assert np.isclose(res, expected) + + res = sr.crps_normal(3.1, 4.0, 0.5, backend=backend) + expected = 0.6321808 + assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_normal( + np.array([-2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([2.984174, 1.935862]) + assert np.allclose(res, expected) def test_crps_2pnormal(backend): - obs = np.random.randn(N) - mu = obs + np.random.randn(N) * 0.1 - sigma1 = abs(np.random.randn(N)) * 0.3 - sigma2 = abs(np.random.randn(N)) * 0.2 + ## test shape + + # one observation, multiple forecasts + res = sr.crps_2pnormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - res = sr.crps_2pnormal(obs, sigma1, sigma2, mu, backend=backend) - res = np.asarray(res) - assert not np.any(np.isnan(res)) - assert not np.any(res < 0.0) + # multiple observations, one forecast + res = sr.crps_2pnormal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + 2.8, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_2pnormal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale1 parameter + with pytest.raises(ValueError): + sr.crps_2pnormal(7, -0.1, 0.1, backend=backend, check_pars=True) + return + + # zero scale1 parameter + with pytest.raises(ValueError): + sr.crps_2pnormal(7, 0.0, backend=backend, check_pars=True) + return + + # negative scale2 parameter + with pytest.raises(ValueError): + sr.crps_2pnormal(7, 0.1, -2.1, backend=backend, check_pars=True) + return + + # zero scale2 parameter + with pytest.raises(ValueError): + sr.crps_2pnormal(7, 1.1, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default scale1 + res0 = sr.crps_2pnormal(-3, backend=backend) + res = sr.crps_2pnormal(-3, scale1=1.0, backend=backend) + assert np.isclose(res0, res) + + # default scale2 + res = sr.crps_2pnormal(-3, scale1=1.0, scale2=1.0, backend=backend) + assert np.isclose(res0, res) + + # default location + res = sr.crps_2pnormal(-3, scale1=1.0, scale2=1.0, location=0.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.crps_2pnormal(-3 + 0.1, 1.0, 1.0, location=0.1, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_2pnormal(7.1, 3.8, 0.3, -2.0, backend=backend) + expected = 10.5914 + assert np.isclose(res, expected) + + # check equivalence with standard normal distribution + res0 = sr.crps_normal(3.1, 4.0, 0.5, backend=backend) + res = sr.crps_2pnormal(3.1, 0.5, 0.5, 4.0, backend=backend) + assert np.isclose(res0, res) + + # multiple observations + res = sr.crps_2pnormal( + np.array([-2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([6.572028, 4.026696]) + assert np.allclose(res, expected) def test_crps_poisson(backend): - obs, mean = 1.0, 3.0 - res = sr.crps_poisson(obs, mean, backend=backend) + ## test shape + + # one observation, multiple forecasts + res = sr.crps_poisson( + 17, + np.random.uniform(0, 20, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_poisson( + np.random.uniform(-5, 10, (4, 3)), + 3.2, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_poisson( + np.random.uniform(-5, 10, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative mean parameter + with pytest.raises(ValueError): + sr.crps_poisson(7, -0.1, backend=backend, check_pars=True) + return + + # zero mean parameter + with pytest.raises(ValueError): + sr.crps_poisson(7, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.crps_poisson(1.0, 3.0, backend=backend) expected = 1.143447 assert np.isclose(res, expected) - obs, mean = 1.5, 2.3 - res = sr.crps_poisson(obs, mean, backend=backend) + res = sr.crps_poisson(1.5, 2.3, backend=backend) expected = 0.5001159 assert np.isclose(res, expected) - obs, mean = -1.0, 1.5 - res = sr.crps_poisson(obs, mean, backend=backend) + # negative observation + res = sr.crps_poisson(-1.0, 1.5, backend=backend) expected = 1.840259 assert np.isclose(res, expected) + # multiple observations + res = sr.crps_poisson(np.array([-2.9, 4.4]), np.array([10.1, 1.2]), backend=backend) + expected = np.array([11.218179, 2.630758]) + assert np.allclose(res, expected) + def test_crps_t(backend): if backend in ["jax", "torch", "tensorflow"]: pytest.skip("Not implemented in jax, torch or tensorflow backends") - obs, df, mu, sigma = 11.1, 5.2, 13.8, 2.3 - expected = 1.658226 - res = sr.crps_t(obs, df, mu, sigma, backend=backend) + ## test shape + + # one observation, multiple forecasts + res = sr.crps_t( + 17, + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_t( + np.random.uniform(-5, 20, (4, 3)), + 12, + 12, + 12, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_t( + np.random.uniform(-5, 20, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative df parameter + with pytest.raises(ValueError): + sr.crps_t(7, -4, backend=backend, check_pars=True) + return + + # zero df parameter + with pytest.raises(ValueError): + sr.crps_t(7, 0, backend=backend, check_pars=True) + return + + # negative scale parameter + with pytest.raises(ValueError): + sr.crps_t(7, 4, scale=-0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.crps_t(7, 4, scale=0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default location + res0 = sr.crps_t(-3, 2.1, backend=backend) + res = sr.crps_t(-3, 2.1, location=0, backend=backend) + assert np.isclose(res0, res) + + # default scale + res = sr.crps_t(-3, 2.1, location=0, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.crps_t(-3 + 0.1, 2.1, location=0.1, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to rescaling + res0 = sr.crps_t((-3 - 0.1) / 0.8, 2.1, backend=backend) + res = sr.crps_t(-3, 2.1, location=0.1, scale=0.8, backend=backend) + assert np.isclose(res0 * 0.8, res) + + # single observation + res = sr.crps_t(7.1, 4.2, 3.8, 0.3, backend=backend) + expected = 3.082766 assert np.isclose(res, expected) - obs, df = 0.7, 4.0 - expected = 0.4387929 - res = sr.crps_t(obs, df, backend=backend) + # negative observation + res = sr.crps_t(-3.1, 2.9, 4.0, 0.5, backend=backend) + expected = 6.682638 assert np.isclose(res, expected) + # multiple observations + res = sr.crps_t( + np.array([-2.9, 4.4]), + np.array([5.1, 19.4]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([3.183587, 1.914685]) + assert np.allclose(res, expected) + def test_crps_uniform(backend): - obs, min, max, lmass, umass = 0.3, -1.0, 2.1, 0.3, 0.1 - res = sr.crps_uniform(obs, min, max, lmass, umass, backend=backend) + ## test shape + + # one observation, multiple forecasts + res = sr.crps_uniform( + 7, + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(5, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_uniform( + np.random.uniform(0, 10, (4, 3)), + 1, + 7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.crps_uniform( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(5, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative lmass parameter + with pytest.raises(ValueError): + sr.crps_uniform(0.7, lmass=-0.1, backend=backend, check_pars=True) + return + + # lmass parameter greater than one + with pytest.raises(ValueError): + sr.crps_uniform(0.7, lmass=4, backend=backend, check_pars=True) + return + + # negative umass parameter + with pytest.raises(ValueError): + sr.crps_uniform(0.7, umass=-0.1, backend=backend, check_pars=True) + return + + # umass parameter greater than one + with pytest.raises(ValueError): + sr.crps_uniform(0.7, umass=4, backend=backend, check_pars=True) + return + + # lmass + umass >= 1 + with pytest.raises(ValueError): + sr.crps_uniform(0.7, lmass=0.2, umass=0.9, backend=backend, check_pars=True) + return + + # min >= max + with pytest.raises(ValueError): + sr.crps_uniform(0.7, min=2, max=1, backend=backend, check_pars=True) + return + + ## test correctness + + # default min + res0 = sr.crps_uniform(0.7, backend=backend) + res = sr.crps_uniform(0.7, min=0, backend=backend) + assert np.isclose(res0, res) + + # default sigma + res = sr.crps_uniform(0.7, min=0, max=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.crps_uniform(0.7 + 0.1, min=0.1, max=1.1, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.crps_uniform(0.3, -1.0, 2.1, 0.3, 0.1, backend=backend) expected = 0.3960968 assert np.isclose(res, expected) - obs, min, max, lmass = -17.9, -15.2, -8.7, 0.2 - res = sr.crps_uniform(obs, min, max, lmass, backend=backend) - expected = 4.086667 + res = sr.crps_uniform(2.2, 0.1, 3.1, backend=backend) + expected = 0.37 assert np.isclose(res, expected) - obs, min, max = 2.2, 0.1, 3.1 - res = sr.crps_uniform(obs, min, max, backend=backend) - expected = 0.37 + # observation outside of interval range + res = sr.crps_uniform(-17.9, -15.2, -8.7, 0.2, backend=backend) + expected = 4.086667 assert np.isclose(res, expected) + + # multiple observations + res = sr.crps_uniform( + np.array([-2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([10.1, 2.1]), + backend=backend, + ) + expected = np.array([7.0, 2.4]) + assert np.allclose(res, expected) From ce513d7d230a545c7e956c959a2e81446e23c95e Mon Sep 17 00:00:00 2001 From: sallen12 Date: Mon, 30 Mar 2026 18:29:41 +0200 Subject: [PATCH 08/11] add errors to crps functions when unavailable backend is used --- scoringrules/_crps.py | 29 ++++++++++++++++++++++++++++- tests/test_crps.py | 27 +++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/scoringrules/_crps.py b/scoringrules/_crps.py index 115b611..7fc4469 100644 --- a/scoringrules/_crps.py +++ b/scoringrules/_crps.py @@ -513,6 +513,8 @@ def crps_beta( >>> sr.crps_beta(0.3, 0.7, 1.1) 0.08501024366637236 """ + if backend == "torch": + raise TypeError("Torch backend is not supported for the Beta distribution.") if check_pars: B = backends.active if backend is None else backends[backend] a, b, lower, upper = map(B.asarray, (a, b, lower, upper)) @@ -580,6 +582,8 @@ def crps_binomial( >>> sr.crps_binomial(4, 10, 0.5) 0.5955772399902344 """ + if backend == "torch": + raise TypeError("Torch backend is not supported for the Binomial distribution.") if check_pars: B = backends.active if backend is None else backends[backend] prob = B.asarray(prob) @@ -1073,6 +1077,8 @@ def crps_gev( >>> sr.crps_gev(0.3, 0.1) 0.2924712413052034 """ + if backend == "torch": + raise TypeError("Torch backend is not supported for the GEV distribution.") if check_pars: B = backends.active if backend is None else backends[backend] scale, shape = map(B.asarray, (scale, shape)) @@ -1675,6 +1681,10 @@ def crps_gtct( >>> sr.crps_gtct(0.0, 2.0, 0.1, 0.4, -1.0, 1.0, 0.1, 0.1) 0.13997789333289662 """ + if backend in ["torch", "tensorflow", "jax"]: + raise TypeError( + "Torch, Tensorflow, and JAX backends are not supported for the Generalised t distribution." + ) if check_pars: B = backends.active if backend is None else backends[backend] scale, lmass, umass, lower, upper = map( @@ -1756,6 +1766,10 @@ def crps_tt( >>> sr.crps_tt(0.0, 2.0, 0.1, 0.4, -1.0, 1.0) 0.10323007471747117 """ + if backend in ["torch", "tensorflow", "jax"]: + raise TypeError( + "Torch, Tensorflow, and JAX backends are not supported for the Truncated t distribution." + ) if check_pars: B = backends.active if backend is None else backends[backend] scale, lower, upper = map(B.asarray, (scale, lower, upper)) @@ -1829,6 +1843,10 @@ def crps_ct( >>> sr.crps_ct(0.0, 2.0, 0.1, 0.4, -1.0, 1.0) 0.12672580744453948 """ + if backend in ["torch", "tensorflow", "jax"]: + raise TypeError( + "Torch, Tensorflow, and JAX backends are not supported for the Censored t distribution." + ) if check_pars: B = backends.active if backend is None else backends[backend] scale, lower, upper = map(B.asarray, (scale, lower, upper)) @@ -2198,6 +2216,10 @@ def crps_loglogistic( >>> sr.crps_loglogistic(3.0, 0.1, 0.9) 1.1329527730161177 """ + if backend == "torch": + raise TypeError( + "Torch backend is not supported for the log-logistic distribution." + ) if check_pars: B = backends.active if backend is None else backends[backend] scalelog = B.asarray(scalelog) @@ -2414,11 +2436,14 @@ def crps_negbinom( ValueError If both `prob` and `mu` are provided, or if neither is provided. """ + if backend in ["torch", "tensorflow", "jax"]: + raise TypeError( + "Torch, Tensorflow, and JAX backends are not supported for the Negative Binomial distribution." + ) if (prob is None and mu is None) or (prob is not None and mu is not None): raise ValueError( "Either `prob` or `mu` must be provided, but not both or neither." ) - if check_pars: B = backends.active if backend is None else backends[backend] if prob is not None: @@ -2704,6 +2729,8 @@ def crps_t( >>> sr.crps_t(0.0, 0.1, 0.4, 0.1) 0.07687151141732129 """ + if backend == "torch": + raise TypeError("Torch backend is not supported for the t distribution.") if check_pars: B = backends.active if backend is None else backends[backend] df, scale = map(B.asarray, (df, scale)) diff --git a/tests/test_crps.py b/tests/test_crps.py index 03d9e1e..7684fbc 100644 --- a/tests/test_crps.py +++ b/tests/test_crps.py @@ -109,10 +109,29 @@ def test_crps_beta(backend): ## test shape + # one observation, multiple forecasts + res = sr.crps_beta( + 0.1, + np.random.uniform(0, 3, (4, 3)), + np.random.uniform(0, 3, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.crps_beta( + np.random.uniform(0, 1, (4, 3)), + 2.4, + 0.5, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts res = sr.crps_beta( np.random.uniform(0, 1, (4, 3)), np.random.uniform(0, 3, (4, 3)), - 1.1, + np.random.uniform(0, 3, (4, 3)), backend=backend, ) assert res.shape == (4, 3) @@ -662,7 +681,7 @@ def test_crps_csg0(backend): def test_crps_gev(backend): if backend == "torch": - pytest.skip("`expi` not implemented in torch backend") + pytest.skip("Not implemented in torch backend") ## test shape @@ -2750,8 +2769,8 @@ def test_crps_poisson(backend): def test_crps_t(backend): - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") + if backend == "torch": + pytest.skip("Not implemented in torch backend") ## test shape From 355a406de84c74bdda095b91725e04074ca658d3 Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 31 Mar 2026 18:02:45 +0200 Subject: [PATCH 09/11] add parameter checks to log score tests --- scoringrules/_logs.py | 87 +- scoringrules/core/logarithmic.py | 9 +- tests/test_crps.py | 18 +- tests/test_logs.py | 2014 +++++++++++++++++++++++++----- 4 files changed, 1785 insertions(+), 343 deletions(-) diff --git a/scoringrules/_logs.py b/scoringrules/_logs.py index a817ea2..d8f7eb9 100644 --- a/scoringrules/_logs.py +++ b/scoringrules/_logs.py @@ -733,8 +733,8 @@ def logs_loglaplace( def logs_logistic( obs: "ArrayLike", - mu: "ArrayLike", - sigma: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, *, backend: "Backend" = None, check_pars: bool = False, @@ -747,9 +747,9 @@ def logs_logistic( ---------- obs : array_like Observed values. - mu : array_like + location : array_like Location parameter of the forecast logistic distribution. - sigma : array_like + scale : array_like Scale parameter of the forecast logistic distribution. backend : str The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. @@ -760,7 +760,7 @@ def logs_logistic( Returns ------- score : array_like - The LS for the Logistic(mu, sigma) forecasts given the observations. + The LS for the Logistic(location, scale) forecasts given the observations. Examples -------- @@ -769,18 +769,18 @@ def logs_logistic( """ if check_pars: B = backends.active if backend is None else backends[backend] - sigma = B.asarray(sigma) - if B.any(sigma <= 0): + scale = B.asarray(scale) + if B.any(scale <= 0): raise ValueError( - "`sigma` contains non-positive entries. The scale parameter of the logistic distribution must be positive." + "`scale` contains non-positive entries. The scale parameter of the logistic distribution must be positive." ) - return logarithmic.logistic(obs, mu, sigma, backend=backend) + return logarithmic.logistic(obs, location, scale, backend=backend) def logs_loglogistic( obs: "ArrayLike", - mulog: "ArrayLike", - sigmalog: "ArrayLike", + locationlog: "ArrayLike", + scalelog: "ArrayLike", *, backend: "Backend" = None, check_pars: bool = False, @@ -793,9 +793,9 @@ def logs_loglogistic( ---------- obs : array_like The observed values. - mulog : array_like + locationlog : array_like Location parameter of the log-logistic distribution. - sigmalog : array_like + scalelog : array_like Scale parameter of the log-logistic distribution. backend : str The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. @@ -806,7 +806,7 @@ def logs_loglogistic( Returns ------- score : array_like - The LS between obs and Loglogis(mulog, sigmalog). + The LS between obs and Loglogis(locationlog, scalelog). Examples -------- @@ -815,12 +815,12 @@ def logs_loglogistic( """ if check_pars: B = backends.active if backend is None else backends[backend] - sigmalog = B.asarray(sigmalog) - if B.any(sigmalog <= 0) or B.any(sigmalog >= 1): + scalelog = B.asarray(scalelog) + if B.any(scalelog <= 0) or B.any(scalelog >= 1): raise ValueError( - "`sigmalog` contains entries outside of the range (0, 1). The scale parameter of the log-logistic distribution must be between 0 and 1." + "`scalelog` contains entries outside of the range (0, 1). The scale parameter of the log-logistic distribution must be between 0 and 1." ) - return logarithmic.loglogistic(obs, mulog, sigmalog, backend=backend) + return logarithmic.loglogistic(obs, locationlog, scalelog, backend=backend) def logs_lognormal( @@ -874,7 +874,7 @@ def logs_mixnorm( m: "ArrayLike", s: "ArrayLike", w: "ArrayLike" = None, - mc_axis: "ArrayLike" = -1, + m_axis: "ArrayLike" = -1, *, backend: "Backend" = None, check_pars: bool = False, @@ -893,7 +893,7 @@ def logs_mixnorm( Standard deviations of the component normal distributions. w : array_like Non-negative weights assigned to each component. - mc_axis : int + m_axis : int The axis corresponding to the mixture components. Default is the last axis. backend : str The name of the backend used for computations. Defaults to ``numba`` if available, else ``numpy``. @@ -915,11 +915,11 @@ def logs_mixnorm( obs, m, s = map(B.asarray, (obs, m, s)) if w is None: - M: int = m.shape[mc_axis] + M: int = m.shape[m_axis] w = B.ones(m.shape) / M else: w = B.asarray(w) - w = w / B.sum(w, axis=mc_axis, keepdims=True) + w = w / B.sum(w, axis=m_axis, keepdims=True) if check_pars: if B.any(s <= 0): @@ -929,10 +929,10 @@ def logs_mixnorm( if B.any(w < 0): raise ValueError("`w` contains negative entries") - if mc_axis != -1: - m = B.moveaxis(m, mc_axis, -1) - s = B.moveaxis(s, mc_axis, -1) - w = B.moveaxis(w, mc_axis, -1) + if m_axis != -1: + m = B.moveaxis(m, m_axis, -1) + s = B.moveaxis(s, m_axis, -1) + w = B.moveaxis(w, m_axis, -1) return logarithmic.mixnorm(obs, m, s, w, backend=backend) @@ -981,11 +981,14 @@ def logs_negbinom( ValueError If both `prob` and `mu` are provided, or if neither is provided. """ + if backend in ["torch", "tensorflow", "jax"]: + raise TypeError( + "Torch, Tensorflow, and JAX backends are not supported for the Negative Binomial distribution." + ) if (prob is None and mu is None) or (prob is not None and mu is not None): raise ValueError( "Either `prob` or `mu` must be provided, but not both or neither." ) - if check_pars: B = backends.active if backend is None else backends[backend] if prob is not None: @@ -1015,8 +1018,8 @@ def logs_negbinom( def logs_normal( obs: "ArrayLike", - mu: "ArrayLike", - sigma: "ArrayLike", + mu: "ArrayLike" = 0.0, + sigma: "ArrayLike" = 1.0, *, backend: "Backend" = None, check_pars: bool = False, @@ -1061,9 +1064,9 @@ def logs_normal( def logs_2pnormal( obs: "ArrayLike", - scale1: "ArrayLike", - scale2: "ArrayLike", - location: "ArrayLike", + scale1: "ArrayLike" = 1.0, + scale2: "ArrayLike" = 1.0, + location: "ArrayLike" = 0.0, *, backend: "Backend" = None, check_pars: bool = False, @@ -1193,6 +1196,10 @@ def logs_t( >>> import scoringrules as sr >>> sr.logs_t(0.0, 0.1, 0.4, 0.1) """ + if backend in ["torch", "tensorflow", "jax"]: + raise TypeError( + "Torch, Tensorflow, and JAX backends are not supported for the t distribution." + ) if check_pars: B = backends.active if backend is None else backends[backend] df, scale = map(B.asarray, (df, scale)) @@ -1209,8 +1216,8 @@ def logs_t( def logs_tlogistic( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), *, @@ -1263,8 +1270,8 @@ def logs_tlogistic( def logs_tnormal( obs: "ArrayLike", - location: "ArrayLike", - scale: "ArrayLike", + location: "ArrayLike" = 0.0, + scale: "ArrayLike" = 1.0, lower: "ArrayLike" = float("-inf"), upper: "ArrayLike" = float("inf"), *, @@ -1360,6 +1367,10 @@ def logs_tt( >>> import scoringrules as sr >>> sr.logs_tt(0.0, 2.0, 0.1, 0.4, -1.0, 1.0) """ + if backend in ["torch", "tensorflow", "jax"]: + raise TypeError( + "Torch, Tensorflow, and JAX backends are not supported for the Truncated t distribution." + ) if check_pars: B = backends.active if backend is None else backends[backend] scale, lower, upper = map(B.asarray, (scale, lower, upper)) @@ -1378,8 +1389,8 @@ def logs_tt( def logs_uniform( obs: "ArrayLike", - min: "ArrayLike", - max: "ArrayLike", + min: "ArrayLike" = 0.0, + max: "ArrayLike" = 1.0, *, backend: "Backend" = None, check_pars: bool = False, diff --git a/scoringrules/core/logarithmic.py b/scoringrules/core/logarithmic.py index fffc1f6..42bb671 100644 --- a/scoringrules/core/logarithmic.py +++ b/scoringrules/core/logarithmic.py @@ -95,6 +95,8 @@ def binomial( obs = B.where(zind, B.nan, obs) prob = _binom_pdf(obs, n, prob, backend=backend) s = B.where(zind, float("inf"), -B.log(prob)) + ints = obs % 1 == 0 + s = B.where(ints, s, float("inf")) return s @@ -209,8 +211,13 @@ def hypergeometric( obs, m, n, k = map(B.asarray, (obs, m, n, k)) M = m + n N = k + zind = obs < 0.0 + obs = B.where(zind, B.nan, obs) prob = _hypergeo_pdf(obs, M, m, N, backend=backend) - return -B.log(prob) + s = B.where(zind, float("inf"), -B.log(prob)) + ints = obs % 1 == 0 + s = B.where(ints, s, float("inf")) + return s def laplace( diff --git a/tests/test_crps.py b/tests/test_crps.py index 7684fbc..e820e5a 100644 --- a/tests/test_crps.py +++ b/tests/test_crps.py @@ -181,12 +181,12 @@ def test_crps_beta(backend): # multiple forecasts res = sr.crps_beta( -3.0, - 0.7, - 1.1, + np.array([0.7, 2.0]), + np.array([1.1, 4.8]), lower=np.array([-5.0, -5.0]), upper=np.array([4.0, 4.0]), ) - expected = np.array([0.883206751, 0.883206751]) + expected = np.array([0.8832068, 0.4149867]) assert np.allclose(res, expected) @@ -1963,17 +1963,22 @@ def test_crps_hypergeometric(backend): ## test correctness # single observation - res = sr.crps_hypergeometric(5, 7, 13, 12) + res = sr.crps_hypergeometric(5, 7, 13, 12, backend=backend) expected = 0.4469742 assert np.isclose(res, expected) # negative observation - res = sr.crps_hypergeometric(-5, 7, 13, 12) + res = sr.crps_hypergeometric(-5, 7, 13, 12, backend=backend) expected = 8.615395 assert np.isclose(res, expected) + # non-integer observation + res = sr.crps_hypergeometric(5.6, 7, 13, 12, backend=backend) + expected = 0.9202868 + assert np.isclose(res, expected) + # multiple observations - res = sr.crps_hypergeometric(np.array([2, 7.6, -2.1]), 7, 13, 3) + res = sr.crps_hypergeometric(np.array([2, 7.6, -2.1]), 7, 13, 3, backend=backend) expected = np.array([0.5965259, 6.1351223, 2.7351223]) assert np.allclose(res, expected) @@ -2640,7 +2645,6 @@ def test_crps_2pnormal(backend): np.random.uniform(-10, 10, (4, 3)), np.random.uniform(-10, 10, (4, 3)), np.random.uniform(0, 5, (4, 3)), - np.random.uniform(0, 5, (4, 3)), backend=backend, ) assert res.shape == (4, 3) diff --git a/tests/test_logs.py b/tests/test_logs.py index 53857ea..321d9b4 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -68,452 +68,1872 @@ def test_clogs(backend): assert np.isclose(res, expected) -def test_beta(backend): - if backend == "torch": - pytest.skip("Not implemented in torch backend") +def test_logs_beta(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_beta( + 0.1, + np.random.uniform(0, 3, (4, 3)), + np.random.uniform(0, 3, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_beta( + np.random.uniform(0, 1, (4, 3)), + 2.4, + 0.5, + backend=backend, + ) + assert res.shape == (4, 3) + # multiple observations, multiple forecasts res = sr.logs_beta( - np.random.uniform(0, 1, (3, 3)), - np.random.uniform(0, 3, (3, 3)), - 1.1, + np.random.uniform(0, 1, (4, 3)), + np.random.uniform(0, 3, (4, 3)), + np.random.uniform(0, 3, (4, 3)), backend=backend, ) - assert res.shape == (3, 3) - assert not np.any(np.isnan(res)) + assert res.shape == (4, 3) + + ## test exceptions + + # lower bound smaller than upper bound + with pytest.raises(ValueError): + sr.logs_beta( + 0.3, 0.7, 1.1, lower=1.0, upper=0.0, backend=backend, check_pars=True + ) + return + + # lower bound equal to upper bound + with pytest.raises(ValueError): + sr.logs_beta( + 0.3, 0.7, 1.1, lower=0.5, upper=0.5, backend=backend, check_pars=True + ) + return + + # negative shape parameters + with pytest.raises(ValueError): + sr.logs_beta(0.3, -0.7, 1.1, backend=backend, check_pars=True) + return + + with pytest.raises(ValueError): + sr.logs_beta(0.3, 0.7, -1.1, backend=backend, check_pars=True) + return + + ## correctness tests - # TODO: investigate why JAX results are different from other backends - # (would fail test with smaller tolerance) - obs, shape1, shape2, lower, upper = 0.3, 0.7, 1.1, 0.0, 1.0 - res = sr.logs_beta(obs, shape1, shape2, lower, upper, backend=backend) + # single forecast + res = sr.logs_beta(0.3, 0.7, 1.1, backend=backend) expected = -0.04344567 - assert np.isclose(res, expected, atol=1e-4) + assert np.isclose(res, expected) - obs, shape1, shape2, lower, upper = -3.0, 0.9, 2.1, -5.0, 4.0 - res = sr.logs_beta(obs, shape1, shape2, lower, upper, backend=backend) - expected = 1.74193 + # custom bounds + res = sr.logs_beta(-3.0, 0.7, 1.1, lower=-5.0, upper=4.0, backend=backend) + expected = 2.053211 assert np.isclose(res, expected) - obs, shape1, shape2, lower, upper = -3.0, 0.9, 2.1, -2.0, 4.0 - res = sr.logs_beta(obs, shape1, shape2, lower, upper, backend=backend) + # observation outside bounds + res = sr.logs_beta(-3.0, 0.7, 1.1, lower=-1.3, upper=4.0, backend=backend) expected = float("inf") assert np.isclose(res, expected) + # multiple forecasts + res = sr.logs_beta( + -3.0, + np.array([0.7, 2.0]), + np.array([1.1, 4.8]), + lower=np.array([-5.0, -5.0]), + upper=np.array([4.0, 4.0]), + ) + expected = np.array([2.053211, 1.329823]) + assert np.allclose(res, expected) + + +def test_logs_binomial(backend): + if backend == "torch": + pytest.skip("Not implemented in torch backend") + + ## test shape + + # res = sr.logs_binomial( + # np.random.randint(0, 10, size=(4, 3)), + # np.full((4, 3), 10), + # np.random.uniform(0, 1, (4, 3)), + # backend=backend, + # ) + # assert res.shape == (4, 3) + # + + ones = np.ones(2) + k, n, p = 8, 10, 0.9 + s = sr.logs_binomial(k * ones, n, p, backend=backend) + assert np.isclose(s, np.array([1.64139182, 1.64139182])).all() + s = sr.logs_binomial(k * ones, n * ones, p, backend=backend) + assert np.isclose(s, np.array([1.64139182, 1.64139182])).all() + s = sr.logs_binomial(k * ones, n * ones, p * ones, backend=backend) + assert np.isclose(s, np.array([1.64139182, 1.64139182])).all() + s = sr.logs_binomial(k, n * ones, p * ones, backend=backend) + assert np.isclose(s, np.array([1.64139182, 1.64139182])).all() + s = sr.logs_binomial(k * ones, n, p * ones, backend=backend) + assert np.isclose(s, np.array([1.64139182, 1.64139182])).all() + + ## test exceptions + + # negative size parameter + with pytest.raises(ValueError): + sr.logs_binomial(7, -1, 0.9, backend=backend, check_pars=True) + return + + # zero size parameter + with pytest.raises(ValueError): + sr.logs_binomial(2.1, 0, 0.1, backend=backend, check_pars=True) + return + + # negative prob parameter + with pytest.raises(ValueError): + sr.logs_binomial(7, 15, -0.1, backend=backend, check_pars=True) + return -def test_binomial(backend): - # test correctness + # prob parameter greater than one + with pytest.raises(ValueError): + sr.logs_binomial(1, 10, 1.1, backend=backend, check_pars=True) + return + + # non-integer size parameter + with pytest.raises(ValueError): + sr.logs_binomial(4, 8.99, 0.5, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation res = sr.logs_binomial(8, 10, 0.9, backend=backend) expected = 1.641392 assert np.isclose(res, expected) + # negative observation res = sr.logs_binomial(-8, 10, 0.9, backend=backend) expected = float("inf") assert np.isclose(res, expected) - res = sr.logs_binomial(18, 30, 0.5, backend=backend) - expected = 2.518839 + # zero prob parameter + res = sr.logs_binomial(71, 212, 0, backend=backend) + expected = float("inf") assert np.isclose(res, expected) - -def test_exponential(backend): - obs, rate = 0.3, 0.1 - res = sr.logs_exponential(obs, rate, backend=backend) - expected = 2.332585 + # observation larger than size + res = sr.logs_binomial(18, 10, 0.9, backend=backend) + expected = float("inf") assert np.isclose(res, expected) - obs, rate = -1.3, 2.4 - res = sr.logs_exponential(obs, rate, backend=backend) + # non-integer observation + res = sr.logs_binomial(5.6, 10, 0.9, backend=backend) expected = float("inf") assert np.isclose(res, expected) - obs, rate = 0.0, 0.9 - res = sr.logs_exponential(obs, rate, backend=backend) - expected = 0.1053605 - assert np.isclose(res, expected) + # multiple observations + res = sr.logs_binomial(np.array([5.0, 17.2]), 10, 0.9, backend=backend) + expected = np.array([6.510299, float("inf")]) + assert np.allclose(res, expected) -def test_gamma(backend): - obs, shape, rate = 0.2, 1.1, 0.7 - expected = 0.6434138 +def test_logs_exponential(backend): + ## test shape - res = sr.logs_gamma(obs, shape, rate, backend=backend) - assert np.isclose(res, expected) + # one observation, multiple forecasts + res = sr.logs_exponential( + 7.2, + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - res = sr.logs_gamma(obs, shape, scale=1 / rate, backend=backend) - assert np.isclose(res, expected) + # multiple observations, one forecast + res = sr.logs_exponential( + np.random.uniform(0, 10, (4, 3)), + 7.2, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_exponential( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + # negative rate with pytest.raises(ValueError): - sr.logs_gamma(obs, shape, rate, scale=1 / rate, backend=backend) + sr.logs_exponential(3.2, -1, backend=backend, check_pars=True) return + # zero rate with pytest.raises(ValueError): - sr.logs_gamma(obs, shape, backend=backend) + sr.logs_exponential(3.2, 0, backend=backend, check_pars=True) return + ## test correctness -def test_gev(backend): - obs, xi, mu, sigma = 0.3, 0.7, 0.0, 1.0 - res0 = sr.logs_gev(obs, xi, backend=backend) - expected = 1.22455 - assert np.isclose(res0, expected) - - res = sr.logs_gev(obs, xi, mu, sigma, backend=backend) - assert np.isclose(res, res0) - - obs, xi, mu, sigma = 0.3, -0.7, 0.0, 1.0 - res = sr.logs_gev(obs, xi, mu, sigma, backend=backend) - expected = 0.8151139 - assert np.isclose(res, expected) - - obs, xi, mu, sigma = 0.3, 0.0, 0.0, 1.0 - res = sr.logs_gev(obs, xi, mu, sigma, backend=backend) - expected = 1.040818 + # single observation + res = sr.logs_exponential(3, 0.7, backend=backend) + expected = 2.456675 assert np.isclose(res, expected) - obs, xi, mu, sigma = -3.6, 0.7, 0.0, 1.0 - res = sr.logs_gev(obs, xi, mu, sigma, backend=backend) + # negative observation + res = sr.logs_exponential(-3, 0.7, backend=backend) expected = float("inf") assert np.isclose(res, expected) - obs, xi, mu, sigma = -3.6, 1.7, -4.0, 2.7 - res = sr.logs_gev(obs, xi, mu, sigma, backend=backend) - expected = 2.226233 - assert np.isclose(res, expected) + # multiple observations + res = sr.logs_exponential(np.array([5.6, 17.2]), 10.1, backend=backend) + expected = np.array([54.24746, 171.40746]) + assert np.allclose(res, expected) -def test_gpd(backend): - obs, shape, location, scale = 0.8, 0.9, 0.0, 1.0 - res0 = sr.logs_gpd(obs, shape, backend=backend) - expected = 1.144907 - assert np.isclose(res0, expected) +def test_logs_2pexponential(backend): + ## test shape - res = sr.logs_gpd(obs, shape, location, scale, backend=backend) - assert np.isclose(res0, res) + # one observation, multiple forecasts + res = sr.logs_2pexponential( + 6.2, + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(-2, 2, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_2pexponential( + np.random.uniform(-5, 5, (4, 3)), + 2.4, + 10.1, + np.random.uniform(-2, 2, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_2pexponential( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(-2, 2, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - obs = -0.8 - res = sr.logs_gpd(obs, shape, location, scale, backend=backend) - expected = float("inf") - assert np.isclose(res, expected) + ## test exceptions - obs, shape = 0.8, -0.9 - res = sr.logs_gpd(obs, shape, location, scale, backend=backend) - expected = 0.1414406 - assert np.isclose(res, expected) + # negative scale parameters + with pytest.raises(ValueError): + sr.logs_2pexponential(3.2, -1, 2, 2.1, backend=backend, check_pars=True) + return + + with pytest.raises(ValueError): + sr.logs_2pexponential(3.2, 1, -2, 2.1, backend=backend, check_pars=True) + return + + # zero scale parameters + with pytest.raises(ValueError): + sr.logs_2pexponential(3.2, 0, 2, 2.1, backend=backend, check_pars=True) + return + + with pytest.raises(ValueError): + sr.logs_2pexponential(3.2, 1, 0, 2.1, backend=backend, check_pars=True) + return - shape, scale = 0.0, 2.1 - res = sr.logs_gpd(obs, shape, location, scale, backend=backend) - expected = 1.12289 + ## test correctness + + # single observation + res = sr.logs_2pexponential(0.3, 0.1, 4.3, 0.0, backend=backend) + expected = 1.551372 assert np.isclose(res, expected) - obs, shape, location, scale = -17.4, 2.3, -21.0, 4.3 - res = sr.logs_gpd(obs, shape, location, scale, backend=backend) - expected = 2.998844 + # negative location + res = sr.logs_2pexponential(-20.8, 7.1, 2.0, -25.4, backend=backend) + expected = 4.508274 assert np.isclose(res, expected) + # multiple observations + res = sr.logs_2pexponential( + np.array([-1.2, 3.7]), 0.1, 4.3, np.array([-0.1, 0.1]), backend=backend + ) + expected = np.array([12.481605, 2.318814]) + assert np.allclose(res, expected) -def test_hypergeometric(backend): - res = sr.logs_hypergeometric(5, 7, 13, 12) - expected = 1.251525 - assert np.isclose(res, expected) + # check equivalence with laplace distribution + res = sr.logs_2pexponential(-20.8, 2.0, 2.0, -25.4, backend=backend) + res0 = sr.logs_laplace(-20.8, -25.4, 2.0, backend=backend) + assert np.isclose(res, res0) - res = sr.logs_hypergeometric(5 * np.ones((2, 2)), 7, 13, 12, backend=backend) - assert res.shape == (2, 2) - res = sr.logs_hypergeometric(5, 7 * np.ones((2, 2)), 13, 12, backend=backend) - assert res.shape == (2, 2) +def test_logs_gamma(backend): + ## test shape + # one observation, multiple forecasts + res = sr.logs_gamma( + 6.2, + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) -def test_logis(backend): - obs, mu, sigma = 17.1, 13.8, 3.3 - res = sr.logs_logistic(obs, mu, sigma, backend=backend) - expected = 2.820446 - assert np.isclose(res, expected) + # multiple observations, one forecast + res = sr.logs_gamma( + np.random.uniform(0, 10, (4, 3)), + 4.0, + 2.1, + backend=backend, + ) + assert res.shape == (4, 3) - obs, mu, sigma = 3.1, 4.0, 0.5 - res = sr.logs_logistic(obs, mu, sigma, backend=backend) - expected = 1.412808 - assert np.isclose(res, expected) + # multiple observations, multiple forecasts + res = sr.logs_gamma( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + ## test exceptions -def test_loglogistic(backend): - obs, mulog, sigmalog = 3.0, 0.1, 0.9 - res = sr.logs_loglogistic(obs, mulog, sigmalog, backend=backend) - expected = 2.672729 - assert np.isclose(res, expected) + # rate and scale provided + with pytest.raises(ValueError): + sr.logs_gamma(3.2, -1, rate=2, scale=0.4, backend=backend, check_pars=True) + return - obs, mulog, sigmalog = 0.0, 0.1, 0.9 - res = sr.logs_loglogistic(obs, mulog, sigmalog, backend=backend) - expected = float("inf") - assert np.isclose(res, expected) + # neither rate nor scale provided + with pytest.raises(ValueError): + sr.logs_gamma(3.2, -1, backend=backend, check_pars=True) + return - obs, mulog, sigmalog = 12.0, 12.1, 4.9 - res = sr.logs_loglogistic(obs, mulog, sigmalog, backend=backend) - expected = 6.299409 - assert np.isclose(res, expected) + # negative shape parameter + with pytest.raises(ValueError): + sr.logs_gamma(3.2, -1, 2, backend=backend, check_pars=True) + return + # negative rate/scale parameter + with pytest.raises(ValueError): + sr.logs_gamma(3.2, 1, -2, backend=backend, check_pars=True) + return -def test_lognormal(backend): - obs, mulog, sigmalog = 3.0, 0.1, 0.9 - res = sr.logs_lognormal(obs, mulog, sigmalog, backend=backend) - expected = 2.527762 - assert np.isclose(res, expected) + # zero shape parameter + with pytest.raises(ValueError): + sr.logs_gamma(3.2, 0.0, 2, backend=backend, check_pars=True) + return - obs, mulog, sigmalog = 0.0, 0.1, 0.9 - res = sr.logs_lognormal(obs, mulog, sigmalog, backend=backend) - expected = float("inf") - assert np.isclose(res, expected) + ## test correctness - obs, mulog, sigmalog = 12.0, 12.1, 4.9 - res = sr.logs_lognormal(obs, mulog, sigmalog, backend=backend) - expected = 6.91832 + # single observation + res = sr.logs_gamma(0.2, 1.1, 0.7, backend=backend) + expected = 0.6434138 assert np.isclose(res, expected) - -def test_exponential2(backend): - obs, location, scale = 8.3, 7.0, 1.2 - res = sr.logs_exponential2(obs, location, scale, backend=backend) - expected = 1.265655 + # scale instead of rate + res = sr.logs_gamma(0.2, 1.1, scale=1 / 0.7, backend=backend) assert np.isclose(res, expected) - obs, location, scale = -1.3, 2.4, 4.5 - res = sr.logs_exponential2(obs, location, scale, backend=backend) + # negative observation + res = sr.logs_gamma(-4.2, 1.1, rate=0.7, backend=backend) expected = float("inf") assert np.isclose(res, expected) - obs, location, scale = 0.9, 0.9, 2.0 - res = sr.logs_exponential2(obs, location, scale, backend=backend) - expected = 0.6931472 - assert np.isclose(res, expected) + # multiple observations + res = sr.logs_gamma(np.array([-1.2, 2.3]), 1.1, 0.7, backend=backend) + expected = np.array([float("inf"), 1.869179]) + assert np.allclose(res, expected) - res0 = sr.logs_exponential(obs, 1 / scale, backend=backend) - res = sr.logs_exponential2(obs, 0.0, scale, backend=backend) - assert np.isclose(res, res0) + # check equivalence with exponential distribution + res0 = sr.logs_exponential(3.1, 2, backend=backend) + res = sr.logs_gamma(3.1, 1, 2, backend=backend) + assert np.isclose(res0, res) -def test_2pexponential(backend): - obs, scale1, scale2, location = 0.3, 0.1, 4.3, 0.0 - res = sr.logs_2pexponential(obs, scale1, scale2, location, backend=backend) - expected = 1.551372 - assert np.isclose(res, expected) +def test_logs_gev(backend): + ## test shape - obs, scale1, scale2, location = -20.8, 7.1, 2.0, -15.4 - res = sr.logs_2pexponential(obs, scale1, scale2, location, backend=backend) - expected = 2.968838 - assert np.isclose(res, expected) + # one observation, multiple forecasts + res = sr.logs_gev( + 6.2, + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + 0.5, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_gev( + np.random.uniform(-5, 5, (4, 3)), + -4.9, + 2.3, + 0.7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_gev( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + 0.7, + backend=backend, + ) + assert res.shape == (4, 3) + ## test exceptions -def test_laplace(backend): - obs, location, scale = -3.0, 0.1, 0.9 - res = sr.logs_laplace(obs, backend=backend) - expected = 3.693147 - assert np.isclose(res, expected) + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_gev(0.7, 0.5, 2.0, -0.5, backend=backend, check_pars=True) + return - res = sr.logs_laplace(obs, location, backend=backend) - expected = 3.793147 - assert np.isclose(res, expected) + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_gev(0.7, 0.5, 2.0, 0.0, backend=backend, check_pars=True) + return - res = sr.logs_laplace(obs, location, scale, backend=backend) - expected = 4.032231 - assert np.isclose(res, expected) + ## test correctness + # test default location + res0 = sr.logs_gev(0.3, 0.0, backend=backend) + res = sr.logs_gev(0.3, 0.0, 0.0, backend=backend) + assert np.isclose(res0, res) -def test_loglaplace(backend): - obs, locationlog, scalelog = 3.0, 0.1, 0.9 - res = sr.logs_loglaplace(obs, locationlog, scalelog, backend=backend) - expected = 2.795968 - assert np.isclose(res, expected) + # test default scale + res = sr.logs_gev(0.3, 0.0, 0.0, 1.0, backend=backend) + assert np.isclose(res0, res) - obs, locationlog, scalelog = 0.0, 0.1, 0.9 - res = sr.logs_loglaplace(obs, locationlog, scalelog, backend=backend) - expected = float("inf") + # test invariance to shift + mu = 0.1 + res = sr.logs_gev(0.3 + mu, 0.0, mu, 1.0, backend=backend) + assert np.isclose(res0, res) + + # positive shape parameter + res = sr.logs_gev(0.3, 0.7, backend=backend) + expected = 1.22455 assert np.isclose(res, expected) - obs, locationlog, scalelog = 12.0, 12.1, 4.9 - res = sr.logs_loglaplace(obs, locationlog, scalelog, backend=backend) - expected = 6.729553 + # negative shape parameter + res = sr.logs_gev(0.3, -0.7, backend=backend) + expected = 0.8151139 assert np.isclose(res, expected) + # zero shape parameter + res = sr.logs_gev(0.3, 0.0, backend=backend) + expected = 1.040818 + assert np.isclose(res, expected) -def test_mixnorm(backend): - obs, m, s, w = 0.3, [0.0, -2.9, 0.9], [0.5, 1.4, 0.7], [1 / 3, 1 / 3, 1 / 3] - res = sr.logs_mixnorm(obs, m, s, w, backend=backend) - expected = 1.019742 + # shape parameter greater than one + res = sr.logs_gev(0.7, 1.5, 0.0, 0.5, backend=backend) + expected = 1.662878 assert np.isclose(res, expected) - res0 = sr.logs_mixnorm(obs, m, s, backend=backend) - assert np.isclose(res, res0) + # multiple observations + res = sr.logs_gev(np.array([0.9, -2.1]), -0.7, 0, 1.4, backend=backend) + expected = np.array([1.018374, 2.817275]) + assert np.allclose(res, expected) - w = [0.3, 0.1, 0.6] - res = sr.logs_mixnorm(obs, m, s, w, backend=backend) - expected = 0.8235977 - assert np.isclose(res, expected) - obs = [-1.6, 0.3] - m = [[0.0, -2.9], [0.6, 0.0], [-1.1, -2.3]] - s = [[0.5, 1.7], [1.1, 0.7], [1.4, 1.5]] - res1 = sr.logs_mixnorm(obs, m, s, mc_axis=0, backend=backend) +def test_logs_gpd(backend): + ## test shape - m = [[0.0, 0.6, -1.1], [-2.9, 0.0, -2.3]] - s = [[0.5, 1.1, 1.4], [1.7, 0.7, 1.5]] - res2 = sr.logs_mixnorm(obs, m, s, backend=backend) - assert np.allclose(res1, res2) + # one observation, multiple forecasts + res = sr.logs_gpd( + 6.2, + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_gpd( + np.random.uniform(-5, 5, (4, 3)), + -4.9, + 2.3, + 0.7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_gpd( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(-10, 1, (4, 3)), + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + ## test exceptions -def test_negbinom(backend): - if backend in ["jax", "torch"]: - pytest.skip("Not implemented in jax or torch backends") + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_gpd(0.7, 0.5, 2.0, -0.5, backend=backend, check_pars=True) + return - # test exceptions + # zero scale parameter with pytest.raises(ValueError): - sr.logs_negbinom(1, 1.1, 0.1, mu=0.1, backend=backend) + sr.logs_gpd(0.7, 0.5, 2.0, 0.0, backend=backend, check_pars=True) return - # TODO: investigate why JAX and torch results are different from other backends - # (they fail the test) - obs, n, prob = 2.0, 7.0, 0.8 - res = sr.logs_negbinom(obs, n, prob, backend=backend) - expected = 1.448676 + ## test correctness + + # test default location + res0 = sr.logs_gpd(0.3, 0.0, backend=backend) + res = sr.logs_gpd(0.3, 0.0, 0.0, backend=backend) + assert np.isclose(res0, res) + + # test default scale + res = sr.logs_gpd(0.3, 0.0, 0.0, 1.0, backend=backend) + assert np.isclose(res0, res) + + # test invariance to shift + res = sr.logs_gpd(0.3 + 0.1, 0.0, 0.1, 1.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_gpd(0.3, 0.9, backend=backend) + expected = 0.5045912 assert np.isclose(res, expected) - obs, n, prob = 1.5, 2.0, 0.5 - res = sr.logs_negbinom(obs, n, prob, backend=backend) + # negative observation + res = sr.logs_gpd(-0.3, 0.9, backend=backend) expected = float("inf") assert np.isclose(res, expected) - obs, n, prob = -1.0, 17.0, 0.1 - res = sr.logs_negbinom(obs, n, prob, backend=backend) - expected = float("inf") + # negative shape parameter + res = sr.logs_gpd(0.3, -0.9, backend=backend) + expected = 0.03496786 assert np.isclose(res, expected) - obs, n, mu = 2.0, 11.0, 7.3 - res = sr.logs_negbinom(obs, n, mu=mu, backend=backend) - expected = 3.247462 + # shape parameter greater than one + res = sr.logs_gpd(0.7, 1.5, 0.0, 0.5, backend=backend) + expected = 1.192523 assert np.isclose(res, expected) + # multiple observations + res = sr.logs_gpd(np.array([0.9, -2.1]), -0.7, 0, 1.4, backend=backend) + expected = np.array([0.5926881, float("inf")]) + assert np.allclose(res, expected) -def test_normal(backend): - res = sr.logs_normal(0.0, 0.1, 0.1, backend=backend) - assert np.isclose(res, -0.8836466, rtol=1e-5) +def test_logs_tlogis(backend): + ## test shape -def test_2pnormal(backend): - obs, scale1, scale2, location = 29.1, 4.6, 1.3, 27.9 - res = sr.logs_2pnormal(obs, scale1, scale2, location, backend=backend) - expected = 2.426779 - assert np.isclose(res, expected) + # one observation, multiple forecasts + res = sr.logs_tlogistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - obs, scale1, scale2, location = -2.2, 1.6, 3.3, -1.9 - res = sr.logs_2pnormal(obs, scale1, scale2, location, backend=backend) - expected = 1.832605 - assert np.isclose(res, expected) + # multiple observations, one forecast + res = sr.logs_tlogistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) - obs, scale, location = 1.5, 4.5, 5.4 - res0 = sr.logs_normal(obs, location, scale, backend=backend) - res = sr.logs_2pnormal(obs, scale, scale, location, backend=backend) - assert np.isclose(res, res0) - obs, mu, sigma = 17.1, 13.8, 3.3 - res = sr.logs_normal(obs, mu, sigma, backend=backend) - expected = 2.612861 - assert np.isclose(res, expected) + # multiple observations, multiple forecasts + res = sr.logs_tlogistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - obs, mu, sigma = 3.1, 4.0, 0.5 - res = sr.logs_normal(obs, mu, sigma, backend=backend) - expected = 1.845791 - assert np.isclose(res, expected) + ## test exceptions + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_tlogistic(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return -def test_poisson(backend): - obs, mean = 1.0, 3.0 - res = sr.logs_poisson(obs, mean, backend=backend) - expected = 1.901388 + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_tlogistic(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.logs_tlogistic( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.logs_tlogistic(1.8, backend=backend) + res = sr.logs_tlogistic(1.8, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_tlogistic(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.485943 assert np.isclose(res, expected) - obs, mean = 1.5, 2.3 - res = sr.logs_poisson(obs, mean, backend=backend) + # observation below lower bound + res = sr.logs_tlogistic(-5.8, -3.0, 3.3, -5.0, 4.7, backend=backend) expected = float("inf") assert np.isclose(res, expected) - obs, mean = -1.0, 1.5 - res = sr.logs_poisson(obs, mean, backend=backend) + # observation above upper bound + res = sr.logs_tlogistic(7.8, -3.0, 3.3, -5.0, 4.7, backend=backend) expected = float("inf") assert np.isclose(res, expected) + # aligns with logs_logistic + res0 = sr.logs_logistic(1.8, -3.0, 3.3, backend=backend) + res = sr.logs_tlogistic(1.8, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.logs_tlogistic( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([3.631568, 3.778196]) + assert np.allclose(res, expected) -def test_t(backend): - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") - obs, df, mu, sigma = 11.1, 5.2, 13.8, 2.3 - res = sr.logs_t(obs, df, mu, sigma, backend=backend) - expected = 2.528398 - assert np.isclose(res, expected) +def test_logs_tnormal(backend): + ## test shape - obs, df = 0.7, 4.0 - res = sr.logs_t(obs, df, backend=backend) - expected = 1.269725 - assert np.isclose(res, expected) + # one observation, multiple forecasts + res = sr.logs_tnormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + # multiple observations, one forecast + res = sr.logs_tnormal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) -def test_tlogis(backend): - obs, location, scale, lower, upper = 4.9, 3.5, 2.3, 0.0, 20.0 - res = sr.logs_tlogistic(obs, location, scale, lower, upper, backend=backend) - expected = 2.11202 - assert np.isclose(res, expected) + # multiple observations, multiple forecasts + res = sr.logs_tnormal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) - # aligns with logs_logistic - res0 = sr.logs_logistic(obs, location, scale, backend=backend) - res = sr.logs_tlogistic(obs, location, scale, backend=backend) - assert np.isclose(res, res0) + ## test exceptions + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_tnormal(0.7, 2.0, -0.5, backend=backend, check_pars=True) + return -def test_tnormal(backend): - obs, location, scale, lower, upper = 4.2, 2.9, 2.2, 1.5, 17.3 - res = sr.logs_tnormal(obs, location, scale, lower, upper, backend=backend) - expected = 1.577806 - assert np.isclose(res, expected) + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_tnormal(0.7, 2.0, 0.0, backend=backend, check_pars=True) + return - obs, location, scale, lower, upper = -1.0, 2.9, 2.2, 1.5, 17.3 - res = sr.logs_tnormal(obs, location, scale, lower, upper, backend=backend) - expected = float("inf") - assert np.isclose(res, expected) + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.logs_tnormal( + 0.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return - # aligns with logs_normal - res0 = sr.logs_normal(obs, location, scale, backend=backend) - res = sr.logs_tnormal(obs, location, scale, backend=backend) - assert np.isclose(res, res0) + ## test correctness + # default values + res0 = sr.logs_tnormal(1.8, backend=backend) + res = sr.logs_tnormal(1.8, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) -def test_tt(backend): - if backend in ["jax", "torch", "tensorflow"]: - pytest.skip("Not implemented in jax, torch or tensorflow backends") + # single observation + res = sr.logs_tnormal(1.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.839353 + assert np.isclose(res, expected) - obs, df, location, scale, lower, upper = 1.9, 2.9, 3.1, 4.2, 1.5, 17.3 - res = sr.logs_tt(obs, df, location, scale, lower, upper, backend=backend) - expected = 2.002856 + # observation below lower bound + res = sr.logs_tnormal(-5.8, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = float("inf") assert np.isclose(res, expected) - obs, df, location, scale, lower, upper = -1.0, 2.9, 3.1, 4.2, 1.5, 17.3 - res = sr.logs_tt(obs, df, location, scale, lower, upper, backend=backend) + # observation above upper bound + res = sr.logs_tnormal(7.8, -3.0, 3.3, -5.0, 4.7, backend=backend) expected = float("inf") assert np.isclose(res, expected) - # aligns with logs_t - res0 = sr.logs_t(obs, df, location, scale, backend=backend) - res = sr.logs_tt(obs, df, location, scale, backend=backend) + # aligns with logs_normal + res0 = sr.logs_normal(1.8, -3.0, 3.3, backend=backend) + res = sr.logs_tnormal(1.8, -3.0, 3.3, backend=backend) assert np.isclose(res, res0) + # multiple observations + res = sr.logs_tnormal( + np.array([1.8, -2.3]), 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([3.415483, 3.726619]) + assert np.allclose(res, expected) -def test_uniform(backend): - obs, min, max = 0.3, -1.0, 2.1 - res = sr.logs_uniform(obs, min, max, backend=backend) - expected = 1.131402 - assert np.isclose(res, expected) - - obs, min, max = -17.9, -15.2, -8.7 - res = sr.logs_uniform(obs, min, max, backend=backend) - expected = float("inf") - assert np.isclose(res, expected) - obs, min, max = 0.1, 0.1, 3.1 - res = sr.logs_uniform(obs, min, max, backend=backend) +def test_logs_tt(backend): + if backend in ["jax", "torch", "tensorflow"]: + pytest.skip("Not implemented in jax, torch or tensorflow backends") + + ## test shape + + # one observation, multiple forecasts + res = sr.logs_tt( + 17, + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_tt( + np.random.uniform(-10, 10, (4, 3)), + 11.1, + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_tt( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative df parameter + with pytest.raises(ValueError): + sr.logs_tt(0.7, -1.7, 2.0, 0.5, backend=backend, check_pars=True) + return + + # zero df parameter + with pytest.raises(ValueError): + sr.logs_tt(0.7, 0.0, 2.0, 0.5, backend=backend, check_pars=True) + return + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_tt(0.7, 4.7, 2.0, -0.5, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_tt(0.7, 4.7, 2.0, 0.0, backend=backend, check_pars=True) + return + + # lower bound larger than upper bound + with pytest.raises(ValueError): + sr.logs_tt( + 0.7, 4.7, 2.0, 0.4, lower=-1, upper=-4, backend=backend, check_pars=True + ) + return + + ## test correctness + + # default values + res0 = sr.logs_tt(1.8, 6.9, backend=backend) + res = sr.logs_tt(1.8, 6.9, 0.0, 1.0, -np.inf, np.inf, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_tt(1.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = 2.836673 + assert np.isclose(res, expected) + + # observation below lower bound + res = sr.logs_tt(-5.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # observation above upper bound + res = sr.logs_tt(7.8, 6.9, -3.0, 3.3, -5.0, 4.7, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # aligns with logs_t + res0 = sr.logs_t(1.8, 6.9, -3.0, 3.3, backend=backend) + res = sr.logs_tt(1.8, 6.9, -3.0, 3.3, backend=backend) + assert np.isclose(res, res0) + + # multiple observations + res = sr.logs_tt( + np.array([1.8, -2.3]), 6.9, 9.1, 11.1, -4.0, np.inf, backend=backend + ) + expected = np.array([3.453054, 3.774802]) + assert np.allclose(res, expected) + + +def test_logs_hypergeometric(backend): + if backend == "torch": + pytest.skip("Currently not working in torch backend") + + ## test shape + + # one observation, multiple forecasts + res = sr.logs_hypergeometric( + 17, + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(1, 10, size=(4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_hypergeometric( + np.random.randint(0, 20, size=(4, 3)), + 13, + 4, + 7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_hypergeometric( + np.random.randint(0, 20, size=(4, 3)), + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(10, 20, size=(4, 3)), + np.random.randint(1, 10, size=(4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative m parameter + with pytest.raises(ValueError): + sr.logs_hypergeometric(7, m=-1, n=10, k=5, backend=backend, check_pars=True) + return + + # negative n parameter + with pytest.raises(ValueError): + sr.logs_hypergeometric(7, m=11, n=-2, k=5, backend=backend, check_pars=True) + return + + # negative k parameter + with pytest.raises(ValueError): + sr.logs_hypergeometric(7, m=11, n=10, k=-5, backend=backend, check_pars=True) + return + + # k > m + n + with pytest.raises(ValueError): + sr.logs_hypergeometric(7, m=11, n=10, k=25, backend=backend, check_pars=True) + return + + # non-integer m parameter + with pytest.raises(ValueError): + sr.logs_hypergeometric(7, m=11.1, n=10, k=5, backend=backend, check_pars=True) + return + + # non-integer n parameter + with pytest.raises(ValueError): + sr.logs_hypergeometric(7, m=11, n=10.8, k=5, backend=backend, check_pars=True) + return + + # non-integer k parameter + with pytest.raises(ValueError): + sr.logs_hypergeometric(7, m=11, n=10, k=4.3, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.logs_hypergeometric(5, 7, 13, 12, backend=backend) + expected = 1.251525 + assert np.isclose(res, expected) + + # negative observation + res = sr.logs_hypergeometric(-5, 7, 13, 12, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # non-integer observation + res = sr.logs_hypergeometric(5.6, 7, 13, 12, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_hypergeometric(np.array([2, 7.6, -2.1]), 7, 13, 3, backend=backend) + expected = np.array([1.429312, float("inf"), float("inf")]) + assert np.allclose(res, expected) + + +def test_logs_laplace(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_laplace( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_laplace( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_laplace( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_laplace(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_laplace(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default location + res0 = sr.logs_laplace(-3, backend=backend) + res = sr.logs_laplace(-3, location=0, backend=backend) + assert np.isclose(res0, res) + + # default scale + res = sr.logs_laplace(-3, location=0, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.logs_laplace(-3 + 0.1, location=0.1, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_laplace(-2.9, 0.1, backend=backend) + expected = 3.693147 + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_laplace( + np.array([-2.9, 4.1]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([3.401722, 2.792135]) + assert np.allclose(res, expected) + + +def test_logs_logis(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_logistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_logistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_logistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_logistic(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_logistic(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default location + res0 = sr.logs_logistic(-3, backend=backend) + res = sr.logs_logistic(-3, location=0, backend=backend) + assert np.isclose(res0, res) + + # default scale + res = sr.logs_logistic(-3, location=0, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.logs_logistic(-3 + 0.1, location=0.1, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_logistic(17.1, 13.8, 3.3, backend=backend) + expected = 2.820446 + assert np.isclose(res, expected) + + res = sr.logs_logistic(3.1, 4.0, 0.5, backend=backend) + expected = 1.412808 + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_logistic( + np.array([-2.9, 4.1]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([3.737788, 2.373456]) + assert np.allclose(res, expected) + + +def test_logs_loglaplace(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_loglaplace( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_loglaplace( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 0.9, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_loglaplace( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_loglaplace(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_loglaplace(7, 4, 0.0, backend=backend, check_pars=True) + return + + # scale parameter larger than one + with pytest.raises(ValueError): + sr.logs_loglaplace(7, 4, 1.1, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.logs_loglaplace(7.1, 3.8, 0.3, backend=backend) + expected = 7.582287 + assert np.isclose(res, expected) + + # negative observation + res = sr.logs_loglaplace(-4.1, -3.8, 0.3, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_loglaplace( + np.array([2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([0.1, 0.9]), + backend=backend, + ) + expected = np.array([-0.1918345, 2.4231639]) + assert np.allclose(res, expected) + + +def test_logs_loglogistic(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_loglogistic( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_loglogistic( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 0.9, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_loglogistic( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_loglogistic(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_loglogistic(7, 4, 0.0, backend=backend, check_pars=True) + return + + # scale parameter larger than one + with pytest.raises(ValueError): + sr.logs_loglogistic(7, 4, 1.1, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.logs_loglogistic(7.1, 3.8, 0.3, backend=backend) + expected = 6.893475 + assert np.isclose(res, expected) + + # negative observation + res = sr.logs_loglogistic(-4.1, -3.8, 0.3, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_loglogistic( + np.array([2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([0.1, 0.9]), + backend=backend, + ) + expected = np.array([0.1793931, 2.7936654]) + assert np.allclose(res, expected) + + +def test_logs_lognormal(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_lognormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_lognormal( + np.random.uniform(0, 10, (4, 3)), + 3.2, + 2.9, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_lognormal( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_lognormal(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_lognormal(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.logs_lognormal(7.1, 3.8, 0.3, backend=backend) + expected = 20.48201 + assert np.isclose(res, expected) + + # negative observation + res = sr.logs_lognormal(-4.1, -3.8, 0.3, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_lognormal( + np.array([2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([0.1, 0.9]), + backend=backend, + ) + expected = np.array([-0.2566692, 2.3577601]) + assert np.allclose(res, expected) + + +def test_logs_mixnorm(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_mixnorm( + 17, + np.random.uniform(-5, 5, (4, 3, 5)), + np.random.uniform(0, 5, (4, 3, 5)), + np.random.uniform(0, 1, (4, 3, 5)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_mixnorm( + np.random.uniform(-5, 5, (4, 3)), + np.array([0.0, -2.9, 0.9, 3.2, 7.0]), + np.array([0.6, 2.9, 0.9, 3.2, 7.0]), + np.array([0.6, 2.9, 0.9, 0.2, 0.1]), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_mixnorm( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(-5, 5, (4, 3, 5)), + np.random.uniform(0, 5, (4, 3, 5)), + np.random.uniform(0, 1, (4, 3, 5)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_mixnorm( + 7, np.array([4, 2]), np.array([-1, 2]), backend=backend, check_pars=True + ) + return + + # negative weight parameter + with pytest.raises(ValueError): + sr.logs_mixnorm( + 7, + np.array([4, 2]), + np.array([1, 2]), + np.array([-1, 2]), + backend=backend, + check_pars=True, + ) + return + + ## test correctness + + # single observation + obs, m, s, w = 0.3, [0.0, -2.9, 0.9], [0.5, 1.4, 0.7], [1 / 3, 1 / 3, 1 / 3] + res = sr.logs_mixnorm(obs, m, s, w, backend=backend) + expected = 1.019742 + assert np.isclose(res, expected) + + # default weights + res0 = sr.logs_mixnorm(obs, m, s, backend=backend) + assert np.isclose(res, res0) + + # non-constant weights + w = [0.3, 0.1, 0.6] + res = sr.logs_mixnorm(obs, m, s, w, backend=backend) + expected = 0.8235977 + assert np.isclose(res, expected) + + # weights that do not sum to one + w = [8.3, 0.1, 4.6] + res = sr.logs_mixnorm(obs, m, s, w, backend=backend) + expected = 0.5703479 + assert np.isclose(res, expected) + + # m-axis argument + obs = [-1.6, 0.3] + m = [[0.0, -2.9], [0.6, 0.0], [-1.1, -2.3]] + s = [[0.5, 1.7], [1.1, 0.7], [1.4, 1.5]] + res1 = sr.logs_mixnorm(obs, m, s, m_axis=0, backend=backend) + + m = [[0.0, 0.6, -1.1], [-2.9, 0.0, -2.3]] + s = [[0.5, 1.1, 1.4], [1.7, 0.7, 1.5]] + res2 = sr.logs_mixnorm(obs, m, s, backend=backend) + assert np.allclose(res1, res2) + + # check equality with normal distribution + obs = 1.6 + m, s, w = [1.8, 1.8], [2.3, 2.3], [0.6, 0.8] + res0 = sr.logs_normal(obs, m[0], s[0], backend=backend) + res = sr.logs_mixnorm(obs, m, s, w, backend=backend) + assert np.isclose(res0, res) + + +def test_logs_negbinom(backend): + if backend in ["jax", "torch", "tensorflow"]: + pytest.skip("Not implemented in jax, torch or tensorflow backends") + + ## test shape + + # one observation, multiple forecasts + res = sr.logs_negbinom( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_negbinom( + np.random.uniform(0, 20, (4, 3)), + 12, + 0.2, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_negbinom( + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 1, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # prob and mu are both provided + with pytest.raises(ValueError): + sr.logs_negbinom(0.3, 7.0, 0.8, mu=7.3, backend=backend) + + # neither prob nor mu are provided + with pytest.raises(ValueError): + sr.logs_negbinom(0.3, 8, backend=backend) + + # negative size parameter + with pytest.raises(ValueError): + sr.logs_negbinom(4.3, -8, 0.8, backend=backend, check_pars=True) + + # negative mu parameter + with pytest.raises(ValueError): + sr.logs_negbinom(4.3, 8, mu=-0.8, backend=backend, check_pars=True) + + # negative prob parameter + with pytest.raises(ValueError): + sr.logs_negbinom(4.3, 8, -0.8, backend=backend, check_pars=True) + + # prob parameter larger than one + with pytest.raises(ValueError): + sr.logs_negbinom(4.3, 8, 1.8, backend=backend, check_pars=True) + + ## test correctness + + # single observation + res = sr.logs_negbinom(2.0, 7.0, 0.8, backend=backend) + expected = 1.448676 + assert np.isclose(res, expected) + + # non-integer observation + res = sr.logs_negbinom(1.5, 2.4, 0.5, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # negative observation + res = sr.logs_negbinom(-1.0, 17.0, 0.1, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # mu given instead of prob + res = sr.logs_negbinom(2.0, 11.0, mu=7.3, backend=backend) + expected = 3.247462 + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_negbinom(np.array([2.0, 7.0]), 7.0, 0.8, backend=backend) + expected = np.array([1.448676, 5.380319]) + assert np.allclose(res, expected) + + +def test_logs_normal(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_normal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_normal( + np.random.uniform(-10, 10, (4, 3)), + 3.2, + 4.1, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_normal( + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(-10, 10, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_normal(7, 4, -0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_normal(7, 4, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default mu + res0 = sr.logs_normal(-3, backend=backend) + res = sr.logs_normal(-3, mu=0, backend=backend) + assert np.isclose(res0, res) + + # default sigma + res = sr.logs_normal(-3, mu=0, sigma=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.logs_normal(-3 + 0.1, mu=0.1, sigma=1.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_normal(7.1, 3.8, 0.3, backend=backend) + expected = 60.21497 + assert np.isclose(res, expected) + + res = sr.logs_normal(3.1, 4.0, 0.5, backend=backend) + expected = 1.845791 + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_normal( + np.array([-2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([3.309898, 3.448482]) + assert np.allclose(res, expected) + + +def test_logs_2pnormal(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_2pnormal( + 17, + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_2pnormal( + np.random.uniform(-5, 5, (4, 3)), + 3.2, + 4.1, + 2.8, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_2pnormal( + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(-5, 5, (4, 3)), + np.random.uniform(0, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative scale1 parameter + with pytest.raises(ValueError): + sr.logs_2pnormal(7, -0.1, 0.1, backend=backend, check_pars=True) + return + + # zero scale1 parameter + with pytest.raises(ValueError): + sr.logs_2pnormal(7, 0.0, backend=backend, check_pars=True) + return + + # negative scale2 parameter + with pytest.raises(ValueError): + sr.logs_2pnormal(7, 0.1, -2.1, backend=backend, check_pars=True) + return + + # zero scale2 parameter + with pytest.raises(ValueError): + sr.logs_2pnormal(7, 1.1, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default scale1 + res0 = sr.logs_2pnormal(-3, backend=backend) + res = sr.logs_2pnormal(-3, scale1=1.0, backend=backend) + assert np.isclose(res0, res) + + # default scale2 + res = sr.logs_2pnormal(-3, scale1=1.0, scale2=1.0, backend=backend) + assert np.isclose(res0, res) + + # default location + res = sr.logs_2pnormal(-3, scale1=1.0, scale2=1.0, location=0.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.logs_2pnormal(-3 + 0.1, 1.0, 1.0, location=0.1, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_2pnormal(7.1, 3.8, 1.3, 2.0, backend=backend) + expected = 9.550298 + assert np.isclose(res, expected) + + # check equivalence with standard normal distribution + res0 = sr.logs_normal(3.1, 4.0, 0.5, backend=backend) + res = sr.logs_2pnormal(3.1, 0.5, 0.5, 4.0, backend=backend) + assert np.isclose(res0, res) + + # multiple observations + res = sr.logs_2pnormal( + np.array([-2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([6.116912, 8.046626]) + assert np.allclose(res, expected) + + +def test_logs_poisson(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_poisson( + 17, + np.random.uniform(0, 20, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_poisson( + np.random.uniform(-5, 10, (4, 3)), + 3.2, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_poisson( + np.random.uniform(-5, 10, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative mean parameter + with pytest.raises(ValueError): + sr.logs_poisson(7, -0.1, backend=backend, check_pars=True) + return + + # zero mean parameter + with pytest.raises(ValueError): + sr.logs_poisson(7, 0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # single observation + res = sr.logs_poisson(1.0, 3.0, backend=backend) + expected = 1.901388 + assert np.isclose(res, expected) + + res = sr.logs_poisson(1.5, 2.3, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # negative observation + res = sr.logs_poisson(-1.0, 1.5, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_poisson(np.array([2.0, 4.0]), np.array([10.1, 1.2]), backend=backend) + expected = np.array([6.168076, 3.648768]) + assert np.allclose(res, expected) + + +def test_logs_t(backend): + if backend == "torch": + pytest.skip("Not implemented in torch backend") + + ## test shape + + # one observation, multiple forecasts + res = sr.logs_t( + 17, + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_t( + np.random.uniform(-5, 20, (4, 3)), + 12, + 12, + 12, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_t( + np.random.uniform(-5, 20, (4, 3)), + np.random.uniform(0, 20, (4, 3)), + np.random.uniform(10, 20, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # negative df parameter + with pytest.raises(ValueError): + sr.logs_t(7, -4, backend=backend, check_pars=True) + return + + # zero df parameter + with pytest.raises(ValueError): + sr.logs_t(7, 0, backend=backend, check_pars=True) + return + + # negative scale parameter + with pytest.raises(ValueError): + sr.logs_t(7, 4, scale=-0.1, backend=backend, check_pars=True) + return + + # zero scale parameter + with pytest.raises(ValueError): + sr.logs_t(7, 4, scale=0.0, backend=backend, check_pars=True) + return + + ## test correctness + + # default location + res0 = sr.logs_t(-3, 2.1, backend=backend) + res = sr.logs_t(-3, 2.1, location=0, backend=backend) + assert np.isclose(res0, res) + + # default scale + res = sr.logs_t(-3, 2.1, location=0, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.logs_t(-3 + 0.1, 2.1, location=0.1, scale=1.0, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_t(7.1, 4.2, 3.8, 0.3, backend=backend) + expected = 8.600513 + assert np.isclose(res, expected) + + # negative observation + res = sr.logs_t(-3.1, 2.9, 4.0, 0.5, backend=backend) + expected = 8.60978 + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_t( + np.array([-2.9, 4.4]), + np.array([5.1, 19.4]), + np.array([1.1, 1.8]), + np.array([10.1, 1.2]), + backend=backend, + ) + expected = np.array([3.372580, 3.324565]) + assert np.allclose(res, expected) + + +def test_logs_uniform(backend): + ## test shape + + # one observation, multiple forecasts + res = sr.logs_uniform( + 7, + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(5, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, one forecast + res = sr.logs_uniform( + np.random.uniform(0, 10, (4, 3)), + 1, + 7, + backend=backend, + ) + assert res.shape == (4, 3) + + # multiple observations, multiple forecasts + res = sr.logs_uniform( + np.random.uniform(0, 10, (4, 3)), + np.random.uniform(0, 5, (4, 3)), + np.random.uniform(5, 10, (4, 3)), + backend=backend, + ) + assert res.shape == (4, 3) + + ## test exceptions + + # min >= max + with pytest.raises(ValueError): + sr.logs_uniform(0.7, min=2, max=1, backend=backend, check_pars=True) + return + + ## test correctness + + # default min + res0 = sr.logs_uniform(0.7, backend=backend) + res = sr.logs_uniform(0.7, min=0, backend=backend) + assert np.isclose(res0, res) + + # default sigma + res = sr.logs_uniform(0.7, min=0, max=1.0, backend=backend) + assert np.isclose(res0, res) + + # invariance to location shifts + res = sr.logs_uniform(0.7 + 0.1, min=0.1, max=1.1, backend=backend) + assert np.isclose(res0, res) + + # single observation + res = sr.logs_uniform(0.3, -1.0, 2.1, backend=backend) + expected = 1.131402 + assert np.isclose(res, expected) + + res = sr.logs_uniform(2.2, 0.1, 3.1, backend=backend) expected = 1.098612 assert np.isclose(res, expected) + + # observation outside of interval range + res = sr.logs_uniform(-17.9, -15.2, -8.7, backend=backend) + expected = float("inf") + assert np.isclose(res, expected) + + # multiple observations + res = sr.logs_uniform( + np.array([2.9, 4.4]), + np.array([1.1, 1.8]), + np.array([10.1, 5.1]), + backend=backend, + ) + expected = np.array([2.197225, 1.193922]) + assert np.allclose(res, expected) From bd677ebad0ba6171a03145f5eb50115980982c67 Mon Sep 17 00:00:00 2001 From: sallen12 Date: Tue, 31 Mar 2026 18:17:19 +0200 Subject: [PATCH 10/11] fix bug in vertically-rescaled energy score argument ordering --- scoringrules/_energy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scoringrules/_energy.py b/scoringrules/_energy.py index fdc101a..3bfaacc 100644 --- a/scoringrules/_energy.py +++ b/scoringrules/_energy.py @@ -219,10 +219,10 @@ def vres_ensemble( obs: "Array", fct: "Array", w_func: tp.Callable[["ArrayLike"], "ArrayLike"], - *, - ens_w: "Array" = None, m_axis: int = -2, v_axis: int = -1, + *, + ens_w: "Array" = None, backend: "Backend" = None, ) -> "Array": r"""Compute the Vertically Re-scaled Energy Score (vrES) for a finite multivariate ensemble. From 52c6466829e9e802e03ba2984fc17f7c3dc6d3df Mon Sep 17 00:00:00 2001 From: sallen12 Date: Wed, 1 Apr 2026 10:14:23 +0200 Subject: [PATCH 11/11] allow tensorflow and jax backends for t log score --- scoringrules/_logs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scoringrules/_logs.py b/scoringrules/_logs.py index d8f7eb9..4d70381 100644 --- a/scoringrules/_logs.py +++ b/scoringrules/_logs.py @@ -1196,10 +1196,8 @@ def logs_t( >>> import scoringrules as sr >>> sr.logs_t(0.0, 0.1, 0.4, 0.1) """ - if backend in ["torch", "tensorflow", "jax"]: - raise TypeError( - "Torch, Tensorflow, and JAX backends are not supported for the t distribution." - ) + if backend == "torch": + raise TypeError("Torch backend is not supported for the t distribution.") if check_pars: B = backends.active if backend is None else backends[backend] df, scale = map(B.asarray, (df, scale))