Skip to content
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repos:
args: [--maxkb=2048]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
rev: v0.15.16
hooks:
# Quote --select=...: in YAML flow style, commas inside [...] split list items (E902 on Windows).
- id: ruff
Expand Down
65 changes: 65 additions & 0 deletions elsim/elections.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,71 @@ def normal_electorate(n_voters, n_cands, dims=2, corr=0.0, disp=1.0,
return voters, candidates


def bimodal_electorate(n_voters_each, n_cands_each, dims=2, corr=0.0, disp=1.0,
separation=0.5, random_state=None):
"""
Generate two-party spatial electorate and candidates (two clusters).

Voters and candidates are drawn in two equal groups. The first group is
centered at ``-separation`` and the second at ``+separation`` on the first
dimension (after the same correlation/scaling transform as
:func:`normal_electorate`). This matches a simple two-mode voter model (for
example two partisan bases at ±0.5 on a left–right axis when
``separation`` is 0.5).

Convention for downstream partisan simulations: candidate indices ``0 ..``
``n_cands_each - 1`` belong to the first cluster ("left"), and
``n_cands_each .. 2 * n_cands_each - 1`` to the second ("right"). Voter
indices ``0 .. n_voters_each - 1`` are left-cluster voters;
``n_voters_each .. 2 * n_voters_each - 1`` are right-cluster voters.

Parameters
----------
n_voters_each : int
Number of voters in each cluster (total voters ``2 * n_voters_each``).
n_cands_each : int
Number of candidates per cluster (total candidates ``2 * n_cands_each``).
dims : int
Number of dimensions (same meaning as in :func:`normal_electorate`).
corr : float
Correlation between dimensions (same meaning as in :func:`normal_electorate`).
disp : float
Relative dispersion of candidates vs voters (same meaning as in
:func:`normal_electorate`).
separation : float
Distance along the first axis between the two cluster centers.
random_state : {None, int, np.random.Generator}, optional
Same as :func:`normal_electorate`.

Returns
-------
voters : numpy.ndarray
Shape ``(2 * n_voters_each, dims)``.
cands : numpy.ndarray
Shape ``(2 * n_cands_each, dims)``.
"""
rng = _check_random_state(random_state)

left_v, left_c = normal_electorate(n_voters_each, n_cands_each, dims=dims,
corr=corr, disp=disp, random_state=rng)
left_v = _np.asarray(left_v).copy()
left_c = _np.asarray(left_c).copy()
left_v[:, 0] -= separation
left_c[:, 0] -= separation

right_v, right_c = normal_electorate(n_voters_each, n_cands_each, dims=dims,
corr=corr, disp=disp, random_state=rng)
right_v = _np.asarray(right_v).copy()
right_c = _np.asarray(right_c).copy()
right_v[:, 0] += separation
right_c[:, 0] += separation

voters = _np.vstack((left_v, right_v))
cands = _np.vstack((left_c, right_c))

return voters, cands


def normed_dist_utilities(voters, cands):
"""
Generate normalized utilities from a spatial model.
Expand Down
14 changes: 14 additions & 0 deletions elsim/methods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,40 @@
from elsim.methods.coombs import coombs
from elsim.methods.fptp import fptp, sntv
from elsim.methods.irv import irv
from elsim.methods.partisan_primaries import (
closed_partisan_primary_runoff,
nominee_restricted_plurality,
open_partisan_primary,
pairwise_majority_from_rankings,
top_two_runoff_reduced_turnout,
)
from elsim.methods.runoff import runoff
from elsim.methods.score import score
from elsim.methods.star import matrix_from_scores, star
from elsim.methods.three_two_one import three_two_one
from elsim.methods.utility_winner import utility_winner

__all__ = [
'approval',
'black',
'borda',
'closed_partisan_primary_runoff',
'combined_approval',
'condorcet',
'condorcet_from_matrix',
'coombs',
'fptp',
'irv',
'matrix_from_scores',
'nominee_restricted_plurality',
'open_partisan_primary',
'pairwise_majority_from_rankings',
'ranked_election_to_matrix',
'runoff',
'sntv',
'score',
'star',
'three_two_one',
'top_two_runoff_reduced_turnout',
'utility_winner',
]
218 changes: 218 additions & 0 deletions elsim/methods/partisan_primaries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""Two-party primary-style tallies for spatial simulations."""
import numpy as np

from elsim.methods._common import (_get_tiebreak, _no_tiebreak,
_order_tiebreak_keep, _random_tiebreak)
from elsim.methods.fptp import sntv

_tiebreak_map = {'order': _order_tiebreak_keep,
'random': _random_tiebreak,
None: _no_tiebreak}


def nominee_restricted_plurality(rankings, voter_indices, allowed_candidates,
tiebreaker=None):
"""
Plurality winner restricted to candidates in ``allowed_candidates``.

Each voter contributes one vote to their highest-ranked candidate among the
allowed set (same order as on their full ranking).

Parameters
----------
rankings : array_like
Full ranked ballots, shape ``(n_voters, n_cands)``.
voter_indices : array_like
Indices of voters participating in this round (e.g. primary electorate).
allowed_candidates : array_like
Candidate IDs allowed on this ballot.

tiebreaker : {'random', 'order', None}, optional
Breaks ties for first place.

Returns
-------
winner : int
Candidate ID of the nominee.
"""
rankings = np.asarray(rankings)
allowed_candidates = np.asarray(allowed_candidates, dtype=np.int64)
tallies = np.zeros(rankings.shape[1], dtype=np.int64)
allowed_set = frozenset(int(x) for x in allowed_candidates.flat)

for i in voter_indices:
for c in rankings[i]:
cc = int(c)
if cc in allowed_set:
tallies[cc] += 1
break

sub = tallies[allowed_candidates]
winners_rel = np.flatnonzero(sub == np.max(sub))
winners = allowed_candidates[winners_rel].tolist()
tiebreak = _get_tiebreak(tiebreaker, _tiebreak_map)
return tiebreak(winners)[0]


def pairwise_majority_from_rankings(rankings, voter_indices, cand_a, cand_b,
tiebreaker=None):
"""
Majority winner between two candidates using relative ranking order.

Parameters
----------
rankings : array_like
Full rankings.
voter_indices : array_like
Voters participating in this comparison (e.g. general-election turnout).
cand_a, cand_b : int
Candidate IDs.

tiebreaker : {'random', 'order', None}, optional
Used when exactly half prefer each candidate.

Returns
-------
winner : int
"""
rankings = np.asarray(rankings)
tiebreak = _get_tiebreak(tiebreaker, _tiebreak_map)
tally_a = 0
tally_b = 0

for i in voter_indices:
ballot = rankings[i]
pos_a = np.argmax(ballot == cand_a)
pos_b = np.argmax(ballot == cand_b)
if pos_a < pos_b:
tally_a += 1
elif pos_b < pos_a:
tally_b += 1

if tally_a > tally_b:
return cand_a
if tally_b > tally_a:
return cand_b
return tiebreak([cand_a, cand_b])[0]


def open_partisan_primary(rankings, n_cands_left_cluster,
primary_left_voters, primary_right_voters,
general_voters, tiebreaker=None):
"""
Open partisan primary then general election (both plurality).

Each party holds a primary among its own candidates using only that party's
primary electorate; the winners face each other in a general election using
``general_voters``.

Candidate IDs ``0 .. n_cands_left_cluster - 1`` are the left party;
``n_cands_left_cluster .. n_cands - 1`` are the right party.

Parameters
----------
rankings : array_like
Honest full rankings for all voters.
n_cands_left_cluster : int
Number of candidates in the left party.
primary_left_voters, primary_right_voters : array_like
Voter indices participating in each party's primary (often subsets).
general_voters : array_like
Voter indices in the general election.

tiebreaker : {'random', 'order', None}, optional

Returns
-------
winner : int
Winning candidate ID.
"""
rankings = np.asarray(rankings)
n_cands = rankings.shape[1]
left_c = np.arange(0, n_cands_left_cluster)
right_c = np.arange(n_cands_left_cluster, n_cands)

nom_l = nominee_restricted_plurality(rankings, primary_left_voters,
left_c, tiebreaker)
nom_r = nominee_restricted_plurality(rankings, primary_right_voters,
right_c, tiebreaker)

return pairwise_majority_from_rankings(rankings, general_voters,
nom_l, nom_r, tiebreaker)


def closed_partisan_primary_runoff(rankings, n_cands_left_cluster,
primary_left_voters, primary_right_voters,
runoff_voters, tiebreaker=None):
"""
Closed partisan primaries followed by a top-two runoff.

Same primaries as :func:`open_partisan_primary`, but the contest between
nominees uses only ``runoff_voters`` (can be a subset of all voters).

Parameters
----------
rankings : array_like
n_cands_left_cluster : int
primary_left_voters, primary_right_voters : array_like
runoff_voters : array_like
Voters participating in the general/runoff between nominees.

tiebreaker : {'random', 'order', None}, optional

Returns
-------
winner : int
"""
rankings = np.asarray(rankings)
n_cands = rankings.shape[1]
left_c = np.arange(0, n_cands_left_cluster)
right_c = np.arange(n_cands_left_cluster, n_cands)

nom_l = nominee_restricted_plurality(rankings, primary_left_voters,
left_c, tiebreaker)
nom_r = nominee_restricted_plurality(rankings, primary_right_voters,
right_c, tiebreaker)

return pairwise_majority_from_rankings(rankings, runoff_voters,
nom_l, nom_r, tiebreaker)


def top_two_runoff_reduced_turnout(rankings, first_round_voters,
runoff_voters, tiebreaker=None):
"""
Top-two runoff where the pairwise round uses a subset of voters.

The top two candidates by first-preference plurality among
``first_round_voters`` advance; the winner is decided by pairwise majority
among ``runoff_voters``.

Parameters
----------
rankings : array_like
first_round_voters : array_like
Voters counted for establishing the top two (general-election cohort).
runoff_voters : array_like
Usually a subset of voters for the second round.

tiebreaker : {'random', 'order', None}, optional

Returns
-------
winner : int
"""
rankings = np.asarray(rankings)
first_prefs = rankings[first_round_voters, 0]
top_two = sntv(first_prefs, n=2, tiebreaker=tiebreaker)
if top_two is None:
return None

finalists = sorted(top_two)
if len(finalists) == 1:
return finalists[0]
if len(finalists) != 2:
return None

a, b = finalists[0], finalists[1]
return pairwise_majority_from_rankings(rankings, runoff_voters,
a, b, tiebreaker)
Loading