Score-based Voting

As we saw in the introductory notebook, in addition to ranking-based voting, there are also a host of election systems that make use of score-based ballots. By the end of this section, you should be comfortable with score ballots, Rating elections, and Cumulative elections and generators.

Score ballots

First, let’s revisit how to define score ballots.

from votekit import Ballot

score_ballot = Ballot(scores={"A": 4, "B": 3, "C": 4}, weight=3)
print(score_ballot)
print("ranking:", score_ballot.ranking)
Scores
A: 4.00
B: 3.00
C: 4.00
Weight: 3.0
ranking: None

Notice that despite the scores inducing the ranking \(A,C>B\), the ballot only knows the scores. This is to conceptually separate score ballots from ranking ballots. If you want to convert a score ballot to a ranking, you can use the score_dict_to_ranking function from the utils module.

from votekit.utils import score_dict_to_ranking

ranking = score_dict_to_ranking(score_ballot.scores)
print(ranking)

ranked_ballot = Ballot(ranking=ranking, weight=score_ballot.weight)
print(ranked_ballot)
(frozenset({'A', 'C'}), frozenset({'B'}))
Ranking
1.) A, C, (tie)
2.) B,
Weight: 3.0

If you had an entire profile of score ballots and wanted to convert them all to ranked, you could do so as follows.

from votekit import PreferenceProfile

score_profile = PreferenceProfile(
    ballots=[
        Ballot(scores={"A": 4, "B": 3, "C": 4}, weight=3),
        Ballot(scores={"A": 2, "B": 3, "C": 4}, weight=2),
        Ballot(scores={"A": 1, "B": 5, "C": 4}, weight=5),
        Ballot(scores={"A": 0, "B": 2, "C": 0}, weight=3),
    ]
)

print("Score profile\n", score_profile)

ranked_ballots = [
    Ballot(ranking=score_dict_to_ranking(b.scores), weight=b.weight)
    for b in score_profile.ballots
]

ranked_profile = PreferenceProfile(ballots=ranked_ballots)


print("Ranked profile\n", ranked_profile.df)
Score profile
 Profile contains rankings: False
Profile contains scores: True
Candidates: ('A', 'B', 'C')
Candidates who received votes: ('A', 'B', 'C')
Total number of Ballot objects: 4
Total weight of Ballot objects: 13.0

Ranked profile
              Ranking_1 Ranking_2 Ranking_3 Voter Set  Weight
Ballot Index
0               (A, C)       (B)       (~)        {}     3.0
1                  (C)       (B)       (A)        {}     2.0
2                  (B)       (C)       (A)        {}     5.0
3                  (B)       (~)       (~)        {}     3.0

Score ballots are flexible enough to allow any non-zero score, including negative scores. Scores of 0 are dropped from the dictionary. Note that not all election methods support negative scoring, but these elections in VoteKit validate your ballots and will raise a TypeError if an invalid score is passed.

score_ballot = Ballot(scores={"A": -1, "B": 3.14159, "C": 0}, weight=3)
print(score_ballot)
Scores
A: -1.00
B: 3.14
Weight: 3.0

Rating Election

In a Rating election, to fill \(m\) seats, voters score each candidate independently from \(0-L\), where \(L\) is some user-specified limit. The \(m\) winners are those with the highest total score.

from votekit.elections import Rating

score_profile = PreferenceProfile(
    ballots=[
        Ballot(scores={"A": 4, "B": 3, "C": 4}, weight=3),
        Ballot(scores={"A": 2, "B": 3, "C": 4}, weight=2),
        Ballot(scores={"A": 1, "B": 5, "C": 4}, weight=5),
        Ballot(scores={"A": 0, "B": 2, "C": 0}, weight=3),
    ]
)

# elect 1 seat, each voter can rate candidates up to 5 points independently
election = Rating(score_profile, m=1, L=5)
print(election)
      Status  Round
B    Elected      1
C  Remaining      1
A  Remaining      1

Let’s look at the score totals to convince ourselves B was the winner.

print(election.election_states[0].scores)
{'A': 21.0, 'B': 46.0, 'C': 40.0}

Now let’s see that the Rating election validates our profile before running the election. All of these code blocks should raise TypeErrors.

ranking_profile = PreferenceProfile(ballots=[Ballot(ranking=[{"A"}, {"B"}, {"C"}])])

# should raise a TypeError since this profile has no scores
try:
    election = Rating(ranking_profile, m=1, L=5)
except Exception as e:
    print(f"Found the following error:\n\t{e.__class__.__name__}: {e}")
Found the following error:
    TypeError: All ballots must have score dictionary.
