"""
File: grade_methods.py
Author: Wes Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu)
Date: September 24, 2023
Implementations of grading methods (also called evaluative methods).
"""
from itertools import product
import numpy as np
from pref_voting.voting_method import vm
[docs]
@vm(name="Score Voting")
def score_voting(
gprofile, curr_cands=None, evaluation_method="sum", ungraded_score=None
):
"""Return the candidates with the largest scores, where scores are evaluated using the
``evaluation_method``, where the default is summing the scores of the candidates. If
``curr_cands`` is provided, then the score vote is restricted to the candidates in ``curr_cands``.
If ``ungraded_score`` is None (the default), candidates not graded by any voter are excluded
from the scoring. If ``ungraded_score`` is a number, those candidates are instead given that score
(e.g. ``0``), so they are included in the comparison. This is used by approval and dis&approval,
where an ungraded candidate counts as a 0.
"""
curr_cands = gprofile.candidates if curr_cands is None else curr_cands
if evaluation_method == "sum":
evaluation_method_func = gprofile.sum
elif evaluation_method == "mean" or evaluation_method == "average":
evaluation_method_func = gprofile.avg
elif evaluation_method == "median": # returns lower median
evaluation_method_func = gprofile.median
if ungraded_score is None:
scores = {
c: evaluation_method_func(c) for c in curr_cands if gprofile.has_grade(c)
}
else:
scores = {
c: evaluation_method_func(c) if gprofile.has_grade(c) else ungraded_score
for c in curr_cands
}
if len(scores) == 0:
# no candidate is graded by anyone -- everyone is tied
return sorted(curr_cands)
max_score = max(scores.values())
return sorted([c for c in scores.keys() if scores[c] == max_score])
[docs]
@vm(name="Approval")
def approval(gprofile, curr_cands=None):
"""Return the approval vote of the grade profile ``gprofile``. If ``curr_cands`` is provided,
then the approval vote is restricted to the candidates in ``curr_cands``.
.. warning::
Approval Vote only works on Grade Profiles whose grades are a subset of {0, 1}.
Ballots may use both 0 and 1, or only 1 (approvals), with candidates left
ungraded by a voter counting as 0 (not approved).
"""
assert set(gprofile.grades).issubset({0, 1}), (
"The grades in the profile must be a subset of {0, 1}."
)
return score_voting(
gprofile, curr_cands=curr_cands, evaluation_method="sum", ungraded_score=0
)
@vm(name="Dis&approval")
def dis_and_approval(gprofile, curr_cands=None):
"""Return the Dis&approval vote of the grade profile ``gprofile``. If ``curr_cands`` is provided, then the dis&approval vote is restricted to the candidates in ``curr_cands``. See
https://link.springer.com/article/10.1007/s00355-013-0766-7 for more information.
.. warning::
Dis&approval only works on Grade Profiles whose grades are a subset of {-1, 0, 1}.
Ballots may use -1, 0, and 1, or only -1 and 1, with candidates left ungraded
by a voter counting as 0 (neither approved nor disapproved).
"""
assert set(gprofile.grades).issubset({-1, 0, 1}), (
"The grades in the profile must be a subset of {-1, 0, 1}."
)
return score_voting(
gprofile, curr_cands=curr_cands, evaluation_method="sum", ungraded_score=0
)
[docs]
@vm(name="Cumulative Voting")
def cumulative_voting(gprofile, curr_cands=None, max_total_grades=5):
"""Return the cumulative vote winner of the grade profile ``gprofile``. This is the
candidates with the largest sum of the grades where each voter submits a ballot of scores that sum
to ``max_total_grades``. If ``curr_cands`` is provided, then the cumulative vote is restricted to
the candidates in ``curr_cands``."""
assert sorted(gprofile.grades) == list(range(max_total_grades + 1)), (
f"For cumulative voting, the grades must be 0,...,{max_total_grades}."
)
assert all(
np.sum([g(c) for c in g.graded_candidates]) == max_total_grades
for g, _ in zip(*gprofile.grades_counts)
), f"For cumulative voting, each ballot must sum to {max_total_grades}."
return score_voting(gprofile, curr_cands=curr_cands, evaluation_method="sum")
[docs]
@vm(name="STAR")
def star(gprofile, curr_cands=None):
"""Identify the top two candidates according to the sum of the grades for each candidate. Then hold a runoff between the top two candidates where the candidate that is ranked above the other by the most voters is the winner. The candidates that move to the runoff round are: the candidate(s) with the largest sum of the grades and the candidate(s) with the 2nd largest sum of the grades (or perhaps tied for the largest sum of the grades). In the case of multiple candidates tied for the largest or 2nd largest sum of the grades, use parallel-universe tiebreaking: a candidate is a Star Vote winner if it is a winner in some head-to-head runoff as described. If the candidates are all tied for the largest sum of the grades, then all candidates are winners.
See https://starvoting.us for more information.
If ``curr_cands`` is provided, then the winners is restricted to the candidates in ``curr_cands``.
.. warning::
Star Vote only works on Grade Profiles that are based on 6 grades: 0, 1, 2, 3, 4, and 5.
"""
assert sorted(gprofile.grades) == [0, 1, 2, 3, 4, 5], (
"The grades in the profile must be {0, 1, 2, 3, 4, 5}."
)
curr_cands = gprofile.candidates if curr_cands is None else curr_cands
if len(curr_cands) == 1:
return list(curr_cands)
cand_to_scores = {c: gprofile.sum(c) for c in curr_cands if gprofile.has_grade(c)}
scores = sorted(list(set(cand_to_scores.values())), reverse=True)
max_score = scores[0]
first = [c for c in cand_to_scores.keys() if cand_to_scores[c] == max_score]
second = list()
if len(first) == 1:
second_score = scores[1]
second = [c for c in cand_to_scores.keys() if cand_to_scores[c] == second_score]
if len(second) > 0:
all_runoff_pairs = product(first, second)
else:
all_runoff_pairs = [(c1, c2) for c1, c2 in product(first, first) if c1 != c2]
winners = list()
for c1, c2 in all_runoff_pairs:
if gprofile.margin(c1, c2) > 0:
winners.append(c1)
elif gprofile.margin(c1, c2) < 0:
winners.append(c2)
elif gprofile.margin(c1, c2) == 0:
winners.append(c1)
winners.append(c2)
return sorted(list(set(winners)))
[docs]
def tiebreaker_diff(gprofile, cand, median_grade):
"""
Tiebreaker when the there are multiple candidates with the largest median grade.
The tiebreaker is the difference between the proportion of voters who grade the candidate higher than the median grade and the proportion of voters who grade the candidate lower than the median grade.
"""
prop_proponents = gprofile.proportion_with_higher_grade(cand, median_grade)
prop_opponents = gprofile.proportion_with_lower_grade(cand, median_grade)
return prop_proponents - prop_opponents
[docs]
def tiebreaker_relative_shares(gprofile, cand, median_grade):
"""
Tiebreaker when the there are multiple candidates with the largest median grade.
Returns the *relative shares* of the proponents and opponents of the candidate.
"""
prop_proponents = gprofile.proportion_with_higher_grade(cand, median_grade)
prop_opponents = gprofile.proportion_with_lower_grade(cand, median_grade)
return (prop_proponents - prop_opponents) / (2 * (prop_proponents + prop_opponents))
[docs]
def tiebreaker_normalized_difference(gprofile, cand, median_grade):
"""
Tiebreaker when the there are multiple candidates with the largest median grade.
Returns the *normalized difference* of the proponents and opponents of the candidate.
"""
prop_proponents = gprofile.proportion_with_higher_grade(cand, median_grade)
prop_opponents = gprofile.proportion_with_lower_grade(cand, median_grade)
return (prop_proponents - prop_opponents) / (
2 * (1 - prop_proponents - prop_opponents)
)
[docs]
def tiebreaker_majority_judgement(gprofile, cand, median_grade):
"""
Tiebreaker when the there are multiple candidates with the largest median grade.
Returns the proportion of voters assigning a higher grade than the median to cand if it is greater than the proportion of voters assigning a lower grade than the median to cand, otherwise return -1 * the proportion of voters assigning a lower grade than the median to cand.
"""
prop_proponents = gprofile.proportion_with_higher_grade(cand, median_grade)
prop_opponents = gprofile.proportion_with_lower_grade(cand, median_grade)
if prop_proponents > prop_opponents:
return prop_proponents
elif prop_opponents >= prop_proponents:
return -prop_opponents
[docs]
@vm(name="Majority Judgement")
def majority_judgement(gprofile, curr_cands=None):
"""
The Majority Judgement voting method as describe in Balinski and Laraki (https://mitpress.mit.edu/9780262545716/majority-judgment/).
"""
return greatest_median(
gprofile, curr_cands=curr_cands, tb_func=tiebreaker_majority_judgement
)