EDIT: best to ignore this. While it works fine for our election, in has weird behavior modes for elections with materially fewer winners than candidates. At heart the code can vastly overestimate the worst outcome a given ballot can encounter, and so overestimate the “happiness level”, pushing all of them toward 100. My bot suggested after-the-fact rescaling back into 0…100. Expedient, but papering over the fundamental flaw of overestimating worst cases so badly.
We worked at repairing that. and I have somewhat different code now, with a crisp accounting for worst possible cases. A surprise: the geometry of the space is such that it doesn’t work for 1-winner elections. Turns out that’s not a limiting case of k-winner as k approaches 1 from above. A 1-winner election only has 1 bit of “happiness”: a candidate in your top tier won, or they didn’t. End of story. Trying to create more nuance than just that is a fool’s errand.
“Happiness”, round 2
Major changes here. For example, if I give stars to 6 candidates
A:5 B:5 C:4 D:4 E:2 F:0
I’m saying I want A and B to be picked 1st and 2nd (but don’t care in which order), C and D to be picked 3rd and 4th (again regardless of order), E 5th, and F 6th.
If, say, E is picked first or second, I’m “less happy” than if E is picked third or fourth. But in all those cases, the outcome didn’t match my raw preference order, so in the current scheme simply doesn’t add 1 to the count of preference matches for E.
It’s throwing out too much info. Such a ballot defines 4 “tiers”, and their order matters to me (although not their abosolute star values).
A different measure calculates the tier structure of a ballot, and for each candidate computes the number of ranks in the outcome it’s removed from the relevant end of the “ideal rank range” the ballot’s tier structure expresses (or 0 if the candidate’s actual rank is within that range).
The higher that is, the more a ballot’s tier preferences weren’t achieved, so it’s more a measure of pain than of happiness
I’ll skip details here. Computing “happiness” requires reversing the sense of it, and then, finally rescale the full range of values seen to lie in the interval [0.0, 1..0].
So no more pretention to be an “absolute” measure of anything. At least one ballot will have normalized score 0, and at least one 100. That spreads the results across the full range so you can see the differences clearly.
And a value of 50 doesn’t mean “half satisfied” - it means “halfway between the happiest and least‑happy ballots in this election.” The distribution is such that many ballots were closer to the middle than the extremes. Which isn’t surprising - the mean and median of the normalized values are very close.
Normalized "happiness" deciles (10 roughly equal-size bins)
- lo: lowest happiness value in this bin
- hi: highest happiness value in this bin
- #: number of ballots in this bin
- %: percent of all ballots in this bin
- cum: percent of all ballots in this and earlier bins
this generally isn't exactly a multiple of 10, but
as close as the data allows for
lo hi # % cum
----- ----- - ----- ------
0.0 11.2 7 9.5% 9.5%
11.2 32.7 8 10.8% 20.3%
34.5 42.1 7 9.5% 29.7%
42.9 47.6 8 10.8% 40.5%
47.6 50.0 7 9.5% 50.0%
52.9 57.1 7 9.5% 59.5%
57.1 68.6 8 10.8% 70.3%
70.1 72.7 7 9.5% 79.7%
73.8 80.4 8 10.8% 90.5%
85.7 100.0 7 9.5% 100.0%
extremes
0.0 0.0 1 1.4% 1.4%
100.0 100.0 3 4.1% 100.0%
This was the unique ballot that scored lowest:
{'Barry Warsaw': 5,
'Thomas Wouters': 5,
'Gregory P. Smith': 4,
'Pablo Galindo Salgado': 3,
'Savannah Ostrowski': 3,
'Donghee Na': 0,
}
4 tiers, with “ideal rank ranges” 1-2, 3, 4-5, and 6. No candidate ended up in this ballot’s ideal range, and some quite far removed.
Is this a “better” measure? I think so, but no QED to be had with this kind of thing.
[EDIT: adding code for this measure - simple enough, but lots of steps]
ballot_happiness() code
# winners: list[str] is list of winners in order picked
# scands: list[str] is global iist of all candidates, unordered
def ballot_happiness(ballot, winners):
# Normalize ballot to include all candidates -
# Missing candidates get score 0
full_ballot = {c: ballot.get(c, 0) for c in scands}
# Build tiers from the full ballot
tiers = defaultdict(list)
for c, s in full_ballot.items():
tiers[s].append(c)
sorted_scores = sorted(tiers, reverse=True)
tier_list = [tiers[s] for s in sorted_scores]
# Compute ideal intervals for each tier ---
ideal_interval = {}
start = 1
for group in tier_list:
end = start + len(group) - 1
r = start, end
for c in group:
ideal_interval[c] = r
start = end + 1
# Assign actual ranks ---
# Winners get ranks 1..k
rank = {c: i+1 for i, c in enumerate(winners)}
# All non-winners share the same "last place" rank
last_rank = len(winners) + 1
for c in scands:
if c not in rank:
rank[c] = last_rank
# Compute displacement for each candidate ---
total_disp = 0
for c in scands:
r = rank[c]
s, e = ideal_interval[c]
total_disp += s - r if r < s else r - e if r > e else 0
# Maximum possible displacement for normalization.
# The largest rank in the ballot's tier structure is
# len(tiers).
# The largest rank among all candidates is
# last_rank (len(winners) + 1).
N = max(len(tiers), last_rank)
max_disp = sum(
max(s - 1, N - e)
for (s, e) in ideal_interval.values()
)
if max_disp == 0:
return 1.0
result = 1.0 - (total_disp / max_disp)
assert 0.0 <= result <= 1.0
return result