Skip to content

Commit 6921dbe

Browse files
committed
STV Elections
1 parent 895677f commit 6921dbe

File tree

4 files changed

+101
-78
lines changed

4 files changed

+101
-78
lines changed

src/votekit/elections/election_types.py

Lines changed: 92 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from fractions import Fraction
22
import itertools as it
33
import numpy as np
4-
from typing import Callable, Optional
4+
from typing import Callable, Optional, Union
55

66
from ..models import Election
77
from ..election_state import ElectionState
@@ -92,82 +92,103 @@ def next_round(self) -> bool:
9292
cands_elected += len(s)
9393
return cands_elected < self.seats
9494

95-
def run_step(self) -> ElectionState:
95+
def run_step(self, step: Optional[Union[int, None]] = None) -> ElectionState:
9696
"""
97-
Simulates one round an STV election
97+
Simulates one round of an STV election.
98+
99+
Args:
100+
step: If an integer is given, run that round. Round numbers must be non-negative.
101+
If None, run the next round given the value of self.state.curr_round.
102+
Defaults to None.
98103
99104
Returns:
100-
An ElectionState object for a given round
105+
An ElectionState object for a given round.
101106
"""
102-
if not self.next_round():
103-
raise ValueError(
104-
f"Length of elected set equal to number of seats ({self.seats})"
105-
)
107+
# run given step
108+
if isinstance(step, int):
109+
if step < 0:
110+
raise ValueError("Step must be a non-negative integer.")
111+
112+
elif step >= 0:
113+
while self.state.curr_round < step:
114+
self.run_step()
106115

107-
remaining = self.state.profile.get_candidates()
108-
ballots = self.state.profile.get_ballots()
109-
round_votes = compute_votes(remaining, ballots)
110-
elected = []
111-
eliminated = []
112-
113-
# if number of remaining candidates equals number of remaining seats,
114-
# everyone is elected
115-
if len(remaining) == self.seats - len(self.state.get_all_winners()):
116-
elected = [{cand} for cand, votes in round_votes]
117-
remaining = []
118-
ballots = []
119-
120-
# elect all candidates who crossed threshold
121-
elif round_votes[0].votes >= self.threshold:
122-
for candidate, votes in round_votes:
123-
if votes >= self.threshold:
124-
elected.append({candidate})
125-
remaining.remove(candidate)
126-
ballots = self.transfer(
127-
candidate,
128-
ballots,
129-
{cand: votes for cand, votes in round_votes},
130-
self.threshold,
131-
)
132-
# since no one has crossed threshold, eliminate one of the people
133-
# with least first place votes
134-
elif self.next_round():
135-
lp_candidates = [
136-
candidate
137-
for candidate, votes in round_votes
138-
if votes == round_votes[-1].votes
139-
]
140-
141-
lp_cand = tie_broken_ranking(
142-
ranking=[set(lp_candidates)],
143-
profile=self.state.profile,
144-
tiebreak=self.tiebreak,
145-
)[-1]
146-
eliminated.append(lp_cand)
147-
ballots = remove_cand(lp_cand, ballots)
148-
remaining.remove(next(iter(lp_cand)))
149-
150-
if len(elected) >= 1:
151-
elected = scores_into_set_list(
152-
first_place_votes(self.state.profile), [c for s in elected for c in s]
153-
)
116+
while self.state.curr_round > step:
117+
self.state = self.state.previous
154118

155-
# Make sure list-of-sets have non-empty elements
156-
elected = [s for s in elected if s != set()]
157-
eliminated = [s for s in eliminated if s != set()]
119+
return(self.state)
158120

