Implement Meek STV
This commit is contained in:
parent
81b70984c9
commit
39d0adbd0d
@ -15,6 +15,7 @@ pyRCV2 accepts data in the [BLT file format](http://www.dia.govt.nz/diawebsite.N
|
||||
* weighted inclusive Gregory STV (e.g. [Scottish STV](https://www.opavote.com/methods/scottish-stv-rules))
|
||||
* unweighted inclusive Gregory STV (e.g. [Australian Senate STV](https://www.legislation.gov.au/Details/C2020C00400/Html/Text#_Toc59107700))
|
||||
* exclusive Gregory STV (e.g. [PRSA 1977](https://www.prsa.org.au/rule1977.htm) and [ERS97](https://www.electoral-reform.org.uk/latest-news-and-research/publications/how-to-conduct-an-election-by-the-single-transferable-vote-3rd-edition/))
|
||||
* [Meek STV](http://www.dia.govt.nz/diawebsite.NSF/Files/meekm/%24file/meekm.pdf)
|
||||
* [Wright STV](https://www.aph.gov.au/Parliamentary_Business/Committees/House_of_Representatives_Committees?url=em/elect07/subs/sub051.1.pdf)
|
||||
|
||||
pyRCV2 is highly customisable, including options for:
|
||||
|
@ -6,6 +6,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)
|
||||
* [Australian Senate STV](https://www.legislation.gov.au/Details/C2020C00400/Html/Text#_Toc59107700)
|
||||
* [Meek STV](http://www.dia.govt.nz/diawebsite.NSF/Files/meekm/%24file/meekm.pdf)
|
||||
* [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) – with counting performed to the thousandth of a vote, and results reported in raw votes to 3 decimal places
|
||||
* [ERS97](https://www.electoral-reform.org.uk/latest-news-and-research/publications/how-to-conduct-an-election-by-the-single-transferable-vote-3rd-edition/)
|
||||
@ -91,9 +92,10 @@ Some STV counting rules provide, for example, that ‘no surplus shall be transf
|
||||
|
||||
## Method (-m/--method)
|
||||
|
||||
This dropdown allows you to select how ballots are transferred during surplus transfers or exclusions. The recommended method is:
|
||||
This dropdown allows you to select how ballots are transferred during surplus transfers or exclusions. The recommended methods are:
|
||||
|
||||
* *Weighted inclusive Gregory* (default): During surplus transfers, all applicable ballot papers of the transferring candidate are examined. Transfers are weighted according to the weights of the ballot papers.
|
||||
* *Meek STV*: Transfers are computed as described at <http://www.dia.govt.nz/diawebsite.NSF/Files/meekm/%24file/meekm.pdf>.
|
||||
|
||||
Other methods are supported, but not recommended:
|
||||
|
||||
|
@ -33,6 +33,7 @@
|
||||
<option value="scottish" selected>Scottish STV</option>
|
||||
<!--<option value="stvc">pyRCV STV-C</option>-->
|
||||
<option value="senate">Australian Senate STV</option>
|
||||
<option value="meek">Meek STV</option>
|
||||
<option value="wright">Wright STV</option>
|
||||
<option value="prsa77">PRSA 1977</option>
|
||||
<option value="ers97">ERS97</option>
|
||||
@ -86,6 +87,7 @@
|
||||
<option value="wig" selected>Weighted inclusive Gregory</option>
|
||||
<option value="uig">Unweighted inclusive Gregory</option>
|
||||
<option value="eg">Exclusive Gregory (last bundle)</option>
|
||||
<option value="meek">Meek method</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
@ -86,6 +86,25 @@ function changePreset() {
|
||||
document.getElementById('selPapers').value = 'both';
|
||||
document.getElementById('selExclusion').value = 'by_value';
|
||||
document.getElementById('selTies').value = 'backwards_random';
|
||||
} else if (document.getElementById('selPreset').value === 'meek') {
|
||||
document.getElementById('selQuotaCriterion').value = 'gt';
|
||||
document.getElementById('selQuota').value = 'droop_exact';
|
||||
document.getElementById('selQuotaMode').value = 'progressive';
|
||||
document.getElementById('chkBulkElection').checked = true;
|
||||
document.getElementById('chkBulkExclusion').checked = false;
|
||||
document.getElementById('chkDeferSurpluses').checked = false;
|
||||
document.getElementById('selNumbers').value = 'fixed';
|
||||
document.getElementById('txtDP').value = '9';
|
||||
document.getElementById('txtPPDP').value = '2';
|
||||
document.getElementById('chkRoundQuota').checked = false;
|
||||
document.getElementById('chkRoundVotes').checked = false;
|
||||
document.getElementById('chkRoundTVs').checked = false;
|
||||
document.getElementById('chkRoundWeights').checked = false;
|
||||
document.getElementById('selSurplus').value = 'size';
|
||||
document.getElementById('selTransfers').value = 'meek';
|
||||
document.getElementById('selPapers').value = 'both';
|
||||
document.getElementById('selExclusion').value = 'one_round';
|
||||
document.getElementById('selTies').value = 'random';
|
||||
} else if (document.getElementById('selPreset').value === 'wright') {
|
||||
document.getElementById('selQuotaCriterion').value = 'geq';
|
||||
document.getElementById('selQuota').value = 'droop';
|
||||
|
@ -46,6 +46,8 @@ onmessage = function(evt) {
|
||||
counter = py.pyRCV2.method.base_stv.UIGSTVCounter(election, evt.data.data.options);
|
||||
} else if (evt.data.data.transfers === 'eg') {
|
||||
counter = py.pyRCV2.method.base_stv.EGSTVCounter(election, evt.data.data.options);
|
||||
} else if (evt.data.data.transfers === 'meek') {
|
||||
counter = py.pyRCV2.method.meek.MeekSTVCounter(election, evt.data.data.options);
|
||||
} else {
|
||||
counter = py.pyRCV2.method.base_stv.WIGSTVCounter(election, evt.data.data.options);
|
||||
}
|
||||
@ -63,9 +65,19 @@ onmessage = function(evt) {
|
||||
|
||||
// Reset
|
||||
stage = 1;
|
||||
result = counter.reset();
|
||||
try {
|
||||
result = counter.reset();
|
||||
} catch (ex) {
|
||||
handleException(ex);
|
||||
return;
|
||||
}
|
||||
|
||||
postMessage({'type': 'result', 'result': resultToJS(result)});
|
||||
if (py.isinstance(result, py.pyRCV2.model.CountCompleted)) {
|
||||
postMessage({'type': 'done', 'result': resultToJS(result)});
|
||||
return;
|
||||
} else {
|
||||
postMessage({'type': 'result', 'result': resultToJS(result)});
|
||||
}
|
||||
|
||||
stepElection();
|
||||
} else if (evt.data.type === 'require_input') {
|
||||
@ -79,22 +91,13 @@ function stepElection() {
|
||||
while (true) {
|
||||
try {
|
||||
result = counter.step();
|
||||
stage += 1;
|
||||
} catch (ex) {
|
||||
if (py.isinstance(ex, py.pyRCV2.ties.RequireInput)) {
|
||||
// Signals we require input to break a tie
|
||||
postMessage({'type': 'require_input', 'message': ex.message});
|
||||
break;
|
||||
} else if (py.isinstance(ex, py.pyRCV2.method.base_stv.STVException)) {
|
||||
console.error(ex);
|
||||
postMessage({'type': 'stv_exception', 'message': ex.message});
|
||||
break;
|
||||
} else {
|
||||
console.error(ex);
|
||||
throw ex;
|
||||
}
|
||||
handleException(ex);
|
||||
return;
|
||||
}
|
||||
|
||||
stage += 1;
|
||||
|
||||
if (py.isinstance(result, py.pyRCV2.model.CountCompleted)) {
|
||||
postMessage({'type': 'done', 'result': resultToJS(result)});
|
||||
break;
|
||||
@ -104,6 +107,19 @@ function stepElection() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleException(ex) {
|
||||
if (py.isinstance(ex, py.pyRCV2.ties.RequireInput)) {
|
||||
// Signals we require input to break a tie
|
||||
postMessage({'type': 'require_input', 'message': ex.message});
|
||||
} else if (py.isinstance(ex, py.pyRCV2.method.base_stv.STVException)) {
|
||||
console.error(ex);
|
||||
postMessage({'type': 'stv_exception', 'message': ex.message});
|
||||
} else {
|
||||
console.error(ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
function resultToJS(result) {
|
||||
return {
|
||||
'stage': stage,
|
||||
|
@ -19,6 +19,7 @@ import pyRCV2.model
|
||||
import pyRCV2.numbers
|
||||
|
||||
from pyRCV2.method.base_stv import UIGSTVCounter, WIGSTVCounter, EGSTVCounter
|
||||
from pyRCV2.method.meek import MeekSTVCounter
|
||||
from pyRCV2.ties import TiesBackwards, TiesForwards, TiesPrompt, TiesRandom
|
||||
|
||||
import sys
|
||||
@ -41,7 +42,7 @@ def add_parser(subparsers):
|
||||
parser.add_argument('--round-tvs', type=int, help='round transfer values to specified decimal places')
|
||||
parser.add_argument('--round-weights', type=int, help='round ballot weights to specified decimal places')
|
||||
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', 'eg'], default='wig', help='method of transferring surpluses (default: wig - weighted inclusive Gregory)')
|
||||
parser.add_argument('--method', '-m', choices=['wig', 'uig', 'eg', 'meek'], default='wig', help='method of transferring surpluses (default: wig - weighted inclusive Gregory)')
|
||||
parser.add_argument('--transferable-only', action='store_true', help='examine only transferable papers during surplus distributions')
|
||||
parser.add_argument('--exclusion', choices=['one_round', 'parcels_by_order', 'by_value', 'wright'], default='one_round', help='how to perform exclusions (default: one_round)')
|
||||
parser.add_argument('--ties', '-t', action='append', choices=['backwards', 'forwards', 'prompt', 'random'], default=None, help='how to resolve ties (default: backwards then random)')
|
||||
@ -104,6 +105,8 @@ def main(args):
|
||||
counter = UIGSTVCounter(election, vars(args))
|
||||
elif args.method == 'eg':
|
||||
counter = EGSTVCounter(election, vars(args))
|
||||
elif args.method == 'meek':
|
||||
counter = MeekSTVCounter(election, vars(args))
|
||||
else:
|
||||
counter = WIGSTVCounter(election, vars(args))
|
||||
|
||||
|
@ -88,6 +88,7 @@ class BaseSTVCounter:
|
||||
|
||||
self.total_orig = sum((b.value for b in self.election.ballots), Num('0'))
|
||||
|
||||
self.step_results = []
|
||||
self.num_elected = 0
|
||||
self.num_excluded = 0
|
||||
|
||||
|
207
pyRCV2/method/meek.py
Normal file
207
pyRCV2/method/meek.py
Normal file
@ -0,0 +1,207 @@
|
||||
# pyRCV2: Preferential vote counting
|
||||
# Copyright © 2020–2021 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 BaseSTVCounter, STVException
|
||||
from pyRCV2.model import CandidateState, CountCard, CountStepResult
|
||||
from pyRCV2.numbers import Num
|
||||
from pyRCV2.safedict import SafeDict
|
||||
|
||||
class MeekCountCard(CountCard):
|
||||
def __init__(self, *args):
|
||||
CountCard.__init__(self, *args)
|
||||
self.keep_value = Num(1) # Not read by the count algorithm, but can be used for auditing
|
||||
|
||||
def clone(self):
|
||||
"""Overrides CountCard.clone"""
|
||||
result = MeekCountCard()
|
||||
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
|
||||
result.keep_value = keep_value
|
||||
return result
|
||||
|
||||
class MeekSTVCounter(BaseSTVCounter):
|
||||
def __init__(self, *args):
|
||||
BaseSTVCounter.__init__(self, *args)
|
||||
self.candidates = SafeDict([(c, MeekCountCard()) for c in self.election.candidates])
|
||||
|
||||
self._quota_tolerance_ub = Num('1.0001')
|
||||
self._quota_tolerance_lb = Num('0.9999')
|
||||
|
||||
def reset(self):
|
||||
if self.options['quota_mode'] != 'progressive':
|
||||
raise STVException('Meek method requires --quota-mode progressive')
|
||||
if self.options['bulk_exclude']:
|
||||
raise STVException('Meek method is incompatible with --bulk_exclude')
|
||||
if self.options['defer_surpluses']:
|
||||
raise STVException('Meek method is incompatible with --defer-surpluses')
|
||||
if self.options['papers'] != 'both':
|
||||
raise STVException('Meek method is incompatible with --transferable-only')
|
||||
if self.options['exclusion'] != 'one_round':
|
||||
raise STVException('Meek method requires --exclusion one_round')
|
||||
if self.options['round_tvs'] is not None:
|
||||
raise STVException('Meek method is incompatible with --round-tvs')
|
||||
return BaseSTVCounter.reset(self)
|
||||
|
||||
def distribute_first_preferences(self):
|
||||
"""
|
||||
Overrides BaseSTVCounter.distribute_first_preferences
|
||||
Unlike in other STV methods, this is called not only as part of reset() but also at other stages
|
||||
"""
|
||||
|
||||
# Reset the count
|
||||
# Carry over candidate states, keep values, etc.
|
||||
new_candidates = SafeDict()
|
||||
for candidate, count_card in self.candidates.items():
|
||||
new_count_card = MeekCountCard()
|
||||
new_count_card.state = count_card.state
|
||||
new_count_card.keep_value = count_card.keep_value
|
||||
new_count_card.order_elected = count_card.order_elected
|
||||
|
||||
__pragma__('opov')
|
||||
new_candidates[candidate] = new_count_card
|
||||
__pragma__('noopov')
|
||||
|
||||
self.candidates = new_candidates
|
||||
self.exhausted = CountCard()
|
||||
self.loss_fraction = CountCard()
|
||||
|
||||
# Distribute votes
|
||||
for ballot in self.election.ballots:
|
||||
remaining_value = Num(ballot.value) # Clone the value so we don't update ballot.value
|
||||
for candidate in ballot.preferences:
|
||||
__pragma__('opov')
|
||||
count_card = self.candidates[candidate]
|
||||
__pragma__('noopov')
|
||||
|
||||
if count_card.state == CandidateState.HOPEFUL:
|
||||
# Hopeful candidate has keep value 1, so transfer entire remaining value
|
||||
__pragma__('opov')
|
||||
count_card.transfers += remaining_value
|
||||
__pragma__('noopov')
|
||||
remaining_value = Num(0)
|
||||
break
|
||||
elif count_card.state == CandidateState.EXCLUDED:
|
||||
# Excluded candidate has keep value 0, so skip over this candidate
|
||||
pass
|
||||
elif count_card.state == CandidateState.ELECTED:
|
||||
# Transfer according to elected candidate's keep value
|
||||
__pragma__('opov')
|
||||
count_card.transfers += remaining_value * count_card.keep_value
|
||||
remaining_value *= (Num(1) - count_card.keep_value)
|
||||
__pragma__('noopov')
|
||||
else:
|
||||
raise STVException('Unexpected candidate state')
|
||||
|
||||
# Credit exhausted votes
|
||||
__pragma__('opov')
|
||||
self.exhausted.transfers += remaining_value
|
||||
__pragma__('noopov')
|
||||
|
||||
# Recompute transfers
|
||||
if len(self.step_results) > 0:
|
||||
last_result = self.step_results[len(self.step_results)-1]
|
||||
__pragma__('opov')
|
||||
for candidate, count_card in self.candidates.items():
|
||||
count_card.continue_from(last_result.candidates[candidate])
|
||||
self.exhausted.continue_from(last_result.exhausted)
|
||||
self.loss_fraction.continue_from(last_result.loss_fraction)
|
||||
__pragma__('noopov')
|
||||
|
||||
def distribute_surpluses(self):
|
||||
"""
|
||||
Overrides BaseSTVCounter.distribute_surpluses
|
||||
Surpluses are distributed in Meek STV by recomputing the keep values, and redistributing all votes
|
||||
"""
|
||||
|
||||
# Do surpluses need to be distributed?
|
||||
__pragma__('opov')
|
||||
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.ELECTED and cc.votes / self.quota > self._quota_tolerance_ub]
|
||||
__pragma__('noopov')
|
||||
|
||||
if len(has_surplus) > 0:
|
||||
while len(has_surplus) > 0:
|
||||
# Recompute keep values
|
||||
for candidate, count_card in has_surplus:
|
||||
__pragma__('opov')
|
||||
count_card.keep_value *= self.quota / count_card.votes
|
||||
__pragma__('noopov')
|
||||
|
||||
# Redistribute votes
|
||||
self.distribute_first_preferences()
|
||||
|
||||
# Recompute quota if more ballots have become exhausted
|
||||
self.compute_quota()
|
||||
|
||||
__pragma__('opov')
|
||||
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.ELECTED and cc.votes / self.quota > self._quota_tolerance_ub]
|
||||
__pragma__('noopov')
|
||||
|
||||
# Declare elected any candidates meeting the quota as a result of surpluses
|
||||
# NB: We could do this earlier, but this shows the flow of the election more clearly in the count sheet
|
||||
self.elect_meeting_quota()
|
||||
|
||||
__pragma__('opov')
|
||||
result = CountStepResult(
|
||||
'Surpluses distributed',
|
||||
self.candidates,
|
||||
self.exhausted,
|
||||
self.loss_fraction,
|
||||
self.total + self.exhausted.votes + self.loss_fraction.votes,
|
||||
self.quota,
|
||||
self.vote_required_election,
|
||||
)
|
||||
__pragma__('noopov')
|
||||
|
||||
self.step_results.append(result)
|
||||
return result
|
||||
|
||||
def do_exclusion(self, candidates_excluded):
|
||||
"""
|
||||
Overrides BaseSTVCounter.do_exclusion
|
||||
"""
|
||||
for candidate, count_card in candidates_excluded:
|
||||
count_card.state = CandidateState.EXCLUDED
|
||||
|
||||
# Redistribute votes
|
||||
self.distribute_first_preferences()
|
||||
|
||||
def elect_meeting_quota(self):
|
||||
"""
|
||||
Overrides BaseSTVCounter.elect_meeting_quota
|
||||
Skip directly to CandidateState.ELECTED
|
||||
"""
|
||||
|
||||
# Does a candidate meet the quota?
|
||||
meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and self.meets_quota(cc)]
|
||||
|
||||
if len(meets_quota) > 0:
|
||||
meets_quota.sort(key=lambda x: x[1].votes, reverse=True)
|
||||
|
||||
# Declare elected any candidate who meets the quota
|
||||
while len(meets_quota) > 0:
|
||||
x = self.choose_highest(meets_quota)
|
||||
candidate, count_card = x[0], x[1]
|
||||
|
||||
count_card.state = CandidateState.ELECTED
|
||||
self.num_elected += 1
|
||||
count_card.order_elected = self.num_elected
|
||||
|
||||
meets_quota.remove(x)
|
@ -94,6 +94,14 @@ class CountCard:
|
||||
result.order_elected = self.order_elected
|
||||
return result
|
||||
|
||||
def continue_from(self, previous):
|
||||
"""Adjust this count card's transfers, etc. so its total votes continue on from the previous values"""
|
||||
votes = self.votes
|
||||
self.orig_votes = previous.votes
|
||||
__pragma__('opov')
|
||||
self.transfers = votes - self.orig_votes
|
||||
__pragma__('noopov')
|
||||
|
||||
class CountStepResult:
|
||||
def __init__(self, comment, candidates, exhausted, loss_fraction, total, quota, vote_required_election):
|
||||
self.comment = comment
|
||||
|
@ -16,7 +16,7 @@
|
||||
|
||||
import pyRCV2.blt
|
||||
import pyRCV2.model
|
||||
import pyRCV2.method, pyRCV2.method.base_stv
|
||||
import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.meek
|
||||
import pyRCV2.numbers
|
||||
import pyRCV2.random
|
||||
import pyRCV2.ties
|
||||
|
Reference in New Issue
Block a user