pref_voting 1.16.32__py3-none-any.whl → 1.17.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pref_voting/__init__.py +1 -1
- pref_voting/iterative_methods.py +6 -6
- pref_voting/proportional_methods.py +1575 -0
- {pref_voting-1.16.32.dist-info → pref_voting-1.17.2.dist-info}/METADATA +1 -1
- {pref_voting-1.16.32.dist-info → pref_voting-1.17.2.dist-info}/RECORD +7 -6
- {pref_voting-1.16.32.dist-info → pref_voting-1.17.2.dist-info}/WHEEL +0 -0
- {pref_voting-1.16.32.dist-info → pref_voting-1.17.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,1575 @@
|
|
1
|
+
'''
|
2
|
+
File: proportional_methods.py
|
3
|
+
Author: Wes Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu)
|
4
|
+
Date: September 29, 2025
|
5
|
+
|
6
|
+
Implementations of voting methods for proportional representation.
|
7
|
+
'''
|
8
|
+
import os
|
9
|
+
import math
|
10
|
+
import itertools
|
11
|
+
import collections
|
12
|
+
import random
|
13
|
+
|
14
|
+
from pref_voting.weighted_majority_graphs import MarginGraph
|
15
|
+
from pref_voting.margin_based_methods import minimax
|
16
|
+
from pref_voting.profiles_with_ties import ProfileWithTies
|
17
|
+
from pref_voting.voting_method import vm
|
18
|
+
from pref_voting.voting_method_properties import ElectionTypes
|
19
|
+
from pref_voting.profiles import Profile
|
20
|
+
|
21
|
+
EPS = 1e-12
|
22
|
+
TRACE = bool(int(os.environ.get("STV_TRACE", "0") or "0"))
|
23
|
+
|
24
|
+
def _t(msg):
|
25
|
+
if TRACE:
|
26
|
+
print(msg)
|
27
|
+
|
28
|
+
# ---------- Piece model ----------
|
29
|
+
|
30
|
+
class RankingPiece:
|
31
|
+
"""
|
32
|
+
A piece represents a fractional portion of a voter's ballot weight allocated to a specific candidate.
|
33
|
+
|
34
|
+
In STV (Single Transferable Vote), when candidates receive surplus votes above the quota or when
|
35
|
+
candidates are eliminated, ballot weights must be transferred to other candidates according to
|
36
|
+
voter preferences. Rather than transferring whole ballots, the system creates "pieces", which are fractional
|
37
|
+
portions of ballot weight that can be allocated independently.
|
38
|
+
|
39
|
+
For example, if a candidate receives 120 votes but only needs 100 to meet quota, the surplus 20
|
40
|
+
votes are transferred as pieces with reduced weight (20/120 = 1/6 of original weight) to the
|
41
|
+
next preferences on those ballots.
|
42
|
+
|
43
|
+
Each piece tracks:
|
44
|
+
- ranking: The original voter's preference ranking (Ranking object)
|
45
|
+
- weight: The fractional weight of this piece (0.0 to 1.0)
|
46
|
+
- current_rank: The preference level this piece is currently at
|
47
|
+
- cand: The candidate this piece is currently allocated to
|
48
|
+
"""
|
49
|
+
__slots__ = ("ranking", "weight", "current_rank", "cand", "arrived_value")
|
50
|
+
def __init__(self, ranking, weight, current_rank, cand, arrived_value=None):
|
51
|
+
self.ranking = ranking
|
52
|
+
self.weight = weight
|
53
|
+
self.current_rank = current_rank
|
54
|
+
self.cand = cand
|
55
|
+
# 'arrived_value' is the value credited to this candidate when this piece ARRIVED.
|
56
|
+
self.arrived_value = weight if arrived_value is None else arrived_value
|
57
|
+
def clone_to(self, cand, new_rank, weight):
|
58
|
+
# When a piece moves to a new candidate, its arrival value at that candidate is the amount moved.
|
59
|
+
return RankingPiece(self.ranking, weight, new_rank, cand, arrived_value=weight)
|
60
|
+
|
61
|
+
class ParcelIndex:
|
62
|
+
"""
|
63
|
+
Track which pieces belong to which parcel for last-parcel transfer rules.
|
64
|
+
|
65
|
+
In some STV variants (like Australian Senate rules), surplus transfers use only the
|
66
|
+
"last parcel" of votes received by a candidate, rather than all votes. This class
|
67
|
+
tracks which pieces arrived in which order so the last parcel can be identified.
|
68
|
+
|
69
|
+
A "parcel" is a group of pieces that arrived together during a single transfer operation.
|
70
|
+
"""
|
71
|
+
def __init__(self):
|
72
|
+
self._last = collections.defaultdict(list)
|
73
|
+
def start_new_parcel(self, cand):
|
74
|
+
self._last[cand] = []
|
75
|
+
def note_arrival(self, cand, piece_idx):
|
76
|
+
self._last[cand].append(piece_idx)
|
77
|
+
def last_parcel(self, cand):
|
78
|
+
return self._last.get(cand, [])
|
79
|
+
def clear_parcel(self, cand):
|
80
|
+
self._last[cand] = []
|
81
|
+
def remap_indices(self, mapping):
|
82
|
+
if not mapping:
|
83
|
+
return
|
84
|
+
for cand, idxs in list(self._last.items()):
|
85
|
+
remapped = []
|
86
|
+
for idx in idxs:
|
87
|
+
if idx in mapping:
|
88
|
+
remapped.append(mapping[idx])
|
89
|
+
self._last[cand] = remapped
|
90
|
+
|
91
|
+
def _initial_pieces_from_profile(profile, recipients, parcels):
|
92
|
+
"""
|
93
|
+
Create initial ranking pieces from ProfileWithTies.
|
94
|
+
|
95
|
+
Converts a ProfileWithTies into the piece-based representation used by STV algorithms.
|
96
|
+
Each voter's ranking becomes one or more pieces allocated to their most preferred
|
97
|
+
available candidates. If multiple candidates are tied at the top rank, the ballot
|
98
|
+
weight is split equally among them (see approval_stv for a different approach).
|
99
|
+
|
100
|
+
Args:
|
101
|
+
profile: ProfileWithTies object containing voter rankings
|
102
|
+
recipients: Set of candidates eligible to receive pieces
|
103
|
+
parcels: ParcelIndex to track piece arrival order
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
List of RankingPiece objects representing the initial allocation
|
107
|
+
"""
|
108
|
+
pieces = []
|
109
|
+
rankings, rcounts = profile.rankings_counts
|
110
|
+
for ranking, count in zip(rankings, rcounts):
|
111
|
+
if count <= 0:
|
112
|
+
continue
|
113
|
+
rmap = ranking.rmap
|
114
|
+
first_rank = None
|
115
|
+
first_cands = []
|
116
|
+
for c, r in rmap.items():
|
117
|
+
if r is not None and c in recipients:
|
118
|
+
if first_rank is None or r < first_rank:
|
119
|
+
first_rank = r; first_cands = [c]
|
120
|
+
elif r == first_rank:
|
121
|
+
first_cands.append(c)
|
122
|
+
if first_cands:
|
123
|
+
share = float(count) / len(first_cands)
|
124
|
+
for c in first_cands:
|
125
|
+
p = RankingPiece(ranking, share, first_rank, c, arrived_value=share)
|
126
|
+
parcels.note_arrival(c, len(pieces))
|
127
|
+
pieces.append(p)
|
128
|
+
return pieces
|
129
|
+
|
130
|
+
def _tally_from_pieces(pieces, restrict_to=None):
|
131
|
+
"""
|
132
|
+
Tally the total weight allocated to each candidate from a collection of pieces.
|
133
|
+
|
134
|
+
Args:
|
135
|
+
pieces: List of RankingPiece objects
|
136
|
+
restrict_to: Optional set of candidates to include in tally
|
137
|
+
|
138
|
+
Returns:
|
139
|
+
Dictionary mapping candidates to their total allocated weight
|
140
|
+
"""
|
141
|
+
t = collections.defaultdict(float)
|
142
|
+
for p in pieces:
|
143
|
+
if p.weight <= EPS:
|
144
|
+
continue
|
145
|
+
if restrict_to is None or p.cand in restrict_to:
|
146
|
+
t[p.cand] += p.weight
|
147
|
+
return t
|
148
|
+
|
149
|
+
def _next_prefs_from_ranking(ranking, recipients, current_rank):
|
150
|
+
"""Find next preferences in a ranking after current_rank that are in recipients."""
|
151
|
+
rmap = ranking.rmap
|
152
|
+
next_ranks = [r for c, r in rmap.items() if r is not None and r > current_rank and c in recipients]
|
153
|
+
if not next_ranks:
|
154
|
+
return [], -1
|
155
|
+
next_rank = min(next_ranks)
|
156
|
+
next_cands = [c for c, r in rmap.items() if r == next_rank and c in recipients]
|
157
|
+
return next_cands, next_rank
|
158
|
+
|
159
|
+
def _move_piece_forward(piece, recipients):
|
160
|
+
"""Move a ranking piece forward to next preferences."""
|
161
|
+
nxt, new_rank = _next_prefs_from_ranking(piece.ranking, recipients, piece.current_rank)
|
162
|
+
if not nxt:
|
163
|
+
return []
|
164
|
+
share = 1.0 / float(len(nxt))
|
165
|
+
return [(c, share, new_rank) for c in nxt]
|
166
|
+
|
167
|
+
# ---------- Surplus & elimination ----------
|
168
|
+
|
169
|
+
def _transfer_surplus_inclusive(pieces, elect, quota, recipients, parcels,
|
170
|
+
drain_all=True, last_parcel_only=False, ers_rounding=False):
|
171
|
+
"""
|
172
|
+
Inclusive Gregory: drain a common fraction from the winner's pile and move the drained
|
173
|
+
mass to next available continuing preferences.
|
174
|
+
• drain_all=True: drain the fraction from *all* ballots in the pile. Portions with
|
175
|
+
no next preference exhaust. (Used for WIG and last‑parcel.)
|
176
|
+
• drain_all=False: “compensation” (ERS/NB) – only donors that have a next
|
177
|
+
preference are drained; the fraction is increased so the surplus is fully removed.
|
178
|
+
• last_parcel_only=True: only drain pieces in the most recent parcel (Senatorial).
|
179
|
+
Returns True if any weight moved.
|
180
|
+
"""
|
181
|
+
tall = _tally_from_pieces(pieces, restrict_to=(set(recipients) | {elect}))
|
182
|
+
surplus = tall.get(elect, 0.0) - quota
|
183
|
+
if surplus <= EPS:
|
184
|
+
return False
|
185
|
+
|
186
|
+
if last_parcel_only:
|
187
|
+
donor_idxs = list(parcels.last_parcel(elect))
|
188
|
+
else:
|
189
|
+
donor_idxs = [i for i, p in enumerate(pieces) if p.cand == elect and p.weight > EPS]
|
190
|
+
if not donor_idxs:
|
191
|
+
return False
|
192
|
+
|
193
|
+
if drain_all:
|
194
|
+
total_weight = sum(pieces[i].weight for i in donor_idxs)
|
195
|
+
if total_weight <= EPS:
|
196
|
+
return False
|
197
|
+
frac = min(1.0, surplus / total_weight)
|
198
|
+
any_moved = False
|
199
|
+
opened = set() # ensure a *new* parcel is opened for each recipient
|
200
|
+
for i in donor_idxs:
|
201
|
+
p = pieces[i]
|
202
|
+
drain = p.weight * frac
|
203
|
+
if ers_rounding:
|
204
|
+
# ERS practice: round transfer values down to hundredth
|
205
|
+
drain = math.floor(drain * 100.0) / 100.0
|
206
|
+
if drain <= EPS:
|
207
|
+
continue
|
208
|
+
forwards = _move_piece_forward(p, recipients)
|
209
|
+
if forwards:
|
210
|
+
share = drain / float(len(forwards))
|
211
|
+
for nxt_c, _, new_idx in forwards:
|
212
|
+
if nxt_c not in opened:
|
213
|
+
parcels.start_new_parcel(nxt_c)
|
214
|
+
opened.add(nxt_c)
|
215
|
+
pieces.append(p.clone_to(nxt_c, new_idx, share))
|
216
|
+
parcels.note_arrival(nxt_c, len(pieces)-1)
|
217
|
+
# drain even if no forward (exhaust)
|
218
|
+
p.weight -= drain
|
219
|
+
any_moved = True
|
220
|
+
if last_parcel_only:
|
221
|
+
parcels.clear_parcel(elect)
|
222
|
+
return any_moved
|
223
|
+
|
224
|
+
# compensation (ERS/NB)
|
225
|
+
donors = []
|
226
|
+
nxt_cache = {}
|
227
|
+
for i in donor_idxs:
|
228
|
+
forwards = _move_piece_forward(pieces[i], recipients)
|
229
|
+
if forwards:
|
230
|
+
donors.append(i)
|
231
|
+
nxt_cache[i] = forwards
|
232
|
+
total_transferable = sum(pieces[i].weight for i in donors)
|
233
|
+
if total_transferable <= EPS:
|
234
|
+
return False
|
235
|
+
frac = min(1.0, surplus / total_transferable)
|
236
|
+
any_moved = False
|
237
|
+
opened = set() # also open new parcels under compensation variant
|
238
|
+
for i in donors:
|
239
|
+
p = pieces[i]
|
240
|
+
drain = p.weight * frac
|
241
|
+
if ers_rounding:
|
242
|
+
# ERS practice: round transfer values down to hundredth
|
243
|
+
drain = math.floor(drain * 100.0) / 100.0
|
244
|
+
if drain <= EPS:
|
245
|
+
continue
|
246
|
+
forwards = nxt_cache[i]
|
247
|
+
share = drain / float(len(forwards))
|
248
|
+
for nxt_c, _, new_idx in forwards:
|
249
|
+
if nxt_c not in opened:
|
250
|
+
parcels.start_new_parcel(nxt_c)
|
251
|
+
opened.add(nxt_c)
|
252
|
+
pieces.append(p.clone_to(nxt_c, new_idx, share))
|
253
|
+
parcels.note_arrival(nxt_c, len(pieces)-1)
|
254
|
+
p.weight -= drain
|
255
|
+
any_moved = True
|
256
|
+
if last_parcel_only:
|
257
|
+
parcels.clear_parcel(elect)
|
258
|
+
return any_moved
|
259
|
+
|
260
|
+
def _transfer_surplus_scottish(pieces, elect, quota, recipients, parcels, *, decimals=5):
|
261
|
+
"""
|
262
|
+
Scottish STV surplus transfer (SSI 2007/42):
|
263
|
+
For each ballot piece currently credited to the elected candidate, compute
|
264
|
+
transfer = truncate_to_decimals((surplus * piece_value_when_received) / total, decimals)
|
265
|
+
and push that amount to the next available preference(s) among *continuing* candidates.
|
266
|
+
Portions with no next available preference do not transfer (Rule 48(1)(b)).
|
267
|
+
(I.e., one truncation of A/B to `decimals`; A = surplus * piece_value_when_received,
|
268
|
+
B = total currently credited to the winner.) Largest‑surplus‑first is handled by caller (Rule 49).
|
269
|
+
|
270
|
+
Returns: True if any weight moved; False otherwise.
|
271
|
+
"""
|
272
|
+
recipients = set(recipients)
|
273
|
+
|
274
|
+
# Total currently credited to the elected candidate and their surplus over quota.
|
275
|
+
tall = _tally_from_pieces(pieces, restrict_to=(recipients | {elect}))
|
276
|
+
total = tall.get(elect, 0.0)
|
277
|
+
surplus = total - quota
|
278
|
+
if surplus <= EPS or total <= EPS:
|
279
|
+
return False
|
280
|
+
|
281
|
+
scale = 10 ** decimals
|
282
|
+
any_moved = False
|
283
|
+
opened = set()
|
284
|
+
|
285
|
+
# Consider only pieces currently credited to the elected candidate.
|
286
|
+
donor_idxs = [i for i, p in enumerate(pieces) if p.cand == elect and p.weight > EPS]
|
287
|
+
if not donor_idxs:
|
288
|
+
return False
|
289
|
+
|
290
|
+
for i in donor_idxs:
|
291
|
+
p = pieces[i]
|
292
|
+
|
293
|
+
# Only papers that have a next continuing preference are "transferable" (Rule 48(1)(b)).
|
294
|
+
forwards = _move_piece_forward(p, recipients)
|
295
|
+
if not forwards:
|
296
|
+
continue
|
297
|
+
|
298
|
+
# Per‑piece transfer = floor( (surplus * value_when_received / total) * 10^d ) / 10^d
|
299
|
+
# Use arrived_value as the statute’s “value ... when received”.
|
300
|
+
base = p.arrived_value
|
301
|
+
drain = math.floor(((surplus * base / total) * scale) + EPS) / float(scale)
|
302
|
+
if drain <= EPS:
|
303
|
+
continue
|
304
|
+
|
305
|
+
# Never move more than is still credited to this piece.
|
306
|
+
drain = min(drain, p.weight)
|
307
|
+
if drain <= EPS:
|
308
|
+
continue
|
309
|
+
|
310
|
+
# Deduct from the elected candidate and forward equally among next preferences.
|
311
|
+
p.weight -= drain
|
312
|
+
share = drain / float(len(forwards))
|
313
|
+
for nxt_c, _, new_rank in forwards:
|
314
|
+
if nxt_c not in opened:
|
315
|
+
parcels.start_new_parcel(nxt_c) # start a fresh parcel for each recipient in this transfer
|
316
|
+
opened.add(nxt_c)
|
317
|
+
pieces.append(p.clone_to(nxt_c, new_rank, share))
|
318
|
+
parcels.note_arrival(nxt_c, len(pieces) - 1)
|
319
|
+
|
320
|
+
any_moved = True
|
321
|
+
|
322
|
+
return any_moved
|
323
|
+
|
324
|
+
def _eliminate_lowest(pieces, continuing, parcels, tie_break_key=None):
|
325
|
+
if not continuing:
|
326
|
+
return None, pieces
|
327
|
+
tallies = _tally_from_pieces(pieces, restrict_to=continuing)
|
328
|
+
min_t = float('inf'); lowest = []
|
329
|
+
for c in continuing:
|
330
|
+
t = tallies.get(c, 0.0)
|
331
|
+
if t < min_t - EPS:
|
332
|
+
min_t = t; lowest = [c]
|
333
|
+
elif abs(t - min_t) <= EPS:
|
334
|
+
lowest.append(c)
|
335
|
+
if not lowest:
|
336
|
+
return None, pieces
|
337
|
+
if len(lowest) > 1:
|
338
|
+
key = tie_break_key or (lambda x: str(x))
|
339
|
+
lowest.sort(key=key)
|
340
|
+
elim = lowest[0]
|
341
|
+
continuing.remove(elim)
|
342
|
+
|
343
|
+
new_pieces = []
|
344
|
+
old_to_new = {}
|
345
|
+
pending_notes = []
|
346
|
+
for old_idx, p in enumerate(pieces):
|
347
|
+
if p.cand != elim:
|
348
|
+
new_idx = len(new_pieces)
|
349
|
+
new_pieces.append(p)
|
350
|
+
old_to_new[old_idx] = new_idx
|
351
|
+
continue
|
352
|
+
forwards = _move_piece_forward(p, continuing)
|
353
|
+
if not forwards:
|
354
|
+
continue
|
355
|
+
opened = set()
|
356
|
+
share = p.weight / float(len(forwards))
|
357
|
+
for nxt_c, _, new_idx in forwards:
|
358
|
+
if nxt_c not in opened:
|
359
|
+
parcels.start_new_parcel(nxt_c)
|
360
|
+
opened.add(nxt_c)
|
361
|
+
created_idx = len(new_pieces)
|
362
|
+
new_pieces.append(p.clone_to(nxt_c, new_idx, share))
|
363
|
+
pending_notes.append((nxt_c, created_idx))
|
364
|
+
p.weight = 0.0
|
365
|
+
parcels.remap_indices(old_to_new)
|
366
|
+
for cand, idx in pending_notes:
|
367
|
+
parcels.note_arrival(cand, idx)
|
368
|
+
return elim, new_pieces
|
369
|
+
|
370
|
+
def _nb_quota(total_weight, num_seats):
|
371
|
+
return total_weight / float(num_seats + 1)
|
372
|
+
|
373
|
+
def _droop_int_quota(total_weight, num_seats):
|
374
|
+
"""Integer Droop quota used in Scottish STV (SSI 2007/42, Rule 46)."""
|
375
|
+
if num_seats <= 0:
|
376
|
+
return math.inf
|
377
|
+
return math.floor(total_weight / float(num_seats + 1)) + 1
|
378
|
+
|
379
|
+
# ---------- Public STV variants ----------
|
380
|
+
|
381
|
+
@vm(name="STV-Scottish", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
382
|
+
def stv_scottish(profile, num_seats=2, curr_cands=None, decimals=5, rng=None):
|
383
|
+
"""
|
384
|
+
Scottish STV per SSI 2007/42 (https://www.legislation.gov.uk/ssi/2007/42):
|
385
|
+
• Rule 46: Integer Droop quota q = floor(N/(k+1)) + 1
|
386
|
+
• Rule 48(3): per‑ballot transfer = truncate[(surplus × ballot value when received) / total].
|
387
|
+
• Rule 49: If multiple surpluses, transfer largest first; if equal, use *history tie‑break*,
|
388
|
+
else decide by lot.
|
389
|
+
• Rule 50: Exclusions transfer at current transfer value.
|
390
|
+
• Rule 51: Exclusion ties resolved by *history tie‑break*, else decide by lot.
|
391
|
+
• Rule 52: If continuing == vacancies remaining, elect them all; no further transfers.
|
392
|
+
|
393
|
+
Ballot ties (not allowed in actual Scottish STV) are supported by equal splitting of weight.
|
394
|
+
|
395
|
+
Args:
|
396
|
+
profile : Profile or ProfileWithTies
|
397
|
+
num_seats : int
|
398
|
+
curr_cands : iterable or None
|
399
|
+
decimals : int
|
400
|
+
Truncation precision for Rule 48(3). Default 5.
|
401
|
+
rng : random.Random-like or None
|
402
|
+
Source of randomness for “by lot” decisions. If None, uses Python's `random` module.
|
403
|
+
|
404
|
+
Returns:
|
405
|
+
list: Elected candidates (sorted by name for determinism).
|
406
|
+
|
407
|
+
.. warning::
|
408
|
+
STV implementations have not yet been thoroughly vetted.
|
409
|
+
"""
|
410
|
+
if isinstance(profile, Profile):
|
411
|
+
profile = profile.to_profile_with_ties()
|
412
|
+
|
413
|
+
rand = rng if rng is not None else random
|
414
|
+
|
415
|
+
# helpers
|
416
|
+
|
417
|
+
def droop_int_quota(total_weight, k):
|
418
|
+
if k <= 0:
|
419
|
+
return math.inf
|
420
|
+
return math.floor(total_weight / float(k + 1)) + 1
|
421
|
+
|
422
|
+
def snapshot():
|
423
|
+
"""Record end-of-stage totals for *all* candidates currently carrying weight."""
|
424
|
+
history.append(_tally_from_pieces(pieces))
|
425
|
+
|
426
|
+
def history_prefer(cands, prefer="highest"):
|
427
|
+
"""
|
428
|
+
Apply the statute's 'most recent preceding stage where unequal' rule.
|
429
|
+
Returns a single candidate or None if still tied across all previous stages.
|
430
|
+
"""
|
431
|
+
tied = list(cands)
|
432
|
+
# Walk backward over completed stages (the current moment is *after* the last snapshot)
|
433
|
+
for snap in reversed(history):
|
434
|
+
vals = [(c, snap.get(c, 0.0)) for c in tied]
|
435
|
+
if not vals:
|
436
|
+
break
|
437
|
+
if prefer == "highest":
|
438
|
+
extreme = max(v for _, v in vals)
|
439
|
+
narrowed = [c for c, v in vals if abs(v - extreme) <= EPS]
|
440
|
+
else: # prefer == "lowest"
|
441
|
+
extreme = min(v for _, v in vals)
|
442
|
+
narrowed = [c for c, v in vals if abs(v - extreme) <= EPS]
|
443
|
+
# If we strictly narrowed the field, keep going (maybe down to a singleton)
|
444
|
+
if 0 < len(narrowed) < len(tied):
|
445
|
+
tied = narrowed
|
446
|
+
if len(tied) == 1:
|
447
|
+
return tied[0]
|
448
|
+
# else: all equal at this snapshot; look further back
|
449
|
+
return None # equal at all earlier stages
|
450
|
+
|
451
|
+
def eliminate_and_transfer(elim, continuing, parcels):
|
452
|
+
"""Eliminate `elim` and push their pieces forward at current values."""
|
453
|
+
new_pieces = []
|
454
|
+
old_to_new = {}
|
455
|
+
pending_notes = []
|
456
|
+
for old_idx, p in enumerate(pieces):
|
457
|
+
if p.cand != elim:
|
458
|
+
new_idx = len(new_pieces)
|
459
|
+
new_pieces.append(p)
|
460
|
+
old_to_new[old_idx] = new_idx
|
461
|
+
continue
|
462
|
+
forwards = _move_piece_forward(p, continuing)
|
463
|
+
if not forwards:
|
464
|
+
continue
|
465
|
+
opened = set()
|
466
|
+
share = p.weight / float(len(forwards))
|
467
|
+
for nxt_c, _, new_rank in forwards:
|
468
|
+
if nxt_c not in opened:
|
469
|
+
parcels.start_new_parcel(nxt_c)
|
470
|
+
opened.add(nxt_c)
|
471
|
+
created_idx = len(new_pieces)
|
472
|
+
new_pieces.append(p.clone_to(nxt_c, new_rank, share))
|
473
|
+
pending_notes.append((nxt_c, created_idx))
|
474
|
+
p.weight = 0.0
|
475
|
+
parcels.remap_indices(old_to_new)
|
476
|
+
for cand, idx in pending_notes:
|
477
|
+
parcels.note_arrival(cand, idx)
|
478
|
+
return new_pieces
|
479
|
+
|
480
|
+
# set up
|
481
|
+
continuing = set(profile.candidates) if curr_cands is None else set(curr_cands)
|
482
|
+
winners = []
|
483
|
+
parcels = ParcelIndex()
|
484
|
+
pieces = _initial_pieces_from_profile(profile, continuing, parcels)
|
485
|
+
|
486
|
+
# constant quota for the whole count (Rule 46)
|
487
|
+
_, rcounts = profile.rankings_counts
|
488
|
+
total_votes = sum(float(c) for c in rcounts)
|
489
|
+
quota = droop_int_quota(total_votes, num_seats)
|
490
|
+
|
491
|
+
# History of end-of-stage totals (for Rules 49 & 51)
|
492
|
+
history = []
|
493
|
+
snapshot() # First stage: after initial allocation of first preferences
|
494
|
+
|
495
|
+
# main count
|
496
|
+
safety = 0
|
497
|
+
while len(winners) < num_seats:
|
498
|
+
safety += 1
|
499
|
+
if safety > 50000:
|
500
|
+
raise RuntimeError("stv_scottish: loop safety tripped – no progress")
|
501
|
+
|
502
|
+
# Current totals among continuing
|
503
|
+
tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
|
504
|
+
|
505
|
+
# Elect anyone at/above quota
|
506
|
+
elected_now = [c for c in list(continuing) if tallies_c.get(c, 0.0) >= quota - EPS]
|
507
|
+
if elected_now:
|
508
|
+
# Mark as elected (they stop being 'continuing')
|
509
|
+
for c in sorted(elected_now, key=lambda x: str(x)):
|
510
|
+
continuing.remove(c)
|
511
|
+
winners.append(c)
|
512
|
+
|
513
|
+
# Rule 49: transfer surpluses one at a time, always the largest *among all elected*
|
514
|
+
# candidates who currently exceed quota (ties by history, else lot).
|
515
|
+
stuck = set()
|
516
|
+
while True:
|
517
|
+
tall_now = _tally_from_pieces(pieces) # include everyone carrying weight
|
518
|
+
# Anyone already elected whose current total still exceeds quota?
|
519
|
+
elig = [c for c in winners if tall_now.get(c, 0.0) - float(quota) > EPS and c not in stuck]
|
520
|
+
if not elig:
|
521
|
+
break
|
522
|
+
|
523
|
+
# Pick the largest surplus; if tied, apply the statute’s history tie-break; else decide by lot.
|
524
|
+
surpluses = {c: tall_now[c] - float(quota) for c in elig}
|
525
|
+
max_s = max(surpluses.values())
|
526
|
+
tied = [c for c in elig if abs(surpluses[c] - max_s) <= EPS]
|
527
|
+
if len(tied) > 1:
|
528
|
+
chosen = history_prefer(tied, prefer="highest")
|
529
|
+
if chosen is None:
|
530
|
+
chosen = rand.choice(tied)
|
531
|
+
else:
|
532
|
+
chosen = tied[0]
|
533
|
+
|
534
|
+
moved = _transfer_surplus_scottish(
|
535
|
+
pieces, chosen, float(quota),
|
536
|
+
recipients=continuing, parcels=parcels, decimals=decimals
|
537
|
+
)
|
538
|
+
if moved:
|
539
|
+
snapshot() # each successful surplus transfer creates a new "stage" for the Rule 49/51 history
|
540
|
+
else:
|
541
|
+
# nothing to move (no next prefs etc.); don't loop forever on this candidate
|
542
|
+
stuck.add(chosen)
|
543
|
+
|
544
|
+
# After finishing the surpluses from this stage, check last‑vacancy rule.
|
545
|
+
if len(continuing) <= num_seats - len(winners):
|
546
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
547
|
+
break
|
548
|
+
|
549
|
+
continue # start a fresh stage
|
550
|
+
|
551
|
+
if not elected_now:
|
552
|
+
tall_now = _tally_from_pieces(pieces) # totals for everyone carrying weight
|
553
|
+
surplusers = [c for c in winners if tall_now.get(c, 0.0) - float(quota) > EPS]
|
554
|
+
|
555
|
+
if surplusers:
|
556
|
+
# Rule 49: pick the largest surplus; tie by history, else lot
|
557
|
+
max_s = max(tall_now[c] - float(quota) for c in surplusers)
|
558
|
+
tied = [c for c in surplusers if abs((tall_now[c]-float(quota)) - max_s) <= EPS]
|
559
|
+
if len(tied) > 1:
|
560
|
+
chosen = history_prefer(tied, prefer="highest") or rand.choice(tied)
|
561
|
+
else:
|
562
|
+
chosen = tied[0]
|
563
|
+
|
564
|
+
moved = _transfer_surplus_scottish(
|
565
|
+
pieces, chosen, float(quota),
|
566
|
+
recipients=continuing, parcels=parcels, decimals=decimals
|
567
|
+
)
|
568
|
+
if moved:
|
569
|
+
snapshot() # each successful surplus transfer is its own stage
|
570
|
+
continue # try again before excluding anyone
|
571
|
+
|
572
|
+
# No one newly elected → consider last vacancies (Rule 52)
|
573
|
+
if len(continuing) <= num_seats - len(winners):
|
574
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
575
|
+
break
|
576
|
+
|
577
|
+
# Exclude the current lowest (Rule 50) with Rule 51 history tie-break
|
578
|
+
tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
|
579
|
+
min_t = min(tallies_c.get(c, 0.0) for c in continuing)
|
580
|
+
lowest = [c for c in continuing if abs(tallies_c.get(c, 0.0) - min_t) <= EPS]
|
581
|
+
|
582
|
+
if len(lowest) > 1:
|
583
|
+
elim = history_prefer(lowest, prefer="lowest")
|
584
|
+
if elim is None:
|
585
|
+
elim = rand.choice(lowest) # decide by lot if tied at all previous stages
|
586
|
+
else:
|
587
|
+
elim = lowest[0]
|
588
|
+
|
589
|
+
continuing.remove(elim)
|
590
|
+
pieces = eliminate_and_transfer(elim, continuing, parcels)
|
591
|
+
snapshot() # each exclusion is a new stage
|
592
|
+
|
593
|
+
return sorted(winners, key=lambda x: str(x))
|
594
|
+
|
595
|
+
@vm(name="STV-NB", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
596
|
+
def stv_nb(profile, num_seats = 2, curr_cands=None, quota_rule="nb", mann_strict=False, drain_all=False, tie_break_key=None, *, ers_rounding=False):
|
597
|
+
"""
|
598
|
+
Single Transferable Vote — Newland–Britton (ERS) surplus rule (“NB”) with rational Droop quota.
|
599
|
+
|
600
|
+
Summary
|
601
|
+
-------
|
602
|
+
Uses the NB (rational Droop) quota n/(k+1) and the ERS/Newland–Britton *compensation* rule.
|
603
|
+
When a candidate exceeds quota, only ballot pieces that can transfer (i.e., have a next
|
604
|
+
available preference among continuing candidates) are drained; pieces that cannot transfer
|
605
|
+
are left untouched. The drain fraction α is chosen so the total drained from transferable
|
606
|
+
pieces equals the surplus, with α ≤ 1 per piece. This offsets non‑transferables rather than
|
607
|
+
letting surplus “disappear.” If many ballots are non‑transferable, an elected candidate may
|
608
|
+
remain above quota after the surplus step. (In contrast, WIG drains the same fraction from
|
609
|
+
*all* pieces, including those that cannot move; Meek lowers the effective quota via keep
|
610
|
+
factors.)
|
611
|
+
|
612
|
+
Counting details
|
613
|
+
----------------
|
614
|
+
• Quota: NB (rational Droop) quota = total_weight / (seats + 1).
|
615
|
+
If ers_rounding=True, quota is rounded up (to integer if >100, else to hundredth) per ERS practice.
|
616
|
+
Optional "Mann strictness" (mann_strict=True) requires strictly more than the NB quota.
|
617
|
+
• Surpluses: one at a time, largest surplus first among newly elected.
|
618
|
+
• Recipients: transfers go only to continuing (unelected) candidates.
|
619
|
+
• If no surplus: eliminate the current lowest and transfer at current weights; tie broken by
|
620
|
+
`tie_break_key`.
|
621
|
+
• Ballot ties: ties on ballots are supported by equal splitting of weight.
|
622
|
+
• Last vacancies: if continuing == seats_remaining, elect them all.
|
623
|
+
|
624
|
+
References: Tideman ("The Single Transferable Vote", 1995) on ERS compensation vs Meek; and
|
625
|
+
Tideman & Richardson ("Better voting methods through technology: The refinement-manageability trade-off in the single transferable vote", 2000).
|
626
|
+
|
627
|
+
Args:
|
628
|
+
profile: A Profile or ProfileWithTies object containing voter rankings
|
629
|
+
num_seats (int): Number of seats to fill
|
630
|
+
curr_cands: List of candidates to consider, defaults to all candidates in profile
|
631
|
+
quota_rule (str): Quota calculation rule, defaults to "nb" (rational Droop)
|
632
|
+
mann_strict (bool): Whether to use strict Mann-style elimination, defaults to False
|
633
|
+
drain_all (bool): Whether to drain all ballots, defaults to False
|
634
|
+
tie_break_key: Function for tie-breaking, defaults to None
|
635
|
+
ers_rounding: If True, use ERS manual count rounding as described by Tideman and Richardson (2000): quota rounded up (to integer if >100,
|
636
|
+
else to hundredth) and transfer values rounded down to hundredth. If False, defaults to rational Droop.
|
637
|
+
|
638
|
+
Returns:
|
639
|
+
list: List of elected candidates
|
640
|
+
|
641
|
+
.. warning::
|
642
|
+
STV implementations have not yet been thoroughly vetted.
|
643
|
+
"""
|
644
|
+
if isinstance(profile, Profile):
|
645
|
+
profile = profile.to_profile_with_ties()
|
646
|
+
|
647
|
+
candidates_list = list(profile.candidates) if curr_cands is None else curr_cands
|
648
|
+
continuing = set(candidates_list)
|
649
|
+
winners = []
|
650
|
+
parcels = ParcelIndex()
|
651
|
+
pieces = _initial_pieces_from_profile(profile, continuing, parcels)
|
652
|
+
|
653
|
+
# Calculate total weight from profile
|
654
|
+
rankings, rcounts = profile.rankings_counts
|
655
|
+
total_weight = sum(float(count) for count in rcounts)
|
656
|
+
if total_weight <= EPS or not continuing or num_seats <= 0:
|
657
|
+
return []
|
658
|
+
|
659
|
+
rule = (quota_rule or "nb").lower()
|
660
|
+
if rule == "nb":
|
661
|
+
raw_quota = _nb_quota(total_weight, num_seats) # rational Droop
|
662
|
+
if ers_rounding:
|
663
|
+
# ERS practice: round quota up (to integer if >100, else to hundredth)
|
664
|
+
if raw_quota > 100.0:
|
665
|
+
quota = math.ceil(raw_quota)
|
666
|
+
else:
|
667
|
+
quota = math.ceil(raw_quota * 100.0) / 100.0
|
668
|
+
else:
|
669
|
+
quota = raw_quota
|
670
|
+
elif rule == "droop":
|
671
|
+
quota = _droop_int_quota(total_weight, num_seats) # integer Droop
|
672
|
+
else:
|
673
|
+
raise ValueError(f'Unknown quota_rule "{quota_rule}". Use "nb" or "droop".')
|
674
|
+
|
675
|
+
safety = 0
|
676
|
+
while len(winners) < num_seats:
|
677
|
+
safety += 1
|
678
|
+
if safety > 20000:
|
679
|
+
raise RuntimeError("stv_nb: loop safety tripped – no progress")
|
680
|
+
|
681
|
+
tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
|
682
|
+
elected_now = [c for c in list(continuing)
|
683
|
+
if (tallies_c.get(c, 0.0) > quota + EPS if mann_strict
|
684
|
+
else tallies_c.get(c, 0.0) >= quota - EPS)]
|
685
|
+
if elected_now:
|
686
|
+
for c in sorted(elected_now, key=lambda x: str(x)):
|
687
|
+
continuing.remove(c)
|
688
|
+
winners.append(c)
|
689
|
+
_t(f"Elected now: {elected_now}")
|
690
|
+
|
691
|
+
# Surplus transfers: recipients = continuing only
|
692
|
+
stuck = set()
|
693
|
+
while True:
|
694
|
+
tall_all = _tally_from_pieces(pieces, restrict_to=set(continuing) | set(elected_now))
|
695
|
+
surplusers = [c for c in elected_now if tall_all.get(c, 0.0) - quota > EPS and c not in stuck]
|
696
|
+
if not surplusers:
|
697
|
+
break
|
698
|
+
elect = max(surplusers, key=lambda c: (tall_all.get(c, 0.0) - quota, str(c)))
|
699
|
+
moved = _transfer_surplus_inclusive(
|
700
|
+
pieces, elect, quota, recipients=continuing, parcels=parcels,
|
701
|
+
drain_all=drain_all, last_parcel_only=False, ers_rounding=ers_rounding
|
702
|
+
)
|
703
|
+
_t(f"Transfer surplus from {elect}: moved={moved}")
|
704
|
+
if not moved:
|
705
|
+
stuck.add(elect)
|
706
|
+
|
707
|
+
if len(continuing) <= num_seats - len(winners):
|
708
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
709
|
+
break
|
710
|
+
|
711
|
+
continue
|
712
|
+
|
713
|
+
if len(continuing) <= num_seats - len(winners):
|
714
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
715
|
+
break
|
716
|
+
|
717
|
+
elim, new_pieces = _eliminate_lowest(pieces, continuing, parcels, tie_break_key=tie_break_key)
|
718
|
+
if elim is None:
|
719
|
+
break
|
720
|
+
_t(f"Eliminate: {elim}")
|
721
|
+
pieces = new_pieces
|
722
|
+
|
723
|
+
return sorted(winners, key=lambda x: str(x))
|
724
|
+
|
725
|
+
@vm(name="STV-WIG", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
726
|
+
def stv_wig(profile, num_seats = 2, curr_cands=None, quota_rule="nb", drain_all=True, tie_break_key=None):
|
727
|
+
"""
|
728
|
+
STV with **Weighted Inclusive Gregory** (WIG) surplus transfers.
|
729
|
+
|
730
|
+
Surpluses: drain the same fraction from every ballot in a winner’s pile; forward to next
|
731
|
+
available continuing choices (exhaust otherwise); only surpluses of candidates elected in this stage are processed; previously elected winners are not revisited later. Elimination transfers at current weights. Transfers are exact (no ERS‑style rounding).
|
732
|
+
|
733
|
+
Ballot ties are supported by equal splitting of weight.
|
734
|
+
|
735
|
+
Quota options:
|
736
|
+
• quota_rule="nb" → rational Droop: total_weight / (seats + 1)
|
737
|
+
• quota_rule="droop" → integer Droop: floor(total_weight / (seats + 1)) + 1
|
738
|
+
|
739
|
+
Note: WIG + Droop is common in public counts that use inclusive Gregory.
|
740
|
+
|
741
|
+
References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
|
742
|
+
refinement-manageability trade-off in the single transferable vote", 2000).
|
743
|
+
|
744
|
+
Args:
|
745
|
+
profile: A Profile or ProfileWithTies object containing voter rankings
|
746
|
+
num_seats (int): Number of seats to fill
|
747
|
+
curr_cands: List of candidates to consider, defaults to all candidates in profile
|
748
|
+
quota_rule (str): Quota calculation rule, defaults to "nb" (rational Droop)
|
749
|
+
tie_break_key: Function for tie-breaking, defaults to None
|
750
|
+
|
751
|
+
Returns:
|
752
|
+
list: List of elected candidates
|
753
|
+
|
754
|
+
.. warning::
|
755
|
+
STV implementations have not yet been thoroughly vetted.
|
756
|
+
"""
|
757
|
+
if isinstance(profile, Profile):
|
758
|
+
profile = profile.to_profile_with_ties()
|
759
|
+
|
760
|
+
candidates_list = list(profile.candidates) if curr_cands is None else list(curr_cands)
|
761
|
+
continuing = set(candidates_list)
|
762
|
+
winners = []
|
763
|
+
parcels = ParcelIndex()
|
764
|
+
pieces = _initial_pieces_from_profile(profile, continuing, parcels)
|
765
|
+
|
766
|
+
if num_seats <= 0 or not continuing:
|
767
|
+
return []
|
768
|
+
|
769
|
+
# Compute a constant quota for the count
|
770
|
+
rankings, rcounts = profile.rankings_counts
|
771
|
+
total_weight = sum(float(count) for count in rcounts)
|
772
|
+
|
773
|
+
rule = (quota_rule or "nb").lower()
|
774
|
+
if rule == "nb":
|
775
|
+
quota = _nb_quota(total_weight, num_seats)
|
776
|
+
elif rule == "droop":
|
777
|
+
quota = _droop_int_quota(total_weight, num_seats)
|
778
|
+
else:
|
779
|
+
raise ValueError(f'Unknown quota_rule "{quota_rule}". Use "nb" or "droop".')
|
780
|
+
|
781
|
+
safety = 0
|
782
|
+
while len(winners) < num_seats:
|
783
|
+
safety += 1
|
784
|
+
if safety > 20000:
|
785
|
+
raise RuntimeError("stv_wig: loop safety tripped – no progress")
|
786
|
+
|
787
|
+
tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
|
788
|
+
|
789
|
+
# Elect everyone at/above quota
|
790
|
+
elected_now = [c for c in list(continuing) if tallies_c.get(c, 0.0) >= quota - EPS]
|
791
|
+
if elected_now:
|
792
|
+
for c in sorted(elected_now, key=lambda x: str(x)):
|
793
|
+
continuing.remove(c)
|
794
|
+
winners.append(c)
|
795
|
+
|
796
|
+
# Transfer surpluses (largest surplus first), WIG (drain_all=True)
|
797
|
+
stuck = set()
|
798
|
+
while True:
|
799
|
+
tall_all = _tally_from_pieces(pieces, restrict_to=set(continuing) | set(elected_now))
|
800
|
+
surplusers = [c for c in elected_now if tall_all.get(c, 0.0) - quota > EPS and c not in stuck]
|
801
|
+
if not surplusers:
|
802
|
+
break
|
803
|
+
elect = max(surplusers, key=lambda c: (tall_all.get(c, 0.0) - quota, str(c)))
|
804
|
+
moved = _transfer_surplus_inclusive(
|
805
|
+
pieces, elect, quota, recipients=continuing, parcels=parcels,
|
806
|
+
drain_all=True, last_parcel_only=False
|
807
|
+
)
|
808
|
+
if not moved:
|
809
|
+
stuck.add(elect)
|
810
|
+
|
811
|
+
# If remaining candidates equal remaining seats, elect them all
|
812
|
+
if len(continuing) <= num_seats - len(winners):
|
813
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
814
|
+
break
|
815
|
+
|
816
|
+
continue
|
817
|
+
|
818
|
+
# No election this round — eliminate the lowest and redistribute
|
819
|
+
if len(continuing) <= num_seats - len(winners):
|
820
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
821
|
+
break
|
822
|
+
|
823
|
+
elim, pieces = _eliminate_lowest(pieces, continuing, parcels, tie_break_key=tie_break_key)
|
824
|
+
if elim is None:
|
825
|
+
break
|
826
|
+
|
827
|
+
return sorted(winners, key=lambda x: str(x))
|
828
|
+
|
829
|
+
@vm(name="STV-Last-Parcel", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
830
|
+
def stv_last_parcel(profile, num_seats = 2, curr_cands=None, quota_rule="nb", tie_break_key=None):
|
831
|
+
"""
|
832
|
+
Single Transferable Vote using the "last parcel" or "senatorial" transfer rule.
|
833
|
+
|
834
|
+
This is a variant of STV where surplus transfers work differently from the standard method.
|
835
|
+
When a candidate has more votes than the quota (surplus), instead of transferring a proportion
|
836
|
+
of all their votes, only the most recent "parcel" (bundle) of votes that put them over the
|
837
|
+
quota is transferred. This simulates the practice used in some senatorial elections. Only surpluses
|
838
|
+
of candidates elected in this stage are processed; previously elected winners are not revisited later.
|
839
|
+
Only the NB (rational Droop) quota is implemented for this variant
|
840
|
+
|
841
|
+
The last parcel rule can produce different results than standard STV because it treats
|
842
|
+
different bundles of votes differently based on when they arrived at the candidate.
|
843
|
+
|
844
|
+
Ballot ties are supported by equal splitting of weight.
|
845
|
+
|
846
|
+
References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
|
847
|
+
refinement-manageability trade-off in the single transferable vote", 2000).
|
848
|
+
|
849
|
+
Args:
|
850
|
+
profile: A Profile or ProfileWithTies object containing voter rankings
|
851
|
+
num_seats (int): Number of seats to fill
|
852
|
+
curr_cands: List of candidates to consider, defaults to all candidates in profile
|
853
|
+
quota_rule (str): Quota calculation rule, defaults to "nb" (rational Droop)
|
854
|
+
tie_break_key: Function for tie-breaking, defaults to None
|
855
|
+
|
856
|
+
Returns:
|
857
|
+
list: List of elected candidates
|
858
|
+
|
859
|
+
.. warning::
|
860
|
+
STV implementations have not yet been thoroughly vetted.
|
861
|
+
"""
|
862
|
+
if isinstance(profile, Profile):
|
863
|
+
profile = profile.to_profile_with_ties()
|
864
|
+
|
865
|
+
candidates_list = list(profile.candidates) if curr_cands is None else curr_cands
|
866
|
+
continuing = set(candidates_list)
|
867
|
+
winners = []
|
868
|
+
parcels = ParcelIndex()
|
869
|
+
pieces = _initial_pieces_from_profile(profile, continuing, parcels)
|
870
|
+
|
871
|
+
# Calculate total weight from profile
|
872
|
+
rankings, rcounts = profile.rankings_counts
|
873
|
+
total_weight = sum(float(count) for count in rcounts)
|
874
|
+
if total_weight <= EPS or not continuing or num_seats <= 0:
|
875
|
+
return []
|
876
|
+
if quota_rule.lower() != "nb":
|
877
|
+
raise ValueError("Only NB quota is implemented.")
|
878
|
+
quota = _nb_quota(total_weight, num_seats)
|
879
|
+
|
880
|
+
safety = 0
|
881
|
+
while len(winners) < num_seats:
|
882
|
+
safety += 1
|
883
|
+
if safety > 20000:
|
884
|
+
raise RuntimeError("stv_last_parcel: loop safety tripped – no progress")
|
885
|
+
|
886
|
+
tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
|
887
|
+
elected_now = [c for c in list(continuing) if tallies_c.get(c, 0.0) >= quota - EPS]
|
888
|
+
if elected_now:
|
889
|
+
for c in sorted(elected_now, key=lambda x: str(x)):
|
890
|
+
continuing.remove(c)
|
891
|
+
winners.append(c)
|
892
|
+
_t(f"[LP] Elected now: {elected_now}")
|
893
|
+
|
894
|
+
for c in elected_now:
|
895
|
+
moved = _transfer_surplus_inclusive(
|
896
|
+
pieces, c, quota, recipients=continuing, parcels=parcels,
|
897
|
+
drain_all=True, last_parcel_only=True
|
898
|
+
)
|
899
|
+
_t(f"[LP] Transfer surplus (last parcel) from {c}: moved={moved}")
|
900
|
+
continue
|
901
|
+
|
902
|
+
if len(continuing) <= num_seats - len(winners):
|
903
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
904
|
+
break
|
905
|
+
|
906
|
+
elim, new_pieces = _eliminate_lowest(pieces, continuing, parcels, tie_break_key=tie_break_key)
|
907
|
+
if elim is None:
|
908
|
+
break
|
909
|
+
_t(f"[LP] Eliminate: {elim}")
|
910
|
+
pieces = new_pieces
|
911
|
+
|
912
|
+
return sorted(winners, key=lambda x: str(x))
|
913
|
+
|
914
|
+
# ---------- Meek STV ----------
|
915
|
+
|
916
|
+
def _meek_flow_one_ballot(tiers, keep, continuing):
|
917
|
+
remaining = 1.0
|
918
|
+
out = []
|
919
|
+
for tier in tiers:
|
920
|
+
avail = [c for c in tier if c in continuing]
|
921
|
+
if not avail:
|
922
|
+
continue
|
923
|
+
share = remaining / float(len(avail))
|
924
|
+
spilled = 0.0
|
925
|
+
for c in avail:
|
926
|
+
k = keep.get(c, 1.0)
|
927
|
+
kept = k * share
|
928
|
+
out.append((c, kept))
|
929
|
+
spilled += (1.0 - k) * share
|
930
|
+
remaining = spilled
|
931
|
+
if remaining <= EPS:
|
932
|
+
break
|
933
|
+
return out
|
934
|
+
|
935
|
+
def _meek_tally_from_profile(profile, keep, continuing):
|
936
|
+
"""Meek tally working directly with ProfileWithTies."""
|
937
|
+
t = collections.defaultdict(float)
|
938
|
+
rankings, rcounts = profile.rankings_counts
|
939
|
+
|
940
|
+
for ranking, count in zip(rankings, rcounts):
|
941
|
+
if count <= 0:
|
942
|
+
continue
|
943
|
+
rmap = ranking.rmap
|
944
|
+
by_rank = collections.defaultdict(list)
|
945
|
+
for c, r in rmap.items():
|
946
|
+
if r is not None:
|
947
|
+
by_rank[int(r)].append(c)
|
948
|
+
tiers = []
|
949
|
+
for r in sorted(by_rank):
|
950
|
+
tiers.append(sorted(by_rank[r], key=lambda x: str(x)))
|
951
|
+
|
952
|
+
if tiers:
|
953
|
+
for c, a in _meek_flow_one_ballot(tiers, keep, continuing):
|
954
|
+
t[c] += a * float(count)
|
955
|
+
return t
|
956
|
+
|
957
|
+
@vm(name="STV-Meek", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
958
|
+
def stv_meek(profile, num_seats = 2, curr_cands=None, tol=1e-10, max_iter=2000, tie_break_key=None):
|
959
|
+
"""
|
960
|
+
Meek Single Transferable Vote using retention factors for surplus handling.
|
961
|
+
|
962
|
+
Meek STV is an STV variant that uses "retention factors" to handle surplus transfers.
|
963
|
+
Instead of transferring physical ballot papers, each candidate has a "keep factor" that
|
964
|
+
determines what fraction of votes they retain. When a candidate is elected, their keep
|
965
|
+
factor is reduced so they only keep exactly the quota, and the remainder flows to next
|
966
|
+
preferences.
|
967
|
+
|
968
|
+
This method iteratively adjusts keep factors until the system reaches equilibrium.
|
969
|
+
Elected candidates remain "continuing" throughout the process with reduced keep factors.
|
970
|
+
At each iteration, quota = (active weight currently credited to continuing candidates) / (k+1);
|
971
|
+
if no further keep‑factor reductions bring all winners to quota, eliminate the current lowest and repeat.
|
972
|
+
|
973
|
+
Ballot ties are supported by equal splitting of weight.
|
974
|
+
|
975
|
+
References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
|
976
|
+
refinement-manageability trade-off in the single transferable vote", 2000).
|
977
|
+
|
978
|
+
Args:
|
979
|
+
profile: A Profile or ProfileWithTies object containing voter rankings
|
980
|
+
num_seats (int): Number of seats to fill
|
981
|
+
curr_cands: List of candidates to consider, defaults to all candidates in profile
|
982
|
+
tol (float): Tolerance for convergence, defaults to 1e-10
|
983
|
+
max_iter (int): Maximum number of iterations, defaults to 2000
|
984
|
+
tie_break_key: Function for tie-breaking, defaults to None
|
985
|
+
|
986
|
+
Returns:
|
987
|
+
list: List of elected candidates
|
988
|
+
|
989
|
+
.. warning::
|
990
|
+
STV implementations have not yet been thoroughly vetted.
|
991
|
+
"""
|
992
|
+
if isinstance(profile, Profile):
|
993
|
+
profile = profile.to_profile_with_ties()
|
994
|
+
|
995
|
+
candidates_list = list(profile.candidates) if curr_cands is None else curr_cands
|
996
|
+
continuing = set(candidates_list)
|
997
|
+
keep = {c: 1.0 for c in continuing}
|
998
|
+
|
999
|
+
# Calculate total weight from profile
|
1000
|
+
rankings, rcounts = profile.rankings_counts
|
1001
|
+
total_weight = sum(float(count) for count in rcounts)
|
1002
|
+
if total_weight <= EPS or not continuing or num_seats <= 0:
|
1003
|
+
return []
|
1004
|
+
|
1005
|
+
while len(continuing) > num_seats:
|
1006
|
+
# Adjust keep factors (winners remain continuing)
|
1007
|
+
for _ in range(max_iter):
|
1008
|
+
tallies = _meek_tally_from_profile(profile, keep, continuing)
|
1009
|
+
active_total = sum(tallies.get(c, 0.0) for c in continuing)
|
1010
|
+
quota = active_total / float(num_seats + 1) if active_total > EPS else 0.0
|
1011
|
+
|
1012
|
+
changed = False
|
1013
|
+
for c in list(continuing):
|
1014
|
+
t = tallies.get(c, 0.0)
|
1015
|
+
if t > quota + tol and keep.get(c, 1.0) > 0.0:
|
1016
|
+
# Standard Meek step: monotone, non‑increasing keep
|
1017
|
+
new_keep = min(keep[c], quota / t)
|
1018
|
+
if keep[c] - new_keep > tol:
|
1019
|
+
keep[c] = new_keep
|
1020
|
+
changed = True
|
1021
|
+
if not changed:
|
1022
|
+
break
|
1023
|
+
|
1024
|
+
# Eliminate the current lowest
|
1025
|
+
tallies = _meek_tally_from_profile(profile, keep, continuing)
|
1026
|
+
min_t = float('inf'); lowest = []
|
1027
|
+
for c in continuing:
|
1028
|
+
t = tallies.get(c, 0.0)
|
1029
|
+
if t < min_t - EPS:
|
1030
|
+
min_t = t; lowest = [c]
|
1031
|
+
elif abs(t - min_t) <= EPS:
|
1032
|
+
lowest.append(c)
|
1033
|
+
if len(lowest) > 1:
|
1034
|
+
key = tie_break_key or (lambda x: str(x))
|
1035
|
+
lowest.sort(key=key)
|
1036
|
+
elim = lowest[0]
|
1037
|
+
continuing.remove(elim)
|
1038
|
+
keep[elim] = 0.0
|
1039
|
+
_t(f"[Meek] Eliminate: {elim} (t={min_t:.6f})")
|
1040
|
+
|
1041
|
+
return sorted(list(continuing), key=lambda x: str(x))[:num_seats]
|
1042
|
+
|
1043
|
+
@vm(name="STV-Warren", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
1044
|
+
def stv_warren(profile, num_seats = 2, curr_cands=None, tol=1e-10, tie_break_key=None):
|
1045
|
+
"""
|
1046
|
+
Warren's equal‑price STV (Tideman 1995; Tideman & Richardson 2000):
|
1047
|
+
• In each stage, allocate each ballot to its current top among continuing (split ties).
|
1048
|
+
• While any candidate exceeds the (dynamic) quota, compute a uniform price p_c so that
|
1049
|
+
sum_b min(w_{b,c}, p_c) = quota; cap every ballot's contribution to c at p_c and push
|
1050
|
+
the released weight to the next available preference(s); iterate until no one > quota.
|
1051
|
+
• If no one is over quota and too many remain, eliminate the current lowest and repeat.
|
1052
|
+
• Quota in each stage = (active weight among continuing) / (k+1).
|
1053
|
+
|
1054
|
+
Args:
|
1055
|
+
profile: A Profile or ProfileWithTies object containing voter rankings
|
1056
|
+
num_seats (int): Number of seats to fill
|
1057
|
+
curr_cands: List of candidates to consider, defaults to all candidates in profile
|
1058
|
+
tol (float): Tolerance for convergence, defaults to 1e-10
|
1059
|
+
tie_break_key: Function for tie-breaking, defaults to None
|
1060
|
+
|
1061
|
+
References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
|
1062
|
+
refinement-manageability trade-off in the single transferable vote", 2000).
|
1063
|
+
|
1064
|
+
Returns:
|
1065
|
+
list: List of elected candidates
|
1066
|
+
|
1067
|
+
.. warning::
|
1068
|
+
STV implementations have not yet been thoroughly vetted.
|
1069
|
+
"""
|
1070
|
+
if isinstance(profile, Profile):
|
1071
|
+
profile = profile.to_profile_with_ties()
|
1072
|
+
continuing = list(profile.candidates) if curr_cands is None else list(curr_cands)
|
1073
|
+
rankings, rcounts = profile.rankings_counts
|
1074
|
+
|
1075
|
+
def topset(ranking, accept):
|
1076
|
+
rmap = ranking.rmap
|
1077
|
+
ranks = [r for c, r in rmap.items() if c in accept and r is not None]
|
1078
|
+
if not ranks: return []
|
1079
|
+
rmin = min(ranks)
|
1080
|
+
return [c for c, r in rmap.items() if c in accept and r == rmin]
|
1081
|
+
|
1082
|
+
def nextset(ranking, accept, exclude):
|
1083
|
+
rmap = ranking.rmap
|
1084
|
+
pool = [(c, r) for c, r in rmap.items() if c != exclude and c in accept and r is not None]
|
1085
|
+
if not pool: return []
|
1086
|
+
rmin = min(r for _, r in pool)
|
1087
|
+
return [c for c, r in pool if r == rmin]
|
1088
|
+
|
1089
|
+
while len(continuing) > num_seats:
|
1090
|
+
# (1) fresh per‑ballot allocations to current tops
|
1091
|
+
alloc = [collections.defaultdict(float) for _ in rankings]
|
1092
|
+
S = set(continuing)
|
1093
|
+
for i, ranking in enumerate(rankings):
|
1094
|
+
tops = topset(ranking, S)
|
1095
|
+
if tops:
|
1096
|
+
share = 1.0 / float(len(tops))
|
1097
|
+
for t in tops:
|
1098
|
+
alloc[i][t] += share
|
1099
|
+
|
1100
|
+
# (2) shrink over‑quota piles by equal price until none > quota
|
1101
|
+
max_equalize_iters = 2000
|
1102
|
+
iters = 0
|
1103
|
+
|
1104
|
+
while True:
|
1105
|
+
# recompute totals and the current quota
|
1106
|
+
totals = collections.defaultdict(float)
|
1107
|
+
for i, A in enumerate(alloc):
|
1108
|
+
m = float(rcounts[i])
|
1109
|
+
for c, per in A.items():
|
1110
|
+
totals[c] += per * m
|
1111
|
+
active_total = sum(totals.get(c, 0.0) for c in continuing)
|
1112
|
+
quota = active_total / float(num_seats + 1) if num_seats > 0 else float('inf')
|
1113
|
+
|
1114
|
+
over = [c for c in continuing if totals.get(c, 0.0) > quota + tol]
|
1115
|
+
if not over:
|
1116
|
+
break # all piles are at/below quota
|
1117
|
+
|
1118
|
+
iters += 1
|
1119
|
+
if iters > max_equalize_iters:
|
1120
|
+
# Safety valve: stop rather than silently under‑equalizing.
|
1121
|
+
break
|
1122
|
+
|
1123
|
+
changed = False
|
1124
|
+
|
1125
|
+
# Warren equal‑price: for each over‑quota candidate c, find p_c so
|
1126
|
+
# sum_i m_i * min(w_ic, p_c) = quota, where w_ic = per‑ballot share to c.
|
1127
|
+
for c in sorted(over, key=lambda x: str(x)):
|
1128
|
+
per_list = []
|
1129
|
+
for i, A in enumerate(alloc):
|
1130
|
+
w = A.get(c, 0.0)
|
1131
|
+
if w > EPS:
|
1132
|
+
per_list.append((w, float(rcounts[i])))
|
1133
|
+
if not per_list:
|
1134
|
+
continue
|
1135
|
+
|
1136
|
+
# bisection for p_c
|
1137
|
+
lo, hi = 0.0, max(w for w, _ in per_list)
|
1138
|
+
for _ in range(64):
|
1139
|
+
mid = 0.5 * (lo + hi)
|
1140
|
+
s = 0.0
|
1141
|
+
for w, m in per_list:
|
1142
|
+
s += m * (w if w < mid else mid)
|
1143
|
+
if s > quota:
|
1144
|
+
hi = mid
|
1145
|
+
else:
|
1146
|
+
lo = mid
|
1147
|
+
p_c = lo
|
1148
|
+
|
1149
|
+
# cap at p_c and push released weight forward (exhaust if no next)
|
1150
|
+
for i, ranking in enumerate(rankings):
|
1151
|
+
per = alloc[i].get(c, 0.0)
|
1152
|
+
if per <= EPS:
|
1153
|
+
continue
|
1154
|
+
new_per = min(per, p_c)
|
1155
|
+
delta_per = per - new_per
|
1156
|
+
if delta_per > tol:
|
1157
|
+
alloc[i][c] = new_per
|
1158
|
+
nxt = nextset(ranking, set(continuing), exclude=c)
|
1159
|
+
if nxt:
|
1160
|
+
share = delta_per / float(len(nxt)) # m cancels with later re‑weight
|
1161
|
+
for d in nxt:
|
1162
|
+
alloc[i][d] += share
|
1163
|
+
# else: delta exhausts, lowering active_total and the next quota
|
1164
|
+
changed = True
|
1165
|
+
|
1166
|
+
# If nothing changed numerically, we’re as close as floating‑point permits
|
1167
|
+
if not changed:
|
1168
|
+
break
|
1169
|
+
|
1170
|
+
# (3) eliminate lowest if too many remain
|
1171
|
+
totals = collections.defaultdict(float)
|
1172
|
+
for i, A in enumerate(alloc):
|
1173
|
+
m = float(rcounts[i])
|
1174
|
+
for c, per in A.items():
|
1175
|
+
totals[c] += per * m
|
1176
|
+
|
1177
|
+
if len(continuing) == num_seats:
|
1178
|
+
break
|
1179
|
+
|
1180
|
+
min_t = float('inf'); lowest = []
|
1181
|
+
for c in continuing:
|
1182
|
+
t = totals.get(c, 0.0)
|
1183
|
+
if t < min_t - EPS:
|
1184
|
+
min_t = t; lowest = [c]
|
1185
|
+
elif abs(t - min_t) <= EPS:
|
1186
|
+
lowest.append(c)
|
1187
|
+
if len(lowest) > 1:
|
1188
|
+
key = tie_break_key or (lambda x: str(x))
|
1189
|
+
lowest.sort(key=key)
|
1190
|
+
elim = lowest[0]
|
1191
|
+
continuing.remove(elim)
|
1192
|
+
|
1193
|
+
return sorted(list(continuing), key=lambda x: str(x))[:num_seats]
|
1194
|
+
|
1195
|
+
@vm(name="Approval-STV", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
1196
|
+
def approval_stv(profile, num_seats=2, curr_cands=None, quota_rule="droop",
|
1197
|
+
select_tiebreak=None, elim_tiebreak=None, rng=None):
|
1198
|
+
"""
|
1199
|
+
Approval‑STV (Delemazure & Peters 2024, https://arxiv.org/abs/2404.11407):
|
1200
|
+
|
1201
|
+
In each round, a ballot supports all candidates it ranks *top* among the remaining
|
1202
|
+
candidates. Let B_i be the remaining budget of ballot i (start at 1 per voter).
|
1203
|
+
Let q be the quota.
|
1204
|
+
|
1205
|
+
Loop until k winners:
|
1206
|
+
1) For every continuing candidate c, compute support S(c) = sum_{i: c in top_i} B_i.
|
1207
|
+
2) If some c has enough support (strictly > q for Droop; >= q for Hare):
|
1208
|
+
Elect such a c (by default the one with largest S); charge supporters exactly q
|
1209
|
+
in total by multiplying each supporter's budget by (S(c) - q)/S(c) (Gregory);
|
1210
|
+
remove c.
|
1211
|
+
3) Otherwise eliminate a candidate with the smallest S(c); remove it.
|
1212
|
+
|
1213
|
+
Quotas
|
1214
|
+
-------
|
1215
|
+
quota_rule="droop" → q = n / (k+1), elect if support > q
|
1216
|
+
quota_rule="droop_int" → q = floor(n / (k+1)) + 1, elect if support > q
|
1217
|
+
quota_rule="hare" → q = n / k, elect if support ≥ q
|
1218
|
+
|
1219
|
+
Notes
|
1220
|
+
-----
|
1221
|
+
• Matches the budget‑flow pseudocode in Fig. 12 (Approval‑STV) using Gregory charging.
|
1222
|
+
• Equals Approval‑IRV when k=1 with Hare quota (but not with Droop; see Remark 5.1).
|
1223
|
+
|
1224
|
+
Args:
|
1225
|
+
profile: A Profile or ProfileWithTies object containing voter rankings
|
1226
|
+
num_seats (int): Number of seats to fill
|
1227
|
+
curr_cands: List of candidates to consider, defaults to all candidates in profile
|
1228
|
+
quota_rule (str): Quota rule to use, defaults to "droop"
|
1229
|
+
select_tiebreak: Function for tie-breaking, defaults to None
|
1230
|
+
elim_tiebreak: Function for tie-breaking, defaults to None
|
1231
|
+
rng: Random number generator, defaults to None
|
1232
|
+
|
1233
|
+
Returns:
|
1234
|
+
list: List of elected candidates
|
1235
|
+
|
1236
|
+
.. warning::
|
1237
|
+
Approval‑STV implementation has not yet been thoroughly vetted.
|
1238
|
+
"""
|
1239
|
+
|
1240
|
+
if isinstance(profile, Profile):
|
1241
|
+
profile = profile.to_profile_with_ties()
|
1242
|
+
rankings, rcounts = profile.rankings_counts
|
1243
|
+
|
1244
|
+
continuing = list(profile.candidates) if curr_cands is None else [c for c in curr_cands if c in profile.candidates]
|
1245
|
+
winners = []
|
1246
|
+
|
1247
|
+
# Total number of voters (with multiplicities)
|
1248
|
+
n = float(sum(rcounts))
|
1249
|
+
if n <= EPS or num_seats <= 0 or not continuing:
|
1250
|
+
return []
|
1251
|
+
|
1252
|
+
# Select quota and election inequality
|
1253
|
+
if quota_rule == "droop":
|
1254
|
+
quota = n / float(num_seats + 1); strict = True
|
1255
|
+
elif quota_rule == "droop_int":
|
1256
|
+
quota = math.floor(n / float(num_seats + 1)) + 1; strict = True
|
1257
|
+
elif quota_rule == "hare":
|
1258
|
+
quota = n / float(num_seats); strict = False
|
1259
|
+
else:
|
1260
|
+
raise ValueError("quota_rule must be one of {'droop','droop_int','hare'}")
|
1261
|
+
|
1262
|
+
# Budgets are tracked per ranking type, scaled by multiplicity
|
1263
|
+
budgets = [float(c) for c in rcounts]
|
1264
|
+
|
1265
|
+
def topset(ranking, accept):
|
1266
|
+
rmap = ranking.rmap
|
1267
|
+
ranks = [r for c, r in rmap.items() if c in accept and r is not None]
|
1268
|
+
if not ranks:
|
1269
|
+
return []
|
1270
|
+
rmin = min(ranks)
|
1271
|
+
return [c for c, r in rmap.items() if c in accept and r == rmin]
|
1272
|
+
|
1273
|
+
def support_budgets(accept):
|
1274
|
+
S = collections.defaultdict(float)
|
1275
|
+
A = set(accept)
|
1276
|
+
for i, ranking in enumerate(rankings):
|
1277
|
+
b = budgets[i]
|
1278
|
+
if b <= EPS:
|
1279
|
+
continue
|
1280
|
+
tops = topset(ranking, A)
|
1281
|
+
for c in tops:
|
1282
|
+
S[c] += b
|
1283
|
+
for c in accept:
|
1284
|
+
S.setdefault(c, 0.0)
|
1285
|
+
return S
|
1286
|
+
|
1287
|
+
def electable(S):
|
1288
|
+
if strict:
|
1289
|
+
return [c for c, v in S.items() if v > quota + EPS]
|
1290
|
+
else:
|
1291
|
+
return [c for c, v in S.items() if v + EPS >= quota]
|
1292
|
+
|
1293
|
+
def charge_supporters(chosen, S, accept):
|
1294
|
+
"""Multiply each supporter's budget by a common factor so the total charge is exactly q."""
|
1295
|
+
total = S.get(chosen, 0.0)
|
1296
|
+
if total <= EPS:
|
1297
|
+
return
|
1298
|
+
factor = max(0.0, (total - quota) / total)
|
1299
|
+
A = set(accept)
|
1300
|
+
for i, ranking in enumerate(rankings):
|
1301
|
+
if budgets[i] <= EPS:
|
1302
|
+
continue
|
1303
|
+
if chosen in topset(ranking, A):
|
1304
|
+
budgets[i] *= factor
|
1305
|
+
|
1306
|
+
rand = rng or random.Random(0)
|
1307
|
+
|
1308
|
+
while len(winners) < num_seats and continuing:
|
1309
|
+
# Early finish: fill remaining seats if #continuing == seats_left
|
1310
|
+
if len(continuing) <= num_seats - len(winners):
|
1311
|
+
winners.extend(sorted(continuing, key=lambda x: str(x)))
|
1312
|
+
break
|
1313
|
+
|
1314
|
+
S = support_budgets(continuing)
|
1315
|
+
|
1316
|
+
# Elect if any candidate's supporters exceed the quota
|
1317
|
+
elig = electable(S)
|
1318
|
+
if elig:
|
1319
|
+
# default: pick largest support, deterministic by name if tied
|
1320
|
+
if select_tiebreak is None:
|
1321
|
+
maxv = max(S[c] for c in elig)
|
1322
|
+
tied = [c for c in elig if abs(S[c] - maxv) <= EPS]
|
1323
|
+
chosen = sorted(tied, key=lambda x: str(x))[0]
|
1324
|
+
else:
|
1325
|
+
best = max(select_tiebreak(c) for c in elig)
|
1326
|
+
tied = [c for c in elig if abs(select_tiebreak(c) - best) <= EPS]
|
1327
|
+
chosen = rand.choice(sorted(tied, key=lambda x: str(x)))
|
1328
|
+
winners.append(chosen)
|
1329
|
+
# supporters are with respect to the pre‑removal set (which includes `chosen`)
|
1330
|
+
charge_supporters(chosen, S, continuing + [chosen])
|
1331
|
+
continuing.remove(chosen)
|
1332
|
+
_t(f"[Approval‑STV] Elect {chosen}; winners: {winners}")
|
1333
|
+
continue
|
1334
|
+
|
1335
|
+
# Otherwise, eliminate a lowest‑supported candidate
|
1336
|
+
minv = min(S[c] for c in continuing)
|
1337
|
+
lowest = [c for c in continuing if abs(S[c] - minv) <= EPS]
|
1338
|
+
if len(lowest) > 1:
|
1339
|
+
if elim_tiebreak is None:
|
1340
|
+
elim = sorted(lowest, key=lambda x: str(x))[0]
|
1341
|
+
else:
|
1342
|
+
mink = min(elim_tiebreak(c) for c in lowest)
|
1343
|
+
tied = [c for c in lowest if abs(elim_tiebreak(c) - mink) <= EPS]
|
1344
|
+
elim = rand.choice(sorted(tied, key=lambda x: str(x)))
|
1345
|
+
else:
|
1346
|
+
elim = lowest[0]
|
1347
|
+
continuing.remove(elim)
|
1348
|
+
_t(f"[Approval‑STV] Eliminate {elim}")
|
1349
|
+
|
1350
|
+
return sorted(winners, key=lambda x: str(x))
|
1351
|
+
|
1352
|
+
# ---------- CPO-STV ----------
|
1353
|
+
|
1354
|
+
def _committee_margin_pwt(A, B, profile, inpair_surplus="meek"):
|
1355
|
+
"""
|
1356
|
+
Compute the pairwise margin (A over B) for CPO‑STV using a *pair‑specific* quota.
|
1357
|
+
Steps (Tideman 1995/Tideman & Richardson 2000):
|
1358
|
+
• Restrict to S = A ∪ B. Allocate each ballot to its top(s) in S (split ties equally).
|
1359
|
+
• Let I = A ∩ B. Transfer *only* surpluses of candidates in I until no I‑member
|
1360
|
+
exceeds the pair‑quota; do NOT transfer surpluses of candidates outside I.
|
1361
|
+
• Pair‑quota at each iteration = (usable weight inside S) / (k+1), where k = |A|.
|
1362
|
+
Weight that has no next preference within S exhausts, lowering the next quota.
|
1363
|
+
• inpair_surplus = "meek" (ratio shrink) or "warren" (equal‑price).
|
1364
|
+
Returns: float margin = sum(A) − sum(B).
|
1365
|
+
"""
|
1366
|
+
rankings, rcounts = profile.rankings_counts
|
1367
|
+
|
1368
|
+
def _topset_in_ranking(ranking, accept_set):
|
1369
|
+
rmap = ranking.rmap
|
1370
|
+
ranks = [r for c, r in rmap.items() if c in accept_set and r is not None]
|
1371
|
+
if not ranks:
|
1372
|
+
return []
|
1373
|
+
rmin = min(ranks)
|
1374
|
+
return [c for c, r in rmap.items() if c in accept_set and r == rmin]
|
1375
|
+
|
1376
|
+
def _nextset_in_ranking(ranking, accept_set, exclude):
|
1377
|
+
rmap = ranking.rmap
|
1378
|
+
pool = [(c, r) for c, r in rmap.items()
|
1379
|
+
if c != exclude and c in accept_set and r is not None]
|
1380
|
+
if not pool:
|
1381
|
+
return []
|
1382
|
+
rmin = min(r for _, r in pool)
|
1383
|
+
return [c for c, r in pool if r == rmin]
|
1384
|
+
|
1385
|
+
S = set(A) | set(B)
|
1386
|
+
I = set(A) & set(B)
|
1387
|
+
k = len(A)
|
1388
|
+
|
1389
|
+
# Per‑row allocations (already multiplied by row multiplicities)
|
1390
|
+
bal_alloc = [collections.defaultdict(float) for _ in rankings]
|
1391
|
+
for i, (ranking, m) in enumerate(zip(rankings, rcounts)):
|
1392
|
+
tops = _topset_in_ranking(ranking, S)
|
1393
|
+
if not tops:
|
1394
|
+
continue
|
1395
|
+
share = float(m) / float(len(tops))
|
1396
|
+
for t in tops:
|
1397
|
+
bal_alloc[i][t] += share
|
1398
|
+
|
1399
|
+
tol = 1e-12
|
1400
|
+
max_iters = 10000
|
1401
|
+
rule = (inpair_surplus or "meek").lower()
|
1402
|
+
|
1403
|
+
for _ in range(max_iters):
|
1404
|
+
# Recompute totals and the *pair‑specific* quota from current usable weight in S.
|
1405
|
+
totals = collections.defaultdict(float)
|
1406
|
+
for alloc in bal_alloc:
|
1407
|
+
for c, w in alloc.items():
|
1408
|
+
totals[c] += w
|
1409
|
+
usable = sum(totals.get(c, 0.0) for c in S)
|
1410
|
+
if k == 0 or usable <= tol:
|
1411
|
+
break
|
1412
|
+
quota = usable / float(k + 1)
|
1413
|
+
|
1414
|
+
changed = False
|
1415
|
+
for c in sorted(I, key=lambda x: str(x)):
|
1416
|
+
tc = totals.get(c, 0.0)
|
1417
|
+
excess = tc - quota
|
1418
|
+
if excess <= tol or tc <= tol:
|
1419
|
+
continue
|
1420
|
+
|
1421
|
+
if rule == "warren":
|
1422
|
+
# Equal‑price per Warren: choose p_c with Σ_i min(w_ic, p_c) = quota.
|
1423
|
+
w_list = []
|
1424
|
+
for i, alloc in enumerate(bal_alloc):
|
1425
|
+
w_c = alloc.get(c, 0.0)
|
1426
|
+
if w_c <= 0.0:
|
1427
|
+
continue
|
1428
|
+
m = float(rcounts[i])
|
1429
|
+
per = w_c / m
|
1430
|
+
w_list.append((per, m))
|
1431
|
+
if not w_list:
|
1432
|
+
continue
|
1433
|
+
lo, hi = 0.0, max(w for w, _ in w_list)
|
1434
|
+
for _ in range(64):
|
1435
|
+
mid = 0.5 * (lo + hi)
|
1436
|
+
s = 0.0
|
1437
|
+
for w, m in w_list:
|
1438
|
+
s += m * (w if w < mid else mid)
|
1439
|
+
if s > quota:
|
1440
|
+
hi = mid
|
1441
|
+
else:
|
1442
|
+
lo = mid
|
1443
|
+
p_c = lo
|
1444
|
+
|
1445
|
+
# Cap and push inside S; if no next in S, the delta exhausts.
|
1446
|
+
for i, ranking in enumerate(rankings):
|
1447
|
+
w_c = bal_alloc[i].get(c, 0.0)
|
1448
|
+
if w_c <= 0.0:
|
1449
|
+
continue
|
1450
|
+
m = float(rcounts[i])
|
1451
|
+
per = w_c / m
|
1452
|
+
new_per = min(per, p_c)
|
1453
|
+
delta_total = (per - new_per) * m
|
1454
|
+
if delta_total <= tol:
|
1455
|
+
continue
|
1456
|
+
bal_alloc[i][c] = new_per * m
|
1457
|
+
nxt = _nextset_in_ranking(ranking, S, exclude=c)
|
1458
|
+
if nxt:
|
1459
|
+
share = delta_total / float(len(nxt))
|
1460
|
+
for nx in nxt:
|
1461
|
+
bal_alloc[i][nx] = bal_alloc[i].get(nx, 0.0) + share
|
1462
|
+
changed = True
|
1463
|
+
|
1464
|
+
else:
|
1465
|
+
# Meek‑like ratio shrink: remove the same *fraction* from each piece for c.
|
1466
|
+
ratio = excess / tc
|
1467
|
+
for i, ranking in enumerate(rankings):
|
1468
|
+
w_c = bal_alloc[i].get(c, 0.0)
|
1469
|
+
if w_c <= 0.0:
|
1470
|
+
continue
|
1471
|
+
delta = w_c * ratio
|
1472
|
+
if delta <= tol:
|
1473
|
+
continue
|
1474
|
+
bal_alloc[i][c] = w_c - delta
|
1475
|
+
nxt = _nextset_in_ranking(ranking, S, exclude=c)
|
1476
|
+
if nxt:
|
1477
|
+
share = delta / float(len(nxt))
|
1478
|
+
for nx in nxt:
|
1479
|
+
bal_alloc[i][nx] = bal_alloc[i].get(nx, 0.0) + share
|
1480
|
+
changed = True
|
1481
|
+
|
1482
|
+
if not changed:
|
1483
|
+
break
|
1484
|
+
|
1485
|
+
# Final totals & margin
|
1486
|
+
totals = collections.defaultdict(float)
|
1487
|
+
for alloc in bal_alloc:
|
1488
|
+
for c, w in alloc.items():
|
1489
|
+
totals[c] += w
|
1490
|
+
score_A = sum(totals.get(c, 0.0) for c in A)
|
1491
|
+
score_B = sum(totals.get(c, 0.0) for c in B)
|
1492
|
+
return score_A - score_B
|
1493
|
+
|
1494
|
+
@vm(name="CPO-STV", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
|
1495
|
+
def cpo_stv(profile, num_seats = 2, curr_cands=None, inpair_surplus="meek", fallback_vm=minimax):
|
1496
|
+
"""
|
1497
|
+
CPO-STV (Comparison of Pairs of Outcomes) - a Condorcet-consistent proportional method.
|
1498
|
+
|
1499
|
+
Unlike traditional STV which eliminates candidates sequentially, CPO-STV considers all
|
1500
|
+
possible committees (combinations) of the required size and compares them pairwise.
|
1501
|
+
|
1502
|
+
For any two k‑member sets A and B, restrict each ballot to S = A ∪ B, allocate the
|
1503
|
+
ballot to its highest ranked available candidate in S, and then transfer **only**
|
1504
|
+
the surpluses of candidates in the intersection I = A ∩ B (never from candidates
|
1505
|
+
who appear in only one of the two compared sets). The margin of A vs. B is the sum of
|
1506
|
+
votes for A’s members minus the sum for B’s.
|
1507
|
+
|
1508
|
+
Within each A vs B comparison, the quota is q = U/(k+1), where k = |A| and
|
1509
|
+
U is the total weight currently credited to candidates in S
|
1510
|
+
(i.e., not yet exhausted relative to S). When, at the point of transfer,
|
1511
|
+
a ballot has no remaining ranked candidate in S, its remaining weight is
|
1512
|
+
treated as exhausted for this comparison, which reduces U on subsequent iterations.
|
1513
|
+
|
1514
|
+
The winning committee is the one that beats all other possible committees
|
1515
|
+
in these pairwise comparisons. This makes CPO-STV "Condorcet-consistent" - if there's
|
1516
|
+
a committee that is majority-preferred to every other committee, CPO-STV will find it.
|
1517
|
+
If there is no such Condorcet committee, then the fallback voting method is used to
|
1518
|
+
pick the winning committee based on the pairwise margins between committees.
|
1519
|
+
|
1520
|
+
This method is computationally intensive as it must examine C(candidates, seats) committees.
|
1521
|
+
|
1522
|
+
References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
|
1523
|
+
refinement-manageability trade-off in the single transferable vote", 2000).
|
1524
|
+
|
1525
|
+
Args:
|
1526
|
+
profile: A Profile or ProfileWithTies object containing voter rankings
|
1527
|
+
num_seats (int): Number of seats to fill
|
1528
|
+
curr_cands: List of candidates to consider, defaults to all candidates in profile
|
1529
|
+
inpair_surplus (str): Surplus handling method for pairwise comparisons, defaults to "meek"
|
1530
|
+
fallback_vm: Fallback voting method for tie-breaking, defaults to minimax
|
1531
|
+
|
1532
|
+
Returns:
|
1533
|
+
list: List of elected candidates forming the winning committee
|
1534
|
+
|
1535
|
+
.. warning::
|
1536
|
+
This implementation of CPO-STV has not yet been thoroughly vetted.
|
1537
|
+
"""
|
1538
|
+
if isinstance(profile, Profile):
|
1539
|
+
profile = profile.to_profile_with_ties()
|
1540
|
+
|
1541
|
+
curr_cands = list(profile.candidates) if curr_cands is None else curr_cands
|
1542
|
+
committees = list(itertools.combinations(curr_cands, num_seats))
|
1543
|
+
|
1544
|
+
if len(committees) <= 1:
|
1545
|
+
return list(committees[0]) if committees else []
|
1546
|
+
|
1547
|
+
# For efficiency, we first check for a Condorcet committee using an algorithm that does not require constructing the full margin graph.
|
1548
|
+
condorcet_committee_exists = True
|
1549
|
+
C = committees[0]
|
1550
|
+
for A in committees:
|
1551
|
+
if _committee_margin_pwt(A, C, profile, inpair_surplus=inpair_surplus) > 0:
|
1552
|
+
C = A
|
1553
|
+
|
1554
|
+
for B in committees:
|
1555
|
+
if C != B and not _committee_margin_pwt(C, B, profile, inpair_surplus=inpair_surplus) > 0:
|
1556
|
+
condorcet_committee_exists = False
|
1557
|
+
break
|
1558
|
+
|
1559
|
+
if condorcet_committee_exists:
|
1560
|
+
return list(C)
|
1561
|
+
|
1562
|
+
# If no Condorcet committee exists, we construct the full margin graph and use the fallback voting method to find the winning committee.
|
1563
|
+
weighted_edges = []
|
1564
|
+
for i, A in enumerate(committees):
|
1565
|
+
for B in committees[i+1:]:
|
1566
|
+
m = _committee_margin_pwt(A, B, profile, inpair_surplus=inpair_surplus)
|
1567
|
+
if m > 0:
|
1568
|
+
weighted_edges.append((A, B, m))
|
1569
|
+
elif m < 0:
|
1570
|
+
weighted_edges.append((B, A, abs(m)))
|
1571
|
+
|
1572
|
+
mg = MarginGraph(committees, weighted_edges)
|
1573
|
+
winners = fallback_vm(mg)
|
1574
|
+
|
1575
|
+
return sorted(random.choice(winners))
|