Expose advanced rounding options in count software

This commit is contained in:
RunasSudo 2021-01-03 01:23:11 +11:00
parent a3d79e993a
commit e219969711
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
4 changed files with 145 additions and 87 deletions

View File

@ -29,12 +29,17 @@
tr.info td {
background-color: #edededff;
}
i.sep::after {
content: "•";
color: #ccc;
}
</style>
</head>
<body>
<div>
<input type="file" id="bltFile">
<button onclick="clickCount()">Count</button>
<i class="sep"></i>
<label>
Preset:
<select id="selPreset" onchange="changePreset()">
@ -66,10 +71,12 @@
<option value="hare_exact">Hare (exact)</option>
</select>
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkProgQuota">
Progressive quota
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkBulkElection" checked>
Bulk election
@ -87,10 +94,29 @@
<option value="fixed" selected>Fixed</option>
</select>
</label>
<i class="sep"></i>
<label>
Decimal places (if Numbers = Fixed):
<input type="number" id="txtDP" value="5" style="width: 3em;">
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkRoundVotes">
Round votes to
</label>
<label>
<input type="number" id="txtRoundVotes" value="0" style="width: 3em;">
d.p.
</label>
<i class="sep"></i>
<label>
<input type="checkbox" id="chkRoundWeights">
Round ballot weights to
</label>
<label>
<input type="number" id="txtRoundWeights" value="0" style="width: 3em;">
d.p.
</label>
<br>
<label>
Surplus order:
@ -99,6 +125,7 @@
<option value="order">By order</option>
</select>
</label>
<i class="sep"></i>
<label>
Method:
<select id="selTransfers">
@ -131,6 +158,7 @@
<option value="prompt">Prompt</option>
</select>
</label>
<i class="sep"></i>
<label>
Random seed:
<input type="text" id="txtSeed" value="">

View File

@ -35,6 +35,8 @@ function changePreset() {
document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig';
document.getElementById('selPapers').value = 'both';
@ -47,6 +49,8 @@ function changePreset() {
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'rational';
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig';
document.getElementById('selPapers').value = 'both';
@ -59,7 +63,10 @@ function changePreset() {
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '0';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'uig';
document.getElementById('selPapers').value = 'both';
@ -73,6 +80,8 @@ function changePreset() {
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wright';
document.getElementById('selPapers').value = 'both';
@ -85,7 +94,10 @@ function changePreset() {
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '0';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable';
@ -318,7 +330,9 @@ async function clickCount() {
'surplus_order': document.getElementById('selSurplus').value,
'papers': document.getElementById('selPapers').value,
'exclusion': document.getElementById('selExclusion').value,
'ties': document.getElementById('selTies').value
'ties': document.getElementById('selTies').value,
'round_votes': document.getElementById('chkRoundVotes').checked ? parseInt(document.getElementById('txtRoundVotes').value) : null,
'round_weights': document.getElementById('chkRoundWeights').checked ? parseInt(document.getElementById('txtRoundWeights').value) : null,
},
'seed': document.getElementById('txtSeed').value,
'data': text

View File

@ -17,7 +17,7 @@
__pragma__ = lambda x: None
from pyRCV2.model import CandidateState, CountCard, CountCompleted, CountStepResult
from pyRCV2.numbers import Num, Rational
from pyRCV2.numbers import Num
from pyRCV2.safedict import SafeDict
import itertools
@ -33,8 +33,6 @@ class BaseSTVCounter:
def __init__(self, election, options=None):
self.election = election
self.cls_ballot_value = Num # Need to use Rational in unweighted inclusive Gregory
# Default options
self.options = {
'prog_quota': False, # Progressively reducing quota?
@ -44,7 +42,9 @@ class BaseSTVCounter:
'surplus_order': 'size', # 'size' or 'order'
'papers': 'both', # 'both' or 'transferable'
'exclusion': 'one_round', # 'one_round', 'parcels_by_order' or 'by_value'
'ties': []
'ties': [], # List of tie strategies (e.g. TiesRandom)
'round_votes': None, # Number of decimal places or None
'round_weights': None, # Number of decimal places or None
}
if options is not None:
@ -103,12 +103,12 @@ class BaseSTVCounter:
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))])
self.candidates[candidate].parcels.append([(ballot, Num(ballot.value))])
else:
self.candidates[candidate].parcels[0].append((ballot, self.cls_ballot_value(ballot.value)))
self.candidates[candidate].parcels[0].append((ballot, Num(ballot.value)))
else:
self.exhausted.transfers += ballot.value
#self.exhausted.parcels[0].append((ballot, self.cls_ballot_value(ballot.value)))
#self.exhausted.parcels[0].append((ballot, Num(ballot.value)))
__pragma__('noopov')
def step(self):
@ -250,39 +250,6 @@ class BaseSTVCounter:
"""
raise NotImplementedError('Method not implemented')
def next_preferences(self, parcels):
"""
Examine the specified parcels and group ballot papers by next available preference
"""
# SafeDict: Candidate -> [List[Ballot], ballots, votes]
next_preferences = SafeDict([(c, [[], Num('0'), Rational('0')]) for c, cc in self.candidates.items()])
total_ballots = Num('0')
total_votes = Rational('0')
next_exhausted = []
exhausted_ballots = Num('0')
exhausted_votes = Rational('0')
for parcel in parcels:
for ballot, ballot_value in parcel:
__pragma__('opov')
total_ballots += ballot.value
total_votes += ballot_value.to_rational()
candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
if candidate is not None:
next_preferences[candidate][0].append((ballot, ballot_value))
next_preferences[candidate][1] += ballot.value
next_preferences[candidate][2] += ballot_value.to_rational()
else:
next_exhausted.append((ballot, ballot_value))
exhausted_ballots += ballot.value
exhausted_votes += ballot_value.to_rational()
__pragma__('noopov')
return next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes
def before_exclusion(self):
"""
Check before excluding a candidate
@ -428,6 +395,43 @@ class BaseSTVCounter:
meets_quota.remove(x)
# -----------------
# UTILITY FUNCTIONS
# -----------------
def next_preferences(self, parcels):
"""
Examine the specified parcels and group ballot papers by next available preference
"""
# SafeDict: Candidate -> [List[Ballot], ballots, votes]
next_preferences = SafeDict([(c, [[], Num('0'), Num('0')]) for c, cc in self.candidates.items()])
total_ballots = Num('0')
total_votes = Num('0')
next_exhausted = []
exhausted_ballots = Num('0')
exhausted_votes = Num('0')
for parcel in parcels:
for ballot, ballot_value in parcel:
__pragma__('opov')
total_ballots += ballot.value
total_votes += ballot_value
candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None)
if candidate is not None:
next_preferences[candidate][0].append((ballot, ballot_value))
next_preferences[candidate][1] += ballot.value
next_preferences[candidate][2] += ballot_value
else:
next_exhausted.append((ballot, ballot_value))
exhausted_ballots += ballot.value
exhausted_votes += ballot_value
__pragma__('noopov')
return next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes
def choose_lowest(self, l):
"""
Provided a list of tuples (Candidate, CountCard), sorted in ASCENDING order of votes, choose the tuple with the fewest votes, breaking ties appropriately
@ -476,6 +480,16 @@ class BaseSTVCounter:
raise Exception('Unable to resolve tie')
def round_votes(self, num):
if self.options['round_votes'] is None:
return num
return num.round(self.options['round_votes'], num.ROUND_DOWN)
def round_weight(self, num):
if self.options['round_weights'] is None:
return num
return num.round(self.options['round_weights'], num.ROUND_DOWN)
class WIGSTVCounter(BaseSTVCounter):
"""
Basic weighted inclusive Gregory STV counter
@ -486,7 +500,7 @@ class WIGSTVCounter(BaseSTVCounter):
if self.options['papers'] == 'transferable':
__pragma__('opov')
transferable_votes = total_votes.to_num() - exhausted_votes.to_num()
transferable_votes = total_votes - exhausted_votes
__pragma__('noopov')
for candidate, x in next_preferences.items():
@ -503,11 +517,11 @@ class WIGSTVCounter(BaseSTVCounter):
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
self.candidates[candidate].transfers += (num_votes.to_num() * surplus) / transferable_votes
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / transferable_votes)
else:
self.candidates[candidate].transfers += num_votes.to_num() # Do not allow weight to increase
self.candidates[candidate].transfers += self.round_votes(num_votes) # Do not allow weight to increase
else:
self.candidates[candidate].transfers += (num_votes.to_num() * surplus) / total_votes.to_num()
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / total_votes)
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
@ -518,9 +532,9 @@ class WIGSTVCounter(BaseSTVCounter):
else:
new_value = ballot_value
else:
new_value = (ballot_value * surplus) / total_votes.to_num()
new_value = (ballot_value * surplus) / total_votes
new_parcel.append((ballot, new_value))
new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov')
__pragma__('opov')
@ -528,9 +542,9 @@ class WIGSTVCounter(BaseSTVCounter):
if transferable_votes > surplus:
pass # No ballots exhaust
else:
self.exhausted.transfers += (surplus - transferable_votes)
self.exhausted.transfers += self.round_votes((surplus - transferable_votes))
else:
self.exhausted.transfers += (exhausted_votes.to_num() * surplus) / total_votes.to_num()
self.exhausted.transfers += self.round_votes((exhausted_votes * surplus) / total_votes)
__pragma__('noopov')
__pragma__('opov')
@ -552,8 +566,10 @@ class WIGSTVCounter(BaseSTVCounter):
# Sort the ballots by value
ballots = [(b, bv) for p in count_card.parcels for b, bv in p]
__pragma__('opov')
ballots.sort(key=lambda x: x[1] / x[0].value.to_rational(), reverse=True)
count_card.parcels = [list(g) for k, g in itertools.groupby(ballots, lambda x: x[1] / x[0].value.to_rational())]
ballots.sort(key=lambda x: x[1] / x[0].value, reverse=True)
# Round to 8 decimal places to consider equality
# FIXME: Work out a better way of doing this
count_card.parcels = [list(g) for k, g in itertools.groupby(ballots, lambda x: (x[1] / x[0].value).round(8, x[1].ROUND_DOWN))]
__pragma__('noopov')
if len(count_card.parcels) > 0:
@ -579,7 +595,7 @@ class WIGSTVCounter(BaseSTVCounter):
__pragma__('noopov')
__pragma__('opov')
self.candidates[candidate].transfers += num_votes.to_num()
self.candidates[candidate].transfers += self.round_votes(num_votes)
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
@ -588,11 +604,11 @@ class WIGSTVCounter(BaseSTVCounter):
__pragma__('noopov')
__pragma__('opov')
self.exhausted.transfers += exhausted_votes.to_num()
self.exhausted.transfers += self.round_votes(exhausted_votes)
__pragma__('noopov')
__pragma__('opov')
count_card.transfers -= total_votes.to_num()
count_card.transfers -= total_votes
__pragma__('noopov')
if len(count_card.parcels) == 0:
@ -608,22 +624,20 @@ class UIGSTVCounter(WIGSTVCounter):
def __init__(self, *args):
WIGSTVCounter.__init__(self, *args)
# Need to use Rational for ballot value internally, as Num may be set to integers only
self.cls_ballot_value = lambda x: x.to_rational()
def do_surplus(self, candidate_surplus, count_card, surplus):
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences(count_card.parcels)
if self.options['papers'] == 'transferable':
__pragma__('opov')
transferable_ballots = total_ballots - exhausted_ballots # Num
transferable_votes = total_votes - exhausted_votes # Rational
transferable_ballots = total_ballots - exhausted_ballots
transferable_votes = total_votes - exhausted_votes
__pragma__('noopov')
for candidate, x in next_preferences.items():
cand_ballots = x[0]
num_ballots = x[1] # Num
num_votes = x[2] # Rational
num_ballots = x[1]
num_votes = x[2]
new_parcel = []
if len(cand_ballots) > 0:
@ -633,35 +647,35 @@ class UIGSTVCounter(WIGSTVCounter):
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes.to_num() > surplus:
self.candidates[candidate].transfers += (num_ballots * surplus) / transferable_ballots
if transferable_votes > surplus:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
else:
self.candidates[candidate].transfers += num_votes.to_num()
self.candidates[candidate].transfers += self.round_votes(num_votes)
else:
self.candidates[candidate].transfers += (num_ballots * surplus) / total_ballots
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes.to_num() > surplus:
new_value = (ballot.value * surplus).to_rational() / transferable_ballots.to_rational()
if transferable_votes > surplus:
new_value = (ballot.value * surplus) / transferable_ballots
else:
new_value = ballot_value
else:
new_value = (ballot.value * surplus).to_rational() / total_ballots.to_rational()
new_value = (ballot.value * surplus) / total_ballots
new_parcel.append((ballot, new_value))
new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov')
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes.to_num() > surplus:
if transferable_votes > surplus:
pass # No ballots exhaust
else:
self.exhausted.transfers += surplus - transferable_votes.to_num()
self.exhausted.transfers += self.round_votes(surplus - transferable_votes)
else:
self.exhausted.transfers += (exhausted_ballots * surplus) / total_ballots
self.exhausted.transfers += self.round_votes((exhausted_ballots * surplus) / total_ballots)
__pragma__('noopov')
__pragma__('opov')
@ -684,7 +698,7 @@ class EGSTVCounter(UIGSTVCounter):
if self.options['papers'] == 'transferable':
__pragma__('opov')
transferable_ballots = total_ballots - exhausted_ballots
transferable_votes = total_votes.to_num() - exhausted_votes.to_num()
transferable_votes = total_votes - exhausted_votes
__pragma__('noopov')
for candidate, x in next_preferences.items():
@ -701,11 +715,11 @@ class EGSTVCounter(UIGSTVCounter):
__pragma__('opov')
if self.options['papers'] == 'transferable':
if transferable_votes > surplus:
self.candidates[candidate].transfers += (num_ballots * surplus) / transferable_ballots
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
else:
self.candidates[candidate].transfers += num_votes.to_num()
self.candidates[candidate].transfers += self.round_votes(num_votes)
else:
self.candidates[candidate].transfers += (num_ballots * surplus) / total_ballots
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
__pragma__('noopov')
for ballot, ballot_value in cand_ballots:
@ -718,7 +732,7 @@ class EGSTVCounter(UIGSTVCounter):
else:
new_value = (ballot.value * surplus) / total_ballots
new_parcel.append((ballot, new_value))
new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov')
__pragma__('opov')
@ -726,9 +740,9 @@ class EGSTVCounter(UIGSTVCounter):
if transferable_votes > surplus:
pass # No ballots exhaust
else:
self.exhausted.transfers += (surplus - transferable_votes)
self.exhausted.transfers += self.round_votes((surplus - transferable_votes))
else:
self.exhausted.transfers += (exhausted_ballots * surplus) / total_ballots
self.exhausted.transfers += self.round_votes((exhausted_ballots * surplus) / total_ballots)
__pragma__('noopov')
__pragma__('opov')

