Skip to content

Commit 0c1738a

Browse files
authored
Add quantile score (#78)
* implement quantile score * [skip ci] fix docstring
1 parent ea14355 commit 0c1738a

File tree

7 files changed

+99
-5
lines changed

7 files changed

+99
-5
lines changed

docs/api/interval.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,29 @@
44

55
For a prediction interval (PI), the interval or Winkler score is given by:
66

7-
$\text{IS} = \begin{cases}
7+
$$
8+
\text{IS} = \begin{cases}
89
(u - l) + \frac{2}{\alpha}(l - y) & \text{for } y < l \\
910
(u - l) & \text{for } l \leq y \leq u \\
1011
(u - l) + \frac{2}{\alpha}(y - u) & \text{for } y > u. \\
11-
\end{cases}$
12+
\end{cases}
13+
$$
1214

1315
for an $(1 - \alpha)$PI of $[l, u]$ and the true value $y$ [@gneiting_strictly_2007, @bracher2021evaluating @winkler1972decision].
1416

1517
## Weighted Interval Score
1618

1719
The weighted interval score (WIS) is defined as
1820

19-
$\text{WIS}_{\alpha_{0:K}}(F, y) = \frac{1}{K+0.5}(w_0 \times |y - m| + \sum_{k=1}^K (w_k \times IS_{\alpha_k}(F, y)))$
21+
$$
22+
\text{WIS}_{\alpha_{0:K}}(F, y) = \frac{1}{K+0.5}(w_0 \times |y - m| + \sum_{k=1}^K (w_k \times IS_{\alpha_k}(F, y)))
23+
$$
2024

2125
where $m$ denotes the median prediction, $w_0$ denotes the weight of the median prediction, $IS_{\alpha_k}(F, y)$ denotes the interval score for the $1 - \alpha$ prediction interval and $w_k$ is the according weight. The WIS is calculated for a set of (central) PIs and the predictive median [@bracher2021evaluating]. The weights are an optional parameter and default weight is the canonical weight $w_k = \frac{2}{\alpha_k}$ and $w_0 = 0.5$. For these weights, it holds that:
2226

23-
$\text{WIS}_{\alpha_{0:K}}(F, y) \approx \text{CRPS}(F, y).$
27+
$$
28+
\text{WIS}_{\alpha_{0:K}}(F, y) \approx \text{CRPS}(F, y).
29+
$$
2430

2531

2632
::: scoringrules.interval_score

docs/api/quantile.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Quantile score
2+
3+
::: scoringrules.quantile_score

mkdocs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ nav:
1515
- Energy Score: api/energy.md
1616
- Variogram Score: api/variogram.md
1717
- Interval Score: api/interval.md
18+
- Quantile Score: api/quantile.md
1819
- Visualization: api/visualization.md
1920

2021

scoringrules/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@
9696
vrgksmv_ensemble,
9797
)
9898

99+
from scoringrules._quantile import quantile_score
100+
99101
from scoringrules.backend import backends, register_backend
100102

101103
__version__ = version("scoringrules")
@@ -188,4 +190,5 @@
188190
"vrgksmv_ensemble",
189191
"interval_score",
190192
"weighted_interval_score",
193+
"quantile_score",
191194
]

scoringrules/_quantile.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import typing as tp
2+
3+
from scoringrules.backend import backends
4+
5+
if tp.TYPE_CHECKING:
6+
from scoringrules.core.typing import Array, ArrayLike, Backend
7+
8+
9+
def quantile_score(
10+
obs: "ArrayLike",
11+
fct: "ArrayLike",
12+
alpha: "ArrayLike",
13+
backend: "Backend | None" = None,
14+
) -> "Array":
15+
r"""Compute the quantile score for a given quantile level.
16+
17+
The quantile score (Koenker, R. and G. Bassett, 1978) is defined as
18+
19+
$$
20+
S_{\alpha}(q_{\alpha}, y) = \begin{cases}
21+
(1 - \alpha) (q_{\alpha} - y), & \text{if } y \leq q_{\alpha}, \\
22+
\alpha (y - q_{\alpha}), & \text{if } y > q_{\alpha}.
23+
\end{cases}
24+
$$
25+
26+
where $y$ is the observed value and $q_{\alpha}$ is the predicted value at the
27+
$\alpha$ quantile level.
28+
29+
Parameters
30+
----------
31+
obs:
32+
The observed values.
33+
fct:
34+
The forecast values.
35+
alpha:
36+
The quantile level.
37+
38+
Returns
39+
-------
40+
score:
41+
The quantile score.
42+
43+
Examples
44+
--------
45+
>>> import scoringrules as sr
46+
>>> sr.quantile_score(0.3, 0.5, 0.2)
47+
0.16
48+
49+
Raises
50+
------
51+
ValueError
52+
If the quantile level is not between 0 and 1.
53+
"""
54+
B = backends.active if backend is None else backends[backend]
55+
obs, fct, alpha = map(B.asarray, (obs, fct, alpha))
56+
if B.any(alpha < 0) or B.any(alpha > 1):
57+
raise ValueError("The quantile level must be between 0 and 1.")
58+
return (B.asarray((obs < fct), dtype=alpha.dtype) - alpha) * (fct - obs)

tests/test_quantile.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import numpy as np
2+
import pytest
3+
4+
import scoringrules as sr
5+
6+
from .conftest import BACKENDS
7+
8+
9+
@pytest.mark.parametrize("backend", BACKENDS)
10+
def test_quantile_score(backend):
11+
obs = np.array([0.1, 0.2, 0.3, 0.4, 0.5])
12+
fct = np.array([0.1, 0.2, 0.3, 0.4, 0.5])
13+
alpha = np.array([0.1, 0.2, 0.3, 0.4, 0.5])
14+
res = sr.quantile_score(obs, fct, alpha, backend=backend)
15+
res.__class__.__module__ == backend
16+
17+
# correctness
18+
np.isclose(sr.quantile_score(0.3, 0.5, 0.2, backend=backend), 0.16)
19+
np.isclose(sr.quantile_score(0.3, 0.1, 0.2, backend=backend), 0.04)
20+
21+
# value checks
22+
with pytest.raises(ValueError):
23+
sr.quantile_score(0.3, 0.5, -0.2, backend=backend)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)