Skip to content

Commit 0bc8729

Browse files
authored
Move calculation to Prediction class (#39)
* Begin moving calculation to Prediction class * Move Brier calculation * Refactor * Cleanup * Document changes * Update documentation with new API
1 parent 887702a commit 0bc8729

File tree

6 files changed

+189
-132
lines changed

6 files changed

+189
-132
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1313
- Calculation of scores for when the order of alternatives matters.
1414
- Design philosophy documentation.
1515
- Meta documentation
16+
- Simplify API
1617

1718
### Changed
1819
- Decimal input for probabilities instead of integer.

README.md

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,28 @@ There are several ways to score predictions like these. Here, we are using [Brie
3939

4040
(See [plot.py](plot.py) for the code that generated this chart.)
4141

42-
Now, back to our election example. The following code scores the predictions.
42+
Now, back to our election example. The following code scores the predictions using the Brier scoring rule.
4343

4444
```python
4545
from decimal import Decimal
4646

47-
from predictionscorer import calculators, predictions
47+
from predictionscorer import predictions
48+
49+
true_alternative_index = 1 # Alternative 0 is Hillary Clinton. Alternative 1 is Donald Trump.
4850

4951
george = predictions.Prediction(
50-
probabilities=(Decimal(60), Decimal(40)) # George put Clinton at 60 % and Trump at 40 %.
51-
)
52-
kramer = predictions.Prediction(
53-
probabilities=(Decimal(35), Decimal(65)) # Kramer put Clinton at 35 % and Trump at 65 %.
52+
probabilities=(Decimal(60), Decimal(40)), # George put Clinton at 60 % and Trump at 40 %.
53+
true_alternative_index=true_alternative_index,
5454
)
5555

56-
brier = calculators.Brier(
57-
true_alternative_index=1 # Alternative 0 is Hillary Clinton. Alternative 1 is Donald Trump.
56+
print(george.brier_score) # Decimal('0.72')
57+
58+
kramer = predictions.Prediction(
59+
probabilities=(Decimal(35), Decimal(65)), # Kramer put Clinton at 35 % and Trump at 65 %.
60+
true_alternative_index=true_alternative_index,
5861
)
5962

60-
print(brier.calculate(george)) # Decimal('0.72')
61-
print(brier.calculate(kramer)) # Decimal('0.245')
63+
print(kramer.brier_score) # Decimal('0.245')
6264
```
6365

6466
As you can see, Kramer’s score is _lower_ than George’s. How can a better prediction give a lower score? The thing is, with Brier scores, the lower, the better. To help your intuition, you can consider a Brier score as the _distance from the truth_. (A perfect prediction yields 0, while the worst possible prediction yields 2.)
@@ -70,21 +72,18 @@ The above example is binary — there are only two alternatives. But sometimes y
7072
```python
7173
from decimal import Decimal
7274

73-
from predictionscorer import calculators, predictions
75+
from predictionscorer import predictions
7476

7577
prediction = predictions.Prediction(
7678
probabilities=(
7779
Decimal(55), # Clinton
7880
Decimal(35), # Trump
7981
Decimal(10), # Other
80-
)
81-
)
82-
83-
brier = calculators.Brier(
82+
),
8483
true_alternative_index=1,
8584
)
8685

87-
print(brier.calculate(prediction)) # Decimal('0.735')
86+
print(prediction.brier_score) # Decimal('0.735')
8887
```
8988

9089
#### If the order matters
@@ -102,12 +101,12 @@ Sometimes, the ordering of alternatives matters. For example, consider the follo
102101
103102
We [now know that the answer is 3,230.78](https://us.spindices.com/indices/equity/sp-500). This means that, among our alternatives, the one with index 1 turned out to be correct. But notice that index 2 is closer to the right answer than index 3. In such cases, the regular Brier score is a poor measure of forecasting accuracy. Instead, we can use [the ordered categorical scoring rule](https://goodjudgment.io/Training/Ordered_Categorical_Scoring_Rule.pdf).
104103

105-
The code below should look familiar, except that we are now using the `OrderedCategorical` calculator instead of the `Brier` calculator.
104+
The code below should look familiar, except that we are now setting `order_matters=True`:
106105

107106
```python
108107
from decimal import Decimal
109108

110-
from predictionscorer import calculators, predictions
109+
from predictionscorer import predictions
111110

112111
prediction = predictions.Prediction(
113112
probabilities=(
@@ -116,13 +115,11 @@ prediction = predictions.Prediction(
116115
Decimal(30),
117116
Decimal(20),
118117
),
119-
)
120-
121-
ordered_categorical = calculators.OrderedCategorical(
122118
true_alternative_index=1,
119+
order_matters=True,
123120
)
124121

125-
print(ordered_categorical.calculate(prediction)) # Decimal('0.2350')
122+
print(prediction.brier_score) # Decimal('0.2350')
126123
```
127124

128125
## Changelog

predictionscorer/calculators.py

Lines changed: 0 additions & 78 deletions
This file was deleted.

predictionscorer/predictions.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,122 @@
22
from decimal import Decimal
33

44

5+
class Base:
6+
true_alternative_index: int
7+
8+
def __init__(self, true_alternative_index: int) -> None:
9+
self.true_alternative_index = true_alternative_index
10+
11+
12+
class Brier(Base):
13+
"""
14+
Calculates scores when the order of predictions does not matter.
15+
"""
16+
17+
def calculate(self, prediction: "Prediction") -> Decimal:
18+
score = Decimal("0.00")
19+
for index, probability in enumerate(prediction.probabilities):
20+
first_term = probability / Decimal(100)
21+
second_term = Decimal(
22+
"1.00" if index == self.true_alternative_index else "0.00"
23+
)
24+
score = score + (first_term - second_term) ** 2
25+
return score
26+
27+
28+
class OrderedCategorical(Base):
29+
"""
30+
Calculates scores when the order of predictions matters.
31+
"""
32+
33+
def calculate(self, prediction: "Prediction") -> Decimal:
34+
total = Decimal("0.00")
35+
pair_count = self._pair_count(prediction.probabilities)
36+
for index in range(pair_count):
37+
pair = Prediction(
38+
self._split_probabilities(index, prediction.probabilities),
39+
true_alternative_index=1,
40+
)
41+
score = self._score_pair(index, pair)
42+
total += score
43+
return self._average(total, pair_count)
44+
45+
@staticmethod
46+
def _pair_count(probabilities: typing.Tuple[Decimal, ...]) -> int:
47+
"""
48+
We need one fewer pairs than the number of alternatives. For example, if there are three alternatives — A, B and C, the pairs are:
49+
50+
- A and BC
51+
- AB and C
52+
"""
53+
return len(probabilities) - 1
54+
55+
@staticmethod
56+
def _average(total: Decimal, count: int) -> Decimal:
57+
return total / Decimal(count)
58+
59+
def _score_pair(self, index: int, pair: "Prediction") -> Decimal:
60+
assert len(pair.probabilities) == 2, "There must be exactly two probabilities."
61+
true_alternative_index = 0 if index > self.true_alternative_index else 1
62+
brier_calculator = Brier(true_alternative_index=true_alternative_index)
63+
return brier_calculator.calculate(pair)
64+
65+
@staticmethod
66+
def _split_probabilities(
67+
index: int, probabilities: typing.Tuple[Decimal, ...]
68+
) -> typing.Tuple[Decimal, Decimal]:
69+
"""
70+
Given an index and a tuple of more than two probabilities, return a pair of grouped probabilities.
71+
"""
72+
assert len(probabilities) > 2
73+
first_part = probabilities[: (index + 1)]
74+
second_part = probabilities[(index + 1) :]
75+
sum_first_part = Decimal(sum(first_part))
76+
sum_second_part = Decimal(sum(second_part))
77+
return sum_first_part, sum_second_part
78+
79+
580
class Prediction:
681
"""
782
This class encapsulates probabilities for a given question.
883
"""
984

85+
_cached_brier_score: typing.Optional[Decimal] = None
86+
order_matters: bool
1087
probabilities: typing.Tuple[Decimal, ...]
88+
true_alternative_index: int
1189

12-
def __init__(self, probabilities: typing.Tuple[Decimal, ...]) -> None:
90+
def __init__(
91+
self,
92+
probabilities: typing.Tuple[Decimal, ...],
93+
true_alternative_index: int,
94+
order_matters: bool = False,
95+
) -> None:
1396
"""
1497
2 or more probabilities are required. Make sure that they sum to 100.
1598
"""
16-
assert len(probabilities) >= 2, "A prediction needs at least two probabilities."
1799
assert sum(probabilities) == 100, "Probabilities need to sum to 100."
100+
length = len(probabilities)
101+
assert length >= 2, "A prediction needs at least two probabilities."
102+
assert (
103+
true_alternative_index >= 0
104+
), "The true alternative index cannot be negative"
105+
assert (
106+
true_alternative_index <= length - 1
107+
), "Probabilities need to contain the true alternative"
108+
self.order_matters = order_matters
18109
self.probabilities = probabilities
110+
self.true_alternative_index = true_alternative_index
111+
112+
@property
113+
def brier_score(self) -> Decimal:
114+
if isinstance(self._cached_brier_score, Decimal):
115+
return self._cached_brier_score
116+
calculator: typing.Union[Brier, OrderedCategorical] = (
117+
OrderedCategorical(true_alternative_index=self.true_alternative_index)
118+
if self.order_matters
119+
else Brier(true_alternative_index=self.true_alternative_index)
120+
)
121+
score = calculator.calculate(self)
122+
self._cached_brier_score = score
123+
return score

tests/test_brier.py

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)