From ba55121d329ce4343bc01a7f22c0fa39005bc574 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Fri, 25 Aug 2023 13:45:52 -0400 Subject: [PATCH 01/15] added init method from point --- src/votekit/ballot_generator.py | 37 +++++++++++++++++++++++++++------ tests/test_ballot_generator.py | 23 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 882a2eff..959c2686 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -1,4 +1,5 @@ from abc import abstractmethod +from functools import reduce import itertools as it from fractions import Fraction import math @@ -178,17 +179,41 @@ def _preference_interval( class BallotSimplex(BallotGenerator): - def __init__(self, alpha: float, **data): - self.alpha = alpha - super().__init__(**data) + def __init__(self, alpha: float = None, point: dict = None, **data): + if alpha or point: + self.alpha = alpha + self.point = point + super().__init__(**data) + + @classmethod + def fromPoint(cls, point: dict, **data): + if sum(point.values()) != 1.0: + raise ValueError( + f"probability distribution from point ({point.values()}) does not sum to 1" + ) + return cls(point=point, **data) + + @classmethod + def fromAlpha(cls, alpha: float, **data): + return cls(alpha=alpha, **data) def generate_profile(self, number_of_ballots) -> PreferenceProfile: perm_set = it.permutations(self.candidates, self.ballot_length) - # Create a list of every perm [['A', 'B', 'C'], ['A', 'C', 'B'], ...] + perm_rankings = [list(value) for value in perm_set] - # IAC Process is equivalent to drawing from dirichlet dist with uniform parameters - draw_probabilities = np.random.dirichlet([self.alpha] * len(perm_rankings)) + if self.alpha: + draw_probabilities = np.random.dirichlet([self.alpha] * len(perm_rankings)) + elif self.point: + draw_probabilities = [ + reduce(lambda prod, cand: prod * self.point[cand], ranking, 1.0) + for ranking in perm_rankings + ] + draw_probabilities = [ + prob / sum(draw_probabilities) for prob in draw_probabilities + ] + else: + raise AttributeError("alpha or point needs to be initialized.") ballot_pool = [] diff --git a/tests/test_ballot_generator.py b/tests/test_ballot_generator.py index 5692e61f..35d49c11 100644 --- a/tests/test_ballot_generator.py +++ b/tests/test_ballot_generator.py @@ -13,6 +13,7 @@ AlternatingCrossover, CambridgeSampler, OneDimSpatial, + BallotSimplex, ) from votekit.pref_profile import PreferenceProfile @@ -171,6 +172,28 @@ def test_ic_distribution(): ) +def test_ballot_simplex_from_point(): + number_of_ballots = 1000 + ballot_length = 4 + candidates = ["W1", "W2", "C1", "C2"] + pt = {"W1": 1 / 4, "W2": 1 / 4, "C1": 1 / 4, "C2": 1 / 4} + + possible_rankings = it.permutations(candidates, ballot_length) + ballot_prob_dict = { + b: 1 / math.factorial(len(candidates)) for b in possible_rankings + } + + generated_profile = ( + BallotSimplex() + .fromPoint(point=pt, ballot_length=ballot_length, candidates=candidates) + .generate_profile(number_of_ballots=number_of_ballots) + ) + # Test + assert do_ballot_probs_match_ballot_dist( + ballot_prob_dict, generated_profile, len(candidates) + ) + + # def test_iac_distribution(): # number_of_ballots = 1000 # ballot_length = 4 From 0508208631bf0c364fc59a384fb795e09456ff3b Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Fri, 25 Aug 2023 13:55:08 -0400 Subject: [PATCH 02/15] added doc --- src/votekit/ballot_generator.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 959c2686..c97c4126 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -180,6 +180,13 @@ def _preference_interval( class BallotSimplex(BallotGenerator): def __init__(self, alpha: float = None, point: dict = None, **data): + """ + Base class for ballot generation with a ballot simplex + Args: + alpha (float, optional): alpha parameter for ballot simplex. Defaults to None. + point (dict, optional): a point in the ballot simplex, + with candidate as keys and electoral support as values. Defaults to None. + """ if alpha or point: self.alpha = alpha self.point = point From 9ca7560ae136d8b410b5d569ff3daa5f1da78448 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Sun, 27 Aug 2023 14:27:53 -0400 Subject: [PATCH 03/15] refactored ballot generator --- src/votekit/ballot_generator.py | 259 +++++++++++++------------------- tests/test_ballot_generator.py | 84 +++++++---- 2 files changed, 160 insertions(+), 183 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index c97c4126..336ba417 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -7,7 +7,7 @@ from pathlib import Path import pickle import random -from typing import Optional, Union +from typing import Optional from .ballot import Ballot from .pref_profile import PreferenceProfile @@ -25,9 +25,10 @@ class BallotGenerator: def __init__( self, - candidates: Union[list, dict], + candidates: list, ballot_length: Optional[int] = None, - hyperparams: Optional[dict] = {}, + pref_interval_by_bloc=None, + bloc_voter_prop=None, ): """ Base class for ballot generation models @@ -40,27 +41,89 @@ def __init__( self.ballot_length = ( ballot_length if ballot_length is not None else len(candidates) ) - if isinstance(candidates, dict): - self.slate_to_candidate = candidates - self.candidates = list( - {cand for cands in candidates.values() for cand in cands} + self.candidates = candidates + + if bloc_voter_prop and pref_interval_by_bloc: + if round(sum(bloc_voter_prop.values())) != 1: + raise ValueError("Voter proportion for blocs must sum to 1") + for interval in pref_interval_by_bloc.values(): + if round(sum(interval.values())) != 1: + raise ValueError("Preference interval for candidates must sum to 1") + if bloc_voter_prop.keys() != pref_interval_by_bloc.keys(): + raise ValueError("slates and blocs are not the same") + + self.pref_interval_by_bloc = pref_interval_by_bloc + self.bloc_voter_prop = bloc_voter_prop + + @classmethod + def from_params( + cls, + slate_to_candidates: dict, # add type error for list here + blocs: dict, + cohesion: dict, + alphas: dict, + **data, + ): + """ + Creates a Ballot Generator by constructing a preference interval from parameters + + Args: + slate_to_candidates (dict): _description_ + cohesion (dict): _description_ + alphas (dict): _description_ + + Raises: + ValueError: _description_ + ValueError: _description_ + + Returns: + _type_: _description_ + """ + + if sum(blocs.values()) != 1.0: + raise ValueError(f"bloc proportions ({blocs.values()}) do not equal 1") + if slate_to_candidates.keys() != blocs.keys(): + raise ValueError("slates and blocs are not the same") + + def _construct_preference_interval( + alphas: dict, cohesion: int, bloc: str, candidates: dict + ) -> dict: + intervals = {} + + for group, alpha in alphas.items(): + num_cands = len(candidates[group]) + probs = list(np.random.dirichlet([alpha] * num_cands)) + for prob, cand in zip(probs, candidates[group]): + if group == bloc: # e.g W for W cands + pi = cohesion + else: # e.g W for POC cands + pi = 1 - cohesion + intervals[cand] = pi * prob + + return intervals + + interval_by_bloc = {} + for bloc in blocs: + interval = _construct_preference_interval( + alphas[bloc], cohesion[bloc], bloc, slate_to_candidates ) - else: - self.candidates = candidates - self.ballot_length = ( - ballot_length if ballot_length is not None else len(self.candidates) + interval_by_bloc[bloc] = interval + + cands = list({cand for cands in slate_to_candidates.values() for cand in cands}) + + generator = cls( + candidates=cands, + bloc_voter_prop=blocs, + pref_interval_by_bloc=interval_by_bloc, + **data, ) - self.parameterized = False + if isinstance(generator, AlternatingCrossover) or isinstance( + generator, CambridgeSampler + ): + generator.slate_to_candidates = slate_to_candidates - if hyperparams: - if isinstance(candidates, dict): # add type error - self.set_params(candidates, **hyperparams) - self.parameterized = True - else: - raise TypeError( - "'candidates' must be dictionary when hyperparameters are set" - ) + return generator @abstractmethod def generate_profile(self, number_of_ballots: int) -> PreferenceProfile: @@ -114,61 +177,6 @@ def ballot_pool_to_profile(ballot_pool, candidates) -> PreferenceProfile: return PreferenceProfile(ballots=ballot_list, candidates=candidates) - @classmethod - def set_params( - self, - candidates: dict, # add type error for list here - blocs: dict, - cohesion: dict, - alphas: dict, - crossover: dict = {}, - ) -> None: - """ - Generates perference intervals for slates based on pararmeters and specified models. - """ - if sum(blocs.values()) != 1.0: - raise ValueError(f"bloc proportions ({blocs.values()}) do not equal 1") - - def _preference_interval( - alphas: dict, cohesion: int, bloc: str, candidates: dict - ) -> dict: - """ - Creates a preference interval for bloc of votes (e.g. W or POC) - - Inputs: - alphas: dict of alpha parameters for Dirichlet distribution mapped to a - group (e.g. support for POC, or support for W) - cohesion: support for candidates - candidates dict: list of candidates mapped to their group - - Returns: perference interval, a dictionary with candidates mapped to - their intervals - """ - intervals = {} - - for group, alpha in alphas.items(): - num_cands = len(candidates[group]) - probs = list(np.random.dirichlet([alpha] * num_cands)) - for prob, cand in zip(probs, candidates[group]): - if group == bloc: # e.g W for W cands - pi = cohesion - else: # e.g W for POC cands - pi = 1 - cohesion - intervals[cand] = pi * prob - - return intervals - - interval_by_bloc = {} - for bloc in blocs: - interval = _preference_interval( - alphas[bloc], cohesion[bloc], bloc, candidates - ) - interval_by_bloc[bloc] = interval - - self.pref_interval_by_bloc = interval_by_bloc - self.bloc_voter_prop = blocs - self.bloc_crossover_rate = crossover - # inputs: # write ballot simplex generation @@ -193,7 +201,7 @@ def __init__(self, alpha: float = None, point: dict = None, **data): super().__init__(**data) @classmethod - def fromPoint(cls, point: dict, **data): + def from_point(cls, point: dict, **data): if sum(point.values()) != 1.0: raise ValueError( f"probability distribution from point ({point.values()}) does not sum to 1" @@ -201,7 +209,7 @@ def fromPoint(cls, point: dict, **data): return cls(point=point, **data) @classmethod - def fromAlpha(cls, alpha: float, **data): + def from_alpha(cls, alpha: float, **data): return cls(alpha=alpha, **data) def generate_profile(self, number_of_ballots) -> PreferenceProfile: @@ -244,7 +252,7 @@ def __init__(self, **data): class PlackettLuce(BallotGenerator): - def __init__(self, pref_interval_by_bloc=None, bloc_voter_prop=None, **data): + def __init__(self, **data): """ Plackett Luce Ballot Generation Model @@ -254,26 +262,13 @@ def __init__(self, pref_interval_by_bloc=None, bloc_voter_prop=None, **data): bloc_voter_prop (dict): a mapping of slate to voter proportions (ex. {race: voter proportion}) """ - if not pref_interval_by_bloc: - self.pref_interval_by_bloc: dict = {} - self.bloc_voter_prop: dict = {} + # if not pref_interval_by_bloc: + # self.pref_interval_by_bloc: dict = {} + # self.bloc_voter_prop: dict = {} # Call the parent class's __init__ method to handle common parameters super().__init__(**data) - # Assign additional parameters specific to PlackettLuce - if not self.parameterized: - if round(sum(bloc_voter_prop.values())) != 1: - raise ValueError("Voter proportion for blocs must sum to 1") - for interval in pref_interval_by_bloc.values(): - if round(sum(interval.values())) != 1: - raise ValueError("Preference interval for candidates must sum to 1") - if bloc_voter_prop.keys() != pref_interval_by_bloc.keys(): - raise ValueError("slates and blocs are not the same") - - self.pref_interval_by_bloc = pref_interval_by_bloc - self.bloc_voter_prop = bloc_voter_prop - def generate_profile(self, number_of_ballots) -> PreferenceProfile: ballot_pool = [] @@ -304,7 +299,7 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: class BradleyTerry(BallotGenerator): - def __init__(self, pref_interval_by_bloc=None, bloc_voter_prop=None, **data): + def __init__(self, **data): """ Bradley Terry Ballot Generation Model @@ -315,26 +310,13 @@ def __init__(self, pref_interval_by_bloc=None, bloc_voter_prop=None, **data): (ex. {race: voter proportion}) """ - if not pref_interval_by_bloc: - self.pref_interval_by_bloc: dict = {} - self.bloc_voter_prop: dict = {} + # if not pref_interval_by_bloc: + # self.pref_interval_by_bloc: dict = {} + # self.bloc_voter_prop: dict = {} # Call the parent class's __init__ method to handle common parameters super().__init__(**data) - if not self.parameterized: - if round(sum(bloc_voter_prop.values())) != 1: - raise ValueError("Voter proportion for blocs must sum to 1") - for interval in pref_interval_by_bloc.values(): - if round(sum(interval.values())) != 1: - raise ValueError("Preference interval for candidates must sum to 1") - if bloc_voter_prop.keys() != pref_interval_by_bloc.keys(): - raise ValueError("slates and blocs are not the same") - - # Assign additional parameters specific to Bradley Terrys - self.pref_interval_by_bloc = pref_interval_by_bloc - self.bloc_voter_prop = bloc_voter_prop - # TODO: convert to dynamic programming method of calculation def _calc_prob(self, permutations: list[tuple], cand_support_dict: dict) -> dict: @@ -401,9 +383,7 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: class AlternatingCrossover(BallotGenerator): def __init__( self, - slate_to_candidate=None, - pref_interval_by_bloc=None, - bloc_voter_prop=None, + slate_to_candidates=None, bloc_crossover_rate=None, **data, ): @@ -420,33 +400,18 @@ def __init__( bloc_crossover_rate (dict): a mapping of percentage of crossover voters per bloc (ex. {race: {other_race: 0.5}}) """ - if not pref_interval_by_bloc: - self.slate_to_candidate: dict = {} - self.pref_interval_by_bloc: dict = {} - self.bloc_voter_prop: dict = {} - self.bloc_crossover_rate: dict = {} - # Call the parent class's __init__ method to handle common parameters super().__init__(**data) - # Assign additional parameters specific to AC - if not self.parameterized: - if round(sum(bloc_voter_prop.values())) != 1: - raise ValueError("Voter proportion for blocs must sum to 1") - for interval in pref_interval_by_bloc.values(): - if round(sum(interval.values())) != 1: - raise ValueError("Preference interval for candidates must sum to 1") - if ( - slate_to_candidate.keys() - != bloc_voter_prop.keys() - != pref_interval_by_bloc.keys() - ): - raise ValueError("slates and blocs are not the same") + # if ( + # slate_to_candidates.keys() + # != self.bloc_voter_prop.keys() + # != self.pref_interval_by_bloc.keys() + # ): + # raise ValueError("slates and blocs are not the same") - self.slate_to_candidate = slate_to_candidate - self.pref_interval_by_bloc = pref_interval_by_bloc - self.bloc_voter_prop = bloc_voter_prop - self.bloc_crossover_rate = bloc_crossover_rate + self.slate_to_candidates = slate_to_candidates + self.bloc_crossover_rate = bloc_crossover_rate def generate_profile(self, number_of_ballots) -> PreferenceProfile: @@ -463,8 +428,8 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: crossover_rate = crossover_dict[opposing_slate] num_crossover_ballots = self.round_num(crossover_rate * num_ballots) - opposing_cands = self.slate_to_candidate[opposing_slate] - bloc_cands = self.slate_to_candidate[bloc] + opposing_cands = self.slate_to_candidates[opposing_slate] + bloc_cands = self.slate_to_candidates[bloc] for _ in range(num_crossover_ballots): pref_for_opposing = [ @@ -540,26 +505,16 @@ class CambridgeSampler(BallotGenerator): def __init__( self, slate_to_candidate=None, - pref_interval_by_bloc=None, - bloc_voter_prop=None, bloc_crossover_rate=None, path: Optional[Path] = None, **data, ): - if not pref_interval_by_bloc: - self.slate_to_candidate: dict = {} - self.pref_interval_by_bloc: dict = {} - self.bloc_voter_prop: dict = {} - self.bloc_crossover_rate: dict = {} + # Call the parent class's __init__ method to handle common parameters super().__init__(**data) - # Assign additional parameters specific to - if not self.parameterized: - self.slate_to_candidate = slate_to_candidate - self.pref_interval_by_bloc = pref_interval_by_bloc - self.bloc_voter_prop = bloc_voter_prop - self.bloc_crossover_rate = bloc_crossover_rate + self.slate_to_candidate = slate_to_candidate + self.bloc_crossover_rate = bloc_crossover_rate if path: self.path = path diff --git a/tests/test_ballot_generator.py b/tests/test_ballot_generator.py index 35d49c11..9e984d50 100644 --- a/tests/test_ballot_generator.py +++ b/tests/test_ballot_generator.py @@ -64,7 +64,7 @@ def test_AC_completion(): ac = AlternatingCrossover( candidates=["W1", "W2", "C1", "C2"], ballot_length=None, - slate_to_candidate={"W": ["W1", "W2"], "C": ["C1", "C2"]}, + slate_to_candidates={"W": ["W1", "W2"], "C": ["C1", "C2"]}, pref_interval_by_bloc={ "W": {"W1": 0.4, "W2": 0.3, "C1": 0.2, "C2": 0.1}, "C": {"W1": 0.2, "W2": 0.2, "C1": 0.3, "C2": 0.3}, @@ -185,7 +185,7 @@ def test_ballot_simplex_from_point(): generated_profile = ( BallotSimplex() - .fromPoint(point=pt, ballot_length=ballot_length, candidates=candidates) + .from_point(point=pt, ballot_length=ballot_length, candidates=candidates) .generate_profile(number_of_ballots=number_of_ballots) ) # Test @@ -435,7 +435,7 @@ def group_elements_by_mapping(element_list, mapping): candidates=candidates, pref_interval_by_bloc=pref_interval_by_bloc, bloc_voter_prop=bloc_voter_prop, - slate_to_candidate=slate_to_candidate, + slate_to_candidates=slate_to_candidate, bloc_crossover_rate=bloc_crossover_rate, ).generate_profile(number_of_ballots) @@ -677,15 +677,15 @@ def test_bad_cands_input(): PlackettLuce(candidates=cands_lst, hyperparams=twobloc) -def test_pl_both_inputs(): - gen = PlackettLuce( - candidates=cands, - pref_interval_by_bloc=test_slate, - bloc_voter_prop=test_voter_prop, - hyperparams=twobloc, - ) - # check that this attribute matches hyperparam input - assert gen.bloc_voter_prop == {"R": 0.6, "D": 0.4} +# def test_pl_both_inputs(): +# gen = PlackettLuce( +# candidates=cands, +# pref_interval_by_bloc=test_slate, +# bloc_voter_prop=test_voter_prop, +# hyperparams=twobloc, +# ) +# # check that this attribute matches hyperparam input +# assert gen.bloc_voter_prop == {"R": 0.6, "D": 0.4} # def test_bt_single_bloc(): @@ -701,26 +701,48 @@ def test_pl_both_inputs(): # assert math.isclose(sum(interval["R"].values()), 1) -def test_incorrect_blocs(): - params = { - "blocs": {"R": 0.7, "D": 0.4}, - "cohesion": {"R": 0.7, "D": 0.6}, - "alphas": {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}}, - } - cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} +# def test_incorrect_blocs(): +# params = { +# "blocs": {"R": 0.7, "D": 0.4}, +# "cohesion": {"R": 0.7, "D": 0.6}, +# "alphas": {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}}, +# } +# cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} - with pytest.raises(ValueError): - PlackettLuce(candidates=cands, hyperparams=params) +# with pytest.raises(ValueError): +# PlackettLuce(candidates=cands, hyperparams=params) def test_ac_profile_from_params(): - params = { - "blocs": {"R": 0.6, "D": 0.4}, - "cohesion": {"R": 0.7, "D": 0.6}, - "alphas": {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}}, - "crossover": {"R": {"D": 0.5}, "D": {"R": 0.6}}, - } - cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} - ac = AlternatingCrossover(candidates=cands, hyperparams=params) - ballots = ac.generate_profile(3) - assert isinstance(ballots, PreferenceProfile) + blocs = {"R": 0.6, "D": 0.4} + cohesion = {"R": 0.7, "D": 0.6} + alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} + crossover = {"R": {"D": 0.5}, "D": {"R": 0.6}} + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} + ac = AlternatingCrossover.from_params( + blocs=blocs, + cohesion=cohesion, + alphas=alphas, + slate_to_candidates=slate_to_cands, + bloc_crossover_rate=crossover, + ) + + profile = ac.generate_profile(3) + assert type(profile) is PreferenceProfile + + +def test_pl_profile_from_params(): + blocs = {"R": 0.6, "D": 0.4} + cohesion = {"R": 0.7, "D": 0.6} + alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} + + ac = PlackettLuce.from_params( + blocs=blocs, + slate_to_candidates=slate_to_cands, + cohesion=cohesion, + alphas=alphas, + ) + + profile = ac.generate_profile(3) + assert type(profile) is PreferenceProfile From 601a3e6ce89246296122e48bc60801eb73bd7f91 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Sun, 27 Aug 2023 14:32:03 -0400 Subject: [PATCH 04/15] refactored more --- src/votekit/ballot_generator.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 336ba417..83de344a 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -403,13 +403,6 @@ def __init__( # Call the parent class's __init__ method to handle common parameters super().__init__(**data) - # if ( - # slate_to_candidates.keys() - # != self.bloc_voter_prop.keys() - # != self.pref_interval_by_bloc.keys() - # ): - # raise ValueError("slates and blocs are not the same") - self.slate_to_candidates = slate_to_candidates self.bloc_crossover_rate = bloc_crossover_rate From 272ee8147a70f394fce9cbde0c258c0b7b2de488 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:13:09 -0400 Subject: [PATCH 05/15] fixed pylint --- src/votekit/ballot_generator.py | 21 +++++++++------ tests/test_ballot_generator.py | 2 +- tests/test_models.py | 48 ++++++++++++++++----------------- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 83de344a..a5b228bc 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -187,7 +187,9 @@ def ballot_pool_to_profile(ballot_pool, candidates) -> PreferenceProfile: class BallotSimplex(BallotGenerator): - def __init__(self, alpha: float = None, point: dict = None, **data): + def __init__( + self, alpha: Optional[float] = None, point: Optional[dict] = None, **data + ): """ Base class for ballot generation with a ballot simplex Args: @@ -218,10 +220,13 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: perm_rankings = [list(value) for value in perm_set] if self.alpha: - draw_probabilities = np.random.dirichlet([self.alpha] * len(perm_rankings)) + draw_probabilities = list( + np.random.dirichlet([self.alpha] * len(perm_rankings)) + ) elif self.point: + probs = [self.point[cand] for cand in self.candidates] draw_probabilities = [ - reduce(lambda prod, cand: prod * self.point[cand], ranking, 1.0) + reduce(lambda prod, cand: prod * probs[cand], ranking, 1.0) for ranking in perm_rankings ] draw_probabilities = [ @@ -497,7 +502,7 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: class CambridgeSampler(BallotGenerator): def __init__( self, - slate_to_candidate=None, + slate_to_candidates=None, bloc_crossover_rate=None, path: Optional[Path] = None, **data, @@ -506,7 +511,7 @@ def __init__( # Call the parent class's __init__ method to handle common parameters super().__init__(**data) - self.slate_to_candidate = slate_to_candidate + self.slate_to_candidates = slate_to_candidates self.bloc_crossover_rate = bloc_crossover_rate if path: @@ -523,7 +528,7 @@ def generate_profile(self, number_of_ballots: int) -> PreferenceProfile: ballot_pool = [] - blocs = self.slate_to_candidate.keys() + blocs = self.slate_to_candidates.keys() for bloc in blocs: # compute the number of voters in this bloc bloc_voters = self.round_num(self.bloc_voter_prop[bloc] * number_of_ballots) @@ -593,10 +598,10 @@ def generate_profile(self, number_of_ballots: int) -> PreferenceProfile: ) ) ordered_bloc_slate = [ - c for c in pl_ordering if c in self.slate_to_candidate[bloc] + c for c in pl_ordering if c in self.slate_to_candidates[bloc] ] ordered_opp_slate = [ - c for c in pl_ordering if c in self.slate_to_candidate[opp_bloc] + c for c in pl_ordering if c in self.slate_to_candidates[opp_bloc] ] # Fill in the bloc slots as determined diff --git a/tests/test_ballot_generator.py b/tests/test_ballot_generator.py index 9e984d50..0b96fa63 100644 --- a/tests/test_ballot_generator.py +++ b/tests/test_ballot_generator.py @@ -87,7 +87,7 @@ def test_Cambridge_completion(): cs = CambridgeSampler( candidates=["W1", "W2", "C1", "C2"], ballot_length=None, - slate_to_candidate={"W": ["W1", "W2"], "C": ["C1", "C2"]}, + slate_to_candidates={"W": ["W1", "W2"], "C": ["C1", "C2"]}, pref_interval_by_bloc={ "W": {"W1": 0.4, "W2": 0.3, "C1": 0.2, "C2": 0.1}, "C": {"W1": 0.2, "W2": 0.2, "C1": 0.3, "C2": 0.3}, diff --git a/tests/test_models.py b/tests/test_models.py index 53a00c4a..fab7b008 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,4 @@ -import pytest +# import pytest from unittest.mock import MagicMock from votekit.ballot_generator import ( @@ -30,26 +30,26 @@ def sim_election(): pass -def test_gen_ballots(): - model = DummyGenerated() - cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} - specs = { - "blocs": {"R": 0.6, "D": 0.4}, - "cohesion": {"R": 0.7, "D": 0.6}, - "alphas": {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}}, - "crossover": {"R": {"D": 0.5}, "D": {"R": 0.6}}, - } - profiles = model.generate_ballots(num_ballots=10, candidates=cands, params=specs) - name, model = profiles[0] - assert name == "PL" - assert isinstance(model, PreferenceProfile) - # check that a profile exists for each ballot generator - assert len(profiles) == 3 - - -def test_gen_with_real_data(): - model = DummyActual() - cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} - params = {} - with pytest.raises(TypeError): - model.generate_ballots(num_ballots=10, candidates=cands, hyperparams=params) +# def test_gen_ballots(): +# model = DummyGenerated() +# cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} +# specs = { +# "blocs": {"R": 0.6, "D": 0.4}, +# "cohesion": {"R": 0.7, "D": 0.6}, +# "alphas": {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}}, +# "crossover": {"R": {"D": 0.5}, "D": {"R": 0.6}}, +# } +# profiles = model.generate_ballots(num_ballots=10, candidates=cands, params=specs) +# name, model = profiles[0] +# assert name == "PL" +# assert isinstance(model, PreferenceProfile) +# # check that a profile exists for each ballot generator +# assert len(profiles) == 3 + + +# def test_gen_with_real_data(): +# model = DummyActual() +# cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} +# params = {} +# with pytest.raises(TypeError): +# model.generate_ballots(num_ballots=10, candidates=cands, hyperparams=params) From c85664b654fa101c6310440c685c569bfd239cb4 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:21:25 -0400 Subject: [PATCH 06/15] fixed linting --- src/votekit/ballot_generator.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index a5b228bc..f2c76d84 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -224,9 +224,12 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: np.random.dirichlet([self.alpha] * len(perm_rankings)) ) elif self.point: - probs = [self.point[cand] for cand in self.candidates] draw_probabilities = [ - reduce(lambda prod, cand: prod * probs[cand], ranking, 1.0) + reduce( + lambda prod, cand: prod * self.point[cand] if self.point else 0, + ranking, + 1.0, + ) for ranking in perm_rankings ] draw_probabilities = [ @@ -322,8 +325,6 @@ def __init__(self, **data): # Call the parent class's __init__ method to handle common parameters super().__init__(**data) - # TODO: convert to dynamic programming method of calculation - def _calc_prob(self, permutations: list[tuple], cand_support_dict: dict) -> dict: """ given a list of rankings and the preference interval, From fd13d21ca8ac1671de98f78aba864aec51e78930 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:39:26 -0400 Subject: [PATCH 07/15] added back tests --- src/votekit/ballot_generator.py | 19 ++- tests/test_ballot_generator.py | 289 +++++++------------------------- 2 files changed, 73 insertions(+), 235 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index f2c76d84..07f898e6 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -109,14 +109,19 @@ def _construct_preference_interval( ) interval_by_bloc[bloc] = interval - cands = list({cand for cands in slate_to_candidates.values() for cand in cands}) + if "candidates" not in data: + cands = list( + {cand for cands in slate_to_candidates.values() for cand in cands} + ) + data["candidates"] = cands - generator = cls( - candidates=cands, - bloc_voter_prop=blocs, - pref_interval_by_bloc=interval_by_bloc, - **data, - ) + if "pref_interval_by_bloc" not in data: + data["pref_interval_by_bloc"] = interval_by_bloc + + if "bloc_voter_prop" not in data: + data["bloc_voter_prop"] = blocs + + generator = cls(**data) if isinstance(generator, AlternatingCrossover) or isinstance( generator, CambridgeSampler diff --git a/tests/test_ballot_generator.py b/tests/test_ballot_generator.py index 0b96fa63..e00f490c 100644 --- a/tests/test_ballot_generator.py +++ b/tests/test_ballot_generator.py @@ -445,185 +445,6 @@ def group_elements_by_mapping(element_list, mapping): ) -# def test_Cambridge_distribution(): -# BASE_DIR = Path(__file__).resolve().parent -# DATA_DIR = BASE_DIR / "data/" -# path = Path(DATA_DIR, "Cambridge_09to17_ballot_types.p") -# -# candidates = ["W1", "W2", "C1", "C2"] -# ballot_length = None -# slate_to_candidate = {"W": ["W1", "W2"], "C": ["C1", "C2"]} -# pref_interval_by_bloc = { -# "W": {"W1": 0.4, "W2": 0.4, "C1": 0.1, "C2": 0.1}, -# "C": {"W1": 0.1, "W2": 0.1, "C1": 0.4, "C2": 0.4}, -# } -# bloc_voter_prop = {"W": 0.5, "C": 0.5} -# bloc_crossover_rate = {"W": {"C": 0}, "C": {"W": 0}} -# -# cs = CambridgeSampler( -# candidates=candidates, -# ballot_length=ballot_length, -# slate_to_candidate=slate_to_candidate, -# pref_interval_by_bloc=pref_interval_by_bloc, -# bloc_voter_prop=bloc_voter_prop, -# bloc_crossover_rate=bloc_crossover_rate, -# path=path, -# ) -# -# with open(path, "rb") as pickle_file: -# ballot_frequencies = pickle.load(pickle_file) -# slates = list(slate_to_candidate.keys()) -# -# # Let's update the running probability of the ballot based on where we are in the nesting -# ballot_prob_dict = dict() -# ballot_prob = [0, 0, 0, 0, 0] -# # p(white) vs p(poc) -# for slate in slates: -# opp_slate = next(iter(set(slates).difference(set(slate)))) -# -# slate_cands = slate_to_candidate[slate] -# opp_cands = slate_to_candidate[opp_slate] -# -# ballot_prob[0] = bloc_voter_prop[slate] -# prob_ballot_given_slate_first = bloc_order_probs_slate_first( -# slate, ballot_frequencies -# ) -# # p(crossover) vs p(non-crossover) -# for voter_bloc in slates: -# opp_voter_bloc = next(iter(set(slates).difference(set(voter_bloc)))) -# if voter_bloc == slate: -# ballot_prob[1] = 1 - bloc_crossover_rate[voter_bloc][opp_voter_bloc] -# -# # p(bloc ordering) -# for ( -# slate_first_ballot, -# slate_ballot_prob, -# ) in prob_ballot_given_slate_first.items(): -# ballot_prob[2] = slate_ballot_prob -# -# # Count number of each slate in the ballot -# slate_ballot_count_dict = {} -# for s, sc in slate_to_candidate.items(): -# count = sum([c == s for c in slate_first_ballot]) -# slate_ballot_count_dict[s] = min(count, len(sc)) -# -# # Make all possible perms with right number of slate candidates -# slate_perms = list( -# set( -# [ -# p[: slate_ballot_count_dict[slate]] -# for p in list(it.permutations(slate_cands)) -# ] -# ) -# ) -# opp_perms = list( -# set( -# [ -# p[: slate_ballot_count_dict[opp_slate]] -# for p in list(it.permutations(opp_cands)) -# ] -# ) -# ) -# -# only_slate_interval = { -# c: share -# for c, share in pref_interval_by_bloc[voter_bloc].items() -# if c in slate_cands -# } -# only_opp_interval = { -# c: share -# for c, share in pref_interval_by_bloc[voter_bloc].items() -# if c in opp_cands -# } -# for sp in slate_perms: -# ballot_prob[3] = compute_pl_prob(sp, only_slate_interval) -# for op in opp_perms: -# ballot_prob[4] = compute_pl_prob(op, only_opp_interval) -# -# # ADD PROB MULT TO DICT -# ordered_slate_cands = list(sp) -# ordered_opp_cands = list(op) -# ballot_ranking = [] -# for c in slate_first_ballot: -# if c == slate: -# if ordered_slate_cands: -# ballot_ranking.append( -# ordered_slate_cands.pop(0) -# ) -# else: -# if ordered_opp_cands: -# ballot_ranking.append(ordered_opp_cands.pop(0)) -# prob = np.prod(ballot_prob) -# ballot = tuple(ballot_ranking) -# ballot_prob_dict[ballot] = ( -# ballot_prob_dict.get(ballot, 0) + prob -# ) -# else: -# ballot_prob[1] = bloc_crossover_rate[voter_bloc][opp_voter_bloc] -# -# # p(bloc ordering) -# for ( -# slate_first_ballot, -# slate_ballot_prob, -# ) in prob_ballot_given_slate_first.items(): -# ballot_prob[2] = slate_ballot_prob -# -# # Count number of each slate in the ballot -# slate_ballot_count_dict = {} -# for s, sc in slate_to_candidate.items(): -# count = sum([c == s for c in slate_first_ballot]) -# slate_ballot_count_dict[s] = min(count, len(sc)) -# -# # Make all possible perms with right number of slate candidates -# slate_perms = [ -# p[: slate_ballot_count_dict[slate]] -# for p in list(it.permutations(slate_cands)) -# ] -# opp_perms = [ -# p[: slate_ballot_count_dict[opp_slate]] -# for p in list(it.permutations(opp_cands)) -# ] -# only_slate_interval = { -# c: share -# for c, share in pref_interval_by_bloc[opp_voter_bloc].items() -# if c in slate_cands -# } -# only_opp_interval = { -# c: share -# for c, share in pref_interval_by_bloc[opp_voter_bloc].items() -# if c in opp_cands -# } -# for sp in slate_perms: -# ballot_prob[3] = compute_pl_prob(sp, only_slate_interval) -# for op in opp_perms: -# ballot_prob[4] = compute_pl_prob(op, only_opp_interval) -# -# # ADD PROB MULT TO DICT -# ordered_slate_cands = list(sp) -# ordered_opp_cands = list(op) -# ballot_ranking = [] -# for c in slate_first_ballot: -# if c == slate: -# if ordered_slate_cands: -# ballot_ranking.append(ordered_slate_cands.pop()) -# else: -# if ordered_opp_cands: -# ballot_ranking.append(ordered_opp_cands.pop()) -# prob = np.prod(ballot_prob) -# ballot = tuple(ballot_ranking) -# ballot_prob_dict[ballot] = ( -# ballot_prob_dict.get(ballot, 0) + prob -# ) -# -# # Now see if ballot prob dict is right -# test_profile = cs.generate_profile(number_of_ballots=5000) -# assert do_ballot_probs_match_ballot_dist( -# ballot_prob_dict=ballot_prob_dict, -# generated_profile=test_profile, -# n=len(candidates), -# ) - - def compute_pl_prob(perm, interval): pref_interval = interval.copy() prob = 1 @@ -648,69 +469,81 @@ def bloc_order_probs_slate_first(slate, ballot_frequencies): return prob_ballot_given_slate_first -twobloc = { - "blocs": {"R": 0.6, "D": 0.4}, - "cohesion": {"R": 0.7, "D": 0.6}, - "alphas": {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}}, -} +def test_setparams_pl(): + blocs = {"R": 0.6, "D": 0.4} + cohesion = {"R": 0.7, "D": 0.6} + alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} -cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} -cands_lst = ["A", "B", "C"] + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} -test_slate = {"R": {"A1": 0.1, "B1": 0.5, "C1": 0.4}, "D": {"A2": 0.2, "B2": 0.5}} -test_voter_prop = {"R": 0.5, "D": 0.5} + pl = PlackettLuce.from_params( + slate_to_candidates=slate_to_cands, + blocs=blocs, + cohesion=cohesion, + alphas=alphas, + ) + # check params were set + assert pl.bloc_voter_prop == {"R": 0.6, "D": 0.4} + interval = pl.pref_interval_by_bloc + # check if intervals add up to one + assert math.isclose(sum(interval["R"].values()), 1) + assert math.isclose(sum(interval["D"].values()), 1) -# def test_setparams_pl(): -# pl = PlackettLuce(candidates=cands, hyperparams=twobloc) -# # check params were set -# assert pl.bloc_voter_prop == {"R": 0.6, "D": 0.4} -# interval = pl.pref_interval_by_bloc -# # check if intervals add up to one -# assert math.isclose(sum(interval["R"].values()), 1) -# assert math.isclose(sum(interval["D"].values()), 1) +def test_pl_both_inputs(): + blocs = {"R": 0.6, "D": 0.4} + cohesion = {"R": 0.7, "D": 0.6} + alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} -def test_bad_cands_input(): - # construct hyperparmeters with candidates not assigned to a bloc/slate - with pytest.raises(TypeError): - PlackettLuce(candidates=cands_lst, hyperparams=twobloc) + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} + cands_lst = ["A", "B", "C"] + test_slate = {"R": {"A1": 0.1, "B1": 0.5, "C1": 0.4}, "D": {"A2": 0.2, "B2": 0.5}} + test_voter_prop = {"R": 0.5, "D": 0.5} -# def test_pl_both_inputs(): -# gen = PlackettLuce( -# candidates=cands, -# pref_interval_by_bloc=test_slate, -# bloc_voter_prop=test_voter_prop, -# hyperparams=twobloc, -# ) -# # check that this attribute matches hyperparam input -# assert gen.bloc_voter_prop == {"R": 0.6, "D": 0.4} + gen = PlackettLuce.from_params( + candidates=cands_lst, + pref_interval_by_bloc=test_slate, + bloc_voter_prop=test_voter_prop, + slate_to_candidates=slate_to_cands, + blocs=blocs, + cohesion=cohesion, + alphas=alphas, + ) + # bloc_voter_prop will be overridden by test_voter_prop + assert gen.bloc_voter_prop == test_voter_prop -# def test_bt_single_bloc(): -# bloc = { -# "blocs": {"R": 1.0}, -# "cohesion": {"R": 0.7}, -# "alphas": {"R": {"R": 0.5, "D": 1}}, -# } -# cands = {"R": ["X", "Y", "Z"], "D": ["A", "B"]} +def test_bt_single_bloc(): + blocs = {"R": 0.6, "D": 0.4} + cohesion = {"R": 0.7, "D": 0.6} + alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} -# gen = BradleyTerry(candidates=cands, hyperparams=bloc) -# interval = gen.pref_interval_by_bloc -# assert math.isclose(sum(interval["R"].values()), 1) + gen = BradleyTerry.from_params( + slate_to_candidates=slate_to_cands, + blocs=blocs, + cohesion=cohesion, + alphas=alphas, + ) + interval = gen.pref_interval_by_bloc + assert math.isclose(sum(interval["R"].values()), 1) -# def test_incorrect_blocs(): -# params = { -# "blocs": {"R": 0.7, "D": 0.4}, -# "cohesion": {"R": 0.7, "D": 0.6}, -# "alphas": {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}}, -# } -# cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} +def test_incorrect_blocs(): + blocs = {"R": 0.7, "D": 0.4} + cohesion = {"R": 0.7, "D": 0.6} + alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} -# with pytest.raises(ValueError): -# PlackettLuce(candidates=cands, hyperparams=params) + with pytest.raises(ValueError): + PlackettLuce.from_params( + slate_to_candidates=slate_to_cands, + blocs=blocs, + cohesion=cohesion, + alphas=alphas, + ) def test_ac_profile_from_params(): From c7ea66faae363efd158e763e131ed0df7855ef0c Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Tue, 29 Aug 2023 16:16:24 -0400 Subject: [PATCH 08/15] added documentation --- src/votekit/ballot_generator.py | 54 +++++++++++++++++++-------------- tests/test_ballot_generator.py | 35 +++------------------ 2 files changed, 36 insertions(+), 53 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 07f898e6..0019d576 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -35,7 +35,17 @@ def __init__( Args: candidates (list): list of candidates in the election - ballot_length (Optional[int]): length of ballots to generate + ballot_length (Optional[int]): length of ballots to generate. + Defaults to the length of candidates. + pref_interval_by_bloc (dict[dict], optional): a mapping of slate to preference interval + (ex. {race: {candidate : interval length}}) + bloc_voter_prop (dict): a mapping of slate to voter proportions + (ex. {race: voter proportion}). Defaults to None. + + Raises: + ValueError: if the voter proportion for blocs don't sum to 1 + ValueError: if preference interval for candidates must sum to 1 + ValueError: slates and blocs are not the same """ self.ballot_length = ( @@ -43,7 +53,7 @@ def __init__( ) self.candidates = candidates - if bloc_voter_prop and pref_interval_by_bloc: + if bloc_voter_prop and pref_interval_by_bloc: # PL, BT, AC, CS if round(sum(bloc_voter_prop.values())) != 1: raise ValueError("Voter proportion for blocs must sum to 1") for interval in pref_interval_by_bloc.values(): @@ -58,31 +68,36 @@ def __init__( @classmethod def from_params( cls, - slate_to_candidates: dict, # add type error for list here - blocs: dict, + slate_to_candidates: dict, + bloc_voter_prop: dict, cohesion: dict, alphas: dict, **data, ): """ - Creates a Ballot Generator by constructing a preference interval from parameters + Initializes a Ballot Generator by constructing a preference interval from parameters Args: - slate_to_candidates (dict): _description_ - cohesion (dict): _description_ - alphas (dict): _description_ + slate_to_candidate (dict): a mapping of slate to candidates + (ex. {race: [candidate]}) + bloc_voter_prop (dict): a mapping of the percentage of total voters per bloc + (ex. {race: 0.5}) + cohesion (dict): cohension factor for each bloc + alphas (dict): alpha for the dirchlet distribution of each bloc Raises: - ValueError: _description_ - ValueError: _description_ + ValueError: if the voter proportion for blocs don't sum to 1 + ValueError: slates and blocs are not the same Returns: - _type_: _description_ + BallotGenerator: initialized ballot generator """ - if sum(blocs.values()) != 1.0: - raise ValueError(f"bloc proportions ({blocs.values()}) do not equal 1") - if slate_to_candidates.keys() != blocs.keys(): + if sum(bloc_voter_prop.values()) != 1.0: + raise ValueError( + f"bloc proportions ({bloc_voter_prop.values()}) do not equal 1" + ) + if slate_to_candidates.keys() != bloc_voter_prop.keys(): raise ValueError("slates and blocs are not the same") def _construct_preference_interval( @@ -103,7 +118,7 @@ def _construct_preference_interval( return intervals interval_by_bloc = {} - for bloc in blocs: + for bloc in bloc_voter_prop: interval = _construct_preference_interval( alphas[bloc], cohesion[bloc], bloc, slate_to_candidates ) @@ -119,7 +134,7 @@ def _construct_preference_interval( data["pref_interval_by_bloc"] = interval_by_bloc if "bloc_voter_prop" not in data: - data["bloc_voter_prop"] = blocs + data["bloc_voter_prop"] = bloc_voter_prop generator = cls(**data) @@ -275,9 +290,6 @@ def __init__(self, **data): bloc_voter_prop (dict): a mapping of slate to voter proportions (ex. {race: voter proportion}) """ - # if not pref_interval_by_bloc: - # self.pref_interval_by_bloc: dict = {} - # self.bloc_voter_prop: dict = {} # Call the parent class's __init__ method to handle common parameters super().__init__(**data) @@ -323,10 +335,6 @@ def __init__(self, **data): (ex. {race: voter proportion}) """ - # if not pref_interval_by_bloc: - # self.pref_interval_by_bloc: dict = {} - # self.bloc_voter_prop: dict = {} - # Call the parent class's __init__ method to handle common parameters super().__init__(**data) diff --git a/tests/test_ballot_generator.py b/tests/test_ballot_generator.py index e00f490c..10680e97 100644 --- a/tests/test_ballot_generator.py +++ b/tests/test_ballot_generator.py @@ -478,7 +478,7 @@ def test_setparams_pl(): pl = PlackettLuce.from_params( slate_to_candidates=slate_to_cands, - blocs=blocs, + bloc_voter_prop=blocs, cohesion=cohesion, alphas=alphas, ) @@ -490,31 +490,6 @@ def test_setparams_pl(): assert math.isclose(sum(interval["D"].values()), 1) -def test_pl_both_inputs(): - - blocs = {"R": 0.6, "D": 0.4} - cohesion = {"R": 0.7, "D": 0.6} - alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} - - slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} - - cands_lst = ["A", "B", "C"] - test_slate = {"R": {"A1": 0.1, "B1": 0.5, "C1": 0.4}, "D": {"A2": 0.2, "B2": 0.5}} - test_voter_prop = {"R": 0.5, "D": 0.5} - - gen = PlackettLuce.from_params( - candidates=cands_lst, - pref_interval_by_bloc=test_slate, - bloc_voter_prop=test_voter_prop, - slate_to_candidates=slate_to_cands, - blocs=blocs, - cohesion=cohesion, - alphas=alphas, - ) - # bloc_voter_prop will be overridden by test_voter_prop - assert gen.bloc_voter_prop == test_voter_prop - - def test_bt_single_bloc(): blocs = {"R": 0.6, "D": 0.4} cohesion = {"R": 0.7, "D": 0.6} @@ -523,7 +498,7 @@ def test_bt_single_bloc(): gen = BradleyTerry.from_params( slate_to_candidates=slate_to_cands, - blocs=blocs, + bloc_voter_prop=blocs, cohesion=cohesion, alphas=alphas, ) @@ -540,7 +515,7 @@ def test_incorrect_blocs(): with pytest.raises(ValueError): PlackettLuce.from_params( slate_to_candidates=slate_to_cands, - blocs=blocs, + bloc_voter_prop=blocs, cohesion=cohesion, alphas=alphas, ) @@ -553,7 +528,7 @@ def test_ac_profile_from_params(): crossover = {"R": {"D": 0.5}, "D": {"R": 0.6}} slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} ac = AlternatingCrossover.from_params( - blocs=blocs, + bloc_voter_prop=blocs, cohesion=cohesion, alphas=alphas, slate_to_candidates=slate_to_cands, @@ -571,7 +546,7 @@ def test_pl_profile_from_params(): slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} ac = PlackettLuce.from_params( - blocs=blocs, + bloc_voter_prop=blocs, slate_to_candidates=slate_to_cands, cohesion=cohesion, alphas=alphas, From 51003dbee9a8af2741a2957c147d2eec214fb6a3 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Tue, 29 Aug 2023 16:19:22 -0400 Subject: [PATCH 09/15] cleaned up code --- src/votekit/ballot_generator.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 0019d576..b59d2735 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -14,15 +14,6 @@ class BallotGenerator: - - # cand is a set - # number_of_ballots: int - # candidates: list - # ballot_length: Optional[int] - # slate_to_candidate: Optional[dict] # race: [candidate] - # pref_interval_by_bloc: Optional[dict] = None # race: {candidate : interval length} - # demo_breakdown: Optional[dict] = None # race: percentage - def __init__( self, candidates: list, From 5ad6b52e6afb6cb986cf36df013d5f6e920764e1 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Tue, 29 Aug 2023 16:21:55 -0400 Subject: [PATCH 10/15] Update ballot_generator.py --- src/votekit/ballot_generator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index b59d2735..05c95091 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -92,14 +92,14 @@ def from_params( raise ValueError("slates and blocs are not the same") def _construct_preference_interval( - alphas: dict, cohesion: int, bloc: str, candidates: dict + alphas: dict, cohesion: int, bloc: str, slate_to_cands: dict ) -> dict: intervals = {} for group, alpha in alphas.items(): - num_cands = len(candidates[group]) + num_cands = len(slate_to_cands[group]) probs = list(np.random.dirichlet([alpha] * num_cands)) - for prob, cand in zip(probs, candidates[group]): + for prob, cand in zip(probs, slate_to_cands[group]): if group == bloc: # e.g W for W cands pi = cohesion else: # e.g W for POC cands From e8e8109785f6dc06e25d8ec6f1f0eefb27c41497 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:45:46 -0400 Subject: [PATCH 11/15] resolved Jame's comments --- src/votekit/ballot_generator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 05c95091..1239e348 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -17,6 +17,7 @@ class BallotGenerator: def __init__( self, candidates: list, + *, ballot_length: Optional[int] = None, pref_interval_by_bloc=None, bloc_voter_prop=None, @@ -129,9 +130,7 @@ def _construct_preference_interval( generator = cls(**data) - if isinstance(generator, AlternatingCrossover) or isinstance( - generator, CambridgeSampler - ): + if isinstance(generator, (AlternatingCrossover, CambridgeSampler)): generator.slate_to_candidates = slate_to_candidates return generator @@ -235,6 +234,8 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: np.random.dirichlet([self.alpha] * len(perm_rankings)) ) elif self.point: + # calculates probabilities for each ranking + # using probability distribution for candidate support draw_probabilities = [ reduce( lambda prod, cand: prod * self.point[cand] if self.point else 0, From 86f193f82e2db38ceccd357d0648226deda71c75 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:47:51 -0400 Subject: [PATCH 12/15] documentation change --- src/votekit/ballot_generator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 1239e348..ba2a3936 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -68,6 +68,7 @@ def from_params( ): """ Initializes a Ballot Generator by constructing a preference interval from parameters + (the prior parameters will be overwrittern) Args: slate_to_candidate (dict): a mapping of slate to candidates From 3b778d83188db38a620288ec919e99cf3e35fc6d Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:39:11 -0400 Subject: [PATCH 13/15] updated documentation --- src/votekit/ballot_generator.py | 88 ++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index ba2a3936..69a96321 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -14,6 +14,10 @@ class BallotGenerator: + """ + Base class for ballot generation models + """ + def __init__( self, candidates: list, @@ -22,8 +26,9 @@ def __init__( pref_interval_by_bloc=None, bloc_voter_prop=None, ): + """ - Base class for ballot generation models + Initializes a Ballot Generator Args: candidates (list): list of candidates in the election @@ -139,6 +144,8 @@ def _construct_preference_interval( @abstractmethod def generate_profile(self, number_of_ballots: int) -> PreferenceProfile: """ + Generates a preference profile + Args: number_of_ballots (int): number of ballots to generate Returns: @@ -189,20 +196,17 @@ def ballot_pool_to_profile(ballot_pool, candidates) -> PreferenceProfile: return PreferenceProfile(ballots=ballot_list, candidates=candidates) -# inputs: -# write ballot simplex generation -# IC - alpha of infinity -# IAC - alpha of 1 -# bloc election vs non bloc distinction -# BallotSimplex - - class BallotSimplex(BallotGenerator): + """ + Base class for ballot generation models + """ + def __init__( self, alpha: Optional[float] = None, point: Optional[dict] = None, **data ): """ - Base class for ballot generation with a ballot simplex + Initializes a Ballot Simplex model + Args: alpha (float, optional): alpha parameter for ballot simplex. Defaults to None. point (dict, optional): a point in the ballot simplex, @@ -215,6 +219,18 @@ def __init__( @classmethod def from_point(cls, point: dict, **data): + """ + Initializes a Ballot Simplex model from a point in the dirichlet distribution + + Args: + point (dict): a mapping of candidate to candidate support + + Raises: + ValueError: if the candidate support does not sum to 1 + + Returns: + BallotSimplex: initialized from point + """ if sum(point.values()) != 1.0: raise ValueError( f"probability distribution from point ({point.values()}) does not sum to 1" @@ -223,6 +239,15 @@ def from_point(cls, point: dict, **data): @classmethod def from_alpha(cls, alpha: float, **data): + """ + Initializes a Ballot Simplex model from an alpha value for the dirichlet distribution + + Args: + alpha (float): an alpha parameter for the dirichlet distribution + + Returns: + BallotSimplex: initialized from alpha + """ return cls(alpha=alpha, **data) def generate_profile(self, number_of_ballots) -> PreferenceProfile: @@ -263,19 +288,33 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: class ImpartialCulture(BallotSimplex): + """ + Impartial Culture model (child class of BallotSimplex) + with an alpha value of 1e10 (should be infinity theoretically) + """ + def __init__(self, **data): super().__init__(alpha=1e10, **data) class ImpartialAnonymousCulture(BallotSimplex): + """ + Impartial Anonymous Culture model (child class of BallotSimplex) + with an alpha value of 1 + """ + def __init__(self, **data): super().__init__(alpha=1, **data) class PlackettLuce(BallotGenerator): + """ + Plackett Luce Ballot Generation Model (child class of BallotGenerator) + """ + def __init__(self, **data): """ - Plackett Luce Ballot Generation Model + Initializes Plackett Luce Ballot Generation Model Args: pref_interval_by_bloc (dict): a mapping of slate to preference interval @@ -317,9 +356,13 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: class BradleyTerry(BallotGenerator): + """ + Bradley Terry Ballot Generation Model (child class of BallotGenerator) + """ + def __init__(self, **data): """ - Bradley Terry Ballot Generation Model + Initializes a Bradley Terry Ballot Generation Model Args: pref_interval_by_bloc (dict): a mapping of slate to preference interval @@ -393,6 +436,10 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: class AlternatingCrossover(BallotGenerator): + """ + Alternating Crossover Ballot Generation Model (child class of BallotGenerator) + """ + def __init__( self, slate_to_candidates=None, @@ -400,7 +447,7 @@ def __init__( **data, ): """ - Alternating Crossover Ballot Generation Model + Initializes Alternating Crossover Ballot Generation Model Args: slate_to_candidate (dict): a mapping of slate to candidates @@ -507,6 +554,10 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: class CambridgeSampler(BallotGenerator): + """ + Cambridge Sampler Ballot Generation model (child class of BallotGenerator) + """ + def __init__( self, slate_to_candidates=None, @@ -514,6 +565,17 @@ def __init__( path: Optional[Path] = None, **data, ): + """ + Initializes Cambridge Sampler Ballot Generation model + + Args: + slate_to_candidate (dict): a mapping of slate to candidates + (ex. {race: [candidate]}) + pref_interval_by_bloc (dict): a mapping of bloc to preference interval + (ex. {race: {candidate : interval length}}) + path (Optional[Path]): a path to an election data file to sample from. + Defaults to Cambridge elections. + """ # Call the parent class's __init__ method to handle common parameters super().__init__(**data) From 7048f1726ce97986570e6704572b3340a24b4f81 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Thu, 31 Aug 2023 12:31:27 -0400 Subject: [PATCH 14/15] changed ic/iac implementation --- src/votekit/ballot_generator.py | 31 +++++++++++++++++++++---------- src/votekit/pref_profile.py | 14 ++++++++++++++ tests/test_ballot_generator.py | 19 ++++++++++++++----- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 69a96321..11f28a01 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -98,6 +98,9 @@ def from_params( if slate_to_candidates.keys() != bloc_voter_prop.keys(): raise ValueError("slates and blocs are not the same") + # TODO: write test that check preference interval sums to 1 + # write test that checks relative ordering + def _construct_preference_interval( alphas: dict, cohesion: int, bloc: str, slate_to_cands: dict ) -> dict: @@ -105,7 +108,8 @@ def _construct_preference_interval( for group, alpha in alphas.items(): num_cands = len(slate_to_cands[group]) - probs = list(np.random.dirichlet([alpha] * num_cands)) + alpha = [alpha] * num_cands + probs = list(np.random.default_rng().dirichlet(alpha=alpha)) for prob, cand in zip(probs, slate_to_cands[group]): if group == bloc: # e.g W for W cands pi = cohesion @@ -211,11 +215,19 @@ def __init__( alpha (float, optional): alpha parameter for ballot simplex. Defaults to None. point (dict, optional): a point in the ballot simplex, with candidate as keys and electoral support as values. Defaults to None. + + Raises: + AttributeError: if point and alpha are not initialized """ - if alpha or point: - self.alpha = alpha - self.point = point - super().__init__(**data) + if alpha is None and point is None: + raise AttributeError("point or alpha must be initialized") + self.alpha = alpha + if alpha == float("inf"): + self.alpha = 1e20 + if alpha == 0: + self.alpha = 1e-10 + self.point = point + super().__init__(**data) @classmethod def from_point(cls, point: dict, **data): @@ -248,6 +260,7 @@ def from_alpha(cls, alpha: float, **data): Returns: BallotSimplex: initialized from alpha """ + return cls(alpha=alpha, **data) def generate_profile(self, number_of_ballots) -> PreferenceProfile: @@ -255,9 +268,9 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: perm_rankings = [list(value) for value in perm_set] - if self.alpha: + if self.alpha is not None: draw_probabilities = list( - np.random.dirichlet([self.alpha] * len(perm_rankings)) + np.random.default_rng().dirichlet([self.alpha] * len(perm_rankings)) ) elif self.point: # calculates probabilities for each ranking @@ -273,8 +286,6 @@ def generate_profile(self, number_of_ballots) -> PreferenceProfile: draw_probabilities = [ prob / sum(draw_probabilities) for prob in draw_probabilities ] - else: - raise AttributeError("alpha or point needs to be initialized.") ballot_pool = [] @@ -294,7 +305,7 @@ class ImpartialCulture(BallotSimplex): """ def __init__(self, **data): - super().__init__(alpha=1e10, **data) + super().__init__(alpha=float("inf"), **data) class ImpartialAnonymousCulture(BallotSimplex): diff --git a/src/votekit/pref_profile.py b/src/votekit/pref_profile.py index f83bc89d..5bb6cfe1 100644 --- a/src/votekit/pref_profile.py +++ b/src/votekit/pref_profile.py @@ -1,3 +1,4 @@ +import csv from fractions import Fraction import pandas as pd from pydantic import BaseModel, validator @@ -76,6 +77,19 @@ def to_dict(self, standardize: bool) -> dict: class Config: arbitrary_types_allowed = True + def to_csv(self, fpath): + """ + Saves Preference Profile to CSV + Args: + fpath (str): path to the saved csv + """ + with open(fpath, "w", newline="") as csvfile: + fieldnames = ["weight", "ranking"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + for ballot in self.ballots: + writer.writerow({"weight": ballot.weight, "ranking": ballot.ranking}) + def create_df(self) -> pd.DataFrame: """ Creates DF for display and building plots diff --git a/tests/test_ballot_generator.py b/tests/test_ballot_generator.py index 10680e97..028bc2b7 100644 --- a/tests/test_ballot_generator.py +++ b/tests/test_ballot_generator.py @@ -183,17 +183,26 @@ def test_ballot_simplex_from_point(): b: 1 / math.factorial(len(candidates)) for b in possible_rankings } - generated_profile = ( - BallotSimplex() - .from_point(point=pt, ballot_length=ballot_length, candidates=candidates) - .generate_profile(number_of_ballots=number_of_ballots) - ) + generated_profile = BallotSimplex.from_point( + point=pt, ballot_length=ballot_length, candidates=candidates + ).generate_profile(number_of_ballots=number_of_ballots) # Test assert do_ballot_probs_match_ballot_dist( ballot_prob_dict, generated_profile, len(candidates) ) +def test_ballot_simplex_from_alpha_zero(): + number_of_ballots = 1000 + candidates = ["W1", "W2", "C1", "C2"] + + generated_profile = BallotSimplex.from_alpha( + alpha=0, candidates=candidates + ).generate_profile(number_of_ballots=number_of_ballots) + + assert len(generated_profile.ballots) == 1 + + # def test_iac_distribution(): # number_of_ballots = 1000 # ballot_length = 4 From a17660a1384b19b8ea7d207ee167ed2228988b74 Mon Sep 17 00:00:00 2001 From: Jennifer Wang <38547472+jennjwang@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:54:48 -0400 Subject: [PATCH 15/15] added tests --- src/votekit/ballot_generator.py | 3 --- tests/test_ballot_generator.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/votekit/ballot_generator.py b/src/votekit/ballot_generator.py index 11f28a01..84f01fbe 100644 --- a/src/votekit/ballot_generator.py +++ b/src/votekit/ballot_generator.py @@ -98,9 +98,6 @@ def from_params( if slate_to_candidates.keys() != bloc_voter_prop.keys(): raise ValueError("slates and blocs are not the same") - # TODO: write test that check preference interval sums to 1 - # write test that checks relative ordering - def _construct_preference_interval( alphas: dict, cohesion: int, bloc: str, slate_to_cands: dict ) -> dict: diff --git a/tests/test_ballot_generator.py b/tests/test_ballot_generator.py index 028bc2b7..ceda2cef 100644 --- a/tests/test_ballot_generator.py +++ b/tests/test_ballot_generator.py @@ -563,3 +563,44 @@ def test_pl_profile_from_params(): profile = ac.generate_profile(3) assert type(profile) is PreferenceProfile + + +def test_interval_sum_from_params(): + + blocs = {"R": 0.6, "D": 0.4} + cohesion = {"R": 0.7, "D": 0.6} + alphas = {"R": {"R": 0.5, "D": 1}, "D": {"R": 1, "D": 0.5}} + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} + + ac = PlackettLuce.from_params( + bloc_voter_prop=blocs, + slate_to_candidates=slate_to_cands, + cohesion=cohesion, + alphas=alphas, + ) + for b in ac.pref_interval_by_bloc: + if not math.isclose(sum(ac.pref_interval_by_bloc[b].values()), 1): + assert False + assert True + + +def test_interval_from_params(): + + blocs = {"R": 0.9, "D": 0.1} + cohesion = {"R": 0.9, "D": 0.9} + alphas = {"R": {"R": 1, "D": 1}, "D": {"R": 1, "D": 1}} + slate_to_cands = {"R": ["A1", "B1", "C1"], "D": ["A2", "B2"]} + + ac = PlackettLuce.from_params( + bloc_voter_prop=blocs, + slate_to_candidates=slate_to_cands, + cohesion=cohesion, + alphas=alphas, + ) + + for b in blocs: + pref = ac.pref_interval_by_bloc[b].values() + if not any(value > 0.5 for value in pref): + assert False + + assert True