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
226 changes: 225 additions & 1 deletion src/votekit/ballot_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pickle
import random
import warnings
from typing import Optional, Union, Tuple
from typing import Optional, Union, Tuple, Callable, Dict
import apportionment.methods as apportion # type: ignore

from .ballot import Ballot
Expand Down Expand Up @@ -1930,3 +1930,227 @@ def generate_profile(
# else return the combined profiles
else:
return pp


# Not sure where to put this! Poetry complains if I try to put it inside the class
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
def euclidean_distance(point1: np.ndarray, point2: np.ndarray) -> float:
return float(np.linalg.norm(point1 - point2))


class DSpatial(BallotGenerator):
"""
Spatial model for ballot generation. In some metric space determined
by an input distance function, randomly sample each voter's and
each candidate's positions from input voter and candidate distributions.
Using generate_profile() outputs a ranked profile which is consistent
with the sampled positions (respects distances).

Args:
candidates (list[str]): List of candidate strings.
voter_dist (Callable[..., np.ndarray]): Distribution to sample a single
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
voter's position from, defaults to uniform distribution.
voter_params: (dict[str, float], optional): Parameters to be passed to
voter_dist, defaults to uniform(0,1) in 2 dimensions.
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
candidate_dist: (Callable[..., np.ndarray]): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_params: Optional[Dict[str, float]]: Parameters to be passed
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
to candidate_dist, defaults to uniform(0,1) in 2 dimensions.
distance: (Callable[[np.ndarray, np.ndarray], float]], optional):
Computes distance between a voter and a candidate,
defaults to euclidean distance.
Attributes:
candidates (list[str]): List of candidate strings.
voter_dist (Callable[..., np.ndarray]): Distribution to sample a single
voter's position from, defaults to uniform distribution.
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
voter_params: (dict[str, float], optional): Parameters to be passed to
voter_dist, defaults to uniform(0,1) in 2 dimensions.
candidate_dist: (Callable[..., np.ndarray]): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_params: Optional[Dict[str, float]]: Parameters to be passed
to candidate_dist, defaults to uniform(0,1) in 2 dimensions.
distance: (Callable[[np.ndarray, np.ndarray], float]], optional):
Computes distance between a voter and a candidate,
defaults to euclidean distance.

"""

def __init__(
self,
candidates: list[str],
voter_dist: Callable[..., np.ndarray] = np.random.uniform,
voter_params: Optional[Dict[str, float]] = None,
candidate_dist: Callable[..., np.ndarray] = np.random.uniform,
candidate_params: Optional[Dict[str, float]] = None,
distance: Callable[[np.ndarray, np.ndarray], float] = euclidean_distance,
):
super().__init__(candidates=candidates)
self.voter_dist = voter_dist
self.candidate_dist = candidate_dist

if voter_params is None:
# default params used for np.random.uniform
self.voter_params = {"low": 0.0, "high": 1.0, "size": 2.0}
else:
self.voter_params = voter_params
cdonnay marked this conversation as resolved.
Show resolved Hide resolved

if candidate_params is None:
# default params used for np.random.uniform
self.candidate_params = {"low": 0.0, "high": 1.0, "size": 2.0}
else:
self.candidate_params = candidate_params
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

cdonnay marked this conversation as resolved.
Show resolved Hide resolved
self.distance = distance

def generate_profile(
self, number_of_ballots: int, by_bloc: bool = False, 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.
seed (int, optional): Seed for random generation, defaults to None
which resets the random seed.
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved

Returns:
preference profile, candidate positions, voter positions:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Correct return string syntax is Union[PreferenceProfile, Tuple]: explanation of what is returned

Copy link
Collaborator

Choose a reason for hiding this comment

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

@peterrrock2 , I'm just making a note that I think we can clean up how the BallotGenerator class is defined to avoid these weird type issues. The reason this is a Union[PrefProfile, Tuple] is b/c that's what the abstract method expects.

(Union[PreferenceProfile, Tuple])
"""

np.random.seed(seed)
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
candidate_position_dict = {
c: self.candidate_dist(**self.candidate_params) for c in self.candidates
}
voter_positions = np.array(
[self.voter_dist(**self.voter_params) for v in range(number_of_ballots)]
)

ballot_pool = []
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
for v in voter_positions:
distance_dict = {
i: self.distance(v, c) for i, c, in candidate_position_dict.items()
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
}
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 Clustered_DSpatial(BallotGenerator):
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
"""
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
Clustered spatial model for ballot generation. In some metric space
determined by an input distance function, randomly sample
each candidate's positions from input candidate distribution. Then
sample voters's positions from normal distributions centered around each
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does it have to be normal?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So it doesn't have to be normal, but it would have to be a distribution where the mean is a parameter. I kept it as normal because I thought that would often be the most natural choice. But I can definitely try add a more general form with some sort of check to make sure the input has a mean parameter.

of the candidate's positions. Using generate_profile()
outputs a ranked profile which is consistent
with the sampled positions (respects distances).

Args:
candidates (list[str]): List of candidate strings.
voter_dist (Callable[..., np.ndarray]): Distribution to sample a single
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
voter's position from, defaults to uniform distribution.
voter_params: (dict[str, float], optional): Parameters to be passed to
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
voter_dist, defaults to uniform(0,1) in 2 dimensions.
candidate_dist: (Callable[..., np.ndarray]): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_params: Optional[Dict[str, float]]: Parameters to be passed
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
to candidate_dist, defaults to uniform(0,1) in 2 dimensions.
distance: (Callable[[np.ndarray, np.ndarray], float]], optional):
Computes distance between a voter and a candidate,
defaults to euclidean distance.
Attributes:
candidates (list[str]): List of candidate strings.
voter_dist (Callable[..., np.ndarray]): Distribution to sample a single
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
voter's position from, defaults to uniform distribution.
voter_params: (dict[str, float], optional): Parameters to be passed to
voter_dist, defaults to uniform(0,1) in 2 dimensions.
candidate_dist: (Callable[..., np.ndarray]): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_params: Optional[Dict[str, float]]: Parameters to be passed
to candidate_dist, defaults to uniform(0,1) in 2 dimensions.
distance: (Callable[[np.ndarray, np.ndarray], float]], optional):
Computes distance between a voter and a candidate,
defaults to euclidean distance.

