Implement exclusive Gregory and PRSA 1977 rules

This commit is contained in:
RunasSudo 2020-12-27 21:25:40 +11:00
parent 8307ebf86c
commit f6596bd8e8
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
9 changed files with 275 additions and 45 deletions

View File

@ -7,6 +7,7 @@ The preset dropdown allows you to choose from a hardcoded list of preloaded STV
* [Scottish STV](https://www.opavote.com/methods/scottish-stv-rules)
* pyRCV STV-C: Our recommended rules for a computerised STV count
* [Wright STV](https://www.aph.gov.au/Parliamentary_Business/Committees/House_of_Representatives_Committees?url=em/elect07/subs/sub051.1.pdf)
* [PRSA 1977](https://www.prsa.org.au/rule1977.htm)
This functionality is not available on the Python command line.
@ -57,21 +58,23 @@ This dropdown allows you to select in what order surpluses are distributed:
* By size (default): When multiple candidates exceed the quota, the largest surplus is transferred (even if it arose at a later stage of the count).
* By order: When multiple candidates exceed the quota, the surplus of the candidate elected first is transferred (even if it is smaller than another). Candidates are always declared elected in descending order of number of votes.
Some STV counting rules provide, for example, that ‘no surplus shall be transferred before a surplus that arose earlier in the counting whether larger or not’ ([PRSA 1977 rules](https://www.prsa.org.au/rule1977.htm)). In this case, the option ‘By order’ should be selected.
Some STV counting rules provide, for example, that ‘no surplus shall be transferred before a surplus that arose earlier in the counting whether larger or not’ (PRSA 1977). In this case, the option ‘By order’ should be selected.
## Method (-m/--method)
This dropdown allows you to select how ballots are transferred during surplus transfers or exclusions:
This dropdown allows you to select how ballots are transferred during surplus transfers or exclusions. The 2 recommended methods are:
* Weighted inclusive Gregory: During surplus transfers, a transfer value is calculated as the candidate's excess votes divided by the candidate's votes. Ballot weights are multiplied by the transfer value.
* Unweighted inclusive Gregory: During surplus transfers, a transfer value is calculated as the candidate's excess votes divided by the candidate's ballot papers. Ballot weights are set to the transfer value. Transferred votes are credited in parcels to avoid excessive rounding.
* Wright STV: Same as weighted inclusive Gregory, but when a candidate is excluded, the count is reset from the beginning (minus the excluded candidate).
Note that, in each of these options, all a candidate's ballot papers are examined during a surplus transfer. The practice used in some rules (e.g. ERS97) where ballot papers are divided into transferable and non-transferable papers, and only transferable papers further examined, is not currently supported.
Other methods are supported, but not recommended:
Note that, when unweighted inclusive Gregory surplus transfers are used, ballot weights are always represented internally as fractions, while candidate totals are represented according to the *Numbers* option.
* Unweighted inclusive Gregory: During surplus transfers, a transfer value is calculated as the candidate's excess votes divided by the candidate's ballot papers. Ballot weights are set to the transfer value.
* Exclusive Gregory (by parcel): During surplus transfers, only transferable papers in the ‘last bundle’ are examined. During exclusions, transfers are performed ‘parcel by parcel’, and each parcel forms a separate stage, i.e. if a parcel allows another candidate to be elected, no further transfers are made to that candidate.
Other surplus transfer methods, such as exclusive Gregory (‘last bundle’) or non-fractional transfers (e.g. random sample) are not recommended and not supported.
Note that, when unweighted inclusive Gregory or exclusive Gregory surplus transfers are used, ballot weights are always represented internally as fractions, while candidate totals are represented according to the *Numbers* option.
Other surplus transfer methods, such as non-fractional transfers (e.g. random sample) are not supported at this time.
## Ties (-t/--ties)

View File

@ -42,9 +42,11 @@
<option value="stvc">pyRCV STV-C</option>
<!--<option value="senate">Australian Senate STV</option>-->
<option value="wright">Wright STV</option>
<option value="prsa77">PRSA 1977</option>
</select>
</label>
<button id="btnAdvancedOptions" onclick="clickAdvancedOptions()">Show advanced options</button>
<!--GITREV-->
<a href="https://yingtongli.me/blog/2020/12/24/pyrcv2.html">Information and instructions</a>
</div>
@ -103,6 +105,7 @@
<select id="selTransfers">
<option value="wig" selected>Weighted inclusive Gregory</option>
<option value="uig">Unweighted inclusive Gregory</option>
<option value="parcelled_eg">Exclusive Gregory (by parcel)</option>
<option value="wright">Wright STV</option>
</select>
</label>

View File

@ -69,6 +69,17 @@ function changePreset() {
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wright';
document.getElementById('selTies').value = 'backwards_random';
} else if (document.getElementById('selPreset').value === 'prsa77') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false;
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '0';
document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'parcelled_eg';
document.getElementById('selTies').value = 'backwards_random';
}
}
@ -176,9 +187,9 @@ async function clickCount() {
elTd = document.createElement('td');
elTd.classList.add('count');
if (countCard.state === py.pyRCV2.model.CandidateState.WITHDRAWN || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED) {
if (countCard.state === py.pyRCV2.model.CandidateState.WITHDRAWN || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDING) {
elTd.classList.add('excluded');
} else if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED) {
} else if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED || countCard.state === py.pyRCV2.model.CandidateState.DISTRIBUTING_SURPLUS) {
elTd.classList.add('elected');
}
elTd.style.borderTop = '1px solid black';
@ -192,7 +203,7 @@ async function clickCount() {
elTd = document.createElement('td');
elTd.classList.add('count');
if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED) {
if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED || countCard.state === py.pyRCV2.model.CandidateState.DISTRIBUTING_SURPLUS) {
elTd.classList.add('elected');
elTd.innerText = countCard.votes;
elTr1.querySelector('td:first-child').classList.add('elected');
@ -202,7 +213,7 @@ async function clickCount() {
if (countCard.state === py.pyRCV2.model.CandidateState.WITHDRAWN) {
elTd.classList.add('excluded');
elTd.innerText = 'WD';
} else if (countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED) {
} else if (countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDING) {
elTd.classList.add('excluded');
elTd.innerText = 'Ex';
} else {

View File

@ -43,6 +43,8 @@ onmessage = function(evt) {
let counter;
if (evt.data.transfers === 'uig') {
counter = py.pyRCV2.method.base_stv.UIGSTVCounter(election, evt.data.options);
} else if (evt.data.transfers === 'parcelled_eg') {
counter = py.pyRCV2.method.parcels.ParcelledEGSTVCounter(election, evt.data.options);
} else if (evt.data.transfers === 'wright') {
counter = py.pyRCV2.method.wright.WrightSTVCounter(election, evt.data.options);
} else {

View File

@ -19,6 +19,7 @@ import pyRCV2.model
import pyRCV2.numbers
from pyRCV2.method.base_stv import UIGSTVCounter, WIGSTVCounter
from pyRCV2.method.parcels import ParcelledEGSTVCounter
from pyRCV2.method.wright import WrightSTVCounter
from pyRCV2.ties import TiesBackwards, TiesPrompt, TiesRandom
@ -35,7 +36,7 @@ def add_parser(subparsers):
parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', 'int', 'native'], default='fixed', help='numbers mode (default: fixed)')
parser.add_argument('--decimals', type=int, default=5, help='decimal places if --numbers fixed (default: 5)')
parser.add_argument('--surplus-order', '-s', choices=['size', 'order'], default='size', help='whether to distribute surpluses by size or by order of election (default: size)')
parser.add_argument('--method', '-m', choices=['wig', 'uig', 'wright'], default='wig', help='method of surpluses and exclusions (default: wig - weighted inclusive Gregory)')
parser.add_argument('--method', '-m', choices=['wig', 'uig', 'parcelled_eg', 'wright'], default='wig', help='method of surpluses and exclusions (default: wig - weighted inclusive Gregory)')
parser.add_argument('--ties', '-t', action='append', choices=['backwards', 'prompt', 'random'], default=None, help='how to resolve ties (default: backwards then random)')
parser.add_argument('--random-seed', default=None, help='arbitrary string used to seed the RNG for random tie breaking')
@ -81,6 +82,8 @@ def main(args):
# Create counter
if args.method == 'uig':
counter = UIGSTVCounter(election, vars(args))
elif args.method == 'parcelled_eg':
counter = ParcelledEGSTVCounter(election, vars(args))
elif args.method == 'wright':
counter = WrightSTVCounter(election, vars(args))
else:

View File

@ -67,18 +67,7 @@ class BaseSTVCounter:
Does not reset the states of candidates, etc.
"""
# Distribute first preferences
for ballot in self.election.ballots:
__pragma__('opov')
candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
if candidate is not None:
self.candidates[candidate].transfers += ballot.value
self.candidates[candidate].ballots.append((ballot, self.cls_ballot_value(ballot.value)))
else:
self.exhausted.transfers += ballot.value
self.exhausted.ballots.append((ballot, self.cls_ballot_value(ballot.value)))
__pragma__('noopov')
self.distribute_first_preferences()
self.quota = None
self.compute_quota()
@ -98,6 +87,23 @@ class BaseSTVCounter:
self.step_results = [result]
return result
def distribute_first_preferences(self):
"""
Distribute first preferences (called as part of the reset() step)
"""
for ballot in self.election.ballots:
__pragma__('opov')
candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
if candidate is not None:
self.candidates[candidate].transfers += ballot.value
self.candidates[candidate].ballots.append((ballot, self.cls_ballot_value(ballot.value)))
else:
self.exhausted.transfers += ballot.value
self.exhausted.ballots.append((ballot, self.cls_ballot_value(ballot.value)))
__pragma__('noopov')
def step(self):
"""
Public function:
@ -180,10 +186,14 @@ class BaseSTVCounter:
Distribute surpluses, if any
"""
# Are we distributing a surplus?
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.DISTRIBUTING_SURPLUS]
# Do surpluses need to be distributed?
__pragma__('opov')
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota]
__pragma__('noopov')
if len(has_surplus) == 0:
__pragma__('opov')
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota]
__pragma__('noopov')
if len(has_surplus) > 0:
# Distribute surpluses in specified order
@ -196,21 +206,25 @@ class BaseSTVCounter:
else:
raise STVException('Invalid surplus order option')
count_card.state = CandidateState.ELECTED
count_card.state = CandidateState.DISTRIBUTING_SURPLUS
__pragma__('opov')
surplus = count_card.votes - self.quota
__pragma__('noopov')
# Transfer surplus
self.do_surplus(candidate_surplus, count_card, surplus)
is_complete = self.do_surplus(candidate_surplus, count_card, surplus)
__pragma__('opov')
count_card.transfers -= surplus
__pragma__('noopov')
if is_complete:
__pragma__('opov')
count_card.transfers -= surplus
__pragma__('noopov')
count_card.state = CandidateState.ELECTED
# Declare elected any candidates meeting the quota as a result of surpluses
self.compute_quota()
# Declare elected any candidates meeting the quota as a result of surpluses
self.compute_quota()
self.elect_meeting_quota()
__pragma__('opov')
@ -271,17 +285,14 @@ class BaseSTVCounter:
"""
candidate_excluded, count_card = self.candidate_to_exclude()
count_card.state = CandidateState.EXCLUDED
count_card.state = CandidateState.EXCLUDING
# Exclude this candidate
self.do_exclusion(candidate_excluded, count_card)
__pragma__('opov')
count_card.transfers -= count_card.votes
__pragma__('noopov')
is_complete = self.do_exclusion(candidate_excluded, count_card)
# Declare any candidates meeting the quota as a result of exclusion
self.compute_quota()
self.elect_meeting_quota()
__pragma__('opov')
@ -303,6 +314,11 @@ class BaseSTVCounter:
Determine the candidate to exclude
"""
# Continue current exclusion if applicable
excluding = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING]
if len(excluding) > 0:
return excluding[0][0], excluding[0][1]
hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
hopefuls.sort(key=lambda x: x[1].votes)
@ -444,6 +460,8 @@ class WIGSTVCounter(BaseSTVCounter):
self.exhausted.transfers += new_value
self.exhausted.ballots.append((ballot, new_value))
__pragma__('noopov')
return True
def do_exclusion(self, candidate_excluded, count_card):
for ballot, ballot_value in count_card.ballots:
@ -457,6 +475,12 @@ class WIGSTVCounter(BaseSTVCounter):
self.exhausted.transfers += ballot_value
self.exhausted.ballots.append((ballot, ballot_value))
__pragma__('noopov')
__pragma__('opov')
count_card.transfers -= count_card.votes
__pragma__('noopov')
count_card.state = CandidateState.EXCLUDED
class UIGSTVCounter(BaseSTVCounter):
"""
@ -514,6 +538,8 @@ class UIGSTVCounter(BaseSTVCounter):
new_value = (ballot.value.to_rational() * surplus.to_rational()) / total_ballots.to_rational()
__pragma__('noopov')
self.exhausted.ballots.append((ballot, new_value))
return True
def do_exclusion(self, candidate_excluded, count_card):
next_preferences = SafeDict([(c, Rational('0')) for c, cc in self.candidates.items()])
@ -546,4 +572,8 @@ class UIGSTVCounter(BaseSTVCounter):
__pragma__('opov')
self.exhausted.transfers += next_exhausted.to_num()
count_card.transfers -= count_card.votes
__pragma__('noopov')
count_card.state = CandidateState.EXCLUDED

176
pyRCV2/method/parcels.py Normal file
View File

@ -0,0 +1,176 @@
# pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
__pragma__ = lambda x: None
from pyRCV2.method.base_stv import UIGSTVCounter
from pyRCV2.model import CandidateState, CountCard
from pyRCV2.numbers import Num, Rational
from pyRCV2.safedict import SafeDict
class ParcelledEGCountCard(CountCard):
def __init__(self, *args):
CountCard.__init__(self, *args)
del self.ballots
self.parcels = []
#@property
#def ballots(self):
# raise Exception('Attempted to access ballots attribute of ParcelledUIGCountCard')
def clone(self):
"""Overrides CountCard.clone"""
result = ParcelledUIGCountCard()
result.orig_votes = self.orig_votes
result.transfers = self.transfers
result.parcels = [[(b[0].clone(), b[1]) for b in p] for p in self.parcels]
result.state = self.state
result.order_elected = self.order_elected
return result
class ParcelledEGSTVCounter(UIGSTVCounter):
"""
Exclusive Gregory (parcel by parcel) STV implementation
"""
def __init__(self, *args):
UIGSTVCounter.__init__(self, *args)
self.candidates = SafeDict([(c, ParcelledEGCountCard()) for c in self.election.candidates])
def distribute_first_preferences(self):
"""Overrides UIGSTVCounter.distribute_first_preferences"""
for ballot in self.election.ballots:
__pragma__('opov')
candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
if candidate is not None:
self.candidates[candidate].transfers += ballot.value
if len(self.candidates[candidate].parcels) == 0:
self.candidates[candidate].parcels.append([(ballot, self.cls_ballot_value(ballot.value))])
else:
self.candidates[candidate].parcels[0].append((ballot, self.cls_ballot_value(ballot.value)))
else:
self.exhausted.transfers += ballot.value
self.exhausted.ballots.append((ballot, self.cls_ballot_value(ballot.value)))
__pragma__('noopov')
def do_surplus(self, candidate_surplus, count_card, surplus):
"""Overrides UIGSTVCounter.do_surplus"""
next_preferences = SafeDict([(c, []) for c, cc in self.candidates.items()])
total_ballots = Num('0')
total_votes = Rational('0')
parcel = count_card.parcels[len(count_card.parcels)-1] # Last bundle only
__pragma__('opov')
# Count next preferences
for ballot, ballot_value in parcel:
candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
if candidate is not None:
# Examine only transferable ballot papers
total_ballots += ballot.value
total_votes += ballot_value
next_preferences[candidate].append((ballot, ballot_value))
# Make transfers
for candidate, cand_ballots in next_preferences.items():
if len(cand_ballots) == 0:
continue
new_parcel = []
self.candidates[candidate].parcels.append(new_parcel)
if total_votes.to_num() > surplus:
# Reweight according to transfer value
num_ballots = sum((b.value for b, bv in cand_ballots), Num('0'))
self.candidates[candidate].transfers += (num_ballots * surplus) / total_ballots # Multiply first to avoid rounding errors
for ballot, ballot_value in cand_ballots:
new_value = (ballot.value.to_rational() * surplus.to_rational()) / total_ballots.to_rational()
new_parcel.append((ballot, new_value))
else:
# Do not reweight
num_votes = sum((bv for b, bv in cand_ballots), Rational('0'))
self.candidates[candidate].transfers += num_votes.to_num()
for ballot, ballot_value in cand_ballots:
new_parcel.append((ballot, ballot_value))
# Credit exhausted votes (only if not reweighting)
if total_votes.to_num() <= surplus:
self.exhausted.transfers += surplus - total_votes.to_num()
__pragma__('noopov')
return True
def do_exclusion(self, candidate_excluded, count_card):
"""Overrides UIGSTVCounter.do_exclusion"""
next_preferences = SafeDict([(c, []) for c, cc in self.candidates.items()])
next_exhausted = []
parcel = count_card.parcels[0]
count_card.parcels.remove(parcel)
# Count next preferences
for ballot, ballot_value in parcel:
__pragma__('opov')
candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
__pragma__('noopov')
if candidate is not None:
__pragma__('opov')
next_preferences[candidate].append((ballot, ballot_value))
__pragma__('noopov')
else:
next_exhausted.append((ballot, ballot_value))
# Make transfers
for candidate, cand_ballots in next_preferences.items():
if len(cand_ballots) == 0:
continue
new_parcel = []
__pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel)
__pragma__('noopov')
num_votes = sum((bv for b, bv in cand_ballots), Rational('0'))
__pragma__('opov')
self.candidates[candidate].transfers += num_votes.to_num()
count_card.transfers -= num_votes.to_num()
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
new_parcel.append((ballot, ballot_value))
# Credit exhausted votes
num_votes = sum((bv for b, bv in next_exhausted), Rational('0'))
__pragma__('opov')
self.exhausted.transfers += num_votes.to_num()
count_card.transfers -= num_votes.to_num()
__pragma__('noopov')
for ballot, ballot_value in next_exhausted:
self.exhausted.ballots.append((ballot, ballot_value))
if len(count_card.parcels) == 0:
count_card.state = CandidateState.EXCLUDED

View File

@ -30,10 +30,12 @@ class Candidate:
class CandidateState:
HOPEFUL = 0
PROVISIONALLY_ELECTED = 1
ELECTED = 2
EXCLUDED = 3
WITHDRAWN = 4
PROVISIONALLY_ELECTED = 10
DISTRIBUTING_SURPLUS = 20
ELECTED = 30
EXCLUDING = 40
EXCLUDED = 50
WITHDRAWN = 60
class Ballot:
def __init__(self, value, preferences):
@ -82,7 +84,7 @@ class CountCard:
result = CountCard()
result.orig_votes = self.orig_votes
result.transfers = self.transfers
result.ballots = [b.clone() for b in self.ballots]
result.ballots = [(b[0].clone(), b[1]) for b in self.ballots]
result.state = self.state
result.order_elected = self.order_elected
return result

View File

@ -16,7 +16,7 @@
import pyRCV2.blt
import pyRCV2.model
import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.wright
import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.parcels, pyRCV2.method.wright
import pyRCV2.numbers
import pyRCV2.random
import pyRCV2.ties