-
Notifications
You must be signed in to change notification settings - Fork 29
Add Quadratic voting #346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
spoole626
wants to merge
3
commits into
mggg:feat/quadratic_voting
Choose a base branch
from
spoole626:feat/quadratic_voting
base: feat/quadratic_voting
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add Quadratic voting #346
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| """ | ||
|
spoole626 marked this conversation as resolved.
|
||
| profile_copy = copy.deepcopy(profile) | ||
| for b in profile_copy.ballots: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.