negative_profile = PreferenceProfile(
    ballots=[Ballot(scores={"A": -1, "B": 3.14159, "C": 0})]
)

# should raise a TypeError since this profile has negative score
try:
    election = Rating(negative_profile, m=1, L=5)
except Exception as e:
    print(f"Found the following error:\n\t{e.__class__.__name__}: {e}")
Found the following error:
    TypeError: Ballot Scores
A: -1.00
B: 3.14
Weight: 1.0 must have non-negative scores.
over_L_profile = PreferenceProfile(ballots=[Ballot(scores={"A": 0, "B": 10, "C": 1})])

# should raise a TypeError since this profile has score over 5
try:
    election = Rating(over_L_profile, m=1, L=5)
except Exception as e:
    print(f"Found the following error:\n\t{e.__class__.__name__}: {e}")
Found the following error:
    TypeError: Ballot Scores
B: 10.00
C: 1.00
Weight: 1.0 violates score limit 5 per candidate.

Cumulative election

In a Cumulative election, voters can score each candidate as in a Rating election, but have a total budget of \(m\) points, where \(m\) is the number of seats to be filled. This means candidates cannot be scored independently, the total must sum to no more than \(m\).

Winners are those with highest total score. Giving a candidate multiple points is known as “plumping” the vote.

from votekit.elections import Cumulative

score_profile = PreferenceProfile(
    ballots=[
        Ballot(scores={"A": 2, "B": 0, "C": 0}, weight=3),
        Ballot(scores={"A": 1, "B": 1, "C": 0}, weight=2),
        Ballot(scores={"A": 0, "B": 0, "C": 2}, weight=5),
        Ballot(scores={"A": 0, "B": 2, "C": 0}, weight=4),
    ]
)

# elect 2 seat, each voter can rate candidates up to 2 points total
election = Cumulative(score_profile, m=2)
print(election)
print(election.get_ranking())
print(election.election_states[0].scores)
      Status  Round
B    Elected      1
C    Elected      1
A  Remaining      1
(frozenset({'B', 'C'}), frozenset({'A'}))
{'A': 8.0, 'B': 10.0, 'C': 10.0}

Here, B and C tied for 10 points and are thus elected in the same set.

Again, the Cumulative class does validation for us.

over_m_profile = PreferenceProfile(ballots=[Ballot(scores={"A": 0, "B": 2, "C": 1})])

# should raise a TypeError since this profile has total score over 2
try:
    election = Cumulative(over_m_profile, m=2)
except Exception as e:
    print(f"Found the following error:\n\t{e.__class__.__name__}: {e}")
Found the following error:
    TypeError: Ballot Scores
B: 2.00
C: 1.00
Weight: 1.0 violates total score budget 2.

Cumulative generator

We have a ballot generator that generates cumulative style ballots from a preference interval. It samples with replacement, thus allowing for the possibility that you give one candidate multiple points (this is known as “plumping”).

import votekit.ballot_generator as bg
from votekit import PreferenceInterval

m = 2
bloc_voter_prop = {"all_voters": 1}
slate_to_candidates = {"all_voters": ["A", "B", "C"]}

# the preference interval (80,15,5)
pref_intervals_by_bloc = {
    "all_voters": {"all_voters": PreferenceInterval({"A": 0.80, "B": 0.15, "C": 0.05})}
}

cohesion_parameters = {"all_voters": {"all_voters": 1}}

# the num_votes parameter says how many total points the voter is given
# for a cumulative election, this is m, the number of seats
# in a limited election, this could be less than m
cumu = bg.name_Cumulative(
    pref_intervals_by_bloc=pref_intervals_by_bloc,
    bloc_voter_prop=bloc_voter_prop,
    slate_to_candidates=slate_to_candidates,
    cohesion_parameters=cohesion_parameters,
    num_votes=m,
)

profile = cumu.generate_profile(number_of_ballots=100)
print(profile.df)
                B    A    C Voter Set  Weight
Ballot Index
0             1.0  1.0  NaN        {}    22.0
1             NaN  1.0  1.0        {}     8.0
2             NaN  2.0  NaN        {}    63.0
3             1.0  NaN  1.0        {}     2.0
4             2.0  NaN  NaN        {}     3.0
5             NaN  NaN  2.0        {}     2.0

Verify that the ballots make sense given the interval. A should receive the most votes.

Cumulative(profile, m)
      Status  Round
A    Elected      1
B    Elected      1
C  Remaining      1

Try it yourself

Change the preference interval and rerun the election. Does the profile make sense?

Conclusion

You have now seen score ballots, Rating elections, and Cumulative elections and generators. VoteKit also implements Limited elections, as well as approval elections, which are like score-based elections but each candidate can only be scored 0 or 1.