Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/votekit/elections/election_types/scores/quadratic.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make the strong assumption that by the time a ScoreProfile is passed to this election method, the profile only contains scores and not credits.

  • Provide meaningful errors when a profile has a value over budget
  • Provide a cleaning function that converts a ScoreProfile that has credits to one that only has vote counts.
  • Provide a cleaning function to remove illegal ballots (ballots over budget)

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import copy
import math

from votekit.elections.election_types.scores.rating import GeneralRating
from votekit.pref_profile import ScoreProfile


class Quadratic(GeneralRating):
"""
Quadratic election where the cost of casting multiple votes for the same candidate increases quadratically.

Each voter is given a fixed number of voting credits, which can be spent to support candidates. The
cost of casting multiple votes for the same candidate increases quadratically (i.e. n votes
cost n^2 credits). Note that the ballot score values can represent either votes or credits spend on
candidates, and you specify which it is in the boolean is_credits. Score values and k (the
credit budget) must be whole numbers.

Raises:
ValueError: k must be a whole number

Args:
profile (ScoreProfile): Profile to conduct election on
m (int, optional): Number of seats to elect. Defaults to 1.
k (float): Total budget per voter. k must be a whole number.
In the case of Quadratic voting, this refers to the total budget of credits a voter can spend.
is_credits (boolean): is_credits = True means that scores represent credits and
is_credits = False means scores represent votes.
tiebreak (str,optional): Tiebreak method to use. Options are None and 'random'.
Defaults to None, in which case a tie raises a ValueError.
"""

def __init__(
self,
profile: ScoreProfile,
m: int = 1,
k: float = 1,
is_credits=False,
tiebreak: str | None = None,
):
if isinstance(k, float) and not k.is_integer():
raise ValueError(f"Credit budget k must be a whole number.")
profile = self._check_credits(profile, k, is_credits)
super().__init__(profile, m=m, k=k, L=int(math.sqrt(k)), tiebreak=tiebreak)
# super().__init__(profile, m=m, k=k, L=k, tiebreak=tiebreak)

def _check_credits(self, profile: ScoreProfile, k=1, is_credits=False):
"""
Ensures that every ballot is within credit budget (k).

No matter if ballot scores was given in credits or votes, ensures that all ballots remain within
the budget.

Args:
profile (PreferenceProfile): Profile to validate.
k (float): Total budget per voter
is_credits (boolean): dictates whether scores are credits or votes

Raises:
ValueError: scores must be whole numbers
ValueError: When scores refer to credits, credits must be within budget k
ValueError: When scores refer to credits, credits must be perfect squares
ValueError: When scores refer to votes, votes squared must be within budget k

Returns: ScoreProfile where scores are the votes.
"""
Comment thread
spoole626 marked this conversation as resolved.
profile_copy = copy.deepcopy(profile)
for b in profile_copy.ballots:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's change this to loop over the dataframe

# check to make sure that scores are whole numbers
if b.scores is not None:
if not all(isinstance(v, float) and v.is_integer() for v in b.scores.values()):
raise ValueError(f"Scores must be whole numbers.")
if is_credits: # is_credits -> scores mean credits
# check to make sure credits are in budget
if sum(b.scores.values()) > k:
raise ValueError(f"Ballot {b} is above the credit budget.")
# check to make sure credits are perfect squares
if not all(float(math.sqrt(v)).is_integer() for v in b.scores.values()):
raise ValueError(
f"Ballot {b} score violates credit's perfect squares requirement."
)
# convert scores to votes (takes the square root of score values)
# b.scores = {key: math.sqrt(v) for key, v in b.scores.items()}
for v in b.scores:
b.scores[v] = math.sqrt(b.scores[v])
else: # not is_credits -> scores mean votes
# convert scores (votes) to credits
# check to make sure credits are in budget
if sum(v**2 for v in b.scores.values()) > k:
raise ValueError(f"Ballot {b} is above the credit budget.")
return profile_copy
118 changes: 118 additions & 0 deletions tests/elections/election_types/score/test_quadratic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import pytest

from votekit.ballot import ScoreBallot
from votekit.elections.election_types.scores.quadratic import Quadratic
from votekit.pref_profile import ScoreProfile

