From a329fa4500e11bf554859fa074747c22c1de2bc3 Mon Sep 17 00:00:00 2001 From: Marat Date: Mon, 12 Mar 2018 23:31:00 -0400 Subject: [PATCH 1/4] docstring and pep8 fixes --- election.py | 177 ++++++++-------------------------------------------- tests.py | 104 ++++++++++++++---------------- 2 files changed, 73 insertions(+), 208 deletions(-) diff --git a/election.py b/election.py index 4614201..596fd65 100644 --- a/election.py +++ b/election.py @@ -11,7 +11,7 @@ __status__ = "Production" -class Candidate: +class Candidate(object): """Candidate with a name and unique identifier. Attributes: @@ -21,50 +21,20 @@ class Candidate: """ def __init__(self, uid, name=None): - """Initializes Candidate with name and uid. - - Args: - uid: String representing the unique identifier of the Candidate. - Used for equality, hashing, and ordering in a random tiebreak. - name: String representing the name of the Candidate. - """ self.uid = uid self.name = name def __eq__(self, other): - """Checks equality between two Candidates using uids. - - Args: - other: Candidate to check equality with. - - Returns: - Boolean indicating if the Candidates are equal or not. - """ if isinstance(other, Candidate): return self.uid == other.uid def __hash__(self): - """Returns the hash value of the Candidate. - - Returns: - Integer hash value of the Candidate. - """ return hash(self.uid) def __repr__(self): - """Returns a printable system representation of the Candidate. - - Returns: - String containing the printable representation of the Candidate. - """ return 'Candidate({!r}, name={!r})'.format(self.uid, self.name) def __str__(self): - """Returns a printable user representation of the Candidate. - - Returns: - String containing the printable representation of the Candidate. - """ return '{} ({})'.format(self.uid, self.name if self.name is not None else self.uid) @@ -78,20 +48,14 @@ class NoConfidence(Candidate): """ def __init__(self): - """Initializes NoConfidence.""" self.uid = 'NC' self.name = 'No Confidence' def __repr__(self): - """Returns a printable system representation of NoConfidence. - - Returns: - String containing the printable representation of NoConfidence. - """ return 'NoConfidence()' -class Ballot: +class Ballot(object): """Ballot consisting of ranked candidates and a vote value The vote value of the ballot is awarded to the most preferred candidate that @@ -118,25 +82,12 @@ def __init__(self, candidates=None, starting_rank=0, vote_value=1.0): self._preferred_active_rank = starting_rank def __eq__(self, other): - """Checks equality between two Ballots. - - Args: - other: Ballot to check equality with. - - Returns: - Boolean indicating if the Ballots are equal or not. - """ if isinstance(other, Ballot): return (self.candidates == other.candidates and self.vote_value == other.vote_value and self._preferred_active_rank == other._preferred_active_rank) def __repr__(self): - """Returns a printable system representation of the Ballot. - - Returns: - String containing the printable representation of the Ballot. - """ return 'Ballot(candidates={!r}, vote_value={!r}, starting_rank={!r})'.format( self.candidates, self.vote_value, self._preferred_active_rank) @@ -196,7 +147,7 @@ def set_candidates(self, candidates): self._preferred_active_rank = 0 -class VoteTracker: +class VoteTracker(object): """Vote Tracker for assigning votes to Candidates. Attributes: @@ -205,44 +156,24 @@ class VoteTracker: """ def __init__(self, votes_cast=0.0, votes_for_candidate=None): - """Initializes VoteTracker with votes cast and votes for candidates. - - Args: - votes_cast: Float value of the total votes cast. - votes_for_candidate: Dict mapping Candidates to float values of - votes. - """ self.votes_cast = votes_cast self._votes_for_candidate = (votes_for_candidate if votes_for_candidate is not None else dict()) def __eq__(self, other): - """Checks equality between two VoteTrackers. - - Args: - other: VoteTracker to check equality with. - - Returns: - Boolean indicating if the VoteTrackers are equal or not. - """ if isinstance(other, VoteTracker): return (self.votes_cast == other.votes_cast and self._votes_for_candidate == other._votes_for_candidate) def __repr__(self): - """Returns a printable system representation of the VoteTracker. - - Returns: - String containing the printable representation of the VoteTracker. - """ return 'VoteTracker(votes_for_candidate={!r}, votes_cast={!r})'.format( self._votes_for_candidate, self.votes_cast) def decription(self): """Returns a printable long-form user representation of the VoteTracker. - Returns: - String containing the printable representation of the VoteTracker. + :return String containing the printable representation of the + VoteTracker. """ description = 'VoteTracker for {} votes:'.format(self.votes_cast) for candidate in sorted(self._votes_for_candidate, key=self._votes_for_candidate.get, reverse=True): @@ -252,9 +183,8 @@ def decription(self): def cast_vote_for_candidate(self, candidate, vote_value): """Casts the vote for the Candidate, updating the stored vote totals. - Args: - candidate: Candidate to receive the vote. - vote_value: Float value of the vote. + :param candidate: Candidate to receive the vote. + :param vote_value: Float value of the vote. """ if candidate is None: @@ -278,31 +208,26 @@ def cast_vote_for_candidate(self, candidate, vote_value): def votes_for_candidate(self, candidate): """Returns the value of the votes for the given Candidate. - Args: - Candidate: Candidate to obtain the vote value. + :param candidate: Candidate to obtain the vote value. - Returns: - Float value of the votes for the Candidate. + :return Float value of the votes for the Candidate. """ return self._votes_for_candidate.get(candidate, 0.0) def candidates(self): """Returns the Candidate(s) being tracked. - Returns: - Set of candidates being tracked. + :return Set of candidates being tracked. """ return set(self._votes_for_candidate.keys()) def candidates_reaching_threshold(self, candidates, threshold): """Returns the Candidate(s) with vote values meeting the threshold. - Args: - candidates: Set of Candidates to check. - threshold: Float value of the vote threshold. + :param candidates: Set of Candidates to check. + :param threshold: Float value of the vote threshold. - Returns: - Set of Candidates meeting the vote threshold. + :return Set of Candidates meeting the vote threshold. """ candidates_reaching_threshold = set() @@ -316,11 +241,9 @@ def candidates_reaching_threshold(self, candidates, threshold): def candidates_with_fewest_votes(self, candidates): """Returns the Candidate(s) with the fewest votes. - Args: - candidates: Set of Candidates to check. + :param candidates: Set of Candidates to check. - Returns: - Set of Candidates with the fewest votes. + :return Set of Candidates with the fewest votes. """ candidates_with_fewest_votes = set() fewest_votes = -1 @@ -338,7 +261,7 @@ def candidates_with_fewest_votes(self, candidates): return candidates_with_fewest_votes -class ElectionRound: +class ElectionRound(object): """Election data for a round of voting. Attributes: @@ -353,16 +276,6 @@ class ElectionRound: def __init__(self, candidates_elected=None, candidates_eliminated=None, threshold=0, random_tiebreak_occurred=False, vote_tracker=None): - """Initializes ElectionRound with threshold, Candidate, and vote data. - - Args: - candidates_elected: Set of Candidates elected in this round. - candidates_eliminated: Set of Candidates eliminated in this round. - threshold: Float value of the vote threshold to be elected. - random_tiebreak_occured: Boolean indicating if a random tiebreak - occurred or not in this round. - vote_tracker: VoteTracker for counting votes in this round. - """ self.threshold = threshold self.candidates_elected = (candidates_elected if candidates_elected is not None else set()) self.candidates_eliminated = (candidates_eliminated if candidates_eliminated is not None else set()) @@ -370,11 +283,6 @@ def __init__(self, candidates_elected=None, candidates_eliminated=None, self.vote_tracker = (vote_tracker if vote_tracker is not None else VoteTracker()) def __repr__(self): - """Returns a printable system representation of the ElectionRound. - - Returns: - String containing the printable representation of the ElectionRound. - """ return 'ElectionRound(threshold={!r}, candidates_elected={!r}, candidates_eliminated={!r}, random_tiebreak_occurred={}, vote_tracker={!r})'.format( self.threshold, self.candidates_elected, self.candidates_eliminated, self.random_tiebreak_occurred, self.vote_tracker) @@ -402,7 +310,7 @@ def description(self): return description -class ElectionResults: +class ElectionResults(object): """Election results and data for all rounds. Attributes: @@ -418,17 +326,6 @@ class ElectionResults: def __init__(self, ballots, candidates_elected, election_rounds, random_alphanumeric, seats, name=''): - """Initializes ElectionResults with election results and data. - - Args: - ballots: List of all Ballots. - candidates_elected: Set of Candidates elected. - election_rounds: List of ElectionRounds. - random_alphanumeric: String containing the random alphanumeric used - for final tiebreaks. - seats: Number of vacant seats before the election. - name: String representing the name of the election. - """ self.ballots = ballots self.candidates_elected = candidates_elected self.election_rounds = election_rounds @@ -437,12 +334,6 @@ def __init__(self, ballots, candidates_elected, self.seats = seats def __repr__(self): - """Returns a printable system representation of the ElectionResults. - - Returns: - String containing the printable representation of the - ElectionResults. - """ return 'ElectionResults(name={!r}, seats={!r}, ballots={!r}, random_alphanumeric={!r}, candidates_elected={!r}, election_rounds={!r})'.format( self.name, self.seats, self.ballots, self.random_alphanumeric, self.candidates_elected, self.election_rounds) @@ -450,9 +341,8 @@ def description(self): """Returns a printable long-form user representation of the ElectionResults. - Returns: - String containing the printable representation of the - ElectionResults. + :return String containing the printable representation of the + ElectionResults. """ description = 'Results for election {}:\n'.format(self.name) if len(self.candidates_elected) > 0: @@ -468,7 +358,7 @@ def description(self): return description -class Election: +class Election(object): """Election configuration and computation. Attributes: @@ -486,19 +376,6 @@ class Election: def __init__(self, ballots, seats, can_eliminate_no_confidence=True, can_random_tiebreak=True, name='', random_alphanumeric=None): - """Initializes Election with ballots, seats, and configuration data. - - Args: - ballots: List of all Ballots. - seats: Number of vacant seats before the election. - can_eliminate_no_confidence: Boolean indicating if No Confidence may - be eliminated in the election. - can_random_tiebreak: Boolean indicating if random elimination may be - used for final tiebreaks. Otherwise, the election is halted. - name: String representing the name of the election. - random_alphanumeric: String containing the rcandom alphanumeric used - for final tiebreaks. - """ self.ballots = ballots self.can_eliminate_no_confidence = can_eliminate_no_confidence self.can_random_tiebreak = can_random_tiebreak @@ -512,18 +389,16 @@ def droop_quota(self, seats, votes): This threshold is the minimum number of votes a candidate must receive in order to be elected outright. - Args: - seats_vacant: Integer value of the seats vacant. - votes: Float value of the value of votes cast. - Returns: An int representing the vote quota + :param seats: Integer value of the seats vacant. + :param votes: Float value of the value of votes cast. + :return An int representing the vote quota """ return (float(votes) / (float(seats) + 1.0)) + 1.0 def compute_results(self): """Run the election using the single transferable vote algorithm. - Returns: - ElectionResults containing the election results and data. + :return ElectionResults containing the election results and data. """ election_rounds = list() @@ -764,7 +639,9 @@ def compute_results(self): # Sort the candidates by uid according to the random # alphanumeric. - candidates_random_sort = sorted(candidates_to_eliminate, key=lambda candidate: [tiebreak_alphanumeric.index(c) for c in candidate.uid]) + candidates_random_sort = sorted( + candidates_to_eliminate, + key=lambda candidate: [tiebreak_alphanumeric.index(c) for c in candidate.uid]) # Eliminate the first candidate in this random sort that is not # No Confidence. for candidate in candidates_random_sort: diff --git a/tests.py b/tests.py index 26c913c..5774a2b 100644 --- a/tests.py +++ b/tests.py @@ -13,6 +13,35 @@ __license__ = "GPLv3" __status__ = "Production" +CANDIDATE_IDS = { + 'A': ('dgund', 'Devin Gund'), + 'B': ('gwashington', 'George Washington'), + 'C': ('jadams', 'John Adams'), + 'D': ('tjefferson', 'Thomas Jefferson'), + 'E': ('jmadison', 'James Madison'), + 'F': ('jmonroe', 'James Monroe'), + 'G': ('jqadams', 'John Quincy Adams'), + 'H': ('ajackson', 'Andrew Jackson'), + 'I': ('mvburen', 'Martin Van Buren'), + 'J': ('wharrison', 'William Harrison'), + 'K': ('jtyler', 'John Tyler'), + 'L': ('jpolk', 'James Polk'), + 'M': ('ztaylor', 'Zachary Taylor'), + 'N': ('mfillmore', 'Millard Fillmore'), + 'O': ('fpierce', 'Franklin Pierce'), + 'P': ('jbuchanan', 'James Buchanan'), + 'Q': ('alincoln', 'Abraham Lincoln'), + 'R': ('ajohnson', 'Andrew Johnson'), + 'S': ('ugrant', 'Ulysses Grant'), + 'T': ('rhayes', 'Rutherford Hayes'), + 'U': ('jgarfield', 'James Garfield'), + 'V': ('carthur', 'Chester Arthur'), + 'W': ('gcleveland', 'Grover Cleveland'), + 'X': ('bharrison', 'Benjamin Harrison'), + 'Y': ('wmckinley', 'William McKinley'), + 'Z': ('troosevelt', 'Theodore Roosevelt'), +} + def candidates_for_ids(candidate_ids): """Returns an ordered list of Candidates for an ordered list of ids. @@ -21,75 +50,34 @@ def candidates_for_ids(candidate_ids): short strings (i.e. 'A', 'B', 'NC') in the test cases, which can then be converted to proper Candidates. - Args: - candidate_ids: List of strings representing candidate ids. + :param candidate_ids: List of strings representing candidate ids. - Returns: - List of Candidates corresponding to the given ids. + :return List of Candidates corresponding to the given ids. """ - candidate_for_id = { - 'NC': NoConfidence(), - 'A': Candidate('dgund', name='Devin Gund'), - 'B': Candidate('gwashington', name='George Washington'), - 'C': Candidate('jadams', name='John Adams'), - 'D': Candidate('tjefferson', name='Thomas Jefferson'), - 'E': Candidate('jmadison', name='James Madison'), - 'F': Candidate('jmonroe', name='James Monroe'), - 'G': Candidate('jqadams', name='John Quincy Adams'), - 'H': Candidate('ajackson', name='Andrew Jackson'), - 'I': Candidate('mvburen', name='Martin Van Buren'), - 'J': Candidate('wharrison', name='William Harrison'), - 'K': Candidate('jtyler', name='John Tyler'), - 'L': Candidate('jpolk', name='James Polk'), - 'M': Candidate('ztaylor', name='Zachary Taylor'), - 'N': Candidate('mfillmore', name='Millard Fillmore'), - 'O': Candidate('fpierce', name='Franklin Pierce'), - 'P': Candidate('jbuchanan', name='James Buchanan'), - 'Q': Candidate('alincoln', name='Abraham Lincoln'), - 'R': Candidate('ajohnson', name='Andrew Johnson'), - 'S': Candidate('ugrant', name='Ulysses Grant'), - 'T': Candidate('rhayes', name='Rutherford Hayes'), - 'U': Candidate('jgarfield', name='James Garfield'), - 'V': Candidate('carthur', name='Chester Arthur'), - 'W': Candidate('gcleveland', name='Grover Cleveland'), - 'X': Candidate('bharrison', name='Benjamin Harrison'), - 'Y': Candidate('wmckinley', name='William McKinley'), - 'Z': Candidate('troosevelt', name='Theodore Roosevelt'), - } - - candidates = [] - for candidate_id in candidate_ids: - candidates.append(candidate_for_id[candidate_id]) - return candidates + + return [Candidate(CANDIDATE_IDS[candidate_id]) + if candidate_id != "NC" else NoConfidence() + for candidate_id in candidate_ids] def ballots_for_candidates(candidates, count): """Returns a list of Ballots for the given Candidates and count. - Args: - candidates: List of Candidates. - count: Integer value of the number of Ballots to create. + :param candidates: List of Candidates. + :param count: Integer value of the number of Ballots to create. - Returns: - List of Ballots for the given Candidates, with the given count. + :return List of Ballots for the given Candidates, with the given count. """ - ballots = [] - for i in range(count): - ballot = Ballot() - ballot.set_candidates(candidates) - ballots.append(ballot) - return ballots + return [Ballot(candidates) for _ in range(count)] def ballots_for_ids(candidate_ids, count): """Returns a list of Ballots for the given ids and count. - Args: - candidate_ids: List of strings representing candidate ids. - count: Integer value of the number of Ballots to create. + :param candidate_ids: List of strings representing candidate ids. + :param count: Integer value of the number of Ballots to create. - Returns: - List of Ballots for the given ids, with the given count. + :return List of Ballots for the given ids, with the given count. """ return ballots_for_candidates(candidates_for_ids(candidate_ids), count) @@ -863,7 +851,7 @@ def test_cgp_grey_animal_kingdom(self): tiger = Candidate('tiger', name='Tiger') lynx = Candidate('lynx', name='Lynx') - expected_winners = set([gorilla, monkey, tiger]) + expected_winners = {gorilla, monkey, tiger} seats = 3 tiebreak_alphanumeric = 'abcdefghijklmnopqrstuvwxyz' @@ -906,7 +894,7 @@ def test_cgp_grey_stv_election_walkthrough(self): jackalope = Candidate('jackalope', name='Jackalope') buffalo = Candidate('buffalo', name='Buffalo') - expected_winners = set([gorilla, silverback, owl, turtle, tiger]) + expected_winners = {gorilla, silverback, owl, turtle, tiger} seats = 5 tiebreak_alphanumeric = 'abcdefghijklmnopqrstuvwxyz' @@ -958,7 +946,7 @@ def test_wikipedia_food_selection(self): strawberries = Candidate('strawberries', name='Strawberries') sweets = Candidate('sweets', name='Sweets') - expected_winners = set([chocolate, oranges, strawberries]) + expected_winners = {chocolate, oranges, strawberries} seats = 3 tiebreak_alphanumeric = 'abcdefghijklmnopqrstuvwxyz' @@ -1013,7 +1001,7 @@ def test_florida_2000_presidential(self): bush = Candidate('gbush', name='George Bush') gore = Candidate('agore', name='Al Gore') nader = Candidate('rnader', name='Ralph Nader') - expected_winners = set([gore]) + expected_winners = {gore} seats = 1 tiebreak_alphanumeric = 'abcdefghijklmnopqrstuvwxyz' From e65de9544faaef4fe5fe5710e69201f0ce1a6e28 Mon Sep 17 00:00:00 2001 From: Marat Date: Wed, 14 Mar 2018 15:59:15 -0400 Subject: [PATCH 2/4] fix expected winners in a docstring --- tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests.py b/tests.py index 5774a2b..b823b2f 100644 --- a/tests.py +++ b/tests.py @@ -217,7 +217,7 @@ def test_1_candidate_2_seats(self): def test_3_candidates_2_seats(self): """Tests a 3 candidate election for 2 seats. - Expected winners: C + Expected winners: B, C Round 0 Ballots: From ef222f037dadb581713a7504c3d2972f73b2efb0 Mon Sep 17 00:00:00 2001 From: Marat Date: Wed, 14 Mar 2018 15:59:42 -0400 Subject: [PATCH 3/4] conscise reimplementation of STV --- stv.py | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 stv.py diff --git a/stv.py b/stv.py new file mode 100644 index 0000000..53aeed3 --- /dev/null +++ b/stv.py @@ -0,0 +1,255 @@ + +import numpy as np +import pandas as pd +from typing import Iterable + +import argparse +import logging +import string + +# logging.basicConfig(level=logging.INFO) + + +def stv(raw_ballots, seats, eliminate_NC=True, tiebreak=None): + # type: (Iterable, int, bool, str) -> set + """ + :param raw_ballots: np.array, columns are preferences, rows are voters + number of seats inferred from number of columns + :param seats: number of seats + :param eliminate_NC: whether it's possible to eliminate No Confidence + :param tiebreak: alphanumeric to break ties + :return: winning candidates + + >>> stv([['A']]*10, 1) == {'A'} + True + >>> stv([['A', 'B']]*5 + [['B', 'A']]*10, 1) == {'B'} + True + >>> stv([['A', 'C']]*5 + [['B']]*10 + [['C']]*7, 1) == {'C'} + True + >>> stv([['A']]*10, 2) == {'A'} + True + >>> stv([['A']]*7 + [['B', 'C']]*10 + [['C']]*7, 2) == {'B', 'C'} + True + >>> stv([['A', 'NC']]*10 + [['B', 'NC']]*16 + + ... [['C', 'NC']]*15 + [['D', 'A']]*7, 2) == {'A', 'B'} + True + + Test No Confidence + >>> stv([['A', 'NC']]*5 + [['B', 'NC']]*6 + + ... [['C', 'NC']]*4 + [['NC']]*5, 3) == {'B', 'NC'} + True + >>> stv([['A']]*10 + [['B']]*6 + [['NC']]*8, 3) == {'A', 'NC'} + True + >>> stv([['A', 'NC']]*6 + [['B', 'NC']]*5 + [['C', 'NC']]*1, 1) == {'A'} + True + >>> stv([['A', 'NC']]*6 + [['B', 'NC']]*5 + [['C', 'NC']]*1, 1, False + ... ) == {'NC'} + True + + + Test tie breaks + >>> stv([['A']]*3 + [['B']]*3 + [['C', 'B']]*1 + [['D', 'A']]*1, 2 + ... ) == {'A', 'B'} + True + >>> stv([['A']]*6 + [['B']]*3 + [['C']]*2 + [['D', 'C']]*1, 2) == {'A', 'B'} + True + >>> stv([['A']]*6 + [['B', 'C']]*3 + [['C']]*3, 2) == {'A', 'C'} + True + >>> stv([['A']]*6 + [['B']]*3 + [['C']]*3, 2) == {'A', 'C'} + True + + Test large elections + >>> scale = 100 + >>> stv( + ... [['tarsier', 'gorilla']]*scale*5 + + ... [['gorilla', 'tarsier', 'monkey']]*scale*28 + + ... [['monkey']]*scale*33 + + ... [['tiger']]*scale*21 + + ... [['lynx', 'tiger', 'tarsier', 'monkey', 'gorilla']]*scale*13, + ... 3) == {'gorilla', 'monkey', 'tiger'} + True + >>> stv( + ... [['tarsier', 'silverback']]*scale*5 + + ... [['gorilla', 'silverback']]*scale*21 + + ... [['gorilla', 'tarsier', 'silverback']]*scale*11 + + ... [['silverback']]*scale*3 + + ... [['owl', 'turtle']]*scale*33 + + ... [['turtle']]*scale*1 + + ... [['snake', 'turtle']]*scale*1 + + ... [['tiger']]*scale*16 + + ... [['lynx', 'tiger']]*scale*4 + + ... [['jackalope']]*scale*2 + + ... [['buffalo', 'jackalope']]*scale*2 + + ... [['buffalo', 'jackalope', 'turtle']]*scale*1, + ... 5) == {'gorilla', 'silverback', 'owl', 'turtle', 'tiger'} + True + >>> stv( + ... [['oranges']]*4 + + ... [['pears', 'oranges']]*2 + + ... [['chocolate', 'strawberries']]*8 + + ... [['chocolate', 'sweets']]*4 + + ... [['strawberries']]*1 + + ... [['sweets']]*1, + ... 3) == {'chocolate', 'oranges', 'strawberries'} + True + >>> stv( + ... [['Bush']]*29127 + + ... [['Gore']]*29122 + + ... [['Nader', 'Bush']]*324 + + ... [['Nader', 'Gore']]*649, + ... 1) == {'Gore'} + True + >>> stv( + ... [['G', 'F', 'H']]*14 + + ... [['J']]*12 + + ... [['F', 'G']]*11 + + ... [['A', 'B', 'C']]*11 + + ... [['D', 'E', 'A']]*8 + + ... [['D', 'E', 'A', 'B']]*8 + + ... [['D', 'E', 'C', 'F']]*8 + + ... [['E', 'D', 'F', 'G', 'H']]*8 + + ... [['E', 'D', 'G']]*8 + + ... [['D', 'E', 'NC']]*8 + + ... [['I', 'A', 'B', 'C']]*7 + + ... [['H', 'G']]*6 + + ... [['C', 'B', 'A']]*6 + + ... [['J', 'NC']]*6 + + ... [['B', 'A', 'C']]*3 + + ... [['I', 'A', 'C', 'B']]*3, + ... 6) == {'D', 'E', 'G', 'A', 'F', 'J'} + True + """ + + default_tiebreak = "0123456789abcdefghijklmnopqrstuvwxyz" + tiebreak = tiebreak or default_tiebreak + + log = logging.getLogger("stv") + # input can be any kind of iterable, including generator + # pd.DataFrame looks like the most straightforward way to get a consistent + # array of ballots of uniform size preserving order and filling the rest + # with NaNs + ballots = pd.DataFrame(raw_ballots).values + candidates = [c for c in pd.unique(ballots.ravel()) + if c and pd.notnull(c)] + winners = set() + + # how much of each position was casted from each ballot + votes = np.zeros(ballots.shape) + # if candidate at valid[ballot, position] is valid + valid = pd.notnull(ballots) + stats = pd.Series() + stats_history = [] + + def least_preferred_candidate(): + """ Implementation of tiebreak rules """ + lpc = stats.sort_values() + if not eliminate_NC and 'NC' in lpc: + lpc = lpc.drop('NC') + # it looks like bulk elimination is the same as sequential + lpc = lpc[lpc == lpc.min()].keys() + if len(lpc) == 1: + return lpc[0] + # backward tie break + log.info("There is a tie for a candidate to be eliminated: %s", lpc) + log.info("First, let's lok at prior rounds to find who scored less") + for snapshot in reversed(stats_history[:-1]): + shortlist = snapshot.reindex(lpc, fill_value=0) + log.info("Counts: %s", dict(shortlist)) + lpc = shortlist[shortlist == shortlist.min()].keys() + if len(lpc) == 1: + log.info("Apparently %s is the least liked", lpc[0]) + return lpc[0] + log.info("There is no clear anti winner, let's try one step back") + + log.info("Ok, backward tie break didn't work. let's look who has " + "the most next choice votes") + # count, how many votes will each of remaining candidates have + # if all others are eliminated + scores = pd.Series(0, index=lpc) + tb_valid = valid.copy() + for candidate in lpc: + tb_valid[ballots == candidate] = False + for candidate in lpc: + tb_valid[ballots == candidate] = True + for i in range(len(ballots)): + # almost a copy from vote count below + rv = 1 - votes[i].dot(tb_valid[i]) + ao = np.where(tb_valid[i] * (votes[i] == 0))[0] + if ao.size > 0 and rv > 0: + npc = ballots[i, ao.min()] + if npc in scores: + stats[npc] += rv + tb_valid[ballots == candidate] = False + log.info("Next scores: \n, %s", dict(scores)) + lpc = scores[scores == scores.min()].keys() + if len(lpc) == 1: + log.info("Apparently %s is the least liked", lpc[0]) + return lpc[0] + + log.info("Ok, they're all equal. Now going to use random tiebreak") + lpc = sorted(lpc, key=lambda x: x.translate(string.maketrans(tiebreak, default_tiebreak))) + return lpc[0] + + # Actual STV. + for elections_round in range(len(ballots)): # same as while True + counter + # count votes + for i in range(len(ballots)): + # at each round, move remaining vote right + remaining_vote = 1 - votes[i].dot(valid[i]) + # np.where returns #n-dimensions tuple. We've 1D, so [0] + available_options = np.where(valid[i] * (votes[i] == 0))[0] + if available_options.size > 0 and remaining_vote > 0: + next_preference_idx = available_options.min() + next_preference_candidate = ballots[i, next_preference_idx] + votes[i, next_preference_idx] = remaining_vote + if next_preference_candidate not in stats: + stats[next_preference_candidate] = 0.0 + stats[next_preference_candidate] += remaining_vote + # for backward tie breaking + stats_history.append(stats.copy()) + + total = votes[valid][map(lambda x: x not in winners, ballots[valid])].sum() + threshold = total * 1.0 / (seats - len(winners) + 1) + 1 + + winners_before = len(winners) + # check if there are winners. redistribute votes/end if necessary + for candidate, score in stats.sort_values(ascending=False).items(): + if candidate in winners: + continue + if pd.isnull(score) or score < threshold: + break + winners.add(candidate) + # redistribute excess, if any + # don't worry about people checking for new winners -they'll be + # calculated automatically on the next round + votes[ballots == candidate] *= threshold / score + stats[candidate] *= threshold / score + + log.info("Results after round %d:", elections_round) + log.info("Ballots:\n%s", ballots) + log.info("Valid choices:\n%s", valid) + log.debug("Votes:\n%s", pd.DataFrame( + np.concatenate((ballots, votes * valid), axis=1), + columns=["ballot_%d" % i for i in range(ballots.shape[1])] + + ["vote_%d" % i for i in range(ballots.shape[1])] + )) + log.info("Stats:\n%s", stats.T) + log.info("Threshold: %s", threshold) + log.info("Winners: %s", winners) + + if len(winners) == seats or 'NC' in winners: + return winners + + if len(winners) == winners_before: + # eliminate least popular + lp = least_preferred_candidate() + if lp in winners: # too many people abstained` + log.info("Ran out of candidates, some seats remain vacant") + return winners + log.info("Eliminating %s as the least popular", lp) + del(stats[lp]) + valid[ballots == lp] = False + + +if __name__ == "__main__": + pass From a82e410f5f8666ebd73079d85629667bc827f355 Mon Sep 17 00:00:00 2001 From: Marat Date: Wed, 14 Mar 2018 16:38:01 -0400 Subject: [PATCH 4/4] adjustable NC name --- stv.py | 13 +++++++------ tests.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/stv.py b/stv.py index 53aeed3..f5f5447 100644 --- a/stv.py +++ b/stv.py @@ -3,14 +3,15 @@ import pandas as pd from typing import Iterable -import argparse import logging import string +# To get debug output, just do: # logging.basicConfig(level=logging.INFO) -def stv(raw_ballots, seats, eliminate_NC=True, tiebreak=None): +def stv(raw_ballots, seats, eliminate_NC=True, no_confidence='NC', + tiebreak=None): # type: (Iterable, int, bool, str) -> set """ :param raw_ballots: np.array, columns are preferences, rows are voters @@ -143,8 +144,8 @@ def stv(raw_ballots, seats, eliminate_NC=True, tiebreak=None): def least_preferred_candidate(): """ Implementation of tiebreak rules """ lpc = stats.sort_values() - if not eliminate_NC and 'NC' in lpc: - lpc = lpc.drop('NC') + if not eliminate_NC and no_confidence in lpc: + lpc = lpc.drop(no_confidence) # it looks like bulk elimination is the same as sequential lpc = lpc[lpc == lpc.min()].keys() if len(lpc) == 1: @@ -162,7 +163,7 @@ def least_preferred_candidate(): log.info("There is no clear anti winner, let's try one step back") log.info("Ok, backward tie break didn't work. let's look who has " - "the most next choice votes") + "the most next choice votes") # count, how many votes will each of remaining candidates have # if all others are eliminated scores = pd.Series(0, index=lpc) @@ -237,7 +238,7 @@ def least_preferred_candidate(): log.info("Threshold: %s", threshold) log.info("Winners: %s", winners) - if len(winners) == seats or 'NC' in winners: + if len(winners) == seats or no_confidence in winners: return winners if len(winners) == winners_before: diff --git a/tests.py b/tests.py index b823b2f..a9628ef 100644 --- a/tests.py +++ b/tests.py @@ -55,7 +55,7 @@ def candidates_for_ids(candidate_ids): :return List of Candidates corresponding to the given ids. """ - return [Candidate(CANDIDATE_IDS[candidate_id]) + return [Candidate(*CANDIDATE_IDS[candidate_id]) if candidate_id != "NC" else NoConfidence() for candidate_id in candidate_ids]