Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

random elections and spatial generation #130

Merged
merged 10 commits into from
Aug 9, 2024
137 changes: 137 additions & 0 deletions src/votekit/ballot_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1930,3 +1930,140 @@ def generate_profile(
# else return the combined profiles
else:
return pp


class UniformSpatial(BallotGenerator):
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
"""
Uniform spatial model for ballot generation. Assumes the candidates are uniformly distributed in
d dimensional space. Voters are then distributed equivalently, and vote with
ballots based on Euclidean distance to the candidates.
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Args:
candidates (list): List of candidate strings.
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Attributes:
candidates (list): List of candidate strings.
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

cdonnay marked this conversation as resolved.
Show resolved Hide resolved
"""

def generate_profile(
self,
number_of_ballots: int,
by_bloc: bool = False,
dim: int = 2,
lower: float = 0,
upper: float = 1,
seed: Optional[int] = None,
) -> Union[PreferenceProfile, Tuple]:
"""
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
Args:
number_of_ballots (int): The number of ballots to generate.
by_bloc (bool): Dummy variable from parent class.
dim (int, optional): number of dimensions to use, defaults to 2d
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
lower (float, optional): lower bound for uniform distribution, defaults to 0
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
upper (float, optional): upper bound for uniform distribution, defaults to 1
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
seed (int, optional): seed for random generation
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Returns:
Union[PreferenceProfile, Tuple]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you explain what is in the tuple and in what order?

"""

np.random.seed(seed)
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
candidate_position_dict = {
c: np.random.uniform(lower, upper, size=dim) for c in self.candidates
}
voter_positions = np.random.uniform(lower, upper, size=(number_of_ballots, dim))

ballot_pool = []
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

for vp in voter_positions:
distance_dict = {
c: np.linalg.norm(v - vp) for c, v, in candidate_position_dict.items()
}
candidate_order = sorted(distance_dict, key=distance_dict.__getitem__)
ballot_pool.append(candidate_order)
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

# reset the seed
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
np.random.seed(None)

return (
self.ballot_pool_to_profile(ballot_pool, self.candidates),
candidate_position_dict,
voter_positions,
)


class ClusteredSpatial(BallotGenerator):
"""
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
Clustered spatial model for ballot generation.
Assumes the candidates are uniformly distributed in d dimensional space.
Voters are then distributed normally around them, and vote with
ballots based on Euclidean distance to the candidates.

Args:
candidates (list): List of candidate strings.
Copy link
Collaborator

Choose a reason for hiding this comment

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

(list[str])

Copy link
Collaborator

Choose a reason for hiding this comment

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

also move distribution, distance to init method instead of generate_profile


Attributes:
candidates (list): List of candidate strings.
Copy link
Collaborator

Choose a reason for hiding this comment

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

(list[str])


"""

def generate_profile_with_dict(
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm guessing this renaming is necessary since the parent class method ballot_generator doesn't have this dictionary argument. @peterrrock2 , I'd love to keep all the ballot generator methods the same name, any ideas?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I mean, you can just call it generate_profile. Abstract classes generally allow you to add additional keyword arguments in the derived class function. You just need to make sure that you at least include the keywords from the abstract class's original definition

self,
number_of_ballots: dict,
by_bloc: bool = False,
dim: int = 2,
lower: float = 0,
upper: float = 1,
std: float = 1,
seed: Optional[int] = None,
) -> Union[PreferenceProfile, Tuple]:
"""
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
Args:
number_of_ballots (dict): The number of voters attributed
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
to each candidate {candidate string: # voters}
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
by_bloc (bool): Dummy variable from parent class.
dim (int, optional): number of dimensions to use, defaults to 2d
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
lower (float, optional): lower bound for uniform distribution, defaults to 0
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
upper (float, optional): upper bound for uniform distribution, defaults to 1
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
std (float, optional): standard deviation for voters normally distributed around
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
candidates, defaults to 1
seed (int, optional): seed for random generation
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Returns:
Union[PreferenceProfile, Tuple]
Copy link
Collaborator

Choose a reason for hiding this comment

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

can you say what is returned and in what order?

"""

np.random.seed(seed)
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
candidate_position_dict = {
c: np.random.uniform(lower, upper, size=dim) for c in self.candidates
}
voter_positions = []
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
for c in self.candidates:
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
voter_positions.append(
np.random.normal(
loc=candidate_position_dict[c],
scale=std,
size=(number_of_ballots[c], dim),
)
)

voter_positions_array = np.vstack(voter_positions)

ballot_pool = []
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

for vp in voter_positions_array:
distance_dict = {
c: np.linalg.norm(v - vp) for c, v, in candidate_position_dict.items()
}
candidate_order = sorted(distance_dict, key=distance_dict.__getitem__)
ballot_pool.append(candidate_order)

# reset the seed
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
np.random.seed(None)

return (
self.ballot_pool_to_profile(ballot_pool, self.candidates),
candidate_position_dict,
voter_positions_array,
)
2 changes: 2 additions & 0 deletions src/votekit/elections/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
IRV,
HighestScore,
Cumulative,
RandomDictator,
BoostedRandomDictator,
)

from .transfers import fractional_transfer, random_transfer # noqa
181 changes: 181 additions & 0 deletions src/votekit/elections/election_types.py
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fractions import Fraction
import itertools as it
import random
import numpy as np
from typing import Callable, Optional, Union
from functools import lru_cache
Expand Down Expand Up @@ -1117,3 +1118,183 @@ def __init__(
seats=seats,
tiebreak=tiebreak,
)


