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):
"""
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.

Args:
candidates (list): List of candidate strings.

Attributes:
candidates (list): List of candidate strings.

"""

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]:
"""
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
lower (float, optional): lower bound for uniform distribution, defaults to 0
upper (float, optional): upper bound for uniform distribution, defaults to 1
seed (int, optional): seed for random generation

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)
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 = []

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)

# reset the seed
np.random.seed(None)

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


class ClusteredSpatial(BallotGenerator):
"""
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]:
"""
Args:
number_of_ballots (dict): The number of voters attributed
to each candidate {candidate string: # voters}
by_bloc (bool): Dummy variable from parent class.
dim (int, optional): number of dimensions to use, defaults to 2d
lower (float, optional): lower bound for uniform distribution, defaults to 0
upper (float, optional): upper bound for uniform distribution, defaults to 1
std (float, optional): standard deviation for voters normally distributed around
candidates, defaults to 1
seed (int, optional): seed for random generation

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)
candidate_position_dict = {
c: np.random.uniform(lower, upper, size=dim) for c in self.candidates
}
voter_positions = []
for c in self.candidates:
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 = []

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
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
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

Args:
profile (PreferenceProfile): PreferenceProfile to run election on
seats (int): number of seats to select

Attributes:
_profile (PreferenceProfile): PreferenceProfile to run election on
seats (int): Number of seats to be elected.

"""

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
"""
cands_elected = 0
for s in self.state.winners():
cands_elected += len(s)
return cands_elected < self.seats

def run_step(self):
if self.next_round():
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]

# 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?
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():
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)

For multi-winner elections
repeat this process for every winner, removing that candidate from every voter's ballot
once they have been elected

Args:
profile (PreferenceProfile): PreferenceProfile to run election on
seats (int): number of seats to select

Attributes:
_profile (PreferenceProfile): PreferenceProfile to run election on
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
super().__init__(profile, seats)

def run_step(self):
if self.next_round():
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}
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()]
)
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():
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
Loading
Loading