pref_voting 1.16.31__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.
Files changed (92) hide show
  1. pref_voting/__init__.py +1 -0
  2. pref_voting/analysis.py +496 -0
  3. pref_voting/axiom.py +38 -0
  4. pref_voting/axiom_helpers.py +129 -0
  5. pref_voting/axioms.py +10 -0
  6. pref_voting/c1_methods.py +963 -0
  7. pref_voting/combined_methods.py +514 -0
  8. pref_voting/create_methods.py +128 -0
  9. pref_voting/data/examples/condorcet_winner/minimal_Anti-Plurality.soc +16 -0
  10. pref_voting/data/examples/condorcet_winner/minimal_Borda.soc +17 -0
  11. pref_voting/data/examples/condorcet_winner/minimal_Bracket_Voting.soc +20 -0
  12. pref_voting/data/examples/condorcet_winner/minimal_Bucklin.soc +19 -0
  13. pref_voting/data/examples/condorcet_winner/minimal_Coombs.soc +20 -0
  14. pref_voting/data/examples/condorcet_winner/minimal_Coombs_PUT.soc +20 -0
  15. pref_voting/data/examples/condorcet_winner/minimal_Coombs_TB.soc +20 -0
  16. pref_voting/data/examples/condorcet_winner/minimal_Dowdall.soc +19 -0
  17. pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff.soc +18 -0
  18. pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff_PUT.soc +18 -0
  19. pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff_TB.soc +18 -0
  20. pref_voting/data/examples/condorcet_winner/minimal_Iterated_Removal_Condorcet_Loser.soc +17 -0
  21. pref_voting/data/examples/condorcet_winner/minimal_Pareto.soc +17 -0
  22. pref_voting/data/examples/condorcet_winner/minimal_Plurality.soc +18 -0
  23. pref_voting/data/examples/condorcet_winner/minimal_PluralityWRunoff_PUT.soc +18 -0
  24. pref_voting/data/examples/condorcet_winner/minimal_Positive-Negative_Voting.soc +17 -0
  25. pref_voting/data/examples/condorcet_winner/minimal_Simplified_Bucklin.soc +18 -0
  26. pref_voting/data/examples/condorcet_winner/minimal_Superior_Voting.soc +19 -0
  27. pref_voting/data/examples/condorcet_winner/minimal_Weighted_Bucklin.soc +19 -0
  28. pref_voting/data/examples/condorcet_winner/minimal_resolute_Anti-Plurality.soc +17 -0
  29. pref_voting/data/examples/condorcet_winner/minimal_resolute_Borda.soc +17 -0
  30. pref_voting/data/examples/condorcet_winner/minimal_resolute_Bracket_Voting.soc +20 -0
  31. pref_voting/data/examples/condorcet_winner/minimal_resolute_Bucklin.soc +19 -0
  32. pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs.soc +21 -0
  33. pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs_PUT.soc +21 -0
  34. pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs_TB.soc +20 -0
  35. pref_voting/data/examples/condorcet_winner/minimal_resolute_Dowdall.soc +18 -0
  36. pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff.soc +18 -0
  37. pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff_PUT.soc +18 -0
  38. pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff_TB.soc +18 -0
  39. pref_voting/data/examples/condorcet_winner/minimal_resolute_Plurality.soc +18 -0
  40. pref_voting/data/examples/condorcet_winner/minimal_resolute_PluralityWRunoff_PUT.soc +18 -0
  41. pref_voting/data/examples/condorcet_winner/minimal_resolute_Positive-Negative_Voting.soc +17 -0
  42. pref_voting/data/examples/condorcet_winner/minimal_resolute_Simplified_Bucklin.soc +20 -0
  43. pref_voting/data/examples/condorcet_winner/minimal_resolute_Weighted_Bucklin.soc +19 -0
  44. pref_voting/data/voting_methods_properties.json +414 -0
  45. pref_voting/data/voting_methods_properties.json.lock +0 -0
  46. pref_voting/dominance_axioms.py +387 -0
  47. pref_voting/generate_profiles.py +801 -0
  48. pref_voting/generate_spatial_profiles.py +198 -0
  49. pref_voting/generate_utility_profiles.py +160 -0
  50. pref_voting/generate_weighted_majority_graphs.py +506 -0
  51. pref_voting/grade_methods.py +184 -0
  52. pref_voting/grade_profiles.py +357 -0
  53. pref_voting/helper.py +370 -0
  54. pref_voting/invariance_axioms.py +671 -0
  55. pref_voting/io/__init__.py +0 -0
  56. pref_voting/io/readers.py +432 -0
  57. pref_voting/io/writers.py +256 -0
  58. pref_voting/iterative_methods.py +2425 -0
  59. pref_voting/maj_graph_ex1.png +0 -0
  60. pref_voting/mappings.py +577 -0
  61. pref_voting/margin_based_methods.py +2345 -0
  62. pref_voting/monotonicity_axioms.py +872 -0
  63. pref_voting/num_evaluation_method.py +77 -0
  64. pref_voting/other_axioms.py +161 -0
  65. pref_voting/other_methods.py +939 -0
  66. pref_voting/pairwise_profiles.py +547 -0
  67. pref_voting/prob_voting_method.py +105 -0
  68. pref_voting/probabilistic_methods.py +287 -0
  69. pref_voting/profiles.py +856 -0
  70. pref_voting/profiles_with_ties.py +1069 -0
  71. pref_voting/rankings.py +466 -0
  72. pref_voting/scoring_methods.py +481 -0
  73. pref_voting/social_welfare_function.py +59 -0
  74. pref_voting/social_welfare_functions.py +7 -0
  75. pref_voting/spatial_profiles.py +448 -0
  76. pref_voting/stochastic_methods.py +99 -0
  77. pref_voting/strategic_axioms.py +1394 -0
  78. pref_voting/swf_axioms.py +173 -0
  79. pref_voting/utility_functions.py +102 -0
  80. pref_voting/utility_methods.py +178 -0
  81. pref_voting/utility_profiles.py +333 -0
  82. pref_voting/variable_candidate_axioms.py +640 -0
  83. pref_voting/variable_voter_axioms.py +3747 -0
  84. pref_voting/voting_method.py +355 -0
  85. pref_voting/voting_method_properties.py +92 -0
  86. pref_voting/voting_methods.py +8 -0
  87. pref_voting/voting_methods_registry.py +136 -0
  88. pref_voting/weighted_majority_graphs.py +1539 -0
  89. pref_voting-1.16.31.dist-info/METADATA +208 -0
  90. pref_voting-1.16.31.dist-info/RECORD +92 -0
  91. pref_voting-1.16.31.dist-info/WHEEL +4 -0
  92. pref_voting-1.16.31.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,939 @@
1
+ '''
2
+ File: other_methods.py
3
+ Author: Wes Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu)
4
+ Date: January 12, 2022
5
+ Updated: April 21, 2025
6
+
7
+ '''
8
+ from pref_voting.voting_method import *
9
+ from pref_voting.scoring_methods import plurality
10
+ from pref_voting.profiles import _find_updated_profile, _num_rank
11
+ from pref_voting.profiles_with_ties import ProfileWithTies
12
+ from pref_voting.weighted_majority_graphs import MarginGraph
13
+ from itertools import combinations, permutations
14
+ from pref_voting.voting_method_properties import ElectionTypes
15
+ from pref_voting.rankings import Ranking
16
+ from pref_voting.social_welfare_function import swf
17
+ import numpy as np
18
+ from pref_voting.profiles_with_ties import _num_rank_profile_with_ties
19
+ import copy
20
+ from ortools.linear_solver import pywraplp
21
+
22
+ @vm(name = "Absolute Majority",
23
+ skip_registration=True, # skip registration since aboslute majority may return an empty list
24
+ input_types = [ElectionTypes.PROFILE])
25
+ def absolute_majority(profile, curr_cands = None):
26
+ """The absolute majority winner is the candidate with a strict majority of first place votes. Returns an empty list if there is no candidate with a strict majority of first place votes. Otherwise returns the absolute majority winner in the ``profile`` restricted to ``curr_cands``.
27
+
28
+ ..note:
29
+ The term 'absolute majority' for this voting method comes from Charles Dodgson's famous pamplet of 1873, "A Discussion of the Various Methods of Procedure in Conducting Elections" (see I. McLean and A. Urken, *Classics of Social Choice*, 1995, p. 281, or A. D. Taylor, "Social Choice and the Mathematics of Manipulation," 2005, p. 11).
30
+
31
+ Args:
32
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
33
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
34
+
35
+ Returns:
36
+ A sorted list of candidates
37
+
38
+ .. important::
39
+ Formally, this is *not* a voting method since the function might return an empty list (when there is no candidate with a strict majority of first place votes). Also, if there is an absolute majority winner, then that winner is unique.
40
+
41
+ :Example:
42
+ .. exec_code::
43
+
44
+ from pref_voting.profiles import Profile
45
+ from pref_voting.other_methods import absolute_majority
46
+
47
+ prof1 = Profile([[0, 1, 2], [1, 0, 2], [2, 1, 0]], [3, 1, 2])
48
+ prof1.display()
49
+ absolute_majority.display(prof1)
50
+
51
+ prof2 = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0]], [5, 1, 2])
52
+ prof2.display()
53
+ absolute_majority.display(prof2)
54
+
55
+ """
56
+ maj_size = profile.strict_maj_size()
57
+ curr_cands = profile.candidates if curr_cands is None else curr_cands
58
+
59
+ plurality_scores = profile.plurality_scores(curr_cands = curr_cands)
60
+ abs_maj_winner = [c for c in curr_cands if plurality_scores[c] >= maj_size]
61
+
62
+ return sorted(abs_maj_winner)
63
+
64
+ @vm(name = "Pareto",
65
+ input_types = [ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
66
+ def pareto(profile, curr_cands = None, strong_Pareto = False, use_extended_strict_preferences = True):
67
+ """Returns the set of candidates who are not Pareto dominated.
68
+
69
+ For ProfilesWithTies, if strong_Pareto == True, then a dominates b if some voter strictly prefers a to b and no voter strictly prefers b to a.
70
+
71
+ Args:
72
+ prof (Profile, ProfileWithTies): An anonymous profile of linear (or strict weak) orders on a set of candidates
73
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
74
+
75
+ Returns:
76
+ A sorted list of candidates
77
+
78
+ """
79
+
80
+ if type(profile) == ProfileWithTies:
81
+ currently_using_extended_strict_preferences = profile.using_extended_strict_preference
82
+ if use_extended_strict_preferences:
83
+ profile.use_extended_strict_preference()
84
+
85
+ Pareto_dominated = set()
86
+ candidates = profile.candidates if curr_cands is None else curr_cands
87
+ for a in candidates:
88
+ for b in candidates:
89
+ if not strong_Pareto and profile.support(a,b) == profile.num_voters:
90
+ Pareto_dominated.add(b)
91
+
92
+ if strong_Pareto and profile.support(a,b) > 0 and profile.support(b,a) == 0:
93
+ Pareto_dominated.add(b)
94
+
95
+ if type(profile) == ProfileWithTies and use_extended_strict_preferences:
96
+ if not currently_using_extended_strict_preferences:
97
+ profile.use_strict_preference()
98
+
99
+ return sorted(list(set(candidates) - Pareto_dominated))
100
+
101
+
102
+ ## Kemeny-Young Method
103
+ #
104
+ def kendalltau_dist(rank_a, rank_b):
105
+ index_b = {c: i for i, c in enumerate(rank_b)}
106
+ tau = 0
107
+ for i, j in combinations(rank_a, 2):
108
+ # by definition of itertools.combinations, index_a[i] < index_a[j]
109
+ if index_b[i] > index_b[j]:
110
+ tau += 1
111
+ return tau
112
+
113
+ def kendalltau_dist_for_rankings_with_ties(
114
+ candidates,
115
+ ranking1,
116
+ ranking2,
117
+ penalty=0.5):
118
+
119
+ tau = 0
120
+ for c1, c2 in combinations(candidates, 2):
121
+ # by definition of itertools.combinations, index_a[i] < index_a[j]
122
+
123
+ if (ranking1.extended_strict_pref(c1, c2) and ranking2.extended_strict_pref(c2, c1)) or (ranking1.extended_strict_pref(c2, c1) and ranking2.extended_strict_pref(c1, c2)):
124
+ tau += 1
125
+ elif (ranking1.extended_strict_pref(c1, c2) and ranking2.extended_indiff(c1, c2)) or (ranking1.extended_strict_pref(c2, c1) and ranking2.extended_indiff(c1, c2)):
126
+ tau += penalty
127
+ elif (ranking1.extended_indiff(c1, c2) and ranking2.extended_strict_pref(c1, c2)) or (ranking1.extended_indiff(c1, c2) and ranking2.extended_strict_pref(c2, c1)) :
128
+ tau += penalty
129
+
130
+ return tau
131
+
132
+
133
+ def _kemeny_young_rankings(rankings, rcounts, candidates):
134
+
135
+ rankings_dist = dict()
136
+ for ranking in permutations(candidates):
137
+ rankings_dist[tuple(ranking)] = sum(c * kendalltau_dist(tuple(r), ranking)
138
+ for r,c in zip(rankings, rcounts))
139
+ min_dist = min(rankings_dist.values())
140
+
141
+ lin_orders = [r for r in rankings_dist.keys() if rankings_dist[r] == min_dist]
142
+
143
+ return lin_orders, min_dist
144
+
145
+ def kemeny_young_rankings(profile, curr_cands = None):
146
+ """
147
+ A Kemeny-Young ranking is a ranking that minimizes the sum of the Kendall tau distances to the voters' rankings.
148
+
149
+ Args:
150
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
151
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
152
+
153
+ Returns:
154
+ rankings: A list of Kemeny-Young rankings.
155
+
156
+ dist: The minimum distance of the Kemeny-Young rankings.
157
+
158
+
159
+ :Example:
160
+ .. exec_code::
161
+
162
+ from pref_voting.profiles import Profile
163
+ from pref_voting.other_methods import kemeny_young, kemeny_young_rankings
164
+
165
+ prof1 = Profile([[0, 1, 2], [1, 0, 2], [2, 1, 0]], [3, 1, 2])
166
+ prof1.display()
167
+ kyrs, d = kemeny_young_rankings(prof1)
168
+ print(f"Minimal distance: {d}")
169
+ for kyr in kyrs:
170
+ print(f"ranking: {kyr}")
171
+
172
+ prof2 = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0]], [5, 1, 2])
173
+ prof2.display()
174
+ kyrs, d = kemeny_young_rankings(prof2)
175
+ print(f"Minimal distance: {d}")
176
+ for kyr in kyrs:
177
+ print(f"ranking: {kyr}")
178
+
179
+ """
180
+ candidates = profile.candidates if curr_cands is None else curr_cands
181
+
182
+ rankings = profile._rankings if curr_cands is None else _find_updated_profile(profile._rankings, np.array([c for c in profile.candidates if c not in curr_cands]), profile.num_cands)
183
+ return _kemeny_young_rankings(list(rankings), list(profile._rcounts), candidates)
184
+
185
+
186
+ @vm(name = "Kemeny-Young",
187
+ input_types = [ElectionTypes.PROFILE,ElectionTypes.PROFILE_WITH_TIES, ElectionTypes.MARGIN_GRAPH])
188
+ def kemeny_young(edata, curr_cands = None, algorithm = "marginal"):
189
+ """A Kemeny-Young ranking is a ranking that maximizes the sum of the margins of pairs of candidates in the ranking. Equivalently, a Kemeny-Young ranking is a ranking that minimizes the sum of the Kendall tau distances to the voters' rankings. The Kemeny-Young winners are the candidates that are ranked first by some Kemeny-Young ranking.
190
+
191
+ Args:
192
+ edata (Profile, ProfileWithTies, MarginGraph): Any election data that has a `margin` method.
193
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
194
+ algorithm (str, optional): The algorithm to use. Options are "marginal" and "Kendall tau". If "marginal" is used, then the Kemeny-Young rankings are computed by finding the sum of the margins of each pair of candidates in the ranking. If "Kendall tau" is used, then the Kemeny-Young rankings are computed by summing the Kendall tau distances to the voters' rankings. Default is "marginal".
195
+
196
+ Returns:
197
+ A sorted list of candidates
198
+
199
+ :Example:
200
+
201
+ .. exec_code::
202
+
203
+ from pref_voting.profiles import Profile
204
+ from pref_voting.other_methods import kemeny_young, kemeny_young_rankings
205
+
206
+ prof1 = Profile([[0, 1, 2], [1, 0, 2], [2, 1, 0]], [3, 1, 2])
207
+ prof1.display()
208
+ kyrs, d = kemeny_young_rankings(prof1)
209
+ print(f"Minimal distance: {d}")
210
+ for kyr in kyrs:
211
+ print(f"ranking: {kyr}")
212
+ kemeny_young.display(prof1)
213
+
214
+ prof2 = Profile([[0, 1, 2], [1, 0, 2], [1, 2, 0]], [5, 1, 2])
215
+ prof2.display()
216
+ kyrs, d = kemeny_young_rankings(prof2)
217
+ print(f"Minimal distance: {d}")
218
+ for kyr in kyrs:
219
+ print(f"ranking: {kyr}")
220
+ kemeny_young.display(prof2)
221
+
222
+ """
223
+ assert algorithm in ["marginal", "Kendall tau"], "Algorithm must be either 'marginal' or 'Kendall tau'."
224
+
225
+ candidates = edata.candidates if curr_cands is None else curr_cands
226
+
227
+ if isinstance(edata, MarginGraph) or isinstance(edata,ProfileWithTies):
228
+ algorithm = "marginal"
229
+
230
+ if algorithm == "Kendall tau":
231
+ rankings = edata._rankings if curr_cands is None else _find_updated_profile(edata._rankings, np.array([c for c in edata.candidates if c not in curr_cands]), edata.num_cands)
232
+ ky_rankings, min_dist = _kemeny_young_rankings(list(rankings), list(edata._rcounts), candidates)
233
+
234
+ if algorithm == "marginal":
235
+
236
+ best_ranking_score = 0
237
+ ky_rankings = []
238
+
239
+ for r in permutations(candidates):
240
+
241
+ score_of_r = 0
242
+ for i in r[:-1]:
243
+ for j in r[r.index(i)+1:]:
244
+ score_of_r += edata.margin(i,j)
245
+
246
+ if score_of_r > best_ranking_score:
247
+ best_ranking_score = score_of_r
248
+ ky_rankings = [r]
249
+ if score_of_r == best_ranking_score:
250
+ ky_rankings.append(r)
251
+
252
+ return sorted(list(set([r[0] for r in ky_rankings])))
253
+
254
+ @vm("Preliminary Weighted Condorcet",
255
+ input_types = [ElectionTypes.PROFILE])
256
+ def preliminary_weighted_condorcet(prof, curr_cands = None, show_orders = False, require_positive_plurality_score = False):
257
+ """The preliminary version of the Weighted Condorcet Rule in Tideman's book, Collective Decisions and Voting (p. 223). The winners are the candidates ranked first by some linear order of the candidates with highest score, where the score of an order (c_1,...,c_n) is the sum over all i<j of the margin of c_i vs. c_j multiplied by the plurality scores of c_i and c_j.
258
+
259
+ The multiplication by plurality scores is what distinguishes this method from the Kemeny-Young method.
260
+
261
+ Tideman (p. 224) defines a more complicated Weighted Condorcet rule that is intended to be used when some candidates receive zero first-place votes.
262
+
263
+ Args:
264
+ prof (Profile): An anonymous profile of linear orders on a set of candidates
265
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
266
+ show_orders (bool): If True, then print the set of best orders.
267
+ require_positive_plurality_score (bool): If True, then require that all candidates have a positive plurality score.
268
+
269
+ Returns:
270
+ A sorted list of candidates
271
+ """
272
+
273
+ cands = curr_cands if curr_cands is not None else prof.candidates
274
+
275
+ if require_positive_plurality_score:
276
+ assert all([prof.plurality_scores(curr_cands=curr_cands)[c] > 0 for c in cands]), "All candidates must have a positive plurality score."
277
+
278
+ best_order_score = 0
279
+ best_orders = []
280
+
281
+ for r in permutations(cands):
282
+
283
+ score_of_r = 0
284
+ for i in r[:-1]:
285
+ for j in r[r.index(i)+1:]:
286
+ score_of_r += (prof.plurality_scores(curr_cands=curr_cands)[i] * prof.plurality_scores(curr_cands=curr_cands)[j] * prof.margin(i,j))
287
+
288
+ if score_of_r > best_order_score:
289
+ best_order_score = score_of_r
290
+ best_orders = [r]
291
+ if score_of_r == best_order_score:
292
+ best_orders.append(r)
293
+
294
+ if show_orders == True:
295
+ print(f"Best orders: {set(best_orders)}")
296
+
297
+ winners = [r[0] for r in best_orders]
298
+
299
+ return list(set(winners))
300
+
301
+ ### Bucklin
302
+
303
+ @vm(name = "Bucklin",
304
+ input_types = [ElectionTypes.PROFILE])
305
+ def bucklin(profile, curr_cands = None):
306
+ """If a candidate has a strict majority of first-place votes, then that candidate is the winner. If no such candidate exists, then check the candidates that are ranked first or second. If a candidate has a strict majority of first- or second-place voters, then that candidate is the winner. If no such winner is found move on to the 3rd, 4th, etc. place votes. Return the candidates with the greatest overall score.
307
+
308
+ Args:
309
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
310
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
311
+
312
+ Returns:
313
+ A sorted list of candidates
314
+
315
+ :Example:
316
+
317
+ .. exec_code::
318
+
319
+ from pref_voting.profiles import Profile
320
+ from pref_voting.other_methods import bucklin
321
+
322
+ prof = Profile([[1, 0, 2], [0, 2, 1], [0, 1, 2]], [2, 1, 1])
323
+
324
+ prof.display()
325
+ bucklin.display(prof)
326
+
327
+ """
328
+ strict_maj_size = profile.strict_maj_size()
329
+
330
+ candidates = profile.candidates if curr_cands is None else curr_cands
331
+
332
+ num_cands = candidates
333
+
334
+ rankings = profile._rankings if curr_cands is None else _find_updated_profile(profile._rankings, np.array([c for c in profile.candidates if c not in curr_cands]), profile.num_cands)
335
+
336
+ rcounts = profile._rcounts
337
+
338
+ num_cands = len(candidates)
339
+ ranks = range(1, num_cands + 1)
340
+
341
+ cand_to_num_voters_rank = dict()
342
+ for r in ranks:
343
+ cand_to_num_voters_rank[r] = {c: _num_rank(rankings, rcounts, c, r)
344
+ for c in candidates}
345
+ cand_scores = {c:sum([cand_to_num_voters_rank[_r][c] for _r in cand_to_num_voters_rank.keys()])
346
+ for c in candidates}
347
+ if any([s >= strict_maj_size for s in cand_scores.values()]):
348
+ break
349
+ max_score = max(cand_scores.values())
350
+ return sorted([c for c in candidates if cand_scores[c] >= max_score])
351
+
352
+
353
+ def bucklin_with_explanation(profile, curr_cands = None):
354
+ """Return the Bucklin winners and the score for each candidate.
355
+
356
+ Args:
357
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
358
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
359
+
360
+ Returns:
361
+
362
+ A sorted list of candidates
363
+
364
+ A dictionary assigning the score for each candidate.
365
+
366
+ :Example:
367
+
368
+ .. exec_code::
369
+
370
+ from pref_voting.profiles import Profile
371
+ from pref_voting.other_methods import bucklin_with_explanation
372
+
373
+ prof = Profile([[1, 0, 2], [0, 2, 1], [0, 1, 2]], [2, 1, 1])
374
+
375
+ prof.display()
376
+ sb_ws, scores = bucklin_with_explanation(prof)
377
+
378
+ print(f"The winners are {sb_ws}")
379
+ print(f"The candidate scores are {scores}")
380
+
381
+ """
382
+ strict_maj_size = profile.strict_maj_size()
383
+
384
+ candidates = profile.candidates if curr_cands is None else curr_cands
385
+
386
+ num_cands = candidates
387
+
388
+ rankings = profile._rankings if curr_cands is None else _find_updated_profile(profile._rankings, np.array([c for c in profile.candidates if c not in curr_cands]), profile.num_cands)
389
+
390
+ rcounts = profile._rcounts
391
+
392
+ num_cands = len(candidates)
393
+ ranks = range(1, num_cands + 1)
394
+
395
+ cand_to_num_voters_rank = dict()
396
+ for r in ranks:
397
+ cand_to_num_voters_rank[r] = {c: _num_rank(rankings, rcounts, c, r)
398
+ for c in candidates}
399
+ cand_scores = {c:sum([cand_to_num_voters_rank[_r][c] for _r in cand_to_num_voters_rank.keys()])
400
+ for c in candidates}
401
+ if any([s >= strict_maj_size for s in cand_scores.values()]):
402
+ break
403
+ max_score = max(cand_scores.values())
404
+ return sorted([c for c in candidates if cand_scores[c] >= max_score]), cand_scores
405
+
406
+ @vm(name="Bucklin for Truncated Linear Orders",
407
+ input_types=[ElectionTypes.PROFILE_WITH_TIES])
408
+ def bucklin_for_truncated_linear_orders(profile, curr_cands=None):
409
+ """The Bucklin voting method adapted for truncated linear orders using ProfileWithTies.
410
+
411
+ Args:
412
+ profile: A ProfileWithTies object that represents an election profile.
413
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in curr_cands
414
+
415
+ Returns:
416
+ A sorted list of candidates that win according to the Bucklin method.
417
+
418
+ .. note::
419
+ This is an adaptation of the Bucklin method for truncated linear orders. Empty ballots are removed before calculating
420
+ the majority threshold, similar to how instant_runoff_for_truncated_linear_orders handles truncated profiles.
421
+ """
422
+ assert all([not r.has_overvote() for r in profile.rankings]), "Bucklin is only defined when all the ballots are truncated linear orders."
423
+
424
+ # Remove empty rankings and get working copy of profile
425
+ working_profile = copy.deepcopy(profile)
426
+ working_profile.remove_empty_rankings()
427
+
428
+ strict_maj_size = working_profile.strict_maj_size()
429
+ candidates = working_profile.candidates if curr_cands is None else curr_cands
430
+
431
+ if curr_cands is not None:
432
+ working_profile = working_profile.remove_candidates([c for c in working_profile.candidates if c not in curr_cands])
433
+ rcounts = working_profile.rcounts
434
+
435
+ num_cands = len(candidates)
436
+ ranks = range(1, num_cands + 1)
437
+
438
+ # Get rankings and their counts
439
+ rankings, rcounts = working_profile.rankings_counts
440
+
441
+ # Track scores at each rank level
442
+ cand_to_num_voters_rank = dict()
443
+ for r in range(1, num_cands + 1):
444
+ cand_to_num_voters_rank[r] = {c: _num_rank_profile_with_ties(rankings, rcounts, c, r)
445
+ for c in candidates}
446
+ # Calculate cumulative scores up to current rank
447
+ cand_scores = {c: sum([cand_to_num_voters_rank[_r][c] for _r in range(1, r + 1)])
448
+ for c in candidates}
449
+
450
+ # Check if any candidate has a majority
451
+ if any([s >= strict_maj_size for s in cand_scores.values()]):
452
+ max_score = max(cand_scores.values())
453
+ return sorted([c for c in candidates if cand_scores[c] >= max_score])
454
+
455
+ # If no candidate has majority, return those with highest cumulative score
456
+ max_score = max(cand_scores.values())
457
+ return sorted([c for c in candidates if cand_scores[c] >= max_score])
458
+
459
+
460
+ @vm(name = "Simplified Bucklin",
461
+ input_types = [ElectionTypes.PROFILE])
462
+ def simplified_bucklin(profile, curr_cands = None):
463
+ """If a candidate has a strict majority of first-place votes, then that candidate is the winner. If no such candidate exists, then check the candidates that are ranked first or second. If a candidate has a strict majority of first- or second-place voters, then that candidate is the winner. If no such winner is found move on to the 3rd, 4th, etc. place votes.
464
+
465
+ Args:
466
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
467
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
468
+
469
+ Returns:
470
+ A sorted list of candidates
471
+
472
+ :Example:
473
+
474
+ .. exec_code::
475
+
476
+ from pref_voting.profiles import Profile
477
+ from pref_voting.other_methods import simplified_bucklin
478
+
479
+ prof = Profile([[1, 0, 2], [0, 2, 1], [0, 1, 2]], [2, 1, 1])
480
+
481
+ prof.display()
482
+ simplified_bucklin.display(prof)
483
+
484
+ """
485
+ strict_maj_size = profile.strict_maj_size()
486
+
487
+ candidates = profile.candidates if curr_cands is None else curr_cands
488
+
489
+ num_cands = candidates
490
+
491
+ rankings = profile._rankings if curr_cands is None else _find_updated_profile(profile._rankings, np.array([c for c in profile.candidates if c not in curr_cands]), profile.num_cands)
492
+
493
+ rcounts = profile._rcounts
494
+
495
+ num_cands = len(candidates)
496
+ ranks = range(1, num_cands + 1)
497
+
498
+ cand_to_num_voters_rank = dict()
499
+ for r in ranks:
500
+ cand_to_num_voters_rank[r] = {c: _num_rank(rankings, rcounts, c, r)
501
+ for c in candidates}
502
+ cand_scores = {c:sum([cand_to_num_voters_rank[_r][c] for _r in cand_to_num_voters_rank.keys()])
503
+ for c in candidates}
504
+ if any([s >= strict_maj_size for s in cand_scores.values()]):
505
+ break
506
+
507
+ return sorted([c for c in candidates if cand_scores[c] >= strict_maj_size])
508
+
509
+ def simplified_bucklin_with_explanation(profile, curr_cands = None):
510
+ """Return the Simplified Bucklin winners and the score for each candidate.
511
+
512
+ Args:
513
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
514
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
515
+
516
+ Returns:
517
+
518
+ A sorted list of candidates
519
+
520
+ A dictionary assigning the score for each candidate.
521
+
522
+ :Example:
523
+
524
+ .. exec_code::
525
+
526
+ from pref_voting.profiles import Profile
527
+ from pref_voting.other_methods import simplified_bucklin_with_explanation
528
+
529
+ prof = Profile([[1, 0, 2], [0, 2, 1], [0, 1, 2]], [2, 1, 1])
530
+
531
+ prof.display()
532
+ sb_ws, scores = simplified_bucklin_with_explanation(prof)
533
+
534
+ print(f"The winners are {sb_ws}")
535
+ print(f"The candidate scores are {scores}")
536
+
537
+ """
538
+ strict_maj_size = profile.strict_maj_size()
539
+
540
+ candidates = profile.candidates if curr_cands is None else curr_cands
541
+
542
+ num_cands = candidates
543
+
544
+ rankings = profile._rankings if curr_cands is None else _find_updated_profile(profile._rankings, np.array([c for c in profile.candidates if c not in curr_cands]), profile.num_cands)
545
+
546
+ rcounts = profile._rcounts
547
+
548
+ num_cands = len(candidates)
549
+ ranks = range(1, num_cands + 1)
550
+
551
+ cand_to_num_voters_rank = dict()
552
+ for r in ranks:
553
+ cand_to_num_voters_rank[r] = {c: _num_rank(rankings, rcounts, c, r)
554
+ for c in candidates}
555
+ cand_scores = {c:sum([cand_to_num_voters_rank[_r][c] for _r in cand_to_num_voters_rank.keys()])
556
+ for c in candidates}
557
+ if any([s >= strict_maj_size for s in cand_scores.values()]):
558
+ break
559
+
560
+ return sorted([c for c in candidates if cand_scores[c] >= strict_maj_size]), cand_scores
561
+
562
+ @vm(name = "Weighted Bucklin",
563
+ input_types = [ElectionTypes.PROFILE])
564
+ def weighted_bucklin(profile, curr_cands = None, strict_threshold = False, score = lambda num_cands, rank: (num_cands - rank)/ (num_cands - 1) if num_cands > 1 else 1):
565
+ """The Weighted Bucklin procedure, studied by D. Marc Kilgour, Jean-Charles Grégoire, and Angèle Foley. The k-th Weighted Bucklin score of a candidate c is the sum for j \leq k of the product of score(num_cands,j) and the number of voters who rank c in j-th place. Compute higher-order Weighted Bucklin scores until reaching a k such that some candidate's k-th Weighted Bucklin score is at least half the number of voters (or the strict majority size if strict_threshold = True). Then return the candidates with maximal k-th Weighted Bucklin score. Bucklin is the special case where strict_threshold = True and score = lambda num_cands, rank: 1.
566
+
567
+ Args:
568
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
569
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
570
+ strict_threshold: If True, makes the threshold for the Bucklin procedure the strict majority size; otherwise threshold is half the number of voters, following Kilgour et al.
571
+ score (function): A function that accepts two parameters ``num_cands`` (the number of candidates) and ``rank`` (a rank of a candidate) used to calculate the score of a candidate. The default ``score`` function is the normalized version of the classic Borda score vector.
572
+
573
+ Returns:
574
+ A sorted list of candidates
575
+
576
+ :Example:
577
+
578
+ .. exec_code::
579
+
580
+ from pref_voting.profiles import Profile
581
+ from pref_voting.other_methods import weighted_bucklin
582
+
583
+ prof = Profile([[1, 0, 2], [0, 2, 1], [0, 1, 2]], [2, 1, 1])
584
+
585
+ prof.display()
586
+ weighted_bucklin.display(prof)
587
+
588
+ """
589
+ if strict_threshold == True:
590
+ threshold = profile.strict_maj_size()
591
+ else:
592
+ threshold = profile.num_voters / 2
593
+
594
+ candidates = profile.candidates if curr_cands is None else curr_cands
595
+
596
+ num_cands = candidates
597
+
598
+ rankings = profile._rankings if curr_cands is None else _find_updated_profile(profile._rankings, np.array([c for c in profile.candidates if c not in curr_cands]), profile.num_cands)
599
+
600
+ rcounts = profile._rcounts
601
+
602
+ num_cands = len(candidates)
603
+ ranks = range(1, num_cands + 1)
604
+
605
+ cand_to_num_voters_rank = dict()
606
+ for r in ranks:
607
+ cand_to_num_voters_rank[r] = {c: _num_rank(rankings, rcounts, c, r)
608
+ for c in candidates}
609
+ cand_scores = {c:sum([score(len(candidates), _r) * cand_to_num_voters_rank[_r][c] for _r in cand_to_num_voters_rank.keys()])
610
+ for c in candidates}
611
+ if any([s >= threshold for s in cand_scores.values()]):
612
+ break
613
+ max_score = max(cand_scores.values())
614
+
615
+ return sorted([c for c in candidates if cand_scores[c] >= max_score])
616
+
617
+ def _dodgson_score(profile, cand):
618
+ """
619
+ Return the **Dodgson score** of ``cand`` in ``profile``.
620
+
621
+ The Dodgson score of a candidate *c* is the minimum number of
622
+ *adjacent* swaps, summed over *all* ballots, needed to make *c* the
623
+ **Condorcet winner**.
624
+
625
+ This is equivalent to the the minimum number of places that $c$
626
+ must *move up* in ballots to become a Condorcet winner
627
+ (see Lemma 4.0.5 of John C. McCabe-Dansted,
628
+ *Approximability and computational feasibility of Dodgson’s rule*.
629
+ Master’s thesis, University of Auckland, 2006.)
630
+
631
+ We formulate this as a *mixed‑integer program* and solve it with
632
+ OR‑Tools/SCIP:
633
+
634
+ • Variables x[r, k] = number of voters with ranking r who move
635
+ *c* upward by *exactly* k positions (0 ≤ k ≤ pos₍r₎(c)).
636
+ • Objective: minimise Σ k · x[r, k] (total swaps).
637
+ • Constraints
638
+ – Partition: for each distinct ranking r, the x[r,·] must
639
+ sum to the observed multiplicity of r.
640
+ – Condorcet: for every rival d ≠ c, after the moves
641
+ *c* must beat d by a strict majority.
642
+
643
+ Parameters
644
+ ----------
645
+ profile : pref_voting.profiles.Profile
646
+ cand : int, the candidate whose Dodgson score we compute.
647
+
648
+ Returns
649
+ -------
650
+ int, the Dodgson score of *cand*.
651
+ """
652
+ rankings, counts = profile.rankings_counts # ranking types & their multiplicities
653
+ majority = profile.strict_maj_size() # ⌊number of voters / 2⌋ + 1
654
+
655
+ solver = pywraplp.Solver.CreateSolver("SCIP")
656
+ if solver is None:
657
+ raise EnvironmentError("This OR‑Tools build lacks a MIP solver.")
658
+
659
+ # Variables x[r, k]
660
+ #
661
+ # For each ranking type and each possible upward
662
+ # move k (0..pos_c) we create an *integer* variable representing
663
+ # the number of voters with that ranking who move *c* upward by
664
+ # exactly k positions.
665
+ #
666
+ # The partition constraints (one per ranking) ensure that we do not
667
+ # duplicate or delete ballots but only *re‑order* the existing ones.
668
+
669
+ x = {} # maps (r_idx, k) -> IntVar
670
+ for r_idx, (ranking, w_np) in enumerate(zip(rankings, counts)):
671
+ w = int(w_np) # NumPy scalar → Python int (SCIP wants int bounds)
672
+ order = list(ranking)
673
+ pos_c = order.index(cand) # 0‑based position of cand in this ballot
674
+
675
+ # create variables for k = 0 … pos_c
676
+ for k in range(pos_c + 1):
677
+ x[(r_idx, k)] = solver.IntVar(0, w, f"x_{r_idx}_{k}")
678
+
679
+ # partition constraint Σ_k x[r,k] = multiplicity of ranking r
680
+ solver.Add(solver.Sum(x[(r_idx, k)] for k in range(pos_c + 1)) == w)
681
+
682
+ # Condorcet constraints: make cand beat every rival d ≠ cand
683
+ # For each rival d we compare:
684
+ # current_support – voters already preferring cand over d
685
+ # contributed_flips – voters whose ballots we move enough for cand
686
+ # to pass d (k ≥ distance in that ranking)
687
+ # The sum must reach the strict majority threshold.
688
+ for d in range(profile.num_cands):
689
+ if d == cand:
690
+ continue # skip self‑comparison
691
+
692
+ current_support = profile.support(cand, d)
693
+ flip_terms = []
694
+
695
+ # identify voters who can move cand up by k positions to go above d
696
+ for r_idx, ranking in enumerate(rankings):
697
+ order = list(ranking)
698
+ pos_c = order.index(cand)
699
+ pos_d = order.index(d)
700
+ if pos_d < pos_c: # cand currently below d
701
+ dist = pos_c - pos_d # minimum upward steps to pass d
702
+ for k in range(dist, pos_c + 1): # any k ≥ dist suffices
703
+ flip_terms.append(x[(r_idx, k)])
704
+
705
+ # enforce majority: support_after ≥ majority
706
+ solver.Add(current_support + solver.Sum(flip_terms) >= majority)
707
+
708
+ # Objective: minimise total adjacent swaps
709
+ # Each voter who moves cand up by k positions contributes exactly k swaps.
710
+ solver.Minimize(
711
+ solver.Sum(k * var for (r_idx, k), var in x.items() if k > 0)
712
+ )
713
+
714
+ if solver.Solve() != pywraplp.Solver.OPTIMAL:
715
+ raise RuntimeError("SCIP failed to prove optimality.")
716
+
717
+ return int(solver.Objective().Value())
718
+
719
+ @vm(name="Dodgson",
720
+ input_types = [ElectionTypes.PROFILE])
721
+ def dodgson(profile, curr_cands=None, global_score = True):
722
+ """The Dodgson score of a candidate is the minimum number of adjacent swaps in the ballots needed to make them a Condorcet winner. The Dodgson method selects the candidate with the minimum Dodgson score.
723
+
724
+ Args:
725
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
726
+ curr_cands (List[int], optional): If set, then find the candidates in curr_cands with the best Dodgson score.
727
+ global_score (bool, optional): If True, then the Dodgson score is computed using the entire profile, including candidates not in curr_cands. If False, then the Dodgson score is computed using the profile restricted to the candidates in curr_cands.
728
+
729
+ Returns:
730
+ A sorted list of candidates
731
+
732
+ """
733
+ if curr_cands is None:
734
+ curr_cands = profile.candidates
735
+
736
+ if not global_score:
737
+ profile.remove_candidates([c for c in profile.candidates if c not in curr_cands])
738
+
739
+ scores = {c: _dodgson_score(profile, c) for c in curr_cands}
740
+
741
+ best = min(scores.values())
742
+ return sorted([c for c, s in scores.items() if s == best])
743
+
744
+ @vm(name = "Bracket Voting",
745
+ input_types = [ElectionTypes.PROFILE])
746
+ def bracket_voting(profile, curr_cands = None, seed = None, tie_break = "random"):
747
+ """The candidates with the top four plurality scores are seeded into a bracket: the candidate with the highest plurality score is seeded 1st, the candidate with the second highest plurality score is seeded 2nd, etc. The 1st seed faces the 4th seed in a head-to-head match decided by majority rule, and the 2nd seed faces the 3rd seed in a head-to-head match decided by majority rule. The winners of these two matches face each other in a final head-to-head match decided by majority rule. The winner of the final is the winner of the election.
748
+
749
+ .. note::
750
+ A version of bracket voting as proposed by Edward B. Foley. By default, this is a probabilistic method that always returns a unique winner, as ties are broken using a random tie breaking ordering of the candidates.
751
+
752
+ Args:
753
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
754
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
755
+ seed (int, optional): The seed for the random tie breaking ordering of the candidates.
756
+ tie_break (str, optional): The method used to break ties in the head-to-head matches. If set to "random", then a random tie breaking ordering of the candidates is used. If set to "lexicographic", then the candidates are sorted lexicographically.
757
+ Returns:
758
+ A sorted list of candidates
759
+
760
+ """
761
+ cands = curr_cands if curr_cands else profile.candidates
762
+
763
+ if len(cands) == 2:
764
+ return plurality(profile, curr_cands = curr_cands)
765
+
766
+ if tie_break == "random":
767
+ rng = np.random.default_rng(seed)
768
+ tie_breaking_ordering = cands.copy()
769
+ rng.shuffle(tie_breaking_ordering)
770
+
771
+ elif tie_break == "lexicographic":
772
+ tie_breaking_ordering = sorted(cands)
773
+
774
+ plurality_scores = profile.plurality_scores(curr_cands = cands)
775
+ descending_plurality_scores = sorted(plurality_scores.values(), reverse=True)
776
+
777
+ # If there is a tie for max plurality score, the first seed is the candidate with max plurality score who appears first in the tie breaking ordering
778
+ potential_first_seeds = [c for c in cands if plurality_scores[c] == descending_plurality_scores[0]]
779
+ first_seed = min(potential_first_seeds, key = lambda c: tie_breaking_ordering.index(c))
780
+
781
+ potential_second_seeds = [c for c in cands if plurality_scores[c] == descending_plurality_scores[1] and c != first_seed]
782
+ second_seed = min(potential_second_seeds, key = lambda c: tie_breaking_ordering.index(c))
783
+
784
+ potential_third_seeds = [c for c in cands if plurality_scores[c] == descending_plurality_scores[2] and c not in [first_seed, second_seed]]
785
+ third_seed = min(potential_third_seeds, key = lambda c: tie_breaking_ordering.index(c))
786
+
787
+ potential_fourth_seeds = [c for c in cands if plurality_scores[c] == descending_plurality_scores[3] and c not in [first_seed, second_seed, third_seed]] if len(cands) > 3 else []
788
+ fourth_seed = min(potential_fourth_seeds, key = lambda c: tie_breaking_ordering.index(c)) if len(potential_fourth_seeds) > 0 else None
789
+
790
+ # Ties in semi-final head-to-head matches are broken in favor of the higher-seeded candidate
791
+ if len(cands) == 3:
792
+ one_four_winner = first_seed
793
+ one_four_winner_seed = 1
794
+ else:
795
+ one_four_winner = first_seed if profile.margin(first_seed, fourth_seed) >= 0 else fourth_seed
796
+ one_four_winner_seed = 1 if one_four_winner == first_seed else 4
797
+
798
+ two_three_winner = second_seed if profile.margin(second_seed, third_seed) >= 0 else third_seed
799
+ two_three_winner_seed = 2 if two_three_winner == second_seed else 3
800
+
801
+ if profile.margin(one_four_winner, two_three_winner) > 0:
802
+ winner = one_four_winner
803
+
804
+ elif profile.margin(one_four_winner, two_three_winner) < 0:
805
+ winner = two_three_winner
806
+
807
+ # Ties in the final head-to-head match are broken in favor of the higher-seeded candidate
808
+ else:
809
+ winner = one_four_winner if one_four_winner_seed < two_three_winner_seed else two_three_winner
810
+
811
+ return [winner]
812
+
813
+ @vm(name = "Superior Voting",
814
+ input_types = [ElectionTypes.PROFILE])
815
+ def superior_voting(profile, curr_cands = None):
816
+ """One candidate is superior to another if more ballots rank the first candidate above the second than vice versa. A candidate earns a point from a ballot if they are ranked first on that ballot or they are superior to the candidate ranked first on that ballot. The candidate with the most points wins.
817
+
818
+ .. note::
819
+ Devised by Wesley H. Holliday as a simple Condorcet-compliant method for political elections. Always elects a Condorcet winner if one exists and elects only the Condorcet winner provided the Condorcet winner receives at least one first-place vote. Edward B. Foley suggested the name 'Superior Voting' because the method is based on the idea that if A is superior to B, then A should get B's first-place votes added to their own.
820
+
821
+ Args:
822
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
823
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
824
+
825
+ Returns:
826
+ A sorted list of candidates
827
+
828
+ """
829
+ curr_cands = profile.candidates if curr_cands is None else curr_cands
830
+
831
+ # Calculate the points for each candidate
832
+ points = {cand: profile.plurality_scores(curr_cands)[cand] for cand in curr_cands}
833
+ for cand in curr_cands:
834
+ for other_cand in curr_cands:
835
+ if profile.margin(cand, other_cand) > 0:
836
+ points[cand] += profile.plurality_scores(curr_cands)[other_cand]
837
+
838
+ # Find the candidates with the most points
839
+ max_score = max(points.values())
840
+ winners = [cand for cand in curr_cands if points[cand] == max_score]
841
+
842
+ return winners
843
+
844
+ def bt_mle(pmat, max_iter=100):
845
+ """Lucas Maystre's implementation of MLE for the Bradley-Terry model (https://datascience.stackexchange.com/questions/18828/from-pairwise-comparisons-to-ranking-python).
846
+
847
+ Note we change the interpretation of p_{i,j} to be the probability that i is preferred to j, rather than vice versa as in the original implementation.
848
+ """
849
+ n = pmat.shape[0]
850
+ wins = np.sum(pmat, axis=1)
851
+ params = np.ones(n, dtype=float)
852
+ for _ in range(max_iter):
853
+ tiled = np.tile(params, (n, 1))
854
+ combined = 1.0 / (tiled + tiled.T)
855
+ np.fill_diagonal(combined, 0)
856
+ nxt = wins / np.sum(combined, axis=0)
857
+ nxt = nxt / np.mean(nxt)
858
+ if np.linalg.norm(nxt - params, ord=np.inf) < 1e-6:
859
+ return nxt
860
+ params = nxt
861
+ raise RuntimeError('did not converge')
862
+
863
+ @vm(name = "Bradley-Terry",
864
+ input_types = [ElectionTypes.PROFILE])
865
+ def bradley_terry(prof, curr_cands = None, threshold = .00001):
866
+ """The Bradley-Terry model is a probabilistic model for pairwise comparisons. In this model, the probability that a voter prefers candidate i to candidate j is given by p_{i,j} = v_i / (v_i + v_j), where v_i is the strength of candidate i. Given a profile, we take p_{i,j} to be the proportion of voters who prefer candidate i to candidate j. We then estimate the strength of each candidate using maximum likelihood estimation. The winning candidates are those whose estimated strength is within +/- threshold of the maximum strength.
867
+
868
+ .. note::
869
+ For profiles of linear ballots, this is equivalent to Borda (see Theorem 3.1 of https://arxiv.org/abs/2312.08358).
870
+
871
+ Args:
872
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
873
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
874
+ threshold (float, optional): The threshold for determining the winners. The winners are those whose estimated strength is within +/- threshold of the maximum strength.
875
+
876
+ Returns:
877
+ A sorted list of candidates
878
+ """
879
+
880
+ curr_cands = prof.candidates if curr_cands is None else curr_cands
881
+
882
+ prop_matrix = np.zeros((len(curr_cands), len(curr_cands)))
883
+
884
+ for i, c in enumerate(curr_cands):
885
+ for j, d in enumerate(curr_cands):
886
+ if i != j:
887
+ prop_matrix[i][j] = prof.support(c,d) / prof.num_voters
888
+
889
+ params = bt_mle(prop_matrix)
890
+
891
+ max_value = np.max(params)
892
+ winner_indices = np.where(np.abs(params - max_value) <= threshold)[0]
893
+ winners = [curr_cands[i] for i in winner_indices]
894
+
895
+ return sorted(winners)
896
+
897
+ @swf(name = "Bradley-Terry Ranking")
898
+ def bradley_terry_ranking(prof, curr_cands = None, threshold = .00001):
899
+ """The Bradley-Terry model is a probabilistic model for pairwise comparisons. In this model, the probability that a voter prefers candidate i to candidate j is given by p_{i,j} = v_i / (v_i + v_j), where v_i is the strength of candidate i. Given a profile, we take p_{i,j} to be the proportion of voters who prefer candidate i to candidate j. We then estimate the strength of each candidate using maximum likelihood estimation. Finally, the candidates are ranked in decreasing order of their estimated strength (where candidates whose estimated strength is within +/- threshold of each other are considered tied).
900
+
901
+ .. note::
902
+ For profiles of linear ballots, this is equivalent to Borda (see Theorem 3.1 of https://arxiv.org/abs/2312.08358).
903
+
904
+ Args:
905
+ profile (Profile): An anonymous profile of linear orders on a set of candidates
906
+ curr_cands (List[int], optional): If set, then find the winners for the profile restricted to the candidates in ``curr_cands``
907
+ threshold (float, optional): The threshold for equivalence classes of candidates.
908
+
909
+ Returns:
910
+ A Ranking object.
911
+ """
912
+
913
+ curr_cands = prof.candidates if curr_cands is None else curr_cands
914
+
915
+ support_matrix = np.zeros((len(curr_cands), len(curr_cands)))
916
+
917
+ for i, c in enumerate(curr_cands):
918
+ for j, d in enumerate(curr_cands):
919
+ if i != j:
920
+ support_matrix[i][j] = prof.support(c,d) / prof.num_voters
921
+
922
+ params = bt_mle(support_matrix)
923
+
924
+ ranking_dict = dict()
925
+ cands_assigned = list()
926
+ curr_ranking = 1
927
+
928
+ while len(cands_assigned) < len(curr_cands):
929
+
930
+ max_value = np.max([params[curr_cands.index(c)] for c in curr_cands if c not in cands_assigned])
931
+
932
+ for c in curr_cands:
933
+ if c not in cands_assigned and np.abs(params[curr_cands.index(c)] - max_value) <= threshold:
934
+ ranking_dict[c] = curr_ranking
935
+ cands_assigned.append(c)
936
+
937
+ curr_ranking += 1
938
+
939
+ return Ranking(ranking_dict)