TLDR: the 1000th example of why floating point arithmetic is bad, in an excessively long post
Running the same election with starpy (Python) and Equal-Vote's star-core (Javascript) gives different results.
I am using these ballots from the star-core test cases, for STAR-PR:
https://github.com/Equal-Vote/star-core/blob/9a554932e4fc7ae9d6eb295fa1076437c812f5ce/src/Tests/pr.test.js#L105-L122
After spending an hour staring at two different debuggers simultaneously, I think the reason is because pandas in starpy somehow has different floating point behavior, causing a different candidate to be elected in the first round. This cascades throughout the end and we get a different election result overall.
Steps to reproduce
The ballot data is copied from star-core (JS) tests as linked above
# Paste in everything from https://github.com/Equal-Vote/starpy/blob/main/starpy/Allocated_Score.py here
b = [
[0., 0., 3., 2., 3., 0., 2., 1.],
[3., 2., 4., 4., 4., 3., 4., 2.],
[3., 3., 2., 1., 2., 4., 3., 0.],
[4., 3., 1., 2., 0., 4., 4., 2.],
[4., 2., 1., 0., 2., 2., 1., 0.],
[1., 0., 2., 1., 1., 1., 4., 2.],
[3., 0., 3., 0., 2., 2., 0., 4.],
[2., 0., 2., 4., 1., 3., 0., 2.],
[1., 4., 0., 1., 1., 1., 2., 4.],
[2., 3., 4., 2., 0., 2., 3., 3.],
[0., 0., 3., 3., 0., 0., 2., 3.],
[1., 2., 3., 4., 3., 3., 1., 4.],
[2., 3., 0., 0., 2., 4., 4., 3.],
[2., 1., 1., 1., 2., 1., 3., 0.],
[1., 0., 4., 3., 1., 3., 0., 0.],
]
n_cands = 8
n_voters = 15
ballots = pd.DataFrame(b)
n_seats = 3
assert ballots.shape[1] >= n_seats
winners = Allocated_Score(5, n_seats, ballots)
print(winners)
The answer I get is [2, 6, 7]. This is 0-indexed so candidates 3, 7, and 8 are elected. This does not match the JS tests, where candidates 1, 3, and 7 are elected:
https://github.com/Equal-Vote/star-core/blob/9a554932e4fc7ae9d6eb295fa1076437c812f5ce/src/Tests/pr.test.js#L129
What is happening
Let's look at the normalized ballots. Each row is a voter and each column is a candidate. There are 15 voters and eight candidates for a 3 seat election:
0 1 2 3 4 5 6 7
0 0.0 0.0 0.6 0.4 0.6 0.0 0.4 0.2
1 0.6 0.4 0.8 0.8 0.8 0.6 0.8 0.4
2 0.6 0.6 0.4 0.2 0.4 0.8 0.6 0.0
3 0.8 0.6 0.2 0.4 0.0 0.8 0.8 0.4
4 0.8 0.4 0.2 0.0 0.4 0.4 0.2 0.0
5 0.2 0.0 0.4 0.2 0.2 0.2 0.8 0.4
6 0.6 0.0 0.6 0.0 0.4 0.4 0.0 0.8
7 0.4 0.0 0.4 0.8 0.2 0.6 0.0 0.4
8 0.2 0.8 0.0 0.2 0.2 0.2 0.4 0.8
9 0.4 0.6 0.8 0.4 0.0 0.4 0.6 0.6
10 0.0 0.0 0.6 0.6 0.0 0.0 0.4 0.6
11 0.2 0.4 0.6 0.8 0.6 0.6 0.2 0.8
12 0.4 0.6 0.0 0.0 0.4 0.8 0.8 0.6
13 0.4 0.2 0.2 0.2 0.4 0.2 0.6 0.0
14 0.2 0.0 0.8 0.6 0.2 0.6 0.0 0.0
The first problem happens in column 2. We are summing to find the candidate with the highest score, so for column 2, we do this: 0.6+0.8+0.4+0.2+0.2+0.4+0.6+0.4+0.0+0.8+0.6+0.6+0.0+0.2+0.8.
|
w = weighted_scores.sum().idxmax() |
In JS, this is 6.599999999999999. In my Python debugger and script, it is also 6.599999999999999. However when pandas actually sums it up, it becomes 6.6.
Here's what my debugger session looks like:
> /home/me/star_pr.py(187)Allocated_Score()
-> w = weighted_scores.sum().idxmax()
(Pdb) weighted_scores.sum()
0 5.8
1 4.6
2 6.6
3 5.6
4 4.8
5 6.6
6 6.6
7 6.0
dtype: float64
(Pdb) weighted_scores.sum()[2]
6.6
(Pdb) type(weighted_scores.sum()[2])
<class 'numpy.float64'>
(Pdb) float(weighted_scores.sum()[2])
6.6
(Pdb) type(float(weighted_scores.sum()[2]))
<class 'float'>
Hence it is not an artifact of pandas/numpy rounding for printing.
Compare the sums with the sums in JS (emphasis mine):
[
5.800000000000002,
4.6,
6.599999999999999, // <--- #2
5.6000000000000005,
4.800000000000001,
6.6, // <--- #5
6.6000000000000005, // <--- #6
5.999999999999999
]
(Setting a breakpoint here: https://github.com/Equal-Vote/star-core/blob/9a554932e4fc7ae9d6eb295fa1076437c812f5ce/src/StarResults.js#L442)
For comparison, normal Python sums column 5 to 6.6, and column 6 to 6.6000000000000005. So this is a problem with pandas/numpy
The consequences is that indices 2, 5, and 6 gets the same value of 6.6 in Python, but in JS they are all different. In JS, the highest score belongs to candidate 6, but since there is a tie in Python, it arbitrarily selects index 2.
Python elects index 2 then 6. JS elects index 6 then 2. But the error has cascaded too much for the final seat. The out of order election resulted in different re-weightings. See the debugging values:
| Value |
Candidate |
JS |
Python |
| split_point |
7 |
0.8 |
3.0 |
| spent_above |
7 |
0 |
3.0 |
| weight_on_split |
7 |
4 |
2.0 |
| split_point |
3 |
0.6 |
0.6 |
| spent_above |
3 |
2.0 |
3.0 |
| weight_on_split |
3 |
4.0 |
4 |
Raw data (hopefully I transcribed it correctly)
js split_point for 7 is 0.8
python split_point for 3 is 0.6
js spent_above for 7 is 0
python spent_above for 3 is 3.0
js weight_on_split for 7 is 4
python weight_on_split for 3 is 4
python split_point for 7 is 0.6
js split_point for 3 is 0.6
python spent_above for 7 is 3.0
js spent_above for 3 is 2.0
python weight_on_split for 7 is 2.0
js weight_on_split for 3 is 4.0
The sum of scores at the end of the 2nd round is completely different, causing Python to elect candidate 8 and JS to elect candidate 1.
What can we do about this?
Some projects such as tallystick offers the ability to use exact representations for rational fractions, or fixed point decimals. (For example, Scottish Councils use 5 d.p. for STV)
I'm not engaged in the STAR project, but I think if the code is supposed to be a "gold standard" then it might be worth it to show that it is rigorous, production, and real world ready.
I know it's not easy to change something so fundamental, and I totally understand because I haven't bothered with more exact decimals in my own project too. But at least people can see this issue and evaluate this for their own needs. I might not need this issue to be fixed, but hopefully it will save time for someone else who does.
(I stumbled on to this issue, because I was going to use the JS tests to validate my own implementation. This has implications for #2 and #9 as the JS tests couldn't be simply copied to Python)
Misc
- Pandas 1.4.3 (the
requirements.txt states pandas>=1.3.4)
- Numpy 1.24.2 (the
requirements.txt states numpy>=1.21.4)
- Python 3.10.10
- 64-bit Linux
This issue is appropriate for this repo or the star-core repo, but I think this repo the most appropriate because only pandas shows this behavior
See also
TLDR: the 1000th example of why floating point arithmetic is bad, in an excessively long post
Running the same election with starpy (Python) and Equal-Vote's star-core (Javascript) gives different results.
I am using these ballots from the star-core test cases, for STAR-PR:
https://github.com/Equal-Vote/star-core/blob/9a554932e4fc7ae9d6eb295fa1076437c812f5ce/src/Tests/pr.test.js#L105-L122
After spending an hour staring at two different debuggers simultaneously, I think the reason is because pandas in starpy somehow has different floating point behavior, causing a different candidate to be elected in the first round. This cascades throughout the end and we get a different election result overall.
Steps to reproduce
The ballot data is copied from star-core (JS) tests as linked above
The answer I get is
[2, 6, 7]. This is 0-indexed so candidates 3, 7, and 8 are elected. This does not match the JS tests, where candidates 1, 3, and 7 are elected:https://github.com/Equal-Vote/star-core/blob/9a554932e4fc7ae9d6eb295fa1076437c812f5ce/src/Tests/pr.test.js#L129
What is happening
Let's look at the normalized ballots. Each row is a voter and each column is a candidate. There are 15 voters and eight candidates for a 3 seat election:
The first problem happens in column 2. We are summing to find the candidate with the highest score, so for column 2, we do this:
0.6+0.8+0.4+0.2+0.2+0.4+0.6+0.4+0.0+0.8+0.6+0.6+0.0+0.2+0.8.starpy/starpy/Allocated_Score.py
Line 21 in 77e00d9
In JS, this is
6.599999999999999. In my Python debugger and script, it is also6.599999999999999. However when pandas actually sums it up, it becomes6.6.Here's what my debugger session looks like:
Hence it is not an artifact of pandas/numpy rounding for printing.
Compare the sums with the sums in JS (emphasis mine):
(Setting a breakpoint here: https://github.com/Equal-Vote/star-core/blob/9a554932e4fc7ae9d6eb295fa1076437c812f5ce/src/StarResults.js#L442)
For comparison, normal Python sums column 5 to
6.6, and column 6 to6.6000000000000005. So this is a problem with pandas/numpyThe consequences is that indices 2, 5, and 6 gets the same value of
6.6in Python, but in JS they are all different. In JS, the highest score belongs to candidate 6, but since there is a tie in Python, it arbitrarily selects index 2.Python elects index 2 then 6. JS elects index 6 then 2. But the error has cascaded too much for the final seat. The out of order election resulted in different re-weightings. See the debugging values:
Raw data (hopefully I transcribed it correctly)
The sum of scores at the end of the 2nd round is completely different, causing Python to elect candidate 8 and JS to elect candidate 1.
What can we do about this?
Some projects such as tallystick offers the ability to use exact representations for rational fractions, or fixed point decimals. (For example, Scottish Councils use 5 d.p. for STV)
I'm not engaged in the STAR project, but I think if the code is supposed to be a "gold standard" then it might be worth it to show that it is rigorous, production, and real world ready.
I know it's not easy to change something so fundamental, and I totally understand because I haven't bothered with more exact decimals in my own project too. But at least people can see this issue and evaluate this for their own needs. I might not need this issue to be fixed, but hopefully it will save time for someone else who does.
(I stumbled on to this issue, because I was going to use the JS tests to validate my own implementation. This has implications for #2 and #9 as the JS tests couldn't be simply copied to Python)
Misc
requirements.txtstatespandas>=1.3.4)requirements.txtstatesnumpy>=1.21.4)This issue is appropriate for this repo or the star-core repo, but I think this repo the most appropriate because only pandas shows this behavior
See also