This repository has been archived by the owner on Jul 26, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
/
profile.py
428 lines (320 loc) · 14 KB
/
profile.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
import csv
import profile
import random
from rule import Rule
from ballot import Ballot
from typing import Set
from os import stat
class Profile:
def __init__(self, ballots = {}, alternatives = set(), alternatives_names = None):
# A ballot is a key-value pair where the key is the voter id and the
# value is an ordered list of alternatives
self.ballots = ballots
# Save the available alternatives as a set
self.alternatives = alternatives
# Optionally save names of alternatives
self.alternatives_names = alternatives_names
@classmethod
def from_csv(cls, path: str) -> 'Profile':
"""Converts a csv file containing a profile to a Profile object.
Expects voter ids as column names and preferences as columns, e.g.:
```
1, 2, 3
a, c, a
b, a, c
c, b, b
```
You can imagine this as a profile, but rotated by 90° clockwise.
Whitespace is ignored.
Returns:
Profile: A profile object.
"""
ballots = {}
alternatives = set()
with open(path) as csvfile:
reader = csv.DictReader(csvfile, delimiter=",",
skipinitialspace = True, quoting = csv.QUOTE_MINIMAL)
for voter in reader.fieldnames:
voter_id = int(voter)
# note: the Ballot constructor has a default value of [] for
# its preference field, but not initialising it breaks the
# program; all ballots then reference the same list. (???)
ballots[voter_id] = Ballot(id=voter_id,preference=[])
for line in reader:
for voter in reader.fieldnames:
ballots[int(voter)].preference.append(line[voter])
new_profile = cls(ballots, alternatives)
# This will raise an error if the profile is invalid
new_profile.__validate()
return new_profile
@classmethod
def from_txt(cls, path: str) -> 'Profile':
"""Converts a file containing a profile to a Profile object.
Expects the following format:
```
1: a b c
2: c b a
3: a c b
```
Whitespace is ignored.
Returns:
Profile: A profile object.
"""
ballots = {}
alternatives = set()
with open(path) as txtfile:
reader = txtfile.readlines()
# save set of alternatives
alternatives.update(reader[0].split(":")[1].split())
# save voter preferences
for voter in reader:
split = voter.split(":", 1)
voter_id = int(split[0])
preference = split[1].split()
ballots[voter_id] = Ballot(id=voter_id, preference=preference)
# use constructor to create a profile
new_profile = cls(ballots, alternatives)
# This will raise an error if the profile is invalid
new_profile.__validate()
return new_profile
@classmethod
def from_soc(cls, path: str) -> 'Profile':
"""Imports a `.soc` file from PrefLib representing a complete strict order
"""
ballots = {}
alternatives = set()
alternative_names = {}
with open(path) as socfile:
reader = socfile.readlines()
cur_line = 0
# read alternatives
num_alternatives = int(reader[cur_line])
cur_line += 1
for alternative in range(cur_line, cur_line + num_alternatives):
alternative_line = reader[alternative].split(",")
alternative_id = alternative_line[0].strip()
alternative_name = alternative_line[1].strip()
alternatives.add(alternative_id)
alternative_names[alternative_id] = alternative_name
cur_line += 1
# read info
info = reader[cur_line].split(",")
total_votes = int(info[0])
unknown_1 = int(info[1])
num_unique_ballots = int(info[2])
cur_line += 1
# read profile
for ballot in range(cur_line, cur_line + num_unique_ballots):
ballot_line = reader[ballot].split(",", maxsplit=1)
ballot_id = ballot - cur_line + 1
ballot_weight = int(ballot_line[0])
ballot_preference = ballot_line[1].strip().split(",")
ballot = Ballot(id=ballot_id, preference=ballot_preference, weight=ballot_weight)
ballots[ballot_id] = ballot
new_profile = cls(ballots, alternatives, alternative_names)
new_profile.__validate()
return new_profile
@classmethod
def random(cls, num_voters: int, num_alternatives: int, num_groups: int = None) -> 'Profile':
"""Generate a random profile with the given number of voters and alternatives.
If the num_groups parameter is given, that number of different preferences will be generated
and assigned to equal groups.
"""
if num_groups == None:
num_groups = num_voters
alternatives = set([ chr(c) for c in range(ord('a'), ord('a') + num_alternatives) ])
ballots = {}
group_ballots = {}
for group in [g + 1 for g in range(num_groups)]:
group_ballots[group] = random.sample(alternatives, num_alternatives)
for voter in [v + 1 for v in range(num_voters)]:
preference = group_ballots[(voter - 1) % num_groups + 1]
ballots[voter] = Ballot(id=voter, preference=preference)
random_profile = cls(ballots, alternatives)
random_profile.__validate()
return random_profile
def alternative_name(self, name) -> str:
"""Helper method for printing the name of an alternative.
"""
if self.alternatives_names != None:
return self.alternatives_names[name]
else:
return name
def __validate(self):
"""Validates the profile by:
* Checking the length of all ballots is the same
* Checking if the set of alternatives is the same for all ballots
"""
# Retrieve the first ballot and take the alternatives in there as the
# available alternatives
first_voter_id = next(iter(self.ballots))
# update(list) on a Set adds all items in that list to the set
self.alternatives.update(self.ballots.get(first_voter_id).preference)
# iterate over voter preferences
# (self.ballots.values() is a list of lists of alternatives)
for voter, ballot in self.ballots.items():
# if a ballot has a different number of alternatives than are
# available, or if it contains alternatives not in the set of
# alternatives, throw an error
length_different = len(ballot.preference) != len(self.alternatives)
alternatives_differ = set(ballot.preference).difference(self.alternatives)
# Provide helpful error message
if len(alternatives_differ) != 0:
if length_different:
# Different length _and_ different composition
raise ValueError(f"\n\tVoter {voter} has a different set "
"of alternatives than I was expecting. I was expecting "
f"{len(self.alternatives)} alternatives but got "
f"{len(ballot.preference)} instead.\n\tAlso, these alternatives "
f"do not appear in the first ballot: {alternatives_differ}.")
else:
# Only different composition
raise ValueError(f"\n\tVoter {voter} has a different set "
"of alternatives than I was expecting. These alternatives "
f"do not appear in the first ballot: {alternatives_differ}.")
elif length_different:
raise ValueError(f"\n\tVoter {voter} has a different set "
"of alternatives than I was expecting. I was expecting "
f"{len(self.alternatives)} alternatives but got "
f"{len(ballot.preference)} instead.")
def __sorted_alternatives(self):
return sorted(list(self.alternatives))
def ballot(self, id):
"""Returns the ballot of a voter with id `id`.
Returns:
[type]: [description]
"""
return self.ballots[id]
def prefers(self, id, a1, a2) -> bool:
"""Whether a voter with id `id` prefers alternative `a1` over `a2`.
"""
ballot = self.ballot(id)
# lower index means higher ranking
return ballot.preference.index(a1) < ballot.preference.index(a2)
def num_prefers(self, a1, a2):
"""The number of agents that prefer alternative `a1` over `a2`.
"""
num = 0
for ballot in self.ballots.values():
if self.prefers(ballot.id, a1, a2):
num += ballot.weight
return num
def dominance(self):
"""Calculate the dominance matrix of a profile
Returns:
An 𝑚×𝑚 matrix representing the dominance relation P
"""
sorted_alternatives = self.__sorted_alternatives()
dominance = [[self.num_prefers(y, x) for x in sorted_alternatives] for y in sorted_alternatives]
return dominance
def print(self):
"""Pretty-print a profile
"""
maxlen = len(str(len(self.ballots)))
for ballot in self.ballots.values():
length = len(str(ballot.weight))
if length > maxlen:
maxlen = length
has_weights = max([ballot.weight for ballot in self.ballots.values()]) > 1
print("Profile:\n")
for voter, ballot in self.ballots.items():
if has_weights:
print(f"\t#{ballot.weight:{maxlen}} │ ", end='')
for alternative in ballot.preference:
print(alternative, end=' ')
print() # newline
else:
print(f"\t{voter:{maxlen}} │ ", end='')
for alternative in ballot.preference:
print(alternative, end=' ')
print() # newline
print() # newline
if self.alternatives_names != None:
print("IDs represent the following alternatives:\n")
brk = 0
for id, name in self.alternatives_names.items():
brk += 1
print(f"{id:>2}: {name:30}", end=' ')
if brk % 3 == 0:
print()
print()
def print_dominance(self):
"""Pretty-print a dominance matrix
"""
dominance = self.dominance()
maxlen = len(str(max(max(dominance))))
for x in self.__sorted_alternatives():
name = self.alternative_name(x)
if len(name) > maxlen:
maxlen = len(name)
# Print header
print("Dominance matrix:\n")
print("\t", end='')
for _ in range(maxlen):
print(" ", end='')
print(" │ ", end='')
for x in self.__sorted_alternatives():
name = self.alternative_name(x)
print(f"{name:^{maxlen + 1}}", end='')
print() # newline
# Print separating line
print("\t", end='')
for _ in range(maxlen + 1):
print("─", end='')
print("┼", end='')
for _ in range(len(self.alternatives) * (maxlen + 1)):
print("─", end='')
print()
# Print rows
for i, x in enumerate(self.__sorted_alternatives()):
name = self.alternative_name(x)
print(f"\t{name:{maxlen}} │ ", end = '')
for j, _ in enumerate(self.__sorted_alternatives()):
print(f"{str(dominance[i][j]):^{maxlen + 1}}", end='')
print() # newline
print() # newline
def winner(self, rule: Rule):
if rule == Rule.PLURALITY:
return self.__winner_plurality()
elif rule == Rule.BORDA:
return self.__winner_borda()
elif rule == Rule.CONDORCET:
return self.__winner_condorcet()
elif rule == Rule.WEAK_CONDORCET:
return self.__winner_weak_condorcet()
def __winner_plurality(self):
"""Plurality with alphabetic tie-breaking
"""
# initialize dictionary of plurality scores for all alternatives with an
# initial value of 0
plur_score = dict.fromkeys(self.alternatives, 0)
# Calculate plurality scores by traversing all ballots
for ballot in self.ballots.values():
# the top choice for the current ballot
voter_max = ballot.preference[0]
plur_score[voter_max] += ballot.weight
(winner_id, winner_score) = plur_score.popitem()
for alternative, score in plur_score.items():
if score > winner_score:
winner_id = alternative
winner_score = score
elif score == winner_score:
# Tie breaking. Sort alphabetically and take first item.
# Score is unchanged, so doesn't need to be updated
winner_id = sorted([str(winner_id), str(alternative)])[0]
return winner_id
def __winner_borda(self):
m = len(self.alternatives)
borda_score = dict.fromkeys(self.alternatives, 0)
for ballot in self.ballots.values():
for index, alternative in enumerate(ballot.preference):
# calculate weight
w = m - index - 1
# update borda score
borda_score[alternative] += w
outcome = max(borda_score, key = borda_score.get)
return outcome
def __winner_condorcet(self):
raise NotImplementedError()
def __winner_weak_condorcet(self):
raise NotImplementedError()