159-
remaining = [set(remaining)]
160-
remaining = [s for s in remaining if s != set()]
121+
# run next step
122+
else:
123+
if not self.next_round():
124+
raise ValueError(
125+
f"Length of elected set equal to number of seats ({self.seats})"
126+
)
127+
128+
remaining = self.state.profile.get_candidates()
129+
ballots = self.state.profile.get_ballots()
130+
round_votes = compute_votes(remaining, ballots)
131+
elected = []
132+
eliminated = []
133+
134+
# if number of remaining candidates equals number of remaining seats,
135+
# everyone is elected
136+
if len(remaining) == self.seats - len(self.state.get_all_winners()):
137+
elected = [{cand} for cand, votes in round_votes]
138+
remaining = []
139+
ballots = []
140+
141+
# elect all candidates who crossed threshold
142+
elif round_votes[0].votes >= self.threshold:
143+
for candidate, votes in round_votes:
144+
if votes >= self.threshold:
145+
elected.append({candidate})
146+
remaining.remove(candidate)
147+
ballots = self.transfer(
148+
candidate,
149+
ballots,
150+
{cand: votes for cand, votes in round_votes},
151+
self.threshold,
152+
)
153+
# since no one has crossed threshold, eliminate one of the people
154+
# with least first place votes
155+
elif self.next_round():
156+
lp_candidates = [
157+
candidate
158+
for candidate, votes in round_votes
159+
if votes == round_votes[-1].votes
160+
]
161+
162+
lp_cand = tie_broken_ranking(
163+
ranking=[set(lp_candidates)],
164+
profile=self.state.profile,
165+
tiebreak=self.tiebreak,
166+
)[-1]
167+
eliminated.append(lp_cand)
168+
ballots = remove_cand(lp_cand, ballots)
169+
remaining.remove(next(iter(lp_cand)))
170+
171+
if len(elected) >= 1:
172+
elected = scores_into_set_list(
173+
first_place_votes(self.state.profile), [c for s in elected for c in s]
174+
)
161175

162-
self.state = ElectionState(
163-
curr_round=self.state.curr_round + 1,
164-
elected=elected,
165-
eliminated=eliminated,
166-
remaining=remaining,
167-
profile=PreferenceProfile(ballots=ballots),
168-
previous=self.state,
169-
)
170-
return self.state
176+
# Make sure list-of-sets have non-empty elements
177+
elected = [s for s in elected if s != set()]
178+
eliminated = [s for s in eliminated if s != set()]
179+
180+
remaining = [set(remaining)]
181+
remaining = [s for s in remaining if s != set()]
182+
183+
self.state = ElectionState(
184+
curr_round=self.state.curr_round + 1,
185+
elected=elected,
186+
eliminated=eliminated,
187+
remaining=remaining,
188+
profile=PreferenceProfile(ballots=ballots),
189+
previous=self.state,
190+
)
191+
return self.state
171192

172193
def run_election(self) -> ElectionState:
173194
"""
@@ -176,10 +197,8 @@ def run_election(self) -> ElectionState:
176197
Returns:
177198
An ElectionState object with results for a complete election
178199
"""
179-
if not self.next_round():
180-
raise ValueError(
181-
f"Length of elected set equal to number of seats ({self.seats})"
182-
)
200+
# always start election from round 0
201+
self.reset()
183202

184203
while self.next_round():
185204
self.run_step()

src/votekit/elections/transfers.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,8 @@ def fractional_transfer(
2424
transfer_value = (votes[winner] - threshold) / votes[winner]
2525

2626
for ballot in ballots:
27-
new_ranking = []
2827
if ballot.ranking and ballot.ranking[0] == {winner}:
2928
ballot.weight = ballot.weight * transfer_value
30-
for cand in ballot.ranking:
31-
if cand != {winner}:
32-
new_ranking.append(cand)
3329

3430
return remove_cand(winner, ballots)
3531

src/votekit/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ def __init__(self, profile: PreferenceProfile, ballot_ties: bool = True):
2424
def run_step(self, *args: Any, **kwargs: Any):
2525
pass
2626

27+
def reset(self):
28+
"""
29+
Reset the ElectionState object to initial conditions.
30+
"""
31+
32+
self.state = ElectionState(curr_round=0, profile=self._profile)
33+
2734
@abstractmethod
2835
def run_election(self, *args: Any, **kwargs: Any):
2936
pass

src/votekit/pref_profile.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pandas as pd
44
from pydantic import BaseModel, validator
55
from typing import Optional
6+
import copy
67

78
from .ballot import Ballot
89

@@ -37,7 +38,7 @@ def get_ballots(self) -> list[Ballot]:
3738
"""
3839
Returns list of ballots
3940
"""
40-
return self.ballots
41+
return copy.deepcopy(self.ballots)
4142

4243
def get_candidates(self) -> list:
4344
"""

0 commit comments

Comments
 (0)