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 supports declarative spatial-model sweeps (see
:mod:`elsim.studies.spatial_normal`), parameter expansion for scenario grids,
serial batching via :func:`run_batched`, and small tallies shared by several
examples (Condorcet agreement, social-utility totals).

The ``elections`` / ``strategies`` / ``methods`` modules remain the core model;
``studies`` orchestrates repeated draws and aggregation.
"""

from .backends import SerialBackend
from .condorcet_metrics import approval_at_optimal, 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,
)
from .spatial_normal import accumulate_spatial_condorcet_by_ncands, accumulate_spatial_sue_by_ncands

__all__ = [
"SerialBackend",
"approval_at_optimal",
"expand_product",
"expand_rows",
"expand_zip",
"merge_counters",
"run_batched",
"tally_condorcet_agreement",
"accumulate_spatial_condorcet_by_ncands",
"accumulate_spatial_sue_by_ncands",
"spatial_random_reference_utility_updates",
"random_society_utility_updates",
"ranked_rated_utility_updates",
]
24 changes: 24 additions & 0 deletions elsim/studies/backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
Execution helpers for repeating independent Monte Carlo batches.

Serial execution is always available for :func:`elsim.studies.runner.run_batched`.
"""

from __future__ import annotations

from typing import 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]
80 changes: 80 additions & 0 deletions elsim/studies/condorcet_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
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, condorcet
from elsim.strategies import approval_optimal

RankedMethod = Callable[..., Optional[int]]
RatedMethod = Callable[..., Optional[int]]


def approval_at_optimal(utilities: np.ndarray, tiebreaker: str = "random") -> Optional[int]: # noqa: UP045
"""
Rated-method helper: build an optimal approval ballot, then apply :func:`elsim.methods.approval`.

Intended for use in a ``rated_methods`` mapping passed to
:func:`tally_condorcet_agreement` or the spatial sweep helpers, so scripts
stay declarative without repeating lambdas.
"""
return approval(approval_optimal(utilities), tiebreaker)


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; ranked methods take ``(rankings, tiebreaker=...)``;
rated methods take ``(utilities, tiebreaker=...)``.
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
69 changes: 69 additions & 0 deletions elsim/studies/runner.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading