root / stv.py @ 50eaf441
History | View | Annotate | Download (17.5 kB)
1 |
# Copyright 2011 GRNET S.A. All rights reserved.
|
---|---|
2 |
#
|
3 |
# Redistribution and use in source and binary forms, with or without
|
4 |
# modification, are permitted provided that the following conditions
|
5 |
# are met:
|
6 |
#
|
7 |
# 1. Redistributions of source code must retain the above copyright
|
8 |
# notice, this list of conditions and the following disclaimer.
|
9 |
#
|
10 |
# 2. Redistributions in binary form must reproduce the above
|
11 |
# copyright notice, this list of conditions and the following
|
12 |
# disclaimer in the documentation and/or other materials provided
|
13 |
# with the distribution.
|
14 |
#
|
15 |
# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS''
|
16 |
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
17 |
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
18 |
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR
|
19 |
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
20 |
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
21 |
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
|
22 |
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
23 |
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
24 |
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
|
25 |
# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
26 |
# SUCH DAMAGE.
|
27 |
#
|
28 |
# The views and conclusions contained in the software and
|
29 |
# documentation are those of the authors and should not be interpreted
|
30 |
# as representing official policies, either expressed or implied, of
|
31 |
# GRNET S.A.
|
32 |
|
33 |
from operator import mul, itemgetter |
34 |
from random import random, seed |
35 |
import logging |
36 |
import sys |
37 |
import math |
38 |
import csv |
39 |
import argparse |
40 |
|
41 |
SVT_LOGGER = 'SVT'
|
42 |
LOGGER_FORMAT = '%(message)s'
|
43 |
LOG_MESSAGE = "{action} {desc}"
|
44 |
|
45 |
class Action: |
46 |
COUNT_ROUND = "@ROUND"
|
47 |
TRANSFER = ">TRANSFER"
|
48 |
ELIMINATE = "-ELIMINATE"
|
49 |
QUOTA ="!QUOTA"
|
50 |
ELECT = "+ELECT"
|
51 |
COUNT = ".COUNT"
|
52 |
ZOMBIES = "~ZOMBIES"
|
53 |
RANDOM = "*RANDOM"
|
54 |
THRESHOLD = "^THRESHOLD"
|
55 |
|
56 |
class Ballot: |
57 |
"""A ballot class for Single Transferable Voting.
|
58 |
|
59 |
The ballot class contains an ordered list of candidates (in
|
60 |
decreasing order of preference) and an ordered list of weights
|
61 |
(new weights are added to the front of the list). The index of the
|
62 |
current preference (for the first count and subsequent rounds)
|
63 |
is also kept.
|
64 |
|
65 |
"""
|
66 |
|
67 |
candidates = [] |
68 |
weights = [1.0]
|
69 |
current_preference = 0
|
70 |
_value = 1.0
|
71 |
|
72 |
def __init__(self, candidates=[]): |
73 |
self.candidates = candidates
|
74 |
|
75 |
def add_weight(self, weight): |
76 |
self.weights.insert(0, weight) |
77 |
self._value *= weight
|
78 |
|
79 |
def get_value(self): |
80 |
return self._value |
81 |
|
82 |
def randomly_select_first(sequence, key, action, random_generator=None): |
83 |
"""Selects the first item of equals in a sorted sequence of items.
|
84 |
|
85 |
For the given sorted sequence, returns the first item if it
|
86 |
is different than the second; if there are ties so that there
|
87 |
are items with equal values, it randomly selects among those items.
|
88 |
The value of each item in the sequence is provided by applying the
|
89 |
function key to the item. The action parameter indicates the context
|
90 |
in which the random selection takes place (election or elimination).
|
91 |
random_generator, if given, is the function that produces the random
|
92 |
selection.
|
93 |
|
94 |
"""
|
95 |
|
96 |
first_value = key(sequence[0])
|
97 |
collected = [] |
98 |
for item in sequence: |
99 |
if key(item) == first_value:
|
100 |
collected.append(item) |
101 |
else:
|
102 |
break
|
103 |
index = 0
|
104 |
selected = collected[index] |
105 |
num_eligibles = len(collected)
|
106 |
if (num_eligibles > 1): |
107 |
if random_generator is None: |
108 |
index = int(random() * num_eligibles)
|
109 |
selected = collected[index] |
110 |
else:
|
111 |
if not random_generator: |
112 |
print "Missing value for random selection among ", collected |
113 |
sys.exit(1)
|
114 |
selected = random_generator.pop(0)
|
115 |
logger = logging.getLogger(SVT_LOGGER) |
116 |
description = "{0} from {1} to {2}".format(selected, collected, action)
|
117 |
logger.info(LOG_MESSAGE.format(action=Action.RANDOM, desc=description)) |
118 |
return selected
|
119 |
|
120 |
|
121 |
def redistribute_ballots(selected, weight, hopefuls, allocated, vote_count): |
122 |
"""Redistributes the ballots from selected to the hopefuls.
|
123 |
|
124 |
Redistributes the ballots currently allocated to the selected
|
125 |
candidate. The ballots are redistributed with the given weight.
|
126 |
The total ballot allocation is given by the allocated map, which
|
127 |
is modified accordingly. The current vote count is given by
|
128 |
vote_count and is adjusted according to the redistribution.
|
129 |
|
130 |
"""
|
131 |
|
132 |
logger = logging.getLogger(SVT_LOGGER) |
133 |
transferred = [] |
134 |
# Keep a hash of ballot moves for logging purposes.
|
135 |
# Keys are a tuple of the form (from_recipient, to_recipient, value)
|
136 |
# where value is the current value of the ballot. Each tuple points
|
137 |
# to the ballot being moved.
|
138 |
moves = {} |
139 |
|
140 |
for ballot in allocated[selected]: |
141 |
reallocated = False
|
142 |
i = ballot.current_preference + 1
|
143 |
while not reallocated and i < len(ballot.candidates): |
144 |
recipient = ballot.candidates[i] |
145 |
if recipient in hopefuls: |
146 |
ballot.current_preference = i |
147 |
ballot.add_weight(weight) |
148 |
current_value = ballot.get_value() |
149 |
if recipient in allocated: |
150 |
allocated[recipient].append(ballot) |
151 |
else:
|
152 |
allocated[recipient] = [ballot] |
153 |
if recipient in vote_count: |
154 |
vote_count[recipient] += current_value |
155 |
else:
|
156 |
vote_count[recipient] = current_value |
157 |
vote_count[selected] -= current_value |
158 |
reallocated = True
|
159 |
if (selected, recipient, current_value) in moves: |
160 |
moves[(selected, recipient, current_value)].append(ballot) |
161 |
else:
|
162 |
moves[(selected, recipient, current_value)] = [ballot] |
163 |
transferred.append(ballot) |
164 |
else:
|
165 |
i += 1
|
166 |
for move, ballots in moves.iteritems(): |
167 |
times = len(ballots)
|
168 |
description = "from {0} to {1} {2}*{3}={4}".format(move[0], |
169 |
move[1],
|
170 |
times, |
171 |
move[2],
|
172 |
times * move[2])
|
173 |
logger.debug(LOG_MESSAGE.format(action=Action.TRANSFER, |
174 |
desc=description)) |
175 |
allocated[selected][:] = [x for x in allocated[selected] |
176 |
if x not in transferred ] |
177 |
|
178 |
def elect_reject(candidate, vote_count, constituencies, quota_limit, |
179 |
current_round, elected, rejected, constituencies_elected): |
180 |
"""Elects or rejects the candidate, based on quota restrictions.
|
181 |
|
182 |
If there are no quota limits, the candidate is elected. If there
|
183 |
are quota limits, the candidate is either elected or rejected, if
|
184 |
the quota limits are exceeded. The elected and rejected lists
|
185 |
are modified accordingly, as well as the constituencies_elected map.
|
186 |
|
187 |
Returns true if the candidate is elected, false otherwise.
|
188 |
"""
|
189 |
|
190 |
|
191 |
logger = logging.getLogger(SVT_LOGGER) |
192 |
quota_exceeded = False
|
193 |
# If there is a quota limit, check if it is exceeded
|
194 |
if quota_limit > 0 and candidate in constituencies: |
195 |
current_constituency = constituencies[candidate] |
196 |
if constituencies_elected[current_constituency] >= quota_limit:
|
197 |
quota_exceeded = True
|
198 |
# If the quota limit has been exceeded, reject the candidate
|
199 |
if quota_exceeded:
|
200 |
rejected.append((candidate, current_round, vote_count[candidate])) |
201 |
d = candidate + " = " + str(vote_count[candidate]) |
202 |
msg = LOG_MESSAGE.format(action=Action.QUOTA, desc=d) |
203 |
logger.info(msg) |
204 |
return False |
205 |
# Otherwise, elect the candidate
|
206 |
else:
|
207 |
elected.append((candidate, current_round, vote_count[candidate])) |
208 |
if constituencies:
|
209 |
current_constituency = constituencies[candidate] |
210 |
constituencies_elected[current_constituency] += 1
|
211 |
d = candidate + " = " + str(vote_count[candidate]) |
212 |
msg = LOG_MESSAGE.format(action=Action.ELECT, desc=d) |
213 |
logger.info(msg) |
214 |
return True |
215 |
|
216 |
def count_description(vote_count, candidates): |
217 |
"""Returns a string with count results.
|
218 |
|
219 |
The string is of the form of {0} = {1} separated by ; where each {0}
|
220 |
is a candidate and each {1} is the corresponding vote count.
|
221 |
"""
|
222 |
|
223 |
return ';'.join(map(lambda x: "{0} = {1}".format(x, vote_count[x]), |
224 |
candidates)) |
225 |
|
226 |
|
227 |
def count_stv(ballots, seats, droop = True, constituencies = None, |
228 |
quota_limit = 0, rnd_gen=None): |
229 |
"""Performs a STV vote for the given ballots and number of seats.
|
230 |
|
231 |
If droop is true the election threshold is calculated according to the
|
232 |
Droop quota:
|
233 |
threshold = int(1 + (len(ballots) / (seats + 1.0)))
|
234 |
otherwise it is calculated according to the following formula:
|
235 |
threshold = int(math.ceil(1 + len(ballots) / (seats + 1.0)))
|
236 |
The constituencies argument is a map of candidates to constituencies, if
|
237 |
any. The quota_limit, if different than zero, is the limit of candidates
|
238 |
that can be elected by a constituency.
|
239 |
"""
|
240 |
|
241 |
allocated = {} # The allocation of ballots to candidates
|
242 |
vote_count = {} # A hash of ballot counts, indexed by candidates
|
243 |
candidates = [] # All candidates
|
244 |
elected = [] # The candidates that have been elected
|
245 |
hopefuls = [] # The candidates that may be elected
|
246 |
# The candidates that have been eliminated because of low counts
|
247 |
eliminated = [] |
248 |
# The candidates that have been eliminated because of quota restrictions
|
249 |
rejected = [] |
250 |
# The number of candidates elected per constituency
|
251 |
constituencies_elected = {} |
252 |
for constituency in constituencies.values(): |
253 |
constituencies_elected[constituency] = 0
|
254 |
|
255 |
seed() |
256 |
|
257 |
if droop:
|
258 |
threshold = int(1 + (len(ballots) / (seats + 1.0))) |
259 |
else:
|
260 |
threshold = int(math.ceil(1 + len(ballots) / (seats + 1.0))) |
261 |
|
262 |
logger = logging.getLogger(SVT_LOGGER) |
263 |
logger.info(LOG_MESSAGE.format(action=Action.THRESHOLD, |
264 |
desc=threshold)) |
265 |
|
266 |
# Do initial count
|
267 |
for ballot in ballots: |
268 |
selected = ballot.candidates[0]
|
269 |
for candidate in ballot.candidates: |
270 |
if candidate not in candidates: |
271 |
candidates.append(candidate) |
272 |
vote_count[candidate] = 0
|
273 |
if candidate not in allocated: |
274 |
allocated[candidate] = [] |
275 |
allocated[selected].append(ballot) |
276 |
vote_count[selected] += 1
|
277 |
|
278 |
# In the beginning, all candidates are hopefuls
|
279 |
hopefuls = [x for x in candidates] |
280 |
|
281 |
# Start rounds
|
282 |
current_round = 1
|
283 |
num_elected = len(elected)
|
284 |
num_hopefuls = len(hopefuls)
|
285 |
while num_elected < seats and num_hopefuls > 0: |
286 |
# Log round
|
287 |
logger.info(LOG_MESSAGE.format(action=Action.COUNT_ROUND, |
288 |
desc=current_round)) |
289 |
# Log count
|
290 |
description = count_description(vote_count, hopefuls) |
291 |
|
292 |
logger.info(LOG_MESSAGE.format(action=Action.COUNT, |
293 |
desc=description)) |
294 |
hopefuls_sorted = sorted(hopefuls, key=vote_count.get, reverse=True ) |
295 |
# If there is a surplus record it so that we can try to
|
296 |
# redistribute the best candidate's votes according to their
|
297 |
# next preferences
|
298 |
surplus = vote_count[hopefuls_sorted[0]] - threshold
|
299 |
# If there is either a candidate with surplus votes, or
|
300 |
# there are hopeful candidates beneath the threshold.
|
301 |
if surplus >= 0 or num_hopefuls <= (seats - num_elected): |
302 |
best_candidate = randomly_select_first(hopefuls_sorted, |
303 |
key=vote_count.get, |
304 |
action=Action.ELECT, |
305 |
random_generator=rnd_gen) |
306 |
if best_candidate not in hopefuls: |
307 |
print "Not a valid candidate: ",best_candidate |
308 |
sys.exit(1)
|
309 |
hopefuls.remove(best_candidate) |
310 |
was_elected = elect_reject(best_candidate, vote_count, |
311 |
constituencies, quota_limit, |
312 |
current_round, |
313 |
elected, rejected, |
314 |
constituencies_elected) |
315 |
if not was_elected: |
316 |
redistribute_ballots(best_candidate, 1.0, hopefuls, allocated,
|
317 |
vote_count) |
318 |
if surplus > 0: |
319 |
# Calculate the weight for this round
|
320 |
weight = float(surplus) / vote_count[best_candidate]
|
321 |
# Find the next eligible preference for each one of the ballots
|
322 |
# cast for the candidate, and transfer the vote to that
|
323 |
# candidate with its value adjusted by the correct weight.
|
324 |
redistribute_ballots(best_candidate, weight, hopefuls, |
325 |
allocated, vote_count) |
326 |
# If nobody can get elected, take the least hopeful candidate
|
327 |
# (i.e., the hopeful candidate with the less votes) and
|
328 |
# redistribute that candidate's votes.
|
329 |
else:
|
330 |
hopefuls_sorted.reverse() |
331 |
worst_candidate = randomly_select_first(hopefuls_sorted, |
332 |
key=vote_count.get, |
333 |
action=Action.ELIMINATE, |
334 |
random_generator=rnd_gen) |
335 |
hopefuls.remove(worst_candidate) |
336 |
eliminated.append(worst_candidate) |
337 |
d = worst_candidate + " = " + str(vote_count[worst_candidate]) |
338 |
msg = LOG_MESSAGE.format(action=Action.ELIMINATE, desc=d) |
339 |
logger.info(msg) |
340 |
redistribute_ballots(worst_candidate, 1.0, hopefuls, allocated,
|
341 |
vote_count) |
342 |
|
343 |
current_round += 1
|
344 |
num_hopefuls = len(hopefuls)
|
345 |
num_elected = len(elected)
|
346 |
|
347 |
# If there is either a candidate with surplus votes, or
|
348 |
# there are hopeful candidates beneath the threshold.
|
349 |
while (seats - num_elected) > 0 and len(eliminated) > 0: |
350 |
logger.info(LOG_MESSAGE.format(action=Action.COUNT_ROUND, |
351 |
desc=current_round)) |
352 |
description = count_description(vote_count, eliminated) |
353 |
|
354 |
logger.info(LOG_MESSAGE.format(action=Action.ZOMBIES, |
355 |
desc=description)) |
356 |
|
357 |
best_candidate = eliminated.pop() |
358 |
elect_reject(best_candidate, vote_count, constituencies, |
359 |
quota_limit, current_round, |
360 |
elected, rejected, constituencies_elected) |
361 |
current_round += 1
|
362 |
|
363 |
return elected, vote_count
|
364 |
|
365 |
if __name__ == "__main__": |
366 |
parser = argparse.ArgumentParser(description='Perform STV')
|
367 |
parser.add_argument('-b', '--ballots', default='sys.stdin', |
368 |
dest='ballots_file', help='input ballots file') |
369 |
parser.add_argument('-n', '--not_droop', action="store_false", |
370 |
dest='droop', help="don't use droop quota") |
371 |
parser.add_argument('-s', '--seats', type=int, default=0, |
372 |
dest='seats', help='number of seats') |
373 |
parser.add_argument('-c', '--constituencies', |
374 |
dest='constituencies_file',
|
375 |
help='input constituencies file')
|
376 |
parser.add_argument('-q', '--quota', type=int, default=0, |
377 |
dest='quota', help='constituency quota') |
378 |
parser.add_argument('-r', '--random', nargs='*', |
379 |
dest='random', help='random selection results') |
380 |
parser.add_argument('-l', '--loglevel', default=logging.INFO, |
381 |
dest='loglevel', help='logging level') |
382 |
args = parser.parse_args() |
383 |
|
384 |
stream_handler = logging.StreamHandler(stream=sys.stdout) |
385 |
logger = logging.getLogger(SVT_LOGGER) |
386 |
logger.setLevel(args.loglevel) |
387 |
logger.addHandler(stream_handler) |
388 |
|
389 |
ballots = [] |
390 |
ballots_file = sys.stdin |
391 |
if args.ballots_file != 'sys.stdin': |
392 |
ballots_file = open(args.ballots_file, 'U') |
393 |
ballots_reader = csv.reader(ballots_file, delimiter=',',
|
394 |
quotechar='"',
|
395 |
skipinitialspace=True)
|
396 |
for ballot in ballots_reader: |
397 |
ballots.append(Ballot(ballot)) |
398 |
|
399 |
if args.seats == 0: |
400 |
args.seats = len(ballots) / 2 |
401 |
|
402 |
constituencies = {} |
403 |
if args.constituencies_file:
|
404 |
constituencies_file = open(args.constituencies_file, 'U') |
405 |
constituencies_reader = csv.reader(constituencies_file, |
406 |
delimiter=',',
|
407 |
quotechar='"',
|
408 |
skipinitialspace=True)
|
409 |
constituency_id = 0
|
410 |
for constituency in constituencies_reader: |
411 |
for candidate in constituency: |
412 |
constituencies[candidate] = constituency_id |
413 |
constituency_id += 1
|
414 |
|
415 |
(elected, vote_count) = count_stv(ballots, args.seats, args.droop, |
416 |
constituencies, |
417 |
args.quota, |
418 |
args.random) |
419 |
|
420 |
print "Results:" |
421 |
for result in elected: |
422 |
print result
|