Skip to content

Commit

Permalink
Add CRPS for the generalised truncated and censored normal distributi…
Browse files Browse the repository at this point in the history
…on (#49)

* add crps for gtc normal

* add documentation entries for gtc normal crps

* add tests for gtc normal crps

* fix bug in cnormal crps test

* fix bug in tnormal crps test
  • Loading branch information
sallen12 authored Sep 5, 2024
1 parent 145167c commit d51df17
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 2 deletions.
6 changes: 6 additions & 0 deletions docs/api/crps.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ When the true forecast CDF is not fully known, but represented by a finite ensem

::: scoringrules.crps_gpd

::: scoringrules.crps_gtcnormal

::: scoringrules.crps_tnormal

::: scoringrules.crps_cnormal

::: scoringrules.crps_hypergeometric

::: scoringrules.crps_laplace
Expand Down
8 changes: 7 additions & 1 deletion scoringrules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
crps_gamma,
crps_gev,
crps_gpd,
crps_gtcnormal,
crps_tnormal,
crps_cnormal,
crps_hypergeometric,
crps_laplace,
crps_logistic,
Expand Down Expand Up @@ -48,18 +51,21 @@
"crps_ensemble",
"crps_beta",
"crps_binomial",
"crps_normal",
"crps_exponential",
"crps_exponentialM",
"crps_gamma",
"crps_gev",
"crps_gpd",
"crps_gtcnormal",
"crps_tnormal",
"crps_cnormal",
"crps_hypergeometric",
"crps_laplace",
"crps_loglaplace",
"crps_loglogistic",
"crps_logistic",
"crps_lognormal",
"crps_normal",
"crps_quantile",
"owcrps_ensemble",
"twcrps_ensemble",
Expand Down
146 changes: 145 additions & 1 deletion scoringrules/_crps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import typing as tp

from scoringrules.backend import backends
from scoringrules.core import crps
from scoringrules.core import crps, stats

if tp.TYPE_CHECKING:
from scoringrules.core.typing import Array, ArrayLike, Backend
Expand Down Expand Up @@ -759,6 +759,147 @@ def crps_gpd(
return crps.gpd(observation, shape, location, scale, mass, backend=backend)


def crps_gtcnormal(
observation: "ArrayLike",
location: "ArrayLike",
scale: "ArrayLike",
/,
lower: "ArrayLike" = float("-inf"),
upper: "ArrayLike" = float("inf"),
lmass: "ArrayLike" = 0.0,
umass: "ArrayLike" = 0.0,
*,
backend: "Backend" = None,
) -> "ArrayLike":
r"""Compute the closed form of the CRPS for the generalised truncated and censored normal distribution.
It is based on the following formulation from
[Jordan et al. (2019)](https://www.jstatsoft.org/article/view/v090i12):
$$ \mathrm{CRPS}(F_{l, L}^{u, U}, y) = |y - z| + uU^{2} - lL^{2} + \left( \frac{1 - L - U}{\Phi(u) - \Phi(l)} \right) z \left( 2 \Phi(z) - \frac{(1 - 2L) \Phi(u) + (1 - 2U) \Phi(l)}{1 - L - U} \right) + \left( \frac{1 - L - U}{\Phi(u) - \Phi(l)} \right) \left( 2 \phi(z) - 2 \phi(u)U - 2 \phi(l)L \right) - \left( \frac{1 - L - U}{\Phi(u) - \Phi(l)} \right)^{2} \left( \frac{1}{\sqrt{\pi}} \right) \left( \Phi(u \sqrt{2}) - \Phi(l \sqrt{2}) \right), $$
$$ \mathrm{CRPS}(F_{l, L, \mu, \sigma}^{u, U}, y) = \sigma \mathrm{CRPS}(F_{(l - \mu)/\sigma, L}^{(u - \mu)/\sigma, U}, \frac{y - \mu}{\sigma}), $$
where $\Phi$ and $\phi$ are respectively the CDF and PDF of the standard normal
distribution, $F_{l, L, \mu, \sigma}^{u, U}$ is the CDF of the normal distribution
truncated below at $l$ and above at $u$, with point masses $L, U > 0$ at the lower and upper
boundaries, respectively, and location and scale parameters $\mu$ and $\sigma > 0$.
$F_{l, L}^{u, U} = F_{l, L, 0, 1}^{u, U}$.
Parameters
----------
observation: ArrayLike
The observed values.
location: ArrayLike
Location parameter of the forecast distribution.
scale: ArrayLike
Scale parameter of the forecast distribution.
lower: ArrayLike
Lower boundary of the truncated forecast distribution.
upper: ArrayLike
Upper boundary of the truncated forecast distribution.
lmass: ArrayLike
Point mass assigned to the lower boundary of the forecast distribution.
umass: ArrayLike
Point mass assigned to the upper boundary of the forecast distribution.
Returns
-------
crps: array_like
The CRPS between gtcNormal(location, scale, lower, upper, lmass, umass) and obs.
Examples
--------
>>> from scoringrules import crps
>>> crps.gtcnormal(0.0, 0.1, 0.4, -1.0, 1.0, 0.1, 0.1)
"""
return crps.gtcnormal(observation, location, scale, lower, upper, lmass, umass, backend=backend)


def crps_tnormal(
observation: "ArrayLike",
location: "ArrayLike",
scale: "ArrayLike",
/,
lower: "ArrayLike" = float("-inf"),
upper: "ArrayLike" = float("inf"),
*,
backend: "Backend" = None,
) -> "ArrayLike":
r"""Compute the closed form of the CRPS for the truncated normal distribution.
It is based on the formulation for the generalised truncated and censored normal distribution with
lmass and umass set to zero.
Parameters
----------
observation: ArrayLike
The observed values.
location: ArrayLike
Location parameter of the forecast distribution.
scale: ArrayLike
Scale parameter of the forecast distribution.
lower: ArrayLike
Lower boundary of the truncated forecast distribution.
upper: ArrayLike
Upper boundary of the truncated forecast distribution.
Returns
-------
crps: array_like
The CRPS between tNormal(location, scale, lower, upper) and obs.
Examples
--------
>>> from scoringrules import crps
>>> crps.tnormal(0.0, 0.1, 0.4, -1.0, 1.0)
"""
return crps.gtcnormal(observation, location, scale, lower, upper, 0.0, 0.0, backend=backend)


def crps_cnormal(
observation: "ArrayLike",
location: "ArrayLike",
scale: "ArrayLike",
/,
lower: "ArrayLike" = float("-inf"),
upper: "ArrayLike" = float("inf"),
*,
backend: "Backend" = None,
) -> "ArrayLike":
r"""Compute the closed form of the CRPS for the censored normal distribution.
It is based on the formulation for the generalised truncated and censored normal distribution with
lmass and umass set to the tail probabilities of the predictive distribution.
Parameters
----------
observation: ArrayLike
The observed values.
location: ArrayLike
Location parameter of the forecast distribution.
scale: ArrayLike
Scale parameter of the forecast distribution.
lower: ArrayLike
Lower boundary of the truncated forecast distribution.
upper: ArrayLike
Upper boundary of the truncated forecast distribution.
Returns
-------
crps: array_like
The CRPS between cNormal(location, scale, lower, upper) and obs.
Examples
--------
>>> from scoringrules import crps
>>> crps.cnormal(0.0, 0.1, 0.4, -1.0, 1.0)
"""
lmass = stats._norm_cdf((lower - location) / scale)
umass = 1 - stats._norm_cdf((upper - location) / scale)
return crps.gtcnormal(observation, location, scale, lower, upper, lmass, umass, backend=backend)


def crps_hypergeometric(
observation: "ArrayLike",
m: "ArrayLike",
Expand Down Expand Up @@ -1100,6 +1241,9 @@ def crps_loglogistic(
"crps_gamma",
"crps_gev",
"crps_gpd",
"crps_gtcnormal",
"crps_tnormal",
"crps_cnormal",
"crps_hypergeometric",
"crps_logistic",
"crps_lognormal",
Expand Down
2 changes: 2 additions & 0 deletions scoringrules/core/crps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
gamma,
gev,
gpd,
gtcnormal,
hypergeometric,
laplace,
logistic,
Expand All @@ -28,6 +29,7 @@
"gamma",
"gev",
"gpd",
"gtcnormal",
"hypergeometric",
"laplace",
"loglaplace",
Expand Down
36 changes: 36 additions & 0 deletions scoringrules/core/crps/_closed.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,42 @@ def gpd(
return scale * s


def gtcnormal(
obs: "ArrayLike", location: "ArrayLike", scale: "ArrayLike", lower: "ArrayLike", upper: "ArrayLike", lmass: "ArrayLike", umass: "ArrayLike", backend: "Backend" = None
) -> "Array":
"""Compute the CRPS for the generalised truncated and censored normal distribution."""
B = backends.active if backend is None else backends[backend]
mu, sigma, lower, upper, lmass, umass, obs = map(B.asarray, (location, scale, lower, upper, lmass, umass, obs))
ω = (obs - mu) / sigma
u = (upper - mu) / sigma
l = (lower - mu) / sigma
z = B.minimum(B.maximum(ω, l), u)
F_u = _norm_cdf(u, backend=backend)
F_l = _norm_cdf(l, backend=backend)
F_z = _norm_cdf(z, backend=backend)
F_u2 = _norm_cdf(u * B.sqrt(2), backend=backend)
F_l2 = _norm_cdf(l * B.sqrt(2), backend=backend)
f_u = _norm_pdf(u, backend=backend)
f_l = _norm_pdf(l, backend=backend)
f_z = _norm_pdf(z, backend=backend)

u_inf = u == float("inf")
l_inf = l == float("-inf")

u = B.where(u_inf, B.nan, u)
l = B.where(l_inf, B.nan, l)
s1_u = B.where(u_inf and umass == 0.0, 0.0, u * umass**2)
s1_l = B.where(l_inf and lmass == 0.0, 0.0, l * lmass**2)

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)


def hypergeometric(
obs: "ArrayLike",
m: "ArrayLike",
Expand Down
44 changes: 44 additions & 0 deletions tests/test_crps.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,50 @@ def test_gpd(backend):
)


@pytest.mark.parametrize("backend", BACKENDS)
def test_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 = _crps.crps_gtcnormal(obs, location, scale, lower, upper, lmass, umass, backend=backend)
assert np.isclose(res, expected)

# aligns with crps_normal
res0 = _crps.crps_normal(obs, location, scale, backend=backend)
res = _crps.crps_gtcnormal(obs, location, scale, backend=backend)
assert np.isclose(res, res0)

# aligns with crps_tnormal
res0 = _crps.crps_tnormal(obs, location, scale, lower, upper, backend=backend)
res = _crps.crps_gtcnormal(obs, location, scale, lower, upper, backend=backend)
assert np.isclose(res, res0)


@pytest.mark.parametrize("backend", BACKENDS)
def test_tnormal(backend):
obs, location, scale, lower, upper = -1.0, 2.9, 2.2, 1.5, 17.3
expected = 3.982434
res = _crps.crps_tnormal(obs, location, scale, lower, upper, backend=backend)
assert np.isclose(res, expected)

# aligns with crps_normal
res0 = _crps.crps_normal(obs, location, scale, backend=backend)
res = _crps.crps_tnormal(obs, location, scale, backend=backend)
assert np.isclose(res, res0)


@pytest.mark.parametrize("backend", BACKENDS)
def test_cnormal(backend):
obs, location, scale, lower, upper = 1.8, 0.4, 1.1, 0.0, 2.0
expected = 0.8296078
res = _crps.crps_cnormal(obs, location, scale, lower, upper, backend=backend)
assert np.isclose(res, expected)

# aligns with crps_normal
res0 = _crps.crps_normal(obs, location, scale, backend=backend)
res = _crps.crps_cnormal(obs, location, scale, backend=backend)
assert np.isclose(res, res0)


@pytest.mark.parametrize("backend", BACKENDS)
def test_hypergeometric(backend):
res = _crps.crps_hypergeometric(5 * np.ones((2, 2)), 7, 13, 12, backend=backend)
Expand Down

0 comments on commit d51df17

Please sign in to comment.