Implement Meek STV

This commit is contained in:
RunasSudo 2021-01-08 19:16:56 +11:00
parent 81b70984c9
commit 39d0adbd0d
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
10 changed files with 277 additions and 18 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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>

View File

@ -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';

View File

@ -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,

View File

@ -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))

View File

@ -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
View 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)

View File

@ -93,6 +93,14 @@ class CountCard:
result.state = self.state
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):

View File

@ -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