diff --git a/docs/elsim.rst b/docs/elsim.rst index b2c4686..96d97e0 100644 --- a/docs/elsim.rst +++ b/docs/elsim.rst @@ -18,3 +18,4 @@ Subpackages elsim.elections elsim.strategies elsim.methods + elsim.studies diff --git a/docs/elsim.studies.rst b/docs/elsim.studies.rst new file mode 100644 index 0000000..50bccf2 --- /dev/null +++ b/docs/elsim.studies.rst @@ -0,0 +1,8 @@ +elsim.studies module +==================== + +.. automodule:: elsim.studies + :members: + :undoc-members: + :imported-members: + :show-inheritance: diff --git a/elsim/__init__.py b/elsim/__init__.py index bfc59ca..afd8c81 100644 --- a/elsim/__init__.py +++ b/elsim/__init__.py @@ -15,6 +15,6 @@ Functions that implement voting methods, which calculate a winner from a collection of ballots. """ -from . import elections, methods, strategies +from . import elections, methods, strategies, studies __version__ = "0.1.3" diff --git a/elsim/studies/__init__.py b/elsim/studies/__init__.py new file mode 100644 index 0000000..918e04a --- /dev/null +++ b/elsim/studies/__init__.py @@ -0,0 +1,38 @@ +""" +Tools for Monte Carlo election studies and paper-style reproduction scripts. + +This subpackage addresses the design goals in +https://github.com/endolith/elsim/issues/10: reusable parameter expansion +(Cartesian product vs. zipped columns vs. explicit scenario rows), batched trial +execution with a swappable backend (serial or Joblib), including repeating one +worker or mapping a list of independent zero-argument callables (for example +``functools.partial`` batch jobs), and small tallies shared by several examples. + +The ``elections`` / ``strategies`` / ``methods`` modules remain the core model; +``studies`` only orchestrates repeated draws and aggregation. +""" + +from .backends import JoblibBackend, SerialBackend +from .condorcet_metrics import merrill_1984_comparison_methods, tally_condorcet_agreement +from .parameters import expand_product, expand_rows, expand_zip +from .runner import merge_counters, run_batched +from .social_utility import ( + random_society_utility_updates, + ranked_rated_utility_updates, + spatial_random_reference_utility_updates, +) + +__all__ = [ + "JoblibBackend", + "SerialBackend", + "expand_product", + "expand_rows", + "expand_zip", + "merge_counters", + "run_batched", + "merrill_1984_comparison_methods", + "tally_condorcet_agreement", + "spatial_random_reference_utility_updates", + "random_society_utility_updates", + "ranked_rated_utility_updates", +] diff --git a/elsim/studies/backends.py b/elsim/studies/backends.py new file mode 100644 index 0000000..241e3f3 --- /dev/null +++ b/elsim/studies/backends.py @@ -0,0 +1,82 @@ +""" +Execution backends for repeating independent Monte Carlo batches. + +Serial execution is always available. Parallel execution uses Joblib when +installed (same optional dependency as the example scripts). +""" + +from __future__ import annotations + +from typing import Any, Callable, Sequence, TypeVar + +T = TypeVar("T") + + +class SerialBackend: + """Run ``fn()`` ``n`` times in the current process.""" + + def map_repeat(self, fn: Callable[[], T], n: int) -> list[T]: + if n < 0: + raise ValueError("n must be non-negative") + return [fn() for _ in range(n)] + + def map_each(self, fns: Sequence[Callable[[], T]]) -> list[T]: + """Invoke each zero-argument callable once, in order, and collect results.""" + return [fn() for fn in fns] + + +class JoblibBackend: + """ + Run independent calls with :class:`joblib.Parallel`. + + Parameters mirror the common ``Parallel`` constructor; see Joblib docs for + ``prefer``, ``backend``, etc. + + Raises + ------ + ImportError + If ``joblib`` is not installed. + """ + + def __init__(self, n_jobs: int = -1, verbose: int = 0, **parallel_kwargs: Any): + self.n_jobs = n_jobs + self.verbose = verbose + self.parallel_kwargs = parallel_kwargs + + def map_repeat(self, fn: Callable[[], T], n: int) -> list[T]: + if n < 0: + raise ValueError("n must be non-negative") + try: + from joblib import Parallel, delayed + except ImportError as exc: + raise ImportError( + "JoblibBackend requires the 'joblib' package " + "(install with pip install 'elsim[examples]' or pip install joblib)." + ) from exc + + jobs = [delayed(fn)() for _ in range(n)] + return Parallel( + n_jobs=self.n_jobs, + verbose=self.verbose, + **self.parallel_kwargs, + )(jobs) + + def map_each(self, fns: Sequence[Callable[[], T]]) -> list[T]: + """ + Invoke each zero-argument callable once (typical pattern: a list of + :func:`functools.partial` objects), preserving order of ``fns``. + """ + try: + from joblib import Parallel, delayed + except ImportError as exc: + raise ImportError( + "JoblibBackend requires the 'joblib' package " + "(install with pip install 'elsim[examples]' or pip install joblib)." + ) from exc + + jobs = [delayed(fn)() for fn in fns] + return Parallel( + n_jobs=self.n_jobs, + verbose=self.verbose, + **self.parallel_kwargs, + )(jobs) diff --git a/elsim/studies/condorcet_metrics.py b/elsim/studies/condorcet_metrics.py new file mode 100644 index 0000000..6916a5b --- /dev/null +++ b/elsim/studies/condorcet_metrics.py @@ -0,0 +1,98 @@ +""" +Condorcet-efficiency tallies used by several Merrill (1984) example scripts. + +These are thin wrappers around existing ``methods`` and ``strategies`` helpers +so driver loops can ``counter.update(...)`` in one line. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Callable, Mapping, Optional + +import numpy as np + +from elsim.methods import approval, black, borda, condorcet, coombs, fptp, irv, runoff, utility_winner +from elsim.strategies import approval_optimal + +RankedMethod = Callable[..., Optional[int]] +RatedMethod = Callable[..., Optional[int]] + + +def _approval_at_optimal(utilities: np.ndarray, tiebreaker: str) -> Optional[int]: # noqa: UP045 + return approval(approval_optimal(utilities), tiebreaker) + + +def merrill_1984_comparison_methods() -> tuple[dict[str, RankedMethod], dict[str, RatedMethod]]: + """ + Voting methods compared in Merrill (1984) Condorcet-efficiency tables. + + Returns + ------- + ranked_methods, rated_methods + Callables match the ``elsim.methods`` signatures: ranked methods take + ``(rankings, tiebreaker=...)``; rated methods take + ``(utilities, tiebreaker=...)``. + """ + ranked_methods: dict[str, RankedMethod] = { + "Plurality": fptp, + "Runoff": runoff, + "Hare": irv, + "Borda": borda, + "Coombs": coombs, + "Black": black, + } + rated_methods: dict[str, RatedMethod] = { + "SU max": utility_winner, + "Approval": _approval_at_optimal, + } + return ranked_methods, rated_methods + + +def tally_condorcet_agreement( + rankings: np.ndarray, + utilities: np.ndarray, + ranked_methods: Mapping[str, RankedMethod], + rated_methods: Mapping[str, RatedMethod], + *, + tiebreaker: str = "random", +) -> Counter: + """ + Count whether each method agrees with the Condorcet winner for one election. + + If there is no Condorcet winner, returns an empty counter. + + Parameters + ---------- + rankings : array_like + Honest (or strategic) rankings, shape ``(n_voters, n_cands)``. + utilities : array_like + Utilities aligned with ``rankings``, shape ``(n_voters, n_cands)``. + ranked_methods, rated_methods + Name to callable maps, same shapes as :func:`merrill_1984_comparison_methods`. + tiebreaker : str, optional + Passed through to each method callable. + + Returns + ------- + collections.Counter + Includes key ``\"CW\"`` when a Condorcet winner exists, plus one key per + supplied method name when that method's winner matches the Condorcet + winner. + """ + cw = condorcet(rankings) + if cw is None: + return Counter() + + out: Counter = Counter() + out["CW"] += 1 + + for name, fn in ranked_methods.items(): + if fn(rankings, tiebreaker=tiebreaker) == cw: + out[name] += 1 + + for name, fn in rated_methods.items(): + if fn(utilities, tiebreaker=tiebreaker) == cw: + out[name] += 1 + + return out diff --git a/elsim/studies/parameters.py b/elsim/studies/parameters.py new file mode 100644 index 0000000..019a27d --- /dev/null +++ b/elsim/studies/parameters.py @@ -0,0 +1,116 @@ +""" +Expand simulation parameters into explicit scenario dictionaries. + +Issue `#10 `_ called out three +common shapes: + +* **Cartesian product** — every combination of voter counts, candidate counts, + methods, etc. Use :func:`expand_product`. +* **Zipped columns** — parallel lists of the same length (e.g. sweep ``n_voters`` + and ``n_cands`` together). Use :func:`expand_zip`. +* **Explicit rows** — a small table of tuples such as Merrill 1984 Table 2 + ``(disp, corr, D)`` that is *not* a full product. Use :func:`expand_rows` or + pass your own sequence of mappings to your driver loop. + +Strings are treated as scalars (not iterated character-wise). +""" + +from __future__ import annotations + +from itertools import product +from typing import Any, Iterable, Mapping, Sequence, Union + +Scalar = Any +ScalarOrIterable = Union[Scalar, Iterable[Scalar]] + + +def _as_tuple(x: ScalarOrIterable) -> tuple[Scalar, ...]: + if isinstance(x, (str, bytes)): + return (x,) + if isinstance(x, Mapping): + raise TypeError("Mappings are not treated as iterables of scenarios; " + "pass keys to expand_product or use expand_rows.") + if isinstance(x, Iterable): + return tuple(x) + return (x,) + + +def expand_product(**params: ScalarOrIterable) -> list[dict[str, Any]]: + """ + Cartesian product of parameter values. + + Each keyword argument is either a single value or an iterable of values. + The return value is a list of dicts, one per combination, in deterministic + order (same as :func:`itertools.product`). + + Examples + -------- + >>> expand_product(n_voters=[10, 20], n_cands=3) + [{'n_voters': 10, 'n_cands': 3}, {'n_voters': 20, 'n_cands': 3}] + """ + if not params: + return [{}] + keys = list(params) + value_lists = [_as_tuple(params[k]) for k in keys] + return [dict(zip(keys, combo)) for combo in product(*value_lists)] + + +def expand_zip(**params: Iterable) -> list[dict[str, Any]]: + """ + Zip parallel parameter columns into scenario dicts. + + All iterables must have the same length. + + Parameters + ---------- + **params + Each value must be an iterable of scenario values for that key. + + Examples + -------- + >>> expand_zip(n_voters=[100, 200], n_cands=[3, 5]) + [{'n_voters': 100, 'n_cands': 3}, {'n_voters': 200, 'n_cands': 5}] + """ + if not params: + return [] + keys = list(params) + columns = [list(params[k]) for k in keys] + lengths = {len(c) for c in columns} + if len(lengths) > 1: + raise ValueError( + "expand_zip: all parameter lists must have the same length; " + f"got {dict(zip(keys, map(len, columns)))}" + ) + rows = zip(*columns) + return [dict(zip(keys, row)) for row in rows] + + +def expand_rows(rows: Sequence[Sequence[Any]], keys: Sequence[str]) -> list[dict[str, Any]]: + """ + Turn fixed scenario rows into dicts. + + Use this for tables like Merrill (1984) Table 2 where each row is a + deliberate ``(disp, corr, D)`` triple rather than a combination from a grid. + + Parameters + ---------- + rows : sequence of row sequences + Each inner sequence must have ``len(keys)`` entries. + keys : sequence of str + Names for each column. + + Examples + -------- + >>> expand_rows([(1.0, 0.5, 2), (0.5, 0.0, 4)], ('disp', 'corr', 'D')) + [{'disp': 1.0, 'corr': 0.5, 'D': 2}, {'disp': 0.5, 'corr': 0.0, 'D': 4}] + """ + keys_t = tuple(keys) + out: list[dict[str, Any]] = [] + for i, row in enumerate(rows): + row_t = tuple(row) + if len(row_t) != len(keys_t): + raise ValueError( + f"expand_rows: row {i} has length {len(row_t)} but {len(keys_t)} keys were given" + ) + out.append(dict(zip(keys_t, row_t))) + return out diff --git a/elsim/studies/runner.py b/elsim/studies/runner.py new file mode 100644 index 0000000..61e39e2 --- /dev/null +++ b/elsim/studies/runner.py @@ -0,0 +1,69 @@ +""" +Batched Monte Carlo execution and simple result merging. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Callable, Iterable, TypeVar + +from .backends import SerialBackend + +T = TypeVar("T") + + +def run_batched( + batch_fn: Callable[[int], T], + n_trials: int, + batch_size: int, + *, + backend=None, +) -> list[T]: + """ + Run a trial batch worker an integer number of times. + + ``batch_fn(k)`` is invoked with ``k == batch_size`` for each full batch, + and once more with ``k == n_trials % batch_size`` when the remainder is + non-zero. + + Parameters + ---------- + batch_fn : callable + ``batch_fn(batch_size) -> partial result`` for one batch. + n_trials : int + Total number of trials across all batches. + batch_size : int + Preferred batch size (must be positive). + backend : object with ``map_repeat(fn, n) -> list``, optional + Defaults to :class:`elsim.studies.backends.SerialBackend` inside + :func:`map_repeat` for the full-sized batches only; the remainder batch + always runs in-process. + + Returns + ------- + list + One return value per batch invocation (length ``ceil(n_trials / batch_size)``). + """ + if batch_size <= 0: + raise ValueError("batch_size must be positive") + if n_trials < 0: + raise ValueError("n_trials must be non-negative") + if n_trials == 0: + return [] + + n_full, rem = divmod(n_trials, batch_size) + if backend is None: + backend = SerialBackend() + + parts: list[T] = backend.map_repeat(lambda: batch_fn(batch_size), n_full) + if rem: + parts.append(batch_fn(rem)) + return parts + + +def merge_counters(partials: Iterable[Counter]) -> Counter: + """Sum a sequence of :class:`~collections.Counter` objects.""" + total: Counter = Counter() + for c in partials: + total.update(c) + return total diff --git a/elsim/studies/social_utility.py b/elsim/studies/social_utility.py new file mode 100644 index 0000000..7dbb21d --- /dev/null +++ b/elsim/studies/social_utility.py @@ -0,0 +1,104 @@ +""" +Scalar social-utility totals for Monte Carlo scripts (Merrill, Weber, etc.). + +These return per-election increments as plain floats so callers can accumulate +into :class:`collections.Counter` objects keyed by scenario (as in Merrill +Table 4) or by ``(method, n_cands)`` via nested counters. +""" + +from __future__ import annotations + +import random +from typing import Callable, Mapping, Optional + +import numpy as np + +from elsim.methods import utility_winner + +RankedMethod = Callable[..., Optional[int]] +RatedMethod = Callable[..., Optional[int]] + + +def spatial_random_reference_utility_updates( + utilities: np.ndarray, + rankings: np.ndarray, + ranked_methods: Mapping[str, RankedMethod], + rated_methods: Mapping[str, RatedMethod], + *, + tiebreaker: str = "random", +) -> dict[str, float]: + """ + Total utility (summed over voters) for each method winner plus random baseline. + + Matches the Merrill (1984) spatial social-utility-efficiency figures: pick + ``RW`` with ``random.randint``, accumulate rated and ranked method winners, + and use the same per-winner column sum as ``utilities.sum(axis=0)[w]``. + """ + n_cands = utilities.shape[1] + rw = random.randint(0, n_cands - 1) + out: dict[str, float] = {"RW": float(utilities.sum(axis=0)[rw])} + + for name, fn in rated_methods.items(): + w = fn(utilities, tiebreaker=tiebreaker) + out[name] = float(utilities.sum(axis=0)[w]) + + for name, fn in ranked_methods.items(): + w = fn(rankings, tiebreaker=tiebreaker) + out[name] = float(utilities.sum(axis=0)[w]) + + return out + + +def random_society_utility_updates( + utilities: np.ndarray, + rankings: np.ndarray, + ranked_methods: Mapping[str, RankedMethod], + rated_methods: Mapping[str, RatedMethod], + *, + tiebreaker: str = "random", + uw_key: str = "UW", + utility_winner_tiebreaker: Optional[str] = "random", +) -> dict[str, float]: + """ + Utility totals for Merrill-style random societies (Table 3 / Fig 3). + + Parameters + ---------- + utility_winner_tiebreaker + If ``None``, call ``utility_winner(utilities)`` with no tiebreaker + (Weber-style scripts). Otherwise pass through to ``utility_winner``. + """ + if utility_winner_tiebreaker is None: + uw = utility_winner(utilities) + else: + uw = utility_winner(utilities, tiebreaker=utility_winner_tiebreaker) + out: dict[str, float] = {uw_key: float(utilities.sum(axis=0)[uw])} + + for name, fn in rated_methods.items(): + w = fn(utilities, tiebreaker=tiebreaker) + out[name] = float(utilities.sum(axis=0)[w]) + + for name, fn in ranked_methods.items(): + w = fn(rankings, tiebreaker=tiebreaker) + out[name] = float(utilities.sum(axis=0)[w]) + + return out + + +def ranked_rated_utility_updates( + utilities: np.ndarray, + rankings: np.ndarray, + ranked_methods: Mapping[str, RankedMethod], + rated_methods: Mapping[str, RatedMethod], + *, + tiebreaker: str = "random", +) -> dict[str, float]: + """Per-election utility totals for ranked and rated methods only (no UW/RW).""" + out: dict[str, float] = {} + for name, fn in rated_methods.items(): + w = fn(utilities, tiebreaker=tiebreaker) + out[name] = float(utilities.sum(axis=0)[w]) + for name, fn in ranked_methods.items(): + w = fn(rankings, tiebreaker=tiebreaker) + out[name] = float(utilities.sum(axis=0)[w]) + return out diff --git a/examples/distributions_by_dispersion.py b/examples/distributions_by_dispersion.py index 91a9b81..e3052fe 100644 --- a/examples/distributions_by_dispersion.py +++ b/examples/distributions_by_dispersion.py @@ -7,12 +7,12 @@ import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from seaborn import histplot, kdeplot from elsim.elections import normal_electorate, normed_dist_utilities from elsim.methods import black, fptp, irv, star, utility_winner from elsim.strategies import honest_normed_scores, honest_rankings +from elsim.studies import JoblibBackend n_elections = 50_000 # Roughly 60 seconds on a 2019 6-core i7-9750H n_voters = 1_000 @@ -40,7 +40,7 @@ def human_format(num): def simulate_batch(): winners = defaultdict(list) for disp in disps_list: - for iteration in range(batch_size): + for _iteration in range(batch_size): v, c = normal_electorate(n_voters, n_cands, dims=1, disp=disp) if cand_dist == 'uniform': @@ -85,9 +85,9 @@ def simulate_batch(): return winners -jobs = [delayed(simulate_batch)()] * n_batches -print(f'{len(jobs)} tasks total:') -results = Parallel(n_jobs=-3, verbose=5)(jobs) +backend = JoblibBackend(n_jobs=-3, verbose=5) +print(f'{n_batches} tasks total:') +results = backend.map_repeat(simulate_batch, n_batches) winners = {k: [v for d in results for v in d[k]] for k in results[0]} diff --git a/examples/distributions_by_method.py b/examples/distributions_by_method.py index 1af6342..4d63236 100644 --- a/examples/distributions_by_method.py +++ b/examples/distributions_by_method.py @@ -6,12 +6,12 @@ import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from seaborn import histplot, kdeplot from elsim.elections import normal_electorate, normed_dist_utilities from elsim.methods import black, fptp, irv, runoff, star from elsim.strategies import honest_normed_scores, honest_rankings +from elsim.studies import JoblibBackend n_elections = 100_000 # Roughly 1 minute on a 2019 6-core i7-9750H n_voters = 1_000 @@ -35,7 +35,7 @@ def human_format(num): def simulate_batch(): winners = defaultdict(list) - for iteration in range(batch_size): + for _iteration in range(batch_size): v, c = normal_electorate(n_voters, n_cands, dims=1, disp=disp) if cand_dist == 'uniform': @@ -87,9 +87,9 @@ def simulate_batch(): return winners -jobs = [delayed(simulate_batch)()] * n_batches -print(f'{len(jobs)} tasks total:') -results = Parallel(n_jobs=-3, verbose=5)(jobs) +backend = JoblibBackend(n_jobs=-3, verbose=5) +print(f'{n_batches} tasks total:') +results = backend.map_repeat(simulate_batch, n_batches) winners = {k: [v for d in results for v in d[k]] for k in results[0]} diff --git a/examples/distributions_by_method_2D.py b/examples/distributions_by_method_2D.py index 0d3531c..012c143 100644 --- a/examples/distributions_by_method_2D.py +++ b/examples/distributions_by_method_2D.py @@ -24,20 +24,19 @@ import os import pickle from collections import defaultdict +from functools import partial import matplotlib.pyplot as plt import numpy as np -# from colorcet import fire -from joblib import Parallel, delayed +# from colorcet import fire from elsim.elections import normal_electorate, normed_dist_utilities -from elsim.methods import (approval, black, borda, coombs, fptp, irv, runoff, - star) -from elsim.strategies import (approval_optimal, honest_normed_scores, - honest_rankings, vote_for_k) +from elsim.methods import approval, black, borda, coombs, fptp, irv, runoff, star +from elsim.strategies import approval_optimal, honest_normed_scores, honest_rankings, vote_for_k +from elsim.studies import JoblibBackend try: - import ehtplot.color # Creates afmhot_u colormap + pass # Creates afmhot_u colormap except ValueError: # https://github.com/liamedeiros/ehtplot/pull/6 pass @@ -72,7 +71,7 @@ def human_format(num): def simulate_batch(n_cands): winners = defaultdict(list) - for iteration in range(batch_size): + for _iteration in range(batch_size): v, c = normal_electorate(n_voters, n_cands, dims=dims, disp=disp) # Contrived candidate at exact center @@ -155,21 +154,20 @@ def simulate_batch(n_cands): title += f'{human_format(n_voters)} voters, ' title += f'{human_format(n_cands)} candidates' if cand_dist == 'normal': - title += f', both Gaussian' + title += ', both Gaussian' title += f', {disp:.1f} relative dispersion' # Load from .pkl file if it exists pkl_filename = title + '.pkl' if os.path.exists(pkl_filename): - print('Loading pickled simulation results') with open(pkl_filename, "rb") as file: aggregated_histograms, standard_deviations = pickle.load(file) else: print('Running simulations') - jobs = [delayed(simulate_batch)(n_cands)] * n_batches - print(f'{len(jobs)} tasks total:') - results = Parallel(n_jobs=-3, verbose=5)(jobs) - del jobs + backend = JoblibBackend(n_jobs=-3, verbose=5) + worker = partial(simulate_batch, n_cands) + print(f'{n_batches} tasks total:') + results = backend.map_repeat(worker, n_batches) # Get keys from the histograms of the first result keys = results[0][0].keys() @@ -203,9 +201,7 @@ def simulate_batch(n_cands): # %% Measure distributions for method, std in standard_deviations.items(): - print(f"{method}:") - print(f"Winner distribution std: {std[0]:.3f}") - print() + pass # %% Plotting diff --git a/examples/distributions_by_n_cands.py b/examples/distributions_by_n_cands.py index a220836..fa6f9a3 100644 --- a/examples/distributions_by_n_cands.py +++ b/examples/distributions_by_n_cands.py @@ -7,12 +7,12 @@ import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from seaborn import histplot, kdeplot from elsim.elections import normal_electorate, normed_dist_utilities from elsim.methods import black, fptp, irv, star, utility_winner from elsim.strategies import honest_normed_scores, honest_rankings +from elsim.studies import JoblibBackend n_elections = 20_000 # Roughly 30 seconds on a 2019 6-core i7-9750H n_voters = 1_000 @@ -40,7 +40,7 @@ def human_format(num): def simulate_batch(): winners = defaultdict(list) for n_cands in n_cands_list: - for iteration in range(batch_size): + for _iteration in range(batch_size): v, c = normal_electorate(n_voters, n_cands, dims=1, disp=disp) if cand_dist == 'uniform': @@ -85,9 +85,9 @@ def simulate_batch(): return winners -jobs = [delayed(simulate_batch)()] * n_batches -print(f'{len(jobs)} tasks total:') -results = Parallel(n_jobs=-3, verbose=5)(jobs) +backend = JoblibBackend(n_jobs=-3, verbose=5) +print(f'{n_batches} tasks total:') +results = backend.map_repeat(simulate_batch, n_batches) winners = {k: [v for d in results for v in d[k]] for k in results[0]} diff --git a/examples/hypothesis_election_finder.py b/examples/hypothesis_election_finder.py index 97d80ca..1944583 100644 --- a/examples/hypothesis_election_finder.py +++ b/examples/hypothesis_election_finder.py @@ -1,6 +1,9 @@ """ Use Hypothesis to find simple elections that violate Condorcet compliance. +This is property-based search (Hypothesis), not a batched Monte Carlo study, so +it does not use ``elsim.studies`` parallel helpers. + This depends on Hypothesis' "shrinking" algorithm, which is not guaranteed to find the absolute simplest case (or any at all), but typically works well. https://hypothesis.readthedocs.io/en/latest/data.html#shrinking diff --git a/examples/merrill_1984_fig_2a_2b.py b/examples/merrill_1984_fig_2a_2b.py index ff4774f..72764e6 100644 --- a/examples/merrill_1984_fig_2a_2b.py +++ b/examples/merrill_1984_fig_2a_2b.py @@ -14,6 +14,7 @@ import matplotlib.pyplot as plt from elsim.elections import normal_electorate +from elsim.studies import expand_product n_voters = 201 n_cands = 5 @@ -22,7 +23,8 @@ # rotated to principal axes.)" corr = 0.5 -for disp in (0.5, 1.0): +for scenario in expand_product(disp=(0.5, 1.0)): + disp = scenario['disp'] voters, cands = normal_electorate(n_voters, n_cands, dims=2, corr=corr, disp=disp) diff --git a/examples/merrill_1984_fig_2c_2d.py b/examples/merrill_1984_fig_2c_2d.py index 8d50b09..68862ac 100644 --- a/examples/merrill_1984_fig_2c_2d.py +++ b/examples/merrill_1984_fig_2c_2d.py @@ -49,9 +49,8 @@ from tabulate import tabulate from elsim.elections import normal_electorate, normed_dist_utilities -from elsim.methods import (approval, black, borda, condorcet, coombs, fptp, - irv, runoff, utility_winner) -from elsim.strategies import approval_optimal, honest_rankings +from elsim.strategies import honest_rankings +from elsim.studies import merrill_1984_comparison_methods, tally_condorcet_agreement n_elections = 10_000 # Roughly 30 seconds each on a 2019 6-core i7-9750H n_voters = 201 @@ -59,12 +58,7 @@ corr = 0.5 D = 2 -ranked_methods = {'Plurality': fptp, 'Runoff': runoff, 'Hare': irv, - 'Borda': borda, 'Coombs': coombs, 'Black': black} - -rated_methods = {'SU max': utility_winner, - 'Approval': lambda utilities, tiebreaker: - approval(approval_optimal(utilities), tiebreaker)} +ranked_methods, rated_methods = merrill_1984_comparison_methods() # Plot Merrill's results as dotted lines for comparison (traced from plots) merrill_fig_2c = { @@ -94,26 +88,19 @@ ranked_methods.keys() | rated_methods.keys() | {'CW'})} start_time = time.monotonic() - for iteration in range(n_elections): + for _ in range(n_elections): for n_cands in n_cands_list: v, c = normal_electorate(n_voters, n_cands, dims=D, corr=corr, disp=disp) utilities = normed_dist_utilities(v, c) rankings = honest_rankings(utilities) - # If there is a Condorcet winner, analyze election, otherwise skip - # it - CW = condorcet(rankings) - if CW is not None: - condorcet_winner_count['CW'][n_cands] += 1 - - for name, method in ranked_methods.items(): - if method(rankings, tiebreaker='random') == CW: - condorcet_winner_count[name][n_cands] += 1 - - for name, method in rated_methods.items(): - if method(utilities, tiebreaker='random') == CW: - condorcet_winner_count[name][n_cands] += 1 + delta = tally_condorcet_agreement( + rankings, utilities, ranked_methods, rated_methods, + tiebreaker='random', + ) + for key, value in delta.items(): + condorcet_winner_count[key][n_cands] += value elapsed_time = time.monotonic() - start_time print('Elapsed:', time.strftime("%H:%M:%S", time.gmtime(elapsed_time)), @@ -148,6 +135,7 @@ print(tabulate(table, ["Method", *x], tablefmt="pipe", floatfmt='.1f')) print() + plt.plot([], [], 'k:', lw=0.8, label='Merrill') # Dummy plot for label plt.legend() plt.grid(True, color='0.7', linestyle='-', which='major', axis='both') diff --git a/examples/merrill_1984_fig_2c_2d_updated.py b/examples/merrill_1984_fig_2c_2d_updated.py index a5aaa24..6f38201 100644 --- a/examples/merrill_1984_fig_2c_2d_updated.py +++ b/examples/merrill_1984_fig_2c_2d_updated.py @@ -53,10 +53,9 @@ from tabulate import tabulate from elsim.elections import normal_electorate, normed_dist_utilities -from elsim.methods import (approval, black, borda, condorcet, coombs, fptp, - irv, runoff, score, star, utility_winner) -from elsim.strategies import (approval_optimal, honest_normed_scores, - honest_rankings) +from elsim.methods import approval, black, borda, coombs, fptp, irv, runoff, score, star, utility_winner +from elsim.strategies import approval_optimal, honest_normed_scores, honest_rankings +from elsim.studies import tally_condorcet_agreement n_elections = 5_000 # Roughly 30 seconds each on a 2019 6-core i7-9750H n_voters = 201 @@ -83,26 +82,19 @@ ranked_methods.keys() | rated_methods.keys() | {'CW'})} start_time = time.monotonic() - for iteration in range(n_elections): + for _ in range(n_elections): for n_cands in n_cands_list: v, c = normal_electorate(n_voters, n_cands, dims=D, corr=corr, disp=disp) utilities = normed_dist_utilities(v, c) rankings = honest_rankings(utilities) - # If there is a Condorcet winner, analyze election, otherwise skip - # it - CW = condorcet(rankings) - if CW is not None: - condorcet_winner_count['CW'][n_cands] += 1 - - for name, method in ranked_methods.items(): - if method(rankings, tiebreaker='random') == CW: - condorcet_winner_count[name][n_cands] += 1 - - for name, method in rated_methods.items(): - if method(utilities, tiebreaker='random') == CW: - condorcet_winner_count[name][n_cands] += 1 + delta = tally_condorcet_agreement( + rankings, utilities, ranked_methods, rated_methods, + tiebreaker='random', + ) + for key, value in delta.items(): + condorcet_winner_count[key][n_cands] += value elapsed_time = time.monotonic() - start_time print('Elapsed:', time.strftime("%H:%M:%S", time.gmtime(elapsed_time)), diff --git a/examples/merrill_1984_fig_4a_4b.py b/examples/merrill_1984_fig_4a_4b.py index d229c28..843d098 100644 --- a/examples/merrill_1984_fig_4a_4b.py +++ b/examples/merrill_1984_fig_4a_4b.py @@ -14,27 +14,27 @@ 4.a -| Method | 2 | 3 | 4 | 5 | 6 | 7 | -|:----------|------:|-----:|-----:|-----:|-----:|-----:| -| Black | 100.0 | 97.2 | 97.1 | 97.3 | 97.6 | 97.8 | -| Coombs | 100.0 | 97.1 | 96.8 | 97.0 | 97.2 | 97.4 | -| Borda | 100.0 | 98.7 | 98.2 | 97.9 | 97.7 | 97.6 | -| Approval | 100.0 | 98.7 | 97.3 | 96.2 | 95.6 | 95.2 | -| Hare | 100.0 | 94.2 | 92.6 | 91.7 | 91.0 | 90.3 | -| Runoff | 100.0 | 94.2 | 92.0 | 90.4 | 88.9 | 87.4 | -| Plurality | 100.0 | 84.7 | 77.1 | 72.1 | 68.1 | 64.8 | +| Method | 2 | 3 | 4 | 5 | 6 | 7 | +|:----------|------:|------:|------:|------:|------:|------:| +| Black | 100.0 | 97.2 | 97.1 | 97.3 | 97.6 | 97.8 | +| Coombs | 100.0 | 97.1 | 96.8 | 97.0 | 97.2 | 97.4 | +| Borda | 100.0 | 98.7 | 98.2 | 97.9 | 97.7 | 97.6 | +| Approval | 100.0 | 98.7 | 97.3 | 96.2 | 95.6 | 95.2 | +| Hare | 100.0 | 94.2 | 92.6 | 91.7 | 91.0 | 90.3 | +| Runoff | 100.0 | 94.2 | 92.0 | 90.4 | 88.9 | 87.4 | +| Plurality | 100.0 | 84.7 | 77.1 | 72.1 | 68.1 | 64.8 | 4.b -| Method | 2 | 3 | 4 | 5 | 6 | 7 | -|:----------|------:|-----:|-----:|-----:|------:|------:| -| Black | 100.0 | 95.5 | 95.2 | 95.5 | 95.8 | 96.2 | -| Coombs | 100.0 | 94.9 | 94.1 | 94.0 | 94.0 | 94.1 | -| Borda | 100.0 | 97.9 | 97.1 | 96.6 | 96.4 | 96.3 | -| Approval | 100.0 | 98.6 | 96.7 | 95.6 | 94.9 | 94.5 | -| Hare | 100.0 | 70.2 | 55.9 | 46.7 | 39.7 | 34.6 | -| Runoff | 100.0 | 70.2 | 51.7 | 36.9 | 24.3 | 13.5 | -| Plurality | 100.0 | 50.1 | 23.7 | 4.3 | -11.8 | -25.1 | +| Method | 2 | 3 | 4 | 5 | 6 | 7 | +|:----------|------:|------:|------:|------:|------:|------:| +| Black | 100.0 | 95.5 | 95.2 | 95.5 | 95.8 | 96.2 | +| Coombs | 100.0 | 94.9 | 94.1 | 94.0 | 94.0 | 94.1 | +| Borda | 100.0 | 97.9 | 97.1 | 96.6 | 96.4 | 96.3 | +| Approval | 100.0 | 98.6 | 96.7 | 95.6 | 94.9 | 94.5 | +| Hare | 100.0 | 70.2 | 55.9 | 46.7 | 39.7 | 34.6 | +| Runoff | 100.0 | 70.2 | 51.7 | 36.9 | 24.3 | 13.5 | +| Plurality | 100.0 | 50.1 | 23.7 | 4.3 | -11.8 | -25.1 | The general trend is similar to Merrill's, but there are significant discrepancies. It is smoother, so maybe the original just had lower number of @@ -42,16 +42,17 @@ """ import time from collections import Counter -from random import randint import matplotlib.pyplot as plt import numpy as np from tabulate import tabulate from elsim.elections import normal_electorate, normed_dist_utilities -from elsim.methods import (approval, black, borda, coombs, fptp, irv, runoff, - utility_winner) -from elsim.strategies import approval_optimal, honest_rankings +from elsim.strategies import honest_rankings +from elsim.studies import ( + merrill_1984_comparison_methods, + spatial_random_reference_utility_updates, +) n_elections = 10_000 # Roughly 30 seconds each on a 2019 6-core i7-9750H n_voters = 201 @@ -59,12 +60,7 @@ corr = 0.5 D = 2 -ranked_methods = {'Plurality': fptp, 'Runoff': runoff, 'Hare': irv, - 'Borda': borda, 'Coombs': coombs, 'Black': black} - -rated_methods = {'SU max': utility_winner, - 'Approval': lambda utilities, tiebreaker: - approval(approval_optimal(utilities), tiebreaker)} +ranked_methods, rated_methods = merrill_1984_comparison_methods() # Plot Merrill's results as dotted lines for comparison (traced from plots) merrill_fig_4a = { @@ -95,24 +91,19 @@ {'SU max', 'RW'})} start_time = time.monotonic() - for iteration in range(n_elections): + for _ in range(n_elections): for n_cands in n_cands_list: v, c = normal_electorate(n_voters, n_cands, dims=D, corr=corr, disp=disp) utilities = normed_dist_utilities(v, c) rankings = honest_rankings(utilities) - # Pick a random winner and accumulate utilities - RW = randint(0, n_cands - 1) - utility_sums['RW'][n_cands] += utilities.sum(axis=0)[RW] - - for name, method in rated_methods.items(): - winner = method(utilities, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] - - for name, method in ranked_methods.items(): - winner = method(rankings, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] + delta = spatial_random_reference_utility_updates( + utilities, rankings, ranked_methods, rated_methods, + tiebreaker='random', + ) + for name, value in delta.items(): + utility_sums[name][n_cands] += value elapsed_time = time.monotonic() - start_time print('Elapsed:', time.strftime("%H:%M:%S", time.gmtime(elapsed_time)), diff --git a/examples/merrill_1984_fig_4a_4b_updated.py b/examples/merrill_1984_fig_4a_4b_updated.py index f6b286b..c55a7e7 100644 --- a/examples/merrill_1984_fig_4a_4b_updated.py +++ b/examples/merrill_1984_fig_4a_4b_updated.py @@ -46,17 +46,15 @@ """ import time from collections import Counter -from random import randint import matplotlib.pyplot as plt import numpy as np from tabulate import tabulate from elsim.elections import normal_electorate, normed_dist_utilities -from elsim.methods import (approval, black, borda, coombs, fptp, irv, runoff, - score, star, utility_winner) -from elsim.strategies import (approval_optimal, honest_normed_scores, - honest_rankings) +from elsim.methods import approval, black, borda, coombs, fptp, irv, runoff, score, star, utility_winner +from elsim.strategies import approval_optimal, honest_normed_scores, honest_rankings +from elsim.studies import spatial_random_reference_utility_updates n_elections = 5_000 # Roughly 30 seconds each on a 2019 6-core i7-9750H n_voters = 201 @@ -78,32 +76,27 @@ tiebreaker), } -for fig, disp, ymin in (('4.a', 1.0, 55), - ('4.b', 0.5, 0)): +for fig, disp in (('4.a', 1.0), + ('4.b', 0.5)): utility_sums = {key: Counter() for key in (ranked_methods.keys() | rated_methods.keys() | {'SU max', 'RW'})} start_time = time.monotonic() - for iteration in range(n_elections): + for _ in range(n_elections): for n_cands in n_cands_list: v, c = normal_electorate(n_voters, n_cands, dims=D, corr=corr, disp=disp) utilities = normed_dist_utilities(v, c) rankings = honest_rankings(utilities) - # Pick a random winner and accumulate utilities - RW = randint(0, n_cands - 1) - utility_sums['RW'][n_cands] += utilities.sum(axis=0)[RW] - - for name, method in rated_methods.items(): - winner = method(utilities, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] - - for name, method in ranked_methods.items(): - winner = method(rankings, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] + delta = spatial_random_reference_utility_updates( + utilities, rankings, ranked_methods, rated_methods, + tiebreaker='random', + ) + for name, value in delta.items(): + utility_sums[name][n_cands] += value elapsed_time = time.monotonic() - start_time print('Elapsed:', time.strftime("%H:%M:%S", time.gmtime(elapsed_time)), @@ -132,6 +125,6 @@ plt.legend() plt.grid(True, color='0.7', linestyle='-', which='major', axis='both') plt.grid(True, color='0.9', linestyle='-', which='minor', axis='both') - plt.ylim(85, 100.5) # or ymin + plt.ylim(85, 100.5) plt.xlim(1.8, 7.2) plt.show() diff --git a/examples/merrill_1984_table_1_fig_1.py b/examples/merrill_1984_table_1_fig_1.py index 4a817c2..2604ac1 100644 --- a/examples/merrill_1984_table_1_fig_1.py +++ b/examples/merrill_1984_table_1_fig_1.py @@ -32,20 +32,14 @@ from tabulate import tabulate from elsim.elections import random_utilities -from elsim.methods import (approval, black, borda, condorcet, coombs, fptp, - irv, runoff, utility_winner) -from elsim.strategies import approval_optimal, honest_rankings +from elsim.strategies import honest_rankings +from elsim.studies import merrill_1984_comparison_methods, tally_condorcet_agreement n_elections = 10_000 # Roughly 15 seconds on a 2019 6-core i7-9750H n_voters = 25 n_cands_list = (2, 3, 4, 5, 7, 10) -ranked_methods = {'Plurality': fptp, 'Runoff': runoff, 'Hare': irv, - 'Borda': borda, 'Coombs': coombs, 'Black': black} - -rated_methods = {'SU max': utility_winner, - 'Approval': lambda utilities, tiebreaker: - approval(approval_optimal(utilities), tiebreaker)} +ranked_methods, rated_methods = merrill_1984_comparison_methods() condorcet_winner_count = {key: Counter() for key in ( ranked_methods.keys() | rated_methods.keys() | {'CW'})} @@ -68,18 +62,11 @@ rankings = honest_rankings(utilities) - # If there is a Condorcet winner, analyze election, otherwise skip it - CW = condorcet(rankings) - if CW is not None: - condorcet_winner_count['CW'][n_cands] += 1 - - for name, method in ranked_methods.items(): - if method(rankings, tiebreaker='random') == CW: - condorcet_winner_count[name][n_cands] += 1 - - for name, method in rated_methods.items(): - if method(utilities, tiebreaker='random') == CW: - condorcet_winner_count[name][n_cands] += 1 + delta = tally_condorcet_agreement( + rankings, utilities, ranked_methods, rated_methods, tiebreaker='random', + ) + for key, value in delta.items(): + condorcet_winner_count[key][n_cands] += value elapsed_time = time.monotonic() - start_time print('Elapsed:', time.strftime("%H:%M:%S", time.gmtime(elapsed_time)), '\n') diff --git a/examples/merrill_1984_table_2.py b/examples/merrill_1984_table_2.py index bda0e82..c684900 100644 --- a/examples/merrill_1984_table_2.py +++ b/examples/merrill_1984_table_2.py @@ -38,42 +38,38 @@ from tabulate import tabulate from elsim.elections import normal_electorate, normed_dist_utilities -from elsim.methods import (approval, black, borda, condorcet, coombs, fptp, - irv, runoff, utility_winner) -from elsim.strategies import approval_optimal, honest_rankings +from elsim.strategies import honest_rankings +from elsim.studies import expand_rows, merrill_1984_comparison_methods, tally_condorcet_agreement n_elections = 10_000 # Roughly 60 seconds on a 2019 6-core i7-9750H n_voters = 201 n_cands = 5 -ranked_methods = {'Plurality': fptp, 'Runoff': runoff, 'Hare': irv, - 'Borda': borda, 'Coombs': coombs, 'Black': black} +ranked_methods, rated_methods = merrill_1984_comparison_methods() -rated_methods = {'SU max': utility_winner, - 'Approval': lambda utilities, tiebreaker: - approval(approval_optimal(utilities), tiebreaker)} +# disp, corr, D +condition_rows = ((1.0, 0.5, 2), + (1.0, 0.5, 4), + (1.0, 0.0, 2), + (1.0, 0.0, 4), + (0.5, 0.5, 2), + (0.5, 0.5, 4), + (0.5, 0.0, 2), + (0.5, 0.0, 4), + ) +conditions = expand_rows(condition_rows, ('disp', 'corr', 'D')) start_time = time.monotonic() -# disp, corr, D -conditions = ((1.0, 0.5, 2), - (1.0, 0.5, 4), - (1.0, 0.0, 2), - (1.0, 0.0, 4), - (0.5, 0.5, 2), - (0.5, 0.5, 4), - (0.5, 0.0, 2), - (0.5, 0.0, 4), - ) - results = [] -for disp, corr, D in conditions: +for scenario in conditions: + disp, corr, D = scenario['disp'], scenario['corr'], scenario['D'] print(disp, corr, D) condorcet_winner_count = Counter() - for iteration in range(n_elections): + for _ in range(n_elections): v, c = normal_electorate(n_voters, n_cands, dims=D, corr=corr, disp=disp) @@ -90,18 +86,11 @@ utilities = normed_dist_utilities(v, c) rankings = honest_rankings(utilities) - # If there is a Condorcet winner, analyze election, otherwise skip it - CW = condorcet(rankings) - if CW is not None: - condorcet_winner_count['CW'] += 1 - - for name, method in ranked_methods.items(): - if method(rankings, tiebreaker='random') == CW: - condorcet_winner_count[name] += 1 - - for name, method in rated_methods.items(): - if method(utilities, tiebreaker='random') == CW: - condorcet_winner_count[name] += 1 + condorcet_winner_count.update( + tally_condorcet_agreement( + rankings, utilities, ranked_methods, rated_methods, tiebreaker='random', + ), + ) results.append(condorcet_winner_count) @@ -110,7 +99,7 @@ # Neither Tabulate nor Markdown support column span or multiple headers, but # at least this prints to plain text in a readable way. -header = ['Disp\nCorr\nDims'] + [f'{x}\n{y}\n{z}' for x, y, z in conditions] +header = ['Disp\nCorr\nDims'] + [f'{x}\n{y}\n{z}' for x, y, z in condition_rows] # Of those elections with CW, likelihood that method chooses CW table = [] diff --git a/examples/merrill_1984_table_3_fig_3.py b/examples/merrill_1984_table_3_fig_3.py index 7fc1676..7d51d12 100644 --- a/examples/merrill_1984_table_3_fig_3.py +++ b/examples/merrill_1984_table_3_fig_3.py @@ -30,9 +30,9 @@ from tabulate import tabulate from elsim.elections import random_utilities -from elsim.methods import (approval, black, borda, coombs, fptp, irv, runoff, - utility_winner) +from elsim.methods import approval, black, borda, coombs, fptp, irv, runoff from elsim.strategies import approval_optimal, honest_rankings +from elsim.studies import random_society_utility_updates n_elections = 10_000 # Roughly 30 seconds on a 2019 6-core i7-9750H n_voters = 25 @@ -49,7 +49,7 @@ start_time = time.monotonic() -for iteration in range(n_elections): +for _ in range(n_elections): for n_cands in n_cands_list: utilities = random_utilities(n_voters, n_cands) @@ -62,18 +62,16 @@ utilities -= utilities.min(1)[:, np.newaxis] utilities /= utilities.max(1)[:, np.newaxis] - # Find the social utility winner and accumulate utilities - UW = utility_winner(utilities) - utility_sums['UW'][n_cands] += utilities.sum(axis=0)[UW] - - for name, method in rated_methods.items(): - winner = method(utilities, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] - rankings = honest_rankings(utilities) - for name, method in ranked_methods.items(): - winner = method(rankings, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] + + delta = random_society_utility_updates( + utilities, rankings, ranked_methods, rated_methods, + tiebreaker='random', + uw_key='UW', + utility_winner_tiebreaker=None, + ) + for name, value in delta.items(): + utility_sums[name][n_cands] += value elapsed_time = time.monotonic() - start_time diff --git a/examples/merrill_1984_table_4.py b/examples/merrill_1984_table_4.py index 80e706f..a3f5bec 100644 --- a/examples/merrill_1984_table_4.py +++ b/examples/merrill_1984_table_4.py @@ -31,48 +31,47 @@ """ import time from collections import Counter -from random import randint import numpy as np from tabulate import tabulate from elsim.elections import normal_electorate, normed_dist_utilities -from elsim.methods import (approval, black, borda, coombs, fptp, irv, runoff, - utility_winner) -from elsim.strategies import approval_optimal, honest_rankings +from elsim.strategies import honest_rankings +from elsim.studies import ( + expand_rows, + merrill_1984_comparison_methods, + spatial_random_reference_utility_updates, +) n_elections = 10_000 # Roughly 60 seconds on a 2019 6-core i7-9750H n_voters = 201 n_cands = 5 -ranked_methods = {'Plurality': fptp, 'Runoff': runoff, 'Hare': irv, - 'Borda': borda, 'Coombs': coombs, 'Black': black} +ranked_methods, rated_methods = merrill_1984_comparison_methods() -rated_methods = {'SU max': utility_winner, - 'Approval': lambda utilities, tiebreaker: - approval(approval_optimal(utilities), tiebreaker)} +# disp, corr, D +condition_rows = ((1.0, 0.5, 2), + (1.0, 0.5, 4), + (1.0, 0.0, 2), + (1.0, 0.0, 4), + (0.5, 0.5, 2), + (0.5, 0.5, 4), + (0.5, 0.0, 2), + (0.5, 0.0, 4), + ) +conditions = expand_rows(condition_rows, ('disp', 'corr', 'D')) start_time = time.monotonic() -# disp, corr, D -conditions = ((1.0, 0.5, 2), - (1.0, 0.5, 4), - (1.0, 0.0, 2), - (1.0, 0.0, 4), - (0.5, 0.5, 2), - (0.5, 0.5, 4), - (0.5, 0.0, 2), - (0.5, 0.0, 4), - ) - results = [] -for disp, corr, D in conditions: +for scenario in conditions: + disp, corr, D = scenario['disp'], scenario['corr'], scenario['D'] print(disp, corr, D) utility_sums = Counter() - for iteration in range(n_elections): + for _ in range(n_elections): v, c = normal_electorate(n_voters, n_cands, dims=D, corr=corr, disp=disp) @@ -89,17 +88,12 @@ utilities = normed_dist_utilities(v, c) rankings = honest_rankings(utilities) - # Pick a random winner and accumulate utilities - RW = randint(0, n_cands - 1) - utility_sums['RW'] += utilities.sum(axis=0)[RW] - - for name, method in rated_methods.items(): - winner = method(utilities, tiebreaker='random') - utility_sums[name] += utilities.sum(axis=0)[winner] - - for name, method in ranked_methods.items(): - winner = method(rankings, tiebreaker='random') - utility_sums[name] += utilities.sum(axis=0)[winner] + delta = spatial_random_reference_utility_updates( + utilities, rankings, ranked_methods, rated_methods, + tiebreaker='random', + ) + for name, value in delta.items(): + utility_sums[name] += value results.append(utility_sums) @@ -108,7 +102,7 @@ # Neither Tabulate nor Markdown support column span or multiple headers, but # at least this prints to plain text in a readable way. -header = ['Disp\nCorr\nDims'] + [f'{x}\n{y}\n{z}' for x, y, z in conditions] +header = ['Disp\nCorr\nDims'] + [f'{x}\n{y}\n{z}' for x, y, z in condition_rows] # Calculate Social Utility Efficiency from summed utilities y_uw = np.array([c['SU max'] for c in results]) diff --git a/examples/niemi_1968_table_1.py b/examples/niemi_1968_table_1.py index ede3e34..2bdd381 100644 --- a/examples/niemi_1968_table_1.py +++ b/examples/niemi_1968_table_1.py @@ -36,14 +36,15 @@ """ from collections import Counter +from functools import partial import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from tabulate import tabulate from elsim.elections import impartial_culture from elsim.methods import condorcet +from elsim.studies import JoblibBackend, merge_counters # Probability That There Is No Majority Winner niemi_table = [.0000, .0000, .0877, .1755, .2513, .3152, .3692, .4151, .4545, @@ -77,14 +78,15 @@ def simulate_batch(n_voters, n_cands, batch_size): return condorcet_paradox_count -jobs = [] -for n_cands in n_cands_list: - jobs.extend(n_batches * - [delayed(simulate_batch)(n_voters, n_cands, batch_size)]) - -print(f'{len(jobs)} tasks total:') -results = Parallel(n_jobs=-3, verbose=5)(jobs) -condorcet_paradox_counts = sum(results, Counter()) +backend = JoblibBackend(n_jobs=-3, verbose=5) +fns = [ + partial(simulate_batch, n_voters, n_cands, batch_size) + for n_cands in n_cands_list + for _ in range(n_batches) +] +print(f'{len(fns)} tasks total:') +results = backend.map_each(fns) +condorcet_paradox_counts = merge_counters(results) x, y = zip(*niemi_table.items()) plt.plot(x, y, label='Niemi') diff --git a/examples/niemi_1968_table_2.py b/examples/niemi_1968_table_2.py index f74ba9f..7563e9c 100644 --- a/examples/niemi_1968_table_2.py +++ b/examples/niemi_1968_table_2.py @@ -22,13 +22,14 @@ """ from collections import Counter +from functools import partial import numpy as np -from joblib import Parallel, delayed from tabulate import tabulate from elsim.elections import impartial_culture from elsim.methods import condorcet +from elsim.studies import JoblibBackend, merge_counters # It needs many simulations to get similar accuracy as the analytical results n_elections = 100_000 # Roughly 30 seconds on a 2019 6-core i7-9750H @@ -45,26 +46,24 @@ def simulate_batch(n_voters, n_cands, batch_size): condorcet_paradox_count = Counter() # Reuse the same chunk of memory to save time election = np.empty((n_voters, n_cands), dtype=np.uint8) - for iteration in range(batch_size): + for _iteration in range(batch_size): election[:] = impartial_culture(n_voters, n_cands) - CW = condorcet(election) - if CW is None: + cw = condorcet(election) + if cw is None: condorcet_paradox_count[n_cands, n_voters] += 1 return condorcet_paradox_count -jobs = [] -for n_voters in n_voters_list: - for n_cands in n_cands_list: - jobs.extend(n_batches * - [delayed(simulate_batch)(n_voters, n_cands, batch_size)]) - -print(f'{len(jobs)} tasks total:') -results = Parallel(n_jobs=-3, verbose=5)(jobs) -condorcet_paradox_counts = sum(results, Counter()) - -nm, P = zip(*sorted(condorcet_paradox_counts.items())) -P = np.asarray(P) / n_elections # Percent likelihood of paradox +backend = JoblibBackend(n_jobs=-3, verbose=5) +fns = [ + partial(simulate_batch, n_voters, n_cands, batch_size) + for n_voters in n_voters_list + for n_cands in n_cands_list + for _ in range(n_batches) +] +print(f'{len(fns)} tasks total:') +results = backend.map_each(fns) +condorcet_paradox_counts = merge_counters(results) table = [] for n in n_cands_list: @@ -73,4 +72,4 @@ def simulate_batch(n_voters, n_cands, batch_size): table.append(row) print(tabulate(table, n_voters_list, tablefmt="pipe", showindex=n_cands_list, - floatfmt='.4f')) + floatfmt='.4f')) \ No newline at end of file diff --git a/examples/tomlinson_2023_figure_3.py b/examples/tomlinson_2023_figure_3.py index 9c7f24d..2b31898 100644 --- a/examples/tomlinson_2023_figure_3.py +++ b/examples/tomlinson_2023_figure_3.py @@ -12,15 +12,16 @@ """ import pickle from collections import defaultdict +from functools import partial import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from seaborn import histplot from elsim.elections import normed_dist_utilities from elsim.methods import fptp, irv from elsim.strategies import honest_rankings +from elsim.studies import JoblibBackend n_elections = 200_000 # Roughly 30 seconds each on a 2019 6-core i7-9750H n_voters = 1_000 @@ -46,7 +47,7 @@ def human_format(num): def simulate_batch(n_cands): winners = defaultdict(list) - for iteration in range(batch_size): + for _iteration in range(batch_size): # "voters and candidates come from the uniform distribution on [0, 1]" v = np.random.uniform(0, 1, n_voters) @@ -80,9 +81,10 @@ def simulate_batch(n_cands): fig.suptitle(title) for n_cands in n_cands_list: - jobs = [delayed(simulate_batch)(n_cands)] * n_batches - print(f'{len(jobs)} tasks total:') - results = Parallel(n_jobs=-3, verbose=5)(jobs) + backend = JoblibBackend(n_jobs=-3, verbose=5) + worker = partial(simulate_batch, n_cands) + print(f'{n_batches} tasks total:') + results = backend.map_repeat(worker, n_batches) winners = {k: [v for d in results for v in d[k]] for k in results[0]} diff --git a/examples/tomlinson_2023_figure_3_updated.py b/examples/tomlinson_2023_figure_3_updated.py index 1f433a4..969b8a1 100644 --- a/examples/tomlinson_2023_figure_3_updated.py +++ b/examples/tomlinson_2023_figure_3_updated.py @@ -15,17 +15,16 @@ """ import pickle from collections import defaultdict +from functools import partial import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from seaborn import histplot from elsim.elections import normed_dist_utilities -from elsim.methods import (approval, black, borda, coombs, fptp, irv, runoff, - star) -from elsim.strategies import (approval_optimal, honest_normed_scores, - honest_rankings, vote_for_k) +from elsim.methods import approval, black, borda, coombs, fptp, irv, runoff, star +from elsim.strategies import approval_optimal, honest_normed_scores, honest_rankings, vote_for_k +from elsim.studies import JoblibBackend n_elections = 100_000 # Roughly 1 minute on a 2019 6-core i7-9750H n_voters = 1_000 @@ -54,7 +53,7 @@ def human_format(num): def simulate_batch(n_cands): winners = defaultdict(list) - for iteration in range(batch_size): + for _iteration in range(batch_size): # v, c = normal_electorate(n_voters, n_cands, dims=1, disp=disp) if cand_dist == 'uniform': @@ -115,9 +114,10 @@ def simulate_batch(n_cands): return winners -jobs = [delayed(simulate_batch)(n_cands)] * n_batches -print(f'{len(jobs)} tasks total:') -results = Parallel(n_jobs=-3, verbose=5)(jobs) +backend = JoblibBackend(n_jobs=-3, verbose=5) +worker = partial(simulate_batch, n_cands) +print(f'{n_batches} tasks total:') +results = backend.map_repeat(worker, n_batches) winners = {k: [v for d in results for v in d[k]] for k in results[0]} title = f'{human_format(n_elections)} 1D elections, ' diff --git a/examples/weber_1977_effectiveness_table.py b/examples/weber_1977_effectiveness_table.py index 3b8b3f9..1f19ff6 100644 --- a/examples/weber_1977_effectiveness_table.py +++ b/examples/weber_1977_effectiveness_table.py @@ -27,11 +27,12 @@ import matplotlib.pyplot as plt import numpy as np from tabulate import tabulate +from weber_1977_expressions import eff_borda, eff_standard, eff_vote_for_half from elsim.elections import random_utilities -from elsim.methods import approval, borda, fptp, utility_winner +from elsim.methods import approval, borda, fptp from elsim.strategies import honest_rankings, vote_for_k -from weber_1977_expressions import eff_borda, eff_standard, eff_vote_for_half +from elsim.studies import random_society_utility_updates n_elections = 2_000 # Roughly 60 seconds on a 2019 6-core i7-9750H n_voters = 1_000 @@ -47,22 +48,20 @@ start_time = time.monotonic() -for iteration in range(n_elections): +for _ in range(n_elections): for n_cands in n_cands_list: utilities = random_utilities(n_voters, n_cands) - # Find the social utility winner and accumulate utilities - UW = utility_winner(utilities) - utility_sums['UW'][n_cands] += utilities.sum(axis=0)[UW] - - for name, method in rated_methods.items(): - winner = method(utilities, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] - rankings = honest_rankings(utilities) - for name, method in ranked_methods.items(): - winner = method(rankings, tiebreaker='random') - utility_sums[name][n_cands] += utilities.sum(axis=0)[winner] + + delta = random_society_utility_updates( + utilities, rankings, ranked_methods, rated_methods, + tiebreaker='random', + uw_key='UW', + utility_winner_tiebreaker=None, + ) + for name, value in delta.items(): + utility_sums[name][n_cands] += value elapsed_time = time.monotonic() - start_time print('Elapsed:', time.strftime("%H:%M:%S", time.gmtime(elapsed_time)), '\n') diff --git a/examples/weber_1977_table_4.py b/examples/weber_1977_table_4.py index 9331205..973afee 100644 --- a/examples/weber_1977_table_4.py +++ b/examples/weber_1977_table_4.py @@ -34,6 +34,7 @@ from elsim.elections import random_utilities from elsim.methods import approval, borda, fptp from elsim.strategies import approval_optimal, honest_rankings +from elsim.studies import ranked_rated_utility_updates n_elections = 30_000 # Roughly 30 seconds on a 2019 6-core i7-9750H n_voters_list = (2, 3, 4, 5, 10, 15, 20, 25, 30) @@ -48,18 +49,18 @@ start_time = time.monotonic() -for iteration in range(n_elections): +for _ in range(n_elections): for n_voters in n_voters_list: utilities = random_utilities(n_voters, n_cands) - for name, method in rated_methods.items(): - winner = method(utilities, tiebreaker='random') - utility_sums[name][n_voters] += utilities.sum(axis=0)[winner] - rankings = honest_rankings(utilities) - for name, method in ranked_methods.items(): - winner = method(rankings, tiebreaker='random') - utility_sums[name][n_voters] += utilities.sum(axis=0)[winner] + + delta = ranked_rated_utility_updates( + utilities, rankings, ranked_methods, rated_methods, + tiebreaker='random', + ) + for name, value in delta.items(): + utility_sums[name][n_voters] += value elapsed_time = time.monotonic() - start_time print('Elapsed:', time.strftime("%H:%M:%S", time.gmtime(elapsed_time)), '\n') @@ -72,4 +73,4 @@ table.update({method: np.array(y) / n_elections}) print(tabulate(table, 'keys', showindex=n_voters_list, - tablefmt="pipe", floatfmt='.4f')) + tablefmt="pipe", floatfmt='.4f')) \ No newline at end of file diff --git a/examples/weber_1977_verify_vote_for_k.py b/examples/weber_1977_verify_vote_for_k.py index f754551..8f5cdcb 100644 --- a/examples/weber_1977_verify_vote_for_k.py +++ b/examples/weber_1977_verify_vote_for_k.py @@ -44,14 +44,13 @@ import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from tabulate import tabulate from elsim.elections import random_utilities from elsim.methods import approval, fptp, utility_winner from elsim.strategies import honest_rankings, vote_for_k -from weber_1977_expressions import (eff_standard, eff_vote_for_half, - eff_vote_for_k) +from elsim.studies import JoblibBackend +from weber_1977_expressions import eff_standard, eff_vote_for_half, eff_vote_for_k n_elections = 10_000 # Roughly 60 seconds on a 2019 6-core i7-9750H n_voters = 1_000 @@ -102,10 +101,8 @@ def simulate_election(): return utility_sums -print(f'Doing {n_elections:,} elections (tasks), {n_voters:,} voters, ' - f'{n_cands_list} candidates') -results = Parallel(n_jobs=-3, verbose=5)(delayed(simulate_election)() - for i in range(n_elections)) +backend = JoblibBackend(n_jobs=-3, verbose=5) +results = backend.map_repeat(simulate_election, n_elections) for result in results: for method, d in result.items(): diff --git a/examples/wikipedia_condorcet_paradox_likelihood.py b/examples/wikipedia_condorcet_paradox_likelihood.py index f6954d1..c39c905 100644 --- a/examples/wikipedia_condorcet_paradox_likelihood.py +++ b/examples/wikipedia_condorcet_paradox_likelihood.py @@ -21,14 +21,15 @@ """ from collections import Counter +from functools import partial import matplotlib.pyplot as plt import numpy as np -from joblib import Parallel, delayed from tabulate import tabulate from elsim.elections import impartial_culture from elsim.methods import condorcet +from elsim.studies import JoblibBackend, merge_counters # Number of voters vs percent of elections with Condorcet paradox. WP_table = {3: 5.556, @@ -53,22 +54,23 @@ def simulate_batch(n_voters, n_cands, batch_size): condorcet_paradox_count = Counter() # Reuse the same chunk of memory to save time election = np.empty((n_voters, n_cands), dtype=np.uint8) - for iteration in range(batch_size): + for _iteration in range(batch_size): election[:] = impartial_culture(n_voters, n_cands) - CW = condorcet(election) - if CW is None: + cw = condorcet(election) + if cw is None: condorcet_paradox_count[n_voters] += 1 return condorcet_paradox_count -jobs = [] -for n_voters in WP_table: - jobs.extend(n_batches * - [delayed(simulate_batch)(n_voters, n_cands, batch_size)]) - -print(f'{len(jobs)} tasks total:') -results = Parallel(n_jobs=-3, verbose=5)(jobs) -condorcet_paradox_counts = sum(results, Counter()) +backend = JoblibBackend(n_jobs=-3, verbose=5) +fns = [ + partial(simulate_batch, n_voters, n_cands, batch_size) + for n_voters in WP_table + for _ in range(n_batches) +] +print(f'{len(fns)} tasks total:') +results = backend.map_each(fns) +condorcet_paradox_counts = merge_counters(results) x, y = zip(*WP_table.items()) plt.plot(x, y, label='WP') diff --git a/pyproject.toml b/pyproject.toml index 235e83d..5d95414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ test = [ "pytest-cov", "hypothesis", "ruff", + "joblib", ] examples = [ "tabulate", diff --git a/tests/test_studies.py b/tests/test_studies.py new file mode 100644 index 0000000..99263a0 --- /dev/null +++ b/tests/test_studies.py @@ -0,0 +1,326 @@ +"""Tests for elsim.studies (Monte Carlo helpers, parameter expansion).""" + +import builtins +from collections import Counter + +import numpy as np +import pytest + +from elsim.methods import approval, condorcet, fptp +from elsim.strategies import approval_optimal +from elsim.studies import ( + JoblibBackend, + SerialBackend, + expand_product, + expand_rows, + expand_zip, + merge_counters, + merrill_1984_comparison_methods, + random_society_utility_updates, + ranked_rated_utility_updates, + run_batched, + spatial_random_reference_utility_updates, + tally_condorcet_agreement, +) + + +def test_expand_product_scalar_and_list(): + got = expand_product(n_voters=[10, 20], n_cands=3) + assert got == [{"n_voters": 10, "n_cands": 3}, {"n_voters": 20, "n_cands": 3}] + + +def test_expand_product_bytes_scalar(): + assert expand_product(blob=b"ab") == [{"blob": b"ab"}] + + +def test_expand_rows_empty(): + assert expand_rows((), ("a",)) == [] + + +def test_run_batched_uses_implicit_serial_backend(): + out = run_batched(lambda k: k, n_trials=5, batch_size=2) + assert out == [2, 2, 1] + + +def test_expand_zip_basic(): + assert expand_zip(a=[1, 2], b=[3, 4]) == [{"a": 1, "b": 3}, {"a": 2, "b": 4}] + + +def test_expand_zip_length_mismatch(): + with pytest.raises(ValueError, match="same length"): + expand_zip(a=[1, 2], b=[3]) + + +def test_expand_rows_merrill_style(): + rows = ((1.0, 0.5, 2), (0.5, 0.0, 4)) + keys = ("disp", "corr", "D") + assert expand_rows(rows, keys) == [ + {"disp": 1.0, "corr": 0.5, "D": 2}, + {"disp": 0.5, "corr": 0.0, "D": 4}, + ] + + +def test_expand_rows_width_mismatch(): + with pytest.raises(ValueError, match="row 0"): + expand_rows([(1, 2, 3)], ("a", "b")) + + +def test_merge_counters(): + assert merge_counters([Counter({"a": 1}), Counter({"a": 2, "b": 1})]) == Counter({"a": 3, "b": 1}) + + +def test_run_batched_serial(): + sizes = [] + + def batch_fn(k): + sizes.append(k) + return k + + out = run_batched(batch_fn, n_trials=25, batch_size=10, backend=SerialBackend()) + assert out == [10, 10, 5] + assert sizes == [10, 10, 5] + + +def test_run_batched_exact_batches(): + out = run_batched(lambda k: k, n_trials=30, batch_size=10, backend=SerialBackend()) + assert out == [10, 10, 10] + + +def test_tally_condorcet_agreement_no_cw(): + rankings = np.array( + [ + [0, 1, 2], + [1, 2, 0], + [2, 0, 1], + ], + dtype=np.uint8, + ) + utilities = np.zeros_like(rankings, dtype=float) + ranked, rated = merrill_1984_comparison_methods() + assert tally_condorcet_agreement(rankings, utilities, ranked, rated) == Counter() + + +def test_tally_condorcet_agreement_with_cw(): + # Candidate 0 beats everyone pairwise + rankings = np.array( + [ + [0, 1, 2], + [0, 2, 1], + [0, 1, 2], + ], + dtype=np.uint8, + ) + utilities = np.array( + [ + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ] + ) + assert condorcet(rankings) == 0 + assert fptp(rankings, tiebreaker="random") == 0 + + ranked = {"Plurality": fptp} + rated: dict = {} + c = tally_condorcet_agreement(rankings, utilities, ranked, rated, tiebreaker="random") + assert c["CW"] == 1 + assert c["Plurality"] == 1 + + +def test_serial_backend_map_each(): + out = SerialBackend().map_each([lambda: 1, lambda: 2]) + assert out == [1, 2] + + +def test_joblib_backend_map_repeat(): + pytest.importorskip("joblib") + + backend = JoblibBackend(n_jobs=2, verbose=0) + out = backend.map_repeat(lambda: 1, n=4) + assert out == [1, 1, 1, 1] + + +def test_ranked_rated_utility_updates(): + utilities = np.array([[1.0, 0.0], [1.0, 0.0], [1.0, 0.0]]) + rankings = np.array([[0, 1], [0, 1], [0, 1]], dtype=np.uint8) + + delta = ranked_rated_utility_updates( + utilities, rankings, {'Plurality': fptp}, {}, tiebreaker='random', + ) + assert set(delta) == {'Plurality'} + assert delta['Plurality'] == float(utilities.sum(axis=0)[0]) + + +def test_spatial_random_reference_includes_rw(): + np.random.seed(0) + utilities = np.array([[1.0, 0.0, 0.5], [0.0, 1.0, 0.5]]) + rankings = np.array([[0, 1, 2], [1, 0, 2]], dtype=np.uint8) + + delta = spatial_random_reference_utility_updates( + utilities, rankings, {'Plurality': fptp}, {}, tiebreaker='random', + ) + assert 'RW' in delta + assert 'Plurality' in delta + + +def test_joblib_backend_map_each(): + from functools import partial + + pytest.importorskip("joblib") + + backend = JoblibBackend(n_jobs=2, verbose=0) + + def f(x): + return x + + out = backend.map_each([partial(f, 1), partial(f, 2)]) + assert out == [1, 2] + + +def test_expand_zip_empty(): + assert expand_zip() == [] + + +def test_expand_product_mapping_value_raises(): + with pytest.raises(TypeError, match="Mappings"): + expand_product(x={"a": 1}) + + +def test_run_batched_zero_trials(): + assert run_batched(lambda k: k, n_trials=0, batch_size=10) == [] + + +def test_run_batched_negative_trials_raises(): + with pytest.raises(ValueError, match="non-negative"): + run_batched(lambda k: k, n_trials=-1, batch_size=10) + + +def test_run_batched_invalid_batch_size_raises(): + with pytest.raises(ValueError, match="positive"): + run_batched(lambda k: k, n_trials=10, batch_size=0) + + +def test_serial_backend_map_repeat_negative_raises(): + with pytest.raises(ValueError, match="non-negative"): + SerialBackend().map_repeat(lambda: 1, n=-1) + + +def test_merge_counters_empty(): + assert merge_counters([]) == Counter() + + +def test_random_society_utility_updates_tiebreaker_none(): + utilities = np.array([[0.9, 0.1], [0.8, 0.2]]) + rankings = np.array([[0, 1], [0, 1]], dtype=np.uint8) + delta = random_society_utility_updates( + utilities, + rankings, + {"Plurality": fptp}, + {}, + tiebreaker="random", + uw_key="UW", + utility_winner_tiebreaker=None, + ) + assert "UW" in delta + assert "Plurality" in delta + + +def test_random_society_utility_updates_custom_uw_key_and_rated(): + utilities = np.array([[1.0, 0.0], [1.0, 0.0]]) + rankings = np.array([[0, 1], [0, 1]], dtype=np.uint8) + + rated = { + "Approval": lambda u, tiebreaker: approval( + approval_optimal(u), tiebreaker, + ), + } + delta = random_society_utility_updates( + utilities, + rankings, + {"Plurality": fptp}, + rated, + tiebreaker="random", + uw_key="XX", + utility_winner_tiebreaker="random", + ) + assert "XX" in delta + assert "Approval" in delta + assert "Plurality" in delta + + +def test_spatial_random_reference_with_rated(): + utilities = np.array([[1.0, 0.0], [1.0, 0.0]]) + rankings = np.array([[0, 1], [0, 1]], dtype=np.uint8) + + rated = { + "Approval": lambda u, tiebreaker: approval( + approval_optimal(u), tiebreaker, + ), + } + delta = spatial_random_reference_utility_updates( + utilities, rankings, {"Plurality": fptp}, rated, tiebreaker="random", + ) + assert set(delta) >= {"RW", "Plurality", "Approval"} + + +def test_ranked_rated_with_both_method_kinds(): + utilities = np.array([[1.0, 0.0], [1.0, 0.0]]) + rankings = np.array([[0, 1], [0, 1]], dtype=np.uint8) + + rated = { + "Approval": lambda u, tiebreaker: approval( + approval_optimal(u), tiebreaker, + ), + } + delta = ranked_rated_utility_updates( + utilities, rankings, {"Plurality": fptp}, rated, tiebreaker="random", + ) + assert "Plurality" in delta + assert "Approval" in delta + + +def test_merrill_1984_comparison_methods_keys(): + ranked, rated = merrill_1984_comparison_methods() + assert set(ranked) == {"Plurality", "Runoff", "Hare", "Borda", "Coombs", "Black"} + assert set(rated) == {"SU max", "Approval"} + + +def test_tally_condorcet_agreement_rated_branch(): + rankings = np.array([[0, 1], [0, 1], [0, 1]], dtype=np.uint8) + utilities = np.array( + [ + [1.0, 0.0], + [1.0, 0.0], + [1.0, 0.0], + ], + ) + assert condorcet(rankings) == 0 + ranked, rated = merrill_1984_comparison_methods() + c = tally_condorcet_agreement(rankings, utilities, ranked, rated, tiebreaker="random") + assert c["CW"] == 1 + assert c["SU max"] == 1 + assert c["Plurality"] == 1 + + +def test_joblib_backend_requires_joblib(monkeypatch): + pytest.importorskip("joblib") + + real_import = builtins.__import__ + + def block_joblib(name, globals=None, locals=None, fromlist=(), level=0): + if name == "joblib" and fromlist and "Parallel" in fromlist: + raise ImportError("blocked joblib for test") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", block_joblib) + backend = JoblibBackend(n_jobs=1, verbose=0) + with pytest.raises(ImportError, match="joblib"): + backend.map_repeat(lambda: 1, n=1) + with pytest.raises(ImportError, match="joblib"): + backend.map_each([lambda: 1]) + + +def test_joblib_backend_map_repeat_negative_raises(): + pytest.importorskip("joblib") + with pytest.raises(ValueError, match="non-negative"): + JoblibBackend().map_repeat(lambda: 1, n=-1)