From b88805e4bf5245a4660c4abd97736c508409544e Mon Sep 17 00:00:00 2001 From: Jack Gibson Date: Sun, 20 Aug 2023 11:27:48 -0400 Subject: [PATCH 1/6] added project urls --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 72d7dbc7..e6dc46a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,6 @@ build-backend = "poetry.core.masonry.api" [tool.ruff] line-length = 100 + +[project.urls] +repository = "https://github.com/mggg/VoteKit" From dedcdd84620422db4587e2dea0c41ad789a1808d Mon Sep 17 00:00:00 2001 From: Jack Gibson Date: Wed, 23 Aug 2023 18:52:41 -0400 Subject: [PATCH 2/6] draft for resloving ties --- src/votekit/election_base.py | 95 ++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/votekit/election_base.py diff --git a/src/votekit/election_base.py b/src/votekit/election_base.py new file mode 100644 index 00000000..6ec94e61 --- /dev/null +++ b/src/votekit/election_base.py @@ -0,0 +1,95 @@ +from abc import ABC, abstractmethod +from typing import Any +from itertools import permutations +import math + +from .ballot import Ballot +from .pref_profile import PreferenceProfile + + +class Election(ABC): + """ + Abstract base class for election types. + + Includes functions to resolve input ties included in PreferenceProfile + """ + + def ___init__(self, profile: PreferenceProfile, *args: Any, **kwargs: Any): + self.profile = None + + @abstractmethod + def run_step(self): + pass + + @abstractmethod + def run_election(self): + pass + + def resolve_input_ties(self, profile: PreferenceProfile) -> PreferenceProfile: + """ + Takes in a PeferenceProfile with potential ties in a ballot. Replaces + ballots with ties with fractionally weighted ballots corresonding to + all permutation of the tied ranking + """ + new_ballots = [] + old_ballots = profile.get_ballots() + + for ballot in old_ballots: + if not any(len(rank) > 1 for rank in ballot.ranking): + new_ballots.append(ballot) + else: + num_ties = 0 + for rank in ballot.ranking: + if len(rank) > 1: + num_ties += 1 + + resolved_ties = fix_ties(ballot) + new_ballots += recursively_fix_ties(resolved_ties, num_ties=1) + + return PreferenceProfile(ballots=new_ballots) + + +# helpers +def recursively_fix_ties(ballot_lst: list[Ballot], num_ties: int) -> list[Ballot]: + """ + Recursively fixes ties in a ballot in the case there is more then one tie + """ + # base case, if only one tie to resolved return the list of already + # resolved ballots + if num_ties == 1: + return ballot_lst + + # in the event multiple positions have ties + else: + updated_lst = [] + for ballot in ballot_lst: + updated_lst += fix_ties(ballot) + + return recursively_fix_ties(updated_lst, num_ties - 1) + + +def fix_ties(ballot: Ballot) -> list[Ballot]: + """ + Helper function for recursively_fix_ties. Resolves the first appearing + tied rank in the input ballot by return list of permuted ballots + """ + + ballots = [] + for idx, rank in ballot.ranking: + if len(rank) > 1: + for order in permutations(rank): + resolved = [] + for cand in order: + resolved.append(set(cand)) + ballots.append( + Ballot( + id=ballot.id, + ranking=ballot.ranking[:idx] + + resolved + + ballot.ranking[idx + 1 :], + weight=ballot.weight / math.factorial(len(rank)), + voters=ballot.voters, + ) + ) + + return ballots From 74b729e469f20b7eb53846f7ffd2a28bbcf9ecfa Mon Sep 17 00:00:00 2001 From: Jack Gibson Date: Wed, 23 Aug 2023 19:12:46 -0400 Subject: [PATCH 3/6] inheritance for STV --- src/votekit/election_types.py | 38 ++++++++++++++--------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/src/votekit/election_types.py b/src/votekit/election_types.py index 70214317..c7351953 100644 --- a/src/votekit/election_types.py +++ b/src/votekit/election_types.py @@ -5,6 +5,7 @@ from typing import Callable, Optional from .ballot import Ballot +from .election_base import Election from .election_state import ElectionState from .graphs.pairwise_comparison_graph import PairwiseComparisonGraph from .pref_profile import PreferenceProfile @@ -18,7 +19,7 @@ ) -class STV: +class STV(Election): """ Class for single-winner IRV and multi-winner STV elections """ @@ -29,29 +30,20 @@ def __init__( transfer: Callable, seats: int, quota: str = "droop", + ties: bool = False, ): """ profile (PreferenceProfile): initial perference profile transfer (function): vote transfer method such as fractional transfer seats (int): number of winners/size of committee """ - self.__profile = profile self.transfer = transfer self.seats = seats - self.election_state = ElectionState( - curr_round=0, - elected=[], - eliminated=[], - remaining=[ - cand - for cand, votes in compute_votes( - profile.get_candidates(), profile.get_ballots() - ) - ], - profile=profile, - ) self.threshold = self.get_threshold(quota) + # let parent class handle the og profile and election state + super().__init__(profile, ties) + # can cache since it will not change throughout rounds def get_threshold(self, quota: str) -> int: quota = quota.lower() @@ -66,22 +58,22 @@ def next_round(self) -> bool: """ Determines if the number of seats has been met to call election """ - return len(self.election_state.get_all_winners()) != self.seats + return len(self.state.get_all_winners()) != self.seats def run_step(self) -> ElectionState: """ Simulates one round an STV election """ ##TODO:must change the way we pass winner_votes - remaining: list[str] = self.election_state.remaining - ballots: list[Ballot] = self.election_state.profile.get_ballots() + remaining: list[str] = self.state.remaining + ballots: list[Ballot] = self.state.profile.get_ballots() fp_votes = compute_votes(remaining, ballots) ##fp means first place elected = [] eliminated = [] # if number of remaining candidates equals number of remaining seats, # everyone is elected - if len(remaining) == self.seats - len(self.election_state.get_all_winners()): + if len(remaining) == self.seats - len(self.state.get_all_winners()): elected = [cand for cand, votes in fp_votes] remaining = [] ballots = [] @@ -112,15 +104,15 @@ def run_step(self) -> ElectionState: ballots = remove_cand(lp_cand, ballots) remaining.remove(lp_cand) - self.election_state = ElectionState( - curr_round=self.election_state.curr_round + 1, + self.state = ElectionState( + curr_round=self.state.curr_round + 1, elected=elected, eliminated=eliminated, remaining=remaining, profile=PreferenceProfile(ballots=ballots), - previous=self.election_state, + previous=self.state, ) - return self.election_state + return self.state def run_election(self) -> ElectionState: """ @@ -134,7 +126,7 @@ def run_election(self) -> ElectionState: while self.next_round(): self.run_step() - return self.election_state + return self.state class Limited: From cb53a24b588351f1aa335c7785a761d34fc556ac Mon Sep 17 00:00:00 2001 From: Jack Gibson Date: Thu, 24 Aug 2023 10:25:36 -0400 Subject: [PATCH 4/6] tests for fix ballots/recursion --- src/votekit/ballot.py | 3 ++ src/votekit/election_base.py | 21 +++++++----- tests/test_models.py | 63 ++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/votekit/ballot.py b/src/votekit/ballot.py index bbe68df7..a33a10bd 100644 --- a/src/votekit/ballot.py +++ b/src/votekit/ballot.py @@ -44,3 +44,6 @@ def __eq__(self, other): return False return True + + def __hash__(self): + return hash(str(self.ranking)) diff --git a/src/votekit/election_base.py b/src/votekit/election_base.py index 6ec94e61..264c03ab 100644 --- a/src/votekit/election_base.py +++ b/src/votekit/election_base.py @@ -1,10 +1,10 @@ from abc import ABC, abstractmethod -from typing import Any from itertools import permutations import math from .ballot import Ballot from .pref_profile import PreferenceProfile +from .election_state import ElectionState class Election(ABC): @@ -14,8 +14,13 @@ class Election(ABC): Includes functions to resolve input ties included in PreferenceProfile """ - def ___init__(self, profile: PreferenceProfile, *args: Any, **kwargs: Any): - self.profile = None + def __init__(self, profile: PreferenceProfile, ties: bool = False): + if ties: + self.__profile = profile + else: + self.__profile = self.resolve_input_ties(profile) + + self.state = ElectionState(curr_round=0, profile=self.__profile) @abstractmethod def run_step(self): @@ -44,7 +49,7 @@ def resolve_input_ties(self, profile: PreferenceProfile) -> PreferenceProfile: num_ties += 1 resolved_ties = fix_ties(ballot) - new_ballots += recursively_fix_ties(resolved_ties, num_ties=1) + new_ballots += recursively_fix_ties(resolved_ties, num_ties) return PreferenceProfile(ballots=new_ballots) @@ -61,11 +66,11 @@ def recursively_fix_ties(ballot_lst: list[Ballot], num_ties: int) -> list[Ballot # in the event multiple positions have ties else: - updated_lst = [] + update = set() for ballot in ballot_lst: - updated_lst += fix_ties(ballot) + update.update(set(fix_ties(ballot))) - return recursively_fix_ties(updated_lst, num_ties - 1) + return recursively_fix_ties(list(update), num_ties - 1) def fix_ties(ballot: Ballot) -> list[Ballot]: @@ -75,7 +80,7 @@ def fix_ties(ballot: Ballot) -> list[Ballot]: """ ballots = [] - for idx, rank in ballot.ranking: + for idx, rank in enumerate(ballot.ranking): if len(rank) > 1: for order in permutations(rank): resolved = [] diff --git a/tests/test_models.py b/tests/test_models.py index 53a00c4a..587fb88b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,6 @@ import pytest from unittest.mock import MagicMock +from fractions import Fraction from votekit.ballot_generator import ( PlackettLuce, @@ -7,7 +8,9 @@ AlternatingCrossover, ) from votekit.models import Simulation +from votekit.ballot import Ballot from votekit.pref_profile import PreferenceProfile +from votekit.election_base import fix_ties, recursively_fix_ties class DummyGenerated(Simulation): @@ -53,3 +56,63 @@ def test_gen_with_real_data(): params = {} with pytest.raises(TypeError): model.generate_ballots(num_ballots=10, candidates=cands, hyperparams=params) + + +profile = PreferenceProfile( + ballots=[ + Ballot(ranking=[{"A"}, {"B"}, {"C"}], weight=Fraction(4)), + Ballot(ranking=[{"C"}, {"B"}, {"A"}], weight=Fraction(3)), + Ballot(ranking=[{"C"}, {"B"}], weight=Fraction(2)), + ] +) + + +def test_single_tie(): + tied = Ballot(ranking=[{"A"}, {"B", "D"}, {"C"}], weight=Fraction(4)) + resolved = [ + Ballot(ranking=[{"A"}, {"B"}, {"D"}, {"C"}], weight=Fraction(2)), + Ballot(ranking=[{"A"}, {"D"}, {"B"}, {"C"}], weight=Fraction(2)), + ] + + test = fix_ties(tied) + # order of permuted ballots is stochastic so we can't test if the two lists + # are equal + assert set(resolved) == (set(test)) + assert len(test) == 2 + + +def test_tie_for_last(): + tied = Ballot(ranking=[{"A"}, {"B"}, {"D"}, {"C", "E"}], weight=Fraction(2, 1)) + resolved = [ + Ballot(ranking=[{"A"}, {"B"}, {"D"}, {"C"}, {"E"}], weight=Fraction(1)), + Ballot(ranking=[{"A"}, {"B"}, {"D"}, {"E"}, {"C"}], weight=Fraction(1)), + ] + test = fix_ties(tied) + + assert set(test) == set(resolved) + + +def test_multiple_ties(): + tied = Ballot(ranking=[{"A"}, {"B", "D"}, {"C", "E"}], weight=Fraction(4)) + part = fix_ties(tied) + complete = recursively_fix_ties(part, 2) + + assert len(complete) == 4 + assert ( + Ballot(ranking=[{"A"}, {"B"}, {"D"}, {"C"}, {"E"}], weight=Fraction(1)) + in complete + ) + + +def test_all_ties(): + tied = Ballot(ranking=[{"A", "F"}, {"B", "D"}, {"C", "E"}], weight=Fraction(4)) + part = fix_ties(tied) + complete = recursively_fix_ties(part, 3) + + assert len(complete) == 8 + assert ( + Ballot( + ranking=[{"A"}, {"F"}, {"B"}, {"D"}, {"C"}, {"E"}], weight=Fraction(1, 2) + ) + in complete + ) From 75b8000b2aa0ef2e1b63c978d09e6de2486f1425 Mon Sep 17 00:00:00 2001 From: Jack Gibson Date: Thu, 24 Aug 2023 13:53:35 -0400 Subject: [PATCH 5/6] integrated election base class --- src/votekit/election_base.py | 100 ---------------------------- src/votekit/election_types.py | 120 ++++++++++++++++------------------ src/votekit/models.py | 96 +++++++++++++++++++++++++++ src/votekit/utils.py | 1 - tests/test_elections.py | 2 +- tests/test_elections_2.py | 24 ++++--- tests/test_models.py | 51 +++++++++++---- 7 files changed, 208 insertions(+), 186 deletions(-) delete mode 100644 src/votekit/election_base.py diff --git a/src/votekit/election_base.py b/src/votekit/election_base.py deleted file mode 100644 index 264c03ab..00000000 --- a/src/votekit/election_base.py +++ /dev/null @@ -1,100 +0,0 @@ -from abc import ABC, abstractmethod -from itertools import permutations -import math - -from .ballot import Ballot -from .pref_profile import PreferenceProfile -from .election_state import ElectionState - - -class Election(ABC): - """ - Abstract base class for election types. - - Includes functions to resolve input ties included in PreferenceProfile - """ - - def __init__(self, profile: PreferenceProfile, ties: bool = False): - if ties: - self.__profile = profile - else: - self.__profile = self.resolve_input_ties(profile) - - self.state = ElectionState(curr_round=0, profile=self.__profile) - - @abstractmethod - def run_step(self): - pass - - @abstractmethod - def run_election(self): - pass - - def resolve_input_ties(self, profile: PreferenceProfile) -> PreferenceProfile: - """ - Takes in a PeferenceProfile with potential ties in a ballot. Replaces - ballots with ties with fractionally weighted ballots corresonding to - all permutation of the tied ranking - """ - new_ballots = [] - old_ballots = profile.get_ballots() - - for ballot in old_ballots: - if not any(len(rank) > 1 for rank in ballot.ranking): - new_ballots.append(ballot) - else: - num_ties = 0 - for rank in ballot.ranking: - if len(rank) > 1: - num_ties += 1 - - resolved_ties = fix_ties(ballot) - new_ballots += recursively_fix_ties(resolved_ties, num_ties) - - return PreferenceProfile(ballots=new_ballots) - - -# helpers -def recursively_fix_ties(ballot_lst: list[Ballot], num_ties: int) -> list[Ballot]: - """ - Recursively fixes ties in a ballot in the case there is more then one tie - """ - # base case, if only one tie to resolved return the list of already - # resolved ballots - if num_ties == 1: - return ballot_lst - - # in the event multiple positions have ties - else: - update = set() - for ballot in ballot_lst: - update.update(set(fix_ties(ballot))) - - return recursively_fix_ties(list(update), num_ties - 1) - - -def fix_ties(ballot: Ballot) -> list[Ballot]: - """ - Helper function for recursively_fix_ties. Resolves the first appearing - tied rank in the input ballot by return list of permuted ballots - """ - - ballots = [] - for idx, rank in enumerate(ballot.ranking): - if len(rank) > 1: - for order in permutations(rank): - resolved = [] - for cand in order: - resolved.append(set(cand)) - ballots.append( - Ballot( - id=ballot.id, - ranking=ballot.ranking[:idx] - + resolved - + ballot.ranking[idx + 1 :], - weight=ballot.weight / math.factorial(len(rank)), - voters=ballot.voters, - ) - ) - - return ballots diff --git a/src/votekit/election_types.py b/src/votekit/election_types.py index c7351953..01d10e83 100644 --- a/src/votekit/election_types.py +++ b/src/votekit/election_types.py @@ -5,7 +5,7 @@ from typing import Callable, Optional from .ballot import Ballot -from .election_base import Election +from .models import Election from .election_state import ElectionState from .graphs.pairwise_comparison_graph import PairwiseComparisonGraph from .pref_profile import PreferenceProfile @@ -30,27 +30,27 @@ def __init__( transfer: Callable, seats: int, quota: str = "droop", - ties: bool = False, + ties: bool = True, ): """ profile (PreferenceProfile): initial perference profile transfer (function): vote transfer method such as fractional transfer seats (int): number of winners/size of committee """ + # let parent class handle the og profile and election state + super().__init__(profile, ties) + self.transfer = transfer self.seats = seats self.threshold = self.get_threshold(quota) - # let parent class handle the og profile and election state - super().__init__(profile, ties) - # can cache since it will not change throughout rounds def get_threshold(self, quota: str) -> int: quota = quota.lower() if quota == "droop": - return int(self.__profile.num_ballots() / (self.seats + 1) + 1) + return int(self._profile.num_ballots() / (self.seats + 1) + 1) elif quota == "hare": - return int(self.__profile.num_ballots() / self.seats) + return int(self._profile.num_ballots() / self.seats) else: raise ValueError("Misspelled or unknown quota type") @@ -65,7 +65,7 @@ def run_step(self) -> ElectionState: Simulates one round an STV election """ ##TODO:must change the way we pass winner_votes - remaining: list[str] = self.state.remaining + remaining: list[str] = self.state.profile.get_candidates() ballots: list[Ballot] = self.state.profile.get_ballots() fp_votes = compute_votes(remaining, ballots) ##fp means first place elected = [] @@ -80,7 +80,7 @@ def run_step(self) -> ElectionState: # TODO: sort remaining candidates by vote share # elect all candidates who crossed threshold - elif fp_votes[0][1] >= self.threshold: + elif fp_votes[0].votes >= self.threshold: for candidate, votes in fp_votes: if votes >= self.threshold: elected.append(candidate) @@ -129,9 +129,11 @@ def run_election(self) -> ElectionState: return self.state -class Limited: - def __init__(self, profile: PreferenceProfile, seats: int, k: int): - self.state = ElectionState(curr_round=0, profile=profile) +class Limited(Election): + def __init__( + self, profile: PreferenceProfile, seats: int, k: int, ties: bool = True + ): + super().__init__(profile, ties) self.seats = seats self.k = k @@ -199,9 +201,9 @@ def run_election(self) -> ElectionState: return outcome -class Bloc: - def __init__(self, profile: PreferenceProfile, seats: int): - self.state = ElectionState(curr_round=0, profile=profile) +class Bloc(Election): + def __init__(self, profile: PreferenceProfile, seats: int, ties: bool = True): + super().__init__(profile, ties) self.seats = seats """Bloc: This rule returns the m candidates with the highest m-approval scores. @@ -220,9 +222,9 @@ def run_election(self) -> ElectionState: return outcome -class SNTV: - def __init__(self, profile: PreferenceProfile, seats: int): - self.state = ElectionState(curr_round=0, profile=profile) +class SNTV(Election): + def __init__(self, profile: PreferenceProfile, seats: int, ties: bool = True): + super().__init__(profile, ties) self.seats = seats """Single nontransferable vote (SNTV): SNTV returns k candidates with the highest @@ -238,11 +240,16 @@ def run_election(self) -> ElectionState: return outcome -class SNTV_STV_Hybrid: +class SNTV_STV_Hybrid(Election): def __init__( - self, profile: PreferenceProfile, transfer: Callable, r1_cutoff: int, seats: int + self, + profile: PreferenceProfile, + transfer: Callable, + r1_cutoff: int, + seats: int, + ties: bool = True, ): - self.state = ElectionState(curr_round=0, profile=profile) + super().__init__(profile, ties) self.transfer = transfer self.r1_cutoff = r1_cutoff self.seats = seats @@ -302,9 +309,9 @@ def run_election(self) -> ElectionState: return outcome # type: ignore -class TopTwo: - def __init__(self, profile: PreferenceProfile): - self.state = ElectionState(curr_round=0, profile=profile) +class TopTwo(Election): + def __init__(self, profile: PreferenceProfile, ties: bool = True): + super().__init__(profile, ties) """Top Two: Top two eliminates all but the top two plurality vote getters, and then conducts a runoff between them, reallocating other ballots.""" @@ -326,9 +333,9 @@ def run_election(self) -> ElectionState: return outcome -class DominatingSets: - def __init__(self, profile: PreferenceProfile): - self.state = ElectionState(curr_round=0, profile=profile) +class DominatingSets(Election): + def __init__(self, profile: PreferenceProfile, ties: bool = True): + super().__init__(profile, ties) """Dominating sets: Return the tiers of candidates by dominating set, which is a set of candidates such that every candidate in the set wins @@ -362,9 +369,9 @@ def run_election(self) -> ElectionState: return outcome -class CondoBorda: - def __init__(self, profile: PreferenceProfile, seats: int): - self.state = ElectionState(curr_round=0, profile=profile) +class CondoBorda(Election): + def __init__(self, profile: PreferenceProfile, seats: int, ties: bool = True): + super().__init__(profile, ties) self.seats = seats """Condo-Borda: Condo-Borda returns candidates ordered by dominating set, @@ -393,33 +400,22 @@ def run_election(self) -> ElectionState: return outcome -class Plurality: +class Plurality(Election): """ Single or multi-winner plurality election """ - def __init__(self, profile: PreferenceProfile, seats: int): + def __init__(self, profile: PreferenceProfile, seats: int, ties: bool = True): + + super().__init__(profile, ties) self.seats = seats - self.election_state = ElectionState( - curr_round=0, - elected=[], - eliminated=[], - remaining=[ - cand - for cand, votes in compute_votes( - profile.get_candidates(), profile.get_ballots() - ) - ], - profile=profile, - ) - self.__profile = profile def run_step(self): """ Simulate 'step' of a plurarity election """ - candidates = self.__profile.get_candidates() - ballots = self.__profile.get_ballots() + candidates = self._profile.get_candidates() + ballots = self._profile.get_ballots() results = compute_votes(candidates, ballots) return ElectionState( @@ -427,38 +423,31 @@ def run_step(self): elected=[result.cand for result in results[: self.seats]], eliminated=[result.cand for result in results[self.seats :]], remaining=[], - profile=self.__profile, + profile=self._profile, ) run_election = run_step -class SequentialRCV: +class SequentialRCV(Election): """ class to run Sequential RCV election """ - def __init__(self, profile: PreferenceProfile, seats: int): + def __init__(self, profile: PreferenceProfile, seats: int, ties: bool = True): """ profile (PreferenceProfile): initial perference profile seats (int): number of winners/size of committee """ + super().__init__(profile, ties) self.seats = seats - self.profile = profile - self.election_state = ElectionState( - curr_round=0, - elected=[], - eliminated=[], - remaining=[], - profile=profile, - ) def run_step(self, old_profile: PreferenceProfile) -> ElectionState: """ Simulates a single step of the sequential RCV contest which is a full IRV election run on the current set of candidates """ - old_election_state = self.election_state + old_election_state = self.state IRVrun = STV(old_profile, transfer=seqRCV_transfer, seats=1) old_election = IRVrun.run_election() @@ -470,23 +459,23 @@ def run_step(self, old_profile: PreferenceProfile) -> ElectionState: # Updates profile with removed candidates updated_profile = PreferenceProfile(ballots=updated_ballots) - self.election_state = ElectionState( + self.state = ElectionState( curr_round=old_election_state.curr_round + 1, elected=list(elected_cand), profile=updated_profile, previous=old_election_state, remaining=old_election.remaining, ) - return self.election_state + return self.state def run_election(self) -> ElectionState: """ Simulates a complete sequential RCV contest. Will run rounds of elections until elected seats fill """ - old_profile = self.profile + old_profile = self._profile elected = [] # type: ignore - seqRCV_step = self.election_state + seqRCV_step = self.state while len(elected) < self.seats: seqRCV_step = self.run_step(old_profile) @@ -495,14 +484,15 @@ def run_election(self) -> ElectionState: return seqRCV_step -class Borda: +class Borda(Election): def __init__( self, profile: PreferenceProfile, seats: int, score_vector: Optional[list[Fraction]], + ties: bool = True, ): - self.state = ElectionState(curr_round=0, profile=profile) + super().__init__(profile, ties) self.seats = seats self.score_vector = score_vector diff --git a/src/votekit/models.py b/src/votekit/models.py index 5e2677b1..7ddf6761 100644 --- a/src/votekit/models.py +++ b/src/votekit/models.py @@ -1,9 +1,12 @@ from abc import ABC, abstractmethod from typing import Union, Any +from itertools import permutations +import math from .ballot_generator import BallotGenerator from .election_state import ElectionState from .pref_profile import PreferenceProfile +from .ballot import Ballot class Simulation(ABC): @@ -55,3 +58,96 @@ def generate_ballots( ballots.append((model_name, generator.generate_profile(num_ballots))) return ballots + + +class Election(ABC): + """ + Abstract base class for election types. + + Includes functions to resolve input ties included in PreferenceProfile + """ + + def __init__(self, profile: PreferenceProfile, ties: bool = True): + if ties: + self._profile = self.resolve_input_ties(profile) + else: + self._profile = profile + + self.state = ElectionState(curr_round=0, profile=self._profile) + + @abstractmethod + def run_step(self): + pass + + @abstractmethod + def run_election(self): + pass + + def resolve_input_ties(self, profile: PreferenceProfile) -> PreferenceProfile: + """ + Takes in a PeferenceProfile with potential ties in a ballot. Replaces + ballots with ties with fractionally weighted ballots corresonding to + all permutation of the tied ranking + """ + new_ballots = [] + old_ballots = profile.get_ballots() + + for ballot in old_ballots: + if not any(len(rank) > 1 for rank in ballot.ranking): + new_ballots.append(ballot) + else: + num_ties = 0 + for rank in ballot.ranking: + if len(rank) > 1: + num_ties += 1 + + resolved_ties = fix_ties(ballot) + new_ballots += recursively_fix_ties(resolved_ties, num_ties) + + return PreferenceProfile(ballots=new_ballots) + + +# helpers +def recursively_fix_ties(ballot_lst: list[Ballot], num_ties: int) -> list[Ballot]: + """ + Recursively fixes ties in a ballot in the case there is more then one tie + """ + # base case, if only one tie to resolved return the list of already + # resolved ballots + if num_ties == 1: + return ballot_lst + + # in the event multiple positions have ties + else: + update = set() + for ballot in ballot_lst: + update.update(set(fix_ties(ballot))) + + return recursively_fix_ties(list(update), num_ties - 1) + + +def fix_ties(ballot: Ballot) -> list[Ballot]: + """ + Helper function for recursively_fix_ties. Resolves the first appearing + tied rank in the input ballot by return list of permuted ballots + """ + + ballots = [] + for idx, rank in enumerate(ballot.ranking): + if len(rank) > 1: + for order in permutations(rank): + resolved = [] + for cand in order: + resolved.append(set(cand)) + ballots.append( + Ballot( + id=ballot.id, + ranking=ballot.ranking[:idx] + + resolved + + ballot.ranking[idx + 1 :], + weight=ballot.weight / math.factorial(len(rank)), + voters=ballot.voters, + ) + ) + + return ballots diff --git a/src/votekit/utils.py b/src/votekit/utils.py index 7d62010b..56f0223f 100644 --- a/src/votekit/utils.py +++ b/src/votekit/utils.py @@ -33,7 +33,6 @@ def compute_votes(candidates: list, ballots: list[Ballot]) -> list[CandidateVote """ Computes first place votes for all candidates in a preference profile """ - votes = {} for candidate in candidates: weight = Fraction(0) diff --git a/tests/test_elections.py b/tests/test_elections.py index ec5082c5..40ed34f1 100644 --- a/tests/test_elections.py +++ b/tests/test_elections.py @@ -187,7 +187,7 @@ def test_plurality(): Ballot(ranking=[{"A"}, {"C"}, {"B"}], weight=Fraction(3), voters={"andy"}), ] ) - election = Plurality(profile, seats=1) + election = Plurality(profile, seats=1, ties=True) results = election.run_election() assert results.get_all_winners() == ["A"] diff --git a/tests/test_elections_2.py b/tests/test_elections_2.py index dd8e44bd..c2f83538 100644 --- a/tests/test_elections_2.py +++ b/tests/test_elections_2.py @@ -4,8 +4,16 @@ from votekit.ballot import Ballot from votekit.cvr_loaders import rank_column_csv from votekit.election_state import ElectionState -import votekit.election_types as et +from votekit.election_types import ( + Bloc, + SNTV, + SNTV_STV_Hybrid, + DominatingSets, + CondoBorda, + Borda, +) from votekit.pref_profile import PreferenceProfile +from votekit.utils import fractional_transfer BASE_DIR = Path(__file__).resolve().parent @@ -32,25 +40,25 @@ def equal_electionstates(state1, state2): def compare_io_bloc(profile, seats, target_state): - bloc_election = et.Bloc(profile=profile, seats=seats) + bloc_election = Bloc(profile=profile, seats=seats) outcome = bloc_election.run_election() # Make assertions equal_electionstates(outcome, target_state) def compare_io_sntv(profile, seats, target_state): - sntv_election = et.SNTV(profile=profile, seats=seats) + sntv_election = SNTV(profile=profile, seats=seats) outcome = sntv_election.run_election() # Make assertions equal_electionstates(outcome, target_state) def compare_io_hybrid(profile, r1_cutoff, seats, target_state): - hybrid_election = et.SNTV_STV_Hybrid( + hybrid_election = SNTV_STV_Hybrid( profile=profile, r1_cutoff=r1_cutoff, seats=seats, - transfer=et.fractional_transfer, + transfer=fractional_transfer, ) outcome = hybrid_election.run_election() # Make assertions @@ -58,14 +66,14 @@ def compare_io_hybrid(profile, r1_cutoff, seats, target_state): def compare_io_domset(profile, target_state): - domset_election = et.DominatingSets(profile=profile) + domset_election = DominatingSets(profile=profile) outcome = domset_election.run_election() # Make assertions equal_electionstates(outcome, target_state) def compare_io_condoborda(profile, seats, target_state): - condoborda_election = et.CondoBorda( + condoborda_election = CondoBorda( profile=profile, seats=seats, ) @@ -75,7 +83,7 @@ def compare_io_condoborda(profile, seats, target_state): def compare_io_borda(profile, seats, score_vector, target_state): - borda_election = et.Borda(profile=profile, seats=seats, score_vector=score_vector) + borda_election = Borda(profile=profile, seats=seats, score_vector=score_vector) outcome = borda_election.run_election() # Make assertations equal_electionstates(outcome, target_state) diff --git a/tests/test_models.py b/tests/test_models.py index 587fb88b..ec7fb961 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,10 +7,11 @@ BradleyTerry, AlternatingCrossover, ) -from votekit.models import Simulation +from votekit.models import Simulation, fix_ties, recursively_fix_ties from votekit.ballot import Ballot from votekit.pref_profile import PreferenceProfile -from votekit.election_base import fix_ties, recursively_fix_ties +from votekit.election_types import STV +from votekit.utils import fractional_transfer class DummyGenerated(Simulation): @@ -58,15 +59,6 @@ def test_gen_with_real_data(): model.generate_ballots(num_ballots=10, candidates=cands, hyperparams=params) -profile = PreferenceProfile( - ballots=[ - Ballot(ranking=[{"A"}, {"B"}, {"C"}], weight=Fraction(4)), - Ballot(ranking=[{"C"}, {"B"}, {"A"}], weight=Fraction(3)), - Ballot(ranking=[{"C"}, {"B"}], weight=Fraction(2)), - ] -) - - def test_single_tie(): tied = Ballot(ranking=[{"A"}, {"B", "D"}, {"C"}], weight=Fraction(4)) resolved = [ @@ -116,3 +108,40 @@ def test_all_ties(): ) in complete ) + + +def test_resolve_ties_STV(): + profile = PreferenceProfile( + ballots=[ + Ballot(ranking=[{"C"}, {"B"}, {"A"}], weight=Fraction(3)), + Ballot(ranking=[{"C", "A"}, {"B", "D"}], weight=Fraction(2)), + ] + ) + + election = STV(profile, seats=1, transfer=fractional_transfer, ties=True) + out = election.state.profile + correct = PreferenceProfile( + ballots=[ + Ballot(ranking=[{"C"}, {"B"}, {"A"}], weight=Fraction(3)), + Ballot(ranking=[{"C"}, {"A"}, {"B"}, {"D"}], weight=Fraction(1, 2)), + Ballot(ranking=[{"C"}, {"A"}, {"D"}, {"B"}], weight=Fraction(1, 2)), + Ballot(ranking=[{"A"}, {"C"}, {"D"}, {"B"}], weight=Fraction(1, 2)), + Ballot(ranking=[{"A"}, {"C"}, {"B"}, {"D"}], weight=Fraction(1, 2)), + ] + ) + + assert correct == out + assert len(out.get_ballots()) == 5 + + +def test_allow_ties_STV(): + profile = PreferenceProfile( + ballots=[ + Ballot(ranking=[{"C"}, {"B"}, {"A"}], weight=Fraction(3)), + Ballot(ranking=[{"C", "A"}, {"B", "D"}], weight=Fraction(2)), + ] + ) + + election = STV(profile, seats=1, transfer=fractional_transfer, ties=False) + out = election.state.profile + assert profile == out From d793e3d1bf64229594a94fcbe2ee6e13fd9b6628 Mon Sep 17 00:00:00 2001 From: Jack Gibson Date: Thu, 24 Aug 2023 14:00:19 -0400 Subject: [PATCH 6/6] type hinting fix --- src/votekit/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/votekit/models.py b/src/votekit/models.py index 7ddf6761..1c699b94 100644 --- a/src/votekit/models.py +++ b/src/votekit/models.py @@ -76,11 +76,11 @@ def __init__(self, profile: PreferenceProfile, ties: bool = True): self.state = ElectionState(curr_round=0, profile=self._profile) @abstractmethod - def run_step(self): + def run_step(self, *args: Any, **kwargs: Any): pass @abstractmethod - def run_election(self): + def run_election(self, *args: Any, **kwargs: Any): pass def resolve_input_ties(self, profile: PreferenceProfile) -> PreferenceProfile: