Skip to content

Commit

Permalink
Merge pull request #148 from mggg/3.0.0.dev
Browse files Browse the repository at this point in the history
Spatial BG and Random Elections
  • Loading branch information
peterrrock2 authored Aug 15, 2024
2 parents faa8260 + 2b78d2b commit a06abef
Show file tree
Hide file tree
Showing 22 changed files with 2,586 additions and 19 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ extra_data/
.venv
.docs_venv
docs/_build
.dev
.dev
vote2
3 changes: 3 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- add some more tests to the dictator, boosted dictator, and plurality veto tests
- maybe more tests for spatial BGs, but we plan to edit that whole module anyway
- PV is not passing its test due to an issue with empty ballots being removed, thus messing up the random order
12 changes: 12 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ Ranking-based
:members:
:show-inheritance:

.. automodule:: votekit.elections.election_types.ranking.boosted_random_dictator
:members:
:show-inheritance:

.. automodule:: votekit.elections.election_types.ranking.condo_borda
:members:
:show-inheritance:
Expand All @@ -74,10 +78,18 @@ Ranking-based
:members:
:show-inheritance:

.. automodule:: votekit.elections.election_types.ranking.plurality_veto
:members:
:show-inheritance:

.. automodule:: votekit.elections.election_types.ranking.plurality
:members:
:show-inheritance:

.. automodule:: votekit.elections.election_types.ranking.random_dictator
:members:
:show-inheritance:

.. automodule:: votekit.elections.election_types.ranking.stv
:members:
:show-inheritance:
Expand Down
1,051 changes: 1,051 additions & 0 deletions notebooks/6_metric_voting.ipynb

Large diffs are not rendered by default.

306 changes: 305 additions & 1 deletion src/votekit/ballot_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
import pickle
import random
import warnings
from typing import Optional, Union, Tuple
from typing import Optional, Union, Tuple, Callable, Dict, Any
import apportionment.methods as apportion # type: ignore

from .ballot import Ballot
from .pref_profile import PreferenceProfile
from .pref_interval import combine_preference_intervals, PreferenceInterval
from votekit.metrics import euclidean_dist


def sample_cohesion_ballot_types(
Expand Down Expand Up @@ -1950,3 +1951,306 @@ def generate_profile(
# else return the combined profiles
else:
return pp


class Spatial(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], optional): Distribution to sample a single
voter's position from, defaults to uniform distribution.
voter_dist_kwargs: (Optional[Dict[str, Any]], optional): Keyword args to be passed to
voter_dist, defaults to None, which creates the unif(0,1) distribution in 2 dimensions.
candidate_dist: (Callable[..., np.ndarray], optional): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_dist_kwargs: (Optional[Dict[str, Any]], optional): Keyword args to be passed
to candidate_dist, defaults to None, which creates the unif(0,1)
distribution 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], optional): Distribution to sample a single
voter's position from, defaults to uniform distribution.
voter_dist_kwargs: (Optional[Dict[str, Any]], optional): Keyword args to be passed to
voter_dist, defaults to None, which creates the unif(0,1) distribution in 2 dimensions.
candidate_dist: (Callable[..., np.ndarray], optional): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_dist_kwargs: (Optional[Dict[str, Any]], optional): Keyword args to be passed
to candidate_dist, defaults to None, which creates the unif(0,1)
distribution 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_dist_kwargs: Optional[Dict[str, Any]] = None,
candidate_dist: Callable[..., np.ndarray] = np.random.uniform,
candidate_dist_kwargs: Optional[Dict[str, Any]] = None,
distance: Callable[[np.ndarray, np.ndarray], float] = euclidean_dist,
):
super().__init__(candidates=candidates)
self.voter_dist = voter_dist
self.candidate_dist = candidate_dist

if voter_dist_kwargs is None:
if voter_dist is np.random.uniform:
voter_dist_kwargs = {"low": 0.0, "high": 1.0, "size": 2.0}
else:
voter_dist_kwargs = {}

try:
self.voter_dist(**voter_dist_kwargs)
except TypeError:
raise TypeError("Invalid kwargs for the voter distribution.")

self.voter_dist_kwargs = voter_dist_kwargs

if candidate_dist_kwargs is None:
if candidate_dist is np.random.uniform:
candidate_dist_kwargs = {"low": 0.0, "high": 1.0, "size": 2.0}
else:
candidate_dist_kwargs = {}

try:
self.candidate_dist(**candidate_dist_kwargs)
except TypeError:
raise TypeError("Invalid kwargs for the candidate distribution.")

self.candidate_dist_kwargs = candidate_dist_kwargs

try:
v = self.voter_dist(**self.voter_dist_kwargs)
c = self.candidate_dist(**self.candidate_dist_kwargs)
distance(v, c)
except TypeError:
raise TypeError(
"Distance function is invalid or incompatible "
"with voter/candidate distributions."
)

self.distance = distance

def generate_profile(
self, number_of_ballots: int, by_bloc: bool = False
) -> Tuple[PreferenceProfile, dict[str, np.ndarray], np.ndarray]:
"""
Samples a metric position for number_of_ballots voters from
the voter distribution. Samples a metric position for each candidate
from the input candidate distribution. With sampled
positions, this method then creates a ranked PreferenceProfile in which
voter's preferences are consistent with their distances to the candidates
in the metric space.
Args:
number_of_ballots (int): The number of ballots to generate.
by_bloc (bool): Dummy variable from parent class.
Returns:
Tuple[PreferenceProfile, dict[str, numpy.ndarray], numpy.ndarray]:
A tuple containing the preference profile object,
a dictionary with each candidate's position in the metric
space, and a matrix where each row is a single voter's position
in the metric space.
"""

candidate_position_dict = {
c: self.candidate_dist(**self.candidate_dist_kwargs)
for c in self.candidates
}
voter_positions = np.array(
[
self.voter_dist(**self.voter_dist_kwargs)
for v in range(number_of_ballots)
]
)

ballot_pool = [["c"] * len(self.candidates) for _ in range(number_of_ballots)]
for v in range(number_of_ballots):
distance_dict = {
c: self.distance(voter_positions[v], c_position)
for c, c_position in candidate_position_dict.items()
}
candidate_order = sorted(distance_dict, key=distance_dict.__getitem__)
ballot_pool[v] = candidate_order

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


class ClusteredSpatial(BallotGenerator):
"""
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 a distribution centered around each
of the candidate's positions.
NOTE: We currently only support the following list of voter distributions:
[np.random.normal, np.random.laplace, np.random.logistic, np.random.gumbel],
which is the complete list of numpy distributions that accept a 'loc' parameter allowing
us to center the distribution around each candidate. For more
information on numpy supported distributions and their parameters, please visit:
https://numpy.org/doc/1.16/reference/routines.random.html.
Args:
candidates (list[str]): List of candidate strings.
voter_dist (Callable[..., np.ndarray], optional): Distribution to sample a single
voter's position from, defaults to normal(0,1) distribution.
voter_dist_kwargs: (Optional[dict[str, Any]], optional): Keyword args to be passed to
voter_dist, defaults to None, which creates the unif(0,1) distribution in 2 dimensions.
candidate_dist: (Callable[..., np.ndarray], optional): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_dist_kwargs: (Optional[Dict[str, float]], optional): Keyword args to be passed
to candidate_dist, defaults None which creates the unif(0,1)
distribution 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], optional): Distribution to sample a single
voter's position from, defaults to uniform distribution.
voter_dist_kwargs: (Optional[dict[str, Any]], optional): Keyword args to be passed to
voter_dist, defaults to None, which creates the unif(0,1) distribution in 2 dimensions.
candidate_dist: (Callable[..., np.ndarray], optional): Distribution to sample a
single candidate's position from, defaults to uniform distribution.
candidate_dist_kwargs: (Optional[Dict[str, float]], optional): Keyword args to be passed
to candidate_dist, defaults None which creates the unif(0,1)
distribution 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.normal,
voter_dist_kwargs: Optional[Dict[str, Any]] = None,
candidate_dist: Callable[..., np.ndarray] = np.random.uniform,
candidate_dist_kwargs: Optional[Dict[str, Any]] = None,
distance: Callable[[np.ndarray, np.ndarray], float] = euclidean_dist,
):
super().__init__(candidates=candidates)
self.candidate_dist = candidate_dist
self.voter_dist = voter_dist

if voter_dist_kwargs is None:
if self.voter_dist is np.random.normal:
voter_dist_kwargs = {
"loc": 0,
"std": np.array(1.0),
"size": np.array(2.0),
}
else:
voter_dist_kwargs = {}

if voter_dist.__name__ not in ["normal", "laplace", "logistic", "gumbel"]:
raise ValueError("Input voter distribution not supported.")

try:
voter_dist_kwargs["loc"] = 0
self.voter_dist(**voter_dist_kwargs)
except TypeError:
raise TypeError("Invalid kwargs for the voter distribution.")

self.voter_dist_kwargs = voter_dist_kwargs

if candidate_dist_kwargs is None:
if self.candidate_dist is np.random.uniform:
candidate_dist_kwargs = {"low": 0.0, "high": 1.0, "size": 2.0}
else:
candidate_dist_kwargs = {}

try:
self.candidate_dist(**candidate_dist_kwargs)
except TypeError:
raise TypeError("Invalid kwargs for the candidate distribution.")

self.candidate_dist_kwargs = candidate_dist_kwargs

try:
v = self.voter_dist(**self.voter_dist_kwargs)
c = self.candidate_dist(**self.candidate_dist_kwargs)
distance(v, c)
except TypeError:
raise TypeError(
"Distance function is invalid or incompatible "
"with voter/candidate distributions."
)

self.distance = distance

def generate_profile_with_dict(
self, number_of_ballots: dict[str, int], by_bloc: bool = False
) -> Tuple[PreferenceProfile, dict[str, np.ndarray], np.ndarray]:
"""
Samples a metric position for each candidate
from the input candidate distribution. For each candidate, then sample
number_of_ballots[candidate] metric positions for voters
which will be centered around the candidate.
With sampled positions, this method then creates a ranked PreferenceProfile in which
voter's preferences are consistent with their distances to the candidates
in the metric space.
Args:
number_of_ballots (dict[str, int]): The number of voters attributed
to each candidate {candidate string: # voters}.
by_bloc (bool): Dummy variable from parent class.
Returns:
Tuple[PreferenceProfile, dict[str, numpy.ndarray], numpy.ndarray]:
A tuple containing the preference profile object,
a dictionary with each candidate's position in the metric
space, and a matrix where each row is a single voter's position
in the metric space.
"""

candidate_position_dict = {
c: self.candidate_dist(**self.candidate_dist_kwargs)
for c in self.candidates
}

n_voters = sum(number_of_ballots.values())
voter_positions = [np.zeros(2) for _ in range(n_voters)]
vidx = 0
for c, c_position in candidate_position_dict.items():
for v in range(number_of_ballots[c]):
self.voter_dist_kwargs["loc"] = c_position
voter_positions[vidx] = self.voter_dist(**self.voter_dist_kwargs)
vidx += 1

ballot_pool = [
["c"] * len(self.candidates) for _ in range(len(voter_positions))
]
for v in range(len(voter_positions)):
v_position = voter_positions[v]
distance_dict = {
c: self.distance(v_position, c_position)
for c, c_position, in candidate_position_dict.items()
}
candidate_order = sorted(distance_dict, key=distance_dict.__getitem__)
ballot_pool[v] = candidate_order

voter_positions_array = np.vstack(voter_positions)

return (
self.ballot_pool_to_profile(ballot_pool, self.candidates),
candidate_position_dict,
voter_positions_array,
)
3 changes: 3 additions & 0 deletions src/votekit/elections/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@
Cumulative,
Approval,
BlocPlurality,
PluralityVeto,
RandomDictator,
BoostedRandomDictator,
)
3 changes: 3 additions & 0 deletions src/votekit/elections/election_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
SequentialRCV,
CondoBorda,
TopTwo,
PluralityVeto,
RandomDictator,
BoostedRandomDictator,
)


Expand Down
3 changes: 3 additions & 0 deletions src/votekit/elections/election_types/ranking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@
from .dominating_sets import DominatingSets # noqa
from .condo_borda import CondoBorda # noqa
from .top_two import TopTwo # noqa
from .plurality_veto import PluralityVeto # noqa
from .random_dictator import RandomDictator # noqa
from .boosted_random_dictator import BoostedRandomDictator # noqa
Loading

0 comments on commit a06abef

Please sign in to comment.