diff --git a/.github/workflows/trunk.yml b/.github/workflows/trunk.yml index 903a726..73b01cb 100644 --- a/.github/workflows/trunk.yml +++ b/.github/workflows/trunk.yml @@ -1,20 +1,26 @@ -name: Annotate PR with trunk issues +name: Trunk Check on: - workflow_run: - workflows: ["Pull Request"] - types: - - completed + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + checks: write jobs: trunk_check: - name: Trunk Check Annotate + name: Trunk Check runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Trunk Check - uses: trunk-io/trunk-action@v1 + uses: trunk-io/trunk-action@75699af9e26881e564e9d832ef7dc3af25ec031b # v1.2.4 with: post-annotations: true diff --git a/.gitignore b/.gitignore index bfdd9f3..961ca84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,13 @@ *.pyc -python3 __pycache__ +.venv/ +.pytest_cache/ +*.egg-info/ .pydevproject *.csv +SimResults*.csv +newResults.txt +tenK.txt *.rtf *.bak .Rhistory diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 2a1093f..0ff2ccd 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -34,7 +34,7 @@ runtimes: enabled: - go@1.21.0 - node@22.16.0 - - python@3.14.4 + - python@3.12.10 actions: enabled: - trunk-announce diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f16fef1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,119 @@ +# AGENTS.md + +## Project + +This repository runs Monte Carlo simulations of Voter Satisfaction Efficiency +(VSE) for voting methods under different electorate and strategy models. The +published explanation and results live in `docs/`. + +The code currently uses a flat module layout. Run commands from the repository +root; do not assume the project is installed as a Python package. + +## Environment and validation + +- Supported Python: 3.10 through 3.12; local and CI default to Python 3.12. +- Dependency manager: `uv`; keep `uv.lock` in sync with `pyproject.toml`. +- Install dependencies with `uv sync --locked`. +- Run the test suite with `uv run python -m pytest`. +- Run repository lint and security checks with `trunk check`. +- Do not commit generated `SimResults*.csv` or ad hoc simulation dumps. + +Pytest is configured with `--doctest-modules`, so examples in module docstrings +are tests. Add focused pytest tests for regressions that are awkward to express +as doctests. Keep random tests deterministic and seed both Python's `random` +module and NumPy. + +## Code map + +- `vse.py`: simulation orchestration, method presets, and CSV output. +- `dataClasses.py`: core method API, tallies, ballot caching, and VSE rows. +- `methods.py`: voting method and ballot implementations. +- `voterModels.py`: voter, electorate, and spatial/clustered voter models. +- `stratFunctions.py`: strategic ballot choosers and media models. +- `mydecorators.py`: local decorators used throughout the simulation. +- `scripts/recalculate_irv_pages.py`: reproducible, parallel IRV calculations. +- `scripts/regenerate_pages_images.py`: generated HTML and chart updates. +- `docs/`: GitHub Pages source plus committed generated charts. +- `sodaTest.py`: experimental legacy code; do not make production code depend + on it. + +## Change guidance + +### Voting methods + +Voting methods derive from `dataClasses.Method`. Preserve the existing ballot +and result conventions unless a deliberate migration updates all callers: + +- candidate results are index-aligned sequences; +- the winning candidate is selected through `Method.winner`; +- ballot functions are memoized on voter objects by method class name; +- chooser names and tally fields are serialized into CSV and may be consumed by + scripts or published-data tooling. + +Add tests for ties, identical utilities, empty or minimal profiles, and cyclic +profiles as applicable. Do not infer correctness from one happy-path doctest. + +### Simulation state and randomness + +Election metadata is held in the method instance's `ElectionContext`, and Mav +cutoffs are captured by the election's ballot function. Keep this state +election-scoped: + +- reset it before each independent election; +- do not parallelize elections that share method classes unless state has first + been isolated; +- do not introduce new class-level mutable simulation state; +- extend `ElectionContext` instead of adding implicit cross-phase state. + +For reproducible runners, derive and set both Python and NumPy seeds. Prefer +local RNG objects in new code over adding more process-global RNG use. + +### Numerical behavior + +VSE normalizes by `best - rand`, and score ballots normalize by each voter's +utility range. Handle zero ranges explicitly. Define and test the intended +result rather than allowing `ZeroDivisionError`, NaN, or infinity. + +Avoid private NumPy import paths such as `numpy.core.*`; use public `numpy` +APIs. NumPy 2.x remains unsupported until behavioral compatibility has been +validated and the dependency bounds are deliberately updated. + +### Published results + +Changes to voter generation, strategies, tabulation, tie-breaking, seeding, or +VSE normalization can alter published numbers. When such behavior changes: + +1. Add a small deterministic regression test. +2. Run an appropriately sized smoke calculation with + `scripts/recalculate_irv_pages.py`. +3. If the change is intended to update published results, regenerate the site + artifacts and explain the changed assumptions in the same change. +4. Do not hand-edit generated HTML or PNG output. + +The full published run can be expensive. Use a small election count while +developing, then use the documented seed and full command before publishing. + +## Refactoring priorities + +When touching nearby code, prefer small staged changes in this order: + +1. Protect correctness with regression tests, especially Schulze cycles, + normalization edge cases, and strategy chooser behavior. +2. Extend the explicit election context instead of introducing shared state. +3. Use `retain_rows=False` for large CSV batches and preserve the streaming + path when changing persistence. +4. Introduce a package layout only as a deliberate migration; update scripts, + doctests, CI, and imports together. + +Do not combine algorithm changes with broad formatting or module moves. Voting +method changes should remain reviewable against the prior mathematical +behavior. + +## Repository hygiene + +- Preserve unrelated working-tree changes. +- Keep runtime dependencies minimal; charting and test tools belong in the dev + dependency group. +- Update `README.md` when setup or common commands change. +- If generated files change, identify the generating command in the change + description. diff --git a/README.md b/README.md index 8eb57b8..f09a08a 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,81 @@ # Voter Satisfaction Efficiency -These are some methods for running VSE (Voter Satisfaction Efficiency) -simulations for various voting systems. +This repository runs Voter Satisfaction Efficiency (VSE) simulations for +different voting systems, electorate models, and strategic behaviors. -See [Voter Satisfaction Efficiency FAQ](http://electionscience.github.io/vse-sim/) for an explanation of the methods and results. +See the [VSE FAQ](https://electionscience.github.io/vse-sim/) for an explanation +of the methods and published results. -## Installing the code +## Setup -Requirements: python3, scipy, pydoc +The project supports Python 3.10 through 3.12 and uses +[uv](https://docs.astral.sh/uv/) with a committed lockfile. -Testing uses pydoc, which should make most things pretty self-documenting. +```sh +uv sync --locked +``` -E.g.: +The repository has a flat module layout, so run commands from its root. - python3 -m doctest methods.py - python3 -m doctest voterModels.py - python3 -m doctest dataClasses.py - python3 vse.py +## Validation + +Doctests are part of the pytest suite: + +```sh +uv run python -m pytest +trunk check +``` ## Running simulations -Try +```python +from voterModels import PolyaModel +from vse import CsvBatch, Mav, Score, baseRuns, medianRuns + +batch = CsvBatch( + PolyaModel(), + [[Score(), baseRuns], [Mav(), medianRuns]], + nvot=5, + ncand=4, + niter=3, +) +batch.saveFile() +``` + +This writes the next available `SimResultsN.csv`. + +Large runs can write rows directly instead of retaining every row in memory: + +```python +CsvBatch( + PolyaModel(), + [[Score(), baseRuns]], + nvot=40, + ncand=6, + niter=15_000, + baseName="SimResults", + retain_rows=False, +) +``` + +## Reproducing published IRV results + +Use a small deterministic run while developing: + +```sh +uv run python scripts/recalculate_irv_pages.py \ + --elections 50 \ + --workers 1 \ + --seed smoke +``` + +The full published configuration and seed are the script defaults: - $ python3 - >>> from vse import CsvBatch, baseRuns, Mav, medianRuns, Score - >>> from voterModels import PolyaModel - >>> csvs = CsvBatch(PolyaModel(), [[Score(), baseRuns], [Mav(), medianRuns]], nvot=5, ncand=4, niter=3) - >>> csvs.saveFile() +```sh +uv run python scripts/recalculate_irv_pages.py +uv run python scripts/regenerate_pages_images.py +``` -and look for the results in `SimResults1.csv` +Changes to voter generation, strategies, tabulation, tie-breaking, random +seeding, or VSE normalization can change published results. See `AGENTS.md` for +the required regeneration workflow. diff --git a/dataClasses.py b/dataClasses.py index 9f6bfdd..a3c25b1 100644 --- a/dataClasses.py +++ b/dataClasses.py @@ -1,8 +1,9 @@ import random from collections import defaultdict +from dataclasses import dataclass, field -from numpy.core.fromnumeric import mean +from numpy import isclose, mean from mydecorators import autoassign, decorator @@ -23,6 +24,30 @@ def __init__(self, method, choosers, results): pass +@dataclass +class ElectionContext: + """Mutable metadata scoped to one method instance and election.""" + + extra_events: dict = field(default_factory=dict) + + +def normalized_vse(utility, best, random_baseline): + """Normalize utility to VSE, including a tied-utility electorate. + + When every candidate has the same social utility there is no possible + improvement over random selection, so every method receives neutral VSE. + + >>> normalized_vse(2, 2, 2) + 0.0 + >>> normalized_vse(3, 3, 1) + 1.0 + """ + denominator = best - random_baseline + if isclose(denominator, 0): + return 0.0 + return (utility - random_baseline) / denominator + + class SideTally(defaultdict): """Used for keeping track of how many voters are being strategic, etc. @@ -31,23 +56,11 @@ class SideTally(defaultdict): """ def __init__(self): super().__init__(int) - #>>> tally = SideTally() - #>>> tally += {1:2,3:4} - #>>> tally - #{1: 2, 3: 4} - #>>> tally += {1:2,3:4,5:6} - #>>> tally - #{1: 4, 3: 8, 5: 6} - #""" - #def __add__(self, other): - # for (key, val) in other.items(): - # try: - # self[key] += val - # except KeyError: - # self[key] = val - # return self + self._keys_initialized = False def initKeys(self, chooser): + if self._keys_initialized: + return try: self.keyList = chooser.allTallyKeys() except AttributeError: @@ -55,9 +68,7 @@ def initKeys(self, chooser): self.keyList = list(chooser) except TypeError: pass - #TODO: Why does this happen? - #debug("Chooser has no tally keys:", str(chooser)) - self.initKeys = staticmethod(lambda x:x) #don't do it again + self._keys_initialized = True def serialize(self): try: @@ -108,10 +119,22 @@ def __next__(self): self.append(tally) return tally -##Election Methods +# Election methods class Method: """Base class for election methods. Holds some of the duct tape.""" + def __init__(self): + self.context = ElectionContext() + + @property + def extraEvents(self): + """Compatibility view of metadata for this method's current election.""" + return self.context.extra_events + + @extraEvents.setter + def extraEvents(self, value): + self.context.extra_events = value + def __str__(self): return self.__class__.__name__ @@ -181,13 +204,16 @@ def multiResults(self, voters, chooserFuns=(), media=(lambda x,t:x), the media). Then, runs a series of elections using each chooserFun in chooserFuns to select the votes for each voter. - Returns a tuple of (honResults, stratResults, ...). The stratresults - are based on common polling information, which is given by media(honresults). + Returns a flat list of ``(result, chooser, tally_items)`` tuples for + the honest, strategic, one-sided strategic, smart one-sided, and + caller-provided chooser runs. Honest-run ``tally_items`` contain the + election's extra event metadata. Strategic results use common polling + information produced by ``media(honest_results)``. """ from stratFunctions import OssChooser honTally = SideTally() - self.__class__.extraEvents = {} + self.context = ElectionContext() hon = self.resultsFor(voters, self.honBallotFor(voters), honTally, isHonest=True) stratTally = SideTally() @@ -215,7 +241,7 @@ def multiResults(self, voters, chooserFuns=(), media=(lambda x,t:x), for (chooserFun, aTally) in zip(chooserFuns, extraTallies, strict=False)] ) return ([(hon["results"], hon["chooser"], - list(self.__class__.extraEvents.items()))] + + list(self.extraEvents.items()))] + [(r["results"], r["chooser"], r["tally"].itemList()) for r in results]) def vseOn(self, voters, chooserFuns=(), **args): @@ -227,12 +253,19 @@ def vseOn(self, voters, chooserFuns=(), **args): best = max(utils) rand = mean(utils) - #import pprint - #pprint.pprint(multiResults) - vses = VseMethodRun(self.__class__, chooserFuns, - [VseOneRun([(utils[self.winner(result)] - rand) / (best - rand)],tally,chooser) - for (result, chooser, tally) in multiResults[0]]) - vses.extraEvents=multiResults[1] + vses = VseMethodRun( + self.__class__, + chooserFuns, + [ + VseOneRun( + [normalized_vse(utils[self.winner(result)], best, rand)], + tally, + chooser, + ) + for result, chooser, tally in multiResults + ], + ) + vses.extraEvents = dict(self.extraEvents) return vses def resultsTable(self, eid, emodel, cands, voters, chooserFuns=(), **args): @@ -243,6 +276,8 @@ def resultsTable(self, eid, emodel, cands, voters, chooserFuns=(), **args): rows = [] nvot=len(voters) for (result, chooser, tallyItems) in multiResults: + winner = self.winner(result) + utility = utils[winner] row = { "eid":eid, "emodel":emodel, @@ -252,27 +287,13 @@ def resultsTable(self, eid, emodel, cands, voters, chooserFuns=(), **args): "rand":rand, "method":str(self), "chooser":chooser,#.getName(), - "util":utils[self.winner(result)], - "vse":(utils[self.winner(result)] - rand) / (best - rand) + "util":utility, + "vse":normalized_vse(utility, best, rand) } - #print(tallyItems) for (i, (k, v)) in enumerate(tallyItems): - #print("Result: tally ",i,k,v) row[f"tallyName{str(i)}"] = str(k) row[f"tallyVal{str(i)}"] = str(v) rows.append(row) - # if len(multiResults[1]): - # row = { - # "eid":eid, - # "emodel":emodel, - # "method":self.__class__.__name__, - # "chooser":"extraEvents", - # "util":None - # } - # for (i, (k, v)) in enumerate(multiResults[1]): - # row["tallyName"+str(i)] = str(k) - # row["tallyVal"+str(i)] = str(v) - # rows.append(row) return(rows) @@ -301,7 +322,6 @@ def stratBallotFor(self,polls): for the given "polling" info.""" places = sorted(enumerate(polls),key=lambda x:-x[1]) #from high to low - #print("places",places) (frontId, frontResult, targId, targResult) = self.stratTargetFor(places) n = len(polls) @rememberBallots diff --git a/debugDump.py b/debugDump.py index 3202098..fca5813 100644 --- a/debugDump.py +++ b/debugDump.py @@ -1,10 +1,20 @@ +import logging + +TRACE = 5 +logging.addLevelName(TRACE, "TRACE") + +logger = logging.getLogger("vse_sim") +logger.setLevel(TRACE) + + +def trace(*args): + """Log low-level diagnostic values at the TRACE level.""" + logger.log(TRACE, " ".join(str(arg) for arg in args)) + + +debug = trace -DEBUG = True -def debug(*args): - if DEBUG: - print(*args) - def setDebug(state): - global DEBUG - DEBUG = state + """Backward-compatible switch for trace diagnostics.""" + logger.setLevel(TRACE if state else logging.CRITICAL + 1) diff --git a/methods.py b/methods.py index b58fdf2..41c97f6 100644 --- a/methods.py +++ b/methods.py @@ -1,14 +1,13 @@ import random -from numpy import argsort, mean, percentile, sign -from numpy.ma.core import floor +from numpy import argsort, floor, mean, percentile, sign from dataClasses import CandidateWithCount, Method, rememberBallot, rememberBallots from voterModels import DeterministicModel, Voter # noqa: F401 -####EMs themselves +# Election methods class Borda(Method): candScore = staticmethod(mean) @@ -74,7 +73,6 @@ def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, if nRanks > 0: cls.fillCands(ballot, places[2:][::-1], lowSlot=1, nSlots=nRanks, remainderScore=0) - # (don't) return dict(strat=ballot, isStrat=isStrat, stratGap=stratGap) RankedMethod = Borda #alias RatedMethod = RankedMethod #Should have same strategies available, plus more @@ -99,33 +97,10 @@ def honBallot(cls, utils): >>> Plurality().stratBallotFor([3,2,1])(Plurality, Voter([-3,-2,-1])) [0, 1, 0] """ - #return cls.oneVote(utils, cls.winner(utils)) ballot = [0] * len(utils) cls.fillPrefOrder(utils, ballot, nSlots = 1, lowSlot=1, remainderScore=0) return ballot - # - # @classmethod - # def xxstratBallot(cls, voter, polls, places, n, - # frontId, frontResult, targId, targResult): - # """Takes utilities and returns a strategic ballot - # for the given "polling" info. - # - # >>> Plurality().stratBallotFor([4,2,1])(Plurality, Voter([-4,-2,-1])) - # [0, 1, 0] - # """ - # stratGap = voter[targId] - voter[frontId] - # if stratGap <= 0: - # #winner is preferred; be complacent. - # isStrat = False - # strat = cls.oneVote(voter, frontId) - # else: - # #runner-up is preferred; be strategic in iss run - # isStrat = True - # #sort cuts high to low - # #cuts = (cuts[1], cuts[0]) - # strat = cls.oneVote(voter, targId) - # return dict(strat=strat, isStrat=isStrat, stratGap=stratGap) @@ -155,20 +130,9 @@ class Score0to(Method): """ - #>>> qs += [Score().resultsFor(PolyaModel()(101,2),Score.honBallot)[0] for i in range(800)] - #>>> std(qs) - #2.770135393419682 - #>>> mean(qs) - #5.1467202970297032 bias2 = 2.770135393419682 - #>>> qs5 = [Score().resultsFor(PolyaModel()(101,5),Score.honBallot)[0] for i in range(400)] - #>>> mean(qs5) - #4.920247524752476 - #>>> std(qs5) - #2.3536762480634343 bias5 = 2.3536762480634343 candScore = staticmethod(mean) - #"""Takes the list of votes for a candidate; returns the candidate's score.""" def __str__(self): @@ -190,6 +154,8 @@ def honBallot(cls, utils): """ bot = min(utils) scale = max(utils)-bot + if scale == 0: + return [cls.topRank] * len(utils) return [floor((cls.topRank + .99) * (util-bot) / scale) for util in utils] @@ -299,20 +265,10 @@ class Mav(Method): """Majority Approval Voting. """ - - #>>> mqs = [Mav().resultsFor(PolyaModel()(101,5),Mav.honBallot)[0] for i in range(400)] - #>>> mean(mqs) - #1.5360519801980208 - #>>> mqs += [Mav().resultsFor(PolyaModel()(101,5),Mav.honBallot)[0] for i in range(1200)] - #>>> mean(mqs) - #1.5343069306930679 - #>>> std(mqs) - #1.0970202515275356 bias5 = 1.0970202515275356 baseCuts = [-0.8, 0, 0.8, 1.6] - specificCuts = None specificPercentiles = [25,50,75,90] def candScore(self, scores): @@ -343,10 +299,23 @@ def candScore(self, scores): lower = (base) - (i - nvot/2) / nvot return max(upper, lower) - @classmethod - def honBallotFor(cls, voters): - cls.specificCuts = percentile(voters,cls.specificPercentiles) - return cls.honBallot + def honBallotFor(self, voters): + """Return an honest ballot function with election-scoped cutoffs.""" + cuts = percentile(voters, self.specificPercentiles) + + def honBallot(cls, voter, tally=None): + ballot = cls._honBallotWithCuts(voter, cuts) + setattr(voter, f"{cls.__name__}_hon", ballot) + return ballot + + honBallot.__name__ = "honBallot" + honBallot.allTallyKeys = lambda: [] + return honBallot + + @staticmethod + def _honBallotWithCuts(voter, cuts): + cuts = [min(cut, max(voter) - 0.001) for cut in cuts] + return [toVote(cuts, util) for util in voter] @staticmethod #cls is provided explicitly, not through binding @rememberBallot @@ -354,7 +323,6 @@ def honBallot(cls, voter): """Takes utilities and returns an honest ballot (on 0..4). honest ballot works as intended, gives highest grade to highest utility: - >>> Mav.specificCuts = None >>> Mav().honBallot(Mav, Voter([-1,-0.5,0.5,1,1.1])) [0, 1, 2, 3, 4] @@ -362,9 +330,7 @@ def honBallot(cls, voter): >>> Mav().honBallot(Mav, Voter([-1,-0.5,0.5])) [0, 1, 4] """ - cuts = cls.specificCuts if (cls.specificCuts is not None) else cls.baseCuts - cuts = [min(cut, max(voter) - 0.001) for cut in cuts] - return [toVote(cuts, util) for util in voter] + return cls._honBallotWithCuts(voter, cls.baseCuts) def stratBallotFor(self, polls): @@ -396,7 +362,6 @@ def stratBallotFor(self, polls): [2, 2, 4] """ places = sorted(enumerate(polls),key=lambda x:-x[1]) #from high to low - #print("places",places) ((frontId,frontResult), (targId, targResult)) = places[:2] @rememberBallots @@ -418,8 +383,6 @@ def stratBallot(cls, voter): #sort cuts high to low frontUtils = (frontUtils[1], frontUtils[0]) top = max(voter) - #print("lll312") - #print(self.baseCuts, front) cutoffs = [( (min(frontUtils[0], self.baseCuts[i])) if (i < floor(targResult)) else ( (frontUtils[1]) @@ -615,7 +578,6 @@ def honBallot(cls, voter): order = sorted(enumerate(voter), key=lambda x:x[1]) for i, cand in enumerate(order): ballot[cand[0]] = i - #print("hballot",ballot) return ballot @@ -646,7 +608,6 @@ def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, if voter[nextLoser] <= winnerQ: ballot[nextLoser] = i i -= 1 - #assert list(range(n)) == sorted(ballot) assert i == -1 class IrvPrime(Irv): @@ -746,7 +707,6 @@ def results(self, ballots, **kwargs): class V321(Mav): baseCuts = [-.1,.8] - specificCuts = None specificPercentiles = [45, 75] stratTargetFor = Method.stratTarget3 @@ -774,13 +734,8 @@ def results(self, ballots, isHonest=False, **kwargs): for r,i in enumerate(o2s): r2s[i] = r semifinalists = o2s[-3:] #[third, second, first] by top ranks - #print(semifinalists) n1s = [sum(1 if s>0 else 0 for s in candScores[sf]) for sf in semifinalists] o1s = argsort(n1s) - #print("n1s",n1s) - #print("o1s",o1s) - #print([semifinalists[o] for o in o1s]) #[third, second, first] by above-bottom - #print("r2s",r2s) r2s[semifinalists[o1s[0]]] -= (o1s[0] +1) * .75 #non-finalist below finalists (runnerUp,top) = semifinalists[o1s[1]], semifinalists[o1s[2]] upset = sum(sign(ballot[runnerUp] - ballot[top]) for ballot in ballots) @@ -789,10 +744,11 @@ def results(self, ballots, isHonest=False, **kwargs): r2s[runnerUp], r2s[top] = r2s[top] - .125, r2s[runnerUp] + .125 r2s[top] = max(r2s[top], r2s[runnerUp] + 0.5) if isHonest: + self.extraEvents.update({"3beats1": False, "3beats2": False, "4beats1": False}) upset2 = sum(sign(ballot[semifinalists[o1s[0]]] - ballot[semifinalists[o1s[2]]]) for ballot in ballots) - self.__class__.extraEvents["3beats1"] = upset2 > 0 + self.extraEvents["3beats1"] = upset2 > 0 upset3 = sum(sign(ballot[semifinalists[o1s[0]]] - ballot[semifinalists[o1s[1]]]) for ballot in ballots) - self.__class__.extraEvents["3beats2"] = upset3 > 0 + self.extraEvents["3beats2"] = upset3 > 0 if len(o2s) > 3: fourth = o2s[-4] fourthNotLasts = sum(1 if s>1 else 0 for s in candScores[fourth]) @@ -800,7 +756,7 @@ def results(self, ballots, isHonest=False, **kwargs): sum(sign(ballot[fourth] - ballot[semifinalists[o1s[2]]]) for ballot in ballots) > 0) - self.__class__.extraEvents["4beats1"] = fourthWin + self.extraEvents["4beats1"] = fourthWin return r2s @@ -818,7 +774,6 @@ def stratBallotFor(self, polls): places = sorted(enumerate(polls),key=lambda x:-x[1]) #high to low top3 = [c for c,r in places[:3]] - #@rememberBallots ... do it later def stratBallot(cls, voter): stratGap = voter[top3[1]] - voter[top3[0]] myPrefs = [c for c,v in sorted(enumerate(voter),key=lambda x:-x[1])] #high to low @@ -831,7 +786,6 @@ def stratBallot(cls, voter): if my3order[1] <= my3order[2]: for i in range(my3order[0]+1,my3order[1]+1): ballot[myPrefs[i]] = 1 - #print("agree",top3, my3order,ballot,[float('%.1g' % c) for c in voter]) return dict(strat=ballot, isStrat=False, stratGap=stratGap) for c in myPrefs: ballot[c] = rating @@ -841,7 +795,6 @@ def stratBallot(cls, voter): else: rating -= 1 - #print("disagree",top3,my3order,ballot,[float('%.1g' % c) for c in voter]) return dict(strat=ballot, isStrat=True, stratGap=stratGap) if self.extraEvents["3beats1"]: @rememberBallots @@ -893,19 +846,22 @@ def stratBallo3(cls, voter): class Schulze(RankedMethod): def resolveCycle(self, cmat, n): - beatStrength = [[0] * n] * n + beatStrength = [[0] * n for _ in range(n)] numWins = [0] * n for i in range(n): for j in range(n): - if (i != j): + if i != j: beatStrength[i][j] = cmat[i][j] if cmat[i][j] > cmat[j][i] else 0 - for i in range(n): - for j in range(n): - if (i != j): - for k in range(n): - if (i != k and j != k): - beatStrength[j][k] = max ( beatStrength[j][k], - min ( beatStrength[j][i], beatStrength[i][k] ) ) + + for i in range(n): + for j in range(n): + if i != j: + for k in range(n): + if i != k and j != k: + beatStrength[j][k] = max( + beatStrength[j][k], + min(beatStrength[j][i], beatStrength[i][k]), + ) for i in range(n): for j in range(n): @@ -920,33 +876,34 @@ def resolveCycle(self, cmat, n): def results(self, ballots, isHonest=False, **kwargs): """Schulze results. - >>> Schulze().resultsFor(DeterministicModel(3)(5,3),Schulze().honBallot,isHonest=True)["results"] - [2, 0, 1] - >>> Schulze.extraEvents + >>> schulze = Schulze() + >>> schulze.resultsFor(DeterministicModel(3)(5,3),schulze.honBallot,isHonest=True)["results"] + [1, 2, 0] + >>> schulze.extraEvents {'scenario': 'cycle'} - >>> Schulze().results([[0,1,2]],isHonest=True)[2] + >>> schulze.results([[0,1,2]],isHonest=True)[2] 2 - >>> Schulze.extraEvents + >>> schulze.extraEvents {'scenario': 'easy'} - >>> Schulze().results([[0,1,2],[2,1,0]],isHonest=True)[1] + >>> schulze.results([[0,1,2],[2,1,0]],isHonest=True)[1] 1 - >>> Schulze.extraEvents + >>> schulze.extraEvents {'scenario': 'easy'} - >>> Schulze().results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2,isHonest=True) + >>> schulze.results([[0,1,2]] * 4 + [[2,1,0]] * 3 + [[1,2,0]] * 2,isHonest=True) [1, 2, 0] - >>> Schulze.extraEvents + >>> schulze.extraEvents {'scenario': 'chicken'} - >>> Schulze().results([[0,1,2]] * 4 + [[2,1,0]] * 2 + [[1,2,0]] * 3,isHonest=True) + >>> schulze.results([[0,1,2]] * 4 + [[2,1,0]] * 2 + [[1,2,0]] * 3,isHonest=True) [1, 2, 0] - >>> Schulze.extraEvents + >>> schulze.extraEvents {'scenario': 'squeeze'} - >>> Schulze().results([[3,2,1,0]] * 5 + [[2,3,1,0]] * 2 + [[0,1,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) + >>> schulze.results([[3,2,1,0]] * 5 + [[2,3,1,0]] * 2 + [[0,1,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) [2, 3, 1, 0] - >>> Schulze.extraEvents + >>> schulze.extraEvents {'scenario': 'other'} - >>> Schulze().results([[3,0,0,0]] * 5 + [[2,3,0,0]] * 2 + [[0,0,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) + >>> schulze.results([[3,0,0,0]] * 5 + [[2,3,0,0]] * 2 + [[0,0,0,3]] * 6 + [[0,0,3,0]] * 3,isHonest=True) [3, 0, 1, 2] - >>> Schulze.extraEvents + >>> schulze.extraEvents {'scenario': 'spoiler'} """ n = len(ballots[0]) @@ -969,8 +926,7 @@ def results(self, ballots, isHonest=False, **kwargs): result = self.resolveCycle(cmat, n) if isHonest: - self.__class__.extraEvents = {} - #check scenarios + self.extraEvents = {} plurTally = [0] * n plur3Tally = [0] * 3 cond3 = [c for c,v in condOrder[:3]] @@ -983,17 +939,17 @@ def results(self, ballots, isHonest=False, **kwargs): plurOrder = sorted(enumerate(plurTally),key=lambda x:-x[1]) plur3Order = sorted(enumerate(plur3Tally),key=lambda x:-x[1]) if cycle: - self.__class__.extraEvents["scenario"] = "cycle" + self.extraEvents["scenario"] = "cycle" elif plurOrder[0][0] == condOrder[0][0]: - self.__class__.extraEvents["scenario"] = "easy" + self.extraEvents["scenario"] = "easy" elif plur3Order[0][0] == condOrder[0][0]: - self.__class__.extraEvents["scenario"] = "spoiler" + self.extraEvents["scenario"] = "spoiler" elif plur3Order[2][0] == condOrder[0][0]: - self.__class__.extraEvents["scenario"] = "squeeze" + self.extraEvents["scenario"] = "squeeze" elif plur3Order[0][0] == condOrder[2][0]: - self.__class__.extraEvents["scenario"] = "chicken" + self.extraEvents["scenario"] = "chicken" else: - self.__class__.extraEvents["scenario"] = "other" + self.extraEvents["scenario"] = "other" return result @@ -1009,7 +965,6 @@ def fillStratBallot(cls, voter, polls, places, n, stratGap, ballot, cls.fillPrefOrder(voter, ballot, whichCands=decentOnes, lowSlot=n-len(decentOnes)) - #ballot[frontId], ballot[targId] = n-len(decentOnes)-1, n-len(decentOnes)-2 ballot[frontId], ballot[targId] = 0, n-len(decentOnes)-1 cls.fillPrefOrder(voter, ballot, whichCands=[c for c in others if voter[c] < notTooBad], @@ -1033,9 +988,7 @@ def resolveCycle(self, cmat, n): if margin < 0: i, j = j, i if cmat[j][i] is not True: - #print(i,j,cmat) cmat[i][j] = True - #print("....",i,j,cmat) for k in range(n): if k not in (i, j): if cmat[j][k] is True: @@ -1043,8 +996,6 @@ def resolveCycle(self, cmat, n): if cmat[k][i] is True: cmat[k][j] = True - #print(".......",i,j,k,cmat) - return [sum(cmat[i][j] is True for j in range(n)) for i in range(n)] diff --git a/mydecorators.py b/mydecorators.py index 1d8d415..2d0f3b8 100644 --- a/mydecorators.py +++ b/mydecorators.py @@ -5,6 +5,8 @@ from inspect import getfullargspec, isfunction from itertools import starmap +from debugDump import trace + _missing = object() @@ -176,8 +178,10 @@ def timed(*args, **kw): result = method(*args, **kw) te = time.time() - print('%r (%r, %r) %2.2f sec' % - (method.__name__, args, kw, te-ts)) + trace( + "%r (%r, %r) %2.2f sec" + % (method.__name__, args, kw, te - ts) + ) return result return timed diff --git a/scripts/recalculate_irv_pages.py b/scripts/recalculate_irv_pages.py index 2d9a74b..a193792 100644 --- a/scripts/recalculate_irv_pages.py +++ b/scripts/recalculate_irv_pages.py @@ -9,22 +9,18 @@ import argparse import csv -import hashlib import os -import random import sys from collections import defaultdict from concurrent.futures import ProcessPoolExecutor from pathlib import Path -import numpy as np - sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from debugDump import setDebug from methods import Irv, Schulze from voterModels import KSModel -from vse import baseRuns, fuzzyMediaFor +from vse import baseRuns, fuzzyMediaFor, seedRandomGenerators DEFAULT_WORKERS = 10 @@ -32,11 +28,7 @@ def _recalculate_chunk(elections, seed): """Simulate one independently seeded chunk of elections.""" setDebug(False) - random.seed(seed) - numpy_seed = int.from_bytes( - hashlib.sha256(str(seed).encode()).digest()[:4], byteorder="little" - ) - np.random.seed(numpy_seed) + seedRandomGenerators(seed) model = KSModel(dcdecay=(1, 3), wcdecay=(1.5, 3), dccut=.2, wcalpha=1.5) method = Irv() scenario_method = Schulze() diff --git a/sodaTest.py b/sodaTest.py index b6f34b3..3aea7e3 100644 --- a/sodaTest.py +++ b/sodaTest.py @@ -4,8 +4,9 @@ import numpy as np +from debugDump import trace + -#from stackexchange... def autoargs(*include,**kwargs): def _autoargs(func): spec = inspect.getfullargspec(func) @@ -43,7 +44,7 @@ def wrapper(self,*args,**kwargs): return _autoargs -#Hah: http://www.pydanny.com/cached-property.html +# Adapted from http://www.pydanny.com/cached-property.html. class cached_property(object): """ A property that is only computed once per instance and then replaces itself with an ordinary attribute. Deleting the attribute resets the @@ -67,7 +68,7 @@ def __get__(self, obj, cls): -##actual code +# Election implementation DEBUG = True arrayType = type(np.array([1])) @@ -141,7 +142,6 @@ def beaters(self, loser, candidates, minwin = [None], rival = [None], private = if private and (c not in outer): continue if (m[best,loser] > m[c,loser]) and (m[best,c] > m[loser,c]): - #print("a",c,loser) if rival[0] is not None: toWin = max((m[best,loser] - m[c,loser]), (m[best,c] - m[loser,c])) if rival[0][0] < toWin: @@ -151,7 +151,6 @@ def beaters(self, loser, candidates, minwin = [None], rival = [None], private = outer.remove(c) yield c elif m[loser,c] >= m[c,loser]: - #print("b",c,loser) if minwin[0] and minwin[0][0] > m[loser, c]: minwin[0] = (m[loser,c],loser,c) if private: @@ -162,7 +161,6 @@ def oneWinner(self, m): start = np.argmax(m[0]) theRest = list(range(self.n)) theRest.remove(start) - #print(theRest) return self.climbFrom(start, theRest) def climbFrom(self, start, theRest): @@ -188,10 +186,8 @@ def majSmith(self): return winners def growFrom(self, seed, plant, soil, minwin = [None], rival = [None]): - #print(seed, plant, soil) """As a SIDE-EFFECT, recursively fill out the set of winners, starting from seed.""" for w in self.beaters(seed,soil, minwin, rival, private=True): - #print(w,"grows on",seed) plant.append(w) self.growFrom(w, plant, soil, minwin, rival) @@ -199,13 +195,11 @@ def delegated(self, amounts, cantWin=None): delegator = self.order[0] appr = np.matrix(np.zeros(self.n)) dprefs = self.prefs[delegator] - #print(dprefs) appr[:,dprefs] = amounts if DEBUG: for i in range(self.n-1): assert appr[0,dprefs[i]] >= appr[0,dprefs[i+1]],"bullshit %i %i %s ... %s" % (appr[0,dprefs[i]],appr[0,dprefs[i+1]],appr,dprefs) - #print(appr) delg = list(self.delg) delg[delegator] = 0 result = ElectionCounts(delg,appr + self.appr,self.prefs,self.order[1:], @@ -216,26 +210,23 @@ def delegated(self, amounts, cantWin=None): def winner(self, verbose = 0): if not len(self.order): #delegation tree leaf - #print(self.matrix) if verbose > 2: - print("leafed out", self.matrix) + trace("leafed out", self.matrix) return np.argmax(self.matrix[0]) smith = self.majSmith if len(smith) <= 1: #Clear winner, not worth finishing - #print(self.matrix) if verbose > 2: - print("crystal ball", smith[0], self.matrix) + trace("crystal ball", smith[0], self.matrix) return smith[0] if self.oldSmith and verbose and len(smith) > len(self.oldSmith): - print("Smith set expanded!")#,self.oldSmith, smith, self.matrix) + trace("Smith set expanded!") if self.cantWin: badWinners = True for possibility in smith: badWinners = badWinners and (possibility in self.cantWin) if badWinners: - #print("badwinners", smith, self.cantWin) if verbose > 2: - print("giving up", self.matrix) + trace("giving up", self.matrix) return None #This is a shortcut. We don't know that this cand will win, but it will be ignored anyway. #figure out reasonable bounds for whom to approve, who might win. @@ -253,29 +244,24 @@ def winner(self, verbose = 0): cantWin = self.cantWin or set() - #print("looping",len(self.order)) for amounts in self.possibleDelegations(worstWinnerIndex, idealWinnerIndex): - #print(".") - #print(self.delegated(np.array([10,10,0,0,0]))) dec = self.delegated(amounts,cantWin) w = dec.winner(verbose) if verbose and len(self.order) > 2: - print(w,len(self.order),"amounts",amounts,bestHope, bestHopeIndex,"and",worstWinnerIndex, idealWinnerIndex,"with",np.trace(dec.matrix)) - #print(" " * (5 - len(self.order)), "winner?", w, bestHope, curPrefs ) + trace(w, len(self.order), "amounts", amounts, bestHope, + bestHopeIndex, "and", worstWinnerIndex, idealWinnerIndex, + "with", np.trace(dec.matrix)) if w == idealWinner: if verbose > 1.5: - print("love it", w, dec.matrix) + trace("love it", w, dec.matrix) return(w) if w is None: - #print("nothing for",amounts) continue i = curPrefs.index(w) - #if len(self.order) == 3: #print(i) if i < bestHopeIndex: if verbose > 2-len(self.order)*1.0/10: - print("updating w,len(self.order),amounts",w,i,len(self.order),amounts,curPrefs,bestHopeIndex) - #print(,amounts) - print() + trace("updating w,len(self.order),amounts", w, i, + len(self.order), amounts, curPrefs, bestHopeIndex) bestHopeIndex = i bestHope = w for rank in range(i+1,self.n): @@ -291,12 +277,9 @@ def possibleDelegations(self, worstWinnerIndex, idealWinnerIndex): for i in range(start_index): delegations[i] = size dcopy = np.array(delegations) - #print("hi",i,worstWinnerIndex + 1) for i in range(start_index - 1,worstWinnerIndex + 1): delegations[i] = size yield np.array(delegations) - #print("there") - #print("you") #Now, try to be clever if self.minWin: @@ -309,10 +292,6 @@ def possibleDelegations(self, worstWinnerIndex, idealWinnerIndex): delegations[j] = needed yield np.array(delegations) - - #self.appMatrixcurPrefs[idealWinnerIndex] - - def scores(self): scores = np.zeros(self.n) for i in range(len(self.delg)): @@ -350,15 +329,15 @@ def monteCarlo(n): funky = [] for i in range(n): if i % 50 == 0: - print("tick",i) + trace("tick", i) re = randomElection(4 + random.randrange(4)) w = re.winner() if w not in re.majSmith: - print("Unsmith!!!",i) + trace("Unsmith!!!", i) funky.append(re) - print(re.delg,re.appr) - print(re.prefs) - print(re.matrix) - print(w,re.majSmith) - print("funny, huh?") + trace(re.delg, re.appr) + trace(re.prefs) + trace(re.matrix) + trace(w, re.majSmith) + trace("funny, huh?") return funky diff --git a/stratFunctions.py b/stratFunctions.py index 91f1c4f..facdf1e 100644 --- a/stratFunctions.py +++ b/stratFunctions.py @@ -1,7 +1,8 @@ import random +from math import isclose -from numpy.core.fromnumeric import std +from numpy import std from dataClasses import SideTally from mydecorators import autoassign, cached_property @@ -21,12 +22,9 @@ def __init__(self, choice, subChoosers=None): def getName(self): if hasattr(self, "choice"): #only true for base class - #print("base") return self.choice if not hasattr(self, "name") or not self.name: - #print("generic") self.name = self.__class__.__name__[:-7] #drop the "Chooser" - #print("specific") return self.name def __call__(self, cls, voter, tally): @@ -104,13 +102,19 @@ def getName(self): class ProbChooser(Chooser): @autoassign def __init__(self, probs): + if not probs: + raise ValueError("ProbChooser requires at least one choice") + if any(probability < 0 for probability, _chooser in probs): + raise ValueError("ProbChooser probabilities cannot be negative") + if not isclose(sum(probability for probability, _chooser in probs), 1.0): + raise ValueError("ProbChooser probabilities must sum to 1") self.subChoosers = [chooser for (p, chooser) in probs] def __call__(self, cls, voter, tally): r = random.random() for (i, (p, chooser)) in enumerate(self.probs): r -= p - if r < 0: + if r < 0 or i == len(self.probs) - 1: if i > 0: #keep tally for all but first option tally[f"{self.getName()}_{chooser.getName()}"] += 1 return chooser(cls, voter, tally) @@ -126,7 +130,7 @@ def getName(self): -###media +# Media models def truth(standings, tally=None): return standings diff --git a/test/test_regressions.py b/test/test_regressions.py new file mode 100644 index 0000000..c701ad5 --- /dev/null +++ b/test/test_regressions.py @@ -0,0 +1,187 @@ +import csv +import random +from pathlib import Path + +import numpy as np +import pytest + +from dataClasses import SideTally +from debugDump import TRACE, setDebug, trace +from methods import Mav, Schulze, Score +from scripts.recalculate_irv_pages import recalculate +from stratFunctions import ProbChooser, beHon, beStrat +from voterModels import Electorate, Voter +from vse import CsvBatch, seedRandomGenerators + + +def test_schulze_uses_independent_strongest_path_rows(): + margins = [ + [0, -3, 1], + [3, 0, -1], + [-1, 1, 0], + ] + + assert Schulze().resolveCycle(margins, 3) == [1, 2, 0] + + +def test_schulze_metadata_is_scoped_to_method_instance(): + cycle_method = Schulze() + easy_method = Schulze() + + cycle_method.results( + [[0, 1, 2], [1, 2, 0], [2, 0, 1]], + isHonest=True, + ) + easy_method.results([[0, 1, 2]], isHonest=True) + + assert cycle_method.extraEvents == {"scenario": "cycle"} + assert easy_method.extraEvents == {"scenario": "easy"} + + +def test_score_and_vse_handle_identical_utilities(): + voters = Electorate([Voter([1, 1, 1]), Voter([1, 1, 1])]) + method = Score() + + assert method.honBallot(method.__class__, voters[0]) == [10, 10, 10] + assert all(row["vse"] == 0.0 for row in method.resultsTable( + "equal", "equal", 3, voters + )) + + +def test_vse_on_returns_every_simulation_run(): + voters = Electorate([Voter([0, 1]), Voter([0, 1])]) + + result = Score().vseOn(voters) + + assert {run.strat for run in result.results} == { + "honBallot", + "stratBallot", + "Oss.hon_strat.", + "smartOss", + } + assert all(run.result == [1.0] for run in result.results) + assert result.extraEvents == {} + + +def test_mav_cutoffs_are_scoped_to_generated_ballot_function(): + method = Mav() + low_electorate = Electorate([Voter([-2, -1]), Voter([-2, -1])]) + high_electorate = Electorate([Voter([1, 2]), Voter([1, 2])]) + low_ballot = method.honBallotFor(low_electorate) + expected = low_ballot(Mav, Voter([-2, -1]), SideTally()) + + method.honBallotFor(high_electorate) + + assert low_ballot(Mav, Voter([-2, -1]), SideTally()) == expected + + +@pytest.mark.parametrize( + "probabilities", + [ + [], + [(-0.1, beHon), (1.1, beStrat)], + [(0.25, beHon), (0.25, beStrat)], + ], +) +def test_prob_chooser_rejects_invalid_probabilities(probabilities): + with pytest.raises(ValueError): + ProbChooser(probabilities) + + +def test_prob_chooser_falls_back_to_last_choice(monkeypatch): + chooser = ProbChooser([(0.5, beHon), (0.5, beStrat)]) + monkeypatch.setattr(random, "random", lambda: 1.0) + + assert chooser(object, object(), SideTally()) == "strat" + + +def test_prob_chooser_selects_both_choices_and_tracks_non_default_choice(): + seedRandomGenerators("prob-chooser") + chooser = ProbChooser([(0.3, beHon), (0.7, beStrat)]) + tally = SideTally() + + choices = [chooser(object, object(), tally) for _ in range(500)] + + assert set(choices) == {"hon", "strat"} + assert tally[f"{chooser.getName()}_strat"] == choices.count("strat") + + +def test_seed_random_generators_is_reproducible(): + seedRandomGenerators("same-seed") + first = (random.random(), np.random.random()) + seedRandomGenerators("same-seed") + + assert (random.random(), np.random.random()) == first + + seedRandomGenerators("seed-a") + sequence_a = (random.random(), np.random.random()) + seedRandomGenerators("seed-b") + sequence_b = (random.random(), np.random.random()) + + assert sequence_a != sequence_b + + +def test_csv_batch_can_stream_without_retaining_rows(tmp_path): + output_base = str(tmp_path / "results") + batch = CsvBatch( + _NumpyModel(), + [[Score(), []]], + nvot=3, + ncand=2, + niter=2, + baseName=output_base, + seed="stream-test", + force=True, + retain_rows=False, + ) + + assert batch.rows == [] + output_path = Path(batch.output_file) + assert output_path.exists() + + with output_path.open(newline="") as output: + assert output.readline().startswith("# {") + rows = list(csv.DictReader(output)) + + expected_choosers = { + "honBallot", + "stratBallot", + "Oss.hon_strat.", + "smartOss", + } + assert len(rows) == batch.niter * len(expected_choosers) + assert {row["chooser"] for row in rows} == expected_choosers + assert {"eid", "util", "vse"} <= rows[0].keys() + + +def test_irv_recalculation_smoke(): + results, outcomes, scenarios, scenario_outcomes = recalculate( + elections=2, + seed="test-irv", + workers=1, + ) + + assert "honBallot" in results + assert outcomes["attempts"] == 2 + assert sum(data["attempts"] for data in scenario_outcomes.values()) == 2 + assert set(scenarios).issubset( + {"cycle", "easy", "spoiler", "squeeze", "chicken", "other"} + ) + + +class _NumpyModel: + def __call__(self, nvot, ncand): + return Electorate( + Voter(np.random.normal(size=ncand)) + for _ in range(nvot) + ) + + +def test_trace_diagnostics_use_logging(caplog): + setDebug(True) + try: + with caplog.at_level(TRACE, logger="vse_sim"): + trace("election", 7) + assert "election 7" in caplog.text + finally: + setDebug(False) diff --git a/voterModels.py b/voterModels.py index 478a365..463bc7e 100644 --- a/voterModels.py +++ b/voterModels.py @@ -1,6 +1,6 @@ import random -from numpy.core.fromnumeric import mean, std # noqa: F401 +from numpy import mean, std # noqa: F401 from numpy.lib.scimath import sqrt from scipy.stats import beta @@ -90,13 +90,7 @@ def __init__(self, *args, **kw): super().__init__()#*args, **kw) #WTF, python? self.cluster = self.__class__.cluster_count self.__class__.cluster_count += 1 - self.personality = random.gauss(0,1) #probably to be used for strategic propensity - #but in future, could be other clustering voter variability, such as media awareness - - #@classmethod - #def rand(cls, ncand): - # voter = super().rand(ncand) - # return voter + self.personality = random.gauss(0,1) @classmethod def resetClusters(cls): @@ -352,7 +346,7 @@ class KSModel(DimModel): #Kitchen sink baseElectorate = RandomModel() @autoassign - #dc = dimensional cluster; vc = voter cluster + # dc = dimensional cluster; wc = within-cluster dimension def __init__(self, dcdecay=(1,1), dccut = .2, wcdecay=(1,1), wccut = .2, wcalpha=1, vccaring=(3,1.5)): diff --git a/vse.py b/vse.py index 046b0ee..f27c06d 100644 --- a/vse.py +++ b/vse.py @@ -1,9 +1,12 @@ import csv +import hashlib import os import random from uuid import uuid4 +import numpy as np + from debugDump import debug, setDebug from methods import ( IRNR, @@ -101,6 +104,7 @@ "medianRuns", "orderOf", "rbeta", + "seedRandomGenerators", "setDebug", "skewedMediaFor", "topNMediaFor", @@ -119,27 +123,39 @@ def uniquify(seq): checked.append(e) return checked + +def seedRandomGenerators(seed): + """Seed the Python and NumPy global generators deterministically.""" + random.seed(seed) + numpy_seed = int.from_bytes( + hashlib.sha256(str(seed).encode()).digest()[:4], byteorder="little" + ) + np.random.seed(numpy_seed) + + class CsvBatch: @timeit @autoassign def __init__(self, model, methods, nvot, ncand, niter, - baseName = None, media=truth, seed=None, force=False): + baseName = None, media=truth, seed=None, force=False, + retain_rows=True): """A harness function which creates niter elections from model and finds three kinds of utility for all methods given. for instance: >>> csvs = CsvBatch(PolyaModel(), [[Score(), baseRuns], [Mav(), medianRuns]], nvot=5, ncand=4, niter=3) # doctest: +ELLIPSIS - '__init__' (...) ... sec >>> len(csvs.rows) 60 + + ``force=True`` permits provenance collection from a dirty Git working + tree. It does not control output-file replacement; ``saveFile`` always + chooses the next available numbered filename. """ - rows = [] - emodel = str(model) if (seed is None): seed = (baseName or '') + str(niter) self.seed = seed - random.seed(seed) + seedRandomGenerators(seed) try: from git import Repo repo = Repo(os.getcwd()) @@ -148,18 +164,36 @@ def __init__(self, model, methods, nvot, ncand, niter, self.repo_version = repo.head.commit.hexsha except Exception: self.repo_version = 'unknown repo version' - for i in range(niter): + generated_rows = self._generateRows() + if baseName and not retain_rows: + self.rows = [] + self.saveFile(baseName, generated_rows) + else: + self.rows = list(generated_rows) + if baseName: + self.saveFile(baseName) + + def _generateRows(self): + emodel = str(self.model) + for i in range(self.niter): eid = uuid4() - electorate = model(nvot, ncand) - for method, chooserFuns in methods: - results = method.resultsTable(eid, emodel, ncand, electorate, chooserFuns, media=media) - rows.extend(results) - debug(i,results[1:3]) - self.rows = rows - if baseName: - self.saveFile(baseName) - - def saveFile(self, baseName="SimResults"): + electorate = self.model(self.nvot, self.ncand) + last_results = None + for method, chooserFuns in self.methods: + results = method.resultsTable( + eid, + emodel, + self.ncand, + electorate, + chooserFuns, + media=self.media, + ) + yield from results + last_results = results + if last_results is not None: + debug(i, last_results[1:3]) + + def saveFile(self, baseName="SimResults", rows=None): """Print the result of doVse in an accessible format. for instance: @@ -168,11 +202,16 @@ def saveFile(self, baseName="SimResults"): i = 1 while os.path.isfile(baseName + str(i) + ".csv"): i += 1 - keys = ["vse", "method", "chooser", *list(self.rows[0].keys())] + rows = iter(self.rows if rows is None else rows) + first_row = next(rows, None) + if first_row is None: + raise ValueError("Cannot save a CSV batch with no result rows") + keys = ["vse", "method", "chooser", *list(first_row.keys())] for n in range(4): keys.extend([f"tallyName{str(n)}", f"tallyVal{str(n)}"]) keys = uniquify(keys) - with open(baseName + str(i) + ".csv", "w") as myFile: + output_file = baseName + str(i) + ".csv" + with open(output_file, "w", newline="") as myFile: print( f"# {dict(media=self.media.__name__, version=self.repo_version, seed=self.seed, model=self.model, methods=self.methods, nvot=self.nvot, ncand=self.ncand, niter=self.niter)}", file=myFile, @@ -180,8 +219,11 @@ def saveFile(self, baseName="SimResults"): dw = csv.DictWriter(myFile, keys, restval = "NA") dw.writeheader() - for r in self.rows: + dw.writerow(first_row) + for r in rows: dw.writerow(r) + self.output_file = output_file + return output_file @@ -229,7 +271,6 @@ def saveFile(self, baseName="SimResults"): [IRNR(), baseRuns], ] -#request from Mark: "SRV0-2, SRV0-3, SRV0-4, SRV0-5, SRV0-6, SRV0-7, SRV0-8, SRV0-9, SRV0-10, Score0-10, 321, Approval, IRV and plurality" markMethods = [ [Srv(2), baseRuns], [Srv(3), baseRuns], @@ -246,12 +287,6 @@ def saveFile(self, baseName="SimResults"): [Plurality(), baseRuns], ] -#usage example: -#>>> from vse import * -#>>> vses = CsvBatch(KSModel(dcdecay=(1,3),wcdecay=(1.5,3), dccut = .2, wcalpha=1.5), -# allSystems, nvot=40, ncand=6, niter=15000, baseName="target", -# media=fuzzyMediaFor()) - if __name__ == "__main__": import doctest setDebug( False)