Statistics
| Branch: | Revision:

root / stv.py @ 990450dd

History | View | Annotate | Download (17.7 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 (candidate, constituency) in constituencies.iteritems():
253
        constituencies_elected[constituency] = 0
254
        if candidate not in allocated:
255
            allocated[candidate] = []
256
        if candidate not in candidates: # check not really needed
257
            candidates.append(candidate)
258
            vote_count[candidate] = 0
259

    
260
    seed()
261

    
262
    if droop:
263
        threshold = int(1 + (len(ballots) / (seats + 1.0)))
264
    else:
265
        threshold = int(math.ceil(1 + len(ballots) / (seats + 1.0)))
266

    
267
    logger = logging.getLogger(SVT_LOGGER)
268
    logger.info(LOG_MESSAGE.format(action=Action.THRESHOLD,
269
                                   desc=threshold))
270
    
271
    # Do initial count
272
    for ballot in ballots:
273
        selected = ballot.candidates[0]
274
        for candidate in ballot.candidates:
275
            if candidate not in candidates:
276
                candidates.append(candidate)
277
                vote_count[candidate] = 0
278
            if candidate not in allocated:
279
                allocated[candidate] = []
280
        allocated[selected].append(ballot)
281
        vote_count[selected] += 1
282

    
283
    # In the beginning, all candidates are hopefuls
284
    hopefuls = [x for x in candidates]
285

    
286
    # Start rounds
287
    current_round = 1
288
    num_elected = len(elected)
289
    num_hopefuls = len(hopefuls)
290
    while num_elected < seats and num_hopefuls > 0:
291
        # Log round
292
        logger.info(LOG_MESSAGE.format(action=Action.COUNT_ROUND,
293
                                       desc=current_round))
294
        # Log count
295
        description  = count_description(vote_count, hopefuls)
296
       
297
        logger.info(LOG_MESSAGE.format(action=Action.COUNT,
298
                                       desc=description))
299
        hopefuls_sorted = sorted(hopefuls, key=vote_count.get, reverse=True )
300
        # If there is a surplus record it so that we can try to
301
        # redistribute the best candidate's votes according to their
302
        # next preferences
303
        surplus = vote_count[hopefuls_sorted[0]] - threshold
304
        # If there is either a candidate with surplus votes, or
305
        # there are hopeful candidates beneath the threshold.
306
        if surplus >= 0 or num_hopefuls <= (seats - num_elected):
307
            best_candidate = randomly_select_first(hopefuls_sorted,
308
                                                   key=vote_count.get,
309
                                                   action=Action.ELECT,
310
                                                   random_generator=rnd_gen)
311
            if best_candidate not in hopefuls:
312
                print "Not a valid candidate: ",best_candidate
313
                sys.exit(1)
314
            hopefuls.remove(best_candidate)
315
            was_elected = elect_reject(best_candidate, vote_count,
316
                                       constituencies, quota_limit,
317
                                       current_round, 
318
                                       elected, rejected,
319
                                       constituencies_elected)
320
            if not was_elected:
321
                redistribute_ballots(best_candidate, 1.0, hopefuls, allocated,
322
                                     vote_count)
323
            if surplus > 0:
324
                # Calculate the weight for this round
325
                weight = float(surplus) / vote_count[best_candidate]
326
                # Find the next eligible preference for each one of the ballots
327
                # cast for the candidate, and transfer the vote to that
328
                # candidate with its value adjusted by the correct weight.
329
                redistribute_ballots(best_candidate, weight, hopefuls,
330
                                     allocated, vote_count)
331
        # If nobody can get elected, take the least hopeful candidate
332
        # (i.e., the hopeful candidate with the less votes) and
333
        # redistribute that candidate's votes.
334
        else:
335
            hopefuls_sorted.reverse()
336
            worst_candidate = randomly_select_first(hopefuls_sorted,
337
                                                    key=vote_count.get,
338
                                                    action=Action.ELIMINATE,
339
                                                    random_generator=rnd_gen)
340
            hopefuls.remove(worst_candidate)
341
            eliminated.append(worst_candidate)
342
            d = worst_candidate + " = " + str(vote_count[worst_candidate])
343
            msg = LOG_MESSAGE.format(action=Action.ELIMINATE, desc=d)
344
            logger.info(msg)
345
            redistribute_ballots(worst_candidate, 1.0, hopefuls, allocated,
346
                                 vote_count)
347
            
348
        current_round += 1
349
        num_hopefuls = len(hopefuls)
350
        num_elected = len(elected)
351

    
352
    # If there is either a candidate with surplus votes, or
353
    # there are hopeful candidates beneath the threshold.
354
    while (seats - num_elected) > 0 and len(eliminated) > 0:
355
        logger.info(LOG_MESSAGE.format(action=Action.COUNT_ROUND,
356
                                       desc=current_round))
357
        description  = count_description(vote_count, eliminated)
358
        
359
        logger.info(LOG_MESSAGE.format(action=Action.ZOMBIES,
360
                                       desc=description))
361

    
362
        best_candidate = eliminated.pop()
363
        elect_reject(best_candidate, vote_count, constituencies,
364
                     quota_limit, current_round,
365
                     elected, rejected, constituencies_elected)
366
        current_round += 1
367

    
368
    return elected, vote_count
369

    
370
if __name__ == "__main__":
371
    parser = argparse.ArgumentParser(description='Perform STV')
372
    parser.add_argument('-b', '--ballots', default='sys.stdin',
373
                        dest='ballots_file', help='input ballots file')
374
    parser.add_argument('-n', '--not_droop', action="store_false",
375
                        dest='droop', help="don't use droop quota")
376
    parser.add_argument('-s', '--seats', type=int, default=0,
377
                        dest='seats', help='number of seats')
378
    parser.add_argument('-c', '--constituencies',
379
                        dest='constituencies_file',
380
                        help='input constituencies file')
381
    parser.add_argument('-q', '--quota', type=int, default=0,
382
                        dest='quota', help='constituency quota')
383
    parser.add_argument('-r', '--random', nargs='*',
384
                        dest='random', help='random selection results')
385
    parser.add_argument('-l', '--loglevel', default=logging.INFO,
386
                        dest='loglevel', help='logging level')
387
    args = parser.parse_args()
388

    
389
    stream_handler = logging.StreamHandler(stream=sys.stdout)
390
    logger = logging.getLogger(SVT_LOGGER)
391
    logger.setLevel(args.loglevel)
392
    logger.addHandler(stream_handler)
393

    
394
    ballots = []
395
    ballots_file = sys.stdin
396
    if args.ballots_file != 'sys.stdin':
397
        ballots_file = open(args.ballots_file, 'U')
398
    ballots_reader = csv.reader(ballots_file, delimiter=',',
399
                                quotechar='"',
400
                                skipinitialspace=True)
401
    for ballot in ballots_reader:
402
        ballots.append(Ballot(ballot))
403

    
404
    if args.seats == 0:
405
        args.seats = len(ballots) / 2
406

    
407
    constituencies = {}
408
    if args.constituencies_file:
409
        constituencies_file = open(args.constituencies_file, 'U')
410
        constituencies_reader = csv.reader(constituencies_file,
411
                                           delimiter=',',
412
                                           quotechar='"',
413
                                           skipinitialspace=True)
414
        constituency_id = 0
415
        for constituency in constituencies_reader:
416
            for candidate in constituency:
417
                constituencies[candidate] = constituency_id
418
            constituency_id += 1
419
        
420
    (elected, vote_count) = count_stv(ballots, args.seats, args.droop,
421
                                      constituencies,
422
                                      args.quota,
423
                                      args.random)
424

    
425
    print "Results:"
426
    for result in elected:
427
        print result