profile_no_tied_votes = ScoreProfile(
ballots=[
ScoreBallot(scores={"A": 2, "B": 1, "C": 1}, weight=2),
ScoreBallot(scores={"A": 1, "B": 0, "C": 1}, weight=2),
ScoreBallot(scores={"A": 2, "B": 1, "C": 1}),
]
)
# votes = 4, 2, 4
# credits = 6, 2, 6

profile_no_tied_credits = ScoreProfile(
ballots=[
ScoreBallot(scores={"A": 4, "B": 1, "C": 1}, weight=2),
ScoreBallot(scores={"A": 1, "B": 0, "C": 1}, weight=2),
ScoreBallot(scores={"A": 4, "B": 1, "C": 1}),
]
)
# votes = 4, 2, 4
# credits = 6, 2, 6

profile_tied_votes = ScoreProfile(
ballots=[
ScoreBallot(scores={"A": 1, "B": 2, "C": 1}),
ScoreBallot(scores={"A": 2, "B": 1, "C": 2}),
]
)
# votes = 4, 5
# credits = 6, 9

# TODO
profile_tied_credits = ScoreProfile(
ballots=[
ScoreBallot(scores={"A": 1, "B": 4, "C": 1}),
ScoreBallot(scores={"A": 4, "B": 1, "C": 4}),
]
)
# votes = 4, 5
# credits = 6, 9


def test_init():
# Votes
e = Quadratic(profile_no_tied_votes, k=6)
assert e.get_elected() == (frozenset({"A"}),)

e = Quadratic(profile_no_tied_votes, m=2, k=6)
assert e.get_elected() == (frozenset({"A"}), frozenset({"C"}))

# Credits
e = Quadratic(profile_no_tied_credits, k=6, is_credits=True)
assert e.get_elected() == (frozenset({"A"}),)

e = Quadratic(profile_no_tied_credits, m=2, k=6, is_credits=True)
assert e.get_elected() == (frozenset({"A"}), frozenset({"C"}))


def test_ties():

# Votes
e_random = Quadratic(profile_tied_votes, m=1, k=10, tiebreak="random")
assert len([c for s in e_random.get_elected() for c in s]) == 1

e_random = Quadratic(profile_tied_votes, m=2, k=10, tiebreak="random")
assert len([c for s in e_random.get_elected() for c in s]) == 2

e_random = Quadratic(profile_tied_votes, m=3, k=10, tiebreak="random")
assert e_random.get_elected() == (frozenset({"A", "C", "B"}),)

# Credits
e_random = Quadratic(profile_tied_credits, m=1, k=10, is_credits=True, tiebreak="random")
assert len([c for s in e_random.get_elected() for c in s]) == 1

e_random = Quadratic(profile_tied_credits, m=2, k=10, is_credits=True, tiebreak="random")
assert len([c for s in e_random.get_elected() for c in s]) == 2

e_random = Quadratic(profile_tied_credits, m=3, k=10, is_credits=True, tiebreak="random")
assert e_random.get_elected() == (frozenset({"A", "C", "B"}),)


def test_errors():
# Votes
# with pytest.raises(ValueError, match="Credit budget k must be a whole number."):
# Quadratic(profile_no_tied_votes, k = 1.5)

# Credits
with pytest.raises(ValueError, match="Credit budget k must be a whole number."):
Quadratic(profile_no_tied_credits, k=1.5)


def test_validate_profile():
# Both votes and credits
with pytest.raises(ValueError, match="Scores must be whole numbers."):
profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 3.5})])
Quadratic(profile, m=1, k=5)

with pytest.raises(ValueError, match="Scores must be whole numbers."):
profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 3.5})])
Quadratic(profile, m=1, k=5)

# Credits
with pytest.raises(ValueError, match="is above the credit budget."):
profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 9})])
Quadratic(profile, m=1, k=2, is_credits=True)

with pytest.raises(ValueError, match="score violates credit's perfect squares requirement."):
profile = ScoreProfile(ballots=[ScoreBallot(), ScoreBallot(scores={"A": 3})])
Quadratic(profile, m=1, k=4, is_credits=True)

# Votes
with pytest.raises(ValueError, match="is above the credit budget."):
profile = ScoreProfile(ballots=[ScoreBallot(scores={"A": 2})])
Quadratic(profile, m=1, k=3)
Loading