View File

@ -14,6 +14,7 @@
# 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/>.
from pytest import approx
from pytest_steps import test_steps
import pyRCV2.blt
@ -36,7 +37,7 @@ candidates = [data[i][0] for i in range(2, len(data) - 2)]
def test_aec_tas19():
"""Compare count of aec-senate-formalpreferences-24310-TAS.blt.gz with model result at https://results.aec.gov.au/24310/Website/External/SenateStateDop-24310-TAS.pdf"""
pyRCV2.numbers.set_numclass(pyRCV2.numbers.NativeInt)
pyRCV2.numbers.set_numclass(pyRCV2.numbers.Native)
with gzip.open('tests/data/aec-senate-formalpreferences-24310-TAS.blt.gz', 'rt') as f:
election = pyRCV2.blt.readBLT(f.read())
@ -45,7 +46,8 @@ def test_aec_tas19():
counter = UIGSTVCounter(election, {
'surplus_order': 'order',
'exclusion': 'by_value'
'exclusion': 'by_value',
'round_votes': 0,
})
result = counter.reset()
@ -56,21 +58,21 @@ def test_aec_tas19():
result = counter.step()
comment = data[1][i]
assert result.comment == comment, 'Failed to verify stage {} comment'.format(stage)
assert result.comment == comment, 'Failed to verify comment'
for j, cand in enumerate(candidates):
votes = pyRCV2.numbers.Num(data[j + 2][i])
cc = next(cc for c, cc in result.candidates.items() if c.name == cand)
assert cc.votes == votes, 'Failed to verify stage {} candidate "{}" votes, got {} expected {}'.format(stage, cand, cc.votes.pp(0), votes.pp(0))
assert cc.votes.impl == approx(votes.impl), 'Failed to verify candidate "{}" votes, got {} expected {}'.format(cand, cc.votes.pp(0), votes.pp(0))
state = data[j + 2][i + 1] if len(data[j + 2]) > (i + 1) else ''
accept = {'': CandidateState.HOPEFUL, 'PEL': CandidateState.PROVISIONALLY_ELECTED, 'EL': CandidateState.ELECTED, 'EX': CandidateState.EXCLUDED, 'EXCLUDING': CandidateState.EXCLUDING}
assert cc.state == accept[state], 'Failed to verify stage {} candidate "{}" state'.format(stage, cand)
assert cc.state == accept[state], 'Failed to verify candidate "{}" state'.format(cand)
exhausted = pyRCV2.numbers.Num(data[len(candidates) + 2][i])
assert result.exhausted.votes == exhausted, 'Failed to verify stage {} exhausted votes, got {} expected {}'.format(stage, result.exhausted.votes.pp(0), exhausted.pp(0))
assert result.exhausted.votes.impl == approx(exhausted.impl), 'Failed to verify exhausted votes, got {} expected {}'.format(result.exhausted.votes.pp(0), exhausted.pp(0))
loss_fraction = pyRCV2.numbers.Num(data[len(candidates) + 3][i])
assert result.loss_fraction.votes == loss_fraction, 'Failed to verify stage {} loss to fraction, got {} expected {}'.format(stage, result.loss_fraction.votes.pp(0), loss_fraction.pp(0))
assert result.loss_fraction.votes.impl == approx(loss_fraction.impl), 'Failed to verify loss to fraction, got {} expected {}'.format(result.loss_fraction.votes.pp(0), loss_fraction.pp(0))
yield 'Stage {}'.format(stage)