diff --git a/src/vse_sim/methods/__init__.py b/src/vse_sim/methods/__init__.py index e913cd2..73d5632 100644 --- a/src/vse_sim/methods/__init__.py +++ b/src/vse_sim/methods/__init__.py @@ -1,4 +1,4 @@ -from .borda import Borda, RankedMethod, RatedMethod +from .borda import Borda from .bullety_approval import BulletyApprovalWith from .irnr import IRNR from .irv import Irv @@ -6,6 +6,7 @@ from .mav import Mav, toVote from .mj import Mj from .plurality import Plurality +from .ranked import RankedMethod, RatedMethod from .ranked_pairs import Rp from .schulze import Schulze from .score import Score diff --git a/src/vse_sim/methods/borda.py b/src/vse_sim/methods/borda.py index 13b9ee7..b86c4bb 100644 --- a/src/vse_sim/methods/borda.py +++ b/src/vse_sim/methods/borda.py @@ -1,81 +1,14 @@ from numpy import mean -from ..core import Method, rememberBallot -from ..voter_models import Voter # noqa: F401 +from .ranked import RankedMethod -class Borda(Method): +class Borda(RankedMethod): """Implement Borda count with larger rank values representing preference. Honest ballots assign consecutive scores from least to most preferred. - Ranked methods also inherit this class's ballot construction and strategy - helpers. """ candScore = staticmethod(mean) nRanks = 999 # infinity - - @staticmethod - def fillPrefOrder(voter, ballot, - whichCands=None, #None means "all"; otherwise, an iterable of cand indexes - lowSlot=0, - nSlots=None, #again, None means "all" - remainderScore=None #what to give candidates that don't fit in nSlots - ): - - venum = list(enumerate(voter)) - if whichCands: - venum = [venum[c] for c in whichCands] - prefOrder = sorted(venum,key=lambda x:-x[1]) #high to low - Borda.fillCands(ballot, prefOrder, lowSlot, nSlots, remainderScore) - #modifies ballot argument, returns nothing. - - @staticmethod - def fillCands(ballot, - whichCands, #list of tuples starting with cand id, in descending order - lowSlot=0, - nSlots=None, #again, None means "all" - remainderScore=None #what to give candidates that don't fit in nSlots - ): - if nSlots is None: - nSlots = len(whichCands) - cur = lowSlot + nSlots - 1 - for i in range(nSlots): - ballot[whichCands[i][0]] = cur - cur -= 1 - if remainderScore is not None: - i += 1 - while i < len(whichCands): - ballot[whichCands[i][0]] = remainderScore - i += 1 - #modifies ballot argument, returns nothing. - - @staticmethod #cls is provided explicitly, not through binding - @rememberBallot - def honBallot(cls, utils): - ballot = [0] * len(utils) - cls.fillPrefOrder(utils, ballot) - return ballot - - - @classmethod - def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, - frontId, frontResult, targId, targResult): - """Mutates the `ballot` argument to be a strategic ballot. - - >>> Borda().stratBallotFor([4,5,2,1])(Borda, Voter([-4,-5,-2,-1])) - [3, 0, 1, 2] - """ - nRanks = min(cls.nRanks,n) - if stratGap <= 0: - ballot[frontId], ballot[targId] = (nRanks - 1), 0 - else: - ballot[frontId], ballot[targId] = 0, (nRanks - 1) - nRanks -= 2 - if nRanks > 0: - cls.fillCands(ballot, places[2:][::-1], - lowSlot=1, nSlots=nRanks, remainderScore=0) - -RankedMethod = Borda #alias -RatedMethod = RankedMethod #Should have same strategies available, plus more diff --git a/src/vse_sim/methods/irnr.py b/src/vse_sim/methods/irnr.py index 12511d5..4f39225 100644 --- a/src/vse_sim/methods/irnr.py +++ b/src/vse_sim/methods/irnr.py @@ -1,5 +1,5 @@ from ..core import Method, rememberBallot -from .borda import RankedMethod +from .ranked import RankedMethod class IRNR(RankedMethod): diff --git a/src/vse_sim/methods/plurality.py b/src/vse_sim/methods/plurality.py index 445633b..0ea44d0 100644 --- a/src/vse_sim/methods/plurality.py +++ b/src/vse_sim/methods/plurality.py @@ -1,6 +1,8 @@ +from numpy import mean + from ..core import rememberBallot from ..voter_models import Voter # noqa: F401 -from .borda import RankedMethod +from .ranked import RankedMethod class Plurality(RankedMethod): @@ -10,6 +12,7 @@ class Plurality(RankedMethod): and every other candidate receives zero. """ + candScore = staticmethod(mean) nRanks = 2 @staticmethod diff --git a/src/vse_sim/methods/ranked.py b/src/vse_sim/methods/ranked.py new file mode 100644 index 0000000..e58a7a3 --- /dev/null +++ b/src/vse_sim/methods/ranked.py @@ -0,0 +1,88 @@ +from ..core import Method, rememberBallot + + +class RankedMethod(Method): + """Base class for methods that use candidate-aligned rank vectors. + + Larger ballot values represent stronger preferences. The class provides + shared helpers for constructing complete or truncated ranked ballots and + the default ranked-method strategy used by Borda and plurality. + """ + + @staticmethod + def fillPrefOrder( + voter, + ballot, + whichCands=None, + lowSlot=0, + nSlots=None, + remainderScore=None, + ): + """Fill ``ballot`` with candidates ordered by decreasing utility.""" + venum = list(enumerate(voter)) + if whichCands: + venum = [venum[c] for c in whichCands] + prefOrder = sorted(venum, key=lambda x: -x[1]) + RankedMethod.fillCands( + ballot, prefOrder, lowSlot, nSlots, remainderScore + ) + + @staticmethod + def fillCands( + ballot, + whichCands, + lowSlot=0, + nSlots=None, + remainderScore=None, + ): + """Assign descending ranks to candidate tuples in ``whichCands``.""" + if nSlots is None: + nSlots = len(whichCands) + cur = lowSlot + nSlots - 1 + for i in range(nSlots): + ballot[whichCands[i][0]] = cur + cur -= 1 + if remainderScore is not None: + for candidate, *_ in whichCands[nSlots:]: + ballot[candidate] = remainderScore + + @staticmethod + @rememberBallot + def honBallot(cls, utils): + """Return a complete rank vector ordered by utility.""" + ballot = [0] * len(utils) + cls.fillPrefOrder(utils, ballot) + return ballot + + @classmethod + def fillStratBallot( + cls, + voter, + polls, + places, + n, + stratGap, + ballot, + frontId, + frontResult, + targId, + targResult, + ): + """Mutate ``ballot`` with the default strategy for ranked methods.""" + nRanks = min(cls.nRanks, n) + if stratGap <= 0: + ballot[frontId], ballot[targId] = (nRanks - 1), 0 + else: + ballot[frontId], ballot[targId] = 0, (nRanks - 1) + nRanks -= 2 + if nRanks > 0: + cls.fillCands( + ballot, + places[2:][::-1], + lowSlot=1, + nSlots=nRanks, + remainderScore=0, + ) + + +RatedMethod = RankedMethod diff --git a/src/vse_sim/methods/schulze.py b/src/vse_sim/methods/schulze.py index 984f49b..d920ba4 100644 --- a/src/vse_sim/methods/schulze.py +++ b/src/vse_sim/methods/schulze.py @@ -1,7 +1,7 @@ from numpy import sign from ..voter_models import DeterministicModel # noqa: F401 -from .borda import RankedMethod +from .ranked import RankedMethod class Schulze(RankedMethod): diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 21030a6..89caa16 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -8,12 +8,51 @@ from scripts.recalculate_irv_pages import recalculate from vse_sim.core import SideTally from vse_sim.diagnostics import TRACE, setDebug, trace -from vse_sim.methods import Irv, Mav, Schulze, Score +from vse_sim.methods import ( + Borda, + Irv, + Mav, + Plurality, + RankedMethod, + RatedMethod, + Schulze, + Score, +) from vse_sim.simulation import CsvBatch, seedRandomGenerators from vse_sim.strategies import ProbChooser, beHon, beStrat from vse_sim.voter_models import Electorate, Voter +def test_ranked_method_is_a_base_class_separate_from_borda(): + assert RankedMethod is not Borda + assert RatedMethod is RankedMethod + assert issubclass(Borda, RankedMethod) + assert issubclass(Schulze, RankedMethod) + assert Borda.honBallot(Borda, Voter([4, 1, 6, 3])) == [2, 0, 3, 1] + assert Schulze.honBallot(Schulze, Voter([4, 1, 6, 3])) == [2, 0, 3, 1] + assert Borda().stratBallotFor([4, 5, 2, 1])( + Borda, Voter([-4, -5, -2, -1]) + ) == [3, 0, 1, 2] + assert Plurality().results([[1, 0], [1, 0], [0, 1]]) == [ + pytest.approx(2 / 3), + pytest.approx(1 / 3), + ] + + +def test_ranked_fill_candidates_handles_zero_slots(): + ballot = [None, None] + + RankedMethod.fillCands( + ballot, + [(0, 2), (1, 1)], + nSlots=0, + remainderScore=0, + ) + + assert ballot == [0, 0] + RankedMethod.fillCands([], [], nSlots=0, remainderScore=0) + + def test_schulze_uses_independent_strongest_path_rows(): margins = [ [0, -3, 1],