class RandomDictator(Election):
"""
Choose a winner randomly from the distribution of first place votes. For multi-winner elections
repeat this process for every winner, removing that candidate from every voter's ballot
once they have been elected
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Args:
profile (PreferenceProfile): PreferenceProfile to run election on
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
seats (int): number of seats to select
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Attributes:
_profile (PreferenceProfile): PreferenceProfile to run election on
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
seats (int): Number of seats to be elected.
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

"""

def __init__(self, profile: PreferenceProfile, seats: int):
# the super method says call the Election class
super().__init__(profile, ballot_ties=False)
self.seats = seats

def next_round(self) -> bool:
"""
Determines if another round is needed.

Returns:
True if number of seats has not been met, False otherwise
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
"""
cands_elected = 0
for s in self.state.winners():
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
cands_elected += len(s)
return cands_elected < self.seats

def run_step(self):
if self.next_round():
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
remaining = self.state.profile.get_candidates()

ballots = self.state.profile.ballots
weights = [b.weight for b in ballots]
random_ballot = random.choices(
self.state.profile.ballots, weights=weights, k=1
)[0]

# randomly choose a winner from the first place rankings
winning_candidate = list(random_ballot.ranking[0])[0]
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

# some formatting to make it compatible with ElectionState, which
# requires a list of sets of strings
elected = [{winning_candidate}]

# remove the winner from the ballots
# Does this move second place votes up to first place?
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
new_ballots = remove_cand(winning_candidate, self.state.profile.ballots)
new_profile = PreferenceProfile(ballots=new_ballots)

# determine who remains
remaining = [{c} for c in remaining if c != winning_candidate]

# update for the next round
self.state = ElectionState(
curr_round=self.state.curr_round + 1,
elected=elected,
eliminated_cands=[],
remaining=remaining,
profile=new_profile,
previous=self.state,
)

# if this is the last round, move remaining to eliminated
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
if not self.next_round():
self.state = ElectionState(
curr_round=self.state.curr_round,
elected=elected,
eliminated_cands=remaining,
remaining=[],
profile=new_profile,
previous=self.state.previous,
)
return self.state

def run_election(self):
# run steps until we elect the required number of candidates
while self.next_round():
self.run_step()

return self.state


class BoostedRandomDictator(RandomDictator):
"""
Modified random dictator where we
- Choose a winner randomly from the distribution of first
place votes with probability (1 - 1/(# Candidates - 1))
- Choose a winner via a proportional to squares rule with
probability 1/(# of Candidates - 1)
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

For multi-winner elections
repeat this process for every winner, removing that candidate from every voter's ballot
once they have been elected
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Args:
profile (PreferenceProfile): PreferenceProfile to run election on
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
seats (int): number of seats to select
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Attributes:
_profile (PreferenceProfile): PreferenceProfile to run election on
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
seats (int): Number of seats to be elected.

"""

def __init__(self, profile: PreferenceProfile, seats: int):
# the super method says call the Election class
# ballot_ties = True means it will resolve any ties in our ballots
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
super().__init__(profile, seats)

def run_step(self):
if self.next_round():
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
remaining = self.state.profile.get_candidates()
u = random.uniform(0, 1)

if len(remaining) == 1:
winning_candidate = remaining[0]

elif u <= 1 / (len(remaining) - 1):
# Choose via proportional to squares
candidate_votes = {c: 0 for c in remaining}
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
for ballot in self.state.profile.get_ballots():
top_choice = list(ballot.ranking[0])[0]
Copy link
Collaborator

Choose a reason for hiding this comment

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

same as above, does list randomize the order of the set?

Copy link
Collaborator

Choose a reason for hiding this comment

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

random.choice(random_ballot.ranking[0])[0]

candidate_votes[top_choice] += float(ballot.weight)

squares = np.array(
[(i / len(remaining)) ** 2 for i in candidate_votes.values()]
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
)
sum_of_squares = np.sum(squares)
probabilities = squares / sum_of_squares
winning_candidate = np.random.choice(remaining, p=probabilities)

else:
ballots = self.state.profile.ballots
weights = [b.weight for b in ballots]
random_ballot = random.choices(
self.state.profile.ballots, weights=weights, k=1
)[0]
# randomly choose a winner according to first place rankings
winning_candidate = list(random_ballot.ranking[0])[0]
Copy link
Collaborator

Choose a reason for hiding this comment

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

same comment as above, does list randomize the order of the set? @peterrrock2

Copy link
Collaborator

Choose a reason for hiding this comment

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

random.choice(random_ballot.ranking[0])[0]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After some googling I believe list does not randomize the order of the set. Unfortunately random.choice() doesn't work on sets either. It could be slow but a good option might be: random.choice(list(random_ballot.ranking[0])). What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

So the order of the set is determined by the hash of the underlying type. There is an environment variable, PYTHONHASHSEED, that controls this for security reasons. If you want this to be deterministic, you will need to use something other than a set. You can make use of the OrderedSet class in the collections module, or just use a dictionary. Convertiing to a list is also fine here. It doesn't really take up a lot of overhead in the grand scheme of things.


# some formatting to make it compatible with ElectionState, which
# requires a list of sets of strings
elected = [{winning_candidate}]

# remove the winner from the ballots
new_ballots = remove_cand(winning_candidate, self.state.profile.ballots)
new_profile = PreferenceProfile(ballots=new_ballots)

# determine who remains
remaining = [{c} for c in remaining if c != winning_candidate]

# update for the next round
self.state = ElectionState(
curr_round=self.state.curr_round + 1,
elected=elected,
eliminated_cands=[],
remaining=remaining,
profile=new_profile,
previous=self.state,
)

# if this is the last round, move remaining to eliminated
if not self.next_round():
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
self.state = ElectionState(
curr_round=self.state.curr_round,
elected=elected,
eliminated_cands=remaining,
remaining=[],
profile=new_profile,
previous=self.state.previous,
)
return self.state
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading