Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/elsim.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ Subpackages
elsim.elections
elsim.strategies
elsim.methods
elsim.studies
8 changes: 8 additions & 0 deletions docs/elsim.studies.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
elsim.studies module
====================

.. automodule:: elsim.studies
:members:
:undoc-members:
:imported-members:
:show-inheritance:
2 changes: 1 addition & 1 deletion elsim/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
38 changes: 38 additions & 0 deletions elsim/studies/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
82 changes: 82 additions & 0 deletions elsim/studies/backends.py
Original file line number Diff line number Diff line change
@@ -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)
98 changes: 98 additions & 0 deletions elsim/studies/condorcet_metrics.py
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions elsim/studies/parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
Expand simulation parameters into explicit scenario dictionaries.

Issue `#10 <https://github.com/endolith/elsim/issues/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
Loading
Loading