"""

def __init__(
self,
candidates: list[str],
voter_dist: Callable[..., np.ndarray] = np.random.uniform,
voter_params: Optional[Dict[str, np.ndarray]] = None,
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
candidate_dist: Callable[..., np.ndarray] = np.random.uniform,
candidate_params: Optional[Dict[str, float]] = None,
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
distance: Callable[[np.ndarray, np.ndarray], float] = euclidean_distance,
):
super().__init__(candidates=candidates)
self.candidate_dist = candidate_dist

if voter_params is None:
# default params used for np.random.normal
self.voter_params = {"std": np.array(1.0), "size": np.array(2.0)}
else:
self.voter_params = voter_params

if candidate_params is None:
# default params used for np.random.uniform
self.candidate_params = {"low": 0.0, "high": 1.0, "size": 2.0}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Check callable signature.

else:
self.candidate_params = candidate_params

self.distance = distance

def generate_profile_with_dict(
self, number_of_ballots: dict, by_bloc: bool = False, 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.
seed (int, optional): Seed for random generation, defaults to None
which resets the random seed.

Returns:
preference profile, candidate positions, voter positions:
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
(Union[PreferenceProfile, Tuple])
"""

np.random.seed(seed)
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
candidate_position_dict = {
c: self.candidate_dist(**self.candidate_params) 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
for v in range(number_of_ballots[c]):
voter_positions.append(
np.random.normal(
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
loc=candidate_position_dict[c], **self.voter_params
)
)

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:
distance_dict = {
i: self.distance(vp, c) for i, c, in candidate_position_dict.items()
kevin-q2 marked this conversation as resolved.
Show resolved Hide resolved
}
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
Loading
Loading